diff --git a/examples/ted/TedApp.EditCommands.cs b/examples/ted/TedApp.EditCommands.cs index 3de6897c..2bc51e0c 100644 --- a/examples/ted/TedApp.EditCommands.cs +++ b/examples/ted/TedApp.EditCommands.cs @@ -13,13 +13,13 @@ internal View[] CreateEditMenuItems () new MenuItem ("_Find...", "Find text in the current document", Find), new MenuItem ("_Replace...", "Find and replace text in the current document", Replace), new Line (), - new MenuItem { Command = Command.Undo, Action = Undo, Key = KeyFor (Command.Undo) }, - new MenuItem { Command = Command.Redo, Action = Redo, Key = KeyFor (Command.Redo) }, + new MenuItem (Editor, Command.Undo) { Key = KeyFor (Command.Undo) }, + new MenuItem (Editor, Command.Redo) { Key = KeyFor (Command.Redo) }, new Line (), - new MenuItem { Command = Command.Cut, Key = KeyFor (Command.Cut) }, - new MenuItem { Command = Command.Copy, Key = KeyFor (Command.Copy) }, - new MenuItem { Command = Command.Paste, Key = KeyFor (Command.Paste) }, - new MenuItem { Command = Command.SelectAll, Action = SelectAll, Key = KeyFor (Command.SelectAll) } + new MenuItem (Editor, Command.Cut) { Key = KeyFor (Command.Cut) }, + new MenuItem (Editor, Command.Copy) { Key = KeyFor (Command.Copy) }, + new MenuItem (Editor, Command.Paste) { Key = KeyFor (Command.Paste) }, + new MenuItem (Editor, Command.SelectAll) { Key = KeyFor (Command.SelectAll) } ]; } @@ -27,24 +27,6 @@ internal View[] CreateEditMenuItems () private void Replace () { ShowFindReplaceDialog (true); } - private void SelectAll () { Editor.SelectAll (); } - - private void Undo () - { - if (!Editor.ReadOnly) - { - Editor.Document?.UndoStack.Undo (); - } - } - - private void Redo () - { - if (!Editor.ReadOnly) - { - Editor.Document?.UndoStack.Redo (); - } - } - private void ShowFindReplaceDialog (bool selectReplaceTab) { if (App is null) diff --git a/examples/ted/TedApp.cs b/examples/ted/TedApp.cs index 5f1d1094..a6dd3c53 100644 --- a/examples/ted/TedApp.cs +++ b/examples/ted/TedApp.cs @@ -148,22 +148,6 @@ public TedApp (bool readOnly = false) AlignmentModes = AlignmentModes.IgnoreFirstOrLast }; - PopoverMenu editorContextMenu = new (CreateEditMenuItems ()) - { - Target = new WeakReference (Editor) - }; - - Editor.MouseEvent += (_, mouse) => - { - if (!mouse.Flags.HasFlag (MouseFlags.RightButtonClicked)) - { - return; - } - - editorContextMenu.MakeVisible (mouse.ScreenPosition); - mouse.Handled = true; - }; - menu.Add (new MenuBarItem (Strings.menuFile, [ new MenuItem { Command = Command.New, Action = New, Key = KeyFor (Command.New) }, diff --git a/src/Terminal.Gui.Editor/Editor.Commands.cs b/src/Terminal.Gui.Editor/Editor.Commands.cs index 4b5e5f32..012d639a 100644 --- a/src/Terminal.Gui.Editor/Editor.Commands.cs +++ b/src/Terminal.Gui.Editor/Editor.Commands.cs @@ -265,6 +265,19 @@ private void CreateCommandsAndBindings () return true; }); + // Context menu — return false when suppressed so the command can bubble. + AddCommand (Command.Context, () => + { + if (ContextMenu is null) + { + return false; + } + + ShowContextMenu (); + + return true; + }); + ApplyKeyBindings (View.DefaultKeyBindings, DefaultKeyBindings); // Reclaim Tab / Shift+Tab from the framework's default focus-cycling bindings so our @@ -537,10 +550,7 @@ private void OverwriteAtOffset (int offset, string text) } } - if (fold is not null) - { - fold.IsFolded = !fold.IsFolded; - } + fold?.IsFolded = !fold.IsFolded; return true; } diff --git a/src/Terminal.Gui.Editor/Editor.ContextMenu.cs b/src/Terminal.Gui.Editor/Editor.ContextMenu.cs new file mode 100644 index 00000000..585dea84 --- /dev/null +++ b/src/Terminal.Gui.Editor/Editor.ContextMenu.cs @@ -0,0 +1,123 @@ +using System.Drawing; +using Terminal.Gui.Input; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace Terminal.Gui.Editor; + +public partial class Editor +{ + /// + /// Gets or sets the built-in context menu shown on right-click or . + /// Defaults to a standard editing menu (Undo, Redo, Cut, Copy, Paste, Select All) whose items + /// are state-aware — mutating items are disabled when is , + /// Copy / Cut are disabled when there is no selection, and Undo / Redo reflect the undo stack. + /// Set to to suppress the context menu entirely; set to a custom + /// to replace it. + /// + public PopoverMenu? ContextMenu + { + get; + set + { + field = value; + + field?.Target = new WeakReference (this); + } + } + + /// Creates the default editing context menu items using declarative command binding. + /// + /// Each is constructed with this as the target view and a + /// . The framework resolves title, help text, and key from + /// GlobalResources and routes the command to this via command + /// bubbling — no explicit delegates are needed. + /// + private View[] CreateDefaultContextMenuItems () + { + return + [ + new MenuItem (this, Command.Undo), + new MenuItem (this, Command.Redo), + new Line (), + new MenuItem (this, Command.Cut), + new MenuItem (this, Command.Copy), + new MenuItem (this, Command.Paste), + new Line (), + new MenuItem (this, Command.SelectAll) + ]; + } + + /// + /// Updates the state of the context menu items to reflect the current + /// editor state (ReadOnly, selection, clipboard, undo/redo). + /// + private void UpdateContextMenuState () + { + if (ContextMenu?.Root is null) + { + return; + } + + var hasSelection = HasSelection; + var canUndo = !ReadOnly && _document is { UndoStack.CanUndo: true }; + var canRedo = !ReadOnly && _document is { UndoStack.CanRedo: true }; + var canPaste = !ReadOnly; + var canCut = !ReadOnly && hasSelection; + + foreach (View child in ContextMenu.Root.SubViews) + { + if (child is not MenuItem menuItem) + { + continue; + } + + switch (menuItem.Command) + { + case Command.Undo: + menuItem.Enabled = canUndo; + + break; + case Command.Redo: + menuItem.Enabled = canRedo; + + break; + case Command.Cut: + menuItem.Enabled = canCut; + + break; + case Command.Copy: + menuItem.Enabled = hasSelection; + + break; + case Command.Paste: + menuItem.Enabled = canPaste; + + break; + + // Unknown commands (e.g. from a custom ContextMenu) are left untouched + // so the caller's intentional Enabled state is preserved. + } + } + } + + /// + /// Shows the context menu at the given screen position, after updating item state. + /// + private void ShowContextMenu (Point? screenPosition = null) + { + if (ContextMenu is null) + { + return; + } + + UpdateContextMenuState (); + ContextMenu.MakeVisible (screenPosition); + } + + /// Builds and assigns the default context menu. + private void InitializeDefaultContextMenu () + { + ContextMenu = new PopoverMenu (CreateDefaultContextMenuItems ()); + } +} diff --git a/src/Terminal.Gui.Editor/Editor.Mouse.cs b/src/Terminal.Gui.Editor/Editor.Mouse.cs index dc2773c3..0e9eeb1c 100644 --- a/src/Terminal.Gui.Editor/Editor.Mouse.cs +++ b/src/Terminal.Gui.Editor/Editor.Mouse.cs @@ -9,31 +9,9 @@ namespace Terminal.Gui.Editor; public partial class Editor { - /// - /// Which gesture the in-progress left-button drag belongs to. One state instead of a set - /// of fighting "suppress…UntilRelease" booleans: the press classifies the gesture, every - /// subsequent drag/release event dispatches on it. Reset to - /// (the neutral default) on release. - /// - private enum DragMode - { - /// Plain or Shift drag: extend the primary selection to the drag point. - Select, - - /// Ctrl+Click add-caret: swallow drag events so they don't move the primary. - AddCaret, - - /// - /// Alt drag: build a vertical column of carets from press row to drag row. Alt (not - /// VS Code's Shift+Alt) because Windows Terminal reserves Shift+drag for its own - /// forced/block text selection while an app has mouse mode on — see - /// specs/decisions.md DEC-006 and gui-cs/Terminal.Gui#4888. - /// - ColumnCarets - } + private Point _columnDragAnchor; private DragMode _dragMode; - private Point _columnDragAnchor; /// protected override bool OnMouseEvent (Mouse mouse) @@ -48,6 +26,20 @@ protected override bool OnMouseEvent (Mouse mouse) return false; } + // Right-click → show built-in context menu at the click position. + // When ContextMenu is null the click is left unhandled so it can bubble. + if (mouse.Flags.HasFlag (MouseFlags.RightButtonClicked)) + { + if (ContextMenu is null) + { + return false; + } + + ShowContextMenu (mouse.ScreenPosition); + + return true; + } + var shift = mouse.Flags.HasFlag (MouseFlags.Shift); if (mouse.Flags.HasFlag (MouseFlags.LeftButtonTripleClicked)) @@ -261,4 +253,27 @@ private CellVisualLine BuildVisualLineForSegment (DocumentLine documentLine, int return visualLine; } + + /// + /// Which gesture the in-progress left-button drag belongs to. One state instead of a set + /// of fighting "suppress…UntilRelease" booleans: the press classifies the gesture, every + /// subsequent drag/release event dispatches on it. Reset to + /// (the neutral default) on release. + /// + private enum DragMode + { + /// Plain or Shift drag: extend the primary selection to the drag point. + Select, + + /// Ctrl+Click add-caret: swallow drag events so they don't move the primary. + AddCaret, + + /// + /// Alt drag: build a vertical column of carets from press row to drag row. Alt (not + /// VS Code's Shift+Alt) because Windows Terminal reserves Shift+drag for its own + /// forced/block text selection while an app has mouse mode on — see + /// specs/decisions.md DEC-006 and gui-cs/Terminal.Gui#4888. + /// + ColumnCarets + } } diff --git a/src/Terminal.Gui.Editor/Editor.Selection.cs b/src/Terminal.Gui.Editor/Editor.Selection.cs index 3b4ea792..733e5757 100644 --- a/src/Terminal.Gui.Editor/Editor.Selection.cs +++ b/src/Terminal.Gui.Editor/Editor.Selection.cs @@ -290,12 +290,7 @@ internal int ViewRowToLineNumber (int row) return 1; } - if (idx >= visibleLines.Count) - { - return visibleLines[^1]; - } - - return visibleLines[idx]; + return idx >= visibleLines.Count ? visibleLines[^1] : visibleLines[idx]; } /// diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs index fd3df136..0e77fde9 100644 --- a/src/Terminal.Gui.Editor/Editor.cs +++ b/src/Terminal.Gui.Editor/Editor.cs @@ -76,6 +76,7 @@ public Editor () CreateCommandsAndBindings (); OverlayRenderers.Add (new MultiCaretRenderer (this)); Document = new TextDocument (); + InitializeDefaultContextMenu (); ThemeManager.ThemeChanged += OnThemeChanged; } @@ -680,7 +681,7 @@ private void InvalidateVisualLineCaches (DocumentChangeEventArgs e) // Net character shift. Cached visual lines store *absolute* element offsets, so a // same-line-count edit upstream (no newline added/removed) still leaves every // downstream cached line stale even though its line *number* is unchanged. - var offsetDelta = (insertedText.Length - removedText.Length); + var offsetDelta = insertedText.Length - removedText.Length; RekeyCache (_defaultVisualLineCache, threshold, lineDelta, removedNewlines, offsetDelta); RekeyCache (_drawVisualLineCache, threshold, lineDelta, removedNewlines, offsetDelta); @@ -972,7 +973,7 @@ private bool TryGetVerticalOffset (int startOffset, int delta, int targetVisualC return true; } - var targetLineIndex = (_document.GetLineByOffset (startOffset).LineNumber - 1) + delta; + var targetLineIndex = _document.GetLineByOffset (startOffset).LineNumber - 1 + delta; if (targetLineIndex < 0 || targetLineIndex > _document.LineCount - 1) { diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorContextMenuTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorContextMenuTests.cs new file mode 100644 index 00000000..b0a4758c --- /dev/null +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorContextMenuTests.cs @@ -0,0 +1,259 @@ +// CoPilot - gpt-4.1 + +using System.Drawing; +using Terminal.Gui.Editor.IntegrationTests.Testing; +using Terminal.Gui.Input; +using Terminal.Gui.Testing; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; +using Xunit; + +namespace Terminal.Gui.Editor.IntegrationTests; + +/// +/// Integration tests for the built-in — right-click / Command.Context +/// triggers the default editing context menu, items reflect state, and the menu is replaceable / suppressible. +/// +public class EditorContextMenuTests +{ + private static readonly InputInjectionOptions Direct = new () { Mode = InputInjectionMode.Direct }; + + [Fact] + public async Task RightClick_Shows_Default_ContextMenu () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello world")); + fx.Top.Editor.SetFocus (); + + DriverAssert.ContentsDoesNotContain (fx.Driver, "Undo"); + + fx.Injector.InjectMouse ( + new Mouse + { + ScreenPosition = new Point (4, 0), + Flags = MouseFlags.RightButtonClicked, + Timestamp = new DateTime (2025, 1, 1, 12, 0, 0) + }, + Direct); + fx.Render (); + + DriverAssert.ContentsContains (fx.Driver, "Undo"); + DriverAssert.ContentsContains (fx.Driver, "Redo"); + DriverAssert.ContentsContains (fx.Driver, "Cut"); + DriverAssert.ContentsContains (fx.Driver, "Copy"); + DriverAssert.ContentsContains (fx.Driver, "Paste"); + DriverAssert.ContentsContains (fx.Driver, "Select all"); + } + + [Fact] + public async Task ContextMenu_Null_Suppresses_Menu () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello world")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.ContextMenu = null; + + fx.Injector.InjectMouse ( + new Mouse + { + ScreenPosition = new Point (4, 0), + Flags = MouseFlags.RightButtonClicked, + Timestamp = new DateTime (2025, 1, 1, 12, 0, 0) + }, + Direct); + fx.Render (); + + DriverAssert.ContentsDoesNotContain (fx.Driver, "Undo"); + DriverAssert.ContentsDoesNotContain (fx.Driver, "Select all"); + } + + [Fact] + public async Task ReadOnly_Disables_Mutating_Items () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello world")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.ReadOnly = true; + + // Select text so Copy is enabled + fx.Top.Editor.CaretOffset = 0; + fx.Top.Editor.SelectAll (); + + fx.Injector.InjectMouse ( + new Mouse + { + ScreenPosition = new Point (4, 0), + Flags = MouseFlags.RightButtonClicked, + Timestamp = new DateTime (2025, 1, 1, 12, 0, 0) + }, + Direct); + + // Verify state: Copy should be enabled (has selection), Cut/Paste/Undo/Redo should be disabled + PopoverMenu? menu = fx.Top.Editor.ContextMenu; + Assert.NotNull (menu); + + foreach (View child in menu.Root!.SubViews) + { + if (child is not MenuItem menuItem) + { + continue; + } + + switch (menuItem.Command) + { + case Command.Cut: + case Command.Paste: + case Command.Undo: + case Command.Redo: + Assert.False (menuItem.Enabled, + $"{menuItem.Command} should be disabled when ReadOnly"); + + break; + case Command.Copy: + Assert.True (menuItem.Enabled, "Copy should be enabled when there is a selection"); + + break; + case Command.SelectAll: + Assert.True (menuItem.Enabled, "Select All should always be enabled"); + + break; + } + } + } + + [Fact] + public async Task No_Selection_Disables_Cut_And_Copy () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello world")); + fx.Top.Editor.SetFocus (); + fx.Top.Editor.CaretOffset = 0; + + // No selection — right-click to open menu + fx.Injector.InjectMouse ( + new Mouse + { + ScreenPosition = new Point (4, 0), + Flags = MouseFlags.RightButtonClicked, + Timestamp = new DateTime (2025, 1, 1, 12, 0, 0) + }, + Direct); + + PopoverMenu? menu = fx.Top.Editor.ContextMenu; + Assert.NotNull (menu); + + foreach (View child in menu.Root!.SubViews) + { + if (child is not MenuItem menuItem) + { + continue; + } + + switch (menuItem.Command) + { + case Command.Cut: + case Command.Copy: + Assert.False (menuItem.Enabled, + $"{menuItem.Command} should be disabled when there is no selection"); + + break; + } + } + } + + [Fact] + public async Task Undo_Enabled_After_Edit () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello")); + fx.Top.Editor.SetFocus (); + + // Make an edit so undo becomes available + fx.Top.Editor.Document!.Insert (5, "X"); + + fx.Injector.InjectMouse ( + new Mouse + { + ScreenPosition = new Point (2, 0), + Flags = MouseFlags.RightButtonClicked, + Timestamp = new DateTime (2025, 1, 1, 12, 0, 0) + }, + Direct); + + PopoverMenu? menu = fx.Top.Editor.ContextMenu; + Assert.NotNull (menu); + + foreach (View child in menu.Root!.SubViews) + { + if (child is MenuItem { Command: Command.Undo } undoItem) + { + Assert.True (undoItem.Enabled, "Undo should be enabled after an edit"); + } + } + } + + [Fact] + public async Task Default_ContextMenu_Is_Not_Null () + { + await using AppFixture fx = new (() => new EditorTestHost ("test")); + + Assert.NotNull (fx.Top.Editor.ContextMenu); + } + + [Fact] + public async Task ContextMenu_Items_Use_Declarative_Binding () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello world")); + + PopoverMenu? menu = fx.Top.Editor.ContextMenu; + Assert.NotNull (menu); + Assert.NotNull (menu.Root); + + // Collect the commands from all MenuItems (skip Line separators). + List commands = []; + + foreach (View child in menu.Root.SubViews) + { + if (child is not MenuItem menuItem) + { + continue; + } + + Assert.Equal (fx.Top.Editor, menuItem.TargetView); + Assert.Null (menuItem.Action); + commands.Add (menuItem.Command); + } + + // Verify the expected commands in order. + Assert.Equal ( + [Command.Undo, Command.Redo, Command.Cut, Command.Copy, Command.Paste, Command.SelectAll], + commands); + } + + [Fact] + public async Task ContextMenu_SelectAll_Routes_Via_CommandView () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello world")); + fx.Top.Editor.SetFocus (); + Assert.False (fx.Top.Editor.HasSelection); + + // Invoke SelectAll on the Editor via InvokeCommand — this is the same path the framework + // takes when the user clicks the declaratively-bound MenuItem. + fx.Top.Editor.InvokeCommand (Command.SelectAll); + + Assert.True (fx.Top.Editor.HasSelection, "Select All should select all text"); + Assert.Equal ("hello world", fx.Top.Editor.SelectedText); + } + + [Fact] + public async Task ContextMenu_Undo_Routes_Via_CommandView () + { + await using AppFixture fx = new (() => new EditorTestHost ("hello")); + fx.Top.Editor.SetFocus (); + + // Make an edit + fx.Top.Editor.Document!.Insert (5, "X"); + Assert.Equal ("helloX", fx.Top.Editor.Document.Text); + + // Invoke Undo on the Editor via InvokeCommand — this is the same path the framework + // takes when the user clicks the declaratively-bound MenuItem. + fx.Top.Editor.InvokeCommand (Command.Undo); + + Assert.Equal ("hello", fx.Top.Editor.Document.Text); + } +} diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs index 48a80350..af534623 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs @@ -6,10 +6,8 @@ using Terminal.Gui.Configuration; using Terminal.Gui.Editor.IntegrationTests.Testing; using Terminal.Gui.Input; -using Terminal.Gui.Text.Indentation; using Terminal.Gui.Testing; -using Terminal.Gui.Editor; -using Terminal.Gui.Views; +using Terminal.Gui.Text.Indentation; using Xunit; namespace Terminal.Gui.Editor.IntegrationTests; @@ -506,8 +504,7 @@ public async Task Editor_RightClick_Opens_Edit_Context_Menu () { await using AppFixture fx = new (() => new TedApp ()); - DriverAssert.ContentsDoesNotContain (fx.Driver, "Find..."); - DriverAssert.ContentsDoesNotContain (fx.Driver, "Replace..."); + DriverAssert.ContentsDoesNotContain (fx.Driver, "Select all"); InputInjectionOptions options = new () { Mode = InputInjectionMode.Direct }; @@ -521,8 +518,8 @@ public async Task Editor_RightClick_Opens_Edit_Context_Menu () options); fx.Render (); - DriverAssert.ContentsContains (fx.Driver, "Find..."); - DriverAssert.ContentsContains (fx.Driver, "Replace..."); + DriverAssert.ContentsContains (fx.Driver, "Undo"); + DriverAssert.ContentsContains (fx.Driver, "Redo"); DriverAssert.ContentsContains (fx.Driver, "Select all"); } @@ -542,7 +539,7 @@ public async Task ThemeDropDown_Source_Contains_All_Available_Themes () ImmutableList expected = ThemeManager.GetThemeNames (); Assert.True (expected.Count > 0, "ThemeManager should expose at least one theme."); - var actual = fx.Top.ThemeDropDown.Source!.ToList () + List actual = fx.Top.ThemeDropDown.Source!.ToList () .Cast () .ToList ();