diff --git a/Terminal.Gui/ViewBase/Adornment/ArrangerButton.cs b/Terminal.Gui/ViewBase/Adornment/ArrangerButton.cs index 58c5b108ab..0da318c9be 100644 --- a/Terminal.Gui/ViewBase/Adornment/ArrangerButton.cs +++ b/Terminal.Gui/ViewBase/Adornment/ArrangerButton.cs @@ -108,6 +108,7 @@ public ArrangeButtons ButtonType } _buttonType = value; + SetTextDirect (GetButtonGlyph ()); ApplyOrientationAndDirection (); SetupKeyBindings (); } @@ -123,19 +124,15 @@ public ArrangeButtons ButtonType public NavigationDirection Direction { get; set; } /// - public override string Text - { - get => - ButtonType switch - { - ArrangeButtons.Move => $"{Glyphs.Move}", - ArrangeButtons.AllSize => $"{Glyphs.SizeBottomRight}", - ArrangeButtons.LeftSize or ArrangeButtons.RightSize => $"{Glyphs.SizeHorizontal}", - ArrangeButtons.TopSize or ArrangeButtons.BottomSize => $"{Glyphs.SizeVertical}", - _ => base.Text - }; - set => base.Text = value; - } + private string GetButtonGlyph () => + ButtonType switch + { + ArrangeButtons.Move => $"{Glyphs.Move}", + ArrangeButtons.AllSize => $"{Glyphs.SizeBottomRight}", + ArrangeButtons.LeftSize or ArrangeButtons.RightSize => $"{Glyphs.SizeHorizontal}", + ArrangeButtons.TopSize or ArrangeButtons.BottomSize => $"{Glyphs.SizeVertical}", + _ => Text + }; /// /// Sets and based on . diff --git a/Terminal.Gui/ViewBase/Adornment/TitleView.cs b/Terminal.Gui/ViewBase/Adornment/TitleView.cs index 0274ce5bb2..49c2cd5d1f 100644 --- a/Terminal.Gui/ViewBase/Adornment/TitleView.cs +++ b/Terminal.Gui/ViewBase/Adornment/TitleView.cs @@ -112,7 +112,12 @@ protected override bool OnGettingAttributeForRole (in VisualRole role, ref Attri public NavigationDirection Direction { get; set; } /// - public override string Text { get => base.Text; set => base.Text = Title = value; } + protected override void OnTextChanged () + { + Title = Text; + + base.OnTextChanged (); + } /// /// Binds the appropriate arrow keys to their directional based on diff --git a/Terminal.Gui/ViewBase/View.Text.cs b/Terminal.Gui/ViewBase/View.Text.cs index 5625c77ecd..b58552d540 100644 --- a/Terminal.Gui/ViewBase/View.Text.cs +++ b/Terminal.Gui/ViewBase/View.Text.cs @@ -1,14 +1,11 @@ +using System.ComponentModel; + namespace Terminal.Gui.ViewBase; public partial class View // Text Property APIs { private string _text = string.Empty; - /// - /// Called when the has changed. Fires the event. - /// - public void OnTextChanged () => TextChanged?.Invoke (this, EventArgs.Empty); - /// /// Gets or sets whether trailing spaces at the end of word-wrapped lines are preserved /// or not when is enabled. @@ -50,9 +47,19 @@ public bool PreserveTrailingSpaces /// If or are using , /// the will be adjusted to fit the text. /// - /// When the text changes, the is fired. + /// + /// Setting to the same value as the current value is a no-op; neither + /// nor will be raised. + /// + /// + /// Before the text is changed, the CWP hook is invoked. If cancelled, + /// the text remains unchanged and is not raised. + /// + /// + /// After the text is changed, the event is raised. + /// /// - public virtual string Text + public string Text { get => _text; set @@ -62,14 +69,107 @@ public virtual string Text return; } - _text = value; + if (OnTextChanging (value)) + { + return; + } - UpdateTextFormatterText (); - SetNeedsLayout (); - OnTextChanged (); + SetTextDirect (value); + + RaiseTextChanged (); } } + /// + /// Sets the backing field directly without raising + /// or events and without invoking the + /// or virtual methods. + /// + /// + /// + /// Use this method in derived views that maintain an internal text model (e.g., an editor + /// buffer) and need to keep in sync after internal edits without + /// re-entering the CWP flow. + /// + /// + /// The new text value to store. + protected void SetTextDirect (string value) + { + _text = value; + UpdateTextFormatterText (); + SetNeedsLayout (); + } + + /// + /// Called before the changes. Invokes the event, which can + /// be cancelled. + /// + /// + /// + /// The base implementation raises the event. Override in derived views + /// to perform validation or fire control-specific pre-change events. + /// + /// + /// The proposed new text value. + /// if the text change should be cancelled; otherwise . + protected virtual bool OnTextChanging (string newText) + { + CancelEventArgs args = new (); + TextChanging?.Invoke (this, args); + + return args.Cancel; + } + + /// + /// Raised when the is about to change. Set to + /// to prevent the change. + /// + /// + /// + /// This is a signal-only notification at the level. It does not carry old or new + /// text values. Derived controls that need richer text-edit semantics may expose their own specific events. + /// + /// + public event EventHandler? TextChanging; + + /// + /// Called after the has been changed. + /// + /// + /// + /// Override in derived views to react to text changes. The base implementation is empty. + /// The event is raised by the caller after this method returns. + /// + /// + protected virtual void OnTextChanged () { } + + /// + /// Invokes the CWP post-change workflow for : calls + /// then raises . + /// + /// + /// + /// Derived views that bypass the base setter (e.g., using new) should + /// call this method after mutating text to participate in the CWP workflow. + /// + /// + protected internal void RaiseTextChanged () + { + OnTextChanged (); + TextChanged?.Invoke (this, EventArgs.Empty); + } + + /// + /// Raised after the has been changed. + /// + /// + /// + /// This is a signal-only notification at the level. It does not carry old or new + /// text values. Derived controls that need richer text-edit semantics may expose their own specific events. + /// + /// + public event EventHandler? TextChanged; + /// /// Gets or sets how the View's is aligned horizontally when drawn. Changing this property will /// redisplay the . @@ -92,11 +192,6 @@ public Alignment TextAlignment } } - /// - /// Text changed event, raised when the text has changed. - /// - public event EventHandler? TextChanged; - /// /// Gets or sets the direction of the View's . Changing this property will redisplay the /// . diff --git a/Terminal.Gui/Views/Button.cs b/Terminal.Gui/Views/Button.cs index 04e3d8d2b5..d010ca4d9b 100644 --- a/Terminal.Gui/Views/Button.cs +++ b/Terminal.Gui/Views/Button.cs @@ -171,13 +171,18 @@ private void SetMouseBindings (MouseFlags? mouseHoldRepeat) private void Button_TitleChanged (object? sender, EventArgs e) { - base.Text = e.Value; + SetTextDirect (e.Value); TextFormatter.HotKeySpecifier = HotKeySpecifier; _interiorTextFormatter.HotKeySpecifier = HotKeySpecifier; } /// - public override string Text { get => Title; set => base.Text = Title = value; } + protected override void OnTextChanged () + { + Title = Text; + + base.OnTextChanged (); + } /// public override Rune HotKeySpecifier diff --git a/Terminal.Gui/Views/CheckBox.cs b/Terminal.Gui/Views/CheckBox.cs index 5fc1bb4cc2..a8551d64be 100644 --- a/Terminal.Gui/Views/CheckBox.cs +++ b/Terminal.Gui/Views/CheckBox.cs @@ -64,12 +64,17 @@ protected override void OnActivated (ICommandContext? commandContext) private void Checkbox_TitleChanged (object? sender, EventArgs e) { - base.Text = e.Value; + SetTextDirect (e.Value); TextFormatter.HotKeySpecifier = HotKeySpecifier; } /// - public override string Text { get => Title; set => base.Text = Title = value; } + protected override void OnTextChanged () + { + Title = Text; + + base.OnTextChanged (); + } /// public override Rune HotKeySpecifier { get => base.HotKeySpecifier; set => TextFormatter.HotKeySpecifier = base.HotKeySpecifier = value; } diff --git a/Terminal.Gui/Views/Code.cs b/Terminal.Gui/Views/Code.cs index 9bbb6b6e78..5c61f8ccab 100644 --- a/Terminal.Gui/Views/Code.cs +++ b/Terminal.Gui/Views/Code.cs @@ -16,19 +16,11 @@ public Code () } /// Gets or sets the source text to render. - public override string Text + protected override void OnTextChanged () { - get => base.Text; - set - { - if (base.Text == value) - { - return; - } + UpdateStyledLines (); - base.Text = value; - UpdateStyledLines (); - } + base.OnTextChanged (); } /// Gets or sets the language hint used for syntax highlighting. diff --git a/Terminal.Gui/Views/Color/ColorPicker.cs b/Terminal.Gui/Views/Color/ColorPicker.cs index 8be7577efa..4780c9de99 100644 --- a/Terminal.Gui/Views/Color/ColorPicker.cs +++ b/Terminal.Gui/Views/Color/ColorPicker.cs @@ -154,16 +154,26 @@ protected override bool OnDrawingContent (DrawContext? context) protected virtual void OnValueChanged (ValueChangedEventArgs args) { } /// - public override string Text + protected override bool OnTextChanging (string newText) { - get => SelectedColor.ToString (); - set + // Reject text that cannot be parsed as a valid color + if (!_colorNameResolver.TryParseColor (newText, out _)) { - if (_colorNameResolver.TryParseColor (value, out Color newColor)) - { - SelectedColor = newColor; - } + return true; + } + + return base.OnTextChanging (newText); + } + + /// + protected override void OnTextChanged () + { + if (_colorNameResolver.TryParseColor (Text, out Color newColor)) + { + SelectedColor = newColor; } + + base.OnTextChanged (); } /// @@ -289,6 +299,9 @@ private void SetSelectedColor (Color value, bool syncBars) // Do the work _selectedColor = value; + // Keep Text in sync with the new color + SetTextDirect (_selectedColor.ToString ()); + // CWP: Fire ValueChanged ValueChangedEventArgs changedArgs = new (oldValue, value); OnValueChanged (changedArgs); diff --git a/Terminal.Gui/Views/DatePicker.cs b/Terminal.Gui/Views/DatePicker.cs index 23a3fdaddc..cf67b4204e 100644 --- a/Terminal.Gui/Views/DatePicker.cs +++ b/Terminal.Gui/Views/DatePicker.cs @@ -50,16 +50,26 @@ public CultureInfo? Culture } /// - public override string Text + protected override bool OnTextChanging (string newText) { - get => Value.ToString (Format); - set + // Reject text that cannot be parsed as a valid DateTime + if (!DateTime.TryParse (newText, out _)) { - if (DateTime.TryParse (value, out DateTime result)) - { - Value = result; - } + return true; + } + + return base.OnTextChanging (newText); + } + + /// + protected override void OnTextChanged () + { + if (DateTime.TryParse (Text, out DateTime result)) + { + Value = result; } + + base.OnTextChanged (); } #region IValue Implementation @@ -93,6 +103,9 @@ public DateTime Value _date = value; + // Keep Text in sync with the new date value + SetTextDirect (_date.ToString (Format)); + // Propagate value to embedded editor _dateEditor?.Value = value; diff --git a/Terminal.Gui/Views/Label.cs b/Terminal.Gui/Views/Label.cs index 7615112cc7..7eb864248c 100644 --- a/Terminal.Gui/Views/Label.cs +++ b/Terminal.Gui/Views/Label.cs @@ -29,12 +29,17 @@ public Label () private void Label_TitleChanged (object? sender, EventArgs e) { - base.Text = e.Value; + SetTextDirect (e.Value); TextFormatter.HotKeySpecifier = HotKeySpecifier; } /// - public override string Text { get => Title; set => base.Text = Title = value; } + protected override void OnTextChanged () + { + Title = Text; + + base.OnTextChanged (); + } /// public override Rune HotKeySpecifier { get => base.HotKeySpecifier; set => TextFormatter.HotKeySpecifier = base.HotKeySpecifier = value; } diff --git a/Terminal.Gui/Views/LinearRange/LinearRangeViewBase.cs b/Terminal.Gui/Views/LinearRange/LinearRangeViewBase.cs index 522b8eac67..44dd2c8bec 100644 --- a/Terminal.Gui/Views/LinearRange/LinearRangeViewBase.cs +++ b/Terminal.Gui/Views/LinearRange/LinearRangeViewBase.cs @@ -189,22 +189,21 @@ protected LinearRangeViewBase (List? options, Orientation orientation, /// /// Setting the Text of a linear range is a shortcut to setting options. The text is a CSV string of the options. /// - public override string Text + protected override void OnTextChanged () { - // Return labels as a CSV string - get => _options is null or { Count: 0 } ? string.Empty : string.Join (",", _options); - set + string value = Text; + + if (string.IsNullOrEmpty (value)) { - if (string.IsNullOrEmpty (value)) - { - Options = []; - } - else - { - IEnumerable list = value.Split (',').Select (x => x.Trim ()); - Options = list.Select (x => new LinearRangeOption { Legend = x }).ToList (); - } + Options = []; + } + else + { + IEnumerable list = value.Split (',').Select (x => x.Trim ()); + Options = list.Select (x => new LinearRangeOption { Legend = x }).ToList (); } + + base.OnTextChanged (); } /// Allow no selection. @@ -370,6 +369,10 @@ public List> Options // Drop any selected indices that are no longer valid _setOptions.RemoveAll (i => i < 0 || i >= _options.Count); + // Keep Text in sync with Options + string csv = _options.Count == 0 ? string.Empty : string.Join (",", _options); + SetTextDirect (csv); + if (_options.Count == 0) { return; diff --git a/Terminal.Gui/Views/Markdown/Markdown.cs b/Terminal.Gui/Views/Markdown/Markdown.cs index 57a7303c2b..1f2ed440dc 100644 --- a/Terminal.Gui/Views/Markdown/Markdown.cs +++ b/Terminal.Gui/Views/Markdown/Markdown.cs @@ -124,7 +124,23 @@ protected override bool OnActivating (CommandEventArgs args) /// Gets or sets the Markdown-formatted text displayed by this view. /// The raw Markdown string. Setting this property triggers reparsing, re-layout, and a redraw. - public override string Text { get => _markdown; set => SetMarkdown (value); } + protected override void OnTextChanged () + { + SetMarkdown (Text); + + base.OnTextChanged (); + } + + /// + /// + /// does not use for rendering. + /// It uses its own styled-line pipeline. Clearing the formatter text prevents the base + /// from drawing raw markdown as plain text. + /// + protected override void UpdateTextFormatterText () + { + TextFormatter.Text = string.Empty; + } /// /// diff --git a/Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs b/Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs index 1ee078382f..a1b046dfc3 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs @@ -43,15 +43,17 @@ public MarkdownCodeBlock () /// (```lang\ncode\n```) and extracts the language automatically. Plain text /// (without fences) is also accepted and treated as language-less code. /// - public override string Text + protected override void OnTextChanged () { - get - { - string body = string.Join ("\n", CodeLines); + ParseFencedText (Text); - return !string.IsNullOrEmpty (Language) ? $"```{Language}\n{body}\n```" : body; - } - set => ParseFencedText (value); + base.OnTextChanged (); + } + + /// + protected override void UpdateTextFormatterText () + { + TextFormatter.Text = string.Empty; } /// diff --git a/Terminal.Gui/Views/Markdown/MarkdownTable.cs b/Terminal.Gui/Views/Markdown/MarkdownTable.cs index 04275e5181..0f22da723e 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownTable.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownTable.cs @@ -86,55 +86,61 @@ public MarkdownTable () /// The setter parses the text via and updates . /// Invalid or empty text clears the table. /// - public override string Text + protected override void OnTextChanged () { - get + string value = Text; + + if (string.IsNullOrWhiteSpace (value)) { - if (_data.ColumnCount == 0) - { - return string.Empty; - } + TableData = _emptyData; + } + else + { + string [] lines = value.Split ('\n', StringSplitOptions.RemoveEmptyEntries); + TableData? parsed = TableData.TryParse (lines); + TableData = parsed ?? _emptyData; + } - // Reconstruct pipe-delimited table text - List lines = [$"| {string.Join (" | ", _data.Headers)} |"]; + base.OnTextChanged (); + } - // Separator row - var seps = new string [_data.ColumnCount]; + /// + protected override void UpdateTextFormatterText () + { + TextFormatter.Text = string.Empty; + } - for (var i = 0; i < _data.ColumnCount; i++) - { - seps [i] = _data.ColumnAlignments [i] switch - { - Alignment.Center => ":---:", - Alignment.End => "---:", - _ => "---" - }; - } + private string BuildTableText () + { + if (_data.ColumnCount == 0) + { + return string.Empty; + } - lines.Add ($"| {string.Join (" | ", seps)} |"); + // Reconstruct pipe-delimited table text + List lines = [$"| {string.Join (" | ", _data.Headers)} |"]; - foreach (string [] row in _data.Rows) - { - lines.Add ($"| {string.Join (" | ", row)} |"); - } + // Separator row + string [] seps = new string [_data.ColumnCount]; - return string.Join ("\n", lines); - } - set + for (var i = 0; i < _data.ColumnCount; i++) { - // Guard: View base constructor calls Text setter before MarkdownTable() initializes fields. - - if (string.IsNullOrWhiteSpace (value)) - { - TableData = _emptyData; + seps [i] = _data.ColumnAlignments [i] switch + { + Alignment.Center => ":---:", + Alignment.End => "---:", + _ => "---" + }; + } - return; - } + lines.Add ($"| {string.Join (" | ", seps)} |"); - string [] lines = value.Split ('\n', StringSplitOptions.RemoveEmptyEntries); - TableData? parsed = TableData.TryParse (lines); - TableData = parsed ?? _emptyData; + foreach (string [] row in _data.Rows) + { + lines.Add ($"| {string.Join (" | ", row)} |"); } + + return string.Join ("\n", lines); } /// @@ -155,6 +161,9 @@ public TableData TableData _rowSegments [r] = ParseCellSegments (value.Rows [r], MarkdownStyleRole.Normal); } + // Keep Text in sync with TableData + SetTextDirect (BuildTableText ()); + // Compute initial layout using current Frame width (or a default for standalone use) int initialWidth = Frame.Width > 0 ? Frame.Width : 80; _lastComputedWidth = -1; diff --git a/Terminal.Gui/Views/ProgressBar.cs b/Terminal.Gui/Views/ProgressBar.cs index 570b269abe..63c1afa208 100644 --- a/Terminal.Gui/Views/ProgressBar.cs +++ b/Terminal.Gui/Views/ProgressBar.cs @@ -108,6 +108,7 @@ public ProgressBar () Height = Dim.Auto (DimAutoStyle.Content, 1); CanFocus = false; _fraction = 0; + SetTextDirect ("0%"); } /// @@ -125,6 +126,7 @@ public float Fraction { _fraction = Math.Min (value, 1); _isActivity = false; + SetTextDirect ($"{_fraction * 100:F0}%"); UpdateTerminalProgress (); SetNeedsDraw (); } @@ -216,16 +218,15 @@ public ProgressBarStyle ProgressBarStyle /// is the percentage will be /// displayed. If is a marquee style, the text will be displayed. /// - public override string Text + protected override bool OnTextChanging (string newText) { - get => string.IsNullOrEmpty (base.Text) ? $"{_fraction * 100:F0}%" : base.Text; - set + // Only allow text to be set externally in marquee mode + if (ProgressBarStyle is not (ProgressBarStyle.MarqueeBlocks or ProgressBarStyle.MarqueeContinuous or ProgressBarStyle.Fire)) { - if (ProgressBarStyle is ProgressBarStyle.MarqueeBlocks or ProgressBarStyle.MarqueeContinuous or ProgressBarStyle.Fire) - { - base.Text = value; - } + return true; } + + return base.OnTextChanging (newText); } /// diff --git a/Terminal.Gui/Views/Shortcut.cs b/Terminal.Gui/Views/Shortcut.cs index 3846cc7051..e9308dcc18 100644 --- a/Terminal.Gui/Views/Shortcut.cs +++ b/Terminal.Gui/Views/Shortcut.cs @@ -140,6 +140,7 @@ public Shortcut (Key key, string? commandText, Action? action, string? helpText HelpView.Id = "_helpView"; #endif HelpView.Text = helpText ?? string.Empty; + SetTextDirect (helpText ?? string.Empty); HelpView.GettingAttributeForRole += SubViewOnGettingAttributeForRole; #if DEBUG @@ -680,14 +681,12 @@ private void SetHelpViewDefaultLayout () /// Gets or sets the help text displayed in the middle of the Shortcut. Identical in function to /// . /// - public override string Text + protected override void OnTextChanged () { - get => HelpView.Text; - set - { - HelpView.Text = value; - ShowHide (); - } + HelpView.Text = Text; + ShowHide (); + + base.OnTextChanged (); } /// @@ -699,6 +698,7 @@ public string HelpText set { HelpView.Text = value; + SetTextDirect (value); ShowHide (); } } diff --git a/Terminal.Gui/Views/TextInput/DateEditor.cs b/Terminal.Gui/Views/TextInput/DateEditor.cs index 4391de81c2..6e339d1122 100644 --- a/Terminal.Gui/Views/TextInput/DateEditor.cs +++ b/Terminal.Gui/Views/TextInput/DateEditor.cs @@ -161,7 +161,7 @@ bool IValue.TrySetValueFromString (string input) /// /// Synchronizes the backing field when the base class - /// property changes programmatically. + /// property changes programmatically. /// protected override void OnValueChanged (ValueChangedEventArgs args) => _value = DateProvider.DateValue; diff --git a/Terminal.Gui/Views/TextInput/TextField/TextField.Text.cs b/Terminal.Gui/Views/TextInput/TextField/TextField.Text.cs index b6fce8850d..fa22cf78be 100644 --- a/Terminal.Gui/Views/TextInput/TextField/TextField.Text.cs +++ b/Terminal.Gui/Views/TextInput/TextField/TextField.Text.cs @@ -9,7 +9,7 @@ public partial class TextField private string? _lastPastedText; /// Raised before changes. The change can be canceled the text adjusted. - public event EventHandler>? TextChanging; + public new event EventHandler>? TextChanging; /// /// Tracks whether the text field should be considered "used", that is, that the user has moved in the entry, so @@ -296,18 +296,6 @@ private static bool IsWithinPasteRange (PasteEditOperation operation, int propos return proposedIndex >= pasteStart && proposedIndex < pasteEnd; } - /// Raises the event, enabling canceling the change or adjusting the text. - /// The event arguments. - /// if the event was cancelled or the text was adjusted by the event. - public bool RaiseTextChanging (ResultEventArgs args) - { - // TODO: CWP: Add an OnTextChanging protected virtual method that can be overridden to handle text changing events. - - TextChanging?.Invoke (this, args); - - return args.Handled; - } - private List DeleteSelectedText () { SetSelectedStartSelectedLength (); @@ -390,77 +378,120 @@ private void InsertText (Key a, bool usePreTextChangedCursorPos) /// private List _text; + /// + /// Stashes the final text value determined during (after sanitization + /// and possible subscriber modification) for use in . + /// + private string? _pendingText; + private void SetText (List newText) => Text = StringExtensions.ToString (newText); private void SetText (IEnumerable newText) => SetText (newText.ToList ()); - /// Sets or gets the text held by the view. - public override string Text + /// + /// + /// + /// TextField overrides this to sanitize text (strip tabs and newlines), fire + /// , and raise the TextField-specific + /// event (which allows subscribers to modify the text). + /// + /// + protected override bool OnTextChanging (string newText) { - get => StringExtensions.ToString (_text); - set + // Guard against base constructor calling before _text is initialized + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (_text is null) { - // Guard against base constructor calling before _text is initialized - // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - if (_text is null) - { - return; - } + return true; + } - var oldText = StringExtensions.ToString (_text); + // Sanitize: single-line, no tabs + string sanitized = newText.Replace ("\t", "").Split ("\n") [0]; - if (oldText == value) - { - return; - } + var oldText = StringExtensions.ToString (_text); - string newText = value.Replace ("\t", "").Split ("\n") [0]; + // After sanitization, check if there is an effective change + if (oldText == sanitized) + { + return true; + } - // Raise IValue.ValueChanging - if (RaiseValueChanging (oldText, newText)) + // Raise IValue.ValueChanging + if (RaiseValueChanging (oldText, sanitized)) + { + return true; + } + + ResultEventArgs args = new (sanitized); + TextChanging?.Invoke (this, args); + + if (args.Handled) + { + if (InsertionPoint > _text.Count) { - return; + InsertionPoint = _text.Count; } - ResultEventArgs args = new (newText); - RaiseTextChanging (args); + return true; + } - if (args.Handled) - { - if (InsertionPoint > _text.Count) - { - InsertionPoint = _text.Count; - } + // Stash the (possibly subscriber-modified) result for OnTextChanged + _pendingText = args.Result; - return; - } + if (base.OnTextChanging (newText)) + { + _pendingText = null; - ClearAllSelection (); + return true; + } - // Note we use NewValue here; TextChanging subscribers may have changed it - _text = args.Result!.ToStringList (); + return false; + } - if (!Secret && !_historyText.IsFromHistory) - { - _historyText.Add ([Cell.ToCellList (oldText)], new Point (InsertionPoint, 0)); + /// + /// + /// + /// Syncs the internal grapheme-list editing model from the base value, + /// applying any sanitization or subscriber modifications stashed during . + /// + /// + protected override void OnTextChanged () + { + // Use stashed text from OnTextChanging if available; otherwise sanitize base.Text + string finalText = _pendingText ?? Text.Replace ("\t", "").Split ("\n") [0]; + _pendingText = null; - _historyText.Add ([Cell.ToCells (_text)], new Point (InsertionPoint, 0), TextEditingLineStatus.Replaced); - } + var oldText = StringExtensions.ToString (_text); + + ClearAllSelection (); + _text = finalText.ToStringList (); - OnTextChanged (); + // Ensure base View._text holds the sanitized/modified value + if (Text != finalText) + { + SetTextDirect (finalText); + } - // Raise IValue.ValueChanged - RaiseValueChanged (oldText, StringExtensions.ToString (_text)); + if (!Secret && !_historyText.IsFromHistory) + { + _historyText.Add ([Cell.ToCellList (oldText)], new Point (InsertionPoint, 0)); - ProcessAutocomplete (); + _historyText.Add ([Cell.ToCells (_text)], new Point (InsertionPoint, 0), TextEditingLineStatus.Replaced); + } - if (InsertionPoint > _text.Count) - { - InsertionPoint = Math.Max (TextModel.DisplaySize (_text, 0).size - 1, 0); - } + base.OnTextChanged (); - Adjust (); - SetNeedsDraw (); + // Raise IValue.ValueChanged + RaiseValueChanged (oldText, StringExtensions.ToString (_text)); + + ProcessAutocomplete (); + + if (InsertionPoint > _text.Count) + { + InsertionPoint = Math.Max (TextModel.DisplaySize (_text, 0).size - 1, 0); } + + Adjust (); + SetNeedsDraw (); } /// diff --git a/Terminal.Gui/Views/TextInput/TextValidateField.cs b/Terminal.Gui/Views/TextInput/TextValidateField.cs index 4ea9f9f723..a3da6c8fd0 100644 --- a/Terminal.Gui/Views/TextInput/TextValidateField.cs +++ b/Terminal.Gui/Views/TextInput/TextValidateField.cs @@ -153,6 +153,7 @@ public ITextValidateProvider? Provider { _provider.TextChanged += ProviderOnTextChanged; _lastKnownText = _provider.Text; + SetTextDirect (_provider.Text); } if (_provider!.Fixed) @@ -203,57 +204,93 @@ protected virtual void OnValueChanged (ValueChangedEventArgs args) { } #endregion - /// Text - public override string Text + /// + /// Stashes the final text value determined during (after possible + /// subscriber modification) for use in . + /// + private string? _pendingValidatedText; + + /// + protected override bool OnTextChanging (string newText) { - get => _provider is null ? string.Empty : _provider.Text; - set + if (_provider is null || SuppressValueEvents) { - if (_provider is null) - { - return; - } + return base.OnTextChanging (newText); + } - string oldValue = _provider.Text; + string oldValue = _provider.Text; - if (oldValue == value) - { - return; - } + if (oldValue == newText) + { + return true; + } - if (!SuppressValueEvents) - { - ValueChangingEventArgs args = new (oldValue, value); + ValueChangingEventArgs args = new (oldValue, newText); - if (OnValueChanging (args) || args.Handled) - { - return; - } + if (OnValueChanging (args) || args.Handled) + { + return true; + } - ValueChanging?.Invoke (this, args); + ValueChanging?.Invoke (this, args); - if (args.Handled) - { - return; - } + if (args.Handled) + { + return true; + } - // Allow subscribers to modify the new value - value = args.NewValue ?? string.Empty; - } + // Allow subscribers to modify the new value + _pendingValidatedText = args.NewValue ?? string.Empty; - _lastKnownText = value; - _provider.Text = value; + return base.OnTextChanging (newText); + } - if (!SuppressValueEvents) - { - ValueChangedEventArgs changedArgs = new (oldValue, value); - OnValueChanged (changedArgs); - ValueChanged?.Invoke (this, changedArgs); - ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (oldValue, value)); - } + /// Text + protected override void OnTextChanged () + { + string value = _pendingValidatedText ?? Text; + _pendingValidatedText = null; + + if (_provider is null) + { + base.OnTextChanged (); - SetNeedsDraw (); + return; } + + string oldValue = _provider.Text; + + if (oldValue == value) + { + base.OnTextChanged (); + + return; + } + + // Apply subscriber-modified value if different from what the base stored + if (value != Text) + { + SetTextDirect (value); + } + + _lastKnownText = value; + _provider.Text = value; + + // The provider may transform the text (e.g., mask formatting). + // Keep base _text in sync with the provider's actual text. + SetTextDirect (_provider.Text); + + if (!SuppressValueEvents) + { + ValueChangedEventArgs changedArgs = new (oldValue, _provider.Text); + OnValueChanged (changedArgs); + ValueChanged?.Invoke (this, changedArgs); + ValueChangedUntyped?.Invoke (this, new ValueChangedEventArgs (oldValue, _provider.Text)); + } + + SetNeedsDraw (); + + base.OnTextChanged (); } private int _insertionPoint; @@ -325,6 +362,9 @@ private void ProviderOnTextChanged (object? sender, EventArgs e) // Sync _lastKnownText with actual provider state (may have been reverted by handler) _lastKnownText = _provider.Text; + + // Keep base Text in sync with provider + SetTextDirect (_provider.Text); } private void RevertProviderText (string oldText) diff --git a/Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs b/Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs index d27e6b4b0a..61ee2ef8af 100644 --- a/Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs +++ b/Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs @@ -165,7 +165,7 @@ public int TabWidth /// The event is fired whenever this property is set. Note, however, that Text is not /// set by as the user types. /// - public override string Text + public new string Text { get { @@ -178,6 +178,12 @@ public override string Text } set { + // Raise View.TextChanging so subscribers holding a View reference can cancel. + if (OnTextChanging (value)) + { + return; + } + ResetPosition (); _model.LoadString (value); @@ -188,13 +194,55 @@ public override string Text _lastWrapWidth = Viewport.Width; } - OnTextChanged (); + // Keep base View._text in sync + SetTextDirect (value); + + _ownSetterActive = true; + RaiseTextChanged (); + _ownSetterActive = false; SetNeedsDraw (); _historyText.Clear (_model.GetAllLines ()); } } + /// Tracks whether the new Text setter is active to avoid redundant sync in . + private bool _ownSetterActive; + + /// + /// + /// Syncs the internal when is set through a + /// polymorphic () reference, ensuring the TextView's editing model stays consistent. + /// + protected override void OnTextChanged () + { + // Skip sync when called from our own `new Text` setter — it already updated the model. + if (_ownSetterActive) + { + base.OnTextChanged (); + + return; + } + + string baseText = base.Text; + + // Sync when the internal model diverges (polymorphic setter case). + ResetPosition (); + _model.LoadString (baseText); + + if (_wordWrap) + { + _wrapManager = new WordWrapManager (_model); + _model = _wrapManager.WrapModel (Viewport.Width, out _, out _, out _, out _); + _lastWrapWidth = Viewport.Width; + } + + _historyText.Clear (_model.GetAllLines ()); + SetNeedsDraw (); + + base.OnTextChanged (); + } + /// /// Tracks whether the text view should be considered "used", that is, that the user has moved in the entry, so /// new input should be appended at the cursor position, rather than clearing the entry diff --git a/Terminal.Gui/Views/TextInput/TextView/TextView.cs b/Terminal.Gui/Views/TextInput/TextView/TextView.cs index 41bc39cc3c..4c05541bf1 100644 --- a/Terminal.Gui/Views/TextInput/TextView/TextView.cs +++ b/Terminal.Gui/Views/TextInput/TextView/TextView.cs @@ -128,6 +128,10 @@ public TextView () _model.LinesLoaded += Model_LinesLoaded!; _historyText.ChangeText += HistoryText_ChangeText; + // Initialize the model with at least one empty line + _model.LoadString (string.Empty); + _historyText.Clear (_model.GetAllLines ()); + CreateCommandsAndBindings (); _currentCulture = Thread.CurrentThread.CurrentUICulture; diff --git a/Terminal.Gui/Views/TextInput/TimeEditor.cs b/Terminal.Gui/Views/TextInput/TimeEditor.cs index b06c8a6271..512d3c5622 100644 --- a/Terminal.Gui/Views/TextInput/TimeEditor.cs +++ b/Terminal.Gui/Views/TextInput/TimeEditor.cs @@ -177,7 +177,7 @@ bool IValue.TrySetValueFromString (string input) /// /// Synchronizes the backing field when the base class - /// property changes programmatically. + /// property changes programmatically. /// protected override void OnValueChanged (ValueChangedEventArgs args) => _value = TimeProvider.TimeValue; diff --git a/Tests/UnitTestsParallelizable/ViewBase/Keyboard/KeyboardEventTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Keyboard/KeyboardEventTests.cs index cbefc74404..ffd16b24bc 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Keyboard/KeyboardEventTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Keyboard/KeyboardEventTests.cs @@ -249,7 +249,7 @@ public class OnNewKeyTestView : View public bool CancelVirtualMethods { set; private get; } public bool OnKeyDownCalled { get; set; } public bool OnProcessKeyDownCalled { get; set; } - public override string Text { get; set; } + public new string Text { get; set; } protected override bool OnKeyDown (Key keyEvent) { diff --git a/Tests/UnitTestsParallelizable/ViewBase/TextCwpTests.cs b/Tests/UnitTestsParallelizable/ViewBase/TextCwpTests.cs new file mode 100644 index 0000000000..1e49faee05 --- /dev/null +++ b/Tests/UnitTestsParallelizable/ViewBase/TextCwpTests.cs @@ -0,0 +1,473 @@ +// Copilot + +using System.ComponentModel; + +namespace ViewBaseTests; + +/// +/// Tests for the CWP-compliant notifications: +/// and . +/// +public class TextCwpTests +{ + [Fact] + public void Text_SetSameValue_NoEventsRaised () + { + View view = new () { Text = "hello" }; + bool changingRaised = false; + bool changedRaised = false; + view.TextChanging += (_, _) => changingRaised = true; + view.TextChanged += (_, _) => changedRaised = true; + + view.Text = "hello"; + + Assert.False (changingRaised); + Assert.False (changedRaised); + } + + [Fact] + public void Text_SetDifferentValue_BothEventsRaised () + { + View view = new () { Text = "old" }; + bool changingRaised = false; + bool changedRaised = false; + view.TextChanging += (_, _) => changingRaised = true; + view.TextChanged += (_, _) => changedRaised = true; + + view.Text = "new"; + + Assert.True (changingRaised); + Assert.True (changedRaised); + } + + [Fact] + public void TextChanging_Cancel_PreventsTextChange () + { + View view = new () { Text = "original" }; + view.TextChanging += (_, e) => e.Cancel = true; + + view.Text = "modified"; + + Assert.Equal ("original", view.Text); + } + + [Fact] + public void TextChanging_Cancel_SuppressesTextChanged () + { + View view = new () { Text = "original" }; + bool changedRaised = false; + view.TextChanging += (_, e) => e.Cancel = true; + view.TextChanged += (_, _) => changedRaised = true; + + view.Text = "modified"; + + Assert.False (changedRaised); + } + + [Fact] + public void TextChanging_RaisedBeforeMutation () + { + View view = new () { Text = "before" }; + string? textDuringChanging = null; + view.TextChanging += (sender, _) => textDuringChanging = ((View)sender!).Text; + + view.Text = "after"; + + Assert.Equal ("before", textDuringChanging); + } + + [Fact] + public void TextChanged_RaisedAfterMutation () + { + View view = new () { Text = "before" }; + string? textDuringChanged = null; + view.TextChanged += (sender, _) => textDuringChanged = ((View)sender!).Text; + + view.Text = "after"; + + Assert.Equal ("after", textDuringChanged); + } + + [Fact] + public void OnTextChanging_Override_CanCancel () + { + CancellingView view = new (); + // Set initial text before enabling cancellation + view.AllowChange = true; + view.Text = "initial"; + view.AllowChange = false; + + view.Text = "blocked"; + + Assert.Equal ("initial", view.Text); + } + + [Fact] + public void OnTextChanged_Override_CalledAfterChange () + { + TrackingView view = new () { Text = "start" }; + + view.Text = "end"; + + Assert.True (view.OnTextChangedCalled); + Assert.Equal ("end", view.TextAtOnTextChanged); + } + + [Fact] + public void TextChanging_EventOrder_ChangingBeforeChanged () + { + View view = new () { Text = "a" }; + List order = []; + view.TextChanging += (_, _) => order.Add ("changing"); + view.TextChanged += (_, _) => order.Add ("changed"); + + view.Text = "b"; + + Assert.Equal (["changing", "changed"], order); + } + + // --- CR feedback tests: these must fail before fixes, pass after --- + + /// + /// CR Issue 1: should raise + /// (CWP pattern: virtual method raises the event). A subclass that calls base.OnTextChanging() + /// should get the event's cancellation result. + /// + [Fact] + public void OnTextChanging_BaseRaisesEvent_SubclassGetsCancel () // Copilot + { + DelegatingView view = new (); + view.TextChanging += (_, e) => e.Cancel = true; + + view.Text = "hello"; + + // base.OnTextChanging() should have raised TextChanging and returned true (cancelled) + Assert.True (view.BaseReturnedCancel); + Assert.Equal (string.Empty, view.Text); + } + + /// + /// CR Issue 2: Setting polymorphically on a + /// should sync the TextField's internal model so that textField.Text returns the new value. + /// + [Fact] + public void TextField_PolymorphicSet_SyncsInternalModel () // Copilot + { + TextField tf = new (); + View v = tf; + + v.Text = "hello"; + + Assert.Equal ("hello", tf.Text); + } + + /// + /// CR Issue 3: Setting polymorphically on a + /// should sync the TextView's internal model so that textView.Text returns the new value. + /// + [Fact] + public void TextView_PolymorphicSet_SyncsInternalModel () // Copilot + { + TextView tv = new (); + View v = tv; + + v.Text = "hello"; + + Assert.Equal ("hello", tv.Text); + } + + /// + /// CR Issue 4: A newly constructed should have + /// set to "0%" so that renders the percentage + /// on first draw. + /// + [Fact] + public void ProgressBar_Constructor_TextShowsZeroPercent () // Copilot + { + ProgressBar pb = new (); + + Assert.Equal ("0%", pb.Text); + } + + /// A test subclass that cancels text changes via . + private class CancellingView : View + { + public bool AllowChange { get; set; } + + protected override bool OnTextChanging (string newText) => !AllowChange; + } + + /// A test subclass that tracks calls to . + private class TrackingView : View + { + public bool OnTextChangedCalled { get; private set; } + public string? TextAtOnTextChanged { get; private set; } + + protected override void OnTextChanged () + { + OnTextChangedCalled = true; + TextAtOnTextChanged = Text; + + base.OnTextChanged (); + } + } + + /// + /// A test subclass that delegates to base.OnTextChanging() and records + /// whether the base returned a cancel signal. + /// + private class DelegatingView : View + { + public bool BaseReturnedCancel { get; private set; } + + protected override bool OnTextChanging (string newText) + { + BaseReturnedCancel = base.OnTextChanging (newText); + + return BaseReturnedCancel; + } + } + + // --- CR feedback regression tests --- + + /// + /// Verifies that when TextField's TextChanging subscriber modifies text, and then a + /// subsequent base TextChanging event cancels, the stale modified text does not leak + /// into the next successful text change. + /// + [Fact] + public void TextField_PendingText_ClearedOnBaseCancel () + { + // Copilot + TextField tf = new () { Width = 20, Height = 1 }; + tf.Text = "initial"; + + // First: a subscriber modifies text via ResultEventArgs + tf.TextChanging += (_, args) => + { + if (args.Result == "modified") + { + args.Result = "subscriber_changed"; + } + }; + + tf.Text = "modified"; + Assert.Equal ("subscriber_changed", tf.Text); + + // Now subscribe to base View.TextChanging to cancel the NEXT change + var cancelOnce = true; + + ((View)tf).TextChanging += (_, e) => + { + if (cancelOnce) + { + e.Cancel = true; + cancelOnce = false; + } + }; + + // This should be cancelled by the base event + tf.Text = "blocked"; + Assert.Equal ("subscriber_changed", tf.Text); + + // Next change should succeed with fresh text, NOT stale _pendingText + tf.Text = "final"; + Assert.Equal ("final", tf.Text); + } + + /// + /// Verifies that TextValidateField does not raise View.TextChanged when + /// ValueChanging is cancelled (CWP semantics: cancel suppresses TextChanged). + /// + [Fact] + public void TextValidateField_ValueChangingCancel_SuppressesTextChanged () + { + // Copilot + TextValidateField field = new () { Width = 20, Height = 1 }; + + // Set a provider that accepts any text + field.Provider = new TextRegexProvider (".*"); + field.Text = "initial"; + + // Cancel ValueChanging + field.ValueChanging += (_, args) => args.Handled = true; + + bool textChangedRaised = false; + ((View)field).TextChanged += (_, _) => textChangedRaised = true; + + field.Text = "blocked"; + + Assert.False (textChangedRaised, "TextChanged should not fire when ValueChanging cancels"); + Assert.Equal ("initial", field.Text); + } + + /// + /// Verifies that DatePicker rejects invalid (unparseable) text: Text should not + /// persist an invalid string that cannot round-trip through DateTime. + /// + [Fact] + public void DatePicker_InvalidText_DoesNotPersist () + { + // Copilot + DatePicker dp = new () { Width = 20, Height = 1 }; + DateTime originalValue = dp.Value; + string originalText = dp.Text; + + // Set invalid date text + dp.Text = "not-a-date"; + + // Value should remain unchanged + Assert.Equal (originalValue, dp.Value); + + // Text should NOT hold the invalid string — it should revert or be rejected + Assert.NotEqual ("not-a-date", dp.Text); + } + + /// + /// Verifies that ColorPicker rejects invalid (unparseable) text: Text should not + /// persist an invalid string that cannot round-trip through Color. + /// + [Fact] + public void ColorPicker_InvalidText_DoesNotPersist () + { + // Copilot + ColorPicker cp = new () { Width = 20, Height = 3 }; + cp.SelectedColor = new Color (255, 0, 0); + string originalText = cp.Text; + + // Set invalid color text + cp.Text = "not-a-color"; + + // SelectedColor should remain unchanged + Assert.Equal (new Color (255, 0, 0), cp.SelectedColor); + + // Text should NOT hold the invalid string — it should revert or be rejected + Assert.NotEqual ("not-a-color", cp.Text); + } + + /// + /// Verifies that setting View.Text on a word-wrapped TextView via a View reference + /// does not corrupt or redundantly re-process the model. + /// + [Fact] + public void TextView_WordWrap_PolymorphicSet_DoesNotCorruptModel () + { + // Copilot + TextView tv = new () { Width = 10, Height = 5, WordWrap = true }; + tv.Text = "Hello World this wraps"; + + // Set via polymorphic View reference + View viewRef = tv; + viewRef.Text = "Short"; + + Assert.Equal ("Short", tv.Text); + Assert.Equal ("Short", viewRef.Text); + } + + /// + /// Verifies that setting a valid date string via Text updates DatePicker.Value accordingly. + /// Ensures Text↔Value consistency on the happy path. + /// + [Fact] + public void DatePicker_ValidText_UpdatesValue () + { + // Copilot + DatePicker dp = new () { Width = 20, Height = 1 }; + DateTime target = new (2025, 12, 25); + + // Use the same short-date format the picker uses internally + string formatted = target.ToShortDateString (); + dp.Text = formatted; + + Assert.Equal (target, dp.Value); + Assert.Equal (formatted, dp.Text); + } + + /// + /// Verifies that setting DatePicker.Value updates Text to the formatted representation. + /// Ensures Value→Text consistency. + /// + [Fact] + public void DatePicker_ValueSet_UpdatesText () + { + // Copilot + DatePicker dp = new () { Width = 20, Height = 1 }; + DateTime target = new (2024, 7, 4); + + dp.Value = target; + + Assert.Equal (target, dp.Value); + + // Text should be parseable back to the same date + Assert.True (DateTime.TryParse (dp.Text, out DateTime roundTrip)); + Assert.Equal (target, roundTrip); + } + + /// + /// Verifies that setting a valid color string via Text updates ColorPicker.SelectedColor. + /// Ensures Text↔Value consistency on the happy path. + /// + [Fact] + public void ColorPicker_ValidText_UpdatesSelectedColor () + { + // Copilot + ColorPicker cp = new () { Width = 20, Height = 3 }; + cp.SelectedColor = new Color (0, 0, 0); + + // StandardColorsNameResolver accepts StandardColor enum names + cp.Text = "Red"; + + // SelectedColor should have changed from black to red + Assert.NotEqual (new Color (0, 0, 0), cp.SelectedColor); + Assert.Equal ("Red", cp.Text); + } + + /// + /// Verifies that setting ColorPicker.SelectedColor updates Text to the color string. + /// Ensures Value→Text consistency. + /// + [Fact] + public void ColorPicker_SelectedColorSet_UpdatesText () + { + // Copilot + ColorPicker cp = new () { Width = 20, Height = 3 }; + + cp.SelectedColor = new Color (0, 0, 255); + + Assert.Equal (new Color (0, 0, 255).ToString (), cp.Text); + } + + /// + /// Verifies that TextValidateField accepts valid text and updates Value accordingly. + /// Ensures Text↔Value consistency on the happy path. + /// + [Fact] + public void TextValidateField_ValidText_UpdatesValue () + { + // Copilot + TextValidateField tvf = new () { Provider = new TextRegexProvider ("^[0-9]+$") }; + tvf.Text = "123"; + + tvf.Text = "456"; + + Assert.Equal ("456", tvf.Text); + } + + /// + /// Verifies that when TextValidateField.ValueChanging cancels, both Text and the + /// provider's internal value remain at the old value (no divergence). + /// + [Fact] + public void TextValidateField_ValueChangingCancel_TextAndProviderStayConsistent () + { + // Copilot + TextValidateField tvf = new () { Provider = new TextRegexProvider ("^[0-9]+$") }; + tvf.Text = "111"; + tvf.ValueChanging += (_, e) => e.Handled = true; + + tvf.Text = "222"; + + // Both must stay at original value + Assert.Equal ("111", tvf.Text); + } +}