diff --git a/.tg-docs/INDEX.md b/.tg-docs/INDEX.md index 9d5229005d..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,TimeField.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 37238ac059..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,TimeField.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 61b017917a..4cd06a7749 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 () { @@ -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 () @@ -227,56 +224,48 @@ 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; }; - // 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 (dateEditor), X = Pos.Right (labelMirroringDateEditor) + 5 }; win.Add (label); - _timeField = new TimeField - { - X = Pos.Right (label) + 1, - Y = Pos.Top (dateField), - Width = 20, - IsShortFormat = false, - Value = DateTime.Now.TimeOfDay - }; - win.Add (_timeField); + _timeEditor = new TimeEditor { X = Pos.Right (label) + 1, Y = Pos.Top (dateEditor), Value = DateTime.Now.TimeOfDay }; + 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 - 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 (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 }; win.Add (netProviderField); @@ -294,14 +283,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, @@ -475,9 +464,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 dcd5192c4b..514dd542ee 100644 --- a/Examples/UICatalog/Scenarios/TimeAndDate.cs +++ b/Examples/UICatalog/Scenarios/TimeAndDate.cs @@ -1,19 +1,16 @@ -#nullable enable +#nullable enable using System; +using System.Globalization; namespace UICatalog.Scenarios; -[ScenarioMetadata ("Time And Date", "Illustrates TimeField and time & date handling")] +[ScenarioMetadata ("Time And Date", "Illustrates TimeEditor, DateEditor, DatePicker, and Prompt")] [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? _lblDateEditorValue; + private Label? _lblTimeEditorValue; public override void Main () { @@ -23,136 +20,235 @@ public override void Main () app.Init (); using Window win = new () { Title = GetQuitKeyAndName () }; - TimeField longTime = new () + + // ── TimeEditor examples ────────────────────────────────────── + Label teLabel = new () { - X = Pos.Center (), - Y = 2, - IsShortFormat = false, - ReadOnly = false, - Value = DateTime.Now.TimeOfDay + X = 0, + Y = 0, + Text = "TimeEditor (based on TextValidateField):" }; - longTime.ValueChanged += TimeChanged; - win.Add (longTime); + win.Add (teLabel); - TimeField shortTime = new () + // Default culture time editor + TimeEditor defaultTimeEditor = new () { - X = Pos.Center (), - Y = Pos.Bottom (longTime) + 1, - IsShortFormat = true, - ReadOnly = false, + X = 0, + Y = Pos.Bottom (teLabel), Value = DateTime.Now.TimeOfDay }; - shortTime.ValueChanged += TimeChanged; - win.Add (shortTime); + defaultTimeEditor.ValueChanged += TimeEditorChanged; + win.Add (defaultTimeEditor); - DateField shortDate = new (DateTime.Now) + Label defaultPatternLabel = new () { - X = Pos.Center (), Y = Pos.Bottom (shortTime) + 1, ReadOnly = true + X = Pos.Right (defaultTimeEditor) + 1, + Y = Pos.Top (defaultTimeEditor), + Text = $"Pattern: {CultureInfo.CurrentCulture.DateTimeFormat.LongTimePattern}" }; - shortDate.ValueChanged += DateChanged; - win.Add (shortDate); + win.Add (defaultPatternLabel); - DateField longDate = new (DateTime.Now) + // 24-hour format time editor + DateTimeFormatInfo format24h = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-GB").DateTimeFormat.Clone (); + + TimeEditor time24Editor = new () { - X = Pos.Center (), Y = Pos.Bottom (shortDate) + 1, ReadOnly = false + X = 0, + Y = Pos.Bottom (defaultTimeEditor) + 1, + Value = DateTime.Now.TimeOfDay, + Format = format24h }; - longDate.ValueChanged += DateChanged; - win.Add (longDate); + time24Editor.ValueChanged += TimeEditorChanged; + win.Add (time24Editor); - _lblOldTime = new() + Label time24PatternLabel = new () { - X = Pos.Center (), - Y = Pos.Bottom (longDate) + 1, - TextAlignment = Alignment.Center, + X = Pos.Right (time24Editor) + 1, + Y = Pos.Top (time24Editor), + Text = $"Pattern: {format24h.LongTimePattern}" + }; + win.Add (time24PatternLabel); - Width = Dim.Fill (), - Text = "Old Time: " + // Short time format time editor + DateTimeFormatInfo shortFormat = (DateTimeFormatInfo)CultureInfo.CurrentCulture.DateTimeFormat.Clone (); + shortFormat.LongTimePattern = shortFormat.ShortTimePattern; + + TimeEditor shortTimeEditor = new () + { + X = 0, + Y = Pos.Bottom (time24Editor) + 1, + Value = DateTime.Now.TimeOfDay, + Format = shortFormat }; - win.Add (_lblOldTime); + shortTimeEditor.ValueChanged += TimeEditorChanged; + win.Add (shortTimeEditor); - _lblNewTime = new() + Label shortPatternLabel = new () { - X = Pos.Center (), - Y = Pos.Bottom (_lblOldTime) + 1, - TextAlignment = Alignment.Center, + X = Pos.Right (shortTimeEditor) + 1, + Y = Pos.Top (shortTimeEditor), + Text = $"Pattern: {shortFormat.LongTimePattern}" + }; + win.Add (shortPatternLabel); - Width = Dim.Fill (), - Text = "New Time: " + _lblTimeEditorValue = new () + { + X = 0, + Y = Pos.Bottom (shortTimeEditor) + 1, + Text = "TimeEditor Value: " + }; + win.Add (_lblTimeEditorValue); + + // ── DateEditor examples ────────────────────────────────────── + Label deLabel = new () + { + X = 0, + Y = Pos.Bottom (_lblTimeEditorValue) + 1, + Text = "DateEditor (based on TextValidateField):" }; - win.Add (_lblNewTime); + win.Add (deLabel); - _lblTimeFmt = new() + // Default culture date editor + DateEditor defaultDateEditor = new () { - X = Pos.Center (), - Y = Pos.Bottom (_lblNewTime) + 1, - TextAlignment = Alignment.Center, + X = 0, + Y = Pos.Bottom (deLabel), + Value = DateTime.Today + }; + defaultDateEditor.ValueChanged += DateEditorChanged; + win.Add (defaultDateEditor); - Width = Dim.Fill (), - Text = "Time Format: " + Label defaultDatePatternLabel = new () + { + X = Pos.Right (defaultDateEditor) + 1, + Y = Pos.Top (defaultDateEditor), + Text = $"Pattern: {CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern}" }; - win.Add (_lblTimeFmt); + win.Add (defaultDatePatternLabel); - _lblOldDate = new() + // US format date editor + DateTimeFormatInfo usFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("en-US").DateTimeFormat.Clone (); + + DateEditor usDateEditor = new () { - X = Pos.Center (), - Y = Pos.Bottom (_lblTimeFmt) + 2, - TextAlignment = Alignment.Center, + X = 0, + Y = Pos.Bottom (defaultDateEditor) + 1, + Value = DateTime.Today, + Format = usFormat + }; + usDateEditor.ValueChanged += DateEditorChanged; + win.Add (usDateEditor); - Width = Dim.Fill (), - Text = "Old Date: " + Label usDatePatternLabel = new () + { + X = Pos.Right (usDateEditor) + 1, + Y = Pos.Top (usDateEditor), + Text = $"Pattern: {usFormat.ShortDatePattern}" }; - win.Add (_lblOldDate); + win.Add (usDatePatternLabel); + + // German format date editor + DateTimeFormatInfo deFormat = (DateTimeFormatInfo)CultureInfo.GetCultureInfo ("de-DE").DateTimeFormat.Clone (); - _lblNewDate = new() + DateEditor germanDateEditor = new () { - X = Pos.Center (), - Y = Pos.Bottom (_lblOldDate) + 1, - TextAlignment = Alignment.Center, + X = 0, + Y = Pos.Bottom (usDateEditor) + 1, + Value = DateTime.Today, + Format = deFormat + }; + germanDateEditor.ValueChanged += DateEditorChanged; + win.Add (germanDateEditor); - Width = Dim.Fill (), - Text = "New Date: " + Label germanDatePatternLabel = new () + { + X = Pos.Right (germanDateEditor) + 1, + Y = Pos.Top (germanDateEditor), + Text = $"Pattern: {deFormat.ShortDatePattern}" }; - win.Add (_lblNewDate); + win.Add (germanDatePatternLabel); - _lblDateFmt = new() + _lblDateEditorValue = new () { - X = Pos.Center (), - Y = Pos.Bottom (_lblNewDate) + 1, - TextAlignment = Alignment.Center, + X = 0, + Y = Pos.Bottom (germanDateEditor) + 1, + Text = "DateEditor Value: " + }; + win.Add (_lblDateEditorValue); - Width = Dim.Fill (), - Text = "Date Format: " + // ── 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 (_lblDateFmt); + win.Add (dpLabel); - Button swapButton = new () + DatePicker inlineDatePicker = new (defaultDateEditor.Value) { - X = Pos.Center (), Y = Pos.Bottom (win) - 5, Text = "Swap Long/Short & Read/Read Only" + X = Pos.Percent (50), + Y = Pos.Bottom (dpLabel) }; + win.Add (inlineDatePicker); + + // Sync DateEditor → DatePicker + defaultDateEditor.ValueChanged += (_, e) => + { + inlineDatePicker.Value = e.NewValue; + }; - swapButton.Accepting += (_, _) => - { - longTime.ReadOnly = !longTime.ReadOnly; - shortTime.ReadOnly = !shortTime.ReadOnly; + // Sync DatePicker → DateEditor + inlineDatePicker.ValueChanged += (_, e) => defaultDateEditor.Value = e.NewValue; - longTime.IsShortFormat = !longTime.IsShortFormat; - shortTime.IsShortFormat = !shortTime.IsShortFormat; + // ── Prompt button ──────────────────────────────── + Button promptDatePickerButton = new () + { + X = Pos.Percent (50), + Y = Pos.Bottom (inlineDatePicker) + 1, + Text = "Prompt..." + }; + win.Add (promptDatePickerButton); - longDate.ReadOnly = !longDate.ReadOnly; - shortDate.ReadOnly = !shortDate.ReadOnly; - }; - win.Add (swapButton); + 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), + 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); } - 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 TimeChanged (object? sender, ValueChangedEventArgs e) + private void TimeEditorChanged (object? sender, ValueChangedEventArgs e) { - _lblNewTime!.Text = $"New Time: {e.NewValue}"; + _lblTimeEditorValue!.Text = $"TimeEditor Value: {e.NewValue}"; } } diff --git a/Terminal.Gui/Views/DatePicker.cs b/Terminal.Gui/Views/DatePicker.cs index e92835f9e2..95e2714e8e 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; @@ -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); @@ -118,14 +130,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 +147,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,18 +185,13 @@ 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) - { - 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) @@ -267,14 +274,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 = date }; _previousMonthButton = new Button @@ -328,51 +335,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..7f246db402 --- /dev/null +++ b/Terminal.Gui/Views/TextInput/DateEditor.cs @@ -0,0 +1,210 @@ +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; + DateProvider.DateValue = newValue; + 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; + DateProvider.DateValue = oldValue; + + 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..354d717666 --- /dev/null +++ b/Terminal.Gui/Views/TextInput/DateTextProvider.cs @@ -0,0 +1,445 @@ +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 (); + RaiseTextChanged (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) + { + RaiseTextChanged (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; + RaiseTextChanged (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; + RaiseTextChanged (new EventArgs (in oldValue)); + + return true; + } + + /// + /// 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 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. + /// + 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) + { + return cleaned; + } + + // 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)) + { + return TryManualParse (text, out result); + } + result = dt; + + return true; + + // Fallback: try manual parsing for partial/invalid input + } + + /// + /// 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/Terminal.Gui/Views/TextInput/ITextValidateProvider.cs b/Terminal.Gui/Views/TextInput/ITextValidateProvider.cs index 03c614da9c..e5f67aa267 100644 --- a/Terminal.Gui/Views/TextInput/ITextValidateProvider.cs +++ b/Terminal.Gui/Views/TextInput/ITextValidateProvider.cs @@ -1,5 +1,3 @@ - - namespace Terminal.Gui.Views; /// TextValidateField Providers Interface. All TextValidateField are created with a ITextValidateProvider. @@ -51,11 +49,6 @@ public interface ITextValidateProvider /// true if the character was successfully inserted, otherwise false. bool InsertAt (char ch, int pos); - /// 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 0ac0659cd6..ef3eb9629b 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 @@ -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)); + RaiseTextChanged (new EventArgs (in oldValue)); } return result; @@ -139,12 +124,25 @@ public bool InsertAt (char ch, int pos) if (result) { - OnTextChanged (new (in oldValue)); + RaiseTextChanged (new EventArgs (in oldValue)); } return result; } - /// - public void OnTextChanged (EventArgs args) { TextChanged?.Invoke (this, args); } + /// + /// 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. + /// + /// 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/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 34ad5e6ffa..b59bce0304 100644 --- a/Terminal.Gui/Views/TextInput/TextRegexProvider.cs +++ b/Terminal.Gui/Views/TextInput/TextRegexProvider.cs @@ -1,4 +1,3 @@ - using System.Text.RegularExpressions; namespace Terminal.Gui.Views; @@ -8,10 +7,10 @@ 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; } + public TextRegexProvider (string pattern) => Pattern = pattern; /// Regex pattern property. public string Pattern @@ -29,7 +28,7 @@ public string Pattern public bool ValidateOnInput { get; set; } = true; /// - public event EventHandler> TextChanged = null!; + public event EventHandler>? TextChanged; /// public string Text @@ -37,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 (); } } @@ -68,10 +67,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) @@ -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); + RaiseTextChanged (new EventArgs (in oldValue)); return true; } @@ -114,32 +114,44 @@ 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); - OnTextChanged (new (in oldValue)); - - return true; + return false; } + string oldValue = Text; + _text.Insert (pos, (Rune)ch); + RaiseTextChanged (new EventArgs (in oldValue)); - return false; + return true; } - /// - public void OnTextChanged (EventArgs args) { TextChanged?.Invoke (this, args); } + /// + /// Called when the text has changed. Subclasses can override this to perform custom actions. + /// + /// Contains the event data representing the new text value. + 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 (StringExtensions.ToString (_pattern), RegexOptions.Compiled); } + private void CompileMask () => _regex = new Regex (StringExtensions.ToString (_pattern), RegexOptions.Compiled); private void SetupText () { - if (_text is { } && IsValid) + if (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 c3e5587f6e..4c64116437 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. @@ -32,6 +40,56 @@ 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; + } + + 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 ()); + + // 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 { @@ -52,11 +110,23 @@ 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) { - 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 @@ -64,6 +134,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 { @@ -75,8 +182,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 (); } } @@ -94,24 +237,70 @@ 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; + UpdateCursor (); + } + } - if (_provider?.Fixed == false && TextAlignment == Alignment.End) - { - curPos = _insertionPoint + left - 1; - } - else - { - curPos = _insertionPoint + left; - } + /// + /// 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; - Cursor = Cursor with { Position = ViewportToScreen (new Point (curPos, 0)), Style = CursorStyle.Default }; + 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 (); } /// @@ -147,7 +336,7 @@ protected override bool OnDrawingContent (DrawContext? context) return true; } - VisualRole role = HasFocus ? VisualRole.Focus : VisualRole.Editable; + var role = VisualRole.Editable; Attribute textColor = IsValid ? GetAttributeForRole (role) : SchemeManager.GetScheme (Schemes.Error).GetAttributeForRole (role); (int marginLeft, int marginRight) = GetMargins (Viewport.Width); @@ -179,6 +368,15 @@ protected override bool OnDrawingContent (DrawContext? context) AddRune ((Rune)' '); } + if (!HasFocus || _provider.DisplayText.Length <= 0 || InsertionPoint >= _provider.DisplayText.Length) + { + return true; + } + + SetAttributeForRole (VisualRole.Focus); + Move (InsertionPoint + marginLeft, 0); + AddRune ((Rune)_provider.DisplayText [InsertionPoint]); + return true; } @@ -199,14 +397,14 @@ protected override bool OnKeyDownNotHandled (Key key) bool inserted = _provider.InsertAt ((char)rune.Value, InsertionPoint); - if (inserted) + if (!inserted) { - CursorRight (); - - return true; + return false; } - return false; + CursorRight (); + + return true; } /// Delete char at cursor position - 1, moving the cursor. @@ -220,7 +418,9 @@ private bool BackspaceKeyHandler () _insertionPoint = _provider.CursorLeft (InsertionPoint); _provider.Delete (InsertionPoint); + SetNeedsDraw (); + UpdateCursor (); return true; } @@ -251,7 +451,21 @@ 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 ()) + { + // 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 new file mode 100644 index 0000000000..4251e97919 --- /dev/null +++ b/Terminal.Gui/Views/TextInput/TimeEditor.cs @@ -0,0 +1,225 @@ +using System.Globalization; + +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, IDesignable +{ + private TimeTextProvider TimeProvider => (TimeTextProvider)Provider!; + + private TimeSpan _value; + + /// + /// Initializes a new instance of the class. + /// + 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); + _value = TimeProvider.TimeValue; + } + + /// + /// 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; + + // Add one so there is always a blank cell after the last editable character for the cursor. + Width = TimeProvider.DisplayText.Length + 1; + SetNeedsDraw (); + } + } + + #region IValue Implementation + + /// + /// Gets or sets the current time value. + /// + /// + /// + /// Setting this property follows the Cancellable Work Pattern (CWP) using + /// . + /// The change can be prevented by handling and setting + /// to . + /// + /// + public new TimeSpan Value + { + 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 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 = TimeProvider.TimeValue; + + /// + /// 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 TimeEditor by raising -typed + /// value events instead of string-typed events. + /// + protected override void HandleProviderTextChanged (string oldText, string newText) + { + TimeSpan newTimeValue = TimeProvider.TimeValue; + + if (_value == newTimeValue) + { + return; + } + + TimeSpan oldTimeValue = _value; + ValueChangingEventArgs args = new (oldTimeValue, newTimeValue); + + if (OnValueChanging (args) || args.Handled) + { + RevertTimeValue (oldTimeValue); + + return; + } + + ValueChanging?.Invoke (this, args); + + if (args.Handled) + { + RevertTimeValue (oldTimeValue); + + return; + } + + _value = newTimeValue; + ValueChangedEventArgs changedArgs = new (oldTimeValue, newTimeValue); + OnValueChanged (changedArgs); + ValueChanged?.Invoke (this, changedArgs); + } + + 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; + } +} 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/Terminal.Gui/Views/TextInput/TimeTextProvider.cs b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs new file mode 100644 index 0000000000..8415444d47 --- /dev/null +++ b/Terminal.Gui/Views/TextInput/TimeTextProvider.cs @@ -0,0 +1,559 @@ +using System.Globalization; + +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 +{ + // 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 string _normalizedPattern = string.Empty; + private TimeSpan _timeValue = TimeSpan.Zero; + private bool _is12Hour; + private bool _hasAmPm; + private int _fieldLength; + private readonly HashSet _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 (); + RaiseTextChanged (new EventArgs (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)) + { + return; + } + string oldValue = Text; + _timeValue = parsedValue; + _isPm = _timeValue.Hours >= 12; + + if (oldValue != Text) + { + RaiseTextChanged (new EventArgs (in oldValue)); + } + } + } + + /// + public string DisplayText => FormatTimeValue (); + + /// + 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 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])) + { + return false; + } + StringBuilder sb = new (currentText) { [pos] = '0' }; + + if (!TryParseTimeValue (sb.ToString (), out TimeSpan newValue)) + { + return false; + } + _timeValue = newValue; + RaiseTextChanged (new EventArgs (in oldValue)); + + return true; + } + + /// + 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') + { + return false; + } + _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); + RaiseTextChanged (new EventArgs (in oldValue)); + + return true; + } + + // 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) + { + return false; + } + StringBuilder sb = new (currentText) { [pos] = ch }; + + if (!TryParseTimeValue (sb.ToString (), out TimeSpan newValue)) + { + return false; + } + _timeValue = newValue; + RaiseTextChanged (new EventArgs (in oldValue)); + + return true; + } + + /// + /// 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. + /// + /// Contains the event data for the text change. + private void RaiseTextChanged (EventArgs args) + { + OnTextChanged (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"); + + // 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 (_normalizedPattern, _format); + + _fieldLength = formatted.Length; + + // Find separator positions + for (var i = 0; i < formatted.Length; i++) + { + if (formatted [i].ToString () == _separator) + { + _separatorPositions.Add (i); + } + } + + // Find AM/PM position + if (!_hasAmPm) + { + return; + } + 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); + } + + /// + /// 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. + /// + private string FormatTimeValue () + { + DateTime dt = DateTime.Today.Add (_timeValue); + + // For 12-hour format, adjust the hours if needed + if (!_is12Hour || !_hasAmPm) + { + return dt.ToString (_normalizedPattern, _format); + } + 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 (_normalizedPattern, _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, _normalizedPattern, _format, DateTimeStyles.None, out DateTime dt)) + { + return TryManualParse (text, out result); + } + result = dt.TimeOfDay; + + return true; + + // Fallback: try manual parsing for partial/invalid input + } + + /// + /// 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 + var isPm = false; + string lastPart = parts [^1].Trim (); + + if (_hasAmPm) + { + if (lastPart.EndsWith (_format.PMDesignator, StringComparison.OrdinalIgnoreCase)) + { + isPm = true; + lastPart = lastPart [..^_format.PMDesignator.Length].Trim (); + } + else if (lastPart.EndsWith (_format.AMDesignator, StringComparison.OrdinalIgnoreCase)) + { + isPm = false; + lastPart = lastPart [..^_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) + var 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 (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/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index c7fcaeef2e..859d404d40 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -537,6 +537,7 @@ True True True + True True True True 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..8c98a70a7e --- /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 = DateTime.MinValue; + DateTime newValue = DateTime.MinValue; + + 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.Year); + Assert.Equal (3, de.Value.Month); + Assert.Equal (15, de.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.Year); + Assert.Equal (3, de.Value.Month); + Assert.Equal (15, de.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.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/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 (); + } } diff --git a/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs b/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs index ff1dccd342..752c6e06de 100644 --- a/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextValidateFieldTests.cs @@ -71,7 +71,9 @@ 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 +351,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,13 +368,70 @@ 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); } + // 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 () { @@ -640,6 +699,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 new file mode 100644 index 0000000000..6709b3e94d --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/TimeEditorTests.cs @@ -0,0 +1,998 @@ +using System.Globalization; +using UnitTests; + +namespace ViewsTests; + +// Claude - Sonnet 4.6 +public class TimeEditorTests (ITestOutputHelper output) : 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 (); + + // Set initial format explicitly to ensure deterministic test + 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 + 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); + Assert.True (newWidth < initialWidth); + } + + [Fact] + public void ValueChanging_Event_Can_Cancel () + { + TimeEditor te = new () { Value = TimeSpan.FromHours (10) }; + var 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) }; + var 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 (); + + // Use 24-hour format to ensure consistent behavior + 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); + } + + [Fact] + public void TimeTextProvider_InsertAt_ReplacesDigit () + { + TimeTextProvider provider = new (); + + // Use 24-hour format to ensure consistent behavior + 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 ()); + } + + [Fact] + public void TimeTextProvider_Delete_ReplacesWithZero () + { + TimeTextProvider provider = new (); + + // Use 24-hour format to avoid culture-specific issues + 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); + } + + [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 + 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); + } + + [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 = ((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 + 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); + } + + [Fact] + public void TimeTextProvider_24Hour_Format () + { + TimeTextProvider provider = new (); + + // Set to a 24-hour format + 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); + } + + [Fact] + 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 + 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); + } + 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 (); + + // Use 24-hour format to ensure consistent parsing + 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); + 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 (var i = 0; i < 3; i++) + { + 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); + } + } + + [Fact] + public void TimeTextProvider_TryManualParse_PartialInput () + { + TimeTextProvider provider = new (); + + // Use 24-hour format + 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); + 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 + 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); + } + + [Fact] + public void TimeTextProvider_TryManualParse_AutoCorrection () + { + TimeTextProvider provider = new (); + + // Use 24-hour format + 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 + Assert.Equal (59, provider.TimeValue.Seconds); // Max 59 + } + + [Fact] + public void TimeTextProvider_12Hour_AM_PM_Parsing () + { + TimeTextProvider provider = new (); + + // Use 12-hour format + 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); + } + + [Fact] + public void TimeTextProvider_CursorNavigation_Comprehensive () + { + TimeTextProvider provider = new (); + + // Use 24-hour format + 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 (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; + } + } + } + + [Fact] + public void TimeEditor_ValueChanging_Cancel () + { + TimeEditor te = new (); + TimeSpan initialValue = TimeSpan.FromHours (10); + te.Value = initialValue; + + var changingEventFired = false; + var 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 + 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); + } + + [Fact] + public void TimeEditor_ValueChangedUntyped_Event () + { + TimeEditor te = new (); + var 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 + 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); + } + + [Fact] + public void TimeTextProvider_CursorRight_FromEnd () + { + TimeTextProvider provider = new (); + + // Use 24-hour format + var 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 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 + var 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 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 (); + var 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 () + { + // Verifies that typing "12" at position 0 in 24h format sets hours to 12 + TimeTextProvider provider = new (); + var 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 (); + var 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 (); + 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); + + 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 (); + var 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); + + 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 }; + 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 (); + var 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 (); + var 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 (); + var 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 (); + var 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); + } +} 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..9e5c7bf023 100644 --- a/docfx/docs/events.md +++ b/docfx/docs/events.md @@ -485,8 +485,8 @@ public interface IValue : IValue | | `CheckState` | Current checked state (Unchecked, Checked, CheckedMark) | | | `string` | Text content | | | `string` | Full text content | -| `DateField` | `DateTime?` | Selected date and time | -| `TimeField` | `TimeSpan` | Selected time | +| `DateEditor` | `DateTime?` | Selected date | +| `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..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.
@@ -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.