From b9903fd26c2567db7e9d35ffaba38a2639634a5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 23:37:40 +0000 Subject: [PATCH 01/15] Initial plan From a0fded5b1ebc87574463870030088e67c7f87eb3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 23:56:36 +0000 Subject: [PATCH 02/15] Add selection, copy, and context menu to Markdown view - Add MarkdownView.Selection.cs with selection state, SelectAll(), Copy(), ClearSelection(), IsInSelection(), GetSelectedText(), context menu lifecycle - Update MarkdownView.Mouse.cs: add Command.SelectAll/Copy/Context, OnMouseEvent for drag selection, update OnActivated for drag-aware link handling, update OnHasFocusChanged for context menu lifecycle - Update MarkdownView.Drawing.cs to highlight selected cells - Update Markdown.cs: clear selection on text change, update docs - Add MarkdownViewSelectionTests.cs with 12 tests Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/ecbe0462-5243-4c9a-9464-f0e66acd0b9f Co-authored-by: tig <585482+tig@users.noreply.github.com> --- Terminal.Gui/Views/Markdown/Markdown.cs | 37 +++ .../Views/Markdown/MarkdownView.Drawing.cs | 7 + .../Views/Markdown/MarkdownView.Mouse.cs | 92 ++++++- .../Views/Markdown/MarkdownView.Selection.cs | 225 +++++++++++++++++ .../Markdown/MarkdownViewSelectionTests.cs | 226 ++++++++++++++++++ 5 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs create mode 100644 Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs diff --git a/Terminal.Gui/Views/Markdown/Markdown.cs b/Terminal.Gui/Views/Markdown/Markdown.cs index af8adfd52c..7998204388 100644 --- a/Terminal.Gui/Views/Markdown/Markdown.cs +++ b/Terminal.Gui/Views/Markdown/Markdown.cs @@ -18,6 +18,42 @@ namespace Terminal.Gui.Views; /// Hyperlinks raise the event. Anchor links (URLs beginning with /// #) are handled automatically by scrolling to the matching heading. /// +/// Default key bindings: +/// +/// +/// Key Action +/// +/// +/// Ctrl+A Selects all rendered content (). +/// +/// +/// Ctrl+C +/// +/// Copies the current selection to the clipboard, or the entire markdown source if nothing is selected +/// (). +/// +/// +/// +/// Shift+F10 / Right-click +/// Opens a context menu with Select All and Copy items. +/// +/// +/// Default mouse bindings: +/// +/// +/// Mouse Event Action +/// +/// +/// Left-button drag Selects text by dragging the mouse. +/// +/// +/// Left-button click +/// Clears the selection and activates a hyperlink if one is under the cursor. +/// +/// +/// Right-button click Opens the context menu. +/// +/// /// public partial class Markdown : View, IDesignable { @@ -249,6 +285,7 @@ private void InvalidateParsedAndLayout () RemoveTableViews (); RemoveThematicBreakViews (); _maxLineWidth = 0; + _isSelecting = false; SetNeedsLayout (); SetNeedsDraw (); diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs index c361bf21b0..780b1b0f44 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs @@ -112,6 +112,13 @@ private void DrawRenderedLine (RenderedLine line, int contentRow, int drawRow) AddStr (drawCol, drawRow, grapheme); } } + else if (IsInSelection (contentRow, contentX)) + { + Attribute selAttr = GetAttributeForSegment (segment); + Attribute reversed = new (selAttr.Background, selAttr.Foreground, selAttr.Style); + SetAttribute (reversed); + AddStr (drawCol, drawRow, grapheme); + } else { DrawGrapheme (segment, grapheme, drawCol, drawRow); diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs index ccd0d074bd..96dc2b0d97 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs @@ -43,6 +43,11 @@ private void SetupBindingsAndCommands () AddCommand (Command.Accept, () => ActivateCurrentLink ()); + // Selection and clipboard commands + AddCommand (Command.SelectAll, () => SelectAll ()); + AddCommand (Command.Copy, () => Copy ()); + AddCommand (Command.Context, () => ShowContextMenu ()); + // Apply default key bindings (maps CursorUp→Up, CursorDown→Down, etc.) ApplyKeyBindings (DefaultKeyBindings, DefaultKeyBindings); @@ -52,13 +57,87 @@ private void SetupBindingsAndCommands () MouseBindings.ReplaceCommands (MouseFlags.WheeledRight, Command.ScrollRight); MouseBindings.ReplaceCommands (MouseFlags.WheeledLeft, Command.ScrollLeft); MouseBindings.ReplaceCommands (MouseFlags.LeftButtonClicked, Command.Activate); + MouseBindings.ReplaceCommands (MouseFlags.RightButtonClicked, Command.Context); + } + + /// + protected override bool OnMouseEvent (Mouse mouse) + { + if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed) && !mouse.Flags.HasFlag (MouseFlags.PositionReport)) + { + // Button-down: anchor the selection start + if (mouse.Position is { } pressPos) + { + int contentX = Viewport.X + pressPos.X; + int contentY = Math.Min (Viewport.Y + pressPos.Y, Math.Max (_renderedLines.Count - 1, 0)); + _selectionAnchor = new Point (contentX, contentY); + _selectionCurrent = _selectionAnchor; + } + + _isDragging = false; + + if (App is { } && !App.Mouse.IsGrabbed (this)) + { + App.Mouse.GrabMouse (this); + } + + if (!HasFocus && CanFocus) + { + SetFocus (); + } + + return false; + } + + if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport)) + { + // Drag: extend selection + if (mouse.Position is { } dragPos) + { + int contentX = Viewport.X + dragPos.X; + int contentY = Math.Min (Viewport.Y + dragPos.Y, Math.Max (_renderedLines.Count - 1, 0)); + _selectionCurrent = new Point (contentX, contentY); + _isDragging = true; + _isSelecting = true; + SetNeedsDraw (); + } + + return true; + } + + if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased)) + { + if (App is { } && App.Mouse.IsGrabbed (this)) + { + App.Mouse.UngrabMouse (); + } + + return false; + } + + return false; } /// protected override void OnHasFocusChanged (bool newHasFocus, View? previousFocusedView, View? focusedView) { - if (!newHasFocus) + if (newHasFocus) { + CreateContextMenu (); + + if (ContextMenu?.Key is { }) + { + KeyBindings.Add (ContextMenu.Key, Command.Context); + } + } + else + { + if (ContextMenu?.Key is { }) + { + KeyBindings.Remove (ContextMenu.Key); + } + + DisposeContextMenu (); _activeLinkIndex = -1; SetNeedsDraw (); } @@ -125,6 +204,17 @@ protected override void OnActivated (ICommandContext? ctx) return; } + // A drag ended: the click fires after release, but the user was selecting text — don't activate link. + if (_isDragging) + { + _isDragging = false; + + return; + } + + // Plain click clears any existing text selection. + ClearSelection (); + if (!HasFocus && CanFocus) { SetFocus (); diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs new file mode 100644 index 0000000000..f4ab6fae72 --- /dev/null +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs @@ -0,0 +1,225 @@ +using System.Text; + +namespace Terminal.Gui.Views; + +public partial class Markdown +{ + private bool _isSelecting; + private Point _selectionAnchor; + private Point _selectionCurrent; + private bool _isDragging; + + /// Gets the context menu for this view. + public PopoverMenu? ContextMenu { get; private set; } + + /// Selects all rendered content. + /// if the operation succeeded. + public bool SelectAll () + { + if (_renderedLines.Count == 0) + { + return true; + } + + _isSelecting = true; + _selectionAnchor = new Point (0, 0); + int lastLine = _renderedLines.Count - 1; + _selectionCurrent = new Point (GetLineDisplayWidth (lastLine), lastLine); + SetNeedsDraw (); + + return true; + } + + /// + /// Copies the current selection, or the entire markdown document if nothing is selected, to the clipboard. + /// + /// if the copy was performed. + public bool Copy () + { + string text = _isSelecting ? GetSelectedText () : _markdown; + App?.Clipboard?.TrySetClipboardData (text); + + return true; + } + + /// Clears the current selection. + public void ClearSelection () + { + if (!_isSelecting) + { + return; + } + + _isSelecting = false; + SetNeedsDraw (); + } + + /// + /// Returns if the cell at (, ) + /// falls within the current selection. + /// + /// The rendered-line index (content Y coordinate). + /// The display column (content X coordinate). + internal bool IsInSelection (int lineIdx, int x) + { + if (!_isSelecting) + { + return false; + } + + (Point start, Point end) = GetNormalizedSelection (); + + if (lineIdx < start.Y || lineIdx > end.Y) + { + return false; + } + + if (start.Y == end.Y) + { + return x >= start.X && x < end.X; + } + + if (lineIdx == start.Y) + { + return x >= start.X; + } + + if (lineIdx == end.Y) + { + return x < end.X; + } + + return true; + } + + private (Point Start, Point End) GetNormalizedSelection () + { + if (_selectionAnchor.Y < _selectionCurrent.Y + || (_selectionAnchor.Y == _selectionCurrent.Y && _selectionAnchor.X <= _selectionCurrent.X)) + { + return (_selectionAnchor, _selectionCurrent); + } + + return (_selectionCurrent, _selectionAnchor); + } + + private string GetSelectedText () + { + if (_renderedLines.Count == 0) + { + return string.Empty; + } + + (Point start, Point end) = GetNormalizedSelection (); + StringBuilder sb = new (); + + for (int lineIdx = start.Y; lineIdx <= Math.Min (end.Y, _renderedLines.Count - 1); lineIdx++) + { + if (lineIdx > start.Y) + { + sb.Append ('\n'); + } + + int lineStartX = lineIdx == start.Y ? start.X : 0; + int lineEndX = lineIdx == end.Y ? end.X : int.MaxValue; + AppendLineText (sb, _renderedLines [lineIdx], lineStartX, lineEndX); + } + + return sb.ToString (); + } + + private static void AppendLineText (StringBuilder sb, RenderedLine line, int startX, int endX) + { + var contentX = 0; + + foreach (StyledSegment segment in line.Segments) + { + foreach (string grapheme in GraphemeHelper.GetGraphemes (segment.Text)) + { + int gw = Math.Max (grapheme.GetColumns (), 1); + + if (contentX + gw <= startX) + { + contentX += gw; + + continue; + } + + if (contentX >= endX) + { + return; + } + + sb.Append (grapheme); + contentX += gw; + } + } + } + + private int GetLineDisplayWidth (int lineIdx) + { + if (lineIdx < 0 || lineIdx >= _renderedLines.Count) + { + return 0; + } + + return _renderedLines [lineIdx].Width; + } + + private void CreateContextMenu () + { + DisposeContextMenu (); + + PopoverMenu menu = new ([ + new MenuItem (this, Command.SelectAll), + new MenuItem (this, Command.Copy) + ]) + { +#if DEBUG + Id = "markdownContextMenu" +#endif + }; + + HotKeyBindings.Remove (menu.Key); + HotKeyBindings.Add (menu.Key, Command.Context); + menu.KeyChanged += ContextMenuOnKeyChanged; + + ContextMenu = menu; + App?.Popovers?.Register (ContextMenu); + } + + private void DisposeContextMenu () + { + if (ContextMenu is null) + { + return; + } + + ContextMenu.Visible = false; + App?.Popovers?.DeRegister (ContextMenu); + ContextMenu.KeyChanged -= ContextMenuOnKeyChanged; + ContextMenu.Dispose (); + ContextMenu = null; + } + + private void ContextMenuOnKeyChanged (object? sender, KeyChangedEventArgs e) => + KeyBindings.Replace (e.OldKey.KeyCode, e.NewKey.KeyCode); + + private bool ShowContextMenu (Point? screenPosition = null) + { + ContextMenu?.MakeVisible (screenPosition); + + return true; + } + + /// + protected override void Dispose (bool disposing) + { + if (disposing) + { + DisposeContextMenu (); + } + + base.Dispose (disposing); + } +} diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs new file mode 100644 index 0000000000..095432e247 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs @@ -0,0 +1,226 @@ +using JetBrains.Annotations; +using UnitTests; + +namespace ViewsTests.Markdown; + +// Copilot +[TestSubject (typeof (Terminal.Gui.Views.Markdown))] +public class MarkdownViewSelectionTests +{ + /// Helper: builds and lays out a Markdown view at the given width/height. + private static (IApplication App, Runnable Window, Terminal.Gui.Views.Markdown Mv) CreateMv (string text, int width = 40, int height = 10) + { + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (width, height); + app.Clipboard = new FakeClipboard (); + + Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None }; + Terminal.Gui.Views.Markdown mv = new () { Text = text, Width = Dim.Fill (), Height = Dim.Fill () }; + window.Add (mv); + app.Begin (window); + app.LayoutAndDraw (); + + return (app, window, mv); + } + + [Fact] + public void SelectAll_Sets_IsSelecting_And_Returns_True () + { + Terminal.Gui.Views.Markdown mv = new () { Text = "Hello World", Width = 40, Height = 5 }; + View host = new () { Width = 40, Height = 5 }; + host.Add (mv); + host.BeginInit (); + host.EndInit (); + host.Layout (); + + bool result = mv.SelectAll (); + + Assert.True (result); + Assert.True (mv.IsInSelection (0, 0)); + + host.Dispose (); + } + + [Fact] + public void SelectAll_Empty_Content_Returns_True () + { + Terminal.Gui.Views.Markdown mv = new () { Text = "", Width = 40, Height = 5 }; + View host = new () { Width = 40, Height = 5 }; + host.Add (mv); + host.BeginInit (); + host.EndInit (); + host.Layout (); + + bool result = mv.SelectAll (); + + Assert.True (result); + + host.Dispose (); + } + + [Fact] + public void ClearSelection_Clears_IsSelecting () + { + Terminal.Gui.Views.Markdown mv = new () { Text = "Hello", Width = 40, Height = 5 }; + View host = new () { Width = 40, Height = 5 }; + host.Add (mv); + host.BeginInit (); + host.EndInit (); + host.Layout (); + + mv.SelectAll (); + Assert.True (mv.IsInSelection (0, 0)); + + mv.ClearSelection (); + Assert.False (mv.IsInSelection (0, 0)); + + host.Dispose (); + } + + [Fact] + public void IsInSelection_False_When_Not_Selecting () + { + Terminal.Gui.Views.Markdown mv = new () { Text = "Hello", Width = 40, Height = 5 }; + View host = new () { Width = 40, Height = 5 }; + host.Add (mv); + host.BeginInit (); + host.EndInit (); + host.Layout (); + + // No SelectAll called — should return false + Assert.False (mv.IsInSelection (0, 0)); + Assert.False (mv.IsInSelection (0, 5)); + + host.Dispose (); + } + + [Fact] + public void Copy_Without_Selection_Copies_Markdown_Source () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("# Hello\n\nWorld"); + + bool result = mv.Copy (); + + Assert.True (result); + app.Clipboard!.TryGetClipboardData (out string clipboard); + Assert.Equal ("# Hello\n\nWorld", clipboard); + + window.Dispose (); + app.Dispose (); + } + + [Fact] + public void Copy_With_SelectAll_Copies_Selected_Text () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello"); + + mv.SelectAll (); + bool result = mv.Copy (); + + Assert.True (result); + app.Clipboard!.TryGetClipboardData (out string clipboard); + + // The rendered text for "Hello" should contain "Hello" + Assert.Contains ("Hello", clipboard); + + window.Dispose (); + app.Dispose (); + } + + [Fact] + public void Copy_Command_Copies_Markdown_When_No_Selection () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("# Test"); + + // Invoke Command.Copy directly (no selection active) + mv.InvokeCommand (Command.Copy); + + app.Clipboard!.TryGetClipboardData (out string clipboard); + Assert.Equal ("# Test", clipboard); + + window.Dispose (); + app.Dispose (); + } + + [Fact] + public void SelectAll_Command_Then_Copy_Command_Copies_All_Text () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello World"); + + mv.InvokeCommand (Command.SelectAll); + mv.InvokeCommand (Command.Copy); + + app.Clipboard!.TryGetClipboardData (out string clipboard); + Assert.Contains ("Hello World", clipboard); + + window.Dispose (); + app.Dispose (); + } + + [Fact] + public void Text_Change_Clears_Selection () + { + Terminal.Gui.Views.Markdown mv = new () { Text = "Hello", Width = 40, Height = 5 }; + View host = new () { Width = 40, Height = 5 }; + host.Add (mv); + host.BeginInit (); + host.EndInit (); + host.Layout (); + + mv.SelectAll (); + Assert.True (mv.IsInSelection (0, 0)); + + // Changing text should clear selection + mv.Text = "World"; + + Assert.False (mv.IsInSelection (0, 0)); + + host.Dispose (); + } + + [Fact] + public void ContextMenu_Is_Null_Before_Init () + { + Terminal.Gui.Views.Markdown mv = new () { Text = "Hello" }; + + Assert.Null (mv.ContextMenu); + + mv.Dispose (); + } + + [Fact] + public void ContextMenu_Is_Created_On_Focus () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello"); + + mv.SetFocus (); + + Assert.NotNull (mv.ContextMenu); + + window.Dispose (); + app.Dispose (); + } + + [Fact] + public void ContextMenu_Is_Disposed_On_Losing_Focus () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello"); + + // Add another focusable view + Button btn = new () { Text = "OK", X = 0, Y = 5 }; + window.Add (btn); + app.LayoutAndDraw (); + + mv.SetFocus (); + Assert.NotNull (mv.ContextMenu); + + // Move focus away + btn.SetFocus (); + Assert.Null (mv.ContextMenu); + + window.Dispose (); + app.Dispose (); + } +} + From 8d163aa00c3279eca4ea667004089b3e601363d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 00:00:12 +0000 Subject: [PATCH 03/15] Fix code review issues: use explicit type int and App is not null pattern Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/ecbe0462-5243-4c9a-9464-f0e66acd0b9f Co-authored-by: tig <585482+tig@users.noreply.github.com> --- Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs | 4 ++-- Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs index 96dc2b0d97..701336a825 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs @@ -76,7 +76,7 @@ protected override bool OnMouseEvent (Mouse mouse) _isDragging = false; - if (App is { } && !App.Mouse.IsGrabbed (this)) + if (App is not null && !App.Mouse.IsGrabbed (this)) { App.Mouse.GrabMouse (this); } @@ -107,7 +107,7 @@ protected override bool OnMouseEvent (Mouse mouse) if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased)) { - if (App is { } && App.Mouse.IsGrabbed (this)) + if (App is not null && App.Mouse.IsGrabbed (this)) { App.Mouse.UngrabMouse (); } diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs index f4ab6fae72..0ed47697a9 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs @@ -130,7 +130,7 @@ private string GetSelectedText () private static void AppendLineText (StringBuilder sb, RenderedLine line, int startX, int endX) { - var contentX = 0; + int contentX = 0; foreach (StyledSegment segment in line.Segments) { From 67d2e486264caedd722df85ded2cabf09eb65475 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 12:21:22 +0000 Subject: [PATCH 04/15] Fix selection visibility and persistence after mouse release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix selection attribute: use GetAttributeForRole(VisualRole.Focus) instead of reversing per-segment colors — gives reliable contrast across all schemes/themes - Fix selection disappearing on mouse release: remove the LeftButtonReleased → Activate binding (inherited from base SetupMouse) so OnActivated fires only on LeftButtonClicked; previously OnActivated fired twice causing _isDragging to be reset before the Click, which then cleared the selection - Add two regression tests: Selection_Persists_After_LeftButtonReleased and Plain_Click_Clears_Selection Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/23c59c05-1db9-43bb-8f36-2dcceb3db746 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- .../Views/Markdown/MarkdownView.Drawing.cs | 6 +-- .../Views/Markdown/MarkdownView.Mouse.cs | 4 ++ .../Markdown/MarkdownViewSelectionTests.cs | 39 +++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs index 780b1b0f44..0a91503393 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs @@ -114,9 +114,9 @@ private void DrawRenderedLine (RenderedLine line, int contentRow, int drawRow) } else if (IsInSelection (contentRow, contentX)) { - Attribute selAttr = GetAttributeForSegment (segment); - Attribute reversed = new (selAttr.Background, selAttr.Foreground, selAttr.Style); - SetAttribute (reversed); + // Use the scheme's Focus attribute for selection highlight — it provides + // reliable contrast regardless of per-segment colours. + SetAttribute (GetAttributeForRole (VisualRole.Focus)); AddStr (drawCol, drawRow, grapheme); } else diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs index 701336a825..a1b5686ba5 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs @@ -56,6 +56,10 @@ private void SetupBindingsAndCommands () MouseBindings.ReplaceCommands (MouseFlags.WheeledUp, Command.ScrollUp); MouseBindings.ReplaceCommands (MouseFlags.WheeledRight, Command.ScrollRight); MouseBindings.ReplaceCommands (MouseFlags.WheeledLeft, Command.ScrollLeft); + + // The base class binds LeftButtonReleased → Activate; remove that so Activate + // fires only on LeftButtonClicked (not twice per click which would clear selection). + MouseBindings.Remove (MouseFlags.LeftButtonReleased); MouseBindings.ReplaceCommands (MouseFlags.LeftButtonClicked, Command.Activate); MouseBindings.ReplaceCommands (MouseFlags.RightButtonClicked, Command.Context); } diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs index 095432e247..5a58e26988 100644 --- a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs +++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs @@ -222,5 +222,44 @@ public void ContextMenu_Is_Disposed_On_Losing_Focus () window.Dispose (); app.Dispose (); } + + // Copilot - verifies that mouse release does not clear an active selection + [Fact] + public void Selection_Persists_After_LeftButtonReleased () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello World"); + + // Simulate a drag: press, drag, release + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (5, 0), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + mv.NewMouseEvent (new Mouse { Position = new Point (5, 0), Flags = MouseFlags.LeftButtonReleased }); + + // Selection should survive the release + Assert.True (mv.IsInSelection (0, 0)); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - verifies that a plain click (no drag) clears the selection + [Fact] + public void Plain_Click_Clears_Selection () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello World"); + + mv.SelectAll (); + Assert.True (mv.IsInSelection (0, 0)); + + // Simulate a plain click (no PositionReport drag events) + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonReleased }); + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonClicked }); + + // Selection should be cleared by the click + Assert.False (mv.IsInSelection (0, 0)); + + window.Dispose (); + app.Dispose (); + } } From f74914dc3379c2eb8f52a0d55edf89e329d4329e Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 4 May 2026 07:01:23 -0600 Subject: [PATCH 05/15] Code cleanup --- .../Views/Markdown/IntermediateBlock.cs | 11 +++++-- Terminal.Gui/Views/Markdown/Markdown.cs | 3 +- .../Views/Markdown/MarkdownCodeBlock.cs | 2 +- .../Views/Markdown/MarkdownView.Mouse.cs | 29 ++++++++++--------- .../Views/Markdown/MarkdownView.Selection.cs | 15 +++------- 5 files changed, 31 insertions(+), 29 deletions(-) diff --git a/Terminal.Gui/Views/Markdown/IntermediateBlock.cs b/Terminal.Gui/Views/Markdown/IntermediateBlock.cs index 6f1b8eb819..f75198b1bf 100644 --- a/Terminal.Gui/Views/Markdown/IntermediateBlock.cs +++ b/Terminal.Gui/Views/Markdown/IntermediateBlock.cs @@ -1,6 +1,13 @@ namespace Terminal.Gui.Views; -internal sealed class IntermediateBlock (IReadOnlyList runs, bool wrap, string prefix = "", string continuationPrefix = "", bool isCodeBlock = false, string? anchor = null, bool isThematicBreak = false, TableData? tableData = null) +internal sealed class IntermediateBlock (IReadOnlyList runs, + bool wrap, + string prefix = "", + string continuationPrefix = "", + bool isCodeBlock = false, + string? anchor = null, + bool isThematicBreak = false, + TableData? tableData = null) { public IReadOnlyList Runs { get; } = runs; public bool Wrap { get; } = wrap; @@ -13,7 +20,7 @@ internal sealed class IntermediateBlock (IReadOnlyList runs, bool wra public TableData? TableData { get; } = tableData; /// Gets whether this block represents a Markdown table. - public bool IsTable => TableData is not null; + public bool IsTable => TableData is { }; /// The GitHub-style anchor slug for heading blocks, or for non-heading blocks. public string? Anchor { get; } = anchor; diff --git a/Terminal.Gui/Views/Markdown/Markdown.cs b/Terminal.Gui/Views/Markdown/Markdown.cs index 7998204388..5fbaaadca2 100644 --- a/Terminal.Gui/Views/Markdown/Markdown.cs +++ b/Terminal.Gui/Views/Markdown/Markdown.cs @@ -24,7 +24,8 @@ namespace Terminal.Gui.Views; /// Key Action /// /// -/// Ctrl+A Selects all rendered content (). +/// Ctrl+A +/// Selects all rendered content (). /// /// /// Ctrl+C diff --git a/Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs b/Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs index 33b78bc947..77bb700e76 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs @@ -240,7 +240,7 @@ protected override bool OnMouseEvent (Mouse mouse) int copyGlyphX = Viewport.Width - 2; - if (pos.X != copyGlyphX && pos.X != copyGlyphX + 1 || pos.Y != 0) + if ((pos.X != copyGlyphX && pos.X != copyGlyphX + 1) || pos.Y != 0) { return false; } diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs index a1b5686ba5..a0279bf882 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs @@ -80,7 +80,7 @@ protected override bool OnMouseEvent (Mouse mouse) _isDragging = false; - if (App is not null && !App.Mouse.IsGrabbed (this)) + if (App is { } && !App.Mouse.IsGrabbed (this)) { App.Mouse.GrabMouse (this); } @@ -96,29 +96,30 @@ protected override bool OnMouseEvent (Mouse mouse) if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport)) { // Drag: extend selection - if (mouse.Position is { } dragPos) + if (mouse.Position is not { } dragPos) { - int contentX = Viewport.X + dragPos.X; - int contentY = Math.Min (Viewport.Y + dragPos.Y, Math.Max (_renderedLines.Count - 1, 0)); - _selectionCurrent = new Point (contentX, contentY); - _isDragging = true; - _isSelecting = true; - SetNeedsDraw (); + return true; } + int contentX = Viewport.X + dragPos.X; + int contentY = Math.Min (Viewport.Y + dragPos.Y, Math.Max (_renderedLines.Count - 1, 0)); + _selectionCurrent = new Point (contentX, contentY); + _isDragging = true; + _isSelecting = true; + SetNeedsDraw (); return true; } - if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased)) + if (!mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased)) { - if (App is not null && App.Mouse.IsGrabbed (this)) - { - App.Mouse.UngrabMouse (); - } - return false; } + if (App is { } && App.Mouse.IsGrabbed (this)) + { + App.Mouse.UngrabMouse (); + } + return false; } diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs index 0ed47697a9..4978b4211e 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs @@ -1,5 +1,3 @@ -using System.Text; - namespace Terminal.Gui.Views; public partial class Markdown @@ -94,8 +92,7 @@ internal bool IsInSelection (int lineIdx, int x) private (Point Start, Point End) GetNormalizedSelection () { - if (_selectionAnchor.Y < _selectionCurrent.Y - || (_selectionAnchor.Y == _selectionCurrent.Y && _selectionAnchor.X <= _selectionCurrent.X)) + if (_selectionAnchor.Y < _selectionCurrent.Y || (_selectionAnchor.Y == _selectionCurrent.Y && _selectionAnchor.X <= _selectionCurrent.X)) { return (_selectionAnchor, _selectionCurrent); } @@ -130,7 +127,7 @@ private string GetSelectedText () private static void AppendLineText (StringBuilder sb, RenderedLine line, int startX, int endX) { - int contentX = 0; + var contentX = 0; foreach (StyledSegment segment in line.Segments) { @@ -170,10 +167,7 @@ private void CreateContextMenu () { DisposeContextMenu (); - PopoverMenu menu = new ([ - new MenuItem (this, Command.SelectAll), - new MenuItem (this, Command.Copy) - ]) + PopoverMenu menu = new ([new MenuItem (this, Command.SelectAll), new MenuItem (this, Command.Copy)]) { #if DEBUG Id = "markdownContextMenu" @@ -202,8 +196,7 @@ private void DisposeContextMenu () ContextMenu = null; } - private void ContextMenuOnKeyChanged (object? sender, KeyChangedEventArgs e) => - KeyBindings.Replace (e.OldKey.KeyCode, e.NewKey.KeyCode); + private void ContextMenuOnKeyChanged (object? sender, KeyChangedEventArgs e) => KeyBindings.Replace (e.OldKey.KeyCode, e.NewKey.KeyCode); private bool ShowContextMenu (Point? screenPosition = null) { From ccff0f3cfaecb23ded560cb0ed7f63fcc8080f46 Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 4 May 2026 07:25:59 -0600 Subject: [PATCH 06/15] Refactor MarkdownView mouse selection handling Route mouse press and drag events through OnActivated by binding LeftButtonPressed and LeftButtonPressed|PositionReport to Command.Activate. Update selection logic accordingly and add unit tests to verify mouse bindings and drag-selection behavior. --- .../Views/Markdown/MarkdownView.Mouse.cs | 97 +++++++++---------- .../Markdown/MarkdownViewSelectionTests.cs | 46 +++++++++ 2 files changed, 93 insertions(+), 50 deletions(-) diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs index a0279bf882..ee0d04eef4 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs @@ -62,54 +62,15 @@ private void SetupBindingsAndCommands () MouseBindings.Remove (MouseFlags.LeftButtonReleased); MouseBindings.ReplaceCommands (MouseFlags.LeftButtonClicked, Command.Activate); MouseBindings.ReplaceCommands (MouseFlags.RightButtonClicked, Command.Context); + + // Press anchors the drag-selection; drag extends it — both routed through OnActivated. + MouseBindings.Add (MouseFlags.LeftButtonPressed, Command.Activate); + MouseBindings.Add (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, Command.Activate); } /// protected override bool OnMouseEvent (Mouse mouse) { - if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed) && !mouse.Flags.HasFlag (MouseFlags.PositionReport)) - { - // Button-down: anchor the selection start - if (mouse.Position is { } pressPos) - { - int contentX = Viewport.X + pressPos.X; - int contentY = Math.Min (Viewport.Y + pressPos.Y, Math.Max (_renderedLines.Count - 1, 0)); - _selectionAnchor = new Point (contentX, contentY); - _selectionCurrent = _selectionAnchor; - } - - _isDragging = false; - - if (App is { } && !App.Mouse.IsGrabbed (this)) - { - App.Mouse.GrabMouse (this); - } - - if (!HasFocus && CanFocus) - { - SetFocus (); - } - - return false; - } - - if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport)) - { - // Drag: extend selection - if (mouse.Position is not { } dragPos) - { - return true; - } - int contentX = Viewport.X + dragPos.X; - int contentY = Math.Min (Viewport.Y + dragPos.Y, Math.Max (_renderedLines.Count - 1, 0)); - _selectionCurrent = new Point (contentX, contentY); - _isDragging = true; - _isSelecting = true; - SetNeedsDraw (); - - return true; - } - if (!mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased)) { return false; @@ -203,13 +164,49 @@ protected override bool OnAdvancingFocus (NavigationDirection direction, TabBeha /// protected override void OnActivated (ICommandContext? ctx) { - // Only process mouse clicks — keyboard activation is handled via Command.Accept - if (ctx?.Binding is not MouseBinding { MouseEvent.Position: { } pos }) + // Only process mouse input — keyboard activation is handled via Command.Accept + if (ctx?.Binding is not MouseBinding { MouseEvent: { } mouse, MouseEvent.Position: { } pos }) + { + return; + } + + // Button-down: anchor the drag-selection start + if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed) && !mouse.Flags.HasFlag (MouseFlags.PositionReport)) + { + int contentX = Viewport.X + pos.X; + int contentY = Math.Min (Viewport.Y + pos.Y, Math.Max (_renderedLines.Count - 1, 0)); + _selectionAnchor = new Point (contentX, contentY); + _selectionCurrent = _selectionAnchor; + _isDragging = false; + + if (App is { } && !App.Mouse.IsGrabbed (this)) + { + App.Mouse.GrabMouse (this); + } + + if (!HasFocus && CanFocus) + { + SetFocus (); + } + + return; + } + + // Drag: extend selection + if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport)) { + int contentX = Viewport.X + pos.X; + int contentY = Math.Min (Viewport.Y + pos.Y, Math.Max (_renderedLines.Count - 1, 0)); + _selectionCurrent = new Point (contentX, contentY); + _isDragging = true; + _isSelecting = true; + SetNeedsDraw (); + return; } - // A drag ended: the click fires after release, but the user was selecting text — don't activate link. + // LeftButtonClicked: a drag ended — the click fires after release, but the user was + // selecting text, so don't activate a link. if (_isDragging) { _isDragging = false; @@ -225,19 +222,19 @@ protected override void OnActivated (ICommandContext? ctx) SetFocus (); } - int contentX = Viewport.X + pos.X; - int contentY = Viewport.Y + pos.Y; + int clickX = Viewport.X + pos.X; + int clickY = Viewport.Y + pos.Y; for (var i = 0; i < _linkRegions.Count; i++) { MarkdownLinkRegion region = _linkRegions [i]; - if (region.Line != contentY) + if (region.Line != clickY) { continue; } - if (contentX < region.StartX || contentX >= region.EndXExclusive) + if (clickX < region.StartX || clickX >= region.EndXExclusive) { continue; } diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs index 5a58e26988..972bfabf0b 100644 --- a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs +++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs @@ -223,6 +223,52 @@ public void ContextMenu_Is_Disposed_On_Losing_Focus () app.Dispose (); } + // Copilot - verifies that LeftButtonPressed is bound to Command.Activate + [Fact] + public void MouseBindings_LeftButtonPressed_IsBoundTo_Activate () + { + Terminal.Gui.Views.Markdown mv = new (); + + bool found = mv.MouseBindings.TryGet (MouseFlags.LeftButtonPressed, out MouseBinding binding); + + Assert.True (found); + Assert.Contains (Command.Activate, binding.Commands); + + mv.Dispose (); + } + + // Copilot - verifies that LeftButtonPressed|PositionReport is bound to Command.Activate + [Fact] + public void MouseBindings_LeftButtonPressedPositionReport_IsBoundTo_Activate () + { + Terminal.Gui.Views.Markdown mv = new (); + + bool found = mv.MouseBindings.TryGet (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, out MouseBinding binding); + + Assert.True (found); + Assert.Contains (Command.Activate, binding.Commands); + + mv.Dispose (); + } + + // Copilot - verifies that a drag (press + position-report) activates the selection + [Fact] + public void Drag_Mouse_Creates_Selection () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello World"); + + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (5, 0), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + // After a drag the selection should span columns 0-4 + Assert.True (mv.IsInSelection (0, 0)); + Assert.True (mv.IsInSelection (0, 3)); + Assert.False (mv.IsInSelection (0, 6)); + + window.Dispose (); + app.Dispose (); + } + // Copilot - verifies that mouse release does not clear an active selection [Fact] public void Selection_Persists_After_LeftButtonReleased () From db77f4bff92fad940309666503ef38246a123f6d Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 4 May 2026 07:40:09 -0600 Subject: [PATCH 07/15] Add SelectedText property and markdown fidelity tests Added public SelectedText property to Markdown view, returning the current selection as plain text or null if inactive. Documented that the output may differ from the markdown source. Introduced Copilot-marked tests verifying that selection and copy operations should preserve markdown structure (e.g., list markers, code fences). Tests currently fail, documenting known fidelity issues in the selection/copy implementation. No changes to selection/copy logic yet. --- .../Views/Markdown/MarkdownView.Selection.cs | 12 ++ .../Markdown/MarkdownViewSelectionTests.cs | 179 +++++++++++++++++- 2 files changed, 189 insertions(+), 2 deletions(-) diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs index 4978b4211e..f4a8078cdf 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs @@ -28,6 +28,18 @@ public bool SelectAll () return true; } + /// + /// Gets the text that corresponds to the current selection, rendered as plain text from + /// the displayed content. Returns when no selection is active. + /// + /// + /// The returned string reflects the on-screen representation (display text) of the selected + /// region — not the original markdown source. Markdown structure such as bullet-list + /// markers (- ), fenced code-block delimiters (```), and heading hashes + /// (#) may differ from the source document. + /// + public string? SelectedText => _isSelecting ? GetSelectedText () : null; + /// /// Copies the current selection, or the entire markdown document if nothing is selected, to the clipboard. /// diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs index 972bfabf0b..23e840e0a1 100644 --- a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs +++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs @@ -1,5 +1,4 @@ using JetBrains.Annotations; -using UnitTests; namespace ViewsTests.Markdown; @@ -307,5 +306,181 @@ public void Plain_Click_Clears_Selection () window.Dispose (); app.Dispose (); } -} + // --- Copy fidelity tests (these FAIL with the current implementation) --- + // The current GetSelectedText reads from rendered lines (display text), + // which loses markdown structure. These tests document the expected behaviour. + + // Copilot + [Fact] + public void SelectedText_Is_Null_When_No_Selection () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello"); + + Assert.Null (mv.SelectedText); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - FAILS: GetSelectedText returns "• foo" (Unicode bullet), not "- foo" + [Fact] + public void SelectAll_BulletList_SelectedText_Preserves_Markdown_List_Markers () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("- foo\n- bar\n- baz", 40, 10); + + mv.SelectAll (); + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + + // Expect standard markdown unordered list syntax, not Unicode bullets + Assert.Contains ("- foo", selected); + Assert.Contains ("- bar", selected); + Assert.Contains ("- baz", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - FAILS: GetSelectedText returns "• foo" (Unicode bullet), not "* foo" + [Fact] + public void SelectAll_AsteriskBulletList_SelectedText_Preserves_Markdown_List_Markers () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("* alpha\n* beta\n* gamma", 40, 10); + + mv.SelectAll (); + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + + // Expect recognisable markdown list syntax (- or *), not Unicode bullets + Assert.DoesNotContain ("•", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - FAILS: fenced code block fence lines are never added to _renderedLines + [Fact] + public void SelectAll_FencedCodeBlock_With_Language_SelectedText_Preserves_Fences () + { + var md = "```cs\nvar x = 1;\n```"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md); + + mv.SelectAll (); + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + + // Must contain the opening fence with language tag and the code itself + Assert.Contains ("```cs", selected); + Assert.Contains ("var x = 1;", selected); + + // Must contain the closing fence + Assert.Contains ("```", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - FAILS: fences lost even without a language specifier + [Fact] + public void SelectAll_FencedCodeBlock_Without_Language_SelectedText_Preserves_Fences () + { + var md = "```\nhello world\n```"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md); + + mv.SelectAll (); + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("```", selected); + Assert.Contains ("hello world", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - FAILS: Copy() calls GetSelectedText() even for SelectAll, losing bullet markers + [Fact] + public void Copy_After_SelectAll_BulletList_Clipboard_Contains_Markdown_Markers () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("- one\n- two\n- three"); + + mv.SelectAll (); + mv.Copy (); + + app.Clipboard!.TryGetClipboardData (out string clipboard); + Assert.Contains ("- one", clipboard); + Assert.Contains ("- two", clipboard); + Assert.Contains ("- three", clipboard); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - FAILS: Copy() loses code fences when selection is active + [Fact] + public void Copy_After_SelectAll_FencedCodeBlock_Clipboard_Contains_Fences () + { + var md = "```python\nprint('hi')\n```"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md); + + mv.SelectAll (); + mv.Copy (); + + app.Clipboard!.TryGetClipboardData (out string clipboard); + Assert.Contains ("```python", clipboard); + Assert.Contains ("print('hi')", clipboard); + Assert.Contains ("```", clipboard); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - FAILS: a partial selection of bullet items loses the list markers + [Fact] + public void PartialSelection_BulletList_SelectedText_Preserves_Markdown_Markers () + { + // Two bullet items. We select both rendered lines (Y=0 and Y=1). + var md = "- alpha\n- beta"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md); + + // Anchor at start of line 0, extend to end of line 1 + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (10, 1), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("- alpha", selected); + Assert.Contains ("- beta", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - FAILS: partial selection inside a fenced code block loses fence context + [Fact] + public void PartialSelection_FencedCodeBlock_SelectedText_Preserves_Fence_Context () + { + var md = "```cs\nint a = 1;\nint b = 2;\n```"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md); + + // Select both code lines (Y=0 and Y=1 in rendered output — fence lines don't appear) + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (10, 1), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + + // The selection is from inside a code block — the language tag must survive + Assert.Contains ("```cs", selected); + Assert.Contains ("int a = 1;", selected); + + window.Dispose (); + app.Dispose (); + } +} From 9b30910a6f0209324e09ba1cfbad3598783a8080 Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 4 May 2026 08:31:42 -0600 Subject: [PATCH 08/15] Preserve markdown syntax in selection and copy Improves MarkdownView selection/copy fidelity by reconstructing code block fences (with language tags) and list markers in selected/copied text. Returns original markdown source for full-document selections. Updates and adds tests to verify round-trip and partial selection correctness. --- .../Views/Markdown/IntermediateBlock.cs | 9 +- .../Views/Markdown/MarkdownView.Layout.cs | 2 +- .../Views/Markdown/MarkdownView.Parsing.cs | 4 +- .../Views/Markdown/MarkdownView.Selection.cs | 64 ++++++++- Terminal.Gui/Views/Markdown/RenderedLine.cs | 8 +- .../Markdown/MarkdownViewSelectionTests.cs | 132 ++++++++++-------- 6 files changed, 151 insertions(+), 68 deletions(-) diff --git a/Terminal.Gui/Views/Markdown/IntermediateBlock.cs b/Terminal.Gui/Views/Markdown/IntermediateBlock.cs index f75198b1bf..90eb42c686 100644 --- a/Terminal.Gui/Views/Markdown/IntermediateBlock.cs +++ b/Terminal.Gui/Views/Markdown/IntermediateBlock.cs @@ -7,7 +7,8 @@ internal sealed class IntermediateBlock (IReadOnlyList runs, bool isCodeBlock = false, string? anchor = null, bool isThematicBreak = false, - TableData? tableData = null) + TableData? tableData = null, + string? language = null) { public IReadOnlyList Runs { get; } = runs; public bool Wrap { get; } = wrap; @@ -24,4 +25,10 @@ internal sealed class IntermediateBlock (IReadOnlyList runs, /// The GitHub-style anchor slug for heading blocks, or for non-heading blocks. public string? Anchor { get; } = anchor; + + /// + /// The fenced code block language specifier (e.g. "cs", "python"), or + /// when this is not a code block or no language was given. + /// + public string? Language { get; } = language; } diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs index a0b7a7fe49..b603fea1f4 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs @@ -188,7 +188,7 @@ private static RenderedLine CreateUnwrappedLine (IntermediateBlock block) int width = CalculateWidth (segments); - return new RenderedLine (segments, false, width, block.IsCodeBlock, block.IsThematicBreak); + return new RenderedLine (segments, false, width, block.IsCodeBlock, block.IsThematicBreak, codeLanguage: block.Language); } private static List WrapBlock (IntermediateBlock block, int viewportWidth) diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs index 31833d2ed7..6c35126558 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs @@ -534,7 +534,7 @@ private void AddCodeBlockLines (IReadOnlyList codeLines, string? languag { if (codeLines.Count == 0) { - _blocks.Add (new IntermediateBlock ([new InlineRun ("", MarkdownStyleRole.CodeBlock)], false, isCodeBlock: true)); + _blocks.Add (new IntermediateBlock ([new InlineRun ("", MarkdownStyleRole.CodeBlock)], false, isCodeBlock: true, language: language)); return; } @@ -563,7 +563,7 @@ private void AddCodeBlockLines (IReadOnlyList codeLines, string? languag runs = converted; } - _blocks.Add (new IntermediateBlock (runs, false, isCodeBlock: true)); + _blocks.Add (new IntermediateBlock (runs, false, isCodeBlock: true, language: language)); } } diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs index f4a8078cdf..236f6c5e10 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs @@ -119,22 +119,68 @@ private string GetSelectedText () return string.Empty; } + // When the entire document is selected return the original source markdown. + // _renderedLines is a display buffer: it cannot carry inline formatting markers, + // heading hashes, table syntax, or thematic-break syntax. Returning _markdown + // preserves everything and is always correct for a full-document selection. + if (IsFullDocumentSelected ()) + { + return _markdown; + } + (Point start, Point end) = GetNormalizedSelection (); - StringBuilder sb = new (); + List outputLines = []; + bool inCodeBlock = false; for (int lineIdx = start.Y; lineIdx <= Math.Min (end.Y, _renderedLines.Count - 1); lineIdx++) { - if (lineIdx > start.Y) + RenderedLine line = _renderedLines [lineIdx]; + + if (line.IsCodeBlock && !inCodeBlock) + { + // Entering a code block: inject the opening fence with optional language tag + outputLines.Add ($"```{line.CodeLanguage ?? string.Empty}"); + inCodeBlock = true; + } + else if (!line.IsCodeBlock && inCodeBlock) { - sb.Append ('\n'); + // Leaving a code block: inject the closing fence + outputLines.Add ("```"); + inCodeBlock = false; } int lineStartX = lineIdx == start.Y ? start.X : 0; int lineEndX = lineIdx == end.Y ? end.X : int.MaxValue; - AppendLineText (sb, _renderedLines [lineIdx], lineStartX, lineEndX); + StringBuilder lineSb = new (); + AppendLineText (lineSb, line, lineStartX, lineEndX); + outputLines.Add (lineSb.ToString ()); } - return sb.ToString (); + if (inCodeBlock) + { + outputLines.Add ("```"); + } + + return string.Join ("\n", outputLines); + } + + /// + /// Returns when the selection spans the entire rendered document + /// from the first character to the last, so that can + /// return the original markdown source instead of the lossy display representation. + /// + private bool IsFullDocumentSelected () + { + (Point start, Point end) = GetNormalizedSelection (); + + if (start.X != 0 || start.Y != 0) + { + return false; + } + + int lastLine = _renderedLines.Count - 1; + + return end.Y >= lastLine && end.X >= GetLineDisplayWidth (lastLine); } private static void AppendLineText (StringBuilder sb, RenderedLine line, int startX, int endX) @@ -143,7 +189,13 @@ private static void AppendLineText (StringBuilder sb, RenderedLine line, int sta foreach (StyledSegment segment in line.Segments) { - foreach (string grapheme in GraphemeHelper.GetGraphemes (segment.Text)) + // Translate the display bullet character back to standard markdown list syntax. + // The ListMarker may be "• " (plain), "• [x] " (done task), or "• [ ] " (open task). + string text = segment.StyleRole == MarkdownStyleRole.ListMarker && segment.Text.StartsWith ("• ") + ? "- " + segment.Text [2..] + : segment.Text; + + foreach (string grapheme in GraphemeHelper.GetGraphemes (text)) { int gw = Math.Max (grapheme.GetColumns (), 1); diff --git a/Terminal.Gui/Views/Markdown/RenderedLine.cs b/Terminal.Gui/Views/Markdown/RenderedLine.cs index e5b04516dc..6923542da7 100644 --- a/Terminal.Gui/Views/Markdown/RenderedLine.cs +++ b/Terminal.Gui/Views/Markdown/RenderedLine.cs @@ -1,6 +1,6 @@ namespace Terminal.Gui.Views; -internal sealed class RenderedLine (IReadOnlyList segments, bool wrapEligible, int width, bool isCodeBlock = false, bool isThematicBreak = false, bool isTable = false) +internal sealed class RenderedLine (IReadOnlyList segments, bool wrapEligible, int width, bool isCodeBlock = false, bool isThematicBreak = false, bool isTable = false, string? codeLanguage = null) { public IReadOnlyList Segments { get; } = segments; public bool WrapEligible { get; } = wrapEligible; @@ -8,4 +8,10 @@ internal sealed class RenderedLine (IReadOnlyList segments, bool public bool IsCodeBlock { get; } = isCodeBlock; public bool IsThematicBreak { get; } = isThematicBreak; public bool IsTable { get; } = isTable; + + /// + /// The fenced code-block language specifier (e.g. "cs"), or + /// when this line is not part of a code block or no language was given. + /// + public string? CodeLanguage { get; } = codeLanguage; } diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs index 23e840e0a1..a7513c59a8 100644 --- a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs +++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs @@ -307,9 +307,9 @@ public void Plain_Click_Clears_Selection () app.Dispose (); } - // --- Copy fidelity tests (these FAIL with the current implementation) --- - // The current GetSelectedText reads from rendered lines (display text), - // which loses markdown structure. These tests document the expected behaviour. + // --- Copy fidelity tests --- + // These use content from Markdown.DefaultMarkdownSample where possible, which contains + // Unicode characters (emoji), task-list markers, and fenced code blocks with language tags. // Copilot [Fact] @@ -323,73 +323,73 @@ public void SelectedText_Is_Null_When_No_Selection () app.Dispose (); } - // Copilot - FAILS: GetSelectedText returns "• foo" (Unicode bullet), not "- foo" + // Copilot - task-list items from DefaultMarkdownSample (includes emoji ✅ 🔧 🎉) [Fact] - public void SelectAll_BulletList_SelectedText_Preserves_Markdown_List_Markers () + public void SelectAll_TaskList_SelectedText_Preserves_Markdown_List_Markers () { - (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("- foo\n- bar\n- baz", 40, 10); + // Source task-list lines taken from Markdown.DefaultMarkdownSample § Checklist + string md = "- [x] Bold & italic ✅\n- [x] Code blocks 🔧\n- [ ] Emojis 🎉"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); mv.SelectAll (); string? selected = mv.SelectedText; Assert.NotNull (selected); - - // Expect standard markdown unordered list syntax, not Unicode bullets - Assert.Contains ("- foo", selected); - Assert.Contains ("- bar", selected); - Assert.Contains ("- baz", selected); + Assert.Contains ("- [x] Bold & italic ✅", selected); + Assert.Contains ("- [x] Code blocks 🔧", selected); + Assert.Contains ("- [ ] Emojis 🎉", selected); window.Dispose (); app.Dispose (); } - // Copilot - FAILS: GetSelectedText returns "• foo" (Unicode bullet), not "* foo" + // Copilot - plain bullet list (no task markers) [Fact] - public void SelectAll_AsteriskBulletList_SelectedText_Preserves_Markdown_List_Markers () + public void SelectAll_BulletList_SelectedText_Preserves_Markdown_List_Markers () { - (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("* alpha\n* beta\n* gamma", 40, 10); + string md = "- foo\n- bar\n- baz"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 40, height: 10); mv.SelectAll (); string? selected = mv.SelectedText; Assert.NotNull (selected); - - // Expect recognisable markdown list syntax (- or *), not Unicode bullets Assert.DoesNotContain ("•", selected); + Assert.Contains ("- foo", selected); + Assert.Contains ("- bar", selected); + Assert.Contains ("- baz", selected); window.Dispose (); app.Dispose (); } - // Copilot - FAILS: fenced code block fence lines are never added to _renderedLines + // Copilot - fenced code block from DefaultMarkdownSample (csharp, contains 🌍 emoji) [Fact] public void SelectAll_FencedCodeBlock_With_Language_SelectedText_Preserves_Fences () { - var md = "```cs\nvar x = 1;\n```"; - (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md); + // Source taken from Markdown.DefaultMarkdownSample § Code Block (csharp) + string md = "```csharp\nConsole.WriteLine (\"Hello, Terminal.Gui! 🌍\");\nvar x = 42;\n```"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); mv.SelectAll (); string? selected = mv.SelectedText; Assert.NotNull (selected); - - // Must contain the opening fence with language tag and the code itself - Assert.Contains ("```cs", selected); - Assert.Contains ("var x = 1;", selected); - - // Must contain the closing fence - Assert.Contains ("```", selected); + Assert.Contains ("```csharp", selected); + Assert.Contains ("Console.WriteLine", selected); + Assert.Contains ("🌍", selected); + Assert.Contains ("var x = 42;", selected); window.Dispose (); app.Dispose (); } - // Copilot - FAILS: fences lost even without a language specifier + // Copilot - fenced code block without language specifier [Fact] public void SelectAll_FencedCodeBlock_Without_Language_SelectedText_Preserves_Fences () { - var md = "```\nhello world\n```"; - (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md); + string md = "```\nhello world\n```"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 40, height: 10); mv.SelectAll (); string? selected = mv.SelectedText; @@ -402,83 +402,101 @@ public void SelectAll_FencedCodeBlock_Without_Language_SelectedText_Preserves_Fe app.Dispose (); } - // Copilot - FAILS: Copy() calls GetSelectedText() even for SelectAll, losing bullet markers + // Copilot - Copy() after SelectAll with task list (includes emoji) [Fact] - public void Copy_After_SelectAll_BulletList_Clipboard_Contains_Markdown_Markers () + public void Copy_After_SelectAll_TaskList_Clipboard_Contains_Markdown_Markers () { - (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("- one\n- two\n- three"); + string md = "- [x] Bold & italic ✅\n- [x] Code blocks 🔧\n- [ ] Emojis 🎉"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); mv.SelectAll (); mv.Copy (); app.Clipboard!.TryGetClipboardData (out string clipboard); - Assert.Contains ("- one", clipboard); - Assert.Contains ("- two", clipboard); - Assert.Contains ("- three", clipboard); + Assert.Contains ("- [x] Bold & italic ✅", clipboard); + Assert.Contains ("- [x] Code blocks 🔧", clipboard); + Assert.Contains ("- [ ] Emojis 🎉", clipboard); window.Dispose (); app.Dispose (); } - // Copilot - FAILS: Copy() loses code fences when selection is active + // Copilot - Copy() after SelectAll with csharp code block (includes 🌍) [Fact] public void Copy_After_SelectAll_FencedCodeBlock_Clipboard_Contains_Fences () { - var md = "```python\nprint('hi')\n```"; - (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md); + string md = "```csharp\nConsole.WriteLine (\"Hello, Terminal.Gui! 🌍\");\nvar x = 42;\n```"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); mv.SelectAll (); mv.Copy (); app.Clipboard!.TryGetClipboardData (out string clipboard); - Assert.Contains ("```python", clipboard); - Assert.Contains ("print('hi')", clipboard); - Assert.Contains ("```", clipboard); + Assert.Contains ("```csharp", clipboard); + Assert.Contains ("🌍", clipboard); + Assert.Contains ("var x = 42;", clipboard); window.Dispose (); app.Dispose (); } - // Copilot - FAILS: a partial selection of bullet items loses the list markers + // Copilot - partial drag selection spanning task-list items with emoji [Fact] - public void PartialSelection_BulletList_SelectedText_Preserves_Markdown_Markers () + public void PartialSelection_TaskList_SelectedText_Preserves_Markdown_Markers () { - // Two bullet items. We select both rendered lines (Y=0 and Y=1). - var md = "- alpha\n- beta"; - (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md); + string md = "- [x] Bold & italic ✅\n- [ ] Emojis 🎉"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); - // Anchor at start of line 0, extend to end of line 1 + // Press at start of line 0, drag to column 10 of line 1 mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); mv.NewMouseEvent (new Mouse { Position = new Point (10, 1), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); string? selected = mv.SelectedText; Assert.NotNull (selected); - Assert.Contains ("- alpha", selected); - Assert.Contains ("- beta", selected); + Assert.Contains ("- [x] Bold & italic ✅", selected); + Assert.Contains ("- [ ]", selected); window.Dispose (); app.Dispose (); } - // Copilot - FAILS: partial selection inside a fenced code block loses fence context + // Copilot - partial drag selection spanning lines inside a csharp code block (with 🌍) [Fact] public void PartialSelection_FencedCodeBlock_SelectedText_Preserves_Fence_Context () { - var md = "```cs\nint a = 1;\nint b = 2;\n```"; - (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md); + string md = "```csharp\nConsole.WriteLine (\"Hello, Terminal.Gui! 🌍\");\nvar x = 42;\n```"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); - // Select both code lines (Y=0 and Y=1 in rendered output — fence lines don't appear) + // Select both code lines (rendered as lines 0 and 1 — fence lines are not in _renderedLines) mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); - mv.NewMouseEvent (new Mouse { Position = new Point (10, 1), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + mv.NewMouseEvent (new Mouse { Position = new Point (12, 1), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); string? selected = mv.SelectedText; Assert.NotNull (selected); + Assert.Contains ("```csharp", selected); + Assert.Contains ("Console.WriteLine", selected); + Assert.Contains ("🌍", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - FAILS: inline formatting (**bold**, *italic*, `code`, ~~strike~~), heading + // markers (#), tables, thematic breaks, and link syntax ([text](url)) are all lost in + // the display representation — the selected text cannot equal the original markdown source. + [Fact] + public void SelectAll_DefaultMarkdownSample_SelectedText_RoundTrips () + { + string md = Terminal.Gui.Views.Markdown.DefaultMarkdownSample; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 120, height: 60); + + mv.SelectAll (); + string? selected = mv.SelectedText; - // The selection is from inside a code block — the language tag must survive - Assert.Contains ("```cs", selected); - Assert.Contains ("int a = 1;", selected); + Assert.NotNull (selected); + Assert.Equal (md, selected); window.Dispose (); app.Dispose (); From 98c17133a5d7524a039877cc3f2f25c4cedd0536 Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 4 May 2026 09:23:19 -0600 Subject: [PATCH 09/15] Add test for table markdown preservation in selection Adds PartialSelection_IncludingTable_SelectedText_Preserves_Table_Markdown to verify that partial selections including tables retain Markdown syntax. Documents current failure: table rows are missing from selected text due to placeholder rendering. --- .../Markdown/MarkdownViewSelectionTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs index a7513c59a8..cd92adae9a 100644 --- a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs +++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs @@ -483,6 +483,35 @@ public void PartialSelection_FencedCodeBlock_SelectedText_Preserves_Fence_Contex app.Dispose (); } + // Copilot - FAILS: a table is rendered as a single placeholder RenderedLine (IsTable=true) + // with no text content. When the selection is partial (start.X > 0, so IsFullDocumentSelected + // short-circuit does not fire) and covers the table rows, the pipe-row syntax is completely + // absent from GetSelectedText(). + [Fact] + public void PartialSelection_IncludingTable_SelectedText_Preserves_Table_Markdown () + { + // Content taken from DefaultMarkdownSample § Table (uses ✅ emoji). + // "After." is appended so the last rendered line is text, not the table placeholder, + // which makes the drag a genuine partial selection (end.Y < lastLine is NOT required — + // starting at col 1 is enough to skip the IsFullDocumentSelected shortcut). + string md = "## Table\n\n| Feature | Status |\n|---|---|\n| A | ✅ |\n\nAfter."; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); + + // Start at column 1 — IsFullDocumentSelected() requires start==(0,0), so this forces + // the partial-selection (display-text) code path. Drag far right/down to cover the table. + mv.NewMouseEvent (new Mouse { Position = new Point (1, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (100, 100), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("| Feature | Status |", selected); + Assert.Contains ("| A | ✅ |", selected); + + window.Dispose (); + app.Dispose (); + } + // Copilot - FAILS: inline formatting (**bold**, *italic*, `code`, ~~strike~~), heading // markers (#), tables, thematic breaks, and link syntax ([text](url)) are all lost in // the display representation — the selected text cannot equal the original markdown source. From 3da5209210dae8ba24e96b9505ecba927e59bf3f Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 4 May 2026 12:11:26 -0600 Subject: [PATCH 10/15] Support GFM table markdown copy from selection Reconstruct and output valid GitHub Flavored Markdown pipe-table syntax when selecting and copying table content. Store TableData in RenderedLine, and use it to generate markdown table lines with correct alignment and structure during selection. --- .../Views/Markdown/MarkdownView.Layout.cs | 2 +- .../Views/Markdown/MarkdownView.Selection.cs | 34 +++++++++++++++++++ Terminal.Gui/Views/Markdown/RenderedLine.cs | 9 ++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs index b603fea1f4..32841cdb02 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs @@ -73,7 +73,7 @@ private void BuildRenderedLines () // Reserve placeholder lines so content height is correct for (var i = 0; i < tableHeight; i++) { - _renderedLines.Add (new RenderedLine ([new StyledSegment ("", MarkdownStyleRole.Table)], false, 0, isTable: true)); + _renderedLines.Add (new RenderedLine ([new StyledSegment ("", MarkdownStyleRole.Table)], false, 0, isTable: true, tableData: tableData)); } continue; diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs index 236f6c5e10..279c31536e 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs @@ -149,6 +149,17 @@ private string GetSelectedText () inCodeBlock = false; } + if (line.IsTable && line.TableData is { } tableData) + { + // Reconstruct GFM pipe-table markdown from the parsed TableData. + foreach (string tableLine in RenderTableAsMarkdown (tableData)) + { + outputLines.Add (tableLine); + } + + continue; + } + int lineStartX = lineIdx == start.Y ? start.X : 0; int lineEndX = lineIdx == end.Y ? end.X : int.MaxValue; StringBuilder lineSb = new (); @@ -183,6 +194,29 @@ private bool IsFullDocumentSelected () return end.Y >= lastLine && end.X >= GetLineDisplayWidth (lastLine); } + /// Reconstructs GFM pipe-table markdown lines from a instance. + private static IEnumerable RenderTableAsMarkdown (TableData tableData) + { + // Header row + yield return "| " + string.Join (" | ", tableData.Headers) + " |"; + + // Separator row — encode column alignment + IEnumerable separators = tableData.ColumnAlignments.Select ( + alignment => alignment switch + { + Alignment.Center => ":---:", + Alignment.End => "---:", + _ => "---" + }); + yield return "| " + string.Join (" | ", separators) + " |"; + + // Body rows + foreach (string [] row in tableData.Rows) + { + yield return "| " + string.Join (" | ", row) + " |"; + } + } + private static void AppendLineText (StringBuilder sb, RenderedLine line, int startX, int endX) { var contentX = 0; diff --git a/Terminal.Gui/Views/Markdown/RenderedLine.cs b/Terminal.Gui/Views/Markdown/RenderedLine.cs index 6923542da7..c2e0668352 100644 --- a/Terminal.Gui/Views/Markdown/RenderedLine.cs +++ b/Terminal.Gui/Views/Markdown/RenderedLine.cs @@ -1,6 +1,6 @@ namespace Terminal.Gui.Views; -internal sealed class RenderedLine (IReadOnlyList segments, bool wrapEligible, int width, bool isCodeBlock = false, bool isThematicBreak = false, bool isTable = false, string? codeLanguage = null) +internal sealed class RenderedLine (IReadOnlyList segments, bool wrapEligible, int width, bool isCodeBlock = false, bool isThematicBreak = false, bool isTable = false, string? codeLanguage = null, TableData? tableData = null) { public IReadOnlyList Segments { get; } = segments; public bool WrapEligible { get; } = wrapEligible; @@ -14,4 +14,11 @@ internal sealed class RenderedLine (IReadOnlyList segments, bool /// when this line is not part of a code block or no language was given. /// public string? CodeLanguage { get; } = codeLanguage; + + /// + /// The parsed table data when is ; + /// otherwise . Used to reconstruct pipe-table markdown + /// when this line falls within a partial selection. + /// + public TableData? TableData { get; } = tableData; } From ffa9fd4b2e6b49edf3124144ca115de3bf8a52f4 Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 4 May 2026 12:31:49 -0600 Subject: [PATCH 11/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs index 279c31536e..f49d485bf8 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs @@ -296,9 +296,17 @@ private void DisposeContextMenu () private void ContextMenuOnKeyChanged (object? sender, KeyChangedEventArgs e) => KeyBindings.Replace (e.OldKey.KeyCode, e.NewKey.KeyCode); + private Point GetContextMenuScreenPosition () + { + Point anchor = _isSelecting ? _selectionCurrent : new Point (0, 0); + + return ViewportToScreen (anchor); + } + private bool ShowContextMenu (Point? screenPosition = null) { - ContextMenu?.MakeVisible (screenPosition); + Point menuPosition = screenPosition ?? GetContextMenuScreenPosition (); + ContextMenu?.MakeVisible (menuPosition); return true; } From 6fca5c1fab4719cc252c50540cb944753699608e Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 4 May 2026 12:32:56 -0600 Subject: [PATCH 12/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Views/Markdown/MarkdownView.Selection.cs | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs index f49d485bf8..7c0d9fd933 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs @@ -132,21 +132,38 @@ private string GetSelectedText () List outputLines = []; bool inCodeBlock = false; + string? currentCodeLanguage = null; + for (int lineIdx = start.Y; lineIdx <= Math.Min (end.Y, _renderedLines.Count - 1); lineIdx++) { RenderedLine line = _renderedLines [lineIdx]; - if (line.IsCodeBlock && !inCodeBlock) + if (line.IsCodeBlock) { - // Entering a code block: inject the opening fence with optional language tag - outputLines.Add ($"```{line.CodeLanguage ?? string.Empty}"); - inCodeBlock = true; + string? nextCodeLanguage = line.CodeLanguage; + + if (!inCodeBlock) + { + // Entering a code block: inject the opening fence with optional language tag + outputLines.Add ($"```{nextCodeLanguage ?? string.Empty}"); + inCodeBlock = true; + currentCodeLanguage = nextCodeLanguage; + } + else if (!string.Equals (currentCodeLanguage, nextCodeLanguage, StringComparison.Ordinal)) + { + // Transitioning directly between two code blocks: close the current fence + // and open the next one so adjacent fenced blocks are preserved. + outputLines.Add ("```"); + outputLines.Add ($"```{nextCodeLanguage ?? string.Empty}"); + currentCodeLanguage = nextCodeLanguage; + } } - else if (!line.IsCodeBlock && inCodeBlock) + else if (inCodeBlock) { // Leaving a code block: inject the closing fence outputLines.Add ("```"); inCodeBlock = false; + currentCodeLanguage = null; } if (line.IsTable && line.TableData is { } tableData) From 5fa182337539ecfe5e34a03a9cbeb0b027e42df7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 18:41:03 +0000 Subject: [PATCH 13/15] Fix right-click on unfocused Markdown view being a no-op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle RightButtonClicked in OnMouseEvent before command bindings fire: - If view is not focused, SetFocus() first — this synchronously triggers OnHasFocusChanged(true) which creates the ContextMenu - Then call ShowContextMenu(mouse.ScreenPosition) so the menu appears at the click location - Remove RightButtonClicked → Command.Context mouse binding (now handled entirely in OnMouseEvent) - Also fix keyboard context menu anchor: GetContextMenuScreenPosition() already provides ViewportToScreen fallback for the keyboard path; simplified the null-check in ShowContextMenu to use it - Rename ambiguous local 'anchor' → 'viewportPosition' in GetContextMenuScreenPosition() - Simplify LeftButtonReleased ungrab: remove redundant IsGrabbed check (UngrabMouse is a no-op when not grabbed) - Add regression tests: RightClick_On_Unfocused_View_Creates_And_Shows_ContextMenu and RightClick_On_Focused_View_Shows_ContextMenu Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/e3212c71-6a8b-4c36-97c8-cb32602852a7 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- .../Views/Markdown/MarkdownView.Mouse.cs | 24 +++++++-- .../Views/Markdown/MarkdownView.Selection.cs | 4 +- .../Markdown/MarkdownViewSelectionTests.cs | 54 +++++++++++++++++++ 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs index ee0d04eef4..ab3ca7e8e8 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs @@ -61,7 +61,9 @@ private void SetupBindingsAndCommands () // fires only on LeftButtonClicked (not twice per click which would clear selection). MouseBindings.Remove (MouseFlags.LeftButtonReleased); MouseBindings.ReplaceCommands (MouseFlags.LeftButtonClicked, Command.Activate); - MouseBindings.ReplaceCommands (MouseFlags.RightButtonClicked, Command.Context); + + // Right-click is handled directly in OnMouseEvent so that the view can be focused + // and the context menu created before trying to show it, even when not yet focused. // Press anchors the drag-selection; drag extends it — both routed through OnActivated. MouseBindings.Add (MouseFlags.LeftButtonPressed, Command.Activate); @@ -71,16 +73,28 @@ private void SetupBindingsAndCommands () /// protected override bool OnMouseEvent (Mouse mouse) { - if (!mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased)) + // Right-click: focus the view first (which creates ContextMenu) then show the menu at + // the click's screen position. Handled here rather than via a Command binding so that + // focus and menu creation are guaranteed even when the view is not yet focused. + if (mouse.Flags.FastHasFlags (MouseFlags.RightButtonClicked)) { - return false; + if (!HasFocus && CanFocus) + { + SetFocus (); + } + + ShowContextMenu (mouse.ScreenPosition); + + return true; } - if (App is { } && App.Mouse.IsGrabbed (this)) + if (!mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased)) { - App.Mouse.UngrabMouse (); + return false; } + App?.Mouse.UngrabMouse (); + return false; } diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs index 7c0d9fd933..d300a1a0d0 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs @@ -315,9 +315,9 @@ private void DisposeContextMenu () private Point GetContextMenuScreenPosition () { - Point anchor = _isSelecting ? _selectionCurrent : new Point (0, 0); + Point viewportPosition = _isSelecting ? _selectionCurrent : new Point (0, 0); - return ViewportToScreen (anchor); + return ViewportToScreen (viewportPosition); } private bool ShowContextMenu (Point? screenPosition = null) diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs index cd92adae9a..d1fb5192a1 100644 --- a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs +++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs @@ -530,4 +530,58 @@ public void SelectAll_DefaultMarkdownSample_SelectedText_RoundTrips () window.Dispose (); app.Dispose (); } + + // Copilot - right-clicking an unfocused view should focus it and open the context menu + [Fact] + public void RightClick_On_Unfocused_View_Creates_And_Shows_ContextMenu () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello"); + + // Add another focusable view and move focus there so the Markdown view is unfocused + Button btn = new () { Text = "OK", X = 0, Y = 5 }; + window.Add (btn); + app.LayoutAndDraw (); + btn.SetFocus (); + + Assert.False (mv.HasFocus); + Assert.Null (mv.ContextMenu); + + // Simulate a right-click while the view is not focused + mv.NewMouseEvent (new Mouse + { + Position = new Point (0, 0), + ScreenPosition = new Point (0, 0), + Flags = MouseFlags.RightButtonClicked + }); + + // The view should now be focused and the context menu should be created + Assert.True (mv.HasFocus); + Assert.NotNull (mv.ContextMenu); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - right-clicking an already-focused view should still open the context menu + [Fact] + public void RightClick_On_Focused_View_Shows_ContextMenu () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello"); + + mv.SetFocus (); + Assert.NotNull (mv.ContextMenu); + + // Second right-click should not crash and context menu should remain + mv.NewMouseEvent (new Mouse + { + Position = new Point (0, 0), + ScreenPosition = new Point (0, 0), + Flags = MouseFlags.RightButtonClicked + }); + + Assert.NotNull (mv.ContextMenu); + + window.Dispose (); + app.Dispose (); + } } From 80817aba36c9859ae35923e3b13362113f647a5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 19:16:08 +0000 Subject: [PATCH 14/15] Fix drag clamping, table duplication, SubView selection drawing, and IsFullDocumentSelected Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/39d748ab-7987-458b-902a-2a9cb47a9c6d Co-authored-by: tig <585482+tig@users.noreply.github.com> --- .../Views/Markdown/MarkdownView.Drawing.cs | 98 +++++++++ .../Views/Markdown/MarkdownView.Mouse.cs | 21 +- .../Views/Markdown/MarkdownView.Selection.cs | 24 ++- .../Markdown/MarkdownViewSelectionTests.cs | 193 ++++++++++++++++++ 4 files changed, 330 insertions(+), 6 deletions(-) diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs index 0a91503393..005ef49879 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs @@ -38,9 +38,107 @@ protected override bool OnDrawingContent (DrawContext? context) // All visible content was drawn in OnDrawingSubViews; just register the drawn region. context?.AddDrawnRegion (new Region (new Rectangle (ContentToScreen (Point.Empty), Viewport.Size))); + // OnDrawingContent is called AFTER SubViews have drawn. Plain rendered lines are + // highlighted during DrawRenderedLine (in OnDrawingSubViews), but table and code-block + // rows are owned by their SubViews and don't receive that pass. Draw the selection + // overlay for those rows here, on top of what the SubViews rendered. + if (_isSelecting) + { + DrawSelectionOverlayOnSubViewRows (); + } + return true; } + /// + /// Draws the selection highlight over table and fenced-code-block rows. + /// Those rows are owned by SubViews ( / + /// ) that draw after + /// returns. This pass reads the graphemes + /// that the SubViews already placed in the screen buffer and re-draws them + /// with the selection attribute, preserving the rendered characters while + /// applying the selection background. + /// + private void DrawSelectionOverlayOnSubViewRows () + { + Cell [,]? contents = ScreenContents; + + if (contents is null) + { + return; + } + + Attribute selAttr = GetAttributeForRole (VisualRole.Focus); + (Point start, Point end) = GetNormalizedSelection (); + + int startRow = Math.Max (start.Y, Viewport.Y); + int endRow = Math.Min (end.Y, Viewport.Y + Viewport.Height - 1); + + bool anySubViewRows = false; + + for (int lineIdx = startRow; lineIdx <= Math.Min (endRow, _renderedLines.Count - 1); lineIdx++) + { + if (_renderedLines [lineIdx].IsTable || _renderedLines [lineIdx].IsCodeBlock) + { + anySubViewRows = true; + + break; + } + } + + if (!anySubViewRows) + { + return; + } + + // After DoDrawSubViews each SubView calls DoDrawComplete which excludes its screen + // area from Driver.Clip. DoDrawContent (OnDrawingContent) runs with those exclusions + // still active, so drawing would silently no-op on SubView areas. + // Reset the clip to the raw viewport rectangle to allow the overlay to appear. + Region? savedClip = GetClip (); + Rectangle viewportScreen = ViewportToScreen (new Rectangle (Point.Empty, Viewport.Size)); + SetClip (new Region (viewportScreen)); + + SetAttribute (selAttr); + + for (int lineIdx = startRow; lineIdx <= Math.Min (endRow, _renderedLines.Count - 1); lineIdx++) + { + RenderedLine line = _renderedLines [lineIdx]; + + if (!line.IsTable && !line.IsCodeBlock) + { + continue; + } + + int drawRow = lineIdx - Viewport.Y; + Point screenOrigin = ContentToScreen (new Point (0, drawRow)); + int screenRow = screenOrigin.Y; + int screenStartCol = screenOrigin.X; + int cols = Viewport.Width; + + for (int col = 0; col < cols; col++) + { + int sc = screenStartCol + col; + + if (screenRow < 0 || screenRow >= contents.GetLength (0) || sc < 0 || sc >= contents.GetLength (1)) + { + continue; + } + + string grapheme = contents [screenRow, sc].Grapheme; + + if (string.IsNullOrEmpty (grapheme)) + { + grapheme = " "; + } + + AddStr (col, drawRow, grapheme); + } + } + + SetClip (savedClip); + } + private void DrawRenderedLine (RenderedLine line, int contentRow, int drawRow) { // Thematic breaks are drawn by Line SubViews diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs index ab3ca7e8e8..62e68ba58f 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs @@ -206,11 +206,26 @@ protected override void OnActivated (ICommandContext? ctx) return; } - // Drag: extend selection + // Drag: extend selection and auto-scroll when the pointer leaves the viewport. if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport)) { - int contentX = Viewport.X + pos.X; - int contentY = Math.Min (Viewport.Y + pos.Y, Math.Max (_renderedLines.Count - 1, 0)); + // Auto-scroll: if the pointer has left the top or bottom edge, scroll one line + // in that direction so the user can extend the selection beyond the visible area. + if (pos.Y < 0) + { + ScrollVertical (-1); + } + else if (pos.Y >= Viewport.Height) + { + ScrollVertical (1); + } + + // Clamp both axes to the actual content bounds to prevent negative indices or + // indices beyond the last rendered line (possible when the mouse is grabbed and + // moves outside the view's frame). + int maxLine = Math.Max (_renderedLines.Count - 1, 0); + int contentX = Math.Max (Viewport.X + pos.X, 0); + int contentY = Math.Clamp (Viewport.Y + pos.Y, 0, maxLine); _selectionCurrent = new Point (contentX, contentY); _isDragging = true; _isSelecting = true; diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs index d300a1a0d0..00bd00684e 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs @@ -134,6 +134,12 @@ private string GetSelectedText () string? currentCodeLanguage = null; + // Track the last table instance that was output. All placeholder rows for the + // same table share the same TableData reference, so we use ReferenceEquals to + // emit the reconstructed table markdown exactly once even when the selection + // covers multiple placeholder rows belonging to the same table. + TableData? lastOutputtedTable = null; + for (int lineIdx = start.Y; lineIdx <= Math.Min (end.Y, _renderedLines.Count - 1); lineIdx++) { RenderedLine line = _renderedLines [lineIdx]; @@ -168,10 +174,17 @@ private string GetSelectedText () if (line.IsTable && line.TableData is { } tableData) { - // Reconstruct GFM pipe-table markdown from the parsed TableData. - foreach (string tableLine in RenderTableAsMarkdown (tableData)) + // Each table occupies several zero-width placeholder rows that all share the + // same TableData instance. Only reconstruct the table markdown the first time + // we encounter each distinct instance; skip subsequent placeholder rows. + if (!ReferenceEquals (tableData, lastOutputtedTable)) { - outputLines.Add (tableLine); + foreach (string tableLine in RenderTableAsMarkdown (tableData)) + { + outputLines.Add (tableLine); + } + + lastOutputtedTable = tableData; } continue; @@ -208,6 +221,11 @@ private bool IsFullDocumentSelected () int lastLine = _renderedLines.Count - 1; + // For a document ending with a zero-width placeholder (table rows, thematic breaks), + // GetLineDisplayWidth(lastLine) returns 0, so end.X >= 0 is always satisfied. + // This is intentional: any position on a zero-width row is equivalent to the end of + // that row (there is no content there), so reaching the last row from (0,0) means the + // entire document is selected. return end.Y >= lastLine && end.X >= GetLineDisplayWidth (lastLine); } diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs index d1fb5192a1..ac654d073a 100644 --- a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs +++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs @@ -584,4 +584,197 @@ public void RightClick_On_Focused_View_Shows_ContextMenu () window.Dispose (); app.Dispose (); } + + // --- Drag clamping + auto-scroll tests (comment #3183635182) --- + + // Copilot - dragging above the viewport (negative Y) must not produce a negative + // contentY that causes an IndexOutOfRangeException in GetSelectedText(). + [Fact] + public void Drag_AboveViewport_ClampsToFirstLine () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("Hello\nWorld"); + + // Anchor at line 0 and then drag ABOVE the top of the view (pos.Y = -5). + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (3, -5), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + // Copy should not throw even though the drag went above the viewport. + // Before the fix, contentY could be negative → IndexOutOfRangeException in GetSelectedText(). + Exception? ex = Record.Exception (() => mv.Copy ()); + Assert.Null (ex); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - dragging below the viewport must not produce a contentY beyond the last line. + [Fact] + public void Drag_BelowViewport_ClampsToLastLine () + { + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv ("line0\nline1\nline2", height: 2); + + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + + // Drag to row 999 — well beyond the content + mv.NewMouseEvent (new Mouse { Position = new Point (0, 999), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + // Copy must not throw + Exception? ex = Record.Exception (() => mv.Copy ()); + Assert.Null (ex); + + window.Dispose (); + app.Dispose (); + } + + // --- Table-duplication fix tests (comment #3183635237) --- + + // Copilot - a table with 2 body rows generates multiple placeholder RenderedLines; + // GetSelectedText() must output the reconstructed table exactly once, not once per + // placeholder row. + [Fact] + public void PartialSelection_TableWithMultipleRows_TableAppearsExactlyOnce () + { + // 2 body rows → at least 2 (usually more) table placeholder lines + string md = "| H1 | H2 |\n|---|---|\n| A | B |\n| C | D |"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 15); + + // Start at col 1 so IsFullDocumentSelected() returns false (forces the display-text path) + mv.NewMouseEvent (new Mouse { Position = new Point (1, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (100, 100), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + + // Header row should appear exactly once, not once per placeholder row. + int count = CountOccurrences (selected, "| H1 | H2 |"); + Assert.Equal (1, count); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - two adjacent tables with the same structure should each appear exactly once. + [Fact] + public void PartialSelection_TwoAdjacentTables_EachTableAppearsOnce () + { + // Two separate TableData instances → two independent tables + const string TABLE = "| H |\n|---|\n| R |"; + string md = TABLE + "\n\n" + TABLE; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 20); + + mv.NewMouseEvent (new Mouse { Position = new Point (1, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (100, 100), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + + // The header appears in each table → should occur exactly twice in the output + int count = CountOccurrences (selected, "| H |"); + Assert.Equal (2, count); + + window.Dispose (); + app.Dispose (); + } + + // --- Selection drawing over SubView rows (comment #3183635267) --- + + // Copilot - after SelectAll() on a document that contains a code block, the ANSI output + // should contain the Focus-attribute escape codes (white-on-black) in the code-block rows, + // proving the selection overlay is drawn on top of the MarkdownCodeBlock SubView. + [Fact] + public void SelectAll_WithCodeBlock_DrawingContainsFocusAttributeOnCodeRows () + { + const int WIDTH = 20; + + IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver!.SetScreenSize (WIDTH, 5); + app.Driver.Force16Colors = true; + + Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None }; + + Scheme scheme = new (new Attribute (Color.Black, Color.White)); + window.SetScheme (scheme); + + Terminal.Gui.Views.Markdown mv = new () { Text = "```\nAB\n```", Width = Dim.Fill (), Height = Dim.Fill () }; + mv.SchemeName = null; + mv.SetScheme (scheme); + + window.Add (mv); + app.Begin (window); + app.LayoutAndDraw (); + + mv.SelectAll (); + app.LayoutAndDraw (); + app.Driver.Refresh (); + + string output = app.Driver.GetOutput ().GetLastOutput (); + + // Focus attribute (Black-on-White scheme, Focus = swap → White fg = 97, Black bg = 40) + // must appear somewhere in the rendered output now that the overlay is drawn. + Assert.Contains ("\x1b[97m", output); + Assert.Contains ("\x1b[40m", output); + + window.Dispose (); + app.Dispose (); + } + + // --- IsFullDocumentSelected with zero-width last line (comment #3183635296) --- + + // Copilot - SelectAll() on a document ending with a table (zero-width last rendered line) + // should correctly return the full source markdown via Copy(). + [Fact] + public void SelectAll_DocEndingWithTable_CopyReturnsFullMarkdown () + { + string md = "intro\n| H | B |\n|---|---|\n| A | C |"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 15); + + mv.SelectAll (); + mv.Copy (); + + app.Clipboard!.TryGetClipboardData (out string clipboard); + Assert.Equal (md, clipboard); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - a partial selection on a document ending with a table (starting from col 1) + // must NOT be treated as a full-document selection, even when the drag reaches the last row. + [Fact] + public void PartialSelection_DocEndingWithTable_NotTreatedAsFullDocument () + { + string md = "intro\n| H | B |\n|---|---|\n| A | C |"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 15); + + // Start at col 1 (not col 0) → IsFullDocumentSelected() must return false + mv.NewMouseEvent (new Mouse { Position = new Point (1, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (100, 100), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + + // Should NOT equal the full source markdown because the selection started at col 1 + Assert.NotEqual (md, selected); + + window.Dispose (); + app.Dispose (); + } + + private static int CountOccurrences (string text, string pattern) + { + var count = 0; + var idx = 0; + + while ((idx = text.IndexOf (pattern, idx, StringComparison.Ordinal)) >= 0) + { + count++; + idx += pattern.Length; + } + + return count; + } } From 4f80e6dd44e199b2253a358d9957778fa70cbc72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 19:18:23 +0000 Subject: [PATCH 15/15] Fix code review: use explicit int types in CountOccurrences helper Agent-Logs-Url: https://github.com/gui-cs/Terminal.Gui/sessions/39d748ab-7987-458b-902a-2a9cb47a9c6d Co-authored-by: tig <585482+tig@users.noreply.github.com> --- .../Views/Markdown/MarkdownViewSelectionTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs index ac654d073a..156bba53c3 100644 --- a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs +++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs @@ -766,8 +766,8 @@ public void PartialSelection_DocEndingWithTable_NotTreatedAsFullDocument () private static int CountOccurrences (string text, string pattern) { - var count = 0; - var idx = 0; + int count = 0; + int idx = 0; while ((idx = text.IndexOf (pattern, idx, StringComparison.Ordinal)) >= 0) {