Skip to content

Commit ad1d4a8

Browse files
committed
feat(markup): append API, programmatic copy, customizable copy key + context menu
Extend MarkupControl's opt-in selection with the remaining text-control capabilities and surface a right-click context menu pattern in the demo. - Append API: AppendLine / AppendLines / AppendText (clears a stale selection) - Programmatic copy: CopySelectionToClipboard() (selection) and CopyToClipboard() (full content), both returning bool - Customizable/disablable copy shortcut: CopyKey / CopyModifiers / CopyEnabled (defaults Ctrl+C); window-level handler consults the active ICopyableControl, MultilineEdit keeps its default Ctrl+C - Richer event: TextSelectionChanged (HasSelection + SelectedText); existing EventHandler<string> SelectionChanged kept and fired together - New public types: ICopyableControl, TextSelectionChangedEventArgs - Builder: WithCopyKey / WithCopyEnabled - DemoApp: portal-based right-click context menu (Copy / Copy All / Clear) on the Selectable Text screen, opening one line below the click point - Docs: MarkupControl.md Properties/Methods/Events tables + context-menu example - Tests: append, programmatic copy, custom/disabled copy key, richer event
1 parent 035f333 commit ad1d4a8

10 files changed

Lines changed: 800 additions & 25 deletions

File tree

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
using System.Drawing;
2+
using SharpConsoleUI;
3+
using SharpConsoleUI.Controls;
4+
using SharpConsoleUI.Drawing;
5+
using SharpConsoleUI.Events;
6+
using SharpConsoleUI.Layout;
7+
using Color = SharpConsoleUI.Color;
8+
using Rectangle = System.Drawing.Rectangle;
9+
10+
namespace DemoApp.DemoWindows;
11+
12+
/// <summary>A single context-menu entry. Label "-" renders as a separator.</summary>
13+
internal record DemoMenuItem(string Label, string? Shortcut = null, Action? Action = null, bool Enabled = true)
14+
{
15+
public bool IsSeparator => Label == "-";
16+
}
17+
18+
/// <summary>
19+
/// A lightweight right-click context menu built on PortalContentContainer + a vertical MenuControl.
20+
/// Mirrors the pattern used by LazyDotIDE. The MenuControl renders separators, shortcuts, disabled
21+
/// items, and highlight, and handles keyboard/mouse navigation natively.
22+
/// </summary>
23+
internal sealed class DemoContextMenuPortal : PortalContentContainer
24+
{
25+
private const int MenuMaxWidth = 50;
26+
private const int MenuMinWidth = 16;
27+
28+
private readonly MenuControl _menu;
29+
private readonly Dictionary<MenuItem, DemoMenuItem> _map = new();
30+
31+
private static readonly Color MenuBg = Color.Grey11;
32+
private static readonly Color MenuFg = Color.Grey93;
33+
private static readonly Color SelBg = Color.SteelBlue;
34+
private static readonly Color SelFg = Color.White;
35+
36+
public event EventHandler<DemoMenuItem>? ItemSelected;
37+
public event EventHandler? Dismissed;
38+
39+
public DemoContextMenuPortal(List<DemoMenuItem> items, int anchorX, int anchorY, int windowWidth, int windowHeight)
40+
{
41+
_menu = new MenuControl
42+
{
43+
Orientation = MenuOrientation.Vertical,
44+
DropdownBackgroundColor = MenuBg,
45+
DropdownForegroundColor = MenuFg,
46+
DropdownHighlightBackgroundColor = SelBg,
47+
DropdownHighlightForegroundColor = SelFg,
48+
MenuBarBackgroundColor = MenuBg,
49+
MenuBarForegroundColor = MenuFg,
50+
MenuBarHighlightBackgroundColor = SelBg,
51+
MenuBarHighlightForegroundColor = SelFg,
52+
};
53+
54+
BackgroundColor = MenuBg;
55+
ForegroundColor = MenuFg;
56+
DismissOnOutsideClick = true;
57+
BorderStyle = BoxChars.Rounded;
58+
BorderColor = Color.Grey50;
59+
BorderBackgroundColor = MenuBg;
60+
61+
foreach (var item in items)
62+
{
63+
var mi = new MenuItem
64+
{
65+
Text = item.Label,
66+
Shortcut = item.Shortcut,
67+
IsSeparator = item.IsSeparator,
68+
IsEnabled = item.Enabled && !item.IsSeparator,
69+
};
70+
_menu.AddItem(mi);
71+
if (!item.IsSeparator)
72+
_map[mi] = item;
73+
}
74+
75+
PortalFocusedControl = _menu;
76+
_menu.ItemSelected += (_, mi) =>
77+
{
78+
if (_map.TryGetValue(mi, out var ci))
79+
ItemSelected?.Invoke(this, ci);
80+
};
81+
82+
AddChild(_menu);
83+
SetFocusOnFirstChild();
84+
85+
int maxLabelW = 0, maxShortcutW = 0;
86+
foreach (var item in items)
87+
{
88+
if (item.IsSeparator) continue;
89+
maxLabelW = Math.Max(maxLabelW, item.Label.Length);
90+
if (item.Shortcut != null)
91+
maxShortcutW = Math.Max(maxShortcutW, item.Shortcut.Length);
92+
}
93+
94+
int contentW = maxLabelW + (maxShortcutW > 0 ? maxShortcutW + 2 : 0) + 4;
95+
int popupW = Math.Clamp(contentW + 2, MenuMinWidth, MenuMaxWidth);
96+
int popupH = items.Count + 2;
97+
98+
// Anchor + bounds are in window CONTENT space (0,0 = first content row), matching how the
99+
// renderer arranges portal nodes. Below places the menu top at anchorY+1 — i.e. one line
100+
// below the click point.
101+
var pos = PortalPositioner.CalculateFromPoint(
102+
new Point(anchorX, anchorY),
103+
new Size(popupW, popupH),
104+
new Rectangle(0, 0, windowWidth - 2, windowHeight - 2),
105+
PortalPlacement.Below,
106+
new Size(MenuMinWidth, 3));
107+
PortalBounds = pos.Bounds;
108+
}
109+
110+
public override bool ProcessMouseEvent(MouseEventArgs args)
111+
{
112+
if (args.HasAnyFlag(SharpConsoleUI.Drivers.MouseFlags.ReportMousePosition))
113+
{
114+
if (_menu is IMouseAwareControl mac && mac.WantsMouseEvents)
115+
mac.ProcessMouseEvent(args);
116+
return true;
117+
}
118+
return base.ProcessMouseEvent(args);
119+
}
120+
121+
public new bool ProcessKey(ConsoleKeyInfo key)
122+
{
123+
if (key.Key == ConsoleKey.Escape)
124+
{
125+
Dismissed?.Invoke(this, EventArgs.Empty);
126+
return true;
127+
}
128+
if (base.ProcessKey(key))
129+
return true;
130+
return true; // consume all keys while open
131+
}
132+
}
133+
134+
/// <summary>
135+
/// Manages showing/dismissing a single <see cref="DemoContextMenuPortal"/> for a window.
136+
/// </summary>
137+
internal sealed class DemoContextMenu
138+
{
139+
private readonly Window _window;
140+
private DemoContextMenuPortal? _portal;
141+
private LayoutNode? _node;
142+
private IWindowControl? _owner;
143+
144+
public DemoContextMenu(Window window) => _window = window;
145+
146+
public void Dismiss()
147+
{
148+
if (_node != null && _owner != null)
149+
_window.RemovePortal(_owner, _node);
150+
_node = null;
151+
_portal = null;
152+
_owner = null;
153+
}
154+
155+
public void Show(List<DemoMenuItem> items, int anchorX, int anchorY, IWindowControl owner)
156+
{
157+
Dismiss();
158+
if (items.Count == 0) return;
159+
160+
var portal = new DemoContextMenuPortal(items, anchorX, anchorY, _window.Width, _window.Height)
161+
{
162+
Container = _window
163+
};
164+
_portal = portal;
165+
_owner = owner;
166+
_node = _window.CreatePortal(owner, portal);
167+
168+
portal.ItemSelected += (_, item) =>
169+
{
170+
Dismiss();
171+
item.Action?.Invoke();
172+
};
173+
portal.Dismissed += (_, _) => Dismiss();
174+
portal.DismissRequested += (_, _) => { _node = null; _portal = null; _owner = null; };
175+
}
176+
177+
/// <summary>Forwards a window PreviewKey to the open menu (so Esc/arrows/Enter reach it).</summary>
178+
public bool ProcessPreviewKey(KeyPressedEventArgs e)
179+
{
180+
if (_portal != null)
181+
{
182+
_portal.ProcessKey(e.KeyInfo);
183+
e.Handled = true;
184+
return true;
185+
}
186+
return false;
187+
}
188+
}

