chore(vendor): import pi 0.85.1
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
This commit is contained in:
1 parent
5ebc7018c3
commit
4def357d09
1669 files changed
+397627
No files matched your search
@@ -0,0 +1,327 @@
|
||||
import { Input } from "./components/input.ts";
|
||||
import { getKeybindings } from "./keybindings.ts";
|
||||
import type { Component, Focusable } from "./tui.ts";
|
||||
import { getGraphemeSegmenter, stripTerminalSequences, truncateToWidth, visibleWidth } from "./utils.ts";
|
||||
|
||||
const segmenter = getGraphemeSegmenter();
|
||||
|
||||
interface SearchSourceSpan {
|
||||
textStart: number;
|
||||
textEnd: number;
|
||||
row: number;
|
||||
startCol: number;
|
||||
endCol: number;
|
||||
linearColumns: boolean;
|
||||
}
|
||||
|
||||
interface SearchCorpus {
|
||||
text: string;
|
||||
spans: SearchSourceSpan[];
|
||||
}
|
||||
|
||||
export interface AltScreenSearchSegment {
|
||||
row: number;
|
||||
startCol: number;
|
||||
endCol: number;
|
||||
}
|
||||
|
||||
export interface AltScreenSearchMatch {
|
||||
segments: AltScreenSearchSegment[];
|
||||
}
|
||||
|
||||
const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
|
||||
|
||||
function buildSearchCorpus(lines: readonly string[]): SearchCorpus {
|
||||
const chunks: string[] = [];
|
||||
const spans: SearchSourceSpan[] = [];
|
||||
let textLength = 0;
|
||||
let pendingSeparator = false;
|
||||
|
||||
const appendSeparator = (): void => {
|
||||
if (!pendingSeparator) return;
|
||||
chunks.push(" ");
|
||||
textLength += 1;
|
||||
pendingSeparator = false;
|
||||
};
|
||||
|
||||
for (let row = 0; row < lines.length; row++) {
|
||||
const line = stripTerminalSequences(lines[row] ?? "");
|
||||
let column = 0;
|
||||
|
||||
// Rendered transcripts are overwhelmingly ASCII. Index complete non-space
|
||||
// runs at once instead of segmenting and allocating one mapping per cell.
|
||||
if (PRINTABLE_ASCII.test(line)) {
|
||||
let index = 0;
|
||||
while (index < line.length) {
|
||||
if (line.charCodeAt(index) === 0x20) {
|
||||
if (textLength > 0) pendingSeparator = true;
|
||||
column += 1;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let end = index + 1;
|
||||
while (end < line.length && line.charCodeAt(end) !== 0x20) end += 1;
|
||||
appendSeparator();
|
||||
const text = line.slice(index, end);
|
||||
chunks.push(text);
|
||||
spans.push({
|
||||
textStart: textLength,
|
||||
textEnd: textLength + text.length,
|
||||
row,
|
||||
startCol: column,
|
||||
endCol: column + text.length,
|
||||
linearColumns: true,
|
||||
});
|
||||
textLength += text.length;
|
||||
column += text.length;
|
||||
index = end;
|
||||
}
|
||||
} else {
|
||||
for (const grapheme of segmenter.segment(line)) {
|
||||
const text = grapheme.segment;
|
||||
const width = visibleWidth(text);
|
||||
if (/^\s+$/u.test(text)) {
|
||||
if (textLength > 0) pendingSeparator = true;
|
||||
column += width;
|
||||
continue;
|
||||
}
|
||||
appendSeparator();
|
||||
chunks.push(text);
|
||||
spans.push({
|
||||
textStart: textLength,
|
||||
textEnd: textLength + text.length,
|
||||
row,
|
||||
startCol: column,
|
||||
endCol: column + width,
|
||||
linearColumns: false,
|
||||
});
|
||||
textLength += text.length;
|
||||
column += width;
|
||||
}
|
||||
}
|
||||
if (textLength > 0) pendingSeparator = true;
|
||||
}
|
||||
|
||||
return { text: chunks.join(""), spans };
|
||||
}
|
||||
|
||||
function normalizeQuery(query: string): string {
|
||||
return query.replace(/\s+/gu, " ").trim();
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function findSearchCorpusMatches(corpus: SearchCorpus, normalizedQuery: string): AltScreenSearchMatch[] {
|
||||
if (!normalizedQuery) return [];
|
||||
const expression = new RegExp(escapeRegExp(normalizedQuery), "giu");
|
||||
const matches: AltScreenSearchMatch[] = [];
|
||||
let spanIndex = 0;
|
||||
|
||||
for (const match of corpus.text.matchAll(expression)) {
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
while (spanIndex < corpus.spans.length && corpus.spans[spanIndex]!.textEnd <= start) spanIndex += 1;
|
||||
|
||||
const segments: AltScreenSearchSegment[] = [];
|
||||
for (let index = spanIndex; index < corpus.spans.length; index++) {
|
||||
const span = corpus.spans[index]!;
|
||||
if (span.textStart >= end) break;
|
||||
if (span.textEnd <= start) continue;
|
||||
const startCol = span.linearColumns
|
||||
? span.startCol + Math.max(start, span.textStart) - span.textStart
|
||||
: span.startCol;
|
||||
const endCol = span.linearColumns ? span.startCol + Math.min(end, span.textEnd) - span.textStart : span.endCol;
|
||||
const previous = segments[segments.length - 1];
|
||||
if (previous && previous.row === span.row && startCol <= previous.endCol) {
|
||||
previous.endCol = Math.max(previous.endCol, endCol);
|
||||
} else {
|
||||
segments.push({ row: span.row, startCol, endCol });
|
||||
}
|
||||
}
|
||||
while (spanIndex < corpus.spans.length && corpus.spans[spanIndex]!.textEnd <= end) spanIndex += 1;
|
||||
if (segments.length > 0) matches.push({ segments });
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export interface AltScreenSearchResult {
|
||||
matches: AltScreenSearchMatch[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
/** Cache the searchable corpus and matches while rendered transcript lines remain unchanged. */
|
||||
export class AltScreenSearchIndex {
|
||||
private sourceLines: string[] | undefined;
|
||||
private corpus: SearchCorpus | undefined;
|
||||
private normalizedQuery: string | undefined;
|
||||
private matches: AltScreenSearchMatch[] = [];
|
||||
|
||||
search(lines: readonly string[], query: string): AltScreenSearchResult {
|
||||
let sourceChanged = this.sourceLines?.length !== lines.length;
|
||||
if (!sourceChanged && this.sourceLines) {
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
if (this.sourceLines[index] === lines[index]) continue;
|
||||
sourceChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sourceChanged || !this.corpus) {
|
||||
this.sourceLines = Array.from(lines);
|
||||
this.corpus = buildSearchCorpus(lines);
|
||||
}
|
||||
|
||||
const normalizedQuery = normalizeQuery(query);
|
||||
const changed = sourceChanged || normalizedQuery !== this.normalizedQuery;
|
||||
if (changed) {
|
||||
this.normalizedQuery = normalizedQuery;
|
||||
this.matches = findSearchCorpusMatches(this.corpus, normalizedQuery);
|
||||
}
|
||||
return { matches: this.matches, changed };
|
||||
}
|
||||
}
|
||||
|
||||
export function findAltScreenSearchMatches(lines: readonly string[], query: string): AltScreenSearchMatch[] {
|
||||
const normalizedQuery = normalizeQuery(query);
|
||||
return normalizedQuery ? findSearchCorpusMatches(buildSearchCorpus(lines), normalizedQuery) : [];
|
||||
}
|
||||
|
||||
export function getAltScreenSearchMatchKey(match: AltScreenSearchMatch): string {
|
||||
const first = match.segments[0];
|
||||
const last = match.segments[match.segments.length - 1];
|
||||
return first && last ? `${first.row}:${first.startCol}:${last.row}:${last.endCol}` : "";
|
||||
}
|
||||
|
||||
export class AltScreenSearchComponent implements Component, Focusable {
|
||||
private readonly input = new Input({
|
||||
prompt: " ",
|
||||
placeholder: "Find in transcript",
|
||||
placeholderStyle: (text) => `\x1b[2m${text}\x1b[22m`,
|
||||
});
|
||||
private readonly onQueryChange: (query: string) => void;
|
||||
private readonly navigationButtonStyle: (text: string, hovered: boolean) => string;
|
||||
private resultCount = 0;
|
||||
private resultIndex = -1;
|
||||
private previousButtonStart = -1;
|
||||
private previousButtonEnd = -1;
|
||||
private nextButtonStart = -1;
|
||||
private nextButtonEnd = -1;
|
||||
private hoveredNavigationDirection: -1 | 1 | undefined;
|
||||
private _focused = false;
|
||||
|
||||
constructor(
|
||||
onQueryChange: (query: string) => void,
|
||||
navigationButtonStyle: (text: string, hovered: boolean) => string = (text) => text,
|
||||
) {
|
||||
this.onQueryChange = onQueryChange;
|
||||
this.navigationButtonStyle = navigationButtonStyle;
|
||||
}
|
||||
|
||||
get focused(): boolean {
|
||||
return this._focused;
|
||||
}
|
||||
|
||||
set focused(value: boolean) {
|
||||
this._focused = value;
|
||||
this.input.focused = value;
|
||||
}
|
||||
|
||||
setResult(index: number, count: number): void {
|
||||
this.resultIndex = index;
|
||||
this.resultCount = count;
|
||||
}
|
||||
|
||||
getNavigationDirectionAt(row: number, column: number): -1 | 1 | undefined {
|
||||
if (row !== 2) return undefined;
|
||||
if (column >= this.previousButtonStart && column < this.previousButtonEnd) return -1;
|
||||
if (column >= this.nextButtonStart && column < this.nextButtonEnd) return 1;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setHoveredNavigationDirection(direction: -1 | 1 | undefined): boolean {
|
||||
if (direction === this.hoveredNavigationDirection) return false;
|
||||
this.hoveredNavigationDirection = direction;
|
||||
return true;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
const previous = this.input.getValue();
|
||||
this.input.handleInput(data);
|
||||
const query = this.input.getValue();
|
||||
if (query !== previous) this.onQueryChange(query);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.input.invalidate();
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const safeWidth = Math.max(1, width);
|
||||
const innerWidth = Math.max(0, safeWidth - 2);
|
||||
const formatKey = (key: string | undefined): string =>
|
||||
key
|
||||
? key
|
||||
.split("+")
|
||||
.map((part) => {
|
||||
if (process.platform === "darwin" && part.toLowerCase() === "alt") return "Option";
|
||||
return part.charAt(0).toUpperCase() + part.slice(1);
|
||||
})
|
||||
.join("+")
|
||||
: "Unbound";
|
||||
const keybindings = getKeybindings();
|
||||
const previousKey = formatKey(keybindings.getKeys("tui.altScreen.searchPrevious")[0]);
|
||||
const nextKey = formatKey(keybindings.getKeys("tui.altScreen.searchNext")[0]);
|
||||
const query = this.input.getValue();
|
||||
const result = !query
|
||||
? ""
|
||||
: this.resultCount === 0
|
||||
? "No matches"
|
||||
: `${this.resultIndex + 1}/${this.resultCount}`;
|
||||
const resultSpace = Math.max(0, innerWidth - 3);
|
||||
const visibleResult = truncateToWidth(result, resultSpace, "");
|
||||
const resultText = visibleResult ? `\x1b[2m ${visibleResult} \x1b[22m` : "";
|
||||
const inputWidth = Math.max(0, innerWidth - visibleWidth(resultText));
|
||||
const inputLine = truncateToWidth(this.input.render(Math.max(1, inputWidth))[0] ?? "", inputWidth, "");
|
||||
const inputPadding = " ".repeat(Math.max(0, inputWidth - visibleWidth(inputLine)));
|
||||
const content = `${inputLine}${inputPadding}${resultText}`;
|
||||
|
||||
let previousButton = `↑ ${previousKey}`;
|
||||
let nextButton = `↓ ${nextKey}`;
|
||||
let separator = " · ";
|
||||
const outerGapWidth = 1;
|
||||
const availableControlsWidth = Math.max(0, innerWidth - outerGapWidth * 2 - 1);
|
||||
let controlsWidth = visibleWidth(previousButton) + visibleWidth(separator) + visibleWidth(nextButton);
|
||||
if (controlsWidth > availableControlsWidth) {
|
||||
previousButton = "↑";
|
||||
nextButton = "↓";
|
||||
separator = " ";
|
||||
controlsWidth = visibleWidth(previousButton) + visibleWidth(separator) + visibleWidth(nextButton);
|
||||
}
|
||||
const showButtons = controlsWidth <= availableControlsWidth;
|
||||
const renderedButtons = showButtons
|
||||
? this.navigationButtonStyle(previousButton, this.hoveredNavigationDirection === -1) +
|
||||
separator +
|
||||
this.navigationButtonStyle(nextButton, this.hoveredNavigationDirection === 1)
|
||||
: "";
|
||||
const outerGapsWidth = showButtons ? outerGapWidth * 2 : 0;
|
||||
const rightRuleWidth = renderedButtons && innerWidth > controlsWidth + outerGapsWidth ? 1 : 0;
|
||||
const leftRuleWidth = Math.max(
|
||||
0,
|
||||
innerWidth - (showButtons ? controlsWidth : 0) - outerGapsWidth - rightRuleWidth,
|
||||
);
|
||||
const previousStart = 1 + leftRuleWidth + outerGapWidth;
|
||||
this.previousButtonStart = showButtons ? previousStart : -1;
|
||||
this.previousButtonEnd = showButtons ? previousStart + visibleWidth(previousButton) : -1;
|
||||
this.nextButtonStart = showButtons ? this.previousButtonEnd + visibleWidth(separator) : -1;
|
||||
this.nextButtonEnd = showButtons ? this.nextButtonStart + visibleWidth(nextButton) : -1;
|
||||
|
||||
if (safeWidth === 1) return ["┌", "│", "└"];
|
||||
return [
|
||||
`┌${"─".repeat(innerWidth)}┐`,
|
||||
`│${content}│`,
|
||||
`└${"─".repeat(leftRuleWidth)}${renderedButtons ? " " : ""}${renderedButtons}${renderedButtons ? " " : ""}${"─".repeat(rightRuleWidth)}┘`,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user