@fresh-editor/fresh-editor 0.3.4 → 0.3.6
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/CHANGELOG.md +72 -0
- package/README.md +9 -2
- package/package.json +1 -1
- package/plugins/config-schema.json +7 -1
- package/plugins/dashboard.ts +16 -93
- package/plugins/git_grep.ts +3 -1
- package/plugins/git_log.ts +196 -224
- package/plugins/goto_with_selection.i18n.json +58 -0
- package/plugins/goto_with_selection.ts +17 -0
- package/plugins/lib/finder.ts +27 -6
- package/plugins/lib/fresh.d.ts +620 -14
- package/plugins/lib/index.ts +34 -0
- package/plugins/lib/widgets.ts +796 -0
- package/plugins/live_diff.ts +324 -29
- package/plugins/live_grep.ts +114 -48
- package/plugins/orchestrator.ts +1685 -0
- package/plugins/pkg.ts +234 -53
- package/plugins/rust-lsp.ts +58 -40
- package/plugins/schemas/theme.schema.json +4 -0
- package/plugins/search_replace.ts +780 -517
- package/plugins/theme_editor.i18n.json +84 -0
- package/plugins/theme_editor.ts +30 -5
- package/plugins/tsconfig.json +2 -0
- package/plugins/vi_mode.ts +38 -17
- package/themes/terminal.json +3 -0
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin widget library — declarative UI for Fresh plugins.
|
|
3
|
+
*
|
|
4
|
+
* Plugins describe panel content as a `WidgetSpec` tree. The host owns
|
|
5
|
+
* rendering, theming, and (in later phases) hit-testing, focus, and
|
|
6
|
+
* keymaps. This module provides:
|
|
7
|
+
*
|
|
8
|
+
* - Type re-exports from the generated `fresh.d.ts` so plugins import
|
|
9
|
+
* `WidgetSpec` / `HintEntry` from one place.
|
|
10
|
+
* - Builder helpers (`row`, `col`, `hintBar`, `raw`) that produce the
|
|
11
|
+
* correct discriminated-union shape.
|
|
12
|
+
* - A `WidgetPanel` class that wraps the
|
|
13
|
+
* `mountWidgetPanel` / `updateWidgetPanel` / `unmountWidgetPanel`
|
|
14
|
+
* IPC trio with mount-once-then-update semantics.
|
|
15
|
+
* - `parseHintString(s)` — parses the legacy `Tab:section Esc:close`
|
|
16
|
+
* string format used by today's plugin i18n bundles into
|
|
17
|
+
* `HintEntry[]`.
|
|
18
|
+
*
|
|
19
|
+
* See `docs/internal/plugin-widget-library-design.md`.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* import { WidgetPanel, hintBar, col, raw, parseHintString } from "./lib/widgets.ts";
|
|
23
|
+
*
|
|
24
|
+
* const panel = new WidgetPanel(bufferId);
|
|
25
|
+
* panel.set(col(
|
|
26
|
+
* raw(myExistingEntries),
|
|
27
|
+
* hintBar(parseHintString(editor.t("panel.help"))),
|
|
28
|
+
* ));
|
|
29
|
+
* // …later, on every state change:
|
|
30
|
+
* panel.set(col(raw(newEntries), hintBar(myHints)));
|
|
31
|
+
* // …on close:
|
|
32
|
+
* panel.unmount();
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/// <reference path="./fresh.d.ts" />
|
|
36
|
+
|
|
37
|
+
// `fresh.d.ts` declares HintEntry / WidgetSpec / TextPropertyEntry as
|
|
38
|
+
// ambient globals (it is not an ES module). Re-export the relevant
|
|
39
|
+
// type names locally so plugin code can write
|
|
40
|
+
// `import type { WidgetSpec } from "./lib/widgets.ts"` without dipping
|
|
41
|
+
// into the ambient namespace directly.
|
|
42
|
+
export type WidgetSpec = globalThis.WidgetSpec;
|
|
43
|
+
export type HintEntry = globalThis.HintEntry;
|
|
44
|
+
export type ButtonKind = globalThis.ButtonKind;
|
|
45
|
+
export type WidgetAction = globalThis.WidgetAction;
|
|
46
|
+
export type WidgetMutation = globalThis.WidgetMutation;
|
|
47
|
+
export type TreeNode = globalThis.TreeNode;
|
|
48
|
+
export type StyledSegment = globalThis.StyledSegment;
|
|
49
|
+
type TextPropertyEntry = globalThis.TextPropertyEntry;
|
|
50
|
+
type InlineOverlay = globalThis.InlineOverlay;
|
|
51
|
+
type OverlayOptions = globalThis.OverlayOptions;
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// Builder helpers — preferred over hand-writing `{ kind: "row", ... }`.
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
/** Horizontal layout. Children laid out left-to-right; inline-sized
|
|
58
|
+
* children collapse into a single line. See §3 of the design doc. */
|
|
59
|
+
export function row(...children: WidgetSpec[]): WidgetSpec {
|
|
60
|
+
return { kind: "row", children };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Vertical layout. Children stacked top-to-bottom. */
|
|
64
|
+
export function col(...children: WidgetSpec[]): WidgetSpec {
|
|
65
|
+
return { kind: "col", children };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Keyboard-hint footer. Renders `<keys> <label>` per entry, with the
|
|
69
|
+
* keys portion styled by the `ui.help_key_fg` theme key.
|
|
70
|
+
*
|
|
71
|
+
* Replaces the per-plugin hand-rolled help row. */
|
|
72
|
+
export function hintBar(entries: HintEntry[]): WidgetSpec {
|
|
73
|
+
return { kind: "hintBar", entries };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Imperative-virtual-buffer escape hatch. Wraps an existing
|
|
77
|
+
* `TextPropertyEntry[]` (the same shape `setVirtualBufferContent`
|
|
78
|
+
* already accepts) so a plugin can migrate its panel one widget at a
|
|
79
|
+
* time. */
|
|
80
|
+
export function raw(entries: TextPropertyEntry[]): WidgetSpec {
|
|
81
|
+
return { kind: "raw", entries };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Build a `TextPropertyEntry` from a sequence of styled segments.
|
|
85
|
+
*
|
|
86
|
+
* The plugin describes row content structurally — each segment is a
|
|
87
|
+
* piece of text plus optional `style` and optional nested
|
|
88
|
+
* `overlays`. The host concatenates the segments and emits one
|
|
89
|
+
* inline overlay per styled segment plus the segment's nested
|
|
90
|
+
* overlays shifted by the segment's start position; both happen in
|
|
91
|
+
* Rust against the final text, so the plugin never names byte or
|
|
92
|
+
* codepoint offsets between segments.
|
|
93
|
+
*
|
|
94
|
+
* Use `padToChars` / `truncateToChars` to constrain the entry's
|
|
95
|
+
* total width — both are applied AFTER segment concatenation (so
|
|
96
|
+
* `padToChars: 80` pads the full row to 80 codepoints, regardless
|
|
97
|
+
* of how the segments split it).
|
|
98
|
+
*
|
|
99
|
+
* For freeform overlays inside a single segment (e.g. highlighting
|
|
100
|
+
* pattern matches inside a context string), pass them via the
|
|
101
|
+
* segment's `overlays` field with `unit: "char"`. */
|
|
102
|
+
export function styledRow(
|
|
103
|
+
segments: StyledSegment[],
|
|
104
|
+
options?: {
|
|
105
|
+
padToChars?: number;
|
|
106
|
+
truncateToChars?: number;
|
|
107
|
+
properties?: Record<string, unknown>;
|
|
108
|
+
style?: Partial<OverlayOptions>;
|
|
109
|
+
inlineOverlays?: InlineOverlay[];
|
|
110
|
+
},
|
|
111
|
+
): TextPropertyEntry {
|
|
112
|
+
// Build the entry by spreading only set fields. The plugin
|
|
113
|
+
// bridge converts JS `undefined` to JSON `null` when an object
|
|
114
|
+
// key is present, which then fails to deserialize as the
|
|
115
|
+
// matching `Option<…>` / `Vec<…>` field on the host. Omitting
|
|
116
|
+
// the key entirely lets serde fall back to `#[serde(default)]`.
|
|
117
|
+
const entry: TextPropertyEntry = { text: "", segments };
|
|
118
|
+
if (options?.padToChars !== undefined) entry.padToChars = options.padToChars;
|
|
119
|
+
if (options?.truncateToChars !== undefined) entry.truncateToChars = options.truncateToChars;
|
|
120
|
+
if (options?.properties !== undefined) entry.properties = options.properties;
|
|
121
|
+
if (options?.style !== undefined) entry.style = options.style;
|
|
122
|
+
if (options?.inlineOverlays !== undefined) entry.inlineOverlays = options.inlineOverlays;
|
|
123
|
+
return entry;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Boolean toggle, rendered as `[v] label` / `[ ] label`.
|
|
127
|
+
* Pass `focused: true` to highlight (the host will own focus once
|
|
128
|
+
* the keymap layer is wired). */
|
|
129
|
+
export function toggle(
|
|
130
|
+
checked: boolean,
|
|
131
|
+
label: string,
|
|
132
|
+
options?: { focused?: boolean; key?: string },
|
|
133
|
+
): WidgetSpec {
|
|
134
|
+
return {
|
|
135
|
+
kind: "toggle",
|
|
136
|
+
checked,
|
|
137
|
+
label,
|
|
138
|
+
focused: options?.focused ?? false,
|
|
139
|
+
key: options?.key,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Action button, rendered as `[ Label ]`. `intent` controls visual
|
|
144
|
+
* emphasis: `"normal"` (default) → no override, `"primary"` → bold,
|
|
145
|
+
* `"danger"` → error theme key. */
|
|
146
|
+
export function button(
|
|
147
|
+
label: string,
|
|
148
|
+
options?: {
|
|
149
|
+
focused?: boolean;
|
|
150
|
+
intent?: ButtonKind;
|
|
151
|
+
key?: string;
|
|
152
|
+
},
|
|
153
|
+
): WidgetSpec {
|
|
154
|
+
return {
|
|
155
|
+
kind: "button",
|
|
156
|
+
label,
|
|
157
|
+
focused: options?.focused ?? false,
|
|
158
|
+
intent: options?.intent ?? "normal",
|
|
159
|
+
key: options?.key,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Horizontal spacer of fixed column count. In a `Row` it produces
|
|
164
|
+
* `cols` spaces; at the top level or in a `Col` it produces a
|
|
165
|
+
* short blank line. */
|
|
166
|
+
export function spacer(cols: number, key?: string): WidgetSpec {
|
|
167
|
+
return { kind: "spacer", cols, flex: false, key };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Flex horizontal spacer — fills remaining row width
|
|
171
|
+
* (`panel_width - sum(non-flex children)`). Use to right-align a
|
|
172
|
+
* trailing widget: `row(label, flexSpacer(), button)`. With
|
|
173
|
+
* multiple flex spacers in one row the leftover splits evenly. */
|
|
174
|
+
export function flexSpacer(key?: string): WidgetSpec {
|
|
175
|
+
return { kind: "spacer", cols: 0, flex: true, key };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Vertical list of pre-rendered rows with host-managed selection
|
|
179
|
+
* styling, click routing, and **virtual scrolling**. Plugin passes
|
|
180
|
+
* the full dataset of items + a `visibleRows` count; the widget
|
|
181
|
+
* owns scroll offset as instance state (keyed by `key`) and
|
|
182
|
+
* auto-clamps it to keep `selectedIndex` in view. Plugins never
|
|
183
|
+
* compute scroll math.
|
|
184
|
+
*
|
|
185
|
+
* Click on a row fires `widget_event` with `eventType: "select"` and
|
|
186
|
+
* `payload: { index, key }` where `index` is the *absolute* index
|
|
187
|
+
* into `items` (not the visible-window index).
|
|
188
|
+
*
|
|
189
|
+
* `key` is required for any List that should preserve scroll across
|
|
190
|
+
* re-renders. Lists without a key reset to scroll=0 each render. */
|
|
191
|
+
export function list(options: {
|
|
192
|
+
items: TextPropertyEntry[];
|
|
193
|
+
itemKeys?: string[];
|
|
194
|
+
selectedIndex?: number;
|
|
195
|
+
visibleRows: number;
|
|
196
|
+
/** Whether Tab / Shift+Tab lands focus on this list. Default
|
|
197
|
+
* true (matches other tabbable widgets). Set to false in
|
|
198
|
+
* picker-style layouts where the filter input stays focused
|
|
199
|
+
* and Up/Down forward to the list via host smart-keys —
|
|
200
|
+
* skipping the list in the Tab cycle keeps focus jumping
|
|
201
|
+
* straight between filter and action buttons. */
|
|
202
|
+
focusable?: boolean;
|
|
203
|
+
key?: string;
|
|
204
|
+
}): WidgetSpec {
|
|
205
|
+
return {
|
|
206
|
+
kind: "list",
|
|
207
|
+
items: options.items,
|
|
208
|
+
itemKeys: options.itemKeys ?? [],
|
|
209
|
+
selectedIndex: options.selectedIndex ?? -1,
|
|
210
|
+
visibleRows: options.visibleRows,
|
|
211
|
+
focusable: options.focusable ?? true,
|
|
212
|
+
key: options.key,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Construct one node in a `Tree` widget's flat-list spec. The
|
|
217
|
+
* plugin emits a depth-first traversal of its hierarchy, one
|
|
218
|
+
* `treeNode(...)` per node, plus a parallel `itemKeys` array for
|
|
219
|
+
* stable per-row identifiers. `depth` controls indent (`depth * 2`
|
|
220
|
+
* spaces); `hasChildren: true` renders a disclosure glyph (`▶`/`▼`)
|
|
221
|
+
* with a click-to-expand hit area in the indent column. The host
|
|
222
|
+
* filters out descendants of collapsed nodes when rendering. */
|
|
223
|
+
export function treeNode(
|
|
224
|
+
text: TextPropertyEntry,
|
|
225
|
+
options?: { depth?: number; hasChildren?: boolean; checked?: boolean },
|
|
226
|
+
): TreeNode {
|
|
227
|
+
// `checked` is intentionally Optional<bool>, not a default-false
|
|
228
|
+
// boolean: omitting it (== undefined here) maps to host-side
|
|
229
|
+
// `None`, which means "no checkbox glyph". Per-node opt-in keeps
|
|
230
|
+
// checkable trees mixing checkbox-bearing rows with rows that
|
|
231
|
+
// shouldn't render one (e.g. a header that doesn't itself have
|
|
232
|
+
// a meaningful checked state).
|
|
233
|
+
const node: TreeNode = {
|
|
234
|
+
text,
|
|
235
|
+
depth: options?.depth ?? 0,
|
|
236
|
+
hasChildren: options?.hasChildren ?? false,
|
|
237
|
+
};
|
|
238
|
+
if (options?.checked !== undefined) {
|
|
239
|
+
node.checked = options.checked;
|
|
240
|
+
}
|
|
241
|
+
return node;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Hierarchical tree with host-managed expand/collapse, selection,
|
|
245
|
+
* scrolling, and click routing.
|
|
246
|
+
*
|
|
247
|
+
* The plugin emits its hierarchy as a flat list of `TreeNode`s
|
|
248
|
+
* (depth-first); the host filters out descendants of collapsed
|
|
249
|
+
* nodes at render time. **Toggling expansion is host-owned** —
|
|
250
|
+
* `Right`/`Left` arrow keys and disclosure clicks update host
|
|
251
|
+
* instance state without the plugin re-emitting. Plugins that
|
|
252
|
+
* need to react to expansion changes listen for
|
|
253
|
+
* `widget_event` `eventType: "expand"`.
|
|
254
|
+
*
|
|
255
|
+
* Click on the disclosure column → `expand` event. Click on the
|
|
256
|
+
* row body → `select` event. Enter/Space on the focused tree →
|
|
257
|
+
* `activate` event with the currently-selected node. Up/Down move
|
|
258
|
+
* selection through the visible (un-collapsed) flat list.
|
|
259
|
+
*
|
|
260
|
+
* `key` is required for any Tree that should preserve scroll +
|
|
261
|
+
* selection + expansion across re-renders. */
|
|
262
|
+
export function tree(options: {
|
|
263
|
+
nodes: TreeNode[];
|
|
264
|
+
itemKeys?: string[];
|
|
265
|
+
selectedIndex?: number;
|
|
266
|
+
visibleRows: number;
|
|
267
|
+
/** Initial expanded keys; subsequent expansion changes are
|
|
268
|
+
* host-owned and don't read this field. Use
|
|
269
|
+
* `panel.setExpandedKeys(...)` to override host state after
|
|
270
|
+
* mount. */
|
|
271
|
+
expandedKeys?: string[];
|
|
272
|
+
/** When true, every node with `checked: true | false` renders
|
|
273
|
+
* a `[v]` / `[ ]` glyph and emits a `toggle` hit area. Click on
|
|
274
|
+
* the glyph fires `widget_event` `eventType: "toggle"` with
|
|
275
|
+
* `payload: { key, index, checked: <new> }`; the plugin updates
|
|
276
|
+
* its model and pushes the new state back via
|
|
277
|
+
* `panel.setCheckedKeys(...)`. */
|
|
278
|
+
checkable?: boolean;
|
|
279
|
+
key?: string;
|
|
280
|
+
}): WidgetSpec {
|
|
281
|
+
return {
|
|
282
|
+
kind: "tree",
|
|
283
|
+
nodes: options.nodes,
|
|
284
|
+
itemKeys: options.itemKeys ?? [],
|
|
285
|
+
selectedIndex: options.selectedIndex ?? -1,
|
|
286
|
+
visibleRows: options.visibleRows,
|
|
287
|
+
expandedKeys: options.expandedKeys ?? [],
|
|
288
|
+
checkable: options.checkable ?? false,
|
|
289
|
+
key: options.key,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Text input — single-line (`rows: 1`, default) or multi-line
|
|
294
|
+
* (`rows >= 2`). The host owns `value` and `cursorByte` as instance
|
|
295
|
+
* state once the widget renders for the first time; multi-line
|
|
296
|
+
* widgets also own a vertical scroll offset.
|
|
297
|
+
*
|
|
298
|
+
* Single-line (`rows: 1`) renders as `[value]` (or `Label: [value]`
|
|
299
|
+
* if `label` is provided), with `fieldWidth` giving a constant
|
|
300
|
+
* visible width — short values pad with trailing spaces, long
|
|
301
|
+
* values head-truncate with `…` so the tail (where the cursor
|
|
302
|
+
* usually is) stays visible. Smart-key dispatch: `Enter` advances
|
|
303
|
+
* focus; `Up`/`Down` are no-ops; `Home`/`End` jump to the start /
|
|
304
|
+
* end of the whole value.
|
|
305
|
+
*
|
|
306
|
+
* Multi-line (`rows >= 2`) renders as a `rows`-tall block, padded
|
|
307
|
+
* with blanks when `value` is shorter. Smart-key dispatch differs:
|
|
308
|
+
* `Enter` inserts a newline; `Up`/`Down` move between lines;
|
|
309
|
+
* `Home`/`End` are line-relative; long lines tail-truncate with `…`
|
|
310
|
+
* per-line. Plugins that want `Enter` to submit can intercept the
|
|
311
|
+
* key in their own mode binding and call
|
|
312
|
+
* `panel.command(focusAdvance(1))` instead.
|
|
313
|
+
*
|
|
314
|
+
* `key` is required for any text widget that should preserve its
|
|
315
|
+
* value, cursor, and scroll across re-renders.
|
|
316
|
+
*
|
|
317
|
+
* Prefer the `textInput()` / `textArea()` helpers below when the
|
|
318
|
+
* intent is unambiguous — they call this with the right `rows`. */
|
|
319
|
+
export function text(
|
|
320
|
+
options: {
|
|
321
|
+
value?: string;
|
|
322
|
+
cursorByte?: number;
|
|
323
|
+
focused?: boolean;
|
|
324
|
+
label?: string;
|
|
325
|
+
placeholder?: string;
|
|
326
|
+
/** Number of visible rows of editing region. `1` (default) =
|
|
327
|
+
* single-line behaviour; `>= 2` = multi-line behaviour. */
|
|
328
|
+
rows?: number;
|
|
329
|
+
/** Visible column width. `0` (default) = auto-fit (single-line)
|
|
330
|
+
* or panel width (multi-line). */
|
|
331
|
+
fieldWidth?: number;
|
|
332
|
+
/** Single-line soft cap on visible chars after the
|
|
333
|
+
* `fieldWidth` pad. `0` = no cap. Ignored when `rows >= 2`. */
|
|
334
|
+
maxVisibleChars?: number;
|
|
335
|
+
/** Stretch the visible field to fill the enclosing
|
|
336
|
+
* container's width. Overrides `fieldWidth` when set:
|
|
337
|
+
* the renderer sizes the bracketed region to
|
|
338
|
+
* `panelWidth - label_overhead - bracket_overhead`. Pair
|
|
339
|
+
* with `labeledSection(...)` to get a uniformly full-width
|
|
340
|
+
* fieldset look. */
|
|
341
|
+
fullWidth?: boolean;
|
|
342
|
+
key?: string;
|
|
343
|
+
} = {},
|
|
344
|
+
): WidgetSpec {
|
|
345
|
+
return {
|
|
346
|
+
kind: "text",
|
|
347
|
+
value: options.value ?? "",
|
|
348
|
+
cursorByte: options.cursorByte ?? -1,
|
|
349
|
+
focused: options.focused ?? false,
|
|
350
|
+
label: options.label ?? "",
|
|
351
|
+
placeholder: options.placeholder,
|
|
352
|
+
rows: options.rows ?? 1,
|
|
353
|
+
fieldWidth: options.fieldWidth ?? 0,
|
|
354
|
+
maxVisibleChars: options.maxVisibleChars ?? 0,
|
|
355
|
+
fullWidth: options.fullWidth ?? false,
|
|
356
|
+
key: options.key,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Multi-line text widget. Thin wrapper over `text({ rows, ... })`
|
|
361
|
+
* for ergonomic intent — renders as a `rows`-tall block with
|
|
362
|
+
* Enter-inserts-newline / Up-Down-line-nav semantics. Default `rows`
|
|
363
|
+
* is 5; pass `rows: N` to override. */
|
|
364
|
+
export function textArea(
|
|
365
|
+
options: {
|
|
366
|
+
value?: string;
|
|
367
|
+
cursorByte?: number;
|
|
368
|
+
focused?: boolean;
|
|
369
|
+
label?: string;
|
|
370
|
+
placeholder?: string;
|
|
371
|
+
/** Visible rows of editing area; default 5. */
|
|
372
|
+
rows?: number;
|
|
373
|
+
/** Visible column width; `0` = use panel width. */
|
|
374
|
+
fieldWidth?: number;
|
|
375
|
+
fullWidth?: boolean;
|
|
376
|
+
key?: string;
|
|
377
|
+
} = {},
|
|
378
|
+
): WidgetSpec {
|
|
379
|
+
return text({
|
|
380
|
+
...options,
|
|
381
|
+
rows: options.rows ?? 5,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Single-line text widget. Thin wrapper over `text({ rows: 1,
|
|
386
|
+
* ... })` matching the historical `textInput(value, opts)`
|
|
387
|
+
* signature. Renders as `[value]` (or `Label: [value]` if `label`
|
|
388
|
+
* is provided), with Enter-advances-focus semantics. */
|
|
389
|
+
export function textInput(
|
|
390
|
+
value: string,
|
|
391
|
+
options?: {
|
|
392
|
+
cursorByte?: number;
|
|
393
|
+
focused?: boolean;
|
|
394
|
+
label?: string;
|
|
395
|
+
placeholder?: string;
|
|
396
|
+
/** Soft truncation cap (legacy). Prefer `fieldWidth`. */
|
|
397
|
+
maxVisibleChars?: number;
|
|
398
|
+
/** Constant visible width inside the brackets. */
|
|
399
|
+
fieldWidth?: number;
|
|
400
|
+
/** See `text({ fullWidth })`. */
|
|
401
|
+
fullWidth?: boolean;
|
|
402
|
+
key?: string;
|
|
403
|
+
},
|
|
404
|
+
): WidgetSpec {
|
|
405
|
+
return text({
|
|
406
|
+
value,
|
|
407
|
+
cursorByte: options?.cursorByte,
|
|
408
|
+
focused: options?.focused,
|
|
409
|
+
label: options?.label,
|
|
410
|
+
placeholder: options?.placeholder,
|
|
411
|
+
rows: 1,
|
|
412
|
+
fieldWidth: options?.fieldWidth,
|
|
413
|
+
maxVisibleChars: options?.maxVisibleChars,
|
|
414
|
+
fullWidth: options?.fullWidth,
|
|
415
|
+
key: options?.key,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Reserve a rectangle in the layout for the host to natively
|
|
420
|
+
* render the editor window identified by `windowId`. The widget
|
|
421
|
+
* itself emits `rows` blank lines so layout reserves the space;
|
|
422
|
+
* the host's paint path then overlays the live window UI (split
|
|
423
|
+
* tree, terminals, syntax highlighting, decorations) into the
|
|
424
|
+
* reserved rectangle.
|
|
425
|
+
*
|
|
426
|
+
* `windowId` of 0 (or any unknown id) renders the placeholder
|
|
427
|
+
* blanks without dispatching the per-window paint. */
|
|
428
|
+
export function windowEmbed(options: {
|
|
429
|
+
windowId: number;
|
|
430
|
+
rows: number;
|
|
431
|
+
key?: string;
|
|
432
|
+
}): WidgetSpec {
|
|
433
|
+
return {
|
|
434
|
+
kind: "windowEmbed",
|
|
435
|
+
windowId: options.windowId,
|
|
436
|
+
rows: options.rows,
|
|
437
|
+
key: options.key,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Group a single child widget inside a rounded, thin border
|
|
442
|
+
* with `label` printed as a top-left legend (HTML
|
|
443
|
+
* `<fieldset>` semantics). The host renders three rows:
|
|
444
|
+
*
|
|
445
|
+
* ╭─ Label ──────────────────╮
|
|
446
|
+
* │ <child rendered content> │
|
|
447
|
+
* ╰──────────────────────────╯
|
|
448
|
+
*
|
|
449
|
+
* The section always spans the parent's available width. The
|
|
450
|
+
* child is rendered with the inner width (parent width minus
|
|
451
|
+
* 4 columns of border + padding), so child widgets that honour
|
|
452
|
+
* `fullWidth: true` size themselves to fill the inner area.
|
|
453
|
+
* Focus, hit areas and cursor positions bubble up from the
|
|
454
|
+
* child unchanged, shifted by the border offset. */
|
|
455
|
+
export function labeledSection(options: {
|
|
456
|
+
label?: string;
|
|
457
|
+
child: WidgetSpec;
|
|
458
|
+
/** When this section is a Block child of a Row, request a
|
|
459
|
+
* specific share of the row's width as a percentage (1..=100).
|
|
460
|
+
* Out-of-range values fall back to the equal-split default.
|
|
461
|
+
* Useful for picker-style layouts: a narrow list pane next to
|
|
462
|
+
* a wide preview pane. */
|
|
463
|
+
widthPct?: number;
|
|
464
|
+
key?: string;
|
|
465
|
+
}): WidgetSpec {
|
|
466
|
+
return {
|
|
467
|
+
kind: "labeledSection",
|
|
468
|
+
label: options.label ?? "",
|
|
469
|
+
child: options.child,
|
|
470
|
+
widthPct: options.widthPct,
|
|
471
|
+
key: options.key,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// =============================================================================
|
|
476
|
+
// HintEntry parsing — for the legacy `Tab:section Esc:close` format
|
|
477
|
+
// shipped in existing plugin i18n bundles.
|
|
478
|
+
// =============================================================================
|
|
479
|
+
|
|
480
|
+
/** Parse a hint string of the form `<keys>:<label> <keys>:<label> ...`.
|
|
481
|
+
*
|
|
482
|
+
* The separator between entries defaults to two-or-more spaces (matching
|
|
483
|
+
* what existing i18n bundles use). The separator between keys and label
|
|
484
|
+
* within an entry is a colon.
|
|
485
|
+
*
|
|
486
|
+
* Empty input yields an empty array. Entries without a colon are
|
|
487
|
+
* preserved with empty label. */
|
|
488
|
+
export function parseHintString(
|
|
489
|
+
s: string,
|
|
490
|
+
options?: { entrySep?: RegExp; keyLabelSep?: string },
|
|
491
|
+
): HintEntry[] {
|
|
492
|
+
if (!s) return [];
|
|
493
|
+
const entrySep = options?.entrySep ?? /\s{2,}/;
|
|
494
|
+
const keyLabelSep = options?.keyLabelSep ?? ":";
|
|
495
|
+
const parts = s.split(entrySep).filter((p) => p.length > 0);
|
|
496
|
+
return parts.map((part) => {
|
|
497
|
+
const idx = part.indexOf(keyLabelSep);
|
|
498
|
+
if (idx < 0) {
|
|
499
|
+
return { keys: part, label: "" };
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
keys: part.slice(0, idx).trim(),
|
|
503
|
+
label: part.slice(idx + keyLabelSep.length).trim(),
|
|
504
|
+
};
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// =============================================================================
|
|
509
|
+
// WidgetPanel — mount-once-update-many wrapper around the IPC trio.
|
|
510
|
+
// =============================================================================
|
|
511
|
+
|
|
512
|
+
/** A handle to a mounted widget panel. Construct one per virtual
|
|
513
|
+
* buffer that should host widget-rendered content; call `set(spec)`
|
|
514
|
+
* on every render; call `unmount()` when the buffer is closed.
|
|
515
|
+
*
|
|
516
|
+
* The first `set()` issues `mountWidgetPanel`; subsequent calls
|
|
517
|
+
* issue `updateWidgetPanel`. Idempotent re-mount is guaranteed by the
|
|
518
|
+
* host (see `WidgetRegistry::mount`). */
|
|
519
|
+
export class WidgetPanel {
|
|
520
|
+
private mounted = false;
|
|
521
|
+
private readonly panelId: number;
|
|
522
|
+
private readonly bufferId: number;
|
|
523
|
+
|
|
524
|
+
constructor(bufferId: number, panelId?: number) {
|
|
525
|
+
this.bufferId = bufferId;
|
|
526
|
+
this.panelId = panelId ?? allocatePanelId();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Returns the plugin-allocated panel id, useful for routing
|
|
530
|
+
* widget events back through `editor.on("widget_event", ...)`. */
|
|
531
|
+
id(): number {
|
|
532
|
+
return this.panelId;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Render or re-render the panel against the given spec.
|
|
536
|
+
* Cheap to call on every state change; the host reconciles. */
|
|
537
|
+
set(spec: WidgetSpec): boolean {
|
|
538
|
+
// deno-lint-ignore no-explicit-any
|
|
539
|
+
const editor = (globalThis as any).editor;
|
|
540
|
+
if (!this.mounted) {
|
|
541
|
+
this.mounted = true;
|
|
542
|
+
return editor.mountWidgetPanel(this.panelId, this.bufferId, spec);
|
|
543
|
+
}
|
|
544
|
+
return editor.updateWidgetPanel(this.panelId, spec);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Tear down the panel. The plugin retains ownership of the
|
|
548
|
+
* underlying virtual buffer. Subsequent `set()` calls re-mount. */
|
|
549
|
+
unmount(): boolean {
|
|
550
|
+
if (!this.mounted) return true;
|
|
551
|
+
this.mounted = false;
|
|
552
|
+
// deno-lint-ignore no-explicit-any
|
|
553
|
+
const editor = (globalThis as any).editor;
|
|
554
|
+
return editor.unmountWidgetPanel(this.panelId);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Route a key/nav action to the focused widget in this panel.
|
|
558
|
+
* The host computes the result on the focused widget's kind and
|
|
559
|
+
* fires `widget_event` as appropriate. See `WidgetAction` for
|
|
560
|
+
* the action shapes. */
|
|
561
|
+
command(action: WidgetAction): boolean {
|
|
562
|
+
// deno-lint-ignore no-explicit-any
|
|
563
|
+
const editor = (globalThis as any).editor;
|
|
564
|
+
return editor.widgetCommand(this.panelId, action);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Apply a targeted mutation in place — the IPC fast path.
|
|
568
|
+
* Use instead of `set(spec)` when only one widget changes;
|
|
569
|
+
* the host applies the mutation directly and re-renders
|
|
570
|
+
* without re-transmitting the full spec. See `WidgetMutation`
|
|
571
|
+
* for the shapes. The typed wrappers below cover the common
|
|
572
|
+
* cases. */
|
|
573
|
+
mutate(mutation: WidgetMutation): boolean {
|
|
574
|
+
// deno-lint-ignore no-explicit-any
|
|
575
|
+
const editor = (globalThis as any).editor;
|
|
576
|
+
return editor.widgetMutate(this.panelId, mutation);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Set a `TextInput`'s value (and optionally cursor byte).
|
|
580
|
+
* Mutates host instance state; doesn't re-transmit the full
|
|
581
|
+
* spec. */
|
|
582
|
+
setValue(widgetKey: string, value: string, cursorByte?: number): boolean {
|
|
583
|
+
return this.mutate({ kind: "setValue", widgetKey, value, cursorByte });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Set a `Toggle`'s checked state. */
|
|
587
|
+
setChecked(widgetKey: string, checked: boolean): boolean {
|
|
588
|
+
return this.mutate({ kind: "setChecked", widgetKey, checked });
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Set a `List`'s selected index. */
|
|
592
|
+
setSelectedIndex(widgetKey: string, index: number): boolean {
|
|
593
|
+
return this.mutate({ kind: "setSelectedIndex", widgetKey, index });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Replace a `List`'s items + parallel `itemKeys`. */
|
|
597
|
+
setItems(
|
|
598
|
+
widgetKey: string,
|
|
599
|
+
items: TextPropertyEntry[],
|
|
600
|
+
itemKeys: string[] = [],
|
|
601
|
+
): boolean {
|
|
602
|
+
return this.mutate({ kind: "setItems", widgetKey, items, itemKeys });
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Replace a `Tree`'s expanded-keys instance state. The host
|
|
606
|
+
* normally owns expansion (Right/Left arrows + disclosure
|
|
607
|
+
* clicks); use this when a non-user action drives expansion
|
|
608
|
+
* (e.g. "expand all", reveal-on-search). */
|
|
609
|
+
setExpandedKeys(widgetKey: string, keys: string[]): boolean {
|
|
610
|
+
return this.mutate({ kind: "setExpandedKeys", widgetKey, keys });
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Stamp `checked` onto every node in the named `Tree` whose
|
|
614
|
+
* `itemKey` appears in `keys`. Used by the `toggle` event
|
|
615
|
+
* handler to push the post-click state back without a full spec
|
|
616
|
+
* re-emit. Nodes whose existing `checked` is `undefined` (no
|
|
617
|
+
* checkbox glyph) are unchanged. */
|
|
618
|
+
setCheckedKeys(widgetKey: string, checked: boolean, keys: string[]): boolean {
|
|
619
|
+
return this.mutate({ kind: "setCheckedKeys", widgetKey, checked, keys });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// =============================================================================
|
|
624
|
+
// FloatingWidgetPanel — mount-once-update-many wrapper for centered
|
|
625
|
+
// floating overlays (no virtual buffer required).
|
|
626
|
+
// =============================================================================
|
|
627
|
+
|
|
628
|
+
/** A handle to a floating widget panel — a modal-ish overlay
|
|
629
|
+
* rendered in a centered frame on top of the editor, dimming the
|
|
630
|
+
* background. Unlike `WidgetPanel`, no virtual buffer is needed;
|
|
631
|
+
* the host owns the rect and paints the spec inside it.
|
|
632
|
+
*
|
|
633
|
+
* `mount({ widthPct, heightPct })` mounts the panel and renders
|
|
634
|
+
* the spec; `update(spec)` re-renders against the previous instance
|
|
635
|
+
* state; `unmount()` tears it down. The host routes keys to the
|
|
636
|
+
* focused widget automatically while a floating panel is up: Esc
|
|
637
|
+
* unmounts and fires a `widget_event` "cancel"; Tab / Enter /
|
|
638
|
+
* arrows / Backspace / printable chars route through the same
|
|
639
|
+
* smart-key dispatch as `WidgetPanel.command(key(...))`. */
|
|
640
|
+
export class FloatingWidgetPanel {
|
|
641
|
+
private mounted = false;
|
|
642
|
+
private readonly panelId: number;
|
|
643
|
+
|
|
644
|
+
constructor(panelId?: number) {
|
|
645
|
+
this.panelId = panelId ?? allocatePanelId();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Returns the plugin-allocated panel id, useful for routing
|
|
649
|
+
* widget events back through `editor.on("widget_event", ...)`. */
|
|
650
|
+
id(): number {
|
|
651
|
+
return this.panelId;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Mount the panel as a centered overlay sized by `widthPct` /
|
|
655
|
+
* `heightPct` (percent of terminal, clamped 1..=100). Cheap to
|
|
656
|
+
* call repeatedly with a new spec — re-mounting replaces the
|
|
657
|
+
* existing panel. */
|
|
658
|
+
mount(
|
|
659
|
+
spec: WidgetSpec,
|
|
660
|
+
options: { widthPct?: number; heightPct?: number } = {},
|
|
661
|
+
): boolean {
|
|
662
|
+
// deno-lint-ignore no-explicit-any
|
|
663
|
+
const editor = (globalThis as any).editor;
|
|
664
|
+
const wp = options.widthPct ?? 60;
|
|
665
|
+
const hp = options.heightPct ?? 40;
|
|
666
|
+
this.mounted = true;
|
|
667
|
+
return editor.mountFloatingWidget(this.panelId, spec, wp, hp);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Re-render the panel against the given spec; instance state on
|
|
671
|
+
* keyed widgets is preserved. No-op when not mounted. */
|
|
672
|
+
update(spec: WidgetSpec): boolean {
|
|
673
|
+
if (!this.mounted) return false;
|
|
674
|
+
// deno-lint-ignore no-explicit-any
|
|
675
|
+
const editor = (globalThis as any).editor;
|
|
676
|
+
return editor.updateFloatingWidget(this.panelId, spec);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Tear down the panel and let the editor return to its normal
|
|
680
|
+
* key/click routing. */
|
|
681
|
+
unmount(): boolean {
|
|
682
|
+
if (!this.mounted) return true;
|
|
683
|
+
this.mounted = false;
|
|
684
|
+
// deno-lint-ignore no-explicit-any
|
|
685
|
+
const editor = (globalThis as any).editor;
|
|
686
|
+
return editor.unmountFloatingWidget(this.panelId);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Route a key/nav action to the focused widget. The host
|
|
690
|
+
* automatically routes keystrokes while a floating panel is up,
|
|
691
|
+
* so plugins rarely need to call this directly — it's exposed
|
|
692
|
+
* for symmetry with `WidgetPanel`. */
|
|
693
|
+
command(action: WidgetAction): boolean {
|
|
694
|
+
// deno-lint-ignore no-explicit-any
|
|
695
|
+
const editor = (globalThis as any).editor;
|
|
696
|
+
return editor.widgetCommand(this.panelId, action);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Apply a targeted mutation in place — the IPC fast path. */
|
|
700
|
+
mutate(mutation: WidgetMutation): boolean {
|
|
701
|
+
// deno-lint-ignore no-explicit-any
|
|
702
|
+
const editor = (globalThis as any).editor;
|
|
703
|
+
return editor.widgetMutate(this.panelId, mutation);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
setValue(widgetKey: string, value: string, cursorByte?: number): boolean {
|
|
707
|
+
return this.mutate({ kind: "setValue", widgetKey, value, cursorByte });
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
setChecked(widgetKey: string, checked: boolean): boolean {
|
|
711
|
+
return this.mutate({ kind: "setChecked", widgetKey, checked });
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
setSelectedIndex(widgetKey: string, index: number): boolean {
|
|
715
|
+
return this.mutate({ kind: "setSelectedIndex", widgetKey, index });
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
setItems(
|
|
719
|
+
widgetKey: string,
|
|
720
|
+
items: TextPropertyEntry[],
|
|
721
|
+
itemKeys: string[] = [],
|
|
722
|
+
): boolean {
|
|
723
|
+
return this.mutate({ kind: "setItems", widgetKey, items, itemKeys });
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
setExpandedKeys(widgetKey: string, keys: string[]): boolean {
|
|
727
|
+
return this.mutate({ kind: "setExpandedKeys", widgetKey, keys });
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
setCheckedKeys(widgetKey: string, checked: boolean, keys: string[]): boolean {
|
|
731
|
+
return this.mutate({ kind: "setCheckedKeys", widgetKey, checked, keys });
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// =============================================================================
|
|
736
|
+
// WidgetAction builders — convenience wrappers around `panel.command(...)`.
|
|
737
|
+
// Plugin's mode bindings call these for keys handled by the widget layer.
|
|
738
|
+
// =============================================================================
|
|
739
|
+
|
|
740
|
+
/** Cycle focus through the panel's tabbable widgets. `delta=+1`
|
|
741
|
+
* for Tab, `-1` for Shift+Tab. Wraps at the ends. */
|
|
742
|
+
export function focusAdvance(delta: number): WidgetAction {
|
|
743
|
+
return { kind: "focusAdvance", delta };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Activate the focused widget (Enter on Button → "activate"
|
|
747
|
+
* event; Enter on Toggle → "toggle" event). No-op for other
|
|
748
|
+
* widget kinds. */
|
|
749
|
+
export function activate(): WidgetAction {
|
|
750
|
+
return { kind: "activate" };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Move the focused List's selection by `delta`. Plugin listens
|
|
754
|
+
* for `widget_event` "select" to mirror back into its model. */
|
|
755
|
+
export function selectMove(delta: number): WidgetAction {
|
|
756
|
+
return { kind: "selectMove", delta };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Apply a non-printable editing key to the focused TextInput:
|
|
760
|
+
* `"Backspace"`, `"Delete"`, `"Left"`, `"Right"`, `"Home"`,
|
|
761
|
+
* `"End"`. Fires `widget_event` "change" with the new value +
|
|
762
|
+
* cursorByte. */
|
|
763
|
+
export function textInputKey(key: string): WidgetAction {
|
|
764
|
+
return { kind: "textInputKey", key };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** Append printable text at the focused TextInput's cursor.
|
|
768
|
+
* Fires `widget_event` "change" with the new value + cursorByte.
|
|
769
|
+
* Used for the `mode_text_input` fall-through path. */
|
|
770
|
+
export function textInputChar(text: string): WidgetAction {
|
|
771
|
+
return { kind: "textInputChar", text };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Smart-key dispatch — routes the keystroke to the right widget
|
|
775
|
+
* action based on the focused widget's kind. Plugin's mode bindings
|
|
776
|
+
* use this rather than picking the right action themselves: bind
|
|
777
|
+
* Tab/Shift+Tab/Enter/Space/Backspace/Delete/Left/Right/Up/Down/
|
|
778
|
+
* Home/End all to one handler that calls `panel.command(key("Tab"))`.
|
|
779
|
+
*
|
|
780
|
+
* See `WidgetAction::Key` (Rust) for the full dispatch table. */
|
|
781
|
+
export function key(name: string): WidgetAction {
|
|
782
|
+
return { kind: "key", key: name };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// =============================================================================
|
|
786
|
+
// Panel-id allocation. Plugin-side counter; need only be unique per
|
|
787
|
+
// plugin instance (the host doesn't interpret the value).
|
|
788
|
+
// =============================================================================
|
|
789
|
+
|
|
790
|
+
let nextPanelId = 1;
|
|
791
|
+
function allocatePanelId(): number {
|
|
792
|
+
// Bias high so plugin-allocated ids don't collide with the
|
|
793
|
+
// editor's internal panel-id space if it ever uses small ints.
|
|
794
|
+
const id = nextPanelId++;
|
|
795
|
+
return 0x1000_0000 + id;
|
|
796
|
+
}
|