Examples/DemoApp/DemoWindows/SelectableTextDemoWindow.cs

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ namespace DemoApp.DemoWindows;
77

88
/// <summary>
99
/// Demonstrates opt-in text selection on MarkupControl (issue #36): drag-select with the mouse,
10-
/// copy with Ctrl+C as plain text (markup stripped). Two selectable markup blocks plus a multiline
11-
/// editor share a single window selection — selecting in one clears the others.
10+
/// copy with Ctrl+C as plain text (markup stripped), a right-click context menu (Copy / Copy All /
11+
/// Clear), and programmatic copy. Two selectable markup blocks plus a multiline editor share a
12+
/// single window selection — selecting in one clears the others.
1213
/// </summary>
1314
internal static class SelectableTextDemoWindow
1415
{
@@ -45,23 +46,50 @@ public static Window Create(ConsoleWindowSystem ws)
4546
.AddControl(paragraph)
4647
.AddControl(Controls.Rule("Read-only editor (shares the selection)"))
4748
.AddControl(editor)
48-
.AddControl(Controls.Markup("[dim]Tip: left-click empty space to clear the selection. Right-click is surfaced to the app.[/]").Build())
49+
.AddControl(Controls.Markup("[dim]Tip: drag to select · Ctrl+C to copy · right-click for a menu · left-click empty space clears.[/]").Build())
4950
.WithVerticalAlignment(SharpConsoleUI.Layout.VerticalAlignment.Fill)
5051
.Build();
5152

52-
return new WindowBuilder(ws)
53+
var window = new WindowBuilder(ws)
5354
.WithTitle("Selectable Text")
5455
.WithSize(80, 28)
5556
.Centered()
5657
.AddControl(content)
57-
.OnKeyPressed((s, e) =>
58-
{
59-
if (e.KeyInfo.Key == ConsoleKey.Escape)
60-
{
61-
ws.CloseWindow((Window)s!);
62-
e.Handled = true;
63-
}
64-
})
6558
.BuildAndShow();
59+
60+
// Right-click context menu (Copy / Copy All / Clear), like LazyDotIDE.
61+
var contextMenu = new DemoContextMenu(window);
62+
63+
void ShowMenuFor(MarkupControl target, SharpConsoleUI.Events.MouseEventArgs args)
64+
{
65+
var items = new List<DemoMenuItem>
66+
{
67+
new("Copy", "Ctrl+C", () => target.CopySelectionToClipboard(), Enabled: target.HasSelection),
68+
new("Copy All", null, () => target.CopyToClipboard()),
69+
new("-"),
70+
new("Clear Selection", null, () => target.ClearSelection(), Enabled: target.HasSelection),
71+
};
72+
// args.WindowPosition is window-space (title/border at 0); the portal is positioned in
73+
// content-space (0,0 = first content row). Convert by subtracting the 1-cell border so
74+
// the menu opens exactly one line below the click.
75+
contextMenu.Show(items, args.WindowPosition.X - 1, args.WindowPosition.Y - 1, content);
76+
}
77+
78+
consoleOutput.MouseRightClick += (_, args) => ShowMenuFor(consoleOutput, args);
79+
paragraph.MouseRightClick += (_, args) => ShowMenuFor(paragraph, args);
80+
81+
// Route Esc / arrows / Enter to an open menu first; otherwise Esc closes the window.
82+
window.PreviewKeyPressed += (s, e) =>
83+
{
84+
if (contextMenu.ProcessPreviewKey(e))
85+
return;
86+
if (e.KeyInfo.Key == ConsoleKey.Escape)
87+
{
88+
ws.CloseWindow(window);
89+
e.Handled = true;
90+
}
91+
};
92+
93+
return window;
6694
}
6795
}

0 commit comments

Comments
 (0)