From 703bfa03f25343b293a00ccc748dda2d37b6588f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 14:18:05 +0000 Subject: [PATCH 01/26] Initial plan From e9ca565b966aa4da188250310998a045d98bef2b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 14:25:16 +0000 Subject: [PATCH 02/26] Add TimeTextProvider and TimeEditor implementation Co-authored-by: tig <585482+tig@users.noreply.github.com> --- Terminal.Gui/Views/TextInput/TimeEditor.cs | 173 ++++++ .../Views/TextInput/TimeTextProvider.cs | 498 ++++++++++++++++++ 2 files changed, 671 insertions(+) create mode 100644 Terminal.Gui/Views/TextInput/TimeEditor.cs create mode 100644 Terminal.Gui/Views/TextInput/TimeTextProvider.cs diff --git a/Terminal.Gui/Views/TextInput/TimeEditor.cs b/Terminal.Gui/Views/TextInput/TimeEditor.cs new file mode 100644 index 0000000000..8833e8f793 --- /dev/null +++ b/Terminal.Gui/Views/TextInput/TimeEditor.cs @@ -0,0 +1,173 @@ +using System.Globalization; +using Terminal.Gui.ViewBase; + +namespace Terminal.Gui.Views; + +/// +/// Provides time editing functionality using with culture-aware formatting. +/// +/// +/// +/// TimeEditor extends with time-specific functionality: +/// +/// Uses for validation and formatting +/// Supports both 12-hour and 24-hour formats via +/// Cursor automatically skips over separator characters +/// Supports AM/PM toggling for 12-hour formats +/// Auto-adjusts width based on time pattern +/// +/// +/// +/// Usage Examples: +/// +/// // Use default (current culture's long time pattern) +/// TimeEditor timeEditor = new () { Value = TimeSpan.FromHours (14.5) }; +/// // en-US displays: " 2:30:00 PM" +/// // en-GB displays: " 14:30:00" +/// +/// // Use specific culture's format +/// timeEditor.Format = CultureInfo.GetCultureInfo ("de-DE").DateTimeFormat; +/// // Displays: " 14:30:00" +/// +/// // Want short time? Modify the LongTimePattern +/// DateTimeFormatInfo format = (DateTimeFormatInfo)CultureInfo.CurrentCulture.DateTimeFormat.Clone (); +/// format.LongTimePattern = format.ShortTimePattern; +/// timeEditor.Format = format; +/// // en-US displays: " 2:30 PM" +/// +/// // Custom pattern with milliseconds +/// DateTimeFormatInfo customFormat = (DateTimeFormatInfo)CultureInfo.CurrentCulture.DateTimeFormat.Clone (); +/// customFormat.LongTimePattern = "HH:mm:ss.fff"; +/// timeEditor.Format = customFormat; +/// // Displays: " 14:30:00.000" +/// +/// +/// +public class TimeEditor : TextValidateField, IValue +{ + private TimeTextProvider TimeProvider => (TimeTextProvider)Provider!; + + /// + /// Initializes a new instance of the class. + /// + public TimeEditor () + { + Provider = new TimeTextProvider (); + Width = Dim.Auto (minimumContentDim: 10); + + // Subscribe to provider's text changed to raise our value events + TimeProvider.TextChanged += (_, _) => RaiseValueChangedEvents (); + } + + /// + /// Gets or sets the used for time formatting. + /// + /// + /// + /// The editor uses to determine the display format. + /// To use a short time format, clone the DateTimeFormatInfo and set LongTimePattern to ShortTimePattern. + /// + /// + /// The width automatically adjusts when the format changes to accommodate the new pattern. + /// + /// + public DateTimeFormatInfo Format + { + get => TimeProvider.Format; + set + { + TimeProvider.Format = value; + Width = TimeProvider.DisplayText.Length + 2; + SetNeedsDraw (); + } + } + + /// + /// Gets or sets the current time value. + /// + /// + /// + /// Setting this property raises (cancellable) and events. + /// The change can be prevented by handling and setting + /// to . + /// + /// + public TimeSpan Value + { + get => TimeProvider.TimeValue; + set + { + TimeSpan oldValue = TimeProvider.TimeValue; + + if (oldValue == value) + { + return; + } + + ValueChangingEventArgs changingArgs = new (oldValue, value); + + if (OnValueChanging (changingArgs) || changingArgs.Handled) + { + return; + } + + ValueChanging?.Invoke (this, changingArgs); + + if (changingArgs.Handled) + { + return; + } + + TimeProvider.TimeValue = value; + Text = TimeProvider.Text; + + ValueChangedEventArgs changedArgs = new (oldValue, value); + OnValueChanged (changedArgs); + ValueChanged?.Invoke (this, changedArgs); + + SetNeedsDraw (); + } + } + + /// + public event EventHandler>? ValueChanging; + + /// + public event EventHandler>? ValueChanged; + + /// + object? IValue.GetValue () => Value; + + /// + /// Called when the is changing. + /// Allows derived classes to cancel the change. + /// + /// The event arguments. + /// to cancel the change; otherwise . + protected virtual bool OnValueChanging (ValueChangingEventArgs args) + { + return false; + } + + /// + /// Called when the has changed. + /// Allows derived classes to react to value changes. + /// + /// The event arguments. + protected virtual void OnValueChanged (ValueChangedEventArgs args) + { + } + + /// + /// Raises value events when the text changes through user input. + /// + private void RaiseValueChangedEvents () + { + TimeSpan currentValue = TimeProvider.TimeValue; + + // The provider already updated the value, just notify + ValueChangedEventArgs changedArgs = new (currentValue, currentValue); + OnValueChanged (changedArgs); + ValueChanged?.Invoke (this, changedArgs); + } +} diff --git a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs new file mode 100644 index 0000000000..d28e14037e --- /dev/null +++ b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs @@ -0,0 +1,498 @@ +using System.Globalization; +using System.Text; + +namespace Terminal.Gui.Views; + +/// +/// Time input provider for . +/// Provides time editing with culture-aware formatting, supporting both 12-hour and 24-hour formats. +/// +/// +/// +/// This provider parses the to determine: +/// +/// 12-hour (h/hh) vs 24-hour (H/HH) format +/// Presence of AM/PM designator (tt) +/// Time separator character +/// Dynamic field width based on pattern +/// +/// +/// +/// The cursor automatically skips over separator characters and AM/PM designators during navigation. +/// For 12-hour formats, typing 'A' or 'P' on the AM/PM position toggles between AM and PM. +/// +/// +public class TimeTextProvider : ITextValidateProvider +{ + private DateTimeFormatInfo _format = CultureInfo.CurrentCulture.DateTimeFormat; + private string _separator = CultureInfo.CurrentCulture.DateTimeFormat.TimeSeparator; + private TimeSpan _timeValue = TimeSpan.Zero; + private bool _is12Hour; + private bool _hasAmPm; + private int _fieldLength; + private List _separatorPositions = []; + private int _amPmPosition = -1; + private bool _isPm; + + /// + /// Initializes a new instance of the class. + /// + public TimeTextProvider () + { + AnalyzePattern (); + } + + /// + /// Gets or sets the used for time formatting. + /// + /// + /// The provider uses to determine the display format. + /// Users can customize patterns by cloning the DateTimeFormatInfo and modifying LongTimePattern. + /// + public DateTimeFormatInfo Format + { + get => _format; + set + { + _format = value; + _separator = value.TimeSeparator; + AnalyzePattern (); + OnTextChanged (new (in string.Empty)); + } + } + + /// + /// Gets or sets the current time value. + /// + public TimeSpan TimeValue + { + get => _timeValue; + set + { + _timeValue = value; + _isPm = value.Hours >= 12; + } + } + + /// + public event EventHandler>? TextChanged; + + /// + public string Text + { + get => FormatTimeValue (); + set + { + if (TryParseTimeValue (value, out TimeSpan parsedValue)) + { + string oldValue = Text; + _timeValue = parsedValue; + _isPm = _timeValue.Hours >= 12; + + if (oldValue != Text) + { + OnTextChanged (new (in oldValue)); + } + } + } + } + + /// + public string DisplayText => " " + FormatTimeValue (); + + /// + public bool IsValid + { + get + { + // Always valid - we auto-correct invalid values + return true; + } + } + + /// + public bool Fixed => true; + + /// + public int Cursor (int pos) + { + if (pos < 0) + { + return CursorStart (); + } + + if (pos >= _fieldLength) + { + return CursorEnd (); + } + + // Skip over separators and AM/PM designator + if (_separatorPositions.Contains (pos)) + { + return CursorRight (pos); + } + + if (_hasAmPm && pos >= _amPmPosition && pos < _amPmPosition + 2) + { + return _amPmPosition; + } + + return pos; + } + + /// + public int CursorStart () => 0; + + /// + public int CursorEnd () + { + if (_hasAmPm) + { + return _amPmPosition; + } + + return _fieldLength - 1; + } + + /// + public int CursorLeft (int pos) + { + if (pos <= 0) + { + return 0; + } + + int newPos = pos - 1; + + // Skip over AM/PM designator + if (_hasAmPm && newPos >= _amPmPosition && newPos < _amPmPosition + 2) + { + newPos = _amPmPosition - 1; + } + + // Skip over separators + while (newPos >= 0 && _separatorPositions.Contains (newPos)) + { + newPos--; + } + + return newPos < 0 ? 0 : newPos; + } + + /// + public int CursorRight (int pos) + { + if (_hasAmPm && pos >= _amPmPosition) + { + return _amPmPosition; + } + + if (pos >= _fieldLength - 1) + { + return CursorEnd (); + } + + int newPos = pos + 1; + + // Skip over separators + while (newPos < _fieldLength && _separatorPositions.Contains (newPos)) + { + newPos++; + } + + // Stop at AM/PM position + if (_hasAmPm && newPos >= _amPmPosition) + { + return _amPmPosition; + } + + return newPos >= _fieldLength ? CursorEnd () : newPos; + } + + /// + public bool Delete (int pos) + { + string oldValue = Text; + + if (_hasAmPm && pos == _amPmPosition) + { + // Can't delete AM/PM, just ignore + return false; + } + + // Replace digit with '0' + string currentText = FormatTimeValue (); + + if (pos >= 0 && pos < currentText.Length && char.IsDigit (currentText [pos])) + { + StringBuilder sb = new (currentText); + sb [pos] = '0'; + + if (TryParseTimeValue (sb.ToString (), out TimeSpan newValue)) + { + _timeValue = newValue; + OnTextChanged (new (in oldValue)); + return true; + } + } + + return false; + } + + /// + public bool InsertAt (char ch, int pos) + { + string oldValue = Text; + + // Handle AM/PM toggle + if (_hasAmPm && pos == _amPmPosition) + { + if (char.ToUpperInvariant (ch) == 'A' || char.ToUpperInvariant (ch) == 'P') + { + _isPm = char.ToUpperInvariant (ch) == 'P'; + + // Update the time value hours to reflect AM/PM change + int hours = _timeValue.Hours % 12; + + if (_isPm && hours < 12) + { + hours += 12; + } + + _timeValue = new TimeSpan (hours, _timeValue.Minutes, _timeValue.Seconds); + OnTextChanged (new (in oldValue)); + + return true; + } + + return false; + } + + // Only accept digits for time positions + if (!char.IsDigit (ch)) + { + return false; + } + + // Replace digit at position + string currentText = FormatTimeValue (); + + if (pos >= 0 && pos < currentText.Length) + { + StringBuilder sb = new (currentText); + sb [pos] = ch; + + if (TryParseTimeValue (sb.ToString (), out TimeSpan newValue)) + { + _timeValue = newValue; + _isPm = _timeValue.Hours >= 12; + OnTextChanged (new (in oldValue)); + + return true; + } + } + + return false; + } + + /// + public void OnTextChanged (EventArgs args) + { + TextChanged?.Invoke (this, args); + } + + /// + /// Analyzes the LongTimePattern to detect format characteristics. + /// + private void AnalyzePattern () + { + string pattern = _format.LongTimePattern; + _separatorPositions.Clear (); + _amPmPosition = -1; + _is12Hour = pattern.Contains ('h'); + _hasAmPm = pattern.Contains ("tt"); + + // Build a sample time to determine field positions + DateTime sampleTime = new (2000, 1, 1, 14, 30, 45); + string formatted = sampleTime.ToString (pattern, _format); + + _fieldLength = formatted.Length; + + // Find separator positions + for (int i = 0; i < formatted.Length; i++) + { + if (formatted [i].ToString () == _separator) + { + _separatorPositions.Add (i); + } + } + + // Find AM/PM position + if (_hasAmPm) + { + string amDesignator = _format.AMDesignator; + string pmDesignator = _format.PMDesignator; + + int amIndex = formatted.IndexOf (amDesignator, StringComparison.Ordinal); + int pmIndex = formatted.IndexOf (pmDesignator, StringComparison.Ordinal); + + _amPmPosition = Math.Max (amIndex, pmIndex); + } + } + + /// + /// Formats the current time value according to the pattern. + /// + private string FormatTimeValue () + { + DateTime dt = DateTime.Today.Add (_timeValue); + + // For 12-hour format, adjust the hours if needed + if (_is12Hour && _hasAmPm) + { + int hours = _timeValue.Hours % 12; + + if (hours == 0) + { + hours = 12; + } + + dt = new DateTime ( + 2000, + 1, + 1, + _isPm ? (hours == 12 ? 12 : hours + 12) : (hours == 12 ? 0 : hours), + _timeValue.Minutes, + _timeValue.Seconds + ); + } + + return dt.ToString (_format.LongTimePattern, _format); + } + + /// + /// Attempts to parse a time string according to the pattern. + /// + private bool TryParseTimeValue (string text, out TimeSpan result) + { + result = TimeSpan.Zero; + + if (string.IsNullOrWhiteSpace (text)) + { + return false; + } + + text = text.Trim (); + + // Try to parse using the current pattern + if (DateTime.TryParseExact ( + text, + _format.LongTimePattern, + _format, + DateTimeStyles.None, + out DateTime dt + )) + { + result = dt.TimeOfDay; + + return true; + } + + // Fallback: try manual parsing for partial/invalid input + return TryManualParse (text, out result); + } + + /// + /// Manual parsing for partially entered or invalid time values. + /// + private bool TryManualParse (string text, out TimeSpan result) + { + result = TimeSpan.Zero; + + try + { + string [] parts = text.Split (_separator [0]); + + if (parts.Length < 2) + { + return false; + } + + // Extract AM/PM if present + bool isPm = false; + string lastPart = parts [^1].Trim (); + + if (_hasAmPm) + { + if (lastPart.EndsWith (_format.PMDesignator, StringComparison.OrdinalIgnoreCase)) + { + isPm = true; + lastPart = lastPart.Substring (0, lastPart.Length - _format.PMDesignator.Length).Trim (); + } + else if (lastPart.EndsWith (_format.AMDesignator, StringComparison.OrdinalIgnoreCase)) + { + isPm = false; + lastPart = lastPart.Substring (0, lastPart.Length - _format.AMDesignator.Length).Trim (); + } + + parts [^1] = lastPart; + } + + // Parse hours + if (!int.TryParse (parts [0], out int hours)) + { + return false; + } + + // Parse minutes + if (!int.TryParse (parts [1], out int minutes)) + { + return false; + } + + // Parse seconds (if present) + int seconds = 0; + + if (parts.Length > 2 && !string.IsNullOrWhiteSpace (parts [2])) + { + if (!int.TryParse (parts [2], out seconds)) + { + return false; + } + } + + // Validate and adjust ranges + if (_is12Hour) + { + // 12-hour format: 1-12 + hours = Math.Max (1, Math.Min (12, hours)); + + if (isPm && hours != 12) + { + hours += 12; + } + else if (!isPm && hours == 12) + { + hours = 0; + } + } + else + { + // 24-hour format: 0-23 + hours = Math.Max (0, Math.Min (23, hours)); + } + + minutes = Math.Max (0, Math.Min (59, minutes)); + seconds = Math.Max (0, Math.Min (59, seconds)); + + result = new TimeSpan (hours, minutes, seconds); + _isPm = hours >= 12; + + return true; + } + catch + { + return false; + } + } +} From dad7634d4b17a82b5e6870eae1049234262ad608 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 14:27:29 +0000 Subject: [PATCH 03/26] Add comprehensive unit tests for TimeEditor and TimeTextProvider Co-authored-by: tig <585482+tig@users.noreply.github.com> --- .../Views/TimeEditorTests.cs | 401 ++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs diff --git a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs new file mode 100644 index 0000000000..75578046bb --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs @@ -0,0 +1,401 @@ +using System.Globalization; +using UnitTests; + +namespace ViewsTests; + +// Claude - Opus 4.5 +public class TimeEditorTests : TestDriverBase +{ + [Fact] + public void Constructor_Defaults () + { + TimeEditor te = new (); + te.Layout (); + + Assert.NotNull (te.Provider); + Assert.IsType (te.Provider); + Assert.Equal (TimeSpan.Zero, te.Value); + Assert.NotNull (te.Format); + Assert.Equal (CultureInfo.CurrentCulture.DateTimeFormat, te.Format); + } + + [Fact] + public void Value_Property_GetSet () + { + TimeEditor te = new (); + TimeSpan testTime = new (14, 30, 45); + + te.Value = testTime; + Assert.Equal (testTime, te.Value); + + // Test setting to zero + te.Value = TimeSpan.Zero; + Assert.Equal (TimeSpan.Zero, te.Value); + + // Test setting to max + te.Value = TimeSpan.FromHours (23) + TimeSpan.FromMinutes (59) + TimeSpan.FromSeconds (59); + Assert.Equal (new TimeSpan (23, 59, 59), te.Value); + } + + [Fact] + public void Format_Property_Changes_Width () + { + TimeEditor te = new (); + te.Layout (); + + int initialWidth = te.Frame.Width; + Assert.True (initialWidth > 0); + + // Change to a different culture with different pattern + DateTimeFormatInfo customFormat = (DateTimeFormatInfo)CultureInfo.CurrentCulture.DateTimeFormat.Clone (); + customFormat.LongTimePattern = "HH:mm"; + te.Format = customFormat; + te.Layout (); + + // Width should change to accommodate shorter pattern + int newWidth = te.Frame.Width; + Assert.NotEqual (initialWidth, newWidth); + } + + [Fact] + public void ValueChanging_Event_Can_Cancel () + { + TimeEditor te = new () { Value = TimeSpan.FromHours (10) }; + bool eventFired = false; + + te.ValueChanging += (_, e) => + { + eventFired = true; + e.Handled = true; // Cancel the change + }; + + te.Value = TimeSpan.FromHours (15); + + Assert.True (eventFired); + Assert.Equal (TimeSpan.FromHours (10), te.Value); // Value should not change + } + + [Fact] + public void ValueChanged_Event_Fires () + { + TimeEditor te = new () { Value = TimeSpan.FromHours (10) }; + bool eventFired = false; + TimeSpan? oldValue = null; + TimeSpan? newValue = null; + + te.ValueChanged += (_, e) => + { + eventFired = true; + oldValue = e.OldValue; + newValue = e.NewValue; + }; + + TimeSpan expectedNewValue = TimeSpan.FromHours (15); + te.Value = expectedNewValue; + + Assert.True (eventFired); + Assert.Equal (TimeSpan.FromHours (10), oldValue); + Assert.Equal (expectedNewValue, newValue); + } + + [Fact] + public void TimeTextProvider_CursorNavigation_SkipsSeparators () + { + TimeTextProvider provider = new (); + + // CursorStart should return 0 + Assert.Equal (0, provider.CursorStart ()); + + // For a format like "HH:mm:ss" (positions: 0,1,:,3,4,:,6,7) + // Position 2 is separator, cursor should skip it + int cursorPos = provider.CursorRight (1); + Assert.NotEqual (2, cursorPos); // Should skip position 2 (separator) + + // CursorLeft from position 3 should skip separator at 2 and go to 1 + cursorPos = provider.CursorLeft (3); + Assert.Equal (1, cursorPos); + } + + [Fact] + public void TimeTextProvider_InsertAt_ReplacesDigit () + { + TimeTextProvider provider = new (); + provider.TimeValue = TimeSpan.Zero; // "00:00:00" in 24h format + + // Insert '1' at position 0 (first hour digit) + bool result = provider.InsertAt ('1', 0); + Assert.True (result); + + // Check that the value was updated + string text = provider.Text; + Assert.StartsWith ("1", text.TrimStart ()); + } + + [Fact] + public void TimeTextProvider_Delete_ReplacesWithZero () + { + TimeTextProvider provider = new (); + provider.TimeValue = new TimeSpan (14, 30, 45); + + // Delete at position 0 should replace with '0' + bool result = provider.Delete (0); + Assert.True (result); + + // The hour should now start with 0 + string text = provider.Text.Trim (); + Assert.StartsWith ("0", text); + } + + [Fact] + public void TimeTextProvider_Format_Change_Updates_Pattern () + { + TimeTextProvider provider = new (); + TimeSpan testTime = new (14, 30, 45); + provider.TimeValue = testTime; + + string initialDisplay = provider.DisplayText; + + // Change to a custom format + DateTimeFormatInfo customFormat = (DateTimeFormatInfo)CultureInfo.CurrentCulture.DateTimeFormat.Clone (); + customFormat.LongTimePattern = "HH:mm"; + provider.Format = customFormat; + + string newDisplay = provider.DisplayText; + + // Display should change + Assert.NotEqual (initialDisplay, newDisplay); + + // New display should not contain seconds + Assert.DoesNotContain ("45", newDisplay); + } + + [Fact] + public void TimeTextProvider_Validates_Hours_Minutes_Seconds () + { + TimeTextProvider provider = new (); + + // Valid time + provider.TimeValue = new TimeSpan (23, 59, 59); + Assert.True (provider.IsValid); + + // Another valid time + provider.TimeValue = new TimeSpan (0, 0, 0); + Assert.True (provider.IsValid); + + // Provider auto-corrects invalid values, so IsValid should always be true + provider.TimeValue = new TimeSpan (12, 30, 15); + Assert.True (provider.IsValid); + } + + [Fact] + public void TimeEditor_KeyInput_UpdatesValue () + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + try + { + TimeEditor te = new () { App = app }; + te.Layout (); + te.Value = TimeSpan.Zero; + + // Simulate typing '1' + te.NewKeyDownEvent (Key.D1); + + // The value should have been updated + string text = te.Text.Trim (); + Assert.Contains ("1", text); + } + finally + { + app.Dispose (); + } + } + + [Fact] + public void TimeEditor_Navigation_Keys () + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + try + { + TimeEditor te = new () { App = app }; + te.Layout (); + + // Home key should move to start + te.NewKeyDownEvent (Key.Home); + + // End key should move to end + te.NewKeyDownEvent (Key.End); + + // Arrow keys should navigate + te.NewKeyDownEvent (Key.CursorLeft); + te.NewKeyDownEvent (Key.CursorRight); + + // No exceptions should be thrown + Assert.NotNull (te); + } + finally + { + app.Dispose (); + } + } + + [Fact] + public void TimeEditor_IValue_GetValue () + { + TimeEditor te = new (); + TimeSpan testTime = new (14, 30, 45); + te.Value = testTime; + + object? value = ((Terminal.Gui.ViewBase.IValue)te).GetValue (); + + Assert.NotNull (value); + Assert.IsType (value); + Assert.Equal (testTime, (TimeSpan)value); + } + + [Fact] + public void TimeTextProvider_12Hour_Format_With_AM_PM () + { + TimeTextProvider provider = new (); + + // Set to a 12-hour format + DateTimeFormatInfo format12h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone (); + format12h.LongTimePattern = "h:mm:ss tt"; + provider.Format = format12h; + + // Set time to 2:30 PM (14:30) + provider.TimeValue = new TimeSpan (14, 30, 0); + + string display = provider.DisplayText.Trim (); + + // Should contain PM + Assert.Contains ("PM", display, StringComparison.OrdinalIgnoreCase); + + // Set time to 2:30 AM (2:30) + provider.TimeValue = new TimeSpan (2, 30, 0); + + display = provider.DisplayText.Trim (); + + // Should contain AM + Assert.Contains ("AM", display, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TimeTextProvider_24Hour_Format () + { + TimeTextProvider provider = new (); + + // Set to a 24-hour format + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + format24h.LongTimePattern = "HH:mm:ss"; + provider.Format = format24h; + + // Set time to 14:30 + provider.TimeValue = new TimeSpan (14, 30, 0); + + string display = provider.DisplayText.Trim (); + + // Should contain 14 + Assert.Contains ("14", display); + + // Should not contain AM/PM + Assert.DoesNotContain ("AM", display, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain ("PM", display, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TimeEditor_Delete_And_Backspace () + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + try + { + TimeEditor te = new () { App = app, Value = new TimeSpan (12, 34, 56) }; + te.Layout (); + + // Move to start and delete + te.NewKeyDownEvent (Key.Home); + te.NewKeyDownEvent (Key.Delete); + + // Value should have changed (first digit replaced with 0) + string text = te.Text.Trim (); + Assert.NotEqual ("12:34:56", text); + + // Backspace should also work + te.NewKeyDownEvent (Key.Backspace); + + // Text should have changed again + Assert.NotNull (te.Text); + } + finally + { + app.Dispose (); + } + } + + [Fact] + public void TimeTextProvider_CursorEnd_Returns_Last_Position () + { + TimeTextProvider provider = new (); + + int endPos = provider.CursorEnd (); + + // End position should be >= 0 + Assert.True (endPos >= 0); + + // For 24-hour format like "HH:mm:ss", end should be at last digit (position 7) + // For 12-hour format with AM/PM, end should be at AM/PM position + int displayLength = provider.DisplayText.Trim ().Length; + Assert.True (endPos < displayLength); + } + + [Fact] + public void TimeEditor_Text_Property_Updates_Value () + { + TimeEditor te = new (); + + // Set text directly + te.Text = "14:30:45"; + + // Value should be updated + Assert.Equal (14, te.Value.Hours); + Assert.Equal (30, te.Value.Minutes); + Assert.Equal (45, te.Value.Seconds); + } + + [Fact] + public void TimeTextProvider_InsertAt_NonDigit_Returns_False () + { + TimeTextProvider provider = new (); + + // Try to insert a non-digit character at a digit position + bool result = provider.InsertAt ('x', 0); + + // Should fail + Assert.False (result); + } + + [Fact] + public void TimeEditor_Multiple_Format_Changes () + { + TimeEditor te = new () { Value = new TimeSpan (14, 30, 45) }; + te.Layout (); + + // Change format multiple times + for (int i = 0; i < 3; i++) + { + DateTimeFormatInfo format = (DateTimeFormatInfo)CultureInfo.CurrentCulture.DateTimeFormat.Clone (); + format.LongTimePattern = i % 2 == 0 ? "HH:mm" : "HH:mm:ss"; + te.Format = format; + te.Layout (); + + // Value should remain the same + Assert.Equal (14, te.Value.Hours); + Assert.Equal (30, te.Value.Minutes); + } + } +} From ba5705756596578b8e2979fae02f2a13cda984f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 14:31:02 +0000 Subject: [PATCH 04/26] Address code review feedback: fix event args, remove AI comment, simplify logic, extract constants Co-authored-by: tig <585482+tig@users.noreply.github.com> --- Terminal.Gui/Views/TextInput/TimeEditor.cs | 13 ++++++-- .../Views/TextInput/TimeTextProvider.cs | 30 +++++++++++++++---- .../Views/TimeEditorTests.cs | 1 - 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/Terminal.Gui/Views/TextInput/TimeEditor.cs b/Terminal.Gui/Views/TextInput/TimeEditor.cs index 8833e8f793..2bc59c2f9d 100644 --- a/Terminal.Gui/Views/TextInput/TimeEditor.cs +++ b/Terminal.Gui/Views/TextInput/TimeEditor.cs @@ -46,6 +46,7 @@ namespace Terminal.Gui.Views; public class TimeEditor : TextValidateField, IValue { private TimeTextProvider TimeProvider => (TimeTextProvider)Provider!; + private TimeSpan _lastKnownValue = TimeSpan.Zero; /// /// Initializes a new instance of the class. @@ -57,6 +58,9 @@ public TimeEditor () // Subscribe to provider's text changed to raise our value events TimeProvider.TextChanged += (_, _) => RaiseValueChangedEvents (); + + // Initialize last known value + _lastKnownValue = TimeProvider.TimeValue; } /// @@ -165,8 +169,13 @@ private void RaiseValueChangedEvents () { TimeSpan currentValue = TimeProvider.TimeValue; - // The provider already updated the value, just notify - ValueChangedEventArgs changedArgs = new (currentValue, currentValue); + if (_lastKnownValue == currentValue) + { + return; + } + + ValueChangedEventArgs changedArgs = new (_lastKnownValue, currentValue); + _lastKnownValue = currentValue; OnValueChanged (changedArgs); ValueChanged?.Invoke (this, changedArgs); } diff --git a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs index d28e14037e..400a05266f 100644 --- a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs +++ b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs @@ -24,6 +24,14 @@ namespace Terminal.Gui.Views; /// public class TimeTextProvider : ITextValidateProvider { + // Constants for DateTime construction to avoid magic numbers + private const int BASE_YEAR = 2000; + private const int BASE_MONTH = 1; + private const int BASE_DAY = 1; + private const int SAMPLE_HOUR = 14; + private const int SAMPLE_MINUTE = 30; + private const int SAMPLE_SECOND = 45; + private DateTimeFormatInfo _format = CultureInfo.CurrentCulture.DateTimeFormat; private string _separator = CultureInfo.CurrentCulture.DateTimeFormat.TimeSeparator; private TimeSpan _timeValue = TimeSpan.Zero; @@ -313,7 +321,7 @@ private void AnalyzePattern () _hasAmPm = pattern.Contains ("tt"); // Build a sample time to determine field positions - DateTime sampleTime = new (2000, 1, 1, 14, 30, 45); + DateTime sampleTime = new (BASE_YEAR, BASE_MONTH, BASE_DAY, SAMPLE_HOUR, SAMPLE_MINUTE, SAMPLE_SECOND); string formatted = sampleTime.ToString (pattern, _format); _fieldLength = formatted.Length; @@ -357,11 +365,23 @@ private string FormatTimeValue () hours = 12; } + // Convert to 24-hour format for DateTime construction + int hours24; + + if (_isPm) + { + hours24 = hours == 12 ? 12 : hours + 12; + } + else + { + hours24 = hours == 12 ? 0 : hours; + } + dt = new DateTime ( - 2000, - 1, - 1, - _isPm ? (hours == 12 ? 12 : hours + 12) : (hours == 12 ? 0 : hours), + BASE_YEAR, + BASE_MONTH, + BASE_DAY, + hours24, _timeValue.Minutes, _timeValue.Seconds ); diff --git a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs index 75578046bb..f01a97bef6 100644 --- a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs @@ -3,7 +3,6 @@ namespace ViewsTests; -// Claude - Opus 4.5 public class TimeEditorTests : TestDriverBase { [Fact] From 181ac3cf16a70858d7bd02022d56e4e3467ccd47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 14:33:31 +0000 Subject: [PATCH 05/26] Add TimeEditor demonstration to UICatalog TimeAndDate scenario Co-authored-by: tig <585482+tig@users.noreply.github.com> --- Examples/UICatalog/Scenarios/TimeAndDate.cs | 77 ++++++++++++++++++++- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/Examples/UICatalog/Scenarios/TimeAndDate.cs b/Examples/UICatalog/Scenarios/TimeAndDate.cs index dcd5192c4b..c575021ddb 100644 --- a/Examples/UICatalog/Scenarios/TimeAndDate.cs +++ b/Examples/UICatalog/Scenarios/TimeAndDate.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Globalization; namespace UICatalog.Scenarios; @@ -14,6 +15,7 @@ public class TimeAndDate : Scenario private Label? _lblOldDate; private Label? _lblOldTime; private Label? _lblTimeFmt; + private Label? _lblTimeEditorValue; public override void Main () { @@ -23,10 +25,20 @@ public override void Main () app.Init (); using Window win = new () { Title = GetQuitKeyAndName () }; + + // TimeField examples (existing) + Label tfLabel = new () + { + X = Pos.Center (), + Y = 1, + Text = "TimeField (Legacy):" + }; + win.Add (tfLabel); + TimeField longTime = new () { X = Pos.Center (), - Y = 2, + Y = Pos.Bottom (tfLabel), IsShortFormat = false, ReadOnly = false, Value = DateTime.Now.TimeOfDay @@ -44,10 +56,53 @@ public override void Main () }; shortTime.ValueChanged += TimeChanged; win.Add (shortTime); + + // TimeEditor examples (new) + Label teLabel = new () + { + X = Pos.Center (), + Y = Pos.Bottom (shortTime) + 1, + Text = "TimeEditor (New - based on TextValidateField):" + }; + win.Add (teLabel); + + // Default culture time editor + TimeEditor defaultTimeEditor = new () + { + X = Pos.Center (), + Y = Pos.Bottom (teLabel), + Value = DateTime.Now.TimeOfDay + }; + defaultTimeEditor.ValueChanged += TimeEditorChanged; + win.Add (defaultTimeEditor); + + // 24-hour format time editor + TimeEditor time24Editor = new () + { + X = Pos.Center (), + Y = Pos.Bottom (defaultTimeEditor) + 1, + Value = DateTime.Now.TimeOfDay, + Format = (DateTimeFormatInfo)System.Globalization.CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone () + }; + time24Editor.ValueChanged += TimeEditorChanged; + win.Add (time24Editor); + + // Short time format time editor + DateTimeFormatInfo shortFormat = (DateTimeFormatInfo)System.Globalization.CultureInfo.CurrentCulture.DateTimeFormat.Clone (); + shortFormat.LongTimePattern = shortFormat.ShortTimePattern; + TimeEditor shortTimeEditor = new () + { + X = Pos.Center (), + Y = Pos.Bottom (time24Editor) + 1, + Value = DateTime.Now.TimeOfDay, + Format = shortFormat + }; + shortTimeEditor.ValueChanged += TimeEditorChanged; + win.Add (shortTimeEditor); DateField shortDate = new (DateTime.Now) { - X = Pos.Center (), Y = Pos.Bottom (shortTime) + 1, ReadOnly = true + X = Pos.Center (), Y = Pos.Bottom (shortTimeEditor) + 1, ReadOnly = true }; shortDate.ValueChanged += DateChanged; win.Add (shortDate); @@ -91,11 +146,22 @@ public override void Main () Text = "Time Format: " }; win.Add (_lblTimeFmt); + + _lblTimeEditorValue = new() + { + X = Pos.Center (), + Y = Pos.Bottom (_lblTimeFmt) + 1, + TextAlignment = Alignment.Center, + + Width = Dim.Fill (), + Text = "TimeEditor Value: " + }; + win.Add (_lblTimeEditorValue); _lblOldDate = new() { X = Pos.Center (), - Y = Pos.Bottom (_lblTimeFmt) + 2, + Y = Pos.Bottom (_lblTimeEditorValue) + 1, TextAlignment = Alignment.Center, Width = Dim.Fill (), @@ -155,4 +221,9 @@ private void TimeChanged (object? sender, ValueChangedEventArgs e) { _lblNewTime!.Text = $"New Time: {e.NewValue}"; } + + private void TimeEditorChanged (object? sender, ValueChangedEventArgs e) + { + _lblTimeEditorValue!.Text = $"TimeEditor Value: {e.NewValue}"; + } } From 6f31b7c8fa48c93653e8536ea4044ba1a600f609 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 15:09:05 +0000 Subject: [PATCH 06/26] Fix culture-dependent test failure in TimeTextProvider_Delete_ReplacesWithZero Co-authored-by: tig <585482+tig@users.noreply.github.com> --- Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs index f01a97bef6..b186c27083 100644 --- a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs @@ -134,6 +134,11 @@ public void TimeTextProvider_InsertAt_ReplacesDigit () public void TimeTextProvider_Delete_ReplacesWithZero () { TimeTextProvider provider = new (); + + // Use 24-hour format to avoid culture-specific issues + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + provider.Format = format24h; + provider.TimeValue = new TimeSpan (14, 30, 45); // Delete at position 0 should replace with '0' From 5dc787f6e5c757777dc0284da4809014a197e1e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 19:09:52 +0000 Subject: [PATCH 07/26] Fix additional culture-dependent tests for macOS/Windows compatibility Co-authored-by: tig <585482+tig@users.noreply.github.com> --- .../Views/TimeEditorTests.cs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs index b186c27083..9660ab42aa 100644 --- a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs @@ -102,6 +102,10 @@ public void TimeTextProvider_CursorNavigation_SkipsSeparators () { TimeTextProvider provider = new (); + // Use 24-hour format to ensure consistent behavior + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + provider.Format = format24h; + // CursorStart should return 0 Assert.Equal (0, provider.CursorStart ()); @@ -119,6 +123,11 @@ public void TimeTextProvider_CursorNavigation_SkipsSeparators () public void TimeTextProvider_InsertAt_ReplacesDigit () { TimeTextProvider provider = new (); + + // Use 24-hour format to ensure consistent behavior + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + provider.Format = format24h; + provider.TimeValue = TimeSpan.Zero; // "00:00:00" in 24h format // Insert '1' at position 0 (first hour digit) @@ -318,7 +327,13 @@ public void TimeEditor_Delete_And_Backspace () try { - TimeEditor te = new () { App = app, Value = new TimeSpan (12, 34, 56) }; + TimeEditor te = new () { App = app }; + + // Use 24-hour format to ensure consistent behavior + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + te.Format = format24h; + + te.Value = new TimeSpan (12, 34, 56); te.Layout (); // Move to start and delete @@ -362,6 +377,10 @@ public void TimeEditor_Text_Property_Updates_Value () { TimeEditor te = new (); + // Use 24-hour format to ensure consistent parsing + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + te.Format = format24h; + // Set text directly te.Text = "14:30:45"; From 94cd4f014072794e0617ac5b6316716e4b961c8e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:46:06 +0000 Subject: [PATCH 08/26] Fix code review issues: double-firing events, ValueChanging for keyboard, exception handling, width consistency, simplify _isPm, test improvements Co-authored-by: tig <585482+tig@users.noreply.github.com> --- Terminal.Gui/Views/TextInput/TimeEditor.cs | 37 ++++++++++- .../Views/TextInput/TimeTextProvider.cs | 65 ++++++------------- .../Views/TimeEditorTests.cs | 11 +++- 3 files changed, 65 insertions(+), 48 deletions(-) diff --git a/Terminal.Gui/Views/TextInput/TimeEditor.cs b/Terminal.Gui/Views/TextInput/TimeEditor.cs index 2bc59c2f9d..57c7896a92 100644 --- a/Terminal.Gui/Views/TextInput/TimeEditor.cs +++ b/Terminal.Gui/Views/TextInput/TimeEditor.cs @@ -54,7 +54,9 @@ public class TimeEditor : TextValidateField, IValue public TimeEditor () { Provider = new TimeTextProvider (); - Width = Dim.Auto (minimumContentDim: 10); + + // Set initial width based on current format + Width = TimeProvider.DisplayText.Length + 2; // Subscribe to provider's text changed to raise our value events TimeProvider.TextChanged += (_, _) => RaiseValueChangedEvents (); @@ -122,12 +124,16 @@ public TimeSpan Value return; } + // Update _lastKnownValue before setting to prevent double-firing from TextChanged handler + _lastKnownValue = value; + TimeProvider.TimeValue = value; Text = TimeProvider.Text; ValueChangedEventArgs changedArgs = new (oldValue, value); OnValueChanged (changedArgs); ValueChanged?.Invoke (this, changedArgs); + ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (oldValue, value)); SetNeedsDraw (); } @@ -139,6 +145,9 @@ public TimeSpan Value /// public event EventHandler>? ValueChanged; + /// + public event EventHandler>? ValueChangedUntyped; + /// object? IValue.GetValue () => Value; @@ -174,9 +183,35 @@ private void RaiseValueChangedEvents () return; } + // Raise ValueChanging to allow cancellation + ValueChangingEventArgs changingArgs = new (_lastKnownValue, currentValue); + + if (OnValueChanging (changingArgs) || changingArgs.Handled) + { + // Revert the change if cancelled + TimeProvider.TimeValue = _lastKnownValue; + Text = TimeProvider.Text; + SetNeedsDraw (); + + return; + } + + ValueChanging?.Invoke (this, changingArgs); + + if (changingArgs.Handled) + { + // Revert the change if cancelled + TimeProvider.TimeValue = _lastKnownValue; + Text = TimeProvider.Text; + SetNeedsDraw (); + + return; + } + ValueChangedEventArgs changedArgs = new (_lastKnownValue, currentValue); _lastKnownValue = currentValue; OnValueChanged (changedArgs); ValueChanged?.Invoke (this, changedArgs); + ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (_lastKnownValue, currentValue)); } } diff --git a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs index 400a05266f..d9297fe3b1 100644 --- a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs +++ b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs @@ -38,9 +38,8 @@ public class TimeTextProvider : ITextValidateProvider private bool _is12Hour; private bool _hasAmPm; private int _fieldLength; - private List _separatorPositions = []; + private HashSet _separatorPositions = []; private int _amPmPosition = -1; - private bool _isPm; /// /// Initializes a new instance of the class. @@ -75,11 +74,7 @@ public DateTimeFormatInfo Format public TimeSpan TimeValue { get => _timeValue; - set - { - _timeValue = value; - _isPm = value.Hours >= 12; - } + set => _timeValue = value; } /// @@ -95,7 +90,6 @@ public string Text { string oldValue = Text; _timeValue = parsedValue; - _isPm = _timeValue.Hours >= 12; if (oldValue != Text) { @@ -257,15 +251,19 @@ public bool InsertAt (char ch, int pos) { if (char.ToUpperInvariant (ch) == 'A' || char.ToUpperInvariant (ch) == 'P') { - _isPm = char.ToUpperInvariant (ch) == 'P'; + bool isPm = char.ToUpperInvariant (ch) == 'P'; // Update the time value hours to reflect AM/PM change int hours = _timeValue.Hours % 12; - if (_isPm && hours < 12) + if (isPm && hours < 12) { hours += 12; } + else if (!isPm && hours >= 12) + { + hours -= 12; + } _timeValue = new TimeSpan (hours, _timeValue.Minutes, _timeValue.Seconds); OnTextChanged (new (in oldValue)); @@ -293,7 +291,6 @@ public bool InsertAt (char ch, int pos) if (TryParseTimeValue (sb.ToString (), out TimeSpan newValue)) { _timeValue = newValue; - _isPm = _timeValue.Hours >= 12; OnTextChanged (new (in oldValue)); return true; @@ -355,38 +352,6 @@ private string FormatTimeValue () { DateTime dt = DateTime.Today.Add (_timeValue); - // For 12-hour format, adjust the hours if needed - if (_is12Hour && _hasAmPm) - { - int hours = _timeValue.Hours % 12; - - if (hours == 0) - { - hours = 12; - } - - // Convert to 24-hour format for DateTime construction - int hours24; - - if (_isPm) - { - hours24 = hours == 12 ? 12 : hours + 12; - } - else - { - hours24 = hours == 12 ? 0 : hours; - } - - dt = new DateTime ( - BASE_YEAR, - BASE_MONTH, - BASE_DAY, - hours24, - _timeValue.Minutes, - _timeValue.Seconds - ); - } - return dt.ToString (_format.LongTimePattern, _format); } @@ -506,12 +471,22 @@ private bool TryManualParse (string text, out TimeSpan result) seconds = Math.Max (0, Math.Min (59, seconds)); result = new TimeSpan (hours, minutes, seconds); - _isPm = hours >= 12; return true; } - catch + catch (ArgumentException) + { + // TimeSpan constructor can throw ArgumentOutOfRangeException + return false; + } + catch (FormatException) + { + // String parsing operations can throw FormatException + return false; + } + catch (OverflowException) { + // Arithmetic operations can throw OverflowException return false; } } diff --git a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs index 9660ab42aa..382be30e60 100644 --- a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs @@ -3,6 +3,7 @@ namespace ViewsTests; +// Claude - Sonnet 4.6 public class TimeEditorTests : TestDriverBase { [Fact] @@ -40,13 +41,18 @@ public void Value_Property_GetSet () public void Format_Property_Changes_Width () { TimeEditor te = new (); + + // Set initial format explicitly to ensure deterministic test + DateTimeFormatInfo initialFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + initialFormat.LongTimePattern = "HH:mm:ss"; + te.Format = initialFormat; te.Layout (); int initialWidth = te.Frame.Width; Assert.True (initialWidth > 0); - // Change to a different culture with different pattern - DateTimeFormatInfo customFormat = (DateTimeFormatInfo)CultureInfo.CurrentCulture.DateTimeFormat.Clone (); + // Change to a different pattern + DateTimeFormatInfo customFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); customFormat.LongTimePattern = "HH:mm"; te.Format = customFormat; te.Layout (); @@ -54,6 +60,7 @@ public void Format_Property_Changes_Width () // Width should change to accommodate shorter pattern int newWidth = te.Frame.Width; Assert.NotEqual (initialWidth, newWidth); + Assert.True (newWidth < initialWidth); } [Fact] From dcdef40e6185efd538c2126997202cc30ff3ab8e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:48:24 +0000 Subject: [PATCH 09/26] Add comprehensive test coverage for TryManualParse, cursor navigation, and edge cases; fix trailing whitespace Co-authored-by: tig <585482+tig@users.noreply.github.com> --- Examples/UICatalog/Scenarios/TimeAndDate.cs | 16 +- .../Views/TimeEditorTests.cs | 228 ++++++++++++++++++ 2 files changed, 236 insertions(+), 8 deletions(-) diff --git a/Examples/UICatalog/Scenarios/TimeAndDate.cs b/Examples/UICatalog/Scenarios/TimeAndDate.cs index c575021ddb..ff60f6dec7 100644 --- a/Examples/UICatalog/Scenarios/TimeAndDate.cs +++ b/Examples/UICatalog/Scenarios/TimeAndDate.cs @@ -25,7 +25,7 @@ public override void Main () app.Init (); using Window win = new () { Title = GetQuitKeyAndName () }; - + // TimeField examples (existing) Label tfLabel = new () { @@ -34,7 +34,7 @@ public override void Main () Text = "TimeField (Legacy):" }; win.Add (tfLabel); - + TimeField longTime = new () { X = Pos.Center (), @@ -56,7 +56,7 @@ public override void Main () }; shortTime.ValueChanged += TimeChanged; win.Add (shortTime); - + // TimeEditor examples (new) Label teLabel = new () { @@ -65,7 +65,7 @@ public override void Main () Text = "TimeEditor (New - based on TextValidateField):" }; win.Add (teLabel); - + // Default culture time editor TimeEditor defaultTimeEditor = new () { @@ -75,7 +75,7 @@ public override void Main () }; defaultTimeEditor.ValueChanged += TimeEditorChanged; win.Add (defaultTimeEditor); - + // 24-hour format time editor TimeEditor time24Editor = new () { @@ -86,7 +86,7 @@ public override void Main () }; time24Editor.ValueChanged += TimeEditorChanged; win.Add (time24Editor); - + // Short time format time editor DateTimeFormatInfo shortFormat = (DateTimeFormatInfo)System.Globalization.CultureInfo.CurrentCulture.DateTimeFormat.Clone (); shortFormat.LongTimePattern = shortFormat.ShortTimePattern; @@ -146,7 +146,7 @@ public override void Main () Text = "Time Format: " }; win.Add (_lblTimeFmt); - + _lblTimeEditorValue = new() { X = Pos.Center (), @@ -221,7 +221,7 @@ private void TimeChanged (object? sender, ValueChangedEventArgs e) { _lblNewTime!.Text = $"New Time: {e.NewValue}"; } - + private void TimeEditorChanged (object? sender, ValueChangedEventArgs e) { _lblTimeEditorValue!.Text = $"TimeEditor Value: {e.NewValue}"; diff --git a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs index 382be30e60..ba4c3a5620 100644 --- a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs @@ -428,4 +428,232 @@ public void TimeEditor_Multiple_Format_Changes () Assert.Equal (30, te.Value.Minutes); } } + + [Fact] + public void TimeTextProvider_TryManualParse_PartialInput () + { + TimeTextProvider provider = new (); + + // Use 24-hour format + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + provider.Format = format24h; + + // Test partial input parsing (minutes only) + provider.Text = "14:30"; + Assert.Equal (14, provider.TimeValue.Hours); + Assert.Equal (30, provider.TimeValue.Minutes); + Assert.Equal (0, provider.TimeValue.Seconds); + + // Test with seconds + provider.Text = "14:30:45"; + Assert.Equal (14, provider.TimeValue.Hours); + Assert.Equal (30, provider.TimeValue.Minutes); + Assert.Equal (45, provider.TimeValue.Seconds); + } + + [Fact] + public void TimeTextProvider_TryManualParse_InvalidInput () + { + TimeTextProvider provider = new (); + + // Use 24-hour format + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + provider.Format = format24h; + + TimeSpan initialValue = provider.TimeValue; + + // Test invalid input (no separator) + provider.Text = "invalid"; + Assert.Equal (initialValue, provider.TimeValue); + + // Test incomplete input (only one part) + provider.Text = "14"; + Assert.Equal (initialValue, provider.TimeValue); + } + + [Fact] + public void TimeTextProvider_TryManualParse_AutoCorrection () + { + TimeTextProvider provider = new (); + + // Use 24-hour format + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + provider.Format = format24h; + + // Test auto-correction for out-of-range values + provider.Text = "25:70:90"; + + // Should auto-correct to valid ranges + Assert.Equal (23, provider.TimeValue.Hours); // Max 23 + Assert.Equal (59, provider.TimeValue.Minutes); // Max 59 + Assert.Equal (59, provider.TimeValue.Seconds); // Max 59 + } + + [Fact] + public void TimeTextProvider_12Hour_AM_PM_Parsing () + { + TimeTextProvider provider = new (); + + // Use 12-hour format + DateTimeFormatInfo format12h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone (); + provider.Format = format12h; + + // Test PM parsing + provider.Text = "2:30:00 PM"; + Assert.Equal (14, provider.TimeValue.Hours); + + // Test AM parsing + provider.Text = "2:30:00 AM"; + Assert.Equal (2, provider.TimeValue.Hours); + + // Test 12 PM (noon) + provider.Text = "12:00:00 PM"; + Assert.Equal (12, provider.TimeValue.Hours); + + // Test 12 AM (midnight) + provider.Text = "12:00:00 AM"; + Assert.Equal (0, provider.TimeValue.Hours); + } + + [Fact] + public void TimeTextProvider_CursorNavigation_Comprehensive () + { + TimeTextProvider provider = new (); + + // Use 24-hour format + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + provider.Format = format24h; + + // Test CursorStart + Assert.Equal (0, provider.CursorStart ()); + + // Test CursorEnd + int endPos = provider.CursorEnd (); + Assert.True (endPos >= 0); + + // Test navigation through all positions + int pos = provider.CursorStart (); + int lastPos = pos; + + for (int i = 0; i < 10; i++) + { + int nextPos = provider.CursorRight (pos); + + // Should skip separators + Assert.NotEqual (pos, nextPos); + pos = nextPos; + + if (pos >= provider.CursorEnd ()) + { + break; + } + } + } + + [Fact] + public void TimeEditor_ValueChanging_Cancel () + { + TimeEditor te = new (); + TimeSpan initialValue = TimeSpan.FromHours (10); + te.Value = initialValue; + + bool changingEventFired = false; + bool changedEventFired = false; + + te.ValueChanging += (_, e) => + { + changingEventFired = true; + e.Handled = true; // Cancel the change + }; + + te.ValueChanged += (_, e) => + { + changedEventFired = true; + }; + + // Try to set new value + te.Value = TimeSpan.FromHours (15); + + // ValueChanging should have fired, but ValueChanged should not + Assert.True (changingEventFired); + Assert.False (changedEventFired); + + // Value should not have changed + Assert.Equal (initialValue, te.Value); + } + + [Fact] + public void TimeTextProvider_Delete_AtSeparatorPosition () + { + TimeTextProvider provider = new (); + + // Use 24-hour format + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + provider.Format = format24h; + + provider.TimeValue = new TimeSpan (14, 30, 45); + + // Try to delete at separator position (position 2 in "14:30:45") + string beforeText = provider.Text.Trim (); + bool result = provider.Delete (2); + + // Delete at separator should not change anything or should skip to next position + string afterText = provider.Text.Trim (); + + // The behavior depends on implementation, but text should still be valid + Assert.NotNull (afterText); + } + + [Fact] + public void TimeEditor_ValueChangedUntyped_Event () + { + TimeEditor te = new (); + bool eventFired = false; + object? oldValue = null; + object? newValue = null; + + te.ValueChangedUntyped += (_, e) => + { + eventFired = true; + oldValue = e.OldValue; + newValue = e.NewValue; + }; + + TimeSpan testValue = TimeSpan.FromHours (15); + te.Value = testValue; + + Assert.True (eventFired); + Assert.Equal (TimeSpan.Zero, oldValue); + Assert.Equal (testValue, newValue); + } + + [Fact] + public void TimeTextProvider_CursorLeft_FromStart () + { + TimeTextProvider provider = new (); + + // Use 24-hour format + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + provider.Format = format24h; + + // CursorLeft from start should return start + int pos = provider.CursorLeft (0); + Assert.Equal (0, pos); + } + + [Fact] + public void TimeTextProvider_CursorRight_FromEnd () + { + TimeTextProvider provider = new (); + + // Use 24-hour format + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + provider.Format = format24h; + + int endPos = provider.CursorEnd (); + + // CursorRight from end should return end + int pos = provider.CursorRight (endPos); + Assert.Equal (endPos, pos); + } } From f00b6dbc2b151b046497fd69eba541b980a6cc3e Mon Sep 17 00:00:00 2001 From: Tig Date: Sun, 25 Jan 2026 14:59:01 -0700 Subject: [PATCH 10/26] TextAlignment = Alignment.End is broken Improve char-level validation in text input controls - Add VerifyChar to ITextValidateProvider and implement in NetMaskedTextProvider, TextRegexProvider, and TimeTextProvider - TextValidateField now blocks invalid chars before insertion and updates cursor more responsively - Enhance cursor and navigation logic, allowing cursor past last char and better handling for right-aligned/fixed fields - Time input fields now restrict input to valid digits/AM/PM and have improved docs - UI labels for masked/regex fields now reflect current mask/pattern - Minor code cleanups and spelling exception for "TimeEditor" --- .../UICatalog/Scenarios/TextInputControls.cs | 68 ++++---- .../Views/TextInput/ITextValidateProvider.cs | 7 + .../Views/TextInput/NetMaskedTextProvider.cs | 8 +- .../Views/TextInput/TextRegexProvider.cs | 38 +++- .../Views/TextInput/TextValidateField.cs | 120 +++++++++---- Terminal.Gui/Views/TextInput/TimeEditor.cs | 59 ++++--- .../Views/TextInput/TimeTextProvider.cs | 163 ++++++++++++------ Terminal.sln.DotSettings | 1 + 8 files changed, 307 insertions(+), 157 deletions(-) diff --git a/Examples/UICatalog/Scenarios/TextInputControls.cs b/Examples/UICatalog/Scenarios/TextInputControls.cs index 61b017917a..546b455650 100644 --- a/Examples/UICatalog/Scenarios/TextInputControls.cs +++ b/Examples/UICatalog/Scenarios/TextInputControls.cs @@ -115,10 +115,7 @@ void TextViewDrawContent (object? sender, DrawEventArgs e) => CheckBox chxReadOnly = new () { - X = Pos.Left (textView), - Y = Pos.Bottom (textView), - Value = textView.ReadOnly ? CheckState.Checked : CheckState.UnChecked, - Text = "Read_Only" + X = Pos.Left (textView), Y = Pos.Bottom (textView), Value = textView.ReadOnly ? CheckState.Checked : CheckState.UnChecked, Text = "Read_Only" }; chxReadOnly.ValueChanging += (_, args) => textView.ReadOnly = args.NewValue == CheckState.Checked; win.Add (chxReadOnly); @@ -155,39 +152,39 @@ void TextViewDrawContent (object? sender, DrawEventArgs e) => }; chxMultiline.ValueChanging += (_, e) => - { - textView.Multiline = e.NewValue == CheckState.Checked; + { + textView.Multiline = e.NewValue == CheckState.Checked; - if (!textView.Multiline && chxWordWrap.Value == CheckState.Checked) - { - chxWordWrap.Value = CheckState.UnChecked; - } + if (!textView.Multiline && chxWordWrap.Value == CheckState.Checked) + { + chxWordWrap.Value = CheckState.UnChecked; + } - if (!textView.Multiline && chxCaptureTabs.Value == CheckState.Checked) - { - chxCaptureTabs.Value = CheckState.UnChecked; - } - }; + if (!textView.Multiline && chxCaptureTabs.Value == CheckState.Checked) + { + chxCaptureTabs.Value = CheckState.UnChecked; + } + }; Key? keyTab = textView.KeyBindings.GetFirstFromCommands (Command.NextTabStop); Key? keyBackTab = textView.KeyBindings.GetFirstFromCommands (Command.PreviousTabStop); chxCaptureTabs.ValueChanging += (_, e) => - { - textView.TabKeyAddsTab = e.NewValue == CheckState.Checked; - - // TODO: This should be in TextView.TabKeyAddsTab_set - if (e.NewValue == CheckState.Checked) - { - textView.KeyBindings.Add (keyTab!, Command.NextTabStop); - textView.KeyBindings.Add (keyBackTab!, Command.PreviousTabStop); - } - else - { - textView.KeyBindings.Remove (keyTab!); - textView.KeyBindings.Remove (keyBackTab!); - } - }; + { + textView.TabKeyAddsTab = e.NewValue == CheckState.Checked; + + // TODO: This should be in TextView.TabKeyAddsTab_set + if (e.NewValue == CheckState.Checked) + { + textView.KeyBindings.Add (keyTab!, Command.NextTabStop); + textView.KeyBindings.Add (keyBackTab!, Command.PreviousTabStop); + } + else + { + textView.KeyBindings.Remove (keyTab!); + textView.KeyBindings.Remove (keyBackTab!); + } + }; win.Add (chxCaptureTabs); CheckBox scrollBars = new () @@ -273,10 +270,9 @@ void TextViewDrawContent (object? sender, DrawEventArgs e) => _timeField.ValueChanged += TimeChanged; // MaskedTextProvider - uses .NET MaskedTextProvider - Label netProviderLabel = new () { X = Pos.Left (dateField), Y = Pos.Bottom (dateField) + 1, Text = "_NetMaskedTextProvider [ +99 (000) 000-0000 ]:" }; - win.Add (netProviderLabel); - NetMaskedTextProvider netProvider = new ("+99 (000) 000-0000"); + Label netProviderLabel = new () { X = Pos.Left (dateField), Y = Pos.Bottom (dateField) + 1, Text = $"_NetMaskedTextProvider ({netProvider.Mask}):" }; + win.Add (netProviderLabel); TextValidateField netProviderField = new () { X = Pos.Right (netProviderLabel) + 1, Y = Pos.Y (netProviderLabel), Provider = netProvider }; win.Add (netProviderField); @@ -294,14 +290,14 @@ void TextViewDrawContent (object? sender, DrawEventArgs e) => netProviderField.Provider.TextChanged += (_, _) => { labelMirroringNetProviderField.Text = netProviderField.Text; }; // TextRegexProvider - Regex provider implemented by Terminal.Gui + TextRegexProvider provider2 = new ("^([0-9]?[0-9]?[0-9]|1000)$") { ValidateOnInput = false }; + Label regexProviderLabel = new () { - X = Pos.Left (netProviderLabel), Y = Pos.Bottom (netProviderLabel) + 1, Text = "Text_RegexProvider [ ^([0-9]?[0-9]?[0-9]|1000)$ ]:" + X = Pos.Left (netProviderLabel), Y = Pos.Bottom (netProviderLabel) + 1, Text = $"Text_RegexProvider ({provider2.Pattern}):" }; win.Add (regexProviderLabel); - TextRegexProvider provider2 = new ("^([0-9]?[0-9]?[0-9]|1000)$") { ValidateOnInput = false }; - TextValidateField regexProviderField = new () { X = Pos.Right (regexProviderLabel) + 1, diff --git a/Terminal.Gui/Views/TextInput/ITextValidateProvider.cs b/Terminal.Gui/Views/TextInput/ITextValidateProvider.cs index 03c614da9c..69c23cd30d 100644 --- a/Terminal.Gui/Views/TextInput/ITextValidateProvider.cs +++ b/Terminal.Gui/Views/TextInput/ITextValidateProvider.cs @@ -1,5 +1,7 @@ +using System.ComponentModel; + namespace Terminal.Gui.Views; /// TextValidateField Providers Interface. All TextValidateField are created with a ITextValidateProvider. @@ -51,6 +53,11 @@ public interface ITextValidateProvider /// true if the character was successfully inserted, otherwise false. bool InsertAt (char ch, int pos); + /// + /// Tests whether the specified character would be set successfully at the specified position. + /// + public bool VerifyChar (char input, int position, out MaskedTextResultHint hint); + /// Method that invoke the event if it's defined. /// The previous text before replaced. /// Returns the diff --git a/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs b/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs index 0ac0659cd6..49b393cb62 100644 --- a/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs +++ b/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs @@ -23,7 +23,7 @@ public class NetMaskedTextProvider : ITextValidateProvider private MaskedTextProvider? _provider; /// Empty Constructor - public NetMaskedTextProvider (string mask) { Mask = mask; } + public NetMaskedTextProvider (string mask) => Mask = mask; /// Mask property public string Mask @@ -145,6 +145,12 @@ public bool InsertAt (char ch, int pos) return result; } + /// + public bool VerifyChar (char input, int position, out MaskedTextResultHint hint) + { + return _provider!.VerifyChar (input, position, out hint); + } + /// public void OnTextChanged (EventArgs args) { TextChanged?.Invoke (this, args); } } diff --git a/Terminal.Gui/Views/TextInput/TextRegexProvider.cs b/Terminal.Gui/Views/TextInput/TextRegexProvider.cs index 34ad5e6ffa..a64a494bea 100644 --- a/Terminal.Gui/Views/TextInput/TextRegexProvider.cs +++ b/Terminal.Gui/Views/TextInput/TextRegexProvider.cs @@ -1,4 +1,4 @@ - +using System.ComponentModel; using System.Text.RegularExpressions; namespace Terminal.Gui.Views; @@ -11,7 +11,7 @@ public class TextRegexProvider : ITextValidateProvider private List _text = null!; /// Empty Constructor. - public TextRegexProvider (string pattern) { Pattern = pattern; } + public TextRegexProvider (string pattern) => Pattern = pattern; /// Regex pattern property. public string Pattern @@ -68,10 +68,10 @@ public int Cursor (int pos) } /// - public int CursorStart () { return 0; } + public int CursorStart () => 0; /// - public int CursorEnd () { return _text.Count; } + public int CursorEnd () => _text.Count; /// public int CursorLeft (int pos) @@ -114,7 +114,7 @@ public bool InsertAt (char ch, int pos) List aux = _text.ToList (); aux.Insert (pos, (Rune)ch); - if (Validate (aux) || ValidateOnInput == false) + if (Validate (aux) || !ValidateOnInput) { string oldValue = Text; _text.Insert (pos, (Rune)ch); @@ -127,10 +127,34 @@ public bool InsertAt (char ch, int pos) } /// - public void OnTextChanged (EventArgs args) { TextChanged?.Invoke (this, args); } + public bool VerifyChar (char input, int position, out MaskedTextResultHint hint) + { + if (position < 0 || position > _text.Count) + { + hint = MaskedTextResultHint.PositionOutOfRange; + + return false; + } + + List aux = _text.ToList (); + aux.Insert (position, (Rune)input); + + if (Validate (aux)) + { + hint = MaskedTextResultHint.Success; + + return true; + } + hint = MaskedTextResultHint.InvalidInput; + + return false; + } + + /// + public void OnTextChanged (EventArgs args) => TextChanged?.Invoke (this, args); /// Compiles the regex pattern for validation./> - private void CompileMask () { _regex = new (StringExtensions.ToString (_pattern), RegexOptions.Compiled); } + private void CompileMask () => _regex = new (StringExtensions.ToString (_pattern), RegexOptions.Compiled); private void SetupText () { diff --git a/Terminal.Gui/Views/TextInput/TextValidateField.cs b/Terminal.Gui/Views/TextInput/TextValidateField.cs index c3e5587f6e..7f74eda55a 100644 --- a/Terminal.Gui/Views/TextInput/TextValidateField.cs +++ b/Terminal.Gui/Views/TextInput/TextValidateField.cs @@ -32,6 +32,51 @@ public TextValidateField () KeyBindings.Add (Key.CursorRight, Command.Right); } + /// + protected override void OnHasFocusChanged (bool newHasFocus, View? previousFocusedView, View? view) + { + if (!newHasFocus) + { + Cursor = Cursor with { Position = null }; + + return; + } + + // When we gain focus, put the insertion point at the start if it's before the start. + InsertionPoint = Math.Max (InsertionPoint, _provider!.CursorStart ()); + + // Match the cursor position to the insertion point. + // Don't call UpdateCursor so we can set the style too. + Cursor = Cursor with { Style = CursorStyle.BlinkingBar }; + UpdateCursor (); + } + + /// Updates the cursor position. + /// + /// This method calculates the cursor position and calls . + /// + private void UpdateCursor () + { + (int left, int right) = GetMargins (Viewport.Width); + + // Fixed = true, is for inputs that have fixed width, like masked ones. + // Fixed = false, is for normal input. + // When it's right-aligned and it's a normal input, the cursor behaves differently. + int curPos; + + if (_provider?.Fixed == false && TextAlignment == Alignment.End) + { + curPos = _insertionPoint + left - 1; + } + else + { + curPos = _insertionPoint + left; + } + + Cursor = Cursor with { Position = ViewportToScreen (new Point (curPos, 0)) }; + SetNeedsDraw (); + } + /// This property returns true if the input is valid. public virtual bool IsValid { @@ -56,7 +101,8 @@ public ITextValidateProvider? Provider if (_provider!.Fixed) { - Width = _provider.DisplayText == string.Empty ? DEFAULT_LENGTH : _provider.DisplayText.Length; + // Add one so there is always a space at the end to show the cursor. + Width = (_provider.DisplayText == string.Empty ? DEFAULT_LENGTH : _provider.DisplayText.Length) + 1; } // HomeKeyHandler already call SetNeedsDisplay @@ -94,23 +140,8 @@ private int InsertionPoint } _insertionPoint = value; - (int left, _) = GetMargins (Viewport.Width); - - // Fixed = true, is for inputs that have fixed width, like masked ones. - // Fixed = false, is for normal input. - // When it's right-aligned and it's a normal input, the cursor behaves differently. - int curPos; - - if (_provider?.Fixed == false && TextAlignment == Alignment.End) - { - curPos = _insertionPoint + left - 1; - } - else - { - curPos = _insertionPoint + left; - } - Cursor = Cursor with { Position = ViewportToScreen (new Point (curPos, 0)), Style = CursorStyle.Default }; + UpdateCursor (); } } @@ -122,7 +153,19 @@ protected override bool OnMouseEvent (Mouse mouse) return false; } - int c = _provider!.Cursor (mouse.Position!.Value.X - GetMargins (Viewport.Width).left); + int cursorPos = mouse.Position!.Value.X - GetMargins (Viewport.Width).left; + + if (cursorPos > _provider!.CursorEnd ()) + { + InsertionPoint = cursorPos; + SetFocus (); + SetNeedsDraw (); + UpdateCursor (); + + return true; + } + + int c = _provider!.Cursor (cursorPos); if (!_provider.Fixed && TextAlignment == Alignment.End && Text.Length > 0) { @@ -147,7 +190,7 @@ protected override bool OnDrawingContent (DrawContext? context) return true; } - VisualRole role = HasFocus ? VisualRole.Focus : VisualRole.Editable; + VisualRole role = VisualRole.Editable; Attribute textColor = IsValid ? GetAttributeForRole (role) : SchemeManager.GetScheme (Schemes.Error).GetAttributeForRole (role); (int marginLeft, int marginRight) = GetMargins (Viewport.Width); @@ -179,6 +222,13 @@ protected override bool OnDrawingContent (DrawContext? context) AddRune ((Rune)' '); } + if (HasFocus && _provider.DisplayText.Length > 0 && InsertionPoint < _provider.DisplayText.Length) + { + SetAttributeForRole (VisualRole.Focus); + Move (InsertionPoint + marginLeft, 0); + AddRune ((Rune)_provider.DisplayText [InsertionPoint]); + } + return true; } @@ -190,23 +240,22 @@ protected override bool OnKeyDownNotHandled (Key key) return false; } - if (key.AsRune == default (Rune) || key == Application.QuitKey) + Rune rune = key.AsRune; + + if (!_provider.VerifyChar ((char)rune.Value, InsertionPoint, out _)) { - return false; + // Not a valid char. If it's a letter or, return true to eat it to prevent hotkeys from triggering. + return Rune.IsLetterOrDigit (rune); } - Rune rune = key.AsRune; - bool inserted = _provider.InsertAt ((char)rune.Value, InsertionPoint); if (inserted) { CursorRight (); - - return true; } - return false; + return true; } /// Delete char at cursor position - 1, moving the cursor. @@ -215,12 +264,14 @@ private bool BackspaceKeyHandler () { if (!_provider!.Fixed && TextAlignment == Alignment.End && InsertionPoint <= 1) { - return false; + //return false; } _insertionPoint = _provider.CursorLeft (InsertionPoint); _provider.Delete (InsertionPoint); + SetNeedsDraw (); + UpdateCursor (); return true; } @@ -251,8 +302,17 @@ private bool CursorRight () } int current = InsertionPoint; - InsertionPoint = _provider.CursorRight (InsertionPoint); + + InsertionPoint = _provider.CursorRight (current); + + if (current == InsertionPoint && current <= _provider.CursorEnd ()) + { + // Allow to move the cursor after the last char in this special case. + InsertionPoint++; + } + SetNeedsDraw (); + UpdateCursor (); return current != InsertionPoint; } @@ -276,7 +336,7 @@ private bool DeleteKeyHandler () /// private bool EndKeyHandler () { - InsertionPoint = _provider!.CursorEnd (); + InsertionPoint = _provider!.CursorEnd () + 1; SetNeedsDraw (); return true; @@ -294,7 +354,7 @@ private bool EndKeyHandler () { Alignment.Start => (0, total), Alignment.Center => (total / 2, total / 2 + total % 2), - Alignment.End => (total, 0), + Alignment.End => (total - 1, 1), _ => (0, total) }; } diff --git a/Terminal.Gui/Views/TextInput/TimeEditor.cs b/Terminal.Gui/Views/TextInput/TimeEditor.cs index 57c7896a92..84ee5371c4 100644 --- a/Terminal.Gui/Views/TextInput/TimeEditor.cs +++ b/Terminal.Gui/Views/TextInput/TimeEditor.cs @@ -1,5 +1,4 @@ using System.Globalization; -using Terminal.Gui.ViewBase; namespace Terminal.Gui.Views; @@ -10,11 +9,23 @@ namespace Terminal.Gui.Views; /// /// TimeEditor extends with time-specific functionality: /// -/// Uses for validation and formatting -/// Supports both 12-hour and 24-hour formats via -/// Cursor automatically skips over separator characters -/// Supports AM/PM toggling for 12-hour formats -/// Auto-adjusts width based on time pattern +/// +/// Uses for validation and formatting +/// +/// +/// +/// Supports both 12-hour and 24-hour formats via +/// +/// +/// +/// Cursor automatically skips over separator characters +/// +/// +/// Supports AM/PM toggling for 12-hour formats +/// +/// +/// Auto-adjusts width based on time pattern +/// /// /// /// @@ -54,13 +65,12 @@ public class TimeEditor : TextValidateField, IValue public TimeEditor () { Provider = new TimeTextProvider (); - - // Set initial width based on current format - Width = TimeProvider.DisplayText.Length + 2; - + Width = Dim.Auto (minimumContentDim: 10); + + // Subscribe to provider's text changed to raise our value events TimeProvider.TextChanged += (_, _) => RaiseValueChangedEvents (); - + // Initialize last known value _lastKnownValue = TimeProvider.TimeValue; } @@ -157,19 +167,14 @@ public TimeSpan Value /// /// The event arguments. /// to cancel the change; otherwise . - protected virtual bool OnValueChanging (ValueChangingEventArgs args) - { - return false; - } + protected virtual bool OnValueChanging (ValueChangingEventArgs args) => false; /// /// Called when the has changed. /// Allows derived classes to react to value changes. /// /// The event arguments. - protected virtual void OnValueChanged (ValueChangedEventArgs args) - { - } + protected virtual void OnValueChanged (ValueChangedEventArgs args) { } /// /// Raises value events when the text changes through user input. @@ -177,37 +182,39 @@ protected virtual void OnValueChanged (ValueChangedEventArgs args) private void RaiseValueChangedEvents () { TimeSpan currentValue = TimeProvider.TimeValue; - + if (_lastKnownValue == currentValue) { return; } - + + // Raise ValueChanging to allow cancellation ValueChangingEventArgs changingArgs = new (_lastKnownValue, currentValue); - + if (OnValueChanging (changingArgs) || changingArgs.Handled) { // Revert the change if cancelled TimeProvider.TimeValue = _lastKnownValue; Text = TimeProvider.Text; SetNeedsDraw (); - + return; } - + ValueChanging?.Invoke (this, changingArgs); - + if (changingArgs.Handled) { // Revert the change if cancelled TimeProvider.TimeValue = _lastKnownValue; Text = TimeProvider.Text; SetNeedsDraw (); - + return; } - + + ValueChangedEventArgs changedArgs = new (_lastKnownValue, currentValue); _lastKnownValue = currentValue; OnValueChanged (changedArgs); diff --git a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs index d9297fe3b1..8b8b6901ec 100644 --- a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs +++ b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs @@ -1,5 +1,5 @@ +using System.ComponentModel; using System.Globalization; -using System.Text; namespace Terminal.Gui.Views; @@ -11,10 +11,18 @@ namespace Terminal.Gui.Views; /// /// This provider parses the to determine: /// -/// 12-hour (h/hh) vs 24-hour (H/HH) format -/// Presence of AM/PM designator (tt) -/// Time separator character -/// Dynamic field width based on pattern +/// +/// 12-hour (h/hh) vs 24-hour (H/HH) format +/// +/// +/// Presence of AM/PM designator (tt) +/// +/// +/// Time separator character +/// +/// +/// Dynamic field width based on pattern +/// /// /// /// @@ -40,14 +48,12 @@ public class TimeTextProvider : ITextValidateProvider private int _fieldLength; private HashSet _separatorPositions = []; private int _amPmPosition = -1; + private bool _isPm; /// /// Initializes a new instance of the class. /// - public TimeTextProvider () - { - AnalyzePattern (); - } + public TimeTextProvider () => AnalyzePattern (); /// /// Gets or sets the used for time formatting. @@ -90,7 +96,9 @@ public string Text { string oldValue = Text; _timeValue = parsedValue; - + _isPm = _timeValue.Hours >= 12; + + if (oldValue != Text) { OnTextChanged (new (in oldValue)); @@ -103,14 +111,10 @@ public string Text public string DisplayText => " " + FormatTimeValue (); /// - public bool IsValid - { - get - { - // Always valid - we auto-correct invalid values - return true; - } - } + public bool IsValid => + + // Always valid - we autocorrect invalid values + true; /// public bool Fixed => true; @@ -224,16 +228,17 @@ public bool Delete (int pos) // Replace digit with '0' string currentText = FormatTimeValue (); - + if (pos >= 0 && pos < currentText.Length && char.IsDigit (currentText [pos])) { StringBuilder sb = new (currentText); sb [pos] = '0'; - + if (TryParseTimeValue (sb.ToString (), out TimeSpan newValue)) { _timeValue = newValue; OnTextChanged (new (in oldValue)); + return true; } } @@ -251,23 +256,20 @@ public bool InsertAt (char ch, int pos) { if (char.ToUpperInvariant (ch) == 'A' || char.ToUpperInvariant (ch) == 'P') { - bool isPm = char.ToUpperInvariant (ch) == 'P'; - + _isPm = char.ToUpperInvariant (ch) == 'P'; + // Update the time value hours to reflect AM/PM change int hours = _timeValue.Hours % 12; - - if (isPm && hours < 12) + + if (_isPm && hours < 12) { hours += 12; } - else if (!isPm && hours >= 12) - { - hours -= 12; - } - + + _timeValue = new TimeSpan (hours, _timeValue.Minutes, _timeValue.Seconds); OnTextChanged (new (in oldValue)); - + return true; } @@ -282,17 +284,17 @@ public bool InsertAt (char ch, int pos) // Replace digit at position string currentText = FormatTimeValue (); - + if (pos >= 0 && pos < currentText.Length) { StringBuilder sb = new (currentText); sb [pos] = ch; - + if (TryParseTimeValue (sb.ToString (), out TimeSpan newValue)) { _timeValue = newValue; OnTextChanged (new (in oldValue)); - + return true; } } @@ -301,11 +303,36 @@ public bool InsertAt (char ch, int pos) } /// - public void OnTextChanged (EventArgs args) + public bool VerifyChar (char input, int position, out MaskedTextResultHint hint) { - TextChanged?.Invoke (this, args); + hint = MaskedTextResultHint.Success; + + // Handle AM/PM toggle + if (_hasAmPm && position == _amPmPosition) + { + if (char.ToUpperInvariant (input) == 'A' || char.ToUpperInvariant (input) == 'P') + { + return true; + } + hint = MaskedTextResultHint.InvalidInput; + + return false; + } + + // Only accept digits for time positions + if (!char.IsDigit (input)) + { + hint = MaskedTextResultHint.InvalidInput; + + return false; + } + + return true; } + /// + public void OnTextChanged (EventArgs args) => TextChanged?.Invoke (this, args); + /// /// Analyzes the LongTimePattern to detect format characteristics. /// @@ -319,12 +346,12 @@ private void AnalyzePattern () // Build a sample time to determine field positions DateTime sampleTime = new (BASE_YEAR, BASE_MONTH, BASE_DAY, SAMPLE_HOUR, SAMPLE_MINUTE, SAMPLE_SECOND); - string formatted = sampleTime.ToString (pattern, _format); - + var formatted = sampleTime.ToString (pattern, _format); + _fieldLength = formatted.Length; // Find separator positions - for (int i = 0; i < formatted.Length; i++) + for (var i = 0; i < formatted.Length; i++) { if (formatted [i].ToString () == _separator) { @@ -337,10 +364,10 @@ private void AnalyzePattern () { string amDesignator = _format.AMDesignator; string pmDesignator = _format.PMDesignator; - + int amIndex = formatted.IndexOf (amDesignator, StringComparison.Ordinal); int pmIndex = formatted.IndexOf (pmDesignator, StringComparison.Ordinal); - + _amPmPosition = Math.Max (amIndex, pmIndex); } } @@ -351,7 +378,33 @@ private void AnalyzePattern () private string FormatTimeValue () { DateTime dt = DateTime.Today.Add (_timeValue); - + + // For 12-hour format, adjust the hours if needed + if (_is12Hour && _hasAmPm) + { + int hours = _timeValue.Hours % 12; + + if (hours == 0) + { + hours = 12; + } + + // Convert to 24-hour format for DateTime construction + int hours24; + + if (_isPm) + { + hours24 = hours == 12 ? 12 : hours + 12; + } + else + { + hours24 = hours == 12 ? 0 : hours; + } + + dt = new DateTime (BASE_YEAR, BASE_MONTH, BASE_DAY, hours24, _timeValue.Minutes, _timeValue.Seconds); + } + + return dt.ToString (_format.LongTimePattern, _format); } @@ -370,16 +423,10 @@ private bool TryParseTimeValue (string text, out TimeSpan result) text = text.Trim (); // Try to parse using the current pattern - if (DateTime.TryParseExact ( - text, - _format.LongTimePattern, - _format, - DateTimeStyles.None, - out DateTime dt - )) + if (DateTime.TryParseExact (text, _format.LongTimePattern, _format, DateTimeStyles.None, out DateTime dt)) { result = dt.TimeOfDay; - + return true; } @@ -397,16 +444,16 @@ private bool TryManualParse (string text, out TimeSpan result) try { string [] parts = text.Split (_separator [0]); - + if (parts.Length < 2) { return false; } // Extract AM/PM if present - bool isPm = false; + var isPm = false; string lastPart = parts [^1].Trim (); - + if (_hasAmPm) { if (lastPart.EndsWith (_format.PMDesignator, StringComparison.OrdinalIgnoreCase)) @@ -419,7 +466,7 @@ private bool TryManualParse (string text, out TimeSpan result) isPm = false; lastPart = lastPart.Substring (0, lastPart.Length - _format.AMDesignator.Length).Trim (); } - + parts [^1] = lastPart; } @@ -436,8 +483,8 @@ private bool TryManualParse (string text, out TimeSpan result) } // Parse seconds (if present) - int seconds = 0; - + var seconds = 0; + if (parts.Length > 2 && !string.IsNullOrWhiteSpace (parts [2])) { if (!int.TryParse (parts [2], out seconds)) @@ -451,7 +498,7 @@ private bool TryManualParse (string text, out TimeSpan result) { // 12-hour format: 1-12 hours = Math.Max (1, Math.Min (12, hours)); - + if (isPm && hours != 12) { hours += 12; @@ -471,7 +518,9 @@ private bool TryManualParse (string text, out TimeSpan result) seconds = Math.Max (0, Math.Min (59, seconds)); result = new TimeSpan (hours, minutes, seconds); - + _isPm = hours >= 12; + + return true; } catch (ArgumentException) diff --git a/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index 9ef3f5fa90..b7231c1f24 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -537,6 +537,7 @@ True True True + True True True True From cf40e87edf3d07df466a8f81558dda345145b341 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 04:01:57 +0000 Subject: [PATCH 11/26] Fix _isPm synchronization in TimeValue setter after f00b6db refactoring Co-authored-by: tig <585482+tig@users.noreply.github.com> --- Terminal.Gui/Views/TextInput/TimeTextProvider.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs index 8b8b6901ec..e7f8544887 100644 --- a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs +++ b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs @@ -80,7 +80,11 @@ public DateTimeFormatInfo Format public TimeSpan TimeValue { get => _timeValue; - set => _timeValue = value; + set + { + _timeValue = value; + _isPm = value.Hours >= 12; + } } /// From e69b34fbf1a9d2d554203c78651641d1b150dd88 Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 7 Mar 2026 08:02:31 -0700 Subject: [PATCH 12/26] Fix TextValidateField test failures and TimeEditor EnableForDesign crash Revert breaking changes to TextValidateField that were introduced for TimeEditor but broke existing TVF tests: Width+1, EndKeyHandler+1, CursorRight past end, mouse click past CursorEnd, OnKeyDownNotHandled rewrite, BackspaceKeyHandler, and GetMargins End alignment. Add explicit IDesignable implementation to TimeEditor to prevent InvalidCastException when AllViewsTests calls EnableForDesign. Co-Authored-By: Claude Opus 4.6 --- .../Views/TextInput/TextValidateField.cs | 49 +++++-------------- Terminal.Gui/Views/TextInput/TimeEditor.cs | 10 +++- 2 files changed, 22 insertions(+), 37 deletions(-) diff --git a/Terminal.Gui/Views/TextInput/TextValidateField.cs b/Terminal.Gui/Views/TextInput/TextValidateField.cs index 7f74eda55a..931dbd9c36 100644 --- a/Terminal.Gui/Views/TextInput/TextValidateField.cs +++ b/Terminal.Gui/Views/TextInput/TextValidateField.cs @@ -101,8 +101,7 @@ public ITextValidateProvider? Provider if (_provider!.Fixed) { - // Add one so there is always a space at the end to show the cursor. - Width = (_provider.DisplayText == string.Empty ? DEFAULT_LENGTH : _provider.DisplayText.Length) + 1; + Width = _provider.DisplayText == string.Empty ? DEFAULT_LENGTH : _provider.DisplayText.Length; } // HomeKeyHandler already call SetNeedsDisplay @@ -153,19 +152,7 @@ protected override bool OnMouseEvent (Mouse mouse) return false; } - int cursorPos = mouse.Position!.Value.X - GetMargins (Viewport.Width).left; - - if (cursorPos > _provider!.CursorEnd ()) - { - InsertionPoint = cursorPos; - SetFocus (); - SetNeedsDraw (); - UpdateCursor (); - - return true; - } - - int c = _provider!.Cursor (cursorPos); + int c = _provider!.Cursor (mouse.Position!.Value.X - GetMargins (Viewport.Width).left); if (!_provider.Fixed && TextAlignment == Alignment.End && Text.Length > 0) { @@ -240,22 +227,23 @@ protected override bool OnKeyDownNotHandled (Key key) return false; } - Rune rune = key.AsRune; - - if (!_provider.VerifyChar ((char)rune.Value, InsertionPoint, out _)) + if (key.AsRune == default (Rune) || key == Application.QuitKey) { - // Not a valid char. If it's a letter or, return true to eat it to prevent hotkeys from triggering. - return Rune.IsLetterOrDigit (rune); + return false; } + Rune rune = key.AsRune; + bool inserted = _provider.InsertAt ((char)rune.Value, InsertionPoint); if (inserted) { CursorRight (); + + return true; } - return true; + return false; } /// Delete char at cursor position - 1, moving the cursor. @@ -264,14 +252,12 @@ private bool BackspaceKeyHandler () { if (!_provider!.Fixed && TextAlignment == Alignment.End && InsertionPoint <= 1) { - //return false; + return false; } _insertionPoint = _provider.CursorLeft (InsertionPoint); _provider.Delete (InsertionPoint); - SetNeedsDraw (); - UpdateCursor (); return true; } @@ -302,17 +288,8 @@ private bool CursorRight () } int current = InsertionPoint; - - InsertionPoint = _provider.CursorRight (current); - - if (current == InsertionPoint && current <= _provider.CursorEnd ()) - { - // Allow to move the cursor after the last char in this special case. - InsertionPoint++; - } - + InsertionPoint = _provider.CursorRight (InsertionPoint); SetNeedsDraw (); - UpdateCursor (); return current != InsertionPoint; } @@ -336,7 +313,7 @@ private bool DeleteKeyHandler () /// private bool EndKeyHandler () { - InsertionPoint = _provider!.CursorEnd () + 1; + InsertionPoint = _provider!.CursorEnd (); SetNeedsDraw (); return true; @@ -354,7 +331,7 @@ private bool EndKeyHandler () { Alignment.Start => (0, total), Alignment.Center => (total / 2, total / 2 + total % 2), - Alignment.End => (total - 1, 1), + Alignment.End => (total, 0), _ => (0, total) }; } diff --git a/Terminal.Gui/Views/TextInput/TimeEditor.cs b/Terminal.Gui/Views/TextInput/TimeEditor.cs index 84ee5371c4..1156dbba16 100644 --- a/Terminal.Gui/Views/TextInput/TimeEditor.cs +++ b/Terminal.Gui/Views/TextInput/TimeEditor.cs @@ -54,7 +54,7 @@ namespace Terminal.Gui.Views; /// /// /// -public class TimeEditor : TextValidateField, IValue +public class TimeEditor : TextValidateField, IValue, IDesignable { private TimeTextProvider TimeProvider => (TimeTextProvider)Provider!; private TimeSpan _lastKnownValue = TimeSpan.Zero; @@ -176,6 +176,14 @@ public TimeSpan Value /// The event arguments. protected virtual void OnValueChanged (ValueChangedEventArgs args) { } + /// + bool IDesignable.EnableForDesign () + { + Value = new TimeSpan (14, 30, 0); + + return true; + } + /// /// Raises value events when the text changes through user input. /// From fe89b5e696bbb84145e440361c96dfeb7cbb0339 Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 7 Mar 2026 09:15:50 -0700 Subject: [PATCH 13/26] Fix TimeEditor cursor/insertion by normalizing time format patterns Single-digit format specifiers (h, H, m, s) caused variable-width fields where cursor positions shifted based on the current time value. Typing "12" at position 0 of a single-digit hour would place "1" in hours and "2" in minutes because CursorRight skipped the separator at position 1. Normalize all format specifiers to 2-digit versions (hh, HH, mm, ss) so field positions are always consistent. Remove the leading space hack from DisplayText since padding is no longer needed. Add 9 unit tests covering cursor navigation, insertion at all digit positions, separator skipping, and DriverAssert rendering verification. Co-Authored-By: Claude Opus 4.6 --- .../Views/TextInput/TimeTextProvider.cs | 41 ++- .../Views/TimeEditorTests.cs | 250 +++++++++++++++++- 2 files changed, 283 insertions(+), 8 deletions(-) diff --git a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs index e7f8544887..e78fa59ebf 100644 --- a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs +++ b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs @@ -42,6 +42,7 @@ public class TimeTextProvider : ITextValidateProvider private DateTimeFormatInfo _format = CultureInfo.CurrentCulture.DateTimeFormat; private string _separator = CultureInfo.CurrentCulture.DateTimeFormat.TimeSeparator; + private string _normalizedPattern = string.Empty; private TimeSpan _timeValue = TimeSpan.Zero; private bool _is12Hour; private bool _hasAmPm; @@ -112,7 +113,7 @@ public string Text } /// - public string DisplayText => " " + FormatTimeValue (); + public string DisplayText => FormatTimeValue (); /// public bool IsValid => @@ -348,9 +349,12 @@ private void AnalyzePattern () _is12Hour = pattern.Contains ('h'); _hasAmPm = pattern.Contains ("tt"); + // Normalize to 2-digit specifiers for consistent fixed-width fields + _normalizedPattern = NormalizePattern (pattern); + // Build a sample time to determine field positions DateTime sampleTime = new (BASE_YEAR, BASE_MONTH, BASE_DAY, SAMPLE_HOUR, SAMPLE_MINUTE, SAMPLE_SECOND); - var formatted = sampleTime.ToString (pattern, _format); + var formatted = sampleTime.ToString (_normalizedPattern, _format); _fieldLength = formatted.Length; @@ -376,6 +380,35 @@ private void AnalyzePattern () } } + /// + /// Normalizes the time pattern to always use 2-digit specifiers (e.g. h → hh, m → mm) + /// so that field positions are consistent regardless of the current time value. + /// + private static string NormalizePattern (string pattern) + { + if (!pattern.Contains ("hh") && pattern.Contains ('h')) + { + pattern = pattern.Replace ("h", "hh"); + } + + if (!pattern.Contains ("HH") && pattern.Contains ('H')) + { + pattern = pattern.Replace ("H", "HH"); + } + + if (!pattern.Contains ("mm") && pattern.Contains ('m')) + { + pattern = pattern.Replace ("m", "mm"); + } + + if (!pattern.Contains ("ss") && pattern.Contains ('s')) + { + pattern = pattern.Replace ("s", "ss"); + } + + return pattern; + } + /// /// Formats the current time value according to the pattern. /// @@ -409,7 +442,7 @@ private string FormatTimeValue () } - return dt.ToString (_format.LongTimePattern, _format); + return dt.ToString (_normalizedPattern, _format); } /// @@ -427,7 +460,7 @@ private bool TryParseTimeValue (string text, out TimeSpan result) text = text.Trim (); // Try to parse using the current pattern - if (DateTime.TryParseExact (text, _format.LongTimePattern, _format, DateTimeStyles.None, out DateTime dt)) + if (DateTime.TryParseExact (text, _normalizedPattern, _format, DateTimeStyles.None, out DateTime dt)) { result = dt.TimeOfDay; diff --git a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs index ba4c3a5620..32d784fb3d 100644 --- a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs @@ -1,10 +1,11 @@ using System.Globalization; +using Terminal.Gui.Tests; using UnitTests; namespace ViewsTests; // Claude - Sonnet 4.6 -public class TimeEditorTests : TestDriverBase +public class TimeEditorTests (ITestOutputHelper output) : TestDriverBase { [Fact] public void Constructor_Defaults () @@ -645,15 +646,256 @@ public void TimeTextProvider_CursorLeft_FromStart () public void TimeTextProvider_CursorRight_FromEnd () { TimeTextProvider provider = new (); - + // Use 24-hour format DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); provider.Format = format24h; - + int endPos = provider.CursorEnd (); - + // CursorRight from end should return end int pos = provider.CursorRight (endPos); Assert.Equal (endPos, pos); } + + // Claude - Opus 4.6 + [Fact] + public void NormalizedPattern_24h_Typing_12_Enters_TwoDigitHour () + { + // Verifies that typing "12" at position 0 in 24h format sets hours to 12 + TimeTextProvider provider = new (); + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + format24h.LongTimePattern = "HH:mm:ss"; + provider.Format = format24h; + provider.TimeValue = new TimeSpan (9, 0, 0); + + output.WriteLine ($"Initial DisplayText: \"{provider.DisplayText}\""); + Assert.Equal ("09:00:00", provider.DisplayText); + + // Type '1' at position 0 (tens digit of hours) + bool inserted = provider.InsertAt ('1', 0); + Assert.True (inserted); + output.WriteLine ($"After '1' at pos 0: \"{provider.DisplayText}\""); + Assert.Equal ("19:00:00", provider.DisplayText); + + // Type '2' at position 1 (ones digit of hours) + inserted = provider.InsertAt ('2', 1); + Assert.True (inserted); + output.WriteLine ($"After '2' at pos 1: \"{provider.DisplayText}\""); + Assert.Equal ("12:00:00", provider.DisplayText); + Assert.Equal (new TimeSpan (12, 0, 0), provider.TimeValue); + } + + // Claude - Opus 4.6 + [Fact] + public void NormalizedPattern_DisplayText_Has_No_LeadingSpace () + { + TimeTextProvider provider = new (); + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + format24h.LongTimePattern = "HH:mm:ss"; + provider.Format = format24h; + provider.TimeValue = new TimeSpan (9, 0, 0); + + string display = provider.DisplayText; + output.WriteLine ($"DisplayText: \"{display}\""); + + Assert.Equal ("09:00:00", display); + Assert.False (display.StartsWith (' ')); + } + + // Claude - Opus 4.6 + [Fact] + public void NormalizedPattern_SingleDigitHourFormat_PadsToTwoDigits () + { + // Verifies that "h:mm:ss tt" is normalized to "hh:mm:ss tt" + TimeTextProvider provider = new (); + DateTimeFormatInfo format12h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone (); + format12h.LongTimePattern = "h:mm:ss tt"; + provider.Format = format12h; + provider.TimeValue = new TimeSpan (9, 0, 0); + + string display = provider.DisplayText; + output.WriteLine ($"DisplayText for 9 AM with 'h:mm:ss tt': \"{display}\""); + + // Should be padded to 2 digits: "09:00:00 AM" + Assert.StartsWith ("09", display); + Assert.Equal (11, display.Length); + } + + // Claude - Opus 4.6 + [Fact] + public void NormalizedPattern_FieldPositions_AreConsistent () + { + // Verifies separator positions don't shift based on time value + TimeTextProvider provider = new (); + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + format24h.LongTimePattern = "H:mm:ss"; // Single-digit H + provider.Format = format24h; + + // Single-digit hour value + provider.TimeValue = new TimeSpan (9, 30, 45); + string display1 = provider.DisplayText; + output.WriteLine ($"9:30:45 → \"{display1}\""); + + // Double-digit hour value + provider.TimeValue = new TimeSpan (14, 30, 45); + string display2 = provider.DisplayText; + output.WriteLine ($"14:30:45 → \"{display2}\""); + + // Both should have the same length due to normalization + Assert.Equal (display1.Length, display2.Length); + } + + // Claude - Opus 4.6 + [Fact] + public void TimeEditor_Typing_12_At_Start_Renders_Correctly () + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (20, 1); + + try + { + Runnable runnable = new () { Width = 20, Height = 1 }; + app.Begin (runnable); + + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + format24h.LongTimePattern = "HH:mm:ss"; + + TimeEditor te = new () + { + Width = 10, + Height = 1, + Value = new TimeSpan (9, 0, 0), + Format = format24h + }; + runnable.Add (te); + app.LayoutAndDraw (); + + output.WriteLine ($"Initial Text: \"{te.Text}\""); + output.WriteLine ($"Initial DisplayText: \"{te.Provider!.DisplayText}\""); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @"09:00:00", + output, + app.Driver); + + // Simulate focus and typing "1" then "2" + te.SetFocus (); + te.NewKeyDownEvent (Key.Home); + te.NewKeyDownEvent (Key.D1); + app.LayoutAndDraw (); + + output.WriteLine ($"After '1': Text=\"{te.Text}\", DisplayText=\"{te.Provider.DisplayText}\""); + + te.NewKeyDownEvent (Key.D2); + app.LayoutAndDraw (); + + output.WriteLine ($"After '2': Text=\"{te.Text}\", DisplayText=\"{te.Provider.DisplayText}\""); + + Assert.Equal (new TimeSpan (12, 0, 0), te.Value); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @"12:00:00", + output, + app.Driver); + } + finally + { + app.Dispose (); + } + } + + // Claude - Opus 4.6 + [Fact] + public void TimeEditor_CursorRight_SkipsSeparator_24h () + { + // Verifies cursor movement: after typing at pos 1, cursor skips separator to pos 3 + TimeTextProvider provider = new (); + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + format24h.LongTimePattern = "HH:mm:ss"; + provider.Format = format24h; + + // Position 0 = H tens, 1 = H ones, 2 = ':', 3 = m tens + int nextPos = provider.CursorRight (1); + output.WriteLine ($"CursorRight(1) = {nextPos}"); + + // Should skip separator at position 2 and land on 3 + Assert.Equal (3, nextPos); + } + + // Claude - Opus 4.6 + [Fact] + public void TimeEditor_CursorLeft_SkipsSeparator_24h () + { + TimeTextProvider provider = new (); + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + format24h.LongTimePattern = "HH:mm:ss"; + provider.Format = format24h; + + // CursorLeft from position 3 (m tens) should skip separator at 2 to position 1 + int prevPos = provider.CursorLeft (3); + output.WriteLine ($"CursorLeft(3) = {prevPos}"); + Assert.Equal (1, prevPos); + } + + // Claude - Opus 4.6 + [Fact] + public void TimeEditor_FullNavigation_24h_AllPositions () + { + TimeTextProvider provider = new (); + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + format24h.LongTimePattern = "HH:mm:ss"; + provider.Format = format24h; + + // Expected editable positions for "HH:mm:ss": 0,1, 3,4, 6,7 + List visitedPositions = [provider.CursorStart ()]; + int pos = provider.CursorStart (); + + while (pos < provider.CursorEnd ()) + { + pos = provider.CursorRight (pos); + visitedPositions.Add (pos); + } + + output.WriteLine ($"Forward positions: [{string.Join (", ", visitedPositions)}]"); + + // Should visit: 0, 1, 3, 4, 6, 7 + Assert.Equal ([0, 1, 3, 4, 6, 7], visitedPositions); + } + + // Claude - Opus 4.6 + [Fact] + public void TimeEditor_InsertAt_AllDigitPositions_24h () + { + TimeTextProvider provider = new (); + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + format24h.LongTimePattern = "HH:mm:ss"; + provider.Format = format24h; + provider.TimeValue = TimeSpan.Zero; // "00:00:00" + + output.WriteLine ($"Initial: \"{provider.DisplayText}\""); + + // Type at each editable position + Assert.True (provider.InsertAt ('1', 0)); // "10:00:00" + output.WriteLine ($"After InsertAt('1', 0): \"{provider.DisplayText}\""); + + Assert.True (provider.InsertAt ('4', 1)); // "14:00:00" + output.WriteLine ($"After InsertAt('4', 1): \"{provider.DisplayText}\""); + + Assert.True (provider.InsertAt ('3', 3)); // "14:30:00" + output.WriteLine ($"After InsertAt('3', 3): \"{provider.DisplayText}\""); + + Assert.True (provider.InsertAt ('5', 4)); // "14:35:00" + output.WriteLine ($"After InsertAt('5', 4): \"{provider.DisplayText}\""); + + Assert.True (provider.InsertAt ('4', 6)); // "14:35:40" + output.WriteLine ($"After InsertAt('4', 6): \"{provider.DisplayText}\""); + + Assert.True (provider.InsertAt ('2', 7)); // "14:35:42" + output.WriteLine ($"After InsertAt('2', 7): \"{provider.DisplayText}\""); + + Assert.Equal ("14:35:42", provider.DisplayText); + Assert.Equal (new TimeSpan (14, 35, 42), provider.TimeValue); + } } From 100977d74c6367c2cf3c89cfe6de3796e8c09083 Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 7 Mar 2026 15:22:27 -0700 Subject: [PATCH 14/26] Improve TimeEditor width and TextValidateField cursor logic - TimeEditor now sets width to fit full DisplayText (incl. AM/PM). - TextValidateField allows cursor one past last editable char. - Fixed width calculation for fixed fields (DisplayText.Length + 1). - Updated tests for new width and cursor behavior. - Added labels in TimeAndDate demo to show time patterns. - Added tests to verify full AM/PM visibility and width logic. - Improves usability and prevents clipping of time strings. --- Examples/UICatalog/Scenarios/TimeAndDate.cs | 31 +++++++- .../Views/TextInput/TextValidateField.cs | 12 +++- Terminal.Gui/Views/TextInput/TimeEditor.cs | 2 +- .../Views/TextValidateFieldTests.cs | 10 ++- .../Views/TimeEditorTests.cs | 70 +++++++++++++++++++ 5 files changed, 119 insertions(+), 6 deletions(-) diff --git a/Examples/UICatalog/Scenarios/TimeAndDate.cs b/Examples/UICatalog/Scenarios/TimeAndDate.cs index ff60f6dec7..62cfc4729c 100644 --- a/Examples/UICatalog/Scenarios/TimeAndDate.cs +++ b/Examples/UICatalog/Scenarios/TimeAndDate.cs @@ -76,20 +76,39 @@ public override void Main () defaultTimeEditor.ValueChanged += TimeEditorChanged; win.Add (defaultTimeEditor); + Label defaultPatternLabel = new () + { + X = Pos.Right (defaultTimeEditor) + 1, + Y = Pos.Top (defaultTimeEditor), + Text = $"Pattern: {CultureInfo.CurrentCulture.DateTimeFormat.LongTimePattern}" + }; + win.Add (defaultPatternLabel); + // 24-hour format time editor + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + TimeEditor time24Editor = new () { X = Pos.Center (), Y = Pos.Bottom (defaultTimeEditor) + 1, Value = DateTime.Now.TimeOfDay, - Format = (DateTimeFormatInfo)System.Globalization.CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone () + Format = format24h }; time24Editor.ValueChanged += TimeEditorChanged; win.Add (time24Editor); + Label time24PatternLabel = new () + { + X = Pos.Right (time24Editor) + 1, + Y = Pos.Top (time24Editor), + Text = $"Pattern: {format24h.LongTimePattern}" + }; + win.Add (time24PatternLabel); + // Short time format time editor - DateTimeFormatInfo shortFormat = (DateTimeFormatInfo)System.Globalization.CultureInfo.CurrentCulture.DateTimeFormat.Clone (); + DateTimeFormatInfo shortFormat = (DateTimeFormatInfo)CultureInfo.CurrentCulture.DateTimeFormat.Clone (); shortFormat.LongTimePattern = shortFormat.ShortTimePattern; + TimeEditor shortTimeEditor = new () { X = Pos.Center (), @@ -100,6 +119,14 @@ public override void Main () shortTimeEditor.ValueChanged += TimeEditorChanged; win.Add (shortTimeEditor); + Label shortPatternLabel = new () + { + X = Pos.Right (shortTimeEditor) + 1, + Y = Pos.Top (shortTimeEditor), + Text = $"Pattern: {shortFormat.LongTimePattern}" + }; + win.Add (shortPatternLabel); + DateField shortDate = new (DateTime.Now) { X = Pos.Center (), Y = Pos.Bottom (shortTimeEditor) + 1, ReadOnly = true diff --git a/Terminal.Gui/Views/TextInput/TextValidateField.cs b/Terminal.Gui/Views/TextInput/TextValidateField.cs index 931dbd9c36..5a5297ab24 100644 --- a/Terminal.Gui/Views/TextInput/TextValidateField.cs +++ b/Terminal.Gui/Views/TextInput/TextValidateField.cs @@ -101,7 +101,8 @@ public ITextValidateProvider? Provider if (_provider!.Fixed) { - Width = _provider.DisplayText == string.Empty ? DEFAULT_LENGTH : _provider.DisplayText.Length; + // Add one so there is always a blank cell after the last editable character for the cursor. + Width = (_provider.DisplayText == string.Empty ? DEFAULT_LENGTH : _provider.DisplayText.Length) + 1; } // HomeKeyHandler already call SetNeedsDisplay @@ -257,7 +258,9 @@ private bool BackspaceKeyHandler () _insertionPoint = _provider.CursorLeft (InsertionPoint); _provider.Delete (InsertionPoint); + SetNeedsDraw (); + UpdateCursor (); return true; } @@ -289,6 +292,13 @@ private bool CursorRight () int current = InsertionPoint; InsertionPoint = _provider.CursorRight (InsertionPoint); + + if (current == InsertionPoint && _provider.Fixed && current == _provider.CursorEnd ()) + { + // Allow cursor to move one past the last editable position (blank cell for cursor). + InsertionPoint = current + 1; + } + SetNeedsDraw (); return current != InsertionPoint; diff --git a/Terminal.Gui/Views/TextInput/TimeEditor.cs b/Terminal.Gui/Views/TextInput/TimeEditor.cs index 1156dbba16..a549a67c43 100644 --- a/Terminal.Gui/Views/TextInput/TimeEditor.cs +++ b/Terminal.Gui/Views/TextInput/TimeEditor.cs @@ -65,7 +65,7 @@ public class TimeEditor : TextValidateField, IValue, IDesignable public TimeEditor () { Provider = new TimeTextProvider (); - Width = Dim.Auto (minimumContentDim: 10); + Width = Dim.Auto (minimumContentDim: Provider!.DisplayText.Length); // Subscribe to provider's text changed to raise our value events diff --git a/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs b/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs index ff1dccd342..a35d0746fc 100644 --- a/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs @@ -71,7 +71,8 @@ public void Default_Width_Is_Always_Equal_To_The_Provider_DisplayText_Length () // A-Alphanumeric, required. a-Alphanumeric, optional. var field = new TextValidateField { Provider = new NetMaskedTextProvider ("999 000 LLL >LLL |AAA aaa") }; field.Layout (); - Assert.Equal (field.Viewport.Width, field.Provider.DisplayText.Length); + // Width is DisplayText.Length + 1 to provide a blank cell for the cursor past the last editable char. + Assert.Equal (field.Viewport.Width, field.Provider.DisplayText.Length + 1); Assert.NotEqual (field.Provider.DisplayText.Length, field.Provider.Text.Length); Assert.Equal (new string (' ', field.Text.Length), field.Provider.Text); } @@ -349,7 +350,7 @@ public void OnTextChanged_TextChanged_Event () } [Fact] - public void Right_Key_Stops_In_Last_Editable_Character () + public void Right_Key_Goes_One_Past_Last_Editable_Character () { var field = new TextValidateField { @@ -366,8 +367,13 @@ public void Right_Key_Stops_In_Last_Editable_Character () field.NewKeyDownEvent (Key.CursorRight); } + // Cursor is now one past CursorEnd (blank cell). Typing here does not insert. field.NewKeyDownEvent (Key.D1); + Assert.Equal ("--(____)--", field.Provider.DisplayText); + // Use End key to go to last editable position, where typing works. + field.NewKeyDownEvent (Key.End); + field.NewKeyDownEvent (Key.D1); Assert.Equal ("--(___1)--", field.Provider.DisplayText); Assert.Equal ("--( 1)--", field.Text); Assert.False (field.IsValid); diff --git a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs index 32d784fb3d..975df8b2a2 100644 --- a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs @@ -658,6 +658,76 @@ public void TimeTextProvider_CursorRight_FromEnd () Assert.Equal (endPos, pos); } + // Claude - Opus 4.6 + [Fact] + public void TimeEditor_12h_DisplayText_Shows_Full_AM_PM () + { + // Verifies that AM/PM is fully visible (not clipped to just "A") + // Uses default constructor without overriding Width to test the real scenario + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (30, 1); + + try + { + Runnable runnable = new () { Width = 30, Height = 1 }; + app.Begin (runnable); + + TimeEditor te = new () + { + Height = 1, + Value = new TimeSpan (9, 0, 0) + }; + + // Explicitly set 12-hour format AFTER construction to simulate the scenario + DateTimeFormatInfo format12h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone (); + format12h.LongTimePattern = "h:mm:ss tt"; + te.Format = format12h; + + runnable.Add (te); + app.LayoutAndDraw (); + + output.WriteLine ($"DisplayText: \"{te.Provider!.DisplayText}\""); + output.WriteLine ($"Frame: {te.Frame}"); + output.WriteLine ($"Viewport: {te.Viewport}"); + + // DisplayText should be "09:00:00 AM" (normalized to 2-digit hours) + Assert.Equal ("09:00:00 AM", te.Provider.DisplayText); + + // The view must be wide enough to show the full display text including "AM" + Assert.True (te.Frame.Width >= te.Provider.DisplayText.Length, + $"Frame width {te.Frame.Width} is too narrow for DisplayText \"{te.Provider.DisplayText}\" ({te.Provider.DisplayText.Length} chars)"); + + DriverAssert.AssertDriverContentsWithFrameAre ( + @"09:00:00 AM", + output, + app.Driver); + } + finally + { + app.Dispose (); + } + } + + // Claude - Opus 4.6 + [Fact] + public void TimeEditor_Default_Constructor_Width_Fits_DisplayText () + { + // Verifies that the default constructor produces a Width that fits the full DisplayText + TimeEditor te = new () + { + Value = new TimeSpan (9, 0, 0) + }; + te.Layout (); + + output.WriteLine ($"DisplayText: \"{te.Provider!.DisplayText}\""); + output.WriteLine ($"DisplayText.Length: {te.Provider.DisplayText.Length}"); + output.WriteLine ($"Frame.Width: {te.Frame.Width}"); + + Assert.True (te.Frame.Width >= te.Provider.DisplayText.Length, + $"Frame width {te.Frame.Width} is too narrow for DisplayText \"{te.Provider.DisplayText}\" ({te.Provider.DisplayText.Length} chars)"); + } + // Claude - Opus 4.6 [Fact] public void NormalizedPattern_24h_Typing_12_Enters_TwoDigitHour () From 75ea98731afb8afb09b2291c4c4e2613b419996a Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 7 Mar 2026 15:44:37 -0700 Subject: [PATCH 15/26] Improve cursor handling at end of masked text fields Enhances navigation and editing in masked fields (TextValidateField, TimeEditor) to prevent the cursor from moving backward when pressing right arrow from the blank cell past the last editable character. Backspace from the blank cell now deletes the last editable character. TimeEditor minimum width is increased by one to always allow a blank cell. Updates logic for right-arrow navigation in fixed providers. Adds unit tests to verify correct cursor and editing behavior at the end of input. These changes address subtle usability issues and make cursor movement more intuitive. --- .../Views/TextInput/TextValidateField.cs | 7 ++ Terminal.Gui/Views/TextInput/TimeEditor.cs | 6 +- .../Views/TextValidateFieldTests.cs | 77 +++++++++++++++++++ .../Views/TimeEditorTests.cs | 57 ++++++++++++++ 4 files changed, 145 insertions(+), 2 deletions(-) diff --git a/Terminal.Gui/Views/TextInput/TextValidateField.cs b/Terminal.Gui/Views/TextInput/TextValidateField.cs index 5a5297ab24..4ebcfdb072 100644 --- a/Terminal.Gui/Views/TextInput/TextValidateField.cs +++ b/Terminal.Gui/Views/TextInput/TextValidateField.cs @@ -291,6 +291,13 @@ private bool CursorRight () } int current = InsertionPoint; + + if (_provider.Fixed && current > _provider.CursorEnd ()) + { + // Already in the blank cell past the last editable position. Don't move. + return false; + } + InsertionPoint = _provider.CursorRight (InsertionPoint); if (current == InsertionPoint && _provider.Fixed && current == _provider.CursorEnd ()) diff --git a/Terminal.Gui/Views/TextInput/TimeEditor.cs b/Terminal.Gui/Views/TextInput/TimeEditor.cs index a549a67c43..a190df19b2 100644 --- a/Terminal.Gui/Views/TextInput/TimeEditor.cs +++ b/Terminal.Gui/Views/TextInput/TimeEditor.cs @@ -65,7 +65,8 @@ public class TimeEditor : TextValidateField, IValue, IDesignable public TimeEditor () { Provider = new TimeTextProvider (); - Width = Dim.Auto (minimumContentDim: Provider!.DisplayText.Length); + // Add one so there is always a blank cell after the last editable character for the cursor. + Width = Dim.Auto (minimumContentDim: Provider!.DisplayText.Length + 1); // Subscribe to provider's text changed to raise our value events @@ -93,7 +94,8 @@ public DateTimeFormatInfo Format set { TimeProvider.Format = value; - Width = TimeProvider.DisplayText.Length + 2; + // Add one so there is always a blank cell after the last editable character for the cursor. + Width = TimeProvider.DisplayText.Length + 1; SetNeedsDraw (); } } diff --git a/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs b/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs index a35d0746fc..488c0340ac 100644 --- a/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs @@ -379,6 +379,58 @@ public void Right_Key_Goes_One_Past_Last_Editable_Character () Assert.False (field.IsValid); } + // Claude - Opus 4.6 + [Fact] + public void Right_Key_From_BlankCell_DoesNotMoveBackward () + { + var field = new TextValidateField + { + TextAlignment = Alignment.Center, + Width = 20, + + // 0123456789 + Provider = new NetMaskedTextProvider ("--(0000)--") { Text = "1234" } + }; + + // Navigate to end, then one past into blank cell + field.NewKeyDownEvent (Key.End); + field.NewKeyDownEvent (Key.CursorRight); + + // Now in blank cell. Pressing right again should NOT move backward. + // It should stay put (return false, allowing focus to move to next view). + field.NewKeyDownEvent (Key.CursorRight); + + // Verify cursor didn't move backward by pressing left once and typing. + // If cursor was in the blank cell, left goes to CursorEnd (position 6). + // If cursor wrongly moved back, left would go somewhere else. + field.NewKeyDownEvent (Key.CursorLeft); + field.NewKeyDownEvent (Key.D9); + Assert.Equal ("--(1239)--", field.Provider.DisplayText); + } + + // Claude - Opus 4.6 + [Fact] + public void Backspace_From_BlankCell_Deletes_Last_Editable_Character () + { + var field = new TextValidateField + { + TextAlignment = Alignment.Center, + Width = 20, + + // 0123456789 + Provider = new NetMaskedTextProvider ("--(0000)--") { Text = "1234" } + }; + + // Navigate to end, then one past into blank cell + field.NewKeyDownEvent (Key.End); + field.NewKeyDownEvent (Key.CursorRight); + + // Backspace from blank cell should delete the last editable character + field.NewKeyDownEvent (Key.Backspace); + Assert.Equal ("--(123_)--", field.Provider.DisplayText); + Assert.False (field.IsValid); + } + [Fact] public void Set_Text_After_Initialization () { @@ -646,6 +698,31 @@ public void Text_With_All_Charset () Assert.False (field.IsValid); } + // Claude - Opus 4.6 + [Fact] + public void Right_Key_At_End_DoesNotMoveBackward () + { + // Regex provider is not Fixed, so no blank cell. Verify cursor stays at end. + var field = new TextValidateField + { + TextAlignment = Alignment.Center, Width = 20, Provider = new TextRegexProvider ("^[0-9][0-9][0-9]$") { ValidateOnInput = false } + }; + + field.Text = "123"; + + // Navigate to end + field.NewKeyDownEvent (Key.End); + + // Pressing right multiple times should not cause issues + field.NewKeyDownEvent (Key.CursorRight); + field.NewKeyDownEvent (Key.CursorRight); + field.NewKeyDownEvent (Key.CursorRight); + + // Text should be unchanged + Assert.Equal ("123", field.Text); + Assert.True (field.IsValid); + } + // Claude - Opus 4.5 [Fact] public void Text_Polymorphism_Works () diff --git a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs index 975df8b2a2..4b9f59a256 100644 --- a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs @@ -728,6 +728,63 @@ public void TimeEditor_Default_Constructor_Width_Fits_DisplayText () $"Frame width {te.Frame.Width} is too narrow for DisplayText \"{te.Provider.DisplayText}\" ({te.Provider.DisplayText.Length} chars)"); } + // Claude - Opus 4.6 + [Fact] + public void TimeEditor_CursorRight_From_BlankCell_DoesNotMoveBackward () + { + // Verifies that pressing right arrow from the blank cell past the last editable + // position does NOT move the cursor backward (e.g., from "M" in "PM" back to "A"). + TimeTextProvider provider = new (); + DateTimeFormatInfo format12h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone (); + format12h.LongTimePattern = "h:mm:ss tt"; + provider.Format = format12h; + + int cursorEnd = provider.CursorEnd (); + output.WriteLine ($"CursorEnd: {cursorEnd}"); + + // CursorRight from CursorEnd should return CursorEnd (can't go further via provider) + int fromEnd = provider.CursorRight (cursorEnd); + output.WriteLine ($"CursorRight({cursorEnd}): {fromEnd}"); + Assert.Equal (cursorEnd, fromEnd); + + // Now test TextValidateField behavior: cursor at CursorEnd+1 should not move backward + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + try + { + TimeEditor te = new () + { + Value = new TimeSpan (9, 0, 0), + Format = format12h + }; + te.Layout (); + te.SetFocus (); + + // Navigate to the end, then one past + te.NewKeyDownEvent (Key.End); + te.NewKeyDownEvent (Key.CursorRight); + + output.WriteLine ($"After End+Right, DisplayText: \"{te.Provider!.DisplayText}\""); + + // Press right again — should NOT move backward + bool handled = te.NewKeyDownEvent (Key.CursorRight); + output.WriteLine ($"Second Right handled: {handled}"); + + // Pressing left from blank cell should go back to CursorEnd + te.NewKeyDownEvent (Key.CursorLeft); + + // Verify we're at a valid position by typing — should insert at CursorEnd + te.NewKeyDownEvent (Key.End); + te.NewKeyDownEvent (Key.D5); + output.WriteLine ($"After End+5: \"{te.Provider.DisplayText}\""); + } + finally + { + app.Dispose (); + } + } + // Claude - Opus 4.6 [Fact] public void NormalizedPattern_24h_Typing_12_Enters_TwoDigitHour () From ad7a30fbf4f2b3ddd70785ec9ceafc448ac7e5f6 Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 7 Mar 2026 15:57:13 -0700 Subject: [PATCH 16/26] Replace TimeField with new TimeEditor control Removed the TimeField control, its tests, and documentation. Introduced TimeEditor as the new time input control, based on TextValidateField with culture-aware formatting. Updated all references, scenarios, and docs to use TimeEditor instead of TimeField. TimeField source and tests have been deleted. --- .tg-docs/INDEX.md | 2 +- AGENTS.md | 2 +- .../UICatalog/Scenarios/TextInputControls.cs | 34 +- Examples/UICatalog/Scenarios/TimeAndDate.cs | 96 +-- Terminal.Gui/Views/TextInput/TimeField.cs | 683 ------------------ .../Views/TimeFieldTests.cs | 206 ------ docfx/docs/events.md | 2 +- docfx/docs/views.md | 4 +- 8 files changed, 30 insertions(+), 999 deletions(-) delete mode 100644 Terminal.Gui/Views/TextInput/TimeField.cs delete mode 100644 Tests/UnitTestsParallelizable/Views/TimeFieldTests.cs diff --git a/.tg-docs/INDEX.md b/.tg-docs/INDEX.md index 9d5229005d..b368600a92 100644 --- a/.tg-docs/INDEX.md +++ b/.tg-docs/INDEX.md @@ -82,7 +82,7 @@ Instead of embedding descriptions, it points to actual source files that agents |Views/SpinnerView:{SpinnerStyle.cs,SpinnerView.cs} |Views/TableView:{CellActivatedEventArgs.cs,CellColorGetterArgs.cs,CellToggledEventArgs.cs,CheckBoxTableSourceWrapper.cs,CheckBoxTableSourceWrapperByIndex.cs,CheckBoxTableSourceWrapperByObject.cs,ColumnStyle.cs,DataTableSource.cs,EnumerableTableSource.cs,IEnumerableTableSource.cs,ITableSource.cs,ListColumnStyle.cs,ListTableSource.cs,RowColorGetterArgs.cs,SelectedCellChangedEventArgs.cs,TableSelection.cs,TableStyle.cs,TableView.CellMapping.cs,TableView.cs,TableView.Drawing.cs,TableView.Mouse.cs,TableView.Navigation.cs,TableView.Selection.cs,TreeTableSource.cs} |Views/TabView:{Tab.cs,TabChangedEventArgs.cs,TabMouseEventArgs.cs,TabRow.cs,TabStyle.cs,TabView.cs} -|Views/TextInput:{ContentsChangedEventArgs.cs,DateField.cs,HistoryText.cs,HistoryTextItemEventArgs.cs,ITextValidateProvider.cs,NetMaskedTextProvider.cs,TextEditingLineStatus.cs,TextModel.cs,TextRegexProvider.cs,TextValidateField.cs,TimeField.cs} +|Views/TextInput:{ContentsChangedEventArgs.cs,DateField.cs,HistoryText.cs,HistoryTextItemEventArgs.cs,ITextValidateProvider.cs,NetMaskedTextProvider.cs,TextEditingLineStatus.cs,TextModel.cs,TextRegexProvider.cs,TextValidateField.cs,TimeEditor.cs,TimeTextProvider.cs} |Views/TextInput/TextField:{TextField.Commands.cs,TextField.cs,TextField.Drawing.cs,TextField.History.cs,TextField.Keyboard.cs,TextField.Mouse.cs,TextField.Selection.cs,TextField.Text.cs,TextFieldAutocomplete.cs} |Views/TextInput/TextView:{TextView.Commands.cs,TextView.cs,TextView.Drawing.cs,TextView.Files.cs,TextView.Find.cs,TextView.History.cs,TextView.Keyboard.cs,TextView.Mouse.cs,TextView.Movement.cs,TextView.Scrolling.cs,TextView.Selection.cs,TextView.Text.cs,TextView.WordWrap.cs,TextViewAutocomplete.cs,WordWrapManager.cs} |Views/TreeView:{AspectGetterDelegate.cs,Branch.cs,DelegateTreeBuilder.cs,DrawTreeViewLineEventArgs.cs,ITreeBuilder.cs,ITreeViewFilter.cs,ObjectActivatedEventArgs.cs,SelectionChangedEventArgs.cs,TreeBuilder.cs,TreeNode.cs,TreeNodeBuilder.cs,TreeStyle.cs,TreeView.cs,TreeViewCollectionNavigatorMatcher.cs,TreeViewTextFilter.cs} diff --git a/AGENTS.md b/AGENTS.md index 37238ac059..ff84951f3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -249,7 +249,7 @@ See `.claude/cookbook/` for common UI patterns: |Views/SpinnerView:{SpinnerStyle.cs,SpinnerView.cs} |Views/TableView:{CellActivatedEventArgs.cs,CellColorGetterArgs.cs,CellToggledEventArgs.cs,CheckBoxTableSourceWrapper.cs,CheckBoxTableSourceWrapperByIndex.cs,CheckBoxTableSourceWrapperByObject.cs,ColumnStyle.cs,DataTableSource.cs,EnumerableTableSource.cs,IEnumerableTableSource.cs,ITableSource.cs,ListColumnStyle.cs,ListTableSource.cs,RowColorGetterArgs.cs,SelectedCellChangedEventArgs.cs,TableSelection.cs,TableStyle.cs,TableView.CellMapping.cs,TableView.cs,TableView.Drawing.cs,TableView.Mouse.cs,TableView.Navigation.cs,TableView.Selection.cs,TreeTableSource.cs} |Views/TabView:{Tab.cs,TabChangedEventArgs.cs,TabMouseEventArgs.cs,TabRow.cs,TabStyle.cs,TabView.cs} -|Views/TextInput:{ContentsChangedEventArgs.cs,DateField.cs,HistoryText.cs,HistoryTextItemEventArgs.cs,ITextValidateProvider.cs,NetMaskedTextProvider.cs,TextEditingLineStatus.cs,TextModel.cs,TextRegexProvider.cs,TextValidateField.cs,TimeField.cs} +|Views/TextInput:{ContentsChangedEventArgs.cs,DateField.cs,HistoryText.cs,HistoryTextItemEventArgs.cs,ITextValidateProvider.cs,NetMaskedTextProvider.cs,TextEditingLineStatus.cs,TextModel.cs,TextRegexProvider.cs,TextValidateField.cs,TimeEditor.cs,TimeTextProvider.cs} |Views/TextInput/TextField:{TextField.Commands.cs,TextField.cs,TextField.Drawing.cs,TextField.History.cs,TextField.Keyboard.cs,TextField.Mouse.cs,TextField.Selection.cs,TextField.Text.cs,TextFieldAutocomplete.cs} |Views/TextInput/TextView:{TextView.Commands.cs,TextView.cs,TextView.Drawing.cs,TextView.Files.cs,TextView.Find.cs,TextView.History.cs,TextView.Keyboard.cs,TextView.Mouse.cs,TextView.Movement.cs,TextView.Scrolling.cs,TextView.Selection.cs,TextView.Text.cs,TextView.WordWrap.cs,TextViewAutocomplete.cs,WordWrapManager.cs} |Views/TreeView:{AspectGetterDelegate.cs,Branch.cs,DelegateTreeBuilder.cs,DrawTreeViewLineEventArgs.cs,ITreeBuilder.cs,ITreeViewFilter.cs,ObjectActivatedEventArgs.cs,SelectionChangedEventArgs.cs,TreeBuilder.cs,TreeNode.cs,TreeNodeBuilder.cs,TreeStyle.cs,TreeView.cs,TreeViewCollectionNavigatorMatcher.cs,TreeViewTextFilter.cs} diff --git a/Examples/UICatalog/Scenarios/TextInputControls.cs b/Examples/UICatalog/Scenarios/TextInputControls.cs index 546b455650..f2b03e06d4 100644 --- a/Examples/UICatalog/Scenarios/TextInputControls.cs +++ b/Examples/UICatalog/Scenarios/TextInputControls.cs @@ -13,8 +13,8 @@ namespace UICatalog.Scenarios; [ScenarioCategory ("DateTime")] public class TextInputControls : Scenario { - private Label? _labelMirroringTimeField; - private TimeField? _timeField; + private Label? _labelMirroringTimeEditor; + private TimeEditor? _timeEditor; public override void Main () { @@ -243,31 +243,29 @@ void TextViewDrawContent (object? sender, DrawEventArgs e) => dateField.TextChanged += (_, _) => { labelMirroringDateField.Text = dateField.Text; }; - // TimeField - label = new Label { Text = "T_imeField:", Y = Pos.Top (dateField), X = Pos.Right (labelMirroringDateField) + 5 }; + // TimeEditor + label = new Label { Text = "T_imeEditor:", Y = Pos.Top (dateField), X = Pos.Right (labelMirroringDateField) + 5 }; win.Add (label); - _timeField = new TimeField + _timeEditor = new TimeEditor () { X = Pos.Right (label) + 1, Y = Pos.Top (dateField), - Width = 20, - IsShortFormat = false, Value = DateTime.Now.TimeOfDay }; - win.Add (_timeField); + win.Add (_timeEditor); - _labelMirroringTimeField = new Label + _labelMirroringTimeEditor = new Label () { - X = Pos.Right (_timeField) + 1, - Y = Pos.Top (_timeField), - Width = Dim.Width (_timeField), - Height = Dim.Height (_timeField), - Text = _timeField.Text + X = Pos.Right (_timeEditor) + 1, + Y = Pos.Top (_timeEditor), + Width = Dim.Width (_timeEditor), + Height = Dim.Height (_timeEditor), + Text = _timeEditor.Text }; - win.Add (_labelMirroringTimeField); + win.Add (_labelMirroringTimeEditor); - _timeField.ValueChanged += TimeChanged; + _timeEditor.ValueChanged += TimeChanged; // MaskedTextProvider - uses .NET MaskedTextProvider NetMaskedTextProvider netProvider = new ("+99 (000) 000-0000"); @@ -471,9 +469,9 @@ void WinOnAccept (object? sender, CommandEventArgs e) private void TimeChanged (object? sender, ValueChangedEventArgs e) { - if (_labelMirroringTimeField is { } && _timeField is { }) + if (_labelMirroringTimeEditor is { } && _timeEditor is { }) { - _labelMirroringTimeField.Text = _timeField.Text; + _labelMirroringTimeEditor.Text = _timeEditor.Text; } } } diff --git a/Examples/UICatalog/Scenarios/TimeAndDate.cs b/Examples/UICatalog/Scenarios/TimeAndDate.cs index 62cfc4729c..3ed06308b2 100644 --- a/Examples/UICatalog/Scenarios/TimeAndDate.cs +++ b/Examples/UICatalog/Scenarios/TimeAndDate.cs @@ -4,17 +4,14 @@ namespace UICatalog.Scenarios; -[ScenarioMetadata ("Time And Date", "Illustrates TimeField and time & date handling")] +[ScenarioMetadata ("Time And Date", "Illustrates TimeEditor and time & date handling")] [ScenarioCategory ("Controls")] [ScenarioCategory ("DateTime")] public class TimeAndDate : Scenario { private Label? _lblDateFmt; private Label? _lblNewDate; - private Label? _lblNewTime; private Label? _lblOldDate; - private Label? _lblOldTime; - private Label? _lblTimeFmt; private Label? _lblTimeEditorValue; public override void Main () @@ -26,43 +23,12 @@ public override void Main () using Window win = new () { Title = GetQuitKeyAndName () }; - // TimeField examples (existing) - Label tfLabel = new () - { - X = Pos.Center (), - Y = 1, - Text = "TimeField (Legacy):" - }; - win.Add (tfLabel); - - TimeField longTime = new () - { - X = Pos.Center (), - Y = Pos.Bottom (tfLabel), - IsShortFormat = false, - ReadOnly = false, - Value = DateTime.Now.TimeOfDay - }; - longTime.ValueChanged += TimeChanged; - win.Add (longTime); - - TimeField shortTime = new () - { - X = Pos.Center (), - Y = Pos.Bottom (longTime) + 1, - IsShortFormat = true, - ReadOnly = false, - Value = DateTime.Now.TimeOfDay - }; - shortTime.ValueChanged += TimeChanged; - win.Add (shortTime); - - // TimeEditor examples (new) + // TimeEditor examples Label teLabel = new () { X = Pos.Center (), - Y = Pos.Bottom (shortTime) + 1, - Text = "TimeEditor (New - based on TextValidateField):" + Y = 1, + Text = "TimeEditor (based on TextValidateField):" }; win.Add (teLabel); @@ -141,51 +107,18 @@ public override void Main () longDate.ValueChanged += DateChanged; win.Add (longDate); - _lblOldTime = new() + _lblTimeEditorValue = new () { X = Pos.Center (), Y = Pos.Bottom (longDate) + 1, TextAlignment = Alignment.Center, - Width = Dim.Fill (), - Text = "Old Time: " - }; - win.Add (_lblOldTime); - - _lblNewTime = new() - { - X = Pos.Center (), - Y = Pos.Bottom (_lblOldTime) + 1, - TextAlignment = Alignment.Center, - - Width = Dim.Fill (), - Text = "New Time: " - }; - win.Add (_lblNewTime); - - _lblTimeFmt = new() - { - X = Pos.Center (), - Y = Pos.Bottom (_lblNewTime) + 1, - TextAlignment = Alignment.Center, - - Width = Dim.Fill (), - Text = "Time Format: " - }; - win.Add (_lblTimeFmt); - - _lblTimeEditorValue = new() - { - X = Pos.Center (), - Y = Pos.Bottom (_lblTimeFmt) + 1, - TextAlignment = Alignment.Center, - Width = Dim.Fill (), Text = "TimeEditor Value: " }; win.Add (_lblTimeEditorValue); - _lblOldDate = new() + _lblOldDate = new () { X = Pos.Center (), Y = Pos.Bottom (_lblTimeEditorValue) + 1, @@ -196,7 +129,7 @@ public override void Main () }; win.Add (_lblOldDate); - _lblNewDate = new() + _lblNewDate = new () { X = Pos.Center (), Y = Pos.Bottom (_lblOldDate) + 1, @@ -207,7 +140,7 @@ public override void Main () }; win.Add (_lblNewDate); - _lblDateFmt = new() + _lblDateFmt = new () { X = Pos.Center (), Y = Pos.Bottom (_lblNewDate) + 1, @@ -220,17 +153,11 @@ public override void Main () Button swapButton = new () { - X = Pos.Center (), Y = Pos.Bottom (win) - 5, Text = "Swap Long/Short & Read/Read Only" + X = Pos.Center (), Y = Pos.Bottom (win) - 5, Text = "Swap Date Read/Read Only" }; swapButton.Accepting += (_, _) => { - longTime.ReadOnly = !longTime.ReadOnly; - shortTime.ReadOnly = !shortTime.ReadOnly; - - longTime.IsShortFormat = !longTime.IsShortFormat; - shortTime.IsShortFormat = !shortTime.IsShortFormat; - longDate.ReadOnly = !longDate.ReadOnly; shortDate.ReadOnly = !shortDate.ReadOnly; }; @@ -244,11 +171,6 @@ private void DateChanged (object? sender, ValueChangedEventArgs e) _lblNewDate!.Text = $"New Date: {e.NewValue}"; } - private void TimeChanged (object? sender, ValueChangedEventArgs e) - { - _lblNewTime!.Text = $"New Time: {e.NewValue}"; - } - private void TimeEditorChanged (object? sender, ValueChangedEventArgs e) { _lblTimeEditorValue!.Text = $"TimeEditor Value: {e.NewValue}"; diff --git a/Terminal.Gui/Views/TextInput/TimeField.cs b/Terminal.Gui/Views/TextInput/TimeField.cs deleted file mode 100644 index 69d19f69b2..0000000000 --- a/Terminal.Gui/Views/TextInput/TimeField.cs +++ /dev/null @@ -1,683 +0,0 @@ -using System.Globalization; - -namespace Terminal.Gui.Views; - -/// -/// Provides time editing functionality with specialized cursor behavior for time entry. -/// -/// -/// -/// TimeField extends with time-specific cursor behavior: -/// -/// -/// Cursor positions are constrained to valid digit positions (skipping separators) -/// -/// -/// Position 0 is reserved for a leading space; valid cursor range is [1, FieldLength] -/// -/// -/// Numeric input replaces characters in-place rather than inserting -/// -/// -/// Delete operations replace digits with '0' rather than removing characters -/// -/// -/// Supports both short (HH:mm) and long (HH:mm:ss) formats -/// -/// -/// -/// -/// Cursor Position Model: -/// -/// -/// -/// : Inherited, but constrained by the override to [1, -/// FieldLength] -/// -/// -/// -/// : Adjusts cursor to skip over time separator characters -/// -/// -/// -/// /: Move cursor while -/// respecting separator positions -/// -/// -/// -/// -/// -/// Example: For long format "HH:mm:ss" with text " 14:30:45": -/// -/// -/// Position 0: Leading space (not user-accessible) -/// -/// -/// Positions 1-2: Hour digits (14) -/// -/// -/// Position 3: Separator ':' (cursor skips over) -/// -/// -/// Positions 4-5: Minute digits (30) -/// -/// -/// Position 6: Separator ':' (cursor skips over) -/// -/// -/// Positions 7-8: Second digits (45) -/// -/// -/// -/// -public class TimeField : TextField, IValue -{ - /// - /// The field length for long format (HH:mm:ss) = 8 characters. - /// - private const int LONG_FIELD_LEN = 8; - - /// - /// The format string for long time format with escaped separators (e.g., " hh\:mm\:ss"). - /// The leading space provides a visual buffer and keeps cursor position 0 inaccessible. - /// - private readonly string _longFormat; - - /// - /// The time separator character for the current culture (typically ':'). - /// The cursor automatically skips over these positions during navigation. - /// - private readonly string _sepChar; - - /// - /// The field length for short format (HH:mm) = 5 characters. - /// - private const int SHORT_FIELD_LEN = 5; - - /// - /// The format string for short time format with escaped separators (e.g., " hh\:mm"). - /// - private readonly string _shortFormat; - - /// - /// Indicates whether the short format (HH:mm) is being used instead of long format (HH:mm:ss). - /// - private bool _isShort; - - /// - /// The current time value being edited. - /// - private TimeSpan _time; - - /// Initializes a new instance of . - public TimeField () - { - CultureInfo cultureInfo = CultureInfo.CurrentCulture; - _sepChar = cultureInfo.DateTimeFormat.TimeSeparator; - _longFormat = $" hh\\{_sepChar}mm\\{_sepChar}ss"; - _shortFormat = $" hh\\{_sepChar}mm"; - Width = FieldLength + 2; - Value = TimeSpan.MinValue; - base.InsertionPoint = 1; - TextChanging += TextField_TextChanging; - - // Things this view knows how to do - AddCommand (Command.DeleteCharRight, - () => - { - DeleteCharRight (); - - return true; - }); - - AddCommand (Command.DeleteCharLeft, - () => - { - DeleteCharLeft (false); - - return true; - }); - AddCommand (Command.LeftStart, () => MoveHome ()); - AddCommand (Command.Left, () => MoveLeft ()); - AddCommand (Command.RightEnd, () => MoveEnd ()); - AddCommand (Command.Right, () => MoveRight ()); - - // Replace the key bindings defined in TextField - KeyBindings.ReplaceCommands (Key.Delete, Command.DeleteCharRight); - KeyBindings.ReplaceCommands (Key.D.WithCtrl, Command.DeleteCharRight); - - KeyBindings.ReplaceCommands (Key.Backspace, Command.DeleteCharLeft); - - KeyBindings.ReplaceCommands (Key.Home, Command.LeftStart); - KeyBindings.ReplaceCommands (Key.A.WithCtrl, Command.LeftStart); - - KeyBindings.ReplaceCommands (Key.CursorLeft, Command.Left); - KeyBindings.ReplaceCommands (Key.B.WithCtrl, Command.Left); - - KeyBindings.ReplaceCommands (Key.End, Command.RightEnd); - KeyBindings.ReplaceCommands (Key.E.WithCtrl, Command.RightEnd); - - KeyBindings.ReplaceCommands (Key.CursorRight, Command.Right); - KeyBindings.ReplaceCommands (Key.F.WithCtrl, Command.Right); - -#if UNIX_KEY_BINDINGS - KeyBindings.ReplaceCommands (Key.D.WithAlt, Command.DeleteCharLeft); -#endif - } - - /// - /// Gets or sets the cursor position within the time field, constrained to valid digit positions. - /// - /// - /// The cursor position, clamped to the range [1, FieldLength]. Unlike , - /// position 0 is not accessible because it contains a leading space. - /// - /// - /// - /// This override constrains the cursor to valid editing positions within the time format: - /// - /// - /// Minimum position is 1 (first digit of hours) - /// - /// - /// Maximum position is FieldLength (5 for short format, 8 for long format) - /// - /// - /// - /// - /// Note: This property only enforces bounds; it does not skip separator characters. - /// Use after setting to ensure the cursor is on a digit position. - /// - /// - /// - /// - public override int InsertionPoint { get => base.InsertionPoint; set => base.InsertionPoint = Math.Max (Math.Min (value, FieldLength), 1); } - - /// Get or sets whether uses the short or long time format. - public bool IsShortFormat - { - get => _isShort; - set - { - _isShort = value; - Width = FieldLength + 2; - - bool ro = ReadOnly; - - if (ro) - { - ReadOnly = false; - } - - SetText (Text); - ReadOnly = ro; - SetNeedsDraw (); - } - } - - /// - /// Gets the length of the time format string (excluding the leading space), which represents - /// the maximum valid cursor position. - /// - /// - /// - /// Returns 5 for short format (HH:mm) or 8 for long format (HH:mm:ss). - /// The valid cursor range is [1, FieldLength], where position 1 is the first digit - /// and FieldLength is the last digit. - /// - /// - private int FieldLength => _isShort ? SHORT_FIELD_LEN : LONG_FIELD_LEN; - - /// - /// Gets the current time format string based on . - /// - private string Format => _isShort ? _shortFormat : _longFormat; - - /// - public override bool DeleteCharLeft (bool useOldCursorPos) - { - if (ReadOnly) - { - return false; - } - - ClearAllSelection (); - SetText ((Rune)'0'); - DecrementInsertionPoint (); - - return true; - } - - /// - public override bool DeleteCharRight () - { - if (ReadOnly) - { - return false; - } - - ClearAllSelection (); - SetText ((Rune)'0'); - - return true; - } - - /// - protected override bool OnMouseEvent (Mouse mouse) - { - if (base.OnMouseEvent (mouse) || mouse.Handled) - { - return true; - } - - if (SelectedLength == 0 && mouse.Flags.HasFlag (MouseFlags.LeftButtonPressed)) - { - int point = mouse.Position!.Value.X; - AdjustInsertionPoint (point); - } - - return mouse.Handled; - } - - /// - protected override bool OnKeyDownNotHandled (Key a) - { - // Ignore non-numeric characters. - if (a.KeyCode is >= (KeyCode)(int)KeyCode.D0 and <= (KeyCode)(int)KeyCode.D9) - { - if (!ReadOnly) - { - if (SetText ((Rune)a)) - { - IncrementInsertionPoint (); - } - } - - return true; - } - - return false; - } - - #region IValue Implementation - - /// Gets or sets the time value of the . - public new TimeSpan Value - { - get => _time; - set - { - if (ReadOnly) - { - return; - } - - TimeSpan oldValue = _time; - - if (oldValue == value) - { - return; - } - - ValueChangingEventArgs changingArgs = new (oldValue, value); - - if (OnValueChanging (changingArgs) || changingArgs.Handled) - { - return; - } - - ValueChanging?.Invoke (this, changingArgs); - - if (changingArgs.Handled) - { - return; - } - - _time = value; - Text = " " + value.ToString (Format.Trim ()); - - ValueChangedEventArgs changedArgs = new (oldValue, _time); - OnValueChanged (changedArgs); - ValueChanged?.Invoke (this, changedArgs); - } - } - - /// - object? IValue.GetValue () => _time; - - /// - /// Called when the is changing. - /// - /// The event arguments containing old and new values. - /// to cancel the change; otherwise . - protected virtual bool OnValueChanging (ValueChangingEventArgs args) => false; - - /// - public new event EventHandler>? ValueChanging; - - /// - /// Called when the has changed. - /// - /// The event arguments containing old and new values. - protected virtual void OnValueChanged (ValueChangedEventArgs args) { } - - /// - public new event EventHandler>? ValueChanged; - - #endregion - - /// - /// Adjusts the cursor position to ensure it lands on a valid digit position, skipping separator characters. - /// - /// The desired cursor position. - /// - /// If true, skip separators by moving right; if false, skip by moving left. - /// This determines the direction of adjustment when the cursor lands on a separator. - /// - /// - /// - /// This method performs two adjustments: - /// - /// - /// Clamps to valid bounds [1, FieldLength] - /// - /// - /// - /// If the cursor is on a separator character, moves it in the specified direction until it - /// reaches a digit - /// - /// - /// - /// - /// - /// Example: For time " 14:30:45" with separator ':': - /// - /// - /// AdjustInsertionPoint(3, true) → cursor moves to position 4 (first digit of minutes) - /// - /// - /// AdjustInsertionPoint(3, false) → cursor moves to position 2 (last digit of hours) - /// - /// - /// - /// - private void AdjustInsertionPoint (int point, bool increment = true) - { - int newPoint = point; - - // Clamp to valid bounds - if (point > FieldLength) - { - newPoint = FieldLength; - } - - if (point < 1) - { - newPoint = 1; - } - - if (newPoint != point) - { - InsertionPoint = newPoint; - } - - // Skip over separator characters in the specified direction - while (InsertionPoint < Text.GetColumns () - 1 && Text [InsertionPoint] == _sepChar [0]) - { - if (increment) - { - InsertionPoint++; - } - else - { - InsertionPoint--; - } - } - } - - /// - /// Decrements the cursor position by one, skipping over separator characters. - /// - /// - /// - /// This method moves the cursor left by one position, then calls - /// with increment=false to skip over any separator that might be at the new position. - /// - /// - /// The cursor will not move below position 1 (the first digit position). - /// - /// - private void DecrementInsertionPoint () - { - if (InsertionPoint <= 1) - { - InsertionPoint = 1; - - return; - } - - InsertionPoint--; - AdjustInsertionPoint (InsertionPoint, false); - } - - /// - /// Increments the cursor position by one, skipping over separator characters. - /// - /// - /// - /// This method moves the cursor right by one position, then calls - /// with increment=true to skip over any separator that might be at the new position. - /// - /// - /// The cursor will not move beyond FieldLength (the last digit position). - /// - /// - private void IncrementInsertionPoint () - { - if (InsertionPoint >= FieldLength) - { - InsertionPoint = FieldLength; - - return; - } - - InsertionPoint++; - AdjustInsertionPoint (InsertionPoint); - } - - private new bool MoveEnd () - { - ClearAllSelection (); - InsertionPoint = FieldLength; - - return true; - } - - private bool MoveHome () - { - // Home, C-A - ClearAllSelection (); - InsertionPoint = 1; - - return true; - } - - private bool MoveLeft () - { - ClearAllSelection (); - DecrementInsertionPoint (); - - return true; - } - - private bool MoveRight () - { - ClearAllSelection (); - IncrementInsertionPoint (); - - return true; - } - - private string NormalizeFormat (string text, string? fmt = null, string? sepChar = null) - { - if (string.IsNullOrEmpty (fmt)) - { - fmt = Format; - } - - fmt = fmt.Replace ("\\", ""); - - if (string.IsNullOrEmpty (sepChar)) - { - sepChar = _sepChar; - } - - if (fmt.Length != text.Length) - { - return text; - } - - char [] fmtText = text.ToCharArray (); - - for (var i = 0; i < text.Length; i++) - { - char c = fmt [i]; - - if (c.ToString () == sepChar && text [i].ToString () != sepChar) - { - fmtText [i] = c; - } - } - - return new string (fmtText); - } - - private bool SetText (Rune key) - { - List text = Text.EnumerateRunes ().ToList (); - List newText = text.GetRange (0, InsertionPoint); - newText.Add (key); - - if (InsertionPoint < FieldLength) - { - newText = [.. newText, .. text.GetRange (InsertionPoint + 1, text.Count - (InsertionPoint + 1))]; - } - - return SetText (StringExtensions.ToString (newText)); - } - - private bool SetText (string text) - { - if (string.IsNullOrEmpty (text)) - { - return false; - } - - text = NormalizeFormat (text); - string [] vals = text.Split (_sepChar); - var isValidTime = true; - int hour = int.Parse (vals [0]); - int minute = int.Parse (vals [1]); - - int second = _isShort ? 0 : vals.Length > 2 ? int.Parse (vals [2]) : 0; - - if (hour < 0) - { - isValidTime = false; - hour = 0; - vals [0] = "0"; - } - else if (hour > 23) - { - isValidTime = false; - hour = 23; - vals [0] = "23"; - } - - if (minute < 0) - { - isValidTime = false; - minute = 0; - vals [1] = "0"; - } - else if (minute > 59) - { - isValidTime = false; - minute = 59; - vals [1] = "59"; - } - - if (second < 0) - { - isValidTime = false; - second = 0; - vals [2] = "0"; - } - else if (second > 59) - { - isValidTime = false; - second = 59; - vals [2] = "59"; - } - - string t = _isShort ? $" {hour,2:00}{_sepChar}{minute,2:00}" : $" {hour,2:00}{_sepChar}{minute,2:00}{_sepChar}{second,2:00}"; - - if (!TimeSpan.TryParseExact (t.Trim (), Format.Trim (), CultureInfo.CurrentCulture, TimeSpanStyles.None, out TimeSpan result) || !isValidTime) - { - return false; - } - - if (IsInitialized) - { - Value = result; - } - - return true; - } - - private void TextField_TextChanging (object? sender, ResultEventArgs e) - { - if (e.Result is null) - { - return; - } - - try - { - var spaces = 0; - - foreach (char t in e.Result) - { - if (t == ' ') - { - spaces++; - } - else - { - break; - } - } - - spaces += FieldLength; - string trimmedText = e.Result [..spaces]; - spaces -= FieldLength; - trimmedText = trimmedText.Replace (new string (' ', spaces), " "); - - if (trimmedText != e.Result) - { - e.Result = trimmedText; - } - - if (!TimeSpan.TryParseExact (e.Result.Trim (), Format.Trim (), CultureInfo.CurrentCulture, TimeSpanStyles.None, out TimeSpan _)) - { - e.Handled = true; - } - - AdjustInsertionPoint (InsertionPoint); - } - catch (Exception) - { - e.Handled = true; - } - } -} diff --git a/Tests/UnitTestsParallelizable/Views/TimeFieldTests.cs b/Tests/UnitTestsParallelizable/Views/TimeFieldTests.cs deleted file mode 100644 index 331ad247b5..0000000000 --- a/Tests/UnitTestsParallelizable/Views/TimeFieldTests.cs +++ /dev/null @@ -1,206 +0,0 @@ -namespace ViewsTests; - -public class TimeFieldTests -{ - [Fact] - public void Constructors_Defaults () - { - TimeField tf = new (); - tf.Layout (); - Assert.False (tf.IsShortFormat); - Assert.Equal (TimeSpan.MinValue, tf.Value); - Assert.Equal (1, tf.InsertionPoint); - Assert.Equal (new Rectangle (0, 0, 10, 1), tf.Frame); - - TimeSpan time = DateTime.Now.TimeOfDay; - tf = new TimeField { Value = time }; - tf.Layout (); - Assert.False (tf.IsShortFormat); - Assert.Equal (time, tf.Value); - Assert.Equal (1, tf.InsertionPoint); - Assert.Equal (new Rectangle (0, 0, 10, 1), tf.Frame); - - tf = new TimeField { X = 1, Y = 2, Value = time }; - tf.Layout (); - Assert.False (tf.IsShortFormat); - Assert.Equal (time, tf.Value); - Assert.Equal (1, tf.InsertionPoint); - Assert.Equal (new Rectangle (1, 2, 10, 1), tf.Frame); - - tf = new TimeField { X = 3, Y = 4, Value = time, IsShortFormat = true }; - tf.Layout (); - Assert.True (tf.IsShortFormat); - Assert.Equal (time, tf.Value); - Assert.Equal (1, tf.InsertionPoint); - Assert.Equal (new Rectangle (3, 4, 7, 1), tf.Frame); - - tf.IsShortFormat = false; - tf.Layout (); - Assert.Equal (new Rectangle (3, 4, 10, 1), tf.Frame); - Assert.Equal (10, tf.Width); - } - - [Fact] - public void Copy_Paste () - { - IApplication app = Application.Create(); - app.Init(DriverRegistry.Names.ANSI); - app.Driver!.Clipboard = new FakeClipboard (); - - try - { - TimeField tf1 = new () { Value = TimeSpan.Parse ("12:12:19"), App = app }; - TimeField tf2 = new () { Value = TimeSpan.Parse ("12:59:01"), App = app }; - - // Select all text - Assert.True (tf2.NewKeyDownEvent (Key.End.WithShift)); - Assert.Equal (1, tf2.SelectedStart); - Assert.Equal (8, tf2.SelectedLength); - Assert.Equal (9, tf2.InsertionPoint); - - // Copy from tf2 - Assert.True (tf2.NewKeyDownEvent (Key.C.WithCtrl)); - - // Paste into tf1 - Assert.True (tf1.NewKeyDownEvent (Key.V.WithCtrl)); - Assert.Equal (" 12:59:01", tf1.Text); - Assert.Equal (9, tf1.InsertionPoint); - } - finally - { - app.Dispose (); - } - } - - [Fact] - public void CursorPosition_Min_Is_Always_One_Max_Is_Always_Max_Format () - { - TimeField tf = new (); - Assert.Equal (1, tf.InsertionPoint); - tf.InsertionPoint = 0; - Assert.Equal (1, tf.InsertionPoint); - tf.InsertionPoint = 9; - Assert.Equal (8, tf.InsertionPoint); - tf.IsShortFormat = true; - tf.InsertionPoint = 0; - Assert.Equal (1, tf.InsertionPoint); - tf.InsertionPoint = 6; - Assert.Equal (5, tf.InsertionPoint); - } - - [Fact] - public void CursorPosition_Min_Is_Always_One_Max_Is_Always_Max_Format_After_Selection () - { - TimeField tf = new (); - - // Start selection - Assert.True (tf.NewKeyDownEvent (Key.CursorLeft.WithShift)); - Assert.Equal (1, tf.SelectedStart); - Assert.Equal (1, tf.SelectedLength); - Assert.Equal (0, tf.InsertionPoint); - - // Without selection - Assert.True (tf.NewKeyDownEvent (Key.CursorLeft)); - Assert.Equal (-1, tf.SelectedStart); - Assert.Equal (0, tf.SelectedLength); - Assert.Equal (1, tf.InsertionPoint); - tf.InsertionPoint = 8; - Assert.True (tf.NewKeyDownEvent (Key.CursorRight.WithShift)); - Assert.Equal (8, tf.SelectedStart); - Assert.Equal (1, tf.SelectedLength); - Assert.Equal (9, tf.InsertionPoint); - Assert.True (tf.NewKeyDownEvent (Key.CursorRight)); - Assert.Equal (-1, tf.SelectedStart); - Assert.Equal (0, tf.SelectedLength); - Assert.Equal (8, tf.InsertionPoint); - Assert.False (tf.IsShortFormat); - Assert.False (tf.IsInitialized); - tf.BeginInit (); - tf.EndInit (); - tf.IsShortFormat = true; - Assert.Equal (5, tf.InsertionPoint); - - // Start selection - Assert.True (tf.NewKeyDownEvent (Key.CursorRight.WithShift)); - Assert.Equal (5, tf.SelectedStart); - Assert.Equal (1, tf.SelectedLength); - Assert.Equal (6, tf.InsertionPoint); - Assert.True (tf.NewKeyDownEvent (Key.CursorRight)); - Assert.Equal (-1, tf.SelectedStart); - Assert.Equal (0, tf.SelectedLength); - Assert.Equal (5, tf.InsertionPoint); - } - - [Fact] - public void KeyBindings_Command () - { - TimeField tf = new () { Value = TimeSpan.Parse ("12:12:19") }; - tf.BeginInit (); - tf.EndInit (); - Assert.Equal (9, tf.InsertionPoint); - tf.InsertionPoint = 1; - tf.ReadOnly = true; - Assert.True (tf.NewKeyDownEvent (Key.Delete)); - Assert.Equal (" 12:12:19", tf.Text); - tf.ReadOnly = false; - Assert.True (tf.NewKeyDownEvent (Key.D.WithCtrl)); - Assert.Equal (" 02:12:19", tf.Text); - tf.InsertionPoint = 4; - tf.ReadOnly = true; - Assert.True (tf.NewKeyDownEvent (Key.Delete)); - Assert.Equal (" 02:12:19", tf.Text); - tf.ReadOnly = false; - Assert.True (tf.NewKeyDownEvent (Key.Backspace)); - Assert.Equal (" 02:02:19", tf.Text); - Assert.True (tf.NewKeyDownEvent (Key.Home)); - Assert.Equal (1, tf.InsertionPoint); - Assert.True (tf.NewKeyDownEvent (Key.End)); - Assert.Equal (8, tf.InsertionPoint); - Assert.True (tf.NewKeyDownEvent (Key.A.WithCtrl)); - Assert.Equal (1, tf.InsertionPoint); - Assert.Equal (9, tf.Text.Length); - Assert.True (tf.NewKeyDownEvent (Key.E.WithCtrl)); - Assert.Equal (8, tf.InsertionPoint); - Assert.True (tf.NewKeyDownEvent (Key.CursorLeft)); - Assert.Equal (7, tf.InsertionPoint); - Assert.True (tf.NewKeyDownEvent (Key.CursorRight)); - Assert.Equal (8, tf.InsertionPoint); - - // Non-numerics are ignored - Assert.False (tf.NewKeyDownEvent (Key.A)); - tf.ReadOnly = true; - tf.InsertionPoint = 1; - Assert.True (tf.NewKeyDownEvent (Key.D1)); - Assert.Equal (" 02:02:19", tf.Text); - tf.ReadOnly = false; - Assert.True (tf.NewKeyDownEvent (Key.D1)); - Assert.Equal (" 12:02:19", tf.Text); - Assert.Equal (2, tf.InsertionPoint); -#if UNIX_KEY_BINDINGS - Assert.True (tf.NewKeyDownEvent (Key.D.WithAlt)); - Assert.Equal (" 10:02:19", tf.Text); -#endif - } - - [Fact] - public void Typing_With_Selection_Normalize_Format () - { - TimeField tf = new () { Value = TimeSpan.Parse ("12:12:19") }; - - // Start selection at before the first separator : - tf.InsertionPoint = 2; - - // Now select the separator : - Assert.True (tf.NewKeyDownEvent (Key.CursorRight.WithShift)); - Assert.Equal (2, tf.SelectedStart); - Assert.Equal (1, tf.SelectedLength); - Assert.Equal (3, tf.InsertionPoint); - - // Type 3 over the separator - Assert.True (tf.NewKeyDownEvent (Key.D3)); - - // The format was normalized and replaced again with : - Assert.Equal (" 12:12:19", tf.Text); - Assert.Equal (4, tf.InsertionPoint); - } -} diff --git a/docfx/docs/events.md b/docfx/docs/events.md index 7ce0e928d5..6468661d4b 100644 --- a/docfx/docs/events.md +++ b/docfx/docs/events.md @@ -486,7 +486,7 @@ public interface IValue : IValue | | `string` | Text content | | | `string` | Full text content | | `DateField` | `DateTime?` | Selected date and time | -| `TimeField` | `TimeSpan` | Selected time | +| `TimeEditor` | `TimeSpan` | Selected time | | `ScrollBar` | `int` | Current scroll position | | `Slider` | `int` | Current slider value | | | `int` | Selected item index | diff --git a/docfx/docs/views.md b/docfx/docs/views.md index 25fd61fc36..35a81b1c46 100644 --- a/docfx/docs/views.md +++ b/docfx/docs/views.md @@ -930,9 +930,9 @@ Fully featured multi-line text editor -## [TimeField](xref:Terminal.Gui.Views.TimeField) +## [TimeEditor](xref:Terminal.Gui.Views.TimeEditor) -Provides time editing functionality with specialized cursor behavior for time entry. +Provides time editing functionality using `TextValidateField` with culture-aware formatting.

From 5e7e2088b2eb7253db1427a699db31e797324d6a Mon Sep 17 00:00:00 2001
From: Tig 
Date: Sat, 7 Mar 2026 16:14:10 -0700
Subject: [PATCH 17/26] Refactor TimeEditor and providers, remove VerifyChar
 method

Refactored TimeEditor, TimeTextProvider, NetMaskedTextProvider, and TextRegexProvider for clarity and consistency. Removed the VerifyChar method from ITextValidateProvider and all implementations, simplifying the provider interface. Standardized event raising and improved handling of AM/PM, manual parsing, and pattern normalization in TimeTextProvider. Updated TextValidateField drawing and key handling logic for simplicity. Modernized and clarified all related tests to match the new API and style. Made various code style improvements and updated documentation throughout.
---
 .../UICatalog/Scenarios/TextInputControls.cs  |   9 +-
 .../Views/TextInput/ITextValidateProvider.cs  |  14 -
 .../Views/TextInput/NetMaskedTextProvider.cs  |  46 +--
 .../Views/TextInput/TextEditingLineStatus.cs  |   3 +-
 .../Views/TextInput/TextRegexProvider.cs      |  56 +--
 .../Views/TextInput/TextValidateField.cs      |  21 +-
 Terminal.Gui/Views/TextInput/TimeEditor.cs    |  11 +-
 .../Views/TextInput/TimeTextProvider.cs       | 183 ++++-----
 .../Views/TextValidateFieldTests.cs           |   1 +
 .../Views/TimeEditorTests.cs                  | 380 ++++++++----------
 10 files changed, 305 insertions(+), 419 deletions(-)

diff --git a/Examples/UICatalog/Scenarios/TextInputControls.cs b/Examples/UICatalog/Scenarios/TextInputControls.cs
index f2b03e06d4..b47edd7d44 100644
--- a/Examples/UICatalog/Scenarios/TextInputControls.cs
+++ b/Examples/UICatalog/Scenarios/TextInputControls.cs
@@ -247,15 +247,10 @@ void TextViewDrawContent (object? sender, DrawEventArgs e) =>
         label = new Label { Text = "T_imeEditor:", Y = Pos.Top (dateField), X = Pos.Right (labelMirroringDateField) + 5 };
         win.Add (label);
 
-        _timeEditor = new TimeEditor ()
-        {
-            X = Pos.Right (label) + 1,
-            Y = Pos.Top (dateField),
-            Value = DateTime.Now.TimeOfDay
-        };
+        _timeEditor = new TimeEditor { X = Pos.Right (label) + 1, Y = Pos.Top (dateField), Value = DateTime.Now.TimeOfDay };
         win.Add (_timeEditor);
 
-        _labelMirroringTimeEditor = new Label ()
+        _labelMirroringTimeEditor = new Label
         {
             X = Pos.Right (_timeEditor) + 1,
             Y = Pos.Top (_timeEditor),
diff --git a/Terminal.Gui/Views/TextInput/ITextValidateProvider.cs b/Terminal.Gui/Views/TextInput/ITextValidateProvider.cs
index 69c23cd30d..e5f67aa267 100644
--- a/Terminal.Gui/Views/TextInput/ITextValidateProvider.cs
+++ b/Terminal.Gui/Views/TextInput/ITextValidateProvider.cs
@@ -1,7 +1,3 @@
-
-
-using System.ComponentModel;
-
 namespace Terminal.Gui.Views;
 
 /// TextValidateField Providers Interface. All TextValidateField are created with a ITextValidateProvider.
@@ -53,16 +49,6 @@ public interface ITextValidateProvider
     /// true if the character was successfully inserted, otherwise false.
     bool InsertAt (char ch, int pos);
 
-    /// 
-    /// Tests whether the specified character would be set successfully at the specified position.
-    /// 
-    public bool VerifyChar (char input, int position, out MaskedTextResultHint hint);
-
-    /// Method that invoke the  event if it's defined.
-    /// The previous text before replaced.
-    /// Returns the 
-    void OnTextChanged (EventArgs oldValue);
-
     /// 
     ///     Changed event, raised when the text has changed.
     ///     
diff --git a/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs b/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs
index 49b393cb62..080d9cb03f 100644
--- a/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs
+++ b/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs
@@ -31,10 +31,8 @@ public string Mask
         get => _provider!.Mask;
         set
         {
-            string current = _provider != null
-                                 ? _provider.ToString (false, false)
-                                 : string.Empty;
-            _provider = new (value == string.Empty ? "&&&&&&" : value);
+            string current = _provider != null ? _provider.ToString (false, false) : string.Empty;
+            _provider = new MaskedTextProvider (value == string.Empty ? "&&&&&&" : value);
 
             if (!string.IsNullOrEmpty (current))
             {
@@ -47,11 +45,7 @@ public string Mask
     public event EventHandler>? TextChanged;
 
     /// 
-    public string Text
-    {
-        get => _provider!.ToString ();
-        set => _provider!.Set (value);
-    }
+    public string Text { get => _provider!.ToString (); set => _provider!.Set (value); }
 
     /// 
     public bool IsValid => _provider!.MaskCompleted;
@@ -86,20 +80,11 @@ public int Cursor (int pos)
     }
 
     /// 
-    public int CursorStart ()
-    {
-        return _provider!.IsEditPosition (0)
-                   ? 0
-                   : _provider.FindEditPositionFrom (0, true);
-    }
+    public int CursorStart () => _provider!.IsEditPosition (0) ? 0 : _provider.FindEditPositionFrom (0, true);
 
     /// 
-    public int CursorEnd ()
-    {
-        return _provider!.IsEditPosition (_provider.Length - 1)
-                   ? _provider.Length - 1
-                   : _provider.FindEditPositionFrom (_provider.Length, false);
-    }
+    public int CursorEnd () =>
+        _provider!.IsEditPosition (_provider.Length - 1) ? _provider.Length - 1 : _provider.FindEditPositionFrom (_provider.Length, false);
 
     /// 
     public int CursorLeft (int pos)
@@ -125,7 +110,7 @@ public bool Delete (int pos)
 
         if (result)
         {
-            OnTextChanged (new (in oldValue));
+            OnTextChanged (new EventArgs (in oldValue));
         }
 
         return result;
@@ -139,18 +124,17 @@ public bool InsertAt (char ch, int pos)
 
         if (result)
         {
-            OnTextChanged (new (in oldValue));
+            OnTextChanged (new EventArgs (in oldValue));
         }
 
         return result;
     }
 
-    /// 
-    public bool VerifyChar (char input, int position, out MaskedTextResultHint hint)
-    {
-        return _provider!.VerifyChar (input, position, out hint);
-    }
-
-    /// 
-    public void OnTextChanged (EventArgs args) { TextChanged?.Invoke (this, args); }
+    /// 
+    /// Raises the TextChanged event to notify subscribers that the text has changed.
+    /// 
+    /// Call this method to trigger the TextChanged event when the text value is updated. Subscribers
+    /// can use this event to respond to changes in the text.
+    /// An EventArgs object that contains the event data for the text change.
+    public void OnTextChanged (EventArgs args) => TextChanged?.Invoke (this, args);
 }
diff --git a/Terminal.Gui/Views/TextInput/TextEditingLineStatus.cs b/Terminal.Gui/Views/TextInput/TextEditingLineStatus.cs
index 0259b15ee3..cd757e60ff 100644
--- a/Terminal.Gui/Views/TextInput/TextEditingLineStatus.cs
+++ b/Terminal.Gui/Views/TextInput/TextEditingLineStatus.cs
@@ -1,4 +1,3 @@
-
 /// 
 ///     Represents the status of a line during text editing operations in a .
 /// 
@@ -8,8 +7,8 @@
 ///     and maintaining the state of text modifications. Each value describes a specific type of change or state for a
 ///     line.
 /// 
+
 // ReSharper disable once CheckNamespace
-#nullable disable
 public enum TextEditingLineStatus
 {
     /// 
diff --git a/Terminal.Gui/Views/TextInput/TextRegexProvider.cs b/Terminal.Gui/Views/TextInput/TextRegexProvider.cs
index a64a494bea..e70b1b3a4a 100644
--- a/Terminal.Gui/Views/TextInput/TextRegexProvider.cs
+++ b/Terminal.Gui/Views/TextInput/TextRegexProvider.cs
@@ -1,4 +1,3 @@
-using System.ComponentModel;
 using System.Text.RegularExpressions;
 
 namespace Terminal.Gui.Views;
@@ -98,12 +97,13 @@ public int CursorRight (int pos)
     /// 
     public bool Delete (int pos)
     {
-        if (_text.Count > 0 && pos < _text.Count)
+        if (_text.Count <= 0 || pos >= _text.Count)
         {
-            string oldValue = Text;
-            _text.RemoveAt (pos);
-            OnTextChanged (new (in oldValue));
+            return true;
         }
+        string oldValue = Text;
+        _text.RemoveAt (pos);
+        OnTextChanged (new EventArgs (in oldValue));
 
         return true;
     }
@@ -114,56 +114,34 @@ public bool InsertAt (char ch, int pos)
         List aux = _text.ToList ();
         aux.Insert (pos, (Rune)ch);
 
-        if (Validate (aux) || !ValidateOnInput)
-        {
-            string oldValue = Text;
-            _text.Insert (pos, (Rune)ch);
-            OnTextChanged (new (in oldValue));
-
-            return true;
-        }
-
-        return false;
-    }
-
-    /// 
-    public bool VerifyChar (char input, int position, out MaskedTextResultHint hint)
-    {
-        if (position < 0 || position > _text.Count)
+        if (!Validate (aux) && ValidateOnInput)
         {
-            hint = MaskedTextResultHint.PositionOutOfRange;
-
             return false;
         }
+        string oldValue = Text;
+        _text.Insert (pos, (Rune)ch);
+        OnTextChanged (new EventArgs (in oldValue));
 
-        List aux = _text.ToList ();
-        aux.Insert (position, (Rune)input);
-
-        if (Validate (aux))
-        {
-            hint = MaskedTextResultHint.Success;
-
-            return true;
-        }
-        hint = MaskedTextResultHint.InvalidInput;
-
-        return false;
+        return true;
     }
 
-    /// 
+    /// 
+    ///     Raises the TextChanged event to notify subscribers that the text has changed.
+    /// 
+    /// An EventArgs object that contains the event data representing the new text value.
     public void OnTextChanged (EventArgs args) => TextChanged?.Invoke (this, args);
 
     /// Compiles the regex pattern for validation./>
-    private void CompileMask () => _regex = new (StringExtensions.ToString (_pattern), RegexOptions.Compiled);
+    private void CompileMask () => _regex = new Regex (StringExtensions.ToString (_pattern), RegexOptions.Compiled);
 
     private void SetupText ()
     {
-        if (_text is { } && IsValid)
+        if (_text is not null && IsValid)
         {
             return;
         }
 
-        _text = new ();
+        _text = [];
     }
 
     private bool Validate (List text)
diff --git a/Terminal.Gui/Views/TextInput/TextValidateField.cs b/Terminal.Gui/Views/TextInput/TextValidateField.cs
index 4ebcfdb072..d7153d5671 100644
--- a/Terminal.Gui/Views/TextInput/TextValidateField.cs
+++ b/Terminal.Gui/Views/TextInput/TextValidateField.cs
@@ -178,7 +178,7 @@ protected override bool OnDrawingContent (DrawContext? context)
             return true;
         }
 
-        VisualRole role = VisualRole.Editable;
+        var role = VisualRole.Editable;
         Attribute textColor = IsValid ? GetAttributeForRole (role) : SchemeManager.GetScheme (Schemes.Error).GetAttributeForRole (role);
 
         (int marginLeft, int marginRight) = GetMargins (Viewport.Width);
@@ -210,12 +210,13 @@ protected override bool OnDrawingContent (DrawContext? context)
             AddRune ((Rune)' ');
         }
 
-        if (HasFocus && _provider.DisplayText.Length > 0 && InsertionPoint < _provider.DisplayText.Length)
+        if (!HasFocus || _provider.DisplayText.Length <= 0 || InsertionPoint >= _provider.DisplayText.Length)
         {
-            SetAttributeForRole (VisualRole.Focus);
-            Move (InsertionPoint + marginLeft, 0);
-            AddRune ((Rune)_provider.DisplayText [InsertionPoint]);
+            return true;
         }
+        SetAttributeForRole (VisualRole.Focus);
+        Move (InsertionPoint + marginLeft, 0);
+        AddRune ((Rune)_provider.DisplayText [InsertionPoint]);
 
         return true;
     }
@@ -237,14 +238,14 @@ protected override bool OnKeyDownNotHandled (Key key)
 
         bool inserted = _provider.InsertAt ((char)rune.Value, InsertionPoint);
 
-        if (inserted)
+        if (!inserted)
         {
-            CursorRight ();
-
-            return true;
+            return false;
         }
+        CursorRight ();
+
+        return true;
 
-        return false;
     }
 
     /// Delete char at cursor position - 1, moving the cursor.
diff --git a/Terminal.Gui/Views/TextInput/TimeEditor.cs b/Terminal.Gui/Views/TextInput/TimeEditor.cs
index a190df19b2..b26f13e9b9 100644
--- a/Terminal.Gui/Views/TextInput/TimeEditor.cs
+++ b/Terminal.Gui/Views/TextInput/TimeEditor.cs
@@ -57,7 +57,7 @@ namespace Terminal.Gui.Views;
 public class TimeEditor : TextValidateField, IValue, IDesignable
 {
     private TimeTextProvider TimeProvider => (TimeTextProvider)Provider!;
-    private TimeSpan _lastKnownValue = TimeSpan.Zero;
+    private TimeSpan _lastKnownValue;
 
     /// 
     ///     Initializes a new instance of the  class.
@@ -65,10 +65,10 @@ public class TimeEditor : TextValidateField, IValue, IDesignable
     public TimeEditor ()
     {
         Provider = new TimeTextProvider ();
+
         // Add one so there is always a blank cell after the last editable character for the cursor.
         Width = Dim.Auto (minimumContentDim: Provider!.DisplayText.Length + 1);
 
-
         // Subscribe to provider's text changed to raise our value events
         TimeProvider.TextChanged += (_, _) => RaiseValueChangedEvents ();
 
@@ -94,6 +94,7 @@ public DateTimeFormatInfo Format
         set
         {
             TimeProvider.Format = value;
+
             // Add one so there is always a blank cell after the last editable character for the cursor.
             Width = TimeProvider.DisplayText.Length + 1;
             SetNeedsDraw ();
@@ -138,7 +139,7 @@ public TimeSpan Value
 
             // Update _lastKnownValue before setting to prevent double-firing from TextChanged handler
             _lastKnownValue = value;
-            
+
             TimeProvider.TimeValue = value;
             Text = TimeProvider.Text;
 
@@ -161,7 +162,7 @@ public TimeSpan Value
     public event EventHandler>? ValueChangedUntyped;
 
     /// 
-    object? IValue.GetValue () => Value;
+    object IValue.GetValue () => Value;
 
     /// 
     ///     Called when the  is changing.
@@ -198,7 +199,6 @@ private void RaiseValueChangedEvents ()
             return;
         }
 
-
         // Raise ValueChanging to allow cancellation
         ValueChangingEventArgs changingArgs = new (_lastKnownValue, currentValue);
 
@@ -224,7 +224,6 @@ private void RaiseValueChangedEvents ()
             return;
         }
 
-
         ValueChangedEventArgs changedArgs = new (_lastKnownValue, currentValue);
         _lastKnownValue = currentValue;
         OnValueChanged (changedArgs);
diff --git a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs
index e78fa59ebf..68aecc04a5 100644
--- a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs
+++ b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs
@@ -47,7 +47,7 @@ public class TimeTextProvider : ITextValidateProvider
     private bool _is12Hour;
     private bool _hasAmPm;
     private int _fieldLength;
-    private HashSet _separatorPositions = [];
+    private readonly HashSet _separatorPositions = [];
     private int _amPmPosition = -1;
     private bool _isPm;
 
@@ -71,7 +71,7 @@ public DateTimeFormatInfo Format
             _format = value;
             _separator = value.TimeSeparator;
             AnalyzePattern ();
-            OnTextChanged (new (in string.Empty));
+            OnTextChanged (new EventArgs (in string.Empty));
         }
     }
 
@@ -97,17 +97,17 @@ public string Text
         get => FormatTimeValue ();
         set
         {
-            if (TryParseTimeValue (value, out TimeSpan parsedValue))
+            if (!TryParseTimeValue (value, out TimeSpan parsedValue))
             {
-                string oldValue = Text;
-                _timeValue = parsedValue;
-                _isPm = _timeValue.Hours >= 12;
-
+                return;
+            }
+            string oldValue = Text;
+            _timeValue = parsedValue;
+            _isPm = _timeValue.Hours >= 12;
 
-                if (oldValue != Text)
-                {
-                    OnTextChanged (new (in oldValue));
-                }
+            if (oldValue != Text)
+            {
+                OnTextChanged (new EventArgs (in oldValue));
             }
         }
     }
@@ -234,21 +234,20 @@ public bool Delete (int pos)
         // Replace digit with '0'
         string currentText = FormatTimeValue ();
 
-        if (pos >= 0 && pos < currentText.Length && char.IsDigit (currentText [pos]))
+        if (pos < 0 || pos >= currentText.Length || !char.IsDigit (currentText [pos]))
         {
-            StringBuilder sb = new (currentText);
-            sb [pos] = '0';
-
-            if (TryParseTimeValue (sb.ToString (), out TimeSpan newValue))
-            {
-                _timeValue = newValue;
-                OnTextChanged (new (in oldValue));
+            return false;
+        }
+        StringBuilder sb = new (currentText) { [pos] = '0' };
 
-                return true;
-            }
+        if (!TryParseTimeValue (sb.ToString (), out TimeSpan newValue))
+        {
+            return false;
         }
+        _timeValue = newValue;
+        OnTextChanged (new EventArgs (in oldValue));
 
-        return false;
+        return true;
     }
 
     /// 
@@ -259,26 +258,24 @@ public bool InsertAt (char ch, int pos)
         // Handle AM/PM toggle
         if (_hasAmPm && pos == _amPmPosition)
         {
-            if (char.ToUpperInvariant (ch) == 'A' || char.ToUpperInvariant (ch) == 'P')
+            if (char.ToUpperInvariant (ch) != 'A' && char.ToUpperInvariant (ch) != 'P')
             {
-                _isPm = char.ToUpperInvariant (ch) == 'P';
-
-                // Update the time value hours to reflect AM/PM change
-                int hours = _timeValue.Hours % 12;
-
-                if (_isPm && hours < 12)
-                {
-                    hours += 12;
-                }
-
+                return false;
+            }
+            _isPm = char.ToUpperInvariant (ch) == 'P';
 
-                _timeValue = new TimeSpan (hours, _timeValue.Minutes, _timeValue.Seconds);
-                OnTextChanged (new (in oldValue));
+            // Update the time value hours to reflect AM/PM change
+            int hours = _timeValue.Hours % 12;
 
-                return true;
+            if (_isPm && hours < 12)
+            {
+                hours += 12;
             }
 
-            return false;
+            _timeValue = new TimeSpan (hours, _timeValue.Minutes, _timeValue.Seconds);
+            OnTextChanged (new EventArgs (in oldValue));
+
+            return true;
         }
 
         // Only accept digits for time positions
@@ -290,52 +287,28 @@ public bool InsertAt (char ch, int pos)
         // Replace digit at position
         string currentText = FormatTimeValue ();
 
-        if (pos >= 0 && pos < currentText.Length)
-        {
-            StringBuilder sb = new (currentText);
-            sb [pos] = ch;
-
-            if (TryParseTimeValue (sb.ToString (), out TimeSpan newValue))
-            {
-                _timeValue = newValue;
-                OnTextChanged (new (in oldValue));
-
-                return true;
-            }
-        }
-
-        return false;
-    }
-
-    /// 
-    public bool VerifyChar (char input, int position, out MaskedTextResultHint hint)
-    {
-        hint = MaskedTextResultHint.Success;
-
-        // Handle AM/PM toggle
-        if (_hasAmPm && position == _amPmPosition)
+        if (pos < 0 || pos >= currentText.Length)
         {
-            if (char.ToUpperInvariant (input) == 'A' || char.ToUpperInvariant (input) == 'P')
-            {
-                return true;
-            }
-            hint = MaskedTextResultHint.InvalidInput;
-
             return false;
         }
+        StringBuilder sb = new (currentText) { [pos] = ch };
 
-        // Only accept digits for time positions
-        if (!char.IsDigit (input))
+        if (!TryParseTimeValue (sb.ToString (), out TimeSpan newValue))
         {
-            hint = MaskedTextResultHint.InvalidInput;
-
             return false;
         }
+        _timeValue = newValue;
+        OnTextChanged (new EventArgs (in oldValue));
 
         return true;
     }
 
-    /// 
+    /// 
+    /// Raises the TextChanged event to notify subscribers that the text has changed.
+    /// 
+    /// Call this method to trigger the TextChanged event when the text value is updated. Subscribers
+    /// can use this event to respond to changes in the text.
+    /// An EventArgs object that contains the event data for the text change.
     public void OnTextChanged (EventArgs args) => TextChanged?.Invoke (this, args);
 
     /// 
@@ -368,16 +341,17 @@ private void AnalyzePattern ()
         }
 
         // Find AM/PM position
-        if (_hasAmPm)
+        if (!_hasAmPm)
         {
-            string amDesignator = _format.AMDesignator;
-            string pmDesignator = _format.PMDesignator;
+            return;
+        }
+        string amDesignator = _format.AMDesignator;
+        string pmDesignator = _format.PMDesignator;
 
-            int amIndex = formatted.IndexOf (amDesignator, StringComparison.Ordinal);
-            int pmIndex = formatted.IndexOf (pmDesignator, StringComparison.Ordinal);
+        int amIndex = formatted.IndexOf (amDesignator, StringComparison.Ordinal);
+        int pmIndex = formatted.IndexOf (pmDesignator, StringComparison.Ordinal);
 
-            _amPmPosition = Math.Max (amIndex, pmIndex);
-        }
+        _amPmPosition = Math.Max (amIndex, pmIndex);
     }
 
     /// 
@@ -417,30 +391,30 @@ private string FormatTimeValue ()
         DateTime dt = DateTime.Today.Add (_timeValue);
 
         // For 12-hour format, adjust the hours if needed
-        if (_is12Hour && _hasAmPm)
+        if (!_is12Hour || !_hasAmPm)
         {
-            int hours = _timeValue.Hours % 12;
-
-            if (hours == 0)
-            {
-                hours = 12;
-            }
+            return dt.ToString (_normalizedPattern, _format);
+        }
+        int hours = _timeValue.Hours % 12;
 
-            // Convert to 24-hour format for DateTime construction
-            int hours24;
+        if (hours == 0)
+        {
+            hours = 12;
+        }
 
-            if (_isPm)
-            {
-                hours24 = hours == 12 ? 12 : hours + 12;
-            }
-            else
-            {
-                hours24 = hours == 12 ? 0 : hours;
-            }
+        // Convert to 24-hour format for DateTime construction
+        int hours24;
 
-            dt = new DateTime (BASE_YEAR, BASE_MONTH, BASE_DAY, hours24, _timeValue.Minutes, _timeValue.Seconds);
+        if (_isPm)
+        {
+            hours24 = hours == 12 ? 12 : hours + 12;
+        }
+        else
+        {
+            hours24 = hours == 12 ? 0 : hours;
         }
 
+        dt = new DateTime (BASE_YEAR, BASE_MONTH, BASE_DAY, hours24, _timeValue.Minutes, _timeValue.Seconds);
 
         return dt.ToString (_normalizedPattern, _format);
     }
@@ -460,15 +434,15 @@ private bool TryParseTimeValue (string text, out TimeSpan result)
         text = text.Trim ();
 
         // Try to parse using the current pattern
-        if (DateTime.TryParseExact (text, _normalizedPattern, _format, DateTimeStyles.None, out DateTime dt))
+        if (!DateTime.TryParseExact (text, _normalizedPattern, _format, DateTimeStyles.None, out DateTime dt))
         {
-            result = dt.TimeOfDay;
-
-            return true;
+            return TryManualParse (text, out result);
         }
+        result = dt.TimeOfDay;
+
+        return true;
 
         // Fallback: try manual parsing for partial/invalid input
-        return TryManualParse (text, out result);
     }
 
     /// 
@@ -496,12 +470,12 @@ private bool TryManualParse (string text, out TimeSpan result)
                 if (lastPart.EndsWith (_format.PMDesignator, StringComparison.OrdinalIgnoreCase))
                 {
                     isPm = true;
-                    lastPart = lastPart.Substring (0, lastPart.Length - _format.PMDesignator.Length).Trim ();
+                    lastPart = lastPart [..^_format.PMDesignator.Length].Trim ();
                 }
                 else if (lastPart.EndsWith (_format.AMDesignator, StringComparison.OrdinalIgnoreCase))
                 {
                     isPm = false;
-                    lastPart = lastPart.Substring (0, lastPart.Length - _format.AMDesignator.Length).Trim ();
+                    lastPart = lastPart [..^_format.AMDesignator.Length].Trim ();
                 }
 
                 parts [^1] = lastPart;
@@ -557,7 +531,6 @@ private bool TryManualParse (string text, out TimeSpan result)
             result = new TimeSpan (hours, minutes, seconds);
             _isPm = hours >= 12;
 
-
             return true;
         }
         catch (ArgumentException)
diff --git a/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs b/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs
index 488c0340ac..752c6e06de 100644
--- a/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs
+++ b/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs
@@ -71,6 +71,7 @@ public void Default_Width_Is_Always_Equal_To_The_Provider_DisplayText_Length ()
         // A-Alphanumeric, required. a-Alphanumeric, optional.
         var field = new TextValidateField { Provider = new NetMaskedTextProvider ("999 000 LLL >LLL |AAA aaa") };
         field.Layout ();
+
         // Width is DisplayText.Length + 1 to provide a blank cell for the cursor past the last editable char.
         Assert.Equal (field.Viewport.Width, field.Provider.DisplayText.Length + 1);
         Assert.NotEqual (field.Provider.DisplayText.Length, field.Provider.Text.Length);
diff --git a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs
index 4b9f59a256..6709b3e94d 100644
--- a/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs
+++ b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs
@@ -1,5 +1,4 @@
 using System.Globalization;
-using Terminal.Gui.Tests;
 using UnitTests;
 
 namespace ViewsTests;
@@ -12,7 +11,7 @@ public void Constructor_Defaults ()
     {
         TimeEditor te = new ();
         te.Layout ();
-        
+
         Assert.NotNull (te.Provider);
         Assert.IsType (te.Provider);
         Assert.Equal (TimeSpan.Zero, te.Value);
@@ -25,14 +24,14 @@ public void Value_Property_GetSet ()
     {
         TimeEditor te = new ();
         TimeSpan testTime = new (14, 30, 45);
-        
+
         te.Value = testTime;
         Assert.Equal (testTime, te.Value);
-        
+
         // Test setting to zero
         te.Value = TimeSpan.Zero;
         Assert.Equal (TimeSpan.Zero, te.Value);
-        
+
         // Test setting to max
         te.Value = TimeSpan.FromHours (23) + TimeSpan.FromMinutes (59) + TimeSpan.FromSeconds (59);
         Assert.Equal (new TimeSpan (23, 59, 59), te.Value);
@@ -42,22 +41,22 @@ public void Value_Property_GetSet ()
     public void Format_Property_Changes_Width ()
     {
         TimeEditor te = new ();
-        
+
         // Set initial format explicitly to ensure deterministic test
-        DateTimeFormatInfo initialFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var initialFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         initialFormat.LongTimePattern = "HH:mm:ss";
         te.Format = initialFormat;
         te.Layout ();
-        
+
         int initialWidth = te.Frame.Width;
         Assert.True (initialWidth > 0);
-        
+
         // Change to a different pattern
-        DateTimeFormatInfo customFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var customFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         customFormat.LongTimePattern = "HH:mm";
         te.Format = customFormat;
         te.Layout ();
-        
+
         // Width should change to accommodate shorter pattern
         int newWidth = te.Frame.Width;
         Assert.NotEqual (initialWidth, newWidth);
@@ -68,16 +67,16 @@ public void Format_Property_Changes_Width ()
     public void ValueChanging_Event_Can_Cancel ()
     {
         TimeEditor te = new () { Value = TimeSpan.FromHours (10) };
-        bool eventFired = false;
-        
+        var eventFired = false;
+
         te.ValueChanging += (_, e) =>
-        {
-            eventFired = true;
-            e.Handled = true; // Cancel the change
-        };
-        
+                            {
+                                eventFired = true;
+                                e.Handled = true; // Cancel the change
+                            };
+
         te.Value = TimeSpan.FromHours (15);
-        
+
         Assert.True (eventFired);
         Assert.Equal (TimeSpan.FromHours (10), te.Value); // Value should not change
     }
@@ -86,20 +85,20 @@ public void ValueChanging_Event_Can_Cancel ()
     public void ValueChanged_Event_Fires ()
     {
         TimeEditor te = new () { Value = TimeSpan.FromHours (10) };
-        bool eventFired = false;
+        var eventFired = false;
         TimeSpan? oldValue = null;
         TimeSpan? newValue = null;
-        
+
         te.ValueChanged += (_, e) =>
-        {
-            eventFired = true;
-            oldValue = e.OldValue;
-            newValue = e.NewValue;
-        };
-        
+                           {
+                               eventFired = true;
+                               oldValue = e.OldValue;
+                               newValue = e.NewValue;
+                           };
+
         TimeSpan expectedNewValue = TimeSpan.FromHours (15);
         te.Value = expectedNewValue;
-        
+
         Assert.True (eventFired);
         Assert.Equal (TimeSpan.FromHours (10), oldValue);
         Assert.Equal (expectedNewValue, newValue);
@@ -109,19 +108,19 @@ public void ValueChanged_Event_Fires ()
     public void TimeTextProvider_CursorNavigation_SkipsSeparators ()
     {
         TimeTextProvider provider = new ();
-        
+
         // Use 24-hour format to ensure consistent behavior
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         provider.Format = format24h;
-        
+
         // CursorStart should return 0
         Assert.Equal (0, provider.CursorStart ());
-        
+
         // For a format like "HH:mm:ss" (positions: 0,1,:,3,4,:,6,7)
         // Position 2 is separator, cursor should skip it
         int cursorPos = provider.CursorRight (1);
         Assert.NotEqual (2, cursorPos); // Should skip position 2 (separator)
-        
+
         // CursorLeft from position 3 should skip separator at 2 and go to 1
         cursorPos = provider.CursorLeft (3);
         Assert.Equal (1, cursorPos);
@@ -131,17 +130,17 @@ public void TimeTextProvider_CursorNavigation_SkipsSeparators ()
     public void TimeTextProvider_InsertAt_ReplacesDigit ()
     {
         TimeTextProvider provider = new ();
-        
+
         // Use 24-hour format to ensure consistent behavior
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         provider.Format = format24h;
-        
+
         provider.TimeValue = TimeSpan.Zero; // "00:00:00" in 24h format
-        
+
         // Insert '1' at position 0 (first hour digit)
         bool result = provider.InsertAt ('1', 0);
         Assert.True (result);
-        
+
         // Check that the value was updated
         string text = provider.Text;
         Assert.StartsWith ("1", text.TrimStart ());
@@ -151,17 +150,17 @@ public void TimeTextProvider_InsertAt_ReplacesDigit ()
     public void TimeTextProvider_Delete_ReplacesWithZero ()
     {
         TimeTextProvider provider = new ();
-        
+
         // Use 24-hour format to avoid culture-specific issues
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         provider.Format = format24h;
-        
+
         provider.TimeValue = new TimeSpan (14, 30, 45);
-        
+
         // Delete at position 0 should replace with '0'
         bool result = provider.Delete (0);
         Assert.True (result);
-        
+
         // The hour should now start with 0
         string text = provider.Text.Trim ();
         Assert.StartsWith ("0", text);
@@ -173,19 +172,19 @@ public void TimeTextProvider_Format_Change_Updates_Pattern ()
         TimeTextProvider provider = new ();
         TimeSpan testTime = new (14, 30, 45);
         provider.TimeValue = testTime;
-        
+
         string initialDisplay = provider.DisplayText;
-        
+
         // Change to a custom format
-        DateTimeFormatInfo customFormat = (DateTimeFormatInfo)CultureInfo.CurrentCulture.DateTimeFormat.Clone ();
+        var customFormat = (DateTimeFormatInfo)CultureInfo.CurrentCulture.DateTimeFormat.Clone ();
         customFormat.LongTimePattern = "HH:mm";
         provider.Format = customFormat;
-        
+
         string newDisplay = provider.DisplayText;
-        
+
         // Display should change
         Assert.NotEqual (initialDisplay, newDisplay);
-        
+
         // New display should not contain seconds
         Assert.DoesNotContain ("45", newDisplay);
     }
@@ -194,15 +193,15 @@ public void TimeTextProvider_Format_Change_Updates_Pattern ()
     public void TimeTextProvider_Validates_Hours_Minutes_Seconds ()
     {
         TimeTextProvider provider = new ();
-        
+
         // Valid time
         provider.TimeValue = new TimeSpan (23, 59, 59);
         Assert.True (provider.IsValid);
-        
+
         // Another valid time
         provider.TimeValue = new TimeSpan (0, 0, 0);
         Assert.True (provider.IsValid);
-        
+
         // Provider auto-corrects invalid values, so IsValid should always be true
         provider.TimeValue = new TimeSpan (12, 30, 15);
         Assert.True (provider.IsValid);
@@ -213,16 +212,16 @@ public void TimeEditor_KeyInput_UpdatesValue ()
     {
         IApplication app = Application.Create ();
         app.Init (DriverRegistry.Names.ANSI);
-        
+
         try
         {
             TimeEditor te = new () { App = app };
             te.Layout ();
             te.Value = TimeSpan.Zero;
-            
+
             // Simulate typing '1'
             te.NewKeyDownEvent (Key.D1);
-            
+
             // The value should have been updated
             string text = te.Text.Trim ();
             Assert.Contains ("1", text);
@@ -238,22 +237,22 @@ public void TimeEditor_Navigation_Keys ()
     {
         IApplication app = Application.Create ();
         app.Init (DriverRegistry.Names.ANSI);
-        
+
         try
         {
             TimeEditor te = new () { App = app };
             te.Layout ();
-            
+
             // Home key should move to start
             te.NewKeyDownEvent (Key.Home);
-            
+
             // End key should move to end
             te.NewKeyDownEvent (Key.End);
-            
+
             // Arrow keys should navigate
             te.NewKeyDownEvent (Key.CursorLeft);
             te.NewKeyDownEvent (Key.CursorRight);
-            
+
             // No exceptions should be thrown
             Assert.NotNull (te);
         }
@@ -269,9 +268,9 @@ public void TimeEditor_IValue_GetValue ()
         TimeEditor te = new ();
         TimeSpan testTime = new (14, 30, 45);
         te.Value = testTime;
-        
-        object? value = ((Terminal.Gui.ViewBase.IValue)te).GetValue ();
-        
+
+        object? value = ((IValue)te).GetValue ();
+
         Assert.NotNull (value);
         Assert.IsType (value);
         Assert.Equal (testTime, (TimeSpan)value);
@@ -281,25 +280,25 @@ public void TimeEditor_IValue_GetValue ()
     public void TimeTextProvider_12Hour_Format_With_AM_PM ()
     {
         TimeTextProvider provider = new ();
-        
+
         // Set to a 12-hour format
-        DateTimeFormatInfo format12h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        var format12h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
         format12h.LongTimePattern = "h:mm:ss tt";
         provider.Format = format12h;
-        
+
         // Set time to 2:30 PM (14:30)
         provider.TimeValue = new TimeSpan (14, 30, 0);
-        
+
         string display = provider.DisplayText.Trim ();
-        
+
         // Should contain PM
         Assert.Contains ("PM", display, StringComparison.OrdinalIgnoreCase);
-        
+
         // Set time to 2:30 AM (2:30)
         provider.TimeValue = new TimeSpan (2, 30, 0);
-        
+
         display = provider.DisplayText.Trim ();
-        
+
         // Should contain AM
         Assert.Contains ("AM", display, StringComparison.OrdinalIgnoreCase);
     }
@@ -308,20 +307,20 @@ public void TimeTextProvider_12Hour_Format_With_AM_PM ()
     public void TimeTextProvider_24Hour_Format ()
     {
         TimeTextProvider provider = new ();
-        
+
         // Set to a 24-hour format
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         format24h.LongTimePattern = "HH:mm:ss";
         provider.Format = format24h;
-        
+
         // Set time to 14:30
         provider.TimeValue = new TimeSpan (14, 30, 0);
-        
+
         string display = provider.DisplayText.Trim ();
-        
+
         // Should contain 14
         Assert.Contains ("14", display);
-        
+
         // Should not contain AM/PM
         Assert.DoesNotContain ("AM", display, StringComparison.OrdinalIgnoreCase);
         Assert.DoesNotContain ("PM", display, StringComparison.OrdinalIgnoreCase);
@@ -332,29 +331,29 @@ public void TimeEditor_Delete_And_Backspace ()
     {
         IApplication app = Application.Create ();
         app.Init (DriverRegistry.Names.ANSI);
-        
+
         try
         {
             TimeEditor te = new () { App = app };
-            
+
             // Use 24-hour format to ensure consistent behavior
-            DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+            var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
             te.Format = format24h;
-            
+
             te.Value = new TimeSpan (12, 34, 56);
             te.Layout ();
-            
+
             // Move to start and delete
             te.NewKeyDownEvent (Key.Home);
             te.NewKeyDownEvent (Key.Delete);
-            
+
             // Value should have changed (first digit replaced with 0)
             string text = te.Text.Trim ();
             Assert.NotEqual ("12:34:56", text);
-            
+
             // Backspace should also work
             te.NewKeyDownEvent (Key.Backspace);
-            
+
             // Text should have changed again
             Assert.NotNull (te.Text);
         }
@@ -368,12 +367,12 @@ public void TimeEditor_Delete_And_Backspace ()
     public void TimeTextProvider_CursorEnd_Returns_Last_Position ()
     {
         TimeTextProvider provider = new ();
-        
+
         int endPos = provider.CursorEnd ();
-        
+
         // End position should be >= 0
         Assert.True (endPos >= 0);
-        
+
         // For 24-hour format like "HH:mm:ss", end should be at last digit (position 7)
         // For 12-hour format with AM/PM, end should be at AM/PM position
         int displayLength = provider.DisplayText.Trim ().Length;
@@ -384,14 +383,14 @@ public void TimeTextProvider_CursorEnd_Returns_Last_Position ()
     public void TimeEditor_Text_Property_Updates_Value ()
     {
         TimeEditor te = new ();
-        
+
         // Use 24-hour format to ensure consistent parsing
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         te.Format = format24h;
-        
+
         // Set text directly
         te.Text = "14:30:45";
-        
+
         // Value should be updated
         Assert.Equal (14, te.Value.Hours);
         Assert.Equal (30, te.Value.Minutes);
@@ -402,10 +401,10 @@ public void TimeEditor_Text_Property_Updates_Value ()
     public void TimeTextProvider_InsertAt_NonDigit_Returns_False ()
     {
         TimeTextProvider provider = new ();
-        
+
         // Try to insert a non-digit character at a digit position
         bool result = provider.InsertAt ('x', 0);
-        
+
         // Should fail
         Assert.False (result);
     }
@@ -415,15 +414,15 @@ public void TimeEditor_Multiple_Format_Changes ()
     {
         TimeEditor te = new () { Value = new TimeSpan (14, 30, 45) };
         te.Layout ();
-        
+
         // Change format multiple times
-        for (int i = 0; i < 3; i++)
+        for (var i = 0; i < 3; i++)
         {
-            DateTimeFormatInfo format = (DateTimeFormatInfo)CultureInfo.CurrentCulture.DateTimeFormat.Clone ();
+            var format = (DateTimeFormatInfo)CultureInfo.CurrentCulture.DateTimeFormat.Clone ();
             format.LongTimePattern = i % 2 == 0 ? "HH:mm" : "HH:mm:ss";
             te.Format = format;
             te.Layout ();
-            
+
             // Value should remain the same
             Assert.Equal (14, te.Value.Hours);
             Assert.Equal (30, te.Value.Minutes);
@@ -434,17 +433,17 @@ public void TimeEditor_Multiple_Format_Changes ()
     public void TimeTextProvider_TryManualParse_PartialInput ()
     {
         TimeTextProvider provider = new ();
-        
+
         // Use 24-hour format
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         provider.Format = format24h;
-        
+
         // Test partial input parsing (minutes only)
         provider.Text = "14:30";
         Assert.Equal (14, provider.TimeValue.Hours);
         Assert.Equal (30, provider.TimeValue.Minutes);
         Assert.Equal (0, provider.TimeValue.Seconds);
-        
+
         // Test with seconds
         provider.Text = "14:30:45";
         Assert.Equal (14, provider.TimeValue.Hours);
@@ -456,17 +455,17 @@ public void TimeTextProvider_TryManualParse_PartialInput ()
     public void TimeTextProvider_TryManualParse_InvalidInput ()
     {
         TimeTextProvider provider = new ();
-        
+
         // Use 24-hour format
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         provider.Format = format24h;
-        
+
         TimeSpan initialValue = provider.TimeValue;
-        
+
         // Test invalid input (no separator)
         provider.Text = "invalid";
         Assert.Equal (initialValue, provider.TimeValue);
-        
+
         // Test incomplete input (only one part)
         provider.Text = "14";
         Assert.Equal (initialValue, provider.TimeValue);
@@ -476,14 +475,14 @@ public void TimeTextProvider_TryManualParse_InvalidInput ()
     public void TimeTextProvider_TryManualParse_AutoCorrection ()
     {
         TimeTextProvider provider = new ();
-        
+
         // Use 24-hour format
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         provider.Format = format24h;
-        
+
         // Test auto-correction for out-of-range values
         provider.Text = "25:70:90";
-        
+
         // Should auto-correct to valid ranges
         Assert.Equal (23, provider.TimeValue.Hours); // Max 23
         Assert.Equal (59, provider.TimeValue.Minutes); // Max 59
@@ -494,23 +493,23 @@ public void TimeTextProvider_TryManualParse_AutoCorrection ()
     public void TimeTextProvider_12Hour_AM_PM_Parsing ()
     {
         TimeTextProvider provider = new ();
-        
+
         // Use 12-hour format
-        DateTimeFormatInfo format12h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        var format12h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
         provider.Format = format12h;
-        
+
         // Test PM parsing
         provider.Text = "2:30:00 PM";
         Assert.Equal (14, provider.TimeValue.Hours);
-        
+
         // Test AM parsing
         provider.Text = "2:30:00 AM";
         Assert.Equal (2, provider.TimeValue.Hours);
-        
+
         // Test 12 PM (noon)
         provider.Text = "12:00:00 PM";
         Assert.Equal (12, provider.TimeValue.Hours);
-        
+
         // Test 12 AM (midnight)
         provider.Text = "12:00:00 AM";
         Assert.Equal (0, provider.TimeValue.Hours);
@@ -520,30 +519,30 @@ public void TimeTextProvider_12Hour_AM_PM_Parsing ()
     public void TimeTextProvider_CursorNavigation_Comprehensive ()
     {
         TimeTextProvider provider = new ();
-        
+
         // Use 24-hour format
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         provider.Format = format24h;
-        
+
         // Test CursorStart
         Assert.Equal (0, provider.CursorStart ());
-        
+
         // Test CursorEnd
         int endPos = provider.CursorEnd ();
         Assert.True (endPos >= 0);
-        
+
         // Test navigation through all positions
         int pos = provider.CursorStart ();
         int lastPos = pos;
-        
-        for (int i = 0; i < 10; i++)
+
+        for (var i = 0; i < 10; i++)
         {
             int nextPos = provider.CursorRight (pos);
-            
+
             // Should skip separators
             Assert.NotEqual (pos, nextPos);
             pos = nextPos;
-            
+
             if (pos >= provider.CursorEnd ())
             {
                 break;
@@ -557,28 +556,25 @@ public void TimeEditor_ValueChanging_Cancel ()
         TimeEditor te = new ();
         TimeSpan initialValue = TimeSpan.FromHours (10);
         te.Value = initialValue;
-        
-        bool changingEventFired = false;
-        bool changedEventFired = false;
-        
+
+        var changingEventFired = false;
+        var changedEventFired = false;
+
         te.ValueChanging += (_, e) =>
-        {
-            changingEventFired = true;
-            e.Handled = true; // Cancel the change
-        };
-        
-        te.ValueChanged += (_, e) =>
-        {
-            changedEventFired = true;
-        };
-        
+                            {
+                                changingEventFired = true;
+                                e.Handled = true; // Cancel the change
+                            };
+
+        te.ValueChanged += (_, e) => { changedEventFired = true; };
+
         // Try to set new value
         te.Value = TimeSpan.FromHours (15);
-        
+
         // ValueChanging should have fired, but ValueChanged should not
         Assert.True (changingEventFired);
         Assert.False (changedEventFired);
-        
+
         // Value should not have changed
         Assert.Equal (initialValue, te.Value);
     }
@@ -587,20 +583,20 @@ public void TimeEditor_ValueChanging_Cancel ()
     public void TimeTextProvider_Delete_AtSeparatorPosition ()
     {
         TimeTextProvider provider = new ();
-        
+
         // Use 24-hour format
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         provider.Format = format24h;
-        
+
         provider.TimeValue = new TimeSpan (14, 30, 45);
-        
+
         // Try to delete at separator position (position 2 in "14:30:45")
         string beforeText = provider.Text.Trim ();
         bool result = provider.Delete (2);
-        
+
         // Delete at separator should not change anything or should skip to next position
         string afterText = provider.Text.Trim ();
-        
+
         // The behavior depends on implementation, but text should still be valid
         Assert.NotNull (afterText);
     }
@@ -609,20 +605,20 @@ public void TimeTextProvider_Delete_AtSeparatorPosition ()
     public void TimeEditor_ValueChangedUntyped_Event ()
     {
         TimeEditor te = new ();
-        bool eventFired = false;
+        var eventFired = false;
         object? oldValue = null;
         object? newValue = null;
-        
+
         te.ValueChangedUntyped += (_, e) =>
-        {
-            eventFired = true;
-            oldValue = e.OldValue;
-            newValue = e.NewValue;
-        };
-        
+                                  {
+                                      eventFired = true;
+                                      oldValue = e.OldValue;
+                                      newValue = e.NewValue;
+                                  };
+
         TimeSpan testValue = TimeSpan.FromHours (15);
         te.Value = testValue;
-        
+
         Assert.True (eventFired);
         Assert.Equal (TimeSpan.Zero, oldValue);
         Assert.Equal (testValue, newValue);
@@ -632,11 +628,11 @@ public void TimeEditor_ValueChangedUntyped_Event ()
     public void TimeTextProvider_CursorLeft_FromStart ()
     {
         TimeTextProvider provider = new ();
-        
+
         // Use 24-hour format
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         provider.Format = format24h;
-        
+
         // CursorLeft from start should return start
         int pos = provider.CursorLeft (0);
         Assert.Equal (0, pos);
@@ -648,7 +644,7 @@ public void TimeTextProvider_CursorRight_FromEnd ()
         TimeTextProvider provider = new ();
 
         // Use 24-hour format
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         provider.Format = format24h;
 
         int endPos = provider.CursorEnd ();
@@ -673,14 +669,10 @@ public void TimeEditor_12h_DisplayText_Shows_Full_AM_PM ()
             Runnable runnable = new () { Width = 30, Height = 1 };
             app.Begin (runnable);
 
-            TimeEditor te = new ()
-            {
-                Height = 1,
-                Value = new TimeSpan (9, 0, 0)
-            };
+            TimeEditor te = new () { Height = 1, Value = new TimeSpan (9, 0, 0) };
 
             // Explicitly set 12-hour format AFTER construction to simulate the scenario
-            DateTimeFormatInfo format12h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+            var format12h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
             format12h.LongTimePattern = "h:mm:ss tt";
             te.Format = format12h;
 
@@ -698,10 +690,7 @@ public void TimeEditor_12h_DisplayText_Shows_Full_AM_PM ()
             Assert.True (te.Frame.Width >= te.Provider.DisplayText.Length,
                          $"Frame width {te.Frame.Width} is too narrow for DisplayText \"{te.Provider.DisplayText}\" ({te.Provider.DisplayText.Length} chars)");
 
-            DriverAssert.AssertDriverContentsWithFrameAre (
-                @"09:00:00 AM",
-                output,
-                app.Driver);
+            DriverAssert.AssertDriverContentsWithFrameAre (@"09:00:00 AM", output, app.Driver);
         }
         finally
         {
@@ -714,10 +703,7 @@ public void TimeEditor_12h_DisplayText_Shows_Full_AM_PM ()
     public void TimeEditor_Default_Constructor_Width_Fits_DisplayText ()
     {
         // Verifies that the default constructor produces a Width that fits the full DisplayText
-        TimeEditor te = new ()
-        {
-            Value = new TimeSpan (9, 0, 0)
-        };
+        TimeEditor te = new () { Value = new TimeSpan (9, 0, 0) };
         te.Layout ();
 
         output.WriteLine ($"DisplayText: \"{te.Provider!.DisplayText}\"");
@@ -735,7 +721,7 @@ public void TimeEditor_CursorRight_From_BlankCell_DoesNotMoveBackward ()
         // Verifies that pressing right arrow from the blank cell past the last editable
         // position does NOT move the cursor backward (e.g., from "M" in "PM" back to "A").
         TimeTextProvider provider = new ();
-        DateTimeFormatInfo format12h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        var format12h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
         format12h.LongTimePattern = "h:mm:ss tt";
         provider.Format = format12h;
 
@@ -753,11 +739,7 @@ public void TimeEditor_CursorRight_From_BlankCell_DoesNotMoveBackward ()
 
         try
         {
-            TimeEditor te = new ()
-            {
-                Value = new TimeSpan (9, 0, 0),
-                Format = format12h
-            };
+            TimeEditor te = new () { Value = new TimeSpan (9, 0, 0), Format = format12h };
             te.Layout ();
             te.SetFocus ();
 
@@ -791,7 +773,7 @@ public void NormalizedPattern_24h_Typing_12_Enters_TwoDigitHour ()
     {
         // Verifies that typing "12" at position 0 in 24h format sets hours to 12
         TimeTextProvider provider = new ();
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         format24h.LongTimePattern = "HH:mm:ss";
         provider.Format = format24h;
         provider.TimeValue = new TimeSpan (9, 0, 0);
@@ -818,7 +800,7 @@ public void NormalizedPattern_24h_Typing_12_Enters_TwoDigitHour ()
     public void NormalizedPattern_DisplayText_Has_No_LeadingSpace ()
     {
         TimeTextProvider provider = new ();
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         format24h.LongTimePattern = "HH:mm:ss";
         provider.Format = format24h;
         provider.TimeValue = new TimeSpan (9, 0, 0);
@@ -836,7 +818,7 @@ public void NormalizedPattern_SingleDigitHourFormat_PadsToTwoDigits ()
     {
         // Verifies that "h:mm:ss tt" is normalized to "hh:mm:ss tt"
         TimeTextProvider provider = new ();
-        DateTimeFormatInfo format12h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        var format12h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
         format12h.LongTimePattern = "h:mm:ss tt";
         provider.Format = format12h;
         provider.TimeValue = new TimeSpan (9, 0, 0);
@@ -855,7 +837,7 @@ public void NormalizedPattern_FieldPositions_AreConsistent ()
     {
         // Verifies separator positions don't shift based on time value
         TimeTextProvider provider = new ();
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         format24h.LongTimePattern = "H:mm:ss"; // Single-digit H
         provider.Format = format24h;
 
@@ -886,26 +868,17 @@ public void TimeEditor_Typing_12_At_Start_Renders_Correctly ()
             Runnable runnable = new () { Width = 20, Height = 1 };
             app.Begin (runnable);
 
-            DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+            var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
             format24h.LongTimePattern = "HH:mm:ss";
 
-            TimeEditor te = new ()
-            {
-                Width = 10,
-                Height = 1,
-                Value = new TimeSpan (9, 0, 0),
-                Format = format24h
-            };
+            TimeEditor te = new () { Width = 10, Height = 1, Value = new TimeSpan (9, 0, 0), Format = format24h };
             runnable.Add (te);
             app.LayoutAndDraw ();
 
             output.WriteLine ($"Initial Text: \"{te.Text}\"");
             output.WriteLine ($"Initial DisplayText: \"{te.Provider!.DisplayText}\"");
 
-            DriverAssert.AssertDriverContentsWithFrameAre (
-                @"09:00:00",
-                output,
-                app.Driver);
+            DriverAssert.AssertDriverContentsWithFrameAre (@"09:00:00", output, app.Driver);
 
             // Simulate focus and typing "1" then "2"
             te.SetFocus ();
@@ -922,10 +895,7 @@ public void TimeEditor_Typing_12_At_Start_Renders_Correctly ()
 
             Assert.Equal (new TimeSpan (12, 0, 0), te.Value);
 
-            DriverAssert.AssertDriverContentsWithFrameAre (
-                @"12:00:00",
-                output,
-                app.Driver);
+            DriverAssert.AssertDriverContentsWithFrameAre (@"12:00:00", output, app.Driver);
         }
         finally
         {
@@ -939,7 +909,7 @@ public void TimeEditor_CursorRight_SkipsSeparator_24h ()
     {
         // Verifies cursor movement: after typing at pos 1, cursor skips separator to pos 3
         TimeTextProvider provider = new ();
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         format24h.LongTimePattern = "HH:mm:ss";
         provider.Format = format24h;
 
@@ -956,7 +926,7 @@ public void TimeEditor_CursorRight_SkipsSeparator_24h ()
     public void TimeEditor_CursorLeft_SkipsSeparator_24h ()
     {
         TimeTextProvider provider = new ();
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         format24h.LongTimePattern = "HH:mm:ss";
         provider.Format = format24h;
 
@@ -971,7 +941,7 @@ public void TimeEditor_CursorLeft_SkipsSeparator_24h ()
     public void TimeEditor_FullNavigation_24h_AllPositions ()
     {
         TimeTextProvider provider = new ();
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         format24h.LongTimePattern = "HH:mm:ss";
         provider.Format = format24h;
 
@@ -996,7 +966,7 @@ public void TimeEditor_FullNavigation_24h_AllPositions ()
     public void TimeEditor_InsertAt_AllDigitPositions_24h ()
     {
         TimeTextProvider provider = new ();
-        DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        var format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
         format24h.LongTimePattern = "HH:mm:ss";
         provider.Format = format24h;
         provider.TimeValue = TimeSpan.Zero; // "00:00:00"

From 548fe80a7593d8a4ead33c1763a59bed6b8a720e Mon Sep 17 00:00:00 2001
From: Tig 
Date: Sat, 7 Mar 2026 16:59:47 -0700
Subject: [PATCH 18/26] Implement CWP and IValue for
 TextValidateField/TimeEditor

Refactored TextValidateField and TimeEditor to fully implement the IValue interface and follow the Cancellable Work Pattern (CWP) for value changes.
- TextValidateField now exposes Value, ValueChanging, and ValueChanged events, and synchronizes value changes from both programmatic and user input, with support for cancellation and reversion.
- Introduced SuppressValueEvents to prevent unwanted event recursion.
- TimeEditor now uses a private _value field, leverages CWPPropertyHelper for value changes, and raises strongly-typed TimeSpan events.
- Removed manual event wiring and last-known-value tracking in favor of the new base class pattern.
- These changes improve consistency, extensibility, and support for data binding and undo/redo scenarios.
---
 .../Views/TextInput/TextValidateField.cs      | 158 +++++++++++++++++-
 Terminal.Gui/Views/TextInput/TimeEditor.cs    | 156 ++++++++---------
 2 files changed, 230 insertions(+), 84 deletions(-)

diff --git a/Terminal.Gui/Views/TextInput/TextValidateField.cs b/Terminal.Gui/Views/TextInput/TextValidateField.cs
index d7153d5671..2139a8da5c 100644
--- a/Terminal.Gui/Views/TextInput/TextValidateField.cs
+++ b/Terminal.Gui/Views/TextInput/TextValidateField.cs
@@ -1,11 +1,19 @@
 namespace Terminal.Gui.Views;
 
 /// Masked text editor that validates input through a 
-public class TextValidateField : View, IDesignable
+public class TextValidateField : View, IDesignable, IValue
 {
     private const int DEFAULT_LENGTH = 10;
 
     private ITextValidateProvider? _provider;
+    private string _lastKnownText = string.Empty;
+
+    /// 
+    ///     Gets or sets whether value change events are suppressed.
+    ///     Subclasses set this to  when programmatically updating the provider
+    ///     to prevent re-entrant event firing.
+    /// 
+    protected bool SuppressValueEvents { get; set; }
 
     /// 
     ///     Initializes a new instance of the  class.
@@ -97,8 +105,19 @@ public ITextValidateProvider? Provider
         get => _provider;
         set
         {
+            if (_provider is { })
+            {
+                _provider.TextChanged -= ProviderOnTextChanged;
+            }
+
             _provider = value;
 
+            if (_provider is { })
+            {
+                _provider.TextChanged += ProviderOnTextChanged;
+                _lastKnownText = _provider.Text;
+            }
+
             if (_provider!.Fixed)
             {
                 // Add one so there is always a blank cell after the last editable character for the cursor.
@@ -110,6 +129,43 @@ public ITextValidateProvider? Provider
         }
     }
 
+    #region IValue Implementation
+
+    /// 
+    ///     Gets or sets the value. This is an alias for .
+    /// 
+    /// 
+    ///     This property enables  to be used with the  pattern
+    ///     for generic value access and command propagation.
+    /// 
+    public string? Value { get => Text; set => Text = value ?? string.Empty; }
+
+    /// 
+    public event EventHandler>? ValueChanging;
+
+    /// 
+    public event EventHandler>? ValueChanged;
+
+    /// 
+    public event EventHandler>? ValueChangedUntyped;
+
+    /// 
+    ///     Called when  is about to change.
+    ///     Allows derived classes to cancel the change.
+    /// 
+    /// The event arguments.
+    ///  to cancel the change; otherwise .
+    protected virtual bool OnValueChanging (ValueChangingEventArgs args) => false;
+
+    /// 
+    ///     Called when  has changed.
+    ///     Allows derived classes to react to value changes.
+    /// 
+    /// The event arguments.
+    protected virtual void OnValueChanged (ValueChangedEventArgs args) { }
+
+    #endregion
+
     /// Text
     public override string Text
     {
@@ -121,8 +177,44 @@ public override string Text
                 return;
             }
 
+            string oldValue = _provider.Text;
+
+            if (oldValue == value)
+            {
+                return;
+            }
+
+            if (!SuppressValueEvents)
+            {
+                ValueChangingEventArgs args = new (oldValue, value);
+
+                if (OnValueChanging (args) || args.Handled)
+                {
+                    return;
+                }
+
+                ValueChanging?.Invoke (this, args);
+
+                if (args.Handled)
+                {
+                    return;
+                }
+
+                // Allow subscribers to modify the new value
+                value = args.NewValue ?? string.Empty;
+            }
+
+            _lastKnownText = value;
             _provider.Text = value;
 
+            if (!SuppressValueEvents)
+            {
+                ValueChangedEventArgs changedArgs = new (oldValue, value);
+                OnValueChanged (changedArgs);
+                ValueChanged?.Invoke (this, changedArgs);
+                ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (oldValue, value));
+            }
+
             SetNeedsDraw ();
         }
     }
@@ -145,6 +237,67 @@ private int InsertionPoint
         }
     }
 
+    /// 
+    ///     Called when the provider's text changes through user input (InsertAt/Delete).
+    ///     The base implementation raises  and  events
+    ///     following the Cancellable Work Pattern.
+    /// 
+    /// The text before the change.
+    /// The text after the change.
+    protected virtual void HandleProviderTextChanged (string oldText, string newText)
+    {
+        ValueChangingEventArgs args = new (oldText, newText);
+
+        if (OnValueChanging (args) || args.Handled)
+        {
+            RevertProviderText (oldText);
+
+            return;
+        }
+
+        ValueChanging?.Invoke (this, args);
+
+        if (args.Handled)
+        {
+            RevertProviderText (oldText);
+
+            return;
+        }
+
+        ValueChangedEventArgs changedArgs = new (oldText, newText);
+        OnValueChanged (changedArgs);
+        ValueChanged?.Invoke (this, changedArgs);
+        ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (oldText, newText));
+    }
+
+    private void ProviderOnTextChanged (object? sender, EventArgs e)
+    {
+        if (SuppressValueEvents)
+        {
+            return;
+        }
+
+        string currentText = _provider!.Text;
+
+        if (_lastKnownText == currentText)
+        {
+            return;
+        }
+
+        HandleProviderTextChanged (_lastKnownText, currentText);
+
+        // Sync _lastKnownText with actual provider state (may have been reverted by handler)
+        _lastKnownText = _provider.Text;
+    }
+
+    private void RevertProviderText (string oldText)
+    {
+        SuppressValueEvents = true;
+        _provider!.Text = oldText;
+        SuppressValueEvents = false;
+        SetNeedsDraw ();
+    }
+
     /// 
     protected override bool OnMouseEvent (Mouse mouse)
     {
@@ -214,6 +367,7 @@ protected override bool OnDrawingContent (DrawContext? context)
         {
             return true;
         }
+
         SetAttributeForRole (VisualRole.Focus);
         Move (InsertionPoint + marginLeft, 0);
         AddRune ((Rune)_provider.DisplayText [InsertionPoint]);
@@ -242,10 +396,10 @@ protected override bool OnKeyDownNotHandled (Key key)
         {
             return false;
         }
+
         CursorRight ();
 
         return true;
-
     }
 
     /// Delete char at cursor position - 1, moving the cursor.
diff --git a/Terminal.Gui/Views/TextInput/TimeEditor.cs b/Terminal.Gui/Views/TextInput/TimeEditor.cs
index b26f13e9b9..4251e97919 100644
--- a/Terminal.Gui/Views/TextInput/TimeEditor.cs
+++ b/Terminal.Gui/Views/TextInput/TimeEditor.cs
@@ -35,17 +35,17 @@ namespace Terminal.Gui.Views;
 ///         TimeEditor timeEditor = new () { Value = TimeSpan.FromHours (14.5) };
 ///         // en-US displays: " 2:30:00 PM"
 ///         // en-GB displays: " 14:30:00"
-///         
+/// 
 ///         // Use specific culture's format
 ///         timeEditor.Format = CultureInfo.GetCultureInfo ("de-DE").DateTimeFormat;
 ///         // Displays: " 14:30:00"
-///         
+/// 
 ///         // Want short time? Modify the LongTimePattern
 ///         DateTimeFormatInfo format = (DateTimeFormatInfo)CultureInfo.CurrentCulture.DateTimeFormat.Clone ();
 ///         format.LongTimePattern = format.ShortTimePattern;
 ///         timeEditor.Format = format;
 ///         // en-US displays: " 2:30 PM"
-///         
+/// 
 ///         // Custom pattern with milliseconds
 ///         DateTimeFormatInfo customFormat = (DateTimeFormatInfo)CultureInfo.CurrentCulture.DateTimeFormat.Clone ();
 ///         customFormat.LongTimePattern = "HH:mm:ss.fff";
@@ -57,7 +57,8 @@ namespace Terminal.Gui.Views;
 public class TimeEditor : TextValidateField, IValue, IDesignable
 {
     private TimeTextProvider TimeProvider => (TimeTextProvider)Provider!;
-    private TimeSpan _lastKnownValue;
+
+    private TimeSpan _value;
 
     /// 
     ///     Initializes a new instance of the  class.
@@ -68,12 +69,7 @@ public TimeEditor ()
 
         // Add one so there is always a blank cell after the last editable character for the cursor.
         Width = Dim.Auto (minimumContentDim: Provider!.DisplayText.Length + 1);
-
-        // Subscribe to provider's text changed to raise our value events
-        TimeProvider.TextChanged += (_, _) => RaiseValueChangedEvents ();
-
-        // Initialize last known value
-        _lastKnownValue = TimeProvider.TimeValue;
+        _value = TimeProvider.TimeValue;
     }
 
     /// 
@@ -101,71 +97,61 @@ public DateTimeFormatInfo Format
         }
     }
 
+    #region IValue Implementation
+
     /// 
     ///     Gets or sets the current time value.
     /// 
     /// 
     ///     
-    ///         Setting this property raises  (cancellable) and  events.
+    ///         Setting this property follows the Cancellable Work Pattern (CWP) using
+    ///         .
     ///         The change can be prevented by handling  and setting
     ///          to .
     ///     
     /// 
-    public TimeSpan Value
+    public new TimeSpan Value
     {
-        get => TimeProvider.TimeValue;
-        set
-        {
-            TimeSpan oldValue = TimeProvider.TimeValue;
-
-            if (oldValue == value)
-            {
-                return;
-            }
-
-            ValueChangingEventArgs changingArgs = new (oldValue, value);
-
-            if (OnValueChanging (changingArgs) || changingArgs.Handled)
-            {
-                return;
-            }
-
-            ValueChanging?.Invoke (this, changingArgs);
-
-            if (changingArgs.Handled)
-            {
-                return;
-            }
-
-            // Update _lastKnownValue before setting to prevent double-firing from TextChanged handler
-            _lastKnownValue = value;
-
-            TimeProvider.TimeValue = value;
-            Text = TimeProvider.Text;
-
-            ValueChangedEventArgs changedArgs = new (oldValue, value);
-            OnValueChanged (changedArgs);
-            ValueChanged?.Invoke (this, changedArgs);
-            ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (oldValue, value));
-
-            SetNeedsDraw ();
-        }
+        get => _value;
+        set =>
+            CWPPropertyHelper.ChangeProperty (this,
+                                              ref _value,
+                                              value,
+                                              OnValueChanging,
+                                              ValueChanging,
+                                              newValue =>
+                                              {
+                                                  SuppressValueEvents = true;
+                                                  TimeProvider.TimeValue = newValue;
+                                                  base.Text = TimeProvider.Text;
+                                                  SuppressValueEvents = false;
+                                                  SetNeedsDraw ();
+                                              },
+                                              OnValueChanged,
+                                              ValueChanged,
+                                              out _);
     }
 
     /// 
-    public event EventHandler>? ValueChanging;
+    public new event EventHandler>? ValueChanging;
 
     /// 
-    public event EventHandler>? ValueChanged;
+    public new event EventHandler>? ValueChanged;
 
     /// 
-    public event EventHandler>? ValueChangedUntyped;
+    public new event EventHandler>? ValueChangedUntyped;
 
     /// 
-    object IValue.GetValue () => Value;
+    object? IValue.GetValue () => Value;
+
+    /// 
+    ///     Synchronizes the  backing field when the base class
+    ///      property changes programmatically.
+    /// 
+    protected override void OnValueChanged (ValueChangedEventArgs args) => _value = TimeProvider.TimeValue;
 
     /// 
-    ///     Called when the  is changing.
+    ///     Called when the  is about to change.
     ///     Allows derived classes to cancel the change.
     /// 
     /// The event arguments.
@@ -177,57 +163,63 @@ public TimeSpan Value
     ///     Allows derived classes to react to value changes.
     /// 
     /// The event arguments.
-    protected virtual void OnValueChanged (ValueChangedEventArgs args) { }
+    protected virtual void OnValueChanged (ValueChangedEventArgs args) =>
+        ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (args.OldValue, args.NewValue));
 
-    /// 
-    bool IDesignable.EnableForDesign ()
-    {
-        Value = new TimeSpan (14, 30, 0);
-
-        return true;
-    }
+    #endregion
 
     /// 
-    ///     Raises value events when the text changes through user input.
+    ///     Handles provider text changes for TimeEditor by raising -typed
+    ///     value events instead of string-typed events.
     /// 
-    private void RaiseValueChangedEvents ()
+    protected override void HandleProviderTextChanged (string oldText, string newText)
     {
-        TimeSpan currentValue = TimeProvider.TimeValue;
+        TimeSpan newTimeValue = TimeProvider.TimeValue;
 
-        if (_lastKnownValue == currentValue)
+        if (_value == newTimeValue)
         {
             return;
         }
 
-        // Raise ValueChanging to allow cancellation
-        ValueChangingEventArgs changingArgs = new (_lastKnownValue, currentValue);
+        TimeSpan oldTimeValue = _value;
+        ValueChangingEventArgs args = new (oldTimeValue, newTimeValue);
 
-        if (OnValueChanging (changingArgs) || changingArgs.Handled)
+        if (OnValueChanging (args) || args.Handled)
         {
-            // Revert the change if cancelled
-            TimeProvider.TimeValue = _lastKnownValue;
-            Text = TimeProvider.Text;
-            SetNeedsDraw ();
+            RevertTimeValue (oldTimeValue);
 
             return;
         }
 
-        ValueChanging?.Invoke (this, changingArgs);
+        ValueChanging?.Invoke (this, args);
 
-        if (changingArgs.Handled)
+        if (args.Handled)
         {
-            // Revert the change if cancelled
-            TimeProvider.TimeValue = _lastKnownValue;
-            Text = TimeProvider.Text;
-            SetNeedsDraw ();
+            RevertTimeValue (oldTimeValue);
 
             return;
         }
 
-        ValueChangedEventArgs changedArgs = new (_lastKnownValue, currentValue);
-        _lastKnownValue = currentValue;
+        _value = newTimeValue;
+        ValueChangedEventArgs changedArgs = new (oldTimeValue, newTimeValue);
         OnValueChanged (changedArgs);
         ValueChanged?.Invoke (this, changedArgs);
-        ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (_lastKnownValue, currentValue));
+    }
+
+    private void RevertTimeValue (TimeSpan oldValue)
+    {
+        SuppressValueEvents = true;
+        TimeProvider.TimeValue = oldValue;
+        base.Text = TimeProvider.Text;
+        SuppressValueEvents = false;
+        SetNeedsDraw ();
+    }
+
+    /// 
+    bool IDesignable.EnableForDesign ()
+    {
+        Value = new TimeSpan (14, 30, 0);
+
+        return true;
     }
 }

From 71c62dd008b3676eace07c97979e21bdd6182295 Mon Sep 17 00:00:00 2001
From: Tig 
Date: Sun, 8 Mar 2026 11:55:09 -0600
Subject: [PATCH 19/26] Replace DateField with new DateEditor based on
 TextValidateField

Add DateTextProvider (ITextValidateProvider for dates with culture-aware
formatting, pattern normalization, separator handling) and DateEditor
(extends TextValidateField, implements IValue with CWP).
Remove legacy DateField and update all usages in DatePicker, scenarios,
docs, and indexes. Full test coverage in DateEditorTests.

Co-Authored-By: Claude Opus 4.6 
---
 .tg-docs/INDEX.md                             |   2 +-
 AGENTS.md                                     |   2 +-
 .../UICatalog/Scenarios/TextInputControls.cs  |  30 +-
 Examples/UICatalog/Scenarios/TimeAndDate.cs   | 117 +--
 Terminal.Gui/Views/DatePicker.cs              |  54 +-
 Terminal.Gui/Views/TextInput/DateEditor.cs    | 219 +++++
 Terminal.Gui/Views/TextInput/DateField.cs     | 789 ----------------
 .../Views/TextInput/DateTextProvider.cs       | 433 +++++++++
 Tests/UnitTests/Views/DatePickerTests.cs      |   4 +-
 .../Views/DateEditorTests.cs                  | 842 ++++++++++++++++++
 .../Views/DateFieldTests.cs                   | 369 --------
 docfx/docs/events.md                          |   2 +-
 docfx/docs/views.md                           |   4 +-
 13 files changed, 1594 insertions(+), 1273 deletions(-)
 create mode 100644 Terminal.Gui/Views/TextInput/DateEditor.cs
 delete mode 100644 Terminal.Gui/Views/TextInput/DateField.cs
 create mode 100644 Terminal.Gui/Views/TextInput/DateTextProvider.cs
 create mode 100644 Tests/UnitTestsParallelizable/Views/DateEditorTests.cs
 delete mode 100644 Tests/UnitTestsParallelizable/Views/DateFieldTests.cs

diff --git a/.tg-docs/INDEX.md b/.tg-docs/INDEX.md
index b368600a92..666f941308 100644
--- a/.tg-docs/INDEX.md
+++ b/.tg-docs/INDEX.md
@@ -82,7 +82,7 @@ Instead of embedding descriptions, it points to actual source files that agents
 |Views/SpinnerView:{SpinnerStyle.cs,SpinnerView.cs}
 |Views/TableView:{CellActivatedEventArgs.cs,CellColorGetterArgs.cs,CellToggledEventArgs.cs,CheckBoxTableSourceWrapper.cs,CheckBoxTableSourceWrapperByIndex.cs,CheckBoxTableSourceWrapperByObject.cs,ColumnStyle.cs,DataTableSource.cs,EnumerableTableSource.cs,IEnumerableTableSource.cs,ITableSource.cs,ListColumnStyle.cs,ListTableSource.cs,RowColorGetterArgs.cs,SelectedCellChangedEventArgs.cs,TableSelection.cs,TableStyle.cs,TableView.CellMapping.cs,TableView.cs,TableView.Drawing.cs,TableView.Mouse.cs,TableView.Navigation.cs,TableView.Selection.cs,TreeTableSource.cs}
 |Views/TabView:{Tab.cs,TabChangedEventArgs.cs,TabMouseEventArgs.cs,TabRow.cs,TabStyle.cs,TabView.cs}
-|Views/TextInput:{ContentsChangedEventArgs.cs,DateField.cs,HistoryText.cs,HistoryTextItemEventArgs.cs,ITextValidateProvider.cs,NetMaskedTextProvider.cs,TextEditingLineStatus.cs,TextModel.cs,TextRegexProvider.cs,TextValidateField.cs,TimeEditor.cs,TimeTextProvider.cs}
+|Views/TextInput:{ContentsChangedEventArgs.cs,DateEditor.cs,DateTextProvider.cs,HistoryText.cs,HistoryTextItemEventArgs.cs,ITextValidateProvider.cs,NetMaskedTextProvider.cs,TextEditingLineStatus.cs,TextModel.cs,TextRegexProvider.cs,TextValidateField.cs,TimeEditor.cs,TimeTextProvider.cs}
 |Views/TextInput/TextField:{TextField.Commands.cs,TextField.cs,TextField.Drawing.cs,TextField.History.cs,TextField.Keyboard.cs,TextField.Mouse.cs,TextField.Selection.cs,TextField.Text.cs,TextFieldAutocomplete.cs}
 |Views/TextInput/TextView:{TextView.Commands.cs,TextView.cs,TextView.Drawing.cs,TextView.Files.cs,TextView.Find.cs,TextView.History.cs,TextView.Keyboard.cs,TextView.Mouse.cs,TextView.Movement.cs,TextView.Scrolling.cs,TextView.Selection.cs,TextView.Text.cs,TextView.WordWrap.cs,TextViewAutocomplete.cs,WordWrapManager.cs}
 |Views/TreeView:{AspectGetterDelegate.cs,Branch.cs,DelegateTreeBuilder.cs,DrawTreeViewLineEventArgs.cs,ITreeBuilder.cs,ITreeViewFilter.cs,ObjectActivatedEventArgs.cs,SelectionChangedEventArgs.cs,TreeBuilder.cs,TreeNode.cs,TreeNodeBuilder.cs,TreeStyle.cs,TreeView.cs,TreeViewCollectionNavigatorMatcher.cs,TreeViewTextFilter.cs}
diff --git a/AGENTS.md b/AGENTS.md
index ff84951f3f..e97bcd3c28 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -249,7 +249,7 @@ See `.claude/cookbook/` for common UI patterns:
 |Views/SpinnerView:{SpinnerStyle.cs,SpinnerView.cs}
 |Views/TableView:{CellActivatedEventArgs.cs,CellColorGetterArgs.cs,CellToggledEventArgs.cs,CheckBoxTableSourceWrapper.cs,CheckBoxTableSourceWrapperByIndex.cs,CheckBoxTableSourceWrapperByObject.cs,ColumnStyle.cs,DataTableSource.cs,EnumerableTableSource.cs,IEnumerableTableSource.cs,ITableSource.cs,ListColumnStyle.cs,ListTableSource.cs,RowColorGetterArgs.cs,SelectedCellChangedEventArgs.cs,TableSelection.cs,TableStyle.cs,TableView.CellMapping.cs,TableView.cs,TableView.Drawing.cs,TableView.Mouse.cs,TableView.Navigation.cs,TableView.Selection.cs,TreeTableSource.cs}
 |Views/TabView:{Tab.cs,TabChangedEventArgs.cs,TabMouseEventArgs.cs,TabRow.cs,TabStyle.cs,TabView.cs}
-|Views/TextInput:{ContentsChangedEventArgs.cs,DateField.cs,HistoryText.cs,HistoryTextItemEventArgs.cs,ITextValidateProvider.cs,NetMaskedTextProvider.cs,TextEditingLineStatus.cs,TextModel.cs,TextRegexProvider.cs,TextValidateField.cs,TimeEditor.cs,TimeTextProvider.cs}
+|Views/TextInput:{ContentsChangedEventArgs.cs,DateEditor.cs,DateTextProvider.cs,HistoryText.cs,HistoryTextItemEventArgs.cs,ITextValidateProvider.cs,NetMaskedTextProvider.cs,TextEditingLineStatus.cs,TextModel.cs,TextRegexProvider.cs,TextValidateField.cs,TimeEditor.cs,TimeTextProvider.cs}
 |Views/TextInput/TextField:{TextField.Commands.cs,TextField.cs,TextField.Drawing.cs,TextField.History.cs,TextField.Keyboard.cs,TextField.Mouse.cs,TextField.Selection.cs,TextField.Text.cs,TextFieldAutocomplete.cs}
 |Views/TextInput/TextView:{TextView.Commands.cs,TextView.cs,TextView.Drawing.cs,TextView.Files.cs,TextView.Find.cs,TextView.History.cs,TextView.Keyboard.cs,TextView.Mouse.cs,TextView.Movement.cs,TextView.Scrolling.cs,TextView.Selection.cs,TextView.Text.cs,TextView.WordWrap.cs,TextViewAutocomplete.cs,WordWrapManager.cs}
 |Views/TreeView:{AspectGetterDelegate.cs,Branch.cs,DelegateTreeBuilder.cs,DrawTreeViewLineEventArgs.cs,ITreeBuilder.cs,ITreeViewFilter.cs,ObjectActivatedEventArgs.cs,SelectionChangedEventArgs.cs,TreeBuilder.cs,TreeNode.cs,TreeNodeBuilder.cs,TreeStyle.cs,TreeView.cs,TreeViewCollectionNavigatorMatcher.cs,TreeViewTextFilter.cs}
diff --git a/Examples/UICatalog/Scenarios/TextInputControls.cs b/Examples/UICatalog/Scenarios/TextInputControls.cs
index b47edd7d44..4cd06a7749 100644
--- a/Examples/UICatalog/Scenarios/TextInputControls.cs
+++ b/Examples/UICatalog/Scenarios/TextInputControls.cs
@@ -224,30 +224,30 @@ void TextViewDrawContent (object? sender, DrawEventArgs e) =>
                             };
         win.Add (labelMirroringHexEditor);
 
-        // DateField
-        label = new Label { Text = " _DateField:", Y = Pos.Bottom (hexEditor) + 1 };
+        // DateEditor
+        label = new Label { Text = "_DateEditor:", Y = Pos.Bottom (hexEditor) + 1 };
         win.Add (label);
 
-        DateField dateField = new (DateTime.Now) { X = Pos.Right (label) + 1, Y = Pos.Bottom (hexEditor) + 1, Width = 20 };
-        win.Add (dateField);
+        DateEditor dateEditor = new () { X = Pos.Right (label) + 1, Y = Pos.Bottom (hexEditor) + 1, Value = DateTime.Today };
+        win.Add (dateEditor);
 
-        Label labelMirroringDateField = new ()
+        Label labelMirroringDateEditor = new ()
         {
-            X = Pos.Right (dateField) + 1,
-            Y = Pos.Top (dateField),
-            Width = Dim.Width (dateField),
-            Height = Dim.Height (dateField),
-            Text = dateField.Text
+            X = Pos.Right (dateEditor) + 1,
+            Y = Pos.Top (dateEditor),
+            Width = Dim.Width (dateEditor),
+            Height = Dim.Height (dateEditor),
+            Text = dateEditor.Text
         };
-        win.Add (labelMirroringDateField);
+        win.Add (labelMirroringDateEditor);
 
-        dateField.TextChanged += (_, _) => { labelMirroringDateField.Text = dateField.Text; };
+        dateEditor.ValueChanged += (_, _) => { labelMirroringDateEditor.Text = dateEditor.Text; };
 
         // TimeEditor
-        label = new Label { Text = "T_imeEditor:", Y = Pos.Top (dateField), X = Pos.Right (labelMirroringDateField) + 5 };
+        label = new Label { Text = "T_imeEditor:", Y = Pos.Top (dateEditor), X = Pos.Right (labelMirroringDateEditor) + 5 };
         win.Add (label);
 
-        _timeEditor = new TimeEditor { X = Pos.Right (label) + 1, Y = Pos.Top (dateField), Value = DateTime.Now.TimeOfDay };
+        _timeEditor = new TimeEditor { X = Pos.Right (label) + 1, Y = Pos.Top (dateEditor), Value = DateTime.Now.TimeOfDay };
         win.Add (_timeEditor);
 
         _labelMirroringTimeEditor = new Label
@@ -264,7 +264,7 @@ void TextViewDrawContent (object? sender, DrawEventArgs e) =>
 
         // MaskedTextProvider - uses .NET MaskedTextProvider
         NetMaskedTextProvider netProvider = new ("+99 (000) 000-0000");
-        Label netProviderLabel = new () { X = Pos.Left (dateField), Y = Pos.Bottom (dateField) + 1, Text = $"_NetMaskedTextProvider ({netProvider.Mask}):" };
+        Label netProviderLabel = new () { X = Pos.Left (dateEditor), Y = Pos.Bottom (dateEditor) + 1, Text = $"_NetMaskedTextProvider ({netProvider.Mask}):" };
         win.Add (netProviderLabel);
 
         TextValidateField netProviderField = new () { X = Pos.Right (netProviderLabel) + 1, Y = Pos.Y (netProviderLabel), Provider = netProvider };
diff --git a/Examples/UICatalog/Scenarios/TimeAndDate.cs b/Examples/UICatalog/Scenarios/TimeAndDate.cs
index 3ed06308b2..3492ec2691 100644
--- a/Examples/UICatalog/Scenarios/TimeAndDate.cs
+++ b/Examples/UICatalog/Scenarios/TimeAndDate.cs
@@ -4,14 +4,12 @@
 
 namespace UICatalog.Scenarios;
 
-[ScenarioMetadata ("Time And Date", "Illustrates TimeEditor and time & date handling")]
+[ScenarioMetadata ("Time And Date", "Illustrates TimeEditor, DateEditor, and time & date handling")]
 [ScenarioCategory ("Controls")]
 [ScenarioCategory ("DateTime")]
 public class TimeAndDate : Scenario
 {
-    private Label? _lblDateFmt;
-    private Label? _lblNewDate;
-    private Label? _lblOldDate;
+    private Label? _lblDateEditorValue;
     private Label? _lblTimeEditorValue;
 
     public override void Main ()
@@ -23,7 +21,7 @@ public override void Main ()
 
         using Window win = new () { Title = GetQuitKeyAndName () };
 
-        // TimeEditor examples
+        // ── TimeEditor examples ──────────────────────────────────────
         Label teLabel = new ()
         {
             X = Pos.Center (),
@@ -93,82 +91,101 @@ public override void Main ()
         };
         win.Add (shortPatternLabel);
 
-        DateField shortDate = new (DateTime.Now)
-        {
-            X = Pos.Center (), Y = Pos.Bottom (shortTimeEditor) + 1, ReadOnly = true
-        };
-        shortDate.ValueChanged += DateChanged;
-        win.Add (shortDate);
-
-        DateField longDate = new (DateTime.Now)
-        {
-            X = Pos.Center (), Y = Pos.Bottom (shortDate) + 1, ReadOnly = false
-        };
-        longDate.ValueChanged += DateChanged;
-        win.Add (longDate);
-
         _lblTimeEditorValue = new ()
         {
             X = Pos.Center (),
-            Y = Pos.Bottom (longDate) + 1,
+            Y = Pos.Bottom (shortTimeEditor) + 1,
             TextAlignment = Alignment.Center,
-
             Width = Dim.Fill (),
             Text = "TimeEditor Value: "
         };
         win.Add (_lblTimeEditorValue);
 
-        _lblOldDate = new ()
+        // ── DateEditor examples ──────────────────────────────────────
+        Label deLabel = new ()
         {
             X = Pos.Center (),
-            Y = Pos.Bottom (_lblTimeEditorValue) + 1,
-            TextAlignment = Alignment.Center,
-
-            Width = Dim.Fill (),
-            Text = "Old Date: "
+            Y = Pos.Bottom (_lblTimeEditorValue) + 2,
+            Text = "DateEditor (based on TextValidateField):"
         };
-        win.Add (_lblOldDate);
+        win.Add (deLabel);
 
-        _lblNewDate = new ()
+        // Default culture date editor
+        DateEditor defaultDateEditor = new ()
         {
             X = Pos.Center (),
-            Y = Pos.Bottom (_lblOldDate) + 1,
-            TextAlignment = Alignment.Center,
+            Y = Pos.Bottom (deLabel),
+            Value = DateTime.Today
+        };
+        defaultDateEditor.ValueChanged += DateEditorChanged;
+        win.Add (defaultDateEditor);
 
-            Width = Dim.Fill (),
-            Text = "New Date: "
+        Label defaultDatePatternLabel = new ()
+        {
+            X = Pos.Right (defaultDateEditor) + 1,
+            Y = Pos.Top (defaultDateEditor),
+            Text = $"Pattern: {CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern}"
         };
-        win.Add (_lblNewDate);
+        win.Add (defaultDatePatternLabel);
+
+        // US format date editor
+        DateTimeFormatInfo usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
 
-        _lblDateFmt = new ()
+        DateEditor usDateEditor = new ()
         {
             X = Pos.Center (),
-            Y = Pos.Bottom (_lblNewDate) + 1,
-            TextAlignment = Alignment.Center,
+            Y = Pos.Bottom (defaultDateEditor) + 1,
+            Value = DateTime.Today,
+            Format = usFormat
+        };
+        usDateEditor.ValueChanged += DateEditorChanged;
+        win.Add (usDateEditor);
 
-            Width = Dim.Fill (),
-            Text = "Date Format: "
+        Label usDatePatternLabel = new ()
+        {
+            X = Pos.Right (usDateEditor) + 1,
+            Y = Pos.Top (usDateEditor),
+            Text = $"Pattern: {usFormat.ShortDatePattern}"
+        };
+        win.Add (usDatePatternLabel);
+
+        // German format date editor
+        DateTimeFormatInfo deFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("de-DE").DateTimeFormat.Clone ();
+
+        DateEditor germanDateEditor = new ()
+        {
+            X = Pos.Center (),
+            Y = Pos.Bottom (usDateEditor) + 1,
+            Value = DateTime.Today,
+            Format = deFormat
         };
-        win.Add (_lblDateFmt);
+        germanDateEditor.ValueChanged += DateEditorChanged;
+        win.Add (germanDateEditor);
 
-        Button swapButton = new ()
+        Label germanDatePatternLabel = new ()
         {
-            X = Pos.Center (), Y = Pos.Bottom (win) - 5, Text = "Swap Date Read/Read Only"
+            X = Pos.Right (germanDateEditor) + 1,
+            Y = Pos.Top (germanDateEditor),
+            Text = $"Pattern: {deFormat.ShortDatePattern}"
         };
+        win.Add (germanDatePatternLabel);
 
-        swapButton.Accepting += (_, _) =>
-                             {
-                                 longDate.ReadOnly = !longDate.ReadOnly;
-                                 shortDate.ReadOnly = !shortDate.ReadOnly;
-                             };
-        win.Add (swapButton);
+        _lblDateEditorValue = new ()
+        {
+            X = Pos.Center (),
+            Y = Pos.Bottom (germanDateEditor) + 1,
+            TextAlignment = Alignment.Center,
+            Width = Dim.Fill (),
+            Text = "DateEditor Value: "
+        };
+        win.Add (_lblDateEditorValue);
 
         app.Run (win);
     }
 
-    private void DateChanged (object? sender, ValueChangedEventArgs e)
+    private void DateEditorChanged (object? sender, ValueChangedEventArgs e)
     {
-        _lblNewDate!.Text = $"New Date: {e.NewValue}";
+        _lblDateEditorValue!.Text = $"DateEditor Value: {e.NewValue:d}";
     }
 
     private void TimeEditorChanged (object? sender, ValueChangedEventArgs e)
diff --git a/Terminal.Gui/Views/DatePicker.cs b/Terminal.Gui/Views/DatePicker.cs
index e92835f9e2..140b5237d6 100644
--- a/Terminal.Gui/Views/DatePicker.cs
+++ b/Terminal.Gui/Views/DatePicker.cs
@@ -14,7 +14,7 @@ public class DatePicker : View, IValue
 {
     private TableView? _calendar;
     private DateTime _date;
-    private DateField? _dateField;
+    private DateEditor? _dateEditor;
     private Label? _dateLabel;
     private Button? _nextMonthButton;
     private Button? _previousMonthButton;
@@ -118,14 +118,14 @@ protected virtual void OnValueChanged (ValueChangedEventArgs args) { }
 
     #endregion
 
-    private string Format => StandardizeDateFormat (Culture?.DateTimeFormat.ShortDatePattern);
+    private string Format => DateTextProvider.NormalizePattern (Culture?.DateTimeFormat.ShortDatePattern ?? "MM/dd/yyyy");
 
     /// 
     protected override void Dispose (bool disposing)
     {
         _dateLabel?.Dispose ();
         _calendar?.Dispose ();
-        _dateField?.Dispose ();
+        _dateEditor?.Dispose ();
         _table?.Dispose ();
         _previousMonthButton?.Dispose ();
         _nextMonthButton?.Dispose ();
@@ -135,7 +135,7 @@ protected override void Dispose (bool disposing)
     private void ChangeDayDate (int day)
     {
         Value = new DateTime (Value.Year, Value.Month, day);
-        _dateField!.Value = Value;
+        _dateEditor!.Value = Value;
         CreateCalendar ();
     }
 
@@ -173,7 +173,7 @@ private DataTable CreateDataTable (int month, int year)
         return _table;
     }
 
-    private void DateField_ValueChanged (object? sender, ValueChangedEventArgs e)
+    private void DateEditor_ValueChanged (object? sender, ValueChangedEventArgs e)
     {
         if (!e.NewValue.HasValue)
         {
@@ -267,14 +267,14 @@ private void SetInitialProperties (DateTime date)
             MultiSelect = false,
         };
 
-        _dateField = new DateField (DateTime.Now)
+        _dateEditor = new DateEditor ()
         {
-            Id = "_dateField",
+            Id = "_dateEditor",
             X = Pos.Right (_dateLabel),
             Y = 0,
             Width = Dim.Width (_calendar) - Dim.Width (_dateLabel),
             Height = 1,
-            Culture = Culture
+            Value = DateTime.Now
         };
 
         _previousMonthButton = new Button
@@ -328,51 +328,19 @@ private void SetInitialProperties (DateTime date)
         Width = Dim.Auto (DimAutoStyle.Content);
         Height = Dim.Auto (DimAutoStyle.Content);
 
-        _dateField.ValueChanged += DateField_ValueChanged;
+        _dateEditor.ValueChanged += DateEditor_ValueChanged;
 
-        Add (_dateLabel, _dateField, _calendar, _previousMonthButton, _nextMonthButton);
+        Add (_dateLabel, _dateEditor, _calendar, _previousMonthButton, _nextMonthButton);
     }
 
     private void AdjustMonth (int offset)
     {
         Value = Value.AddMonths (offset);
         CreateCalendar ();
-        _dateField!.Value = Value;
+        _dateEditor!.Value = Value;
     }
 
     /// 
     protected override bool OnDrawingText () => true;
 
-    private static string StandardizeDateFormat (string? format) =>
-        format switch
-        {
-            "MM/dd/yyyy" => "MM/dd/yyyy",
-            "yyyy-MM-dd" => "yyyy-MM-dd",
-            "yyyy/MM/dd" => "yyyy/MM/dd",
-            "dd/MM/yyyy" => "dd/MM/yyyy",
-            "d?/M?/yyyy" => "dd/MM/yyyy",
-            "dd.MM.yyyy" => "dd.MM.yyyy",
-            "dd-MM-yyyy" => "dd-MM-yyyy",
-            "dd/MM yyyy" => "dd/MM/yyyy",
-            "d. M. yyyy" => "dd.MM.yyyy",
-            "yyyy.MM.dd" => "yyyy.MM.dd",
-            "g yyyy/M/d" => "yyyy/MM/dd",
-            "d/M/yyyy" => "dd/MM/yyyy",
-            "d?/M?/yyyy g" => "dd/MM/yyyy",
-            "d-M-yyyy" => "dd-MM-yyyy",
-            "d.MM.yyyy" => "dd.MM.yyyy",
-            "d.MM.yyyy '?'." => "dd.MM.yyyy",
-            "M/d/yyyy" => "MM/dd/yyyy",
-            "d. M. yyyy." => "dd.MM.yyyy",
-            "d.M.yyyy." => "dd.MM.yyyy",
-            "g yyyy-MM-dd" => "yyyy-MM-dd",
-            "d.M.yyyy" => "dd.MM.yyyy",
-            "d/MM/yyyy" => "dd/MM/yyyy",
-            "yyyy/M/d" => "yyyy/MM/dd",
-            "dd. MM. yyyy." => "dd.MM.yyyy",
-            "yyyy. MM. dd." => "yyyy.MM.dd",
-            "yyyy. M. d." => "yyyy.MM.dd",
-            "d. MM. yyyy" => "dd.MM.yyyy",
-            _ => "dd/MM/yyyy"
-        };
 }
diff --git a/Terminal.Gui/Views/TextInput/DateEditor.cs b/Terminal.Gui/Views/TextInput/DateEditor.cs
new file mode 100644
index 0000000000..f7ee50426f
--- /dev/null
+++ b/Terminal.Gui/Views/TextInput/DateEditor.cs
@@ -0,0 +1,219 @@
+using System.Globalization;
+
+namespace Terminal.Gui.Views;
+
+/// 
+///     Provides date editing functionality using  with culture-aware formatting.
+/// 
+/// 
+///     
+///         DateEditor extends  with date-specific functionality:
+///         
+///             
+///                 Uses  for validation and formatting
+///             
+///             
+///                 
+///                     Supports culture-specific date formats via 
+///                 
+///             
+///             
+///                 Cursor automatically skips over separator characters
+///             
+///             
+///                 Auto-adjusts width based on date pattern
+///             
+///         
+///     
+///     
+///         Usage Examples:
+///         
+///         // Use default (current culture's short date pattern)
+///         DateEditor dateEditor = new () { Value = new DateTime (2024, 3, 15) };
+///         // en-US displays: "03/15/2024"
+///         // en-GB displays: "15/03/2024"
+///
+///         // Use specific culture's format
+///         dateEditor.Format = CultureInfo.GetCultureInfo ("de-DE").DateTimeFormat;
+///         // Displays: "15.03.2024"
+///         
+///     
+/// 
+public class DateEditor : TextValidateField, IValue, IDesignable
+{
+    private DateTextProvider DateProvider => (DateTextProvider)Provider!;
+
+    private DateTime? _value;
+
+    /// 
+    ///     Initializes a new instance of the  class.
+    /// 
+    public DateEditor ()
+    {
+        Provider = new DateTextProvider ();
+
+        // Add one so there is always a blank cell after the last editable character for the cursor.
+        Width = Dim.Auto (minimumContentDim: Provider!.DisplayText.Length + 1);
+        _value = DateProvider.DateValue;
+    }
+
+    /// 
+    ///     Gets or sets the  used for date formatting.
+    /// 
+    /// 
+    ///     
+    ///         The editor uses  to determine the display format.
+    ///     
+    ///     
+    ///         The width automatically adjusts when the format changes to accommodate the new pattern.
+    ///     
+    /// 
+    public DateTimeFormatInfo Format
+    {
+        get => DateProvider.Format;
+        set
+        {
+            DateProvider.Format = value;
+
+            // Add one so there is always a blank cell after the last editable character for the cursor.
+            Width = DateProvider.DisplayText.Length + 1;
+            SetNeedsDraw ();
+        }
+    }
+
+    #region IValue Implementation
+
+    /// 
+    ///     Gets or sets the current date value.
+    /// 
+    /// 
+    ///     
+    ///         Setting this property follows the Cancellable Work Pattern (CWP) using
+    ///         .
+    ///         The change can be prevented by handling  and setting
+    ///          to .
+    ///     
+    /// 
+    public new DateTime? Value
+    {
+        get => _value;
+        set =>
+            CWPPropertyHelper.ChangeProperty (this,
+                                              ref _value,
+                                              value,
+                                              OnValueChanging,
+                                              ValueChanging,
+                                              newValue =>
+                                              {
+                                                  SuppressValueEvents = true;
+
+                                                  if (newValue.HasValue)
+                                                  {
+                                                      DateProvider.DateValue = newValue.Value;
+                                                  }
+
+                                                  base.Text = DateProvider.Text;
+                                                  SuppressValueEvents = false;
+                                                  SetNeedsDraw ();
+                                              },
+                                              OnValueChanged,
+                                              ValueChanged,
+                                              out _);
+    }
+
+    /// 
+    public new event EventHandler>? ValueChanging;
+
+    /// 
+    public new event EventHandler>? ValueChanged;
+
+    /// 
+    public new event EventHandler>? ValueChangedUntyped;
+
+    /// 
+    object? IValue.GetValue () => Value;
+
+    /// 
+    ///     Synchronizes the  backing field when the base class
+    ///      property changes programmatically.
+    /// 
+    protected override void OnValueChanged (ValueChangedEventArgs args) => _value = DateProvider.DateValue;
+
+    /// 
+    ///     Called when the  is about to change.
+    ///     Allows derived classes to cancel the change.
+    /// 
+    /// The event arguments.
+    ///  to cancel the change; otherwise .
+    protected virtual bool OnValueChanging (ValueChangingEventArgs args) => false;
+
+    /// 
+    ///     Called when the  has changed.
+    ///     Allows derived classes to react to value changes.
+    /// 
+    /// The event arguments.
+    protected virtual void OnValueChanged (ValueChangedEventArgs args) =>
+        ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (args.OldValue, args.NewValue));
+
+    #endregion
+
+    /// 
+    ///     Handles provider text changes for DateEditor by raising -typed
+    ///     value events instead of string-typed events.
+    /// 
+    protected override void HandleProviderTextChanged (string oldText, string newText)
+    {
+        DateTime? newDateValue = DateProvider.DateValue;
+
+        if (_value == newDateValue)
+        {
+            return;
+        }
+
+        DateTime? oldDateValue = _value;
+        ValueChangingEventArgs args = new (oldDateValue, newDateValue);
+
+        if (OnValueChanging (args) || args.Handled)
+        {
+            RevertDateValue (oldDateValue);
+
+            return;
+        }
+
+        ValueChanging?.Invoke (this, args);
+
+        if (args.Handled)
+        {
+            RevertDateValue (oldDateValue);
+
+            return;
+        }
+
+        _value = newDateValue;
+        ValueChangedEventArgs changedArgs = new (oldDateValue, newDateValue);
+        OnValueChanged (changedArgs);
+        ValueChanged?.Invoke (this, changedArgs);
+    }
+
+    private void RevertDateValue (DateTime? oldValue)
+    {
+        SuppressValueEvents = true;
+
+        if (oldValue.HasValue)
+        {
+            DateProvider.DateValue = oldValue.Value;
+        }
+
+        base.Text = DateProvider.Text;
+        SuppressValueEvents = false;
+        SetNeedsDraw ();
+    }
+
+    /// 
+    bool IDesignable.EnableForDesign ()
+    {
+        Value = new DateTime (2024, 1, 15);
+
+        return true;
+    }
+}
diff --git a/Terminal.Gui/Views/TextInput/DateField.cs b/Terminal.Gui/Views/TextInput/DateField.cs
deleted file mode 100644
index 9635f3bdd2..0000000000
--- a/Terminal.Gui/Views/TextInput/DateField.cs
+++ /dev/null
@@ -1,789 +0,0 @@
-using System.Globalization;
-
-namespace Terminal.Gui.Views;
-
-/// 
-///     Provides date editing functionality with specialized cursor behavior for date entry.
-/// 
-/// 
-///     
-///         DateField extends  with date-specific cursor behavior:
-///         
-///             
-///                 Cursor positions are constrained to valid digit positions (skipping separators)
-///             
-///             
-///                 Position 0 is reserved for a leading space; valid cursor range is [1, FormatLength]
-///             
-///             
-///                 Numeric input replaces characters in-place rather than inserting
-///             
-///             
-///                 Delete operations replace digits with '0' rather than removing characters
-///             
-///         
-///     
-///     
-///         Cursor Position Model:
-///         
-///             
-///                 
-///                     : Inherited, but constrained by the override to [1,
-///                     FormatLength]
-///                 
-///             
-///             
-///                 : Adjusts cursor to skip over date separator characters
-///             
-///             
-///                 
-///                     /: Move cursor while
-///                     respecting separator positions
-///                 
-///             
-///         
-///     
-///     
-///         Example: For format "MM/dd/yyyy" with text " 01/15/2024":
-///         
-///             
-///                 Position 0: Leading space (not user-accessible)
-///             
-///             
-///                 Positions 1-2: Month digits (01)
-///             
-///             
-///                 Position 3: Separator '/' (cursor skips over)
-///             
-///             
-///                 Positions 4-5: Day digits (15)
-///             
-///             
-///                 Position 6: Separator '/' (cursor skips over)
-///             
-///             
-///                 Positions 7-10: Year digits (2024)
-///             
-///         
-///     
-/// 
-public class DateField : TextField, IValue
-{
-    /// 
-    ///     Unicode Right-to-Left Mark character, used to handle RTL date formats in some cultures.
-    ///     This character is stripped from display text to ensure consistent cursor positioning.
-    /// 
-    private const string RIGHT_TO_LEFT_MARK = "\u200f";
-
-    /// 
-    ///     The fixed width of the date field (12 characters: 1 leading space + 10 date characters + 1 trailing).
-    /// 
-    private readonly int _dateFieldLength = 12;
-
-    /// 
-    ///     The current date value being edited. Setting this updates the display text.
-    /// 
-    private DateTime? _date;
-
-    /// 
-    ///     The date format string with a leading space (e.g., " MM/dd/yyyy").
-    ///     The leading space provides a visual buffer and keeps cursor position 0 inaccessible.
-    /// 
-    private string? _format;
-
-    /// 
-    ///     The date separator character for the current culture (e.g., "/", "-", or ".").
-    ///     The cursor automatically skips over these positions during navigation.
-    /// 
-    private string? _separator;
-
-    /// Initializes a new instance of .
-    public DateField () : this (DateTime.MinValue) { }
-
-    /// Initializes a new instance of .
-    /// 
-    public DateField (DateTime date)
-    {
-        Width = _dateFieldLength;
-        SetInitialProperties (date);
-    }
-
-    private CultureInfo _culture = CultureInfo.CurrentCulture;
-
-    /// CultureInfo for date. The default is CultureInfo.CurrentCulture.
-    public CultureInfo? Culture
-    {
-        get => _culture;
-        set
-        {
-            _culture = value ?? CultureInfo.CurrentCulture;
-            _separator = GetDataSeparator (_culture.DateTimeFormat.DateSeparator);
-            _format = " " + StandardizeDateFormat (_culture.DateTimeFormat.ShortDatePattern);
-            Text = Value?.ToString (_format).Replace (RIGHT_TO_LEFT_MARK, "") ?? string.Empty;
-        }
-    }
-
-    /// 
-    ///     Gets or sets the cursor position within the date field, constrained to valid digit positions.
-    /// 
-    /// 
-    ///     The cursor position, clamped to the range [1, FormatLength]. Unlike ,
-    ///     position 0 is not accessible because it contains a leading space.
-    /// 
-    /// 
-    ///     
-    ///         This override constrains the cursor to valid editing positions within the date format:
-    ///         
-    ///             
-    ///                 Minimum position is 1 (first digit of the date)
-    ///             
-    ///             
-    ///                 Maximum position is FormatLength (last digit of the year)
-    ///             
-    ///         
-    ///     
-    ///     
-    ///         Note: This property only enforces bounds; it does not skip separator characters.
-    ///         Use  after setting to ensure the cursor is on a digit position.
-    ///     
-    /// 
-    /// 
-    public override int InsertionPoint { get => base.InsertionPoint; set => base.InsertionPoint = Math.Max (Math.Min (value, FormatLength), 1); }
-
-    /// 
-    ///     Gets the length of the date format string (excluding the leading space), which represents
-    ///     the maximum valid cursor position.
-    /// 
-    /// 
-    ///     
-    ///         For a standard 10-character date format like "MM/dd/yyyy", this returns 10.
-    ///         The valid cursor range is [1, FormatLength], where position 1 is the first digit
-    ///         and FormatLength is the last digit.
-    ///     
-    /// 
-    private int FormatLength => StandardizeDateFormat (_format).Trim ().Length;
-
-    #region IValue Implementation
-
-    /// Gets or sets the date value of the .
-    public new DateTime? Value
-    {
-        get => _date;
-        set
-        {
-            if (ReadOnly)
-            {
-                return;
-            }
-
-            DateTime? oldValue = _date;
-
-            if (oldValue == value)
-            {
-                return;
-            }
-
-            ValueChangingEventArgs changingArgs = new (oldValue, value);
-
-            if (OnValueChanging (changingArgs) || changingArgs.Handled)
-            {
-                return;
-            }
-
-            ValueChanging?.Invoke (this, changingArgs);
-
-            if (changingArgs.Handled)
-            {
-                return;
-            }
-
-            _date = value;
-
-            if (_format is null)
-            {
-                return;
-            }
-
-            Text = value?.ToString (" " + StandardizeDateFormat (_format.Trim ())).Replace (RIGHT_TO_LEFT_MARK, "") ?? string.Empty;
-
-            ValueChangedEventArgs changedArgs = new (oldValue, _date);
-            OnValueChanged (changedArgs);
-            ValueChanged?.Invoke (this, changedArgs);
-        }
-    }
-
-    /// 
-    object? IValue.GetValue () => _date;
-
-    /// 
-    ///     Called when the   is changing.
-    /// 
-    /// The event arguments containing old and new values.
-    ///  to cancel the change; otherwise .
-    protected virtual bool OnValueChanging (ValueChangingEventArgs args) => false;
-
-    /// 
-    public new event EventHandler>? ValueChanging;
-
-    /// 
-    ///     Called when the   has changed.
-    /// 
-    /// The event arguments containing old and new values.
-    protected virtual void OnValueChanged (ValueChangedEventArgs args) { }
-
-    /// 
-    public new event EventHandler>? ValueChanged;
-
-    #endregion
-
-    /// 
-    public override bool DeleteCharLeft (bool useOldCursorPos)
-    {
-        if (ReadOnly)
-        {
-            return false;
-        }
-
-        ClearAllSelection ();
-        SetText ((Rune)'0');
-        DecrementInsertionPoint ();
-
-        return true;
-    }
-
-    /// 
-    public override bool DeleteCharRight ()
-    {
-        if (ReadOnly)
-        {
-            return false;
-        }
-
-        ClearAllSelection ();
-        SetText ((Rune)'0');
-
-        return true;
-    }
-
-    /// 
-    protected override bool OnMouseEvent (Mouse mouse)
-    {
-        if (base.OnMouseEvent (mouse) || mouse.Handled)
-        {
-            return true;
-        }
-
-        if (SelectedLength == 0 && mouse.Flags.HasFlag (MouseFlags.LeftButtonPressed))
-        {
-            AdjustInsertionPoint (mouse.Position!.Value.X);
-        }
-
-        return mouse.Handled;
-    }
-
-    /// 
-    protected override bool OnKeyDownNotHandled (Key a)
-    {
-        // Ignore non-numeric characters.
-        if (a >= Key.D0 && a <= Key.D9)
-        {
-            if (!ReadOnly)
-            {
-                if (SetText ((Rune)a))
-                {
-                    IncrementInsertionPoint ();
-                }
-            }
-
-            return true;
-        }
-
-        return false;
-    }
-
-    /// 
-    ///     Adjusts the cursor position to ensure it lands on a valid digit position, skipping separator characters.
-    /// 
-    /// The desired cursor position.
-    /// 
-    ///     If true, skip separators by moving right; if false, skip by moving left.
-    ///     This determines the direction of adjustment when the cursor lands on a separator.
-    /// 
-    /// 
-    ///     
-    ///         This method performs two adjustments:
-    ///         
-    ///             
-    ///                 Clamps  to valid bounds [1, FormatLength]
-    ///             
-    ///             
-    ///                 
-    ///                     If the cursor is on a separator character, moves it in the specified direction until it
-    ///                     reaches a digit
-    ///                 
-    ///             
-    ///         
-    ///     
-    ///     
-    ///         Example: For date " 01/15/2024" with separator '/':
-    ///         
-    ///             
-    ///                 AdjustInsertionPoint(3, true) → cursor moves to position 4 (first digit of day)
-    ///             
-    ///             
-    ///                 AdjustInsertionPoint(3, false) → cursor moves to position 2 (last digit of month)
-    ///             
-    ///         
-    ///     
-    /// 
-    private void AdjustInsertionPoint (int point, bool increment = true)
-    {
-        int newPoint = point;
-
-        // Clamp to valid bounds
-        if (point > FormatLength)
-        {
-            newPoint = FormatLength;
-        }
-
-        if (point < 1)
-        {
-            newPoint = 1;
-        }
-
-        if (newPoint != point)
-        {
-            InsertionPoint = newPoint;
-        }
-
-        // Skip over separator characters in the specified direction
-        while (InsertionPoint < Text.GetColumns () - 1 && Text [InsertionPoint].ToString () == _separator)
-        {
-            if (increment)
-            {
-                InsertionPoint++;
-            }
-            else
-            {
-                InsertionPoint--;
-            }
-        }
-    }
-
-    private void OnTextChanging (object? sender, ResultEventArgs e)
-    {
-        if (e.Result is null)
-        {
-            return;
-        }
-
-        try
-        {
-            var spaces = 0;
-
-            for (var i = 0; i < e.Result.Length; i++)
-            {
-                if (e.Result [i] == ' ')
-                {
-                    spaces++;
-                }
-                else
-                {
-                    break;
-                }
-            }
-
-            spaces += FormatLength;
-            string trimmedText = e.Result [..spaces];
-            spaces -= FormatLength;
-            trimmedText = trimmedText.Replace (new string (' ', spaces), " ");
-            var date = Convert.ToDateTime (trimmedText).ToString (_format!.Trim ());
-
-            if ($" {date}" != e.Result)
-            {
-                // Change the date format to match the current culture
-                e.Result = $" {date}".Replace (RIGHT_TO_LEFT_MARK, "");
-            }
-
-            AdjustInsertionPoint (InsertionPoint);
-        }
-        catch (Exception)
-        {
-            e.Handled = true;
-        }
-    }
-
-    /// 
-    ///     Decrements the cursor position by one, skipping over separator characters.
-    /// 
-    /// 
-    ///     
-    ///         This method moves the cursor left by one position, then calls 
-    ///         with increment=false to skip over any separator that might be at the new position.
-    ///     
-    ///     
-    ///         The cursor will not move below position 1 (the first digit position).
-    ///     
-    /// 
-    private void DecrementInsertionPoint ()
-    {
-        if (InsertionPoint <= 1)
-        {
-            InsertionPoint = 1;
-
-            return;
-        }
-
-        InsertionPoint--;
-        AdjustInsertionPoint (InsertionPoint, false);
-    }
-
-    private string GetDataSeparator (string separator)
-    {
-        string sepChar = separator.Trim ();
-
-        if (sepChar.Length > 1 && sepChar.Contains (RIGHT_TO_LEFT_MARK))
-        {
-            sepChar = sepChar.Replace (RIGHT_TO_LEFT_MARK, "");
-        }
-
-        return sepChar;
-    }
-
-    private string GetDate (int month, int day, int year, string [] fm)
-    {
-        var date = " ";
-
-        for (var i = 0; i < fm.Length; i++)
-        {
-            if (fm [i].Contains ('M'))
-            {
-                date += $"{month,2:00}";
-            }
-            else if (fm [i].Contains ('d'))
-            {
-                date += $"{day,2:00}";
-            }
-            else
-            {
-                date += $"{year,4:0000}";
-            }
-
-            if (i < 2)
-            {
-                date += $"{_separator}";
-            }
-        }
-
-        return date;
-    }
-
-    private static int GetFormatIndex (string [] fm, string t)
-    {
-        int idx = -1;
-
-        for (var i = 0; i < fm.Length; i++)
-        {
-            if (fm [i].Contains (t))
-            {
-                idx = i;
-
-                break;
-            }
-        }
-
-        return idx;
-    }
-
-    /// 
-    ///     Increments the cursor position by one, skipping over separator characters.
-    /// 
-    /// 
-    ///     
-    ///         This method moves the cursor right by one position, then calls 
-    ///         with increment=true to skip over any separator that might be at the new position.
-    ///     
-    ///     
-    ///         The cursor will not move beyond FormatLength (the last digit position).
-    ///     
-    /// 
-    private void IncrementInsertionPoint ()
-    {
-        if (InsertionPoint >= FormatLength)
-        {
-            InsertionPoint = FormatLength;
-
-            return;
-        }
-
-        InsertionPoint++;
-        AdjustInsertionPoint (InsertionPoint);
-    }
-
-    private new bool MoveEnd ()
-    {
-        ClearAllSelection ();
-        InsertionPoint = FormatLength;
-
-        return true;
-    }
-
-    private bool MoveHome ()
-    {
-        // Home, C-A
-        ClearAllSelection ();
-        InsertionPoint = 1;
-
-        return true;
-    }
-
-    private bool MoveLeft ()
-    {
-        ClearAllSelection ();
-        DecrementInsertionPoint ();
-
-        return true;
-    }
-
-    private bool MoveRight ()
-    {
-        ClearAllSelection ();
-        IncrementInsertionPoint ();
-
-        return true;
-    }
-
-    private string NormalizeFormat (string text, string? fmt = null, string? sepChar = null)
-    {
-        if (string.IsNullOrEmpty (fmt))
-        {
-            fmt = _format;
-        }
-
-        if (string.IsNullOrEmpty (sepChar))
-        {
-            sepChar = _separator;
-        }
-
-        if (fmt is null || fmt.Length != text.Length)
-        {
-            return text;
-        }
-
-        char [] fmtText = text.ToCharArray ();
-
-        for (var i = 0; i < text.Length; i++)
-        {
-            char c = fmt [i];
-
-            if (c.ToString () == sepChar && text [i].ToString () != sepChar)
-            {
-                fmtText [i] = c;
-            }
-        }
-
-        return new string (fmtText);
-    }
-
-    private void SetInitialProperties (DateTime date)
-    {
-        _format = $" {StandardizeDateFormat (Culture!.DateTimeFormat.ShortDatePattern)}";
-        _separator = GetDataSeparator (Culture.DateTimeFormat.DateSeparator);
-        Value = date;
-        InsertionPoint = 1;
-        TextChanging += OnTextChanging;
-
-        // Things this view knows how to do
-        AddCommand (Command.DeleteCharRight,
-                    () =>
-                    {
-                        DeleteCharRight ();
-
-                        return true;
-                    });
-
-        AddCommand (Command.DeleteCharLeft,
-                    () =>
-                    {
-                        DeleteCharLeft (false);
-
-                        return true;
-                    });
-        AddCommand (Command.LeftStart, () => MoveHome ());
-        AddCommand (Command.Left, () => MoveLeft ());
-        AddCommand (Command.RightEnd, () => MoveEnd ());
-        AddCommand (Command.Right, () => MoveRight ());
-
-        // Replace the commands defined in TextField
-        KeyBindings.ReplaceCommands (Key.Delete, Command.DeleteCharRight);
-        KeyBindings.ReplaceCommands (Key.D.WithCtrl, Command.DeleteCharRight);
-
-        KeyBindings.ReplaceCommands (Key.Backspace, Command.DeleteCharLeft);
-
-        KeyBindings.ReplaceCommands (Key.Home, Command.LeftStart);
-        KeyBindings.ReplaceCommands (Key.Home.WithCtrl, Command.LeftStart);
-
-        KeyBindings.ReplaceCommands (Key.CursorLeft, Command.Left);
-        KeyBindings.ReplaceCommands (Key.B.WithCtrl, Command.Left);
-
-        KeyBindings.ReplaceCommands (Key.End, Command.RightEnd);
-        KeyBindings.ReplaceCommands (Key.E.WithCtrl, Command.RightEnd);
-
-        KeyBindings.ReplaceCommands (Key.CursorRight, Command.Right);
-        KeyBindings.ReplaceCommands (Key.F.WithCtrl, Command.Right);
-
-#if UNIX_KEY_BINDINGS
-        KeyBindings.ReplaceCommands (Key.D.WithAlt, Command.DeleteCharLeft);
-#endif
-    }
-
-    private bool SetText (Rune key)
-    {
-        if (InsertionPoint > FormatLength)
-        {
-            InsertionPoint = FormatLength;
-
-            return false;
-        }
-
-        if (InsertionPoint < 1)
-        {
-            InsertionPoint = 1;
-
-            return false;
-        }
-
-        List text = Text.EnumerateRunes ().ToList ();
-        List newText = text.GetRange (0, InsertionPoint);
-        newText.Add (key);
-
-        if (InsertionPoint < FormatLength)
-        {
-            newText = [.. newText, .. text.GetRange (InsertionPoint + 1, text.Count - (InsertionPoint + 1))];
-        }
-
-        return SetText (StringExtensions.ToString (newText));
-    }
-
-    private bool SetText (string text)
-    {
-        if (string.IsNullOrEmpty (text))
-        {
-            return false;
-        }
-
-        text = NormalizeFormat (text);
-        string [] vals = text.Split (_separator);
-
-        for (var i = 0; i < vals.Length; i++)
-        {
-            if (vals [i].Contains (RIGHT_TO_LEFT_MARK))
-            {
-                vals [i] = vals [i].Replace (RIGHT_TO_LEFT_MARK, "");
-            }
-        }
-
-        string [] frm = _format!.Split (_separator);
-        int year;
-        int month;
-        int day;
-        int idx = GetFormatIndex (frm, "y");
-
-        if (int.Parse (vals [idx]) < 1)
-        {
-            year = 1;
-            vals [idx] = "1";
-        }
-        else
-        {
-            year = int.Parse (vals [idx]);
-        }
-
-        idx = GetFormatIndex (frm, "M");
-
-        if (int.Parse (vals [idx]) < 1)
-        {
-            month = 1;
-            vals [idx] = "1";
-        }
-        else if (int.Parse (vals [idx]) > 12)
-        {
-            month = 12;
-            vals [idx] = "12";
-        }
-        else
-        {
-            month = int.Parse (vals [idx]);
-        }
-
-        idx = GetFormatIndex (frm, "d");
-
-        if (int.Parse (vals [idx]) < 1)
-        {
-            day = 1;
-            vals [idx] = "1";
-        }
-        else if (int.Parse (vals [idx]) > 31)
-        {
-            day = DateTime.DaysInMonth (year, month);
-            vals [idx] = day.ToString ();
-        }
-        else
-        {
-            day = int.Parse (vals [idx]);
-        }
-
-        string d = GetDate (month, day, year, frm);
-
-        DateTime date;
-
-        try
-        {
-            date = Convert.ToDateTime (d);
-        }
-        catch (Exception)
-        {
-            return false;
-        }
-
-        Value = date;
-
-        return true;
-    }
-
-    // Converts various date formats to a uniform 10-character format.
-    // This aids in simplifying the handling of single-digit months and days,
-    // and reduces the number of distinct date formats to maintain.
-    private static string StandardizeDateFormat (string? format) =>
-        format switch
-        {
-            "MM/dd/yyyy" => "MM/dd/yyyy",
-            "yyyy-MM-dd" => "yyyy-MM-dd",
-            "yyyy/MM/dd" => "yyyy/MM/dd",
-            "dd/MM/yyyy" => "dd/MM/yyyy",
-            "d?/M?/yyyy" => "dd/MM/yyyy",
-            "dd.MM.yyyy" => "dd.MM.yyyy",
-            "dd-MM-yyyy" => "dd-MM-yyyy",
-            "dd/MM yyyy" => "dd/MM/yyyy",
-            "d. M. yyyy" => "dd.MM.yyyy",
-            "yyyy.MM.dd" => "yyyy.MM.dd",
-            "g yyyy/M/d" => "yyyy/MM/dd",
-            "d/M/yyyy" => "dd/MM/yyyy",
-            "d?/M?/yyyy g" => "dd/MM/yyyy",
-            "d-M-yyyy" => "dd-MM-yyyy",
-            "d.MM.yyyy" => "dd.MM.yyyy",
-            "d.MM.yyyy '?'." => "dd.MM.yyyy",
-            "M/d/yyyy" => "MM/dd/yyyy",
-            "d. M. yyyy." => "dd.MM.yyyy",
-            "d.M.yyyy." => "dd.MM.yyyy",
-            "g yyyy-MM-dd" => "yyyy-MM-dd",
-            "d.M.yyyy" => "dd.MM.yyyy",
-            "d/MM/yyyy" => "dd/MM/yyyy",
-            "yyyy/M/d" => "yyyy/MM/dd",
-            "dd. MM. yyyy." => "dd.MM.yyyy",
-            "yyyy. MM. dd." => "yyyy.MM.dd",
-            "yyyy. M. d." => "yyyy.MM.dd",
-            "d. MM. yyyy" => "dd.MM.yyyy",
-            _ => "dd/MM/yyyy"
-        };
-}
diff --git a/Terminal.Gui/Views/TextInput/DateTextProvider.cs b/Terminal.Gui/Views/TextInput/DateTextProvider.cs
new file mode 100644
index 0000000000..a49eed258c
--- /dev/null
+++ b/Terminal.Gui/Views/TextInput/DateTextProvider.cs
@@ -0,0 +1,433 @@
+using System.Globalization;
+
+namespace Terminal.Gui.Views;
+
+/// 
+///     Date input provider for .
+///     Provides date editing with culture-aware formatting.
+/// 
+/// 
+///     
+///         This provider parses the  to determine:
+///         
+///             
+///                 Field order (year, month, day) based on culture
+///             
+///             
+///                 Date separator character
+///             
+///             
+///                 Dynamic field width based on pattern
+///             
+///         
+///     
+///     
+///         The cursor automatically skips over separator characters during navigation.
+///         Date values are auto-corrected to valid ranges (e.g., day clamped to days-in-month).
+///     
+/// 
+public class DateTextProvider : ITextValidateProvider
+{
+    private DateTimeFormatInfo _format = CultureInfo.CurrentCulture.DateTimeFormat;
+    private string _separator = CultureInfo.CurrentCulture.DateTimeFormat.DateSeparator;
+    private string _normalizedPattern = string.Empty;
+    private DateTime _dateValue = DateTime.Today;
+    private int _fieldLength;
+    private readonly HashSet _separatorPositions = [];
+
+    /// 
+    ///     Initializes a new instance of the  class.
+    /// 
+    public DateTextProvider () => AnalyzePattern ();
+
+    /// 
+    ///     Gets or sets the  used for date formatting.
+    /// 
+    /// 
+    ///     
+    ///         The provider uses  to determine the display format.
+    ///         Users can customize patterns by cloning the DateTimeFormatInfo and modifying ShortDatePattern.
+    ///     
+    ///     
+    ///         The width automatically adjusts when the format changes to accommodate the new pattern.
+    ///     
+    /// 
+    public DateTimeFormatInfo Format
+    {
+        get => _format;
+        set
+        {
+            _format = value;
+            _separator = CleanSeparator (value.DateSeparator);
+            AnalyzePattern ();
+            OnTextChanged (new EventArgs (in string.Empty));
+        }
+    }
+
+    /// 
+    ///     Gets or sets the current date value.
+    /// 
+    public DateTime DateValue
+    {
+        get => _dateValue;
+        set => _dateValue = value;
+    }
+
+    /// 
+    public event EventHandler>? TextChanged;
+
+    /// 
+    public string Text
+    {
+        get => FormatDateValue ();
+        set
+        {
+            if (!TryParseDateValue (value, out DateTime parsedValue))
+            {
+                return;
+            }
+            string oldValue = Text;
+            _dateValue = parsedValue;
+
+            if (oldValue != Text)
+            {
+                OnTextChanged (new EventArgs (in oldValue));
+            }
+        }
+    }
+
+    /// 
+    public string DisplayText => FormatDateValue ();
+
+    /// 
+    public bool IsValid =>
+
+        // Always valid - we autocorrect invalid values
+        true;
+
+    /// 
+    public bool Fixed => true;
+
+    /// 
+    public int Cursor (int pos)
+    {
+        if (pos < 0)
+        {
+            return CursorStart ();
+        }
+
+        if (pos >= _fieldLength)
+        {
+            return CursorEnd ();
+        }
+
+        // Skip over separators
+        if (_separatorPositions.Contains (pos))
+        {
+            return CursorRight (pos);
+        }
+
+        return pos;
+    }
+
+    /// 
+    public int CursorStart () => 0;
+
+    /// 
+    public int CursorEnd () => _fieldLength - 1;
+
+    /// 
+    public int CursorLeft (int pos)
+    {
+        if (pos <= 0)
+        {
+            return 0;
+        }
+
+        int newPos = pos - 1;
+
+        // Skip over separators
+        while (newPos >= 0 && _separatorPositions.Contains (newPos))
+        {
+            newPos--;
+        }
+
+        return newPos < 0 ? 0 : newPos;
+    }
+
+    /// 
+    public int CursorRight (int pos)
+    {
+        if (pos >= _fieldLength - 1)
+        {
+            return CursorEnd ();
+        }
+
+        int newPos = pos + 1;
+
+        // Skip over separators
+        while (newPos < _fieldLength && _separatorPositions.Contains (newPos))
+        {
+            newPos++;
+        }
+
+        return newPos >= _fieldLength ? CursorEnd () : newPos;
+    }
+
+    /// 
+    public bool Delete (int pos)
+    {
+        string oldValue = Text;
+
+        // Replace digit with '0'
+        string currentText = FormatDateValue ();
+
+        if (pos < 0 || pos >= currentText.Length || !char.IsDigit (currentText [pos]))
+        {
+            return false;
+        }
+        StringBuilder sb = new (currentText) { [pos] = '0' };
+
+        if (!TryParseDateValue (sb.ToString (), out DateTime newValue))
+        {
+            return false;
+        }
+        _dateValue = newValue;
+        OnTextChanged (new EventArgs (in oldValue));
+
+        return true;
+    }
+
+    /// 
+    public bool InsertAt (char ch, int pos)
+    {
+        string oldValue = Text;
+
+        // Only accept digits for date positions
+        if (!char.IsDigit (ch))
+        {
+            return false;
+        }
+
+        // Replace digit at position
+        string currentText = FormatDateValue ();
+
+        if (pos < 0 || pos >= currentText.Length)
+        {
+            return false;
+        }
+        StringBuilder sb = new (currentText) { [pos] = ch };
+
+        if (!TryParseDateValue (sb.ToString (), out DateTime newValue))
+        {
+            return false;
+        }
+        _dateValue = newValue;
+        OnTextChanged (new EventArgs (in oldValue));
+
+        return true;
+    }
+
+    /// 
+    ///     Raises the  event to notify subscribers that the text has changed.
+    /// 
+    /// An  object that contains the event data for the text change.
+    public void OnTextChanged (EventArgs args) => TextChanged?.Invoke (this, args);
+
+    /// 
+    ///     Analyzes the ShortDatePattern to detect format characteristics.
+    /// 
+    private void AnalyzePattern ()
+    {
+        string pattern = _format.ShortDatePattern;
+        _separatorPositions.Clear ();
+
+        // Clean separator (strip RTL marks etc.)
+        _separator = CleanSeparator (_format.DateSeparator);
+
+        // Normalize to 2-digit day/month and 4-digit year for consistent fixed-width fields
+        _normalizedPattern = NormalizePattern (pattern);
+
+        // Build a sample date to determine field positions
+        DateTime sampleDate = new (2024, 11, 25);
+        var formatted = sampleDate.ToString (_normalizedPattern, _format);
+
+        _fieldLength = formatted.Length;
+
+        // Find separator positions
+        for (var i = 0; i < formatted.Length; i++)
+        {
+            if (formatted [i].ToString () == _separator)
+            {
+                _separatorPositions.Add (i);
+            }
+        }
+    }
+
+    /// 
+    ///     Normalizes the date pattern to always use 2-digit day/month and 4-digit year
+    ///     so that field positions are consistent regardless of the current date value.
+    /// 
+    internal static string NormalizePattern (string pattern)
+    {
+        // Strip any era designator prefix/suffix (e.g., "g yyyy/M/d" → "yyyy/M/d")
+        pattern = pattern.Replace ("g ", "").Replace (" g", "").Trim ();
+
+        // Strip any trailing literal text (e.g., "d.MM.yyyy '?'." or "d. M. yyyy.")
+        int quoteIdx = pattern.IndexOf ('\'');
+
+        if (quoteIdx >= 0)
+        {
+            pattern = pattern [..quoteIdx].Trim ();
+        }
+
+        // Remove trailing dots that are not separators (e.g., "d.M.yyyy." → "d.M.yyyy")
+        pattern = pattern.TrimEnd ('.');
+
+        // Normalize spacing around separators (e.g., "d. M. yyyy" → "d.M.yyyy")
+        pattern = pattern.Replace (" ", "");
+
+        // Normalize day
+        if (!pattern.Contains ("dd") && pattern.Contains ('d'))
+        {
+            pattern = pattern.Replace ("d", "dd");
+        }
+
+        // Normalize month
+        if (!pattern.Contains ("MM") && pattern.Contains ('M'))
+        {
+            pattern = pattern.Replace ("M", "MM");
+        }
+
+        // Normalize year to 4 digits
+        if (!pattern.Contains ("yyyy") && pattern.Contains ("yy"))
+        {
+            pattern = pattern.Replace ("yy", "yyyy");
+        }
+        else if (!pattern.Contains ("yyyy") && pattern.Contains ('y'))
+        {
+            pattern = pattern.Replace ("y", "yyyy");
+        }
+
+        // Handle "d?" pattern used by some cultures
+        pattern = pattern.Replace ("dd?", "dd").Replace ("MM?", "MM");
+
+        return pattern;
+    }
+
+    /// 
+    ///     Cleans the date separator by stripping RTL marks and other non-separator characters.
+    /// 
+    private static string CleanSeparator (string separator)
+    {
+        string cleaned = separator.Trim ().Replace ("\u200f", "");
+
+        if (cleaned.Length > 1)
+        {
+            // Take only the first non-whitespace character
+            foreach (char c in cleaned)
+            {
+                if (!char.IsWhiteSpace (c))
+                {
+                    return c.ToString ();
+                }
+            }
+        }
+
+        return cleaned;
+    }
+
+    /// 
+    ///     Formats the current date value according to the pattern.
+    /// 
+    private string FormatDateValue () => _dateValue.ToString (_normalizedPattern, _format);
+
+    /// 
+    ///     Attempts to parse a date string according to the pattern.
+    /// 
+    private bool TryParseDateValue (string text, out DateTime result)
+    {
+        result = DateTime.MinValue;
+
+        if (string.IsNullOrWhiteSpace (text))
+        {
+            return false;
+        }
+
+        text = text.Trim ();
+
+        // Try to parse using the current pattern
+        if (DateTime.TryParseExact (text, _normalizedPattern, _format, DateTimeStyles.None, out DateTime dt))
+        {
+            result = dt;
+
+            return true;
+        }
+
+        // Fallback: try manual parsing for partial/invalid input
+        return TryManualParse (text, out result);
+    }
+
+    /// 
+    ///     Manual parsing for partially entered or invalid date values.
+    /// 
+    private bool TryManualParse (string text, out DateTime result)
+    {
+        result = DateTime.MinValue;
+
+        try
+        {
+            string [] parts = text.Split (_separator [0]);
+
+            if (parts.Length < 3)
+            {
+                return false;
+            }
+
+            // Determine field order from normalized pattern
+            string [] patternParts = _normalizedPattern.Split (_separator [0]);
+
+            if (patternParts.Length < 3)
+            {
+                return false;
+            }
+
+            var year = 1;
+            var month = 1;
+            var day = 1;
+
+            for (var i = 0; i < patternParts.Length && i < parts.Length; i++)
+            {
+                if (!int.TryParse (parts [i], out int val))
+                {
+                    continue;
+                }
+
+                if (patternParts [i].Contains ('y'))
+                {
+                    year = Math.Max (1, Math.Min (9999, val));
+                }
+                else if (patternParts [i].Contains ('M'))
+                {
+                    month = Math.Max (1, Math.Min (12, val));
+                }
+                else if (patternParts [i].Contains ('d'))
+                {
+                    day = Math.Max (1, val);
+                }
+            }
+
+            // Clamp day to valid range for the given month/year
+            int maxDay = DateTime.DaysInMonth (Math.Max (1, year), month);
+            day = Math.Min (day, maxDay);
+
+            result = new DateTime (year, month, day);
+
+            return true;
+        }
+        catch (ArgumentOutOfRangeException)
+        {
+            return false;
+        }
+    }
+}
diff --git a/Tests/UnitTests/Views/DatePickerTests.cs b/Tests/UnitTests/Views/DatePickerTests.cs
index b652b38ca0..7be8544f7d 100644
--- a/Tests/UnitTests/Views/DatePickerTests.cs
+++ b/Tests/UnitTests/Views/DatePickerTests.cs
@@ -16,7 +16,7 @@ public void DatePicker_ShouldNot_SetDateOutOfRange_UsingNextMonthButton ()
         top.Add (datePicker);
         Application.Begin (top);
 
-        Assert.Equal (datePicker.SubViews.First (v => v.Id == "_dateField"), datePicker.Focused);
+        Assert.Equal (datePicker.SubViews.First (v => v.Id == "_dateEditor"), datePicker.Focused);
 
         // Set focus to next month button
         datePicker.AdvanceFocus (NavigationDirection.Forward, TabBehavior.TabStop);
@@ -48,7 +48,7 @@ public void DatePicker_ShouldNot_SetDateOutOfRange_UsingPreviousMonthButton ()
         top.Add (datePicker);
         Application.Begin (top);
 
-        Assert.Equal (datePicker.SubViews.First (v => v.Id == "_dateField"), datePicker.Focused);
+        Assert.Equal (datePicker.SubViews.First (v => v.Id == "_dateEditor"), datePicker.Focused);
 
         datePicker.AdvanceFocus (NavigationDirection.Forward, TabBehavior.TabStop);
         Assert.Equal (datePicker.SubViews.First (v => v.Id == "_calendar"), datePicker.Focused);
diff --git a/Tests/UnitTestsParallelizable/Views/DateEditorTests.cs b/Tests/UnitTestsParallelizable/Views/DateEditorTests.cs
new file mode 100644
index 0000000000..16513c357e
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Views/DateEditorTests.cs
@@ -0,0 +1,842 @@
+using System.Globalization;
+using UnitTests;
+
+namespace ViewsTests;
+
+// Claude - Opus 4.6
+public class DateEditorTests (ITestOutputHelper output) : TestDriverBase
+{
+    [Fact]
+    public void Constructor_Defaults ()
+    {
+        DateEditor de = new ();
+        de.Layout ();
+
+        Assert.NotNull (de.Provider);
+        Assert.IsType (de.Provider);
+        Assert.NotNull (de.Value);
+        Assert.Equal (DateTime.Today, de.Value);
+        Assert.NotNull (de.Format);
+        Assert.Equal (CultureInfo.CurrentCulture.DateTimeFormat, de.Format);
+    }
+
+    [Fact]
+    public void Value_Property_GetSet ()
+    {
+        DateEditor de = new ();
+        DateTime testDate = new (2024, 3, 15);
+
+        de.Value = testDate;
+        Assert.Equal (testDate, de.Value);
+
+        // Test setting to another date
+        DateTime anotherDate = new (2000, 1, 1);
+        de.Value = anotherDate;
+        Assert.Equal (anotherDate, de.Value);
+
+        // Test setting to max year
+        DateTime maxDate = new (9999, 12, 31);
+        de.Value = maxDate;
+        Assert.Equal (maxDate, de.Value);
+    }
+
+    [Fact]
+    public void Format_Property_Changes_Width ()
+    {
+        DateEditor de = new ();
+
+        // Set to US format (MM/dd/yyyy = 10 chars)
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        de.Format = usFormat;
+        de.Layout ();
+
+        int initialWidth = de.Frame.Width;
+        Assert.True (initialWidth > 0);
+
+        // Change to a different culture format
+        var deFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("de-DE").DateTimeFormat.Clone ();
+        de.Format = deFormat;
+        de.Layout ();
+
+        // Width should still be reasonable
+        int newWidth = de.Frame.Width;
+        Assert.True (newWidth > 0);
+    }
+
+    [Fact]
+    public void ValueChanging_Event_Can_Cancel ()
+    {
+        DateEditor de = new () { Value = new DateTime (2024, 1, 1) };
+        var eventFired = false;
+
+        de.ValueChanging += (_, e) =>
+                            {
+                                eventFired = true;
+                                e.Handled = true; // Cancel the change
+                            };
+
+        de.Value = new DateTime (2024, 6, 15);
+
+        Assert.True (eventFired);
+        Assert.Equal (new DateTime (2024, 1, 1), de.Value); // Value should not change
+    }
+
+    [Fact]
+    public void ValueChanged_Event_Fires ()
+    {
+        DateEditor de = new () { Value = new DateTime (2024, 1, 1) };
+        var eventFired = false;
+        DateTime? oldValue = null;
+        DateTime? newValue = null;
+
+        de.ValueChanged += (_, e) =>
+                           {
+                               eventFired = true;
+                               oldValue = e.OldValue;
+                               newValue = e.NewValue;
+                           };
+
+        DateTime expectedNewValue = new (2024, 6, 15);
+        de.Value = expectedNewValue;
+
+        Assert.True (eventFired);
+        Assert.Equal (new DateTime (2024, 1, 1), oldValue);
+        Assert.Equal (expectedNewValue, newValue);
+    }
+
+    [Fact]
+    public void DateTextProvider_CursorNavigation_SkipsSeparators ()
+    {
+        DateTextProvider provider = new ();
+
+        // Use US format: MM/dd/yyyy
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        // CursorStart should return 0
+        Assert.Equal (0, provider.CursorStart ());
+
+        // For "MM/dd/yyyy" (positions: 0,1,/,3,4,/,6,7,8,9)
+        // Position 2 is separator, cursor should skip it
+        int cursorPos = provider.CursorRight (1);
+        Assert.NotEqual (2, cursorPos); // Should skip position 2 (separator)
+
+        // CursorLeft from position 3 should skip separator at 2 and go to 1
+        cursorPos = provider.CursorLeft (3);
+        Assert.Equal (1, cursorPos);
+    }
+
+    [Fact]
+    public void DateTextProvider_InsertAt_ReplacesDigit ()
+    {
+        DateTextProvider provider = new ();
+
+        // Use US format
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        provider.DateValue = new DateTime (2024, 1, 15); // "01/15/2024"
+
+        // Insert '1' at position 0 (first month digit)
+        bool result = provider.InsertAt ('1', 0);
+        Assert.True (result);
+
+        // Check that the value was updated
+        string text = provider.Text;
+        Assert.StartsWith ("1", text);
+    }
+
+    [Fact]
+    public void DateTextProvider_Delete_ReplacesWithZero ()
+    {
+        DateTextProvider provider = new ();
+
+        // Use US format
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        provider.DateValue = new DateTime (2024, 11, 25); // "11/25/2024"
+
+        // Delete at position 0 should replace with '0'
+        bool result = provider.Delete (0);
+        Assert.True (result);
+
+        // The month should now start with 0
+        string text = provider.Text;
+        Assert.StartsWith ("0", text);
+    }
+
+    [Fact]
+    public void DateTextProvider_Format_Change_Updates_Pattern ()
+    {
+        DateTextProvider provider = new ();
+        DateTime testDate = new (2024, 3, 15);
+        provider.DateValue = testDate;
+
+        // Use US format
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        string usDisplay = provider.DisplayText;
+        output.WriteLine ($"US display: \"{usDisplay}\"");
+
+        // Change to German format (dd.MM.yyyy)
+        var deFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("de-DE").DateTimeFormat.Clone ();
+        provider.Format = deFormat;
+
+        string deDisplay = provider.DisplayText;
+        output.WriteLine ($"DE display: \"{deDisplay}\"");
+
+        // Display should change (field order differs)
+        Assert.NotEqual (usDisplay, deDisplay);
+    }
+
+    [Fact]
+    public void DateTextProvider_IsValid_Always_True ()
+    {
+        DateTextProvider provider = new ();
+
+        // Valid date
+        provider.DateValue = new DateTime (2024, 12, 31);
+        Assert.True (provider.IsValid);
+
+        // Another valid date
+        provider.DateValue = new DateTime (2000, 1, 1);
+        Assert.True (provider.IsValid);
+
+        // Provider auto-corrects invalid values, so IsValid should always be true
+        provider.DateValue = new DateTime (2024, 2, 29); // Leap year
+        Assert.True (provider.IsValid);
+    }
+
+    [Fact]
+    public void DateEditor_KeyInput_UpdatesValue ()
+    {
+        IApplication app = Application.Create ();
+        app.Init (DriverRegistry.Names.ANSI);
+
+        try
+        {
+            DateEditor de = new () { App = app };
+            de.Layout ();
+            de.Value = new DateTime (2024, 1, 1);
+
+            // Simulate typing '1'
+            de.NewKeyDownEvent (Key.D1);
+
+            // The value should have been updated
+            string text = de.Text.Trim ();
+            Assert.Contains ("1", text);
+        }
+        finally
+        {
+            app.Dispose ();
+        }
+    }
+
+    [Fact]
+    public void DateEditor_Navigation_Keys ()
+    {
+        IApplication app = Application.Create ();
+        app.Init (DriverRegistry.Names.ANSI);
+
+        try
+        {
+            DateEditor de = new () { App = app };
+            de.Layout ();
+
+            // Home key should move to start
+            de.NewKeyDownEvent (Key.Home);
+
+            // End key should move to end
+            de.NewKeyDownEvent (Key.End);
+
+            // Arrow keys should navigate
+            de.NewKeyDownEvent (Key.CursorLeft);
+            de.NewKeyDownEvent (Key.CursorRight);
+
+            // No exceptions should be thrown
+            Assert.NotNull (de);
+        }
+        finally
+        {
+            app.Dispose ();
+        }
+    }
+
+    [Fact]
+    public void DateEditor_IValue_GetValue ()
+    {
+        DateEditor de = new ();
+        DateTime testDate = new (2024, 3, 15);
+        de.Value = testDate;
+
+        object? value = ((IValue)de).GetValue ();
+
+        Assert.NotNull (value);
+        Assert.IsType (value);
+        Assert.Equal (testDate, (DateTime)value!);
+    }
+
+    [Fact]
+    public void DateTextProvider_US_Format ()
+    {
+        DateTextProvider provider = new ();
+
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        provider.DateValue = new DateTime (2024, 3, 15);
+
+        string display = provider.DisplayText;
+        output.WriteLine ($"US display: \"{display}\"");
+
+        // Should be MM/dd/yyyy format
+        Assert.Contains ("/", display);
+        Assert.Equal ("03/15/2024", display);
+    }
+
+    [Fact]
+    public void DateTextProvider_UK_Format ()
+    {
+        DateTextProvider provider = new ();
+
+        var ukFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+        provider.Format = ukFormat;
+
+        provider.DateValue = new DateTime (2024, 3, 15);
+
+        string display = provider.DisplayText;
+        output.WriteLine ($"UK display: \"{display}\"");
+
+        // Should be dd/MM/yyyy format
+        Assert.Contains ("/", display);
+        Assert.Equal ("15/03/2024", display);
+    }
+
+    [Fact]
+    public void DateTextProvider_German_Format ()
+    {
+        DateTextProvider provider = new ();
+
+        var deFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("de-DE").DateTimeFormat.Clone ();
+        provider.Format = deFormat;
+
+        provider.DateValue = new DateTime (2024, 3, 15);
+
+        string display = provider.DisplayText;
+        output.WriteLine ($"DE display: \"{display}\"");
+
+        // Should be dd.MM.yyyy format
+        Assert.Contains (".", display);
+        Assert.Equal ("15.03.2024", display);
+    }
+
+    [Fact]
+    public void DateEditor_Delete_And_Backspace ()
+    {
+        IApplication app = Application.Create ();
+        app.Init (DriverRegistry.Names.ANSI);
+
+        try
+        {
+            DateEditor de = new () { App = app };
+
+            var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+            de.Format = usFormat;
+
+            de.Value = new DateTime (2024, 11, 25);
+            de.Layout ();
+
+            // Move to start and delete
+            de.NewKeyDownEvent (Key.Home);
+            de.NewKeyDownEvent (Key.Delete);
+
+            // Value should have changed (first digit replaced with 0)
+            string text = de.Text.Trim ();
+            Assert.NotEqual ("11/25/2024", text);
+
+            // Backspace should also work
+            de.NewKeyDownEvent (Key.Backspace);
+
+            // Text should have changed again
+            Assert.NotNull (de.Text);
+        }
+        finally
+        {
+            app.Dispose ();
+        }
+    }
+
+    [Fact]
+    public void DateTextProvider_CursorEnd_Returns_Last_Position ()
+    {
+        DateTextProvider provider = new ();
+
+        int endPos = provider.CursorEnd ();
+
+        // End position should be >= 0
+        Assert.True (endPos >= 0);
+
+        // For "MM/dd/yyyy", end should be at last digit (position 9)
+        int displayLength = provider.DisplayText.Length;
+        Assert.True (endPos < displayLength);
+    }
+
+    [Fact]
+    public void DateEditor_Text_Property_Updates_Value ()
+    {
+        DateEditor de = new ();
+
+        // Use US format
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        de.Format = usFormat;
+
+        // Set text directly
+        de.Text = "03/15/2024";
+
+        // Value should be updated
+        Assert.Equal (2024, de.Value!.Value.Year);
+        Assert.Equal (3, de.Value.Value.Month);
+        Assert.Equal (15, de.Value.Value.Day);
+    }
+
+    [Fact]
+    public void DateTextProvider_InsertAt_NonDigit_Returns_False ()
+    {
+        DateTextProvider provider = new ();
+
+        // Try to insert a non-digit character at a digit position
+        bool result = provider.InsertAt ('x', 0);
+
+        // Should fail
+        Assert.False (result);
+    }
+
+    [Fact]
+    public void DateEditor_Multiple_Format_Changes ()
+    {
+        DateEditor de = new () { Value = new DateTime (2024, 3, 15) };
+        de.Layout ();
+
+        // Change format multiple times between US and UK
+        for (var i = 0; i < 3; i++)
+        {
+            DateTimeFormatInfo format = i % 2 == 0
+                                            ? (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ()
+                                            : (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone ();
+            de.Format = format;
+            de.Layout ();
+
+            // Value should remain the same
+            Assert.Equal (2024, de.Value!.Value.Year);
+            Assert.Equal (3, de.Value.Value.Month);
+            Assert.Equal (15, de.Value.Value.Day);
+        }
+    }
+
+    [Fact]
+    public void DateTextProvider_ManualParse_InvalidInput ()
+    {
+        DateTextProvider provider = new ();
+
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        DateTime initialValue = provider.DateValue;
+
+        // Test invalid input (no separator)
+        provider.Text = "invalid";
+        Assert.Equal (initialValue, provider.DateValue);
+
+        // Test incomplete input (only two parts)
+        provider.Text = "01/15";
+        Assert.Equal (initialValue, provider.DateValue);
+    }
+
+    [Fact]
+    public void DateTextProvider_ManualParse_AutoCorrection ()
+    {
+        DateTextProvider provider = new ();
+
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        // Test auto-correction for out-of-range month
+        provider.Text = "13/15/2024";
+        Assert.Equal (12, provider.DateValue.Month); // Max 12
+
+        // Test auto-correction for out-of-range day
+        provider.Text = "02/30/2024";
+        Assert.Equal (29, provider.DateValue.Day); // Feb 2024 is leap year, max 29
+    }
+
+    [Fact]
+    public void DateEditor_ValueChanging_Cancel ()
+    {
+        DateEditor de = new ();
+        DateTime initialValue = new (2024, 1, 1);
+        de.Value = initialValue;
+
+        var changingEventFired = false;
+        var changedEventFired = false;
+
+        de.ValueChanging += (_, e) =>
+                            {
+                                changingEventFired = true;
+                                e.Handled = true; // Cancel the change
+                            };
+
+        de.ValueChanged += (_, _) => { changedEventFired = true; };
+
+        // Try to set new value
+        de.Value = new DateTime (2024, 6, 15);
+
+        // ValueChanging should have fired, but ValueChanged should not
+        Assert.True (changingEventFired);
+        Assert.False (changedEventFired);
+
+        // Value should not have changed
+        Assert.Equal (initialValue, de.Value);
+    }
+
+    [Fact]
+    public void DateTextProvider_Delete_AtSeparatorPosition ()
+    {
+        DateTextProvider provider = new ();
+
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        provider.DateValue = new DateTime (2024, 3, 15);
+
+        // Try to delete at separator position (position 2 in "03/15/2024")
+        bool result = provider.Delete (2);
+
+        // Delete at separator should fail (not a digit)
+        Assert.False (result);
+    }
+
+    [Fact]
+    public void DateEditor_ValueChangedUntyped_Event ()
+    {
+        DateEditor de = new ();
+        var eventFired = false;
+        object? oldValue = null;
+        object? newValue = null;
+
+        de.ValueChangedUntyped += (_, e) =>
+                                  {
+                                      eventFired = true;
+                                      oldValue = e.OldValue;
+                                      newValue = e.NewValue;
+                                  };
+
+        DateTime testValue = new (2024, 6, 15);
+        de.Value = testValue;
+
+        Assert.True (eventFired);
+        Assert.Equal (testValue, newValue);
+    }
+
+    [Fact]
+    public void DateTextProvider_CursorLeft_FromStart ()
+    {
+        DateTextProvider provider = new ();
+
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        // CursorLeft from start should return start
+        int pos = provider.CursorLeft (0);
+        Assert.Equal (0, pos);
+    }
+
+    [Fact]
+    public void DateTextProvider_CursorRight_FromEnd ()
+    {
+        DateTextProvider provider = new ();
+
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        int endPos = provider.CursorEnd ();
+
+        // CursorRight from end should return end
+        int pos = provider.CursorRight (endPos);
+        Assert.Equal (endPos, pos);
+    }
+
+    [Fact]
+    public void DateEditor_Default_Constructor_Width_Fits_DisplayText ()
+    {
+        DateEditor de = new () { Value = new DateTime (2024, 3, 15) };
+        de.Layout ();
+
+        output.WriteLine ($"DisplayText: \"{de.Provider!.DisplayText}\"");
+        output.WriteLine ($"DisplayText.Length: {de.Provider.DisplayText.Length}");
+        output.WriteLine ($"Frame.Width: {de.Frame.Width}");
+
+        Assert.True (de.Frame.Width >= de.Provider.DisplayText.Length,
+                     $"Frame width {de.Frame.Width} is too narrow for DisplayText \"{de.Provider.DisplayText}\" ({de.Provider.DisplayText.Length} chars)");
+    }
+
+    [Fact]
+    public void DateTextProvider_CursorNavigation_Comprehensive_US ()
+    {
+        DateTextProvider provider = new ();
+
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        // Test CursorStart
+        Assert.Equal (0, provider.CursorStart ());
+
+        // Test navigation through all positions for "MM/dd/yyyy"
+        List visitedPositions = [provider.CursorStart ()];
+        int pos = provider.CursorStart ();
+
+        while (pos < provider.CursorEnd ())
+        {
+            pos = provider.CursorRight (pos);
+            visitedPositions.Add (pos);
+        }
+
+        output.WriteLine ($"Forward positions: [{string.Join (", ", visitedPositions)}]");
+
+        // Should visit: 0, 1, 3, 4, 6, 7, 8, 9 (skipping separators at 2, 5)
+        Assert.Equal ([0, 1, 3, 4, 6, 7, 8, 9], visitedPositions);
+    }
+
+    [Fact]
+    public void DateTextProvider_InsertAt_AllDigitPositions_US ()
+    {
+        DateTextProvider provider = new ();
+
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+        provider.DateValue = new DateTime (2000, 1, 1); // "01/01/2000"
+
+        output.WriteLine ($"Initial: \"{provider.DisplayText}\"");
+
+        // Type at each editable position to enter 12/25/2024
+        Assert.True (provider.InsertAt ('1', 0)); // "11/01/2000"
+        output.WriteLine ($"After InsertAt('1', 0): \"{provider.DisplayText}\"");
+
+        Assert.True (provider.InsertAt ('2', 1)); // "12/01/2000"
+        output.WriteLine ($"After InsertAt('2', 1): \"{provider.DisplayText}\"");
+
+        Assert.True (provider.InsertAt ('2', 3)); // "12/21/2000"
+        output.WriteLine ($"After InsertAt('2', 3): \"{provider.DisplayText}\"");
+
+        Assert.True (provider.InsertAt ('5', 4)); // "12/25/2000"
+        output.WriteLine ($"After InsertAt('5', 4): \"{provider.DisplayText}\"");
+
+        Assert.True (provider.InsertAt ('2', 6)); // "12/25/2000"
+        output.WriteLine ($"After InsertAt('2', 6): \"{provider.DisplayText}\"");
+
+        Assert.True (provider.InsertAt ('0', 7)); // "12/25/2000"
+        output.WriteLine ($"After InsertAt('0', 7): \"{provider.DisplayText}\"");
+
+        Assert.True (provider.InsertAt ('2', 8)); // "12/25/2020"
+        output.WriteLine ($"After InsertAt('2', 8): \"{provider.DisplayText}\"");
+
+        Assert.True (provider.InsertAt ('4', 9)); // "12/25/2024"
+        output.WriteLine ($"After InsertAt('4', 9): \"{provider.DisplayText}\"");
+
+        Assert.Equal ("12/25/2024", provider.DisplayText);
+        Assert.Equal (new DateTime (2024, 12, 25), provider.DateValue);
+    }
+
+    [Fact]
+    public void DateTextProvider_DaysInMonth_AutoCorrection ()
+    {
+        DateTextProvider provider = new ();
+
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        // Set Feb 28 in non-leap year
+        provider.DateValue = new DateTime (2023, 2, 28);
+        output.WriteLine ($"Feb 28 2023: \"{provider.DisplayText}\"");
+        Assert.Equal ("02/28/2023", provider.DisplayText);
+
+        // Set Feb 29 in leap year
+        provider.DateValue = new DateTime (2024, 2, 29);
+        output.WriteLine ($"Feb 29 2024: \"{provider.DisplayText}\"");
+        Assert.Equal ("02/29/2024", provider.DisplayText);
+    }
+
+    [Fact]
+    public void DateEditor_DisplayText_Renders_Correctly ()
+    {
+        IApplication app = Application.Create ();
+        app.Init (DriverRegistry.Names.ANSI);
+        app.Driver!.SetScreenSize (20, 1);
+
+        try
+        {
+            Runnable runnable = new () { Width = 20, Height = 1 };
+            app.Begin (runnable);
+
+            var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+
+            DateEditor de = new () { Height = 1, Value = new DateTime (2024, 3, 15), Format = usFormat };
+            runnable.Add (de);
+            app.LayoutAndDraw ();
+
+            output.WriteLine ($"DisplayText: \"{de.Provider!.DisplayText}\"");
+            output.WriteLine ($"Frame: {de.Frame}");
+
+            Assert.Equal ("03/15/2024", de.Provider.DisplayText);
+
+            DriverAssert.AssertDriverContentsWithFrameAre (@"03/15/2024", output, app.Driver);
+        }
+        finally
+        {
+            app.Dispose ();
+        }
+    }
+
+    [Fact]
+    public void DateEditor_Typing_Date_Renders_Correctly ()
+    {
+        IApplication app = Application.Create ();
+        app.Init (DriverRegistry.Names.ANSI);
+        app.Driver!.SetScreenSize (20, 1);
+
+        try
+        {
+            Runnable runnable = new () { Width = 20, Height = 1 };
+            app.Begin (runnable);
+
+            var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+
+            DateEditor de = new () { Width = 12, Height = 1, Value = new DateTime (2024, 1, 1), Format = usFormat };
+            runnable.Add (de);
+            app.LayoutAndDraw ();
+
+            output.WriteLine ($"Initial: \"{de.Provider!.DisplayText}\"");
+            Assert.Equal ("01/01/2024", de.Provider.DisplayText);
+
+            // Simulate focus and typing "12"
+            de.SetFocus ();
+            de.NewKeyDownEvent (Key.Home);
+            de.NewKeyDownEvent (Key.D1);
+            app.LayoutAndDraw ();
+
+            output.WriteLine ($"After '1': \"{de.Provider.DisplayText}\"");
+
+            de.NewKeyDownEvent (Key.D2);
+            app.LayoutAndDraw ();
+
+            output.WriteLine ($"After '2': \"{de.Provider.DisplayText}\"");
+
+            // Month should now be 12
+            Assert.Equal (12, de.Value!.Value.Month);
+
+            DriverAssert.AssertDriverContentsWithFrameAre (@"12/01/2024", output, app.Driver);
+        }
+        finally
+        {
+            app.Dispose ();
+        }
+    }
+
+    [Fact]
+    public void DateEditor_CursorRight_SkipsSeparator_US ()
+    {
+        DateTextProvider provider = new ();
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        // Position 0 = M tens, 1 = M ones, 2 = '/', 3 = d tens
+        int nextPos = provider.CursorRight (1);
+        output.WriteLine ($"CursorRight(1) = {nextPos}");
+
+        // Should skip separator at position 2 and land on 3
+        Assert.Equal (3, nextPos);
+    }
+
+    [Fact]
+    public void DateEditor_CursorLeft_SkipsSeparator_US ()
+    {
+        DateTextProvider provider = new ();
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        // CursorLeft from position 3 (d tens) should skip separator at 2 to position 1
+        int prevPos = provider.CursorLeft (3);
+        output.WriteLine ($"CursorLeft(3) = {prevPos}");
+        Assert.Equal (1, prevPos);
+    }
+
+    [Fact]
+    public void DateTextProvider_NormalizedPattern_PadsToTwoDigits ()
+    {
+        DateTextProvider provider = new ();
+
+        // US format uses "M/d/yyyy" in some locales - verify normalization
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        // Single-digit month value
+        provider.DateValue = new DateTime (2024, 3, 5);
+        string display = provider.DisplayText;
+        output.WriteLine ($"Display for 2024-03-05: \"{display}\"");
+
+        // Should be padded to 2 digits
+        Assert.Equal (10, display.Length); // "MM/dd/yyyy" = 10 chars
+    }
+
+    [Fact]
+    public void DateTextProvider_FieldPositions_AreConsistent ()
+    {
+        DateTextProvider provider = new ();
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        // Single-digit month and day
+        provider.DateValue = new DateTime (2024, 3, 5);
+        string display1 = provider.DisplayText;
+        output.WriteLine ($"2024-03-05 → \"{display1}\"");
+
+        // Double-digit month and day
+        provider.DateValue = new DateTime (2024, 11, 25);
+        string display2 = provider.DisplayText;
+        output.WriteLine ($"2024-11-25 → \"{display2}\"");
+
+        // Both should have the same length due to normalization
+        Assert.Equal (display1.Length, display2.Length);
+    }
+
+    [Fact]
+    public void DateTextProvider_Fixed_Is_True ()
+    {
+        DateTextProvider provider = new ();
+        Assert.True (provider.Fixed);
+    }
+
+    [Fact]
+    public void DateEditor_FullNavigation_AllPositions_US ()
+    {
+        DateTextProvider provider = new ();
+        var usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone ();
+        provider.Format = usFormat;
+
+        // Navigate backward from end to start
+        List backwardPositions = [provider.CursorEnd ()];
+        int pos = provider.CursorEnd ();
+
+        while (pos > provider.CursorStart ())
+        {
+            pos = provider.CursorLeft (pos);
+            backwardPositions.Add (pos);
+        }
+
+        output.WriteLine ($"Backward positions: [{string.Join (", ", backwardPositions)}]");
+
+        // Should visit: 9, 8, 7, 6, 4, 3, 1, 0 (skipping separators at 5, 2)
+        Assert.Equal ([9, 8, 7, 6, 4, 3, 1, 0], backwardPositions);
+    }
+}
diff --git a/Tests/UnitTestsParallelizable/Views/DateFieldTests.cs b/Tests/UnitTestsParallelizable/Views/DateFieldTests.cs
deleted file mode 100644
index a1580992cf..0000000000
--- a/Tests/UnitTestsParallelizable/Views/DateFieldTests.cs
+++ /dev/null
@@ -1,369 +0,0 @@
-#nullable enable
-using System.Globalization;
-using System.Runtime.InteropServices;
-using UnitTests_Parallelizable;
-
-namespace ViewsTests;
-
-public class DateFieldTests
-{
-    [Fact]
-    [TestDate]
-    public void Constructors_Defaults ()
-    {
-        var df = new DateField ();
-        df.Layout ();
-        Assert.Equal (DateTime.MinValue, df.Value);
-        Assert.Equal (1, df.InsertionPoint);
-        Assert.Equal (new (0, 0, 12, 1), df.Frame);
-        Assert.Equal (" 01/01/0001", df.Text);
-
-        DateTime date = DateTime.Now;
-        df = new (date);
-        df.Layout ();
-        Assert.Equal (date, df.Value);
-        Assert.Equal (1, df.InsertionPoint);
-        Assert.Equal (new (0, 0, 12, 1), df.Frame);
-        Assert.Equal ($" {date.ToString (CultureInfo.InvariantCulture.DateTimeFormat.ShortDatePattern)}", df.Text);
-
-        df = new (date) { X = 1, Y = 2 };
-        df.Layout ();
-        Assert.Equal (date, df.Value);
-        Assert.Equal (1, df.InsertionPoint);
-        Assert.Equal (new (1, 2, 12, 1), df.Frame);
-        Assert.Equal ($" {date.ToString (CultureInfo.InvariantCulture.DateTimeFormat.ShortDatePattern)}", df.Text);
-    }
-
-    [Fact]
-    [TestDate]
-    public void Copy_Paste ()
-    {
-        IApplication app = Application.Create();
-        app.Init(DriverRegistry.Names.ANSI);
-        app.Driver!.Clipboard = new FakeClipboard ();
-
-        try
-        {
-            var df1 = new DateField (DateTime.Parse ("12/12/1971")) { App = app };
-            var df2 = new DateField (DateTime.Parse ("12/31/2023")) { App = app };
-
-            // Select all text
-            Assert.True (df2.NewKeyDownEvent (Key.End.WithShift));
-            Assert.Equal (1, df2.SelectedStart);
-            Assert.Equal (10, df2.SelectedLength);
-            Assert.Equal (11, df2.InsertionPoint);
-
-            // Copy from df2
-            Assert.True (df2.NewKeyDownEvent (Key.C.WithCtrl));
-
-            // Paste into df1
-            Assert.True (df1.NewKeyDownEvent (Key.V.WithCtrl));
-            Assert.Equal (" 12/31/2023", df1.Text);
-            Assert.Equal (11, df1.InsertionPoint);
-        }
-        finally
-        {
-            app.Dispose ();
-        }
-    }
-
-    [Fact]
-    [TestDate]
-    public void CursorPos_Min_Is_Always_One_Max_Is_Always_Max_Format ()
-    {
-        var df = new DateField ();
-        Assert.Equal (1, df.InsertionPoint);
-        df.InsertionPoint = 0;
-        Assert.Equal (1, df.InsertionPoint);
-        df.InsertionPoint = 11;
-        Assert.Equal (10, df.InsertionPoint);
-    }
-
-    [Fact]
-    [TestDate]
-    public void CursorPos_Min_Is_Always_One_Max_Is_Always_Max_Format_After_Selection ()
-    {
-        var df = new DateField ();
-
-        // Start selection
-        Assert.True (df.NewKeyDownEvent (Key.CursorLeft.WithShift));
-        Assert.Equal (1, df.SelectedStart);
-        Assert.Equal (1, df.SelectedLength);
-        Assert.Equal (0, df.InsertionPoint);
-
-        // Without selection
-        Assert.True (df.NewKeyDownEvent (Key.CursorLeft));
-        Assert.Equal (-1, df.SelectedStart);
-        Assert.Equal (0, df.SelectedLength);
-        Assert.Equal (1, df.InsertionPoint);
-        df.InsertionPoint = 10;
-        Assert.True (df.NewKeyDownEvent (Key.CursorRight.WithShift));
-        Assert.Equal (10, df.SelectedStart);
-        Assert.Equal (1, df.SelectedLength);
-        Assert.Equal (11, df.InsertionPoint);
-        Assert.True (df.NewKeyDownEvent (Key.CursorRight));
-        Assert.Equal (-1, df.SelectedStart);
-        Assert.Equal (0, df.SelectedLength);
-        Assert.Equal (10, df.InsertionPoint);
-    }
-
-    [Fact]
-    [TestDate]
-    public void Date_Start_From_01_01_0001_And_End_At_12_31_9999 ()
-    {
-        var df = new DateField (DateTime.Parse ("01/01/0001"));
-        Assert.Equal (" 01/01/0001", df.Text);
-        df.Value = DateTime.Parse ("12/31/9999");
-        Assert.Equal (" 12/31/9999", df.Text);
-    }
-
-    [Fact]
-    [TestDate]
-    public void KeyBindings_Command ()
-    {
-        var df = new DateField (DateTime.Parse ("12/12/1971")) { ReadOnly = true };
-        Assert.True (df.NewKeyDownEvent (Key.Delete));
-        Assert.Equal (" 12/12/1971", df.Text);
-        df.ReadOnly = false;
-        Assert.True (df.NewKeyDownEvent (Key.D.WithCtrl));
-        Assert.Equal (" 02/12/1971", df.Text);
-        df.InsertionPoint = 4;
-        df.ReadOnly = true;
-        Assert.True (df.NewKeyDownEvent (Key.Delete));
-        Assert.Equal (" 02/12/1971", df.Text);
-        df.ReadOnly = false;
-        Assert.True (df.NewKeyDownEvent (Key.Backspace));
-        Assert.Equal (" 02/02/1971", df.Text);
-        Assert.True (df.NewKeyDownEvent (Key.Home));
-        Assert.Equal (1, df.InsertionPoint);
-        Assert.True (df.NewKeyDownEvent (Key.End));
-        Assert.Equal (10, df.InsertionPoint);
-        Assert.True (df.NewKeyDownEvent (Key.E.WithCtrl));
-        Assert.Equal (10, df.InsertionPoint);
-        Assert.True (df.NewKeyDownEvent (Key.CursorLeft));
-        Assert.Equal (9, df.InsertionPoint);
-        Assert.True (df.NewKeyDownEvent (Key.CursorRight));
-        Assert.Equal (10, df.InsertionPoint);
-
-        // Non-numerics are ignored
-        Assert.False (df.NewKeyDownEvent (Key.A));
-        df.ReadOnly = true;
-        df.InsertionPoint = 1;
-        Assert.True (df.NewKeyDownEvent (Key.D1));
-        Assert.Equal (" 02/02/1971", df.Text);
-        df.ReadOnly = false;
-        Assert.True (df.NewKeyDownEvent (Key.D1));
-        Assert.Equal (" 12/02/1971", df.Text);
-        Assert.Equal (2, df.InsertionPoint);
-#if UNIX_KEY_BINDINGS
-        Assert.True (df.NewKeyDownEvent (Key.D.WithAlt));
-        Assert.Equal (" 10/02/1971", df.Text);
-#endif
-    }
-
-    [Fact]
-    [TestDate]
-    public void Typing_With_Selection_Normalize_Format ()
-    {
-        var df = new DateField (DateTime.Parse ("12/12/1971"))
-        {
-            // Start selection at before the first separator /
-            InsertionPoint = 2
-        };
-
-        // Now select the separator /
-        Assert.True (df.NewKeyDownEvent (Key.CursorRight.WithShift));
-        Assert.Equal (2, df.SelectedStart);
-        Assert.Equal (1, df.SelectedLength);
-        Assert.Equal (3, df.InsertionPoint);
-
-        // Type 3 over the separator
-        Assert.True (df.NewKeyDownEvent (Key.D3));
-
-        // The format was normalized and replaced again with /
-        Assert.Equal (" 12/12/1971", df.Text);
-        Assert.Equal (4, df.InsertionPoint);
-    }
-
-    [Fact]
-    [TestDate]
-    public void Culture_Pt_Portuguese ()
-    {
-        CultureInfo cultureBackup = CultureInfo.CurrentCulture;
-
-        try
-        {
-            CultureInfo.CurrentCulture = new ("pt-PT");
-
-            var df = new DateField (DateTime.Parse ("12/12/1971"))
-            {
-                // Move to the first 2
-                InsertionPoint = 2
-            };
-
-            // Type 3 over the separator
-            Assert.True (df.NewKeyDownEvent (Key.D3));
-
-            // If InvariantCulture was used this will fail but not with PT culture
-            Assert.Equal (" 13/12/1971", df.Text);
-            Assert.Equal ("13/12/1971", df.Value!.Value.ToString (CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern));
-            Assert.Equal (4, df.InsertionPoint);
-        }
-        finally
-        {
-            CultureInfo.CurrentCulture = cultureBackup;
-        }
-    }
-
-    /// 
-    ///     Tests specific culture date formatting edge cases.
-    ///     Split from the monolithic culture test for better isolation and maintainability.
-    /// 
-    [Theory]
-    [TestDate]
-    [InlineData ("en-US", "01/01/1971", '/')]
-    [InlineData ("en-GB", "01/01/1971", '/')]
-    [InlineData ("de-DE", "01.01.1971", '.')]
-    [InlineData ("fr-FR", "01/01/1971", '/')]
-    [InlineData ("es-ES", "01/01/1971", '/')]
-    [InlineData ("it-IT", "01/01/1971", '/')]
-    [InlineData ("ja-JP", "1971/01/01", '/')]
-    [InlineData ("zh-CN", "1971/01/01", '/')]
-    [InlineData ("ko-KR", "1971.01.01", '.')]
-    [InlineData ("pt-PT", "01/01/1971", '/')]
-    [InlineData ("pt-BR", "01/01/1971", '/')]
-    [InlineData ("ru-RU", "01.01.1971", '.')]
-    [InlineData ("nl-NL", "01-01-1971", '-')]
-    [InlineData ("sv-SE", "1971-01-01", '-')]
-    [InlineData ("pl-PL", "01.01.1971", '.')]
-    [InlineData ("tr-TR", "01.01.1971", '.')]
-    public void Culture_SpecificCultures_ProducesExpectedFormat (string cultureName, string expectedDate, char expectedSeparator)
-    {
-        // Skip cultures that may have platform-specific issues
-        if (RuntimeInformation.IsOSPlatform (OSPlatform.OSX))
-        {
-            // macOS has known issues with certain cultures - see #3592
-            string [] problematicOnMac = { "ar-SA", "en-SA", "en-TH", "th", "th-TH" };
-
-            if (problematicOnMac.Contains (cultureName))
-            {
-                return;
-            }
-        }
-
-        CultureInfo cultureBackup = CultureInfo.CurrentCulture;
-
-        try
-        {
-            var culture = new CultureInfo (cultureName);
-
-            // Parse date using InvariantCulture BEFORE changing CurrentCulture
-            DateTime date = DateTime.Parse ("1/1/1971", CultureInfo.InvariantCulture);
-
-            CultureInfo.CurrentCulture = culture;
-
-            var df = new DateField (date);
-
-            // Verify the text contains the expected separator
-            Assert.Contains (expectedSeparator, df.Text);
-
-            // Verify the date is formatted correctly (accounting for leading space)
-            Assert.Equal ($" {expectedDate}", df.Text);
-        }
-        catch (CultureNotFoundException)
-        {
-            // Skip cultures not available on this system
-        }
-        finally
-        {
-            CultureInfo.CurrentCulture = cultureBackup;
-        }
-    }
-
-    /// 
-    ///     Tests right-to-left cultures separately due to their complexity.
-    /// 
-    [Theory]
-    [TestDate]
-    [InlineData ("ar-SA")] // Arabic (Saudi Arabia)
-    [InlineData ("he-IL")] // Hebrew (Israel)
-    [InlineData ("fa-IR")] // Persian (Iran)
-    public void Culture_RightToLeft_HandlesFormatting (string cultureName)
-    {
-        if (RuntimeInformation.IsOSPlatform (OSPlatform.OSX))
-        {
-            // macOS has known issues with RTL cultures - see #3592
-            return;
-        }
-
-        CultureInfo cultureBackup = CultureInfo.CurrentCulture;
-
-        try
-        {
-            var culture = new CultureInfo (cultureName);
-
-            // Parse date using InvariantCulture BEFORE changing CurrentCulture
-            // This is critical because RTL cultures may use different calendars
-            DateTime date = DateTime.Parse ("1/1/1971", CultureInfo.InvariantCulture);
-
-            CultureInfo.CurrentCulture = culture;
-
-            var df = new DateField (date);
-
-            // Just verify DateField doesn't crash with RTL cultures
-            // and produces some text
-            Assert.NotEmpty (df.Text);
-            Assert.NotNull (df.Value);
-        }
-        catch (CultureNotFoundException)
-        {
-            // Skip cultures not available on this system
-        }
-        finally
-        {
-            CultureInfo.CurrentCulture = cultureBackup;
-        }
-    }
-
-    /// 
-    ///     Tests that DateField handles calendar systems that differ from Gregorian.
-    /// 
-    [Theory]
-    [TestDate]
-    [InlineData ("th-TH")] // Thai Buddhist calendar
-    public void Culture_NonGregorianCalendar_HandlesFormatting (string cultureName)
-    {
-        if (RuntimeInformation.IsOSPlatform (OSPlatform.OSX))
-        {
-            // macOS has known issues with certain calendars - see #3592
-            return;
-        }
-
-        CultureInfo cultureBackup = CultureInfo.CurrentCulture;
-
-        try
-        {
-            var culture = new CultureInfo (cultureName);
-
-            // Parse date using InvariantCulture BEFORE changing CurrentCulture
-            DateTime date = DateTime.Parse ("1/1/1971", CultureInfo.InvariantCulture);
-
-            CultureInfo.CurrentCulture = culture;
-
-            var df = new DateField (date);
-
-            // Buddhist calendar is 543 years ahead (1971 + 543 = 2514)
-            // Just verify it doesn't crash and produces valid output
-            Assert.NotEmpty (df.Text);
-            Assert.NotNull (df.Value);
-        }
-        catch (CultureNotFoundException)
-        {
-            // Skip cultures not available on this system
-        }
-        finally
-        {
-            CultureInfo.CurrentCulture = cultureBackup;
-        }
-    }
-}
diff --git a/docfx/docs/events.md b/docfx/docs/events.md
index 6468661d4b..9e5c7bf023 100644
--- a/docfx/docs/events.md
+++ b/docfx/docs/events.md
@@ -485,7 +485,7 @@ public interface IValue : IValue
 |  | `CheckState` | Current checked state (Unchecked, Checked, CheckedMark) |
 |  | `string` | Text content |
 |  | `string` | Full text content |
-| `DateField` | `DateTime?` | Selected date and time |
+| `DateEditor` | `DateTime?` | Selected date |
 | `TimeEditor` | `TimeSpan` | Selected time |
 | `ScrollBar` | `int` | Current scroll position |
 | `Slider` | `int` | Current slider value |
diff --git a/docfx/docs/views.md b/docfx/docs/views.md
index 35a81b1c46..aa6e4419c5 100644
--- a/docfx/docs/views.md
+++ b/docfx/docs/views.md
@@ -137,9 +137,9 @@ A sinple color picker that supports the legacy 16 ANSI colors
 
-## [DateField](xref:Terminal.Gui.Views.DateField) +## [DateEditor](xref:Terminal.Gui.Views.DateEditor) -Provides date editing functionality with specialized cursor behavior for date entry. +Provides date editing functionality using [TextValidateField](xref:Terminal.Gui.Views.TextValidateField) with culture-aware formatting.

From 8b3a3096ae848d646d4c72fcaa9378dc9207e21b Mon Sep 17 00:00:00 2001
From: Tig 
Date: Mon, 9 Mar 2026 06:28:10 -0600
Subject: [PATCH 20/26] code cleanup

---
 Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs | 2 +-
 Terminal.Gui/Views/TextInput/TextRegexProvider.cs     | 2 +-
 Terminal.Gui/Views/TextInput/TimeTextProvider.cs      | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs b/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs
index 080d9cb03f..0eeb091331 100644
--- a/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs
+++ b/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs
@@ -135,6 +135,6 @@ public bool InsertAt (char ch, int pos)
     /// 
/// Call this method to trigger the TextChanged event when the text value is updated. Subscribers /// can use this event to respond to changes in the text. - /// An EventArgs object that contains the event data for the text change. + /// Contains the event data for the text change. public void OnTextChanged (EventArgs args) => TextChanged?.Invoke (this, args); } diff --git a/Terminal.Gui/Views/TextInput/TextRegexProvider.cs b/Terminal.Gui/Views/TextInput/TextRegexProvider.cs index e70b1b3a4a..91cfa45a36 100644 --- a/Terminal.Gui/Views/TextInput/TextRegexProvider.cs +++ b/Terminal.Gui/Views/TextInput/TextRegexProvider.cs @@ -128,7 +128,7 @@ public bool InsertAt (char ch, int pos) /// /// Raises the TextChanged event to notify subscribers that the text has changed. /// - /// An EventArgs object that contains the event data representing the new text value. + /// Contains the event data representing the new text value. public void OnTextChanged (EventArgs args) => TextChanged?.Invoke (this, args); /// Compiles the regex pattern for validation./> diff --git a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs index 68aecc04a5..61af631571 100644 --- a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs +++ b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs @@ -308,7 +308,7 @@ public bool InsertAt (char ch, int pos) ///
/// Call this method to trigger the TextChanged event when the text value is updated. Subscribers /// can use this event to respond to changes in the text. - /// An EventArgs object that contains the event data for the text change. + /// Contains the event data for the text change. public void OnTextChanged (EventArgs args) => TextChanged?.Invoke (this, args); /// From 8eae61a8d57172962ebc69d9c95f1ccc041e5556 Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 9 Mar 2026 06:30:34 -0600 Subject: [PATCH 21/26] code cleanup --- Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs | 8 +++++--- Terminal.Gui/Views/TextInput/TextRegexProvider.cs | 4 ++-- Terminal.Gui/Views/TextInput/TimeTextProvider.cs | 9 +++++---- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs b/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs index 0eeb091331..8ce375f9f6 100644 --- a/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs +++ b/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs @@ -131,10 +131,12 @@ public bool InsertAt (char ch, int pos) } /// - /// Raises the TextChanged event to notify subscribers that the text has changed. + /// Raises the TextChanged event to notify subscribers that the text has changed. /// - /// Call this method to trigger the TextChanged event when the text value is updated. Subscribers - /// can use this event to respond to changes in the text. + /// + /// Call this method to trigger the TextChanged event when the text value is updated. Subscribers + /// can use this event to respond to changes in the text. + /// /// Contains the event data for the text change. public void OnTextChanged (EventArgs args) => TextChanged?.Invoke (this, args); } diff --git a/Terminal.Gui/Views/TextInput/TextRegexProvider.cs b/Terminal.Gui/Views/TextInput/TextRegexProvider.cs index 91cfa45a36..6f685d6937 100644 --- a/Terminal.Gui/Views/TextInput/TextRegexProvider.cs +++ b/Terminal.Gui/Views/TextInput/TextRegexProvider.cs @@ -28,7 +28,7 @@ public string Pattern public bool ValidateOnInput { get; set; } = true; /// - public event EventHandler> TextChanged = null!; + public event EventHandler>? TextChanged; /// public string Text @@ -136,7 +136,7 @@ public bool InsertAt (char ch, int pos) private void SetupText () { - if (_text is not null && IsValid) + if (IsValid) { return; } diff --git a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs index 61af631571..11d539af7b 100644 --- a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs +++ b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs @@ -1,4 +1,3 @@ -using System.ComponentModel; using System.Globalization; namespace Terminal.Gui.Views; @@ -304,10 +303,12 @@ public bool InsertAt (char ch, int pos) } /// - /// Raises the TextChanged event to notify subscribers that the text has changed. + /// Raises the TextChanged event to notify subscribers that the text has changed. /// - /// Call this method to trigger the TextChanged event when the text value is updated. Subscribers - /// can use this event to respond to changes in the text. + /// + /// Call this method to trigger the TextChanged event when the text value is updated. Subscribers + /// can use this event to respond to changes in the text. + /// /// Contains the event data for the text change. public void OnTextChanged (EventArgs args) => TextChanged?.Invoke (this, args); From 0a83cc535975a7a646abdbe2182cc8bc7333f9c2 Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 9 Mar 2026 07:37:50 -0600 Subject: [PATCH 22/26] Refactor TextChanged event handling for providers Refactored event raising in Date, Time, Masked, and Regex text providers by introducing a private RaiseTextChanged method. OnTextChanged is now virtual and serves as a subclass hook, improving extensibility. Updated all event triggers to use RaiseTextChanged. Improved null safety in TextRegexProvider by initializing _text to an empty list. Fixed CleanSeparator logic in DateTextProvider for short separators. Enhanced TryParseDateValue to only fall back to manual parsing if needed. Updated documentation to clarify new event handling pattern. --- .../Views/TextInput/DateTextProvider.cs | 48 ++++++++++++------- .../Views/TextInput/NetMaskedTextProvider.cs | 22 +++++---- .../Views/TextInput/TextRegexProvider.cs | 22 ++++++--- .../Views/TextInput/TimeTextProvider.cs | 28 ++++++----- 4 files changed, 77 insertions(+), 43 deletions(-) diff --git a/Terminal.Gui/Views/TextInput/DateTextProvider.cs b/Terminal.Gui/Views/TextInput/DateTextProvider.cs index a49eed258c..354d717666 100644 --- a/Terminal.Gui/Views/TextInput/DateTextProvider.cs +++ b/Terminal.Gui/Views/TextInput/DateTextProvider.cs @@ -60,7 +60,7 @@ public DateTimeFormatInfo Format _format = value; _separator = CleanSeparator (value.DateSeparator); AnalyzePattern (); - OnTextChanged (new EventArgs (in string.Empty)); + RaiseTextChanged (new EventArgs (in string.Empty)); } } @@ -91,7 +91,7 @@ public string Text if (oldValue != Text) { - OnTextChanged (new EventArgs (in oldValue)); + RaiseTextChanged (new EventArgs (in oldValue)); } } } @@ -193,7 +193,7 @@ public bool Delete (int pos) return false; } _dateValue = newValue; - OnTextChanged (new EventArgs (in oldValue)); + RaiseTextChanged (new EventArgs (in oldValue)); return true; } @@ -223,16 +223,26 @@ public bool InsertAt (char ch, int pos) return false; } _dateValue = newValue; - OnTextChanged (new EventArgs (in oldValue)); + RaiseTextChanged (new EventArgs (in oldValue)); return true; } /// - /// Raises the event to notify subscribers that the text has changed. + /// Called when the text has changed. Subclasses can override this to perform custom actions. /// /// An object that contains the event data for the text change. - public void OnTextChanged (EventArgs args) => TextChanged?.Invoke (this, args); + public virtual void OnTextChanged (EventArgs args) { } + + /// + /// Raises the event. + /// + /// An object that contains the event data for the text change. + private void RaiseTextChanged (EventArgs args) + { + OnTextChanged (args); + TextChanged?.Invoke (this, args); + } /// /// Analyzes the ShortDatePattern to detect format characteristics. @@ -322,15 +332,17 @@ private static string CleanSeparator (string separator) { string cleaned = separator.Trim ().Replace ("\u200f", ""); - if (cleaned.Length > 1) + if (cleaned.Length <= 1) { - // Take only the first non-whitespace character - foreach (char c in cleaned) + return cleaned; + } + + // Take only the first non-whitespace character + foreach (char c in cleaned) + { + if (!char.IsWhiteSpace (c)) { - if (!char.IsWhiteSpace (c)) - { - return c.ToString (); - } + return c.ToString (); } } @@ -357,15 +369,15 @@ private bool TryParseDateValue (string text, out DateTime result) text = text.Trim (); // Try to parse using the current pattern - if (DateTime.TryParseExact (text, _normalizedPattern, _format, DateTimeStyles.None, out DateTime dt)) + if (!DateTime.TryParseExact (text, _normalizedPattern, _format, DateTimeStyles.None, out DateTime dt)) { - result = dt; - - return true; + return TryManualParse (text, out result); } + result = dt; + + return true; // Fallback: try manual parsing for partial/invalid input - return TryManualParse (text, out result); } /// diff --git a/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs b/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs index 8ce375f9f6..ef3eb9629b 100644 --- a/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs +++ b/Terminal.Gui/Views/TextInput/NetMaskedTextProvider.cs @@ -110,7 +110,7 @@ public bool Delete (int pos) if (result) { - OnTextChanged (new EventArgs (in oldValue)); + RaiseTextChanged (new EventArgs (in oldValue)); } return result; @@ -124,19 +124,25 @@ public bool InsertAt (char ch, int pos) if (result) { - OnTextChanged (new EventArgs (in oldValue)); + RaiseTextChanged (new EventArgs (in oldValue)); } return result; } /// - /// Raises the TextChanged event to notify subscribers that the text has changed. + /// Called when the text has changed. Subclasses can override this to perform custom actions. /// - /// - /// Call this method to trigger the TextChanged event when the text value is updated. Subscribers - /// can use this event to respond to changes in the text. - /// /// Contains the event data for the text change. - public void OnTextChanged (EventArgs args) => TextChanged?.Invoke (this, args); + public virtual void OnTextChanged (EventArgs args) { } + + /// + /// Raises the event. + /// + /// Contains the event data for the text change. + private void RaiseTextChanged (EventArgs args) + { + OnTextChanged (args); + TextChanged?.Invoke (this, args); + } } diff --git a/Terminal.Gui/Views/TextInput/TextRegexProvider.cs b/Terminal.Gui/Views/TextInput/TextRegexProvider.cs index 6f685d6937..b59bce0304 100644 --- a/Terminal.Gui/Views/TextInput/TextRegexProvider.cs +++ b/Terminal.Gui/Views/TextInput/TextRegexProvider.cs @@ -7,7 +7,7 @@ public class TextRegexProvider : ITextValidateProvider { private List _pattern = null!; private Regex _regex = null!; - private List _text = null!; + private List _text = []; /// Empty Constructor. public TextRegexProvider (string pattern) => Pattern = pattern; @@ -36,7 +36,7 @@ public string Text get => StringExtensions.ToString (_text); set { - _text = (value != string.Empty ? value.ToRuneList () : null)!; + _text = value != string.Empty ? value.ToRuneList () : []; SetupText (); } } @@ -103,7 +103,7 @@ public bool Delete (int pos) } string oldValue = Text; _text.RemoveAt (pos); - OnTextChanged (new EventArgs (in oldValue)); + RaiseTextChanged (new EventArgs (in oldValue)); return true; } @@ -120,16 +120,26 @@ public bool InsertAt (char ch, int pos) } string oldValue = Text; _text.Insert (pos, (Rune)ch); - OnTextChanged (new EventArgs (in oldValue)); + RaiseTextChanged (new EventArgs (in oldValue)); return true; } /// - /// Raises the TextChanged event to notify subscribers that the text has changed. + /// Called when the text has changed. Subclasses can override this to perform custom actions. /// /// Contains the event data representing the new text value. - public void OnTextChanged (EventArgs args) => TextChanged?.Invoke (this, args); + public virtual void OnTextChanged (EventArgs args) { } + + /// + /// Raises the event. + /// + /// Contains the event data representing the new text value. + private void RaiseTextChanged (EventArgs args) + { + OnTextChanged (args); + TextChanged?.Invoke (this, args); + } /// Compiles the regex pattern for validation./> private void CompileMask () => _regex = new Regex (StringExtensions.ToString (_pattern), RegexOptions.Compiled); diff --git a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs index 11d539af7b..8415444d47 100644 --- a/Terminal.Gui/Views/TextInput/TimeTextProvider.cs +++ b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs @@ -70,7 +70,7 @@ public DateTimeFormatInfo Format _format = value; _separator = value.TimeSeparator; AnalyzePattern (); - OnTextChanged (new EventArgs (in string.Empty)); + RaiseTextChanged (new EventArgs (in string.Empty)); } } @@ -106,7 +106,7 @@ public string Text if (oldValue != Text) { - OnTextChanged (new EventArgs (in oldValue)); + RaiseTextChanged (new EventArgs (in oldValue)); } } } @@ -244,7 +244,7 @@ public bool Delete (int pos) return false; } _timeValue = newValue; - OnTextChanged (new EventArgs (in oldValue)); + RaiseTextChanged (new EventArgs (in oldValue)); return true; } @@ -272,7 +272,7 @@ public bool InsertAt (char ch, int pos) } _timeValue = new TimeSpan (hours, _timeValue.Minutes, _timeValue.Seconds); - OnTextChanged (new EventArgs (in oldValue)); + RaiseTextChanged (new EventArgs (in oldValue)); return true; } @@ -297,20 +297,26 @@ public bool InsertAt (char ch, int pos) return false; } _timeValue = newValue; - OnTextChanged (new EventArgs (in oldValue)); + RaiseTextChanged (new EventArgs (in oldValue)); return true; } /// - /// Raises the TextChanged event to notify subscribers that the text has changed. + /// Called when the text has changed. Subclasses can override this to perform custom actions. + /// + /// Contains the event data for the text change. + public virtual void OnTextChanged (EventArgs args) { } + + /// + /// Raises the event. /// - /// - /// Call this method to trigger the TextChanged event when the text value is updated. Subscribers - /// can use this event to respond to changes in the text. - /// /// Contains the event data for the text change. - public void OnTextChanged (EventArgs args) => TextChanged?.Invoke (this, args); + private void RaiseTextChanged (EventArgs args) + { + OnTextChanged (args); + TextChanged?.Invoke (this, args); + } /// /// Analyzes the LongTimePattern to detect format characteristics. From 6b0fa2a2d051d02a0c35da670c0dc5d507520787 Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 9 Mar 2026 07:40:56 -0600 Subject: [PATCH 23/26] Fixed test bug. --- Terminal.Gui/Views/TextInput/TextValidateField.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Terminal.Gui/Views/TextInput/TextValidateField.cs b/Terminal.Gui/Views/TextInput/TextValidateField.cs index 2139a8da5c..4c64116437 100644 --- a/Terminal.Gui/Views/TextInput/TextValidateField.cs +++ b/Terminal.Gui/Views/TextInput/TextValidateField.cs @@ -50,8 +50,13 @@ protected override void OnHasFocusChanged (bool newHasFocus, View? previousFocus return; } + if (_provider is null) + { + return; + } + // When we gain focus, put the insertion point at the start if it's before the start. - InsertionPoint = Math.Max (InsertionPoint, _provider!.CursorStart ()); + InsertionPoint = Math.Max (InsertionPoint, _provider.CursorStart ()); // Match the cursor position to the insertion point. // Don't call UpdateCursor so we can set the style too. From dab9d58e5669ed3b136229322d94df6e2aec278e Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 9 Mar 2026 08:02:47 -0600 Subject: [PATCH 24/26] Update TimeAndDate scenario: remove center alignment, add DatePicker and Prompt demo - Remove Pos.Center() and center text alignment from all editors and labels - Add inline DatePicker synced bidirectionally with default DateEditor - Add Prompt button that opens modal date picker dialog Co-Authored-By: Claude Opus 4.6 --- Examples/UICatalog/Scenarios/TimeAndDate.cs | 98 +++++++++++++++++---- 1 file changed, 80 insertions(+), 18 deletions(-) diff --git a/Examples/UICatalog/Scenarios/TimeAndDate.cs b/Examples/UICatalog/Scenarios/TimeAndDate.cs index 3492ec2691..341735d6c4 100644 --- a/Examples/UICatalog/Scenarios/TimeAndDate.cs +++ b/Examples/UICatalog/Scenarios/TimeAndDate.cs @@ -1,10 +1,10 @@ -#nullable enable +#nullable enable using System; using System.Globalization; namespace UICatalog.Scenarios; -[ScenarioMetadata ("Time And Date", "Illustrates TimeEditor, DateEditor, and time & date handling")] +[ScenarioMetadata ("Time And Date", "Illustrates TimeEditor, DateEditor, DatePicker, and Prompt")] [ScenarioCategory ("Controls")] [ScenarioCategory ("DateTime")] public class TimeAndDate : Scenario @@ -24,8 +24,8 @@ public override void Main () // ── TimeEditor examples ────────────────────────────────────── Label teLabel = new () { - X = Pos.Center (), - Y = 1, + X = 0, + Y = 0, Text = "TimeEditor (based on TextValidateField):" }; win.Add (teLabel); @@ -33,7 +33,7 @@ public override void Main () // Default culture time editor TimeEditor defaultTimeEditor = new () { - X = Pos.Center (), + X = 0, Y = Pos.Bottom (teLabel), Value = DateTime.Now.TimeOfDay }; @@ -53,7 +53,7 @@ public override void Main () TimeEditor time24Editor = new () { - X = Pos.Center (), + X = 0, Y = Pos.Bottom (defaultTimeEditor) + 1, Value = DateTime.Now.TimeOfDay, Format = format24h @@ -75,7 +75,7 @@ public override void Main () TimeEditor shortTimeEditor = new () { - X = Pos.Center (), + X = 0, Y = Pos.Bottom (time24Editor) + 1, Value = DateTime.Now.TimeOfDay, Format = shortFormat @@ -93,10 +93,8 @@ public override void Main () _lblTimeEditorValue = new () { - X = Pos.Center (), + X = 0, Y = Pos.Bottom (shortTimeEditor) + 1, - TextAlignment = Alignment.Center, - Width = Dim.Fill (), Text = "TimeEditor Value: " }; win.Add (_lblTimeEditorValue); @@ -104,8 +102,8 @@ public override void Main () // ── DateEditor examples ────────────────────────────────────── Label deLabel = new () { - X = Pos.Center (), - Y = Pos.Bottom (_lblTimeEditorValue) + 2, + X = 0, + Y = Pos.Bottom (_lblTimeEditorValue) + 1, Text = "DateEditor (based on TextValidateField):" }; win.Add (deLabel); @@ -113,7 +111,7 @@ public override void Main () // Default culture date editor DateEditor defaultDateEditor = new () { - X = Pos.Center (), + X = 0, Y = Pos.Bottom (deLabel), Value = DateTime.Today }; @@ -133,7 +131,7 @@ public override void Main () DateEditor usDateEditor = new () { - X = Pos.Center (), + X = 0, Y = Pos.Bottom (defaultDateEditor) + 1, Value = DateTime.Today, Format = usFormat @@ -154,7 +152,7 @@ public override void Main () DateEditor germanDateEditor = new () { - X = Pos.Center (), + X = 0, Y = Pos.Bottom (usDateEditor) + 1, Value = DateTime.Today, Format = deFormat @@ -172,14 +170,78 @@ public override void Main () _lblDateEditorValue = new () { - X = Pos.Center (), + X = 0, Y = Pos.Bottom (germanDateEditor) + 1, - TextAlignment = Alignment.Center, - Width = Dim.Fill (), Text = "DateEditor Value: " }; win.Add (_lblDateEditorValue); + // ── Inline DatePicker synced to default DateEditor ─────────── + Label dpLabel = new () + { + X = Pos.Percent (50), + Y = Pos.Top (deLabel), + Text = "DatePicker (synced with default DateEditor):" + }; + win.Add (dpLabel); + + DatePicker inlineDatePicker = new (defaultDateEditor.Value ?? DateTime.Today) + { + X = Pos.Percent (50), + Y = Pos.Bottom (dpLabel) + }; + win.Add (inlineDatePicker); + + // Sync DateEditor → DatePicker + defaultDateEditor.ValueChanged += (_, e) => + { + if (e.NewValue.HasValue) + { + inlineDatePicker.Value = e.NewValue.Value; + } + }; + + // Sync DatePicker → DateEditor + inlineDatePicker.ValueChanged += (_, e) => defaultDateEditor.Value = e.NewValue; + + // ── Prompt button ──────────────────────────────── + Button promptDatePickerButton = new () + { + X = Pos.Percent (50), + Y = Pos.Bottom (inlineDatePicker) + 1, + Text = "Prompt..." + }; + win.Add (promptDatePickerButton); + + Label promptResultLabel = new () + { + X = Pos.Percent (50), + Y = Pos.Bottom (promptDatePickerButton), + Text = "Prompt result: (none)" + }; + win.Add (promptResultLabel); + + promptDatePickerButton.Accepting += (_, _) => + { + DateTime? result = win.Prompt ( + view: new DatePicker (defaultDateEditor.Value ?? DateTime.Today), + resultExtractor: dp => dp.Value, + beginInitHandler: prompt => + { + prompt.Title = "Pick a Date"; + }); + + if (result is { } selectedDate) + { + promptResultLabel.Text = $"Prompt result: {selectedDate:d}"; + defaultDateEditor.Value = selectedDate; + } + else + { + promptResultLabel.Text = "Prompt result: (cancelled)"; + } + }; + app.Run (win); } From 5b140f6d6c37f54321d2fddcf95d7e20543e2105 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:26:51 +0000 Subject: [PATCH 25/26] Fix DateEditor.Value to be non-nullable (DateTime instead of DateTime?) Co-authored-by: tig <585482+tig@users.noreply.github.com> --- Examples/UICatalog/Scenarios/TimeAndDate.cs | 11 ++---- Terminal.Gui/Views/DatePicker.cs | 13 ++----- Terminal.Gui/Views/TextInput/DateEditor.cs | 37 +++++++------------ .../Views/DateEditorTests.cs | 18 ++++----- 4 files changed, 31 insertions(+), 48 deletions(-) diff --git a/Examples/UICatalog/Scenarios/TimeAndDate.cs b/Examples/UICatalog/Scenarios/TimeAndDate.cs index 341735d6c4..514dd542ee 100644 --- a/Examples/UICatalog/Scenarios/TimeAndDate.cs +++ b/Examples/UICatalog/Scenarios/TimeAndDate.cs @@ -185,7 +185,7 @@ public override void Main () }; win.Add (dpLabel); - DatePicker inlineDatePicker = new (defaultDateEditor.Value ?? DateTime.Today) + DatePicker inlineDatePicker = new (defaultDateEditor.Value) { X = Pos.Percent (50), Y = Pos.Bottom (dpLabel) @@ -195,10 +195,7 @@ public override void Main () // Sync DateEditor → DatePicker defaultDateEditor.ValueChanged += (_, e) => { - if (e.NewValue.HasValue) - { - inlineDatePicker.Value = e.NewValue.Value; - } + inlineDatePicker.Value = e.NewValue; }; // Sync DatePicker → DateEditor @@ -224,7 +221,7 @@ public override void Main () promptDatePickerButton.Accepting += (_, _) => { DateTime? result = win.Prompt ( - view: new DatePicker (defaultDateEditor.Value ?? DateTime.Today), + view: new DatePicker (defaultDateEditor.Value), resultExtractor: dp => dp.Value, beginInitHandler: prompt => { @@ -245,7 +242,7 @@ public override void Main () app.Run (win); } - private void DateEditorChanged (object? sender, ValueChangedEventArgs e) + private void DateEditorChanged (object? sender, ValueChangedEventArgs e) { _lblDateEditorValue!.Text = $"DateEditor Value: {e.NewValue:d}"; } diff --git a/Terminal.Gui/Views/DatePicker.cs b/Terminal.Gui/Views/DatePicker.cs index 140b5237d6..ba9b6735f3 100644 --- a/Terminal.Gui/Views/DatePicker.cs +++ b/Terminal.Gui/Views/DatePicker.cs @@ -173,18 +173,13 @@ private DataTable CreateDataTable (int month, int year) return _table; } - private void DateEditor_ValueChanged (object? sender, ValueChangedEventArgs e) + private void DateEditor_ValueChanged (object? sender, ValueChangedEventArgs e) { - if (!e.NewValue.HasValue) - { - return; - } - - Value = e.NewValue.Value; + Value = e.NewValue; - if (e.NewValue.Value.Day != Value.Day) + if (e.NewValue.Day != Value.Day) { - SelectDayOnCalendar (e.NewValue.Value.Day); + SelectDayOnCalendar (e.NewValue.Day); } if (Value.Month == DateTime.MinValue.Month && Value.Year == DateTime.MinValue.Year) diff --git a/Terminal.Gui/Views/TextInput/DateEditor.cs b/Terminal.Gui/Views/TextInput/DateEditor.cs index f7ee50426f..7f246db402 100644 --- a/Terminal.Gui/Views/TextInput/DateEditor.cs +++ b/Terminal.Gui/Views/TextInput/DateEditor.cs @@ -39,11 +39,11 @@ namespace Terminal.Gui.Views; /// /// /// -public class DateEditor : TextValidateField, IValue, IDesignable +public class DateEditor : TextValidateField, IValue, IDesignable { private DateTextProvider DateProvider => (DateTextProvider)Provider!; - private DateTime? _value; + private DateTime _value; /// /// Initializes a new instance of the class. @@ -94,7 +94,7 @@ public DateTimeFormatInfo Format /// to . /// /// - public new DateTime? Value + public new DateTime Value { get => _value; set => @@ -106,12 +106,7 @@ public DateTimeFormatInfo Format newValue => { SuppressValueEvents = true; - - if (newValue.HasValue) - { - DateProvider.DateValue = newValue.Value; - } - + DateProvider.DateValue = newValue; base.Text = DateProvider.Text; SuppressValueEvents = false; SetNeedsDraw (); @@ -122,10 +117,10 @@ public DateTimeFormatInfo Format } /// - public new event EventHandler>? ValueChanging; + public new event EventHandler>? ValueChanging; /// - public new event EventHandler>? ValueChanged; + public new event EventHandler>? ValueChanged; /// public new event EventHandler>? ValueChangedUntyped; @@ -145,14 +140,14 @@ public DateTimeFormatInfo Format /// /// The event arguments. /// to cancel the change; otherwise . - protected virtual bool OnValueChanging (ValueChangingEventArgs args) => false; + protected virtual bool OnValueChanging (ValueChangingEventArgs args) => false; /// /// Called when the has changed. /// Allows derived classes to react to value changes. /// /// The event arguments. - protected virtual void OnValueChanged (ValueChangedEventArgs args) => + protected virtual void OnValueChanged (ValueChangedEventArgs args) => ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (args.OldValue, args.NewValue)); #endregion @@ -163,15 +158,15 @@ protected virtual void OnValueChanged (ValueChangedEventArgs args) => /// protected override void HandleProviderTextChanged (string oldText, string newText) { - DateTime? newDateValue = DateProvider.DateValue; + DateTime newDateValue = DateProvider.DateValue; if (_value == newDateValue) { return; } - DateTime? oldDateValue = _value; - ValueChangingEventArgs args = new (oldDateValue, newDateValue); + DateTime oldDateValue = _value; + ValueChangingEventArgs args = new (oldDateValue, newDateValue); if (OnValueChanging (args) || args.Handled) { @@ -190,19 +185,15 @@ protected override void HandleProviderTextChanged (string oldText, string newTex } _value = newDateValue; - ValueChangedEventArgs changedArgs = new (oldDateValue, newDateValue); + ValueChangedEventArgs changedArgs = new (oldDateValue, newDateValue); OnValueChanged (changedArgs); ValueChanged?.Invoke (this, changedArgs); } - private void RevertDateValue (DateTime? oldValue) + private void RevertDateValue (DateTime oldValue) { SuppressValueEvents = true; - - if (oldValue.HasValue) - { - DateProvider.DateValue = oldValue.Value; - } + DateProvider.DateValue = oldValue; base.Text = DateProvider.Text; SuppressValueEvents = false; diff --git a/Tests/UnitTestsParallelizable/Views/DateEditorTests.cs b/Tests/UnitTestsParallelizable/Views/DateEditorTests.cs index 16513c357e..8c98a70a7e 100644 --- a/Tests/UnitTestsParallelizable/Views/DateEditorTests.cs +++ b/Tests/UnitTestsParallelizable/Views/DateEditorTests.cs @@ -86,8 +86,8 @@ public void ValueChanged_Event_Fires () { DateEditor de = new () { Value = new DateTime (2024, 1, 1) }; var eventFired = false; - DateTime? oldValue = null; - DateTime? newValue = null; + DateTime oldValue = DateTime.MinValue; + DateTime newValue = DateTime.MinValue; de.ValueChanged += (_, e) => { @@ -396,9 +396,9 @@ public void DateEditor_Text_Property_Updates_Value () de.Text = "03/15/2024"; // Value should be updated - Assert.Equal (2024, de.Value!.Value.Year); - Assert.Equal (3, de.Value.Value.Month); - Assert.Equal (15, de.Value.Value.Day); + Assert.Equal (2024, de.Value.Year); + Assert.Equal (3, de.Value.Month); + Assert.Equal (15, de.Value.Day); } [Fact] @@ -429,9 +429,9 @@ public void DateEditor_Multiple_Format_Changes () de.Layout (); // Value should remain the same - Assert.Equal (2024, de.Value!.Value.Year); - Assert.Equal (3, de.Value.Value.Month); - Assert.Equal (15, de.Value.Value.Day); + Assert.Equal (2024, de.Value.Year); + Assert.Equal (3, de.Value.Month); + Assert.Equal (15, de.Value.Day); } } @@ -733,7 +733,7 @@ public void DateEditor_Typing_Date_Renders_Correctly () output.WriteLine ($"After '2': \"{de.Provider.DisplayText}\""); // Month should now be 12 - Assert.Equal (12, de.Value!.Value.Month); + Assert.Equal (12, de.Value.Month); DriverAssert.AssertDriverContentsWithFrameAre (@"12/01/2024", output, app.Driver); } From 62e5aa053ba0aec9effbee6c4a027f6463c322eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:34:48 +0000 Subject: [PATCH 26/26] Fix DatePicker Culture and Value propagation to embedded DateEditor Co-authored-by: tig <585482+tig@users.noreply.github.com> --- Terminal.Gui/Views/DatePicker.cs | 14 ++++- .../Views/DatePickerTests.cs | 63 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/Terminal.Gui/Views/DatePicker.cs b/Terminal.Gui/Views/DatePicker.cs index ba9b6735f3..95e2714e8e 100644 --- a/Terminal.Gui/Views/DatePicker.cs +++ b/Terminal.Gui/Views/DatePicker.cs @@ -36,6 +36,12 @@ public CultureInfo? Culture { CultureInfo.CurrentCulture = value; Text = Value.ToString (Format); + + // Propagate format to embedded editor + if (_dateEditor is { }) + { + _dateEditor.Format = value.DateTimeFormat; + } } } } @@ -84,6 +90,12 @@ public DateTime Value _date = value; + // Propagate value to embedded editor + if (_dateEditor is { }) + { + _dateEditor.Value = value; + } + ValueChangedEventArgs changedArgs = new (oldValue, _date); OnValueChanged (changedArgs); ValueChanged?.Invoke (this, changedArgs); @@ -269,7 +281,7 @@ private void SetInitialProperties (DateTime date) Y = 0, Width = Dim.Width (_calendar) - Dim.Width (_dateLabel), Height = 1, - Value = DateTime.Now + Value = date }; _previousMonthButton = new Button diff --git a/Tests/UnitTestsParallelizable/Views/DatePickerTests.cs b/Tests/UnitTestsParallelizable/Views/DatePickerTests.cs index da88632a20..beb5981404 100644 --- a/Tests/UnitTestsParallelizable/Views/DatePickerTests.cs +++ b/Tests/UnitTestsParallelizable/Views/DatePickerTests.cs @@ -97,4 +97,67 @@ public void DatePicker_X_Y_Init () Assert.Equal (DateTime.Now.Date.Month, datePicker.Value.Month); Assert.Equal (DateTime.Now.Date.Year, datePicker.Value.Year); } + + [Fact] + public void DatePicker_Constructor_InitializesEmbeddedEditor_WithCorrectValue () + { + // Test that embedded DateEditor is initialized with the DatePicker's value, not DateTime.Now + DateTime testDate = new DateTime (2020, 5, 15); + DatePicker datePicker = new DatePicker (testDate); + datePicker.BeginInit (); + datePicker.EndInit (); + + // Get the embedded editor + DateEditor? editor = datePicker.SubViews.FirstOrDefault (v => v.Id == "_dateEditor") as DateEditor; + Assert.NotNull (editor); + Assert.Equal (testDate, editor.Value); + + datePicker.Dispose (); + } + + [Fact] + public void DatePicker_Culture_PropagatesTo_EmbeddedEditor () + { + // Test that changing Culture propagates Format to the embedded DateEditor + DateTime testDate = new DateTime (2024, 3, 15); + DatePicker datePicker = new DatePicker (testDate); + datePicker.BeginInit (); + datePicker.EndInit (); + + DateEditor? editor = datePicker.SubViews.FirstOrDefault (v => v.Id == "_dateEditor") as DateEditor; + Assert.NotNull (editor); + + // Change culture + CultureInfo germanCulture = CultureInfo.GetCultureInfo ("de-DE"); + datePicker.Culture = germanCulture; + + // Verify the editor's Format was updated + Assert.Equal (germanCulture.DateTimeFormat, editor.Format); + + datePicker.Dispose (); + } + + [Fact] + public void DatePicker_Value_PropagatesTo_EmbeddedEditor () + { + // Test that changing DatePicker.Value updates the embedded DateEditor.Value + DateTime initialDate = new DateTime (2020, 1, 1); + DateTime newDate = new DateTime (2024, 12, 25); + + DatePicker datePicker = new DatePicker (initialDate); + datePicker.BeginInit (); + datePicker.EndInit (); + + DateEditor? editor = datePicker.SubViews.FirstOrDefault (v => v.Id == "_dateEditor") as DateEditor; + Assert.NotNull (editor); + Assert.Equal (initialDate, editor.Value); + + // Change the DatePicker's value + datePicker.Value = newDate; + + // Verify the editor's value was updated + Assert.Equal (newDate, editor.Value); + + datePicker.Dispose (); + } }