/**
 * Plugin widget library — declarative UI for Fresh plugins.
 *
 * Plugins describe panel content as a `WidgetSpec` tree. The host owns
 * rendering, theming, and (in later phases) hit-testing, focus, and
 * keymaps. This module provides:
 *
 *   - Type re-exports from the generated `fresh.d.ts` so plugins import
 *     `WidgetSpec` / `HintEntry` from one place.
 *   - Builder helpers (`row`, `col`, `hintBar`, `raw`) that produce the
 *     correct discriminated-union shape.
 *   - A `WidgetPanel` class that wraps the
 *     `mountWidgetPanel` / `updateWidgetPanel` / `unmountWidgetPanel`
 *     IPC trio with mount-once-then-update semantics.
 *   - `parseHintString(s)` — parses the legacy `Tab:section  Esc:close`
 *     string format used by today's plugin i18n bundles into
 *     `HintEntry[]`.
 *
 * See `docs/internal/plugin-widget-library-design.md`.
 *
 * @example
 *   import { WidgetPanel, hintBar, col, raw, parseHintString } from "./lib/widgets.ts";
 *
 *   const panel = new WidgetPanel(bufferId);
 *   panel.set(col(
 *     raw(myExistingEntries),
 *     hintBar(parseHintString(editor.t("panel.help"))),
 *   ));
 *   // …later, on every state change:
 *   panel.set(col(raw(newEntries), hintBar(myHints)));
 *   // …on close:
 *   panel.unmount();
 */

/// <reference path="./fresh.d.ts" />

// `fresh.d.ts` declares HintEntry / WidgetSpec / TextPropertyEntry as
// ambient globals (it is not an ES module). Re-export the relevant
// type names locally so plugin code can write
// `import type { WidgetSpec } from "./lib/widgets.ts"` without dipping
// into the ambient namespace directly.
export type WidgetSpec = globalThis.WidgetSpec;
export type HintEntry = globalThis.HintEntry;
export type ButtonKind = globalThis.ButtonKind;
export type WidgetAction = globalThis.WidgetAction;
export type WidgetMutation = globalThis.WidgetMutation;
export type TreeNode = globalThis.TreeNode;
export type StyledSegment = globalThis.StyledSegment;
export type TextPropertyEntry = globalThis.TextPropertyEntry;
type InlineOverlay = globalThis.InlineOverlay;
type OverlayOptions = globalThis.OverlayOptions;

// =============================================================================
// Builder helpers — preferred over hand-writing `{ kind: "row", ... }`.
// =============================================================================

/** Horizontal layout. Children laid out left-to-right; inline-sized
 * children collapse into a single line. See §3 of the design doc. */
export function row(...children: WidgetSpec[]): WidgetSpec {
  return { kind: "row", children };
}

/** Horizontal layout that **wraps**: children that don't fit on one line
 * reflow onto additional lines (growing the row's height) instead of being
 * truncated. Children are never split, so wrap a logical group (e.g. a
 * toggle + its accelerator) in a nested `row(...)` to keep it intact. */
export function wrappingRow(...children: WidgetSpec[]): WidgetSpec {
  return { kind: "row", children, wrap: true };
}

/** Vertical layout. Children stacked top-to-bottom. */
export function col(...children: WidgetSpec[]): WidgetSpec {
  return { kind: "col", children };
}

/** Keyboard-hint footer. Renders `<keys> <label>` per entry, with the
 * keys portion styled by the `ui.help_key_fg` theme key.
 *
 * Replaces the per-plugin hand-rolled help row. */
export function hintBar(entries: HintEntry[]): WidgetSpec {
  return { kind: "hintBar", entries };
}

/** Imperative-virtual-buffer escape hatch. Wraps an existing
 * `TextPropertyEntry[]` (the same shape `setVirtualBufferContent`
 * already accepts) so a plugin can migrate its panel one widget at a
 * time. */
export function raw(entries: TextPropertyEntry[], key?: string): WidgetSpec {
  return { kind: "raw", entries, key };
}

/** Build a `TextPropertyEntry` from a sequence of styled segments.
 *
 * The plugin describes row content structurally — each segment is a
 * piece of text plus optional `style` and optional nested
 * `overlays`. The host concatenates the segments and emits one
 * inline overlay per styled segment plus the segment's nested
 * overlays shifted by the segment's start position; both happen in
 * Rust against the final text, so the plugin never names byte or
 * codepoint offsets between segments.
 *
 * Use `padToChars` / `truncateToChars` to constrain the entry's
 * total width — both are applied AFTER segment concatenation (so
 * `padToChars: 80` pads the full row to 80 codepoints, regardless
 * of how the segments split it).
 *
 * For freeform overlays inside a single segment (e.g. highlighting
 * pattern matches inside a context string), pass them via the
 * segment's `overlays` field with `unit: "char"`. */
export function styledRow(
  segments: StyledSegment[],
  options?: {
    padToChars?: number;
    truncateToChars?: number;
    properties?: Record<string, unknown>;
    style?: Partial<OverlayOptions>;
    inlineOverlays?: InlineOverlay[];
  },
): TextPropertyEntry {
  // Build the entry by spreading only set fields. The plugin
  // bridge converts JS `undefined` to JSON `null` when an object
  // key is present, which then fails to deserialize as the
  // matching `Option<…>` / `Vec<…>` field on the host. Omitting
  // the key entirely lets serde fall back to `#[serde(default)]`.
  const entry: TextPropertyEntry = { text: "", segments };
  if (options?.padToChars !== undefined) entry.padToChars = options.padToChars;
  if (options?.truncateToChars !== undefined) entry.truncateToChars = options.truncateToChars;
  if (options?.properties !== undefined) entry.properties = options.properties;
  if (options?.style !== undefined) entry.style = options.style;
  if (options?.inlineOverlays !== undefined) entry.inlineOverlays = options.inlineOverlays;
  return entry;
}

/** Boolean toggle, rendered as `[v] label` / `[ ] label`.
 * Pass `focused: true` to highlight (the host will own focus once
 * the keymap layer is wired).
 *
 * `labelFirst: true` renders the form layout `label: [v]` instead
 * (chip after the label, and only the chip is clickable);
 * `labelWidth` pads the label so a column of controls aligns.
 * `indeterminate: true` renders a neutral `[-]` chip for an
 * unset/inherited value. */
