@teaui/core
Version:
A high-level terminal UI library for Node
1,413 lines • 49.7 kB
JavaScript
import * as unicode from '@teaui/term';
import { isKeyPrintable } from '../events/index.js';
import { View } from '../View.js';
import { Style } from '../Style.js';
import { Point, Size } from '../geometry.js';
import { FONTS } from './fonts.js';
const NL_SIGIL = '⤦';
const TAB_SIGIL = '⭾ ';
const TAB_SPACES = ' ';
/**
* Text input. Supports selection, word movement via alt+←→, single and multiline
* input, and wrapped lines.
*/
export class Input extends View {
/**
* Array of graphemes, with pre-calculated length
*/
#placeholder = [];
#printableLines = [];
/**
* Cached after assignment - this is converted to #chars and #lines
*/
#value = '';
/**
* For easy edit operations. Gets converted to #lines for printing.
*/
#chars = [];
#wrappedLines = [];
// formatting options
#wrap = false;
#multiline = false;
#font = 'default';
#format;
#formatStyles = [];
#showInvisibles = true;
#onChange;
#onSubmit;
// Printable width
#maxLineWidth = 0;
#cursor = { start: 0, end: 0 };
#visibleWidth = 0;
// Undo/redo
#undoStack = [];
#redoStack = [];
#pendingUndoKind;
#preEditChars = [];
#preEditCursor = { start: 0, end: 0 };
#insertCoalesceEnabled = true;
constructor(props = {}) {
super(props);
this.#update(props);
this.#cursor = { start: this.#chars.length, end: this.#chars.length };
}
update(props) {
this.#update(props);
super.update(props);
}
#update({ value, wrap, multiline, font, format, placeholder, onChange, onSubmit, }) {
this.#onChange = onChange;
this.#onSubmit = onSubmit;
this.#wrap = wrap ?? false;
this.#multiline = multiline ?? false;
this.#format = format;
this.#updatePlaceholderLines(placeholder ?? '');
this.#updateLines(unicode.printableChars(value ?? ''), font ?? 'default');
}
#updatePlaceholderLines(placeholder) {
const placeholderLines = placeholder === ''
? []
: placeholder.split('\n').map(line => unicode.printableChars(line));
this.#placeholder = placeholderLines.map(line => [
line,
line.reduce((w, c) => w + unicode.charWidth(c), 0),
]);
}
#updateLines(_chars, font) {
let chars = _chars ?? this.#chars;
if (font === undefined) {
font = this.#font;
}
else {
this.#font = font;
}
const startIsAtEnd = this.#cursor.start === this.#chars.length;
const endIsAtEnd = this.#cursor.end === this.#chars.length;
if (chars.length > 0) {
if (!this.#multiline) {
chars = chars.map(char => (char === '\n' ? ' ' : char));
}
this.#value = chars.filter(char => !isAccentChar(char)).join('');
this.#chars = chars;
const [charLines] = this.#chars.reduce(([lines, line], char, index) => {
if (char === '\n') {
lines.push(line);
if (index === this.#chars.length - 1) {
lines.push([]);
}
return [lines, []];
}
line.push(char);
if (index === this.#chars.length - 1) {
lines.push(line);
return [lines, []];
}
return [lines, line];
}, [[], []]);
this.#printableLines = charLines.map((printableLine, index, all) => {
const displayLine = printableLine;
// every line needs a ' ' or NL_SIGIL at the end, for the EOL cursor
return [
displayLine.concat(index === all.length - 1 ? ' ' : NL_SIGIL),
displayLine.reduce((width, char) => width + unicode.charWidth(char), 0) + 1,
];
});
}
else {
this.#value = '';
this.#printableLines = this.#placeholder.map(([line, width]) => {
return [line.concat(' '), width];
});
}
this.#visibleWidth = 0;
if (endIsAtEnd) {
this.#cursor.end = this.#chars.length;
}
else {
this.#cursor.end = Math.min(this.#cursor.end, this.#chars.length);
}
if (startIsAtEnd) {
this.#cursor.start = this.#chars.length;
}
else {
this.#cursor.start = Math.min(this.#cursor.start, this.#chars.length);
}
this.#maxLineWidth = this.#printableLines.reduce((maxWidth, [, width]) => {
// the _printable_ width, not the number of characters
return Math.max(maxWidth, width);
}, 0);
this.#updateFormatStyles();
this.invalidateSize();
}
/**
* Run the format function on the current value and parse the ANSI output into
* a flat Style[] array with one entry per character in #chars.
*/
#updateFormatStyles() {
if (!this.#format || !this.#chars.length) {
this.#formatStyles = [];
return;
}
let formatted;
try {
formatted = this.#format(this.#value);
}
catch {
this.#formatStyles = [];
return;
}
// Parse the ANSI output to extract a style per character.
// Walk through the formatted string, tracking ANSI state.
const styles = [];
let currentStyle = Style.NONE;
for (const token of unicode.printableChars(formatted)) {
const charWidth = unicode.charWidth(token);
if (charWidth === 0) {
// ANSI escape sequence — update current style.
// Full resets (ESC[0m) clear back to no styling.
if (RESET_RE.test(token)) {
currentStyle = Style.NONE;
}
else {
currentStyle = currentStyle.merge(Style.fromSGR(token, Style.NONE));
}
}
else {
styles.push(currentStyle);
}
}
this.#formatStyles = styles;
}
get value() {
return this.#value;
}
set value(value) {
if (value !== this.#value) {
this.#updateLines(unicode.printableChars(value), undefined);
}
}
get placeholder() {
return this.#placeholder.map(([chars]) => chars.join('')).join('\n');
}
set placeholder(placeholder) {
this.#updatePlaceholderLines(placeholder ?? '');
}
get font() {
return this.#font;
}
set font(font) {
if (font !== this.#font) {
this.#updateLines(undefined, font);
}
}
get wrap() {
return this.#wrap;
}
set wrap(wrap) {
if (wrap !== this.#wrap) {
this.#wrap = wrap;
this.#updateLines(undefined, undefined);
}
}
get multiline() {
return this.#multiline;
}
set multiline(multiline) {
if (multiline !== this.#multiline) {
this.#multiline = multiline;
this.#updateLines(undefined, undefined);
}
}
legendItems() {
const items = [];
items.push({ key: ['C-a', 'home'], label: 'Line Start' }, { key: ['C-e', 'end'], label: 'Line End' }, { key: 'A-,', label: 'Start' }, { key: 'A-.', label: 'End' });
if (this.#multiline) {
items.push({ key: 'C-]', label: 'Indent' }, { key: 'C-[', label: 'Dedent' }, { key: 'A-click', label: 'Toggle Invisibles' });
}
items.push({ key: 'C-z', label: 'Undo' }, { key: 'C-S-z', label: 'Redo' });
return items;
}
naturalSize(available) {
let lines = this.#printableLines;
if (!lines.length || !available.width) {
return Size.one;
}
let height = 0;
if (this.#wrap) {
for (const [, width] of lines) {
// width + 1 because there should always be room for the cursor to be _after_
// the last character.
height += Math.ceil((width + 1) / available.width);
}
}
else {
height = lines.length;
}
return new Size(this.#maxLineWidth, height);
}
minSelected() {
return Math.min(this.#cursor.start, this.#cursor.end);
}
maxSelected() {
return isEmptySelection(this.#cursor)
? this.#cursor.start + 1
: Math.max(this.#cursor.start, this.#cursor.end);
}
receiveKey(event) {
const prevChars = this.#chars;
const prevText = this.#value;
let removeAccent = true;
if (event.full === 'C-z' || event.full === 'C--') {
this.#undo();
}
else if (event.full === 'C-S-z' || event.full === 'C-S--') {
this.#redo();
}
else if (event.name === 'enter' || event.name === 'return') {
if (this.#multiline) {
this.#beginEdit('replace');
if (event.shift || event.alt) {
// Shift+Enter or Alt+Enter: plain newline without indentation
this.#receiveChar('\n', true);
}
else {
// Enter: newline + preserve current line's indentation
this.#receiveEnterWithIndent();
}
}
else {
this.#onSubmit?.(this.#value);
return;
}
}
else if (event.full === 'C-]') {
this.#beginEdit('replace');
this.#receiveIndent();
}
else if (event.full === 'C-[') {
this.#beginEdit('replace');
this.#receiveDedent();
}
else if (event.name === 'tab' && event.alt) {
this.#beginEdit('insert');
this.#receiveChar('\t', true);
}
else if (event.full === 'C-a' || event.name === 'home') {
this.#insertCoalesceEnabled = false;
this.#receiveHome(event);
}
else if (event.full === 'C-e' || event.name === 'end') {
this.#insertCoalesceEnabled = false;
this.#receiveEnd(event);
}
else if (event.full === 'A-,' ||
event.full === 'A-S-,' ||
event.full === 'A-S-<') {
this.#insertCoalesceEnabled = false;
this.#receiveGotoStart(event);
}
else if (event.full === 'A-.' ||
event.full === 'A-S-.' ||
event.full === 'A-S->') {
this.#insertCoalesceEnabled = false;
this.#receiveGotoEnd(event);
}
else if (event.name === 'up') {
this.#insertCoalesceEnabled = false;
this.#receiveKeyUpArrow(event);
}
else if (event.name === 'down') {
this.#insertCoalesceEnabled = false;
this.#receiveKeyDownArrow(event);
}
else if (event.name === 'left') {
this.#insertCoalesceEnabled = false;
this.#receiveKeyLeftArrow(event);
}
else if (event.name === 'right') {
this.#insertCoalesceEnabled = false;
this.#receiveKeyRightArrow(event);
}
else if (event.full === 'backspace') {
this.#beginEdit('delete');
this.#receiveKeyBackspace();
}
else if (event.name === 'delete') {
this.#beginEdit('delete');
this.#receiveKeyDelete();
}
else if (event.full === 'A-backspace' || event.full === 'C-w') {
this.#beginEdit('delete');
this.#receiveKeyDeleteWord();
}
else if (isKeyAccent(event)) {
this.#beginEdit('insert');
this.#receiveKeyAccent(event);
removeAccent = false;
}
else if (!event.ctrl &&
!event.alt &&
!event.gui &&
isKeyPrintable(event)) {
this.#beginEdit('insert');
this.#receiveKeyPrintable(event);
}
if (removeAccent) {
this.#chars = this.#chars.filter(char => !isAccentChar(char));
}
if (prevChars !== this.#chars) {
this.#commitEdit();
this.#updateLines(this.#chars, undefined);
}
else {
this.#pendingUndoKind = undefined;
}
if (prevText !== this.#value) {
this.#onChange?.(this.#value);
}
}
receivePaste(text) {
const pasteChars = unicode.printableChars(this.#multiline ? text : text.replaceAll('\n', ''));
if (pasteChars.length === 0)
return;
this.#beginEdit('replace');
const prevText = this.#value;
if (isEmptySelection(this.#cursor)) {
this.#chars = this.#chars
.slice(0, this.#cursor.start)
.concat(pasteChars, this.#chars.slice(this.#cursor.start));
this.#cursor.start = this.#cursor.end =
this.#cursor.start + pasteChars.length;
}
else {
this.#chars = this.#chars
.slice(0, this.minSelected())
.concat(pasteChars, this.#chars.slice(this.maxSelected()));
this.#cursor.start = this.#cursor.end =
this.minSelected() + pasteChars.length;
}
this.#commitEdit();
this.#updateLines(this.#chars, undefined);
if (prevText !== this.#value) {
this.#onChange?.(this.#value);
}
}
receiveMouse(event, system) {
if (event.name === 'mouse.button.down') {
system.requestFocus();
if (event.alt) {
this.#showInvisibles = !this.#showInvisibles;
this.invalidateRender();
}
}
}
render(viewport) {
// Register focus before the isEmpty check — Input should participate in the
// focus ring even when clipped to zero size (e.g. inside a Scrollable that
// hasn't scrolled to it). Skipping registration would silently drop it from
// the ring, causing focus to jump unexpectedly when the user tabs through.
const hasFocus = viewport.registerFocus({ isDefault: true });
if (viewport.isEmpty) {
return;
}
const visibleSize = viewport.contentSize;
if (hasFocus) {
viewport.registerTick();
}
viewport.registerMouse('mouse.button.left');
// cursorEnd: the location of the cursor relative to the text
// (ie if the text had been drawn at 0,0, cursorEnd is the screen location of
// the cursor)
// cursorPosition: the location of the cursor relative to the viewport
const [cursorEnd, cursorPosition] = this.#cursorPosition(visibleSize);
const cursorMin = this.#toPosition(this.minSelected(), visibleSize.width);
const cursorMax = this.#toPosition(this.maxSelected(), visibleSize.width);
// cursorVisible: the text location of the first line & char to draw
const cursorVisible = new Point(cursorEnd.x - cursorPosition.x, cursorEnd.y - cursorPosition.y);
let lines = this.#printableLines;
if (visibleSize.width !== this.#visibleWidth ||
this.#wrappedLines.length === 0) {
if (this.#wrap) {
lines = lines.flatMap(line => {
const wrappedLines = [];
let currentLine = [];
let currentWidth = 0;
for (const char of line[0]) {
const charWidth = unicode.charWidth(char);
currentLine.push(char);
currentWidth += charWidth;
if (currentWidth >= visibleSize.width) {
wrappedLines.push([currentLine, currentWidth]);
currentLine = [];
currentWidth = 0;
}
}
if (currentLine.length) {
wrappedLines.push([currentLine, currentWidth]);
}
return wrappedLines;
});
}
this.#wrappedLines = lines;
this.#visibleWidth = visibleSize.width;
}
else {
lines = this.#wrappedLines;
}
let isPlaceholder = !this.#chars.length;
let currentStyle = Style.NONE;
const plainStyle = this.purpose.text({
isPlaceholder,
hasFocus,
});
const selectedStyle = this.purpose.text({
isSelected: true,
hasFocus,
});
const nlStyle = this.purpose.text({ isPlaceholder: true });
const fontMap = this.#font && FONTS[this.#font];
const hasFormatStyles = this.#formatStyles.length > 0;
viewport.usingPen(pen => {
let style = plainStyle;
const visibleLines = lines.slice(cursorVisible.y);
if (visibleLines.length === 0) {
visibleLines.push([[' '], 0]);
}
// Compute the character offset into the format styles array at the start
// of visible lines. Each line's chars (excluding the trailing sigil) map
// 1:1 to format style entries. Newlines do NOT have entries in the styles
// array (charWidth('\n') === 0, so they're skipped during parsing).
let formatOffset = 0;
if (hasFormatStyles) {
for (let i = 0; i < cursorVisible.y && i < lines.length; i++) {
formatOffset += lines[i][0].length - 1;
}
}
// is the viewport tall/wide enough to show ellipses …
const isTallEnough = viewport.contentSize.height > 4;
const isWideEnough = viewport.contentSize.width > 9;
// do we need to show vertical ellipses
const isTooTall = visibleLines.length > visibleSize.height;
// firstPoint is top-left corner of the viewport
const firstPoint = new Point(0, cursorVisible.y);
// lastPoint is bottom-right corner of the viewport
const lastPoint = new Point(visibleSize.width + cursorVisible.x - 1, cursorVisible.y + visibleSize.height - 1);
let scanTextPosition = firstPoint.mutableCopy();
for (const [line, width] of visibleLines) {
// used to determine whether to draw a final …
const isTooWide = this.#wrap
? false
: width - cursorVisible.x > viewport.contentSize.width;
// set to true if any character is skipped
let drawInitialEllipses = false;
scanTextPosition.x = 0;
let charIndex = 0;
for (let char of line) {
char = fontMap?.get(char) ?? char;
const charWidth = unicode.charWidth(char);
const isSigil = charIndex === line.length - 1;
if (scanTextPosition.x >= cursorVisible.x) {
const inSelection = isInSelection(cursorMin, cursorMax, scanTextPosition);
const inCursor = scanTextPosition.x === cursorEnd.x &&
scanTextPosition.y === cursorEnd.y;
const inNewline = char === NL_SIGIL && scanTextPosition.x + charWidth === width;
const inTab = isTabSigil(char);
const isDimSigil = this.#showInvisibles && (inNewline || inTab);
// Look up the format style for this character.
const formatStyle = hasFormatStyles && !isSigil && !inTab
? this.#formatStyles[formatOffset + charIndex]
: undefined;
const baseStyle = formatStyle
? plainStyle.merge(formatStyle)
: plainStyle;
if (isEmptySelection(this.#cursor)) {
if (isAccentChar(char)) {
style = baseStyle.merge({ underline: true, inverse: true });
}
else if (hasFocus && inCursor) {
style = isDimSigil
? nlStyle.merge({ underline: true })
: baseStyle.merge({ underline: true });
}
else if (isDimSigil) {
style = nlStyle;
}
else {
style = baseStyle;
}
}
else {
if (inSelection) {
style = isDimSigil
? selectedStyle.merge({ foreground: nlStyle.foreground })
: selectedStyle.merge({ underline: inCursor });
}
else if (isDimSigil) {
style = nlStyle;
}
else {
style = baseStyle;
}
}
if (!currentStyle.isEqual(style)) {
pen.replacePen(style);
currentStyle = style;
}
let drawEllipses = false;
if (cursorVisible.y > 0 && scanTextPosition.isEqual(firstPoint)) {
drawEllipses = isTallEnough;
}
else if (isTooTall && scanTextPosition.isEqual(lastPoint)) {
drawEllipses = isTallEnough;
}
else if (isWideEnough) {
if (drawInitialEllipses) {
drawEllipses = true;
}
else if (isTooWide &&
scanTextPosition.x - cursorVisible.x + charWidth >=
viewport.contentSize.width) {
drawEllipses = true;
}
}
if (!drawEllipses && char === '\t') {
// Render tab as a two-cell marker, or as spaces when hidden.
const offset = scanTextPosition.offset(-cursorVisible.x, -cursorVisible.y);
const tabText = this.#showInvisibles ? TAB_SIGIL : TAB_SPACES;
viewport.write(tabText[0], offset);
viewport.write(tabText[1], offset.offset(1, 0));
}
else {
viewport.write(drawEllipses ? '…' : this.#displayChar(char), scanTextPosition.offset(-cursorVisible.x, -cursorVisible.y));
}
drawInitialEllipses = false;
}
else {
drawInitialEllipses = true;
}
scanTextPosition.x += charWidth;
charIndex++;
if (scanTextPosition.x - cursorVisible.x >=
viewport.contentSize.width) {
break;
}
}
// Advance formatOffset past this line's chars (excluding the trailing sigil).
if (hasFormatStyles) {
formatOffset += line.length - 1;
}
scanTextPosition.y += 1;
if (scanTextPosition.y - cursorVisible.y >=
viewport.contentSize.height) {
break;
}
}
});
}
#displayChar(char) {
if (!this.#showInvisibles && char === NL_SIGIL) {
return ' ';
}
return char;
}
/**
* The position of the character that is at the desired cursor offset, taking
* character widths into account, relative to the text (as if the text were drawn
* at 0,0), and 'wrap' setting.
*/
#toPosition(offset, visibleWidth) {
if (this.#wrap) {
let y = 0, index = 0;
let x = 0;
// immediately after a line wrap, we don't want to also increase y by 1
let isFirst = true;
for (const [chars] of this.#printableLines) {
if (!isFirst) {
y += 1;
}
isFirst = false;
x = 0;
for (const char of chars) {
if (index === offset) {
if (x === visibleWidth) {
x = 0;
y += 1;
}
return new Point(x, y);
}
const charWidth = unicode.charWidth(char);
if (x + charWidth > visibleWidth) {
x = charWidth;
y += 1;
}
else {
x += charWidth;
}
index += 1;
}
}
return new Point(x, y);
}
let y = 0, index = 0;
for (const [chars] of this.#printableLines) {
if (index + chars.length > offset) {
let x = 0;
for (const char of chars.slice(0, offset - index)) {
x += unicode.charWidth(char);
}
return new Point({ x, y });
}
index += chars.length;
y += 1;
}
return new Point(0, y);
}
/**
* Determine the position of the cursor, relative to the viewport, based on the
* text and viewport sizes.
*
* The cursor is placed so that it will appear at the start or end of the viewport
* when it is near the start or end of the line, otherwise it tries to be centered.
*/
#cursorPosition(visibleSize) {
const halfWidth = Math.floor(visibleSize.width / 2);
const halfHeight = Math.floor(visibleSize.height / 2);
// the cursor, relative to the start of text (as if all text was visible),
// ie in the "coordinate system" of the text.
let cursorEnd = this.#toPosition(this.#cursor.end, visibleSize.width);
let currentLineWidth, totalHeight;
if (!this.#printableLines.length) {
return [cursorEnd, new Point(0, 0)];
}
if (this.#wrap) {
// run through the lines until we get to our desired cursorEnd.y
// but also add all the heights to calculate currentHeight
let h = 0;
currentLineWidth = -1;
totalHeight = 0;
for (const [, width] of this.#printableLines) {
const dh = Math.ceil(width / visibleSize.width);
totalHeight += dh;
if (currentLineWidth === -1 && dh >= cursorEnd.y) {
if (cursorEnd.y - h === dh) {
// the cursor is on the last wrapped line, use modulo divide to calculate the
// last line width, add 1 for the EOL cursor
currentLineWidth = (visibleSize.width % width) + 1;
}
else {
currentLineWidth = visibleSize.width;
}
break;
}
}
currentLineWidth = Math.max(0, currentLineWidth);
}
else {
currentLineWidth = this.#printableLines[cursorEnd.y]?.[1] ?? 0;
totalHeight = this.#printableLines.length;
}
// Calculate the viewport location where the cursor will be drawn
// x location:
let cursorX;
if (currentLineWidth <= visibleSize.width) {
// If the viewport can accommodate the entire line
// draw the cursor at its natural location.
cursorX = cursorEnd.x;
}
else if (cursorEnd.x < halfWidth) {
// If the cursor is at the start of the line
// place the cursor at the start of the viewport
cursorX = cursorEnd.x;
}
else if (cursorEnd.x > currentLineWidth - halfWidth) {
// or if the cursor is at the end of the line
// draw it at the end of the viewport
cursorX = visibleSize.width - currentLineWidth + cursorEnd.x;
}
else {
// otherwise place it in the middle.
cursorX = halfWidth;
}
// y location:
let cursorY;
if (totalHeight <= visibleSize.height) {
// If the viewport can accommodate the entire height
// draw the cursor at its natural location.
cursorY = cursorEnd.y;
}
else if (cursorEnd.y < halfHeight) {
// If the cursor is at the start of the text
// place the cursor at the start of the viewport
cursorY = cursorEnd.y;
}
else if (cursorEnd.y >= totalHeight - halfHeight) {
// or if the cursor is at the end of the text
// draw it at the end of the viewport
cursorY = visibleSize.height - totalHeight + cursorEnd.y;
}
else {
// otherwise place it in the middle.
cursorY = halfHeight;
}
// The viewport location where the cursor will be drawn
return [cursorEnd, new Point(cursorX, cursorY)];
}
#receiveKeyAccent(event) {
this.#chars = this.#chars.filter(char => !isAccentChar(char));
let char = ACCENT_KEYS[event.full];
if (!char) {
return;
}
this.#receiveChar(char, false);
}
#receiveKeyPrintable({ char }) {
if (this.#cursor.start === this.#cursor.end &&
isAccentChar(this.#chars[this.#cursor.start])) {
// if character under cursor is an accent, replace it.
const accented = accentChar(this.#chars[this.#cursor.start], char);
this.#receiveChar(accented, true);
return;
}
this.#receiveChar(char, true);
}
#receiveChar(char, advance) {
if (isEmptySelection(this.#cursor)) {
this.#chars = this.#chars
.slice(0, this.#cursor.start)
.concat(char, this.#chars.slice(this.#cursor.start));
this.#cursor.start = this.#cursor.end =
this.#cursor.start + (advance ? 1 : 0);
}
else {
this.#chars = this.#chars
.slice(0, this.minSelected())
.concat(char, this.#chars.slice(this.maxSelected()));
this.#cursor.start = this.#cursor.end =
this.minSelected() + (advance ? 1 : 0);
}
}
#receiveGotoStart({ shift }) {
if (shift) {
this.#cursor.end = 0;
}
else {
this.#cursor = { start: 0, end: 0 };
}
}
#receiveGotoEnd({ shift }) {
if (shift) {
this.#cursor.end = this.#chars.length;
}
else {
this.#cursor = { start: this.#chars.length, end: this.#chars.length };
}
}
#receiveHome({ shift }) {
let dest = 0;
// move the cursor to the previous line, moving the cursor until it is at the
// same X position.
let cursorPosition = this.#toPosition(this.#cursor.end, this.#visibleWidth).mutableCopy();
if (cursorPosition.y === 0) {
dest = 0;
}
else {
dest = this.#wrappedLines
.slice(0, cursorPosition.y)
.reduce((dest, [chars]) => {
return dest + chars.length;
}, 0);
}
if (shift) {
this.#cursor.end = dest;
}
else {
this.#cursor = { start: dest, end: dest };
}
}
#receiveEnd({ shift }) {
let dest = 0;
// move the cursor to the next line, moving the cursor until it is at the
// same X position.
let cursorPosition = this.#toPosition(this.#cursor.end, this.#visibleWidth).mutableCopy();
if (cursorPosition.y === this.#wrappedLines.length - 1) {
dest = this.#chars.length;
}
else {
dest =
this.#wrappedLines
.slice(0, cursorPosition.y + 1)
.reduce((dest, [chars]) => {
return dest + chars.length;
}, 0) - 1;
}
if (shift) {
this.#cursor.end = dest;
}
else {
this.#cursor = { start: dest, end: dest };
}
}
#receiveKeyUpArrow({ shift }) {
let dest = 0;
// move the cursor to the previous line, moving the cursor until it is at the
// same X position.
let cursorPosition = this.#toPosition(this.#cursor.end, this.#visibleWidth).mutableCopy();
if (cursorPosition.y === 0) {
dest = 0;
}
else if (cursorPosition.y <= this.#wrappedLines.length) {
const [targetChars, targetWidth] = this.#wrappedLines[cursorPosition.y - 1];
dest = this.#wrappedLines
.slice(0, cursorPosition.y - 1)
.reduce((dest, [chars]) => {
return dest + chars.length;
}, 0);
if (targetWidth <= cursorPosition.x) {
dest += targetChars.length - 1;
}
else {
let displayX = 0;
let destOffset = 0;
for (const char of targetChars) {
const charWidth = unicode.charWidth(char);
if (displayX + charWidth > cursorPosition.x) {
break;
}
displayX += charWidth;
destOffset += 1;
}
dest += destOffset;
}
}
if (shift) {
this.#cursor.end = dest;
}
else {
this.#cursor = { start: dest, end: dest };
}
}
#receiveKeyDownArrow({ shift }) {
let dest = 0;
// move the cursor to the next line, moving the cursor until it is at the
// same X position.
let cursorPosition = this.#toPosition(this.#cursor.end, this.#visibleWidth).mutableCopy();
if (cursorPosition.y === this.#wrappedLines.length - 1 ||
this.#wrappedLines.length === 0) {
dest = this.#chars.length;
}
else {
const [targetChars, targetWidth] = this.#wrappedLines[cursorPosition.y + 1];
dest = this.#wrappedLines
.slice(0, cursorPosition.y + 1)
.reduce((dest, [chars]) => {
return dest + chars.length;
}, 0);
if (targetWidth <= cursorPosition.x) {
dest += targetChars.length - 1;
}
else {
let displayX = 0;
let destOffset = 0;
for (const char of targetChars) {
const charWidth = unicode.charWidth(char);
if (displayX + charWidth > cursorPosition.x) {
break;
}
displayX += charWidth;
destOffset += 1;
}
dest += destOffset;
}
}
if (shift) {
this.#cursor.end = dest;
}
else {
this.#cursor = { start: dest, end: dest };
}
}
#prevWordOffset(shift) {
let cursor;
if (shift) {
cursor = this.#cursor.end;
}
else if (isEmptySelection(this.#cursor)) {
cursor = this.#cursor.start;
}
else {
cursor = this.minSelected();
}
let prevWordOffset = 0;
for (const [chars, offset] of unicode.words(this.#chars)) {
prevWordOffset = offset;
if (cursor <= offset + chars.length) {
break;
}
}
return prevWordOffset;
}
#nextWordOffset(shift) {
let cursor;
if (shift) {
cursor = this.#cursor.end;
}
else if (isEmptySelection(this.#cursor)) {
cursor = this.#cursor.start;
}
else {
cursor = this.maxSelected();
}
let nextWordOffset = 0;
for (const [chars, offset] of unicode.words(this.#chars)) {
nextWordOffset = offset + chars.length;
if (cursor < offset + chars.length) {
break;
}
}
return nextWordOffset;
}
#receiveKeyLeftArrow({ shift, alt }) {
if (alt) {
const prevWordOffset = this.#prevWordOffset(shift);
if (shift) {
this.#cursor.end = prevWordOffset;
}
else {
this.#cursor.start = this.#cursor.end = prevWordOffset;
}
}
else if (shift) {
this.#cursor.end = Math.max(0, this.#cursor.end - 1);
}
else if (isEmptySelection(this.#cursor)) {
this.#cursor.start = this.#cursor.end = Math.max(0, this.#cursor.start - 1);
}
else {
this.#cursor.start = this.#cursor.end = this.minSelected();
}
}
#receiveKeyRightArrow({ shift, alt }) {
if (alt) {
const nextWordOffset = this.#nextWordOffset(shift);
if (shift) {
this.#cursor.end = nextWordOffset;
}
else {
this.#cursor.start = this.#cursor.end = nextWordOffset;
}
}
else if (shift) {
this.#cursor.end = Math.min(this.#chars.length, this.#cursor.end + 1);
}
else if (isEmptySelection(this.#cursor)) {
this.#cursor.start = this.#cursor.end = Math.min(this.#chars.length, this.#cursor.start + 1);
}
else {
this.#cursor.start = this.#cursor.end = this.maxSelected();
}
}
#updateWidth() {
this.#maxLineWidth = this.#chars
.map(unicode.charWidth)
.reduce((a, b) => a + b, 0);
}
#deleteSelection() {
this.#chars = this.#chars
.slice(0, this.minSelected())
.concat(this.#chars.slice(this.maxSelected()));
this.#cursor.start = this.#cursor.end = this.minSelected();
this.#updateWidth();
}
#receiveKeyBackspace() {
if (isEmptySelection(this.#cursor)) {
if (this.#cursor.start === 0) {
return;
}
this.#chars = this.#chars
.slice(0, this.#cursor.start - 1)
.concat(this.#chars.slice(this.#cursor.start));
this.#cursor.start = this.#cursor.end = this.#cursor.start - 1;
}
else {
this.#deleteSelection();
}
}
#receiveKeyDelete() {
if (isEmptySelection(this.#cursor)) {
if (this.#cursor.start > this.#chars.length - 1) {
return;
}
this.#maxLineWidth -= unicode.charWidth(this.#chars[this.#cursor.start]);
this.#chars = this.#chars
.slice(0, this.#cursor.start)
.concat(this.#chars.slice(this.#cursor.start + 1));
}
else {
this.#deleteSelection();
}
}
#receiveKeyDeleteWord() {
if (!isEmptySelection(this.#cursor)) {
return this.#deleteSelection();
}
if (this.#cursor.start === 0) {
return;
}
const offset = this.#prevWordOffset(false);
this.#chars = this.#chars
.slice(0, offset)
.concat(this.#chars.slice(this.#cursor.start));
this.#cursor.start = this.#cursor.end = offset;
this.#updateWidth();
}
/**
* Insert a newline followed by the current line's leading whitespace.
*/
#receiveEnterWithIndent() {
const indent = this.#currentLineIndent();
const chars = ['\n', ...indent];
if (isEmptySelection(this.#cursor)) {
this.#chars = this.#chars
.slice(0, this.#cursor.start)
.concat(chars, this.#chars.slice(this.#cursor.start));
this.#cursor.start = this.#cursor.end = this.#cursor.start + chars.length;
}
else {
this.#chars = this.#chars
.slice(0, this.minSelected())
.concat(chars, this.#chars.slice(this.maxSelected()));
this.#cursor.start = this.#cursor.end = this.minSelected() + chars.length;
}
}
/**
* Indent the current line. Uses tabs if any line starts with a tab,
* otherwise uses two spaces.
*/
#receiveIndent() {
const indent = this.#useTabs() ? ['\t'] : [' ', ' '];
const lineStart = this.#currentLineStart();
this.#chars = this.#chars
.slice(0, lineStart)
.concat(indent, this.#chars.slice(lineStart));
this.#cursor.start += indent.length;
this.#cursor.end += indent.length;
}
/**
* Remove one level of indentation from the current line.
*/
#receiveDedent() {
const lineStart = this.#currentLineStart();
if (this.#chars[lineStart] === '\t') {
this.#chars = this.#chars
.slice(0, lineStart)
.concat(this.#chars.slice(lineStart + 1));
this.#cursor.start = Math.max(lineStart, this.#cursor.start - 1);
this.#cursor.end = Math.max(lineStart, this.#cursor.end - 1);
}
else if (this.#chars[lineStart] === ' ' &&
this.#chars[lineStart + 1] === ' ') {
this.#chars = this.#chars
.slice(0, lineStart)
.concat(this.#chars.slice(lineStart + 2));
this.#cursor.start = Math.max(lineStart, this.#cursor.start - 2);
this.#cursor.end = Math.max(lineStart, this.#cursor.end - 2);
}
}
/**
* Returns the leading whitespace characters of the current line.
*/
#currentLineIndent() {
const lineStart = this.#currentLineStart();
const indent = [];
for (let i = lineStart; i < this.#chars.length; i++) {
if (this.#chars[i] === ' ' || this.#chars[i] === '\t') {
indent.push(this.#chars[i]);
}
else {
break;
}
}
return indent;
}
/**
* Returns the index in #chars where the current line starts.
*/
#currentLineStart() {
const pos = Math.min(this.#cursor.end, this.#chars.length);
for (let i = pos - 1; i >= 0; i--) {
if (this.#chars[i] === '\n') {
return i + 1;
}
}
return 0;
}
/**
* Returns true if any line in the current text starts with a tab character.
*/
#useTabs() {
for (let i = 0; i < this.#chars.length; i++) {
if (this.#chars[i] === '\t' && (i === 0 || this.#chars[i - 1] === '\n')) {
return true;
}
}
return false;
}
/**
* Snapshot the current state before an edit.
*/
#beginEdit(kind) {
this.#pendingUndoKind = kind;
this.#preEditChars = [...this.#chars];
this.#preEditCursor = { ...this.#cursor };
}
/**
* Push the completed edit onto the undo stack. For consecutive inserts at
* adjacent positions, coalesce into a single undo entry.
*/
#commitEdit() {
if (!this.#pendingUndoKind) {
return;
}
const kind = this.#pendingUndoKind;
this.#pendingUndoKind = undefined;
const entry = {
kind,
beforeChars: this.#preEditChars,
beforeCursor: this.#preEditCursor,
afterChars: [...this.#chars],
afterCursor: { ...this.#cursor },
insertOffset: kind === 'insert' ? this.#preEditCursor.start : undefined,
};
// Coalesce consecutive inserts at adjacent positions
if (kind === 'insert' &&
this.#insertCoalesceEnabled &&
this.#undoStack.length > 0) {
const last = this.#undoStack[this.#undoStack.length - 1];
if (last.kind === 'insert' &&
last.afterCursor.start === entry.insertOffset) {
// Extend the previous entry
last.afterChars = entry.afterChars;
last.afterCursor = entry.afterCursor;
this.#redoStack = [];
return;
}
}
this.#insertCoalesceEnabled = kind === 'insert';
this.#undoStack.push(entry);
this.#redoStack = [];
}
#undo() {
const entry = this.#undoStack.pop();
if (!entry) {
return;
}
this.#redoStack.push(entry);
this.#chars = [...entry.beforeChars];
this.#cursor = { ...entry.beforeCursor };
this.#updateLines(this.#chars, undefined);
}
#redo() {
const entry = this.#redoStack.pop();
if (!entry) {
return;
}
this.#undoStack.push(entry);
this.#chars = [...entry.afterChars];
this.#cursor = { ...entry.afterCursor };
this.#updateLines(this.#chars, undefined);
}
}
function isEmptySelection(cursor) {
return cursor.start === cursor.end;
}
function isInSelection(cursorMin, cursorMax, scanTextPosition) {
if (scanTextPosition.y < cursorMin.y || scanTextPosition.y > cursorMax.y) {
return false;
}
if (scanTextPosition.y === cursorMin.y) {
if (scanTextPosition.x < cursorMin.x) {
return false;
}
}
if (scanTextPosition.y === cursorMax.y) {
if (scanTextPosition.x >= cursorMax.x) {
return false;
}
}
return true;
}
function isAccentChar(char) {
return ACCENTS[char] !== undefined;
}
const ACCENTS = {
'‵': {
A: 'À',
E: 'È',
I: 'Ì',
O: 'Ò',
U: 'Ù',
N: 'Ǹ',
a: 'à',
e: 'è',
i: 'ì',
o: 'ò',
u: 'ù',
n: 'ǹ',
},
'¸': {
C: 'Ç',
D: 'Ḑ',
E: 'Ȩ',
G: 'Ģ',
H: 'Ḩ',
K: 'Ķ',
L: 'Ļ',
N: 'Ņ',
R: 'Ŗ',
S: 'Ş',
T: 'Ţ',
c: 'ç',
d: 'ḑ',
e: 'ȩ',
g: 'ģ',
h: 'ḩ',
k: 'ķ',
l: 'ļ',
n: 'ņ',
r: 'ŗ',
s: 'ş',
t: 'ţ',
},
'´': {
A: 'Á',
C: 'Ć',
E: 'É',
G: 'Ǵ',
I: 'Í',
K: 'Ḱ',
L: 'Ĺ',
M: 'Ḿ',
N: 'Ń',
O: 'Ó',
P: 'Ṕ',
R: 'Ŕ',
S: 'Ś',
U: 'Ú',
W: 'Ẃ',
Y: 'Ý',
a: 'á',
c: 'ć',
e: 'é',
g: 'ǵ',
i: 'í',
k: 'ḱ',
l: 'ĺ',
m: 'ḿ',
n: 'ń',
o: 'ó',
p: 'ṕ',
r: 'ŕ',
s: 'ś',
u: 'ú',
w: 'ẃ',
y: 'ý',
},
ˆ: {
A: 'Â',
C: 'Ĉ',
E: 'Ê',
G: 'Ĝ',
H: 'Ĥ',
I: 'Î',
J: 'Ĵ',
O: 'Ô',
S: 'Ŝ',
U: 'Û',
W: 'Ŵ',
Y: 'Ŷ',
a: 'â',
c: 'ĉ',
e: 'ê',
g: 'ĝ',
h: 'ĥ',
i: 'î',
j: 'ĵ',
o: 'ô',
s: 'ŝ',
u: 'û',
w: 'ŵ',
y: 'ŷ',
},
'˜': {
A: 'Ã',
I: 'Ĩ',
N: 'Ñ',
O: 'Õ',
U: 'Ũ',
Y: 'Ỹ',
a: 'ã',
i: 'ĩ',
n: 'ñ',
o: 'õ',
u: 'ũ',
y: 'ỹ',
},
'¯': {
A: 'Ā',
E: 'Ē',
I: 'Ī',
O: 'Ō',
U: 'Ū',
Y: 'Ȳ',
a: 'ā',
e: 'ē',
i: 'ī',
o: 'ō',
u: 'ū',
y: 'ȳ',
},
'¨': {
A: 'Ä',
E: 'Ë',
I: 'Ï',
O: 'Ö',
U: 'Ü',
W: 'Ẅ',
X: 'Ẍ',
Y: 'Ÿ',
a: 'ä',
e: 'ë',
i: 'ï',
o: 'ö',
u: 'ü',
w: 'ẅ',
x: 'ẍ',
y: 'ÿ',
},
};
const ACCENT_KEYS = {
'A-a': '‵',
'A-`': '‵',
'A-c': '¸',
'A-e': '´',
'A-i': 'ˆ',
'A-6': 'ˆ',
'A-n': '˜',
'A-o': '¯',
'A-s': '¸',
'A-u': '¨',
};
function accentChar(accent, char) {
return ACCENTS[accent]?.[char] ?? char;
}
function isKeyAccent(event) {
if (!event.alt || event.ctrl) {
return false;
}
return ACCENT_KEYS[event.full] !== undefined;
}
const RESET_RE = /^\x1b\[0?m$/;
function isTabSigil(char) {
return char === '\t';
}
//# sourceMappingURL=Input.js.map