@flowtty/core 1.0.0-alpha.6 → 1.0.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,17 @@
1
1
  import { B as Buffer } from './cells-CaXEx4lH.js';
2
2
 
3
+ /**
4
+ * Every multi-character key name a backend produces. A printable key's name is
5
+ * the character itself (`' '`, `':'`, `'a'`) — there is no `'space'`, `'enter'`
6
+ * or `'colon'`. Sequences the key parser does not recognize surface under a
7
+ * `csi-…` name, which is deliberately not listed here.
8
+ */
9
+ declare const NAMED_KEYS: readonly ["return", "escape", "tab", "backspace", "delete", "insert", "up", "down", "left", "right", "home", "end", "pageup", "pagedown", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12", "paste", "wheelup", "wheeldown"];
10
+ type NamedKey = typeof NAMED_KEYS[number];
11
+ /** A key name: a named key, or the character itself for a printable key. The
12
+ * `string & {}` arm keeps any character valid while editors still complete the
13
+ * named ones — it cannot reject a typo like `'space'`; `TestBackend.press` does. */
14
+ type KeyName = NamedKey | (string & {});
3
15
  interface Key {
4
16
  /**
5
17
  * Canonical name of the key. For printable ASCII characters this is the
@@ -9,7 +21,7 @@ interface Key {
9
21
  * whose content is in `text`. Mouse wheel steps are 'wheelup' / 'wheeldown',
10
22
  * positioned by `x` / `y`.
11
23
  */
12
- name: string;
24
+ name: KeyName;
13
25
  /**
14
26
  * Payload of a 'paste' key: the pasted text, line endings normalized to `\n`.
15
27
  * A paste is delivered whole — its newlines and letters are text, never
@@ -87,4 +99,4 @@ interface Backend {
87
99
  hyperlinks?: boolean;
88
100
  }
89
101
 
90
- export type { Backend as B, Key as K };
102
+ export { type Backend as B, type Key as K, NAMED_KEYS as N, type KeyName as a, type NamedKey as b };
@@ -0,0 +1,34 @@
1
+ // src/keys.ts
2
+ var NAMED_KEYS = [
3
+ "return",
4
+ "escape",
5
+ "tab",
6
+ "backspace",
7
+ "delete",
8
+ "insert",
9
+ "up",
10
+ "down",
11
+ "left",
12
+ "right",
13
+ "home",
14
+ "end",
15
+ "pageup",
16
+ "pagedown",
17
+ "f1",
18
+ "f2",
19
+ "f3",
20
+ "f4",
21
+ "f5",
22
+ "f6",
23
+ "f7",
24
+ "f8",
25
+ "f9",
26
+ "f10",
27
+ "f11",
28
+ "f12",
29
+ "paste",
30
+ "wheelup",
31
+ "wheeldown"
32
+ ];
33
+
34
+ export { NAMED_KEYS };
@@ -1,5 +1,5 @@
1
- import { C as Container } from '../host-zpuR6C1c.js';
2
- export { B as BORDER_CHARS, H as HostType, I as Instance, R as Rect, T as TextInstance, Y as Yoga, a as YogaNode, b as appendChild, c as applyProps, d as computeLayout, e as createInstance, f as createTextInstance, g as getYoga, i as insertBefore, l as layoutOf, m as measureText, o as ownText, r as refreshMeasure, h as removeChild } from '../host-zpuR6C1c.js';
1
+ import { C as Container } from '../host-6NVC0_E3.js';
2
+ export { B as BORDER_CHARS, H as HostType, I as Instance, R as Rect, T as TextInstance, Y as Yoga, a as YogaNode, b as appendChild, c as applyProps, d as computeLayout, e as createInstance, f as createTextInstance, g as getYoga, i as insertBefore, l as layoutOf, m as measureText, o as ownText, r as refreshMeasure, h as removeChild } from '../host-6NVC0_E3.js';
3
3
  import { B as Buffer } from '../cells-CaXEx4lH.js';
4
4
  import 'yoga-layout/load';
5
5
 
@@ -315,13 +315,22 @@ function contentRectOf(inst, box) {
315
315
  height: Math.max(0, box.height - padT - padB)
316
316
  };
317
317
  }
318
+ function paddingRectOf(inst, box) {
319
+ const n = inst.yogaNode;
320
+ const t = n.getComputedBorder(Edge.Top);
321
+ const r = n.getComputedBorder(Edge.Right);
322
+ const b = n.getComputedBorder(Edge.Bottom);
323
+ const l = n.getComputedBorder(Edge.Left);
324
+ return { left: box.left + l, top: box.top + t, width: Math.max(0, box.width - l - r), height: Math.max(0, box.height - t - b) };
325
+ }
318
326
  function paintInstance(inst, buffer, offsetX, offsetY, inheritedBg = void 0, clip = null) {
319
327
  if (inst.props.display === "none") return;
320
328
  const box = layoutOf(inst, offsetX, offsetY);
321
329
  inst.props.onLayout?.(box);
322
330
  const ownBg = inst.props.backgroundColor;
323
331
  const effectiveBg = ownBg ?? inheritedBg;
324
- if (ownBg !== void 0) {
332
+ const offscreen = clip !== null && (box.left >= clip.left + clip.width || box.left + box.width <= clip.left || box.top >= clip.top + clip.height || box.top + box.height <= clip.top);
333
+ if (!offscreen && ownBg !== void 0) {
325
334
  const fillStyle = ownBg === "default" ? {} : { bg: ownBg };
326
335
  for (let y = box.top; y < box.top + box.height; y++) {
327
336
  for (let x = box.left; x < box.left + box.width; x++) {
@@ -329,8 +338,8 @@ function paintInstance(inst, buffer, offsetX, offsetY, inheritedBg = void 0, cli
329
338
  }
330
339
  }
331
340
  }
332
- paintBorder(inst, buffer, box, clip, effectiveBg);
333
- const text = ownText(inst);
341
+ if (!offscreen) paintBorder(inst, buffer, box, clip, effectiveBg);
342
+ const text = offscreen ? "" : ownText(inst);
334
343
  if (text) {
335
344
  const content = contentRectOf(inst, box);
336
345
  const mode = inst.props.wrap ?? "none";
@@ -350,7 +359,23 @@ function paintInstance(inst, buffer, offsetX, offsetY, inheritedBg = void 0, cli
350
359
  }
351
360
  }
352
361
  }
353
- const childClip = inst.props.overflow === "hidden" ? intersectRects(clip, contentRectOf(inst, box)) : clip;
362
+ const scrolls = inst.props.scrollTop !== void 0 || inst.props.scrollBottom !== void 0;
363
+ let scrollTop = 0;
364
+ if (scrolls || inst.props.onScrollMetrics) {
365
+ const viewport = contentRectOf(inst, box);
366
+ let contentBottom = 0;
367
+ for (const child of inst.children) {
368
+ if (child.type !== "box" || child.props.position === "absolute" || child.props.display === "none") continue;
369
+ const n = child.yogaNode;
370
+ contentBottom = Math.max(contentBottom, n.getComputedTop() + n.getComputedHeight() + n.getComputedMargin(Edge.Bottom));
371
+ }
372
+ const contentHeight = Math.max(0, contentBottom - (viewport.top - box.top));
373
+ const maxScrollTop = Math.max(0, contentHeight - viewport.height);
374
+ const wanted = inst.props.scrollBottom !== void 0 ? maxScrollTop - inst.props.scrollBottom : inst.props.scrollTop ?? 0;
375
+ scrollTop = Math.max(0, Math.min(maxScrollTop, Math.round(wanted)));
376
+ inst.props.onScrollMetrics?.({ contentHeight, viewportHeight: viewport.height, scrollTop, maxScrollTop });
377
+ }
378
+ const childClip = inst.props.overflow === "hidden" || scrolls ? intersectRects(clip, contentRectOf(inst, box)) : clip;
354
379
  const stackFlow = [];
355
380
  const absolutes = [];
356
381
  for (const child of inst.children) {
@@ -360,8 +385,9 @@ function paintInstance(inst, buffer, offsetX, offsetY, inheritedBg = void 0, cli
360
385
  const byZ = (a, b) => (a.props.zIndex ?? 0) - (b.props.zIndex ?? 0);
361
386
  stackFlow.sort(byZ);
362
387
  absolutes.sort(byZ);
363
- for (const child of stackFlow) paintInstance(child, buffer, box.left, box.top, effectiveBg, childClip);
364
- for (const child of absolutes) paintInstance(child, buffer, box.left, box.top, effectiveBg, childClip);
388
+ for (const child of stackFlow) paintInstance(child, buffer, box.left, box.top - scrollTop, effectiveBg, childClip);
389
+ const overlayClip = scrolls ? intersectRects(clip, paddingRectOf(inst, box)) : childClip;
390
+ for (const child of absolutes) paintInstance(child, buffer, box.left, box.top, effectiveBg, overlayClip);
365
391
  }
366
392
 
367
393
  export { appendChild, applyProps, computeLayout, createInstance, createTextInstance, getYoga, insertBefore, layoutOf, measureText, ownText, paint, refreshMeasure, removeChild };
@@ -114,6 +114,28 @@ interface BoxProps {
114
114
  * 'hidden' clips ALL descendant writes including their backgrounds and borders.
115
115
  * Does NOT clip this box's own background or border (those are this box's own area). */
116
116
  overflow?: 'visible' | 'hidden';
117
+ /** Scroll the content up by this many rows (0 = top). Setting either scroll
118
+ * prop makes the box a scroll viewport: it clips like `overflow: 'hidden'`
119
+ * and paints its flow children shifted. The value is clamped to the scrollable
120
+ * range at paint time, against the content's CURRENT height — so it never
121
+ * needs a second frame to catch up. `position: 'absolute'` children are
122
+ * overlays: they neither scroll nor count as content (a scrollbar, a
123
+ * "new messages" badge). */
124
+ scrollTop?: number;
125
+ /** Like `scrollTop`, but counted from the END of the content: 0 pins the last
126
+ * rows into view and keeps them there as content grows. Wins over `scrollTop`
127
+ * when both are set. */
128
+ scrollBottom?: number;
129
+ /** Fires every paint (diff before setState, like onLayout) with the numbers a
130
+ * scrolling component needs: content and viewport heights, the effective
131
+ * (clamped) `scrollTop`, and its maximum. */
132
+ onScrollMetrics?: (metrics: ScrollMetrics) => void;
133
+ }
134
+ interface ScrollMetrics {
135
+ contentHeight: number;
136
+ viewportHeight: number;
137
+ scrollTop: number;
138
+ maxScrollTop: number;
117
139
  }
118
140
  interface Instance {
119
141
  type: 'box';
@@ -149,4 +171,4 @@ declare function appendChild(parent: Instance, child: Instance | TextInstance, Y
149
171
  declare function removeChild(parent: Instance, child: Instance | TextInstance, Yoga: Yoga): void;
150
172
  declare function insertBefore(parent: Instance, child: Instance | TextInstance, before: Instance | TextInstance, Yoga: Yoga): void;
151
173
 
152
- export { BORDER_CHARS as B, type Container as C, DEFAULT_BORDER_STYLE as D, GRID_CHARS as G, type HostType as H, type Instance as I, type Rect as R, type TextInstance as T, type Yoga as Y, type YogaNode as a, appendChild as b, applyProps as c, computeLayout as d, createInstance as e, createTextInstance as f, getYoga as g, removeChild as h, insertBefore as i, type BorderChars as j, type BorderStyle as k, layoutOf as l, measureText as m, type BoxProps as n, ownText as o, type GridChars as p, refreshMeasure as r };
174
+ export { BORDER_CHARS as B, type Container as C, DEFAULT_BORDER_STYLE as D, GRID_CHARS as G, type HostType as H, type Instance as I, type Rect as R, type ScrollMetrics as S, type TextInstance as T, type Yoga as Y, type YogaNode as a, appendChild as b, applyProps as c, computeLayout as d, createInstance as e, createTextInstance as f, getYoga as g, removeChild as h, insertBefore as i, type BorderChars as j, type BorderStyle as k, layoutOf as l, measureText as m, type BoxProps as n, ownText as o, type GridChars as p, refreshMeasure as r };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { B as Buffer, C as Cell, S as Style } from './cells-CaXEx4lH.js';
2
- import { K as Key } from './backend-ode0Vc7y.js';
3
- export { B as Backend } from './backend-ode0Vc7y.js';
4
- export { j as BorderChars, k as BorderStyle, n as BoxProps, D as DEFAULT_BORDER_STYLE, G as GRID_CHARS, p as GridChars } from './host-zpuR6C1c.js';
2
+ import { K as Key } from './backend-BYzQoXfC.js';
3
+ export { B as Backend, a as KeyName, N as NAMED_KEYS, b as NamedKey } from './backend-BYzQoXfC.js';
4
+ export { j as BorderChars, k as BorderStyle, n as BoxProps, D as DEFAULT_BORDER_STYLE, G as GRID_CHARS, p as GridChars, S as ScrollMetrics } from './host-6NVC0_E3.js';
5
5
  import 'yoga-layout/load';
6
6
 
7
7
  type WrapMode = 'wrap' | 'truncate' | 'none';
@@ -82,6 +82,10 @@ declare function stringWidth(str: string): number;
82
82
 
83
83
  interface EditorState {
84
84
  value: string;
85
+ /** UTF-16 index into `value` (so `value.slice(0, cursor)` is the text before
86
+ * the caret). It only ever rests on a character boundary: movement and
87
+ * deletion step over a whole code point, and a cursor handed in between the
88
+ * two halves of a surrogate pair is snapped back before anything else. */
85
89
  cursor: number;
86
90
  }
87
91
  type EditorAction = {
@@ -94,7 +98,33 @@ type EditorAction = {
94
98
  } | {
95
99
  kind: 'noop';
96
100
  };
97
- declare function reduce$2(state: EditorState, key: Key): EditorAction;
101
+ interface EditorOptions {
102
+ /** Multi-line editing: line breaks are part of the value. Shift+Enter,
103
+ * Alt+Enter and backslash-then-Enter insert one (plain Enter still submits),
104
+ * up/down move between visual rows, Home/End and the kill bindings work
105
+ * within the current line, and a paste keeps its line breaks. */
106
+ multiline?: boolean;
107
+ /** Wrap width in cells — what up/down use to walk soft-wrapped rows. Only
108
+ * read when `multiline`; without it each source line counts as one row. */
109
+ width?: number;
110
+ }
111
+ declare function reduce$2(state: EditorState, key: Key, opts?: EditorOptions): EditorAction;
112
+
113
+ interface InputRow {
114
+ /** Text of this visual row (no line break). */
115
+ text: string;
116
+ /** Index into the value where this row starts. */
117
+ start: number;
118
+ /** True for a soft-wrap continuation of the previous row's source line. */
119
+ continuation: boolean;
120
+ }
121
+ declare function inputRows(value: string, width: number, cursor?: number): InputRow[];
122
+ /** UTF-16 index of character column `col` within `row` (clamped to its end). */
123
+ declare function rowIndexAt(row: InputRow, col: number): number;
124
+ declare function caretPosition(value: string, cursor: number, width: number): {
125
+ row: number;
126
+ col: number;
127
+ };
98
128
 
99
129
  interface SelectItem<T> {
100
130
  label: string;
@@ -140,4 +170,4 @@ type MultiSelectAction = {
140
170
  };
141
171
  declare function reduce<T>(items: SelectItem<T>[], state: MultiSelectState, key: Key): MultiSelectAction;
142
172
 
143
- export { type EditorAction, type EditorState, Key, type MultiSelectAction, type MultiSelectState, type SelectAction, type SelectItem, type SelectState, type VisualLine, type WrapMode, charWidth, reduce$2 as editorReducer, reduce as multiSelectReducer, reduce$1 as selectReducer, splitVisualLines, stringWidth, visibleIndices, windowAround, wrapText };
173
+ export { type EditorAction, type EditorOptions, type EditorState, type InputRow, Key, type MultiSelectAction, type MultiSelectState, type SelectAction, type SelectItem, type SelectState, type VisualLine, type WrapMode, caretPosition, charWidth, reduce$2 as editorReducer, inputRows, reduce as multiSelectReducer, rowIndexAt, reduce$1 as selectReducer, splitVisualLines, stringWidth, visibleIndices, windowAround, wrapText };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { Buffer, DEFAULT_BORDER_STYLE, GRID_CHARS, wrapText } from './chunk-D6HW2BNM.js';
2
+ export { NAMED_KEYS } from './chunk-AQ33T2P5.js';
2
3
 
3
4
  // src/visualLines.ts
4
5
  function splitVisualLines(text, mode, width) {
@@ -280,7 +281,50 @@ function stringWidth(str) {
280
281
  return w;
281
282
  }
282
283
 
284
+ // src/inputRows.ts
285
+ function inputRows(value, width, cursor) {
286
+ const w = Math.max(1, Math.floor(width));
287
+ const rows = [];
288
+ let lineStart = 0;
289
+ for (const line of value.split("\n")) {
290
+ const chars = [...line];
291
+ if (chars.length === 0) {
292
+ rows.push({ text: "", start: lineStart, continuation: false });
293
+ } else {
294
+ let start = lineStart;
295
+ for (let at = 0; at < chars.length; at += w) {
296
+ const text = chars.slice(at, at + w).join("");
297
+ rows.push({ text, start, continuation: at > 0 });
298
+ start += text.length;
299
+ }
300
+ if (cursor === lineStart + line.length && chars.length % w === 0) {
301
+ rows.push({ text: "", start: cursor, continuation: true });
302
+ }
303
+ }
304
+ lineStart += line.length + 1;
305
+ }
306
+ return rows;
307
+ }
308
+ function rowIndexAt(row, col) {
309
+ return row.start + [...row.text].slice(0, Math.max(0, col)).join("").length;
310
+ }
311
+ function caretPosition(value, cursor, width) {
312
+ const c = Math.max(0, Math.min(value.length, cursor));
313
+ const rows = inputRows(value, width, c);
314
+ for (let r = rows.length - 1; r >= 0; r--) {
315
+ const row = rows[r];
316
+ if (row.start <= c && c <= row.start + row.text.length) {
317
+ return { row: r, col: [...value.slice(row.start, c)].length };
318
+ }
319
+ }
320
+ return { row: 0, col: 0 };
321
+ }
322
+
283
323
  // src/editor.ts
324
+ var isHigh = (u) => u !== void 0 && u >= "\uD800" && u <= "\uDBFF";
325
+ var isLow = (u) => u !== void 0 && u >= "\uDC00" && u <= "\uDFFF";
326
+ var prevIndex = (v, i) => i >= 2 && isLow(v[i - 1]) && isHigh(v[i - 2]) ? i - 2 : Math.max(0, i - 1);
327
+ var nextIndex = (v, i) => isHigh(v[i]) && isLow(v[i + 1]) ? i + 2 : Math.min(v.length, i + 1);
284
328
  var OPT_MAP = {
285
329
  " ": ["\xA0"],
286
330
  // U+00A0 NBSP
@@ -304,24 +348,50 @@ function wordRight(value, cursor) {
304
348
  while (c < value.length && isWord(value[c])) c++;
305
349
  return c;
306
350
  }
307
- function reduce(state, key) {
308
- const { value, cursor } = state;
309
- const clamp = (n) => Math.max(0, Math.min(value.length, n));
351
+ function reduce(state, key, opts = {}) {
352
+ const { value } = state;
353
+ let cursor = Math.max(0, Math.min(value.length, state.cursor));
354
+ if (isLow(value[cursor]) && isHigh(value[cursor - 1])) cursor--;
355
+ const multiline = opts.multiline === true;
356
+ const lineStart = multiline ? value.lastIndexOf("\n", cursor - 1) + 1 : 0;
357
+ const nextBreak = multiline ? value.indexOf("\n", cursor) : -1;
358
+ const lineEnd = nextBreak < 0 ? value.length : nextBreak;
359
+ const insert = (text) => ({
360
+ kind: "edit",
361
+ state: { value: value.slice(0, cursor) + text + value.slice(cursor), cursor: cursor + text.length }
362
+ });
310
363
  if (key.name === "paste") {
311
- const text = (key.text ?? "").replace(/\n/g, " ");
364
+ const pasted = (key.text ?? "").replace(/\r\n?/g, "\n");
365
+ const text = multiline ? pasted : pasted.replace(/\n/g, " ");
312
366
  if (text === "") return { kind: "noop" };
313
- return { kind: "edit", state: { value: value.slice(0, cursor) + text + value.slice(cursor), cursor: cursor + text.length } };
367
+ return insert(text);
368
+ }
369
+ if (multiline) {
370
+ if (key.name === "return" && (key.shift || key.meta)) return insert("\n");
371
+ if (key.name === "return" && cursor > 0 && value[cursor - 1] === "\\") {
372
+ return { kind: "edit", state: { value: value.slice(0, cursor - 1) + "\n" + value.slice(cursor), cursor } };
373
+ }
374
+ if (key.name === "up" || key.name === "down") {
375
+ const width = opts.width ?? Number.MAX_SAFE_INTEGER;
376
+ const rows = inputRows(value, width, cursor);
377
+ const at = caretPosition(value, cursor, width);
378
+ const target = at.row + (key.name === "up" ? -1 : 1);
379
+ if (target < 0) return { kind: "edit", state: { value, cursor: 0 } };
380
+ if (target >= rows.length) return { kind: "edit", state: { value, cursor: value.length } };
381
+ const row = rows[target];
382
+ return { kind: "edit", state: { value, cursor: rowIndexAt(row, at.col) } };
383
+ }
314
384
  }
315
385
  if (key.name === "left" && key.meta) return { kind: "edit", state: { value, cursor: wordLeft(value, cursor) } };
316
386
  if (key.name === "b" && key.meta) return { kind: "edit", state: { value, cursor: wordLeft(value, cursor) } };
317
387
  if (key.name === "right" && key.meta) return { kind: "edit", state: { value, cursor: wordRight(value, cursor) } };
318
388
  if (key.name === "f" && key.meta) return { kind: "edit", state: { value, cursor: wordRight(value, cursor) } };
319
- if (key.name === "left") return { kind: "edit", state: { value, cursor: clamp(cursor - 1) } };
320
- if (key.name === "right") return { kind: "edit", state: { value, cursor: clamp(cursor + 1) } };
321
- if (key.name === "home") return { kind: "edit", state: { value, cursor: 0 } };
322
- if (key.name === "end") return { kind: "edit", state: { value, cursor: value.length } };
323
- if (key.name === "a" && key.ctrl) return { kind: "edit", state: { value, cursor: 0 } };
324
- if (key.name === "e" && key.ctrl) return { kind: "edit", state: { value, cursor: value.length } };
389
+ if (key.name === "left") return { kind: "edit", state: { value, cursor: prevIndex(value, cursor) } };
390
+ if (key.name === "right") return { kind: "edit", state: { value, cursor: nextIndex(value, cursor) } };
391
+ if (key.name === "home") return { kind: "edit", state: { value, cursor: lineStart } };
392
+ if (key.name === "end") return { kind: "edit", state: { value, cursor: lineEnd } };
393
+ if (key.name === "a" && key.ctrl) return { kind: "edit", state: { value, cursor: lineStart } };
394
+ if (key.name === "e" && key.ctrl) return { kind: "edit", state: { value, cursor: lineEnd } };
325
395
  if (key.name === "backspace" && key.meta) {
326
396
  const start = wordLeft(value, cursor);
327
397
  return { kind: "edit", state: { value: value.slice(0, start) + value.slice(cursor), cursor: start } };
@@ -339,10 +409,10 @@ function reduce(state, key) {
339
409
  return { kind: "edit", state: { value: value.slice(0, cursor) + value.slice(end), cursor } };
340
410
  }
341
411
  if (key.name === "k" && key.ctrl) {
342
- return { kind: "edit", state: { value: value.slice(0, cursor), cursor } };
412
+ return { kind: "edit", state: { value: value.slice(0, cursor) + value.slice(lineEnd), cursor } };
343
413
  }
344
414
  if (key.name === "u" && key.ctrl) {
345
- return { kind: "edit", state: { value: value.slice(cursor), cursor: 0 } };
415
+ return { kind: "edit", state: { value: value.slice(0, lineStart) + value.slice(cursor), cursor: lineStart } };
346
416
  }
347
417
  if (key.meta && OPT_MAP[key.name]) {
348
418
  const entry = OPT_MAP[key.name];
@@ -350,19 +420,17 @@ function reduce(state, key) {
350
420
  return { kind: "edit", state: { value: value.slice(0, cursor) + ch + value.slice(cursor), cursor: cursor + 1 } };
351
421
  }
352
422
  if (key.name === "backspace") {
353
- if (cursor === 0) return { kind: "edit", state };
354
- return { kind: "edit", state: { value: value.slice(0, cursor - 1) + value.slice(cursor), cursor: cursor - 1 } };
423
+ if (cursor === 0) return { kind: "edit", state: { value, cursor } };
424
+ const from = prevIndex(value, cursor);
425
+ return { kind: "edit", state: { value: value.slice(0, from) + value.slice(cursor), cursor: from } };
355
426
  }
356
427
  if (key.name === "delete" || key.name === "d" && key.ctrl) {
357
- if (cursor === value.length) return { kind: "edit", state };
358
- return { kind: "edit", state: { value: value.slice(0, cursor) + value.slice(cursor + 1), cursor } };
428
+ if (cursor === value.length) return { kind: "edit", state: { value, cursor } };
429
+ return { kind: "edit", state: { value: value.slice(0, cursor) + value.slice(nextIndex(value, cursor)), cursor } };
359
430
  }
360
431
  if (key.name === "return") return { kind: "submit" };
361
432
  if (key.name === "escape") return { kind: "cancel" };
362
- if (!key.ctrl && !key.meta && key.name.length === 1) {
363
- const ch = key.name;
364
- return { kind: "edit", state: { value: value.slice(0, cursor) + ch + value.slice(cursor), cursor: cursor + 1 } };
365
- }
433
+ if (!key.ctrl && !key.meta && [...key.name].length === 1) return insert(key.name);
366
434
  return { kind: "noop" };
367
435
  }
368
436
 
@@ -422,4 +490,4 @@ function reduce3(items, state, key) {
422
490
  return { kind: "noop" };
423
491
  }
424
492
 
425
- export { charWidth, reduce as editorReducer, reduce3 as multiSelectReducer, reduce2 as selectReducer, splitVisualLines, stringWidth, visibleIndices, windowAround };
493
+ export { caretPosition, charWidth, reduce as editorReducer, inputRows, reduce3 as multiSelectReducer, rowIndexAt, reduce2 as selectReducer, splitVisualLines, stringWidth, visibleIndices, windowAround };
@@ -1,5 +1,5 @@
1
1
  import { B as Buffer } from '../cells-CaXEx4lH.js';
2
- import { B as Backend, K as Key } from '../backend-ode0Vc7y.js';
2
+ import { B as Backend, K as Key } from '../backend-BYzQoXfC.js';
3
3
 
4
4
  declare class TestBackend implements Backend {
5
5
  private readonly cols;
@@ -16,7 +16,11 @@ declare class TestBackend implements Backend {
16
16
  get lastFrame(): string;
17
17
  get lastBuffer(): Buffer | null;
18
18
  onKey(handler: (key: Key) => void): () => void;
19
- /** Synchronously deliver one Key to every subscriber. */
19
+ /**
20
+ * Synchronously deliver one Key to every subscriber. Throws on a name no
21
+ * terminal can produce (`'space'`, `'enter'`): a test that presses such a key
22
+ * exercises a branch real input never reaches, and would pass anyway.
23
+ */
20
24
  press(key: Partial<Key> & {
21
25
  name: string;
22
26
  }): void;
@@ -1,4 +1,25 @@
1
+ import { NAMED_KEYS } from '../chunk-AQ33T2P5.js';
2
+
1
3
  // src/testing/test-backend.ts
4
+ var KEY_NAME_HINTS = {
5
+ space: " ",
6
+ enter: "return",
7
+ esc: "escape",
8
+ del: "delete",
9
+ ins: "insert",
10
+ pgup: "pageup",
11
+ pgdn: "pagedown",
12
+ pgdown: "pagedown",
13
+ bs: "backspace"
14
+ };
15
+ function assertRealKeyName(name) {
16
+ if ([...name].length === 1 || name.startsWith("csi-")) return;
17
+ if (NAMED_KEYS.includes(name)) return;
18
+ const hint = KEY_NAME_HINTS[name.toLowerCase()];
19
+ throw new Error(
20
+ `TestBackend.press: '${name}' is not a key name any terminal produces` + (hint !== void 0 ? ` \u2014 use '${hint}'.` : `. A printable key is named by its character (':' not 'colon'); named keys: ${NAMED_KEYS.join(", ")}.`)
21
+ );
22
+ }
2
23
  var TestBackend = class {
3
24
  constructor(cols = 40, rows = 10) {
4
25
  this.cols = cols;
@@ -28,8 +49,13 @@ var TestBackend = class {
28
49
  this.subscribers.delete(handler);
29
50
  };
30
51
  }
31
- /** Synchronously deliver one Key to every subscriber. */
52
+ /**
53
+ * Synchronously deliver one Key to every subscriber. Throws on a name no
54
+ * terminal can produce (`'space'`, `'enter'`): a test that presses such a key
55
+ * exercises a branch real input never reaches, and would pass anyway.
56
+ */
32
57
  press(key) {
58
+ assertRealKeyName(key.name);
33
59
  const k = {
34
60
  ...key.text !== void 0 ? { text: key.text } : {},
35
61
  ...key.x !== void 0 ? { x: key.x } : {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowtty/core",
3
- "version": "1.0.0-alpha.6",
3
+ "version": "1.0.0-alpha.8",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {