53aaced81f
- Added `textSizing` terminal capability and `tui.textSizing` setting for Kitty OSC66 scaling. - Updated OSC66 range and visible-width handling to preserve grapheme slice behavior. - Updated markdown rendering to apply heading text sizing only when enabled. - Fixed DECCARA trailing-background fill tracking and DECRPM status 3/4 recognition.
453 lines
15 KiB
TypeScript
453 lines
15 KiB
TypeScript
import {
|
||
Ellipsis,
|
||
type ExtractSegmentsResult,
|
||
extractSegments as nativeExtractSegments,
|
||
sliceWithWidth as nativeSliceWithWidth,
|
||
truncateToWidth as nativeTruncateToWidth,
|
||
visibleWidth as nativeVisibleWidth,
|
||
wrapTextWithAnsi as nativeWrapTextWithAnsi,
|
||
type SliceResult,
|
||
} from "@oh-my-pi/pi-natives";
|
||
import { getDefaultTabWidth, getIndentation } from "@oh-my-pi/pi-utils";
|
||
|
||
export { Ellipsis } from "@oh-my-pi/pi-natives";
|
||
|
||
export { getDefaultTabWidth, getIndentation } from "@oh-my-pi/pi-utils";
|
||
|
||
export type TextSizingScale = 1 | 2 | 3;
|
||
export type TextSizingVerticalAlign = "top" | "bottom" | "center";
|
||
export type TextSizingHorizontalAlign = "left" | "right" | "center";
|
||
|
||
export interface TextSizingOptions {
|
||
scale?: TextSizingScale;
|
||
widthCells?: number;
|
||
verticalAlign?: TextSizingVerticalAlign;
|
||
horizontalAlign?: TextSizingHorizontalAlign;
|
||
}
|
||
|
||
const OSC66_UNSAFE = /[\x00-\x1f\x7f-\x9f]/u;
|
||
const OSC66_UNSAFE_GLOBAL = /[\x00-\x1f\x7f-\x9f]/gu;
|
||
|
||
function textSizingVerticalAlignValue(align: TextSizingVerticalAlign | undefined): number | undefined {
|
||
switch (align) {
|
||
case "top":
|
||
return 0;
|
||
case "bottom":
|
||
return 1;
|
||
case "center":
|
||
return 2;
|
||
default:
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
function textSizingHorizontalAlignValue(align: TextSizingHorizontalAlign | undefined): number | undefined {
|
||
switch (align) {
|
||
case "left":
|
||
return 0;
|
||
case "right":
|
||
return 1;
|
||
case "center":
|
||
return 2;
|
||
default:
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Encode a plain-text span using Kitty's OSC 66 text-sizing protocol. The TUI
|
||
* emits only safe UTF-8 payloads and ST terminators so its ANSI parser and the
|
||
* terminal agree on span boundaries.
|
||
*/
|
||
export function encodeTextSized(text: string, options: TextSizingOptions = {}): string {
|
||
const metadata: string[] = [];
|
||
if (options.scale !== undefined) metadata.push(`s=${options.scale}`);
|
||
if (options.widthCells !== undefined && Number.isFinite(options.widthCells)) {
|
||
metadata.push(`w=${Math.max(0, Math.trunc(options.widthCells))}`);
|
||
}
|
||
const verticalAlign = textSizingVerticalAlignValue(options.verticalAlign);
|
||
if (verticalAlign !== undefined) metadata.push(`v=${verticalAlign}`);
|
||
const horizontalAlign = textSizingHorizontalAlignValue(options.horizontalAlign);
|
||
if (horizontalAlign !== undefined) metadata.push(`h=${horizontalAlign}`);
|
||
|
||
const safeText = OSC66_UNSAFE.test(text) ? text.replace(OSC66_UNSAFE_GLOBAL, " ") : text;
|
||
return `\x1b]66;${metadata.join(":")};${safeText}\x1b\\`;
|
||
}
|
||
|
||
export function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult {
|
||
return nativeSliceWithWidth(line, startCol, length, strict ?? null, getDefaultTabWidth());
|
||
}
|
||
|
||
export function truncateToWidth(
|
||
text: string,
|
||
maxWidth: number,
|
||
ellipsisKind?: Ellipsis | null,
|
||
pad?: boolean | null,
|
||
): string {
|
||
// Guard nullish napi inputs: napi-rs 3 on the Windows prebuilt rejects
|
||
// `null` for `Option<u8>` (Ellipsis) / `Option<bool>` (pad) (issue #848),
|
||
// and `maxWidth` is a required `u32` that throws on `null`/`undefined`
|
||
// everywhere. Pass concrete defaults that mirror the Rust `unwrap_or`s.
|
||
const safeWidth = Number.isFinite(maxWidth) ? Math.max(0, Math.trunc(maxWidth)) : 0;
|
||
let resolvedEllipsis: Ellipsis | null | undefined | string = ellipsisKind;
|
||
if (typeof resolvedEllipsis === "string") {
|
||
resolvedEllipsis = resolvedEllipsis === "" ? Ellipsis.Omit : Ellipsis.Unicode;
|
||
}
|
||
return nativeTruncateToWidth(
|
||
text,
|
||
safeWidth,
|
||
resolvedEllipsis ?? Ellipsis.Unicode,
|
||
pad ?? false,
|
||
getDefaultTabWidth(),
|
||
);
|
||
}
|
||
|
||
export function wrapTextWithAnsi(text: string, width: number): string[] {
|
||
return nativeWrapTextWithAnsi(text, width, getDefaultTabWidth());
|
||
}
|
||
|
||
export function extractSegments(
|
||
line: string,
|
||
beforeEnd: number,
|
||
afterStart: number,
|
||
afterLen: number,
|
||
strictAfter: boolean,
|
||
): ExtractSegmentsResult {
|
||
return nativeExtractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, getDefaultTabWidth());
|
||
}
|
||
|
||
// Pre-allocated space buffer for padding
|
||
const SPACE_BUFFER = " ".repeat(512);
|
||
|
||
/**
|
||
* Tab width in columns for `file`, using `process.cwd()` as the project root for relative paths.
|
||
*/
|
||
export function getIndentationNoescape(file?: string): number {
|
||
return getIndentation(file, process.cwd());
|
||
}
|
||
|
||
/*
|
||
* Replace tabs with configured spacing for consistent rendering.
|
||
*/
|
||
export function replaceTabs(text: string, file?: string): string {
|
||
return text.replaceAll("\t", " ".repeat(getIndentation(file)));
|
||
}
|
||
|
||
/**
|
||
* Returns a string of n spaces. Uses a pre-allocated buffer for efficiency.
|
||
*/
|
||
export function padding(n: number): string {
|
||
if (n <= 0) return "";
|
||
if (n <= 512) return SPACE_BUFFER.slice(0, n);
|
||
return " ".repeat(n);
|
||
}
|
||
|
||
// Grapheme segmenter (shared instance)
|
||
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||
|
||
const EXTENDED_PICTOGRAPHIC_REGEX = /\p{Extended_Pictographic}/u;
|
||
|
||
// Matches CSI (`\x1b[…`) and OSC (`\x1b]…` terminated by BEL/ST) escape
|
||
// sequences. Mirrors the standard ansi-regex coverage so visible-span
|
||
// segmentation lines up with the native ANSI scanner.
|
||
const ANSI_ESCAPE_REGEX =
|
||
/[\u001b\u009b][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
|
||
|
||
function pictographicSpanWidth(span: string): number {
|
||
let width = 0;
|
||
for (const { segment } of segmenter.segment(span)) {
|
||
width += EXTENDED_PICTOGRAPHIC_REGEX.test(segment) ? 2 : nativeVisibleWidth(segment, getDefaultTabWidth());
|
||
}
|
||
return width;
|
||
}
|
||
|
||
// Width fallback for strings that mix ANSI styling with ZWJ pictographic
|
||
// emoji. `Intl.Segmenter` would split an escape sequence into individual
|
||
// graphemes, so the native scanner (which only skips ANSI when handed the
|
||
// complete sequence) double-counts the printable SGR bytes. Excise the ANSI
|
||
// spans first — they contribute zero cells — and apply the pictographic
|
||
// grapheme override only to the visible spans, then sum.
|
||
function visibleWidthByGrapheme(str: string): number {
|
||
let width = 0;
|
||
let lastIndex = 0;
|
||
ANSI_ESCAPE_REGEX.lastIndex = 0;
|
||
for (let match = ANSI_ESCAPE_REGEX.exec(str); match !== null; match = ANSI_ESCAPE_REGEX.exec(str)) {
|
||
if (match.index > lastIndex) {
|
||
width += pictographicSpanWidth(str.slice(lastIndex, match.index));
|
||
}
|
||
lastIndex = ANSI_ESCAPE_REGEX.lastIndex;
|
||
}
|
||
if (lastIndex < str.length) {
|
||
width += lastIndex === 0 ? pictographicSpanWidth(str) : pictographicSpanWidth(str.slice(lastIndex));
|
||
}
|
||
return width;
|
||
}
|
||
|
||
/**
|
||
* Get the shared grapheme segmenter instance.
|
||
*/
|
||
export function getSegmenter(): Intl.Segmenter {
|
||
return segmenter;
|
||
}
|
||
|
||
export function visibleWidthRaw(str: string): number {
|
||
if (!str) {
|
||
return 0;
|
||
}
|
||
|
||
// Fast path: printable ASCII has one cell per code unit. Defer every
|
||
// control/non-ASCII case (tabs, ANSI/OSC, combining marks, CJK) to the
|
||
// native text engine so all width/slice/wrap helpers share one Unicode
|
||
// model instead of mixing Bun.stringWidth quirks with Rust truncation.
|
||
for (let i = 0; i < str.length; i++) {
|
||
const code = str.charCodeAt(i);
|
||
if (code < 0x20 || code > 0x7e) {
|
||
const tabWidth = getDefaultTabWidth();
|
||
if (str.includes("\x1b]66;")) return nativeVisibleWidth(str, tabWidth);
|
||
return str.includes("\u200d") ? visibleWidthByGrapheme(str) : nativeVisibleWidth(str, tabWidth);
|
||
}
|
||
}
|
||
return str.length;
|
||
}
|
||
|
||
/**
|
||
* Calculate the visible width of a string in terminal columns.
|
||
*/
|
||
export function visibleWidth(str: string): number {
|
||
if (!str) return 0;
|
||
return visibleWidthRaw(str);
|
||
}
|
||
|
||
const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/;
|
||
const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g;
|
||
|
||
/**
|
||
* Normalize text for terminal output without changing logical editor content.
|
||
* Some terminals render precomposed Thai/Lao AM vowels inconsistently during
|
||
* differential repaint. Their compatibility decompositions have the same cell
|
||
* width but avoid stale-cell artifacts in terminal renderers.
|
||
*/
|
||
export function normalizeTerminalOutput(str: string): string {
|
||
if (!THAI_LAO_AM_REGEX.test(str)) return str;
|
||
return str.replace(THAI_LAO_AM_GLOBAL_REGEX, char => (char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2"));
|
||
}
|
||
|
||
const makeBoolArray = (chars: string): Uint8Array => {
|
||
const table = new Uint8Array(128);
|
||
for (let i = 0; i < chars.length; i++) {
|
||
const code = chars.charCodeAt(i);
|
||
if (code < table.length) {
|
||
table[code] = 1;
|
||
}
|
||
}
|
||
return table;
|
||
};
|
||
|
||
const ASCII_WHITESPACE = makeBoolArray("\x09\x0a\x0b\x0c\x0d\x20");
|
||
|
||
/**
|
||
* Check if a character is whitespace.
|
||
*/
|
||
export function isWhitespaceChar(char: string): boolean {
|
||
const code = char.codePointAt(0) ?? 0;
|
||
return code < 128 && ASCII_WHITESPACE[code] === 1;
|
||
}
|
||
|
||
const ASCII_PUNCTUATION = makeBoolArray("(){}[]<>.,;:'\"!?+-=*/\\|&%^$#@~`");
|
||
|
||
/**
|
||
* Check if a character is punctuation.
|
||
*/
|
||
export function isPunctuationChar(char: string): boolean {
|
||
const code = char.codePointAt(0) ?? 0;
|
||
return code < 128 && ASCII_PUNCTUATION[code] === 1;
|
||
}
|
||
|
||
export type WordNavKind = "whitespace" | "delimiter" | "cjk" | "word" | "other";
|
||
|
||
const WORD_NAV_RE_WHITESPACE = /^\p{White_Space}$/u;
|
||
const WORD_NAV_RE_PUNCT = /^\p{P}$/u;
|
||
const WORD_NAV_RE_SYMBOL = /^\p{S}$/u;
|
||
const WORD_NAV_RE_LETTER = /^\p{L}$/u;
|
||
const WORD_NAV_RE_NUMBER = /^\p{N}$/u;
|
||
const WORD_NAV_RE_HAN = /^\p{Script=Han}$/u;
|
||
const WORD_NAV_RE_HIRAGANA = /^\p{Script=Hiragana}$/u;
|
||
const WORD_NAV_RE_KATAKANA = /^\p{Script=Katakana}$/u;
|
||
const WORD_NAV_RE_HANGUL = /^\p{Script=Hangul}$/u;
|
||
|
||
function firstCodePointChar(str: string): string {
|
||
const cp = str.codePointAt(0);
|
||
if (cp === undefined) return "";
|
||
return String.fromCodePoint(cp);
|
||
}
|
||
|
||
/**
|
||
* Coarse Unicode-aware character classification for word navigation (Option/Alt + Left/Right).
|
||
* This intentionally avoids language-specific word segmentation for predictability across scripts.
|
||
*/
|
||
export function getWordNavKind(grapheme: string): WordNavKind {
|
||
if (!grapheme) return "other";
|
||
const ch = firstCodePointChar(grapheme);
|
||
if (!ch) return "other";
|
||
if (WORD_NAV_RE_WHITESPACE.test(ch)) return "whitespace";
|
||
if (WORD_NAV_RE_PUNCT.test(ch) || WORD_NAV_RE_SYMBOL.test(ch)) return "delimiter";
|
||
if (
|
||
WORD_NAV_RE_HAN.test(ch) ||
|
||
WORD_NAV_RE_HIRAGANA.test(ch) ||
|
||
WORD_NAV_RE_KATAKANA.test(ch) ||
|
||
WORD_NAV_RE_HANGUL.test(ch)
|
||
) {
|
||
return "cjk";
|
||
}
|
||
if (ch === "_" || WORD_NAV_RE_LETTER.test(ch) || WORD_NAV_RE_NUMBER.test(ch)) return "word";
|
||
return "other";
|
||
}
|
||
|
||
const WORD_NAV_JOINERS = new Set(["'", "’", "-", "‐", "‑"]);
|
||
|
||
export function isWordNavJoiner(grapheme: string): boolean {
|
||
const ch = firstCodePointChar(grapheme);
|
||
return WORD_NAV_JOINERS.has(ch);
|
||
}
|
||
|
||
/**
|
||
* Move the cursor one "word" to the left using Unicode-aware coarse navigation.
|
||
*
|
||
* Returns a new cursor index in the range [0, text.length].
|
||
*/
|
||
export function moveWordLeft(text: string, cursor: number): number {
|
||
const len = text.length;
|
||
if (len === 0) return 0;
|
||
let i = Math.min(Math.max(cursor, 0), len);
|
||
if (i === 0) return 0;
|
||
|
||
const graphemes = [...segmenter.segment(text.slice(0, i))];
|
||
if (graphemes.length === 0) return 0;
|
||
|
||
// Skip trailing whitespace.
|
||
while (graphemes.length > 0 && getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === "whitespace") {
|
||
i -= graphemes.pop()?.segment.length || 0;
|
||
}
|
||
if (i === 0 || graphemes.length === 0) return i;
|
||
|
||
const kind = getWordNavKind(graphemes[graphemes.length - 1]?.segment || "");
|
||
if (kind === "delimiter" || kind === "cjk") {
|
||
while (graphemes.length > 0 && getWordNavKind(graphemes[graphemes.length - 1]?.segment || "") === kind) {
|
||
i -= graphemes.pop()?.segment.length || 0;
|
||
}
|
||
return i;
|
||
}
|
||
|
||
if (kind === "word") {
|
||
// Skip word run (letters/numbers/underscore), keeping common joiners inside words.
|
||
let hasRightWord = false;
|
||
while (graphemes.length > 0) {
|
||
const g = graphemes[graphemes.length - 1]?.segment || "";
|
||
const k = getWordNavKind(g);
|
||
if (k === "word") {
|
||
hasRightWord = true;
|
||
i -= graphemes.pop()?.segment.length || 0;
|
||
continue;
|
||
}
|
||
if (hasRightWord && k === "delimiter" && isWordNavJoiner(g)) {
|
||
const left = graphemes[graphemes.length - 2]?.segment || "";
|
||
if (getWordNavKind(left) === "word") {
|
||
i -= graphemes.pop()?.segment.length || 0;
|
||
continue;
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
return i;
|
||
}
|
||
|
||
// Fallback: move by one grapheme.
|
||
i -= graphemes.pop()?.segment.length || 0;
|
||
return Math.max(0, i);
|
||
}
|
||
|
||
/**
|
||
* Move the cursor one "word" to the right using Unicode-aware coarse navigation.
|
||
*
|
||
* Returns a new cursor index in the range [0, text.length].
|
||
*/
|
||
export function moveWordRight(text: string, cursor: number): number {
|
||
const len = text.length;
|
||
if (len === 0) return 0;
|
||
let i = Math.min(Math.max(cursor, 0), len);
|
||
if (i === len) return len;
|
||
|
||
const iterator = segmenter.segment(text.slice(i))[Symbol.iterator]();
|
||
let next = iterator.next();
|
||
|
||
// Skip leading whitespace.
|
||
while (!next.done && getWordNavKind(next.value.segment) === "whitespace") {
|
||
i += next.value.segment.length;
|
||
next = iterator.next();
|
||
}
|
||
if (next.done) return i;
|
||
|
||
const firstKind = getWordNavKind(next.value.segment);
|
||
if (firstKind === "delimiter" || firstKind === "cjk") {
|
||
while (!next.done && getWordNavKind(next.value.segment) === firstKind) {
|
||
i += next.value.segment.length;
|
||
next = iterator.next();
|
||
}
|
||
return i;
|
||
}
|
||
|
||
if (firstKind === "word") {
|
||
let hasLeftWord = false;
|
||
while (!next.done) {
|
||
const segment = next.value.segment;
|
||
const k = getWordNavKind(segment);
|
||
if (k === "word") {
|
||
hasLeftWord = true;
|
||
i += segment.length;
|
||
next = iterator.next();
|
||
continue;
|
||
}
|
||
if (hasLeftWord && k === "delimiter" && isWordNavJoiner(segment)) {
|
||
const lookahead = iterator.next();
|
||
if (!lookahead.done && getWordNavKind(lookahead.value.segment) === "word") {
|
||
i += segment.length;
|
||
next = lookahead;
|
||
continue;
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
return i;
|
||
}
|
||
|
||
// Fallback: move by one grapheme.
|
||
return i + next.value.segment.length;
|
||
}
|
||
|
||
/**
|
||
* Apply background color to a line, padding to full width.
|
||
*
|
||
* @param line - Line of text (may contain ANSI codes)
|
||
* @param width - Total width to pad to
|
||
* @param bgFn - Background color function
|
||
* @returns Line with background applied and padded to width
|
||
*/
|
||
export function applyBackgroundToLine(line: string, width: number, bgFn: (text: string) => string): string {
|
||
// Calculate padding needed
|
||
const visibleLen = visibleWidth(line);
|
||
const paddingNeeded = Math.max(0, width - visibleLen);
|
||
|
||
// Apply background to content + padding
|
||
const withPadding = line + padding(paddingNeeded);
|
||
return bgFn(withPadding);
|
||
}
|
||
|
||
/**
|
||
* Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
|
||
*
|
||
* @param strict - If true, exclude wide chars at boundary that would extend past the range
|
||
*/
|
||
export function sliceByColumn(line: string, startCol: number, length: number, strict = false): string {
|
||
return sliceWithWidth(line, startCol, length, strict).text;
|
||
}
|