export function toggle(
  checked: boolean,
  label: string,
  options?: {
    focused?: boolean;
    indeterminate?: boolean;
    labelFirst?: boolean;
    labelWidth?: number;
    key?: string;
  },
): WidgetSpec {
  return {
    kind: "toggle",
    checked,
    label,
    focused: options?.focused ?? false,
    indeterminate: options?.indeterminate ?? false,
    labelFirst: options?.labelFirst ?? false,
    labelWidth: options?.labelWidth ?? 0,
    key: options?.key,
  };
}

/** Numeric field, rendered as `label: [ 42 ]`. Press Left/Down
 * (decrement) / Right/Up (increment) to step the value by `step`,
 * clamped to `[min, max]` when set. The value is host-owned instance
 * state after first render (the spec `value` is initial-only); each
 * adjustment fires `widget_event { event_type: "change", payload:
 * { value } }`. Push a new value with `WidgetPanel.setNumber(key, v)`.
 *
 * `integer: true` renders without a decimal point; `percent: true`
 * shows `value * 100` with a `%` suffix (store `0.25`, display `25%`).
 * `labelWidth` pads the label so a column of controls aligns. */
export function number(
  value: number,
  options?: {
    min?: number;
    max?: number;
    step?: number;
    integer?: boolean;
    percent?: boolean;
    label?: string;
    focused?: boolean;
    labelWidth?: number;
    key?: string;
  },
): WidgetSpec {
  return {
    kind: "number",
    value,
    min: options?.min,
    max: options?.max,
    step: options?.step ?? 1,
    integer: options?.integer ?? false,
    percent: options?.percent ?? false,
    label: options?.label ?? "",
    focused: options?.focused ?? false,
    labelWidth: options?.labelWidth ?? 0,
    key: options?.key,
  };
}

/** Single-select dropdown, rendered as `label: [option ▼]`.
 * Enter/Space (or a click on the button) opens the option list
 * inline below the button; Up/Down move the live selection,
 * Enter/Space/Esc close it. The selected index is host-owned
 * instance state after first render (the spec `selectedIndex` is a
 * seed); each change fires `widget_event { event_type: "change",
 * payload: { index, value } }`. Push a new selection with
 * `WidgetPanel.setDropdown(key, index)`.
 *
 * `labelWidth` pads the label so a column of controls aligns. */
export function dropdown(
  options: string[],
  options_?: {
    selectedIndex?: number;
    label?: string;
    focused?: boolean;
    labelWidth?: number;
    key?: string;
  },
): WidgetSpec {
  return {
    kind: "dropdown",
    options,
    selectedIndex: options_?.selectedIndex ?? 0,
    label: options_?.label ?? "",
    focused: options_?.focused ?? false,
    labelWidth: options_?.labelWidth ?? 0,
    key: options_?.key,
  };
}

/** Two-column ordered-subset picker: an `Available` column (options
 * not yet chosen) and an ordered `Included` column. Left/Right switch
 * the active column, Up/Down move the cursor, Space moves the focused
 * item across, PageUp/PageDown reorder the Included column. The
 * ordered included set is host-owned instance state after first
 * render; changes fire `widget_event { event_type: "change", payload:
 * { included: [values] } }`. Set it directly with
 * `WidgetPanel.setDualIncluded(key, values)`.
 *
 * `excluded` names option values owned by a sibling list, kept out of
 * this list's Available column (cross-exclusion).
 *
 * The focused column's cursor row is marked `▸`, the other column's
 * parked cursor `▹`, and the active column's header `▾`, so the state
 * reads without relying on color. `activeIncluded` / `availableCursor`
 * / `includedCursor` seed that cursor (host instance state takes over
 * after the first render); `hint` adds a one-line key legend under the
 * columns. */
export function dualList(
  options: { value: string; label: string }[],
  opts?: {
    included?: string[];
    excluded?: string[];
    label?: string;
    focused?: boolean;
    activeIncluded?: boolean;
    availableCursor?: number;
    includedCursor?: number;
    hint?: string;
    visibleRows?: number;
    key?: string;
  },
): WidgetSpec {
  return {
    kind: "dualList",
    options,
    included: opts?.included ?? [],
    excluded: opts?.excluded ?? [],
    label: opts?.label ?? "",
    focused: opts?.focused ?? false,
    activeIncluded: opts?.activeIncluded ?? false,
    availableCursor: opts?.availableCursor ?? 0,
    includedCursor: opts?.includedCursor ?? 0,
    hint: opts?.hint ?? "",
    visibleRows: opts?.visibleRows ?? 6,
    key: opts?.key,
  };
}

/** Action button, rendered as `[ Label ]`. `intent` controls visual
 * emphasis: `"normal"` (default) → no override, `"primary"` → bold,
 * `"danger"` → error theme key.
 *
 * `disabled: true` paints the button with `ui.menu_disabled_fg`,
 * drops it from the Tab cycle, and makes clicks no-ops — for actions
 * that aren't currently available against the surrounding state.
 * The button still occupies its layout cell, so flipping `disabled`
 * doesn't reshuffle the surrounding row. */
export function button(
  label: string,
  options?: {
    focused?: boolean;
    intent?: ButtonKind;
    key?: string;
    disabled?: boolean;
    /** When false, the button renders and stays clickable but is
     * dropped from the Tab cycle. Use for radio-style groups so Tab
     * advances one stop per group (the active option) and ←/→ moves
     * the selection within the group. Defaults to true. */
    focusable?: boolean;
    /** Render the label alone — no `[ ]` frame, no focus-marker
     * gutter — turning the button into an *icon affordance* (a `×`
     * close glyph, a `▾` chevron) rather than a framed action. Use
     * where the glyph itself is the control; keep the default for
     * anything with a word on it. Layout only — `hoverStyle` decides
     * how it looks under the pointer. */
    bare?: boolean;
    /** Stretch the button across the full width it is laid out in (the
     * panel's content width, or its share of an enclosing `row`),
     * padding the label out — and `…`-truncating it when the width
     * can't hold it.
     *
     * Focus and hover paint the button's *own* cells, so a
     * natural-width button leaves the rest of its row unhighlighted
     * even where the container pads the row out around it (a
     * `labeledSection` pads every child to its inner width). Menu
     * entries are rows of a menu rather than free-standing actions, so
     * set this on them and the highlight spans the row — at the width
     * the host actually rendered, with no plugin-side width guess to
     * drift on a resize or a dock drag.
     *
     * Leave it off inside an anchored popup that sizes itself to its
     * content: filling there stretches the popup to the panel width. */
    fullWidth?: boolean;
    /** How the button looks while the pointer is over it. Omit to
     * leave it looking the same hovered as not.
     *
     * The host applies this as the mouse moves, with no round-trip to
     * the plugin, so hover costs a panel re-render and nothing more.
     * It outranks focus styling while both apply.
     *
     * For a close glyph, `{ fg: "ui.tab_close_hover_fg" }` is the
     * editor's shared "close affordance under the pointer" look — the
     * tab `×` and the file explorer's `×` both use it. */
    hoverStyle?: Partial<OverlayOptions>;
  },
): WidgetSpec {
  const spec: WidgetSpec = {
    kind: "button",
    label,
    focused: options?.focused ?? false,
    intent: options?.intent ?? "normal",
    key: options?.key,
    disabled: options?.disabled ?? false,
    focusable: options?.focusable ?? true,
    bare: options?.bare ?? false,
    fullWidth: options?.fullWidth ?? false,
  };
  // Omit rather than pass `undefined`: the plugin bridge turns a
  // present `undefined` into JSON `null`, which fails to deserialize
  // as the host's `Option<OverlayOptions>`.
  if (options?.hoverStyle !== undefined) spec.hoverStyle = options.hoverStyle;
  return spec;
}

/** Horizontal spacer of fixed column count. In a `Row` it produces
 * `cols` spaces; at the top level or in a `Col` it produces a
 * short blank line. */
export function spacer(cols: number, key?: string): WidgetSpec {
  return { kind: "spacer", cols, flex: false, key };
}

/** Flex horizontal spacer — fills remaining row width
 * (`panel_width - sum(non-flex children)`). Use to right-align a
 * trailing widget: `row(label, flexSpacer(), button)`. Inside a
 * `col` on a height-budgeted panel it absorbs leftover ROWS instead —
 * `col(content, flexSpacer(), hintBar)` pins the hints to the panel
 * bottom without counting chrome rows. With
 * multiple flex spacers in one row the leftover splits evenly. */
export function flexSpacer(key?: string): WidgetSpec {
  return { kind: "spacer", cols: 0, flex: true, key };
}

/** Full-width horizontal rule. The host draws `ch` (default `─`)
 * across the panel's inner content width, so the separator always
 * matches the rendered width — including a user-dragged dock —
 * without the plugin computing the width itself. This is the
 * declarative analogue of `flexSpacer()`: express "a rule here",
 * let the host (which owns the width) size it. Optional `style`
 * picks the colour (e.g. a dim `fg` for a quiet separator). */
export function divider(
  options?: { ch?: string; style?: Partial<OverlayOptions>; key?: string },
): WidgetSpec {
  // Omit unset keys rather than passing `undefined`: the plugin bridge
  // turns a present `undefined` into JSON `null`, which fails to
  // deserialize as the host's `Option<…>` fields (see `styledRow`).
  const spec: WidgetSpec = { kind: "divider", ch: options?.ch ?? "─" };
  if (options?.style !== undefined) spec.style = options.style;
  if (options?.key !== undefined) spec.key = options.key;
  return spec;
}

/** Vertical list of pre-rendered rows with host-managed selection
 * styling, click routing, and **virtual scrolling**. Plugin passes
 * the full dataset of items; the widget owns scroll offset as
 * instance state (keyed by `key`) and auto-clamps it to keep
 * `selectedIndex` in view. Plugins never compute scroll math.
 *
 * Omit `visibleRows` to auto-size: the host windows the list to the
 * panel height it already knows, minus the rows the list's Col
 * siblings occupy — so a panel with a header and footer never needs
 * `getViewportHeight()` arithmetic. Pass an explicit count only to
 * pin the window (at most one auto-sized list/tree per Col).
 *
 * Click on a row fires `widget_event` with `eventType: "select"` and
 * `payload: { index, key }` where `index` is the *absolute* index
 * into `items` (not the visible-window index).
 *
 * `key` is required for any List that should preserve scroll across
 * re-renders. Lists without a key reset to scroll=0 each render. */
export function list(options: {
  items: TextPropertyEntry[];
  /** Optional per-item widget specs. When provided (non-empty) they
   * override `items`: each renders as a multi-row "card" (e.g. a
   * `labeledSection` for a rounded pill) and the list selects,
   * scrolls, and routes clicks in item units — one card per item.
   * Pass `items: []` alongside. All cards share a uniform height; a
   * click anywhere on a card fires the same `select` event a classic
   * row would. Interactive widgets nested in a card aren't routed. */
  itemSpecs?: WidgetSpec[];
  itemKeys?: string[];
  selectedIndex?: number;
  /** Rows this widget windows to. Omit to auto-size from the
   * host-known panel height (recommended); an explicit value pins
   * the window. */
  visibleRows?: number;
  /** Whether Tab / Shift+Tab lands focus on this list. Default
   * true (matches other tabbable widgets). Set to false in
   * picker-style layouts where the filter input stays focused
   * and Up/Down forward to the list via host smart-keys —
   * skipping the list in the Tab cycle keeps focus jumping
   * straight between filter and action buttons. */
  focusable?: boolean;
  key?: string;
}): WidgetSpec {
  return {
    kind: "list",
    items: options.items,
    itemSpecs: options.itemSpecs ?? [],
    itemKeys: options.itemKeys ?? [],
    selectedIndex: options.selectedIndex ?? -1,
    visibleRows: options.visibleRows,
    focusable: options.focusable ?? true,
    key: options.key,
  };
}

/** Construct one node in a `Tree` widget's flat-list spec. The
 * plugin emits a depth-first traversal of its hierarchy, one
 * `treeNode(...)` per node, plus a parallel `itemKeys` array for
 * stable per-row identifiers. `depth` controls indent (`depth * 2`
 * spaces); `hasChildren: true` renders a disclosure glyph (`▶`/`▼`)
 * with a click-to-expand hit area in the indent column. The host
 * filters out descendants of collapsed nodes when rendering. */
