@promptctl/rich-js 0.17.0 → 0.18.1
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.
- package/README.md +13 -26
- package/dist/core/anchor.d.ts +45 -0
- package/dist/core/anchor.js +39 -0
- package/dist/core/ansi.d.ts +65 -0
- package/dist/core/ansi.js +231 -0
- package/dist/core/box.d.ts +0 -1
- package/dist/core/box.js +6 -18
- package/dist/core/cells.d.ts +16 -5
- package/dist/core/cells.js +31 -10
- package/dist/core/color.d.ts +4 -8
- package/dist/core/color.js +28 -19
- package/dist/core/console.d.ts +13 -4
- package/dist/core/console.js +91 -36
- package/dist/core/export-html.d.ts +40 -5
- package/dist/core/export-html.js +111 -17
- package/dist/core/markup.d.ts +27 -5
- package/dist/core/markup.js +282 -170
- package/dist/core/measure.d.ts +1 -1
- package/dist/core/measure.js +2 -1
- package/dist/core/place.d.ts +30 -0
- package/dist/core/place.js +51 -0
- package/dist/core/pretty.d.ts +3 -0
- package/dist/core/pretty.js +5 -1
- package/dist/core/protocol.d.ts +136 -6
- package/dist/core/protocol.js +64 -2
- package/dist/core/render.d.ts +2 -2
- package/dist/core/render.js +2 -2
- package/dist/core/segment.d.ts +23 -4
- package/dist/core/segment.js +75 -16
- package/dist/core/spinnerData.d.ts +2 -0
- package/dist/core/spinnerData.js +3 -1
- package/dist/core/strip.d.ts +3 -7
- package/dist/core/strip.js +32 -35
- package/dist/core/style.d.ts +25 -0
- package/dist/core/style.js +61 -89
- package/dist/core/text.d.ts +16 -15
- package/dist/core/text.js +62 -76
- package/dist/host/app.d.ts +121 -0
- package/dist/host/app.js +255 -0
- package/dist/host/host-environment.d.ts +26 -0
- package/dist/host/host-environment.js +44 -0
- package/dist/host/index.d.ts +4 -2
- package/dist/host/index.js +2 -1
- package/dist/host/terminal-host.d.ts +43 -7
- package/dist/host/terminal-host.js +57 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -1
- package/dist/node/terminal-host.d.ts +31 -2
- package/dist/node/terminal-host.js +69 -2
- package/dist/node/traceback.d.ts +16 -6
- package/dist/node/traceback.js +30 -8
- package/dist/renderables/align.d.ts +2 -2
- package/dist/renderables/align.js +6 -19
- package/dist/renderables/columns.js +4 -20
- package/dist/renderables/embed.d.ts +50 -0
- package/dist/renderables/embed.js +71 -0
- package/dist/renderables/group.js +3 -1
- package/dist/renderables/layout.js +31 -34
- package/dist/renderables/live.d.ts +6 -3
- package/dist/renderables/live.js +89 -45
- package/dist/renderables/markdown.d.ts +1 -1
- package/dist/renderables/markdown.js +6 -4
- package/dist/renderables/padding.js +3 -2
- package/dist/renderables/panel.js +26 -41
- package/dist/renderables/progress.js +5 -8
- package/dist/renderables/progressBar.js +6 -5
- package/dist/renderables/rule.d.ts +3 -2
- package/dist/renderables/rule.js +9 -9
- package/dist/renderables/spinner.d.ts +5 -3
- package/dist/renderables/spinner.js +14 -8
- package/dist/renderables/syntax.d.ts +1 -1
- package/dist/renderables/syntax.js +2 -1
- package/dist/renderables/table.d.ts +26 -14
- package/dist/renderables/table.js +239 -141
- package/dist/renderables/traceback.js +8 -9
- package/dist/renderables/tree.d.ts +17 -3
- package/dist/renderables/tree.js +62 -71
- package/dist/renderables/viewport.d.ts +114 -0
- package/dist/renderables/viewport.js +198 -0
- package/dist/template-bindings/color-funcs.js +11 -13
- package/dist/template-bindings/index.js +4 -2
- package/dist/template-bindings/palette-funcs.js +6 -16
- package/dist/template-bindings/style-funcs.js +4 -0
- package/dist/themes/data/ansi-tables.d.ts +14 -0
- package/dist/themes/data/ansi-tables.js +49 -0
- package/dist/themes/data/atom-one-dark.js +19 -0
- package/dist/themes/data/atom-one-light.js +19 -0
- package/dist/themes/data/catppuccin-frappe.js +19 -0
- package/dist/themes/data/catppuccin-latte.js +19 -0
- package/dist/themes/data/catppuccin-macchiato.js +19 -0
- package/dist/themes/data/catppuccin-mocha.js +19 -0
- package/dist/themes/data/cyberpunk.js +2 -0
- package/dist/themes/data/default.js +2 -0
- package/dist/themes/data/dracula.js +19 -0
- package/dist/themes/data/flexoki.js +19 -0
- package/dist/themes/data/gruvbox.js +19 -0
- package/dist/themes/data/monokai.js +19 -0
- package/dist/themes/data/nord.js +19 -0
- package/dist/themes/data/rose-pine-dawn.js +19 -0
- package/dist/themes/data/rose-pine-moon.js +19 -0
- package/dist/themes/data/rose-pine.js +19 -0
- package/dist/themes/data/solarized-dark.js +19 -0
- package/dist/themes/data/solarized-light.js +19 -0
- package/dist/themes/data/svg-export.js +19 -0
- package/dist/themes/data/textual-ansi.js +2 -0
- package/dist/themes/data/textual-dark.js +2 -0
- package/dist/themes/data/textual-light.js +2 -0
- package/dist/themes/data/tokyo-night.js +19 -0
- package/dist/themes/data/types.d.ts +16 -0
- package/dist/themes/data/types.js +10 -1
- package/dist/themes/registry.d.ts +3 -2
- package/dist/themes/registry.js +13 -1
- package/dist/themes/terminalThemes.d.ts +2 -7
- package/dist/themes/terminalThemes.js +4 -9
- package/dist/widgets/button.d.ts +1 -1
- package/dist/widgets/button.js +4 -2
- package/dist/widgets/checkbox.d.ts +2 -2
- package/dist/widgets/checkbox.js +5 -3
- package/dist/widgets/dropdown.d.ts +2 -2
- package/dist/widgets/dropdown.js +13 -16
- package/dist/widgets/event-router.d.ts +21 -28
- package/dist/widgets/event-router.js +80 -73
- package/dist/widgets/focus-manager.d.ts +25 -6
- package/dist/widgets/focus-manager.js +63 -65
- package/dist/widgets/hit.d.ts +64 -0
- package/dist/widgets/hit.js +113 -0
- package/dist/widgets/index.d.ts +5 -7
- package/dist/widgets/index.js +3 -4
- package/dist/widgets/slider.d.ts +2 -2
- package/dist/widgets/slider.js +7 -7
- package/dist/widgets/static-item.d.ts +9 -15
- package/dist/widgets/static-item.js +9 -15
- package/dist/widgets/text-input.d.ts +2 -2
- package/dist/widgets/text-input.js +17 -14
- package/dist/widgets/toggle.d.ts +1 -1
- package/dist/widgets/toggle.js +3 -2
- package/dist/widgets/types.d.ts +14 -35
- package/dist/widgets/types.js +0 -1
- package/dist/widgets/widget-app.d.ts +63 -0
- package/dist/widgets/widget-app.js +199 -0
- package/dist/widgets/widget-base.d.ts +4 -6
- package/dist/widgets/widget-base.js +22 -30
- package/package.json +12 -4
- package/dist/host/host-stream.d.ts +0 -30
- package/dist/host/host-stream.js +0 -35
- package/dist/widgets/screen.d.ts +0 -98
- package/dist/widgets/screen.js +0 -530
package/dist/core/text.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* RichText — styled text with spans. The primary text type for the library.
|
|
3
3
|
*/
|
|
4
|
-
import { cellLen, cellCount } from "./cells.js";
|
|
4
|
+
import { cellLen, cellCount, cellFit, cellFitEnd, asCellCol } from "./cells.js";
|
|
5
5
|
import { divideLine } from "./wrap.js";
|
|
6
6
|
import { Segment } from "./segment.js";
|
|
7
7
|
import { Style, NULL_STYLE, StyleSyntaxError } from "./style.js";
|
|
8
8
|
import { stripOscTerminators } from "./osc8.js";
|
|
9
|
-
import { getStyle, withBoundedWidth } from "./protocol.js";
|
|
9
|
+
import { drawable, getStyle, withBoundedWidth } from "./protocol.js";
|
|
10
10
|
// Strip control characters except \t and \n
|
|
11
11
|
// [LAW:single-enforcer] Single place where control chars are sanitized
|
|
12
12
|
const CONTROL_CHARS_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
|
|
13
|
-
function stripControlChars(text) {
|
|
13
|
+
export function stripControlChars(text) {
|
|
14
14
|
return text.replace(CONTROL_CHARS_RE, "");
|
|
15
15
|
}
|
|
16
16
|
const TRAILING_WHITESPACE_RE = /\s+$/;
|
|
@@ -66,9 +66,16 @@ function sanitizeStyleLink(style) {
|
|
|
66
66
|
* RichText knows when the name arrives — the reference stores span styles the
|
|
67
67
|
* same way. A string holds no link until it is parsed, so only a `Style` has
|
|
68
68
|
* one to sanitize here.
|
|
69
|
+
*
|
|
70
|
+
* [LAW:single-enforcer] Nor does a RichText keep an anchor (`./anchor.ts`). It
|
|
71
|
+
* lays its text out anew, so a stamp naming where a cell sat in the output it
|
|
72
|
+
* came from would name the wrong cell once it wraps, slices or overlaps; the
|
|
73
|
+
* owner that renders the RichText is the one that stamps its cells.
|
|
69
74
|
*/
|
|
70
75
|
function admitStyle(style) {
|
|
71
|
-
|
|
76
|
+
if (!(style instanceof Style))
|
|
77
|
+
return style;
|
|
78
|
+
return sanitizeStyleLink(style.anchor ? style.withAnchor(undefined) : style);
|
|
72
79
|
}
|
|
73
80
|
/** A style that adds nothing: the empty definition, or a null `Style`. */
|
|
74
81
|
function isEmptyStyle(style) {
|
|
@@ -264,6 +271,9 @@ export class RichText {
|
|
|
264
271
|
}
|
|
265
272
|
const offset = this._text.length;
|
|
266
273
|
this._text += content._text;
|
|
274
|
+
// Its base style becomes a span under its own spans, as the reference's
|
|
275
|
+
// `append` does, so appended text looks as it did alone.
|
|
276
|
+
this._addSpan(offset, this._text.length, content._style);
|
|
267
277
|
for (const span of content._spans) {
|
|
268
278
|
this._spans.push(span.move(offset));
|
|
269
279
|
}
|
|
@@ -314,9 +324,10 @@ export class RichText {
|
|
|
314
324
|
* is admitted as given.
|
|
315
325
|
*/
|
|
316
326
|
_addSpan(start, end, style) {
|
|
317
|
-
|
|
327
|
+
const admitted = admitStyle(style);
|
|
328
|
+
if (isEmptyStyle(admitted))
|
|
318
329
|
return;
|
|
319
|
-
this._spans.push(new Span(start, end,
|
|
330
|
+
this._spans.push(new Span(start, end, admitted));
|
|
320
331
|
}
|
|
321
332
|
stylize(style, start, end) {
|
|
322
333
|
const len = this._text.length;
|
|
@@ -486,7 +497,10 @@ export class RichText {
|
|
|
486
497
|
* in the middle.
|
|
487
498
|
*
|
|
488
499
|
* Marker default is `"\u2026"`. Pass `marker: ""` for raw cropping without an
|
|
489
|
-
* indicator glyph.
|
|
500
|
+
* indicator glyph. A marker wider than `width` is itself cut to the glyphs
|
|
501
|
+
* that fit, and the text takes whatever cells that leaves — all of them, when
|
|
502
|
+
* not one glyph of the marker fits — so the result is never wider than asked.
|
|
503
|
+
* Every cut, marker and text alike, falls between grapheme clusters.
|
|
490
504
|
*
|
|
491
505
|
* Spans are preserved through the cut: characters that survive keep their
|
|
492
506
|
* styling; the marker (if any) is inserted as plain text with no span.
|
|
@@ -500,13 +514,12 @@ export class RichText {
|
|
|
500
514
|
if (this.cellLength <= width)
|
|
501
515
|
return this;
|
|
502
516
|
const mode = options?.mode ?? "right";
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
const budget = Math.max(0, width - markerWidth);
|
|
517
|
+
// [LAW:dataflow-not-control-flow] the marker is fitted to the width as a
|
|
518
|
+
// value, so width 0 and a marker wider than the width run the same walk
|
|
519
|
+
// as every other call: what is drawn plus what is kept is `cells` wide.
|
|
520
|
+
const cells = cellCount(width);
|
|
521
|
+
const marker = cellFit(options?.marker ?? "\u2026", cells);
|
|
522
|
+
const budget = asCellCol(cells - cellLen(marker));
|
|
510
523
|
if (mode === "right") {
|
|
511
524
|
this._cropRightTo(budget);
|
|
512
525
|
if (marker)
|
|
@@ -522,11 +535,11 @@ export class RichText {
|
|
|
522
535
|
return this;
|
|
523
536
|
}
|
|
524
537
|
// middle
|
|
525
|
-
const leftBudget = Math.floor(budget / 2);
|
|
526
|
-
const rightBudget = budget - leftBudget;
|
|
538
|
+
const leftBudget = asCellCol(Math.floor(budget / 2));
|
|
539
|
+
const rightBudget = asCellCol(budget - leftBudget);
|
|
527
540
|
// Find the char-index ranges to keep from each side.
|
|
528
|
-
const leftEndCharIdx = this.
|
|
529
|
-
const rightStartCharIdx = this.
|
|
541
|
+
const leftEndCharIdx = cellFit(this._text, leftBudget).length;
|
|
542
|
+
const rightStartCharIdx = this._text.length - cellFitEnd(this._text, rightBudget).length;
|
|
530
543
|
const leftText = this._text.slice(0, leftEndCharIdx);
|
|
531
544
|
const rightText = this._text.slice(rightStartCharIdx);
|
|
532
545
|
const droppedStart = leftEndCharIdx;
|
|
@@ -557,11 +570,10 @@ export class RichText {
|
|
|
557
570
|
return this;
|
|
558
571
|
}
|
|
559
572
|
_cropRightTo(targetWidth) {
|
|
560
|
-
|
|
561
|
-
this.plain = this._text.slice(0, charIdx);
|
|
573
|
+
this.plain = cellFit(this._text, targetWidth);
|
|
562
574
|
}
|
|
563
575
|
_cropLeftTo(targetWidth) {
|
|
564
|
-
const charIdx = this.
|
|
576
|
+
const charIdx = this._text.length - cellFitEnd(this._text, targetWidth).length;
|
|
565
577
|
// Shift spans left by charIdx; clip spans that started before.
|
|
566
578
|
const shift = -charIdx;
|
|
567
579
|
this._spans = this._spans
|
|
@@ -575,34 +587,6 @@ export class RichText {
|
|
|
575
587
|
.filter((s) => s !== undefined);
|
|
576
588
|
this._text = this._text.slice(charIdx);
|
|
577
589
|
}
|
|
578
|
-
/** Number of char-index code units that fit within `targetWidth` cell columns from the left. */
|
|
579
|
-
_cellPrefixCharLength(targetWidth) {
|
|
580
|
-
let width = 0;
|
|
581
|
-
let charIndex = 0;
|
|
582
|
-
for (const char of this._text) {
|
|
583
|
-
const charWidth = cellLen(char);
|
|
584
|
-
if (width + charWidth > targetWidth)
|
|
585
|
-
break;
|
|
586
|
-
width += charWidth;
|
|
587
|
-
charIndex += char.length;
|
|
588
|
-
}
|
|
589
|
-
return charIndex;
|
|
590
|
-
}
|
|
591
|
-
/** Char-index at which the suffix of `targetWidth` cell columns starts. */
|
|
592
|
-
_cellSuffixStartCharIndex(targetWidth) {
|
|
593
|
-
// Walk from right: accumulate widths of trailing chars until we hit the budget.
|
|
594
|
-
const chars = [...this._text];
|
|
595
|
-
let width = 0;
|
|
596
|
-
let kept = 0;
|
|
597
|
-
for (let i = chars.length - 1; i >= 0; i--) {
|
|
598
|
-
const w = cellLen(chars[i]);
|
|
599
|
-
if (width + w > targetWidth)
|
|
600
|
-
break;
|
|
601
|
-
width += w;
|
|
602
|
-
kept += chars[i].length;
|
|
603
|
-
}
|
|
604
|
-
return this._text.length - kept;
|
|
605
|
-
}
|
|
606
590
|
// --- Alignment ---
|
|
607
591
|
align(justify, width) {
|
|
608
592
|
const currentWidth = this.cellLength;
|
|
@@ -671,9 +655,8 @@ export class RichText {
|
|
|
671
655
|
* `{{ red "x" }}{{ blue "y" }}` evaluates to two fragments — one with
|
|
672
656
|
* wrapping `style = red`, one with `blue` — and consumers that want a
|
|
673
657
|
* single styled string for downstream rendering need both styles
|
|
674
|
-
* preserved as spans on the concatenated result
|
|
675
|
-
*
|
|
676
|
-
* lifting `frag.style` into a span before appending.
|
|
658
|
+
* preserved as spans on the concatenated result, which is what `append()`
|
|
659
|
+
* does with each fragment.
|
|
677
660
|
*
|
|
678
661
|
* Empty input → empty `RichText` with `end: ""`. Caller can override
|
|
679
662
|
* `end` (defaults to `""` — the engine-output case rarely wants a
|
|
@@ -681,14 +664,8 @@ export class RichText {
|
|
|
681
664
|
*/
|
|
682
665
|
static fromFragments(fragments, options) {
|
|
683
666
|
const result = new RichText("", { end: options?.end ?? "" });
|
|
684
|
-
for (const frag of fragments)
|
|
685
|
-
|
|
686
|
-
result.append(frag.plain);
|
|
687
|
-
result.stylize(frag.style, start, result.length);
|
|
688
|
-
for (const span of frag.spans) {
|
|
689
|
-
result.stylize(span.style, start + span.start, start + span.end);
|
|
690
|
-
}
|
|
691
|
-
}
|
|
667
|
+
for (const frag of fragments)
|
|
668
|
+
result.append(frag);
|
|
692
669
|
return result;
|
|
693
670
|
}
|
|
694
671
|
// --- Renderable ---
|
|
@@ -704,21 +681,28 @@ export class RichText {
|
|
|
704
681
|
// which pads — and `" ".repeat(Infinity)` throws.
|
|
705
682
|
const maxWidth = cellCount(withBoundedWidth(options, this).maxWidth);
|
|
706
683
|
const overflow = this._overflow ?? options.overflow ?? "fold";
|
|
707
|
-
const justify = this._justify ?? options.justify;
|
|
708
684
|
const noWrap = this._noWrap || (options.noWrap ?? false);
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
// its natural width and whatever
|
|
712
|
-
// `
|
|
713
|
-
//
|
|
685
|
+
// Two widths, and they are the reference's `no_wrap` and `"ignore"` told
|
|
686
|
+
// apart. `edge` is where a line is cut: gone under `"ignore"`, so the line
|
|
687
|
+
// leaves at its natural width and whatever drew it decides about the
|
|
688
|
+
// overhang. `wrapAt` is where a line may break: gone under `noWrap` too,
|
|
689
|
+
// which keeps one line per line and still cuts it at the edge by the
|
|
690
|
+
// overflow method — Rich's `Text.wrap` truncates its no-wrap arm.
|
|
714
691
|
//
|
|
715
|
-
// [LAW:dataflow-not-control-flow]
|
|
716
|
-
// as a step to skip: an unbounded
|
|
692
|
+
// [LAW:dataflow-not-control-flow] Each reaches the pipeline as a width, not
|
|
693
|
+
// as a step to skip: an unbounded width has no edge to break at, so
|
|
717
694
|
// `divideLine` finds no cuts and `_fitLine` finds nothing past the edge,
|
|
718
695
|
// and every line runs the same three steps. `Infinity` is already this
|
|
719
696
|
// library's spelling of an unbounded width offer — `withBoundedWidth` in
|
|
720
697
|
// protocol.ts parses one on the way in.
|
|
721
|
-
const
|
|
698
|
+
const edge = overflow === "ignore" ? cellCount(Infinity) : maxWidth;
|
|
699
|
+
const wrapAt = noWrap ? cellCount(Infinity) : edge;
|
|
700
|
+
// A line with no edge has no width to be justified in, so under `"ignore"`
|
|
701
|
+
// it is not justified — the reference's `Text.wrap` returns that arm before
|
|
702
|
+
// `justify`. `undefined` is the placement that leaves a line as it is.
|
|
703
|
+
const justify = overflow === "ignore" ? undefined : this._justify ?? options.justify;
|
|
704
|
+
// One cell either way, so the marker's stand-in leaves the cut where it was.
|
|
705
|
+
const ellipsis = drawable(options, "\u2026", ".");
|
|
722
706
|
const endsWithNewline = text.endsWith("\n");
|
|
723
707
|
for (let index = 0; index < logicalLines.length; index += 1) {
|
|
724
708
|
const line = logicalLines[index];
|
|
@@ -726,9 +710,9 @@ export class RichText {
|
|
|
726
710
|
// Wrap first, overflow last — the reference's order, and the reason a
|
|
727
711
|
// long sentence grows a table row while an unbreakable word in the same
|
|
728
712
|
// column still ellipsizes.
|
|
729
|
-
const cuts = divideLine(plainOf(line),
|
|
713
|
+
const cuts = divideLine(plainOf(line), wrapAt, { fold: overflow === "fold" });
|
|
730
714
|
const wrapped = Segment.divide(line, cuts);
|
|
731
|
-
const placed = this._justifyLines(wrapped.map((piece) => [...this._fitLine(piece,
|
|
715
|
+
const placed = this._justifyLines(wrapped.map((piece) => [...this._fitLine(piece, edge, overflow, ellipsis)]), maxWidth, base, justify);
|
|
732
716
|
for (let piece = 0; piece < placed.length; piece += 1) {
|
|
733
717
|
yield* placed[piece];
|
|
734
718
|
if (piece < placed.length - 1 || terminateLine) {
|
|
@@ -954,11 +938,13 @@ export class RichText {
|
|
|
954
938
|
*
|
|
955
939
|
* Everything reaching here already survived wrapping, so the only text still
|
|
956
940
|
* too wide is text no break could help: a word longer than the canvas under
|
|
957
|
-
* a non-folding overflow method, a glyph wider than the budget,
|
|
958
|
-
* with no cells at all
|
|
959
|
-
*
|
|
941
|
+
* a non-folding overflow method, a glyph wider than the budget, a canvas
|
|
942
|
+
* with no cells at all, or a `noWrap` line, which no break was allowed to
|
|
943
|
+
* help. That is what makes the overflow method a last resort rather than the
|
|
944
|
+
* first thing a long cell meets. `"fold"` has nothing left to fold here and
|
|
945
|
+
* cuts as `"crop"` does, as the reference's `truncate` does.
|
|
960
946
|
*/
|
|
961
|
-
*_fitLine(line, maxWidth, overflow) {
|
|
947
|
+
*_fitLine(line, maxWidth, overflow, ellipsis) {
|
|
962
948
|
const lineWidth = Segment.getLineLength(line);
|
|
963
949
|
const contentWidth = lineWidth - hangingWhitespace(line);
|
|
964
950
|
// Whitespace hanging past the edge is the wrap's own padding: cropping it
|
|
@@ -982,7 +968,7 @@ export class RichText {
|
|
|
982
968
|
// three different kinds of nothing.
|
|
983
969
|
if (overflow === "ellipsis" && maxWidth > 0) {
|
|
984
970
|
yield* Segment.adjustLineLength(line, maxWidth - 1, undefined, false);
|
|
985
|
-
yield new Segment(
|
|
971
|
+
yield new Segment(ellipsis);
|
|
986
972
|
return;
|
|
987
973
|
}
|
|
988
974
|
yield* Segment.adjustLineLength(line, maxWidth, undefined, false);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App — the runtime a terminal application runs in: it takes the terminal,
|
|
3
|
+
* paints one renderable on it every frame, and hands the terminal back on
|
|
4
|
+
* every path out.
|
|
5
|
+
*
|
|
6
|
+
* [LAW:single-enforcer] Every change this runtime makes to the terminal —
|
|
7
|
+
* the alternate screen, pointer reporting, the hidden cursor, raw mode — is
|
|
8
|
+
* made by `enter` and
|
|
9
|
+
* undone by `leave`, and nothing else touches them. Stopping, suspending, an
|
|
10
|
+
* error in a frame and the program ending by a signal or a crash all hand the
|
|
11
|
+
* terminal back through `leave`, so there is one list of what to undo and no
|
|
12
|
+
* exit path can hold a shorter one.
|
|
13
|
+
*
|
|
14
|
+
* [LAW:one-source-of-truth] The frame on screen is one value: the rows last
|
|
15
|
+
* painted, carrying on every cell where its owner drew it (`core/anchor`).
|
|
16
|
+
* Nothing about it is cached beside it, so a resize invalidates nothing — it
|
|
17
|
+
* is input to the next frame, which replaces the rows and their anchors at
|
|
18
|
+
* once. An event arriving before that repaint lands on the frame on screen,
|
|
19
|
+
* which is the one the user pointed at.
|
|
20
|
+
*
|
|
21
|
+
* [LAW:locality-or-seam] The terminal's bytes are identical under every host
|
|
22
|
+
* and live here: entering and leaving the alternate screen, the cursor, the
|
|
23
|
+
* frame's encoding. What differs per host — whether the program can end
|
|
24
|
+
* under us, how it is suspended — is the `TerminalHost`'s, so the same app
|
|
25
|
+
* runs in node and in a browser with no path of its own for either.
|
|
26
|
+
*/
|
|
27
|
+
import { Segment } from "../core/segment.js";
|
|
28
|
+
import { type Renderable } from "../core/protocol.js";
|
|
29
|
+
import type { Unsubscribe } from "../core/subscription.js";
|
|
30
|
+
import type { TerminalHost } from "./terminal-host.js";
|
|
31
|
+
/**
|
|
32
|
+
* Where the frame is painted. `alternate` is the whole terminal, in the
|
|
33
|
+
* alternate screen buffer, so the rows the program printed before it are
|
|
34
|
+
* there again when it stops; the terminal reports the pointer on it. `inline`
|
|
35
|
+
* starts at the cursor's line, as tall as the frame, and stays on the
|
|
36
|
+
* terminal when the app stops. It gets no pointer events: the terminal
|
|
37
|
+
* reports a pointer by its row on the screen, and the app does not know
|
|
38
|
+
* which row of the screen its frame starts on.
|
|
39
|
+
*/
|
|
40
|
+
export type Surface = "alternate" | "inline";
|
|
41
|
+
/**
|
|
42
|
+
* Where an app is in its life. `idle` has not run; `running` holds the
|
|
43
|
+
* terminal and paints; `suspended` has handed it back until the host resumes
|
|
44
|
+
* the program; `stopped` has handed it back for good.
|
|
45
|
+
*/
|
|
46
|
+
export type AppPhase = "idle" | "running" | "suspended" | "stopped";
|
|
47
|
+
export interface AppOptions {
|
|
48
|
+
/** The terminal the app runs on. The app starts and stops it. */
|
|
49
|
+
readonly host: TerminalHost;
|
|
50
|
+
readonly surface: Surface;
|
|
51
|
+
/**
|
|
52
|
+
* The app's root renderable, asked for once per frame, so a view of the
|
|
53
|
+
* app's state is drawn from the state as it is at that frame.
|
|
54
|
+
*/
|
|
55
|
+
readonly view: () => Renderable;
|
|
56
|
+
/**
|
|
57
|
+
* The terminal can draw only ASCII, so every frame draws its boxes, guides
|
|
58
|
+
* and widget marks with ASCII characters. Default false.
|
|
59
|
+
*/
|
|
60
|
+
readonly asciiOnly?: boolean;
|
|
61
|
+
}
|
|
62
|
+
export declare class App {
|
|
63
|
+
private readonly host;
|
|
64
|
+
private readonly surface;
|
|
65
|
+
private readonly view;
|
|
66
|
+
private readonly console;
|
|
67
|
+
private _phase;
|
|
68
|
+
private _frame;
|
|
69
|
+
private rows;
|
|
70
|
+
private refreshQueued;
|
|
71
|
+
private subscriptions;
|
|
72
|
+
private readonly paintHandlers;
|
|
73
|
+
private settle;
|
|
74
|
+
constructor(options: AppOptions);
|
|
75
|
+
get phase(): AppPhase;
|
|
76
|
+
/** The rows on screen now, as painted: what a pointer event lands on. */
|
|
77
|
+
get frame(): readonly (readonly Segment[])[];
|
|
78
|
+
/**
|
|
79
|
+
* Take the terminal, paint the first frame, and resolve when the app stops
|
|
80
|
+
* — or reject with the error a frame threw or `fail` was given, the terminal
|
|
81
|
+
* handed back first.
|
|
82
|
+
* When the program ends under the app, `run` does not settle: nothing after
|
|
83
|
+
* it runs, as nothing after it would have without the app. An app runs once.
|
|
84
|
+
*/
|
|
85
|
+
run(): Promise<void>;
|
|
86
|
+
/**
|
|
87
|
+
* Paint a new frame from the view, once, in a later task: every change made
|
|
88
|
+
* before then lands in the one frame.
|
|
89
|
+
*
|
|
90
|
+
* [LAW:no-ambient-temporal-coupling] A task, not a microtask. Input is read
|
|
91
|
+
* between tasks, so a view whose every frame asks for another still hears
|
|
92
|
+
* its keys — Ctrl+C included — where a microtask chain would starve them
|
|
93
|
+
* for as long as it ran.
|
|
94
|
+
*/
|
|
95
|
+
refresh(): void;
|
|
96
|
+
/**
|
|
97
|
+
* Hand the terminal to whatever launched the program, and take it back and
|
|
98
|
+
* repaint when that resumes it. What sends the app here is the app's own —
|
|
99
|
+
* in raw mode Ctrl+Z arrives as a key.
|
|
100
|
+
*/
|
|
101
|
+
suspend(): Promise<void>;
|
|
102
|
+
/**
|
|
103
|
+
* Hear each frame once it is on screen — what `frame` now returns. A
|
|
104
|
+
* handler that throws fails the app as a frame that throws does.
|
|
105
|
+
*/
|
|
106
|
+
onPaint(handler: (frame: readonly (readonly Segment[])[]) => void): Unsubscribe;
|
|
107
|
+
/** Hand the terminal back for good; `run` resolves. */
|
|
108
|
+
stop(): void;
|
|
109
|
+
/**
|
|
110
|
+
* Hand the terminal back for good; `run` rejects with `error`. For an error
|
|
111
|
+
* thrown where the app's own code runs outside a frame — a key handler, a
|
|
112
|
+
* timer — which would otherwise reach neither `run`'s caller nor the
|
|
113
|
+
* terminal's hand-back.
|
|
114
|
+
*/
|
|
115
|
+
fail(error: unknown): void;
|
|
116
|
+
private enter;
|
|
117
|
+
private leave;
|
|
118
|
+
private end;
|
|
119
|
+
private paint;
|
|
120
|
+
private draw;
|
|
121
|
+
}
|
package/dist/host/app.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App — the runtime a terminal application runs in: it takes the terminal,
|
|
3
|
+
* paints one renderable on it every frame, and hands the terminal back on
|
|
4
|
+
* every path out.
|
|
5
|
+
*
|
|
6
|
+
* [LAW:single-enforcer] Every change this runtime makes to the terminal —
|
|
7
|
+
* the alternate screen, pointer reporting, the hidden cursor, raw mode — is
|
|
8
|
+
* made by `enter` and
|
|
9
|
+
* undone by `leave`, and nothing else touches them. Stopping, suspending, an
|
|
10
|
+
* error in a frame and the program ending by a signal or a crash all hand the
|
|
11
|
+
* terminal back through `leave`, so there is one list of what to undo and no
|
|
12
|
+
* exit path can hold a shorter one.
|
|
13
|
+
*
|
|
14
|
+
* [LAW:one-source-of-truth] The frame on screen is one value: the rows last
|
|
15
|
+
* painted, carrying on every cell where its owner drew it (`core/anchor`).
|
|
16
|
+
* Nothing about it is cached beside it, so a resize invalidates nothing — it
|
|
17
|
+
* is input to the next frame, which replaces the rows and their anchors at
|
|
18
|
+
* once. An event arriving before that repaint lands on the frame on screen,
|
|
19
|
+
* which is the one the user pointed at.
|
|
20
|
+
*
|
|
21
|
+
* [LAW:locality-or-seam] The terminal's bytes are identical under every host
|
|
22
|
+
* and live here: entering and leaving the alternate screen, the cursor, the
|
|
23
|
+
* frame's encoding. What differs per host — whether the program can end
|
|
24
|
+
* under us, how it is suspended — is the `TerminalHost`'s, so the same app
|
|
25
|
+
* runs in node and in a browser with no path of its own for either.
|
|
26
|
+
*/
|
|
27
|
+
import { Console } from "../core/console.js";
|
|
28
|
+
import { Segment } from "../core/segment.js";
|
|
29
|
+
import { segmentsToString } from "../core/render.js";
|
|
30
|
+
import { fitHeight } from "../core/protocol.js";
|
|
31
|
+
import { hostEnvironment } from "./host-environment.js";
|
|
32
|
+
// Button presses, motion and the wheel, in the SGR encoding: coordinates as
|
|
33
|
+
// decimal numbers, so a terminal wider than 223 columns still reports them.
|
|
34
|
+
const POINTER_ON = "\x1b[?1006h\x1b[?1000h\x1b[?1003h";
|
|
35
|
+
const POINTER_OFF = "\x1b[?1003l\x1b[?1000l\x1b[?1006l";
|
|
36
|
+
const SURFACES = {
|
|
37
|
+
alternate: {
|
|
38
|
+
exact: true,
|
|
39
|
+
enter: "\x1b[?1049h" + POINTER_ON,
|
|
40
|
+
leave: () => POINTER_OFF + "\x1b[?1049l",
|
|
41
|
+
home: () => "\x1b[H",
|
|
42
|
+
// The frame is every row of the screen, so there is nothing left under it
|
|
43
|
+
// — and after the terminal shrinks, painting the old count would scroll.
|
|
44
|
+
painted: (frameRows) => frameRows,
|
|
45
|
+
},
|
|
46
|
+
inline: {
|
|
47
|
+
exact: false,
|
|
48
|
+
enter: "",
|
|
49
|
+
// Below the frame, where the program's next line belongs.
|
|
50
|
+
leave: (rows) => (rows > 0 ? "\n" : ""),
|
|
51
|
+
// After N rows written with N-1 newlines between them, the cursor is on
|
|
52
|
+
// the last. `ESC[0A` still moves a row on some terminals, so a one-row
|
|
53
|
+
// frame is returned to with the carriage return alone.
|
|
54
|
+
home: (rows) => (rows > 1 ? `\x1b[${rows - 1}A\r` : "\r"),
|
|
55
|
+
// A shorter frame blanks the rows the last one left below it.
|
|
56
|
+
painted: (frameRows, lastRows) => Math.max(frameRows, lastRows),
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
60
|
+
const SHOW_CURSOR = "\x1b[?25h";
|
|
61
|
+
const RESET_STYLE = "\x1b[0m";
|
|
62
|
+
const ERASE_LINE = "\x1b[2K";
|
|
63
|
+
export class App {
|
|
64
|
+
host;
|
|
65
|
+
surface;
|
|
66
|
+
view;
|
|
67
|
+
// [LAW:one-source-of-truth] The host is the console's whole environment:
|
|
68
|
+
// its size, its colours, where bytes go. Every frame renders with this
|
|
69
|
+
// console's options and is encoded for its destination.
|
|
70
|
+
console;
|
|
71
|
+
_phase = "idle";
|
|
72
|
+
_frame = [];
|
|
73
|
+
// The last frame's own rows on the terminal — what `home` rewinds.
|
|
74
|
+
rows = 0;
|
|
75
|
+
refreshQueued = false;
|
|
76
|
+
subscriptions = [];
|
|
77
|
+
paintHandlers = new Set();
|
|
78
|
+
settle = () => { };
|
|
79
|
+
constructor(options) {
|
|
80
|
+
this.host = options.host;
|
|
81
|
+
this.surface = SURFACES[options.surface];
|
|
82
|
+
this.view = options.view;
|
|
83
|
+
this.console = new Console({ environment: hostEnvironment(options.host), asciiOnly: options.asciiOnly });
|
|
84
|
+
}
|
|
85
|
+
get phase() {
|
|
86
|
+
return this._phase;
|
|
87
|
+
}
|
|
88
|
+
/** The rows on screen now, as painted: what a pointer event lands on. */
|
|
89
|
+
get frame() {
|
|
90
|
+
return this._frame;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Take the terminal, paint the first frame, and resolve when the app stops
|
|
94
|
+
* — or reject with the error a frame threw or `fail` was given, the terminal
|
|
95
|
+
* handed back first.
|
|
96
|
+
* When the program ends under the app, `run` does not settle: nothing after
|
|
97
|
+
* it runs, as nothing after it would have without the app. An app runs once.
|
|
98
|
+
*/
|
|
99
|
+
run() {
|
|
100
|
+
if (this._phase !== "idle") {
|
|
101
|
+
return Promise.reject(new Error(`App.run: an app runs once, and this one is ${this._phase}`));
|
|
102
|
+
}
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
this.settle = (outcome) => {
|
|
105
|
+
switch (outcome.kind) {
|
|
106
|
+
case "stopped":
|
|
107
|
+
return resolve();
|
|
108
|
+
case "failed":
|
|
109
|
+
return reject(outcome.error);
|
|
110
|
+
// The rest of a crash — its report, then its exit — is still
|
|
111
|
+
// running, and code resumed after `run` would run inside it.
|
|
112
|
+
case "ended":
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
this._phase = "running";
|
|
117
|
+
this.host.start();
|
|
118
|
+
this.subscriptions = [
|
|
119
|
+
this.host.onResize(() => this.refresh()),
|
|
120
|
+
// The program is ending under the app — by a signal, a crash, or an
|
|
121
|
+
// exit the app's own code never saw. Ending hands the terminal back,
|
|
122
|
+
// and the program then ends as it was going to.
|
|
123
|
+
this.host.onExit(() => this.end({ kind: "ended" })),
|
|
124
|
+
];
|
|
125
|
+
this.enter();
|
|
126
|
+
this.paint();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Paint a new frame from the view, once, in a later task: every change made
|
|
131
|
+
* before then lands in the one frame.
|
|
132
|
+
*
|
|
133
|
+
* [LAW:no-ambient-temporal-coupling] A task, not a microtask. Input is read
|
|
134
|
+
* between tasks, so a view whose every frame asks for another still hears
|
|
135
|
+
* its keys — Ctrl+C included — where a microtask chain would starve them
|
|
136
|
+
* for as long as it ran.
|
|
137
|
+
*/
|
|
138
|
+
refresh() {
|
|
139
|
+
if (this.refreshQueued)
|
|
140
|
+
return;
|
|
141
|
+
this.refreshQueued = true;
|
|
142
|
+
setTimeout(() => {
|
|
143
|
+
this.refreshQueued = false;
|
|
144
|
+
// [LAW:types-are-the-program] Only a running app holds the terminal; a
|
|
145
|
+
// suspended one repaints when it resumes, and a stopped one never does.
|
|
146
|
+
if (this._phase === "running")
|
|
147
|
+
this.paint();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Hand the terminal to whatever launched the program, and take it back and
|
|
152
|
+
* repaint when that resumes it. What sends the app here is the app's own —
|
|
153
|
+
* in raw mode Ctrl+Z arrives as a key.
|
|
154
|
+
*/
|
|
155
|
+
async suspend() {
|
|
156
|
+
if (this._phase !== "running")
|
|
157
|
+
return;
|
|
158
|
+
this._phase = "suspended";
|
|
159
|
+
this.leave();
|
|
160
|
+
await this.host.suspend();
|
|
161
|
+
// Stopped while suspended — by a signal, say: the terminal is already
|
|
162
|
+
// handed back for good.
|
|
163
|
+
if (this._phase !== "suspended")
|
|
164
|
+
return;
|
|
165
|
+
this._phase = "running";
|
|
166
|
+
// What the last frame occupied is gone under whatever ran meanwhile, so
|
|
167
|
+
// the next one starts where the cursor is.
|
|
168
|
+
this.rows = 0;
|
|
169
|
+
this.enter();
|
|
170
|
+
this.paint();
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Hear each frame once it is on screen — what `frame` now returns. A
|
|
174
|
+
* handler that throws fails the app as a frame that throws does.
|
|
175
|
+
*/
|
|
176
|
+
onPaint(handler) {
|
|
177
|
+
this.paintHandlers.add(handler);
|
|
178
|
+
return () => this.paintHandlers.delete(handler);
|
|
179
|
+
}
|
|
180
|
+
/** Hand the terminal back for good; `run` resolves. */
|
|
181
|
+
stop() {
|
|
182
|
+
this.end({ kind: "stopped" });
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Hand the terminal back for good; `run` rejects with `error`. For an error
|
|
186
|
+
* thrown where the app's own code runs outside a frame — a key handler, a
|
|
187
|
+
* timer — which would otherwise reach neither `run`'s caller nor the
|
|
188
|
+
* terminal's hand-back.
|
|
189
|
+
*/
|
|
190
|
+
fail(error) {
|
|
191
|
+
this.end({ kind: "failed", error });
|
|
192
|
+
}
|
|
193
|
+
// --- the terminal ---
|
|
194
|
+
enter() {
|
|
195
|
+
this.host.setRawMode(true);
|
|
196
|
+
this.host.write(this.surface.enter + HIDE_CURSOR);
|
|
197
|
+
}
|
|
198
|
+
leave() {
|
|
199
|
+
this.host.write(RESET_STYLE + SHOW_CURSOR + this.surface.leave(this.rows));
|
|
200
|
+
this.host.setRawMode(false);
|
|
201
|
+
}
|
|
202
|
+
end(outcome) {
|
|
203
|
+
const phase = this._phase;
|
|
204
|
+
if (phase === "idle" || phase === "stopped")
|
|
205
|
+
return;
|
|
206
|
+
this._phase = "stopped";
|
|
207
|
+
for (const unsubscribe of this.subscriptions)
|
|
208
|
+
unsubscribe();
|
|
209
|
+
this.subscriptions = [];
|
|
210
|
+
// A suspended app handed the terminal back when it suspended.
|
|
211
|
+
if (phase === "running")
|
|
212
|
+
this.leave();
|
|
213
|
+
this.host.stop();
|
|
214
|
+
this.settle(outcome);
|
|
215
|
+
}
|
|
216
|
+
// [LAW:no-silent-failure] A frame that throws ends the app with that
|
|
217
|
+
// error: the terminal is handed back, and `run` rejects with it.
|
|
218
|
+
paint() {
|
|
219
|
+
try {
|
|
220
|
+
this.draw();
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
this.end({ kind: "failed", error });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
draw() {
|
|
227
|
+
const options = this.console.options;
|
|
228
|
+
const { maxWidth: cols } = options;
|
|
229
|
+
const height = { rows: options.height.rows, exact: this.surface.exact };
|
|
230
|
+
const lines = Segment.splitLines(this.view().render({ ...options, height }));
|
|
231
|
+
// The app set the budget, so the app shapes what comes back: no deeper
|
|
232
|
+
// than the terminal — a taller frame would scroll the rows `home` counts
|
|
233
|
+
// back over — and, as a region, exactly that deep. No row wider than the
|
|
234
|
+
// terminal either: one that soft-wrapped would push every row below it
|
|
235
|
+
// down a row the frame does not know about.
|
|
236
|
+
const frame = fitHeight(lines.slice(0, height.rows), height).map((line) => Segment.adjustLineLength(line, cols, undefined, false));
|
|
237
|
+
const painted = Math.min(this.surface.painted(frame.length, this.rows), height.rows);
|
|
238
|
+
// Each row is erased as it is reached rather than the frame cleared
|
|
239
|
+
// first, so no blank screen shows between two frames. The erase leads
|
|
240
|
+
// its row: after a row that fills the width, the cursor sits on its last
|
|
241
|
+
// cell, and an erase there would take it.
|
|
242
|
+
const destination = this.console.destination;
|
|
243
|
+
const body = Array.from({ length: painted }, (_, row) => ERASE_LINE + segmentsToString(frame[row] ?? [], destination)).join("\n");
|
|
244
|
+
// Rows blanked below the frame are not the frame's: the cursor goes back
|
|
245
|
+
// up to its last row, so the next frame and the program's next line start
|
|
246
|
+
// from the frame's own height.
|
|
247
|
+
const blanked = painted - Math.max(frame.length, 1);
|
|
248
|
+
const back = blanked > 0 ? `\x1b[${blanked}A` : "";
|
|
249
|
+
this.host.write(this.surface.home(this.rows) + body + back);
|
|
250
|
+
this._frame = frame;
|
|
251
|
+
this.rows = frame.length;
|
|
252
|
+
for (const handler of [...this.paintHandlers])
|
|
253
|
+
handler(frame);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hostEnvironment — a `TerminalHost` as the `ConsoleEnvironment` a `Console`
|
|
3
|
+
* consults, so a console writing through a host asks that host every question
|
|
4
|
+
* it has about its destination.
|
|
5
|
+
*
|
|
6
|
+
* [LAW:one-source-of-truth] A console asks its destination four things: where
|
|
7
|
+
* bytes go, is it a terminal, how big is it, and — through the environment —
|
|
8
|
+
* what colours it draws. A bare sink answers only the first, and the other
|
|
9
|
+
* three fall to the ambient `process`: some other stream's answer in node,
|
|
10
|
+
* none in a browser, where a console on xterm.js saw an unnamed TTY and drew
|
|
11
|
+
* 16 colours. Handing the console the whole environment keeps all four
|
|
12
|
+
* answers the host's.
|
|
13
|
+
*
|
|
14
|
+
* The host's size is `size()`, so the environment handed on carries no
|
|
15
|
+
* `COLUMNS` or `LINES`: a console reads those ahead of the stream, and an
|
|
16
|
+
* exported `COLUMNS` in the shell that launched a node host would pin the
|
|
17
|
+
* console's width while the terminal it draws on is resized. The stream's
|
|
18
|
+
* `columns` and `rows` are getters, so a console that was not given a fixed
|
|
19
|
+
* size follows the host through resizes.
|
|
20
|
+
*/
|
|
21
|
+
import type { ConsoleEnvironment, ConsoleStream } from "../core/console.js";
|
|
22
|
+
import type { TerminalHost } from "./terminal-host.js";
|
|
23
|
+
export declare function hostEnvironment(host: TerminalHost): ConsoleEnvironment & {
|
|
24
|
+
readonly stdout: ConsoleStream;
|
|
25
|
+
readonly stderr: ConsoleStream;
|
|
26
|
+
};
|