@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.
@@ -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
+ }