export function treeNode(
  text: TextPropertyEntry,
  options?: {
    depth?: number;
    hasChildren?: boolean;
    checked?: boolean;
    /** Continuation lines rendered below `text` when the parent
     * `tree` has `itemHeight > 1`. Each entry is one screen row,
     * auto-indented to align under the primary line's body. The host
     * renders at most `itemHeight - 1` of them and blank-pads shorter
     * nodes so every card is the same height. Ignored when the tree
     * is single-line (`itemHeight === 1`). */
    extraLines?: TextPropertyEntry[];
  },
): TreeNode {
  // `checked` is intentionally Optional<bool>, not a default-false
  // boolean: omitting it (== undefined here) maps to host-side
  // `None`, which means "no checkbox glyph". Per-node opt-in keeps
  // checkable trees mixing checkbox-bearing rows with rows that
  // shouldn't render one (e.g. a header that doesn't itself have
  // a meaningful checked state).
  const node: TreeNode = {
    text,
    depth: options?.depth ?? 0,
    hasChildren: options?.hasChildren ?? false,
  };
  if (options?.checked !== undefined) {
    node.checked = options.checked;
  }
  if (options?.extraLines && options.extraLines.length > 0) {
    node.extraLines = options.extraLines;
  }
  return node;
}

/** Hierarchical tree with host-managed expand/collapse, selection,
 * scrolling, and click routing.
 *
 * The plugin emits its hierarchy as a flat list of `TreeNode`s
 * (depth-first); the host filters out descendants of collapsed
 * nodes at render time. **Toggling expansion is host-owned** —
 * `Right`/`Left` arrow keys and disclosure clicks update host
 * instance state without the plugin re-emitting. Plugins that
 * need to react to expansion changes listen for
 * `widget_event` `eventType: "expand"`.
 *
 * Click on the disclosure column → `expand` event. Click on the
 * row body → `select` event. Enter/Space on the focused tree →
 * `activate` event with the currently-selected node. Up/Down move
 * selection through the visible (un-collapsed) flat list.
 *
 * `key` is required for any Tree that should preserve scroll +
 * selection + expansion across re-renders. */
export function tree(options: {
  nodes: TreeNode[];
  itemKeys?: string[];
  selectedIndex?: number;
  /** Rows this widget windows to. Omit to auto-size from the
   * host-known panel height (recommended); an explicit value pins
   * the window. */
  visibleRows?: number;
  /** Initial expanded keys; subsequent expansion changes are
   * host-owned and don't read this field. Use
   * `panel.setExpandedKeys(...)` to override host state after
   * mount. */
  expandedKeys?: string[];
  /** When true, every node with `checked: true | false` renders
   * a `[v]` / `[ ]` glyph and emits a `toggle` hit area. Click on
   * the glyph fires `widget_event` `eventType: "toggle"` with
   * `payload: { key, index, checked: <new> }`; the plugin updates
   * its model and pushes the new state back via
   * `panel.setCheckedKeys(...)`. */
  checkable?: boolean;
  /** Fixed number of screen rows per node. `1` (default) is the
   * classic single-line tree. A larger value renders every node as a
   * card that tall — the node's primary `text` plus its `extraLines`,
   * blank-padded to this height. Scroll/selection stay node-based, so
   * single-line trees are unaffected. */
  itemHeight?: number;
  /** When true (and `itemHeight > 1`), card nodes — leaves carrying
   * `extraLines` — render inside a rounded `╭─…─╮` border spanning the
   * panel width (`itemHeight + 2` rows), while non-card nodes (folder
   * headers) render as plain single rows instead of being blank-padded
   * to the card height. */
  cardBorders?: boolean;
  /** Columns of indent per depth level. `2` (default) is the classic
   * tree step. Narrow panels that nest several levels deep can drop to
   * `1` to spend those columns on the node text instead; the disclosure
   * glyph (or the blank standing in for one) still marks each level. */
  indentCols?: number;
  key?: string;
}): WidgetSpec {
  return {
    kind: "tree",
    nodes: options.nodes,
    itemKeys: options.itemKeys ?? [],
    selectedIndex: options.selectedIndex ?? -1,
    visibleRows: options.visibleRows,
    expandedKeys: options.expandedKeys ?? [],
    checkable: options.checkable ?? false,
    itemHeight: options.itemHeight ?? 1,
    cardBorders: options.cardBorders ?? false,
    indentCols: options.indentCols ?? 2,
    key: options.key,
  };
}

/** Text input — single-line (`rows: 1`, default) or multi-line
 * (`rows >= 2`). The host owns `value` and `cursorByte` as instance
 * state once the widget renders for the first time; multi-line
 * widgets also own a vertical scroll offset.
 *
 * Single-line (`rows: 1`) renders as `[value]` (or `Label: [value]`
 * if `label` is provided), with `fieldWidth` giving a constant
 * visible width — short values pad with trailing spaces, long
 * values head-truncate with `…` so the tail (where the cursor
 * usually is) stays visible. Smart-key dispatch: `Enter` advances
 * focus; `Up`/`Down` are no-ops; `Home`/`End` jump to the start /
 * end of the whole value.
 *
 * Multi-line (`rows >= 2`) renders as a `rows`-tall block, padded
 * with blanks when `value` is shorter. Smart-key dispatch differs:
 * `Enter` inserts a newline; `Up`/`Down` move between lines;
 * `Home`/`End` are line-relative; long lines tail-truncate with `…`
 * per-line. Plugins that want `Enter` to submit can intercept the
 * key in their own mode binding and call
 * `panel.command(focusAdvance(1))` instead.
 *
 * `key` is required for any text widget that should preserve its
 * value, cursor, and scroll across re-renders.
 *
 * Prefer the `textInput()` / `textArea()` helpers below when the
 * intent is unambiguous — they call this with the right `rows`. */
