From 9a1408b7eed2d673bff7e38f617b0b8751d720ea Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 13 Aug 2026 22:35:08 -0500 Subject: [PATCH 1/2] feat(panes): select a pane's output with the mouse and copy it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal's own selection cannot do this job — UrlDetector's argument one layer over. Under ?1003, which this client needs for the wheel, tab and rail clicks and pane drag-and-drop, a plain drag belongs to the application; the emulator's escape hatch selects a terminal *row*, which on a vertical split crosses the divider into another pane's output, and since a pane is narrower than the row a logical line wraps and comes back with newlines injected at the wrap points. Almost all of it is the framework's, shipped in the pinned 2.5.14 and off by default: drag, double-click word, triple-click line, drag-autoscroll and a soft-wrap-aware copy. What had to be ours is the colour, the clipboard, and what happens when the buffer moves. - WorkspacePalette.SelectionBand/SelectionInk: one pair per theme, since a selection is not an identity or a focus fact. ReadingPlane pushed further in the direction of travel, leaned toward Theme.Prompt. Held to a fill floor against all fourteen planes and the ink to Contrast.Floor on the band — the highlight replaces the world's foreground too, so that ink is what all selected output is read in. - The clipboard writer is caller-supplied and null by default, the save:/logRoot:/openUrl: family. It also buys one copy path: the framework's ⌃C writes through a static helper no caller can substitute, so a test run would have replaced the developer's real clipboard. - ⌃C is claimed in the main window's key chain, not in AppShortcuts — a global shortcut would take it from the composer's editor as well. - RepaintPane drops any selection: chrome rows go in and out mid-buffer and a selection anchored to display rows would highlight text nobody dragged. - NewPaneControl is now the one place a pane control is made. Enabling selection on PaneContentFor alone left the main window unable to select anything, because that control is built in the constructor. SimulatePaneDrag is the test seam (the framework routes mouse only inside Run()), and the new `selection` view is the frame — in FrameContrastTests' list, because a colour nothing renders is a colour nobody checks. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- CLAUDE.md | 59 ++++- docs/design/README.md | 3 +- .../Commands/CommandCatalog.cs | 7 + src/SharpMUTerm.Tui/Program.cs | 10 +- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 225 +++++++++++++++++- src/SharpMUTerm.Tui/WorkspacePalette.cs | 58 +++++ .../FrameContrastTests.cs | 5 + .../LegiblePaletteTests.cs | 58 +++++ .../PaneSelectionTests.cs | 187 +++++++++++++++ 9 files changed, 602 insertions(+), 10 deletions(-) create mode 100644 tests/SharpMUTerm.Tui.Tests/PaneSelectionTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 036ce35..f697e34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,6 +136,58 @@ fallbacks) for inline images/maps. - **The demo scene loads with `_watching` off.** It pours a spawn window's whole history in before the first frame, and every line would otherwise count as missed — so any frame that later made such a window visible carried an activity bar reporting the client's own setup as news. +- **A pane's output can be selected with the mouse and copied with `⌃C`, and almost all of it is the + framework's** (`MarkupControl.EnableSelection`, shipped in the pinned 2.5.14 and **off by default**). + Drag, double-click word, triple-click line, drag-autoscroll through the `ScrollablePanelControl` each + pane already sits in, one-selection-per-window arbitration and a wrap-aware copy all come from the + package. This is the rare case where the answer was to *switch something on* rather than build it; what + had to be ours is the colour, the clipboard, and what happens to a selection when the buffer moves. + - **The terminal's own selection cannot do this job, which is `UrlDetector`'s argument one layer over.** + Under `?1003` — which this client needs for the wheel, tab and rail clicks and pane drag-and-drop — a + plain drag belongs to the application, and the emulator's escape hatch (kitty's ⇧-drag) selects a + terminal **row**: on a vertical split that row crosses the divider into another pane's output, and + since a pane is narrower than the row a logical line wraps and comes back with newlines injected at + the wrap points. The framework's copy walks the painted cells and breaks a line only where a row is + not a soft-wrap continuation. There is no partial retreat from `?1003` that keeps the wheel. + - **The selection band is one pair per theme, and for a different reason than `ReadingPlane`'s.** There + it is cost; here it is meaning. A pane's plane already says whose connection this is (hue) and where + the keyboard is (luminance), and a selection is neither — it is the client answering a gesture being + made *now*, in exactly one pane at a time, so a highlight that changed colour with whose pane it + landed in would report a fact nobody asked about. `WorkspacePalette.SelectionBand` is `ReadingPlane` + pushed *further* in the direction of travel — brighter on a dark theme, darker on a light one — then + leaned toward `Theme.Prompt`, the same anchor `ArmedBand` uses. It is held to a **fill** floor against + all fourteen planes (1.5:1; tightest measured 2.70:1) and `SelectionInk` to `Contrast.Floor` on the + band, which matters more here than anywhere: the highlight replaces the world's *foreground* too, so + that one ink is what all selected output is read in. + - **The clipboard writer is caller-supplied and null by default** — the `save:`/`logRoot:`/`openUrl:` + family, and here it also buys a *single* copy path. The framework's own ⌃C writes straight to the + system clipboard through a static helper no caller can substitute, so a test run would replace + whatever the developer had copied and the one path that could not be injected would be the one under + test. `CopyEnabled = false` on the pane controls and the chord is answered by + `SharpMUTermApp.CopyFocusedSelection` instead; `Program` supplies `ClipboardHelper.SetText`, which + covers OSC 52 *and* the platform tool, so a copy lands locally and over ssh alike. + - **⌃C is claimed in the main window's key chain, not in `MacroKeys.AppShortcuts`.** A global shortcut + runs ahead of *every* window including the composer, whose `MultilineEditControl` has its own ⌃C and + is a real editor. Being in the chain also puts it after `DispatchMacro`, so a macro bound to ⌃C wins — + the same relationship ⌃←/→ has with pane selection, and the reason `Verdict` needs no special case. + - **A selection is dropped whenever the rows under it move**, in `RepaintPane` — the one seam that + re-feeds a pane. Chrome rows go in and out mid-buffer (the freeze bar, the away bar, `NEW`, the search + bar) and the timestamp toggle re-feeds whole buffers; a selection is anchored to display rows, so one + left alone across an insert highlights text nobody dragged over. + - **`NewPaneControl` is the one place a pane's control is made**, and it exists because enabling + selection on `PaneContentFor` alone left the **main** window — the pane most people are looking at — + unable to select anything. That control is built in the constructor, before a workspace exists; every + other one is built on demand. Two creation paths, and the trap announced itself immediately. + - **`SimulatePaneDrag` is the test seam**, for `SimulatePaneClick`'s reason: the framework registers its + driver-mouse handler inside `Run()`, which no test calls. The drag flag rides *with* the button flag, + because SGR encodes motion-while-held as `Button1Pressed | Button1Dragged` and a seam sending the bare + form would exercise a path the terminal never produces. The `selection` view is the frame, and it is + in `FrameContrastTests`' list — a colour nothing renders is a colour nobody checks. + - **Not fixed, and known**: chrome rows live in the same buffer and are selectable (a terminal selection + would take them too); OSC 52 caps at ~74 KB and `Osc52.BuildSequence` returns null past it, so a very + large copy lands locally and **silently** does not travel over ssh; GNU screen has OSC 52 disabled + upstream; tmux needs `allow-passthrough on`. The Windows mouse path is a separate ad-hoc parser in + `NetConsoleDriver` and nothing here can verify it — treat Windows drag-select as unproven. - **Coming back to a window you were not watching leaves a bar where you left off, and that covers two different absences.** The *window* one is `NEW` and is the common case: a line lands while the window is not `Workspace.IsCaughtUp` — visible **and** at its live tail — and `_missedFrom` records the index @@ -539,7 +591,12 @@ python3 tools/ansi_frame_to_image.py frame.ansi frame.html # or .svg a viewport row), `activity-bar` (the *other* absence — a window the reader was not watching: three lines land in the main window while Chat is in front of it, and picking main back lands on the `NEW` bar with those three under it. Separate from `away` because the two are separate facts with separate - wording, and this is the one that happens many times an hour), `prefix-panel` (the ⌃B which-key + wording, and this is the one that happens many times an hour), + `selection` (a real ⌃-drag across the main window's output, through `SimulatePaneDrag` and the control's + own hit test rather than a highlight posed by hand — the only frame carrying `WorkspacePalette.SelectionBand`, + which is why it is in `FrameContrastTests`' list: the band is the one plane this client invents rather + than derives from a pane, and its ink replaces the world's own on every selected cell), + `prefix-panel` (the ⌃B which-key panel — the state `prefix` becomes a few hundred milliseconds later, if no key has arrived), `focus`/`focus-moved` (a split *and* a second command line — the one geometry showing a focused pane beside an unfocused one and an armed bar above an idle one, before and after a real ⌃→), diff --git a/docs/design/README.md b/docs/design/README.md index 086b097..5c8fe94 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -425,7 +425,8 @@ count on the tab, the rail character, and the rail world. `⌃P` command surface · `⌃F` search the output (`⌥G` next hit) · `⌥F` freeze/resume in focused pane · `⌃R` command-history search · -`⌃N` next tab in the focused pane · `⌥D`/`⌥R` disconnect/reconnect · `⌥↑`/`⌥↓` command history (`↑`/`↓` do it too, +`⌃N` next tab in the focused pane · `⌃C` copy the pane selection (drag to select) · +`⌥D`/`⌥R` disconnect/reconnect · `⌥↑`/`⌥↓` command history (`↑`/`↓` do it too, where the caret has nowhere further to go) · `⌥⏎`, or `⌃L`, newline in input · `F1` composer · `F2`–`F9` config · `Esc` close overlay. diff --git a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs index 2d16928..4562b66 100644 --- a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs +++ b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs @@ -200,6 +200,13 @@ public static IReadOnlyList Build( "term:restore-purge", "deletes every pane's saved content")); + // Copying output. Listed unconditionally and subtitled with the *gesture* as well as the chord, + // because the gesture is the part nobody can guess: under mouse reporting a plain drag belongs to + // the application, so a user who has learnt that their terminal needs ⇧-drag has no reason to try + // dragging here. It refuses out loud with nothing selected, which is what earns it a row at all. + items.Add(new CommandItem( + CommandGroup.Terminal, "Copy the selection", "term:copy", "⌃C · drag across a pane to select")); + // The client's own messages — the status-line notices that dismiss themselves — kept out of the // output window (and so out of the session log) and readable here instead. items.Add(new CommandItem( diff --git a/src/SharpMUTerm.Tui/Program.cs b/src/SharpMUTerm.Tui/Program.cs index fef155a..5c41658 100644 --- a/src/SharpMUTerm.Tui/Program.cs +++ b/src/SharpMUTerm.Tui/Program.cs @@ -143,7 +143,14 @@ private static int Main(string[] args) logRoot: logRoot, restore: restore, mssp: mssp, - openUrl: ExternalBrowser.Open); + openUrl: ExternalBrowser.Open, + + // The one place a real clipboard is reached. SharpConsoleUI's helper writes OSC 52 *and* + // mirrors to the platform tool (wl-copy / xclip / pbcopy / Win32), so a copy lands whether the + // client is local or on the far end of an ssh session. Supplied here rather than reached for + // inside the app for the reason logRoot and the browser launcher are: nothing that is not this + // entry point may touch the developer's clipboard, least of all the test suite. + clipboard: SharpConsoleUI.Helpers.ClipboardHelper.SetText); var exitCode = liveApp.Run(startup); // blocks on the SharpConsoleUI main loop until exit // Persist the workspace so the next launch resumes where this one left off. @@ -300,6 +307,7 @@ private static void WriteUsage(TextWriter usage) usage.WriteLine("character's saved password and connect line — F5's 'login' row says which."); usage.WriteLine(); usage.WriteLine("In-app: Up/Down history · Ctrl+N next tab · Ctrl+W close · Ctrl+P palette · Ctrl+Q quit."); + usage.WriteLine("Selection: drag across a pane's output to select it, Ctrl+C to copy."); // The composer earns a line of its own because what it *sends* is not guessable from the window: // the buffer is one command and its line breaks are written %r, which is what a MUSH board or // mail body wants. Naming the send chord matters for the same reason — Ctrl+Enter is what a diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index d6f7d1b..9db621b 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -446,6 +446,19 @@ private sealed class SizeReport /// private readonly Action? _openUrl; + /// + /// Where a copied selection goes, or null when this app may not reach a clipboard at all. + /// + /// The save/logRoot/openUrl family, for the family's reason: only Program + /// knows it is the live client, so a snapshot and a test provably leave the developer's real clipboard + /// holding whatever it held. It also means there is one copy path — the framework's own ⌃C + /// handler writes straight to the system clipboard and is switched off on these controls, because a + /// second writer would be a second answer and the one that could not be injected would be the one that + /// ran under test. + /// + /// + private readonly Action? _clipboard; + /// /// The directory session transcripts are written under, or null for an app that owns no log /// directory — which is the default, and is what every test and every snapshot gets. See the @@ -562,11 +575,13 @@ public SharpMUTermApp( RestoreLog? restore = null, MsspCache? mssp = null, bool? focusReporting = null, - Action? openUrl = null) + Action? openUrl = null, + Action? clipboard = null) { _config = config; _save = save; _openUrl = openUrl; + _clipboard = clipboard; _logRoot = string.IsNullOrWhiteSpace(logRoot) ? null : logRoot; _restore = restore; _mssp = mssp ?? new MsspCache(); @@ -638,9 +653,7 @@ public SharpMUTermApp( _header.FocusedLinkBackgroundColor = ToColor(new Rgb(brand.R, brand.G, brand.B)); _header.FocusedLinkForegroundColor = ToColor(_theme.Resolve(TerminalColor.Default, isBackground: true)); - var main = new MarkupControl(new List()); - main.LinkClicked += (_, e) => OnLinkClicked(MainWindowId, e.Url); - _panes[MainWindowId] = main; + var main = NewPaneControl(MainWindowId); // The connection rail (worlds → characters → windows) sits left of the pane area, joined by // a splitter. RailModel/RailRenderer keep the projection + markup tested; this just hosts it. @@ -1021,6 +1034,18 @@ public string RenderSnapshot(string? view = null) } } + // A live selection over the main window's output. It exists because the selection band is the one + // plane this client invents rather than derives from a pane, and a colour nothing renders is a + // colour nobody checks — this frame is what puts the pair in front of FrameContrastTests and in + // front of a reader. The drag is the real gesture through the real control (SimulatePaneDrag), not + // a highlight painted in by hand, because the thing worth seeing is what a drag actually produces. + if (string.Equals(view, "selection", StringComparison.OrdinalIgnoreCase)) + { + RenderWholeFrame(); // the grid has to be painted before a hit test can land on it + SimulatePaneDrag(MainWindowId, 0, 1, 46, 3); + ReArmWholeFrame(); + } + // Move mode needs a split to have multiple target panes; set it up then arm move mode. if (string.Equals(view, "move", StringComparison.OrdinalIgnoreCase)) { @@ -2752,6 +2777,10 @@ private void RepaintPane(string windowId) return; } + // Every row this pane holds is about to be replaced, so a selection anchored to the old ones has + // nothing left to describe. + ClearPaneSelection(windowId); + if (_freezePoints.TryGetValue(windowId, out var point)) { var split = Math.Clamp(point, 0, buffer.Count); @@ -6604,6 +6633,45 @@ internal bool SimulatePaneClick(string windowId, int x, int y) new List { MouseFlags.Button1Clicked }, local, onWindow, onWindow, _window)); } + /// + /// Drags across a window's output pane, from one cell to another measured from the control's own + /// top-left — press, move, release, the three events a terminal really sends. + /// + /// It exists for the reason does, and it is the same limitation stated + /// for a gesture with more than one event in it: the framework subscribes its driver-mouse handler + /// inside Run(), which no test calls, so nothing reaches a control here unless it is handed + /// over directly. The drag flag rides with the button flag because that is how the SGR + /// encoding arrives — a move with the button still down is Button1Pressed | Button1Dragged, not + /// a bare drag, and a seam that sent the bare form would exercise a path the terminal never produces. + /// + /// + internal void SimulatePaneDrag(string windowId, int fromX, int fromY, int toX, int toY) + { + if (!_panes.TryGetValue(windowId, out var pane)) + { + return; + } + + Send(new List { MouseFlags.Button1Pressed }, fromX, fromY); + Send(new List { MouseFlags.Button1Pressed, MouseFlags.Button1Dragged }, toX, toY); + Send(new List { MouseFlags.Button1Released }, toX, toY); + + void Send(List flags, int x, int y) + { + var local = new System.Drawing.Point(x, y); + var onWindow = new System.Drawing.Point(pane.ActualX + x, pane.ActualY + y); + pane.ProcessMouseEvent(new MouseEventArgs(flags, local, onWindow, onWindow, _window)); + } + } + + /// + /// What a window's pane currently has selected, as the text a copy would put on the clipboard, or + /// empty when nothing is. Reads the control rather than any state of ours, because the selection + /// belongs to the framework and a second record of it would be a second answer. + /// + internal string PaneSelection(string windowId) => + _panes.TryGetValue(windowId, out var pane) && pane.HasSelection ? pane.GetSelectedText() : string.Empty; + /// /// The markup a window's output pane currently holds, one string per row. Internal so a test can read /// a link payload off the pane the app really drew instead of writing the expected one down — the @@ -6774,6 +6842,8 @@ internal bool DispatchCommand(string id) case "term:input2-off": ToggleSecondBar(); return true; + case "term:copy": + return CopyFocusedSelection(); case "term:messages": if (!ComposerIsInTheWay("the client messages")) { @@ -7632,9 +7702,7 @@ private MarkupControl PaneContentFor(string id, string title) return existing; } - var control = new MarkupControl(new List()); - control.LinkClicked += (_, e) => OnLinkClicked(id, e.Url); - _panes[id] = control; + var control = NewPaneControl(id); if (_lines.TryGetValue(id, out var buffer) && buffer.Count > 0) { @@ -7644,6 +7712,133 @@ private MarkupControl PaneContentFor(string id, string title) return control; } + /// + /// Builds a window's output control and records it — the one place a pane's + /// is made. + /// + /// It is a factory rather than two similar blocks because there really are two callers, and they are + /// not interchangeable: the main window's control is built in the constructor, before any + /// workspace exists, while every other one is built on demand. That split is why enabling selection + /// on the on-demand path alone left the main window — the pane most people are looking at — unable to + /// select anything, which is how this trap announced itself. + /// + /// + private MarkupControl NewPaneControl(string windowId) + { + var control = new MarkupControl(new List()); + control.LinkClicked += (_, e) => OnLinkClicked(windowId, e.Url); + EnableSelection(control); + _panes[windowId] = control; + return control; + } + + /// + /// Lets a pane's output be selected with the mouse and copied with ⌃C. + /// + /// The framework already does all of this and ships it switched off — + /// MarkupControl implements ISelectableControl, ICopyableControl and + /// IDragAutoScrollTarget, so drag, double-click word, triple-click line, autoscroll past the + /// pane's edge and the clipboard write (local tool and OSC 52) come from the pinned package. + /// What could not be inherited is the part that is this client's: which colours, and what happens to a + /// selection when the buffer under it moves. + /// + /// + /// Why not leave it to the terminal. Under ?1003 — which this app needs for the wheel, + /// the tab and rail clicks and pane drag-and-drop — a plain drag belongs to the application, and the + /// emulator's escape hatch (⇧-drag in kitty) selects a terminal row. On a vertical split that + /// row spans two panes and the divider between them, and since a pane is narrower than the row a + /// logical line wraps and comes back with newlines injected at the wrap points. The framework's copy + /// walks the painted cells and emits a newline only where a row is not a soft-wrap continuation, which + /// is the thing no terminal selection can do. + /// + /// + /// Rendered, not Source. Source returns the original markup lines — a reader who dragged + /// across a red word would be handed [bold #ff0000] and the tag it closes with. + /// + /// + private void EnableSelection(MarkupControl control) + { + control.EnableSelection = true; + control.CopyMode = MarkupCopyMode.Rendered; + control.SelectionBackgroundColor = ToColor(WorkspacePalette.SelectionBand(_theme)); + control.SelectionForegroundColor = ToColor(WorkspacePalette.SelectionInk(_theme)); + + // The framework's own ⌃C is switched off here and answered by this app instead (see _clipboard). + // Its handler writes straight to the system clipboard through a static helper, which is not + // something a caller can supply — so a test run would replace whatever the developer had copied, + // and the one code path that could not be injected would be the one running under test. Turning it + // off leaves one copy path rather than two. The composer keeps the framework's ⌃C: it is a + // separate modal window with its own key handling, and an editor's copy is the editor's. + control.CopyEnabled = false; + } + + /// + /// Puts the focused window's selected text on the clipboard (⌃C, and ⌃P ▸ Copy the selection). + /// + /// The focused window's, resolved the way everything else in this client resolves a window — + /// a selection lives in one pane at a time and the framework already arbitrates that, so this asks the + /// pane the keyboard is aimed at rather than hunting for whichever control happens to hold one. + /// A frozen pane is asked too: a pane someone has deliberately stopped is the one they are most likely + /// to be copying out of. + /// + /// + /// Both empty cases speak. Nothing selected is the state ⌃C is pressed in by accident; no writer at all + /// is a client that cannot copy, which is a fact about how it was built and must not look like a + /// gesture that failed. + /// + /// + private bool CopyFocusedSelection() + { + var windowId = ActiveWindowId(); + var text = PaneSelection(windowId); + if (text.Length == 0 + && _frozenPanes.TryGetValue(windowId, out var frozen) + && frozen.HasSelection) + { + text = frozen.GetSelectedText(); + } + + if (text.Length == 0) + { + RefuseCommand("nothing selected — drag across a pane's output to select it"); + return true; + } + + if (_clipboard is null) + { + RefuseCommand("no clipboard is configured, so nothing was copied"); + return true; + } + + _clipboard(text); + Notice($"copied {text.Length} characters", MessageSeverity.Info); + return true; + } + + /// + /// Drops any live selection in a pane. Called wherever the buffer under one moves: a chrome row going + /// in or coming out (the freeze bar, the away bar, the NEW divider) and the whole-buffer re-feed + /// behind the timestamp column. + /// + /// The selection is anchored to display rows, and this client mutates buffers mid-stream — so a + /// selection left alone across an insert describes rows that have shifted under it, and the highlight + /// on screen then marks text nobody dragged over. Dropping is the honest answer: a gesture whose + /// subject has moved is a gesture that is over. + /// + /// + private void ClearPaneSelection(string windowId) + { + if (_panes.TryGetValue(windowId, out var pane)) + { + pane.ClearSelection(); + } + + if (_frozenPanes.TryGetValue(windowId, out var frozen)) + { + frozen.ClearSelection(); + } + } + /// /// A window's output as the pane actually shows it: its markup control inside a scroll viewport. /// @@ -8292,6 +8487,7 @@ private MarkupControl FrozenContentFor(string windowId) var control = new MarkupControl(new List()); control.LinkClicked += (_, e) => OnLinkClicked(windowId, e.Url); + EnableSelection(control); // a frozen pane is the one people most want to copy out of _frozenPanes[windowId] = control; return control; } @@ -8991,6 +9187,21 @@ private bool RouteToInput(ConsoleKeyInfo key) return null; } + // ⌃C: copy the focused pane's selection. Here and not in MacroKeys.AppShortcuts, and the + // difference is load-bearing — a global shortcut runs ahead of *every* window, including the + // composer, whose MultilineEditControl has its own ⌃C and is a real editor. This chain belongs + // to the main window alone, so the composer keeps its copy and the panes get theirs. + // + // After DispatchMacro like everything else below it: a macro the user bound to ⌃C wins, which + // is the same relationship ⌃←/→ has with pane selection and is what lets MacroKeys.Verdict go + // on telling the truth about the chord without a special case. + if (e.KeyInfo.Modifiers == ConsoleModifiers.Control && e.KeyInfo.Key == ConsoleKey.C) + { + e.Handled = true; + CopyFocusedSelection(); + return null; + } + // Ctrl+arrows: move between panes, and at the bottom edge into the command lines. Ahead of // both the scrollback keys and recall because it is a workspace gesture rather than a move // inside one, and ahead of the command line because the bars would otherwise eat it — diff --git a/src/SharpMUTerm.Tui/WorkspacePalette.cs b/src/SharpMUTerm.Tui/WorkspacePalette.cs index 5e76eaa..a1f4087 100644 --- a/src/SharpMUTerm.Tui/WorkspacePalette.cs +++ b/src/SharpMUTerm.Tui/WorkspacePalette.cs @@ -286,6 +286,64 @@ internal static Rgb ReadingPlane(Theme theme) return Extreme(PanePlanes(theme), Surface(theme)); } + /// + /// The band a selected run of a pane's output is painted on. + /// + /// One pair per theme, not per pane, and for a reason of its own rather than + /// 's. There it is cost; here it is meaning. A pane's plane says two things + /// already — whose connection this is, in hue, and where the keyboard is, in luminance — and a + /// selection is neither. It is the client answering a gesture the user is making *now*, it exists in + /// exactly one pane at a time, and a highlight that changed colour depending on whose pane it landed in + /// would be reporting a fact nobody asked it about. + /// + /// + /// Built from — the extreme of the fourteen in the direction of travel — + /// pushed further that way, so it clears the whole band rather than the one plane it was + /// derived from, and then leaned toward . The lean is what makes it read as + /// the client's own mark instead of a pane that has gone brighter, and it is the same anchor + /// uses, which is the other place this client says "your keystrokes are + /// about this". + /// + /// + internal static Rgb SelectionBand(Theme theme) + { + ArgumentNullException.ThrowIfNull(theme); + + // Away from the panes, whichever way that is. On a dark theme the reading plane is the brightest of + // the fourteen and the selection goes brighter still; on a light one it is the darkest and the + // selection goes darker. Scaling the wrong way would land the band *inside* the band it has to + // stand clear of — the exact failure Extreme exists to describe, one step further out. + var plane = ReadingPlane(theme); + var dark = Contrast.RelativeLuminance(Surface(theme)) < LightPlaneLuminance; + return Mix(Scale(plane, dark ? SelectionScale : 1.0 / SelectionScale), theme.Prompt, SelectionTint); + } + + /// + /// The one colour every selected cell's text is painted in, held to on + /// . + /// + /// The framework's highlight replaces the foreground as well as the background on every selected cell, + /// so a world's own colours are gone for the duration and this single ink is what all selected output + /// is read in. That makes it the one place in this client where failing the floor would make text + /// unreadable rather than merely quiet. + /// + /// + internal static Rgb SelectionInk(Theme theme) + { + ArgumentNullException.ThrowIfNull(theme); + return Contrast.Legible(theme.Foreground, SelectionBand(theme)); + } + + /// + /// How far past the reading plane the selection band sits. Larger than on + /// purpose: the focus step separates a pane from its neighbours, and a selection has to separate itself + /// from a pane that may already have taken that step. + /// + private const double SelectionScale = 1.9; + + /// How much of the prompt hue the band carries — enough to be recognised, not a wash. + private const double SelectionTint = 0.35; + /// /// The same worst case for the colours the client paints in its own voice, which land on the /// backdrop and on tab chips as well as on panes — so one ink is legible wherever the chrome puts it. diff --git a/tests/SharpMUTerm.Tui.Tests/FrameContrastTests.cs b/tests/SharpMUTerm.Tui.Tests/FrameContrastTests.cs index ece0c9c..c67141c 100644 --- a/tests/SharpMUTerm.Tui.Tests/FrameContrastTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/FrameContrastTests.cs @@ -42,6 +42,11 @@ public class FrameContrastTests "", "freeze", "away", "highlight", "scrollback", "links", "connections", "tint", "tint-input", "characters", "compose", "mssp", "web", "spawn", "split", "menu", "quit", "worlds", "triggers", "logging", "startup", "history", "prefix-panel", "keypad", + + // The selection band is the one plane this client invents rather than derives from a pane, and + // its ink replaces the world's own colour on every selected cell — so it is the pair with the + // most to lose from a theme it was not measured against. + "selection", ]; public static IEnumerable<(string Theme, string View)> Cases() => diff --git a/tests/SharpMUTerm.Tui.Tests/LegiblePaletteTests.cs b/tests/SharpMUTerm.Tui.Tests/LegiblePaletteTests.cs index 1770c14..6868c82 100644 --- a/tests/SharpMUTerm.Tui.Tests/LegiblePaletteTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/LegiblePaletteTests.cs @@ -185,6 +185,64 @@ public async Task TheReadingPlaneIsTheWorstOfTheBandAndNotAMemberPickedByName() } } + /// + /// A selection has to be seen against the pane it is drawn in, and every pane is a different plane. + /// The band is one pair per theme rather than one per pane for 's + /// reason, so it has to stand clear of the whole band, not of the plane it happened to be derived from. + /// + /// The floor here is a fill against a fill and is deliberately not : + /// three to one is where text stops being invisible, and two backgrounds that differed that hard would + /// make a selected line shout. What is held to the text floor is the ink on it, below. + /// + /// + [Test] + public async Task TheSelectionBandStandsClearOfEveryPlaneAPaneCanWear() + { + var failures = new List(); + + foreach (var theme in Themes()) + { + var band = WorkspacePalette.SelectionBand(theme); + foreach (var (name, plane) in Planes(theme)) + { + var ratio = Contrast.Ratio(band, plane); + if (ratio < SelectionSeparation) + { + failures.Add($"{theme.Name}/{name}: {ratio:0.00}:1"); + } + } + } + + await Assert.That(failures).IsEmpty(); + } + + /// + /// The ink painted on that band, held to the text floor — the rule the whole file exists for, + /// applied to the one plane the client invents rather than inherits. It matters more here than + /// elsewhere: the highlight replaces the game's own foreground on every selected cell, so this single + /// colour is what all selected output is read in. + /// + [Test] + public async Task TheSelectionInkIsLegibleOnItsOwnBand() + { + foreach (var theme in Themes()) + { + var band = WorkspacePalette.SelectionBand(theme); + await Assert.That(Contrast.Ratio(WorkspacePalette.SelectionInk(theme), band)) + .IsGreaterThanOrEqualTo(Contrast.Floor) + .Because($"{theme.Name}'s selection ink must be readable on its own band"); + } + } + + /// + /// How far a selection's fill must sit from a pane's own fill to be seen as a band. Below the text + /// floor deliberately (see ) and far + /// enough above 1:1 that it cannot be a rounding artefact. The band as built clears it with room — + /// the tightest cell measured across the three themes is 2.70:1, on Dark's focused untinted pane — + /// so this is the property being asserted rather than the number that happens to hold today. + /// + private const double SelectionSeparation = 1.5; + private static Rgb ParseHex(string hex) => new( Convert.ToByte(hex.Substring(1, 2), 16), Convert.ToByte(hex.Substring(3, 2), 16), diff --git a/tests/SharpMUTerm.Tui.Tests/PaneSelectionTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneSelectionTests.cs new file mode 100644 index 0000000..645a890 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/PaneSelectionTests.cs @@ -0,0 +1,187 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// Selecting a pane's output with the mouse and copying it. +/// +/// The terminal's own selection cannot do this job: it selects a terminal row, so on a vertical +/// split a drag returns the left pane's text, the divider and the right pane's unrelated output +/// concatenated — and a pane is narrower than the row, so a logical line wraps and comes back with hard +/// newlines injected at the wrap points. Both are UrlDetector's problem one layer over: the +/// decision has to be made where the pane's line is known to end. +/// +/// +/// Every gesture here goes through the control's real ProcessMouseEvent, because the framework +/// only registers its driver-mouse handler inside Run() — which no test calls. That is the same +/// limitation SimulatePaneClick documents, and the reason a drag needs a seam of its own. +/// +/// +/// Serialised: rendering redirects the process-global Console.Out. +[NotInParallel] +public class PaneSelectionTests +{ + private const int Width = 120; + private const int Height = 32; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private static SharpMUTermApp Demo() + { + Console.SetIn(TextReader.Null); + return new SharpMUTermApp(DemoScene.Build(), Headless, new HeadlessConsoleDriver(Width, Height)); + } + + /// The main window, rendered, with a known line of output to drag across. + private static SharpMUTermApp Rendered() + { + var app = Demo(); + app.RenderSnapshot(); + return app; + } + + /// + /// The same, with somewhere for a copy to land. The writer is caller-supplied and null by default — + /// the save/logRoot/browser-launcher family — so a test that does not ask for one + /// provably leaves the real system clipboard alone. + /// + private static (SharpMUTermApp App, List Copied) WithClipboard() + { + Console.SetIn(TextReader.Null); + var copied = new List(); + var app = new SharpMUTermApp( + DemoScene.Build(), Headless, new HeadlessConsoleDriver(Width, Height), clipboard: copied.Add); + app.RenderSnapshot(); + return (app, copied); + } + + private static ConsoleKeyInfo CtrlC => + new('\0', ConsoleKey.C, shift: false, alt: false, control: true); + + [Test] + public async Task CtrlCCopiesWhatWasSelected() + { + var (app, copied) = WithClipboard(); + app.SimulatePaneDrag(SharpMUTermApp.MainWindowId, 0, 0, 25, 1); + + app.SimulateKey(CtrlC); + + await Assert.That(copied).HasSingleItem(); + await Assert.That(copied[0]).IsEqualTo(app.PaneSelection(SharpMUTermApp.MainWindowId)); + } + + /// Nothing selected is not an error, but it is not silence either. + [Test] + public async Task CtrlCWithNothingSelectedSaysSo() + { + var (app, copied) = WithClipboard(); + + app.SimulateKey(CtrlC); + + await Assert.That(copied).IsEmpty(); + await Assert.That(app.StatusMarkup).Contains("nothing selected"); + } + + /// + /// The family rule, asserted rather than assumed: an app given no writer copies nowhere and says so. + /// A test that quietly reached the real clipboard would replace whatever the developer had on it. + /// + [Test] + public async Task AnAppWithNoClipboardWriterCopiesNothingAndSaysSo() + { + var app = Rendered(); + app.SimulatePaneDrag(SharpMUTermApp.MainWindowId, 0, 0, 25, 1); + + app.SimulateKey(CtrlC); + + await Assert.That(app.StatusMarkup).Contains("no clipboard"); + } + + /// The ⌃P entry and the chord are one action, and the entry is how the chord is found at all. + [Test] + public async Task TheCommandSurfaceCopiesTheSameText() + { + var (app, copied) = WithClipboard(); + app.SimulatePaneDrag(SharpMUTermApp.MainWindowId, 0, 0, 25, 1); + + await Assert.That(app.DispatchCommand("term:copy")).IsTrue(); + + await Assert.That(copied).HasSingleItem(); + } + + [Test] + public async Task DraggingAcrossAPaneSelectsTheTextUnderThePointer() + { + var app = Rendered(); + + app.SimulatePaneDrag(SharpMUTermApp.MainWindowId, 0, 0, 12, 0); + + await Assert.That(app.PaneSelection(SharpMUTermApp.MainWindowId)).IsNotEmpty(); + } + + /// + /// What is copied is what the pane shows, not the markup behind it. Source mode would + /// hand back [bold #ff0000]…[/], which is not what anyone dragged over. + /// + [Test] + public async Task TheSelectedTextCarriesNoMarkup() + { + var app = Rendered(); + + app.SimulatePaneDrag(SharpMUTermApp.MainWindowId, 0, 0, 40, 2); + var selected = app.PaneSelection(SharpMUTermApp.MainWindowId); + + await Assert.That(selected).DoesNotContain("[/]"); + await Assert.That(selected).DoesNotContain("[bold"); + } + + /// + /// A pane with nothing selected reports nothing — the state every pane is in until a drag happens, and + /// the one the copy shortcut must not fire in. + /// + [Test] + public async Task AFreshPaneHasNoSelection() + { + var app = Rendered(); + + await Assert.That(app.PaneSelection(SharpMUTermApp.MainWindowId)).IsEmpty(); + } + + /// + /// The pin that stops this being "improved" into something that spends a cell. A selection recolours + /// cells that are already painted; if it ever gained a gutter or a marker column the pane rectangle + /// would change, and per-pane NAWS is derived from that rectangle — so dragging in a pane would + /// announce a new terminal size to every connected server and reflow the game's own output. + /// + [Test] + public async Task SelectingTextMovesNoPaneRectangle() + { + var app = Rendered(); + var before = app.PaneOutputRects(); + + app.SimulatePaneDrag(SharpMUTermApp.MainWindowId, 0, 0, 30, 3); + app.RenderWholeFrame(); + + await Assert.That(app.PaneOutputRects()).IsEquivalentTo(before); + } + + /// + /// A buffer that shifts under a live selection leaves it pointing at rows that have moved — the client + /// inserts and removes chrome rows mid-buffer (the freeze bar, the away bar, the NEW divider) + /// and repaints whole buffers when the timestamp column is toggled. The selection is dropped on those + /// paths rather than left to describe a stale grid. + /// + [Test] + public async Task RepaintingAPaneDropsASelectionThatWouldNowPointAtTheWrongRows() + { + var app = Rendered(); + app.SimulatePaneDrag(SharpMUTermApp.MainWindowId, 0, 0, 20, 1); + await Assert.That(app.PaneSelection(SharpMUTermApp.MainWindowId)).IsNotEmpty(); + + app.DispatchCommand("term:timestamps-on"); // the whole-buffer re-feed + + await Assert.That(app.PaneSelection(SharpMUTermApp.MainWindowId)).IsEmpty(); + } +} From c26538d6ef0c390fe2a60ee039025d5d83944bbe Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Thu, 13 Aug 2026 23:08:34 -0500 Subject: [PATCH 2/2] fix(panes): copy the selection that exists, and drop it wherever rows move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects from review, both real and both confirmed by a test that failed first. The copy asked the *focused pane*. Pane selection moves on ⌃arrows, ⌃O and a tab click, and a press in a pane's body moves none of them — so a drag in the pane beside the focused one left ⌃C looking elsewhere and reporting "nothing selected", which reads as a feature that does not work. It asks the window's SelectionManager now, which owns the one active selection and clears the previous owner when a new one starts. That also retires the special case for a frozen pane: one selection, one owner. The clear was at RepaintPane, which is not the only thing that re-feeds a pane — BuildFrozenContent feeds both halves and ToggleFreeze's thaw branch pours the whole buffer back. It moves to FeedRange, the one function that actually replaces a control's content. MarkupControl.SetContent does not clear a selection; only its append path does, so this cannot be left to the framework. The thaw is the case with teeth: freezing leaves the live control empty so a stale anchor yields nothing, while after a thaw the rows exist again and the new test copied "The Grand Plaza…" before the fix. PaneWindows() is added because the first cut of the non-focused-pane test passed a *pane* id to a seam that takes a *window* id and selected nothing — indistinguishable from the bug it was written to catch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN --- CLAUDE.md | 23 ++++- src/SharpMUTerm.Tui/SharpMUTermApp.cs | 89 +++++++++---------- .../PaneSelectionTests.cs | 52 +++++++++++ 3 files changed, 115 insertions(+), 49 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f697e34..9f9200c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,10 +170,25 @@ fallbacks) for inline images/maps. runs ahead of *every* window including the composer, whose `MultilineEditControl` has its own ⌃C and is a real editor. Being in the chain also puts it after `DispatchMacro`, so a macro bound to ⌃C wins — the same relationship ⌃←/→ has with pane selection, and the reason `Verdict` needs no special case. - - **A selection is dropped whenever the rows under it move**, in `RepaintPane` — the one seam that - re-feeds a pane. Chrome rows go in and out mid-buffer (the freeze bar, the away bar, `NEW`, the search - bar) and the timestamp toggle re-feeds whole buffers; a selection is anchored to display rows, so one - left alone across an insert highlights text nobody dragged over. + - **A selection is dropped whenever the rows under it move, and the clear belongs in `FeedRange`** — the + one function that actually replaces a pane control's content. `RepaintPane` is *not* the only caller: + `BuildFrozenContent` feeds both halves of a frozen pane and `ToggleFreeze`'s thaw branch pours the whole + buffer back, so clearing at the repaint site alone left freeze and thaw re-feeding under a live + selection. The thaw is the case with teeth — freezing leaves the live control empty, so a stale anchor + merely yields nothing, while after a thaw the rows exist again and ⌃C hands over real text nobody + dragged across (`FreezingAndThawingDropsTheSelectionRatherThanReAnchoringIt` copied `The Grand Plaza…` + before this moved). **`MarkupControl.SetContent` does not clear a selection** — only its append path + does (`OnContentAppended`) — so this cannot be left to the framework. + - **The copy asks the window's `SelectionManager`, never the focused pane.** That manager owns the one + active selection and clears the previous owner when a new one starts. The focused pane is the wrong + question: pane selection moves on ⌃arrows, ⌃O and a tab click, and a press in a pane's *body* moves + none of them — so a drag in the pane beside the focused one left the copy looking elsewhere and + reporting `nothing selected`, which reads as a feature that does not work. It also retires the special + case for a frozen pane: one selection, one owner, whichever control that is. + - **A pane id and a window id are different namespaces that are both strings**, and this is where that + bites: `PaneOutputRects` is keyed by *pane*, while `SimulatePaneDrag` and `PaneSelection` take a + *window*. `PaneWindows()` maps between them, and exists because a test that passed a pane id to the + drag seam selected nothing — indistinguishable from the feature being broken. - **`NewPaneControl` is the one place a pane's control is made**, and it exists because enabling selection on `PaneContentFor` alone left the **main** window — the pane most people are looking at — unable to select anything. That control is built in the constructor, before a workspace exists; every diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index 9db621b..b2ab11f 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -2703,6 +2703,16 @@ private void FeedRange(MarkupControl control, List buffer, int from, i markup.Add(Compose(buffer[start + i])); } + // Every row this control holds is being replaced, so a selection anchored to the old ones has + // nothing left to describe. Here rather than at the call sites because this is the one function + // that replaces a pane control's content — RepaintPane is not the only caller, and clearing there + // left freeze and thaw re-feeding under a live selection. It matters most on the way *back*: + // freezing leaves the live control empty, so a stale anchor merely yields nothing, but after a + // thaw the rows exist again and it hands over real text nobody dragged across. + // + // MarkupControl.SetContent does not do this itself — only its append path does + // (OnContentAppended) — so it cannot be left to the framework. + control.ClearSelection(); control.SetContent(markup); } @@ -2777,10 +2787,6 @@ private void RepaintPane(string windowId) return; } - // Every row this pane holds is about to be replaced, so a selection anchored to the old ones has - // nothing left to describe. - ClearPaneSelection(windowId); - if (_freezePoints.TryGetValue(windowId, out var point)) { var split = Math.Clamp(point, 0, buffer.Count); @@ -6843,7 +6849,7 @@ internal bool DispatchCommand(string id) ToggleSecondBar(); return true; case "term:copy": - return CopyFocusedSelection(); + return CopySelection(); case "term:messages": if (!ComposerIsInTheWay("the client messages")) { @@ -7773,13 +7779,15 @@ private void EnableSelection(MarkupControl control) } /// - /// Puts the focused window's selected text on the clipboard (⌃C, and ⌃P ▸ Copy the selection). + /// Puts the selected text on the clipboard (⌃C, and ⌃P ▸ Copy the selection). /// - /// The focused window's, resolved the way everything else in this client resolves a window — - /// a selection lives in one pane at a time and the framework already arbitrates that, so this asks the - /// pane the keyboard is aimed at rather than hunting for whichever control happens to hold one. - /// A frozen pane is asked too: a pane someone has deliberately stopped is the one they are most likely - /// to be copying out of. + /// It asks the window's SelectionManager, not the focused pane. That manager owns the one + /// active selection and clears the previous owner when a new one starts, so it is the thing that knows + /// — and the focused pane is emphatically not. Pane selection moves on ⌃arrows, ⌃O and a tab click, and + /// a press in a pane's body moves none of them, so a drag in the pane beside the focused one + /// left the copy looking in the wrong place and reporting nothing selected — a feature that appeared + /// simply not to work. It also retires the special case for a frozen pane: one selection, one owner, + /// whichever control that turns out to be. /// /// /// Both empty cases speak. Nothing selected is the state ⌃C is pressed in by accident; no writer at all @@ -7787,16 +7795,9 @@ private void EnableSelection(MarkupControl control) /// gesture that failed. /// /// - private bool CopyFocusedSelection() + private bool CopySelection() { - var windowId = ActiveWindowId(); - var text = PaneSelection(windowId); - if (text.Length == 0 - && _frozenPanes.TryGetValue(windowId, out var frozen) - && frozen.HasSelection) - { - text = frozen.GetSelectedText(); - } + var text = _window.SelectionManager.GetSelectedText() ?? string.Empty; if (text.Length == 0) { @@ -7815,30 +7816,6 @@ private bool CopyFocusedSelection() return true; } - /// - /// Drops any live selection in a pane. Called wherever the buffer under one moves: a chrome row going - /// in or coming out (the freeze bar, the away bar, the NEW divider) and the whole-buffer re-feed - /// behind the timestamp column. - /// - /// The selection is anchored to display rows, and this client mutates buffers mid-stream — so a - /// selection left alone across an insert describes rows that have shifted under it, and the highlight - /// on screen then marks text nobody dragged over. Dropping is the honest answer: a gesture whose - /// subject has moved is a gesture that is over. - /// - /// - private void ClearPaneSelection(string windowId) - { - if (_panes.TryGetValue(windowId, out var pane)) - { - pane.ClearSelection(); - } - - if (_frozenPanes.TryGetValue(windowId, out var frozen)) - { - frozen.ClearSelection(); - } - } - /// /// A window's output as the pane actually shows it: its markup control inside a scroll viewport. /// @@ -9198,7 +9175,7 @@ private bool RouteToInput(ConsoleKeyInfo key) if (e.KeyInfo.Modifiers == ConsoleModifiers.Control && e.KeyInfo.Key == ConsoleKey.C) { e.Handled = true; - CopyFocusedSelection(); + CopySelection(); return null; } @@ -9793,6 +9770,28 @@ internal PaneDragSurface PaneSnapshot() /// the claim that the reported rows exclude the chrome. /// /// + /// + /// The window in front of each realised pane, keyed by pane id. It exists because those two + /// ids are different namespaces that are both strings, and a caller holding one of them cannot use it + /// where the other is wanted: is keyed by pane, while + /// and take a window. A test that passed a + /// pane id to the drag seam simply selected nothing, which reads as a broken feature rather than a + /// mistyped argument. + /// + internal IReadOnlyDictionary PaneWindows() + { + var windows = new Dictionary(StringComparer.Ordinal); + foreach (var (paneId, _, _) in RealisedPanes()) + { + if (_workspace.Layout.FindPane(paneId)?.ActiveTab is { } windowId) + { + windows[paneId] = windowId; + } + } + + return windows; + } + internal IReadOnlyDictionary PaneOutputRects() { var rects = new Dictionary(StringComparer.Ordinal); diff --git a/tests/SharpMUTerm.Tui.Tests/PaneSelectionTests.cs b/tests/SharpMUTerm.Tui.Tests/PaneSelectionTests.cs index 645a890..1e41ec9 100644 --- a/tests/SharpMUTerm.Tui.Tests/PaneSelectionTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/PaneSelectionTests.cs @@ -99,6 +99,58 @@ public async Task AnAppWithNoClipboardWriterCopiesNothingAndSaysSo() await Assert.That(app.StatusMarkup).Contains("no clipboard"); } + /// + /// A drag in a pane the keyboard is not aimed at still copies. Pane selection is moved by + /// ⌃arrows, ⌃O and a tab click — never by a press in a pane's body — so a copy resolved through + /// ActiveWindowId looked in the wrong pane and reported nothing selected, which is the shape of + /// a feature that does not work. The framework's SelectionManager already arbitrates one + /// selection per window; asking it is asking the thing that knows. + /// + [Test] + public async Task ADragInAPaneThatDoesNotHoldTheFocusIsStillWhatGetsCopied() + { + var (app, copied) = WithClipboard(); + app.RenderSnapshot("split"); + + // By window, not by pane: the two are separate id namespaces and the drag seam takes a window. + var elsewhere = app.PaneWindows().Values.First(id => id != app.ActiveWindowId()); + + app.SimulatePaneDrag(elsewhere, 0, 0, 20, 1); + app.SimulateKey(CtrlC); + + await Assert.That(app.ActiveWindowId()).IsNotEqualTo(elsewhere); + await Assert.That(copied).HasSingleItem(); + await Assert.That(copied[0]).IsEqualTo(app.PaneSelection(elsewhere)); + } + + /// + /// Freezing rebuilds the pane into a pinned half and a live half and re-feeds both, so a selection + /// anchored to the rows before the split describes a grid that no longer exists. It is a second + /// re-feed seam beside RepaintPane, which is why the clearing lives in FeedRange — the + /// one function that actually replaces a control's content — rather than at the call sites. + /// + /// + /// Freeze is a second re-feed seam beside RepaintPane — it rebuilds the pane into a pinned half + /// and a live half and feeds both — and unfreezing pours the whole buffer back. That round trip is the + /// one with teeth: freezing alone leaves the live control empty, so a stale anchor yields + /// nothing and the client refuses for the wrong reason, while after an unfreeze the rows exist again + /// and a stale anchor hands over real text nobody dragged across. + /// + [Test] + public async Task FreezingAndThawingDropsTheSelectionRatherThanReAnchoringIt() + { + var (app, copied) = WithClipboard(); + app.SimulatePaneDrag(SharpMUTermApp.MainWindowId, 0, 0, 20, 1); + await Assert.That(app.PaneSelection(SharpMUTermApp.MainWindowId)).IsNotEmpty(); + + app.DispatchCommand("term:freeze"); + app.DispatchCommand("term:unfreeze"); + app.SimulateKey(CtrlC); + + await Assert.That(copied).IsEmpty(); + await Assert.That(app.StatusMarkup).Contains("nothing selected"); + } + /// The ⌃P entry and the chord are one action, and the entry is how the chord is found at all. [Test] public async Task TheCommandSurfaceCopiesTheSameText()