Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions Terminal.Gui/Views/Markdown/IntermediateBlock.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
namespace Terminal.Gui.Views;

internal sealed class IntermediateBlock (IReadOnlyList<InlineRun> runs, bool wrap, string prefix = "", string continuationPrefix = "", bool isCodeBlock = false, string? anchor = null, bool isThematicBreak = false, TableData? tableData = null)
internal sealed class IntermediateBlock (IReadOnlyList<InlineRun> runs,
bool wrap,
string prefix = "",
string continuationPrefix = "",
bool isCodeBlock = false,
string? anchor = null,
bool isThematicBreak = false,
TableData? tableData = null,
string? language = null)
{
public IReadOnlyList<InlineRun> Runs { get; } = runs;
public bool Wrap { get; } = wrap;
Expand All @@ -13,8 +21,14 @@ internal sealed class IntermediateBlock (IReadOnlyList<InlineRun> runs, bool wra
public TableData? TableData { get; } = tableData;

/// <summary>Gets whether this block represents a Markdown table.</summary>
public bool IsTable => TableData is not null;
public bool IsTable => TableData is { };

/// <summary>The GitHub-style anchor slug for heading blocks, or <see langword="null"/> for non-heading blocks.</summary>
public string? Anchor { get; } = anchor;

/// <summary>
/// The fenced code block language specifier (e.g. <c>"cs"</c>, <c>"python"</c>), or
/// <see langword="null"/> when this is not a code block or no language was given.
/// </summary>
public string? Language { get; } = language;
}
38 changes: 38 additions & 0 deletions Terminal.Gui/Views/Markdown/Markdown.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,43 @@ namespace Terminal.Gui.Views;
/// Hyperlinks raise the <see cref="LinkClicked"/> event. Anchor links (URLs beginning with
/// <c>#</c>) are handled automatically by scrolling to the matching heading.
/// </para>
/// <para>Default key bindings:</para>
/// <list type="table">
/// <listheader>
/// <term>Key</term> <description>Action</description>
/// </listheader>
/// <item>
/// <term>Ctrl+A</term>
/// <description>Selects all rendered content (<see cref="Command.SelectAll"/>).</description>
/// </item>
/// <item>
/// <term>Ctrl+C</term>
/// <description>
/// Copies the current selection to the clipboard, or the entire markdown source if nothing is selected
/// (<see cref="Command.Copy"/>).
/// </description>
/// </item>
/// <item>
/// <term>Shift+F10 / Right-click</term>
/// <description>Opens a context menu with <b>Select All</b> and <b>Copy</b> items.</description>
/// </item>
/// </list>
/// <para>Default mouse bindings:</para>
/// <list type="table">
/// <listheader>
/// <term>Mouse Event</term> <description>Action</description>
/// </listheader>
/// <item>
/// <term>Left-button drag</term> <description>Selects text by dragging the mouse.</description>
/// </item>
/// <item>
/// <term>Left-button click</term>
/// <description>Clears the selection and activates a hyperlink if one is under the cursor.</description>
/// </item>
/// <item>
/// <term>Right-button click</term> <description>Opens the context menu.</description>
/// </item>
/// </list>
/// </remarks>
public partial class Markdown : View, IDesignable
{
Expand Down Expand Up @@ -249,6 +286,7 @@ private void InvalidateParsedAndLayout ()
RemoveTableViews ();
RemoveThematicBreakViews ();
_maxLineWidth = 0;
_isSelecting = false;

SetNeedsLayout ();
SetNeedsDraw ();
Expand Down
2 changes: 1 addition & 1 deletion Terminal.Gui/Views/Markdown/MarkdownCodeBlock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 7 additions & 0 deletions Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,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);
Comment thread
tig marked this conversation as resolved.
}
else
{
DrawGrapheme (segment, grapheme, drawCol, drawRow);
Expand Down
4 changes: 2 additions & 2 deletions Terminal.Gui/Views/Markdown/MarkdownView.Layout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<RenderedLine> WrapBlock (IntermediateBlock block, int viewportWidth)
Expand Down
120 changes: 113 additions & 7 deletions Terminal.Gui/Views/Markdown/MarkdownView.Mouse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);
}

/// <inheritdoc/>
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;
}

/// <inheritdoc/>
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 ();
}
Expand Down Expand Up @@ -119,30 +178,77 @@ protected override bool OnAdvancingFocus (NavigationDirection direction, TabBeha
/// <inheritdoc/>
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));
Comment thread
tig marked this conversation as resolved.
Outdated
_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;
}
Expand Down
4 changes: 2 additions & 2 deletions Terminal.Gui/Views/Markdown/MarkdownView.Parsing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@ private void AddCodeBlockLines (IReadOnlyList<string> 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;
}
Expand Down Expand Up @@ -563,7 +563,7 @@ private void AddCodeBlockLines (IReadOnlyList<string> codeLines, string? languag
runs = converted;
}

_blocks.Add (new IntermediateBlock (runs, false, isCodeBlock: true));
_blocks.Add (new IntermediateBlock (runs, false, isCodeBlock: true, language: language));
}
}

Expand Down
Loading
Loading