diff --git a/Terminal.Gui/Views/Markdown/IntermediateBlock.cs b/Terminal.Gui/Views/Markdown/IntermediateBlock.cs index 6f1b8eb819..90eb42c686 100644 --- a/Terminal.Gui/Views/Markdown/IntermediateBlock.cs +++ b/Terminal.Gui/Views/Markdown/IntermediateBlock.cs @@ -1,6 +1,14 @@ 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, + string? language = null) { public IReadOnlyList Runs { get; } = runs; public bool Wrap { get; } = wrap; @@ -13,8 +21,14 @@ 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; + + /// + /// 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/Markdown.cs b/Terminal.Gui/Views/Markdown/Markdown.cs index af8adfd52c..5fbaaadca2 100644 --- a/Terminal.Gui/Views/Markdown/Markdown.cs +++ b/Terminal.Gui/Views/Markdown/Markdown.cs @@ -18,6 +18,43 @@ 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 +286,7 @@ private void InvalidateParsedAndLayout () RemoveTableViews (); RemoveThematicBreakViews (); _maxLineWidth = 0; + _isSelecting = false; SetNeedsLayout (); SetNeedsDraw (); 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.Drawing.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs index c361bf21b0..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 @@ -112,6 +210,13 @@ private void DrawRenderedLine (RenderedLine line, int contentRow, int drawRow) AddStr (drawCol, drawRow, grapheme); } } + else if (IsInSelection (contentRow, contentX)) + { + // 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 { DrawGrapheme (segment, grapheme, drawCol, drawRow); diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs index a0b7a7fe49..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; @@ -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.Mouse.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs index ccd0d074bd..62e68ba58f 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); @@ -51,14 +56,68 @@ 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); + + // 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); + MouseBindings.Add (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport, Command.Activate); + } + + /// + protected override bool OnMouseEvent (Mouse mouse) + { + // 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)) + { + if (!HasFocus && CanFocus) + { + SetFocus (); + } + + ShowContextMenu (mouse.ScreenPosition); + + return true; + } + + if (!mouse.Flags.FastHasFlags (MouseFlags.LeftButtonReleased)) + { + return false; + } + + App?.Mouse.UngrabMouse (); + + 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 (); } @@ -119,30 +178,92 @@ 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 and auto-scroll when the pointer leaves the viewport. + if (mouse.Flags.FastHasFlags (MouseFlags.LeftButtonPressed | MouseFlags.PositionReport)) + { + // 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; + SetNeedsDraw (); + + return; + } + + // 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; + + return; + } + + // Plain click clears any existing text selection. + ClearSelection (); + if (!HasFocus && CanFocus) { 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/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 new file mode 100644 index 0000000000..00bd00684e --- /dev/null +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs @@ -0,0 +1,359 @@ +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; + } + + /// + /// 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. + /// + /// 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; + } + + // 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 (); + List outputLines = []; + bool inCodeBlock = false; + + 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]; + + if (line.IsCodeBlock) + { + 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 (inCodeBlock) + { + // Leaving a code block: inject the closing fence + outputLines.Add ("```"); + inCodeBlock = false; + currentCodeLanguage = null; + } + + if (line.IsTable && line.TableData is { } 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)) + { + foreach (string tableLine in RenderTableAsMarkdown (tableData)) + { + outputLines.Add (tableLine); + } + + lastOutputtedTable = tableData; + } + + continue; + } + + int lineStartX = lineIdx == start.Y ? start.X : 0; + int lineEndX = lineIdx == end.Y ? end.X : int.MaxValue; + StringBuilder lineSb = new (); + AppendLineText (lineSb, line, lineStartX, lineEndX); + outputLines.Add (lineSb.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; + + // 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); + } + + /// 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; + + foreach (StyledSegment segment in line.Segments) + { + // 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); + + 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 Point GetContextMenuScreenPosition () + { + Point viewportPosition = _isSelecting ? _selectionCurrent : new Point (0, 0); + + return ViewportToScreen (viewportPosition); + } + + private bool ShowContextMenu (Point? screenPosition = null) + { + Point menuPosition = screenPosition ?? GetContextMenuScreenPosition (); + ContextMenu?.MakeVisible (menuPosition); + + return true; + } + + /// + protected override void Dispose (bool disposing) + { + if (disposing) + { + DisposeContextMenu (); + } + + base.Dispose (disposing); + } +} diff --git a/Terminal.Gui/Views/Markdown/RenderedLine.cs b/Terminal.Gui/Views/Markdown/RenderedLine.cs index e5b04516dc..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) +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; @@ -8,4 +8,17 @@ 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; + + /// + /// 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; } diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs new file mode 100644 index 0000000000..156bba53c3 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs @@ -0,0 +1,780 @@ +using JetBrains.Annotations; + +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 (); + } + + // 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 () + { + (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 (); + } + + // --- 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] + 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 - task-list items from DefaultMarkdownSample (includes emoji ✅ 🔧 🎉) + [Fact] + public void SelectAll_TaskList_SelectedText_Preserves_Markdown_List_Markers () + { + // 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); + Assert.Contains ("- [x] Bold & italic ✅", selected); + Assert.Contains ("- [x] Code blocks 🔧", selected); + Assert.Contains ("- [ ] Emojis 🎉", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - plain bullet list (no task markers) + [Fact] + public void SelectAll_BulletList_SelectedText_Preserves_Markdown_List_Markers () + { + 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); + Assert.DoesNotContain ("•", selected); + Assert.Contains ("- foo", selected); + Assert.Contains ("- bar", selected); + Assert.Contains ("- baz", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - fenced code block from DefaultMarkdownSample (csharp, contains 🌍 emoji) + [Fact] + public void SelectAll_FencedCodeBlock_With_Language_SelectedText_Preserves_Fences () + { + // 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); + Assert.Contains ("```csharp", selected); + Assert.Contains ("Console.WriteLine", selected); + Assert.Contains ("🌍", selected); + Assert.Contains ("var x = 42;", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - fenced code block without language specifier + [Fact] + public void SelectAll_FencedCodeBlock_Without_Language_SelectedText_Preserves_Fences () + { + 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; + + Assert.NotNull (selected); + Assert.Contains ("```", selected); + Assert.Contains ("hello world", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - Copy() after SelectAll with task list (includes emoji) + [Fact] + public void Copy_After_SelectAll_TaskList_Clipboard_Contains_Markdown_Markers () + { + 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 ("- [x] Bold & italic ✅", clipboard); + Assert.Contains ("- [x] Code blocks 🔧", clipboard); + Assert.Contains ("- [ ] Emojis 🎉", clipboard); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - Copy() after SelectAll with csharp code block (includes 🌍) + [Fact] + public void Copy_After_SelectAll_FencedCodeBlock_Clipboard_Contains_Fences () + { + 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 ("```csharp", clipboard); + Assert.Contains ("🌍", clipboard); + Assert.Contains ("var x = 42;", clipboard); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - partial drag selection spanning task-list items with emoji + [Fact] + public void PartialSelection_TaskList_SelectedText_Preserves_Markdown_Markers () + { + string md = "- [x] Bold & italic ✅\n- [ ] Emojis 🎉"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); + + // 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 ("- [x] Bold & italic ✅", selected); + Assert.Contains ("- [ ]", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - partial drag selection spanning lines inside a csharp code block (with 🌍) + [Fact] + public void PartialSelection_FencedCodeBlock_SelectedText_Preserves_Fence_Context () + { + 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 (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 (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: 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. + [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; + + Assert.NotNull (selected); + Assert.Equal (md, selected); + + 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 (); + } + + // --- 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) + { + int count = 0; + int idx = 0; + + while ((idx = text.IndexOf (pattern, idx, StringComparison.Ordinal)) >= 0) + { + count++; + idx += pattern.Length; + } + + return count; + } +}