Merge PR #382: word wrapping in Editor component

This commit is contained in:
Mario Zechner 2026-01-03 00:48:28 +01:00
commit 2b054cdb7c
150 changed files with 10122 additions and 4285 deletions

View file

@ -0,0 +1,39 @@
import { isEscape } from "../keys.js";
import { Loader } from "./loader.js";
/**
* Loader that can be cancelled with Escape.
* Extends Loader with an AbortSignal for cancelling async operations.
*
* @example
* const loader = new CancellableLoader(tui, cyan, dim, "Working...");
* loader.onAbort = () => done(null);
* doWork(loader.signal).then(done);
*/
export class CancellableLoader extends Loader {
private abortController = new AbortController();
/** Called when user presses Escape */
onAbort?: () => void;
/** AbortSignal that is aborted when user presses Escape */
get signal(): AbortSignal {
return this.abortController.signal;
}
/** Whether the loader was aborted */
get aborted(): boolean {
return this.abortController.signal.aborted;
}
handleInput(data: string): void {
if (isEscape(data)) {
this.abortController.abort();
this.onAbort?.();
}
}
dispose(): void {
this.stop();
}
}

View file

@ -22,7 +22,10 @@ import {
isEnter,
isEscape,
isHome,
isShiftBackspace,
isShiftDelete,
isShiftEnter,
isShiftSpace,
isTab,
} from "../keys.js";
import type { Component } from "../tui.js";
@ -637,8 +640,8 @@ export class Editor implements Component {
this.onSubmit(result);
}
}
// Backspace
else if (isBackspace(data)) {
// Backspace (including Shift+Backspace)
else if (isBackspace(data) || isShiftBackspace(data)) {
this.handleBackspace();
}
// Line navigation shortcuts (Home/End keys)
@ -647,8 +650,8 @@ export class Editor implements Component {
} else if (isEnd(data)) {
this.moveToLineEnd();
}
// Forward delete (Fn+Backspace or Delete key)
else if (isDelete(data)) {
// Forward delete (Fn+Backspace or Delete key, including Shift+Delete)
else if (isDelete(data) || isShiftDelete(data)) {
this.handleForwardDelete();
}
// Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
@ -683,6 +686,10 @@ export class Editor implements Component {
// Left
this.moveCursor(0, -1);
}
// Shift+Space - insert regular space (Kitty protocol sends escape sequence)
else if (isShiftSpace(data)) {
this.insertCharacter(" ");
}
// Regular characters (printable characters and unicode, but not control characters)
else if (data.charCodeAt(0) >= 32) {
this.insertCharacter(data);

View file

@ -1,6 +1,6 @@
import { isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape } from "../keys.js";
import type { Component } from "../tui.js";
import { truncateToWidth, visibleWidth } from "../utils.js";
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js";
export interface SettingItem {
/** Unique identifier for this setting */
@ -123,7 +123,10 @@ export class SettingsList implements Component {
const selectedItem = this.items[this.selectedIndex];
if (selectedItem?.description) {
lines.push("");
lines.push(this.theme.description(` ${truncateToWidth(selectedItem.description, width - 4, "")}`));
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
for (const line of wrappedDesc) {
lines.push(this.theme.description(` ${line}`));
}
}
// Add hint

View file

@ -9,6 +9,7 @@ export {
} from "./autocomplete.js";
// Components
export { Box } from "./components/box.js";
export { CancellableLoader } from "./components/cancellable-loader.js";
export { Editor, type EditorTheme } from "./components/editor.js";
export { Image, type ImageOptions, type ImageTheme } from "./components/image.js";
export { Input } from "./components/input.js";

View file

@ -42,6 +42,7 @@ const CODEPOINTS = {
escape: 27,
tab: 9,
enter: 13,
space: 32,
backspace: 127,
} as const;
@ -464,6 +465,15 @@ export function isBackspace(data: string): boolean {
return data === "\x7f" || data === "\x08" || matchesKittySequence(data, CODEPOINTS.backspace, 0);
}
/**
* Check if input matches Shift+Backspace (Kitty protocol).
* Returns true so caller can treat it as regular backspace.
* Ignores lock key bits.
*/
export function isShiftBackspace(data: string): boolean {
return matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.shift);
}
/**
* Check if input matches Shift+Enter.
* Ignores lock key bits.
@ -480,6 +490,15 @@ export function isAltEnter(data: string): boolean {
return data === Keys.ALT_ENTER || data === "\x1b\r" || matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt);
}
/**
* Check if input matches Shift+Space (Kitty protocol).
* Returns true so caller can insert a regular space.
* Ignores lock key bits.
*/
export function isShiftSpace(data: string): boolean {
return matchesKittySequence(data, CODEPOINTS.space, MODIFIERS.shift);
}
/**
* Check if input matches Option/Alt+Left (word navigation).
* Handles multiple formats including Kitty protocol.
@ -545,3 +564,12 @@ export function isEnd(data: string): boolean {
export function isDelete(data: string): boolean {
return data === "\x1b[3~" || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0);
}
/**
* Check if input matches Shift+Delete (Kitty protocol).
* Returns true so caller can treat it as regular delete.
* Ignores lock key bits.
*/
export function isShiftDelete(data: string): boolean {
return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, MODIFIERS.shift);
}

View file

@ -26,6 +26,9 @@ export interface Terminal {
clearLine(): void; // Clear current line
clearFromCursor(): void; // Clear from cursor to end of screen
clearScreen(): void; // Clear entire screen and move cursor to (0,0)
// Title operations
setTitle(title: string): void; // Set terminal window title
}
/**
@ -127,4 +130,9 @@ export class ProcessTerminal implements Terminal {
clearScreen(): void {
process.stdout.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
}
setTitle(title: string): void {
// OSC 0;title BEL - set terminal window title
process.stdout.write(`\x1b]0;${title}\x07`);
}
}

View file

@ -1,11 +1,135 @@
import stringWidth from "string-width";
import { eastAsianWidth } from "get-east-asian-width";
// Grapheme segmenter (shared instance)
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
/**
* Get the shared grapheme segmenter instance.
*/
export function getSegmenter(): Intl.Segmenter {
return segmenter;
}
/**
* Check if a grapheme cluster (after segmentation) could possibly be an RGI emoji.
* This is a fast heuristic to avoid the expensive rgiEmojiRegex test.
* The tested Unicode blocks are deliberately broad to account for future
* Unicode additions.
*/
function couldBeEmoji(segment: string): boolean {
const cp = segment.codePointAt(0)!;
return (
(cp >= 0x1f000 && cp <= 0x1fbff) || // Emoji and Pictograph
(cp >= 0x2300 && cp <= 0x23ff) || // Misc technical
(cp >= 0x2600 && cp <= 0x27bf) || // Misc symbols, dingbats
(cp >= 0x2b50 && cp <= 0x2b55) || // Specific stars/circles
segment.includes("\uFE0F") || // Contains VS16 (emoji presentation selector)
segment.length > 2 // Multi-codepoint sequences (ZWJ, skin tones, etc.)
);
}
// Regexes for character classification (same as string-width library)
const zeroWidthRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
// Cache for non-ASCII strings
const WIDTH_CACHE_SIZE = 512;
const widthCache = new Map<string, number>();
/**
* Calculate the terminal width of a single grapheme cluster.
* Based on code from the string-width library, but includes a possible-emoji
* check to avoid running the RGI_Emoji regex unnecessarily.
*/
function graphemeWidth(segment: string): number {
// Zero-width clusters
if (zeroWidthRegex.test(segment)) {
return 0;
}
// Emoji check with pre-filter
if (couldBeEmoji(segment) && rgiEmojiRegex.test(segment)) {
return 2;
}
// Get base visible codepoint
const base = segment.replace(leadingNonPrintingRegex, "");
const cp = base.codePointAt(0);
if (cp === undefined) {
return 0;
}
let width = eastAsianWidth(cp);
// Trailing halfwidth/fullwidth forms
if (segment.length > 1) {
for (const char of segment.slice(1)) {
const c = char.codePointAt(0)!;
if (c >= 0xff00 && c <= 0xffef) {
width += eastAsianWidth(c);
}
}
}
return width;
}
/**
* Calculate the visible width of a string in terminal columns.
*/
export function visibleWidth(str: string): number {
const normalized = str.replace(/\t/g, " ");
return stringWidth(normalized);
if (str.length === 0) {
return 0;
}
// Fast path: pure ASCII printable
let isPureAscii = true;
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code < 0x20 || code > 0x7e) {
isPureAscii = false;
break;
}
}
if (isPureAscii) {
return str.length;
}
// Check cache
const cached = widthCache.get(str);
if (cached !== undefined) {
return cached;
}
// Normalize: tabs to 3 spaces, strip ANSI escape codes
let clean = str;
if (str.includes("\t")) {
clean = clean.replace(/\t/g, " ");
}
if (clean.includes("\x1b")) {
// Strip SGR codes (\x1b[...m) and cursor codes (\x1b[...G/K/H/J)
clean = clean.replace(/\x1b\[[0-9;]*[mGKHJ]/g, "");
// Strip OSC 8 hyperlinks: \x1b]8;;URL\x07 and \x1b]8;;\x07
clean = clean.replace(/\x1b\]8;;[^\x07]*\x07/g, "");
}
// Calculate width
let width = 0;
for (const { segment } of segmenter.segment(clean)) {
width += graphemeWidth(segment);
}
// Cache result
if (widthCache.size >= WIDTH_CACHE_SIZE) {
const firstKey = widthCache.keys().next().value;
if (firstKey !== undefined) {
widthCache.delete(firstKey);
}
}
widthCache.set(str, width);
return width;
}
/**
@ -406,15 +530,6 @@ function wrapSingleLine(line: string, width: number): string[] {
return wrapped.length > 0 ? wrapped : [""];
}
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
/**
* Get the shared grapheme segmenter instance.
*/
export function getSegmenter(): Intl.Segmenter {
return segmenter;
}
const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
/**
@ -472,6 +587,9 @@ function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): s
}
const grapheme = seg.value;
// Skip empty graphemes to avoid issues with string-width calculation
if (!grapheme) continue;
const graphemeWidth = visibleWidth(grapheme);
if (currentWidth + graphemeWidth > width) {
@ -576,6 +694,9 @@ export function truncateToWidth(text: string, maxWidth: number, ellipsis: string
}
const grapheme = seg.value;
// Skip empty graphemes to avoid issues with string-width calculation
if (!grapheme) continue;
const graphemeWidth = visibleWidth(grapheme);
if (currentWidth + graphemeWidth > targetWidth) {