export function text(
  options: {
    value?: string;
    cursorByte?: number;
    focused?: boolean;
    label?: string;
    placeholder?: string;
    /** Number of visible rows of editing region. `1` (default) =
     * single-line behaviour; `>= 2` = multi-line behaviour. */
    rows?: number;
    /** Visible column width. `0` (default) = auto-fit (single-line)
     * or panel width (multi-line). */
    fieldWidth?: number;
    /** Single-line soft cap on visible chars after the
     * `fieldWidth` pad. `0` = no cap. Ignored when `rows >= 2`. */
    maxVisibleChars?: number;
    /** Stretch the visible field to fill the enclosing
     * container's width. Overrides `fieldWidth` when set:
     * the renderer sizes the bracketed region to
     * `panelWidth - label_overhead - bracket_overhead`. Pair
     * with `labeledSection(...)` to get a uniformly full-width
     * fieldset look. */
    fullWidth?: boolean;
    /** Initial completion candidates. Use the
     * `setCompletions(widgetKey, items)` mutation for live
     * updates — the spec field seeds the host's instance state
     * on first render only. Empty = no popup. See
     * `WidgetSpec::Text.completions` (Rust) for the rendering
     * + keyboard semantics. */
    completions?: string[];
    /** Paint the caret as a REVERSED block cell (modal surfaces
     * without a hardware cursor). Default false. */
    blockCaret?: boolean;
    /** Seed selection byte range within `value` (shown with the
     * selection bg while focused). `-1` = none. Seed-only, like
     * `cursorByte`. */
    selStart?: number;
    selEnd?: number;
    /** Form label-column width. When `> 0` (and `label` is set) the
     * single-line field pads the label to this column and separates it
     * from the value with `: `, so a column of controls aligns their
     * value cells. `0` (default) keeps the compact `label [value]`. */
    labelWidth?: number;
    /** Reject every mutating operation (typing, Backspace/Delete, Cut,
     * Paste) while keeping caret motion, selection, and Copy. Implied
     * by `markdown`. */
    readOnly?: boolean;
    /** Render `value` as a markdown document (multi-line only): the
     * host renders it through the same engine as LSP hover docs and
     * word-wraps to the widget's width. Forcibly read-only; the caret,
     * selection, and Copy operate on the rendered plain text. */
    markdown?: boolean;
    key?: string;
  } = {},
): WidgetSpec {
  return {
    kind: "text",
    value: options.value ?? "",
    cursorByte: options.cursorByte ?? -1,
    focused: options.focused ?? false,
    label: options.label ?? "",
    placeholder: options.placeholder,
    rows: options.rows ?? 1,
    fieldWidth: options.fieldWidth ?? 0,
    maxVisibleChars: options.maxVisibleChars ?? 0,
    fullWidth: options.fullWidth ?? false,
    completions: options.completions ?? [],
    blockCaret: options.blockCaret ?? false,
    selStart: options.selStart ?? -1,
    selEnd: options.selEnd ?? -1,
    labelWidth: options.labelWidth ?? 0,
    readOnly: options.readOnly ?? false,
    markdown: options.markdown ?? false,
    key: options.key,
  };
}

/** Multi-line text widget. Thin wrapper over `text({ rows, ... })`
 * for ergonomic intent — renders as a `rows`-tall block with
 * Enter-inserts-newline / Up-Down-line-nav semantics. Default `rows`
 * is 5; pass `rows: N` to override. */
export function textArea(
  options: {
    value?: string;
    cursorByte?: number;
    focused?: boolean;
    label?: string;
    placeholder?: string;
    /** Visible rows of editing area; default 5. */
    rows?: number;
    /** Visible column width; `0` = use panel width. */
    fieldWidth?: number;
    fullWidth?: boolean;
    key?: string;
  } = {},
): WidgetSpec {
  return text({
    ...options,
    rows: options.rows ?? 5,
  });
}

/** Single-line text widget. Thin wrapper over `text({ rows: 1,
 * ... })` matching the historical `textInput(value, opts)`
 * signature. Renders as `[value]` (or `Label: [value]` if `label`
 * is provided), with Enter-advances-focus semantics. */
export function textInput(
  value: string,
  options?: {
    cursorByte?: number;
    focused?: boolean;
    label?: string;
    placeholder?: string;
    /** Soft truncation cap (legacy). Prefer `fieldWidth`. */
    maxVisibleChars?: number;
    /** Constant visible width inside the brackets. */
    fieldWidth?: number;
    /** See `text({ fullWidth })`. */
    fullWidth?: boolean;
    key?: string;
  },
): WidgetSpec {
  return text({
    value,
    cursorByte: options?.cursorByte,
    focused: options?.focused,
    label: options?.label,
    placeholder: options?.placeholder,
    rows: 1,
    fieldWidth: options?.fieldWidth,
    maxVisibleChars: options?.maxVisibleChars,
    fullWidth: options?.fullWidth,
    key: options?.key,
  });
}

/** Reserve a rectangle in the layout for the host to natively
 * render the editor window identified by `windowId`. The widget
 * itself emits `rows` blank lines so layout reserves the space;
 * the host's paint path then overlays the live window UI (split
 * tree, terminals, syntax highlighting, decorations) into the
 * reserved rectangle.
 *
 * `windowId` of 0 (or any unknown id) renders the placeholder
 * blanks without dispatching the per-window paint. */
export function windowEmbed(options: {
  windowId: number;
  rows: number;
  key?: string;
}): WidgetSpec {
  return {
    kind: "windowEmbed",
    windowId: options.windowId,
    rows: options.rows,
    key: options.key,
  };
}

/** Group a single child widget inside a rounded, thin border
 * with `label` printed as a top-left legend (HTML
 * `<fieldset>` semantics). The host renders three rows:
 *
 *     ╭─ Label ──────────────────╮
 *     │ <child rendered content> │
 *     ╰──────────────────────────╯
 *
 * The section always spans the parent's available width. The
 * child is rendered with the inner width (parent width minus
 * 4 columns of border + padding), so child widgets that honour
 * `fullWidth: true` size themselves to fill the inner area.
 * Focus, hit areas and cursor positions bubble up from the
 * child unchanged, shifted by the border offset. */
export function labeledSection(options: {
  label?: string;
  child: WidgetSpec;
  /** When this section is a Block child of a Row, request a
   * specific share of the row's width as a percentage (1..=100).
   * Out-of-range values fall back to the equal-split default.
   * Useful for picker-style layouts: a narrow list pane next to
   * a wide preview pane. */
  widthPct?: number;
  key?: string;
}): WidgetSpec {
  return {
    kind: "labeledSection",
    label: options.label ?? "",
    child: options.child,
    widthPct: options.widthPct,
    key: options.key,
  };
}

/** Float `child` over the rest of the layout instead of consuming
 * vertical space. Place inside a `Col` at the position where you
 * want the overlay to anchor — at paint time the child renders
 * at that row but DOES NOT push the rows below it down. Used for
 * dropdown completions, tooltips, hover popups — anything that
 * should appear next to a focused widget without reflowing the
 * panel each time it shows or hides.
 *
 * Hit-testing: overlays paint on top, so clicks inside the
 * overlay's region go to the overlay (not what's underneath).
 * Tab cycle: the child IS walked for tabbable keys — give it a
 * `key` if you want focus to reach it, or leave it keyless to
 * keep it out of the cycle (the typical popup case). */
export function overlay(
  child: WidgetSpec,
  options?: { key?: string },
): WidgetSpec {
  return {
    kind: "overlay",
    child,
    key: options?.key,
  };
}

/** A popup layer: the child paints OVER the panel's rows (like
 * `overlay`) as a first-class tree node — part of the layout-box
 * tree, pointer-opaque, and slated to grow screen-space anchoring
 * so a popup near a panel edge is not clipped. Prefer this over
 * `overlay` for new popup UI. */
export function popup(
  child: WidgetSpec,
  options?: {
    key?: string;
    /** Anchor [row, col] in panel-inner coordinates the popup drops
     * from (host resolves the final rect: below the anchor, flipping
     * above near the frame edge, clamped on screen). */
    anchor?: [number, number];
    /** Escape the panel's clipping and paint at screen level (what
     * the dropdown pop-over does). Default: panel-clipped. */
    screenSpace?: boolean;
  },
): WidgetSpec {
  return {
    kind: "popup",
    child,
    key: options?.key,
    anchor: options?.anchor,
    screenSpace: options?.screenSpace ?? false,
  };
}

/** A focus and event scope around a subtree — the unit for composing
 * reusable pieces. Renders its child transparently (no chrome of its
 * own); Tab / Shift+Tab cycle among the focusable widgets INSIDE the
 * component instead of the whole panel, so a picker or dialog subtree
 * keeps its own ring without hand-written focus code. Give it a `key`
 * for stable identity (keyed reconciliation, targeted swaps). */
export function component(
  child: WidgetSpec,
  options?: { key?: string },
): WidgetSpec {
  return {
    kind: "component",
    child,
    key: options?.key,
  };
}

// =============================================================================
// HintEntry parsing — for the legacy `Tab:section  Esc:close` format
// shipped in existing plugin i18n bundles.
// =============================================================================

/** Parse a hint string of the form `<keys>:<label>  <keys>:<label> ...`.
 *
 * The separator between entries defaults to two-or-more spaces (matching
 * what existing i18n bundles use). The separator between keys and label
 * within an entry is a colon.
 *
 * Empty input yields an empty array. Entries without a colon are
 * preserved with empty label. */
export function parseHintString(
  s: string,
  options?: { entrySep?: RegExp; keyLabelSep?: string },
): HintEntry[] {
  if (!s) return [];
  const entrySep = options?.entrySep ?? /\s{2,}/;
  const keyLabelSep = options?.keyLabelSep ?? ":";
  const parts = s.split(entrySep).filter((p) => p.length > 0);
  return parts.map((part) => {
    const idx = part.indexOf(keyLabelSep);
    if (idx < 0) {
      return { keys: part, label: "" };
    }
    return {
      keys: part.slice(0, idx).trim(),
      label: part.slice(idx + keyLabelSep.length).trim(),
    };
  });
}

// =============================================================================
// WidgetPanel — mount-once-update-many wrapper around the IPC trio.
// =============================================================================

/** A handle to a mounted widget panel. Construct one per virtual
 * buffer that should host widget-rendered content; call `set(spec)`
 * on every render; call `unmount()` when the buffer is closed.
 *
 * The first `set()` issues `mountWidgetPanel`; subsequent calls
 * issue `updateWidgetPanel`. Idempotent re-mount is guaranteed by the
 * host (see `WidgetRegistry::mount`). */
export class WidgetPanel {
  private mounted = false;
  private readonly panelId: number;
  private readonly bufferId: number;

  constructor(bufferId: number, panelId?: number) {
    this.bufferId = bufferId;
    this.panelId = panelId ?? allocatePanelId();
  }

  /** Returns the plugin-allocated panel id, useful for routing
   * widget events back through `editor.on("widget_event", ...)`. */
  id(): number {
    return this.panelId;
  }

  /** Render or re-render the panel against the given spec.
   * Cheap to call on every state change; the host reconciles. */
  set(spec: WidgetSpec): boolean {
    // deno-lint-ignore no-explicit-any
    const editor = (globalThis as any).editor;
    if (!this.mounted) {
      this.mounted = true;
      return editor.mountWidgetPanel(this.panelId, this.bufferId, spec);
    }
    return editor.updateWidgetPanel(this.panelId, spec);
  }

  /** Tear down the panel. The plugin retains ownership of the
   * underlying virtual buffer. Subsequent `set()` calls re-mount. */
  unmount(): boolean {
    if (!this.mounted) return true;
    this.mounted = false;
    // deno-lint-ignore no-explicit-any
    const editor = (globalThis as any).editor;
    return editor.unmountWidgetPanel(this.panelId);
  }

  /** Route a key/nav action to the focused widget in this panel.
   * The host computes the result on the focused widget's kind and
   * fires `widget_event` as appropriate. See `WidgetAction` for
   * the action shapes. */
  command(action: WidgetAction): boolean {
    // deno-lint-ignore no-explicit-any
    const editor = (globalThis as any).editor;
    return editor.widgetCommand(this.panelId, action);
  }

  /** Apply a targeted mutation in place — the IPC fast path.
   * Use instead of `set(spec)` when only one widget changes;
   * the host applies the mutation directly and re-renders
   * without re-transmitting the full spec. See `WidgetMutation`
   * for the shapes. The typed wrappers below cover the common
   * cases. */
  mutate(mutation: WidgetMutation): boolean {
    // deno-lint-ignore no-explicit-any
    const editor = (globalThis as any).editor;
    return editor.widgetMutate(this.panelId, mutation);
  }

  /** Set a `TextInput`'s value (and optionally cursor byte).
   * Mutates host instance state; doesn't re-transmit the full
   * spec. */
  setValue(widgetKey: string, value: string, cursorByte?: number): boolean {
    return this.mutate({ kind: "setValue", widgetKey, value, cursorByte });
  }

  /** Set a `Toggle`'s checked state. */
  setChecked(widgetKey: string, checked: boolean): boolean {
    return this.mutate({ kind: "setChecked", widgetKey, checked });
  }

  /** Set a `List`'s selected index. */
  setSelectedIndex(widgetKey: string, index: number): boolean {
    return this.mutate({ kind: "setSelectedIndex", widgetKey, index });
  }

  /** Set a `Number` widget's value. Clamped to the widget's
   * `[min, max]` on the next render. Does not fire a `change`
   * event (a plugin-driven set is not a user edit). */
  setNumber(widgetKey: string, value: number): boolean {
    return this.mutate({ kind: "setNumber", widgetKey, value });
  }

