Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
1722 lines
60 KiB
TypeScript
1722 lines
60 KiB
TypeScript
import {
|
|
AltScreenSearchComponent,
|
|
AltScreenSearchIndex,
|
|
type AltScreenSearchMatch,
|
|
getAltScreenSearchMatchKey,
|
|
} from "./alt-screen-search.ts";
|
|
import { AltScreenFlashContainer } from "./components/alt-screen-flash.ts";
|
|
import { ScrollView } from "./components/scroll-view.ts";
|
|
import { getKeybindings } from "./keybindings.ts";
|
|
import { isKeyRelease } from "./keys.ts";
|
|
import {
|
|
getLayoutBoxesAt,
|
|
getScrollbarGeometry,
|
|
getScrollViewBox,
|
|
getScrollViewsAt,
|
|
type LayoutFrame,
|
|
renderLayoutFrame,
|
|
type ScrollbarGeometry,
|
|
} from "./layout.ts";
|
|
import { getLayoutNode } from "./layout-node.ts";
|
|
import type { Terminal } from "./terminal.ts";
|
|
import {
|
|
deleteAllKittyImages,
|
|
deleteAllKittyPlacements,
|
|
deleteKittyImage,
|
|
getCapabilities,
|
|
getKittyImagePlacement,
|
|
type ImageProtocol,
|
|
isImageLine,
|
|
setCapabilities,
|
|
type TerminalCapabilities,
|
|
} from "./terminal-image.ts";
|
|
import {
|
|
type Component,
|
|
Container,
|
|
CURSOR_MARKER,
|
|
compositeTuiLine,
|
|
dispatchMouseEvent,
|
|
type OverlayHandle,
|
|
retargetMouseEvent,
|
|
TuiBase,
|
|
type TuiMouseButton,
|
|
type TuiMouseDispatchResult,
|
|
type TuiMouseDispatchTarget,
|
|
type TuiMouseEvent,
|
|
type TuiStopOptions,
|
|
VIEWPORT_TUI,
|
|
type ViewportTUI,
|
|
} from "./tui.ts";
|
|
import {
|
|
extractAnsiCode,
|
|
getGraphemeCellRange,
|
|
getOsc8LinkAtColumn,
|
|
getWordSegmenter,
|
|
sliceByColumn,
|
|
stripTerminalSequences,
|
|
truncateToWidth,
|
|
visibleWidth,
|
|
} from "./utils.ts";
|
|
|
|
const ENTER_ALT_SCREEN = "\x1b[?1049h";
|
|
const EXIT_ALT_SCREEN = "\x1b[?1049l";
|
|
const DISABLE_AUTOWRAP = "\x1b[?7l";
|
|
const ENABLE_AUTOWRAP = "\x1b[?7h";
|
|
const ENABLE_BUTTON_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h";
|
|
const ENABLE_ALL_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h";
|
|
const DISABLE_MOUSE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
|
|
const FOCUS_IN = "\x1b[I";
|
|
const FOCUS_OUT = "\x1b[O";
|
|
const BEGIN_SYNCHRONIZED_OUTPUT = "\x1b[?2026h";
|
|
const END_SYNCHRONIZED_OUTPUT = "\x1b[?2026l";
|
|
const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/;
|
|
const OSC133_PROMPT_START = /^\x1b\]133;A(?:\x07|\x1b\\)/;
|
|
const PAGE_SCROLL_OVERLAP = 4;
|
|
const ALT_WHEEL_SCROLL_MULTIPLIER = 5;
|
|
const MAX_CACHED_OFFSCREEN_KITTY_IMAGES = 16;
|
|
const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024;
|
|
const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024;
|
|
const DOUBLE_CLICK_INTERVAL_MS = 500;
|
|
// Regular mode delegates double-click selection to the terminal emulator. Fullscreen owns mouse selection,
|
|
// so mirror common terminal word-selection behavior by keeping paths and kebab-case tokens whole.
|
|
const TERMINAL_WORD_SELECTION_JOINERS = new Set(["/", "-"]);
|
|
const wordSegmenter = getWordSegmenter();
|
|
|
|
interface CachedKittyImage {
|
|
transmissionGeneration: number;
|
|
transmissionBytes: number;
|
|
estimatedDecodedBytes: number;
|
|
}
|
|
|
|
interface SelectionPoint {
|
|
row: number;
|
|
col: number;
|
|
scrollView?: ScrollView;
|
|
/** Whether this point lies between terminal cells rather than on a cell. */
|
|
boundary?: boolean;
|
|
}
|
|
|
|
interface SelectionRange {
|
|
start: SelectionPoint;
|
|
end: SelectionPoint;
|
|
}
|
|
|
|
type SelectionGranularity = "character" | "word" | "line";
|
|
|
|
interface ClickTarget {
|
|
timestamp: number;
|
|
count: number;
|
|
row: number;
|
|
scrollView?: ScrollView;
|
|
wordStart: number;
|
|
wordEnd: number;
|
|
}
|
|
|
|
interface SgrMouseEvent {
|
|
button: number;
|
|
x: number;
|
|
y: number;
|
|
release: boolean;
|
|
}
|
|
|
|
interface WheelEvent {
|
|
direction: -1 | 1;
|
|
x: number;
|
|
y: number;
|
|
button: number;
|
|
}
|
|
|
|
interface ScrollbarDrag {
|
|
scrollView: ScrollView;
|
|
grabOffset: number;
|
|
}
|
|
|
|
interface ScrollbarTarget {
|
|
scrollView: ScrollView;
|
|
geometry: ScrollbarGeometry;
|
|
}
|
|
|
|
interface ScrollToEndIndicatorRect {
|
|
row: number;
|
|
column: number;
|
|
width: number;
|
|
}
|
|
|
|
type SearchSelectionMode = "query" | "retain" | "next" | "previous";
|
|
|
|
interface ActiveSearch {
|
|
component: AltScreenSearchComponent;
|
|
index: AltScreenSearchIndex;
|
|
overlay?: OverlayHandle;
|
|
query: string;
|
|
matches: AltScreenSearchMatch[];
|
|
selectedIndex: number;
|
|
selectedKey?: string;
|
|
anchorRow: number;
|
|
selectionMode: SearchSelectionMode;
|
|
}
|
|
|
|
interface SearchHighlightRange {
|
|
startCol: number;
|
|
endCol: number;
|
|
current: boolean;
|
|
}
|
|
|
|
export interface TuiAltScreenOptions {
|
|
/** Number of logical lines moved for each mouse-wheel event. */
|
|
wheelScrollLines?: number;
|
|
/** Capture mouse events for viewport scrolling and application-owned text selection. */
|
|
mouse?: boolean;
|
|
/** Style a non-current transcript search match. */
|
|
searchMatchStyle?: (text: string) => string;
|
|
/** Style the current transcript search match. */
|
|
searchCurrentMatchStyle?: (text: string) => string;
|
|
/** Style a transcript search navigation button. */
|
|
searchNavigationButtonStyle?: (text: string, hovered: boolean) => string;
|
|
/**
|
|
* Render a clickable jump-to-end label. It is centered on the last row of a follow-end
|
|
* primary scroll view while that view is scrolled away from its end.
|
|
*/
|
|
scrollToEndIndicator?: () => string;
|
|
/** Open an OSC 8 hyperlink activated with a primary-button click. */
|
|
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.
|
|
*/
|
|
copySelection?: (text: string) => Promise<boolean>;
|
|
}
|
|
|
|
/** Alternate-screen TUI with a scrollable, application-owned viewport. */
|
|
export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|
readonly mode = "fullscreen" as const;
|
|
readonly [VIEWPORT_TUI] = true as const;
|
|
private previousScreen: string[] = [];
|
|
private lastDocument: string[] = [];
|
|
private previousScreenWidth = 0;
|
|
private previousScreenHeight = 0;
|
|
private layoutRoot: Component | undefined;
|
|
private currentLayout: LayoutFrame | undefined;
|
|
private readonly implicitDocument: Component;
|
|
private readonly implicitScrollView: ScrollView;
|
|
private readonly flashes: AltScreenFlashContainer;
|
|
private altScreenActive = false;
|
|
private imageProtocol: ImageProtocol = null;
|
|
private savedCapabilities?: TerminalCapabilities;
|
|
private readonly uploadedKittyImages = new Map<number, CachedKittyImage>();
|
|
private selectionAnchor?: SelectionPoint;
|
|
private selectionFocus?: SelectionPoint;
|
|
private selectionGranularity: SelectionGranularity = "character";
|
|
private selectionInitialRange?: SelectionRange;
|
|
private lastClick?: ClickTarget;
|
|
private selectionDragPointer?: { x: number; y: number };
|
|
private selectionAutoScrollDirection: -1 | 0 | 1 = 0;
|
|
private selectionAutoScrollTimer?: NodeJS.Timeout;
|
|
private selectionPressActive = false;
|
|
private scrollbarDrag?: ScrollbarDrag;
|
|
private scrollbarHover?: ScrollView;
|
|
private scrollToEndIndicatorRect?: ScrollToEndIndicatorRect;
|
|
private activeSearch?: ActiveSearch;
|
|
private pressedUrl?: string;
|
|
private selectionDragged = false;
|
|
private mouseCapture?: TuiMouseDispatchTarget;
|
|
private mousePressTarget?: TuiMouseDispatchTarget;
|
|
private mousePressPoint?: { x: number; y: number };
|
|
private mousePressMoved = false;
|
|
private lastComponentClick?: {
|
|
timestamp: number;
|
|
count: number;
|
|
component: Component;
|
|
x: number;
|
|
y: number;
|
|
};
|
|
private readonly wheelScrollLines: number;
|
|
private readonly mouseEnabled: boolean;
|
|
private readonly searchMatchStyle: (text: string) => string;
|
|
private readonly searchCurrentMatchStyle: (text: string) => string;
|
|
private readonly searchNavigationButtonStyle: (text: string, hovered: boolean) => string;
|
|
private readonly scrollToEndIndicator?: () => string;
|
|
private readonly openUrl?: (url: string) => void;
|
|
private readonly onRightClickPaste?: () => void;
|
|
private copyOnSelect: boolean;
|
|
private readonly copySelection?: (text: string) => Promise<boolean>;
|
|
|
|
constructor(
|
|
terminal: Terminal,
|
|
showHardwareCursor?: boolean,
|
|
logDirectory?: string,
|
|
options: TuiAltScreenOptions = {},
|
|
) {
|
|
super(terminal, showHardwareCursor, logDirectory);
|
|
this.implicitDocument = {
|
|
render: (width) => super.render(width),
|
|
handleMouse: (event) => super.handleMouse(event),
|
|
invalidate: () => {
|
|
for (const child of this.children) child.invalidate();
|
|
},
|
|
};
|
|
this.implicitScrollView = new ScrollView(this.implicitDocument, { follow: "end", primary: true });
|
|
this.flashes = new AltScreenFlashContainer(() => this.requestRender());
|
|
this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1));
|
|
this.mouseEnabled = options.mouse ?? true;
|
|
this.searchMatchStyle = options.searchMatchStyle ?? ((text) => `\x1b[4m${text}\x1b[24m`);
|
|
this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`);
|
|
this.searchNavigationButtonStyle = options.searchNavigationButtonStyle ?? ((text) => text);
|
|
this.scrollToEndIndicator = options.scrollToEndIndicator;
|
|
this.openUrl = options.openUrl;
|
|
this.onRightClickPaste = options.onRightClickPaste;
|
|
this.copyOnSelect = options.copyOnSelect ?? true;
|
|
this.copySelection = options.copySelection;
|
|
this.addInputListener((data) => this.handleViewportInput(data));
|
|
}
|
|
|
|
get viewportTop(): number {
|
|
return this.getPrimaryScrollView().scrollTop;
|
|
}
|
|
|
|
get isFollowingOutput(): boolean {
|
|
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;
|
|
this.currentLayout = undefined;
|
|
this.requestRender();
|
|
}
|
|
|
|
override render(width: number): string[] {
|
|
return this.layoutRoot?.render(width) ?? super.render(width);
|
|
}
|
|
|
|
protected override getMountedRoots(): readonly Component[] {
|
|
return this.layoutRoot ? [this.layoutRoot] : this.children;
|
|
}
|
|
|
|
private getPrimaryScrollView(): ScrollView {
|
|
return this.currentLayout?.primaryScrollView ?? this.implicitScrollView;
|
|
}
|
|
|
|
protected override beforeTerminalStart(): void {
|
|
this.stopSelectionAutoScroll();
|
|
this.selectionPressActive = false;
|
|
this.stopScrollbarHover();
|
|
this.stopScrollbarDrag();
|
|
this.flashes.dispose();
|
|
this.altScreenActive = true;
|
|
const capabilities = getCapabilities();
|
|
this.imageProtocol = capabilities.images;
|
|
this.uploadedKittyImages.clear();
|
|
if (capabilities.images === "iterm2") {
|
|
this.savedCapabilities = capabilities;
|
|
setCapabilities({ ...capabilities, images: null });
|
|
this.invalidate();
|
|
}
|
|
this.lastDocument = [];
|
|
this.selectionAnchor = undefined;
|
|
this.selectionFocus = undefined;
|
|
this.selectionGranularity = "character";
|
|
this.selectionInitialRange = undefined;
|
|
this.lastClick = undefined;
|
|
this.pressedUrl = undefined;
|
|
this.selectionDragged = false;
|
|
this.clearComponentMouseGesture();
|
|
this.lastComponentClick = undefined;
|
|
this.resetRenderState();
|
|
const term = process.env.TERM?.toLowerCase() ?? "";
|
|
// Multiplexers can lag when every pointer movement is forwarded. Button-motion
|
|
// tracking preserves clicks, wheel events, selections, and scrollbar dragging.
|
|
const mouseSequence =
|
|
process.env.TMUX !== undefined ||
|
|
process.env.ZELLIJ !== undefined ||
|
|
process.env.STY !== undefined ||
|
|
term.startsWith("tmux") ||
|
|
term.startsWith("screen")
|
|
? ENABLE_BUTTON_MOTION_MOUSE
|
|
: ENABLE_ALL_MOTION_MOUSE;
|
|
this.terminal.write(
|
|
`${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? mouseSequence : ""}\x1b[2J\x1b[H\x1b[?25l`,
|
|
);
|
|
}
|
|
|
|
protected override beforeTerminalStop(_options: TuiStopOptions): void {
|
|
this.closeSearch();
|
|
this.stopSelectionAutoScroll();
|
|
this.selectionPressActive = false;
|
|
this.stopScrollbarHover();
|
|
this.stopScrollbarDrag();
|
|
this.clearComponentMouseGesture();
|
|
this.flashes.dispose();
|
|
if (!this.altScreenActive) return;
|
|
this.terminal.write(
|
|
`${BEGIN_SYNCHRONIZED_OUTPUT}${this.deleteKittyImages()}${this.mouseEnabled ? DISABLE_MOUSE : ""}${ENABLE_AUTOWRAP}${END_SYNCHRONIZED_OUTPUT}`,
|
|
);
|
|
this.uploadedKittyImages.clear();
|
|
}
|
|
|
|
protected override afterTerminalStop(options: TuiStopOptions): void {
|
|
if (!this.altScreenActive) return;
|
|
this.altScreenActive = false;
|
|
if (options.preserveScreen) {
|
|
this.terminal.write(`${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`);
|
|
} else {
|
|
const width = Math.max(1, this.terminal.columns);
|
|
const documentLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
|
|
this.lastDocument = this.applyLineResets(documentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map(
|
|
(line) => (isImageLine(line) || visibleWidth(line) <= width ? line : sliceByColumn(line, 0, width, true)),
|
|
);
|
|
let buffer = `${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}${DISABLE_AUTOWRAP}`;
|
|
for (let row = 0; row < this.lastDocument.length; row++) {
|
|
if (row > 0) buffer += "\r\n";
|
|
buffer += `\r\x1b[2K${this.lastDocument[row] ?? ""}`;
|
|
}
|
|
buffer += `\x1b[0m${ENABLE_AUTOWRAP}\r\n\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`;
|
|
this.terminal.write(buffer);
|
|
}
|
|
if (this.savedCapabilities) {
|
|
setCapabilities(this.savedCapabilities);
|
|
this.savedCapabilities = undefined;
|
|
}
|
|
}
|
|
|
|
private deleteKittyImages(): string {
|
|
return this.imageProtocol === "kitty" ? deleteAllKittyImages() : "";
|
|
}
|
|
|
|
private prepareKittyScreen(screen: string[]): { lines: string[]; evictedImageDeletion: string } {
|
|
const visibleImageIds = new Set<number>();
|
|
const lines = screen.map((line) => {
|
|
const placement = getKittyImagePlacement(line);
|
|
if (!placement) return line;
|
|
visibleImageIds.add(placement.imageId);
|
|
|
|
const cachedImage = this.uploadedKittyImages.get(placement.imageId);
|
|
const nextCachedImage = {
|
|
transmissionGeneration: placement.transmissionGeneration,
|
|
transmissionBytes: placement.transmissionBytes,
|
|
estimatedDecodedBytes: placement.estimatedDecodedBytes,
|
|
};
|
|
if (cachedImage) this.uploadedKittyImages.delete(placement.imageId);
|
|
this.uploadedKittyImages.set(placement.imageId, nextCachedImage);
|
|
|
|
return cachedImage?.transmissionGeneration === placement.transmissionGeneration
|
|
? placement.replacementLine
|
|
: line;
|
|
});
|
|
|
|
let cachedOffscreenImageCount = 0;
|
|
let cachedOffscreenTransmissionBytes = 0;
|
|
let cachedOffscreenDecodedBytes = 0;
|
|
for (const [imageId, cachedImage] of this.uploadedKittyImages) {
|
|
if (visibleImageIds.has(imageId)) continue;
|
|
cachedOffscreenImageCount += 1;
|
|
cachedOffscreenTransmissionBytes += cachedImage.transmissionBytes;
|
|
cachedOffscreenDecodedBytes += cachedImage.estimatedDecodedBytes;
|
|
}
|
|
|
|
let evictedImageDeletion = "";
|
|
for (const [imageId, cachedImage] of this.uploadedKittyImages) {
|
|
if (
|
|
cachedOffscreenImageCount <= MAX_CACHED_OFFSCREEN_KITTY_IMAGES &&
|
|
cachedOffscreenTransmissionBytes <= MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES &&
|
|
cachedOffscreenDecodedBytes <= MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES
|
|
) {
|
|
break;
|
|
}
|
|
if (visibleImageIds.has(imageId)) continue;
|
|
evictedImageDeletion += deleteKittyImage(imageId);
|
|
this.uploadedKittyImages.delete(imageId);
|
|
cachedOffscreenImageCount -= 1;
|
|
cachedOffscreenTransmissionBytes -= cachedImage.transmissionBytes;
|
|
cachedOffscreenDecodedBytes -= cachedImage.estimatedDecodedBytes;
|
|
}
|
|
return { lines, evictedImageDeletion };
|
|
}
|
|
|
|
protected override resetRenderState(): void {
|
|
this.previousScreen = [];
|
|
this.previousScreenWidth = 0;
|
|
this.previousScreenHeight = 0;
|
|
this.currentLayout = undefined;
|
|
}
|
|
|
|
scrollBy(lines: number): void {
|
|
this.getPrimaryScrollView().scrollBy(lines);
|
|
this.requestRender();
|
|
}
|
|
|
|
scrollToTop(): void {
|
|
this.getPrimaryScrollView().scrollToStart();
|
|
this.requestRender();
|
|
}
|
|
|
|
scrollToBottom(): void {
|
|
this.getPrimaryScrollView().scrollToEnd();
|
|
this.requestRender();
|
|
}
|
|
|
|
private scrollToPrompt(direction: -1 | 1): void {
|
|
if (!this.currentLayout) return;
|
|
const scrollView = this.getPrimaryScrollView();
|
|
const lines = getScrollViewBox(this.currentLayout, scrollView)?.scrollContentLines;
|
|
if (!lines) return;
|
|
|
|
for (let row = scrollView.scrollTop + direction; row >= 0 && row < lines.length; row += direction) {
|
|
if (!OSC133_PROMPT_START.test(lines[row] ?? "")) continue;
|
|
scrollView.scrollTo(row);
|
|
this.requestRender();
|
|
return;
|
|
}
|
|
}
|
|
|
|
private toggleSearch(): void {
|
|
if (this.activeSearch) {
|
|
this.closeSearch();
|
|
return;
|
|
}
|
|
const component = new AltScreenSearchComponent(
|
|
(query) => this.updateSearchQuery(query),
|
|
this.searchNavigationButtonStyle,
|
|
);
|
|
const search: ActiveSearch = {
|
|
component,
|
|
index: new AltScreenSearchIndex(),
|
|
query: "",
|
|
matches: [],
|
|
selectedIndex: -1,
|
|
anchorRow: this.getPrimaryScrollView().scrollTop,
|
|
selectionMode: "query",
|
|
};
|
|
this.activeSearch = search;
|
|
search.overlay = this.showOverlay(component, {
|
|
anchor: "top-right",
|
|
width: "40%",
|
|
minWidth: 32,
|
|
margin: 1,
|
|
});
|
|
}
|
|
|
|
private closeSearch(): void {
|
|
const search = this.activeSearch;
|
|
if (!search) return;
|
|
this.activeSearch = undefined;
|
|
search.overlay?.hide();
|
|
this.requestRender();
|
|
}
|
|
|
|
private updateSearchQuery(query: string): void {
|
|
const search = this.activeSearch;
|
|
if (!search || query === search.query) return;
|
|
const selected = search.matches[search.selectedIndex];
|
|
search.anchorRow = selected?.segments[0]?.row ?? this.getPrimaryScrollView().scrollTop;
|
|
search.query = query;
|
|
search.selectionMode = "query";
|
|
search.component.setResult(-1, 0);
|
|
this.requestRender();
|
|
}
|
|
|
|
private navigateSearch(direction: -1 | 1): void {
|
|
const search = this.activeSearch;
|
|
if (!search?.query) return;
|
|
search.selectionMode = direction < 0 ? "previous" : "next";
|
|
this.requestRender();
|
|
}
|
|
|
|
private getSearchNavigationDirectionAt(x: number, y: number): -1 | 1 | undefined {
|
|
const search = this.activeSearch;
|
|
const bounds = search?.overlay?.getBounds();
|
|
if (!search || !bounds) return undefined;
|
|
if (x < bounds.col || x >= bounds.col + bounds.width || y < bounds.row || y >= bounds.row + bounds.height) {
|
|
return undefined;
|
|
}
|
|
return search.component.getNavigationDirectionAt(y - bounds.row, x - bounds.col);
|
|
}
|
|
|
|
private handleSearchMouseEvent(event: SgrMouseEvent): boolean {
|
|
const search = this.activeSearch;
|
|
if (!search) return false;
|
|
const direction = this.getSearchNavigationDirectionAt(event.x, event.y);
|
|
if (search.component.setHoveredNavigationDirection(direction)) this.requestRender();
|
|
if (direction === undefined || event.release || (event.button & 32) !== 0 || (event.button & 3) !== 0) {
|
|
return false;
|
|
}
|
|
this.navigateSearch(direction);
|
|
return true;
|
|
}
|
|
|
|
private refreshSearch(layout: LayoutFrame): boolean {
|
|
const search = this.activeSearch;
|
|
if (!search) return false;
|
|
const scrollView = layout.primaryScrollView ?? this.implicitScrollView;
|
|
const box = getScrollViewBox(layout, scrollView);
|
|
const lines = box?.scrollContentLines;
|
|
if (!lines || !search.query.trim()) {
|
|
search.matches = [];
|
|
search.selectedIndex = -1;
|
|
search.selectedKey = undefined;
|
|
search.selectionMode = "retain";
|
|
search.component.setResult(-1, 0);
|
|
return false;
|
|
}
|
|
|
|
const shouldRevealSelection = search.selectionMode !== "retain";
|
|
const result = search.index.search(lines, search.query);
|
|
const matches = result.matches;
|
|
search.matches = matches;
|
|
if (!result.changed && search.selectionMode === "retain") return false;
|
|
|
|
const exactIndex = result.changed
|
|
? search.selectedKey
|
|
? matches.findIndex((match) => getAltScreenSearchMatchKey(match) === search.selectedKey)
|
|
: -1
|
|
: search.selectedIndex;
|
|
let selectedIndex = -1;
|
|
if (matches.length > 0) {
|
|
if (search.selectionMode === "query") {
|
|
let low = 0;
|
|
let high = matches.length;
|
|
while (low < high) {
|
|
const middle = low + Math.floor((high - low) / 2);
|
|
if ((matches[middle]!.segments[0]?.row ?? 0) < search.anchorRow) low = middle + 1;
|
|
else high = middle;
|
|
}
|
|
selectedIndex = low < matches.length ? low : 0;
|
|
} else if (search.selectionMode === "next") {
|
|
const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1);
|
|
selectedIndex = baseIndex < 0 ? 0 : (baseIndex + 1) % matches.length;
|
|
} else if (search.selectionMode === "previous") {
|
|
const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1);
|
|
selectedIndex = baseIndex < 0 ? matches.length - 1 : (baseIndex - 1 + matches.length) % matches.length;
|
|
} else {
|
|
selectedIndex =
|
|
exactIndex >= 0 ? exactIndex : Math.min(Math.max(0, search.selectedIndex), matches.length - 1);
|
|
}
|
|
}
|
|
|
|
search.selectedIndex = selectedIndex;
|
|
search.selectedKey = selectedIndex >= 0 ? getAltScreenSearchMatchKey(matches[selectedIndex]!) : undefined;
|
|
search.selectionMode = "retain";
|
|
search.component.setResult(selectedIndex, matches.length);
|
|
if (!shouldRevealSelection) return false;
|
|
|
|
const selected = matches[selectedIndex];
|
|
const firstSegment = selected?.segments[0];
|
|
const lastSegment = selected?.segments[selected.segments.length - 1];
|
|
if (!box || !firstSegment || !lastSegment || scrollView.viewportHeight <= 0) return false;
|
|
const before = scrollView.scrollTop;
|
|
const visibleBottom = before + scrollView.viewportHeight - 1;
|
|
let target = before;
|
|
if (firstSegment.row < before || lastSegment.row > visibleBottom) {
|
|
target = firstSegment.row - Math.floor(scrollView.viewportHeight / 3);
|
|
}
|
|
scrollView.scrollTo(target, { disableFollow: true });
|
|
return scrollView.scrollTop !== before;
|
|
}
|
|
|
|
/** Show a transient message in the alternate-screen flash stack. */
|
|
flash(message: string, durationMs?: number): void {
|
|
this.flashes.flash(message, durationMs);
|
|
}
|
|
|
|
private shouldDeferViewportInputToOverlay(): boolean {
|
|
return this.isOverlayFocused() && this.activeSearch?.overlay?.isFocused() !== true;
|
|
}
|
|
|
|
private clearComponentMouseGesture(): void {
|
|
this.mouseCapture = undefined;
|
|
this.mousePressTarget = undefined;
|
|
this.mousePressPoint = undefined;
|
|
this.mousePressMoved = false;
|
|
}
|
|
|
|
private handleViewportInput(data: string): { consume?: boolean } | undefined {
|
|
if (data === FOCUS_OUT) {
|
|
const hadActiveSelection = this.selectionPressActive;
|
|
const hadNonEmptyActiveSelection = hadActiveSelection && this.getSelectionBounds() !== undefined;
|
|
this.selectionPressActive = false;
|
|
this.stopSelectionAutoScroll();
|
|
this.stopScrollbarHover();
|
|
if (this.activeSearch?.component.setHoveredNavigationDirection(undefined)) this.requestRender();
|
|
this.stopScrollbarDrag();
|
|
this.pressedUrl = undefined;
|
|
this.selectionDragged = false;
|
|
this.clearComponentMouseGesture();
|
|
this.lastComponentClick = undefined;
|
|
if (hadActiveSelection) {
|
|
this.selectionAnchor = undefined;
|
|
this.selectionFocus = undefined;
|
|
this.selectionGranularity = "character";
|
|
this.selectionInitialRange = undefined;
|
|
if (hadNonEmptyActiveSelection) this.requestRender();
|
|
}
|
|
this.lastClick = undefined;
|
|
return { consume: true };
|
|
}
|
|
if (data === FOCUS_IN) return { consume: true };
|
|
|
|
const wheelEvent = this.parseWheelEvent(data);
|
|
if (wheelEvent) {
|
|
const event = this.createMouseEvent("wheel", wheelEvent.button, wheelEvent.x, wheelEvent.y, {
|
|
wheelDelta: wheelEvent.direction * this.getWheelScrollLines(wheelEvent.button),
|
|
});
|
|
const overlay = this.dispatchMouseToOverlay(event);
|
|
const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(event));
|
|
if (result) {
|
|
if (this.applyMouseDispatchResult(event, result)) this.requestRender();
|
|
return { consume: true };
|
|
}
|
|
if (this.shouldDeferViewportInputToOverlay()) return undefined;
|
|
this.routeWheel(wheelEvent);
|
|
return { consume: true };
|
|
}
|
|
const mouseEvent = this.parseSgrMouseEvent(data);
|
|
if (mouseEvent) {
|
|
this.handleMouseEvent(mouseEvent);
|
|
return { consume: true };
|
|
}
|
|
if (this.isMouseSequence(data)) return { consume: true };
|
|
|
|
const keybindings = getKeybindings();
|
|
const isRelease = isKeyRelease(data);
|
|
if (keybindings.matches(data, "tui.altScreen.search")) {
|
|
if (!isRelease) this.toggleSearch();
|
|
return { consume: true };
|
|
}
|
|
if (this.activeSearch?.overlay?.isFocused()) {
|
|
if (keybindings.matches(data, "tui.altScreen.searchNext")) {
|
|
if (!isRelease) this.navigateSearch(1);
|
|
return { consume: true };
|
|
}
|
|
if (keybindings.matches(data, "tui.altScreen.searchPrevious")) {
|
|
if (!isRelease) this.navigateSearch(-1);
|
|
return { consume: true };
|
|
}
|
|
if (keybindings.matches(data, "tui.altScreen.searchClose")) {
|
|
if (!isRelease) this.closeSearch();
|
|
return { consume: true };
|
|
}
|
|
}
|
|
if (this.shouldDeferViewportInputToOverlay()) return undefined;
|
|
if (keybindings.matches(data, "tui.altScreen.pageUp")) {
|
|
if (!isRelease) {
|
|
this.scrollBy(-Math.max(1, this.getPrimaryScrollView().viewportHeight - PAGE_SCROLL_OVERLAP));
|
|
}
|
|
return { consume: true };
|
|
}
|
|
if (keybindings.matches(data, "tui.altScreen.pageDown")) {
|
|
if (!isRelease) {
|
|
this.scrollBy(Math.max(1, this.getPrimaryScrollView().viewportHeight - PAGE_SCROLL_OVERLAP));
|
|
}
|
|
return { consume: true };
|
|
}
|
|
if (keybindings.matches(data, "tui.altScreen.halfPageUp")) {
|
|
if (!isRelease) this.scrollBy(-Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2)));
|
|
return { consume: true };
|
|
}
|
|
if (keybindings.matches(data, "tui.altScreen.halfPageDown")) {
|
|
if (!isRelease) this.scrollBy(Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2)));
|
|
return { consume: true };
|
|
}
|
|
if (keybindings.matches(data, "tui.altScreen.lineUp")) {
|
|
if (!isRelease) this.scrollBy(-1);
|
|
return { consume: true };
|
|
}
|
|
if (keybindings.matches(data, "tui.altScreen.lineDown")) {
|
|
if (!isRelease) this.scrollBy(1);
|
|
return { consume: true };
|
|
}
|
|
if (keybindings.matches(data, "tui.altScreen.previousPrompt")) {
|
|
if (!isRelease) this.scrollToPrompt(-1);
|
|
return { consume: true };
|
|
}
|
|
if (keybindings.matches(data, "tui.altScreen.nextPrompt")) {
|
|
if (!isRelease) this.scrollToPrompt(1);
|
|
return { consume: true };
|
|
}
|
|
if (keybindings.matches(data, "tui.altScreen.top")) {
|
|
if (!isRelease) this.scrollToTop();
|
|
return { consume: true };
|
|
}
|
|
if (keybindings.matches(data, "tui.altScreen.bottom")) {
|
|
if (!isRelease) this.scrollToBottom();
|
|
return { consume: true };
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private decodeMouseButton(button: number): TuiMouseButton {
|
|
switch (button & 3) {
|
|
case 0:
|
|
return "left";
|
|
case 1:
|
|
return "middle";
|
|
case 2:
|
|
return "right";
|
|
default:
|
|
return "none";
|
|
}
|
|
}
|
|
|
|
private createMouseEvent(
|
|
type: TuiMouseEvent["type"],
|
|
button: number,
|
|
x: number,
|
|
y: number,
|
|
extra: Partial<Pick<TuiMouseEvent, "wheelDelta" | "clickCount">> = {},
|
|
): TuiMouseEvent {
|
|
return {
|
|
type,
|
|
button: type === "wheel" ? "none" : this.decodeMouseButton(button),
|
|
x,
|
|
y,
|
|
screenX: x,
|
|
screenY: y,
|
|
width: Math.max(1, this.terminal.columns),
|
|
height: Math.max(1, this.terminal.rows),
|
|
shift: (button & 4) !== 0,
|
|
alt: (button & 8) !== 0,
|
|
ctrl: (button & 16) !== 0,
|
|
...(extra.wheelDelta === undefined ? {} : { wheelDelta: extra.wheelDelta }),
|
|
...(extra.clickCount === undefined ? {} : { clickCount: extra.clickCount }),
|
|
};
|
|
}
|
|
|
|
private dispatchMouseToLayout(event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
|
|
if (!this.currentLayout) return undefined;
|
|
const visited = new Set<Component>();
|
|
const boxes = getLayoutBoxesAt(this.currentLayout, event.screenX, event.screenY);
|
|
for (const box of boxes) {
|
|
if (visited.has(box.component)) continue;
|
|
if (getLayoutNode(box.component) && box.component.handleMouse === Container.prototype.handleMouse) continue;
|
|
visited.add(box.component);
|
|
const result = dispatchMouseEvent(box.component, {
|
|
...event,
|
|
x: event.screenX - box.rect.x,
|
|
y: event.screenY - box.rect.y,
|
|
width: box.rect.width,
|
|
height: box.rect.height,
|
|
});
|
|
if (result) return result;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private applyMouseDispatchResult(event: TuiMouseEvent, result: TuiMouseDispatchResult): boolean {
|
|
const focusTarget = this.resolveMouseFocusTarget(result.focusTarget ?? result.target.component);
|
|
const focusChanged = result.focus === true && this.getFocusedComponent() !== focusTarget;
|
|
if (result.focus) this.setFocus(focusTarget);
|
|
if (result.capture) this.mouseCapture = result.target;
|
|
return (
|
|
result.render ??
|
|
(focusChanged ||
|
|
event.type === "press" ||
|
|
event.type === "click" ||
|
|
event.type === "drag" ||
|
|
event.type === "wheel")
|
|
);
|
|
}
|
|
|
|
private dispatchMouseToTarget(
|
|
event: TuiMouseEvent,
|
|
target: TuiMouseDispatchTarget,
|
|
): TuiMouseDispatchResult | undefined {
|
|
return dispatchMouseEvent(target.component, retargetMouseEvent(event, target));
|
|
}
|
|
|
|
private getComponentClickCount(target: TuiMouseDispatchTarget, x: number, y: number): number {
|
|
const now = Date.now();
|
|
const previous = this.lastComponentClick;
|
|
const count =
|
|
previous &&
|
|
now - previous.timestamp <= DOUBLE_CLICK_INTERVAL_MS &&
|
|
previous.component === target.component &&
|
|
previous.x === x &&
|
|
previous.y === y
|
|
? (previous.count % 3) + 1
|
|
: 1;
|
|
this.lastComponentClick = { timestamp: now, count, component: target.component, x, y };
|
|
return count;
|
|
}
|
|
|
|
private clearTextSelection(): void {
|
|
this.stopSelectionAutoScroll();
|
|
this.selectionPressActive = false;
|
|
this.selectionAnchor = undefined;
|
|
this.selectionFocus = undefined;
|
|
this.selectionGranularity = "character";
|
|
this.selectionInitialRange = undefined;
|
|
this.pressedUrl = undefined;
|
|
this.selectionDragged = false;
|
|
}
|
|
|
|
private handleMouseEvent(raw: SgrMouseEvent): void {
|
|
const isMotion = (raw.button & 32) !== 0;
|
|
const type: TuiMouseEvent["type"] = raw.release
|
|
? "release"
|
|
: isMotion
|
|
? this.decodeMouseButton(raw.button) === "none"
|
|
? "move"
|
|
: "drag"
|
|
: "press";
|
|
const event = this.createMouseEvent(type, raw.button, raw.x, raw.y);
|
|
|
|
if (this.mouseCapture || this.mousePressTarget) {
|
|
const target = this.mouseCapture ?? this.mousePressTarget!;
|
|
if (this.mousePressPoint && (raw.x !== this.mousePressPoint.x || raw.y !== this.mousePressPoint.y)) {
|
|
this.mousePressMoved = true;
|
|
this.lastComponentClick = undefined;
|
|
}
|
|
let render = false;
|
|
const targetResult = this.dispatchMouseToTarget(event, target);
|
|
if (targetResult) render = this.applyMouseDispatchResult(event, targetResult);
|
|
if (raw.release) {
|
|
if (!this.mousePressMoved && this.mousePressPoint?.x === raw.x && this.mousePressPoint.y === raw.y) {
|
|
const clickEvent = this.createMouseEvent("click", raw.button, raw.x, raw.y, {
|
|
clickCount: this.getComponentClickCount(target, raw.x, raw.y),
|
|
});
|
|
const clickResult = this.dispatchMouseToTarget(clickEvent, target);
|
|
if (clickResult) render = this.applyMouseDispatchResult(clickEvent, clickResult) || render;
|
|
}
|
|
this.clearComponentMouseGesture();
|
|
}
|
|
if (render) this.requestRender();
|
|
return;
|
|
}
|
|
|
|
if (this.handleSearchMouseEvent(raw)) return;
|
|
|
|
const overlay = this.dispatchMouseToOverlay(event);
|
|
if (!overlay.hit) {
|
|
if (this.handleScrollToEndIndicatorMouseEvent(raw)) return;
|
|
const scrollbarHandled = this.handleScrollbarMouseEvent(raw);
|
|
if (!this.scrollbarDrag) this.updateScrollbarHover(raw.x, raw.y);
|
|
if (scrollbarHandled) return;
|
|
} else {
|
|
this.stopScrollbarHover();
|
|
}
|
|
|
|
const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(event));
|
|
if (result) {
|
|
const render = this.applyMouseDispatchResult(event, result);
|
|
if (type === "press") {
|
|
this.clearTextSelection();
|
|
this.mousePressTarget = result.target;
|
|
this.mousePressPoint = { x: raw.x, y: raw.y };
|
|
this.mousePressMoved = false;
|
|
}
|
|
if (render) this.requestRender();
|
|
return;
|
|
}
|
|
|
|
if (this.handleRightClickPaste(raw)) return;
|
|
this.handleSelectionMouseEvent(raw);
|
|
}
|
|
|
|
private parseWheelEvent(data: string): WheelEvent | undefined {
|
|
const sgr = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
|
|
if (sgr) {
|
|
const button = Number.parseInt(sgr[1], 10);
|
|
if ((button & 64) === 0) return undefined;
|
|
const direction = button & 3;
|
|
if (direction !== 0 && direction !== 1) return undefined;
|
|
return {
|
|
direction: direction === 0 ? -1 : 1,
|
|
x: Number.parseInt(sgr[2], 10) - 1,
|
|
y: Number.parseInt(sgr[3], 10) - 1,
|
|
button,
|
|
};
|
|
}
|
|
if (data.length === 6 && data.startsWith("\x1b[M")) {
|
|
const button = data.charCodeAt(3) - 32;
|
|
if ((button & 64) === 0) return undefined;
|
|
const direction = button & 3;
|
|
if (direction !== 0 && direction !== 1) return undefined;
|
|
return {
|
|
direction: direction === 0 ? -1 : 1,
|
|
x: data.charCodeAt(4) - 33,
|
|
y: data.charCodeAt(5) - 33,
|
|
button,
|
|
};
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private getWheelScrollLines(button: number): number {
|
|
// SGR mouse button codes use bit 3 (value 8) for the Alt modifier.
|
|
return (button & 8) !== 0 ? this.wheelScrollLines * ALT_WHEEL_SCROLL_MULTIPLIER : this.wheelScrollLines;
|
|
}
|
|
|
|
private routeWheel(event: WheelEvent): void {
|
|
let remaining = event.direction * this.getWheelScrollLines(event.button);
|
|
const seen = new Set<ScrollView>();
|
|
for (const scrollView of this.currentLayout ? getScrollViewsAt(this.currentLayout, event.x, event.y) : []) {
|
|
seen.add(scrollView);
|
|
remaining = scrollView.scrollBy(remaining);
|
|
if (remaining === 0 || scrollView.overscroll === "contain") break;
|
|
}
|
|
const primary = this.getPrimaryScrollView();
|
|
if (remaining !== 0 && !seen.has(primary)) primary.scrollBy(remaining);
|
|
this.updateScrollbarHover(event.x, event.y);
|
|
this.requestRender();
|
|
}
|
|
|
|
private parseSgrMouseEvent(data: string): SgrMouseEvent | undefined {
|
|
const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data);
|
|
if (!match) return undefined;
|
|
return {
|
|
button: Number.parseInt(match[1], 10),
|
|
x: Number.parseInt(match[2], 10) - 1,
|
|
y: Number.parseInt(match[3], 10) - 1,
|
|
release: match[4] === "m",
|
|
};
|
|
}
|
|
|
|
private handleRightClickPaste(event: SgrMouseEvent): boolean {
|
|
if (
|
|
!this.onRightClickPaste ||
|
|
process.platform !== "win32" ||
|
|
process.env.TERM_PROGRAM?.toLowerCase() === "vscode" ||
|
|
event.release ||
|
|
event.button !== 2
|
|
) {
|
|
return false;
|
|
}
|
|
try {
|
|
this.onRightClickPaste();
|
|
} catch {
|
|
// Clipboard paste is best-effort.
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private handleScrollToEndIndicatorMouseEvent(event: SgrMouseEvent): boolean {
|
|
const rect = this.scrollToEndIndicatorRect;
|
|
if (!rect || event.release || (event.button & 32) !== 0 || (event.button & 3) !== 0) return false;
|
|
if (event.y !== rect.row || event.x < rect.column || event.x >= rect.column + rect.width) return false;
|
|
this.scrollToBottom();
|
|
return true;
|
|
}
|
|
|
|
private getScrollbarTargetAt(x: number, y: number, includeHiddenAuto = false): ScrollbarTarget | undefined {
|
|
if (this.hasOverlay() || !this.currentLayout) return undefined;
|
|
for (const scrollView of getScrollViewsAt(this.currentLayout, x, y)) {
|
|
const box = getScrollViewBox(this.currentLayout, scrollView);
|
|
const geometry = box ? getScrollbarGeometry(box, includeHiddenAuto) : undefined;
|
|
if (
|
|
geometry &&
|
|
x === geometry.column &&
|
|
y >= geometry.trackTop &&
|
|
y < geometry.trackTop + geometry.trackHeight
|
|
) {
|
|
return { scrollView, geometry };
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private setScrollbarHover(scrollView: ScrollView | undefined): void {
|
|
if (scrollView === this.scrollbarHover) return;
|
|
this.scrollbarHover?.setScrollbarActive(false);
|
|
this.scrollbarHover = scrollView;
|
|
this.scrollbarHover?.setScrollbarActive(true);
|
|
}
|
|
|
|
private updateScrollbarHover(x: number, y: number): void {
|
|
this.setScrollbarHover(this.getScrollbarTargetAt(x, y, true)?.scrollView);
|
|
}
|
|
|
|
private stopScrollbarHover(): void {
|
|
this.setScrollbarHover(undefined);
|
|
}
|
|
|
|
private scrollScrollbarToPointer(
|
|
scrollView: ScrollView,
|
|
geometry: ScrollbarGeometry,
|
|
pointerY: number,
|
|
grabOffset: number,
|
|
): void {
|
|
const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight;
|
|
const thumbOffset = Math.max(0, Math.min(maxThumbOffset, pointerY - geometry.trackTop - grabOffset));
|
|
const scrollTop = maxThumbOffset === 0 ? 0 : Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop);
|
|
scrollView.scrollTo(scrollTop);
|
|
}
|
|
|
|
private handleScrollbarMouseEvent(event: SgrMouseEvent): boolean {
|
|
if (this.scrollbarDrag) {
|
|
if (event.release) {
|
|
this.stopScrollbarDrag();
|
|
return true;
|
|
}
|
|
const box = this.currentLayout
|
|
? getScrollViewBox(this.currentLayout, this.scrollbarDrag.scrollView)
|
|
: undefined;
|
|
const geometry = box ? getScrollbarGeometry(box) : undefined;
|
|
if (geometry) {
|
|
this.scrollScrollbarToPointer(
|
|
this.scrollbarDrag.scrollView,
|
|
geometry,
|
|
event.y,
|
|
this.scrollbarDrag.grabOffset,
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (event.release || (event.button & 32) !== 0 || (event.button & 3) !== 0) return false;
|
|
const target = this.getScrollbarTargetAt(event.x, event.y);
|
|
if (!target) return false;
|
|
this.stopSelectionAutoScroll();
|
|
this.selectionPressActive = false;
|
|
this.selectionAnchor = undefined;
|
|
this.selectionFocus = undefined;
|
|
this.selectionGranularity = "character";
|
|
this.selectionInitialRange = undefined;
|
|
this.lastClick = undefined;
|
|
this.pressedUrl = undefined;
|
|
this.selectionDragged = false;
|
|
this.setScrollbarHover(target.scrollView);
|
|
const onThumb =
|
|
event.y >= target.geometry.thumbTop && event.y < target.geometry.thumbTop + target.geometry.thumbHeight;
|
|
const grabOffset = onThumb ? event.y - target.geometry.thumbTop : Math.floor(target.geometry.thumbHeight / 2);
|
|
if (!onThumb) this.scrollScrollbarToPointer(target.scrollView, target.geometry, event.y, grabOffset);
|
|
this.scrollbarDrag = {
|
|
scrollView: target.scrollView,
|
|
grabOffset,
|
|
};
|
|
return true;
|
|
}
|
|
|
|
private stopScrollbarDrag(): void {
|
|
this.scrollbarDrag = undefined;
|
|
}
|
|
|
|
private getScrollSelectionPoint(scrollView: ScrollView, x: number, y: number): SelectionPoint | undefined {
|
|
if (!this.currentLayout) return undefined;
|
|
const box = getScrollViewBox(this.currentLayout, scrollView);
|
|
if (!box || box.rect.height <= 0 || box.clip.height <= 0) return undefined;
|
|
const visibleTop = Math.max(0, box.rect.y, box.clip.y);
|
|
const visibleBottom = Math.min(
|
|
this.terminal.rows - 1,
|
|
box.rect.y + box.rect.height - 1,
|
|
box.clip.y + box.clip.height - 1,
|
|
);
|
|
if (visibleBottom < visibleTop) return undefined;
|
|
const pointerRow = Math.max(visibleTop, Math.min(visibleBottom, y));
|
|
const maxContentRow = Math.max(0, (box.scrollContentLines?.length ?? 1) - 1);
|
|
return {
|
|
row: Math.max(0, Math.min(maxContentRow, scrollView.scrollTop + pointerRow - box.rect.y)),
|
|
col: Math.max(0, Math.min(box.rect.width - 1, x - box.rect.x)),
|
|
scrollView,
|
|
};
|
|
}
|
|
|
|
private getSelectionPoint(event: SgrMouseEvent, scrollView?: ScrollView): SelectionPoint {
|
|
if (scrollView) {
|
|
const point = this.getScrollSelectionPoint(scrollView, event.x, event.y);
|
|
if (point) return point;
|
|
}
|
|
return {
|
|
row: Math.max(0, Math.min(this.terminal.rows - 1, event.y)),
|
|
col: Math.max(0, Math.min(this.terminal.columns - 1, event.x)),
|
|
};
|
|
}
|
|
|
|
private getSelectionSourceLine(point: SelectionPoint): string {
|
|
if (point.scrollView && this.currentLayout) {
|
|
const lines = getScrollViewBox(this.currentLayout, point.scrollView)?.scrollContentLines;
|
|
if (lines) return lines[point.row] ?? "";
|
|
}
|
|
return this.previousScreen[point.row] ?? "";
|
|
}
|
|
|
|
private getWordSelection(point: SelectionPoint): SelectionRange | undefined {
|
|
const line = stripTerminalSequences(this.getSelectionSourceLine(point));
|
|
const segments: Array<{ start: number; end: number; selectable: boolean; joiner: boolean }> = [];
|
|
let start = 0;
|
|
for (const segment of wordSegmenter.segment(line)) {
|
|
const end = start + visibleWidth(segment.segment);
|
|
const joiner = TERMINAL_WORD_SELECTION_JOINERS.has(segment.segment);
|
|
segments.push({ start, end, selectable: segment.isWordLike === true || joiner, joiner });
|
|
start = end;
|
|
}
|
|
const clickedSegmentIndex = segments.findIndex(
|
|
(segment) => point.col >= segment.start && point.col < segment.end,
|
|
);
|
|
if (clickedSegmentIndex < 0) return undefined;
|
|
|
|
const canJoin = (
|
|
left: { selectable: boolean; joiner: boolean },
|
|
right: { selectable: boolean; joiner: boolean },
|
|
): boolean => left.selectable && right.selectable && (left.joiner || right.joiner);
|
|
let selectionStart = segments[clickedSegmentIndex].start;
|
|
let selectionEnd = segments[clickedSegmentIndex].end;
|
|
for (let index = clickedSegmentIndex; index > 0 && canJoin(segments[index - 1], segments[index]); index--) {
|
|
selectionStart = segments[index - 1].start;
|
|
}
|
|
for (
|
|
let index = clickedSegmentIndex;
|
|
index < segments.length - 1 && canJoin(segments[index], segments[index + 1]);
|
|
index++
|
|
) {
|
|
selectionEnd = segments[index + 1].end;
|
|
}
|
|
return {
|
|
start: { ...point, col: selectionStart },
|
|
end: { ...point, col: selectionEnd, boundary: true },
|
|
};
|
|
}
|
|
|
|
private getLineSelection(point: SelectionPoint): SelectionRange {
|
|
return {
|
|
start: { ...point, col: 0 },
|
|
end: { ...point, col: visibleWidth(this.getSelectionSourceLine(point)), boundary: true },
|
|
};
|
|
}
|
|
|
|
private updateSelectionFocus(point: SelectionPoint): void {
|
|
if (this.selectionGranularity === "character" || !this.selectionInitialRange) {
|
|
this.selectionFocus = point;
|
|
return;
|
|
}
|
|
const range = this.selectionGranularity === "word" ? this.getWordSelection(point) : this.getLineSelection(point);
|
|
if (!range) return;
|
|
const initial = this.selectionInitialRange;
|
|
const targetBeforeInitial =
|
|
range.start.row < initial.start.row ||
|
|
(range.start.row === initial.start.row && range.start.col < initial.start.col);
|
|
if (targetBeforeInitial) {
|
|
this.selectionAnchor = initial.end;
|
|
this.selectionFocus = range.start;
|
|
} else {
|
|
this.selectionAnchor = initial.start;
|
|
this.selectionFocus = range.end;
|
|
}
|
|
}
|
|
|
|
private getClickCount(point: SelectionPoint, word: SelectionRange | undefined): number {
|
|
const now = Date.now();
|
|
const previous = this.lastClick;
|
|
const count =
|
|
word &&
|
|
previous &&
|
|
now - previous.timestamp <= DOUBLE_CLICK_INTERVAL_MS &&
|
|
previous.row === point.row &&
|
|
previous.scrollView === point.scrollView &&
|
|
previous.wordStart === word.start.col &&
|
|
previous.wordEnd === word.end.col
|
|
? (previous.count % 3) + 1
|
|
: 1;
|
|
this.lastClick = word
|
|
? {
|
|
timestamp: now,
|
|
count,
|
|
row: point.row,
|
|
scrollView: point.scrollView,
|
|
wordStart: word.start.col,
|
|
wordEnd: word.end.col,
|
|
}
|
|
: undefined;
|
|
return count;
|
|
}
|
|
|
|
private updateSelectionAutoScroll(event: SgrMouseEvent): void {
|
|
const scrollView = this.selectionAnchor?.scrollView;
|
|
if (!scrollView || !this.currentLayout) {
|
|
this.stopSelectionAutoScroll();
|
|
return;
|
|
}
|
|
const box = getScrollViewBox(this.currentLayout, scrollView);
|
|
if (!box || box.rect.height <= 0 || box.clip.height <= 0) {
|
|
this.stopSelectionAutoScroll();
|
|
return;
|
|
}
|
|
const visibleTop = Math.max(0, box.rect.y, box.clip.y);
|
|
const visibleBottom = Math.min(
|
|
this.terminal.rows - 1,
|
|
box.rect.y + box.rect.height - 1,
|
|
box.clip.y + box.clip.height - 1,
|
|
);
|
|
this.selectionDragPointer = { x: event.x, y: event.y };
|
|
this.selectionAutoScrollDirection = event.y <= visibleTop ? -1 : event.y >= visibleBottom ? 1 : 0;
|
|
if (this.selectionAutoScrollDirection === 0) {
|
|
this.stopSelectionAutoScroll();
|
|
return;
|
|
}
|
|
if (this.selectionAutoScrollTimer) return;
|
|
this.selectionAutoScrollTimer = setInterval(() => this.autoScrollSelection(), 50);
|
|
this.selectionAutoScrollTimer.unref();
|
|
}
|
|
|
|
private autoScrollSelection(): void {
|
|
const scrollView = this.selectionAnchor?.scrollView;
|
|
const pointer = this.selectionDragPointer;
|
|
const direction = this.selectionAutoScrollDirection;
|
|
if (!scrollView || !pointer || direction === 0) {
|
|
this.stopSelectionAutoScroll();
|
|
return;
|
|
}
|
|
const remaining = scrollView.scrollBy(direction);
|
|
if (remaining === direction) {
|
|
this.stopSelectionAutoScroll();
|
|
return;
|
|
}
|
|
const point = this.getScrollSelectionPoint(scrollView, pointer.x, pointer.y);
|
|
if (point) this.updateSelectionFocus(point);
|
|
this.requestRender();
|
|
}
|
|
|
|
private stopSelectionAutoScroll(): void {
|
|
if (this.selectionAutoScrollTimer) {
|
|
clearInterval(this.selectionAutoScrollTimer);
|
|
this.selectionAutoScrollTimer = undefined;
|
|
}
|
|
this.selectionAutoScrollDirection = 0;
|
|
this.selectionDragPointer = undefined;
|
|
}
|
|
|
|
private handleSelectionMouseEvent(event: SgrMouseEvent): void {
|
|
const button = event.button & 3;
|
|
if (button !== 0 && !(event.release && button === 3)) return;
|
|
const anchorScrollView = this.selectionAnchor?.scrollView;
|
|
const point = this.getSelectionPoint(event, anchorScrollView);
|
|
if (event.release) {
|
|
if (!this.selectionPressActive) return;
|
|
this.selectionPressActive = false;
|
|
this.stopSelectionAutoScroll();
|
|
if (!this.selectionAnchor) return;
|
|
this.updateSelectionFocus(point);
|
|
const isClick =
|
|
!this.selectionDragged &&
|
|
this.selectionAnchor.scrollView === point.scrollView &&
|
|
this.selectionAnchor.row === point.row &&
|
|
this.selectionAnchor.col === point.col;
|
|
const clickedUrl = isClick ? this.pressedUrl : undefined;
|
|
this.pressedUrl = undefined;
|
|
if (clickedUrl && this.openUrl) {
|
|
this.selectionAnchor = undefined;
|
|
this.selectionFocus = undefined;
|
|
try {
|
|
this.openUrl(clickedUrl);
|
|
} catch {
|
|
// URL activation is best-effort.
|
|
}
|
|
this.requestRender();
|
|
return;
|
|
}
|
|
if (isClick) {
|
|
const clickEvent = this.createMouseEvent("click", event.button, event.x, event.y, {
|
|
clickCount: this.lastClick?.count ?? 1,
|
|
});
|
|
const overlay = this.dispatchMouseToOverlay(clickEvent);
|
|
const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(clickEvent));
|
|
if (result) {
|
|
const render = this.applyMouseDispatchResult(clickEvent, result);
|
|
this.clearTextSelection();
|
|
if (render) this.requestRender();
|
|
return;
|
|
}
|
|
}
|
|
if (this.copyOnSelect) void this.copySelectionToClipboard();
|
|
this.requestRender();
|
|
return;
|
|
}
|
|
if ((event.button & 32) !== 0) {
|
|
if (!this.selectionPressActive || !this.selectionAnchor) return;
|
|
this.selectionDragged = true;
|
|
this.lastClick = undefined;
|
|
this.pressedUrl = undefined;
|
|
this.updateSelectionFocus(point);
|
|
this.updateSelectionAutoScroll(event);
|
|
this.requestRender();
|
|
return;
|
|
}
|
|
this.stopSelectionAutoScroll();
|
|
this.selectionPressActive = true;
|
|
const scrollView =
|
|
!this.hasOverlay() && this.currentLayout
|
|
? getScrollViewsAt(this.currentLayout, event.x, event.y)[0]
|
|
: undefined;
|
|
const anchor = this.getSelectionPoint(event, scrollView);
|
|
const word = this.getWordSelection(anchor);
|
|
const clickCount = this.getClickCount(anchor, word);
|
|
const range = clickCount === 2 ? word : clickCount === 3 ? this.getLineSelection(anchor) : undefined;
|
|
this.selectionGranularity = range ? (clickCount === 2 ? "word" : "line") : "character";
|
|
this.selectionInitialRange = range;
|
|
this.selectionAnchor = range?.start ?? anchor;
|
|
this.selectionFocus = range?.end ?? anchor;
|
|
this.selectionDragged = false;
|
|
this.pressedUrl = range
|
|
? undefined
|
|
: getOsc8LinkAtColumn(
|
|
this.previousScreen[Math.max(0, Math.min(this.terminal.rows - 1, event.y))] ?? "",
|
|
Math.max(0, Math.min(this.terminal.columns - 1, event.x)),
|
|
);
|
|
this.requestRender();
|
|
}
|
|
|
|
private getSelectionBounds(): { start: SelectionPoint; end: SelectionPoint } | undefined {
|
|
if (!this.selectionAnchor || !this.selectionFocus) return undefined;
|
|
if (this.selectionAnchor.scrollView !== this.selectionFocus.scrollView) return undefined;
|
|
const anchorBeforeFocus =
|
|
this.selectionAnchor.row < this.selectionFocus.row ||
|
|
(this.selectionAnchor.row === this.selectionFocus.row && this.selectionAnchor.col < this.selectionFocus.col);
|
|
if (
|
|
this.selectionAnchor.row === this.selectionFocus.row &&
|
|
this.selectionAnchor.col === this.selectionFocus.col
|
|
) {
|
|
return undefined;
|
|
}
|
|
return anchorBeforeFocus
|
|
? { start: this.selectionAnchor, end: this.selectionFocus }
|
|
: { start: this.selectionFocus, end: this.selectionAnchor };
|
|
}
|
|
|
|
private getSelectionColumns(
|
|
line: string,
|
|
row: number,
|
|
selection: { start: SelectionPoint; end: SelectionPoint },
|
|
minColumn = 0,
|
|
maxColumn = visibleWidth(line),
|
|
): { start: number; end: number } {
|
|
const lineWidth = visibleWidth(line);
|
|
let start = Math.max(0, minColumn);
|
|
let end = Math.min(lineWidth, maxColumn);
|
|
if (row === selection.start.row) {
|
|
start = getGraphemeCellRange(line, selection.start.col)?.start ?? Math.min(selection.start.col, lineWidth);
|
|
}
|
|
if (row === selection.end.row) {
|
|
end = selection.end.boundary
|
|
? Math.min(selection.end.col, lineWidth)
|
|
: (getGraphemeCellRange(line, selection.end.col)?.end ?? Math.min(selection.end.col + 1, lineWidth));
|
|
}
|
|
return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) };
|
|
}
|
|
|
|
private getActiveSelectionText(): string | undefined {
|
|
const selection = this.getSelectionBounds();
|
|
if (!selection) return undefined;
|
|
let sourceLines: readonly string[] = this.previousScreen;
|
|
if (selection.start.scrollView) {
|
|
if (!this.currentLayout) return undefined;
|
|
const box = getScrollViewBox(this.currentLayout, selection.start.scrollView);
|
|
if (!box?.scrollContentLines) return undefined;
|
|
sourceLines = box.scrollContentLines;
|
|
}
|
|
const lines: string[] = [];
|
|
for (let row = selection.start.row; row <= selection.end.row; row++) {
|
|
const line = sourceLines[row] ?? "";
|
|
const columns = this.getSelectionColumns(line, row, selection);
|
|
lines.push(
|
|
stripTerminalSequences(
|
|
sliceByColumn(line, columns.start, Math.max(0, columns.end - columns.start), true),
|
|
).trimEnd(),
|
|
);
|
|
}
|
|
const text = lines.join("\n");
|
|
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
|
|
// without OSC 52 clipboard passthrough), so only report success when it actually copies.
|
|
if (this.copySelection) {
|
|
const ok = await this.copySelection(text);
|
|
this.flash(ok ? "Copied!" : "Copy failed");
|
|
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 {
|
|
const style = current ? this.searchCurrentMatchStyle : this.searchMatchStyle;
|
|
let result = "";
|
|
let plainStart = 0;
|
|
let index = 0;
|
|
while (index < text.length) {
|
|
const ansi = extractAnsiCode(text, index);
|
|
if (!ansi) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (index > plainStart) result += style(text.slice(plainStart, index));
|
|
result += ansi.code;
|
|
index += ansi.length;
|
|
plainStart = index;
|
|
}
|
|
if (plainStart < text.length) result += style(text.slice(plainStart));
|
|
return result;
|
|
}
|
|
|
|
private applySearchHighlights(screen: string[], layout: LayoutFrame): string[] {
|
|
const search = this.activeSearch;
|
|
if (!search || search.selectedIndex < 0 || search.matches.length === 0) return screen;
|
|
const scrollView = layout.primaryScrollView ?? this.implicitScrollView;
|
|
const box = getScrollViewBox(layout, scrollView);
|
|
if (!box) return screen;
|
|
|
|
const rangesByRow = new Map<number, SearchHighlightRange[]>();
|
|
const scrollbarColumn = getScrollbarGeometry(box)?.column;
|
|
const minRow = Math.max(0, box.rect.y, box.clip.y);
|
|
const maxRow = Math.min(screen.length, box.rect.y + box.rect.height, box.clip.y + box.clip.height);
|
|
const minColumn = Math.max(0, box.rect.x, box.clip.x);
|
|
const maxColumn = Math.min(
|
|
this.terminal.columns,
|
|
box.rect.x + box.rect.width,
|
|
box.clip.x + box.clip.width,
|
|
scrollbarColumn ?? Number.POSITIVE_INFINITY,
|
|
);
|
|
const minContentRow = scrollView.scrollTop + minRow - box.rect.y;
|
|
const maxContentRow = scrollView.scrollTop + maxRow - box.rect.y - 1;
|
|
let low = 0;
|
|
let high = search.matches.length;
|
|
while (low < high) {
|
|
const middle = low + Math.floor((high - low) / 2);
|
|
const match = search.matches[middle]!;
|
|
const lastRow = match.segments[match.segments.length - 1]?.row ?? -1;
|
|
if (lastRow < minContentRow) low = middle + 1;
|
|
else high = middle;
|
|
}
|
|
for (let matchIndex = low; matchIndex < search.matches.length; matchIndex++) {
|
|
const match = search.matches[matchIndex]!;
|
|
if ((match.segments[0]?.row ?? 0) > maxContentRow) break;
|
|
for (const segment of match.segments) {
|
|
const row = box.rect.y + segment.row - scrollView.scrollTop;
|
|
if (row < minRow || row >= maxRow) continue;
|
|
const startCol = Math.max(minColumn, box.rect.x + segment.startCol);
|
|
const endCol = Math.min(maxColumn, box.rect.x + segment.endCol);
|
|
if (endCol <= startCol) continue;
|
|
const ranges = rangesByRow.get(row) ?? [];
|
|
ranges.push({ startCol, endCol, current: matchIndex === search.selectedIndex });
|
|
rangesByRow.set(row, ranges);
|
|
}
|
|
}
|
|
|
|
const result = [...screen];
|
|
for (const [row, ranges] of rangesByRow) {
|
|
let line = result[row] ?? "";
|
|
if (isImageLine(line)) continue;
|
|
const lineWidth = visibleWidth(line);
|
|
for (const range of ranges.sort((a, b) => b.startCol - a.startCol)) {
|
|
const startCol = Math.min(range.startCol, lineWidth);
|
|
const endCol = Math.min(range.endCol, lineWidth);
|
|
if (endCol <= startCol) continue;
|
|
const before = sliceByColumn(line, 0, startCol, true);
|
|
const highlighted = sliceByColumn(line, startCol, endCol - startCol, true);
|
|
const after = sliceByColumn(line, endCol, Math.max(0, lineWidth - endCol), true);
|
|
line = `${before}${this.applySearchTextHighlight(highlighted, range.current)}${after}`;
|
|
}
|
|
result[row] = line;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private applySelectionHighlight(text: string): string {
|
|
let result = "\x1b[7m";
|
|
let index = 0;
|
|
while (index < text.length) {
|
|
const ansi = extractAnsiCode(text, index);
|
|
if (!ansi) {
|
|
result += text[index];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
result += ansi.code;
|
|
if (ansi.code.endsWith("m")) result += "\x1b[7m";
|
|
index += ansi.length;
|
|
}
|
|
return `${result}\x1b[27m`;
|
|
}
|
|
|
|
private applySelection(screen: string[], layout = this.currentLayout): string[] {
|
|
const selection = this.getSelectionBounds();
|
|
if (!selection) return screen;
|
|
let screenSelection = selection;
|
|
let minRow = 0;
|
|
let maxRow = screen.length - 1;
|
|
let minColumn = 0;
|
|
let maxColumn = this.terminal.columns;
|
|
if (selection.start.scrollView) {
|
|
if (!layout) return screen;
|
|
const box = getScrollViewBox(layout, selection.start.scrollView);
|
|
if (!box) return screen;
|
|
minRow = Math.max(0, box.rect.y, box.clip.y);
|
|
maxRow = Math.min(screen.length - 1, box.rect.y + box.rect.height - 1, box.clip.y + box.clip.height - 1);
|
|
minColumn = Math.max(0, box.rect.x, box.clip.x);
|
|
maxColumn = Math.min(this.terminal.columns, box.rect.x + box.rect.width, box.clip.x + box.clip.width);
|
|
screenSelection = {
|
|
start: {
|
|
...selection.start,
|
|
row: box.rect.y + selection.start.row - selection.start.scrollView.scrollTop,
|
|
col: box.rect.x + selection.start.col,
|
|
},
|
|
end: {
|
|
...selection.end,
|
|
row: box.rect.y + selection.end.row - selection.start.scrollView.scrollTop,
|
|
col: box.rect.x + selection.end.col,
|
|
},
|
|
};
|
|
}
|
|
return screen.map((line, row) => {
|
|
if (
|
|
row < minRow ||
|
|
row > maxRow ||
|
|
row < screenSelection.start.row ||
|
|
row > screenSelection.end.row ||
|
|
isImageLine(line)
|
|
) {
|
|
return line;
|
|
}
|
|
const lineWidth = visibleWidth(line);
|
|
const columns = this.getSelectionColumns(line, row, screenSelection, minColumn, maxColumn);
|
|
if (columns.end <= columns.start) return line;
|
|
const before = sliceByColumn(line, 0, columns.start, true);
|
|
const selected = sliceByColumn(line, columns.start, columns.end - columns.start, true);
|
|
const after = sliceByColumn(line, columns.end, Math.max(0, lineWidth - columns.end), true);
|
|
return `${before}${this.applySelectionHighlight(selected)}${after}`;
|
|
});
|
|
}
|
|
|
|
private isMouseSequence(data: string): boolean {
|
|
return /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data) || (data.length === 6 && data.startsWith("\x1b[M"));
|
|
}
|
|
|
|
private compositeScrollToEndIndicator(screen: string[], layout: LayoutFrame, width: number): string[] {
|
|
this.scrollToEndIndicatorRect = undefined;
|
|
const scrollView = layout.primaryScrollView ?? this.implicitScrollView;
|
|
if (!this.scrollToEndIndicator || !scrollView.followEnd || scrollView.isFollowingEnd) return screen;
|
|
const box = getScrollViewBox(layout, scrollView);
|
|
const clip = box?.clip;
|
|
if (!clip || clip.width <= 0 || clip.height <= 0) return screen;
|
|
const row = clip.y + clip.height - 1;
|
|
if (row >= screen.length || isImageLine(screen[row] ?? "")) return screen;
|
|
const scrollbarColumn = box ? getScrollbarGeometry(box)?.column : undefined;
|
|
const availableWidth = Math.max(0, (scrollbarColumn ?? clip.x + clip.width) - clip.x);
|
|
const text = truncateToWidth(this.scrollToEndIndicator(), availableWidth, "");
|
|
const textWidth = visibleWidth(text);
|
|
if (textWidth === 0) return screen;
|
|
const column = clip.x + Math.floor((availableWidth - textWidth) / 2);
|
|
const result = [...screen];
|
|
result[row] = compositeTuiLine(result[row] ?? "", text, column, textWidth, width);
|
|
this.scrollToEndIndicatorRect = { row, column, width: textWidth };
|
|
return result;
|
|
}
|
|
|
|
private compositeFlashes(screen: string[], width: number, height: number): string[] {
|
|
const flashLines = this.flashes.render(width).slice(-height);
|
|
if (flashLines.length === 0) return screen;
|
|
const result = [...screen];
|
|
while (result.length < height) result.push("");
|
|
for (let row = 0; row < flashLines.length; row++) {
|
|
const line = flashLines[row]!;
|
|
const flashWidth = visibleWidth(line);
|
|
if (flashWidth === 0) continue;
|
|
result[row] = compositeTuiLine(result[row] ?? "", line, width - flashWidth, flashWidth, width);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
protected override doRender(): void {
|
|
if (this.stopped || !this.altScreenActive) return;
|
|
const width = Math.max(1, this.terminal.columns);
|
|
const height = Math.max(1, this.terminal.rows);
|
|
const root = this.layoutRoot ?? this.implicitScrollView;
|
|
let nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender());
|
|
if (this.refreshSearch(nextLayout)) {
|
|
nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender());
|
|
}
|
|
let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
|
|
screen = this.applySearchHighlights(screen, nextLayout);
|
|
screen = this.compositeScrollToEndIndicator(screen, nextLayout, width);
|
|
screen = this.compositeOverlays(screen, width, height);
|
|
if (screen.length > height) screen = screen.slice(screen.length - height);
|
|
screen = this.applySelection(screen, nextLayout);
|
|
screen = this.compositeFlashes(screen, width, height);
|
|
|
|
const cursorPos = this.extractCursorPosition(screen, height);
|
|
screen = this.applyLineResets(screen).map((line) => {
|
|
if (isImageLine(line) || visibleWidth(line) <= width) return line;
|
|
return sliceByColumn(line, 0, width, true);
|
|
});
|
|
|
|
const fullRedraw =
|
|
this.previousScreen.length === 0 || this.previousScreenWidth !== width || this.previousScreenHeight !== height;
|
|
const imagesNeedRedraw = screen.some(
|
|
(line, row) =>
|
|
line !== this.previousScreen[row] && (isImageLine(line) || isImageLine(this.previousScreen[row] ?? "")),
|
|
);
|
|
const redrawImages = fullRedraw || imagesNeedRedraw;
|
|
const hadUploadedKittyImages = this.uploadedKittyImages.size > 0;
|
|
const preparedKittyScreen =
|
|
redrawImages && this.imageProtocol === "kitty"
|
|
? this.prepareKittyScreen(screen)
|
|
: { lines: screen, evictedImageDeletion: "" };
|
|
|
|
let buffer = BEGIN_SYNCHRONIZED_OUTPUT;
|
|
if (fullRedraw) {
|
|
this.fullRedrawCount += 1;
|
|
const clearImages =
|
|
this.imageProtocol === "kitty" && hadUploadedKittyImages
|
|
? deleteAllKittyPlacements()
|
|
: this.deleteKittyImages();
|
|
buffer += `${clearImages}\x1b[2J`;
|
|
} else if (imagesNeedRedraw) {
|
|
if (this.imageProtocol === "iterm2") buffer += "\x1b[2J";
|
|
else if (this.imageProtocol === "kitty") buffer += deleteAllKittyPlacements();
|
|
}
|
|
buffer += preparedKittyScreen.evictedImageDeletion;
|
|
|
|
for (let row = 0; row < height; row++) {
|
|
if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue;
|
|
buffer += `\x1b[${row + 1};1H\x1b[2K${preparedKittyScreen.lines[row] ?? ""}`;
|
|
}
|
|
|
|
if (cursorPos) {
|
|
buffer += `\x1b[${cursorPos.row + 1};${Math.min(width, cursorPos.col) + 1}H`;
|
|
buffer += this.getShowHardwareCursor() ? "\x1b[?25h" : "\x1b[?25l";
|
|
} else {
|
|
buffer += "\x1b[?25l";
|
|
}
|
|
buffer += END_SYNCHRONIZED_OUTPUT;
|
|
this.terminal.write(buffer);
|
|
|
|
this.previousScreen = screen;
|
|
this.previousScreenWidth = width;
|
|
this.previousScreenHeight = height;
|
|
this.currentLayout = nextLayout;
|
|
}
|
|
}
|