Skip to content

Commit 36de7a7

Browse files
committed
fix(selection): keep selection when clicking inside a context-menu portal; add clipboard docs
Bug: a right-click context menu whose "Copy" item calls CopySelectionToClipboard() copied nothing. The menu-item click is a Button1Pressed, and the window-level clear-selection-on-press fired before routing the click to the menu action — so the selection was already gone when Copy ran. (Copy All, which ignores the selection, still worked — the tell.) Fix: WindowEventDispatcher skips the clear-on-press when the press lands inside an open portal (IsPositionInsideAnyPortal, mirroring DismissOutsideClickPortals' bounds logic). Overlays like context menus / dropdowns can act on the selection without wiping it; genuine content clicks still clear it. Tests: Button1Press_InsideOpenPortal_DoesNotClearSelection (the bug) and Button1Press_OutsidePortal_StillClearsSelection (clear behavior preserved). Docs: new docs/CLIPBOARD.md covering OSC 52 remote copy (works over SSH), bracketed paste, IPasteTarget, the session matrix, and configuration; cross- referenced from README and the MultilineEdit / Prompt / Markup control docs.
1 parent fa4516e commit 36de7a7

7 files changed

Lines changed: 310 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ Full reference: [nickprotop.github.io/ConsoleEx/docfx/_site/CONTROLS.html](https
287287
| [Plugins](https://nickprotop.github.io/ConsoleEx/docfx/_site/PLUGINS.html) | Extending the framework |
288288
| [State Services](https://nickprotop.github.io/ConsoleEx/docfx/_site/STATE-SERVICES.html) | All 11 built-in services |
289289
| [Threading & Async](docs/THREADING_AND_ASYNC.md) | The UI thread model, async events, and the unresponsive watchdog |
290+
| [Clipboard, Copy & Paste](docs/CLIPBOARD.md) | OSC 52 remote copy (works over SSH), bracketed paste, and configuration |
290291
| [Themes](https://nickprotop.github.io/ConsoleEx/docfx/_site/THEMES.html) | Built-in and custom themes |
291292
| [Comparison](https://nickprotop.github.io/ConsoleEx/docfx/_site/COMPARISON.html) | vs Terminal.Gui, Spectre.Console |
292293
| [API Reference](https://nickprotop.github.io/ConsoleEx/docfx/_site/api/SharpConsoleUI.html) | Full API docs |
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// -----------------------------------------------------------------------
2+
// ConsoleEx - A simple console window system for .NET Core
3+
//
4+
// Author: Nikolaos Protopapas
5+
// Email: nikolaos.protopapas@gmail.com
6+
// License: MIT
7+
// -----------------------------------------------------------------------
8+
9+
using System.Drawing;
10+
using SharpConsoleUI.Controls;
11+
using SharpConsoleUI.Drivers;
12+
using SharpConsoleUI.Events;
13+
using SharpConsoleUI.Layout;
14+
using SharpConsoleUI.Tests.Infrastructure;
15+
using Xunit;
16+
using Color = SharpConsoleUI.Color;
17+
18+
namespace SharpConsoleUI.Tests.Controls;
19+
20+
/// <summary>
21+
/// A right-click context menu (a portal) commonly acts on the current text selection — e.g. a "Copy"
22+
/// item. Clicking inside that menu must NOT clear the window's active selection, otherwise the menu's
23+
/// action (CopySelectionToClipboard) runs against an already-cleared selection and copies nothing.
24+
/// </summary>
25+
public class ContextMenuSelectionTests
26+
{
27+
private static MouseEventArgs WindowMouse(int wx, int wy, params MouseFlags[] flags)
28+
{
29+
var p = new Point(wx, wy);
30+
return new MouseEventArgs(flags.ToList(), p, p, p);
31+
}
32+
33+
[Fact]
34+
public void Button1Press_InsideOpenPortal_DoesNotClearSelection()
35+
{
36+
var system = TestWindowSystemBuilder.CreateTestSystem(80, 25);
37+
var window = new Window(system) { Title = "T", Left = 0, Top = 0, Width = 80, Height = 25 };
38+
var markup = new MarkupControl(new List<string> { "selectable text here" }) { EnableSelection = true };
39+
window.AddControl(markup);
40+
system.AddWindow(window);
41+
system.Render.UpdateDisplay();
42+
system.Render.UpdateDisplay();
43+
44+
// Establish a real selection on the markup control.
45+
var buffer = new CharacterBuffer(45, 15);
46+
var bounds = new LayoutRect(0, 0, 40, 10);
47+
markup.PaintDOM(buffer, bounds, bounds, Color.White, Color.Black);
48+
markup.ProcessMouseEvent(new MouseEventArgs(
49+
new List<MouseFlags> { MouseFlags.Button1Pressed }, new Point(0, 0), new Point(0, 0), new Point(0, 0)));
50+
markup.ProcessMouseEvent(new MouseEventArgs(
51+
new List<MouseFlags> { MouseFlags.Button1Dragged }, new Point(8, 0), new Point(8, 0), new Point(8, 0)));
52+
Assert.True(window.SelectionManager.HasSelection);
53+
54+
// Open a context-menu-like portal with known bounds (content space).
55+
var portal = new PortalContentContainer { DismissOnOutsideClick = true };
56+
portal.PortalBounds = new Rectangle(5, 5, 20, 6); // content-space bounds
57+
var node = window.CreatePortal(markup, portal);
58+
Assert.NotNull(node);
59+
60+
// A left-press INSIDE the portal bounds (window space = content + 1 border) — i.e. clicking a
61+
// menu item — must NOT clear the active selection.
62+
window.EventDispatcher!.ProcessMouseEvent(WindowMouse(10, 8, MouseFlags.Button1Pressed));
63+
64+
Assert.True(window.SelectionManager.HasSelection,
65+
"Clicking inside an open context-menu portal must not clear the text selection.");
66+
}
67+
68+
[Fact]
69+
public void Button1Press_OutsidePortal_StillClearsSelection()
70+
{
71+
// Regression guard: the clear-on-empty-click behavior is preserved for genuine content clicks.
72+
var system = TestWindowSystemBuilder.CreateTestSystem(80, 25);
73+
var window = new Window(system) { Title = "T", Left = 0, Top = 0, Width = 80, Height = 25 };
74+
var markup = new MarkupControl(new List<string> { "selectable text here" }) { EnableSelection = true };
75+
window.AddControl(markup);
76+
system.AddWindow(window);
77+
system.Render.UpdateDisplay();
78+
system.Render.UpdateDisplay();
79+
80+
var buffer = new CharacterBuffer(45, 15);
81+
var bounds = new LayoutRect(0, 0, 40, 10);
82+
markup.PaintDOM(buffer, bounds, bounds, Color.White, Color.Black);
83+
markup.ProcessMouseEvent(new MouseEventArgs(
84+
new List<MouseFlags> { MouseFlags.Button1Pressed }, new Point(0, 0), new Point(0, 0), new Point(0, 0)));
85+
markup.ProcessMouseEvent(new MouseEventArgs(
86+
new List<MouseFlags> { MouseFlags.Button1Dragged }, new Point(8, 0), new Point(8, 0), new Point(8, 0)));
87+
Assert.True(window.SelectionManager.HasSelection);
88+
89+
// No portal open. Press on empty space far from the markup control clears the selection.
90+
window.EventDispatcher!.ProcessMouseEvent(WindowMouse(5, 20, MouseFlags.Button1Pressed));
91+
92+
Assert.False(window.SelectionManager.HasSelection);
93+
}
94+
}

SharpConsoleUI/Windows/WindowEventDispatcher.cs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,12 @@ public bool ProcessMouseEvent(Events.MouseEventArgs args)
228228
// (empty space, a panel, or another control). The control under the cursor — if it
229229
// is selectable — re-establishes its own selection as the drag proceeds. We guard on
230230
// "no mouse capture in progress" so SGR drag-continuation presses (Button1Pressed sent
231-
// during an active drag) don't wipe the selection being extended.
232-
if (args.HasFlag(MouseFlags.Button1Pressed) && _mouseCaptureControl == null)
231+
// during an active drag) don't wipe the selection being extended. We also skip the
232+
// clear when the press lands inside an open portal (e.g. a right-click context menu
233+
// whose "Copy" item acts on the current selection) — otherwise the selection would be
234+
// gone before the menu action runs.
235+
if (args.HasFlag(MouseFlags.Button1Pressed) && _mouseCaptureControl == null
236+
&& !IsPositionInsideAnyPortal(args))
233237
{
234238
_window.SelectionManager.ClearSelection();
235239
}
@@ -302,6 +306,32 @@ public bool ProcessMouseEvent(Events.MouseEventArgs args)
302306
}
303307
}
304308

309+
/// <summary>
310+
/// Returns true if the given window position lands inside any open portal's bounds.
311+
/// Used so that clicking inside an overlay (e.g. a context menu acting on the current text
312+
/// selection) does not trigger the window-level clear-selection-on-press.
313+
/// </summary>
314+
private bool IsPositionInsideAnyPortal(MouseEventArgs args)
315+
{
316+
var root = _window.RootLayoutNode;
317+
if (root == null) return false;
318+
319+
var contentPos = GetContentCoordinates(args.WindowPosition);
320+
bool inside = false;
321+
root.Visit(node =>
322+
{
323+
foreach (var portal in node.PortalChildren)
324+
{
325+
if (portal.Control is IHasPortalBounds hasPortalBounds
326+
&& hasPortalBounds.GetPortalBounds().Contains(contentPos))
327+
{
328+
inside = true;
329+
}
330+
}
331+
});
332+
return inside;
333+
}
334+
305335
/// <summary>
306336
/// Dismisses portals that have DismissOnOutsideClick enabled when a click lands outside their bounds.
307337
/// Collects targets first to avoid modifying collections during iteration.

docs/CLIPBOARD.md

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# Clipboard, Copy & Paste
2+
3+
SharpConsoleUI's clipboard is designed to work **both on a local desktop and over SSH** — a copy
4+
inside an app running on a remote server can land on the operator's **local** clipboard. This guide
5+
explains how copy and paste work, the remote/SSH behavior, and the configuration knobs.
6+
7+
## TL;DR
8+
9+
- **Copy** writes the system clipboard via the local tool (`xclip`/`wl-copy`/`pbcopy`/`clip.exe`)
10+
**and** emits an [OSC 52](#osc-52-the-remote-clipboard-escape) escape so the text also reaches the
11+
**local** clipboard over SSH. Belt-and-braces — it works whether the session is local or remote.
12+
- **Paste** uses the terminal's own paste. [Bracketed paste](#bracketed-paste) is enabled so a
13+
multi-line paste inserts as one atomic block (no runaway auto-indent), and `Ctrl+V` reads the
14+
clipboard for local sessions.
15+
- **Backward compatible:** the local-tool path is unchanged; everything new is additive and on by
16+
default in a way that never breaks existing local behavior.
17+
18+
## The `ClipboardHelper` API
19+
20+
All copy/paste flows through `SharpConsoleUI.Helpers.ClipboardHelper`:
21+
22+
```csharp
23+
ClipboardHelper.SetText("hello"); // copy (local tool + OSC 52)
24+
string text = ClipboardHelper.GetText(); // read back (local tool / in-process buffer)
25+
```
26+
27+
`SetText` never throws — clipboard operations are best-effort.
28+
29+
## How copy works
30+
31+
`SetText` does two things, in order:
32+
33+
1. **Emit OSC 52** (when enabled — see below) through the terminal's output stream. This is what
34+
reaches the **local** clipboard over SSH.
35+
2. **Run the local clipboard tool** (unchanged from earlier versions): `clip.exe` (Windows),
36+
`pbcopy` (macOS), `wl-copy` / `xclip` / `xsel` (Linux), or an in-process buffer when none is
37+
found.
38+
39+
Both run on every copy. On a local desktop the OSC 52 is harmless (the terminal either honors it —
40+
setting the same clipboard the local tool just set — or silently ignores an unknown escape). Over
41+
SSH the local tool typically finds no clipboard on the (often headless) server, and OSC 52 carries
42+
the text to your machine.
43+
44+
> OSC 52 is only emitted when running inside a live `ConsoleWindowSystem` (the console driver
45+
> registers the emitter at startup). In tests or non-driver contexts no escape is written.
46+
47+
### OSC 52: the remote-clipboard escape
48+
49+
[OSC 52](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) is a
50+
terminal escape sequence (`ESC ] 52 ; c ; <base64> BEL`) that asks the **terminal** — i.e. the
51+
program on your local machine — to set its clipboard. Because the terminal runs locally, this is the
52+
mechanism that crosses an SSH boundary. Most modern terminals support it (iTerm2, WezTerm, kitty,
53+
Alacritty, Windows Terminal, foot, …); some disable it by default for security and must be opted in.
54+
55+
Whether OSC 52 actually lands is therefore a property of **your terminal**, not the library. The
56+
library always emits a correct sequence when enabled; the terminal decides whether to honor it.
57+
58+
## Behavior by session type
59+
60+
| Session | OSC 52 | Where a copy lands |
61+
|---|---|---|
62+
| Local (X11 / Wayland / macOS / Windows) | emitted (redundant) | Local clipboard via the local tool (and OSC 52 if the terminal honors it) |
63+
| SSH, plain terminal | emitted | **Local** clipboard (if the terminal supports OSC 52) |
64+
| SSH + **tmux** | emitted, **passthrough-wrapped** | **Local** clipboard — no `~/.tmux.conf` change needed |
65+
| SSH + **screen** (`STY` set) | **skipped** | Server-side tool / in-process buffer (OSC 52 is unreliable under screen) |
66+
| Copy larger than ~74 KB | **skipped** | Local tool only (OSC 52 has terminal size limits) |
67+
68+
Session detection happens once at startup and is exposed (read-only) on
69+
`SharpConsoleUI.Helpers.TerminalCapabilities`:
70+
71+
```csharp
72+
TerminalCapabilities.IsRemoteSession // SSH_TTY or SSH_CONNECTION set
73+
TerminalCapabilities.IsTmux // TMUX set — OSC 52 is passthrough-wrapped
74+
TerminalCapabilities.IsScreen // STY set — OSC 52 skipped
75+
TerminalCapabilities.SupportsOsc52 // whether OSC 52 will be attempted (false under screen)
76+
```
77+
78+
## Configuration
79+
80+
OSC 52 emission is controlled by `ClipboardHelper.Osc52Mode` (default `Auto`):
81+
82+
```csharp
83+
// Default: emit when the session is believed to support OSC 52.
84+
ClipboardHelper.Osc52Mode = Osc52Mode.Auto;
85+
86+
// Always emit, regardless of detection (e.g. a terminal you know supports it).
87+
ClipboardHelper.Osc52Mode = Osc52Mode.Enabled;
88+
89+
// Never emit — local tools / in-process buffer only.
90+
ClipboardHelper.Osc52Mode = Osc52Mode.Disabled;
91+
92+
// Override capability detection (e.g. force-enable under screen, or disable for a known-bad terminal).
93+
TerminalCapabilities.SetOsc52Override(true); // force on
94+
TerminalCapabilities.SetOsc52Override(false); // force off
95+
TerminalCapabilities.SetOsc52Override(null); // restore auto-detection
96+
97+
// Adjust the OSC 52 size cap (base64 payload length). Larger copies skip OSC 52.
98+
ClipboardHelper.MaxOsc52Bytes = Osc52.DefaultMaxBytes; // 74000
99+
```
100+
101+
## How paste works
102+
103+
### Bracketed paste
104+
105+
When the app starts it enables **bracketed paste** (`ESC[?2004h`). The terminal then wraps pasted
106+
content in `ESC[200~``ESC[201~`, so the app can recognize a paste as one block instead of a flood
107+
of individual keystrokes. Without it, a multi-line paste would be processed key-by-key — newlines
108+
running as Enter, auto-indent corrupting the text. With it, the block is delivered atomically and
109+
inserted as content.
110+
111+
This is also **the paste path that works over SSH**: when you press your terminal's paste
112+
(Cmd/Ctrl+Shift+V, middle-click, etc.), the terminal injects your **local** clipboard into the SSH
113+
stream, and bracketed paste lets the app insert it correctly.
114+
115+
### `Ctrl+V` and `IPasteTarget`
116+
117+
Paste is centralized: the window routes **both** a bracketed-paste block and `Ctrl+V` to the focused
118+
control's `IPasteTarget.Paste(string)`:
119+
120+
```csharp
121+
public interface IPasteTarget
122+
{
123+
void Paste(string text); // insert text at the current position as a single block
124+
}
125+
```
126+
127+
Built-in editors implement it (`MultilineEditControl`, `PromptControl`, `TableControl`). `Ctrl+V`
128+
reads `ClipboardHelper.GetText()` (the local-session source); bracketed paste uses the
129+
terminal-delivered text. A read-only editor's `Paste` is a no-op; `PromptControl` (single-line)
130+
flattens embedded newlines to spaces.
131+
132+
> **Remote paste note:** reading the clipboard *back* (`GetText`, the `Ctrl+V` source) reads the
133+
> **server-side** clipboard over SSH — most terminals disable OSC 52 clipboard *reads* for security,
134+
> so there is no reliable app-driven remote read. Use your **terminal's** paste over SSH; bracketed
135+
> paste makes it insert correctly.
136+
137+
## Verifying it works
138+
139+
Automated tests cover the encoder (byte-exact OSC 52 + tmux wrap), session detection, the layered
140+
`SetText`, the bracketed-paste parser, and end-to-end paste routing. The real round-trip depends on
141+
your terminal, so confirm it manually:
142+
143+
```bash
144+
# Local
145+
dotnet run --project Examples/DemoApp
146+
# → Controls → Selectable Text: drag-select, Ctrl+C, paste into a local editor.
147+
148+
# Remote (the real test) — from an OSC 52-capable terminal
149+
ssh you@server
150+
dotnet run --project Examples/DemoApp
151+
# → select + Ctrl+C in the app → paste on your LOCAL machine.
152+
# → paste a multi-line block into the editor → it inserts as one block.
153+
```
154+
155+
## Backward compatibility
156+
157+
- The local clipboard tool still runs on every copy exactly as before; OSC 52 is added in front of
158+
it and is a no-op on terminals that don't support it.
159+
- `GetText` is unchanged.
160+
- All new surface (`Osc52Mode`, `MaxOsc52Bytes`, the `TerminalCapabilities` session flags,
161+
`IPasteTarget`) is additive; nothing existing was removed or changed in default behavior.
162+
163+
## See also
164+
165+
- [MultilineEditControl](controls/MultilineEditControl.md) — multi-line editor (Ctrl+C/X/V)
166+
- [PromptControl](controls/PromptControl.md) — single-line input with clipboard support
167+
- [MarkupControl](controls/MarkupControl.md) — opt-in selectable/copyable display text
168+
169+
---
170+
171+
[Back to Documentation](../README.md#documentation)

docs/controls/MarkupControl.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@ The control implements `ISelectableControl`, so it participates in the window's
212212
`MultilineEditControl` also implements `ISelectableControl`, so an editor and selectable markup
213213
controls in the same window share the single-selection behavior.
214214

215+
> Copy works locally **and over SSH** — see [Clipboard, Copy & Paste](../CLIPBOARD.md) for how OSC 52
216+
> carries the copy to the local clipboard over a remote session, and how to configure it.
217+
215218
### Programmatic Copy
216219

217220
```csharp

docs/controls/MultilineEditControl.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,11 @@ editor.ClearFind(); // Remove all match highlighting
320320
| Insert | Toggle overwrite mode |
321321
| Escape | Exit edit mode (if `EscapeExitsEditMode` is true) |
322322

323+
> Copy/paste works locally and over SSH. The editor implements `IPasteTarget`, so `Ctrl+V` and
324+
> terminal-native (bracketed) paste both insert atomically. See
325+
> [Clipboard, Copy & Paste](../CLIPBOARD.md) for how copy reaches the local clipboard over SSH
326+
> (OSC 52) and how to configure it.
327+
323328
## Mouse Support
324329

325330
| Interaction | Action |

docs/controls/PromptControl.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ prompt.Entered += (sender, text) => ProcessInput(text);
9090
| `Ctrl+V` | Paste from clipboard |
9191
| `Ctrl+X` | Cut selection to clipboard |
9292

93+
> Copy/paste works locally and over SSH. The control implements `IPasteTarget` (paste is single-line:
94+
> newlines are flattened to spaces). See [Clipboard, Copy & Paste](../CLIPBOARD.md) for the OSC 52
95+
> remote-clipboard behavior and configuration.
96+
9397
### History & Completion
9498

9599
| Key | Action |

0 commit comments

Comments
 (0)