  /** Set a `Dropdown` widget's selected index. Clamped to the option
   * set on the next render; does not fire a `change` event. */
  setDropdown(widgetKey: string, index: number): boolean {
    return this.mutate({ kind: "setDropdown", widgetKey, index });
  }

  /** Replace a `DualList` widget's ordered included set. Unknown
   * values are dropped on the next render; does not fire a `change`
   * event. */
  setDualIncluded(widgetKey: string, included: string[]): boolean {
    return this.mutate({ kind: "setDualIncluded", widgetKey, included });
  }

  /** Update a Text widget's completion popup candidates. Empty
   * `items` closes the popup; non-empty opens it and resets the
   * host-managed selection to index 0. The host repaints the
   * popup on its own; the plugin doesn't need to follow up with
   * an `update(spec)` call. */
  setCompletions(
    widgetKey: string,
    items: Array<string | { value: string; kind?: string }>,
  ): boolean {
    return this.mutate({ kind: "setCompletions", widgetKey, items });
  }

  /** Replace a `List`'s items + parallel `itemKeys`. */
  setItems(
    widgetKey: string,
    items: TextPropertyEntry[],
    itemKeys: string[] = [],
  ): boolean {
    return this.mutate({ kind: "setItems", widgetKey, items, itemKeys });
  }

  /** Replace a `Tree`'s expanded-keys instance state. The host
   * normally owns expansion (Right/Left arrows + disclosure
   * clicks); use this when a non-user action drives expansion
   * (e.g. "expand all", reveal-on-search). */
  setExpandedKeys(widgetKey: string, keys: string[]): boolean {
    return this.mutate({ kind: "setExpandedKeys", widgetKey, keys });
  }

  /** Stamp `checked` onto every node in the named `Tree` whose
   * `itemKey` appears in `keys`. Used by the `toggle` event
   * handler to push the post-click state back without a full spec
   * re-emit. Nodes whose existing `checked` is `undefined` (no
   * checkbox glyph) are unchanged. */
  setCheckedKeys(widgetKey: string, checked: boolean, keys: string[]): boolean {
    return this.mutate({ kind: "setCheckedKeys", widgetKey, checked, keys });
  }

  /** Append `newNodes` (and parallel `newItemKeys`) to an existing
   * `Tree`'s node list — the streaming-friendly counterpart to
   * `setItems`. Existing selection, scroll, and expansion state are
   * preserved; the renderer simply has more tail to paint on the next
   * cycle. Cheap relative to a full spec re-emit for plugins that
   * stream large result sets (e.g. a project-wide grep). */
  appendTreeNodes(
    widgetKey: string,
    newNodes: TreeNode[],
    newItemKeys: string[] = [],
  ): boolean {
    return this.mutate({
      kind: "appendTreeNodes",
      widgetKey,
      newNodes,
      newItemKeys,
    });
  }

  /** Replace the entries of a `Raw` widget identified by `widgetKey`.
   *
   * Use this when a small piece of panel chrome (a label, a header,
   * a status line) needs to change but the rest of the spec is
   * unchanged — calling `set(...)` to push the whole spec just to
   * flip a few characters would force a `js_to_json` walk of every
   * other widget (and every `Tree` node) in the panel, which can
   * block the JS thread for hundreds of ms on large panels. */
  setRawEntries(widgetKey: string, entries: TextPropertyEntry[]): boolean {
    return this.mutate({ kind: "setRawEntries", widgetKey, entries });
  }

  /** Set the panel's focused widget by key. Passing a key that isn't
   * a current tabbable is harmless — the next render clamps focus to
   * the first tabbable. */
  setFocusKey(widgetKey: string): boolean {
    return this.mutate({ kind: "setFocusKey", widgetKey });
  }
}

// =============================================================================
// FloatingWidgetPanel — mount-once-update-many wrapper for centered
// floating overlays (no virtual buffer required).
// =============================================================================

/** A handle to a floating widget panel — a modal-ish overlay
 * rendered in a centered frame on top of the editor, dimming the
 * background. Unlike `WidgetPanel`, no virtual buffer is needed;
 * the host owns the rect and paints the spec inside it.
 *
 * `mount({ widthPct, heightPct })` mounts the panel and renders
 * the spec; `update(spec)` re-renders against the previous instance
 * state; `unmount()` tears it down. The host routes keys to the
 * focused widget automatically while a floating panel is up: Esc
 * unmounts and fires a `widget_event` "cancel"; Tab / Enter /
 * arrows / Backspace / printable chars route through the same
 * smart-key dispatch as `WidgetPanel.command(key(...))`. */
export class FloatingWidgetPanel {
  private mounted = false;
  private readonly panelId: number;

  constructor(panelId?: number) {
    this.panelId = panelId ?? allocatePanelId();
  }

  /** Returns the plugin-allocated panel id, useful for routing
   * widget events back through `editor.on("widget_event", ...)`. */
  id(): number {
    return this.panelId;
  }

  /** Mount the panel as a centered overlay sized by `widthPct` /
   * `heightPct` (percent of terminal, clamped 1..=100). Cheap to
   * call repeatedly with a new spec — re-mounting replaces the
   * existing panel. */
  mount(
    spec: WidgetSpec,
    options: {
      widthPct?: number;
      heightPct?: number;
      asDock?: boolean;
      /** When true, the host reserves a two-column gutter on every
       * focusable control for the `▸ ` focus marker, so the focused
       * control is legible from a plain terminal capture and the
       * layout stays constant as focus moves. Default false. */
      focusMarker?: boolean;
      /** Native modal-frame title. When set, the host draws a title bar
       * into the centered panel's top border (the dialog's *shell* —
       * you no longer fake a title with a `labeledSection` border inside
       * the spec). Ignored for `asDock`. Omitted → untitled frame. */
      title?: string;
      /** Native modal-frame close button. When true, the host draws a
       * `[×]` at the centered panel's top-right; clicking it dismisses
       * the panel exactly like Esc / Cancel (fires the panel's `cancel`
       * `widget_event`). Ignored for `asDock`. Default false. */
      closable?: boolean;
      /** Mount without taking keyboard focus — the editor keeps the
       * keys. Use this instead of mount-then-`blur` for a panel that
       * must never own the keyboard (command dispatch is budgeted
       * across frames, so a follow-up blur can land a tick late).
       * Default false: mounting focuses the panel. */
      startBlurred?: boolean;
    } = {},
  ): boolean {
    // deno-lint-ignore no-explicit-any
    const editor = (globalThis as any).editor;
    const wp = options.widthPct ?? 60;
    const hp = options.heightPct ?? 40;
    this.mounted = true;
    return editor.mountFloatingWidget(
      this.panelId,
      spec,
      wp,
      hp,
      options.asDock ?? false,
      options.focusMarker ?? false,
      options.title ?? "",
      options.closable ?? false,
      options.startBlurred ?? false,
    );
  }

