mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(tui): allow disable copy on fullscreen, ctrl + x copies selection (#8731)
This commit is contained in:
parent
1defa151e0
commit
4e49492999
15 changed files with 260 additions and 16 deletions
|
|
@ -5,6 +5,7 @@
|
|||
### Added
|
||||
|
||||
- Added environment and programmatic overrides for OSC 8 hyperlinks, inline image protocols, and truecolor terminal capabilities ([#8665](https://github.com/earendil-works/pi/issues/8665)).
|
||||
- Added a `TuiAltScreen` `copyOnSelect` option plus helpers to detect and copy the active fullscreen text selection programmatically ([#7720](https://github.com/earendil-works/pi/issues/7720)).
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
|
|
@ -657,7 +657,7 @@ if (matchesKey(data, Key.enter)) {
|
|||
2. **Width Changed or Change Above Viewport**: Clear screen and fully re-render
|
||||
3. **Normal Update**: Move the cursor to the first changed line, clear to the end, and render changed lines
|
||||
|
||||
`TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting.
|
||||
`TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and, unless `TuiAltScreenOptions.copyOnSelect` is `false`, copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting.
|
||||
|
||||
Both renderers wrap updates in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering.
|
||||
|
||||
|
|
|
|||
|
|
@ -156,6 +156,8 @@ export interface TuiAltScreenOptions {
|
|||
openUrl?: (url: string) => void;
|
||||
/** Handle an unmodified secondary-button press for clipboard paste. Currently enabled on Windows only. */
|
||||
onRightClickPaste?: () => void;
|
||||
/** Automatically copy selected text to the clipboard on mouse release (default: true). */
|
||||
copyOnSelect?: boolean;
|
||||
/**
|
||||
* Copy selected text to the system clipboard. Return `true` on success; the caller flashes
|
||||
* an error otherwise. When omitted, the selection is copied via an OSC 52 write.
|
||||
|
|
@ -200,6 +202,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
private readonly searchCurrentMatchStyle: (text: string) => string;
|
||||
private readonly openUrl?: (url: string) => void;
|
||||
private readonly onRightClickPaste?: () => void;
|
||||
private copyOnSelect: boolean;
|
||||
private readonly copySelection?: (text: string) => Promise<boolean>;
|
||||
|
||||
constructor(
|
||||
|
|
@ -223,6 +226,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`);
|
||||
this.openUrl = options.openUrl;
|
||||
this.onRightClickPaste = options.onRightClickPaste;
|
||||
this.copyOnSelect = options.copyOnSelect ?? true;
|
||||
this.copySelection = options.copySelection;
|
||||
this.addInputListener((data) => this.handleViewportInput(data));
|
||||
}
|
||||
|
|
@ -235,6 +239,26 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
return this.getPrimaryScrollView().isFollowingEnd;
|
||||
}
|
||||
|
||||
getCopyOnSelect(): boolean {
|
||||
return this.copyOnSelect;
|
||||
}
|
||||
|
||||
setCopyOnSelect(enabled: boolean): void {
|
||||
this.copyOnSelect = enabled;
|
||||
}
|
||||
|
||||
/** Whether the fullscreen viewport has a non-empty active text selection. */
|
||||
hasActiveSelection(): boolean {
|
||||
return this.getActiveSelectionText() !== undefined;
|
||||
}
|
||||
|
||||
/** Copy the active fullscreen text selection, if any, using the configured selection clipboard path. */
|
||||
async copyActiveSelectionToClipboard(): Promise<boolean> {
|
||||
const text = this.getActiveSelectionText();
|
||||
if (!text) return false;
|
||||
return this.copyTextToClipboard(text);
|
||||
}
|
||||
|
||||
setLayoutRoot(component: Component | undefined): void {
|
||||
if (this.layoutRoot === component) return;
|
||||
this.layoutRoot = component;
|
||||
|
|
@ -1008,7 +1032,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
this.requestRender();
|
||||
return;
|
||||
}
|
||||
void this.copySelectionToClipboard();
|
||||
if (this.copyOnSelect) void this.copySelectionToClipboard();
|
||||
this.requestRender();
|
||||
return;
|
||||
}
|
||||
|
|
@ -1084,14 +1108,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) };
|
||||
}
|
||||
|
||||
private async copySelectionToClipboard(): Promise<void> {
|
||||
private getActiveSelectionText(): string | undefined {
|
||||
const selection = this.getSelectionBounds();
|
||||
if (!selection) return;
|
||||
if (!selection) return undefined;
|
||||
let sourceLines: readonly string[] = this.previousScreen;
|
||||
if (selection.start.scrollView) {
|
||||
if (!this.currentLayout) return;
|
||||
if (!this.currentLayout) return undefined;
|
||||
const box = getScrollViewBox(this.currentLayout, selection.start.scrollView);
|
||||
if (!box?.scrollContentLines) return;
|
||||
if (!box?.scrollContentLines) return undefined;
|
||||
sourceLines = box.scrollContentLines;
|
||||
}
|
||||
const lines: string[] = [];
|
||||
|
|
@ -1105,7 +1129,16 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
);
|
||||
}
|
||||
const text = lines.join("\n");
|
||||
if (text.length === 0) return;
|
||||
return text.length === 0 ? undefined : text;
|
||||
}
|
||||
|
||||
private async copySelectionToClipboard(): Promise<boolean> {
|
||||
const text = this.getActiveSelectionText();
|
||||
if (!text) return false;
|
||||
return this.copyTextToClipboard(text);
|
||||
}
|
||||
|
||||
private async copyTextToClipboard(text: string): Promise<boolean> {
|
||||
// Prefer an injected clipboard implementation (native clipboard + platform tools with a
|
||||
// verified success path) when the host app provides one. A bare OSC 52 write can show
|
||||
// "Copied!" while leaving the system clipboard untouched (e.g. macOS Terminal.app, tmux
|
||||
|
|
@ -1113,10 +1146,11 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
if (this.copySelection) {
|
||||
const ok = await this.copySelection(text);
|
||||
this.flash(ok ? "Copied!" : "Copy failed");
|
||||
return;
|
||||
return ok;
|
||||
}
|
||||
this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
|
||||
this.flash("Copied!");
|
||||
return true;
|
||||
}
|
||||
|
||||
private applySearchTextHighlight(text: string, current: boolean): string {
|
||||
|
|
|
|||
|
|
@ -1000,6 +1000,67 @@ describe("TuiAltScreen", () => {
|
|||
tui.stop();
|
||||
});
|
||||
|
||||
it("leaves selections visible without copying when copyOnSelect is disabled", async () => {
|
||||
const terminal = new RecordingTerminal(20, 4);
|
||||
const copied: string[] = [];
|
||||
const tui = new TuiAltScreen(terminal, undefined, undefined, {
|
||||
copyOnSelect: false,
|
||||
copySelection: async (text) => {
|
||||
copied.push(text);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0));
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
terminal.sendInput("\x1b[<0;1;1M");
|
||||
terminal.sendInput("\x1b[<32;4;2M");
|
||||
terminal.sendInput("\x1b[<0;4;2m");
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.deepStrictEqual(copied, []);
|
||||
assert.strictEqual(tui.hasActiveSelection(), true);
|
||||
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m")));
|
||||
assert.ok(terminal.getViewport().every((line) => !line.includes("Copied!")));
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("copies an active selection programmatically", async () => {
|
||||
const terminal = new RecordingTerminal(20, 4);
|
||||
const copied: string[] = [];
|
||||
const tui = new TuiAltScreen(terminal, undefined, undefined, {
|
||||
copySelection: async (text) => {
|
||||
copied.push(text);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0));
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.strictEqual(tui.hasActiveSelection(), false);
|
||||
assert.strictEqual(await tui.copyActiveSelectionToClipboard(), false);
|
||||
|
||||
terminal.sendInput("\x1b[<0;1;1M");
|
||||
terminal.sendInput("\x1b[<32;4;2M");
|
||||
terminal.sendInput("\x1b[<0;4;2m");
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.deepStrictEqual(copied, ["alpha\nbeta"]);
|
||||
assert.strictEqual(tui.hasActiveSelection(), true);
|
||||
|
||||
copied.length = 0;
|
||||
assert.strictEqual(await tui.copyActiveSelectionToClipboard(), true);
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.deepStrictEqual(copied, ["alpha\nbeta"]);
|
||||
assert.ok(terminal.getViewport().some((line) => line.includes("Copied!")));
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("flashes an error when the injected copySelection handler fails", async () => {
|
||||
const terminal = new RecordingTerminal(20, 4);
|
||||
const tui = new TuiAltScreen(terminal, undefined, undefined, {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue