Public Access
Make the terminal's right button copy and paste, and never open a menu
Selecting output and right-clicking it opened the WebView's own context menu with Copy on it, which is correct behaviour for a web page and wrong for a terminal. What a terminal's right button has meant since the Windows console got QuickEdit, and since PuTTY shipped, is this: with something selected it copies and drops the selection, with nothing selected it pastes. No menu, no second click. The gesture can only live in the page. The selection is drawn into a canvas the host never sees, and once a terminal has focus the host's window receives no input events at all — the same reason the Ctrl+plus chords are handled there. ◆ THE CLIPBOARD IS THE HOST'S IN BOTH DIRECTIONS, AND THE READING IS WHY. navigator.clipboard.readText() is gated behind the clipboard-read permission in every Chromium, and an embedded WebView with no PermissionRequested handler answers a permission request by putting a prompt on the screen. Trading a context menu for a permission dialog is not this change. So the page asks and the host answers, exactly as requestFontSizeStep does. Writing does not go the other way either, although navigator.clipboard.writeText would have worked from the page on Windows: a clipboard that is the host's in one direction and the page's in the other is two mechanisms to keep working across WebView2, WebKitGTK and Android's WebView. The host has owned a clipboard delegate since the keychain grew its COPY buttons, so each App.axaml.cs grows a ClipboardReader beside its ClipboardWriter and MainWindowViewModel takes it. Three opcodes: client ClipboardCopy and ClipboardPaste, server Clipboard. ◆ Clipboard IS NOT Paste, AND THE DIFFERENCE IS THE BRACKETING. Paste carries a snippet off the sidebar, and the page deliberately *types* a single-line one rather than bracketing it, because bash marks a bracketed paste as an active region and a snippet should read as though it had been typed. A clipboard paste is the case those markers are for — readline uses them to decide it has been pasted into — so Clipboard is always bracketed and never followed by an Enter. One opcode for both would have needed a flag meaning "which of the two kinds of paste is this", on a payload whose existing flag byte means something else. Both client frames are dispatched before the session lookup, which is what keeps the gesture working in a pane whose shell has just ended: squinting at the message a remote left behind is exactly when somebody copies the line. Dispatch was past MA0051's length once it grew a second such branch, so the frames no pump needs to see moved into Announce() and the font-size chord went with them. ◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED. preventDefault alone suppresses the menu and still lets xterm's own contextmenu handler run, which moves its helper textarea under the pointer at z-index 1000 and leaves the selection sitting in it, selected. Both of those exist to serve the native menu. Taking the event in the capture phase on the pane and stopping it there is what keeps the gesture wholly the page's. rightClickSelectsWord is now false, which on macOS is a change of default rather than a restatement of it: xterm sets it true there alone, so a right-click would have selected the word under the pointer and copied that instead of pasting — wrong on one platform only, which is the worst way for it to be wrong. The renderer harness grows the pane it has been inviting since RendererReconnectionTests: a fake Terminal, the two addons, and a real UTF-8 codec, because both directions run through the page's own TextEncoder and TextDecoder and a Latin-1 stub would pass a test with an em dash in the selection. Its warning about declaring names terminal.js declares now covers the half that does not SyntaxError — a harness frame() was silently replaced by the page's frame() and failed six tests on a type error a long way from the cause. Manual checks 1.11 and 11.13 are what is left for a person: that a real contextmenu event reaches a capture listener, that no native menu is drawn once it is prevented, and that xterm's real paste() brackets. 11.13 also records what is unverified — whether Android's WebView delivers a pointer's secondary click as a contextmenu event at all. ClipboardReader is wired on that head regardless, because the alternative is a head where the paste half silently does nothing and nobody finds out; that head is not compiled here either, this machine having no android workload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2810f7c12b
commit
1908189100
16 files changed
+1557
-22
No files matched your search
@@ -72,6 +72,28 @@ public sealed class TerminalFontSizeStepEventArgs(int step) : EventArgs
|
||||
public int Step { get; } = step;
|
||||
}
|
||||
|
||||
/// <summary>The renderer handing over text for the system clipboard.</summary>
|
||||
/// <param name="text">
|
||||
/// The selection, exactly as the page had it. Never empty — the transport drops an empty copy rather than
|
||||
/// letting a stray gesture wipe the machine's clipboard.
|
||||
/// </param>
|
||||
public sealed class TerminalClipboardCopyEventArgs(string text) : EventArgs
|
||||
{
|
||||
/// <summary>What to put on the clipboard.</summary>
|
||||
public string Text { get; } = text;
|
||||
}
|
||||
|
||||
/// <summary>The renderer asking for the system clipboard's text.</summary>
|
||||
/// <param name="sessionId">
|
||||
/// The pane the pointer was over, which is where the text is to land. The page sends it rather than leaving
|
||||
/// the host to infer it from whichever tab it believes is selected.
|
||||
/// </param>
|
||||
public sealed class TerminalClipboardPasteEventArgs(uint sessionId) : EventArgs
|
||||
{
|
||||
/// <summary>The terminal to paste into.</summary>
|
||||
public uint SessionId { get; } = sessionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the loopback data plane and every live terminal session.
|
||||
/// </summary>
|
||||
@@ -148,6 +170,12 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
// what remembers it between launches.
|
||||
dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e);
|
||||
|
||||
// Forwarded for the same reason and decided no more than the size is. What a clipboard is and how it
|
||||
// is reached is a property of the platform the shell is running on, and this object has never seen a
|
||||
// visual.
|
||||
dataPlane.ClipboardCopyRequested += (_, e) => ClipboardCopyRequested?.Invoke(this, e);
|
||||
dataPlane.ClipboardPasteRequested += (_, e) => ClipboardPasteRequested?.Invoke(this, e);
|
||||
|
||||
// Fire-and-forget: this fires on the socket-accept thread, in the middle of the data plane's own
|
||||
// handshake handling, and has no business making that wait on however long a replay takes. See
|
||||
// ReplayAfterAttachAsync for what "replay" means and why racing the fresh page's own first frames
|
||||
@@ -313,6 +341,24 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
/// </remarks>
|
||||
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
|
||||
|
||||
/// <summary>Raised when a right-click over a selection asks for it to be copied.</summary>
|
||||
/// <remarks>
|
||||
/// The gesture can only be heard by the page, for the reason <see cref="FontSizeStepRequested"/> gives,
|
||||
/// and the selection can only be <em>read</em> there: xterm draws it into a canvas. So the text arrives
|
||||
/// here, and what a clipboard is on this platform is the shell's to know. Raised on the data plane's
|
||||
/// receive loop; marshal before touching one.
|
||||
/// </remarks>
|
||||
public event EventHandler<TerminalClipboardCopyEventArgs>? ClipboardCopyRequested;
|
||||
|
||||
/// <summary>Raised when a right-click with nothing selected asks for the clipboard to be pasted.</summary>
|
||||
/// <remarks>
|
||||
/// Answered with <see cref="SendClipboardAsync"/>, which is the other half: this event carries no text
|
||||
/// because the page has none to give — reading the clipboard is exactly what it cannot do. Raised on the
|
||||
/// data plane's receive loop; marshal before touching a view model, and before touching a clipboard,
|
||||
/// which is a UI-thread object on more than one platform.
|
||||
/// </remarks>
|
||||
public event EventHandler<TerminalClipboardPasteEventArgs>? ClipboardPasteRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Raised once a (re)attached renderer has been sent everything this workspace owns for it.
|
||||
/// </summary>
|
||||
@@ -359,6 +405,55 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
TerminalFrame.CreateFontSizePayload(pixels)),
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Hands the system clipboard's text to one pane, to be pasted into it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The answer to <see cref="ClipboardPasteRequested"/>, and the only thing that sends this frame. Always
|
||||
/// bracketed and never followed by an Enter — see <see cref="TerminalServerOpcode.Clipboard"/> for why a
|
||||
/// clipboard paste is not the same act as inserting a snippet, and why the two do not share an opcode.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refused for a dead session, exactly as <see cref="PasteAsync"/> is refused and for the weaker of its
|
||||
/// two reasons: nothing visible would happen either way, because a paste is keystrokes rather than
|
||||
/// output and there is no shell left to echo them. Checked anyway so that the answer is honest, and so
|
||||
/// that a caller which grows a message one day has something to hang it on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An empty clipboard is the caller's to notice. Sending nothing is not the same as sending an empty
|
||||
/// string — the page would bracket it and the remote would see an empty paste — and "there was nothing
|
||||
/// to paste" is a fact about the machine rather than about this terminal.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="sessionId">The terminal to paste into.</param>
|
||||
/// <param name="text">The clipboard's text. Sent verbatim.</param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <returns><see langword="false"/> when that session's shell is not running.</returns>
|
||||
public async Task<bool> SendClipboardAsync(
|
||||
uint sessionId,
|
||||
string text,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
|
||||
if (!IsSessionLive(sessionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dataPlane
|
||||
.SendAsync(
|
||||
TerminalFrame.Create(
|
||||
(byte)TerminalServerOpcode.Clipboard,
|
||||
sessionId,
|
||||
Encoding.UTF8.GetBytes(text)),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until the renderer page has attached its socket.
|
||||
/// </summary>
|
||||
|
||||
Reference in new issue
Block a user