  /** Re-render the panel against the given spec; instance state on
   * keyed widgets is preserved. No-op when not mounted. */
  update(spec: WidgetSpec): boolean {
    if (!this.mounted) return false;
    // deno-lint-ignore no-explicit-any
    const editor = (globalThis as any).editor;
    return editor.updateFloatingWidget(this.panelId, spec);
  }

  /** Tear down the panel and let the editor return to its normal
   * key/click routing. */
  unmount(): boolean {
    if (!this.mounted) return true;
    this.mounted = false;
    // deno-lint-ignore no-explicit-any
    const editor = (globalThis as any).editor;
    return editor.unmountFloatingWidget(this.panelId);
  }

  /** Route a key/nav action to the focused widget. The host
   * automatically routes keystrokes while a floating panel is up,
   * so plugins rarely need to call this directly — it's exposed
   * for symmetry with `WidgetPanel`. */
  command(action: WidgetAction): boolean {
    // deno-lint-ignore no-explicit-any
    const editor = (globalThis as any).editor;
    return editor.widgetCommand(this.panelId, action);
  }

  /** Apply a targeted mutation in place — the IPC fast path. */
  mutate(mutation: WidgetMutation): boolean {
    // deno-lint-ignore no-explicit-any
    const editor = (globalThis as any).editor;
    return editor.widgetMutate(this.panelId, mutation);
  }

  setValue(widgetKey: string, value: string, cursorByte?: number): boolean {
    return this.mutate({ kind: "setValue", widgetKey, value, cursorByte });
  }

  setChecked(widgetKey: string, checked: boolean): boolean {
    return this.mutate({ kind: "setChecked", widgetKey, checked });
  }

  setSelectedIndex(widgetKey: string, index: number): boolean {
    return this.mutate({ kind: "setSelectedIndex", widgetKey, index });
  }

  /** Set a `Number` widget's value. Clamped to `[min, max]` on the
   * next render; does not fire a `change` event. */
  setNumber(widgetKey: string, value: number): boolean {
    return this.mutate({ kind: "setNumber", widgetKey, value });
  }

  /** Set a `Dropdown` widget's selected index. Clamped on the next
   * render; does not fire a `change` event. */
  setDropdown(widgetKey: string, index: number): boolean {
    return this.mutate({ kind: "setDropdown", widgetKey, index });
  }

  /** Replace a `DualList` widget's ordered included set. Unknown
   * values dropped on the next render; does not fire `change`. */
  setDualIncluded(widgetKey: string, included: string[]): boolean {
    return this.mutate({ kind: "setDualIncluded", widgetKey, included });
  }

  /** Update a Text widget's completion popup candidates. Empty
   * `items` closes the popup; non-empty opens it and resets the
   * host-managed selection to index 0. */
  setCompletions(
    widgetKey: string,
    items: Array<string | { value: string; kind?: string }>,
  ): boolean {
    return this.mutate({ kind: "setCompletions", widgetKey, items });
  }

  setItems(
    widgetKey: string,
    items: TextPropertyEntry[],
    itemKeys: string[] = [],
  ): boolean {
    return this.mutate({ kind: "setItems", widgetKey, items, itemKeys });
  }

  setExpandedKeys(widgetKey: string, keys: string[]): boolean {
    return this.mutate({ kind: "setExpandedKeys", widgetKey, keys });
  }

  setCheckedKeys(widgetKey: string, checked: boolean, keys: string[]): boolean {
    return this.mutate({ kind: "setCheckedKeys", widgetKey, checked, keys });
  }

  /** Set the panel's focused widget by key. Passing a key that isn't
   * a current tabbable is harmless — the next render clamps focus to
   * the first tabbable. */
  setFocusKey(widgetKey: string): boolean {
    return this.mutate({ kind: "setFocusKey", widgetKey });
  }
}

// =============================================================================
// WidgetAction builders — convenience wrappers around `panel.command(...)`.
// Plugin's mode bindings call these for keys handled by the widget layer.
// =============================================================================

/** Cycle focus through the panel's tabbable widgets. `delta=+1`
 * for Tab, `-1` for Shift+Tab. Wraps at the ends. */
export function focusAdvance(delta: number): WidgetAction {
  return { kind: "focusAdvance", delta };
}

/** Activate the focused widget (Enter on Button → "activate"
 * event; Enter on Toggle → "toggle" event). No-op for other
 * widget kinds. */
export function activate(): WidgetAction {
  return { kind: "activate" };
}

/** Move the focused List's selection by `delta`. Plugin listens
 * for `widget_event` "select" to mirror back into its model. */
export function selectMove(delta: number): WidgetAction {
  return { kind: "selectMove", delta };
}

/** Apply a non-printable editing key to the focused TextInput:
 * `"Backspace"`, `"Delete"`, `"Left"`, `"Right"`, `"Home"`,
 * `"End"`. Fires `widget_event` "change" with the new value +
 * cursorByte. */
export function textInputKey(key: string): WidgetAction {
  return { kind: "textInputKey", key };
}

/** Append printable text at the focused TextInput's cursor.
 * Fires `widget_event` "change" with the new value + cursorByte.
 * Used for the `mode_text_input` fall-through path. */
export function textInputChar(text: string): WidgetAction {
  return { kind: "textInputChar", text };
}

/** Smart-key dispatch — routes the keystroke to the right widget
 * action based on the focused widget's kind. Plugin's mode bindings
 * use this rather than picking the right action themselves: bind
 * Tab/Shift+Tab/Enter/Space/Backspace/Delete/Left/Right/Up/Down/
 * Home/End all to one handler that calls `panel.command(key("Tab"))`.
 *
 * See `WidgetAction::Key` (Rust) for the full dispatch table. */
export function key(name: string): WidgetAction {
  return { kind: "key", key: name };
}

// =============================================================================
// Panel-id allocation.
// =============================================================================

// Panel ids are plugin-local: the host keys panels by (plugin, id) and
// delivers widget_events only to the owning plugin, so a plain per-context
// counter is all the uniqueness a panel id ever needs.
let nextPanelId = 1;
function allocatePanelId(): number {
  return nextPanelId++;
}
