@zaly/tui 0.0.4
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/LICENSE +21 -0
- package/README.md +59 -0
- package/assets/schemas/theme.schema.json +899 -0
- package/assets/themes/LICENSE-tokyonight.txt +201 -0
- package/assets/themes/NOTICES.md +69 -0
- package/assets/themes/ayu-dark.json +119 -0
- package/assets/themes/catppuccin-frappe.json +124 -0
- package/assets/themes/catppuccin-latte.json +124 -0
- package/assets/themes/catppuccin-macchiato.json +124 -0
- package/assets/themes/catppuccin-mocha.json +124 -0
- package/assets/themes/dracula-soft.json +119 -0
- package/assets/themes/dracula.json +119 -0
- package/assets/themes/everforest-dark.json +118 -0
- package/assets/themes/everforest-light.json +118 -0
- package/assets/themes/github-dark.json +123 -0
- package/assets/themes/github-light.json +123 -0
- package/assets/themes/gruvbox-dark-medium.json +112 -0
- package/assets/themes/kanagawa-dragon.json +115 -0
- package/assets/themes/kanagawa-wave.json +115 -0
- package/assets/themes/material-theme-palenight.json +114 -0
- package/assets/themes/min-dark.json +55 -0
- package/assets/themes/min-light.json +59 -0
- package/assets/themes/monokai.json +116 -0
- package/assets/themes/night-owl.json +116 -0
- package/assets/themes/nord.json +101 -0
- package/assets/themes/one-dark-pro.json +110 -0
- package/assets/themes/one-light.json +108 -0
- package/assets/themes/poimandres.json +123 -0
- package/assets/themes/rose-pine.json +98 -0
- package/assets/themes/solarized-dark.json +116 -0
- package/assets/themes/solarized-light.json +116 -0
- package/assets/themes/synthwave-84.json +100 -0
- package/assets/themes/tokyonight-day.json +157 -0
- package/assets/themes/tokyonight-moon.json +157 -0
- package/assets/themes/tokyonight-night.json +157 -0
- package/assets/themes/tokyonight-storm.json +157 -0
- package/assets/themes/vesper.json +103 -0
- package/assets/themes/vitesse-dark.json +126 -0
- package/assets/themes/vitesse-light.json +126 -0
- package/dist/ansi-Clq0ozyA.mjs +206 -0
- package/dist/ansi-Clq0ozyA.mjs.map +1 -0
- package/dist/ansi.d.mts +48 -0
- package/dist/ansi.d.mts.map +1 -0
- package/dist/ansi.mjs +2 -0
- package/dist/autocomplete-Dfw-yQDa.d.mts +100 -0
- package/dist/autocomplete-Dfw-yQDa.d.mts.map +1 -0
- package/dist/box-DyaByEiG.mjs +386 -0
- package/dist/box-DyaByEiG.mjs.map +1 -0
- package/dist/builder-D49ECBEf.mjs +385 -0
- package/dist/builder-D49ECBEf.mjs.map +1 -0
- package/dist/callbacks-CNjGNxVw.mjs +279 -0
- package/dist/callbacks-CNjGNxVw.mjs.map +1 -0
- package/dist/client-D6C_uczF.mjs +142 -0
- package/dist/client-D6C_uczF.mjs.map +1 -0
- package/dist/clipboard.d.mts +104 -0
- package/dist/clipboard.d.mts.map +1 -0
- package/dist/clipboard.mjs +404 -0
- package/dist/clipboard.mjs.map +1 -0
- package/dist/flex-Bguo-fUe.mjs +202 -0
- package/dist/flex-Bguo-fUe.mjs.map +1 -0
- package/dist/image-Cm5T-uw-.mjs +108 -0
- package/dist/image-Cm5T-uw-.mjs.map +1 -0
- package/dist/image.d.mts +123 -0
- package/dist/image.d.mts.map +1 -0
- package/dist/image.mjs +2 -0
- package/dist/index.d.mts +64 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +222 -0
- package/dist/index.mjs.map +1 -0
- package/dist/input-B5Vy-iaa.d.mts +141 -0
- package/dist/input-B5Vy-iaa.d.mts.map +1 -0
- package/dist/inspect-D1d9t-ni.mjs +101 -0
- package/dist/inspect-D1d9t-ni.mjs.map +1 -0
- package/dist/inspect-D6FW_QUo.d.mts +67 -0
- package/dist/inspect-D6FW_QUo.d.mts.map +1 -0
- package/dist/kitty-JbCJC0iQ.mjs +673 -0
- package/dist/kitty-JbCJC0iQ.mjs.map +1 -0
- package/dist/markdown.d.mts +24 -0
- package/dist/markdown.d.mts.map +1 -0
- package/dist/markdown.mjs +2 -0
- package/dist/md.bun.d.mts +14 -0
- package/dist/md.bun.d.mts.map +1 -0
- package/dist/md.bun.mjs +28 -0
- package/dist/md.bun.mjs.map +1 -0
- package/dist/md.node.d.mts +14 -0
- package/dist/md.node.d.mts.map +1 -0
- package/dist/md.node.mjs +126 -0
- package/dist/md.node.mjs.map +1 -0
- package/dist/node-BZ7f6HzU.mjs +316 -0
- package/dist/node-BZ7f6HzU.mjs.map +1 -0
- package/dist/picker-D8OLb6kZ.d.mts +36 -0
- package/dist/picker-D8OLb6kZ.d.mts.map +1 -0
- package/dist/picker-ndVVt6qF.mjs +621 -0
- package/dist/picker-ndVVt6qF.mjs.map +1 -0
- package/dist/queries-DH3bj74K.d.mts +1963 -0
- package/dist/queries-DH3bj74K.d.mts.map +1 -0
- package/dist/reactive-f7NpAi69.mjs +623 -0
- package/dist/reactive-f7NpAi69.mjs.map +1 -0
- package/dist/registry-U654j61Y.mjs +356 -0
- package/dist/registry-U654j61Y.mjs.map +1 -0
- package/dist/render-DxVLqqB9.mjs +74 -0
- package/dist/render-DxVLqqB9.mjs.map +1 -0
- package/dist/renderer-CdLCkJB-.mjs +42 -0
- package/dist/renderer-CdLCkJB-.mjs.map +1 -0
- package/dist/renderer-CxL4xbkl.mjs +2644 -0
- package/dist/renderer-CxL4xbkl.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/search-FvGnswRl.d.mts +49 -0
- package/dist/search-FvGnswRl.d.mts.map +1 -0
- package/dist/select-CbmAZdpt.d.mts +116 -0
- package/dist/select-CbmAZdpt.d.mts.map +1 -0
- package/dist/services/logger.d.mts +2 -0
- package/dist/services/logger.mjs +71 -0
- package/dist/services/logger.mjs.map +1 -0
- package/dist/services/notifier.d.mts +17 -0
- package/dist/services/notifier.d.mts.map +1 -0
- package/dist/services/notifier.mjs +71 -0
- package/dist/services/notifier.mjs.map +1 -0
- package/dist/services/picker.d.mts +36 -0
- package/dist/services/picker.d.mts.map +1 -0
- package/dist/services/picker.mjs +137 -0
- package/dist/services/picker.mjs.map +1 -0
- package/dist/services/prompt.d.mts +30 -0
- package/dist/services/prompt.d.mts.map +1 -0
- package/dist/services/prompt.mjs +83 -0
- package/dist/services/prompt.mjs.map +1 -0
- package/dist/shiki-BrjVUkeh.mjs +423 -0
- package/dist/shiki-BrjVUkeh.mjs.map +1 -0
- package/dist/size-D8JogYLy.mjs +34 -0
- package/dist/size-D8JogYLy.mjs.map +1 -0
- package/dist/style.d.mts +10 -0
- package/dist/style.d.mts.map +1 -0
- package/dist/style.mjs +4 -0
- package/dist/text.d.mts +33 -0
- package/dist/text.d.mts.map +1 -0
- package/dist/text.mjs +111 -0
- package/dist/text.mjs.map +1 -0
- package/dist/theme.config-CU3B50HN.mjs +832 -0
- package/dist/theme.config-CU3B50HN.mjs.map +1 -0
- package/dist/themes.d.mts +113 -0
- package/dist/themes.d.mts.map +1 -0
- package/dist/themes.mjs +2 -0
- package/dist/tree-C28QOVIK.d.mts +23 -0
- package/dist/tree-C28QOVIK.d.mts.map +1 -0
- package/dist/types-B9Eh7k2v.d.mts +212 -0
- package/dist/types-B9Eh7k2v.d.mts.map +1 -0
- package/dist/types-D3jk1rtk.d.mts +140 -0
- package/dist/types-D3jk1rtk.d.mts.map +1 -0
- package/dist/utils-3M4D_AXZ.mjs +40 -0
- package/dist/utils-3M4D_AXZ.mjs.map +1 -0
- package/dist/widgets/autocomplete.d.mts +2 -0
- package/dist/widgets/autocomplete.mjs +165 -0
- package/dist/widgets/autocomplete.mjs.map +1 -0
- package/dist/widgets/box.d.mts +2 -0
- package/dist/widgets/box.mjs +2 -0
- package/dist/widgets/code.d.mts +48 -0
- package/dist/widgets/code.d.mts.map +1 -0
- package/dist/widgets/code.mjs +59 -0
- package/dist/widgets/code.mjs.map +1 -0
- package/dist/widgets/completions/actions.d.mts +41 -0
- package/dist/widgets/completions/actions.d.mts.map +1 -0
- package/dist/widgets/completions/actions.mjs +48 -0
- package/dist/widgets/completions/actions.mjs.map +1 -0
- package/dist/widgets/completions/files.d.mts +29 -0
- package/dist/widgets/completions/files.d.mts.map +1 -0
- package/dist/widgets/completions/files.mjs +42 -0
- package/dist/widgets/completions/files.mjs.map +1 -0
- package/dist/widgets/completions/github.d.mts +64 -0
- package/dist/widgets/completions/github.d.mts.map +1 -0
- package/dist/widgets/completions/github.mjs +137 -0
- package/dist/widgets/completions/github.mjs.map +1 -0
- package/dist/widgets/completions/index.d.mts +4 -0
- package/dist/widgets/completions/index.mjs +4 -0
- package/dist/widgets/diff.d.mts +57 -0
- package/dist/widgets/diff.d.mts.map +1 -0
- package/dist/widgets/diff.mjs +199 -0
- package/dist/widgets/diff.mjs.map +1 -0
- package/dist/widgets/divider.d.mts +11 -0
- package/dist/widgets/divider.d.mts.map +1 -0
- package/dist/widgets/divider.mjs +9 -0
- package/dist/widgets/divider.mjs.map +1 -0
- package/dist/widgets/image.d.mts +37 -0
- package/dist/widgets/image.d.mts.map +1 -0
- package/dist/widgets/image.mjs +77 -0
- package/dist/widgets/image.mjs.map +1 -0
- package/dist/widgets/input.d.mts +2 -0
- package/dist/widgets/input.mjs +497 -0
- package/dist/widgets/input.mjs.map +1 -0
- package/dist/widgets/log.d.mts +2 -0
- package/dist/widgets/log.mjs +130 -0
- package/dist/widgets/log.mjs.map +1 -0
- package/dist/widgets/markdown.d.mts +45 -0
- package/dist/widgets/markdown.d.mts.map +1 -0
- package/dist/widgets/markdown.mjs +124 -0
- package/dist/widgets/markdown.mjs.map +1 -0
- package/dist/widgets/overlay.d.mts +2 -0
- package/dist/widgets/overlay.mjs +18 -0
- package/dist/widgets/overlay.mjs.map +1 -0
- package/dist/widgets/picker.d.mts +2 -0
- package/dist/widgets/picker.mjs +2 -0
- package/dist/widgets/progress.d.mts +48 -0
- package/dist/widgets/progress.d.mts.map +1 -0
- package/dist/widgets/progress.mjs +48 -0
- package/dist/widgets/progress.mjs.map +1 -0
- package/dist/widgets/select.d.mts +2 -0
- package/dist/widgets/select.mjs +195 -0
- package/dist/widgets/select.mjs.map +1 -0
- package/dist/widgets/show.d.mts +55 -0
- package/dist/widgets/show.d.mts.map +1 -0
- package/dist/widgets/show.mjs +85 -0
- package/dist/widgets/show.mjs.map +1 -0
- package/dist/widgets/spinner.d.mts +65 -0
- package/dist/widgets/spinner.d.mts.map +1 -0
- package/dist/widgets/spinner.mjs +147 -0
- package/dist/widgets/spinner.mjs.map +1 -0
- package/dist/widgets/text.d.mts +2 -0
- package/dist/widgets/text.mjs +32 -0
- package/dist/widgets/text.mjs.map +1 -0
- package/dist/widgets/tree.d.mts +2 -0
- package/dist/widgets/tree.mjs +73 -0
- package/dist/widgets/tree.mjs.map +1 -0
- package/dist/widgets/widget.d.mts +49 -0
- package/dist/widgets/widget.d.mts.map +1 -0
- package/dist/widgets/widget.mjs +40 -0
- package/dist/widgets/widget.mjs.map +1 -0
- package/dist/worker-DjHuQSbY.mjs +27 -0
- package/dist/worker-DjHuQSbY.mjs.map +1 -0
- package/package.json +117 -0
|
@@ -0,0 +1,2644 @@
|
|
|
1
|
+
import { C as useActiveOwner, E as withOwner, S as unwrap, _ as peek, d as createSuspenseBoundary, h as memo, l as createRoot, n as SuspenseContext, o as createNode, v as provideContext, x as untrack, y as signal } from "./reactive-f7NpAi69.mjs";
|
|
2
|
+
import { t as defaultTheme } from "./registry-U654j61Y.mjs";
|
|
3
|
+
import { s as stripAnsiBg } from "./ansi-Clq0ozyA.mjs";
|
|
4
|
+
import { t as styleBuilder } from "./builder-D49ECBEf.mjs";
|
|
5
|
+
import { n as box } from "./box-DyaByEiG.mjs";
|
|
6
|
+
import { r as createCtx, t as RenderContext } from "./render-DxVLqqB9.mjs";
|
|
7
|
+
import { TuiReporter } from "./services/logger.mjs";
|
|
8
|
+
import { o as bumpPlacements } from "./kitty-JbCJC0iQ.mjs";
|
|
9
|
+
import { TerminalQueries, canonical } from "./index.mjs";
|
|
10
|
+
import { Emitter } from "@zaly/shared";
|
|
11
|
+
import { sliceAnsi, stringWidth, stripAnsi } from "@zaly/shared/ansi";
|
|
12
|
+
import { inspect, isDeepStrictEqual } from "node:util";
|
|
13
|
+
import { Logger } from "@zaly/shared/logger";
|
|
14
|
+
import { throttle } from "@zaly/shared/throttle";
|
|
15
|
+
//#region src/input/actions.ts
|
|
16
|
+
function filterAction(action, filter) {
|
|
17
|
+
if (!action) return false;
|
|
18
|
+
if (!filter) return true;
|
|
19
|
+
if (filter.cmd && filter.cmd !== action.cmd) return false;
|
|
20
|
+
if (filter.id) {
|
|
21
|
+
if (typeof filter.id === "string" && filter.id !== action.id) return false;
|
|
22
|
+
if (filter.id instanceof RegExp && !filter.id.test(action.id)) return false;
|
|
23
|
+
}
|
|
24
|
+
if (filter.hidden !== void 0 && filter.hidden !== (action.hidden ?? false)) return false;
|
|
25
|
+
if (filter.filter && !filter.filter(action)) return false;
|
|
26
|
+
if (filter.source && filter.source !== action.source) return false;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Runtime registry of action metadata + impls.
|
|
31
|
+
*
|
|
32
|
+
* `register(entries)` merges new `ActionInfo` objects into the catalog
|
|
33
|
+
* by id — passing `{ "x": { keys: [...] } }` updates only the `keys`
|
|
34
|
+
* field of `x`, preserving existing `fn`/`desc`. That lets docs,
|
|
35
|
+
* bindings, and impls be registered in separate calls / from different
|
|
36
|
+
* layers (bundled defaults, app-level overrides, plugin contributions).
|
|
37
|
+
*
|
|
38
|
+
* `dispatch(id, ctx)` runs the action:
|
|
39
|
+
* - If the catalog entry has `fn`, it's called directly (global
|
|
40
|
+
* actions).
|
|
41
|
+
* - Otherwise, the focus chain is walked from `ctx.target` (default:
|
|
42
|
+
* the router's focused node) up to the root, and the first node
|
|
43
|
+
* with `actions[id]` handles it.
|
|
44
|
+
*
|
|
45
|
+
* `list()` returns the catalog for command palette / help enumeration.
|
|
46
|
+
*/
|
|
47
|
+
var Actions = class extends Emitter {
|
|
48
|
+
#catalog = /* @__PURE__ */ new Map();
|
|
49
|
+
/** Optional focus-chain walker. Supplied by the Renderer so Actions
|
|
50
|
+
* doesn't have to know about the router directly. Returns the
|
|
51
|
+
* starting node (focused by default) so dispatch can walk up. */
|
|
52
|
+
#getTarget = () => void 0;
|
|
53
|
+
#logger;
|
|
54
|
+
#keymap = /* @__PURE__ */ new Map();
|
|
55
|
+
constructor(logger) {
|
|
56
|
+
super();
|
|
57
|
+
this.#logger = logger ?? new Logger();
|
|
58
|
+
this.on("change", () => this.#updateKeymap());
|
|
59
|
+
}
|
|
60
|
+
/** Internal — the Renderer wires this after construction. */
|
|
61
|
+
setTargetResolver(fn) {
|
|
62
|
+
this.#getTarget = fn;
|
|
63
|
+
}
|
|
64
|
+
bind(binding) {
|
|
65
|
+
const { id, keys, ...info } = binding;
|
|
66
|
+
return this.register({ [id]: {
|
|
67
|
+
...info,
|
|
68
|
+
keys: (Array.isArray(keys) ? keys : [keys]).map((k) => canonical(k))
|
|
69
|
+
} });
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Register or merge catalog entries. Values are shallow-merged into
|
|
73
|
+
* existing entries by id.
|
|
74
|
+
*
|
|
75
|
+
* When `opts.default` is `true`, the new entry is merged *before* the
|
|
76
|
+
* existing one, so it provides defaults.
|
|
77
|
+
*
|
|
78
|
+
* When `opts.default` is `false` (the default), the new entry is merged
|
|
79
|
+
* *after* the existing one, so it overrides prior values.
|
|
80
|
+
*
|
|
81
|
+
* Returns an unregister function that rolls this call back.
|
|
82
|
+
*/
|
|
83
|
+
register(actions, opts = {}) {
|
|
84
|
+
const entries = Array.isArray(actions) ? Object.fromEntries(actions.map((a) => [a.id, a])) : actions;
|
|
85
|
+
const isDefault = opts.default ?? false;
|
|
86
|
+
const ids = Object.keys(entries);
|
|
87
|
+
const prior = /* @__PURE__ */ new Map();
|
|
88
|
+
for (const id of ids) prior.set(id, this.#catalog.get(id));
|
|
89
|
+
for (const [id, info] of Object.entries(entries)) {
|
|
90
|
+
info.keys = info.keys?.map((k) => canonical(k));
|
|
91
|
+
const existing = this.#catalog.get(id);
|
|
92
|
+
this.#catalog.set(id, isDefault ? {
|
|
93
|
+
...info,
|
|
94
|
+
...existing,
|
|
95
|
+
id
|
|
96
|
+
} : {
|
|
97
|
+
...existing,
|
|
98
|
+
...info,
|
|
99
|
+
id
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
this.emit("change");
|
|
103
|
+
return () => {
|
|
104
|
+
for (const id of ids) {
|
|
105
|
+
const before = prior.get(id);
|
|
106
|
+
if (before === void 0) this.#catalog.delete(id);
|
|
107
|
+
else this.#catalog.set(id, before);
|
|
108
|
+
}
|
|
109
|
+
this.emit("change");
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Remove entries by id. */
|
|
113
|
+
delete(filter) {
|
|
114
|
+
const ids = Array.isArray(filter) ? filter : this.list(filter).map((a) => a.id);
|
|
115
|
+
let changed = false;
|
|
116
|
+
for (const id of ids) if (this.#catalog.delete(id)) changed = true;
|
|
117
|
+
if (changed) this.emit("change");
|
|
118
|
+
}
|
|
119
|
+
get(id) {
|
|
120
|
+
return this.#catalog.get(id);
|
|
121
|
+
}
|
|
122
|
+
list(opts) {
|
|
123
|
+
return [...this.#catalog.values()].filter((action) => opts ? filterAction(action, opts) : true);
|
|
124
|
+
}
|
|
125
|
+
find(opts) {
|
|
126
|
+
return this.list(opts)[0];
|
|
127
|
+
}
|
|
128
|
+
dispatch(idOrAction, partial = {}) {
|
|
129
|
+
const id = typeof idOrAction === "string" ? idOrAction : idOrAction.id;
|
|
130
|
+
const action = typeof idOrAction === "string" ? this.#catalog.get(id) : idOrAction;
|
|
131
|
+
const target = partial.target ?? this.#getTarget();
|
|
132
|
+
const source = partial.source ?? "programmatic";
|
|
133
|
+
if (action?.fn) {
|
|
134
|
+
const ctx = {
|
|
135
|
+
id,
|
|
136
|
+
source,
|
|
137
|
+
target,
|
|
138
|
+
...partial
|
|
139
|
+
};
|
|
140
|
+
const fn = action.fn;
|
|
141
|
+
this.#logger.try(() => fn(ctx), {
|
|
142
|
+
id,
|
|
143
|
+
name: "dispatch",
|
|
144
|
+
source
|
|
145
|
+
});
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
for (let node = target; node !== void 0; node = node.parent) {
|
|
149
|
+
if (!node.isVisible() || !node.mounted) continue;
|
|
150
|
+
const entry = node.actions?.[id];
|
|
151
|
+
const fn = typeof entry === "function" ? entry : entry?.fn;
|
|
152
|
+
if (typeof fn === "function") {
|
|
153
|
+
const ctx = {
|
|
154
|
+
id,
|
|
155
|
+
node,
|
|
156
|
+
source,
|
|
157
|
+
target,
|
|
158
|
+
...partial
|
|
159
|
+
};
|
|
160
|
+
this.#logger.try(() => fn(ctx), {
|
|
161
|
+
id,
|
|
162
|
+
name: "dispatch",
|
|
163
|
+
source
|
|
164
|
+
});
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
dispatchKey(routed, opts = {}) {
|
|
171
|
+
const actions = (this.#keymap.get(canonical(routed.pattern)) ?? []).map((id) => this.get(id)).filter((a) => filterAction(a)).toSorted((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
172
|
+
if (!actions.length) return false;
|
|
173
|
+
const nodeActions = opts.node ?? true ? actions.filter((a) => !a.fn) : [];
|
|
174
|
+
const globalActions = opts.global ?? true ? actions.filter((a) => a.fn) : [];
|
|
175
|
+
const target = this.#getTarget();
|
|
176
|
+
if (nodeActions.length) {
|
|
177
|
+
for (const t of target ? [...target.actionTargets, target] : []) for (const a of nodeActions) if (this.dispatch(a, {
|
|
178
|
+
key: routed,
|
|
179
|
+
source: "key",
|
|
180
|
+
target: t
|
|
181
|
+
})) return true;
|
|
182
|
+
}
|
|
183
|
+
for (const a of globalActions) if (this.dispatch(a, {
|
|
184
|
+
key: routed,
|
|
185
|
+
source: "key"
|
|
186
|
+
})) return true;
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Build a `canonical-key → action-id[]` keymap from every catalog
|
|
191
|
+
* entry's `keys` field. Multiple actions can share the same default
|
|
192
|
+
* binding (e.g. both `input.submit` and `menu.select` default to
|
|
193
|
+
* `enter`) — the router's dispatch tries each candidate in order
|
|
194
|
+
* and the first whose handler is reachable on the focus chain
|
|
195
|
+
* wins. Registration order defines priority; later wins.
|
|
196
|
+
*/
|
|
197
|
+
#updateKeymap() {
|
|
198
|
+
const out = /* @__PURE__ */ new Map();
|
|
199
|
+
for (const [id, info] of this.#catalog) {
|
|
200
|
+
if (!info.keys) continue;
|
|
201
|
+
for (const pattern of info.keys) {
|
|
202
|
+
const c = canonical(pattern);
|
|
203
|
+
const list = out.get(c) ?? [];
|
|
204
|
+
list.push(id);
|
|
205
|
+
out.set(c, list);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
this.#keymap = out;
|
|
209
|
+
}
|
|
210
|
+
whichKey(node, opts = {}) {
|
|
211
|
+
const ret = [];
|
|
212
|
+
const actions = Object.keys(node.actions ?? {}).map((id) => this.get(id)).filter((a) => filterAction(a, opts.filter)).toSorted((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
213
|
+
const used = /* @__PURE__ */ new Set();
|
|
214
|
+
for (const action of actions) {
|
|
215
|
+
const keys = (action.keys ?? []).map((k) => canonical(k)).filter((k) => !used.has(k));
|
|
216
|
+
if (keys.length === 0) continue;
|
|
217
|
+
keys.forEach((k) => used.add(k));
|
|
218
|
+
ret.push({
|
|
219
|
+
...action,
|
|
220
|
+
keys
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return ret;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region src/input/decoder.ts
|
|
228
|
+
var Decoder = class {
|
|
229
|
+
#pending = "";
|
|
230
|
+
#paste;
|
|
231
|
+
feed(chunk) {
|
|
232
|
+
const out = [];
|
|
233
|
+
let buf = this.#pending + chunk;
|
|
234
|
+
this.#pending = "";
|
|
235
|
+
while (buf.length > 0) {
|
|
236
|
+
if (this.#paste !== void 0) {
|
|
237
|
+
const end = buf.indexOf(PASTE_END);
|
|
238
|
+
if (end === -1) {
|
|
239
|
+
this.#paste += buf;
|
|
240
|
+
buf = "";
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
this.#paste += buf.slice(0, end);
|
|
244
|
+
out.push({
|
|
245
|
+
text: this.#paste,
|
|
246
|
+
type: "paste"
|
|
247
|
+
});
|
|
248
|
+
this.#paste = void 0;
|
|
249
|
+
buf = buf.slice(end + PASTE_END.length);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (buf[0] === "\x1B") {
|
|
253
|
+
const consumed = parseEsc(buf, out, () => {
|
|
254
|
+
this.#paste = "";
|
|
255
|
+
});
|
|
256
|
+
if (consumed === 0) {
|
|
257
|
+
this.#pending = buf;
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
buf = buf.slice(consumed);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const step = decodeChar(buf, out, { alt: false });
|
|
264
|
+
buf = buf.slice(step);
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Force-emit any state we've been holding. Currently only the bare-ESC
|
|
270
|
+
* case: if `feed` ended on a lone `\x1b` we hold it in case `a` follows
|
|
271
|
+
* (alt+a); call `flush` after a short idle timeout to commit it as an
|
|
272
|
+
* `esc` key press.
|
|
273
|
+
*/
|
|
274
|
+
flush() {
|
|
275
|
+
if (this.#pending === "\x1B") {
|
|
276
|
+
this.#pending = "";
|
|
277
|
+
return [key({ name: "esc" })];
|
|
278
|
+
}
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
const PASTE_END = "\x1B[201~";
|
|
283
|
+
function parseEsc(buf, out, onPasteStart) {
|
|
284
|
+
if (buf.length === 1) return 0;
|
|
285
|
+
const second = buf[1];
|
|
286
|
+
if (second === "[") return parseCsi(buf, out, onPasteStart);
|
|
287
|
+
if (second === "O") return parseSs3(buf, out);
|
|
288
|
+
if (second === "]") return parseStringControl(buf, out, "osc");
|
|
289
|
+
if (second === "P") return parseStringControl(buf, out, "dcs");
|
|
290
|
+
if (second === "_") return parseStringControl(buf, out, "apc");
|
|
291
|
+
return 1 + decodeChar(buf.slice(1), out, { alt: true });
|
|
292
|
+
}
|
|
293
|
+
function parseCsi(buf, out, onPasteStart) {
|
|
294
|
+
let i = 2;
|
|
295
|
+
while (i < buf.length) {
|
|
296
|
+
const code = buf.charCodeAt(i);
|
|
297
|
+
if (code >= 64 && code <= 126) {
|
|
298
|
+
const params = buf.slice(2, i);
|
|
299
|
+
const final = buf[i];
|
|
300
|
+
const consumed = i + 1;
|
|
301
|
+
if (params === "200" && final === "~") {
|
|
302
|
+
onPasteStart();
|
|
303
|
+
return consumed;
|
|
304
|
+
}
|
|
305
|
+
if (params === "" && final === "I") {
|
|
306
|
+
out.push({
|
|
307
|
+
gained: true,
|
|
308
|
+
type: "focus"
|
|
309
|
+
});
|
|
310
|
+
return consumed;
|
|
311
|
+
}
|
|
312
|
+
if (params === "" && final === "O") {
|
|
313
|
+
out.push({
|
|
314
|
+
gained: false,
|
|
315
|
+
type: "focus"
|
|
316
|
+
});
|
|
317
|
+
return consumed;
|
|
318
|
+
}
|
|
319
|
+
if (isTerminalCsiResponse(params, final)) {
|
|
320
|
+
out.push({
|
|
321
|
+
final,
|
|
322
|
+
kind: "csi",
|
|
323
|
+
params,
|
|
324
|
+
sequence: buf.slice(0, consumed),
|
|
325
|
+
type: "term-response"
|
|
326
|
+
});
|
|
327
|
+
return consumed;
|
|
328
|
+
}
|
|
329
|
+
handleCsi(params, final, out);
|
|
330
|
+
return consumed;
|
|
331
|
+
}
|
|
332
|
+
i++;
|
|
333
|
+
}
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
336
|
+
function parseStringControl(buf, out, kind) {
|
|
337
|
+
const payloadStart = 2;
|
|
338
|
+
const st = buf.indexOf("\x1B\\", payloadStart);
|
|
339
|
+
const bel = kind === "osc" ? buf.indexOf("\x07", payloadStart) : -1;
|
|
340
|
+
const useBel = bel !== -1 && (st === -1 || bel < st);
|
|
341
|
+
const end = useBel ? bel : st;
|
|
342
|
+
if (end === -1) return 0;
|
|
343
|
+
const consumed = end + (useBel ? 1 : 2);
|
|
344
|
+
out.push({
|
|
345
|
+
kind,
|
|
346
|
+
payload: buf.slice(payloadStart, end),
|
|
347
|
+
sequence: buf.slice(0, consumed),
|
|
348
|
+
type: "term-response"
|
|
349
|
+
});
|
|
350
|
+
return consumed;
|
|
351
|
+
}
|
|
352
|
+
function parseSs3(buf, out) {
|
|
353
|
+
if (buf.length < 3) return 0;
|
|
354
|
+
const final = buf[2];
|
|
355
|
+
const name = SS3_NAMES[final];
|
|
356
|
+
if (name !== void 0) out.push(key({ name }));
|
|
357
|
+
return 3;
|
|
358
|
+
}
|
|
359
|
+
const ARROW_NAMES = {
|
|
360
|
+
A: "up",
|
|
361
|
+
B: "down",
|
|
362
|
+
C: "right",
|
|
363
|
+
D: "left",
|
|
364
|
+
F: "end",
|
|
365
|
+
H: "home"
|
|
366
|
+
};
|
|
367
|
+
const TILDE_NAMES = {
|
|
368
|
+
"1": "home",
|
|
369
|
+
"11": "f1",
|
|
370
|
+
"12": "f2",
|
|
371
|
+
"13": "f3",
|
|
372
|
+
"14": "f4",
|
|
373
|
+
"15": "f5",
|
|
374
|
+
"17": "f6",
|
|
375
|
+
"18": "f7",
|
|
376
|
+
"19": "f8",
|
|
377
|
+
"2": "insert",
|
|
378
|
+
"20": "f9",
|
|
379
|
+
"21": "f10",
|
|
380
|
+
"23": "f11",
|
|
381
|
+
"24": "f12",
|
|
382
|
+
"3": "delete",
|
|
383
|
+
"4": "end",
|
|
384
|
+
"5": "pageup",
|
|
385
|
+
"6": "pagedown",
|
|
386
|
+
"7": "home",
|
|
387
|
+
"8": "end"
|
|
388
|
+
};
|
|
389
|
+
const SS3_NAMES = {
|
|
390
|
+
P: "f1",
|
|
391
|
+
Q: "f2",
|
|
392
|
+
R: "f3",
|
|
393
|
+
S: "f4"
|
|
394
|
+
};
|
|
395
|
+
function isTerminalCsiResponse(params, final) {
|
|
396
|
+
return final === "c" && (params.startsWith("?") || params.startsWith(">"));
|
|
397
|
+
}
|
|
398
|
+
function handleCsi(params, final, out) {
|
|
399
|
+
if ((final === "M" || final === "m") && params.startsWith("<")) {
|
|
400
|
+
handleMouse(params, final, out);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const parts = params.length === 0 ? [] : params.split(";");
|
|
404
|
+
const arrow = ARROW_NAMES[final];
|
|
405
|
+
if (arrow !== void 0) {
|
|
406
|
+
const mod = parts.length === 2 ? Number.parseInt(parts[1], 10) : 1;
|
|
407
|
+
out.push(key({
|
|
408
|
+
name: arrow,
|
|
409
|
+
...modBits(mod)
|
|
410
|
+
}));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (final === "~") {
|
|
414
|
+
const code = parts[0] ?? "";
|
|
415
|
+
const name = TILDE_NAMES[code];
|
|
416
|
+
if (name === void 0) return;
|
|
417
|
+
const mod = parts.length === 2 ? Number.parseInt(parts[1], 10) : 1;
|
|
418
|
+
out.push(key({
|
|
419
|
+
name,
|
|
420
|
+
...modBits(mod)
|
|
421
|
+
}));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function handleMouse(params, final, out) {
|
|
426
|
+
const [button, x, y] = params.slice(1).split(";").map((p) => Number.parseInt(p, 10));
|
|
427
|
+
if (!Number.isFinite(button) || !Number.isFinite(x) || !Number.isFinite(y)) return;
|
|
428
|
+
const code = button & 3;
|
|
429
|
+
const scroll = (button & 64) !== 0;
|
|
430
|
+
const mods = mouseModBits(button);
|
|
431
|
+
if (scroll) {
|
|
432
|
+
out.push({
|
|
433
|
+
...mods,
|
|
434
|
+
deltaY: code === 0 ? -1 : 1,
|
|
435
|
+
kind: "scroll",
|
|
436
|
+
type: "mouse",
|
|
437
|
+
x,
|
|
438
|
+
y
|
|
439
|
+
});
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const mouseButton = buttonName(code);
|
|
443
|
+
if (!mouseButton) return;
|
|
444
|
+
const kind = mouseKind(button, final);
|
|
445
|
+
out.push({
|
|
446
|
+
...mods,
|
|
447
|
+
button: mouseButton,
|
|
448
|
+
kind,
|
|
449
|
+
type: "mouse",
|
|
450
|
+
x,
|
|
451
|
+
y
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
function mouseKind(button, final) {
|
|
455
|
+
if (final === "m") return "up";
|
|
456
|
+
if ((button & 32) !== 0) return "drag";
|
|
457
|
+
return "down";
|
|
458
|
+
}
|
|
459
|
+
function buttonName(code) {
|
|
460
|
+
if (code === 0) return "left";
|
|
461
|
+
if (code === 1) return "middle";
|
|
462
|
+
if (code === 2) return "right";
|
|
463
|
+
}
|
|
464
|
+
function mouseModBits(button) {
|
|
465
|
+
return {
|
|
466
|
+
alt: (button & 8) !== 0,
|
|
467
|
+
ctrl: (button & 16) !== 0,
|
|
468
|
+
meta: false,
|
|
469
|
+
shift: (button & 4) !== 0
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
function modBits(m) {
|
|
473
|
+
const v = Number.isFinite(m) ? m - 1 : 0;
|
|
474
|
+
return {
|
|
475
|
+
alt: (v & 2) !== 0,
|
|
476
|
+
ctrl: (v & 4) !== 0,
|
|
477
|
+
meta: (v & 8) !== 0,
|
|
478
|
+
shift: (v & 1) !== 0
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function decodeChar(buf, out, opts) {
|
|
482
|
+
const alt = opts.alt;
|
|
483
|
+
const code = buf.charCodeAt(0);
|
|
484
|
+
if (code === 9) {
|
|
485
|
+
out.push(key({
|
|
486
|
+
alt,
|
|
487
|
+
name: "tab"
|
|
488
|
+
}));
|
|
489
|
+
return 1;
|
|
490
|
+
}
|
|
491
|
+
if (code === 10 || code === 13) {
|
|
492
|
+
out.push(key({
|
|
493
|
+
alt,
|
|
494
|
+
name: "enter"
|
|
495
|
+
}));
|
|
496
|
+
return 1;
|
|
497
|
+
}
|
|
498
|
+
if (code === 8 || code === 127) {
|
|
499
|
+
out.push(key({
|
|
500
|
+
alt,
|
|
501
|
+
name: "backspace"
|
|
502
|
+
}));
|
|
503
|
+
return 1;
|
|
504
|
+
}
|
|
505
|
+
if (code === 32) {
|
|
506
|
+
out.push(key({
|
|
507
|
+
alt,
|
|
508
|
+
name: "space",
|
|
509
|
+
text: " "
|
|
510
|
+
}));
|
|
511
|
+
return 1;
|
|
512
|
+
}
|
|
513
|
+
if (code >= 1 && code <= 26) {
|
|
514
|
+
const letter = String.fromCharCode(code + 96);
|
|
515
|
+
out.push(key({
|
|
516
|
+
alt,
|
|
517
|
+
ctrl: true,
|
|
518
|
+
name: letter
|
|
519
|
+
}));
|
|
520
|
+
return 1;
|
|
521
|
+
}
|
|
522
|
+
const cp = buf.codePointAt(0) ?? 0;
|
|
523
|
+
const ch = String.fromCodePoint(cp);
|
|
524
|
+
const isUpper = ch.length === 1 && ch >= "A" && ch <= "Z";
|
|
525
|
+
out.push(key({
|
|
526
|
+
alt,
|
|
527
|
+
name: ch,
|
|
528
|
+
shift: isUpper,
|
|
529
|
+
text: ch
|
|
530
|
+
}));
|
|
531
|
+
return ch.length;
|
|
532
|
+
}
|
|
533
|
+
function key(partial) {
|
|
534
|
+
return {
|
|
535
|
+
event: {
|
|
536
|
+
alt: partial.alt ?? false,
|
|
537
|
+
ctrl: partial.ctrl ?? false,
|
|
538
|
+
meta: partial.meta ?? false,
|
|
539
|
+
name: partial.name,
|
|
540
|
+
shift: partial.shift ?? false,
|
|
541
|
+
...partial.text !== void 0 ? { text: partial.text } : {}
|
|
542
|
+
},
|
|
543
|
+
type: "key"
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
//#endregion
|
|
547
|
+
//#region src/input/defaults.ts
|
|
548
|
+
/**
|
|
549
|
+
* Catalog of built-in actions with descriptions and default bindings.
|
|
550
|
+
* Typed as `Record<BuiltinAction, ActionInfo>` so missing entries are a
|
|
551
|
+
* compile error — add an action to a widget and you'll be nudged to
|
|
552
|
+
* document it here.
|
|
553
|
+
*
|
|
554
|
+
* The Renderer registers this into its `actions` registry at
|
|
555
|
+
* construction; apps compose further catalogs via
|
|
556
|
+
* `renderer.actions.register(...)`.
|
|
557
|
+
*/
|
|
558
|
+
const defaultActions = {
|
|
559
|
+
"global.quit": {
|
|
560
|
+
cmd: "quit",
|
|
561
|
+
desc: "quit",
|
|
562
|
+
keys: ["ctrl-c"]
|
|
563
|
+
},
|
|
564
|
+
"input.cursorDown": {
|
|
565
|
+
desc: "move cursor down one line",
|
|
566
|
+
hidden: true,
|
|
567
|
+
keys: ["down"]
|
|
568
|
+
},
|
|
569
|
+
"input.cursorLeft": {
|
|
570
|
+
desc: "move cursor left",
|
|
571
|
+
hidden: true,
|
|
572
|
+
keys: ["left"]
|
|
573
|
+
},
|
|
574
|
+
"input.cursorLineEnd": {
|
|
575
|
+
desc: "jump to end of current line",
|
|
576
|
+
hidden: true,
|
|
577
|
+
keys: ["end", "ctrl-e"]
|
|
578
|
+
},
|
|
579
|
+
"input.cursorLineStart": {
|
|
580
|
+
desc: "jump to start of current line",
|
|
581
|
+
hidden: true,
|
|
582
|
+
keys: ["home", "ctrl-a"]
|
|
583
|
+
},
|
|
584
|
+
"input.cursorRight": {
|
|
585
|
+
desc: "move cursor right",
|
|
586
|
+
hidden: true,
|
|
587
|
+
keys: ["right"]
|
|
588
|
+
},
|
|
589
|
+
"input.cursorUp": {
|
|
590
|
+
desc: "move cursor up one line",
|
|
591
|
+
hidden: true,
|
|
592
|
+
keys: ["up"]
|
|
593
|
+
},
|
|
594
|
+
"input.deleteCharBack": {
|
|
595
|
+
desc: "delete the character before the cursor",
|
|
596
|
+
hidden: true,
|
|
597
|
+
keys: ["backspace"]
|
|
598
|
+
},
|
|
599
|
+
"input.deleteCharForward": {
|
|
600
|
+
desc: "delete the character at the cursor",
|
|
601
|
+
hidden: true,
|
|
602
|
+
keys: ["delete"]
|
|
603
|
+
},
|
|
604
|
+
"input.deleteWordBack": {
|
|
605
|
+
desc: "delete the word before the cursor",
|
|
606
|
+
hidden: true,
|
|
607
|
+
keys: ["ctrl-w"]
|
|
608
|
+
},
|
|
609
|
+
"input.insertNewline": {
|
|
610
|
+
desc: "insert a newline at the cursor (copies leading indent)",
|
|
611
|
+
hidden: true,
|
|
612
|
+
keys: ["shift-enter", "alt-enter"]
|
|
613
|
+
},
|
|
614
|
+
"input.insertTab": {
|
|
615
|
+
desc: "insert an indent (two spaces) at the cursor",
|
|
616
|
+
hidden: true,
|
|
617
|
+
keys: ["tab"]
|
|
618
|
+
},
|
|
619
|
+
"input.paste": {
|
|
620
|
+
desc: "paste text from the system clipboard, or attach a pasted image",
|
|
621
|
+
hidden: true,
|
|
622
|
+
keys: ["ctrl-v"]
|
|
623
|
+
},
|
|
624
|
+
"input.submit": {
|
|
625
|
+
desc: "submit the current value",
|
|
626
|
+
hidden: true,
|
|
627
|
+
keys: ["enter"]
|
|
628
|
+
},
|
|
629
|
+
"picker.next": {
|
|
630
|
+
desc: "move to the next item that matches the query",
|
|
631
|
+
hidden: true,
|
|
632
|
+
keys: ["ctrl-n"]
|
|
633
|
+
},
|
|
634
|
+
"picker.prev": {
|
|
635
|
+
desc: "move to the previous item that matches the query",
|
|
636
|
+
hidden: true,
|
|
637
|
+
keys: ["ctrl-p"]
|
|
638
|
+
},
|
|
639
|
+
"select.accept": {
|
|
640
|
+
desc: "select the active item",
|
|
641
|
+
hidden: true,
|
|
642
|
+
keys: ["enter"]
|
|
643
|
+
},
|
|
644
|
+
"select.close": {
|
|
645
|
+
desc: "close",
|
|
646
|
+
keys: ["esc"]
|
|
647
|
+
},
|
|
648
|
+
"select.complete": {
|
|
649
|
+
desc: "complete the active item",
|
|
650
|
+
hidden: true,
|
|
651
|
+
keys: ["tab"]
|
|
652
|
+
},
|
|
653
|
+
"select.first": {
|
|
654
|
+
desc: "jump to the first item",
|
|
655
|
+
hidden: true,
|
|
656
|
+
keys: ["home"]
|
|
657
|
+
},
|
|
658
|
+
"select.last": {
|
|
659
|
+
desc: "jump to the last item",
|
|
660
|
+
hidden: true,
|
|
661
|
+
keys: ["end"]
|
|
662
|
+
},
|
|
663
|
+
"select.next": {
|
|
664
|
+
desc: "move to the next item",
|
|
665
|
+
hidden: true,
|
|
666
|
+
keys: ["down"]
|
|
667
|
+
},
|
|
668
|
+
"select.page-down": {
|
|
669
|
+
desc: "move down one page",
|
|
670
|
+
hidden: true,
|
|
671
|
+
keys: ["pagedown", "ctrl-d"]
|
|
672
|
+
},
|
|
673
|
+
"select.page-up": {
|
|
674
|
+
desc: "move up one page",
|
|
675
|
+
hidden: true,
|
|
676
|
+
keys: ["pageup", "ctrl-u"]
|
|
677
|
+
},
|
|
678
|
+
"select.prev": {
|
|
679
|
+
desc: "move to the previous item",
|
|
680
|
+
hidden: true,
|
|
681
|
+
keys: ["up"]
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
//#endregion
|
|
685
|
+
//#region src/input/router.ts
|
|
686
|
+
/**
|
|
687
|
+
* Routes decoder events to the right node and/or action.
|
|
688
|
+
*
|
|
689
|
+
* Dispatch priority for a key event (most-specific-first):
|
|
690
|
+
*
|
|
691
|
+
* 1. **Per-node `"key"` bubble** — focused node up to root. Each
|
|
692
|
+
* node's `on("key")` / `bind()` handlers fire. Calling `.stop()`
|
|
693
|
+
* ends dispatch. This is where local overrides live.
|
|
694
|
+
* 2. **Keymap lookup** — a single table built by the `Actions`
|
|
695
|
+
* registry (`canonical pattern → action id | direct handler`).
|
|
696
|
+
* Action ids are dispatched through `Actions.dispatch(id)`,
|
|
697
|
+
* which walks the focus chain for `node.actions[id]` — or
|
|
698
|
+
* calls a catalog-level `fn` if the action has one.
|
|
699
|
+
* 3. **Global `bind()` handlers** — a last-resort fallback for
|
|
700
|
+
* ad-hoc app-level bindings registered via `router.bind(...)`.
|
|
701
|
+
*
|
|
702
|
+
* Paste events follow a simple emit-and-bubble path — no keymap.
|
|
703
|
+
*/
|
|
704
|
+
var InputRouter = class extends Emitter {
|
|
705
|
+
#focused = [];
|
|
706
|
+
/** Set by the Renderer so keymap-matched action ids can be
|
|
707
|
+
* dispatched through the catalog. */
|
|
708
|
+
#actions;
|
|
709
|
+
#logger;
|
|
710
|
+
#mouseClick;
|
|
711
|
+
#terminalFocus = true;
|
|
712
|
+
constructor(logger) {
|
|
713
|
+
super();
|
|
714
|
+
this.#logger = logger ?? new Logger();
|
|
715
|
+
}
|
|
716
|
+
/** Internal — wired by the Renderer at construction. */
|
|
717
|
+
setActions(actions) {
|
|
718
|
+
this.#actions = actions;
|
|
719
|
+
}
|
|
720
|
+
/** Currently-focused node, or `undefined` when none. */
|
|
721
|
+
get focused() {
|
|
722
|
+
this.#focused = this.#focused.filter((n) => n.mounted);
|
|
723
|
+
return this.#focused.at(-1);
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Move focus to `node`. Emits `blur`
|
|
727
|
+
* on the previously-focused node and `focus` on the new one.
|
|
728
|
+
* Focusing the already-focused node is a no-op.
|
|
729
|
+
*/
|
|
730
|
+
focus(node) {
|
|
731
|
+
if (!node.mounted || this.focused === node) return;
|
|
732
|
+
const prev = this.focused;
|
|
733
|
+
this.#focused = this.#focused.filter((n) => n !== node);
|
|
734
|
+
this.#focused.push(node);
|
|
735
|
+
if (prev !== void 0) {
|
|
736
|
+
prev.emit("blur");
|
|
737
|
+
this.emit("blur", { node: prev });
|
|
738
|
+
}
|
|
739
|
+
node.emit("focus");
|
|
740
|
+
this.emit("focus", { node });
|
|
741
|
+
}
|
|
742
|
+
/** Clear focus from `node`. Emits `blur` on the node and `focus`
|
|
743
|
+
* on the next-most-recently-focused node, if any. */
|
|
744
|
+
blur(node) {
|
|
745
|
+
const top = this.focused;
|
|
746
|
+
this.#focused = this.#focused.filter((n) => n !== node);
|
|
747
|
+
if (top !== node) return;
|
|
748
|
+
const next = this.focused;
|
|
749
|
+
node.emit("blur");
|
|
750
|
+
this.emit("blur", { node });
|
|
751
|
+
if (next !== void 0) {
|
|
752
|
+
next.emit("focus");
|
|
753
|
+
this.emit("focus", { node: next });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
get terminalFocus() {
|
|
757
|
+
return this.#terminalFocus;
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Dispatch a decoder event. Returns `true` if consumed.
|
|
761
|
+
*/
|
|
762
|
+
dispatch(ev) {
|
|
763
|
+
return this.#logger.try(() => this.#dispatch(ev), "dispatch") ?? false;
|
|
764
|
+
}
|
|
765
|
+
#dispatch(ev) {
|
|
766
|
+
if (ev.type === "key") return this.#dispatchKey(ev.event);
|
|
767
|
+
if (ev.type === "paste") return this.#dispatchPaste(ev.text);
|
|
768
|
+
if (ev.type === "term-response") {
|
|
769
|
+
this.emit("term-response", { event: ev });
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
if (ev.type === "mouse") {
|
|
773
|
+
this.emit("mouse", { event: this.#routeMouse(ev) });
|
|
774
|
+
return false;
|
|
775
|
+
} else {
|
|
776
|
+
this.#terminalFocus = ev.gained;
|
|
777
|
+
this.emit("terminal-focus", { gained: ev.gained });
|
|
778
|
+
}
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
#routeMouse(event) {
|
|
782
|
+
if (event.kind !== "down") return event;
|
|
783
|
+
const now = Date.now();
|
|
784
|
+
const prev = this.#mouseClick;
|
|
785
|
+
const count = prev !== void 0 && prev.button === event.button && prev.x === event.x && prev.y === event.y && now - prev.time <= 500 ? prev.count % 3 + 1 : 1;
|
|
786
|
+
this.#mouseClick = {
|
|
787
|
+
button: event.button,
|
|
788
|
+
count,
|
|
789
|
+
time: now,
|
|
790
|
+
x: event.x,
|
|
791
|
+
y: event.y
|
|
792
|
+
};
|
|
793
|
+
return {
|
|
794
|
+
...event,
|
|
795
|
+
click: count
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
#dispatchKey(event) {
|
|
799
|
+
this.emit("key", { event });
|
|
800
|
+
const routed = makeRoutedKey(event);
|
|
801
|
+
if (this.#actions?.dispatchKey(routed, {
|
|
802
|
+
global: false,
|
|
803
|
+
node: true
|
|
804
|
+
})) return true;
|
|
805
|
+
for (let node = this.focused; node !== void 0; node = node.parent) {
|
|
806
|
+
node.emit("key", { key: routed });
|
|
807
|
+
if (routed.stopped) return true;
|
|
808
|
+
}
|
|
809
|
+
return this.#actions?.dispatchKey(routed, {
|
|
810
|
+
global: true,
|
|
811
|
+
node: false
|
|
812
|
+
}) ?? false;
|
|
813
|
+
}
|
|
814
|
+
#dispatchPaste(text) {
|
|
815
|
+
const routed = makeRoutedPaste(text);
|
|
816
|
+
let node = this.focused;
|
|
817
|
+
while (node !== void 0) {
|
|
818
|
+
node.emit("paste", { paste: routed });
|
|
819
|
+
if (routed.stopped) return true;
|
|
820
|
+
node = node.parent;
|
|
821
|
+
}
|
|
822
|
+
return false;
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
function makeRoutedKey(ev) {
|
|
826
|
+
const r = {
|
|
827
|
+
alt: ev.alt,
|
|
828
|
+
ctrl: ev.ctrl,
|
|
829
|
+
meta: ev.meta,
|
|
830
|
+
name: ev.name,
|
|
831
|
+
pattern: canonical(ev),
|
|
832
|
+
shift: ev.shift,
|
|
833
|
+
stop: () => {
|
|
834
|
+
r.stopped = true;
|
|
835
|
+
},
|
|
836
|
+
stopped: false
|
|
837
|
+
};
|
|
838
|
+
if (ev.text !== void 0) r.text = ev.text;
|
|
839
|
+
return r;
|
|
840
|
+
}
|
|
841
|
+
function makeRoutedPaste(text) {
|
|
842
|
+
const r = {
|
|
843
|
+
stop: () => {
|
|
844
|
+
r.stopped = true;
|
|
845
|
+
},
|
|
846
|
+
stopped: false,
|
|
847
|
+
text
|
|
848
|
+
};
|
|
849
|
+
return r;
|
|
850
|
+
}
|
|
851
|
+
//#endregion
|
|
852
|
+
//#region src/renderer/frame.ts
|
|
853
|
+
function sliceLine(line, from, to, width) {
|
|
854
|
+
const start = from ? Math.max(0, from - 1) : 0;
|
|
855
|
+
const end = to ? Math.max(start, to - 1) : width;
|
|
856
|
+
if (end <= start) return;
|
|
857
|
+
const lineWidth = stringWidth(line);
|
|
858
|
+
if (start >= lineWidth) return;
|
|
859
|
+
const clampedEnd = Math.min(end, lineWidth);
|
|
860
|
+
return {
|
|
861
|
+
from: start + 1,
|
|
862
|
+
line,
|
|
863
|
+
to: clampedEnd + 1
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
var Frame = class {
|
|
867
|
+
terminal;
|
|
868
|
+
#base = [];
|
|
869
|
+
#current = [];
|
|
870
|
+
constructor(terminal) {
|
|
871
|
+
this.terminal = terminal;
|
|
872
|
+
}
|
|
873
|
+
reset(rows = this.terminal.rows) {
|
|
874
|
+
this.#base = Array(rows).fill(void 0);
|
|
875
|
+
this.#current = Array(rows).fill(void 0);
|
|
876
|
+
}
|
|
877
|
+
begin() {
|
|
878
|
+
const rows = this.terminal.rows;
|
|
879
|
+
if (this.#current.length !== rows || this.#base.length !== rows) this.reset(rows);
|
|
880
|
+
return new RenderFrame(this.terminal, this.#current, this.#base);
|
|
881
|
+
}
|
|
882
|
+
slice(row, from, to, width = this.terminal.cols) {
|
|
883
|
+
return sliceLine(this.#current[row - 1] ?? "", from, to, width);
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
var RenderFrame = class {
|
|
887
|
+
terminal;
|
|
888
|
+
current;
|
|
889
|
+
base;
|
|
890
|
+
#ops = [];
|
|
891
|
+
#next;
|
|
892
|
+
constructor(terminal, current, base) {
|
|
893
|
+
this.terminal = terminal;
|
|
894
|
+
this.current = current;
|
|
895
|
+
this.base = base;
|
|
896
|
+
this.#next = base.map((row) => row ?? "");
|
|
897
|
+
}
|
|
898
|
+
get(row) {
|
|
899
|
+
return this.#next[row - 1] ?? "";
|
|
900
|
+
}
|
|
901
|
+
set(row, content) {
|
|
902
|
+
if (row < 1 || row > this.#next.length) return;
|
|
903
|
+
this.#next[row - 1] = content;
|
|
904
|
+
}
|
|
905
|
+
clear(row) {
|
|
906
|
+
this.set(row, "");
|
|
907
|
+
}
|
|
908
|
+
overlay(row, col, content) {
|
|
909
|
+
if (row < 1 || row > this.#next.length) return;
|
|
910
|
+
const base = this.get(row);
|
|
911
|
+
const start = Math.max(0, col - 1);
|
|
912
|
+
const width = stringWidth(content);
|
|
913
|
+
const prefix = sliceAnsi(base, 0, start);
|
|
914
|
+
const pad = " ".repeat(Math.max(0, start - stringWidth(prefix)));
|
|
915
|
+
this.set(row, `${prefix}${pad}${content}${sliceAnsi(base, start + width)}`);
|
|
916
|
+
}
|
|
917
|
+
slice(row, from, to, width = this.terminal.cols) {
|
|
918
|
+
if (row < 1 || row > this.#next.length) return;
|
|
919
|
+
return sliceLine(this.get(row), from, to, width);
|
|
920
|
+
}
|
|
921
|
+
highlight(row, from, to, ctx) {
|
|
922
|
+
const ret = this.slice(row, from, to, ctx.width);
|
|
923
|
+
if (!ret) return;
|
|
924
|
+
const start = ret.from - 1;
|
|
925
|
+
const end = (ret.to ?? ctx.width + 1) - 1;
|
|
926
|
+
const text = sliceAnsi(ret.line, start, end);
|
|
927
|
+
const highlighted = ctx.style.selection(stripAnsiBg(text));
|
|
928
|
+
this.set(row, `${sliceAnsi(ret.line, 0, start)}${highlighted}${sliceAnsi(ret.line, end)}`);
|
|
929
|
+
return ret;
|
|
930
|
+
}
|
|
931
|
+
queue(op) {
|
|
932
|
+
this.#ops.push(op);
|
|
933
|
+
}
|
|
934
|
+
commitBase() {
|
|
935
|
+
for (let i = 0; i < this.#next.length; i++) this.base[i] = this.#next[i];
|
|
936
|
+
}
|
|
937
|
+
flush() {
|
|
938
|
+
for (let i = 0; i < this.#next.length; i++) {
|
|
939
|
+
const next = this.#next[i];
|
|
940
|
+
if (this.current[i] === next) continue;
|
|
941
|
+
const row = i + 1;
|
|
942
|
+
this.queue((terminal) => {
|
|
943
|
+
terminal.write(terminal.moveTo(row, 1) + terminal.clearLine() + next.trimEnd());
|
|
944
|
+
});
|
|
945
|
+
this.current[i] = next;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
scrollUp(top, bottom, lines, op) {
|
|
949
|
+
if (lines <= 0 || top > bottom) return;
|
|
950
|
+
this.flush();
|
|
951
|
+
this.queue(op);
|
|
952
|
+
const from = Math.max(1, top);
|
|
953
|
+
const to = Math.min(this.#next.length, bottom);
|
|
954
|
+
const amount = Math.min(lines, to - from + 1);
|
|
955
|
+
for (let i = from - 1; i < to; i++) {
|
|
956
|
+
const source = i + amount;
|
|
957
|
+
const value = source < to ? this.current[source] ?? "" : "";
|
|
958
|
+
this.current[i] = value;
|
|
959
|
+
this.#next[i] = value;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
scrollDown(top, bottom, lines, op) {
|
|
963
|
+
if (lines <= 0 || top > bottom) return;
|
|
964
|
+
this.flush();
|
|
965
|
+
this.queue(op);
|
|
966
|
+
const from = Math.max(1, top);
|
|
967
|
+
const to = Math.min(this.#next.length, bottom);
|
|
968
|
+
const amount = Math.min(lines, to - from + 1);
|
|
969
|
+
for (let i = to - 1; i >= from - 1; i--) {
|
|
970
|
+
const source = i - amount;
|
|
971
|
+
const value = source >= from - 1 ? this.current[source] ?? "" : "";
|
|
972
|
+
this.current[i] = value;
|
|
973
|
+
this.#next[i] = value;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
paint() {
|
|
977
|
+
const ops = this.#ops.length;
|
|
978
|
+
this.flush();
|
|
979
|
+
const rows = this.#ops.length - ops;
|
|
980
|
+
for (const op of this.#ops) op(this.terminal);
|
|
981
|
+
this.#ops.length = 0;
|
|
982
|
+
return {
|
|
983
|
+
ops,
|
|
984
|
+
rows
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
//#endregion
|
|
989
|
+
//#region src/renderer/surface.ts
|
|
990
|
+
/**
|
|
991
|
+
* Shared base for the three renderer surfaces (`Stream`, `UI`,
|
|
992
|
+
* `OverlaySurface`). Owns the common lifecycle and paint bookkeeping:
|
|
993
|
+
*
|
|
994
|
+
* - `#running` flag and `#mountCtx` reference, driven by the Renderer
|
|
995
|
+
* `start` / `stop` events. Subclasses read `mountCtx` to mount nodes
|
|
996
|
+
* appended while the renderer is running.
|
|
997
|
+
* - `#dirty`, set by `invalidate()`, means this surface has content or
|
|
998
|
+
* state changes and schedules a frame.
|
|
999
|
+
* Concrete surfaces override `mountAll` / `unmountAll` to walk whichever
|
|
1000
|
+
* node set they track, and `_render()` to compute rows and enqueue a paint
|
|
1001
|
+
* closure into the renderer's atomic terminal sync.
|
|
1002
|
+
*
|
|
1003
|
+
* @internal
|
|
1004
|
+
*/
|
|
1005
|
+
var Surface = class extends Emitter {
|
|
1006
|
+
#running = false;
|
|
1007
|
+
#mountCtx;
|
|
1008
|
+
#rendering;
|
|
1009
|
+
$r;
|
|
1010
|
+
#dirty = false;
|
|
1011
|
+
constructor(renderer) {
|
|
1012
|
+
super();
|
|
1013
|
+
this.$r = renderer.on("start", () => {
|
|
1014
|
+
if (this.#running) return;
|
|
1015
|
+
this.#running = true;
|
|
1016
|
+
this.#mountCtx = this.$r.mountCtx(this.type);
|
|
1017
|
+
this.mountAll(this.#mountCtx);
|
|
1018
|
+
}).on("stop", () => {
|
|
1019
|
+
if (!this.#running) return;
|
|
1020
|
+
this.#running = false;
|
|
1021
|
+
this.unmountAll();
|
|
1022
|
+
this.#mountCtx = void 0;
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
/** Check if this surface contains the given screen position */
|
|
1026
|
+
contains(point) {
|
|
1027
|
+
const { top, bottom } = this.bounds;
|
|
1028
|
+
return point.row >= top && point.row <= bottom;
|
|
1029
|
+
}
|
|
1030
|
+
get dirty() {
|
|
1031
|
+
return this.#dirty;
|
|
1032
|
+
}
|
|
1033
|
+
track(ev, inc = 1) {
|
|
1034
|
+
this.$r.stats.inc(ev, inc);
|
|
1035
|
+
}
|
|
1036
|
+
/** Mark this surface as changed and schedule a renderer frame. */
|
|
1037
|
+
invalidate = () => {
|
|
1038
|
+
this.#dirty = true;
|
|
1039
|
+
this.track(`${this.type}.dirty`);
|
|
1040
|
+
untrack(() => void this.$r.emit("dirty"));
|
|
1041
|
+
};
|
|
1042
|
+
/** `true` between `onStart()` and `onStop()`. Subclasses read this
|
|
1043
|
+
* to decide whether an append should mount immediately or wait. */
|
|
1044
|
+
get running() {
|
|
1045
|
+
return this.#running;
|
|
1046
|
+
}
|
|
1047
|
+
/** The MountCtx handed in by the Renderer on `start()`. Undefined
|
|
1048
|
+
* when the surface isn't running. Subclasses read this to mount
|
|
1049
|
+
* nodes appended while the renderer is up. */
|
|
1050
|
+
get mountCtx() {
|
|
1051
|
+
return this.#mountCtx;
|
|
1052
|
+
}
|
|
1053
|
+
async render(frame) {
|
|
1054
|
+
const ownFrame = frame ?? this.$r.frame.begin();
|
|
1055
|
+
return this.#rendering ??= (async () => {
|
|
1056
|
+
this.#dirty = false;
|
|
1057
|
+
await this._render(ownFrame);
|
|
1058
|
+
if (frame === void 0) this.$r.terminal.sync(() => ownFrame.paint());
|
|
1059
|
+
})().finally(() => {
|
|
1060
|
+
this.#rendering = void 0;
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
//#endregion
|
|
1065
|
+
//#region src/renderer/overlay.ts
|
|
1066
|
+
/**
|
|
1067
|
+
* Overlay surface — paints absolute-positioned `Overlay` nodes on top
|
|
1068
|
+
* of the stream and UI surfaces after each tick.
|
|
1069
|
+
*
|
|
1070
|
+
* Open/close is explicit. While an overlay is open, mutations on its
|
|
1071
|
+
* state (or its subtree) bubble through `invalidate` → this surface's
|
|
1072
|
+
* `dirty` → Renderer's scheduler, same as the other surfaces. On
|
|
1073
|
+
* close, the covered area is overdrawn by asking the stream and UI
|
|
1074
|
+
* to invalidate so the next flush repaints them in full.
|
|
1075
|
+
*
|
|
1076
|
+
* v1 is intentionally minimal:
|
|
1077
|
+
* - Overlays are painted top-to-bottom in z-order on every flush; we
|
|
1078
|
+
* don't track per-overlay dirty flags, just paint all active ones.
|
|
1079
|
+
* - No clipping against the viewport — callers are responsible for
|
|
1080
|
+
* sizing / positioning sensibly. The stream lives inside DECSTBM,
|
|
1081
|
+
* so overlays that touch row 0 can disrupt scrollback.
|
|
1082
|
+
*/
|
|
1083
|
+
var OverlaySurface = class extends Surface {
|
|
1084
|
+
type = "overlay";
|
|
1085
|
+
#overlays = [];
|
|
1086
|
+
/** Cached render state for each active overlay, in z-order (low -> high). */
|
|
1087
|
+
#state = [];
|
|
1088
|
+
get state() {
|
|
1089
|
+
return [...this.#state];
|
|
1090
|
+
}
|
|
1091
|
+
get bounds() {
|
|
1092
|
+
return {
|
|
1093
|
+
bottom: this.$r.terminal.rows,
|
|
1094
|
+
top: 1
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
get dirty() {
|
|
1098
|
+
return this.active.length > 0 || super.dirty;
|
|
1099
|
+
}
|
|
1100
|
+
/** Active overlays in paint order (low → high z-index). */
|
|
1101
|
+
get active() {
|
|
1102
|
+
return this.#overlays.filter((o) => o.visible && o.ctx !== void 0);
|
|
1103
|
+
}
|
|
1104
|
+
/** Return the topmost overlay at the given `(row, col)`, if any. */
|
|
1105
|
+
at({ row, col }) {
|
|
1106
|
+
return this.#state.findLast((s) => {
|
|
1107
|
+
if (row < s.y || row >= s.y + s.height) return false;
|
|
1108
|
+
if (col < s.x || col >= s.x + s.width) return false;
|
|
1109
|
+
return true;
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
contains(point) {
|
|
1113
|
+
return this.at(point) !== void 0;
|
|
1114
|
+
}
|
|
1115
|
+
/** Register an overlay with the surface. Function form runs `fn`
|
|
1116
|
+
* inside a fresh Owner scope and adopts the returned Overlay. */
|
|
1117
|
+
add(overlay) {
|
|
1118
|
+
const resolved = withOwner(this.$r.rootOwner, () => createNode(overlay));
|
|
1119
|
+
if (this.#overlays.includes(resolved)) return resolved;
|
|
1120
|
+
this.#overlays.push(resolved);
|
|
1121
|
+
this.#overlays.sort((a, b) => (a.state.zIndex ?? 0) - (b.state.zIndex ?? 0));
|
|
1122
|
+
resolved.on("invalidate", this.invalidate);
|
|
1123
|
+
resolved.on("unmount", () => this.remove(resolved));
|
|
1124
|
+
const ctx = this.mountCtx;
|
|
1125
|
+
if (this.running && ctx) resolved.mount(ctx);
|
|
1126
|
+
if (resolved.visible) this.invalidate();
|
|
1127
|
+
return resolved;
|
|
1128
|
+
}
|
|
1129
|
+
remove(overlay) {
|
|
1130
|
+
const i = this.#overlays.indexOf(overlay);
|
|
1131
|
+
if (i === -1) return this;
|
|
1132
|
+
this.#overlays.splice(i, 1);
|
|
1133
|
+
overlay.emit("close");
|
|
1134
|
+
if (overlay.mounted) overlay.unmount();
|
|
1135
|
+
overlay.off("invalidate", this.invalidate);
|
|
1136
|
+
this.invalidate();
|
|
1137
|
+
return this;
|
|
1138
|
+
}
|
|
1139
|
+
open(overlay) {
|
|
1140
|
+
return this.add(overlay).show();
|
|
1141
|
+
}
|
|
1142
|
+
close(overlay) {
|
|
1143
|
+
overlay.hide();
|
|
1144
|
+
this.remove(overlay);
|
|
1145
|
+
return this;
|
|
1146
|
+
}
|
|
1147
|
+
/** Every currently-open overlay node, for renderer traversals. */
|
|
1148
|
+
get nodes() {
|
|
1149
|
+
return this.#overlays;
|
|
1150
|
+
}
|
|
1151
|
+
mountAll(ctx) {
|
|
1152
|
+
for (const o of this.#overlays) if (!o.mounted) o.mount(ctx);
|
|
1153
|
+
}
|
|
1154
|
+
unmountAll() {
|
|
1155
|
+
for (const o of this.#overlays) if (o.mounted) o.unmount();
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Render pass: compute rows for each active overlay, then paint them
|
|
1159
|
+
* at their absolute `(y, x)` inside the outer sync block. Matches the
|
|
1160
|
+
* `render(sync?)` shape used by Stream and UI so the Renderer can
|
|
1161
|
+
* capture all three paints and emit them in one atomic frame.
|
|
1162
|
+
*/
|
|
1163
|
+
async _render(frame) {
|
|
1164
|
+
const painted = [];
|
|
1165
|
+
const ctx = this.$r.ctx;
|
|
1166
|
+
this.#state = await Promise.all(this.active.map(async (node) => {
|
|
1167
|
+
const rows = await node.render(ctx);
|
|
1168
|
+
const height = rows.length;
|
|
1169
|
+
const width = Math.max(...rows.map((r) => stringWidth(r)));
|
|
1170
|
+
let x = node.state.x;
|
|
1171
|
+
let y = node.state.y;
|
|
1172
|
+
if (x < 1 && x > 0) x = Math.floor(this.$r.terminal.cols * x);
|
|
1173
|
+
if (x < 0) x = this.$r.terminal.cols + x;
|
|
1174
|
+
let refY = 1;
|
|
1175
|
+
let refHeight = this.$r.terminal.rows;
|
|
1176
|
+
if (node.state.relative === "ui") {
|
|
1177
|
+
refHeight = this.$r.ui.height;
|
|
1178
|
+
refY = this.$r.terminal.footerTop;
|
|
1179
|
+
} else if (node.state.relative === "stream") refHeight = this.$r.stream.liveHeight;
|
|
1180
|
+
if (y < 1 && y > 0) y = Math.floor(refHeight * y);
|
|
1181
|
+
const anchorY = y < 0 ? refY + refHeight + y : refY + y - 1;
|
|
1182
|
+
const va = node.state.verticalAnchor ?? "top";
|
|
1183
|
+
if (va === "bottom") y = anchorY - height + 1;
|
|
1184
|
+
else if (va === "center") y = anchorY - Math.floor(height / 2);
|
|
1185
|
+
else y = anchorY;
|
|
1186
|
+
const ha = node.state.horizontalAnchor ?? "left";
|
|
1187
|
+
if (ha === "right") x = x - width + 1;
|
|
1188
|
+
else if (ha === "center") x -= Math.floor(width / 2);
|
|
1189
|
+
painted.push({
|
|
1190
|
+
rows,
|
|
1191
|
+
x,
|
|
1192
|
+
y
|
|
1193
|
+
});
|
|
1194
|
+
const s = {
|
|
1195
|
+
height,
|
|
1196
|
+
node,
|
|
1197
|
+
width,
|
|
1198
|
+
x,
|
|
1199
|
+
y,
|
|
1200
|
+
zIndex: node.state.zIndex ?? 0
|
|
1201
|
+
};
|
|
1202
|
+
this.emit("render-node", s);
|
|
1203
|
+
return s;
|
|
1204
|
+
}));
|
|
1205
|
+
for (const { rows, x, y } of painted) for (let r = 0; r < rows.length; r++) frame.overlay(y + r, x, rows[r]);
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
//#endregion
|
|
1209
|
+
//#region src/renderer/selection.ts
|
|
1210
|
+
var SelectionLayer = class extends Emitter {
|
|
1211
|
+
#selection;
|
|
1212
|
+
#text = "";
|
|
1213
|
+
#dirty = false;
|
|
1214
|
+
#emitted;
|
|
1215
|
+
$r;
|
|
1216
|
+
constructor(renderer) {
|
|
1217
|
+
super();
|
|
1218
|
+
this.$r = renderer;
|
|
1219
|
+
}
|
|
1220
|
+
get selection() {
|
|
1221
|
+
return this.#selection;
|
|
1222
|
+
}
|
|
1223
|
+
set selection(value) {
|
|
1224
|
+
const prev = this.#selection;
|
|
1225
|
+
if (isDeepStrictEqual(prev, value)) return;
|
|
1226
|
+
this.#selection = value;
|
|
1227
|
+
this.#dirty = true;
|
|
1228
|
+
this.#emitted = void 0;
|
|
1229
|
+
if (!value) {
|
|
1230
|
+
this.#text = "";
|
|
1231
|
+
this.#dirty = false;
|
|
1232
|
+
}
|
|
1233
|
+
this.invalidate();
|
|
1234
|
+
this.emit("change", {
|
|
1235
|
+
prev,
|
|
1236
|
+
selection: value
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
get text() {
|
|
1240
|
+
return this.#selection ? this.#text : void 0;
|
|
1241
|
+
}
|
|
1242
|
+
set text(value) {
|
|
1243
|
+
if (this.#text === value && !this.#dirty) return;
|
|
1244
|
+
this.#text = value;
|
|
1245
|
+
const selection = this.#selection;
|
|
1246
|
+
if (!selection || selection.dragging || this.#emitted === selection) return;
|
|
1247
|
+
this.#dirty = false;
|
|
1248
|
+
setImmediate(() => {
|
|
1249
|
+
if (selection !== this.#selection || value !== this.#text) return;
|
|
1250
|
+
this.#emitted = selection;
|
|
1251
|
+
this.emit("selection", {
|
|
1252
|
+
selection,
|
|
1253
|
+
text: value
|
|
1254
|
+
});
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
invalidate() {
|
|
1258
|
+
this.$r.overlay.invalidate();
|
|
1259
|
+
untrack(() => void this.$r.emit("dirty"));
|
|
1260
|
+
}
|
|
1261
|
+
mouse(event) {
|
|
1262
|
+
if (event.kind === "scroll") return false;
|
|
1263
|
+
if (event.button !== "left") return false;
|
|
1264
|
+
const point = this.#hit(event);
|
|
1265
|
+
if (event.kind === "down") {
|
|
1266
|
+
if (event.click === 2) {
|
|
1267
|
+
this.select(point.screen, "word");
|
|
1268
|
+
return true;
|
|
1269
|
+
}
|
|
1270
|
+
if (event.click === 3) {
|
|
1271
|
+
this.select(point.screen, "line");
|
|
1272
|
+
return true;
|
|
1273
|
+
}
|
|
1274
|
+
this.selection = point.stream ? {
|
|
1275
|
+
dragging: true,
|
|
1276
|
+
from: point.stream,
|
|
1277
|
+
surface: "stream",
|
|
1278
|
+
to: point.stream
|
|
1279
|
+
} : {
|
|
1280
|
+
dragging: true,
|
|
1281
|
+
from: point.screen,
|
|
1282
|
+
surface: "screen",
|
|
1283
|
+
to: point.screen
|
|
1284
|
+
};
|
|
1285
|
+
return true;
|
|
1286
|
+
}
|
|
1287
|
+
const selection = this.#selection;
|
|
1288
|
+
if (!selection?.dragging) return false;
|
|
1289
|
+
if (event.kind === "drag") {
|
|
1290
|
+
this.selection = this.#update(selection, point, true);
|
|
1291
|
+
return true;
|
|
1292
|
+
}
|
|
1293
|
+
const next = this.#update(selection, point, false);
|
|
1294
|
+
this.selection = samePoint(next.from, next.to) ? void 0 : next;
|
|
1295
|
+
return true;
|
|
1296
|
+
}
|
|
1297
|
+
clear() {
|
|
1298
|
+
if (!this.#selection) return;
|
|
1299
|
+
this.selection = void 0;
|
|
1300
|
+
}
|
|
1301
|
+
select(screen, what) {
|
|
1302
|
+
const point = this.#hitPoint(screen);
|
|
1303
|
+
const line = this.$r.frame.slice(screen.row, 1, void 0);
|
|
1304
|
+
if (!line) return;
|
|
1305
|
+
const slice = what === "line" ? lineRange(line.line) : wordRange(line.line, screen.col);
|
|
1306
|
+
if (!slice) return;
|
|
1307
|
+
const row = point.stream?.row ?? screen.row;
|
|
1308
|
+
this.selection = {
|
|
1309
|
+
dragging: false,
|
|
1310
|
+
from: {
|
|
1311
|
+
col: slice.from,
|
|
1312
|
+
row
|
|
1313
|
+
},
|
|
1314
|
+
surface: point.stream ? "stream" : "screen",
|
|
1315
|
+
to: {
|
|
1316
|
+
col: slice.to,
|
|
1317
|
+
row
|
|
1318
|
+
}
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
renderStream(frame, ctx) {
|
|
1322
|
+
if (this.#selection?.surface === "stream") this.render(frame, ctx);
|
|
1323
|
+
}
|
|
1324
|
+
renderScreen(frame, ctx) {
|
|
1325
|
+
if (this.#selection?.surface === "screen") this.render(frame, ctx);
|
|
1326
|
+
}
|
|
1327
|
+
render(frame, ctx) {
|
|
1328
|
+
const selection = this.#selection;
|
|
1329
|
+
if (!selection) return;
|
|
1330
|
+
const from = this.#toScreen(selection.surface, selection.from);
|
|
1331
|
+
const to = this.#toScreen(selection.surface, selection.to);
|
|
1332
|
+
const start = before(from, to) ? from : to;
|
|
1333
|
+
const end = start === from ? to : from;
|
|
1334
|
+
const bounds = selection.surface === "stream" ? this.$r.stream.bounds : void 0;
|
|
1335
|
+
const highlighted = renderRange({
|
|
1336
|
+
bounds,
|
|
1337
|
+
ctx,
|
|
1338
|
+
end,
|
|
1339
|
+
frame,
|
|
1340
|
+
start
|
|
1341
|
+
});
|
|
1342
|
+
this.text = normalizeText(selection.surface === "stream" ? this.#streamSlices(start, end, bounds) : highlighted);
|
|
1343
|
+
}
|
|
1344
|
+
#hit(event) {
|
|
1345
|
+
return this.#hitPoint({
|
|
1346
|
+
col: event.x,
|
|
1347
|
+
row: event.y
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
#hitPoint(screen) {
|
|
1351
|
+
if (this.$r.overlay.contains(screen)) return { screen };
|
|
1352
|
+
if (this.$r.ui.contains(screen)) return { screen };
|
|
1353
|
+
return {
|
|
1354
|
+
screen,
|
|
1355
|
+
stream: this.$r.stream.contains(screen) ? this.$r.stream.fromScreen(screen) : void 0
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
#update(selection, point, dragging) {
|
|
1359
|
+
if (selection.surface === "stream" && point.stream) return {
|
|
1360
|
+
...selection,
|
|
1361
|
+
dragging,
|
|
1362
|
+
to: point.stream
|
|
1363
|
+
};
|
|
1364
|
+
return {
|
|
1365
|
+
dragging,
|
|
1366
|
+
from: selection.surface === "stream" ? this.$r.stream.toScreen(selection.from) : selection.from,
|
|
1367
|
+
surface: "screen",
|
|
1368
|
+
to: point.screen
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
#toScreen(surface, point) {
|
|
1372
|
+
return surface === "stream" ? this.$r.stream.toScreen(point) : point;
|
|
1373
|
+
}
|
|
1374
|
+
#streamSlices(start, end, bounds) {
|
|
1375
|
+
const ret = [];
|
|
1376
|
+
const firstRow = Math.max(start.row, bounds.top);
|
|
1377
|
+
const lastRow = Math.min(end.row, bounds.bottom);
|
|
1378
|
+
if (lastRow < firstRow) return ret;
|
|
1379
|
+
for (let row = firstRow; row <= lastRow; row++) {
|
|
1380
|
+
const streamPoint = this.$r.stream.fromScreen({
|
|
1381
|
+
col: 1,
|
|
1382
|
+
row
|
|
1383
|
+
});
|
|
1384
|
+
if (!streamPoint) continue;
|
|
1385
|
+
const line = this.$r.stream.getRow(streamPoint.row);
|
|
1386
|
+
if (line === void 0) continue;
|
|
1387
|
+
ret.push({
|
|
1388
|
+
from: row === start.row ? start.col : 1,
|
|
1389
|
+
line,
|
|
1390
|
+
to: row === end.row ? end.col : void 0
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
return ret;
|
|
1394
|
+
}
|
|
1395
|
+
};
|
|
1396
|
+
const WORD = /^[\w\-/\\@]$/;
|
|
1397
|
+
function lineRange(line) {
|
|
1398
|
+
const text = stripAnsi(line);
|
|
1399
|
+
const start = text.search(/\S/);
|
|
1400
|
+
if (start === -1) return;
|
|
1401
|
+
let end = text.length;
|
|
1402
|
+
while (end > start && /\s/.test(text[end - 1])) end--;
|
|
1403
|
+
return {
|
|
1404
|
+
from: start + 1,
|
|
1405
|
+
to: end + 1
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
function wordRange(line, col) {
|
|
1409
|
+
const text = stripAnsi(line);
|
|
1410
|
+
const index = col - 1;
|
|
1411
|
+
if (index < 0 || index >= text.length) return;
|
|
1412
|
+
const match = charClass(text[index]);
|
|
1413
|
+
let start = index;
|
|
1414
|
+
while (start > 0 && charClass(text[start - 1]) === match) start--;
|
|
1415
|
+
let end = index + 1;
|
|
1416
|
+
while (end < text.length && charClass(text[end]) === match) end++;
|
|
1417
|
+
return {
|
|
1418
|
+
from: start + 1,
|
|
1419
|
+
to: end + 1
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
function charClass(char) {
|
|
1423
|
+
if (/\s/.test(char)) return "space";
|
|
1424
|
+
if (WORD.test(char)) return "word";
|
|
1425
|
+
return "punct";
|
|
1426
|
+
}
|
|
1427
|
+
function samePoint(a, b) {
|
|
1428
|
+
return a.row === b.row && a.col === b.col;
|
|
1429
|
+
}
|
|
1430
|
+
function before(a, b) {
|
|
1431
|
+
return a.row < b.row || a.row === b.row && a.col <= b.col;
|
|
1432
|
+
}
|
|
1433
|
+
function normalizeText(slices) {
|
|
1434
|
+
const lines = slices.map((slice) => {
|
|
1435
|
+
const full = stripAnsi(slice.line).trimEnd();
|
|
1436
|
+
const text = stripAnsi(sliceAnsi(slice.line, slice.from - 1, slice.to ? slice.to - 1 : void 0)).trimEnd();
|
|
1437
|
+
return {
|
|
1438
|
+
from: slice.from,
|
|
1439
|
+
full,
|
|
1440
|
+
text
|
|
1441
|
+
};
|
|
1442
|
+
});
|
|
1443
|
+
const indent = commonIndent(lines.map((line) => line.full));
|
|
1444
|
+
return lines.map((line) => stripIndent(line.text, line.from, indent)).join("\n");
|
|
1445
|
+
}
|
|
1446
|
+
function commonIndent(lines) {
|
|
1447
|
+
const nonEmpty = lines.filter((line) => line.trim() !== "");
|
|
1448
|
+
if (nonEmpty.length === 0) return 0;
|
|
1449
|
+
return Math.min(...nonEmpty.map((line) => line.match(/^[ \t]*/)?.[0].length ?? 0));
|
|
1450
|
+
}
|
|
1451
|
+
function stripIndent(line, from, indent) {
|
|
1452
|
+
if (indent === 0 || line.trim() === "") return line;
|
|
1453
|
+
const visibleBeforeSelection = from - 1;
|
|
1454
|
+
const remove = Math.max(0, indent - visibleBeforeSelection);
|
|
1455
|
+
return remove === 0 ? line : line.slice(remove);
|
|
1456
|
+
}
|
|
1457
|
+
function renderRange({ bounds, ctx, end, frame, start }) {
|
|
1458
|
+
const lines = [];
|
|
1459
|
+
const firstRow = bounds ? Math.max(start.row, bounds.top) : start.row;
|
|
1460
|
+
const lastRow = bounds ? Math.min(end.row, bounds.bottom) : end.row;
|
|
1461
|
+
if (lastRow < firstRow) return lines;
|
|
1462
|
+
if (firstRow === lastRow) {
|
|
1463
|
+
const from = firstRow === start.row ? start.col : 1;
|
|
1464
|
+
const to = firstRow === end.row ? end.col : void 0;
|
|
1465
|
+
const text = frame.highlight(firstRow, from, to, ctx);
|
|
1466
|
+
if (text !== void 0) lines.push(text);
|
|
1467
|
+
return lines;
|
|
1468
|
+
}
|
|
1469
|
+
const first = frame.highlight(firstRow, firstRow === start.row ? start.col : 1, void 0, ctx);
|
|
1470
|
+
if (first !== void 0) lines.push(first);
|
|
1471
|
+
for (let row = firstRow + 1; row < lastRow; row++) {
|
|
1472
|
+
const text = frame.highlight(row, 1, void 0, ctx);
|
|
1473
|
+
if (text !== void 0) lines.push(text);
|
|
1474
|
+
}
|
|
1475
|
+
const last = frame.highlight(lastRow, 1, lastRow === end.row ? end.col : void 0, ctx);
|
|
1476
|
+
if (last !== void 0) lines.push(last);
|
|
1477
|
+
return lines;
|
|
1478
|
+
}
|
|
1479
|
+
//#endregion
|
|
1480
|
+
//#region src/renderer/stream.ts
|
|
1481
|
+
/**
|
|
1482
|
+
* Stream surface — append-only list of nodes that render into the bottom
|
|
1483
|
+
* of the terminal's scroll region and commit to scrollback as newer
|
|
1484
|
+
* content arrives.
|
|
1485
|
+
*
|
|
1486
|
+
* We keep previously-appended nodes in `#state` as long as any of their
|
|
1487
|
+
* rows are still on-screen, so `rows` (the getter) can report the full
|
|
1488
|
+
* visible slice, and so the overlay surface can reconstruct what was
|
|
1489
|
+
* underneath it. Only the current tail is `live` — committed nodes are
|
|
1490
|
+
* frozen.
|
|
1491
|
+
*
|
|
1492
|
+
* Scrollback commits happen via `\n` at `scrollBottom`: the only
|
|
1493
|
+
* guaranteed-portable way to move a row into scrollback (xterm.js and
|
|
1494
|
+
* ghostty-web's WASM parser both ignore `CSI S` for scrollback
|
|
1495
|
+
* promotion). So growth of the stream always flows through the bottom
|
|
1496
|
+
* row with a `\n`-prefix write per new row.
|
|
1497
|
+
*/
|
|
1498
|
+
function findFrozenPrefixEnd(rendered, frozen) {
|
|
1499
|
+
if (frozen.length === 0) return 0;
|
|
1500
|
+
for (let start = 0; start <= rendered.length - frozen.length; start++) {
|
|
1501
|
+
let match = true;
|
|
1502
|
+
for (let i = 0; i < frozen.length; i++) if (rendered[start + i] !== frozen[i]) {
|
|
1503
|
+
match = false;
|
|
1504
|
+
break;
|
|
1505
|
+
}
|
|
1506
|
+
if (match) return start + frozen.length;
|
|
1507
|
+
}
|
|
1508
|
+
return frozen.length;
|
|
1509
|
+
}
|
|
1510
|
+
const SCROLL_DURATION = 120;
|
|
1511
|
+
var Stream = class extends Surface {
|
|
1512
|
+
type = "stream";
|
|
1513
|
+
#state = [];
|
|
1514
|
+
#snapshot = {
|
|
1515
|
+
bottom: 0,
|
|
1516
|
+
historyLength: 0,
|
|
1517
|
+
rows: [],
|
|
1518
|
+
scrollTop: 0,
|
|
1519
|
+
top: 1,
|
|
1520
|
+
virtual: false,
|
|
1521
|
+
visible: []
|
|
1522
|
+
};
|
|
1523
|
+
#opts;
|
|
1524
|
+
#scrollback = [];
|
|
1525
|
+
/** 0 = follow live bottom; otherwise 1-based top row inside history. */
|
|
1526
|
+
#scrollTop = 0;
|
|
1527
|
+
#scrollAnim;
|
|
1528
|
+
constructor(renderer, opts = {}) {
|
|
1529
|
+
super(renderer);
|
|
1530
|
+
this.#opts = {
|
|
1531
|
+
fixedFooterHeight: opts.fixedFooterHeight ?? 0,
|
|
1532
|
+
maxLive: opts.maxLive ?? 3
|
|
1533
|
+
};
|
|
1534
|
+
this.emitScroll = throttle(this.emitScroll.bind(this), 1e3 / 60);
|
|
1535
|
+
}
|
|
1536
|
+
get bounds() {
|
|
1537
|
+
return {
|
|
1538
|
+
bottom: this.terminal.scrollBottom,
|
|
1539
|
+
top: 1
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
get terminal() {
|
|
1543
|
+
return this.$r.terminal;
|
|
1544
|
+
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Append `node` as the new live tail. The previous tail is frozen —
|
|
1547
|
+
* it stops receiving re-renders even if its state mutates.
|
|
1548
|
+
*
|
|
1549
|
+
* Function form: `append(() => node)` runs the function inside a
|
|
1550
|
+
* fresh Owner scope (so `signal` / `effect` / `onCleanup` /
|
|
1551
|
+
* `provideContext` inside `fn` attach to that scope) and appends the
|
|
1552
|
+
* returned Node. The Owner disposes when the Node unmounts.
|
|
1553
|
+
*/
|
|
1554
|
+
append(node) {
|
|
1555
|
+
const boundary = createSuspenseBoundary();
|
|
1556
|
+
const resolved = withOwner(this.$r.rootOwner, () => createNode(() => {
|
|
1557
|
+
provideContext(SuspenseContext, boundary);
|
|
1558
|
+
return node();
|
|
1559
|
+
}));
|
|
1560
|
+
const invalidate = () => {
|
|
1561
|
+
state.dirty = true;
|
|
1562
|
+
this.invalidate();
|
|
1563
|
+
};
|
|
1564
|
+
const state = {
|
|
1565
|
+
boundary,
|
|
1566
|
+
commit: 0,
|
|
1567
|
+
dirty: true,
|
|
1568
|
+
freeze: () => {
|
|
1569
|
+
if (state.node.mounted) state.node.unmount();
|
|
1570
|
+
state.node.off("invalidate", invalidate);
|
|
1571
|
+
},
|
|
1572
|
+
get live() {
|
|
1573
|
+
return this.boundary.active() || this.dirty || !!this.node.state.sticky;
|
|
1574
|
+
},
|
|
1575
|
+
node: resolved
|
|
1576
|
+
};
|
|
1577
|
+
this.#state.push(state);
|
|
1578
|
+
const ctx = this.mountCtx;
|
|
1579
|
+
if (this.running && ctx) resolved.mount(ctx);
|
|
1580
|
+
resolved.on("invalidate", invalidate);
|
|
1581
|
+
this.invalidate();
|
|
1582
|
+
return resolved;
|
|
1583
|
+
}
|
|
1584
|
+
/** How many rows the live region may occupy. */
|
|
1585
|
+
get liveHeight() {
|
|
1586
|
+
return this.terminal.scrollBottom;
|
|
1587
|
+
}
|
|
1588
|
+
/** Tracked nodes (oldest first). Used by renderer traversals. */
|
|
1589
|
+
get nodes() {
|
|
1590
|
+
return this.#state.map((s) => s.node);
|
|
1591
|
+
}
|
|
1592
|
+
async waitIdle() {
|
|
1593
|
+
if (!this.pending) return;
|
|
1594
|
+
await new Promise((resolve) => this.once("idle", resolve));
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* Rows currently painted in the live region, top-to-bottom. Bottom
|
|
1598
|
+
* of the returned array aligns with `terminal.scrollBottom`. Exposed
|
|
1599
|
+
* so the overlay surface can restore what it drew over.
|
|
1600
|
+
*/
|
|
1601
|
+
get rows() {
|
|
1602
|
+
return this.#snapshot.visible;
|
|
1603
|
+
}
|
|
1604
|
+
getRow(row) {
|
|
1605
|
+
return this.#snapshot.rows[row - 1];
|
|
1606
|
+
}
|
|
1607
|
+
fromScreen(point) {
|
|
1608
|
+
const { top, visible, scrollTop } = this.#snapshot;
|
|
1609
|
+
if (point.row < top || point.row >= top + visible.length) return;
|
|
1610
|
+
return {
|
|
1611
|
+
col: point.col,
|
|
1612
|
+
row: scrollTop + point.row - top
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
toScreen(point) {
|
|
1616
|
+
const { top, scrollTop } = this.#snapshot;
|
|
1617
|
+
const offset = point.row - scrollTop;
|
|
1618
|
+
return {
|
|
1619
|
+
col: point.col,
|
|
1620
|
+
row: top + offset
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
get #historyLength() {
|
|
1624
|
+
return this.#snapshot.historyLength;
|
|
1625
|
+
}
|
|
1626
|
+
emitScroll() {
|
|
1627
|
+
setImmediate(() => {
|
|
1628
|
+
this.emit("scroll", {
|
|
1629
|
+
below: this.#scrollTop === 0 ? 0 : this.#historyLength - this.#scrollTop - this.liveHeight + 1,
|
|
1630
|
+
offset: this.#scrollTop === 0 ? this.#historyLength : this.#scrollTop,
|
|
1631
|
+
total: this.#historyLength
|
|
1632
|
+
});
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
scroll(lines = .5) {
|
|
1636
|
+
const amount = Math.trunc(Math.abs(lines) < 1 ? lines * this.liveHeight : lines);
|
|
1637
|
+
if (amount === 0) return Promise.resolve();
|
|
1638
|
+
const total = this.#historyLength;
|
|
1639
|
+
const maxTop = Math.max(1, total - this.liveHeight + 1);
|
|
1640
|
+
const current = this.#scrollTop === 0 ? maxTop : this.#scrollTop;
|
|
1641
|
+
const target = Math.max(1, Math.min(maxTop, current + amount));
|
|
1642
|
+
if (target === current) return Promise.resolve();
|
|
1643
|
+
this.#scrollAnim?.cancel();
|
|
1644
|
+
const ret = Promise.withResolvers();
|
|
1645
|
+
const delta = Math.abs(target - current);
|
|
1646
|
+
const dir = Math.sign(target - current);
|
|
1647
|
+
const stepTime = Math.max(8, Math.min(16, SCROLL_DURATION / delta));
|
|
1648
|
+
const stepAmount = Math.max(1, Math.ceil(delta / Math.ceil(SCROLL_DURATION / stepTime)));
|
|
1649
|
+
const tick = () => {
|
|
1650
|
+
const lastTop = Math.max(1, this.#historyLength - this.liveHeight + 1);
|
|
1651
|
+
const top = this.#scrollTop === 0 ? lastTop : this.#scrollTop;
|
|
1652
|
+
const remaining = Math.abs(target - top);
|
|
1653
|
+
if (remaining === 0) {
|
|
1654
|
+
this.#scrollTop = target >= lastTop ? 0 : target;
|
|
1655
|
+
this.emitScroll();
|
|
1656
|
+
ret.resolve();
|
|
1657
|
+
this.#scrollAnim = void 0;
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
const next = top + dir * Math.min(stepAmount, remaining);
|
|
1661
|
+
this.#scrollTop = next >= lastTop ? 0 : next;
|
|
1662
|
+
this.emitScroll();
|
|
1663
|
+
this.invalidate();
|
|
1664
|
+
const t = setTimeout(tick, stepTime);
|
|
1665
|
+
t.unref();
|
|
1666
|
+
this.#scrollAnim = { cancel: () => {
|
|
1667
|
+
clearTimeout(t);
|
|
1668
|
+
ret.resolve();
|
|
1669
|
+
this.#scrollAnim = void 0;
|
|
1670
|
+
} };
|
|
1671
|
+
};
|
|
1672
|
+
tick();
|
|
1673
|
+
return ret.promise;
|
|
1674
|
+
}
|
|
1675
|
+
async scrollBottom() {
|
|
1676
|
+
if (this.#scrollTop === 0) return;
|
|
1677
|
+
await this.scroll(Math.max(1, this.#historyLength - this.liveHeight + 1));
|
|
1678
|
+
if (this.#scrollTop === 0) return;
|
|
1679
|
+
if (this.#scrollAnim) return;
|
|
1680
|
+
this.#scrollTop = 0;
|
|
1681
|
+
this.emitScroll();
|
|
1682
|
+
this.invalidate();
|
|
1683
|
+
}
|
|
1684
|
+
scrollTop() {
|
|
1685
|
+
return this.scroll(-this.#historyLength + 1);
|
|
1686
|
+
}
|
|
1687
|
+
scrollUp(lines = .5) {
|
|
1688
|
+
return this.scroll(-lines);
|
|
1689
|
+
}
|
|
1690
|
+
scrollDown(lines = .5) {
|
|
1691
|
+
return this.scroll(lines);
|
|
1692
|
+
}
|
|
1693
|
+
async #render() {
|
|
1694
|
+
const ctx = this.$r.ctx;
|
|
1695
|
+
await Promise.all(this.#state.map(async (s) => {
|
|
1696
|
+
const prev = s.rows ?? [];
|
|
1697
|
+
const len = prev.length;
|
|
1698
|
+
const frozen = prev.slice(0, s.commit);
|
|
1699
|
+
s.dirty = false;
|
|
1700
|
+
const rendered = await s.node.render(ctx);
|
|
1701
|
+
const mutableStart = s.commit === 0 ? 0 : findFrozenPrefixEnd(rendered, frozen);
|
|
1702
|
+
s.rows = [...frozen, ...rendered.slice(mutableStart)];
|
|
1703
|
+
if (s.rows.length < len) s.rows.push(...Array(len - s.rows.length).fill(""));
|
|
1704
|
+
}));
|
|
1705
|
+
await Promise.resolve();
|
|
1706
|
+
const states = [];
|
|
1707
|
+
const sticky = [];
|
|
1708
|
+
for (const s of this.#state) if (s.node.state.sticky) sticky.push(s);
|
|
1709
|
+
else states.push(s);
|
|
1710
|
+
states.push(...sticky);
|
|
1711
|
+
this.#state = [...states];
|
|
1712
|
+
const rows = [];
|
|
1713
|
+
let commitLimit;
|
|
1714
|
+
for (const s of this.#state) {
|
|
1715
|
+
if (commitLimit === void 0 && s.live) commitLimit = rows.length;
|
|
1716
|
+
if (s.rows) rows.push(...s.rows);
|
|
1717
|
+
}
|
|
1718
|
+
return {
|
|
1719
|
+
commitLimit: commitLimit ?? rows.length,
|
|
1720
|
+
rows
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
async _render(frame) {
|
|
1724
|
+
const { commitLimit, rows: allRows } = await this.#render();
|
|
1725
|
+
const old = this.#snapshot;
|
|
1726
|
+
const liveHeight = this.liveHeight;
|
|
1727
|
+
const committed = this.#committedRows();
|
|
1728
|
+
const commitThreshold = Math.max(1, this.terminal.rows - this.#opts.fixedFooterHeight);
|
|
1729
|
+
const liveStart = Math.min(commitLimit, Math.max(committed, allRows.length - commitThreshold));
|
|
1730
|
+
const commit = allRows.slice(committed, liveStart);
|
|
1731
|
+
this.#scrollback.push(...commit);
|
|
1732
|
+
const liveRows = allRows.slice(liveStart);
|
|
1733
|
+
const historyLength = this.#scrollback.length + liveRows.length;
|
|
1734
|
+
const maxTop = Math.max(1, historyLength - liveHeight + 1);
|
|
1735
|
+
if (this.#scrollTop > maxTop) this.#scrollTop = 0;
|
|
1736
|
+
let newVisible = liveRows.slice(-liveHeight);
|
|
1737
|
+
const historyChanged = this.#historyLength !== historyLength;
|
|
1738
|
+
let deleteImages;
|
|
1739
|
+
if (this.#scrollTop > 0) {
|
|
1740
|
+
if (historyChanged) this.emitScroll();
|
|
1741
|
+
const start = this.#scrollTop - 1;
|
|
1742
|
+
const sbl = this.#scrollback.length;
|
|
1743
|
+
if (start >= sbl) newVisible = liveRows.slice(start - sbl, start - sbl + liveHeight);
|
|
1744
|
+
else {
|
|
1745
|
+
newVisible = this.#scrollback.slice(start, start + liveHeight);
|
|
1746
|
+
if (newVisible.length < liveHeight) newVisible.push(...liveRows.slice(0, liveHeight - newVisible.length));
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
if (this.#scrollTop > 0 || this.terminal.altScreen) {
|
|
1750
|
+
const kittyPlacements = bumpPlacements(newVisible);
|
|
1751
|
+
if (kittyPlacements) {
|
|
1752
|
+
newVisible = kittyPlacements.rows;
|
|
1753
|
+
deleteImages = kittyPlacements.delete;
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
const next = {
|
|
1757
|
+
bottom: liveHeight,
|
|
1758
|
+
deleteVirtualPlacements: deleteImages,
|
|
1759
|
+
historyLength,
|
|
1760
|
+
rows: [...this.#scrollback, ...liveRows],
|
|
1761
|
+
scrollTop: this.#scrollTop === 0 ? maxTop : this.#scrollTop,
|
|
1762
|
+
top: liveHeight - newVisible.length + 1,
|
|
1763
|
+
virtual: this.#scrollTop > 0,
|
|
1764
|
+
visible: newVisible
|
|
1765
|
+
};
|
|
1766
|
+
const plan = {
|
|
1767
|
+
commit,
|
|
1768
|
+
frame,
|
|
1769
|
+
next,
|
|
1770
|
+
old
|
|
1771
|
+
};
|
|
1772
|
+
this.#paint(plan);
|
|
1773
|
+
this.#snapshot = next;
|
|
1774
|
+
this.#advanceCommits(commit.length);
|
|
1775
|
+
this.#dropCommittedStates();
|
|
1776
|
+
if (!this.pending) this.emit("idle");
|
|
1777
|
+
}
|
|
1778
|
+
#paint(plan) {
|
|
1779
|
+
if (plan.old.deleteVirtualPlacements) plan.frame.queue((terminal) => terminal.write(plan.old.deleteVirtualPlacements()));
|
|
1780
|
+
if (!this.terminal.altScreen) this.#commitToScrollback(plan);
|
|
1781
|
+
this.#clearAboveVisible(plan);
|
|
1782
|
+
this.#paintVirtualScroll(plan);
|
|
1783
|
+
this.#paintVisible(plan);
|
|
1784
|
+
}
|
|
1785
|
+
#commitToScrollback(plan) {
|
|
1786
|
+
let i = 0;
|
|
1787
|
+
while (i < plan.commit.length) {
|
|
1788
|
+
const batch = Math.min(plan.commit.length - i, plan.next.bottom);
|
|
1789
|
+
for (let j = 0; j < batch; j++) {
|
|
1790
|
+
const row = 1 + j;
|
|
1791
|
+
const content = plan.commit[i + j];
|
|
1792
|
+
plan.frame.set(row, content);
|
|
1793
|
+
}
|
|
1794
|
+
plan.frame.scrollUp(1, plan.next.bottom, batch, (terminal) => {
|
|
1795
|
+
terminal.write(terminal.moveTo(plan.next.bottom, 1));
|
|
1796
|
+
for (let j = 0; j < batch; j++) terminal.write("\n");
|
|
1797
|
+
});
|
|
1798
|
+
i += batch;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
#clearAboveVisible(plan) {
|
|
1802
|
+
const clearTop = plan.old.bottom !== plan.next.bottom ? Math.max(1, Math.min(plan.old.top, plan.next.top)) : 1;
|
|
1803
|
+
for (let r = clearTop; r < plan.next.top; r++) {
|
|
1804
|
+
if (r < 1 || r > plan.next.bottom) continue;
|
|
1805
|
+
plan.frame.clear(r);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
#paintVirtualScroll(plan) {
|
|
1809
|
+
if (plan.commit.length > 0) return;
|
|
1810
|
+
if (!plan.old.virtual || !plan.next.virtual) return;
|
|
1811
|
+
if (plan.old.bottom !== plan.next.bottom) return;
|
|
1812
|
+
if (plan.old.visible.length !== plan.next.visible.length) return;
|
|
1813
|
+
if (plan.next.visible.length === 0) return;
|
|
1814
|
+
const delta = plan.next.scrollTop - plan.old.scrollTop;
|
|
1815
|
+
const lines = Math.abs(delta);
|
|
1816
|
+
if (lines === 0 || lines >= plan.next.visible.length) return;
|
|
1817
|
+
if (delta > 0) plan.frame.scrollUp(1, plan.next.bottom, lines, (terminal) => {
|
|
1818
|
+
terminal.write(terminal.scrollUp(lines));
|
|
1819
|
+
});
|
|
1820
|
+
else plan.frame.scrollDown(1, plan.next.bottom, lines, (terminal) => {
|
|
1821
|
+
terminal.write(terminal.scrollDown(lines));
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
#paintVisible(plan) {
|
|
1825
|
+
for (let k = 0; k < plan.next.visible.length; k++) {
|
|
1826
|
+
const row = plan.next.top + k;
|
|
1827
|
+
plan.frame.set(row, plan.next.visible[k]);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
#advanceCommits(commitCount) {
|
|
1831
|
+
let remaining = commitCount;
|
|
1832
|
+
for (const s of this.#state) {
|
|
1833
|
+
if (remaining <= 0) break;
|
|
1834
|
+
const len = s.rows?.length ?? 0;
|
|
1835
|
+
const n = Math.min(remaining, Math.max(0, len - s.commit));
|
|
1836
|
+
s.commit += n;
|
|
1837
|
+
remaining -= n;
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
#dropCommittedStates() {
|
|
1841
|
+
while (this.#state.length > 0) {
|
|
1842
|
+
const first = this.#state[0];
|
|
1843
|
+
const len = first.rows?.length ?? 0;
|
|
1844
|
+
if (first.live || first.commit < len) break;
|
|
1845
|
+
this.#state.shift();
|
|
1846
|
+
first.freeze();
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
#committedRows() {
|
|
1850
|
+
return this.#state.reduce((sum, s) => sum + s.commit, 0);
|
|
1851
|
+
}
|
|
1852
|
+
get pending() {
|
|
1853
|
+
return this.active;
|
|
1854
|
+
}
|
|
1855
|
+
get active() {
|
|
1856
|
+
return this.#state.some((s) => s.boundary.active());
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* Terminal was resized. Forget only the paint mirror that mapped stream
|
|
1860
|
+
* rows onto the old screen geometry. App scrollback and per-state commit
|
|
1861
|
+
* boundaries are preserved; retained nodes re-render at the new width
|
|
1862
|
+
* through Node.render()'s cache key.
|
|
1863
|
+
*
|
|
1864
|
+
* Paired with a screen-clear in the Renderer's resize handler — this
|
|
1865
|
+
* method only resets our mirror of what's on screen; the actual wipe
|
|
1866
|
+
* and re-establishment of DECSTBM lives in the terminal-level handler.
|
|
1867
|
+
*/
|
|
1868
|
+
onResize() {
|
|
1869
|
+
this.resetPaint();
|
|
1870
|
+
}
|
|
1871
|
+
resetPaint() {
|
|
1872
|
+
this.#snapshot = {
|
|
1873
|
+
...this.#snapshot,
|
|
1874
|
+
bottom: this.terminal.scrollBottom,
|
|
1875
|
+
rows: [],
|
|
1876
|
+
scrollTop: 0,
|
|
1877
|
+
top: 1,
|
|
1878
|
+
virtual: false,
|
|
1879
|
+
visible: []
|
|
1880
|
+
};
|
|
1881
|
+
this.#scrollAnim?.cancel();
|
|
1882
|
+
this.invalidate();
|
|
1883
|
+
}
|
|
1884
|
+
reset(opts = {}) {
|
|
1885
|
+
this.resetPaint();
|
|
1886
|
+
this.#scrollback = [];
|
|
1887
|
+
this.#snapshot.historyLength = 0;
|
|
1888
|
+
this.#scrollTop = 0;
|
|
1889
|
+
for (const s of this.#state) s.commit = 0;
|
|
1890
|
+
if (!opts.keepNodes) {
|
|
1891
|
+
for (const s of this.#state) s.freeze();
|
|
1892
|
+
this.#state = [];
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
mountAll(ctx) {
|
|
1896
|
+
for (const s of this.#state) if (!s.node.mounted) s.node.mount(ctx);
|
|
1897
|
+
}
|
|
1898
|
+
unmountAll() {
|
|
1899
|
+
for (const s of this.#state) if (s.node.mounted) s.node.unmount();
|
|
1900
|
+
}
|
|
1901
|
+
};
|
|
1902
|
+
//#endregion
|
|
1903
|
+
//#region src/renderer/terminal.ts
|
|
1904
|
+
/**
|
|
1905
|
+
* Terminal primitive — owns stdin raw mode, stdout writes, and the
|
|
1906
|
+
* standard set of control modes we flip for a direct-mode renderer:
|
|
1907
|
+
* hide cursor, DECAWM off, DECSTBM scroll region, synchronized output.
|
|
1908
|
+
*
|
|
1909
|
+
* No rendering logic lives here; upstream surfaces (stream, ui) push
|
|
1910
|
+
* escape sequences through the helpers exposed below.
|
|
1911
|
+
*/
|
|
1912
|
+
const progressStates = {
|
|
1913
|
+
inactive: 0,
|
|
1914
|
+
progress: 1,
|
|
1915
|
+
error: 2,
|
|
1916
|
+
loading: 3,
|
|
1917
|
+
paused: 4
|
|
1918
|
+
};
|
|
1919
|
+
const CSI = "\x1B[";
|
|
1920
|
+
const ST = "\x1B\\";
|
|
1921
|
+
/** @internal */
|
|
1922
|
+
var Terminal = class {
|
|
1923
|
+
#stdout;
|
|
1924
|
+
#stdin;
|
|
1925
|
+
#altScreen;
|
|
1926
|
+
#hookSignals;
|
|
1927
|
+
#started = false;
|
|
1928
|
+
#reserveBottom;
|
|
1929
|
+
#mouse = false;
|
|
1930
|
+
#progress = { type: "inactive" };
|
|
1931
|
+
#progressInterval;
|
|
1932
|
+
#prevRawMode;
|
|
1933
|
+
#resizeListeners = /* @__PURE__ */ new Set();
|
|
1934
|
+
#signalCleanup;
|
|
1935
|
+
#onResize = () => {
|
|
1936
|
+
for (const l of this.#resizeListeners) l(this.cols, this.rows);
|
|
1937
|
+
};
|
|
1938
|
+
constructor(opts = {}) {
|
|
1939
|
+
this.#stdout = opts.stdout ?? process.stdout;
|
|
1940
|
+
this.#stdin = opts.stdin ?? process.stdin;
|
|
1941
|
+
this.#reserveBottom = opts.reserveBottom ?? 0;
|
|
1942
|
+
this.#altScreen = opts.altScreen ?? false;
|
|
1943
|
+
this.#hookSignals = opts.hookSignals ?? true;
|
|
1944
|
+
this.#mouse = opts.mouse ?? false;
|
|
1945
|
+
}
|
|
1946
|
+
/** Current terminal columns (number of cells wide). */
|
|
1947
|
+
get cols() {
|
|
1948
|
+
return this.#stdout.columns || 80;
|
|
1949
|
+
}
|
|
1950
|
+
/** Current terminal rows (number of cells tall). */
|
|
1951
|
+
get rows() {
|
|
1952
|
+
return this.#stdout.rows || 24;
|
|
1953
|
+
}
|
|
1954
|
+
/** Rows reserved at the bottom for the footer. */
|
|
1955
|
+
get reserveBottom() {
|
|
1956
|
+
return this.#reserveBottom;
|
|
1957
|
+
}
|
|
1958
|
+
/** Bottom row of the scroll region (1-based, inclusive). */
|
|
1959
|
+
get scrollBottom() {
|
|
1960
|
+
return Math.max(1, this.rows - this.#reserveBottom);
|
|
1961
|
+
}
|
|
1962
|
+
/** Top row of the footer (1-based, inclusive). 0 when no footer. */
|
|
1963
|
+
get footerTop() {
|
|
1964
|
+
return this.#reserveBottom > 0 ? this.rows - this.#reserveBottom + 1 : 0;
|
|
1965
|
+
}
|
|
1966
|
+
get altScreen() {
|
|
1967
|
+
return this.#altScreen;
|
|
1968
|
+
}
|
|
1969
|
+
get mouse() {
|
|
1970
|
+
return this.#mouse;
|
|
1971
|
+
}
|
|
1972
|
+
set mouse(mouse) {
|
|
1973
|
+
if (this.#mouse === mouse) return;
|
|
1974
|
+
this.#mouse = mouse;
|
|
1975
|
+
if (!this.#started) return;
|
|
1976
|
+
this.write(mouse ? `${CSI}?1002h${CSI}?1006h${CSI}?1007h` : `${CSI}?1007l${CSI}?1006l${CSI}?1002l`);
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* Install control-mode state: hide cursor, DECAWM off, DECSTBM scroll
|
|
1980
|
+
* region, stdin raw mode, SIGWINCH + SIGINT/SIGTERM hooks.
|
|
1981
|
+
*/
|
|
1982
|
+
start() {
|
|
1983
|
+
if (this.#started) return;
|
|
1984
|
+
this.#started = true;
|
|
1985
|
+
this.#writeProgress("progress", 0);
|
|
1986
|
+
this.#writeProgress("inactive");
|
|
1987
|
+
if (this.#altScreen) this.write(`${CSI}?1049h`);
|
|
1988
|
+
this.write(`${CSI}?25l${CSI}?7l${CSI}?2004h${CSI}?1004h`);
|
|
1989
|
+
if (this.#mouse) this.write(`${CSI}?1002h${CSI}?1006h${CSI}?1007h`);
|
|
1990
|
+
this.#writeProgress();
|
|
1991
|
+
if (this.#reserveBottom > 0) this.setScrollRegion(1, this.scrollBottom);
|
|
1992
|
+
if (this.#stdin?.isTTY && typeof this.#stdin.setRawMode === "function") {
|
|
1993
|
+
this.#prevRawMode = Boolean(this.#stdin.isRaw);
|
|
1994
|
+
this.#stdin.setRawMode(true);
|
|
1995
|
+
}
|
|
1996
|
+
process.on("SIGWINCH", this.#onResize);
|
|
1997
|
+
if (this.#hookSignals) {
|
|
1998
|
+
const bye = () => {
|
|
1999
|
+
this.stop();
|
|
2000
|
+
process.exit(0);
|
|
2001
|
+
};
|
|
2002
|
+
const onCrash = (err) => {
|
|
2003
|
+
this.stop();
|
|
2004
|
+
const insp = process.versions.bun ? Bun.inspect : inspect;
|
|
2005
|
+
process.stdout.write("", () => {
|
|
2006
|
+
process.stdout.write(insp(err, { colors: true }));
|
|
2007
|
+
});
|
|
2008
|
+
};
|
|
2009
|
+
process.once("uncaughtException", onCrash);
|
|
2010
|
+
process.once("SIGINT", bye);
|
|
2011
|
+
process.once("SIGTERM", bye);
|
|
2012
|
+
const onExit = () => this.stop();
|
|
2013
|
+
process.once("exit", onExit);
|
|
2014
|
+
this.#signalCleanup = () => {
|
|
2015
|
+
process.off("SIGINT", bye);
|
|
2016
|
+
process.off("SIGTERM", bye);
|
|
2017
|
+
process.off("exit", onExit);
|
|
2018
|
+
process.off("uncaughtException", onCrash);
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
deleteImages(opts = {}) {
|
|
2023
|
+
if (!this.#started) return;
|
|
2024
|
+
this.write(`\x1b_Ga=d,d=${opts.data ?? true ? "A" : "a"},q=2\x1b\\`);
|
|
2025
|
+
}
|
|
2026
|
+
/** Restore terminal modes, detach listeners. Idempotent. */
|
|
2027
|
+
stop() {
|
|
2028
|
+
if (!this.#started) return;
|
|
2029
|
+
try {
|
|
2030
|
+
this.deleteImages();
|
|
2031
|
+
this.setProgress();
|
|
2032
|
+
if (this.#reserveBottom > 0) this.clearScrollRegion();
|
|
2033
|
+
this.write(`${CSI}?1007l${CSI}?1006l${CSI}?1002l${CSI}?1000l${CSI}?1004l${CSI}?2004l${CSI}?7h${CSI}?25h`);
|
|
2034
|
+
if (this.#altScreen) this.write(`${CSI}?1049l`);
|
|
2035
|
+
} finally {
|
|
2036
|
+
this.#started = false;
|
|
2037
|
+
}
|
|
2038
|
+
if (this.#stdin?.isTTY && typeof this.#stdin.setRawMode === "function") {
|
|
2039
|
+
this.#stdin.setRawMode(this.#prevRawMode ?? false);
|
|
2040
|
+
this.#stdin.pause?.();
|
|
2041
|
+
}
|
|
2042
|
+
process.off("SIGWINCH", this.#onResize);
|
|
2043
|
+
this.#signalCleanup?.();
|
|
2044
|
+
this.#signalCleanup = void 0;
|
|
2045
|
+
}
|
|
2046
|
+
setProgress(state, value) {
|
|
2047
|
+
state ??= "inactive";
|
|
2048
|
+
const resetError = () => {
|
|
2049
|
+
this.#writeProgress("progress", 50);
|
|
2050
|
+
this.#writeProgress("loading");
|
|
2051
|
+
};
|
|
2052
|
+
if (this.#progress.type === "error" && state !== "error") resetError();
|
|
2053
|
+
if (state === "progress") this.#progress = {
|
|
2054
|
+
type: "progress",
|
|
2055
|
+
value: value ?? 0
|
|
2056
|
+
};
|
|
2057
|
+
else if (state === "error" || state === "paused") this.#progress = {
|
|
2058
|
+
type: state,
|
|
2059
|
+
value: value ?? this.#progress.value
|
|
2060
|
+
};
|
|
2061
|
+
else this.#progress = { type: state };
|
|
2062
|
+
this.#writeProgress();
|
|
2063
|
+
if (state === "inactive") {
|
|
2064
|
+
if (this.#progressInterval) clearInterval(this.#progressInterval);
|
|
2065
|
+
this.#progressInterval = void 0;
|
|
2066
|
+
} else if (!this.#progressInterval) {
|
|
2067
|
+
this.#progressInterval = setInterval(() => this.#writeProgress(), 100);
|
|
2068
|
+
this.#progressInterval.unref();
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
#writeProgress(state, value) {
|
|
2072
|
+
if (!this.#started) return;
|
|
2073
|
+
value ??= this.#progress.value === void 0 ? void 0 : Math.max(0, Math.min(100, Math.round(this.#progress.value)));
|
|
2074
|
+
const seq = `\x1b]9;4;${progressStates[state ?? this.#progress.type]}${typeof value === "number" ? `;${value}` : ""}${ST}`;
|
|
2075
|
+
this.write(seq);
|
|
2076
|
+
}
|
|
2077
|
+
/** Change the bottom reservation and re-apply the scroll region. */
|
|
2078
|
+
setReserveBottom(rows) {
|
|
2079
|
+
const next = Math.max(0, Math.min(rows, this.rows - 1));
|
|
2080
|
+
if (next === this.#reserveBottom) return;
|
|
2081
|
+
this.#reserveBottom = next;
|
|
2082
|
+
if (!this.#started) return;
|
|
2083
|
+
if (next > 0) this.setScrollRegion(1, this.scrollBottom);
|
|
2084
|
+
else this.clearScrollRegion();
|
|
2085
|
+
}
|
|
2086
|
+
/** Low-level write. Use escape helpers + this to emit sequences. */
|
|
2087
|
+
write(s) {
|
|
2088
|
+
if (!this.#started) return;
|
|
2089
|
+
this.#stdout.write(s);
|
|
2090
|
+
}
|
|
2091
|
+
#write(s) {
|
|
2092
|
+
this.#stdout.write(s);
|
|
2093
|
+
}
|
|
2094
|
+
#pendingTransmits = [];
|
|
2095
|
+
/** Queue a side-channel ANSI payload (KGP transmit, etc.) for the
|
|
2096
|
+
* next `flushTransmits()`. No-op for empty strings. */
|
|
2097
|
+
enqueueTransmit(seq) {
|
|
2098
|
+
if (seq) this.#pendingTransmits.push(seq);
|
|
2099
|
+
}
|
|
2100
|
+
/** Drain the transmit queue. Called by the renderer at the start of
|
|
2101
|
+
* each paint, before frame composition. Idempotent when empty. */
|
|
2102
|
+
flushTransmits() {
|
|
2103
|
+
if (this.#pendingTransmits.length === 0) return;
|
|
2104
|
+
this.#stdout.write(this.#pendingTransmits.join(""));
|
|
2105
|
+
this.#pendingTransmits.length = 0;
|
|
2106
|
+
}
|
|
2107
|
+
/** Absolute cursor move (1-based). */
|
|
2108
|
+
moveTo(row, col = 1) {
|
|
2109
|
+
return `${CSI}${row};${col}H`;
|
|
2110
|
+
}
|
|
2111
|
+
/** Erase the entire line the cursor is on. */
|
|
2112
|
+
clearLine() {
|
|
2113
|
+
return `${CSI}2K`;
|
|
2114
|
+
}
|
|
2115
|
+
/** Clear from cursor to end of screen. */
|
|
2116
|
+
clearBelow() {
|
|
2117
|
+
return `${CSI}0J`;
|
|
2118
|
+
}
|
|
2119
|
+
/** Scroll the scroll region up by `n` rows (SU). Top rows enter scrollback. */
|
|
2120
|
+
scrollUp(n) {
|
|
2121
|
+
return n > 0 ? `${CSI}${n}S` : "";
|
|
2122
|
+
}
|
|
2123
|
+
/**
|
|
2124
|
+
* Scroll the scroll region down by `n` rows (SD). Top `n` rows become
|
|
2125
|
+
* blank, existing content shifts down, bottom `n` rows fall off the
|
|
2126
|
+
* region (they don't move to scrollback — scrollback is a top-side
|
|
2127
|
+
* concept; SD's displaced rows are simply lost).
|
|
2128
|
+
*/
|
|
2129
|
+
scrollDown(n) {
|
|
2130
|
+
return n > 0 ? `${CSI}${n}T` : "";
|
|
2131
|
+
}
|
|
2132
|
+
/** Delete `n` lines at the cursor, pulling content below upward (DL). */
|
|
2133
|
+
deleteLines(n) {
|
|
2134
|
+
return n > 0 ? `${CSI}${n}M` : "";
|
|
2135
|
+
}
|
|
2136
|
+
/** Set the scroll region (DECSTBM), 1-based inclusive. */
|
|
2137
|
+
setScrollRegion(top, bottom) {
|
|
2138
|
+
this.write(`${CSI}${top};${bottom}r`);
|
|
2139
|
+
}
|
|
2140
|
+
/** Reset to the full-viewport scroll region. */
|
|
2141
|
+
clearScrollRegion() {
|
|
2142
|
+
this.write(`${CSI}r`);
|
|
2143
|
+
}
|
|
2144
|
+
/**
|
|
2145
|
+
* Wrap a block of writes in begin/end-synchronized-update. Supporting
|
|
2146
|
+
* terminals buffer the enclosed bytes and paint atomically — eliminates
|
|
2147
|
+
* flicker on multi-row diff rewrites. Unsupported terminals ignore
|
|
2148
|
+
* both escapes, so there's no downside.
|
|
2149
|
+
*/
|
|
2150
|
+
sync(fn) {
|
|
2151
|
+
if (!this.#started) return;
|
|
2152
|
+
this.write(`${CSI}?2026h`);
|
|
2153
|
+
try {
|
|
2154
|
+
fn();
|
|
2155
|
+
} finally {
|
|
2156
|
+
this.#write(`${CSI}?2026l`);
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
onResize(fn) {
|
|
2160
|
+
this.#resizeListeners.add(fn);
|
|
2161
|
+
return () => this.#resizeListeners.delete(fn);
|
|
2162
|
+
}
|
|
2163
|
+
};
|
|
2164
|
+
//#endregion
|
|
2165
|
+
//#region src/renderer/ui.ts
|
|
2166
|
+
/**
|
|
2167
|
+
* UI surface — the sticky footer. Renders a single Box tree at the
|
|
2168
|
+
* bottom of the terminal, inside the rows reserved by `Terminal`'s
|
|
2169
|
+
* scroll region. Row-diff redraws: only changed rows are rewritten on
|
|
2170
|
+
* each flush, so an input widget re-rendering every keystroke doesn't
|
|
2171
|
+
* repaint the entire footer.
|
|
2172
|
+
*
|
|
2173
|
+
* Height is determined by the root's rendered row count, capped to a
|
|
2174
|
+
* sensible upper bound so a runaway render can't take over the screen.
|
|
2175
|
+
* When the height changes the terminal's reserved bottom gets updated,
|
|
2176
|
+
* which rewrites `DECSTBM`; the stream surface then has less/more room
|
|
2177
|
+
* and its next flush naturally fills the new geometry.
|
|
2178
|
+
*/
|
|
2179
|
+
var UI = class extends Surface {
|
|
2180
|
+
type = "ui";
|
|
2181
|
+
#root = box({ flexDirection: "column" });
|
|
2182
|
+
#rows = [];
|
|
2183
|
+
constructor(renderer) {
|
|
2184
|
+
super(renderer);
|
|
2185
|
+
this.#root.on("invalidate", this.invalidate);
|
|
2186
|
+
}
|
|
2187
|
+
/** The footer's root Box. Add children via `ui.root.add(child)`. */
|
|
2188
|
+
get root() {
|
|
2189
|
+
return this.#root;
|
|
2190
|
+
}
|
|
2191
|
+
get terminal() {
|
|
2192
|
+
return this.$r.terminal;
|
|
2193
|
+
}
|
|
2194
|
+
/** Shortcut: add a child to the footer root. Same semantics as
|
|
2195
|
+
* `ui.root.add(child)`; returns `this` for chaining.
|
|
2196
|
+
*
|
|
2197
|
+
* Function form: `add(() => box(…))` runs the function inside a
|
|
2198
|
+
* fresh Owner scope (so `signal` / `effect` / `onCleanup` /
|
|
2199
|
+
* `provideContext` inside `fn` attach to that scope) and adds the
|
|
2200
|
+
* returned Node. The Owner disposes when the Node unmounts. */
|
|
2201
|
+
add(child) {
|
|
2202
|
+
const ret = withOwner(this.$r.rootOwner, () => createNode(child));
|
|
2203
|
+
this.#root.add(ret);
|
|
2204
|
+
return ret;
|
|
2205
|
+
}
|
|
2206
|
+
/** Current rendered height (rows). */
|
|
2207
|
+
get height() {
|
|
2208
|
+
return this.#rows.length;
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* The rows currently painted into the footer region, in order from the
|
|
2212
|
+
* top of the reserved area downward. Exposed so the overlay surface
|
|
2213
|
+
* can re-emit this content after it tears down, without re-rendering
|
|
2214
|
+
* the tree.
|
|
2215
|
+
*/
|
|
2216
|
+
get rows() {
|
|
2217
|
+
return this.#rows;
|
|
2218
|
+
}
|
|
2219
|
+
/**
|
|
2220
|
+
* Render the footer tree and paint the diff against the last paint.
|
|
2221
|
+
* Updates the terminal's reservation whenever the height changes so
|
|
2222
|
+
* the scroll region stays correctly sized.
|
|
2223
|
+
*
|
|
2224
|
+
* When called from `Renderer.render()`, a capture-style `sync` is
|
|
2225
|
+
* provided so all surfaces paint inside one atomic sync frame. Direct
|
|
2226
|
+
* callers (tests) omit it and get an immediate `terminal.sync`.
|
|
2227
|
+
*/
|
|
2228
|
+
async _render(frame) {
|
|
2229
|
+
const ctx = this.$r.ctx;
|
|
2230
|
+
const rendered = await this.#root.render({
|
|
2231
|
+
...ctx,
|
|
2232
|
+
width: this.terminal.cols
|
|
2233
|
+
});
|
|
2234
|
+
const visible = Math.max(0, this.terminal.rows - 1);
|
|
2235
|
+
const rows = rendered.length > visible ? rendered.slice(-visible) : rendered;
|
|
2236
|
+
const prevHeight = this.#rows.length;
|
|
2237
|
+
const nextHeight = rows.length;
|
|
2238
|
+
if (nextHeight !== this.terminal.reserveBottom) {
|
|
2239
|
+
frame.flush();
|
|
2240
|
+
frame.queue((terminal) => terminal.setReserveBottom(nextHeight));
|
|
2241
|
+
this.$r.stream.invalidate();
|
|
2242
|
+
}
|
|
2243
|
+
const top = this.terminal.rows - nextHeight + 1;
|
|
2244
|
+
const prevTop = this.terminal.rows - prevHeight + 1;
|
|
2245
|
+
for (let row = prevTop; row < top; row++) frame.clear(row);
|
|
2246
|
+
for (let i = 0; i < nextHeight; i++) frame.set(top + i, rows[i]);
|
|
2247
|
+
this.#rows = rows;
|
|
2248
|
+
}
|
|
2249
|
+
/**
|
|
2250
|
+
* Terminal was resized. Drop the painted-rows mirror so the next
|
|
2251
|
+
* render's grow/shrink math treats the footer as if it's just
|
|
2252
|
+
* appearing — correct after a screen clear, when the terminal no
|
|
2253
|
+
* longer holds any footer bytes. The scroll region gets re-established
|
|
2254
|
+
* through the normal `setReserveBottom` path inside `render()`.
|
|
2255
|
+
*/
|
|
2256
|
+
onResize() {
|
|
2257
|
+
this.#rows = [];
|
|
2258
|
+
this.invalidate();
|
|
2259
|
+
}
|
|
2260
|
+
/** UI's tracked node set is just the footer root. */
|
|
2261
|
+
get nodes() {
|
|
2262
|
+
return [this.#root];
|
|
2263
|
+
}
|
|
2264
|
+
mountAll(ctx) {
|
|
2265
|
+
if (!this.#root.mounted) this.#root.mount(ctx);
|
|
2266
|
+
}
|
|
2267
|
+
unmountAll() {
|
|
2268
|
+
if (this.#root.mounted) this.#root.unmount();
|
|
2269
|
+
}
|
|
2270
|
+
get bounds() {
|
|
2271
|
+
const top = this.terminal.footerTop || this.terminal.rows + 1;
|
|
2272
|
+
return {
|
|
2273
|
+
bottom: this.terminal.rows,
|
|
2274
|
+
top
|
|
2275
|
+
};
|
|
2276
|
+
}
|
|
2277
|
+
};
|
|
2278
|
+
//#endregion
|
|
2279
|
+
//#region src/renderer/renderer.ts
|
|
2280
|
+
var RenderStats = class {
|
|
2281
|
+
#stats = {};
|
|
2282
|
+
/** Increment a stat by `delta` (default: `1`). */
|
|
2283
|
+
inc(stat, delta = 1) {
|
|
2284
|
+
this.#stats[stat] = (this.#stats[stat] ?? 0) + delta;
|
|
2285
|
+
}
|
|
2286
|
+
set(stat, value) {
|
|
2287
|
+
this.#stats[stat] = value;
|
|
2288
|
+
}
|
|
2289
|
+
get() {
|
|
2290
|
+
return { ...this.#stats };
|
|
2291
|
+
}
|
|
2292
|
+
};
|
|
2293
|
+
/**
|
|
2294
|
+
* Wires a `Terminal` primitive with the `Stream` and `UI` surfaces.
|
|
2295
|
+
* Doesn't render anything until you either append to `renderer.stream`
|
|
2296
|
+
* or add children to `renderer.ui.root`.
|
|
2297
|
+
*
|
|
2298
|
+
* ```ts
|
|
2299
|
+
* const renderer = new Renderer()
|
|
2300
|
+
* renderer.start()
|
|
2301
|
+
* renderer.stream.append(text("hello"))
|
|
2302
|
+
* renderer.ui.root.add(input({ placeholder: "> " }))
|
|
2303
|
+
* // ... later
|
|
2304
|
+
* renderer.stop()
|
|
2305
|
+
* ```
|
|
2306
|
+
*/
|
|
2307
|
+
var Renderer = class extends Emitter {
|
|
2308
|
+
stream;
|
|
2309
|
+
ui;
|
|
2310
|
+
overlay;
|
|
2311
|
+
selection;
|
|
2312
|
+
terminal;
|
|
2313
|
+
input;
|
|
2314
|
+
frame;
|
|
2315
|
+
/** Always-on logger, auto-attached to `this.stream`. Calling
|
|
2316
|
+
* `renderer.log("msg")` logs at `"log"` level; level methods
|
|
2317
|
+
* (`renderer.log.error(...)` etc.) are also available. */
|
|
2318
|
+
logger;
|
|
2319
|
+
#decoder = new Decoder();
|
|
2320
|
+
#stdin;
|
|
2321
|
+
#theme = signal(void 0);
|
|
2322
|
+
/** Root Owner — every surface's function-shape `add` / `append` runs
|
|
2323
|
+
* its child under this Owner via `withOwner(#rootOwner, …)`, so
|
|
2324
|
+
* context values provided here (`RenderContext`, future global
|
|
2325
|
+
* scopes) are visible to every widget body in the tree. */
|
|
2326
|
+
#rootOwner;
|
|
2327
|
+
#running = false;
|
|
2328
|
+
#escTimer;
|
|
2329
|
+
#rendering;
|
|
2330
|
+
#dirty = false;
|
|
2331
|
+
#debug = false;
|
|
2332
|
+
/** Monotonic cache-key for `RenderCtx`. Bumped on any event that
|
|
2333
|
+
* invalidates every node cache in the tree (resize, theme swap). */
|
|
2334
|
+
#ctxVersion = 0;
|
|
2335
|
+
#ctx;
|
|
2336
|
+
queries;
|
|
2337
|
+
/** Runtime action registry (catalog + dispatcher). Populated with
|
|
2338
|
+
* `defaultActions` + `globalActions` at construction; apps extend
|
|
2339
|
+
* via `renderer.actions.register({ "app.foo": { ... } })`. */
|
|
2340
|
+
actions;
|
|
2341
|
+
stats = new RenderStats();
|
|
2342
|
+
/** Global action impls. Merged into the `actions` catalog on
|
|
2343
|
+
* construction so the ids declared here gain their keys and desc
|
|
2344
|
+
* from `defaultActions`. Using `satisfies` (rather than an explicit
|
|
2345
|
+
* type annotation) keeps the literal keys so `BuiltinAction` can
|
|
2346
|
+
* pull them out. */
|
|
2347
|
+
globalActions = { "global.quit": { fn: () => {
|
|
2348
|
+
this.stop();
|
|
2349
|
+
process.exit(0);
|
|
2350
|
+
} } };
|
|
2351
|
+
constructor(opts = {}) {
|
|
2352
|
+
super();
|
|
2353
|
+
this.terminal = new Terminal({
|
|
2354
|
+
altScreen: opts.altScreen,
|
|
2355
|
+
hookSignals: opts.hookSignals,
|
|
2356
|
+
mouse: opts.mouse,
|
|
2357
|
+
stdin: opts.stdin,
|
|
2358
|
+
stdout: opts.stdout
|
|
2359
|
+
});
|
|
2360
|
+
this.frame = new Frame(this.terminal);
|
|
2361
|
+
if (opts.theme) this.#theme.set(opts.theme);
|
|
2362
|
+
this.ui = new UI(this);
|
|
2363
|
+
this.stream = new Stream(this, { fixedFooterHeight: opts.fixedFooterHeight });
|
|
2364
|
+
this.overlay = new OverlaySurface(this);
|
|
2365
|
+
this.selection = new SelectionLayer(this);
|
|
2366
|
+
this.logger = opts.logger ?? new Logger({ name: "renderer" });
|
|
2367
|
+
this.input = new InputRouter(this.logger.child({ name: "input" }));
|
|
2368
|
+
this.queries = new TerminalQueries(this.input, this.terminal);
|
|
2369
|
+
this.actions = new Actions(this.logger.child({ name: "actions" }));
|
|
2370
|
+
this.#rootOwner = createRoot(() => {
|
|
2371
|
+
provideContext(RenderContext, {
|
|
2372
|
+
images: memo(() => unwrap(opts.images) ?? true),
|
|
2373
|
+
queries: this.queries,
|
|
2374
|
+
style: memo(() => styleBuilder(this.#theme.get())),
|
|
2375
|
+
theme: this.#theme.get
|
|
2376
|
+
});
|
|
2377
|
+
return useActiveOwner();
|
|
2378
|
+
});
|
|
2379
|
+
const reporter = new TuiReporter(opts.reporter);
|
|
2380
|
+
reporter.attach({ append: (node) => this.stream.append(node) });
|
|
2381
|
+
this.logger.attach("tui", reporter);
|
|
2382
|
+
this.actions.setTargetResolver(() => this.input.focused);
|
|
2383
|
+
this.input.setActions(this.actions);
|
|
2384
|
+
this.input.on("mouse", ({ event }) => {
|
|
2385
|
+
if (event.kind === "scroll") this.stream.scroll(event.deltaY);
|
|
2386
|
+
else this.selection.mouse(event);
|
|
2387
|
+
});
|
|
2388
|
+
this.input.on("key", ({ event }) => {
|
|
2389
|
+
if (event.name === "esc") this.selection.clear();
|
|
2390
|
+
});
|
|
2391
|
+
this.actions.register(defaultActions, { default: true });
|
|
2392
|
+
this.actions.register(this.globalActions, { default: true });
|
|
2393
|
+
this.on("dirty", () => untrack(() => this.#schedule()));
|
|
2394
|
+
this.terminal.onResize(() => {
|
|
2395
|
+
this.#ctxVersion++;
|
|
2396
|
+
this.#ctx = void 0;
|
|
2397
|
+
this.terminal.sync(() => {
|
|
2398
|
+
this.terminal.clearScrollRegion();
|
|
2399
|
+
this.terminal.write(`${this.terminal.moveTo(1, 1)}\x1b[2J`);
|
|
2400
|
+
if (this.terminal.reserveBottom > 0) this.terminal.setScrollRegion(1, this.terminal.scrollBottom);
|
|
2401
|
+
});
|
|
2402
|
+
this.frame.reset();
|
|
2403
|
+
this.stream.onResize();
|
|
2404
|
+
this.ui.onResize();
|
|
2405
|
+
});
|
|
2406
|
+
this.#stdin = opts.stdin ?? process.stdin;
|
|
2407
|
+
}
|
|
2408
|
+
/**
|
|
2409
|
+
* Shared `RenderCtx` for the current tick. Built on demand and
|
|
2410
|
+
* reused across every `Node.render(ctx)` call until the version
|
|
2411
|
+
* bumps (resize, theme swap). Nodes compare `ctx.version` against
|
|
2412
|
+
* their cached row's version — simple integer check, no hashing.
|
|
2413
|
+
*/
|
|
2414
|
+
get ctx() {
|
|
2415
|
+
if (this.#ctx === void 0 || this.#ctx.version !== this.#ctxVersion || this.#ctx.width !== this.terminal.cols) this.#ctx = createCtx({
|
|
2416
|
+
theme: peek(this.#theme.get),
|
|
2417
|
+
transmit: (seq) => this.terminal.enqueueTransmit(seq),
|
|
2418
|
+
version: this.#ctxVersion,
|
|
2419
|
+
width: this.terminal.cols
|
|
2420
|
+
});
|
|
2421
|
+
return this.#ctx;
|
|
2422
|
+
}
|
|
2423
|
+
get rootOwner() {
|
|
2424
|
+
return this.#rootOwner;
|
|
2425
|
+
}
|
|
2426
|
+
get mouse() {
|
|
2427
|
+
return this.terminal.mouse;
|
|
2428
|
+
}
|
|
2429
|
+
set mouse(mouse) {
|
|
2430
|
+
this.terminal.mouse = mouse;
|
|
2431
|
+
}
|
|
2432
|
+
get debug() {
|
|
2433
|
+
return this.#debug;
|
|
2434
|
+
}
|
|
2435
|
+
set debug(debug) {
|
|
2436
|
+
if (this.#debug === debug) return;
|
|
2437
|
+
this.#debug = debug;
|
|
2438
|
+
this.#schedule();
|
|
2439
|
+
}
|
|
2440
|
+
/** Whether the renderer is currently running. Surfaces use this to
|
|
2441
|
+
* decide whether a fresh append/open should immediately mount the
|
|
2442
|
+
* node or just queue it for the next `start()`. */
|
|
2443
|
+
get running() {
|
|
2444
|
+
return this.#running;
|
|
2445
|
+
}
|
|
2446
|
+
/** Shortcut for `renderer.input.bind(pattern, handler)`. Returns an
|
|
2447
|
+
* unsubscribe function. Use for one-off app-level bindings where a
|
|
2448
|
+
* named action would be overkill. */
|
|
2449
|
+
bind = (binding) => this.actions.bind(binding);
|
|
2450
|
+
/** Swap the ambient theme. Widgets that read `theme()` / `style()`
|
|
2451
|
+
* from `useContext(RenderContext)` re-fire automatically; primitive
|
|
2452
|
+
* Nodes that compare cache against `ctx.version` need the version
|
|
2453
|
+
* bump that already happens elsewhere on theme-driven invalidation. */
|
|
2454
|
+
set theme(theme) {
|
|
2455
|
+
this.#theme.set(theme);
|
|
2456
|
+
this.#ctxVersion++;
|
|
2457
|
+
this.stream.invalidate();
|
|
2458
|
+
this.ui.invalidate();
|
|
2459
|
+
this.overlay.invalidate();
|
|
2460
|
+
this.#schedule();
|
|
2461
|
+
}
|
|
2462
|
+
/** Get the current theme. (tracked) */
|
|
2463
|
+
get theme() {
|
|
2464
|
+
return this.#theme.get() ?? defaultTheme;
|
|
2465
|
+
}
|
|
2466
|
+
start() {
|
|
2467
|
+
if (this.#running) return;
|
|
2468
|
+
this.#running = true;
|
|
2469
|
+
this.terminal.start();
|
|
2470
|
+
this.#stdin.setEncoding?.("utf8");
|
|
2471
|
+
this.#stdin.on("data", this.#onData);
|
|
2472
|
+
this.#stdin.resume?.();
|
|
2473
|
+
this.emit("start");
|
|
2474
|
+
if (this.#dirty) this.#schedule();
|
|
2475
|
+
process.on("unhandledRejection", (reason) => this.logger.child("unhandledRejection").error("Unhandled Rejection:", reason));
|
|
2476
|
+
}
|
|
2477
|
+
stop() {
|
|
2478
|
+
if (!this.#running) return;
|
|
2479
|
+
this.emit("stop");
|
|
2480
|
+
this.#running = false;
|
|
2481
|
+
this.#stdin.off("data", this.#onData);
|
|
2482
|
+
this.#stdin.pause?.();
|
|
2483
|
+
if (this.#escTimer !== void 0) {
|
|
2484
|
+
clearTimeout(this.#escTimer);
|
|
2485
|
+
this.#escTimer = void 0;
|
|
2486
|
+
}
|
|
2487
|
+
this.terminal.stop();
|
|
2488
|
+
}
|
|
2489
|
+
/** Look up a node by its `id`. Returns `undefined` if nothing matches. */
|
|
2490
|
+
getNode(id) {
|
|
2491
|
+
let found;
|
|
2492
|
+
this.walk((n) => {
|
|
2493
|
+
if (n.id() !== id) return void 0;
|
|
2494
|
+
found = n;
|
|
2495
|
+
return "stop";
|
|
2496
|
+
});
|
|
2497
|
+
return found;
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* Find every node matching a predicate. When passed a string, it's
|
|
2501
|
+
* interpreted as `node.type === string` — the common "find all
|
|
2502
|
+
* Inputs" case. For anything richer, pass a function.
|
|
2503
|
+
*/
|
|
2504
|
+
findNode(match) {
|
|
2505
|
+
const pred = typeof match === "string" ? (n) => n.type === match : match;
|
|
2506
|
+
const out = [];
|
|
2507
|
+
this.walk((n) => {
|
|
2508
|
+
if (pred(n)) out.push(n);
|
|
2509
|
+
});
|
|
2510
|
+
return out;
|
|
2511
|
+
}
|
|
2512
|
+
/**
|
|
2513
|
+
* Walk every node in the combined stream + UI tree, depth-first,
|
|
2514
|
+
* parents before children. Return `"stop"` from the visitor to halt.
|
|
2515
|
+
*/
|
|
2516
|
+
walk(visit) {
|
|
2517
|
+
const stopped = { v: false };
|
|
2518
|
+
const visitNode = (n) => {
|
|
2519
|
+
if (stopped.v) return;
|
|
2520
|
+
if (visit(n) === "stop") {
|
|
2521
|
+
stopped.v = true;
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
for (const c of n.children) visitNode(c);
|
|
2525
|
+
};
|
|
2526
|
+
visitNode(this.ui.root);
|
|
2527
|
+
for (const n of this.stream.nodes) visitNode(n);
|
|
2528
|
+
for (const n of this.overlay.nodes) visitNode(n);
|
|
2529
|
+
}
|
|
2530
|
+
/** Build a MountCtx for a given surface. Each call creates a fresh
|
|
2531
|
+
* object but closes over the same underlying services, so nodes
|
|
2532
|
+
* across surfaces share a single overlay surface, router, etc. */
|
|
2533
|
+
mountCtx(surface) {
|
|
2534
|
+
const logger = this.logger.child({
|
|
2535
|
+
name: surface,
|
|
2536
|
+
surface
|
|
2537
|
+
});
|
|
2538
|
+
const input = this.input;
|
|
2539
|
+
return {
|
|
2540
|
+
actions: this.actions,
|
|
2541
|
+
findNode: (m) => this.findNode(m),
|
|
2542
|
+
getNode: (id) => this.getNode(id),
|
|
2543
|
+
input: {
|
|
2544
|
+
bind: (binding) => this.actions.bind(binding),
|
|
2545
|
+
blur: (node) => this.input.blur(node),
|
|
2546
|
+
events: this.input,
|
|
2547
|
+
focus: (node) => this.input.focus(node),
|
|
2548
|
+
queries: this.queries,
|
|
2549
|
+
get terminalFocus() {
|
|
2550
|
+
return input.terminalFocus;
|
|
2551
|
+
}
|
|
2552
|
+
},
|
|
2553
|
+
logger,
|
|
2554
|
+
overlay: {
|
|
2555
|
+
add: (o) => this.overlay.add(o),
|
|
2556
|
+
remove: (o) => this.overlay.remove(o)
|
|
2557
|
+
},
|
|
2558
|
+
surface
|
|
2559
|
+
};
|
|
2560
|
+
}
|
|
2561
|
+
/** Mark dirty and ensure the render loop is active. Cheap and
|
|
2562
|
+
* idempotent — synchronous invalidations within the same tick all
|
|
2563
|
+
* collapse into one paint via the microtask boundary at the top of
|
|
2564
|
+
* the loop. */
|
|
2565
|
+
#schedule() {
|
|
2566
|
+
this.#dirty = true;
|
|
2567
|
+
if (!this.#running) return;
|
|
2568
|
+
if (this.#rendering !== void 0) return;
|
|
2569
|
+
this.#rendering = (async () => {
|
|
2570
|
+
try {
|
|
2571
|
+
await Promise.resolve();
|
|
2572
|
+
while (this.#dirty && this.#running) {
|
|
2573
|
+
this.#dirty = false;
|
|
2574
|
+
try {
|
|
2575
|
+
this.stats.inc("render");
|
|
2576
|
+
await this.#render();
|
|
2577
|
+
} catch (error) {
|
|
2578
|
+
this.#running = false;
|
|
2579
|
+
this.logger.child("render").error(error);
|
|
2580
|
+
process.nextTick(() => {
|
|
2581
|
+
throw error;
|
|
2582
|
+
});
|
|
2583
|
+
return;
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
} finally {
|
|
2587
|
+
this.#rendering = void 0;
|
|
2588
|
+
}
|
|
2589
|
+
})();
|
|
2590
|
+
}
|
|
2591
|
+
/** Force a paint if dirty, and wait until the loop is idle. Useful
|
|
2592
|
+
* for tests and external "redraw and tell me when done" flows.
|
|
2593
|
+
* Concurrent callers share the same in-flight promise — no extra
|
|
2594
|
+
* paints are queued. */
|
|
2595
|
+
async render() {
|
|
2596
|
+
if (!this.#running) return;
|
|
2597
|
+
this.#schedule();
|
|
2598
|
+
if (this.#rendering !== void 0) await this.#rendering;
|
|
2599
|
+
}
|
|
2600
|
+
/** Render dirty surfaces into a shared full-viewport frame, then flush
|
|
2601
|
+
* queued terminal ops and final row diffs in one atomic terminal sync. */
|
|
2602
|
+
async #render() {
|
|
2603
|
+
const frame = this.frame.begin();
|
|
2604
|
+
const render = async (surface) => {
|
|
2605
|
+
if (!surface.dirty) return;
|
|
2606
|
+
await surface.render(frame);
|
|
2607
|
+
};
|
|
2608
|
+
await render(this.stream);
|
|
2609
|
+
await render(this.ui);
|
|
2610
|
+
frame.commitBase();
|
|
2611
|
+
this.selection.renderStream(frame, this.ctx);
|
|
2612
|
+
await render(this.overlay);
|
|
2613
|
+
if (this.#debug) this.#renderDebug(frame);
|
|
2614
|
+
this.selection.renderScreen(frame, this.ctx);
|
|
2615
|
+
this.terminal.flushTransmits();
|
|
2616
|
+
this.terminal.sync(() => {
|
|
2617
|
+
const s = frame.paint();
|
|
2618
|
+
this.stats.inc("paint-ops", s.ops);
|
|
2619
|
+
this.stats.inc("paint-rows", s.rows);
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
#renderDebug(frame) {
|
|
2623
|
+
const stats = this.stats.get();
|
|
2624
|
+
const rows = Object.entries(stats).toSorted(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}: ${value}`);
|
|
2625
|
+
rows.unshift(`debug: ${(/* @__PURE__ */ new Date()).toLocaleTimeString()}`);
|
|
2626
|
+
const width = Math.min(this.terminal.cols, Math.max(...rows.map((row) => stringWidth(row)), 0));
|
|
2627
|
+
const x = Math.max(1, this.terminal.cols - width + 1);
|
|
2628
|
+
for (let i = 0; i < rows.length && i < this.terminal.rows; i++) frame.overlay(i + 1, x, rows[i]);
|
|
2629
|
+
}
|
|
2630
|
+
#onData = (chunk) => {
|
|
2631
|
+
const text = typeof chunk === "string" ? chunk : String(chunk);
|
|
2632
|
+
for (const ev of this.#decoder.feed(text)) this.input.dispatch(ev);
|
|
2633
|
+
if (this.#escTimer !== void 0) clearTimeout(this.#escTimer);
|
|
2634
|
+
this.#escTimer = setTimeout(() => {
|
|
2635
|
+
this.#escTimer = void 0;
|
|
2636
|
+
for (const ev of this.#decoder.flush()) this.input.dispatch(ev);
|
|
2637
|
+
}, 25);
|
|
2638
|
+
this.#escTimer.unref();
|
|
2639
|
+
};
|
|
2640
|
+
};
|
|
2641
|
+
//#endregion
|
|
2642
|
+
export { Renderer };
|
|
2643
|
+
|
|
2644
|
+
//# sourceMappingURL=renderer-CxL4xbkl.mjs.map
|