Skip to main content

fresh/widgets/
render.rs

1//! Render a `WidgetSpec` tree into `Vec<TextPropertyEntry>`.
2//!
3//! This is the path from declarative spec to the bytes the existing
4//! virtual-buffer pipeline already knows how to display. By going
5//! through `TextPropertyEntry`, widgets paint via exactly the same
6//! renderer that today's `setVirtualBufferContent` uses — no parallel
7//! render path. This is what makes the new widget API additive: the
8//! buffer mid-bytes are indistinguishable from hand-rolled output.
9//!
10//! v1 dispatches on four kinds:
11//!   * `Row` — children laid out left-to-right within a single line
12//!     (the result is one `TextPropertyEntry`).
13//!   * `Col` — children stacked vertically (the result is one
14//!     `TextPropertyEntry` per child output line).
15//!   * `HintBar` — keyboard-hint footer (one `TextPropertyEntry`).
16//!   * `Raw` — pass-through (zero interpretation; plugin's entries
17//!     flow through unchanged).
18//!
19//! Future kinds (`Toggle`, `Button`, `TextInput`, `List`, `Tree`,
20//! `Layer`, `Transient`, `Table`) extend the dispatch without
21//! changing the public function signature.
22
23use crate::widgets::registry::{HitArea, WidgetInstanceState};
24use fresh_core::api::{
25    ButtonKind, HintEntry, OverlayColorSpec, OverlayOptions, TreeNode, WidgetSpec,
26};
27use fresh_core::text_property::{InlineOverlay, OffsetUnit, TextPropertyEntry};
28use serde_json::json;
29use std::collections::{HashMap, HashSet};
30
31// Theme keys used by the v1 widget renderers. Centralized so future
32// "role-based" theming (§7 of the design doc) has one place to
33// substitute the role→key mapping.
34const KEY_HELP_KEY_FG: &str = "ui.help_key_fg";
35// Foreground of a checked Toggle's `[v]` glyph. `ui.help_key_fg`
36// is the "keyboard-key / highlight on a popup body" theme key —
37// every shipped theme picks a colour that contrasts with
38// `ui.popup_bg`. The previous choice (`ui.tab_active_fg`) was
39// designed to contrast with `tab_active_bg`, not the popup body;
40// in `high-contrast` both ended up black so the `[v]` glyph
41// vanished on every unfocused toggle. `help_key_fg` keeps the
42// emphasis intent (a bright accent colour) while reliably
43// surviving the popup background.
44const KEY_TOGGLE_ON_FG: &str = "ui.help_key_fg";
45// Selection/focus highlight for widgets inside floating panels
46// (list rows, tree nodes, buttons). Originally pointed at
47// `ui.menu_active_{fg,bg}` which defaults to rgb(255,255,255) on
48// rgb(60,60,60) — a 30-unit gray-on-gray bump that quantizes flat
49// on 256-colour terminals and is hard to see on dark themes (the
50// surrounding panel bg is rgb(30,30,30)). `ui.popup_selection_{fg,bg}`
51// is the theme key designed for "selected item inside a popup
52// surface" — white on rgb(58,79,120) blue, ~6× the perceptual
53// contrast — and it's the same key the prompt/palette already uses
54// so the cue reads consistently across selection UIs.
55const KEY_FOCUSED_FG: &str = "ui.popup_selection_fg";
56const KEY_FOCUSED_BG: &str = "ui.popup_selection_bg";
57// Leading marker prepended to the *focused* control (button /
58// toggle / text input) so "which control is focused" is legible
59// from a plain terminal capture — not just from the (theme-
60// dependent, capture-invisible) `popup_selection` background or
61// the hardware cursor. One glyph + a trailing space = two display
62// columns. Only ever applied to the single focused widget, so at
63// most one `▸` is on screen at a time; combined with the
64// `popup_selection` fg/bg flip it makes focus unmistakable, and
65// distinct from a `Primary` button's standing bold accent (which
66// carries no marker). See `render_button` / `render_toggle` /
67// `render_widget_text`.
68const FOCUS_MARKER: &str = "▸ ";
69// The unfocused counterpart to `FOCUS_MARKER`: two spaces, the same
70// two display columns the marker occupies, so reserving the gutter
71// keeps control widths identical whether or not they're focused.
72const FOCUS_GUTTER_BLANK: &str = "  ";
73
74/// The two-column gutter prefix a focusable control leads with when
75/// the current render reserves the focus-marker gutter
76/// ([`MARKER_GUTTER`]): `▸ ` for the focused control, two spaces for
77/// every other control. Returns `""` when the panel didn't opt into
78/// the gutter, so non-marker panels render byte-for-byte as before.
79fn focus_gutter_prefix(focused: bool) -> &'static str {
80    if !marker_gutter_enabled() {
81        ""
82    } else if focused {
83        FOCUS_MARKER
84    } else {
85        FOCUS_GUTTER_BLANK
86    }
87}
88// `ui.status_error_indicator_fg` defaults to white (designed as
89// the text-on-red status badge), so using it as a standalone fg
90// renders invisible against the panel bg. The diagnostic.error_fg
91// key is the canonical "red text" theme slot.
92const KEY_DANGER_FG: &str = "diagnostic.error_fg";
93const KEY_INPUT_BG: &str = "ui.prompt_bg";
94// Background tint for the selection span inside a widget Text
95// input. Distinct from the buffer's `ui.selection_bg` because
96// widget inputs sit on top of the `ui.prompt_bg` field-bg overlay
97// and the contrast needs to read against that tint, not the
98// editor surface.
99const KEY_TEXT_INPUT_SELECTION_BG: &str = "ui.text_input_selection_bg";
100// Placeholder text uses the whitespace-indicator key — a dimmer
101// grey than `ui.menu_disabled_fg` (themes ship ~RGB(70,70,70)
102// vs ~RGB(100,100,100) for disabled menu items), so hint copy
103// reads as background guidance rather than a half-active value.
104const KEY_PLACEHOLDER_FG: &str = "editor.whitespace_indicator_fg";
105// Section-legend tint. `ui.help_key_fg` is the same key the
106// hint-bar uses to highlight keys against panel bg, so we know
107// it's tuned for readability against the same surface a
108// LabeledSection sits on.
109const KEY_SECTION_LABEL_FG: &str = "ui.help_key_fg";
110// Dim separator that replaces the input's bottom border when the
111// completion popup is open. `ui.menu_disabled_fg` is the closest
112// "muted chrome" key already shipped by every theme (gray-ish in
113// dark themes, light gray in light themes) so the separator reads
114// as a recessed transition between the active input and the
115// candidate list rather than as a hard divider.
116const KEY_COMPLETION_DIM_FG: &str = "ui.menu_disabled_fg";
117// Selected completion row foreground/background. Same keys the
118// popup-driven selection highlight uses everywhere else (host
119// prompt suggestions, action-popup menu), so themes that
120// re-skin one re-skin the other.
121const KEY_COMPLETION_SEL_FG: &str = "ui.popup_selection_fg";
122const KEY_COMPLETION_SEL_BG: &str = "ui.popup_selection_bg";
123// Foreground for *unselected* completion rows. Without this, the
124// row text inherits the terminal's default foreground, which has
125// no relationship to the popup's themed `popup_bg` and reads
126// poorly on coloured backgrounds.
127const KEY_COMPLETION_FG: &str = "ui.popup_text_fg";
128// Border chrome the popup paints around its own rows (the
129// `│ ... │` sides extending below the input + the `╰─...─╯`
130// closing border). Distinct theme key from the wrapping
131// labeled section's default (unstyled) chrome so the popup
132// reads as its own surface — matches the user's "use a theme
133// key for the popup border" expectation.
134const KEY_COMPLETION_BORDER_FG: &str = "ui.popup_border_fg";
135
136/// Where the host should place the buffer's hardware cursor — the
137/// terminal's blinking caret — when a `TextInput` is focused. Built
138/// by the renderer; the dispatcher translates `(buffer_row,
139/// byte_in_row)` to an absolute byte position in the virtual buffer
140/// and sets the panel buffer's primary cursor there. When a
141/// non-text widget is focused (Toggle / Button / List) or the
142/// panel has no tabbable widgets, this is `None` and the host
143/// hides the cursor entirely.
144#[derive(Debug, Clone, Copy)]
145pub struct FocusCursor {
146    pub buffer_row: u32,
147    pub byte_in_row: u32,
148}
149
150/// What a single render of a `WidgetSpec` produces.
151///
152/// * `entries` — the bytes for `set_virtual_buffer_content`.
153/// * `hits` — click rectangles for the `WidgetRegistry` so a later
154///   `mouse_click` dispatches a semantic `widget_event`.
155/// * `instance_states` — next-tick widget instance state (List
156///   scroll offsets / selection, TextInput value+cursor, …).
157/// * `focus_key` — currently focused widget key, clamped to a
158///   tabbable that exists in the spec (or `""` when there are no
159///   tabbables).
160/// * `tabbable` — focusable widget keys collected in declaration
161///   order. The Tab-cycle command finds the current `focus_key`'s
162///   index in this list to advance it.
163/// * `focus_cursor` — when a `TextInput` is focused, where the
164///   terminal cursor should land. Replaces the previous
165///   "overlay-as-cursor" hack — the actual hardware cursor blinks
166///   at the right byte, with no theme-color guesswork.
167pub struct RenderOutput {
168    pub entries: Vec<TextPropertyEntry>,
169    pub hits: Vec<HitArea>,
170    pub instance_states: HashMap<String, WidgetInstanceState>,
171    pub focus_key: String,
172    pub tabbable: Vec<String>,
173    pub focus_cursor: Option<FocusCursor>,
174    /// Rectangles reserved by `WindowEmbed` widgets. Each entry
175    /// names a window id and the cell range (relative to the
176    /// rendered panel's inner area) the host should paint that
177    /// window into after laying down the regular entries.
178    pub embeds: Vec<EmbedRect>,
179    /// Rows produced by `WidgetSpec::Overlay` children. Each
180    /// row carries its anchor `buffer_row` (relative to the
181    /// rendered panel's inner area) and is painted by the host
182    /// AFTER the main `entries`, on top of whatever is at that
183    /// row. Used for dropdown completions, tooltips, hover
184    /// popups — anything that should appear next to a focused
185    /// widget without reflowing the rest of the layout when it
186    /// shows or hides.
187    pub overlays: Vec<OverlayRow>,
188    /// Scrollable `List` widgets that overflowed their visible height,
189    /// with the geometry + state the host needs to paint and drag a
190    /// scrollbar. Empty for lists that fit.
191    pub scroll_regions: Vec<ScrollRegion>,
192}
193
194/// One row produced by an `Overlay` widget. `buffer_row` is the
195/// 0-based row inside the panel's inner area where the entry
196/// should be painted; the host's paint pass writes overlay rows
197/// after the main entries so they sit on top.
198#[derive(Debug, Clone)]
199pub struct OverlayRow {
200    pub buffer_row: u32,
201    pub entry: TextPropertyEntry,
202}
203
204/// A rectangle reserved by a `WindowEmbed` widget. All
205/// coordinates are in display **columns** (not bytes), so the
206/// host can map straight to screen cells via `inner.x +
207/// col_in_row`. `width_cols` is the column count; `height_rows`
208/// matches the spec's `rows`. The host's floating-panel render
209/// walks these and invokes the per-window paint path scoped to
210/// the rect.
211#[derive(Debug, Clone, Copy)]
212pub struct EmbedRect {
213    pub window_id: u32,
214    pub buffer_row: u32,
215    pub col_in_row: u32,
216    pub width_cols: u32,
217    pub height_rows: u32,
218}
219
220/// A scrollable `List` widget's geometry + scroll state, surfaced so
221/// the host can paint a draggable scrollbar over the list's rightmost
222/// column and hit-test mouse press/drag against it. Threaded through
223/// the compositor (Row/Col/Section) identically to [`EmbedRect`] —
224/// `buffer_row`/`col_in_row` are panel-relative display coordinates.
225/// `width_cols` spans the list's column so `col_in_row + width_cols -
226/// 1` is the scrollbar column; `height_rows` is the visible track
227/// height. `total`/`visible`/`scroll` feed `ScrollbarState`.
228#[derive(Debug, Clone)]
229pub struct ScrollRegion {
230    pub list_key: String,
231    pub buffer_row: u32,
232    pub col_in_row: u32,
233    pub width_cols: u32,
234    pub height_rows: u32,
235    pub total: usize,
236    pub visible: usize,
237    pub scroll: usize,
238}
239
240/// Output of a single [`render_collected`] call (or one of the
241/// standalone arm helpers). Replaces the six-element tuple that was
242/// the previous return type, giving call sites named fields instead
243/// of positional slots.
244#[derive(Default)]
245struct CollectedOutput {
246    entries: Vec<TextPropertyEntry>,
247    hits: Vec<HitArea>,
248    focus_cursor: Option<FocusCursor>,
249    embeds: Vec<EmbedRect>,
250    overlays: Vec<OverlayRow>,
251    scroll_regions: Vec<ScrollRegion>,
252}
253
254/// Render a spec to a [`RenderOutput`].
255///
256/// `prev` is the previous render's instance state (or empty on
257/// first mount). `prev_focus_key` is the previous render's focus
258/// key (or `""`); the renderer keeps it if it matches a tabbable in
259/// the new spec, otherwise falls back to the first tabbable.
260/// `panel_width` is the buffer's column width — used by `Row` to
261/// size flex `Spacer`s. Pass `u32::MAX` to disable flex (children
262/// won't be padded).
263pub fn render_spec(
264    spec: &WidgetSpec,
265    prev: &HashMap<String, WidgetInstanceState>,
266    prev_focus_key: &str,
267    panel_width: u32,
268) -> RenderOutput {
269    let _guard = MarkerGutterGuard::set(false);
270    render_spec_inner(spec, prev, prev_focus_key, panel_width, true)
271}
272
273// Whether the *current* render reserves a leading two-column gutter
274// on every focusable control for the `▸ ` focus marker. Opt-in per
275// panel (see `render_spec_with_marker`): when on, the focused
276// control leads with `▸ ` and every other focusable control leads
277// with two spaces, so focus is legible from a plain capture AND the
278// layout never shifts as focus moves (the gutter is always present,
279// only its glyph changes). When off — the default for every existing
280// panel — controls render exactly as before (no gutter, no marker),
281// so other dialogs are byte-for-byte unchanged. A thread-local keeps
282// the flag out of the ~dozen recursive `collect_*` signatures; it's
283// read only by the three leaf renderers (`render_button`,
284// `render_toggle`, `render_widget_text`). Rendering is synchronous
285// and non-re-entrant, so a thread-local with a restore guard is
286// sufficient.
287thread_local! {
288    static MARKER_GUTTER: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
289}
290
291fn marker_gutter_enabled() -> bool {
292    MARKER_GUTTER.with(|c| c.get())
293}
294
295/// RAII guard that sets the marker-gutter thread-local for the
296/// duration of one render and restores the previous value on drop —
297/// so a direct `render_button` call after a marker render doesn't
298/// observe a stale `true`.
299struct MarkerGutterGuard(bool);
300impl MarkerGutterGuard {
301    fn set(enabled: bool) -> Self {
302        let prev = MARKER_GUTTER.with(|c| c.replace(enabled));
303        MarkerGutterGuard(prev)
304    }
305}
306impl Drop for MarkerGutterGuard {
307    fn drop(&mut self) {
308        MARKER_GUTTER.with(|c| c.set(self.0));
309    }
310}
311
312/// Like [`render_spec`], but reserves the `▸ ` focus-marker gutter on
313/// every focusable control (see [`MARKER_GUTTER`]). Panels that want
314/// capture-legible, layout-stable focus (the Orchestrator New Session
315/// form) render through this entry point; everything else uses
316/// [`render_spec`] and is unaffected.
317pub fn render_spec_with_marker(
318    spec: &WidgetSpec,
319    prev: &HashMap<String, WidgetInstanceState>,
320    prev_focus_key: &str,
321    panel_width: u32,
322) -> RenderOutput {
323    let _guard = MarkerGutterGuard::set(true);
324    render_spec_inner(spec, prev, prev_focus_key, panel_width, true)
325}
326
327/// Like [`render_spec`] but does **not** fall back to focusing the first
328/// tabbable widget when `focus_key` matches none. Use this when the host owns
329/// the focus ring and a state of "no widget focused" is meaningful — e.g. the
330/// search overlay, where focus can rest on the input (no toggle highlighted)
331/// rather than always on a toolbar control. Pass `""` for no focus.
332pub fn render_spec_no_autofocus(
333    spec: &WidgetSpec,
334    prev: &HashMap<String, WidgetInstanceState>,
335    focus_key: &str,
336    panel_width: u32,
337) -> RenderOutput {
338    let _guard = MarkerGutterGuard::set(false);
339    render_spec_inner(spec, prev, focus_key, panel_width, false)
340}
341
342fn render_spec_inner(
343    spec: &WidgetSpec,
344    prev: &HashMap<String, WidgetInstanceState>,
345    prev_focus_key: &str,
346    panel_width: u32,
347    auto_focus_first: bool,
348) -> RenderOutput {
349    // Walk the spec to collect tabbable keys, then resolve the
350    // active focus key. This must happen before the entry pass so
351    // that widget arms know whether they're focused.
352    let mut tabbable = Vec::new();
353    collect_tabbable(spec, &mut tabbable);
354    let focus_key = if !prev_focus_key.is_empty() && tabbable.iter().any(|k| k == prev_focus_key) {
355        prev_focus_key.to_string()
356    } else if auto_focus_first {
357        tabbable.first().cloned().unwrap_or_default()
358    } else {
359        String::new()
360    };
361
362    let mut next_state = HashMap::new();
363    let collected = render_collected(spec, prev, &mut next_state, &focus_key, panel_width);
364    RenderOutput {
365        entries: collected.entries,
366        hits: collected.hits,
367        instance_states: next_state,
368        focus_key,
369        tabbable,
370        focus_cursor: collected.focus_cursor,
371        embeds: collected.embeds,
372        overlays: collected.overlays,
373        scroll_regions: collected.scroll_regions,
374    }
375}
376
377/// Predict whether a `WidgetSpec` will render as a multi-line
378/// (Block) child of a Row, without doing the actual render. The
379/// Row's layout uses this up-front to decide whether a child
380/// should get its full `panel_width` (inline path) or a smaller
381/// per-column budget (horizontal-zip path).
382///
383/// Slightly conservative — a `Col` with one inline child is
384/// predicted inline (matches its actual one-line render); a `Row`
385/// containing any block descendant is predicted block (so nested
386/// rows participate in the zip correctly).
387/// Extract the `width_pct` declaration of a Row child, if any
388/// and in-range (1..=100). Currently only `LabeledSection`
389/// carries this — other block kinds (Col, Tree, List,
390/// multi-line Text, Raw) participate in the equal-split path.
391/// Out-of-range (0, > 100, or unset) collapses to `None` so
392/// callers don't have to re-check.
393fn labeled_section_width_pct(spec: &WidgetSpec) -> Option<u32> {
394    let WidgetSpec::LabeledSection { width_pct, .. } = spec else {
395        return None;
396    };
397    width_pct.filter(|pct| (1..=100).contains(pct))
398}
399
400fn predicts_block(spec: &WidgetSpec) -> bool {
401    match spec {
402        WidgetSpec::Col { children, .. } => {
403            if children.len() > 1 {
404                return true;
405            }
406            children.first().map(predicts_block).unwrap_or(false)
407        }
408        WidgetSpec::LabeledSection { .. } => true,
409        WidgetSpec::Tree { .. } => true,
410        WidgetSpec::List { .. } => true,
411        WidgetSpec::Text { rows, .. } => *rows > 1,
412        WidgetSpec::WindowEmbed { rows, .. } => *rows > 1,
413        WidgetSpec::Raw { entries, .. } => entries.len() > 1,
414        WidgetSpec::Row { children, .. } => children.iter().any(predicts_block),
415        _ => false,
416    }
417}
418
419/// One position in a Row's two-pass layout. Used internally to
420/// defer flex-spacer sizing until after we know all the inline
421/// children's natural widths.
422enum RowPiece {
423    Inline {
424        entry: TextPropertyEntry,
425        hits: Vec<HitArea>,
426        /// Some when this inline child was a focused TextInput.
427        /// `byte_in_row` is the cursor's offset within the *child's*
428        /// text — the Row collapse pass shifts it by the merged
429        /// inline_shift before publishing.
430        focus_cursor: Option<FocusCursor>,
431        /// Embed rects propagated up from this inline child.
432        /// Inlines collapse to row 0, so embeds inside them are
433        /// pinned to that row. Rare but worth carrying through
434        /// rather than dropping.
435        embeds: Vec<EmbedRect>,
436        /// Scroll regions propagated up from this inline child.
437        scroll_regions: Vec<ScrollRegion>,
438    },
439    Block {
440        /// Allocated column width for the zip path. May differ
441        /// from the entries' natural widths (each block was
442        /// rendered with this as its `panel_width`, so the
443        /// entries should already fit).
444        column_width: u32,
445        entries: Vec<TextPropertyEntry>,
446        hits: Vec<HitArea>,
447        focus_cursor: Option<FocusCursor>,
448        /// Embed rects propagated up from this block child.
449        /// Their `buffer_row` is already relative to the block's
450        /// own row 0; the zip pass shifts row by `starting_row`
451        /// and byte_in_row by the block's `byte_shift`.
452        embeds: Vec<EmbedRect>,
453        /// Scroll regions propagated up from this block child,
454        /// shifted by the zip pass identically to `embeds`.
455        scroll_regions: Vec<ScrollRegion>,
456    },
457    Flex,
458}
459
460/// Strip a trailing `'\n'` from `entry.text` if present (overlays /
461/// hits aren't affected because the newline is at the very end and
462/// no overlay should span it). Used to prepare an inline-rendered
463/// child for Row inline-collapse, where individual newlines would
464/// split the merged row across multiple buffer lines.
465fn strip_trailing_newline(entry: &mut TextPropertyEntry) {
466    if entry.text.ends_with('\n') {
467        entry.text.pop();
468    }
469}
470
471/// Append a single trailing newline to `entry.text` if it doesn't
472/// already end with one. Each top-level entry needs to end with
473/// `\n` so it occupies its own line in the underlying virtual
474/// buffer (the buffer's line model is byte-driven; without `\n`
475/// adjacent entries concatenate into one logical line).
476fn ensure_trailing_newline(entry: &mut TextPropertyEntry) {
477    if !entry.text.ends_with('\n') {
478        entry.text.push('\n');
479    }
480}
481
482/// Walk a spec tree and append tabbable widget keys (`Toggle`,
483/// `Button`, `TextInput`, `List`, `Tree` with a non-empty `key`) in
484/// declaration order. Layout containers (`Row`, `Col`) recurse;
485/// `Raw`, `Spacer`, `HintBar` skip.
486fn collect_tabbable(spec: &WidgetSpec, out: &mut Vec<String>) {
487    match spec {
488        WidgetSpec::Button {
489            key: Some(k),
490            disabled,
491            focusable,
492            ..
493        } if !k.is_empty() && !*disabled && *focusable => {
494            out.push(k.clone());
495        }
496        WidgetSpec::Toggle { key: Some(k), .. }
497        | WidgetSpec::Text { key: Some(k), .. }
498        | WidgetSpec::Tree { key: Some(k), .. }
499            if !k.is_empty() =>
500        {
501            out.push(k.clone());
502        }
503        WidgetSpec::List {
504            key: Some(k),
505            focusable,
506            ..
507        } if !k.is_empty() && *focusable => {
508            out.push(k.clone());
509        }
510        _ => {}
511    }
512    for c in spec.children() {
513        collect_tabbable(c, out);
514    }
515}
516
517/// Internal renderer. Returns the entries and the hit areas
518/// produced by `spec` *as if* it were rendered at row 0; callers
519/// (Col, Row block path) shift `buffer_row` upward by their own
520/// row offset before forwarding. `prev` is read-only previous
521/// instance state; `next_state` accumulates the post-render state
522/// the host should persist. `focus_key` is the panel's currently
523/// focused widget key — widget arms compare against their own
524/// `key` to decide whether to render with focus styling, ignoring
525/// the spec's `focused` field. (Plugin-passed `focused` is the
526/// initial-only hint that becomes redundant once the host's focus
527/// key takes over.)
528fn render_collected(
529    spec: &WidgetSpec,
530    prev: &HashMap<String, WidgetInstanceState>,
531    next_state: &mut HashMap<String, WidgetInstanceState>,
532    focus_key: &str,
533    panel_width: u32,
534) -> CollectedOutput {
535    match spec {
536        WidgetSpec::Row { children, wrap, .. } => {
537            collect_row(children, *wrap, prev, next_state, focus_key, panel_width)
538        }
539        WidgetSpec::Col { children, .. } => {
540            collect_col(children, prev, next_state, focus_key, panel_width)
541        }
542        WidgetSpec::HintBar { entries, .. } => collect_hint_bar(entries),
543        WidgetSpec::Toggle {
544            checked,
545            label,
546            focused,
547            key,
548        } => collect_toggle(*checked, label, *focused, key.as_deref(), focus_key),
549        WidgetSpec::Button {
550            label,
551            focused,
552            intent,
553            key,
554            disabled,
555            ..
556        } => collect_button(
557            label,
558            *focused,
559            *intent,
560            key.as_deref(),
561            *disabled,
562            focus_key,
563        ),
564        WidgetSpec::Spacer { cols, .. } => collect_spacer(*cols),
565        WidgetSpec::Divider { ch, style, .. } => collect_divider(ch, style.as_ref(), panel_width),
566        WidgetSpec::List {
567            items,
568            item_specs,
569            item_keys,
570            selected_index,
571            visible_rows,
572            key: list_key,
573            ..
574        } => collect_list(
575            items,
576            item_specs,
577            item_keys,
578            *selected_index,
579            *visible_rows,
580            list_key.as_deref(),
581            prev,
582            next_state,
583            focus_key,
584            panel_width,
585        ),
586        WidgetSpec::Tree {
587            nodes,
588            item_keys,
589            selected_index,
590            visible_rows,
591            expanded_keys,
592            checkable,
593            key: tree_key,
594        } => render_widget_tree(
595            nodes,
596            item_keys,
597            *selected_index,
598            *visible_rows,
599            expanded_keys,
600            *checkable,
601            tree_key.as_deref(),
602            prev,
603            next_state,
604        ),
605        WidgetSpec::Text {
606            value,
607            cursor_byte,
608            focused,
609            label,
610            placeholder,
611            rows,
612            field_width,
613            max_visible_chars,
614            full_width,
615            completions: _,
616            completions_visible_rows,
617            key,
618        } => render_widget_text(
619            value,
620            *cursor_byte,
621            *focused,
622            label,
623            placeholder.as_deref(),
624            *rows,
625            *field_width,
626            *max_visible_chars,
627            *full_width,
628            *completions_visible_rows,
629            key.as_deref(),
630            prev,
631            next_state,
632            focus_key,
633            panel_width,
634        ),
635        WidgetSpec::LabeledSection { label, child, .. } => {
636            collect_labeled_section(label, child, prev, next_state, focus_key, panel_width)
637        }
638        WidgetSpec::WindowEmbed {
639            window_id, rows, ..
640        } => collect_window_embed(*window_id, *rows, panel_width),
641        WidgetSpec::Raw { entries, .. } => collect_raw(entries),
642        WidgetSpec::Overlay { child, .. } => {
643            collect_overlay(child, prev, next_state, focus_key, panel_width)
644        }
645    }
646}
647
648// =========================================================================
649// Standalone arm helpers — extracted from the render_collected match to keep
650// that function navigable. Each returns a CollectedOutput the caller folds
651// back into its local accumulators.
652// =========================================================================
653
654#[allow(clippy::too_many_arguments)]
655fn collect_row(
656    children: &[WidgetSpec],
657    wrap: bool,
658    prev: &HashMap<String, WidgetInstanceState>,
659    next_state: &mut HashMap<String, WidgetInstanceState>,
660    focus_key: &str,
661    panel_width: u32,
662) -> CollectedOutput {
663    let mut entries: Vec<TextPropertyEntry> = Vec::new();
664    let mut hits: Vec<HitArea> = Vec::new();
665    let mut focus_cursor: Option<FocusCursor> = None;
666    let mut embeds: Vec<EmbedRect> = Vec::new();
667    let mut overlays: Vec<OverlayRow> = Vec::new();
668    let mut scroll_regions: Vec<ScrollRegion> = Vec::new();
669
670    // Two-pass layout for Row:
671    //  1. Walk children, render each. Track flex spacers
672    //     by index in the accumulator; their text starts
673    //     empty and grows in pass 2.
674    //  2. Compute leftover width = panel_width - sum of
675    //     non-flex widths; distribute evenly across flex
676    //     slots; expand each flex spacer's text + shift
677    //     subsequent overlays / hits accordingly.
678    //
679    // When ≥1 child is multi-line (a `Block`), the
680    // assembly switches to a per-line zip instead of
681    // the inline-collapse path — each block gets a
682    // column budget and the layout walks block lines
683    // left-to-right. See [the Phase 1b note in
684    // docs/internal/orchestrator-open-dialog-and-lifecycle.md]
685    // for the rationale.
686    //
687    // Width allocation for the zip path: blocks share
688    // `panel_width`. Children with a `width_pct`
689    // declaration get their explicit share first
690    // (`panel_width * pct / 100`); the remainder splits
691    // equally among blocks without an explicit width.
692    // Inline children render at full `panel_width` (they
693    // collapse to a single line so width is a soft cap).
694    let block_indices: Vec<usize> = children
695        .iter()
696        .enumerate()
697        .filter(|(_, c)| predicts_block(c))
698        .map(|(i, _)| i)
699        .collect();
700    let block_count = block_indices.len();
701    // Per-child target width, aligned with `children`.
702    // For non-block children the value is unused; for
703    // blocks it's the panel_width passed to that child's
704    // render.
705    let mut per_child_width: Vec<u32> = children.iter().map(|_| panel_width).collect();
706    if block_count > 0 {
707        let mut explicit_total: u32 = 0;
708        let mut explicit_count: u32 = 0;
709        for &idx in &block_indices {
710            if let Some(pct) = labeled_section_width_pct(&children[idx]) {
711                let w = (panel_width as u64 * pct as u64 / 100) as u32;
712                per_child_width[idx] = w.max(1);
713                explicit_total = explicit_total.saturating_add(w);
714                explicit_count += 1;
715            }
716        }
717        let remaining = panel_width.saturating_sub(explicit_total);
718        let implicit_count = (block_count as u32).saturating_sub(explicit_count).max(1);
719        let each_implicit = (remaining / implicit_count).max(1);
720        for &idx in &block_indices {
721            if labeled_section_width_pct(&children[idx]).is_none() {
722                per_child_width[idx] = each_implicit;
723            }
724        }
725    }
726    let mut row_pieces: Vec<RowPiece> = Vec::new();
727    for (idx, child) in children.iter().enumerate() {
728        if let WidgetSpec::Spacer { flex: true, .. } = child {
729            row_pieces.push(RowPiece::Flex);
730            continue;
731        }
732        let child_panel_width = per_child_width[idx];
733        let child_out = render_collected(child, prev, next_state, focus_key, child_panel_width);
734        // Rows can host overlays in principle (e.g. a
735        // tooltip on a button); forward them up without
736        // a row-offset adjustment — Row pieces all sit
737        // on the same buffer-row as the merged row.
738        overlays.extend(child_out.overlays);
739        if child_out.entries.is_empty() {
740            debug_assert!(child_out.hits.is_empty(), "empty children produce no hits");
741            continue;
742        }
743        if child_out.entries.len() == 1 {
744            let mut entry = child_out.entries.into_iter().next().unwrap();
745            // Inline children can't carry their own newlines
746            // — that would split the merged Row across
747            // buffer lines. The Row's final merged entry
748            // gets exactly one newline appended below.
749            strip_trailing_newline(&mut entry);
750            row_pieces.push(RowPiece::Inline {
751                entry,
752                hits: child_out.hits,
753                focus_cursor: child_out.focus_cursor,
754                embeds: child_out.embeds,
755                scroll_regions: child_out.scroll_regions,
756            });
757        } else {
758            row_pieces.push(RowPiece::Block {
759                column_width: child_panel_width,
760                entries: child_out.entries,
761                hits: child_out.hits,
762                focus_cursor: child_out.focus_cursor,
763                embeds: child_out.embeds,
764                scroll_regions: child_out.scroll_regions,
765            });
766        }
767    }
768    // If any Block pieces survived classification, take
769    // the horizontal-zip path; otherwise fall through to
770    // the original inline-collapse assembly.
771    let has_blocks = row_pieces
772        .iter()
773        .any(|p| matches!(p, RowPiece::Block { .. }));
774    if has_blocks {
775        zip_row_blocks(
776            row_pieces,
777            panel_width,
778            &mut entries,
779            &mut hits,
780            &mut focus_cursor,
781            &mut embeds,
782            &mut scroll_regions,
783        );
784    } else if wrap {
785        // Wrapping path: greedily pack inline pieces onto lines no
786        // wider than `panel_width`; a piece that doesn't fit starts a
787        // new line (pieces are never split). Each piece's hits get
788        // their byte offset shifted by the line-so-far and their
789        // `buffer_row` set to the line index.
790        assemble_wrapped_row(row_pieces, panel_width, &mut entries, &mut hits);
791    } else {
792        // Compute flex sizing. Width is measured in display columns
793        // (`str_width`) to match `panel_width`; using the raw byte length
794        // would over-count multi-byte glyphs (▣ · ▸ ↑ − …) and under-size
795        // the flex spacer, leaving a right-aligned group floating short of
796        // the edge.
797        let inline_natural: usize = row_pieces
798            .iter()
799            .filter_map(|p| match p {
800                RowPiece::Inline { entry, .. } => {
801                    Some(crate::primitives::display_width::str_width(&entry.text))
802                }
803                _ => None,
804            })
805            .sum();
806        let flex_count = row_pieces
807            .iter()
808            .filter(|p| matches!(p, RowPiece::Flex))
809            .count();
810        let flex_total = (panel_width as usize).saturating_sub(inline_natural);
811        // Distribute leftover evenly. With multiple flex slots,
812        // the leftover bytes spread as evenly as possible (any
813        // remainder lands in the first slot).
814        let (flex_each, flex_extra) = match flex_total.checked_div(flex_count) {
815            Some(each) => (each, flex_total % flex_count),
816            None => (0, 0),
817        };
818
819        // Pass 2: assemble. Accumulate inline pieces (with
820        // collapsed flex spacers) into one entry; flush block
821        // pieces. Track byte-shift so child hits' offsets stay
822        // correct.
823        let mut acc: Option<TextPropertyEntry> = None;
824        let mut flex_seen = 0usize;
825        for piece in row_pieces {
826            match piece {
827                RowPiece::Inline {
828                    mut entry,
829                    hits: child_hits,
830                    focus_cursor: child_focus,
831                    embeds: child_embeds,
832                    scroll_regions: child_scroll,
833                } => {
834                    let inline_shift = match acc.as_ref() {
835                        Some(e) => e.text.len(),
836                        None => 0,
837                    };
838                    for mut h in child_hits {
839                        h.byte_start += inline_shift;
840                        h.byte_end += inline_shift;
841                        hits.push(h);
842                    }
843                    if let Some(mut fc) = child_focus {
844                        // buffer_row stays 0 — caller shifts.
845                        fc.byte_in_row += inline_shift as u32;
846                        focus_cursor = Some(fc);
847                    }
848                    for mut emb in child_embeds {
849                        // Inline shift is in bytes; for ASCII
850                        // inline content this matches columns,
851                        // which is the only case that lands here
852                        // in practice (single-row embeds are
853                        // rare).
854                        emb.col_in_row += inline_shift as u32;
855                        embeds.push(emb);
856                    }
857                    for mut sr in child_scroll {
858                        sr.col_in_row += inline_shift as u32;
859                        scroll_regions.push(sr);
860                    }
861                    match acc.as_mut() {
862                        Some(merged) => merge_inline(merged, &mut entry),
863                        None => acc = Some(entry),
864                    }
865                }
866                RowPiece::Flex => {
867                    // Materialize the flex spacer as N spaces.
868                    let n = flex_each + if flex_seen < flex_extra { 1 } else { 0 };
869                    flex_seen += 1;
870                    if n > 0 {
871                        let mut text = String::with_capacity(n);
872                        for _ in 0..n {
873                            text.push(' ');
874                        }
875                        let entry = TextPropertyEntry {
876                            text,
877                            properties: Default::default(),
878                            style: None,
879                            inline_overlays: Vec::new(),
880                            segments: Vec::new(),
881                            pad_to_chars: None,
882                            truncate_to_chars: None,
883                        };
884                        match acc.as_mut() {
885                            Some(merged) => {
886                                let mut e = entry;
887                                merge_inline(merged, &mut e);
888                            }
889                            None => acc = Some(entry),
890                        }
891                    }
892                }
893                RowPiece::Block { .. } => {
894                    // Unreachable in the inline-only path —
895                    // `has_blocks` was false here.
896                    debug_assert!(false, "block piece in inline-only Row path");
897                }
898            }
899        }
900        if let Some(mut merged) = acc {
901            ensure_trailing_newline(&mut merged);
902            entries.push(merged);
903        }
904    }
905
906    CollectedOutput {
907        entries,
908        hits,
909        focus_cursor,
910        embeds,
911        overlays,
912        scroll_regions,
913    }
914}
915
916#[allow(clippy::too_many_arguments)]
917fn collect_col(
918    children: &[WidgetSpec],
919    prev: &HashMap<String, WidgetInstanceState>,
920    next_state: &mut HashMap<String, WidgetInstanceState>,
921    focus_key: &str,
922    panel_width: u32,
923) -> CollectedOutput {
924    let mut entries: Vec<TextPropertyEntry> = Vec::new();
925    let mut hits: Vec<HitArea> = Vec::new();
926    let mut focus_cursor: Option<FocusCursor> = None;
927    let mut embeds: Vec<EmbedRect> = Vec::new();
928    let mut overlays: Vec<OverlayRow> = Vec::new();
929    let mut scroll_regions: Vec<ScrollRegion> = Vec::new();
930
931    for child in children {
932        // Overlay children DO NOT contribute vertical
933        // space to the col. Render them, but stash the
934        // produced entries as overlays anchored at the
935        // current `entries.len()` (the row they would
936        // have occupied) — they get painted on top
937        // afterwards without pushing the rest of the
938        // col downward.
939        let is_overlay = matches!(child, WidgetSpec::Overlay { .. });
940        let child_out = render_collected(child, prev, next_state, focus_key, panel_width);
941        let row_offset = entries.len() as u32;
942        if is_overlay {
943            // Promote the overlay child's regular
944            // entries to overlay rows anchored at the
945            // current col cursor (`row_offset`). Hits
946            // for those entries are shifted to the same
947            // anchor row so click-to-pick targets the
948            // painted row.
949            for (i, e) in child_out.entries.into_iter().enumerate() {
950                overlays.push(OverlayRow {
951                    buffer_row: row_offset + i as u32,
952                    entry: e,
953                });
954            }
955            for mut h in child_out.hits {
956                h.buffer_row += row_offset;
957                hits.push(h);
958            }
959            // Focus cursor inside an overlay (rare but
960            // legal) anchors at the same row; without
961            // this shift Up/Down + cursor placement
962            // would land on the col's "natural" row.
963            if let Some(mut fc) = child_out.focus_cursor {
964                fc.buffer_row += row_offset;
965                focus_cursor = Some(fc);
966            }
967            // Forward nested overlays without further
968            // adjustment (already anchored).
969            overlays.extend(child_out.overlays);
970            // Embeds inside an overlay don't make sense
971            // today (a window-embed below a popup would
972            // be confusing) — propagate at the same
973            // anchor row so behaviour is well-defined
974            // if someone tries it.
975            for mut emb in child_out.embeds {
976                emb.buffer_row += row_offset;
977                embeds.push(emb);
978            }
979            for mut sr in child_out.scroll_regions {
980                sr.buffer_row += row_offset;
981                scroll_regions.push(sr);
982            }
983            continue;
984        }
985        for mut h in child_out.hits {
986            h.buffer_row += row_offset;
987            hits.push(h);
988        }
989        if let Some(mut fc) = child_out.focus_cursor {
990            fc.buffer_row += row_offset;
991            focus_cursor = Some(fc);
992        }
993        for mut emb in child_out.embeds {
994            emb.buffer_row += row_offset;
995            embeds.push(emb);
996        }
997        for mut sr in child_out.scroll_regions {
998            sr.buffer_row += row_offset;
999            scroll_regions.push(sr);
1000        }
1001        overlays.extend(child_out.overlays.into_iter().map(|mut o| {
1002            o.buffer_row += row_offset;
1003            o
1004        }));
1005        entries.extend(child_out.entries);
1006    }
1007
1008    CollectedOutput {
1009        entries,
1010        hits,
1011        focus_cursor,
1012        embeds,
1013        overlays,
1014        scroll_regions,
1015    }
1016}
1017
1018fn collect_hint_bar(entries: &[HintEntry]) -> CollectedOutput {
1019    let mut out = CollectedOutput::default();
1020    let mut entry = render_hint_bar(entries);
1021    ensure_trailing_newline(&mut entry);
1022    out.entries.push(entry);
1023    // No hits — HintBar is read-only in v1. (When the
1024    // keymap layer arrives, individual entries become
1025    // clickable command targets.)
1026    out
1027}
1028
1029fn collect_toggle(
1030    checked: bool,
1031    label: &str,
1032    focused: bool,
1033    key: Option<&str>,
1034    focus_key: &str,
1035) -> CollectedOutput {
1036    let mut out = CollectedOutput::default();
1037    // Host-managed focus overrides the spec's `focused`
1038    // when this widget has a key and is the panel's focused
1039    // widget. Plugin-passed `focused` is ignored when the
1040    // host owns focus (i.e. the panel has any tabbable
1041    // widgets); without it, the renderer falls back to the
1042    // spec value (legacy path).
1043    let is_focused = match key {
1044        Some(k) if !k.is_empty() => k == focus_key,
1045        _ => focused,
1046    };
1047    let mut entry = render_toggle(checked, label, is_focused);
1048    let byte_end = entry.text.len();
1049    out.hits.push(HitArea {
1050        widget_key: key.unwrap_or("").to_string(),
1051        widget_kind: "toggle",
1052        buffer_row: 0,
1053        byte_start: 0,
1054        byte_end,
1055        payload: json!({ "checked": !checked }),
1056        event_type: "toggle",
1057    });
1058    ensure_trailing_newline(&mut entry);
1059    out.entries.push(entry);
1060    out
1061}
1062
1063#[allow(clippy::too_many_arguments)]
1064fn collect_button(
1065    label: &str,
1066    focused: bool,
1067    intent: ButtonKind,
1068    key: Option<&str>,
1069    disabled: bool,
1070    focus_key: &str,
1071) -> CollectedOutput {
1072    let mut out = CollectedOutput::default();
1073    let is_focused = match key {
1074        Some(k) if !k.is_empty() && !disabled => k == focus_key,
1075        _ => !disabled && focused,
1076    };
1077    let mut entry = render_button(label, is_focused, intent, disabled);
1078    // Disabled buttons skip the hit area entirely — clicks on
1079    // them are no-ops, matching the non-tabbable behavior in
1080    // `collect_tabbable`. Without this, a stray click would
1081    // still focus + activate a button whose handler is
1082    // already gated by the same disabled condition the
1083    // plugin computed.
1084    if !disabled {
1085        let byte_end = entry.text.len();
1086        out.hits.push(HitArea {
1087            widget_key: key.unwrap_or("").to_string(),
1088            widget_kind: "button",
1089            buffer_row: 0,
1090            byte_start: 0,
1091            byte_end,
1092            payload: json!({}),
1093            event_type: "activate",
1094        });
1095    }
1096    ensure_trailing_newline(&mut entry);
1097    out.entries.push(entry);
1098    out
1099}
1100
1101fn collect_spacer(cols: u32) -> CollectedOutput {
1102    let mut out = CollectedOutput::default();
1103    // Top-level / Col context: flex Spacers don't fill at
1104    // this level (no Row to absorb their flexibility), so
1105    // they fall back to `cols`. Row uses a separate code
1106    // path that sees the Spacer spec directly and handles
1107    // flex sizing — see RowPiece::Flex.
1108    let cols = cols.min(4096) as usize;
1109    let mut text = String::with_capacity(cols + 1);
1110    for _ in 0..cols {
1111        text.push(' ');
1112    }
1113    let mut entry = TextPropertyEntry {
1114        text,
1115        properties: Default::default(),
1116        style: None,
1117        inline_overlays: Vec::new(),
1118        segments: Vec::new(),
1119        pad_to_chars: None,
1120        truncate_to_chars: None,
1121    };
1122    ensure_trailing_newline(&mut entry);
1123    out.entries.push(entry);
1124    out
1125}
1126
1127fn collect_divider(ch: &str, style: Option<&OverlayOptions>, panel_width: u32) -> CollectedOutput {
1128    let mut out = CollectedOutput::default();
1129    // Draw the rule at the host's authoritative inner width, so it
1130    // always spans the panel exactly — no plugin-side width guess.
1131    // One column per glyph (the default `─` is a single cell); an
1132    // empty `ch` falls back to a space so a stray empty divider
1133    // still occupies its row instead of collapsing.
1134    let glyph = if ch.is_empty() { " " } else { ch };
1135    let cols = (panel_width as usize).min(4096);
1136    let mut text = String::with_capacity(cols * glyph.len() + 1);
1137    for _ in 0..cols {
1138        text.push_str(glyph);
1139    }
1140    let mut entry = TextPropertyEntry {
1141        text,
1142        properties: Default::default(),
1143        style: style.cloned(),
1144        inline_overlays: Vec::new(),
1145        segments: Vec::new(),
1146        pad_to_chars: None,
1147        truncate_to_chars: None,
1148    };
1149    ensure_trailing_newline(&mut entry);
1150    out.entries.push(entry);
1151    out
1152}
1153
1154#[allow(clippy::too_many_arguments)]
1155fn collect_list(
1156    items: &[TextPropertyEntry],
1157    item_specs: &[WidgetSpec],
1158    item_keys: &[String],
1159    selected_index: i32,
1160    visible_rows: u32,
1161    list_key: Option<&str>,
1162    prev: &HashMap<String, WidgetInstanceState>,
1163    next_state: &mut HashMap<String, WidgetInstanceState>,
1164    focus_key: &str,
1165    panel_width: u32,
1166) -> CollectedOutput {
1167    let mut entries: Vec<TextPropertyEntry> = Vec::new();
1168    let mut hits: Vec<HitArea> = Vec::new();
1169    let mut scroll_regions: Vec<ScrollRegion> = Vec::new();
1170
1171    // Two layouts share one selection/scroll model:
1172    //   * classic — one `items` `TextPropertyEntry` per row;
1173    //   * cards    — one `item_specs` `WidgetSpec` per item,
1174    //                each rendered into a multi-row block (a
1175    //                rounded `LabeledSection` "pill", say).
1176    // Selection, scroll, `visible_rows`, and clicks are always
1177    // in *item* units; the card path just maps an item to a
1178    // fixed band of `item_height` rows instead of one row.
1179    let use_specs = !item_specs.is_empty();
1180    let total = if use_specs {
1181        item_specs.len() as u32
1182    } else {
1183        items.len() as u32
1184    };
1185    // Available height, in terminal rows.
1186    let avail_rows = visible_rows.max(1);
1187
1188    // Look up host-owned scroll + selected index from prev
1189    // state (becomes authoritative after first render).
1190    // Spec's `selected_index` is initial-only on first mount.
1191    let (prev_scroll, prev_sel, prev_user_scrolled) = list_key
1192        .and_then(|k| prev.get(k))
1193        .and_then(|s| match s {
1194            WidgetInstanceState::List {
1195                scroll_offset,
1196                selected_index,
1197                user_scrolled,
1198                ..
1199            } => Some((*scroll_offset, *selected_index, *user_scrolled)),
1200            _ => None,
1201        })
1202        .unwrap_or((0, selected_index, false));
1203    // Clamp the previous selection to the current dataset
1204    // size — items may have shrunk between renders. Out-of-
1205    // range selections collapse to the last item, or -1 if
1206    // the list is now empty.
1207    let effective_sel = if prev_sel < 0 || total == 0 {
1208        -1
1209    } else if (prev_sel as u32) >= total {
1210        (total - 1) as i32
1211    } else {
1212        prev_sel
1213    };
1214
1215    // Pre-render the card blocks (if any) so we know the
1216    // uniform card height; the visible-item count and all the
1217    // scroll math derive from it. Nested hits/embeds/overlays/
1218    // scroll are dropped: a card is a single `select` target
1219    // (interactive widgets nested in a card aren't routed yet).
1220    let mut rendered_cards: Vec<Vec<TextPropertyEntry>> = Vec::new();
1221    let mut item_height: u32 = 1;
1222    if use_specs {
1223        rendered_cards.reserve(item_specs.len());
1224        for item_spec in item_specs.iter() {
1225            let mut scratch = HashMap::new();
1226            let card_entries =
1227                render_collected(item_spec, prev, &mut scratch, focus_key, panel_width).entries;
1228            item_height = item_height.max((card_entries.len() as u32).max(1));
1229            rendered_cards.push(card_entries);
1230        }
1231    }
1232    // How many items fit, and the per-item scroll window.
1233    let visible_items = if use_specs {
1234        (avail_rows / item_height).max(1)
1235    } else {
1236        avail_rows
1237    };
1238
1239    // When the card list overflows, the host paints a scrollbar
1240    // in the rightmost column — which would sit on top of each
1241    // card's right border. Re-render the cards one column
1242    // narrower so they leave that column free. (Row count is
1243    // width-independent, so `item_height` stays valid.)
1244    if use_specs && total > visible_items && panel_width > 1 {
1245        let card_width = panel_width - 1;
1246        rendered_cards.clear();
1247        for item_spec in item_specs.iter() {
1248            let mut scratch = HashMap::new();
1249            let card_entries =
1250                render_collected(item_spec, prev, &mut scratch, focus_key, card_width).entries;
1251            rendered_cards.push(card_entries);
1252        }
1253    }
1254
1255    // Compute scroll. Normally we auto-clamp to keep the
1256    // selection in view, but once the user has scrolled by mouse
1257    // (`user_scrolled`) we respect their offset as-is so the
1258    // selected card can sit off-screen — only the range clamp
1259    // below still applies. Selection moves (keyboard/click/plugin)
1260    // clear `user_scrolled`, re-arming this follow behaviour.
1261    let mut scroll = prev_scroll;
1262    if effective_sel >= 0 && !prev_user_scrolled {
1263        let sel = effective_sel as u32;
1264        if sel < scroll {
1265            scroll = sel;
1266        }
1267        if sel >= scroll + visible_items {
1268            scroll = sel + 1 - visible_items;
1269        }
1270    }
1271    let max_scroll = total.saturating_sub(visible_items);
1272    if scroll > max_scroll {
1273        scroll = max_scroll;
1274    }
1275    // Persist scroll + selection for the next render.
1276    // Lists without a `key` lose state across updates.
1277    if let Some(k) = list_key {
1278        next_state.insert(
1279            k.to_string(),
1280            WidgetInstanceState::List {
1281                scroll_offset: scroll,
1282                selected_index: effective_sel,
1283                item_height,
1284                user_scrolled: prev_user_scrolled,
1285            },
1286        );
1287    }
1288
1289    let start = scroll as usize;
1290    let end = ((scroll + visible_items) as usize).min(total as usize);
1291    // Blank full-height-padding row factory.
1292    let blank_row = || {
1293        let mut padding = TextPropertyEntry {
1294            text: String::new(),
1295            properties: Default::default(),
1296            style: None,
1297            inline_overlays: Vec::new(),
1298            segments: Vec::new(),
1299            pad_to_chars: None,
1300            truncate_to_chars: None,
1301        };
1302        ensure_trailing_newline(&mut padding);
1303        padding
1304    };
1305    // Style a row as the selected item (highlight band that
1306    // runs to line end behind a card's borders / text).
1307    let mark_selected = |entry: &mut TextPropertyEntry| {
1308        let mut style = entry.style.clone().unwrap_or_default();
1309        style.bg = Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG));
1310        style.extend_to_line_end = true;
1311        entry.style = Some(style);
1312    };
1313    // Cards indicate selection three ways so it reads in any
1314    // theme — even if colours are too subtle: a *heavy* box
1315    // border (colour-independent marker), bold, and an accent fg
1316    // on the pure-border rows. No background band — it reads
1317    // garish over a multi-row card and fights theme colours.
1318    // Every box glyph is 3 bytes in both light and heavy forms,
1319    // so swapping them preserves inline-overlay byte offsets.
1320    let mark_selected_card = |entry: &mut TextPropertyEntry| {
1321        entry.text = entry
1322            .text
1323            .replace('╭', "┏")
1324            .replace('╮', "┓")
1325            .replace('╰', "┗")
1326            .replace('╯', "┛")
1327            .replace('─', "━")
1328            .replace('│', "┃");
1329        let mut style = entry.style.clone().unwrap_or_default();
1330        style.bold = true;
1331        if entry.text.starts_with('┏') || entry.text.starts_with('┗') {
1332            // Top / bottom rows are pure border, so a whole-row fg tints
1333            // the corner-to-corner run.
1334            style.fg = Some(OverlayColorSpec::theme_key("ui.popup_border_fg"));
1335            entry.style = Some(style);
1336        } else {
1337            // Side rows hold the session text between two vertical border
1338            // glyphs. A whole-row fg would repaint the name / git text
1339            // (which only carries an fg overlay when the row is *active*),
1340            // so tint just the leading and trailing `┃` glyphs with
1341            // sub-range overlays. This frames the selected card on all
1342            // four sides instead of only top + bottom.
1343            entry.style = Some(style);
1344            let bar = '┃';
1345            let bar_len = bar.len_utf8();
1346            let first = entry.text.find(bar);
1347            let last = entry.text.rfind(bar);
1348            for pos in [first, last].into_iter().flatten().collect::<HashSet<_>>() {
1349                entry.inline_overlays.push(InlineOverlay {
1350                    start: pos,
1351                    end: pos + bar_len,
1352                    style: OverlayOptions {
1353                        fg: Some(OverlayColorSpec::theme_key("ui.popup_border_fg")),
1354                        bold: true,
1355                        ..Default::default()
1356                    },
1357                    properties: Default::default(),
1358                    unit: OffsetUnit::Byte,
1359                });
1360            }
1361        }
1362    };
1363
1364    let rows_emitted: u32 = if use_specs {
1365        // Each item occupies a band of `item_height` rows; shorter
1366        // cards pad within their band so every card lines up. A
1367        // `select` hit covers every row, so a click anywhere on
1368        // the card selects it. When the list height isn't a whole
1369        // multiple of the card height, the next item below the
1370        // fold is rendered *partially* into the leftover rows
1371        // (rather than a blank gap) so it's clear there's more to
1372        // scroll.
1373        let mut emitted = 0u32;
1374        let last = if end < total as usize { end + 1 } else { end };
1375        'cards: for i in start..last {
1376            let is_selected = i as i32 == effective_sel;
1377            let item_key = item_keys.get(i).cloned().unwrap_or_default();
1378            let card = &rendered_cards[i];
1379            for r in 0..item_height as usize {
1380                if emitted >= avail_rows {
1381                    break 'cards;
1382                }
1383                let mut entry = card.get(r).cloned().unwrap_or_else(blank_row);
1384                entry.normalize_widths();
1385                if is_selected {
1386                    mark_selected_card(&mut entry);
1387                }
1388                let byte_end = entry.text.len();
1389                ensure_trailing_newline(&mut entry);
1390                let hit_row = entries.len() as u32;
1391                entries.push(entry);
1392                hits.push(HitArea {
1393                    widget_key: item_key.clone(),
1394                    widget_kind: "list",
1395                    buffer_row: hit_row,
1396                    byte_start: 0,
1397                    byte_end,
1398                    payload: json!({
1399                        "index": i as i64,
1400                        "key": item_key,
1401                        "list_key": list_key,
1402                    }),
1403                    event_type: "select",
1404                });
1405                emitted += 1;
1406            }
1407        }
1408        emitted
1409    } else {
1410        // Classic one-row-per-item path.
1411        for (offset, item) in items[start..end.min(items.len())].iter().enumerate() {
1412            let i = start + offset;
1413            let mut entry = item.clone();
1414            entry.normalize_widths();
1415            if i as i32 == effective_sel {
1416                mark_selected(&mut entry);
1417            }
1418            let byte_end = entry.text.len();
1419            ensure_trailing_newline(&mut entry);
1420            entries.push(entry);
1421            let item_key = item_keys.get(i).cloned().unwrap_or_default();
1422            let hit_row = (entries.len() - 1) as u32;
1423            hits.push(HitArea {
1424                widget_key: item_key.clone(),
1425                widget_kind: "list",
1426                buffer_row: hit_row,
1427                byte_start: 0,
1428                byte_end,
1429                payload: json!({
1430                    "index": i as i64,
1431                    "key": item_key,
1432                    // The List's own spec key, so a click handler can
1433                    // update the host-owned selection instance state
1434                    // (keyed by this) — the item key in `key` is not
1435                    // enough to find the widget. Null for keyless lists.
1436                    "list_key": list_key,
1437                }),
1438                event_type: "select",
1439            });
1440        }
1441        (end - start) as u32
1442    };
1443
1444    // Pad to the advertised height with blank rows so the List
1445    // occupies its full `visible_rows` (keeps a sibling pane's
1446    // bottom border aligned). Padding rows aren't clickable.
1447    for _ in rows_emitted..avail_rows {
1448        entries.push(blank_row());
1449    }
1450
1451    // Surface a scroll region for the host to paint a draggable
1452    // scrollbar when the list overflows. Totals are in items;
1453    // height_rows is the painted band so the thumb spans it.
1454    if total > visible_items {
1455        if let Some(k) = list_key {
1456            scroll_regions.push(ScrollRegion {
1457                list_key: k.to_string(),
1458                buffer_row: 0,
1459                col_in_row: 0,
1460                width_cols: panel_width,
1461                height_rows: avail_rows,
1462                total: total as usize,
1463                visible: visible_items as usize,
1464                scroll: scroll as usize,
1465            });
1466        }
1467    }
1468
1469    CollectedOutput {
1470        entries,
1471        hits,
1472        focus_cursor: None,
1473        embeds: Vec::new(),
1474        overlays: Vec::new(),
1475        scroll_regions,
1476    }
1477}
1478
1479#[allow(clippy::too_many_arguments)]
1480fn collect_labeled_section(
1481    label: &str,
1482    child: &WidgetSpec,
1483    prev: &HashMap<String, WidgetInstanceState>,
1484    next_state: &mut HashMap<String, WidgetInstanceState>,
1485    focus_key: &str,
1486    panel_width: u32,
1487) -> CollectedOutput {
1488    let mut entries: Vec<TextPropertyEntry> = Vec::new();
1489    let mut hits: Vec<HitArea> = Vec::new();
1490    let mut focus_cursor: Option<FocusCursor> = None;
1491    let mut embeds: Vec<EmbedRect> = Vec::new();
1492    let mut overlays: Vec<OverlayRow> = Vec::new();
1493    let mut scroll_regions: Vec<ScrollRegion> = Vec::new();
1494
1495    // Inner area: 1 column of border + 1 column of
1496    // padding on each side ⇒ 4 columns of chrome.
1497    let inner_width = panel_width.saturating_sub(4).max(1);
1498    let child_out = render_collected(child, prev, next_state, focus_key, inner_width);
1499    // Shift child overlays by 1 to account for the top
1500    // border row this section emits — the child authored
1501    // its anchors relative to its own row 0 (e.g. anchor 1
1502    // = "one row below me"), so an unshifted forward
1503    // would land them one row earlier than intended. The
1504    // Text widget's completion-popup overlays rely on
1505    // this: anchor 1 lands on the section's bottom
1506    // border row (replacing it visually with the dim
1507    // separator), anchor 2+ lands below the section.
1508    overlays.extend(child_out.overlays.into_iter().map(|mut o| {
1509        o.buffer_row += 1;
1510        o
1511    }));
1512
1513    // Render the top border with the label embedded as a
1514    // legend: `╭─ <label> ─...─╮`. When the label is empty,
1515    // produce a plain `╭─...─╮` bar.
1516    let total_cols = panel_width.max(2) as usize;
1517    entries.push(render_section_top_border(label, total_cols));
1518
1519    // Render each child row wrapped with the side borders
1520    // and one column of padding. Pad/truncate the child
1521    // text to exactly `inner_width` so the right border
1522    // lines up regardless of the child's natural width.
1523    for mut child_entry in child_out.entries {
1524        strip_trailing_newline(&mut child_entry);
1525        let wrapped = wrap_in_side_border(child_entry, inner_width as usize);
1526        let row_offset = entries.len() as u32;
1527        // Shift hits/focus emitted by the child by 1 row
1528        // (top border) and by the left-border prefix
1529        // ("│ " — 4 bytes for the box-drawing char + 1
1530        // for the space).
1531        let _ = row_offset;
1532        entries.push(wrapped);
1533    }
1534
1535    // The child's hit areas were rendered with row 0 at
1536    // the *first child line*; shift them by 1 (top
1537    // border) and by the left-border byte prefix.
1538    let prefix_bytes = LEFT_BORDER_PREFIX.len();
1539    for mut h in child_out.hits {
1540        h.buffer_row += 1;
1541        h.byte_start += prefix_bytes;
1542        h.byte_end += prefix_bytes;
1543        hits.push(h);
1544    }
1545    if let Some(mut fc) = child_out.focus_cursor {
1546        fc.buffer_row += 1;
1547        fc.byte_in_row += prefix_bytes as u32;
1548        focus_cursor = Some(fc);
1549    }
1550    // Embeds are column-addressed; the `│ ` prefix is
1551    // 4 UTF-8 bytes but only 2 display columns wide.
1552    let prefix_cols = LEFT_BORDER_PREFIX.chars().count() as u32;
1553    for mut emb in child_out.embeds {
1554        emb.buffer_row += 1;
1555        emb.col_in_row += prefix_cols;
1556        embeds.push(emb);
1557    }
1558    for mut sr in child_out.scroll_regions {
1559        sr.buffer_row += 1;
1560        sr.col_in_row += prefix_cols;
1561        // The section padded the child to `inner_width`, so the
1562        // scroll region's usable width is the inner width (not
1563        // the child's requested width).
1564        sr.width_cols = inner_width;
1565        scroll_regions.push(sr);
1566    }
1567
1568    entries.push(render_section_bottom_border(total_cols));
1569
1570    CollectedOutput {
1571        entries,
1572        hits,
1573        focus_cursor,
1574        embeds,
1575        overlays,
1576        scroll_regions,
1577    }
1578}
1579
1580fn collect_window_embed(window_id: u32, embed_rows: u32, panel_width: u32) -> CollectedOutput {
1581    let mut out = CollectedOutput::default();
1582    // Emit `rows` blank lines of `panel_width` width so
1583    // layout reserves the rectangle. The host paint
1584    // path overlays the native window render on top of
1585    // these blanks after the rest of the panel paints.
1586    let cols = panel_width.max(1) as usize;
1587    for _ in 0..embed_rows {
1588        let mut text = String::with_capacity(cols + 1);
1589        for _ in 0..cols {
1590            text.push(' ');
1591        }
1592        text.push('\n');
1593        out.entries.push(TextPropertyEntry {
1594            text,
1595            properties: Default::default(),
1596            style: None,
1597            inline_overlays: Vec::new(),
1598            segments: Vec::new(),
1599            pad_to_chars: None,
1600            truncate_to_chars: None,
1601        });
1602    }
1603    out.embeds.push(EmbedRect {
1604        window_id,
1605        buffer_row: 0,
1606        col_in_row: 0,
1607        width_cols: panel_width,
1608        height_rows: embed_rows,
1609    });
1610    out
1611}
1612
1613fn collect_raw(raw_entries: &[TextPropertyEntry]) -> CollectedOutput {
1614    let mut out = CollectedOutput::default();
1615    // Raw is the migration escape hatch: the plugin's own
1616    // bytes flow through unchanged. The plugin still owns
1617    // mouse clicks within Raw regions (via the existing
1618    // `mouse_click` hook); the widget runtime intentionally
1619    // emits no hit areas here. We *do* ensure each Raw
1620    // entry ends with a newline so it occupies its own
1621    // buffer line — plugins that already include `\n` are
1622    // unaffected.
1623    for raw_entry in raw_entries {
1624        let mut e = raw_entry.clone();
1625        e.normalize_widths();
1626        ensure_trailing_newline(&mut e);
1627        out.entries.push(e);
1628    }
1629    out
1630}
1631
1632#[allow(clippy::too_many_arguments)]
1633fn collect_overlay(
1634    child: &WidgetSpec,
1635    prev: &HashMap<String, WidgetInstanceState>,
1636    next_state: &mut HashMap<String, WidgetInstanceState>,
1637    focus_key: &str,
1638    panel_width: u32,
1639) -> CollectedOutput {
1640    // Renders the child normally; the parent (`Col`)
1641    // is what decides to promote the resulting entries
1642    // into the overlay set instead of consuming
1643    // vertical space. Outside of a `Col`, an Overlay
1644    // behaves like a transparent wrapper — entries
1645    // flow through unchanged. This keeps the
1646    // Overlay-as-root case (no enclosing Col) sane:
1647    // it just renders inline.
1648    let child_out = render_collected(child, prev, next_state, focus_key, panel_width);
1649    CollectedOutput {
1650        entries: child_out.entries,
1651        hits: child_out.hits,
1652        focus_cursor: child_out.focus_cursor,
1653        embeds: child_out.embeds,
1654        overlays: child_out.overlays,
1655        scroll_regions: child_out.scroll_regions,
1656    }
1657}
1658
1659#[allow(clippy::too_many_arguments)]
1660fn render_widget_text(
1661    value: &str,
1662    cursor_byte: i32,
1663    focused: bool,
1664    label: &str,
1665    placeholder: Option<&str>,
1666    rows: u32,
1667    field_width: u32,
1668    max_visible_chars: u32,
1669    full_width: bool,
1670    completions_visible_rows: u32,
1671    key: Option<&str>,
1672    prev: &HashMap<String, WidgetInstanceState>,
1673    next_state: &mut HashMap<String, WidgetInstanceState>,
1674    focus_key: &str,
1675    panel_width: u32,
1676) -> CollectedOutput {
1677    let mut out = CollectedOutput::default();
1678    // Default popup height: 5 visible rows. Plugins override per-widget
1679    // by setting `completions_visible_rows`; 0 falls back to the default
1680    // so the orchestrator's existing `text({...})` calls Just Work.
1681    let effective_visible_rows = if completions_visible_rows == 0 {
1682        5u32
1683    } else {
1684        completions_visible_rows
1685    };
1686
1687    let is_focused = match key.filter(|k| !k.is_empty()) {
1688        Some(k) => k == focus_key,
1689        None => focused,
1690    };
1691    // Host-owned value/cursor (+ scroll, multi-line only):
1692    // read instance state if it exists; else seed from spec
1693    // on first render. See WidgetInstanceState::Text doc.
1694    //
1695    // `rows == 0` shouldn't happen because of serde's
1696    // default = 1, but if it slips through (raw struct
1697    // construction in tests, etc.) treat it as single-line.
1698    let multiline = rows > 1;
1699    let mut effective_editor: crate::primitives::text_edit::TextEdit;
1700    let prev_scroll: u32;
1701    // Completions + selected index ride along on the
1702    // Text widget's instance state — neither comes from
1703    // the spec (plugins push via `SetCompletions`), so we
1704    // carry them across renders verbatim and clamp the
1705    // index to the current list size below.
1706    let mut prev_completions: Vec<fresh_core::api::CompletionItem> = Vec::new();
1707    let mut prev_completion_idx: usize = 0;
1708    let mut prev_completion_scroll: u32 = 0;
1709    let mut prev_completion_navigated = false;
1710    match key.filter(|k| !k.is_empty()).and_then(|k| prev.get(k)) {
1711        Some(WidgetInstanceState::Text {
1712            editor,
1713            scroll,
1714            completions,
1715            completion_selected_index,
1716            completion_scroll_offset,
1717            completion_navigated,
1718        }) => {
1719            effective_editor = editor.clone();
1720            prev_scroll = *scroll;
1721            prev_completions = completions.clone();
1722            prev_completion_idx = *completion_selected_index;
1723            prev_completion_scroll = *completion_scroll_offset;
1724            prev_completion_navigated = *completion_navigated;
1725        }
1726        _ => {
1727            effective_editor = if multiline {
1728                crate::primitives::text_edit::TextEdit::with_text(value)
1729            } else {
1730                crate::primitives::text_edit::TextEdit::single_line_with_text(value)
1731            };
1732            let seed = if cursor_byte < 0 {
1733                value.len()
1734            } else {
1735                (cursor_byte as usize).min(value.len())
1736            };
1737            effective_editor.set_cursor_from_flat(seed);
1738            prev_scroll = 0;
1739        }
1740    }
1741    // Clamp once per render so a list that shrank
1742    // host-side (or arrived empty) doesn't keep a stale
1743    // out-of-bounds index alive.
1744    if !prev_completions.is_empty() {
1745        prev_completion_idx = prev_completion_idx.min(prev_completions.len() - 1);
1746    } else {
1747        prev_completion_idx = 0;
1748    }
1749    let effective_value = effective_editor.value();
1750    let effective_cursor_byte = effective_editor.flat_cursor_byte() as i32;
1751    let effective_cursor = if is_focused {
1752        effective_cursor_byte
1753    } else {
1754        -1
1755    };
1756    // When `full_width` is requested, override the
1757    // plugin-supplied `field_width` with the slice of
1758    // `panel_width` remaining after the label prefix,
1759    // the two surrounding `[` / `]` brackets, and one
1760    // trailing column reserved for the cursor-park space
1761    // `render_text_input` appends when focused. Reserving
1762    // unconditionally costs an unfocused field one
1763    // trailing space but keeps the rendered width stable
1764    // across the focus transition — without it the field
1765    // would overflow the parent on focus. For multi-line
1766    // we don't need the focus reservation but keep the
1767    // same calculation for symmetry; `render_text_area`
1768    // already fills the panel width by default.
1769    let effective_field_width = if full_width && !multiline {
1770        let label_overhead = if label.is_empty() {
1771            0u32
1772        } else {
1773            label.chars().count() as u32 + 1
1774        };
1775        // Reserve the two columns the focus-marker gutter occupies
1776        // (when the panel opted in), so the bracketed region shrinks
1777        // by the gutter width instead of overflowing the enclosing
1778        // section's right border. Reserved unconditionally — focused
1779        // and unfocused alike — so the closing bracket sits at the
1780        // same column regardless of focus and the box never reflows.
1781        let marker_reserve = if marker_gutter_enabled() { 2 } else { 0 };
1782        panel_width
1783            .saturating_sub(label_overhead)
1784            .saturating_sub(3)
1785            .saturating_sub(marker_reserve)
1786            .max(1)
1787    } else {
1788        field_width
1789    };
1790    // Selection overlay is only meaningful for the focused
1791    // widget — passing `None` otherwise keeps the no-selection
1792    // rendering paths unchanged.
1793    let selection_for_render = if is_focused {
1794        effective_editor.selection_flat_range()
1795    } else {
1796        None
1797    };
1798    let new_scroll;
1799    if multiline {
1800        let rendered = render_text_area(
1801            &effective_value,
1802            effective_cursor,
1803            selection_for_render,
1804            is_focused,
1805            label,
1806            placeholder,
1807            rows,
1808            effective_field_width,
1809            prev_scroll,
1810            panel_width,
1811        );
1812        new_scroll = rendered.scroll_row;
1813        if let (Some(buffer_row), Some(byte_in_row)) =
1814            (rendered.cursor_buffer_row, rendered.cursor_byte_in_row)
1815        {
1816            out.focus_cursor = Some(FocusCursor {
1817                buffer_row,
1818                byte_in_row: byte_in_row as u32,
1819            });
1820        }
1821        for (row_idx, mut e) in rendered.entries.into_iter().enumerate() {
1822            // Clicking any rendered row of the text area focuses the field
1823            // (see the single-line branch / #2234 item 1).
1824            if let Some(k) = key.filter(|k| !k.is_empty()) {
1825                out.hits.push(HitArea {
1826                    widget_key: k.to_string(),
1827                    widget_kind: "text",
1828                    buffer_row: row_idx as u32,
1829                    byte_start: 0,
1830                    byte_end: e.text.len(),
1831                    payload: json!({}),
1832                    event_type: "focus",
1833                });
1834            }
1835            ensure_trailing_newline(&mut e);
1836            out.entries.push(e);
1837        }
1838    } else {
1839        let rendered = render_text_input(
1840            &effective_value,
1841            effective_cursor,
1842            selection_for_render,
1843            is_focused,
1844            label,
1845            placeholder,
1846            max_visible_chars,
1847            effective_field_width,
1848            full_width,
1849        );
1850        new_scroll = 0;
1851        let mut entry = rendered.entry;
1852        // Lead the single-line input with the focus-marker gutter
1853        // (`▸ ` when focused, two spaces otherwise) so focus is
1854        // legible from a plain capture — the hardware cursor lands
1855        // inside the field too, but a cursor doesn't show up in
1856        // `tmux capture-pane`. Shift the cursor offset and every
1857        // inline overlay right by the gutter's byte length so the
1858        // bracket bg / placeholder / selection spans still line up.
1859        // The field width was already reduced by the gutter's two
1860        // columns above, so the box doesn't overflow, and the gutter
1861        // is present whether or not the field is focused so the
1862        // layout never shifts.
1863        let gutter = focus_gutter_prefix(is_focused);
1864        let marker_bytes = gutter.len();
1865        let mut cursor_in_row = rendered.cursor_byte_in_entry;
1866        if marker_bytes > 0 {
1867            entry.text.insert_str(0, gutter);
1868            for ov in entry.inline_overlays.iter_mut() {
1869                ov.start += marker_bytes;
1870                ov.end += marker_bytes;
1871            }
1872            cursor_in_row = cursor_in_row.map(|c| c + marker_bytes);
1873        }
1874        if let Some(byte_in_row) = cursor_in_row {
1875            out.focus_cursor = Some(FocusCursor {
1876                buffer_row: 0,
1877                byte_in_row: byte_in_row as u32,
1878            });
1879        }
1880        // A click anywhere on the input line focuses the field so a mouse user
1881        // can type. Text widgets previously emitted no hit area, so clicks fell
1882        // through and the field stayed unfocused (#2234 item 1). Focusing is
1883        // driven by the tabbable path in `handle_floating_widget_click`; the
1884        // `focus` event keeps the plugin's focus mirror in step.
1885        if let Some(k) = key.filter(|k| !k.is_empty()) {
1886            out.hits.push(HitArea {
1887                widget_key: k.to_string(),
1888                widget_kind: "text",
1889                buffer_row: 0,
1890                byte_start: 0,
1891                byte_end: entry.text.len(),
1892                payload: json!({}),
1893                event_type: "focus",
1894            });
1895        }
1896        ensure_trailing_newline(&mut entry);
1897        out.entries.push(entry);
1898    }
1899    // Persist instance state for next render. `editor`
1900    // already carries the canonical cursor (row/col +
1901    // selection); `scroll` carries the renderer's
1902    // auto-clamped first-visible-row for multi-line, or `0`
1903    // for single-line.
1904    //
1905    // Emit the completion popup as *overlay rows* rather
1906    // than regular entries so it floats — the rest of the
1907    // form below the input keeps its layout position and
1908    // the popup paints on top. The overlay anchors are
1909    // chosen so the dim separator lands on top of the
1910    // wrapping `LabeledSection`'s bottom border (visually
1911    // replacing it), and the side borders + bottom
1912    // border that follow paint over whatever sits below
1913    // the section. See `render_completion_*` helpers for
1914    // the chrome detail.
1915    if !prev_completions.is_empty() {
1916        // `panel_width` here is the inner-area width the
1917        // wrapping `LabeledSection` handed us (it has
1918        // already subtracted its own 4 columns of chrome
1919        // — `│ ` on the left + ` │` on the right). The
1920        // overlay rows need to paint into the full panel
1921        // width (including those `│ ... │` columns), so
1922        // we widen by 4 here so the side borders the
1923        // popup paints line up with the section's.
1924        let popup_inner = panel_width as usize;
1925        let popup_total = popup_inner.saturating_add(4); // re-add section chrome
1926        let total = prev_completions.len() as u32;
1927        let visible = effective_visible_rows.max(1).min(total);
1928        // Forward-only auto-scroll: when the selection
1929        // walks past the bottom of the visible window
1930        // (Down past the last visible row), pull the
1931        // scroll forward to keep selection in view. We
1932        // deliberately do NOT pull the scroll *back* if
1933        // the selection is above the window — the
1934        // mouse-wheel scroll handler explicitly diverges
1935        // scroll from selection (the user is scrolling
1936        // the view, not the selection), and a back-pull
1937        // here would undo the wheel's scroll on the very
1938        // next render. The keyboard Up handler updates
1939        // scroll itself when needed, so it doesn't rely
1940        // on a back-pull from the renderer either.
1941        let sel = prev_completion_idx as u32;
1942        let mut scroll = prev_completion_scroll;
1943        if sel >= scroll + visible {
1944            scroll = sel + 1 - visible;
1945        }
1946        let max_scroll = total.saturating_sub(visible);
1947        if scroll > max_scroll {
1948            scroll = max_scroll;
1949        }
1950        prev_completion_scroll = scroll;
1951
1952        // Overlay anchors:
1953        //   anchor 0 = the text widget's own row (input)
1954        //   anchor 1 = labeledSection's bottom border row
1955        //              (the dim separator paints here,
1956        //              replacing the section's `╰─...─╯`
1957        //              visually)
1958        //   anchor 2..N+1 = item rows
1959        //   anchor N+2 = popup's own bottom border
1960        //              `╰─...─╯` (a `LabeledSection`
1961        //              passes child overlays through
1962        //              unchanged, see widgets/render.rs
1963        //              `LabeledSection` branch).
1964        let mut anchor: u32 = 1;
1965        out.overlays.push(OverlayRow {
1966            buffer_row: anchor,
1967            entry: render_completion_dim_separator_overlay(popup_total),
1968        });
1969        anchor += 1;
1970        let needs_scrollbar = total > visible;
1971        let end = (scroll + visible).min(total) as usize;
1972        for (visible_row, i) in (scroll as usize..end).enumerate() {
1973            let item = &prev_completions[i];
1974            let thumb = if needs_scrollbar {
1975                completion_scrollbar_glyph(visible_row as u32, visible, scroll, total)
1976            } else {
1977                None
1978            };
1979            out.overlays.push(OverlayRow {
1980                buffer_row: anchor,
1981                entry: render_completion_item_overlay(
1982                    &item.value,
1983                    item.kind.as_deref(),
1984                    // Only paint a selected-row highlight once the user
1985                    // has stepped into the dropdown (↓/↑). A freshly
1986                    // surfaced popup shows plain suggestions so it's
1987                    // clear Enter acts on the form, not the list.
1988                    prev_completion_navigated && i == prev_completion_idx,
1989                    popup_total,
1990                    thumb,
1991                ),
1992            });
1993            anchor += 1;
1994        }
1995        out.overlays.push(OverlayRow {
1996            buffer_row: anchor,
1997            entry: render_completion_bottom_border(popup_total),
1998        });
1999    } else {
2000        prev_completion_scroll = 0;
2001    }
2002    if let Some(k) = key.filter(|k| !k.is_empty()) {
2003        next_state.insert(
2004            k.to_string(),
2005            WidgetInstanceState::Text {
2006                editor: effective_editor.clone(),
2007                scroll: new_scroll,
2008                completions: prev_completions,
2009                completion_selected_index: prev_completion_idx,
2010                completion_scroll_offset: prev_completion_scroll,
2011                completion_navigated: prev_completion_navigated,
2012            },
2013        );
2014    }
2015    out
2016}
2017
2018#[allow(clippy::too_many_arguments)]
2019fn render_widget_tree(
2020    nodes: &[TreeNode],
2021    item_keys: &[String],
2022    selected_index: i32,
2023    visible_rows: u32,
2024    expanded_keys: &[String],
2025    checkable: bool,
2026    tree_key: Option<&str>,
2027    prev: &HashMap<String, WidgetInstanceState>,
2028    next_state: &mut HashMap<String, WidgetInstanceState>,
2029) -> CollectedOutput {
2030    let mut out = CollectedOutput::default();
2031    // Look up host-owned instance state (scroll, selection,
2032    // expanded set). Spec values are initial-only.
2033    let prev_state = tree_key.filter(|k| !k.is_empty()).and_then(|k| prev.get(k));
2034    let (prev_scroll, prev_sel, prev_expanded) = match prev_state {
2035        Some(WidgetInstanceState::Tree {
2036            scroll_offset,
2037            selected_index,
2038            expanded_keys,
2039        }) => (*scroll_offset, *selected_index, expanded_keys.clone()),
2040        _ => {
2041            // First render: seed expanded_keys from spec.
2042            let seeded: HashSet<String> = expanded_keys.iter().cloned().collect();
2043            (0, selected_index, seeded)
2044        }
2045    };
2046
2047    // Compute the visible (un-collapsed) flat slice of the
2048    // full `nodes` list. A node at depth d is visible iff
2049    // every ancestor (the most recent earlier node at depth
2050    // d-1, that node's most recent earlier at d-2, etc.) is
2051    // expanded. Walk linearly tracking ancestor expansion at
2052    // each depth — set ancestor[d] = is_expanded(node) when
2053    // we visit a node at depth d, and consider a node
2054    // visible iff ancestor[0..node.depth] are all true.
2055    //
2056    // O(N * max_depth) — fine; trees in this editor are
2057    // shallow (filesystem trees, search-results trees).
2058    let mut ancestor_open: Vec<bool> = Vec::new();
2059    let mut visible_indices: Vec<usize> = Vec::with_capacity(nodes.len());
2060    for (i, node) in nodes.iter().enumerate() {
2061        let depth = node.depth as usize;
2062        // Truncate the ancestor stack to this node's depth.
2063        ancestor_open.truncate(depth);
2064        let visible = ancestor_open.iter().all(|open| *open);
2065        if visible {
2066            visible_indices.push(i);
2067        }
2068        // Push this node's own openness onto the stack so
2069        // descendants see it. The node is "open" iff it has
2070        // children AND its key is in expanded_keys; leaves
2071        // act like open nodes (their nonexistent descendants
2072        // can't be hidden anyway).
2073        let key = item_keys.get(i).cloned().unwrap_or_default();
2074        let is_open = if node.has_children {
2075            !key.is_empty() && prev_expanded.contains(&key)
2076        } else {
2077            true
2078        };
2079        ancestor_open.push(is_open);
2080    }
2081
2082    // Clamp the previous selection to a visible index. The
2083    // selected_index in the spec/instance state references
2084    // the *absolute* `nodes` index; if that node is now
2085    // hidden (parent collapsed), find the closest visible
2086    // node at-or-before it. If no visible nodes, -1.
2087    let total_visible = visible_indices.len() as u32;
2088    let visible = visible_rows.max(1);
2089    let clamp_to_visible = |abs: i32| -> i32 {
2090        if abs < 0 || nodes.is_empty() {
2091            return -1;
2092        }
2093        let abs = abs.min((nodes.len() as i32) - 1) as usize;
2094        if let Ok(_pos) = visible_indices.binary_search(&abs) {
2095            return abs as i32;
2096        }
2097        // Not visible — fall back to the nearest earlier
2098        // visible node, else the first visible node, else -1.
2099        let earlier = visible_indices.iter().rev().find(|&&v| v <= abs);
2100        if let Some(&v) = earlier {
2101            return v as i32;
2102        }
2103        visible_indices.first().map(|&v| v as i32).unwrap_or(-1)
2104    };
2105    let effective_sel_abs = clamp_to_visible(prev_sel);
2106    // Find the position of the selected absolute index in
2107    // visible_indices — that's its "visible-window position"
2108    // used for scroll math.
2109    let sel_visible_pos: i32 = if effective_sel_abs < 0 {
2110        -1
2111    } else {
2112        visible_indices
2113            .iter()
2114            .position(|&v| v == effective_sel_abs as usize)
2115            .map(|p| p as i32)
2116            .unwrap_or(-1)
2117    };
2118
2119    // Compute scroll: same auto-clamp logic as List, but
2120    // operating on the visible-windowed indices.
2121    let mut scroll = prev_scroll;
2122    if sel_visible_pos >= 0 {
2123        let sel = sel_visible_pos as u32;
2124        if sel < scroll {
2125            scroll = sel;
2126        }
2127        if sel >= scroll + visible {
2128            scroll = sel + 1 - visible;
2129        }
2130    }
2131    let max_scroll = total_visible.saturating_sub(visible);
2132    if scroll > max_scroll {
2133        scroll = max_scroll;
2134    }
2135
2136    // Persist instance state.
2137    if let Some(k) = tree_key.filter(|k| !k.is_empty()) {
2138        next_state.insert(
2139            k.to_string(),
2140            WidgetInstanceState::Tree {
2141                scroll_offset: scroll,
2142                selected_index: effective_sel_abs,
2143                expanded_keys: prev_expanded.clone(),
2144            },
2145        );
2146    }
2147
2148    // Render the visible window.
2149    let start = scroll as usize;
2150    let end = ((scroll + visible) as usize).min(visible_indices.len());
2151    for &abs_idx in &visible_indices[start..end] {
2152        // Apply pad/truncate hints and convert any char-unit
2153        // overlays to byte offsets *before* the disclosure
2154        // prefix is prepended; render_tree_row then byte-shifts
2155        // the (now byte-unit) overlays uniformly.
2156        let mut node = nodes[abs_idx].clone();
2157        node.text.normalize_widths();
2158        let item_key = item_keys.get(abs_idx).cloned().unwrap_or_default();
2159        let is_expanded =
2160            node.has_children && !item_key.is_empty() && prev_expanded.contains(&item_key);
2161        let rendered = render_tree_row(&node, is_expanded, checkable);
2162        let mut entry = rendered.entry;
2163        let is_selected = abs_idx as i32 == effective_sel_abs;
2164        if is_selected {
2165            let mut style = entry.style.unwrap_or_default();
2166            style.bg = Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG));
2167            style.extend_to_line_end = true;
2168            entry.style = Some(style);
2169        }
2170        let row_byte_end = entry.text.len();
2171        ensure_trailing_newline(&mut entry);
2172        out.entries.push(entry);
2173        let hit_row = (out.entries.len() - 1) as u32;
2174        // Disclosure hit (only when has_children) — fires
2175        // `expand`. The host toggles instance-state
2176        // `expanded_keys` and re-renders before firing the
2177        // event; the plugin only listens if it cares about
2178        // expansion changes.
2179        // Tree hits use the *tree's* spec key for
2180        // `widget_key` (so click-to-focus works the same
2181        // as Toggle/Button — the tree is tabbable). The
2182        // per-row key travels in the payload.
2183        let tree_spec_key = tree_key.unwrap_or("").to_string();
2184        if let Some(disc_range) = rendered.disclosure_range {
2185            out.hits.push(HitArea {
2186                widget_key: tree_spec_key.clone(),
2187                widget_kind: "tree",
2188                buffer_row: hit_row,
2189                byte_start: disc_range.0,
2190                byte_end: disc_range.1,
2191                payload: json!({
2192                    "index": abs_idx as i64,
2193                    "key": item_key.clone(),
2194                    "expanded": !is_expanded,
2195                }),
2196                event_type: "expand",
2197            });
2198        }
2199        // Checkbox hit (when the parent Tree is checkable
2200        // *and* this node has Some(_) checked) — fires
2201        // `toggle` with the *new* checked value. The host
2202        // does not mutate the spec; the plugin owns the
2203        // truth and pushes the new state back via
2204        // `WidgetMutation::SetCheckedKeys`.
2205        if let Some(cb_range) = rendered.checkbox_range {
2206            let new_checked = !nodes[abs_idx].checked.unwrap_or(false);
2207            out.hits.push(HitArea {
2208                widget_key: tree_spec_key.clone(),
2209                widget_kind: "tree",
2210                buffer_row: hit_row,
2211                byte_start: cb_range.0,
2212                byte_end: cb_range.1,
2213                payload: json!({
2214                    "index": abs_idx as i64,
2215                    "key": item_key.clone(),
2216                    "checked": new_checked,
2217                }),
2218                event_type: "toggle",
2219            });
2220        }
2221        // Row body hit — fires `select`. Spans whatever's
2222        // left of the row text after the disclosure +
2223        // checkbox prefix.
2224        let body_start = match (rendered.checkbox_range, rendered.disclosure_range) {
2225            (Some((_, end)), _) => end + 1, // +1 for the trailing space after [v]
2226            (None, Some((_, end))) => end,
2227            (None, None) => 0,
2228        };
2229        if body_start < row_byte_end {
2230            out.hits.push(HitArea {
2231                widget_key: tree_spec_key,
2232                widget_kind: "tree",
2233                buffer_row: hit_row,
2234                byte_start: body_start,
2235                byte_end: row_byte_end,
2236                payload: json!({
2237                    "index": abs_idx as i64,
2238                    "key": item_key,
2239                }),
2240                event_type: "select",
2241            });
2242        }
2243    }
2244    out
2245}
2246
2247// =========================================================================
2248// LabeledSection helpers.
2249// =========================================================================
2250
2251const LEFT_BORDER_PREFIX: &str = "│ ";
2252const RIGHT_BORDER_SUFFIX: &str = " │";
2253
2254/// Build the top border row for a `LabeledSection`.
2255///
2256/// Output (with label "Session name", total_cols = 30):
2257///
2258/// ```text
2259/// ╭─ Session name ─────────────╮
2260/// ```
2261///
2262/// When `label` is empty the legend separators collapse and the
2263/// border is one unbroken `─` run.
2264fn render_section_top_border(label: &str, total_cols: usize) -> TextPropertyEntry {
2265    let mut text = String::new();
2266    let mut overlays: Vec<InlineOverlay> = Vec::new();
2267    text.push('╭');
2268    if label.is_empty() {
2269        for _ in 0..total_cols.saturating_sub(2) {
2270            text.push('─');
2271        }
2272    } else {
2273        // `╭─ label ─...─╮`. Capture the byte range of `label`
2274        // (after the leading `─ ` and before the trailing ` `)
2275        // so the renderer can paint it in a distinct fg, marking
2276        // it as the section caption rather than border chrome.
2277        let label_cols = label.chars().count();
2278        let used = 1 + 1 + 1 + label_cols + 1; // ╭ ─ ` ` label ` `
2279        text.push('─');
2280        text.push(' ');
2281        let label_byte_start = text.len();
2282        text.push_str(label);
2283        let label_byte_end = text.len();
2284        text.push(' ');
2285        let remaining = total_cols.saturating_sub(used + 1); // -1 for `╮`
2286        for _ in 0..remaining {
2287            text.push('─');
2288        }
2289        overlays.push(InlineOverlay {
2290            start: label_byte_start,
2291            end: label_byte_end,
2292            style: OverlayOptions {
2293                fg: Some(OverlayColorSpec::theme_key(KEY_SECTION_LABEL_FG)),
2294                bold: true,
2295                ..Default::default()
2296            },
2297            properties: Default::default(),
2298            unit: OffsetUnit::Byte,
2299        });
2300    }
2301    text.push('╮');
2302    text.push('\n');
2303    TextPropertyEntry {
2304        text,
2305        properties: Default::default(),
2306        style: None,
2307        inline_overlays: overlays,
2308        segments: Vec::new(),
2309        pad_to_chars: None,
2310        truncate_to_chars: None,
2311    }
2312}
2313
2314/// Build the bottom border row: `╰──...──╯` spanning `total_cols`
2315/// display columns.
2316fn render_section_bottom_border(total_cols: usize) -> TextPropertyEntry {
2317    let mut text = String::new();
2318    text.push('╰');
2319    for _ in 0..total_cols.saturating_sub(2) {
2320        text.push('─');
2321    }
2322    text.push('╯');
2323    text.push('\n');
2324    TextPropertyEntry {
2325        text,
2326        properties: Default::default(),
2327        style: None,
2328        inline_overlays: Vec::new(),
2329        segments: Vec::new(),
2330        pad_to_chars: None,
2331        truncate_to_chars: None,
2332    }
2333}
2334
2335/// Dim-separator overlay row for the completion popup. Unlike
2336/// `render_completion_dim_separator` (which targets a child of
2337/// a `LabeledSection` and lets the section wrap the row with
2338/// `│ ... │`), this one paints into the FULL panel width
2339/// directly and supplies its own `│ ... │` chrome — overlay
2340/// rows skip the wrapping section's per-row wrap and land on
2341/// the parent col's row directly. `total_cols` is the section's
2342/// outer width.
2343fn render_completion_dim_separator_overlay(total_cols: usize) -> TextPropertyEntry {
2344    let inner = total_cols.saturating_sub(2).max(1);
2345    let mut text = String::with_capacity(total_cols * 4 + 2);
2346    text.push('│');
2347    for _ in 0..inner {
2348        text.push('┄');
2349    }
2350    text.push('│');
2351    text.push('\n');
2352    // Side `│` chars paint in the popup's border theme key
2353    // (`ui.popup_border_fg`) so the popup chrome reads as
2354    // distinct from the wrapping labeled section's default
2355    // border (per the "use a theme key for the popup border"
2356    // requirement). The dashed run between them paints in the
2357    // dim foreground so it reads as a recessed transition
2358    // rather than chrome.
2359    let left_border_bytes = "│".len();
2360    let dash_bytes = "┄".len() * inner;
2361    let right_border_start = left_border_bytes + dash_bytes;
2362    let right_border_end = right_border_start + "│".len();
2363    let inline_overlays = vec![
2364        InlineOverlay {
2365            start: 0,
2366            end: left_border_bytes,
2367            style: OverlayOptions {
2368                fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2369                ..Default::default()
2370            },
2371            properties: Default::default(),
2372            unit: OffsetUnit::Byte,
2373        },
2374        InlineOverlay {
2375            start: left_border_bytes,
2376            end: left_border_bytes + dash_bytes,
2377            style: OverlayOptions {
2378                fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_DIM_FG)),
2379                ..Default::default()
2380            },
2381            properties: Default::default(),
2382            unit: OffsetUnit::Byte,
2383        },
2384        InlineOverlay {
2385            start: right_border_start,
2386            end: right_border_end,
2387            style: OverlayOptions {
2388                fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2389                ..Default::default()
2390            },
2391            properties: Default::default(),
2392            unit: OffsetUnit::Byte,
2393        },
2394    ];
2395    TextPropertyEntry {
2396        text,
2397        properties: Default::default(),
2398        style: None,
2399        inline_overlays,
2400        segments: Vec::new(),
2401        pad_to_chars: None,
2402        truncate_to_chars: None,
2403    }
2404}
2405
2406/// Completion-popup bottom border overlay row: `│╰─...─╯│`
2407/// shape — wait no, the bottom-border row is exactly
2408/// `╰─...─╯` (the side `│ ... │` columns become the corner
2409/// glyphs at the very bottom of the popup). Paints at the row
2410/// right after the last visible candidate, closing the
2411/// unified box.
2412fn render_completion_bottom_border(total_cols: usize) -> TextPropertyEntry {
2413    let mut text = String::with_capacity(total_cols * 4 + 2);
2414    text.push('╰');
2415    for _ in 0..total_cols.saturating_sub(2).max(1) {
2416        text.push('─');
2417    }
2418    text.push('╯');
2419    text.push('\n');
2420    // The whole row is chrome; stamp the popup-border theme key
2421    // at the entry level so every glyph paints in the same
2422    // colour (no hard-coded RGB or ratatui `Color` value
2423    // anywhere in the popup rendering — every fg/bg goes
2424    // through a `ui.*` theme key).
2425    TextPropertyEntry {
2426        text,
2427        properties: Default::default(),
2428        style: Some(OverlayOptions {
2429            fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2430            ..Default::default()
2431        }),
2432        inline_overlays: Vec::new(),
2433        segments: Vec::new(),
2434        pad_to_chars: None,
2435        truncate_to_chars: None,
2436    }
2437}
2438
2439/// Overlay variant of `render_completion_item`. Same body
2440/// (leading space + candidate text + optional scrollbar glyph
2441/// + trailing pad), but wrapped with the popup's own
2442/// `│ ... │` chrome since overlay rows paint at the panel
2443/// width directly without going through a `LabeledSection`'s
2444/// row wrapper.
2445fn render_completion_item_overlay(
2446    item: &str,
2447    kind: Option<&str>,
2448    selected: bool,
2449    total_cols: usize,
2450    scrollbar: Option<char>,
2451) -> TextPropertyEntry {
2452    let inner = total_cols.saturating_sub(2).max(1);
2453    // Reuse the inline-row builder for the body — same layout
2454    // rules (2 leading chars, item text, pad-to-(inner-1),
2455    // scrollbar in the last column).
2456    let body_entry = render_completion_item(item, kind, selected, inner, scrollbar);
2457    // Build the wrapped text: `│` + body content + `│`. We
2458    // strip the body's trailing newline first so the borders
2459    // sit on the same line.
2460    let mut text = String::with_capacity(body_entry.text.len() + 8);
2461    text.push('│');
2462    let body_no_nl = body_entry.text.trim_end_matches('\n');
2463    text.push_str(body_no_nl);
2464    text.push('│');
2465    text.push('\n');
2466    // Selection highlight is emitted as an inline overlay that
2467    // covers ONLY the body byte range (between the two `│`
2468    // chars) instead of a row-level `extend_to_line_end` style.
2469    // A row-level selection style would also cover the border
2470    // cells, and the per-border fg-only overlay below couldn't
2471    // paint bg back over them — the right `│` would sit on
2472    // selection blue. With the highlight scoped to the body
2473    // range, the borders fall outside the selection's reach
2474    // and paint with the panel's base bg (`theme.suggestion_bg`,
2475    // filled in by the painter when no overlay supplies a bg).
2476    //
2477    // The body inline overlay covers the leading space, the
2478    // candidate text, the trailing pad, AND the scrollbar
2479    // column — so the selection reads as a single solid block
2480    // across the whole inside of the popup rather than
2481    // truncating at the end of the candidate text. The
2482    // scrollbar's own fg-only overlay is appended after the
2483    // selection overlay so it re-tints the scrollbar glyph's
2484    // fg (per-property overlay merge keeps the selection bg).
2485    let left_border_bytes = "│".len();
2486    let body_no_nl_bytes = body_no_nl.len();
2487    let right_border_start = left_border_bytes + body_no_nl_bytes;
2488    let right_border_end = right_border_start + "│".len();
2489    let mut inline_overlays: Vec<InlineOverlay> = Vec::new();
2490    if selected {
2491        inline_overlays.push(InlineOverlay {
2492            start: left_border_bytes,
2493            end: right_border_start,
2494            style: OverlayOptions {
2495                fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_FG)),
2496                bg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_BG)),
2497                ..Default::default()
2498            },
2499            properties: Default::default(),
2500            unit: OffsetUnit::Byte,
2501        });
2502    }
2503    // Shift the body's inline overlays right by one byte
2504    // (the leading `│`) so the scrollbar tint still lands on
2505    // the right cell. Then add two more inline overlays for
2506    // the side `│` chars themselves so they paint in the
2507    // popup-border theme key — same key the dim separator and
2508    // bottom border use, so the popup chrome reads as a
2509    // single themed surface.
2510    inline_overlays.extend(body_entry.inline_overlays.into_iter().map(|mut io| {
2511        io.start += left_border_bytes;
2512        io.end += left_border_bytes;
2513        io
2514    }));
2515    inline_overlays.push(InlineOverlay {
2516        start: 0,
2517        end: left_border_bytes,
2518        style: OverlayOptions {
2519            fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2520            ..Default::default()
2521        },
2522        properties: Default::default(),
2523        unit: OffsetUnit::Byte,
2524    });
2525    inline_overlays.push(InlineOverlay {
2526        start: right_border_start,
2527        end: right_border_end,
2528        style: OverlayOptions {
2529            fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2530            ..Default::default()
2531        },
2532        properties: Default::default(),
2533        unit: OffsetUnit::Byte,
2534    });
2535    TextPropertyEntry {
2536        text,
2537        properties: Default::default(),
2538        style: None,
2539        inline_overlays,
2540        segments: Vec::new(),
2541        pad_to_chars: None,
2542        truncate_to_chars: None,
2543    }
2544}
2545
2546/// One completion-candidate row. Renders as two leading spaces
2547/// followed by the candidate text, padded / truncated by the
2548/// wrapping `LabeledSection` to `total_cols`. The two leading
2549/// spaces place the candidate's first character at the same
2550/// column as the input value's first character: the input
2551/// row's leading chrome is `│ [` (border + section padding +
2552/// open bracket) — three columns — and the popup row's leading
2553/// chrome is `│ ` plus the body's two leading spaces, also
2554/// three columns. So the popup item's first char sits directly
2555/// under the value's first char, matching the user's "below
2556/// the input, aligned with what you typed" expectation.
2557///
2558/// `selected` rows paint with the standard popup-selection
2559/// fg/bg theme keys + `extend_to_line_end` so the highlight
2560/// runs all the way to the right side border instead of
2561/// stopping at the end of the candidate text.
2562///
2563/// `scrollbar` is `Some(glyph)` when the popup is scrollable
2564/// AND this row owns a scrollbar character (thumb or track).
2565/// The glyph paints at the right edge of the row, just inside
2566/// the wrapping section's `│` border, so the scrollbar lives
2567/// in the popup's chrome rather than crowding the candidate
2568/// text. `None` rows leave the column blank — either because
2569/// the popup fits without scrolling or because every row gets
2570/// `None` when there's nothing to indicate.
2571fn render_completion_item(
2572    item: &str,
2573    kind: Option<&str>,
2574    selected: bool,
2575    total_cols: usize,
2576    scrollbar: Option<char>,
2577) -> TextPropertyEntry {
2578    // Build the row up to `total_cols - 1` so the scrollbar (or
2579    // a trailing space when there isn't one) lands at exactly
2580    // `total_cols - 1`. The wrapping section pads/truncates the
2581    // resulting row to `total_cols`, but we want the scrollbar
2582    // glyph to keep its position regardless of how long the
2583    // candidate text is, so we hand-pad rather than relying on
2584    // entry-level `pad_to_chars`.
2585    //
2586    // When the panel reserves the focus-marker gutter, the input's
2587    // bracketed value is itself shifted right by the two-column gutter
2588    // (`▸ ` / two spaces, inserted before its `[`). Lead the candidate
2589    // rows by the same two columns so the candidate text stays directly
2590    // under the typed value instead of sitting two columns to its left.
2591    // Zero when the panel didn't opt into the gutter (every other
2592    // popup), so those render exactly as before.
2593    let lead = if marker_gutter_enabled() { 2 } else { 0 };
2594    // Budget = total_cols - (2 leading chars) - (gutter lead) - (1 scrollbar col).
2595    // The two leading chars align the item with the bracketed
2596    // input value (see the function docstring).
2597    let text_budget = total_cols.saturating_sub(2 + lead).saturating_sub(1);
2598    let item_chars: Vec<char> = item.chars().collect();
2599    let (visible_item, truncated): (String, bool) = if item_chars.len() <= text_budget {
2600        (item.to_string(), false)
2601    } else {
2602        // Tail-truncate with `…` so the prefix the user typed
2603        // stays anchored at the left, which is the common case
2604        // for path / branch completions (the divergent part is
2605        // at the end).
2606        let keep = text_budget.saturating_sub(1);
2607        let head: String = item_chars.iter().take(keep).collect();
2608        (format!("{}…", head), true)
2609    };
2610    let _ = truncated;
2611    let scrollbar_ch = scrollbar.unwrap_or(' ');
2612    let is_history = kind == Some("history");
2613    // For history rows we replace the second leading space (the
2614    // column that lines up with the bracketed input's `[`) with
2615    // a small `↶` marker so the row visibly reads as "from
2616    // history" at a glance. Regular rows keep two leading
2617    // spaces. The marker is one display column wide so the
2618    // item text starts in the same column on both kinds.
2619    let history_marker: char = '↶';
2620    let mut text = String::with_capacity(total_cols * 4 + 2);
2621    // Gutter lead (see `lead` above): keeps the candidate aligned under
2622    // the gutter-shifted input value. The history `↶` marker and the
2623    // selection highlight are positioned by byte offsets captured *after*
2624    // these spaces, so they ride along correctly.
2625    for _ in 0..lead {
2626        text.push(' ');
2627    }
2628    text.push(' ');
2629    let marker_start_byte = text.len();
2630    if is_history {
2631        text.push(history_marker);
2632    } else {
2633        text.push(' ');
2634    }
2635    let marker_end_byte = text.len();
2636    let item_start_byte = text.len();
2637    text.push_str(&visible_item);
2638    let item_end_byte = text.len();
2639    // Pad with spaces between the candidate text and the
2640    // scrollbar column so all rows have the scrollbar glyph in
2641    // the same column regardless of candidate length.
2642    let used_cols = 2 + lead + visible_item.chars().count();
2643    let pad_cols = total_cols.saturating_sub(used_cols).saturating_sub(1);
2644    for _ in 0..pad_cols {
2645        text.push(' ');
2646    }
2647    text.push(scrollbar_ch);
2648    text.push('\n');
2649
2650    let body_style = if selected {
2651        Some(OverlayOptions {
2652            fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_FG)),
2653            bg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_BG)),
2654            extend_to_line_end: true,
2655            fg_on_collision_only: false,
2656            ..Default::default()
2657        })
2658    } else {
2659        // Stamp the popup's text fg on the whole row so the
2660        // candidate text reads against `popup_bg` rather than
2661        // inheriting the terminal's default foreground (which
2662        // has no relationship to the themed popup surface).
2663        Some(OverlayOptions {
2664            fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_FG)),
2665            extend_to_line_end: true,
2666            fg_on_collision_only: false,
2667            ..Default::default()
2668        })
2669    };
2670    let mut inline_overlays: Vec<InlineOverlay> = Vec::new();
2671    // History rows: paint the `↶` marker in the popup-border
2672    // theme key (so it reads as chrome, not item content) and
2673    // italicize the item text. Same dim fg key the scrollbar
2674    // uses so all popup chrome stays in one theme slot.
2675    if is_history {
2676        inline_overlays.push(InlineOverlay {
2677            start: marker_start_byte,
2678            end: marker_end_byte,
2679            style: OverlayOptions {
2680                fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2681                ..Default::default()
2682            },
2683            properties: Default::default(),
2684            unit: OffsetUnit::Byte,
2685        });
2686        inline_overlays.push(InlineOverlay {
2687            start: item_start_byte,
2688            end: item_end_byte,
2689            style: OverlayOptions {
2690                italic: true,
2691                ..Default::default()
2692            },
2693            properties: Default::default(),
2694            unit: OffsetUnit::Byte,
2695        });
2696    }
2697    // Scrollbar glyph paints in the dim theme key so it reads as
2698    // chrome rather than as part of the candidate text. We do
2699    // this as an inline overlay over the last visible cell so
2700    // the selection highlight on selected rows doesn't repaint
2701    // the scrollbar in white-on-blue.
2702    if scrollbar.is_some() {
2703        let total_bytes = text.trim_end_matches('\n').len();
2704        let scrollbar_byte_len = scrollbar_ch.len_utf8();
2705        let start = total_bytes - scrollbar_byte_len;
2706        let end = total_bytes;
2707        inline_overlays.push(InlineOverlay {
2708            start,
2709            end,
2710            style: OverlayOptions {
2711                fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_DIM_FG)),
2712                ..Default::default()
2713            },
2714            properties: Default::default(),
2715            unit: OffsetUnit::Byte,
2716        });
2717    }
2718
2719    TextPropertyEntry {
2720        text,
2721        properties: Default::default(),
2722        style: body_style,
2723        inline_overlays,
2724        segments: Vec::new(),
2725        pad_to_chars: None,
2726        truncate_to_chars: None,
2727    }
2728}
2729
2730/// Compute the scrollbar glyph for the given visible row
2731/// position. Returns `Some(...)` for rows that overlap the
2732/// thumb's vertical extent (rendered as a solid `█`); `None`
2733/// otherwise (rendered as a blank track cell so the candidate
2734/// row still aligns with the scrollbar column).
2735///
2736/// The thumb size is proportional to `visible / total` and
2737/// snaps to at least one row. The thumb's top row is
2738/// `floor(scroll / total * visible)` — first row of the
2739/// visible window when scrolled to the top, last row when
2740/// scrolled to the bottom.
2741fn completion_scrollbar_glyph(
2742    visible_row: u32,
2743    visible: u32,
2744    scroll: u32,
2745    total: u32,
2746) -> Option<char> {
2747    if total <= visible || visible == 0 {
2748        return None;
2749    }
2750    // Thumb size: at least 1 row, otherwise proportional. Float
2751    // math is fine — `total` and `visible` are tiny (popup
2752    // height capped to a handful of rows).
2753    let thumb_size = ((visible as f32 * visible as f32) / total as f32).round() as u32;
2754    let thumb_size = thumb_size.max(1).min(visible);
2755    let max_scroll = total - visible;
2756    let thumb_top = if max_scroll == 0 {
2757        0
2758    } else {
2759        // `(scroll / max_scroll) * (visible - thumb_size)` —
2760        // 0 when at the top, `visible - thumb_size` when at the
2761        // bottom.
2762        ((scroll as f32 / max_scroll as f32) * (visible - thumb_size) as f32).round() as u32
2763    };
2764    if visible_row >= thumb_top && visible_row < thumb_top + thumb_size {
2765        Some('█')
2766    } else {
2767        None
2768    }
2769}
2770
2771/// Wrap a single child row with `│ ... │` and pad / truncate the
2772/// child text to fit exactly `inner_width` display columns.
2773/// Inline overlays are byte-shifted by the left-prefix length so
2774/// they keep aligning with the right characters.
2775fn wrap_in_side_border(mut child: TextPropertyEntry, inner_width: usize) -> TextPropertyEntry {
2776    let prefix_bytes = LEFT_BORDER_PREFIX.len();
2777    // Pad / truncate `child.text` to `inner_width` display cols.
2778    let cur_cols = child.text.chars().count();
2779    if cur_cols < inner_width {
2780        for _ in 0..(inner_width - cur_cols) {
2781            child.text.push(' ');
2782        }
2783    } else if cur_cols > inner_width {
2784        // Tail-truncate at the codepoint boundary corresponding
2785        // to `inner_width` chars, then if there's room replace
2786        // the final visible char with `…` so the cut is visible
2787        // (mirrors `pad_or_truncate_cols`).
2788        let indices: Vec<usize> = child.text.char_indices().map(|(i, _)| i).collect();
2789        let byte_cutoff = indices
2790            .get(inner_width)
2791            .copied()
2792            .unwrap_or(child.text.len());
2793        child.text.truncate(byte_cutoff);
2794        if inner_width >= 2 {
2795            // Replace the last visible char with `…`. `pop()` walks
2796            // codepoint boundaries so multi-byte tails are handled
2797            // correctly. We then update `byte_cutoff` to the new
2798            // string length so overlay clamping below uses the
2799            // post-ellipsis boundary.
2800            child.text.pop();
2801            child.text.push('…');
2802        }
2803        let byte_cutoff = child.text.len();
2804        // Drop any overlay that would now reference past the
2805        // truncation point; clamp the rest.
2806        child.inline_overlays.retain_mut(|o| {
2807            if o.start >= byte_cutoff {
2808                return false;
2809            }
2810            if o.end > byte_cutoff {
2811                o.end = byte_cutoff;
2812            }
2813            true
2814        });
2815    }
2816
2817    // Compose final text: `│ ` + child + ` │\n`.
2818    let mut text = String::with_capacity(
2819        LEFT_BORDER_PREFIX.len() + child.text.len() + RIGHT_BORDER_SUFFIX.len() + 1,
2820    );
2821    text.push_str(LEFT_BORDER_PREFIX);
2822    text.push_str(&child.text);
2823    text.push_str(RIGHT_BORDER_SUFFIX);
2824    text.push('\n');
2825
2826    // Shift child overlays by the left-prefix byte count.
2827    let overlays: Vec<InlineOverlay> = child
2828        .inline_overlays
2829        .into_iter()
2830        .map(|o| InlineOverlay {
2831            start: o.start + prefix_bytes,
2832            end: o.end + prefix_bytes,
2833            style: o.style,
2834            properties: o.properties,
2835            unit: o.unit,
2836        })
2837        .collect();
2838
2839    TextPropertyEntry {
2840        text,
2841        properties: child.properties,
2842        style: child.style,
2843        inline_overlays: overlays,
2844        segments: Vec::new(),
2845        pad_to_chars: None,
2846        truncate_to_chars: None,
2847    }
2848}
2849
2850/// Render a HintBar into a single `TextPropertyEntry`.
2851///
2852/// Layout: `<keys> <label>  <keys> <label>  …`. The key portion of
2853/// each entry is highlighted with the `ui.help_key_fg` theme key;
2854/// labels use the buffer's default foreground.
2855///
2856/// This replaces the per-plugin hand-rolled footer at e.g.
2857/// `crates/fresh-editor/plugins/search_replace.ts:535–541`,
2858/// `audit_mode.ts:1068–1158`, `pkg.ts:2136–2145`.
2859pub fn render_hint_bar(entries: &[HintEntry]) -> TextPropertyEntry {
2860    let separator = "  ";
2861    let mut text = String::new();
2862    let mut overlays = Vec::new();
2863    for (i, entry) in entries.iter().enumerate() {
2864        if i > 0 {
2865            text.push_str(separator);
2866        }
2867        let key_start = text.len();
2868        text.push_str(&entry.keys);
2869        let key_end = text.len();
2870        if key_end > key_start {
2871            overlays.push(InlineOverlay {
2872                start: key_start,
2873                end: key_end,
2874                style: OverlayOptions {
2875                    fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
2876                    bold: true,
2877                    ..Default::default()
2878                },
2879                properties: Default::default(),
2880                unit: OffsetUnit::Byte,
2881            });
2882        }
2883        if !entry.label.is_empty() {
2884            text.push(' ');
2885            text.push_str(&entry.label);
2886        }
2887    }
2888    TextPropertyEntry {
2889        text,
2890        properties: Default::default(),
2891        style: None,
2892        inline_overlays: overlays,
2893        segments: Vec::new(),
2894        pad_to_chars: None,
2895        truncate_to_chars: None,
2896    }
2897}
2898
2899/// Render a `Toggle` to a single `TextPropertyEntry`.
2900///
2901/// Layout: `[v] label` when checked, `[ ] label` when not. The check
2902/// glyph is colored via `ui.help_key_fg` when checked (a popup-bg-
2903/// safe highlight key; no override when unchecked). When focused,
2904/// the entire entry is given a focused fg/bg pair
2905/// (`ui.popup_selection_fg`/`ui.popup_selection_bg`) plus bold —
2906/// matching the prompt / palette's selected-row affordance.
2907pub fn render_toggle(checked: bool, label: &str, focused: bool) -> TextPropertyEntry {
2908    let glyph = if checked { "[v]" } else { "[ ]" };
2909    // When the panel reserves the focus-marker gutter, every toggle
2910    // leads with a two-column gutter — `▸ ` when focused, two spaces
2911    // otherwise — so focus is capture-legible and the width never
2912    // changes as focus moves. Panels without the gutter render
2913    // exactly as before (no prefix).
2914    let marker = focus_gutter_prefix(focused);
2915    let mut text = String::with_capacity(marker.len() + glyph.len() + 1 + label.len());
2916    text.push_str(marker);
2917    let glyph_start = text.len();
2918    text.push_str(glyph);
2919    text.push(' ');
2920    text.push_str(label);
2921
2922    let mut overlays = Vec::new();
2923
2924    // Check-glyph color (only when checked — leaves default fg
2925    // when unchecked, which is what plugins do today).
2926    if checked {
2927        overlays.push(InlineOverlay {
2928            start: glyph_start,
2929            end: glyph_start + glyph.len(),
2930            style: OverlayOptions {
2931                fg: Some(OverlayColorSpec::theme_key(KEY_TOGGLE_ON_FG)),
2932                bold: true,
2933                ..Default::default()
2934            },
2935            properties: Default::default(),
2936            unit: OffsetUnit::Byte,
2937        });
2938    }
2939
2940    // Focused: full-entry fg/bg + bold.
2941    if focused {
2942        overlays.push(InlineOverlay {
2943            start: 0,
2944            end: text.len(),
2945            style: OverlayOptions {
2946                fg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_FG)),
2947                bg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG)),
2948                bold: true,
2949                ..Default::default()
2950            },
2951            properties: Default::default(),
2952            unit: OffsetUnit::Byte,
2953        });
2954    }
2955
2956    TextPropertyEntry {
2957        text,
2958        properties: Default::default(),
2959        style: None,
2960        inline_overlays: overlays,
2961        segments: Vec::new(),
2962        pad_to_chars: None,
2963        truncate_to_chars: None,
2964    }
2965}
2966
2967/// Render a `Button` to a single `TextPropertyEntry`.
2968///
2969/// Layout: `[ Label ]` (with explicit space padding so the label
2970/// is visually inset from the brackets). Styling depends on `kind`
2971/// and `focused`:
2972///
2973/// * `Normal`  — default fg; focused → fg/bg flip + bold.
2974/// * `Primary` — bold; focused → fg/bg flip.
2975/// * `Danger`  — red fg (theme `ui.status_error_indicator_fg`);
2976///   focused → bold.
2977pub fn render_button(
2978    label: &str,
2979    focused: bool,
2980    kind: ButtonKind,
2981    disabled: bool,
2982) -> TextPropertyEntry {
2983    // In a marker-gutter panel, focused buttons lead with `▸ ` and
2984    // every other button with two spaces. This is the cue that
2985    // distinguishes "focused" from "Primary": a Primary button keeps
2986    // its standing bold accent whether or not it's focused, so
2987    // without the marker (and the focused bg flip) `[ Create Session ]`
2988    // looked permanently selected. The marker rides only on the one
2989    // focused control, so exactly one button reads as focused — and
2990    // because the gutter is always reserved, the row never reflows as
2991    // focus moves between buttons.
2992    let marker = focus_gutter_prefix(focused && !disabled);
2993    let text = format!("{}[ {} ]", marker, label);
2994    let mut overlays = Vec::new();
2995
2996    // Disabled overrides intent: a "Delete" button that isn't
2997    // available should not still scream red — the muted-grey of
2998    // `ui.menu_disabled_fg` is the canonical "this control is
2999    // present but inert" cue across the editor. Focus is also
3000    // forced off (the caller already gates focus on `!disabled`,
3001    // but bake it in here so a stale `focused: true` from the spec
3002    // can't paint the focused bg over a disabled button).
3003    let base_style = if disabled {
3004        OverlayOptions {
3005            fg: Some(OverlayColorSpec::theme_key("ui.menu_disabled_fg")),
3006            ..Default::default()
3007        }
3008    } else {
3009        match kind {
3010            ButtonKind::Normal => OverlayOptions::default(),
3011            // Primary marks the affirmative action with a bold,
3012            // strong fg drawn directly on the surrounding surface —
3013            // no opinionated bg. Focus is the only state that paints
3014            // a backing color (handled below).
3015            ButtonKind::Primary => OverlayOptions {
3016                fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
3017                bold: true,
3018                ..Default::default()
3019            },
3020            // Danger gets the error fg, bold, on the surrounding
3021            // surface — same fg-only treatment as Primary.
3022            ButtonKind::Danger => OverlayOptions {
3023                fg: Some(OverlayColorSpec::theme_key(KEY_DANGER_FG)),
3024                bold: true,
3025                ..Default::default()
3026            },
3027        }
3028    };
3029
3030    let style = if focused && !disabled {
3031        OverlayOptions {
3032            fg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_FG)),
3033            bg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG)),
3034            bold: true,
3035            ..base_style
3036        }
3037    } else {
3038        base_style
3039    };
3040
3041    // Only emit an overlay if the style is non-default — keeps the
3042    // serialized entry tight.
3043    if style.fg.is_some()
3044        || style.bg.is_some()
3045        || style.bold
3046        || style.italic
3047        || style.underline
3048        || style.strikethrough
3049    {
3050        overlays.push(InlineOverlay {
3051            start: 0,
3052            end: text.len(),
3053            style,
3054            properties: Default::default(),
3055            unit: OffsetUnit::Byte,
3056        });
3057    }
3058
3059    TextPropertyEntry {
3060        text,
3061        properties: Default::default(),
3062        style: None,
3063        inline_overlays: overlays,
3064        segments: Vec::new(),
3065        pad_to_chars: None,
3066        truncate_to_chars: None,
3067    }
3068}
3069
3070/// Output of `render_tree_row` — the rendered entry plus the byte
3071/// range covered by the disclosure glyph (when present) so the
3072/// caller can emit a separate hit area for click-to-expand.
3073pub struct RenderedTreeRow {
3074    pub entry: TextPropertyEntry,
3075    /// Byte range within `entry.text` of the disclosure glyph
3076    /// (`▶`/`▼`). `None` for leaf nodes (no glyph rendered).
3077    pub disclosure_range: Option<(usize, usize)>,
3078    /// Byte range within `entry.text` of the checkbox glyph
3079    /// (`[v]` / `[ ]`). `None` when the parent Tree is not
3080    /// `checkable`, or when this node has `checked: None`. The
3081    /// caller emits a `toggle` hit area over this range.
3082    pub checkbox_range: Option<(usize, usize)>,
3083}
3084
3085/// Render a single `TreeNode` row.
3086///
3087/// Layout: `<indent><disclosure><space>[<checkbox><space>]<node-text>`
3088/// where:
3089/// * `indent` = `depth * 2` spaces.
3090/// * `disclosure` = `▶` (collapsed) / `▼` (expanded) for internal
3091///   nodes; two spaces (alignment) for leaves.
3092/// * `checkbox` = `[v]` (checked) / `[ ]` (unchecked) when the
3093///   parent Tree opted into `checkable: true` *and* this node has
3094///   `checked: Some(_)`; otherwise omitted entirely.
3095/// * `<node-text>` is the plugin's pre-rendered row content, with
3096///   its inline overlays byte-shifted by the prefix length.
3097///
3098/// The disclosure glyph is colored with `ui.help_key_fg`; the
3099/// checkbox glyph reuses `ui.tab_active_fg` (the same key the
3100/// `Toggle` widget uses for its checked-state glyph) so it reads
3101/// as a control surface against the row's text.
3102pub fn render_tree_row(node: &TreeNode, expanded: bool, checkable: bool) -> RenderedTreeRow {
3103    let indent_cols = (node.depth as usize) * 2;
3104    let disclosure_glyph: &str = if node.has_children {
3105        if expanded {
3106            "▼"
3107        } else {
3108            "▶"
3109        }
3110    } else {
3111        // Two spaces — same display width as the glyph plus space,
3112        // keeping leaf rows aligned with their internal siblings.
3113        "  "
3114    };
3115    // `disclosure_glyph` (▶/▼) is 1 column wide; we want the row
3116    // text to start at the same column whether or not the row is
3117    // a leaf. With glyph + one separator space, that's 2 cols. The
3118    // leaf branch uses two literal spaces for the same width.
3119    let separator: &str = if node.has_children { " " } else { "" };
3120
3121    let checkbox_glyph: Option<&'static str> = if checkable {
3122        match node.checked {
3123            Some(true) => Some("[v]"),
3124            Some(false) => Some("[ ]"),
3125            None => None,
3126        }
3127    } else {
3128        None
3129    };
3130    let checkbox_extra = checkbox_glyph.map(|g| g.len() + 1).unwrap_or(0);
3131
3132    let mut text = String::with_capacity(
3133        indent_cols
3134            + disclosure_glyph.len()
3135            + separator.len()
3136            + checkbox_extra
3137            + node.text.text.len(),
3138    );
3139    for _ in 0..indent_cols {
3140        text.push(' ');
3141    }
3142    let disc_start = text.len();
3143    text.push_str(disclosure_glyph);
3144    let disc_end = text.len();
3145    text.push_str(separator);
3146    let checkbox_range = if let Some(g) = checkbox_glyph {
3147        let cb_start = text.len();
3148        text.push_str(g);
3149        let cb_end = text.len();
3150        text.push(' ');
3151        Some((cb_start, cb_end))
3152    } else {
3153        None
3154    };
3155    let body_start = text.len();
3156    text.push_str(&node.text.text);
3157
3158    // Carry over the plugin's inline overlays, shifted right by
3159    // `body_start` so they land on the correct bytes after the
3160    // prefix.
3161    let mut overlays: Vec<InlineOverlay> = node
3162        .text
3163        .inline_overlays
3164        .iter()
3165        .map(|o| {
3166            let mut shifted = o.clone();
3167            shifted.start += body_start;
3168            shifted.end += body_start;
3169            shifted
3170        })
3171        .collect();
3172
3173    // Disclosure glyph color — only on internal nodes, where the
3174    // glyph is a real character (not just two spaces).
3175    if node.has_children {
3176        overlays.push(InlineOverlay {
3177            start: disc_start,
3178            end: disc_end,
3179            style: OverlayOptions {
3180                fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
3181                bold: true,
3182                ..Default::default()
3183            },
3184            properties: Default::default(),
3185            unit: OffsetUnit::Byte,
3186        });
3187    }
3188    // Checkbox glyph color — bright for checked, dim for unchecked,
3189    // matching the Toggle widget's convention.
3190    if let Some((cb_start, cb_end)) = checkbox_range {
3191        let theme_key = match node.checked {
3192            Some(true) => KEY_TOGGLE_ON_FG,
3193            _ => KEY_PLACEHOLDER_FG,
3194        };
3195        overlays.push(InlineOverlay {
3196            start: cb_start,
3197            end: cb_end,
3198            style: OverlayOptions {
3199                fg: Some(OverlayColorSpec::theme_key(theme_key)),
3200                bold: matches!(node.checked, Some(true)),
3201                ..Default::default()
3202            },
3203            properties: Default::default(),
3204            unit: OffsetUnit::Byte,
3205        });
3206    }
3207
3208    let disclosure_range = if node.has_children {
3209        Some((disc_start, disc_end))
3210    } else {
3211        None
3212    };
3213    let entry = TextPropertyEntry {
3214        text,
3215        // The plugin's own row-level properties (e.g. file-row
3216        // metadata) carry through unchanged so existing
3217        // mouse_click handlers still see them.
3218        properties: node.text.properties.clone(),
3219        style: node.text.style.clone(),
3220        inline_overlays: overlays,
3221        // segments / pad / truncate hints are consumed by the
3222        // caller before render_tree_row is invoked (see
3223        // normalize_widths in the Tree match arm). The output
3224        // entry's text is already final, so these are cleared.
3225        segments: Vec::new(),
3226        pad_to_chars: None,
3227        truncate_to_chars: None,
3228    };
3229    RenderedTreeRow {
3230        entry,
3231        disclosure_range,
3232        checkbox_range,
3233    }
3234}
3235
3236/// Output of `render_text_input` — the rendered entry plus the
3237/// byte offset within `entry.text` where the host should place the
3238/// hardware cursor when this input is focused.
3239pub struct RenderedTextInput {
3240    pub entry: TextPropertyEntry,
3241    /// Byte offset within `entry.text` where the cursor lands.
3242    /// When the input is unfocused or has no cursor, `None`.
3243    pub cursor_byte_in_entry: Option<usize>,
3244}
3245
3246/// Render a `TextInput`.
3247///
3248/// Layout: `Label: [<inner>]` (or `[<inner>]` with no label).
3249/// `<inner>` is exactly `field_width` chars wide when
3250/// `field_width > 0` — short values pad with trailing spaces, long
3251/// values head-truncate with `…` so the cursor (typically near the
3252/// tail) stays visible. With `field_width == 0` the input grows
3253/// with the value (legacy behaviour, also used by tests).
3254///
3255/// Placeholder: when unfocused and empty, the placeholder string
3256/// is shown in `ui.menu_disabled_fg`. Focused inputs always show
3257/// their (possibly empty) value, never the placeholder.
3258///
3259/// Focused-bg: the bracketed region gets `ui.prompt_bg` so the
3260/// field visually reads as the active editing target.
3261///
3262/// **No cursor overlay**: this renderer does not paint the cursor
3263/// itself — it returns the byte offset where the host should drop
3264/// the *real* hardware cursor (the terminal's blinking caret). The
3265/// dispatcher uses that offset to position
3266/// `SplitViewState::cursors.primary` and flip `show_cursors=true`
3267/// on the panel buffer. Result: the cursor is always visible
3268/// regardless of theme contrast, blinks correctly, and matches
3269/// every other text-input field in the editor.
3270#[allow(clippy::too_many_arguments)]
3271pub fn render_text_input(
3272    value: &str,
3273    cursor_byte: i32,
3274    selection: Option<(usize, usize)>,
3275    focused: bool,
3276    label: &str,
3277    placeholder: Option<&str>,
3278    max_visible_chars: u32,
3279    field_width: u32,
3280    full_width: bool,
3281) -> RenderedTextInput {
3282    // Placeholder visibility: the value-empty state, regardless of
3283    // focus. The placeholder remains in the field until the user
3284    // types something — a focused-empty input still shows the
3285    // hint. The cursor (when focused) sits on top of the
3286    // placeholder's first char, which is the natural way the
3287    // user "overwrites" the hint as they type.
3288    let show_placeholder = value.is_empty() && placeholder.is_some();
3289
3290    // Compute the user-cursor's char position within `value`. We
3291    // operate in bytes here, which is correct for the cursor on
3292    // ASCII; multibyte chars resolve via is_char_boundary checks.
3293    let raw_cursor_byte = if cursor_byte < 0 {
3294        value.len()
3295    } else {
3296        (cursor_byte as usize).min(value.len())
3297    };
3298
3299    // Build `<inner>` plus the byte offset of the cursor *within*
3300    // `<inner>` (not yet including `[`/label offsets). This is the
3301    // single place where field-width truncation/padding lives.
3302    let (inner, cursor_in_inner) = if show_placeholder && field_width == 0 {
3303        // No constant width: render the placeholder as-is. Cursor
3304        // (when focused) parks at byte 0 of the placeholder so
3305        // the first typed char replaces it.
3306        let inner = placeholder.unwrap_or("").to_string();
3307        let cursor = if focused { Some(0usize) } else { None };
3308        (inner, cursor)
3309    } else if show_placeholder {
3310        // Constant-width placeholder: pad / truncate the hint to
3311        // the same total_inner width the value would occupy, so
3312        // the bracketed field has a stable visual size whether
3313        // the user has typed yet or not. Same `pad_extra = 1`
3314        // rule as the value path (under `full_width`) so the
3315        // closing bracket doesn't shift on focus.
3316        let target = field_width as usize;
3317        let pad_extra = if focused || full_width { 1 } else { 0 };
3318        let total_inner = target + pad_extra;
3319        let raw = placeholder.unwrap_or("");
3320        let raw_chars: Vec<char> = raw.chars().collect();
3321        let inner = if raw_chars.len() <= total_inner {
3322            let mut s = raw.to_string();
3323            while s.chars().count() < total_inner {
3324                s.push(' ');
3325            }
3326            s
3327        } else {
3328            // Tail-truncate the placeholder with `…` so a long
3329            // hint doesn't bleed past the field.
3330            let keep = total_inner.saturating_sub(1);
3331            let prefix: String = raw_chars.iter().take(keep).collect();
3332            format!("{}…", prefix)
3333        };
3334        let cursor = if focused { Some(0usize) } else { None };
3335        (inner, cursor)
3336    } else if field_width > 0 {
3337        // Constant-width. Visible value occupies `target` chars;
3338        // when focused (or when the caller asked for `full_width`,
3339        // which stabilises the visual width across focus
3340        // transitions) we add one trailing pad space so the cursor
3341        // never lands on the closing bracket.
3342        let target = field_width as usize;
3343        let pad_extra = if focused || full_width { 1 } else { 0 };
3344        let total_inner = target + pad_extra;
3345        let value_chars: Vec<char> = value.chars().collect();
3346        if value_chars.len() <= target {
3347            // Short or exact-fit value: pad with trailing spaces
3348            // to total_inner. Cursor at byte k of value lands at
3349            // byte k of inner.
3350            let mut padded = value.to_string();
3351            while padded.chars().count() < total_inner {
3352                padded.push(' ');
3353            }
3354            (padded, Some(raw_cursor_byte))
3355        } else {
3356            // Long value: head-truncate to fit `target - 1` value
3357            // chars + 1 ellipsis. When focused, append a trailing
3358            // pad space (cursor parks there at end-of-value).
3359            let keep = target - 1;
3360            let drop_chars = value_chars.len() - keep;
3361            let mut dropped_bytes = 0usize;
3362            for ch in value_chars.iter().take(drop_chars) {
3363                dropped_bytes += ch.len_utf8();
3364            }
3365            let tail = &value[dropped_bytes..];
3366            let mut s = String::with_capacity("…".len() + tail.len() + pad_extra);
3367            s.push('…');
3368            s.push_str(tail);
3369            for _ in 0..pad_extra {
3370                s.push(' ');
3371            }
3372            // Cursor: if it sits in the dropped prefix, clamp to
3373            // right after the `…` glyph; otherwise translate
3374            // through the truncation.
3375            let cursor_in_inner = if raw_cursor_byte < dropped_bytes {
3376                "…".len()
3377            } else {
3378                "…".len() + (raw_cursor_byte - dropped_bytes)
3379            };
3380            (s, Some(cursor_in_inner))
3381        }
3382    } else if max_visible_chars > 0 && value.chars().count() > max_visible_chars as usize {
3383        // Legacy max_visible_chars path: tail-truncate with `…`
3384        // (drops the *tail*, not the head — matches the original
3385        // cursor-invisible v1 behaviour for callers still using it).
3386        let chars: Vec<char> = value.chars().collect();
3387        let take = (max_visible_chars as usize).saturating_sub(1);
3388        let start = chars.len().saturating_sub(take);
3389        let tail: String = chars[start..].iter().collect();
3390        let s = format!("…{}", tail);
3391        (s, Some(raw_cursor_byte.min(value.len())))
3392    } else {
3393        // No fixed width and no truncation: render the value as-is.
3394        // When focused we still need somewhere for the cursor to
3395        // land at end-of-value — append a trailing space so the
3396        // cursor sits on it instead of overlapping the closing
3397        // bracket.
3398        let mut s = value.to_string();
3399        if focused {
3400            s.push(' ');
3401        }
3402        (s, Some(raw_cursor_byte))
3403    };
3404
3405    // Compose the final text: optional label, `[`, inner, `]`.
3406    let mut text = String::new();
3407    if !label.is_empty() {
3408        text.push_str(label);
3409        text.push(' ');
3410    }
3411    let bracket_open_byte = text.len();
3412    text.push('[');
3413    let inner_byte_start = text.len();
3414    text.push_str(&inner);
3415    let inner_byte_end = text.len();
3416    text.push(']');
3417    let bracket_close_byte = text.len();
3418
3419    let mut overlays = Vec::new();
3420
3421    if show_placeholder {
3422        overlays.push(InlineOverlay {
3423            start: inner_byte_start,
3424            end: inner_byte_end,
3425            style: OverlayOptions {
3426                fg: Some(OverlayColorSpec::theme_key(KEY_PLACEHOLDER_FG)),
3427                italic: true,
3428                ..Default::default()
3429            },
3430            properties: Default::default(),
3431            unit: OffsetUnit::Byte,
3432        });
3433    }
3434
3435    if focused {
3436        overlays.push(InlineOverlay {
3437            start: bracket_open_byte,
3438            end: bracket_close_byte,
3439            style: OverlayOptions {
3440                bg: Some(OverlayColorSpec::theme_key(KEY_INPUT_BG)),
3441                ..Default::default()
3442            },
3443            properties: Default::default(),
3444            unit: OffsetUnit::Byte,
3445        });
3446    }
3447
3448    // Selection overlay: paint `ui.text_input_selection_bg` over the
3449    // selected range. Only emitted when focused (matches the cursor
3450    // visibility rule) and when no per-row truncation is in play —
3451    // the head-truncated `…` path remaps cursor bytes via
3452    // `cursor_in_inner`, but a similar remap for an arbitrary
3453    // range is intricate enough that the v1 widget framework just
3454    // skips the highlight when the inner is `…`-prefixed. Cursor
3455    // still renders correctly there.
3456    let inner_is_truncated = inner.starts_with('…');
3457    if focused && !inner_is_truncated {
3458        if let Some((sel_start, sel_end)) = selection {
3459            // Clamp to the visible value bytes. `inner` may have
3460            // trailing padding (spaces) when `field_width > 0` —
3461            // selection never extends into the pad area.
3462            let visible_value_len = value.len();
3463            let s = sel_start.min(sel_end).min(visible_value_len);
3464            let e = sel_start.max(sel_end).min(visible_value_len);
3465            if e > s {
3466                overlays.push(InlineOverlay {
3467                    start: inner_byte_start + s,
3468                    end: inner_byte_start + e,
3469                    style: OverlayOptions {
3470                        bg: Some(OverlayColorSpec::theme_key(KEY_TEXT_INPUT_SELECTION_BG)),
3471                        ..Default::default()
3472                    },
3473                    properties: Default::default(),
3474                    unit: OffsetUnit::Byte,
3475                });
3476            }
3477        }
3478    }
3479
3480    let cursor_byte_in_entry = if focused {
3481        cursor_in_inner.map(|c| inner_byte_start + c)
3482    } else {
3483        None
3484    };
3485
3486    RenderedTextInput {
3487        entry: TextPropertyEntry {
3488            text,
3489            properties: Default::default(),
3490            style: None,
3491            inline_overlays: overlays,
3492            segments: Vec::new(),
3493            pad_to_chars: None,
3494            truncate_to_chars: None,
3495        },
3496        cursor_byte_in_entry,
3497    }
3498}
3499
3500/// Output of `render_text_area`. One entry per visible row of the
3501/// editing region, plus optionally one preceding label row.
3502pub struct RenderedTextArea {
3503    /// The label row (if any) followed by `visible_rows` rows of
3504    /// editing content. Empty `value` lines are rendered as blank
3505    /// padded rows so the widget always occupies its full visual
3506    /// height.
3507    pub entries: Vec<TextPropertyEntry>,
3508    /// Auto-clamped scroll row (first visible line of `value`)
3509    /// after this render. Persisted into instance state by the
3510    /// caller.
3511    pub scroll_row: u32,
3512    /// Buffer row (within `entries`) where the host should drop
3513    /// the hardware cursor when focused. `None` when unfocused or
3514    /// when `value` is empty and the placeholder is showing.
3515    pub cursor_buffer_row: Option<u32>,
3516    /// Byte offset within the cursor's row text where the cursor
3517    /// lands. Pairs with `cursor_buffer_row`.
3518    pub cursor_byte_in_row: Option<usize>,
3519}
3520
3521/// Render a multi-line `TextArea`.
3522///
3523/// Layout:
3524/// * If `label` is non-empty, one `Label:` row precedes the editing
3525///   region.
3526/// * Then exactly `visible_rows` rows of editing content. Lines of
3527///   `value` between `[scroll_row, scroll_row + visible_rows)` are
3528///   rendered; rows beyond the value are blanks (padded so the
3529///   editing region's input-bg block keeps its rectangular shape).
3530/// * The editing region uses `field_width` columns when set; `0`
3531///   means "use up to `panel_width`". Long lines are truncated with
3532///   `…` at the right when they exceed the field width — this is
3533///   different from `TextInput`'s head-truncation, because the
3534///   cursor is no longer pinned to end-of-value (it can be
3535///   anywhere within multi-line content).
3536/// * When focused, every visible content row gets the
3537///   `ui.prompt_bg` overlay extended to the field width so the
3538///   editing region reads as a single block.
3539/// * Placeholder: shown on the *first* row only when unfocused and
3540///   `value` is empty.
3541///
3542/// Cursor: returns the visible row index (relative to `entries`)
3543/// and byte offset within that row's text. The auto-clamp policy:
3544/// keep the cursor's line in view by adjusting `scroll_row` when
3545/// the cursor's line falls outside `[scroll_row, scroll_row +
3546/// visible_rows)`.
3547#[allow(clippy::too_many_arguments)]
3548pub fn render_text_area(
3549    value: &str,
3550    cursor_byte: i32,
3551    selection: Option<(usize, usize)>,
3552    focused: bool,
3553    label: &str,
3554    placeholder: Option<&str>,
3555    visible_rows: u32,
3556    field_width: u32,
3557    prev_scroll: u32,
3558    panel_width: u32,
3559) -> RenderedTextArea {
3560    // Resolve effective field width: caller's value if set, else
3561    // `panel_width` (or a small default if the panel is unsized).
3562    let target_width: usize = if field_width > 0 {
3563        field_width as usize
3564    } else if panel_width != u32::MAX && panel_width > 0 {
3565        panel_width as usize
3566    } else {
3567        40
3568    };
3569
3570    // Split value into lines (without the `\n`). Empty value still
3571    // produces one (empty) line — matching how a single-line
3572    // editor would treat an empty buffer.
3573    let mut lines: Vec<&str> = value.split('\n').collect();
3574    if lines.is_empty() {
3575        lines.push("");
3576    }
3577
3578    // Cursor → (line_index, byte_in_line). When `cursor_byte` is
3579    // negative (no cursor), we still compute a line for scroll
3580    // bookkeeping but don't emit a focus_cursor.
3581    let raw_cursor_byte = if cursor_byte < 0 {
3582        value.len()
3583    } else {
3584        (cursor_byte as usize).min(value.len())
3585    };
3586    let (cursor_line, cursor_col) = byte_to_line_col(value, raw_cursor_byte);
3587
3588    // Selection decomposed onto (line_start, byte_in_line) →
3589    // (line_end, byte_in_line) so each visible row can emit its own
3590    // background overlay. Only meaningful when focused; we trust the
3591    // caller to pass `None` for unfocused renders.
3592    let selection_lc: Option<((usize, usize), (usize, usize))> = selection.and_then(|(a, b)| {
3593        let lo = a.min(b);
3594        let hi = a.max(b);
3595        if hi <= lo || hi > value.len() {
3596            return None;
3597        }
3598        Some((byte_to_line_col(value, lo), byte_to_line_col(value, hi)))
3599    });
3600
3601    // Auto-clamp scroll: keep cursor's line in [scroll_row,
3602    // scroll_row + visible_rows). On first render, prev_scroll == 0.
3603    let visible_rows_usize = visible_rows.max(1) as usize;
3604    let mut scroll_row = prev_scroll as usize;
3605    if cursor_line < scroll_row {
3606        scroll_row = cursor_line;
3607    } else if cursor_line >= scroll_row + visible_rows_usize {
3608        scroll_row = cursor_line + 1 - visible_rows_usize;
3609    }
3610    // Don't scroll past the last line.
3611    let max_scroll = lines.len().saturating_sub(visible_rows_usize);
3612    if scroll_row > max_scroll {
3613        scroll_row = max_scroll;
3614    }
3615
3616    let show_placeholder =
3617        !focused && value.is_empty() && placeholder.is_some() && !placeholder.unwrap().is_empty();
3618
3619    let mut entries: Vec<TextPropertyEntry> = Vec::new();
3620    let mut cursor_buffer_row: Option<u32> = None;
3621    let mut cursor_byte_in_row: Option<usize> = None;
3622
3623    if !label.is_empty() {
3624        let mut text = String::with_capacity(label.len() + 2);
3625        text.push_str(label);
3626        text.push(':');
3627        entries.push(TextPropertyEntry {
3628            text,
3629            properties: Default::default(),
3630            style: None,
3631            inline_overlays: Vec::new(),
3632            segments: Vec::new(),
3633            pad_to_chars: None,
3634            truncate_to_chars: None,
3635        });
3636    }
3637    let label_offset: u32 = entries.len() as u32;
3638
3639    for row_in_view in 0..visible_rows_usize {
3640        let line_idx = scroll_row + row_in_view;
3641        let mut row_text;
3642        let mut overlays: Vec<InlineOverlay> = Vec::new();
3643
3644        if line_idx < lines.len() {
3645            row_text = pad_or_truncate_line(lines[line_idx], target_width);
3646        } else {
3647            row_text = " ".repeat(target_width);
3648        }
3649
3650        // Placeholder shows on the first row only.
3651        if show_placeholder && row_in_view == 0 {
3652            let ph = placeholder.unwrap();
3653            row_text = pad_or_truncate_line(ph, target_width);
3654            overlays.push(InlineOverlay {
3655                start: 0,
3656                end: row_text.len(),
3657                style: OverlayOptions {
3658                    fg: Some(OverlayColorSpec::theme_key(KEY_PLACEHOLDER_FG)),
3659                    ..Default::default()
3660                },
3661                properties: Default::default(),
3662                unit: OffsetUnit::Byte,
3663            });
3664        }
3665
3666        // Focused-bg covers the full row width — the editing
3667        // region reads as a single block.
3668        if focused {
3669            overlays.push(InlineOverlay {
3670                start: 0,
3671                end: row_text.len(),
3672                style: OverlayOptions {
3673                    bg: Some(OverlayColorSpec::theme_key(KEY_INPUT_BG)),
3674                    ..Default::default()
3675                },
3676                properties: Default::default(),
3677                unit: OffsetUnit::Byte,
3678            });
3679        }
3680
3681        // Selection overlay for this row, clamped to the row's text
3682        // length. Rows are padded out to `target_width`; selection
3683        // never paints into the trailing pad area.
3684        if focused {
3685            if let Some(((sl, sc), (el, ec))) = selection_lc {
3686                if line_idx >= sl && line_idx <= el {
3687                    let line_text_len = if line_idx < lines.len() {
3688                        lines[line_idx].len()
3689                    } else {
3690                        0
3691                    };
3692                    let row_start = if line_idx == sl { sc } else { 0 };
3693                    let row_end = if line_idx == el { ec } else { line_text_len };
3694                    let s = row_start.min(line_text_len);
3695                    let e = row_end.min(line_text_len);
3696                    if e > s {
3697                        overlays.push(InlineOverlay {
3698                            start: s,
3699                            end: e,
3700                            style: OverlayOptions {
3701                                bg: Some(OverlayColorSpec::theme_key(KEY_TEXT_INPUT_SELECTION_BG)),
3702                                ..Default::default()
3703                            },
3704                            properties: Default::default(),
3705                            unit: OffsetUnit::Byte,
3706                        });
3707                    }
3708                }
3709            }
3710        }
3711
3712        // Drop the cursor on this row if it matches.
3713        if focused && line_idx == cursor_line && cursor_byte >= 0 {
3714            // The cursor's byte column on its line. If the line was
3715            // truncated, the cursor may have shifted past the
3716            // visible region — clamp to the last visible byte so
3717            // the hardware cursor stays in the row.
3718            let col_in_line = cursor_col.min(row_text.len());
3719            cursor_buffer_row = Some(label_offset + row_in_view as u32);
3720            cursor_byte_in_row = Some(col_in_line);
3721        }
3722
3723        entries.push(TextPropertyEntry {
3724            text: row_text,
3725            properties: Default::default(),
3726            style: None,
3727            inline_overlays: overlays,
3728            segments: Vec::new(),
3729            pad_to_chars: None,
3730            truncate_to_chars: None,
3731        });
3732    }
3733
3734    RenderedTextArea {
3735        entries,
3736        scroll_row: scroll_row as u32,
3737        cursor_buffer_row,
3738        cursor_byte_in_row,
3739    }
3740}
3741
3742/// Translate a byte offset in `value` to (line_index, byte_in_line).
3743fn byte_to_line_col(value: &str, byte: usize) -> (usize, usize) {
3744    let byte = byte.min(value.len());
3745    let mut line = 0usize;
3746    let mut line_start = 0usize;
3747    for (i, &b) in value.as_bytes().iter().enumerate().take(byte) {
3748        if b == b'\n' {
3749            line += 1;
3750            line_start = i + 1;
3751        }
3752    }
3753    (line, byte - line_start)
3754}
3755
3756/// Pad `line` with trailing spaces to `target` chars, or
3757/// tail-truncate with `…` if it overflows. Operates on chars to keep
3758/// the visual width predictable for ASCII; multibyte chars count as
3759/// one char each (terminal column width != char count for CJK, but
3760/// that's an acceptable v1 limitation matching `TextInput`).
3761fn pad_or_truncate_line(line: &str, target: usize) -> String {
3762    let chars: Vec<char> = line.chars().collect();
3763    if chars.len() <= target {
3764        let mut out = line.to_string();
3765        let pad = target - chars.len();
3766        for _ in 0..pad {
3767            out.push(' ');
3768        }
3769        out
3770    } else {
3771        let keep = target.saturating_sub(1);
3772        let mut out: String = chars.iter().take(keep).collect();
3773        out.push('…');
3774        out
3775    }
3776}
3777
3778/// Assemble a wrapping Row: pack inline pieces onto lines no wider than
3779/// `panel_width` (display columns), starting a new line when the next piece
3780/// would overflow. Pieces are never split, so wrap logical groups in a
3781/// nested non-wrapping Row to keep them intact. A whitespace-only piece (a
3782/// separator spacer) at the start of a fresh line is dropped so wrapped lines
3783/// don't begin with stray indentation. `Flex` spacers are ignored in the
3784/// wrap path (flex distribution is meaningless across reflowed lines).
3785fn assemble_wrapped_row(
3786    pieces: Vec<RowPiece>,
3787    panel_width: u32,
3788    entries: &mut Vec<TextPropertyEntry>,
3789    hits: &mut Vec<HitArea>,
3790) {
3791    use crate::primitives::display_width::str_width;
3792    let max_w = panel_width as usize;
3793    let mut acc: Option<TextPropertyEntry> = None;
3794    let mut row: u32 = 0;
3795    // Hits for the current (not-yet-flushed) line, with byte offsets already
3796    // shifted but buffer_row not yet stamped (set when the line is started).
3797    let flush = |acc: &mut Option<TextPropertyEntry>, entries: &mut Vec<TextPropertyEntry>| {
3798        if let Some(mut merged) = acc.take() {
3799            ensure_trailing_newline(&mut merged);
3800            entries.push(merged);
3801        }
3802    };
3803    for piece in pieces {
3804        let RowPiece::Inline {
3805            mut entry,
3806            hits: child_hits,
3807            ..
3808        } = piece
3809        else {
3810            // Flex / Block: ignored in the wrap path.
3811            continue;
3812        };
3813        let is_blank = entry.text.trim().is_empty();
3814        let piece_w = str_width(&entry.text);
3815        let acc_w = acc.as_ref().map(|e| str_width(&e.text)).unwrap_or(0);
3816        // Overflow → start a new line first.
3817        if acc.is_some() && acc_w + piece_w > max_w {
3818            flush(&mut acc, entries);
3819            row += 1;
3820        }
3821        // Drop a separator spacer that would lead a fresh line.
3822        if acc.is_none() && is_blank {
3823            continue;
3824        }
3825        let shift = acc.as_ref().map(|e| e.text.len()).unwrap_or(0);
3826        for mut h in child_hits {
3827            h.byte_start += shift;
3828            h.byte_end += shift;
3829            h.buffer_row = row;
3830            hits.push(h);
3831        }
3832        match acc.as_mut() {
3833            Some(merged) => merge_inline(merged, &mut entry),
3834            None => acc = Some(entry),
3835        }
3836    }
3837    flush(&mut acc, entries);
3838}
3839
3840/// Merge `next` into `merged` for the inline-row collapse path.
3841/// `next`'s overlays are byte-shifted to account for the merged
3842/// text length so far.
3843fn merge_inline(merged: &mut TextPropertyEntry, next: &mut TextPropertyEntry) {
3844    let shift = merged.text.len();
3845    merged.text.push_str(&next.text);
3846    for overlay in next.inline_overlays.drain(..) {
3847        merged.inline_overlays.push(InlineOverlay {
3848            start: overlay.start + shift,
3849            end: overlay.end + shift,
3850            style: overlay.style,
3851            properties: overlay.properties,
3852            unit: overlay.unit,
3853        });
3854    }
3855    // `style` and `properties` from `next` are dropped — Row inline
3856    // collapse only preserves inline_overlays. Whole-entry style on
3857    // an inline-row child has no meaningful semantics here; if a
3858    // plugin needs whole-line styling it should produce a Col with
3859    // the styled child as its sole element.
3860}
3861
3862/// Pad / truncate `text` to exactly `cols` display columns, in
3863/// place. Uses char count as the display-width approximation —
3864/// good for ASCII; wide-char-aware width would need
3865/// `unicode-width`, but no current caller relies on that.
3866///
3867/// When truncating, the final visible column is replaced with `…`
3868/// so the cut is visually distinguishable from a value that
3869/// happens to be exactly `cols` long. Degenerate `cols == 0` and
3870/// `cols == 1` (no room for the ellipsis itself) fall back to a
3871/// plain cut.
3872fn pad_or_truncate_cols(text: &mut String, cols: usize) {
3873    let cur = text.chars().count();
3874    if cur < cols {
3875        for _ in 0..(cols - cur) {
3876            text.push(' ');
3877        }
3878    } else if cur > cols {
3879        // Cut to `cols` chars, then if we have room replace the
3880        // last char with `…` so the truncation is visible.
3881        let cutoff = text
3882            .char_indices()
3883            .nth(cols)
3884            .map(|(i, _)| i)
3885            .unwrap_or(text.len());
3886        text.truncate(cutoff);
3887        if cols >= 2 {
3888            // Drop the last char and append the ellipsis. We pop a
3889            // char (not a byte) so multi-byte tails stay intact.
3890            text.pop();
3891            text.push('…');
3892        }
3893    }
3894}
3895
3896/// Clamp `idx` to `s.len()`, then walk it down to the nearest
3897/// char boundary. Byte-unit inline overlays computed against a
3898/// pre-truncation line must pass through this after the line is
3899/// column-truncated, so they can never index inside a multi-byte
3900/// char (the panic the span splitter raises on `text[a..b]`).
3901fn snap_down_to_char_boundary(s: &str, idx: usize) -> usize {
3902    let mut i = idx.min(s.len());
3903    while i > 0 && !s.is_char_boundary(i) {
3904        i -= 1;
3905    }
3906    i
3907}
3908
3909/// Horizontal-zip pass for a Row that contains ≥1 multi-line
3910/// (Block) child. Each block has already been rendered with its
3911/// per-column budget (`block_width`); this helper walks the
3912/// row's pieces left-to-right per visual row and stitches them
3913/// into one merged line at a time.
3914///
3915/// Layout rules:
3916///   * Inline pieces sit at row 0 and become `chars().count()`
3917///     spaces on subsequent rows (so the right-hand block stays
3918///     aligned with its column).
3919///   * Block pieces contribute their `entries[row]` (or a blank
3920///     row of `block_width` spaces past their height).
3921///   * Flex pieces are intentionally a no-op in the block path —
3922///     `row(block, flexSpacer(), block)` is a rare shape and we
3923///     skip honouring flex here to keep the budget arithmetic
3924///     simple. Plugins that need a fixed gap should use
3925///     `spacer(n)` instead.
3926///
3927/// Hits and focus cursors get shifted by both the buffer-row
3928/// offset (which output line we're on) and the per-piece
3929/// byte-column offset (where in the merged text the piece
3930/// starts).
3931fn zip_row_blocks(
3932    pieces: Vec<RowPiece>,
3933    panel_width: u32,
3934    out_entries: &mut Vec<TextPropertyEntry>,
3935    out_hits: &mut Vec<HitArea>,
3936    out_focus_cursor: &mut Option<FocusCursor>,
3937    out_embeds: &mut Vec<EmbedRect>,
3938    out_scroll: &mut Vec<ScrollRegion>,
3939) {
3940    let starting_row = out_entries.len() as u32;
3941    let _ = panel_width;
3942
3943    // Compute the merged height = max(block.entries.len()).
3944    let max_height = pieces
3945        .iter()
3946        .filter_map(|p| match p {
3947            RowPiece::Block { entries, .. } => Some(entries.len()),
3948            _ => None,
3949        })
3950        .max()
3951        .unwrap_or(0);
3952    if max_height == 0 {
3953        return;
3954    }
3955
3956    for row_idx in 0..max_height {
3957        let mut text = String::new();
3958        let mut overlays: Vec<InlineOverlay> = Vec::new();
3959        for piece in &pieces {
3960            match piece {
3961                RowPiece::Inline {
3962                    entry,
3963                    hits,
3964                    focus_cursor,
3965                    embeds: inline_embeds,
3966                    scroll_regions: inline_scroll,
3967                } => {
3968                    let inline_cols = entry.text.chars().count();
3969                    let byte_shift = text.len();
3970                    // Cumulative column width to the left of this
3971                    // piece, for embed positioning. Embeds are
3972                    // column-addressed, not byte-addressed.
3973                    let col_shift = text.chars().count() as u32;
3974                    if row_idx == 0 {
3975                        text.push_str(&entry.text);
3976                        for emb in inline_embeds {
3977                            out_embeds.push(EmbedRect {
3978                                window_id: emb.window_id,
3979                                buffer_row: starting_row + emb.buffer_row,
3980                                col_in_row: emb.col_in_row + col_shift,
3981                                width_cols: emb.width_cols,
3982                                height_rows: emb.height_rows,
3983                            });
3984                        }
3985                        for sr in inline_scroll {
3986                            let mut sr = sr.clone();
3987                            sr.buffer_row += starting_row;
3988                            sr.col_in_row += col_shift;
3989                            out_scroll.push(sr);
3990                        }
3991                        for overlay in &entry.inline_overlays {
3992                            overlays.push(InlineOverlay {
3993                                start: overlay.start + byte_shift,
3994                                end: overlay.end + byte_shift,
3995                                style: overlay.style.clone(),
3996                                properties: overlay.properties.clone(),
3997                                unit: overlay.unit,
3998                            });
3999                        }
4000                        for h in hits {
4001                            let mut h = h.clone();
4002                            h.byte_start += byte_shift;
4003                            h.byte_end += byte_shift;
4004                            h.buffer_row = starting_row;
4005                            out_hits.push(h);
4006                        }
4007                        if let Some(fc) = focus_cursor {
4008                            *out_focus_cursor = Some(FocusCursor {
4009                                buffer_row: starting_row,
4010                                byte_in_row: fc.byte_in_row + byte_shift as u32,
4011                            });
4012                        }
4013                    } else {
4014                        for _ in 0..inline_cols {
4015                            text.push(' ');
4016                        }
4017                    }
4018                }
4019                RowPiece::Flex => {
4020                    // Skipped — see fn doc.
4021                }
4022                RowPiece::Block {
4023                    column_width,
4024                    entries,
4025                    hits,
4026                    focus_cursor,
4027                    embeds: block_embeds,
4028                    scroll_regions: block_scroll,
4029                } => {
4030                    let block_w = *column_width as usize;
4031                    let byte_shift = text.len();
4032                    // Cumulative column width to the left of this
4033                    // block, for embed positioning.
4034                    let col_shift = text.chars().count() as u32;
4035                    // Emit each embed exactly once, on the row
4036                    // where its top edge lands. The embed's
4037                    // buffer_row is relative to the block's row
4038                    // 0; absolute = starting_row + that.
4039                    if row_idx == 0 {
4040                        for emb in block_embeds {
4041                            out_embeds.push(EmbedRect {
4042                                window_id: emb.window_id,
4043                                buffer_row: starting_row + emb.buffer_row,
4044                                col_in_row: emb.col_in_row + col_shift,
4045                                width_cols: emb.width_cols,
4046                                height_rows: emb.height_rows,
4047                            });
4048                        }
4049                        for sr in block_scroll {
4050                            let mut sr = sr.clone();
4051                            sr.buffer_row += starting_row;
4052                            sr.col_in_row += col_shift;
4053                            out_scroll.push(sr);
4054                        }
4055                    }
4056                    if let Some(line) = entries.get(row_idx) {
4057                        let mut line_text = line.text.clone();
4058                        // Strip the entry's trailing newline so it
4059                        // doesn't split our merged line.
4060                        if line_text.ends_with('\n') {
4061                            line_text.pop();
4062                        }
4063                        pad_or_truncate_cols(&mut line_text, block_w);
4064                        let padded_byte_len = line_text.len();
4065                        text.push_str(&line_text);
4066                        // Convert the entry's whole-line `style`
4067                        // into an inline overlay covering the
4068                        // block's column in the merged row. This is
4069                        // what carries through the list widget's
4070                        // selected-row bg (and any other
4071                        // whole-entry styling on individual block
4072                        // lines) — without it, the picker's
4073                        // selection highlight disappears in the
4074                        // zipped output.
4075                        if let Some(line_style) = &line.style {
4076                            overlays.push(InlineOverlay {
4077                                start: byte_shift,
4078                                end: byte_shift + padded_byte_len,
4079                                style: line_style.clone(),
4080                                properties: Default::default(),
4081                                unit: OffsetUnit::Byte,
4082                            });
4083                        }
4084                        for overlay in &line.inline_overlays {
4085                            // `pad_or_truncate_cols` may have cut the
4086                            // line (and appended a multi-byte `…`), so
4087                            // an overlay computed against the original
4088                            // line can now point past — or *inside* — a
4089                            // char of the truncated text. Clamp both
4090                            // ends to the truncated length and snap to a
4091                            // char boundary; otherwise the downstream
4092                            // span splitter slices mid-char and panics.
4093                            let start = snap_down_to_char_boundary(&line_text, overlay.start);
4094                            let end = snap_down_to_char_boundary(&line_text, overlay.end);
4095                            if start >= end {
4096                                continue;
4097                            }
4098                            overlays.push(InlineOverlay {
4099                                start: start + byte_shift,
4100                                end: end + byte_shift,
4101                                style: overlay.style.clone(),
4102                                properties: overlay.properties.clone(),
4103                                unit: overlay.unit,
4104                            });
4105                        }
4106                        for h in hits {
4107                            if h.buffer_row != row_idx as u32 {
4108                                continue;
4109                            }
4110                            let mut h = h.clone();
4111                            h.byte_start += byte_shift;
4112                            h.byte_end += byte_shift;
4113                            h.buffer_row = starting_row + row_idx as u32;
4114                            out_hits.push(h);
4115                        }
4116                        if let Some(fc) = focus_cursor {
4117                            if fc.buffer_row == row_idx as u32 {
4118                                *out_focus_cursor = Some(FocusCursor {
4119                                    buffer_row: starting_row + row_idx as u32,
4120                                    byte_in_row: fc.byte_in_row + byte_shift as u32,
4121                                });
4122                            }
4123                        }
4124                    } else {
4125                        // Past this block's height — emit a blank
4126                        // column of `block_w` spaces.
4127                        for _ in 0..block_w {
4128                            text.push(' ');
4129                        }
4130                    }
4131                }
4132            }
4133        }
4134        text.push('\n');
4135        out_entries.push(TextPropertyEntry {
4136            text,
4137            properties: Default::default(),
4138            style: None,
4139            inline_overlays: overlays,
4140            segments: Vec::new(),
4141            pad_to_chars: None,
4142            truncate_to_chars: None,
4143        });
4144    }
4145}
4146
4147#[cfg(test)]
4148mod tests {
4149    use super::*;
4150
4151    /// Most existing tests don't care about the new focus_key /
4152    /// tabbable fields. Wrap the no-focus-needed render path so
4153    /// they keep destructuring a 3-tuple; new tests destructure
4154    /// `RenderOutput` directly.
4155    fn render_no_focus(
4156        spec: &WidgetSpec,
4157        prev: &HashMap<String, WidgetInstanceState>,
4158    ) -> (
4159        Vec<TextPropertyEntry>,
4160        Vec<HitArea>,
4161        HashMap<String, WidgetInstanceState>,
4162    ) {
4163        // u32::MAX disables flex sizing (no leftover to distribute).
4164        let out = render_spec(spec, prev, "", u32::MAX);
4165        (out.entries, out.hits, out.instance_states)
4166    }
4167
4168    #[test]
4169    fn hint_bar_renders_entries_with_key_overlays() {
4170        let entries = vec![
4171            HintEntry {
4172                keys: "Tab".into(),
4173                label: "next".into(),
4174            },
4175            HintEntry {
4176                keys: "Esc".into(),
4177                label: "close".into(),
4178            },
4179        ];
4180        let entry = render_hint_bar(&entries);
4181        assert_eq!(entry.text, "Tab next  Esc close");
4182        assert_eq!(entry.inline_overlays.len(), 2);
4183        // First overlay covers "Tab" (bytes 0..3).
4184        assert_eq!(entry.inline_overlays[0].start, 0);
4185        assert_eq!(entry.inline_overlays[0].end, 3);
4186        // Second overlay covers "Esc" (bytes 10..13).
4187        assert_eq!(entry.inline_overlays[1].start, 10);
4188        assert_eq!(entry.inline_overlays[1].end, 13);
4189    }
4190
4191    #[test]
4192    fn hint_bar_omits_label_when_empty() {
4193        let entries = vec![HintEntry {
4194            keys: "?".into(),
4195            label: "".into(),
4196        }];
4197        let entry = render_hint_bar(&entries);
4198        assert_eq!(entry.text, "?");
4199    }
4200
4201    #[test]
4202    fn col_stacks_children_top_to_bottom() {
4203        let spec = WidgetSpec::Col {
4204            children: vec![
4205                WidgetSpec::HintBar {
4206                    entries: vec![HintEntry {
4207                        keys: "A".into(),
4208                        label: "alpha".into(),
4209                    }],
4210                    key: None,
4211                },
4212                WidgetSpec::HintBar {
4213                    entries: vec![HintEntry {
4214                        keys: "B".into(),
4215                        label: "beta".into(),
4216                    }],
4217                    key: None,
4218                },
4219            ],
4220            key: None,
4221        };
4222        let (out, hits, _state) = render_no_focus(&spec, &HashMap::new());
4223        assert_eq!(out.len(), 2);
4224        assert_eq!(out[0].text, "A alpha\n");
4225        assert_eq!(out[1].text, "B beta\n");
4226        assert!(hits.is_empty(), "HintBar emits no hit areas in v1");
4227    }
4228
4229    #[test]
4230    fn raw_passes_through_unchanged() {
4231        let spec = WidgetSpec::Raw {
4232            entries: vec![TextPropertyEntry::text("hello")],
4233            key: None,
4234        };
4235        let (out, hits, _state) = render_no_focus(&spec, &HashMap::new());
4236        assert_eq!(out.len(), 1);
4237        assert_eq!(out[0].text, "hello\n");
4238        assert!(hits.is_empty());
4239    }
4240
4241    #[test]
4242    fn toggle_checked_emits_glyph_overlay() {
4243        let entry = render_toggle(true, "Case", false);
4244        assert_eq!(entry.text, "[v] Case");
4245        // One overlay for the glyph, no focused overlay.
4246        assert_eq!(entry.inline_overlays.len(), 1);
4247        assert_eq!(entry.inline_overlays[0].start, 0);
4248        assert_eq!(entry.inline_overlays[0].end, 3);
4249    }
4250
4251    #[test]
4252    fn toggle_unchecked_no_glyph_overlay() {
4253        let entry = render_toggle(false, "Case", false);
4254        assert_eq!(entry.text, "[ ] Case");
4255        assert_eq!(entry.inline_overlays.len(), 0);
4256    }
4257
4258    #[test]
4259    fn toggle_focused_adds_full_entry_overlay() {
4260        let entry = render_toggle(true, "Case", true);
4261        // Glyph overlay + focused overlay.
4262        assert_eq!(entry.inline_overlays.len(), 2);
4263        // Focused overlay spans the full entry.
4264        assert_eq!(entry.inline_overlays[1].start, 0);
4265        assert_eq!(entry.inline_overlays[1].end, entry.text.len());
4266        assert!(entry.inline_overlays[1].style.bold);
4267    }
4268
4269    #[test]
4270    fn button_normal_unfocused_has_no_overlay() {
4271        let entry = render_button("Replace All", false, ButtonKind::Normal, false);
4272        assert_eq!(entry.text, "[ Replace All ]");
4273        assert!(entry.inline_overlays.is_empty());
4274    }
4275
4276    #[test]
4277    fn button_primary_unfocused_is_bold_help_key_fg_with_no_bg() {
4278        // Primary marks the "good" action with a bold, strong fg
4279        // on the surrounding surface. Only the focused state
4280        // paints a backing colour — verified in
4281        // `button_focused_overrides_with_menu_active_keys`.
4282        let entry = render_button("Submit", false, ButtonKind::Primary, false);
4283        assert_eq!(entry.inline_overlays.len(), 1);
4284        let style = &entry.inline_overlays[0].style;
4285        assert!(style.bold);
4286        assert_eq!(
4287            style.fg.as_ref().and_then(|c| c.as_theme_key()),
4288            Some("ui.help_key_fg"),
4289        );
4290        assert!(style.bg.is_none(), "unfocused primary must not paint a bg");
4291    }
4292
4293    #[test]
4294    fn button_danger_uses_error_theme_key() {
4295        let entry = render_button("Delete", false, ButtonKind::Danger, false);
4296        assert_eq!(entry.inline_overlays.len(), 1);
4297        let fg = entry.inline_overlays[0].style.fg.as_ref().unwrap();
4298        assert_eq!(fg.as_theme_key(), Some("diagnostic.error_fg"));
4299        assert!(entry.inline_overlays[0].style.bold);
4300    }
4301
4302    #[test]
4303    fn button_focused_overrides_with_popup_selection_keys() {
4304        // Picker / palette / list / button focus now resolves through
4305        // `ui.popup_selection_{fg,bg}` (white-on-blue) instead of
4306        // `ui.menu_active_{fg,bg}` (white-on-rgb(60,60,60)) — the
4307        // former has ~6× the perceptual contrast against the popup
4308        // bg and is the same key the prompt already uses. See the
4309        // `KEY_FOCUSED_FG/BG` const comment.
4310        let entry = render_button("OK", true, ButtonKind::Normal, false);
4311        let style = &entry.inline_overlays[0].style;
4312        assert_eq!(
4313            style.fg.as_ref().and_then(|c| c.as_theme_key()),
4314            Some("ui.popup_selection_fg")
4315        );
4316        assert_eq!(
4317            style.bg.as_ref().and_then(|c| c.as_theme_key()),
4318            Some("ui.popup_selection_bg")
4319        );
4320        assert!(style.bold);
4321    }
4322
4323    #[test]
4324    fn flex_spacer_fills_remaining_row_width() {
4325        let spec = WidgetSpec::Row {
4326            wrap: false,
4327            children: vec![
4328                WidgetSpec::Toggle {
4329                    checked: false,
4330                    label: "A".into(),
4331                    focused: false,
4332                    key: None,
4333                },
4334                WidgetSpec::Spacer {
4335                    cols: 0,
4336                    flex: true,
4337                    key: None,
4338                },
4339                WidgetSpec::Button {
4340                    label: "B".into(),
4341                    focused: false,
4342                    intent: ButtonKind::Normal,
4343                    key: None,
4344                    disabled: false,
4345                    focusable: true,
4346                },
4347            ],
4348            key: None,
4349        };
4350        // Toggle "[ ] A" = 5 bytes; Button "[ B ]" = 5 bytes;
4351        // panel_width = 30 → flex fills 20 spaces. Plus a trailing
4352        // newline added by the Row's terminator.
4353        let out = render_spec(&spec, &HashMap::new(), "", 30);
4354        assert_eq!(out.entries.len(), 1);
4355        let text = &out.entries[0].text;
4356        assert_eq!(text.len(), 31);
4357        assert!(text.starts_with("[ ] A"));
4358        assert!(text.ends_with("[ B ]\n"));
4359        let button_hit = out.hits.iter().find(|h| h.widget_kind == "button").unwrap();
4360        assert_eq!(button_hit.byte_start, 25);
4361        assert_eq!(button_hit.byte_end, 30);
4362    }
4363
4364    #[test]
4365    fn flex_spacer_with_no_leftover_collapses_to_zero() {
4366        let spec = WidgetSpec::Row {
4367            wrap: false,
4368            children: vec![
4369                WidgetSpec::Toggle {
4370                    checked: false,
4371                    label: "A".into(),
4372                    focused: false,
4373                    key: None,
4374                },
4375                WidgetSpec::Spacer {
4376                    cols: 0,
4377                    flex: true,
4378                    key: None,
4379                },
4380                WidgetSpec::Toggle {
4381                    checked: false,
4382                    label: "B".into(),
4383                    focused: false,
4384                    key: None,
4385                },
4386            ],
4387            key: None,
4388        };
4389        // Both toggles use 5+5=10 bytes; panel_width=10 → flex=0.
4390        let out = render_spec(&spec, &HashMap::new(), "", 10);
4391        assert_eq!(out.entries[0].text, "[ ] A[ ] B\n");
4392    }
4393
4394    #[test]
4395    fn spacer_in_row_pads_with_spaces() {
4396        let spec = WidgetSpec::Row {
4397            wrap: false,
4398            children: vec![
4399                WidgetSpec::Toggle {
4400                    checked: false,
4401                    label: "A".into(),
4402                    focused: false,
4403                    key: None,
4404                },
4405                WidgetSpec::Spacer {
4406                    cols: 4,
4407                    flex: false,
4408                    key: None,
4409                },
4410                WidgetSpec::Button {
4411                    label: "Go".into(),
4412                    focused: false,
4413                    intent: ButtonKind::Normal,
4414                    key: None,
4415                    disabled: false,
4416                    focusable: true,
4417                },
4418            ],
4419            key: None,
4420        };
4421        let (out, _hits, _state) = render_no_focus(&spec, &HashMap::new());
4422        assert_eq!(out.len(), 1);
4423        assert_eq!(out[0].text, "[ ] A    [ Go ]\n");
4424    }
4425
4426    #[test]
4427    fn row_collapses_inline_children_with_shifted_overlays() {
4428        let spec = WidgetSpec::Row {
4429            wrap: false,
4430            children: vec![
4431                WidgetSpec::HintBar {
4432                    entries: vec![HintEntry {
4433                        keys: "Tab".into(),
4434                        label: "x".into(),
4435                    }],
4436                    key: None,
4437                },
4438                WidgetSpec::HintBar {
4439                    entries: vec![HintEntry {
4440                        keys: "Esc".into(),
4441                        label: "y".into(),
4442                    }],
4443                    key: None,
4444                },
4445            ],
4446            key: None,
4447        };
4448        let (out, _hits, _state) = render_no_focus(&spec, &HashMap::new());
4449        assert_eq!(out.len(), 1);
4450        // Two adjacent HintBars are concatenated; the second's overlay shifts.
4451        assert_eq!(out[0].text, "Tab xEsc y\n");
4452        assert_eq!(out[0].inline_overlays.len(), 2);
4453        assert_eq!(out[0].inline_overlays[1].start, 5);
4454        assert_eq!(out[0].inline_overlays[1].end, 8);
4455    }
4456
4457    // -------------------------------------------------------------
4458    // Hit-area tests
4459    // -------------------------------------------------------------
4460
4461    #[test]
4462    fn toggle_emits_hit_area_with_toggle_payload() {
4463        let spec = WidgetSpec::Toggle {
4464            checked: false,
4465            label: "Case".into(),
4466            focused: false,
4467            key: Some("case".into()),
4468        };
4469        let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4470        assert_eq!(hits.len(), 1);
4471        let h = &hits[0];
4472        assert_eq!(h.widget_key, "case");
4473        assert_eq!(h.widget_kind, "toggle");
4474        assert_eq!(h.event_type, "toggle");
4475        assert_eq!(h.buffer_row, 0);
4476        assert_eq!(h.byte_start, 0);
4477        assert_eq!(h.byte_end, "[ ] Case".len());
4478        assert_eq!(h.payload, json!({"checked": true}));
4479    }
4480
4481    #[test]
4482    fn button_emits_hit_area_with_activate_payload() {
4483        let spec = WidgetSpec::Button {
4484            label: "Replace All".into(),
4485            focused: false,
4486            intent: ButtonKind::Primary,
4487            key: Some("replace".into()),
4488            disabled: false,
4489            focusable: true,
4490        };
4491        let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4492        assert_eq!(hits.len(), 1);
4493        let h = &hits[0];
4494        assert_eq!(h.widget_key, "replace");
4495        assert_eq!(h.widget_kind, "button");
4496        assert_eq!(h.event_type, "activate");
4497        assert_eq!(h.byte_end, "[ Replace All ]".len());
4498        assert_eq!(h.payload, json!({}));
4499    }
4500
4501    #[test]
4502    fn disabled_button_omits_hit_area_and_skips_tabbable() {
4503        let spec = WidgetSpec::Row {
4504            wrap: false,
4505            children: vec![
4506                WidgetSpec::Button {
4507                    label: "Archive".into(),
4508                    focused: false,
4509                    intent: ButtonKind::Normal,
4510                    key: Some("archive".into()),
4511                    disabled: true,
4512                    focusable: true,
4513                },
4514                WidgetSpec::Button {
4515                    label: "Cancel".into(),
4516                    focused: false,
4517                    intent: ButtonKind::Normal,
4518                    key: Some("cancel".into()),
4519                    disabled: false,
4520                    focusable: true,
4521                },
4522            ],
4523            key: None,
4524        };
4525        let out = render_spec(&spec, &HashMap::new(), "", 30);
4526        assert_eq!(
4527            out.hits
4528                .iter()
4529                .filter(|h| h.widget_kind == "button")
4530                .count(),
4531            1,
4532            "disabled button should not emit a hit area"
4533        );
4534        assert_eq!(
4535            out.tabbable,
4536            vec!["cancel".to_string()],
4537            "disabled button must drop out of the Tab cycle"
4538        );
4539    }
4540
4541    #[test]
4542    fn disabled_button_uses_menu_disabled_fg_overlay() {
4543        let entry = render_button("Archive", false, ButtonKind::Danger, true);
4544        assert_eq!(entry.inline_overlays.len(), 1);
4545        let style = &entry.inline_overlays[0].style;
4546        assert_eq!(
4547            style.fg.as_ref().and_then(|c| c.as_theme_key()),
4548            Some("ui.menu_disabled_fg"),
4549            "disabled overrides Danger fg with the muted theme key"
4550        );
4551        assert!(
4552            !style.bold,
4553            "disabled buttons drop the intent's bold emphasis"
4554        );
4555        assert!(style.bg.is_none(), "disabled buttons paint no bg");
4556    }
4557
4558    #[test]
4559    fn row_inline_collapse_shifts_hit_byte_offsets() {
4560        let spec = WidgetSpec::Row {
4561            wrap: false,
4562            children: vec![
4563                WidgetSpec::Toggle {
4564                    checked: true,
4565                    label: "A".into(),
4566                    focused: false,
4567                    key: Some("a".into()),
4568                },
4569                WidgetSpec::Spacer {
4570                    cols: 2,
4571                    flex: false,
4572                    key: None,
4573                },
4574                WidgetSpec::Toggle {
4575                    checked: false,
4576                    label: "B".into(),
4577                    focused: false,
4578                    key: Some("b".into()),
4579                },
4580            ],
4581            key: None,
4582        };
4583        let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4584        // One merged row with text "[v] A  [ ] B"
4585        assert_eq!(entries.len(), 1);
4586        assert_eq!(entries[0].text, "[v] A  [ ] B\n");
4587        assert_eq!(hits.len(), 2);
4588        assert_eq!(hits[0].widget_key, "a");
4589        assert_eq!(hits[0].buffer_row, 0);
4590        assert_eq!(hits[0].byte_start, 0);
4591        assert_eq!(hits[0].byte_end, 5); // "[v] A".len()
4592                                         // Second toggle shifts past first toggle ("[v] A".len() = 5)
4593                                         // + spacer ("  ".len() = 2) = 7.
4594        assert_eq!(hits[1].widget_key, "b");
4595        assert_eq!(hits[1].buffer_row, 0);
4596        assert_eq!(hits[1].byte_start, 7);
4597        assert_eq!(hits[1].byte_end, 12);
4598    }
4599
4600    #[test]
4601    fn col_stacks_hit_rows() {
4602        let spec = WidgetSpec::Col {
4603            children: vec![
4604                WidgetSpec::Toggle {
4605                    checked: false,
4606                    label: "row0".into(),
4607                    focused: false,
4608                    key: Some("k0".into()),
4609                },
4610                WidgetSpec::Toggle {
4611                    checked: true,
4612                    label: "row1".into(),
4613                    focused: false,
4614                    key: Some("k1".into()),
4615                },
4616            ],
4617            key: None,
4618        };
4619        let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4620        assert_eq!(hits.len(), 2);
4621        assert_eq!(hits[0].buffer_row, 0);
4622        assert_eq!(hits[1].buffer_row, 1);
4623    }
4624
4625    // -------------------------------------------------------------
4626    // Focus management
4627    // -------------------------------------------------------------
4628
4629    #[test]
4630    fn collect_tabbable_visits_widgets_with_keys_in_declaration_order() {
4631        let spec = WidgetSpec::Col {
4632            children: vec![
4633                WidgetSpec::HintBar {
4634                    entries: vec![],
4635                    key: Some("hb".into()),
4636                },
4637                WidgetSpec::Row {
4638                    wrap: false,
4639                    children: vec![
4640                        WidgetSpec::Toggle {
4641                            checked: false,
4642                            label: "T".into(),
4643                            focused: false,
4644                            key: Some("t".into()),
4645                        },
4646                        WidgetSpec::Spacer {
4647                            cols: 1,
4648                            flex: false,
4649                            key: None,
4650                        },
4651                        WidgetSpec::Button {
4652                            label: "B".into(),
4653                            focused: false,
4654                            intent: ButtonKind::Normal,
4655                            key: Some("b".into()),
4656                            disabled: false,
4657                            focusable: true,
4658                        },
4659                    ],
4660                    key: None,
4661                },
4662                WidgetSpec::Text {
4663                    value: "".into(),
4664                    cursor_byte: -1,
4665                    focused: false,
4666                    label: "".into(),
4667                    placeholder: None,
4668                    rows: 1,
4669                    field_width: 0,
4670                    max_visible_chars: 0,
4671                    full_width: false,
4672                    completions: Vec::new(),
4673                    completions_visible_rows: 0,
4674                    key: Some("ti".into()),
4675                },
4676                WidgetSpec::Toggle {
4677                    checked: false,
4678                    label: "no key".into(),
4679                    focused: false,
4680                    key: None,
4681                },
4682            ],
4683            key: None,
4684        };
4685        let mut tabbable = Vec::new();
4686        collect_tabbable(&spec, &mut tabbable);
4687        // HintBar without a key isn't tabbable; tabbables are
4688        // Toggle/Button/TextInput/List with non-empty keys.
4689        assert_eq!(tabbable, vec!["t", "b", "ti"]);
4690    }
4691
4692    #[test]
4693    fn first_render_focuses_first_tabbable() {
4694        let spec = WidgetSpec::Row {
4695            wrap: false,
4696            children: vec![
4697                WidgetSpec::Toggle {
4698                    checked: false,
4699                    label: "A".into(),
4700                    focused: false,
4701                    key: Some("a".into()),
4702                },
4703                WidgetSpec::Toggle {
4704                    checked: false,
4705                    label: "B".into(),
4706                    focused: false,
4707                    key: Some("b".into()),
4708                },
4709            ],
4710            key: None,
4711        };
4712        let out = render_spec(&spec, &HashMap::new(), "", u32::MAX);
4713        assert_eq!(out.focus_key, "a");
4714        assert_eq!(out.tabbable, vec!["a", "b"]);
4715    }
4716
4717    #[test]
4718    fn render_preserves_focus_key_across_re_renders() {
4719        let spec = WidgetSpec::Row {
4720            wrap: false,
4721            children: vec![
4722                WidgetSpec::Toggle {
4723                    checked: false,
4724                    label: "A".into(),
4725                    focused: false,
4726                    key: Some("a".into()),
4727                },
4728                WidgetSpec::Toggle {
4729                    checked: false,
4730                    label: "B".into(),
4731                    focused: false,
4732                    key: Some("b".into()),
4733                },
4734            ],
4735            key: None,
4736        };
4737        let out = render_spec(&spec, &HashMap::new(), "b", u32::MAX);
4738        assert_eq!(out.focus_key, "b");
4739    }
4740
4741    #[test]
4742    fn render_clamps_stale_focus_key_to_first_tabbable() {
4743        // Previous render focused "stale", but the new spec doesn't
4744        // have any widget with that key — fall back to the first
4745        // tabbable.
4746        let spec = WidgetSpec::Toggle {
4747            checked: false,
4748            label: "Only".into(),
4749            focused: false,
4750            key: Some("only".into()),
4751        };
4752        let out = render_spec(&spec, &HashMap::new(), "stale", u32::MAX);
4753        assert_eq!(out.focus_key, "only");
4754    }
4755
4756    #[test]
4757    fn focused_widget_renders_with_focused_styling() {
4758        let spec = WidgetSpec::Row {
4759            wrap: false,
4760            children: vec![
4761                WidgetSpec::Toggle {
4762                    checked: false,
4763                    label: "A".into(),
4764                    focused: false,
4765                    key: Some("a".into()),
4766                },
4767                WidgetSpec::Toggle {
4768                    checked: false,
4769                    label: "B".into(),
4770                    focused: false,
4771                    key: Some("b".into()),
4772                },
4773            ],
4774            key: None,
4775        };
4776        let out = render_spec(&spec, &HashMap::new(), "b", u32::MAX);
4777        assert_eq!(out.entries.len(), 1, "row collapses inline");
4778        // Two overlays expected from the focused B: one for B's
4779        // glyph (none, since unchecked) — actually unchecked emits
4780        // no glyph overlay. So only the focused-style overlay.
4781        // Find the focused overlay by its popup_selection_bg key
4782        // (white-on-blue; see KEY_FOCUSED_BG).
4783        let entry = &out.entries[0];
4784        let focused_overlay = entry
4785            .inline_overlays
4786            .iter()
4787            .find(|o| {
4788                o.style.bg.as_ref().and_then(|c| c.as_theme_key()) == Some("ui.popup_selection_bg")
4789            })
4790            .expect("focused overlay present on B");
4791        // B's text is "[ ] B", starting after "[ ] A".len()==5 + spacer 0 (no spacer here).
4792        // Inline collapse: A is "[ ] A" then immediately "[ ] B" = 10 bytes.
4793        assert_eq!(focused_overlay.start, 5);
4794        assert_eq!(focused_overlay.end, 10);
4795    }
4796
4797    #[test]
4798    fn no_tabbables_yields_empty_focus_key() {
4799        let spec = WidgetSpec::Col {
4800            children: vec![WidgetSpec::HintBar {
4801                entries: vec![],
4802                key: None,
4803            }],
4804            key: None,
4805        };
4806        let out = render_spec(&spec, &HashMap::new(), "", u32::MAX);
4807        assert_eq!(out.focus_key, "");
4808        assert!(out.tabbable.is_empty());
4809    }
4810
4811    // -------------------------------------------------------------
4812    // List
4813    // -------------------------------------------------------------
4814
4815    #[test]
4816    fn list_emits_one_entry_and_one_hit_per_item() {
4817        let spec = WidgetSpec::List {
4818            items: vec![
4819                TextPropertyEntry::text("alpha"),
4820                TextPropertyEntry::text("beta"),
4821                TextPropertyEntry::text("gamma"),
4822            ],
4823            item_specs: vec![],
4824            item_keys: vec!["a".into(), "b".into(), "c".into()],
4825            selected_index: -1,
4826            visible_rows: 10,
4827            focusable: true,
4828            key: None,
4829        };
4830        let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4831        // 3 real items + 7 blank padding rows to fill `visible_rows=10`.
4832        // Padding ensures the labeledSection that wraps a List stays
4833        // the height it advertises, so a sibling pane lands its
4834        // bottom border on the matching row (orchestrator picker
4835        // depends on this).
4836        assert_eq!(entries.len(), 10);
4837        // Real items still produce exactly one hit each; padded rows
4838        // are intentionally not clickable.
4839        assert_eq!(hits.len(), 3);
4840        for (i, h) in hits.iter().enumerate() {
4841            assert_eq!(h.buffer_row, i as u32);
4842            assert_eq!(h.widget_kind, "list");
4843            assert_eq!(h.event_type, "select");
4844            assert_eq!(h.payload["index"], i);
4845        }
4846        assert_eq!(hits[0].widget_key, "a");
4847        assert_eq!(hits[2].widget_key, "c");
4848    }
4849
4850    #[test]
4851    fn list_item_specs_render_multirow_cards_in_item_units() {
4852        // Two cards, each a LabeledSection (rounded box) wrapping one
4853        // body row ⇒ 3 rows tall (top border, body, bottom border).
4854        let card = |body: &str| WidgetSpec::LabeledSection {
4855            label: String::new(),
4856            child: Box::new(WidgetSpec::Raw {
4857                entries: vec![TextPropertyEntry::text(body)],
4858                key: None,
4859            }),
4860            width_pct: None,
4861            key: None,
4862        };
4863        let spec = WidgetSpec::List {
4864            items: vec![],
4865            item_specs: vec![card("aaa"), card("bbb")],
4866            item_keys: vec!["a".into(), "b".into()],
4867            selected_index: 1,
4868            // 12 rows available: 2 cards * 3 rows = 6, padded to 12.
4869            visible_rows: 12,
4870            focusable: true,
4871            key: Some("cards".into()),
4872        };
4873        // Finite panel width (cards draw borders sized to it; the
4874        // u32::MAX `render_no_focus` uses would loop drawing `─`).
4875        let out = render_spec(&spec, &HashMap::new(), "", 40);
4876        let (entries, hits) = (out.entries, out.hits);
4877        // Fills the advertised height.
4878        assert_eq!(entries.len(), 12);
4879        // Card height is 3 rows; both cards render → 6 hit rows, all
4880        // mapping back to their item index (whole card is clickable).
4881        assert_eq!(hits.len(), 6, "3 rows per card * 2 cards");
4882        assert!(hits[0..3]
4883            .iter()
4884            .all(|h| h.payload["index"] == 0 && h.widget_key == "a"));
4885        assert!(hits[3..6]
4886            .iter()
4887            .all(|h| h.payload["index"] == 1 && h.widget_key == "b"));
4888        // The selected card (index 1, rows 3..6) is marked by a heavy
4889        // box border + bold — NOT a background band (which read garish
4890        // over a multi-row card). The unselected card (rows 0..3) keeps
4891        // the light rounded border and no bold.
4892        for r in 0..3 {
4893            assert!(
4894                !entries[r].text.contains('┓') && !entries[r].text.contains('┃'),
4895                "unselected card row {r} should keep the light border"
4896            );
4897            assert!(entries[r].style.as_ref().map_or(true, |s| s.bg.is_none()));
4898        }
4899        // Heavy border glyphs appear somewhere in the selected card, and
4900        // its rows are bold, with no background band.
4901        let heavy = (3..6).any(|r| {
4902            entries[r].text.contains('┏')
4903                || entries[r].text.contains('┗')
4904                || entries[r].text.contains('┃')
4905        });
4906        assert!(heavy, "selected card should use a heavy box border");
4907        for r in 3..6 {
4908            let style = entries[r].style.as_ref();
4909            assert!(
4910                style.map(|s| s.bold).unwrap_or(false),
4911                "row {r} of the selected card should be bold"
4912            );
4913            assert!(
4914                style.and_then(|s| s.bg.as_ref()).is_none(),
4915                "row {r} of the selected card should NOT use a background band"
4916            );
4917        }
4918        // Rounded corners survived the per-item render.
4919        assert!(entries[0].text.starts_with('╭'));
4920        assert!(entries[2].text.starts_with('╰'));
4921    }
4922
4923    #[test]
4924    fn selected_card_accent_frames_all_four_sides() {
4925        // A selected multi-row card frames itself with a heavy accent
4926        // border. Regression: the accent fg was applied only to the
4927        // top/bottom border rows, leaving the vertical `┃` glyphs on the
4928        // body rows uncoloured — so the highlight framed only two sides.
4929        // The fix tints the side `┃` glyphs via sub-range overlays without
4930        // repainting the body text between them.
4931        let card = |body: &str| WidgetSpec::LabeledSection {
4932            label: String::new(),
4933            child: Box::new(WidgetSpec::Raw {
4934                entries: vec![TextPropertyEntry::text(body)],
4935                key: None,
4936            }),
4937            width_pct: None,
4938            key: None,
4939        };
4940        let spec = WidgetSpec::List {
4941            items: vec![],
4942            item_specs: vec![card("aaa"), card("bbb")],
4943            item_keys: vec!["a".into(), "b".into()],
4944            selected_index: 1,
4945            visible_rows: 12,
4946            focusable: true,
4947            key: Some("cards".into()),
4948        };
4949        let out = render_spec(&spec, &HashMap::new(), "", 40);
4950        let entries = out.entries;
4951        // Selected card is index 1 → rows 3 (top), 4 (body/side), 5 (bottom).
4952        let accent_is = |c: &OverlayColorSpec| matches!(c, OverlayColorSpec::ThemeKey(k) if k == "ui.popup_border_fg");
4953        // Top + bottom carry the accent as a whole-row fg (entire row is border).
4954        for r in [3usize, 5] {
4955            let fg = entries[r].style.as_ref().and_then(|s| s.fg.as_ref());
4956            assert!(
4957                fg.map(accent_is).unwrap_or(false),
4958                "row {r} (top/bottom border) should carry the accent fg"
4959            );
4960        }
4961        // The body row keeps heavy side borders but must NOT set a
4962        // whole-row fg (that would repaint the session text). Its vertical
4963        // `┃` glyphs are tinted via sub-range overlays instead.
4964        let body = &entries[4];
4965        assert!(
4966            body.text.contains('┃'),
4967            "selected card body row should have heavy side borders: {:?}",
4968            body.text
4969        );
4970        assert!(
4971            body.style.as_ref().and_then(|s| s.fg.as_ref()).is_none(),
4972            "body row must not set a whole-row fg (would repaint the text)"
4973        );
4974        let bar_overlays: Vec<_> = body
4975            .inline_overlays
4976            .iter()
4977            .filter(|o| o.style.fg.as_ref().map(accent_is).unwrap_or(false))
4978            .collect();
4979        assert_eq!(
4980            bar_overlays.len(),
4981            2,
4982            "both the leading and trailing ┃ should be accent-tinted: {:?}",
4983            body.inline_overlays
4984        );
4985        // Each accent overlay covers exactly one `┃` glyph.
4986        for o in bar_overlays {
4987            assert_eq!(o.end - o.start, '┃'.len_utf8());
4988            assert_eq!(&body.text[o.start..o.end], "┃");
4989        }
4990    }
4991
4992    #[test]
4993    fn list_applies_selection_bg_to_selected_row() {
4994        let spec = WidgetSpec::List {
4995            items: vec![
4996                TextPropertyEntry::text("first"),
4997                TextPropertyEntry::text("second"),
4998            ],
4999            item_specs: vec![],
5000            item_keys: vec!["x".into(), "y".into()],
5001            selected_index: 1,
5002            visible_rows: 10,
5003            focusable: true,
5004            key: None,
5005        };
5006        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5007        assert!(entries[0].style.is_none(), "unselected row keeps no style");
5008        let style = entries[1].style.as_ref().expect("selected row gets style");
5009        assert_eq!(
5010            style.bg.as_ref().and_then(|c| c.as_theme_key()),
5011            Some("ui.popup_selection_bg"),
5012        );
5013        assert!(style.extend_to_line_end);
5014    }
5015
5016    #[test]
5017    fn list_inside_col_offsets_hit_rows_by_preceding_lines() {
5018        let spec = WidgetSpec::Col {
5019            children: vec![
5020                WidgetSpec::HintBar {
5021                    entries: vec![HintEntry {
5022                        keys: "h".into(),
5023                        label: "header".into(),
5024                    }],
5025                    key: None,
5026                },
5027                WidgetSpec::List {
5028                    items: vec![
5029                        TextPropertyEntry::text("row0"),
5030                        TextPropertyEntry::text("row1"),
5031                    ],
5032                    item_specs: vec![],
5033                    item_keys: vec!["a".into(), "b".into()],
5034                    selected_index: -1,
5035                    visible_rows: 10,
5036                    key: None,
5037                    focusable: true,
5038                },
5039            ],
5040            key: None,
5041        };
5042        let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5043        // HintBar (1 row) + List items (2) + padding rows (8) to fill
5044        // `visible_rows=10` = 11 total entries.
5045        assert_eq!(entries.len(), 11);
5046        // Real list rows still produce one hit each; padding is not
5047        // clickable.
5048        assert_eq!(hits.len(), 2);
5049        // List rows land at buffer_row 1 and 2 (after the HintBar).
5050        assert_eq!(hits[0].buffer_row, 1);
5051        assert_eq!(hits[1].buffer_row, 2);
5052    }
5053
5054    #[test]
5055    fn list_payload_includes_absolute_index_and_key() {
5056        let spec = WidgetSpec::List {
5057            items: vec![TextPropertyEntry::text("only")],
5058            item_specs: vec![],
5059            item_keys: vec!["match:42".into()],
5060            selected_index: 0,
5061            visible_rows: 10,
5062            focusable: true,
5063            key: None,
5064        };
5065        let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5066        assert_eq!(hits[0].payload["index"], 0);
5067        assert_eq!(hits[0].payload["key"], "match:42");
5068    }
5069
5070    #[test]
5071    fn list_hit_payload_carries_list_key() {
5072        // The click handler needs the List's *spec* key to update the
5073        // host-owned selection (instance state is keyed by it) and to
5074        // report a `widget_key` consistent with keyboard nav. The
5075        // per-item key alone (in `payload.key`) can't identify the
5076        // widget, so every list hit must carry `list_key`.
5077        let spec = make_list(-1, 10, 2, Some("mylist"));
5078        let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5079        assert_eq!(hits.len(), 2);
5080        assert_eq!(hits[0].payload["list_key"], "mylist");
5081        assert_eq!(hits[1].payload["list_key"], "mylist");
5082    }
5083
5084    #[test]
5085    fn list_hit_payload_list_key_is_null_when_keyless() {
5086        // A keyless List has no instance state to update, so the click
5087        // handler must be able to tell (null) and skip the sync.
5088        let spec = make_list(-1, 10, 1, None);
5089        let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5090        assert!(hits[0].payload["list_key"].is_null());
5091    }
5092
5093    #[test]
5094    fn list_with_missing_key_emits_empty_widget_key() {
5095        let spec = WidgetSpec::List {
5096            items: vec![TextPropertyEntry::text("a"), TextPropertyEntry::text("b")],
5097            // Only one key for two items — second hit gets an empty key.
5098            item_specs: vec![],
5099            item_keys: vec!["only".into()],
5100            selected_index: -1,
5101            visible_rows: 10,
5102            focusable: true,
5103            key: None,
5104        };
5105        let (_, hits, _state) = render_no_focus(&spec, &HashMap::new());
5106        assert_eq!(hits[0].widget_key, "only");
5107        assert_eq!(hits[1].widget_key, "");
5108    }
5109
5110    fn make_list(selected: i32, visible: u32, total: usize, key: Option<&str>) -> WidgetSpec {
5111        let items = (0..total)
5112            .map(|i| TextPropertyEntry::text(format!("row{}", i)))
5113            .collect();
5114        let item_keys = (0..total).map(|i| format!("k{}", i)).collect();
5115        WidgetSpec::List {
5116            items,
5117            item_specs: vec![],
5118            item_keys,
5119            selected_index: selected,
5120            visible_rows: visible,
5121            focusable: true,
5122            key: key.map(|s| s.to_string()),
5123        }
5124    }
5125
5126    #[test]
5127    fn list_renders_only_visible_window() {
5128        let spec = make_list(-1, 3, 10, Some("L"));
5129        let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5130        assert_eq!(entries.len(), 3);
5131        assert_eq!(hits.len(), 3);
5132        // First three items, absolute indices 0..2.
5133        assert_eq!(hits[0].payload["index"], 0);
5134        assert_eq!(hits[2].payload["index"], 2);
5135    }
5136
5137    #[test]
5138    fn list_scrolls_to_keep_selected_below_window_in_view() {
5139        // 10 items, visible=3, select index 5: scroll should be 3
5140        // (so selected lands at the bottom of the window). On
5141        // *first* render (empty prev), the spec's selected_index
5142        // seeds instance state.
5143        let spec = make_list(5, 3, 10, Some("L"));
5144        let (_entries, hits, state) = render_no_focus(&spec, &HashMap::new());
5145        // Visible window is items 3..6 → hits index 3, 4, 5.
5146        assert_eq!(hits.len(), 3);
5147        assert_eq!(hits[0].payload["index"], 3);
5148        assert_eq!(hits[2].payload["index"], 5);
5149        let scroll = match state.get("L").unwrap() {
5150            WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5151            _ => unreachable!(),
5152        };
5153        assert_eq!(scroll, 3);
5154    }
5155
5156    #[test]
5157    fn list_scrolls_to_keep_selected_above_window_in_view() {
5158        // Previous render scrolled to 5 with selection at 5; user
5159        // pressed Up enough times that select_move set instance
5160        // state's selection to 1; renderer should scroll back up
5161        // to 1. (Spec's selected_index is initial-only; instance
5162        // state is authoritative once present.)
5163        let mut prev = HashMap::new();
5164        prev.insert(
5165            "L".into(),
5166            WidgetInstanceState::List {
5167                scroll_offset: 5,
5168                selected_index: 1,
5169                item_height: 1,
5170                user_scrolled: false,
5171            },
5172        );
5173        // Spec's selected_index doesn't matter (instance state wins).
5174        let spec = make_list(99, 3, 10, Some("L"));
5175        let (_entries, hits, state) = render_no_focus(&spec, &prev);
5176        assert_eq!(hits[0].payload["index"], 1);
5177        let scroll = match state.get("L").unwrap() {
5178            WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5179            _ => unreachable!(),
5180        };
5181        assert_eq!(scroll, 1);
5182    }
5183
5184    #[test]
5185    fn list_scroll_preserved_when_selection_remains_in_view() {
5186        // Previous render scrolled to 4 with selection at 4; user
5187        // moved selection to 5 (still in window 4..6); scroll stays.
5188        let mut prev = HashMap::new();
5189        prev.insert(
5190            "L".into(),
5191            WidgetInstanceState::List {
5192                scroll_offset: 4,
5193                selected_index: 5,
5194                item_height: 1,
5195                user_scrolled: false,
5196            },
5197        );
5198        let spec = make_list(99, 3, 10, Some("L"));
5199        let (_entries, hits, state) = render_no_focus(&spec, &prev);
5200        assert_eq!(hits[0].payload["index"], 4);
5201        let scroll = match state.get("L").unwrap() {
5202            WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5203            _ => unreachable!(),
5204        };
5205        assert_eq!(scroll, 4);
5206    }
5207
5208    #[test]
5209    fn list_clamps_scroll_to_max_when_dataset_is_smaller_than_old_offset() {
5210        // Previous scroll past the end of a now-shorter dataset
5211        // clamps to max_scroll = total - visible.
5212        let mut prev = HashMap::new();
5213        prev.insert(
5214            "L".into(),
5215            WidgetInstanceState::List {
5216                scroll_offset: 8,
5217                selected_index: -1,
5218                item_height: 1,
5219                user_scrolled: false,
5220            },
5221        );
5222        let spec = make_list(-1, 3, 5, Some("L"));
5223        let (entries, _hits, state) = render_no_focus(&spec, &prev);
5224        assert_eq!(entries.len(), 3);
5225        let scroll = match state.get("L").unwrap() {
5226            WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5227            _ => unreachable!(),
5228        };
5229        // total=5, visible=3 → max=2.
5230        assert_eq!(scroll, 2);
5231    }
5232
5233    #[test]
5234    fn list_does_not_scroll_when_total_smaller_than_visible() {
5235        let spec = make_list(-1, 10, 3, Some("L"));
5236        let (entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
5237        // 3 items + 7 blank padding rows to fill `visible_rows=10`.
5238        // The labeledSection wrapping a List keeps the height it
5239        // advertises so a sibling pane (orchestrator picker's
5240        // preview) can match.
5241        assert_eq!(entries.len(), 10);
5242        let scroll = match state.get("L").unwrap() {
5243            WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5244            _ => unreachable!(),
5245        };
5246        assert_eq!(scroll, 0);
5247    }
5248
5249    #[test]
5250    fn list_without_key_does_not_persist_state() {
5251        let spec = make_list(5, 3, 10, None);
5252        let (_entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
5253        assert!(
5254            state.is_empty(),
5255            "Lists without a `key` opt out of state preservation"
5256        );
5257    }
5258
5259    // -------------------------------------------------------------
5260    // TextInput
5261    // -------------------------------------------------------------
5262
5263    #[test]
5264    fn text_input_renders_value_in_brackets() {
5265        let entry = render_text_input("hello", -1, None, false, "", None, 0, 0, false).entry;
5266        assert_eq!(entry.text, "[hello]");
5267        assert!(entry.inline_overlays.is_empty());
5268    }
5269
5270    #[test]
5271    fn text_input_with_label_prefixes_with_label_space() {
5272        let entry = render_text_input("foo", -1, None, false, "Search:", None, 0, 0, false).entry;
5273        assert_eq!(entry.text, "Search: [foo]");
5274    }
5275
5276    #[test]
5277    fn text_input_focused_adds_input_bg_overlay() {
5278        let entry = render_text_input("x", -1, None, true, "", None, 0, 0, false).entry;
5279        // Focused → input-bg overlay (no cursor since cursor_byte < 0).
5280        assert_eq!(entry.inline_overlays.len(), 1);
5281        let bg = entry.inline_overlays[0].style.bg.as_ref().unwrap();
5282        assert_eq!(bg.as_theme_key(), Some("ui.prompt_bg"));
5283    }
5284
5285    #[test]
5286    fn text_input_focused_with_selection_adds_selection_bg_overlay() {
5287        // Focused + selection range → input-bg overlay AND a
5288        // selection-bg overlay scoped to the selected bytes.
5289        let entry =
5290            render_text_input("hello world", 5, Some((0, 5)), true, "", None, 0, 0, false).entry;
5291        // First char is at byte 1 (after `[`); selection over
5292        // bytes 0..5 of value → entry bytes 1..6.
5293        let sel = entry
5294            .inline_overlays
5295            .iter()
5296            .find(|o| {
5297                o.style.bg.as_ref().and_then(|c| c.as_theme_key())
5298                    == Some("ui.text_input_selection_bg")
5299            })
5300            .expect("selection overlay present");
5301        assert_eq!(sel.start, 1);
5302        assert_eq!(sel.end, 6);
5303    }
5304
5305    #[test]
5306    fn text_input_unfocused_skips_selection_overlay() {
5307        // Selection only paints when focused — an inactive widget
5308        // shows no highlight.
5309        let entry =
5310            render_text_input("hello", -1, Some((0, 5)), false, "", None, 0, 0, false).entry;
5311        let has_sel_overlay = entry.inline_overlays.iter().any(|o| {
5312            o.style.bg.as_ref().and_then(|c| c.as_theme_key()) == Some("ui.text_input_selection_bg")
5313        });
5314        assert!(!has_sel_overlay);
5315    }
5316
5317    #[test]
5318    fn text_area_focused_with_selection_emits_per_row_overlays() {
5319        // Multi-line selection from line 0 col 2 to line 1 col 3.
5320        // Each visible row gets its own selection overlay clamped
5321        // to that row's content bytes.
5322        let r = render_text_area("abcd\nefgh", 8, Some((2, 8)), true, "", None, 2, 0, 0, 80);
5323        // Row 0 (line 0): selection from byte 2..4 (last 2 chars of "abcd").
5324        // Row 1 (line 1): selection from byte 0..3 (first 3 chars of "efgh").
5325        let row0 = &r.entries[0];
5326        let row1 = &r.entries[1];
5327        let sel0 = row0
5328            .inline_overlays
5329            .iter()
5330            .find(|o| {
5331                o.style.bg.as_ref().and_then(|c| c.as_theme_key())
5332                    == Some("ui.text_input_selection_bg")
5333            })
5334            .expect("row 0 selection overlay");
5335        assert_eq!((sel0.start, sel0.end), (2, 4));
5336        let sel1 = row1
5337            .inline_overlays
5338            .iter()
5339            .find(|o| {
5340                o.style.bg.as_ref().and_then(|c| c.as_theme_key())
5341                    == Some("ui.text_input_selection_bg")
5342            })
5343            .expect("row 1 selection overlay");
5344        assert_eq!((sel1.start, sel1.end), (0, 3));
5345    }
5346
5347    #[test]
5348    fn text_input_cursor_byte_in_entry_at_value_position() {
5349        // Cursor mid-value: returned byte points at the position
5350        // *within entry.text*. text = "[abc ]" (focused → trailing
5351        // pad space). 'a' at byte 1, 'b' at 2, 'c' at 3 — so a
5352        // cursor at value-byte 1 lands at entry-byte 2.
5353        let r = render_text_input("abc", 1, None, true, "", None, 0, 0, false);
5354        assert_eq!(r.cursor_byte_in_entry, Some(2));
5355    }
5356
5357    #[test]
5358    fn text_input_cursor_at_end_lands_on_padding_space_not_bracket() {
5359        // Cursor at end-of-value: with focused + no field_width,
5360        // a trailing pad space is appended so the cursor never
5361        // overlaps the closing bracket. text = "[ab ]" → cursor
5362        // at value-byte 2 lands at entry-byte 3 (the space), not
5363        // at byte 4 (the `]`).
5364        let r = render_text_input("ab", 2, None, true, "", None, 0, 0, false);
5365        assert_eq!(r.entry.text, "[ab ]");
5366        assert_eq!(r.cursor_byte_in_entry, Some(3));
5367        assert_ne!(r.cursor_byte_in_entry, Some(4), "must not overlap ]");
5368    }
5369
5370    #[test]
5371    fn text_input_unfocused_empty_shows_placeholder_in_muted() {
5372        let entry =
5373            render_text_input("", -1, None, false, "", Some("type here"), 0, 0, false).entry;
5374        assert_eq!(entry.text, "[type here]");
5375        // Placeholder gets a muted-fg italic overlay.
5376        let placeholder_overlay = entry
5377            .inline_overlays
5378            .iter()
5379            .find(|o| o.style.fg.as_ref().and_then(|c| c.as_theme_key()).is_some())
5380            .expect("placeholder fg overlay");
5381        let fg = placeholder_overlay.style.fg.as_ref().unwrap();
5382        assert_eq!(fg.as_theme_key(), Some("editor.whitespace_indicator_fg"));
5383        assert!(placeholder_overlay.style.italic);
5384    }
5385
5386    #[test]
5387    fn text_input_focused_empty_still_shows_placeholder() {
5388        // New behaviour: placeholder remains visible while focused
5389        // until the user types something. Cursor parks at byte 0
5390        // of the placeholder so the first keystroke replaces it.
5391        let r = render_text_input("", -1, None, true, "", Some("type here"), 0, 0, false);
5392        assert_eq!(r.entry.text, "[type here]");
5393        assert_eq!(r.cursor_byte_in_entry, Some(1));
5394    }
5395
5396    #[test]
5397    fn text_input_field_width_pads_short_value_unfocused() {
5398        // field_width=10, unfocused, not full_width → inner is 10
5399        // chars (no extra cursor-park pad).
5400        let r = render_text_input("hi", 2, None, false, "", None, 0, 10, false);
5401        assert_eq!(r.entry.text, "[hi        ]");
5402    }
5403
5404    #[test]
5405    fn text_input_field_width_focused_adds_cursor_park_space() {
5406        // field_width=10, focused, value fills exactly 10 → inner
5407        // is 11 chars (10 + 1 cursor-park space) so the cursor at
5408        // end-of-value never lands on `]`.
5409        let r = render_text_input("0123456789", 10, None, true, "", None, 0, 10, false);
5410        assert_eq!(r.entry.text, "[0123456789 ]");
5411        // Cursor at byte 10 of value → byte 10 of inner → byte 11
5412        // of entry.text (after `[`). That's the cursor-park space,
5413        // not `]` (which lives at byte 12).
5414        assert_eq!(r.cursor_byte_in_entry, Some(11));
5415        assert_ne!(r.cursor_byte_in_entry, Some(12), "must not land on ]");
5416    }
5417
5418    #[test]
5419    fn text_input_field_width_full_width_pads_to_same_size_when_unfocused() {
5420        // full_width=true makes the inner reserve the cursor-park
5421        // space whether or not the input is focused, so the field
5422        // doesn't "jump" wider on focus.
5423        let r = render_text_input("hi", -1, None, false, "", None, 0, 10, true);
5424        assert_eq!(r.entry.text, "[hi         ]"); // 10 + 1 trailing pad
5425    }
5426
5427    #[test]
5428    fn text_input_field_width_head_truncates_long_value() {
5429        // 30-char value, field_width=10, unfocused → keep last 9
5430        // chars + `…`; no pad space.
5431        let r = render_text_input(
5432            "0123456789abcdefghijklmnopqrst",
5433            30,
5434            None,
5435            false,
5436            "",
5437            None,
5438            0,
5439            10,
5440            false,
5441        );
5442        assert!(r.entry.text.contains("…lmnopqrst"));
5443    }
5444
5445    #[test]
5446    fn text_input_field_width_clamps_cursor_in_dropped_prefix() {
5447        // Long value, field_width=5, focused, cursor at byte 0 (in
5448        // dropped prefix) → clamped to right after the `…`.
5449        let r = render_text_input("abcdefghij", 0, None, true, "", None, 0, 5, false);
5450        // Inner = `…fghij ` (1 ellipsis + 4 tail chars + 1 pad).
5451        // Cursor at "right after `…`" = byte 3 of inner (3 = `…`'s
5452        // UTF-8 byte length). entry.text has `[` before, so
5453        // absolute byte = 1 + 3 = 4.
5454        assert_eq!(r.cursor_byte_in_entry, Some(1 + "…".len()));
5455    }
5456
5457    #[test]
5458    fn text_input_truncates_long_value_keeping_tail_visible() {
5459        let value: String = "0123456789abcdefghij".to_string();
5460        let entry = render_text_input(&value, -1, None, false, "", None, 6, 0, false).entry;
5461        // Tail-truncated to "…fghij" (max=6, take=5 chars).
5462        assert_eq!(entry.text, "[…fghij]");
5463    }
5464
5465    #[test]
5466    fn raw_inside_col_offsets_following_hits() {
5467        let spec = WidgetSpec::Col {
5468            children: vec![
5469                WidgetSpec::Raw {
5470                    entries: vec![
5471                        TextPropertyEntry::text("line0"),
5472                        TextPropertyEntry::text("line1"),
5473                        TextPropertyEntry::text("line2"),
5474                    ],
5475                    key: None,
5476                },
5477                WidgetSpec::Toggle {
5478                    checked: false,
5479                    label: "after raw".into(),
5480                    focused: false,
5481                    key: Some("post".into()),
5482                },
5483            ],
5484            key: None,
5485        };
5486        let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5487        assert_eq!(entries.len(), 4);
5488        assert_eq!(hits.len(), 1);
5489        assert_eq!(hits[0].buffer_row, 3);
5490    }
5491
5492    // -------------------------------------------------------------
5493    // Tree
5494    // -------------------------------------------------------------
5495
5496    fn tnode(text: &str, depth: u32, has_children: bool) -> TreeNode {
5497        TreeNode {
5498            text: TextPropertyEntry::text(text),
5499            depth,
5500            has_children,
5501            checked: None,
5502        }
5503    }
5504
5505    fn make_tree(
5506        nodes: Vec<TreeNode>,
5507        item_keys: Vec<&str>,
5508        selected: i32,
5509        visible: u32,
5510        expanded: Vec<&str>,
5511        key: Option<&str>,
5512    ) -> WidgetSpec {
5513        WidgetSpec::Tree {
5514            nodes,
5515            item_keys: item_keys.iter().map(|s| s.to_string()).collect(),
5516            selected_index: selected,
5517            visible_rows: visible,
5518            expanded_keys: expanded.iter().map(|s| s.to_string()).collect(),
5519            checkable: false,
5520            key: key.map(|s| s.to_string()),
5521        }
5522    }
5523
5524    #[test]
5525    fn tree_row_renders_disclosure_glyph_for_internal_collapsed() {
5526        let r = render_tree_row(&tnode("file.txt", 0, true), false, false);
5527        assert!(r.entry.text.starts_with('\u{25B6}'), "starts with ▶");
5528        assert!(r.entry.text.contains("file.txt"));
5529        assert!(r.disclosure_range.is_some());
5530    }
5531
5532    #[test]
5533    fn tree_row_renders_disclosure_glyph_for_internal_expanded() {
5534        let r = render_tree_row(&tnode("file.txt", 0, true), true, false);
5535        assert!(r.entry.text.starts_with('\u{25BC}'), "starts with ▼");
5536    }
5537
5538    #[test]
5539    fn tree_row_leaf_uses_two_spaces_no_disclosure_hit() {
5540        let r = render_tree_row(&tnode("match", 0, false), false, false);
5541        // No glyph, just spaces for alignment.
5542        assert!(r.entry.text.starts_with("  "));
5543        assert!(r.entry.text.contains("match"));
5544        assert!(r.disclosure_range.is_none());
5545    }
5546
5547    #[test]
5548    fn tree_row_indents_by_depth_times_two() {
5549        let r = render_tree_row(&tnode("nested", 2, false), false, false);
5550        // depth=2 → 4 leading spaces, then 2 alignment spaces, then "nested".
5551        assert!(r.entry.text.starts_with("      nested"));
5552    }
5553
5554    #[test]
5555    fn tree_row_shifts_plugin_overlays_by_prefix() {
5556        let mut node = tnode("hello", 1, false);
5557        node.text.inline_overlays.push(InlineOverlay {
5558            start: 0,
5559            end: 5,
5560            style: OverlayOptions {
5561                bold: true,
5562                ..Default::default()
5563            },
5564            properties: Default::default(),
5565            unit: OffsetUnit::Byte,
5566        });
5567        let r = render_tree_row(&node, false, false);
5568        // depth=1 → 2 indent + 2 alignment = 4 prefix bytes (ASCII).
5569        // The plugin's [0..5] becomes [4..9].
5570        let plugin_overlay = r
5571            .entry
5572            .inline_overlays
5573            .iter()
5574            .find(|o| o.style.bold)
5575            .expect("bold overlay carried through");
5576        assert_eq!(plugin_overlay.start, 4);
5577        assert_eq!(plugin_overlay.end, 9);
5578    }
5579
5580    #[test]
5581    fn tree_row_omits_checkbox_when_not_checkable() {
5582        // Even with `checked: Some(_)`, no glyph if `checkable: false`.
5583        let mut node = tnode("file.rs", 0, false);
5584        node.checked = Some(true);
5585        let r = render_tree_row(&node, false, false);
5586        assert!(r.checkbox_range.is_none());
5587        assert!(!r.entry.text.contains("[v]"));
5588        assert!(!r.entry.text.contains("[ ]"));
5589    }
5590
5591    #[test]
5592    fn tree_row_omits_checkbox_when_checked_is_none() {
5593        // `checkable: true` but `checked: None` → still no glyph.
5594        // Lets a checkable tree mix non-checkbox-bearing nodes
5595        // (e.g. a separator or header) with checkbox rows.
5596        let node = tnode("section", 0, false);
5597        let r = render_tree_row(&node, false, true);
5598        assert!(r.checkbox_range.is_none());
5599        assert!(!r.entry.text.contains("[v]"));
5600        assert!(!r.entry.text.contains("[ ]"));
5601    }
5602
5603    #[test]
5604    fn tree_row_renders_checked_glyph_after_disclosure() {
5605        let mut node = tnode("file.rs", 0, true);
5606        node.checked = Some(true);
5607        let r = render_tree_row(&node, true, true);
5608        assert!(r.checkbox_range.is_some(), "checkbox range emitted");
5609        let (cb_start, cb_end) = r.checkbox_range.unwrap();
5610        // Layout: ▼(3 bytes UTF-8) + " " + [v] + " " + body
5611        assert_eq!(&r.entry.text[cb_start..cb_end], "[v]");
5612        assert!(r.entry.text.contains("[v] file.rs"));
5613    }
5614
5615    #[test]
5616    fn tree_row_renders_unchecked_glyph_for_leaf() {
5617        let mut node = tnode("match-row", 1, false);
5618        node.checked = Some(false);
5619        let r = render_tree_row(&node, false, true);
5620        let (cb_start, cb_end) = r
5621            .checkbox_range
5622            .expect("checkbox range for leaf with checked: Some");
5623        assert_eq!(&r.entry.text[cb_start..cb_end], "[ ]");
5624        // depth=1 → 2-space indent; leaf-alignment → 2 spaces; then `[ ]` + " ".
5625        assert!(r.entry.text.starts_with("    [ ] match-row"));
5626    }
5627
5628    #[test]
5629    fn tree_row_checkbox_glyph_byte_range_addresses_correct_text() {
5630        // Sanity: byte_start..byte_end must extract the glyph
5631        // verbatim (no UTF-8 boundary issues from the disclosure).
5632        let mut node = tnode("path/with/é", 0, true);
5633        node.checked = Some(true);
5634        let r = render_tree_row(&node, false, true);
5635        let (cb_start, cb_end) = r.checkbox_range.unwrap();
5636        assert!(r.entry.text.is_char_boundary(cb_start));
5637        assert!(r.entry.text.is_char_boundary(cb_end));
5638        assert_eq!(&r.entry.text[cb_start..cb_end], "[v]");
5639    }
5640
5641    #[test]
5642    fn tree_node_pad_to_chars_pads_text_before_prefix_offset_shift() {
5643        // depth=0 prefix is "▶ " (1 codepoint glyph + 1 space).
5644        // Plugin sends body "x" with pad_to_chars=5; renderer pads
5645        // body to "x    " then prepends prefix.
5646        let mut node = tnode("x", 0, true);
5647        node.text.pad_to_chars = Some(5);
5648        let spec = make_tree(vec![node], vec!["x"], -1, 10, vec!["x"], Some("T"));
5649        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5650        assert_eq!(entries.len(), 1);
5651        // The full row is prefix + padded body + trailing newline.
5652        // Body region must be "x    " (5 columns).
5653        let trimmed = entries[0].text.trim_end_matches('\n');
5654        assert!(
5655            trimmed.ends_with("x    "),
5656            "row should end with the padded body, got {trimmed:?}"
5657        );
5658    }
5659
5660    #[test]
5661    fn tree_node_truncate_to_chars_cuts_body_before_prefix_offset_shift() {
5662        let mut node = tnode("abcdefghij", 0, false);
5663        node.text.truncate_to_chars = Some(6);
5664        let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5665        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5666        let trimmed = entries[0].text.trim_end_matches('\n');
5667        // With budget=6, truncation produces "abc..." (3 head chars
5668        // + ellipsis), then prefix is prepended.
5669        assert!(
5670            trimmed.ends_with("abc..."),
5671            "row should end with truncated body, got {trimmed:?}"
5672        );
5673    }
5674
5675    #[test]
5676    fn tree_node_char_unit_overlay_resolves_against_padded_text_and_shifts_by_prefix() {
5677        // Body text "x" padded to 5 codepoints — the host pads to
5678        // "x    " before resolving overlays. A char-unit overlay at
5679        // [0..5] must end up covering the full padded body in bytes,
5680        // shifted right by the prefix length.
5681        let mut node = tnode("x", 0, false);
5682        node.text.pad_to_chars = Some(5);
5683        node.text.inline_overlays.push(InlineOverlay {
5684            start: 0,
5685            end: 5,
5686            style: OverlayOptions {
5687                bold: true,
5688                ..Default::default()
5689            },
5690            properties: Default::default(),
5691            unit: OffsetUnit::Char,
5692        });
5693        let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5694        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5695        let entry = &entries[0];
5696        let bold = entry
5697            .inline_overlays
5698            .iter()
5699            .find(|o| o.style.bold)
5700            .expect("bold overlay carried through");
5701        // depth=0, leaf → prefix is two spaces (no glyph). Body
5702        // starts at byte 2 and is 5 bytes (ASCII pad), so [2..7].
5703        assert_eq!(bold.start, 2);
5704        assert_eq!(bold.end, 7);
5705    }
5706
5707    #[test]
5708    fn tree_node_char_unit_overlay_with_multibyte_body_resolves_correctly() {
5709        // Body text "éxé" — 3 codepoints, 5 bytes. A char-unit
5710        // overlay at [1..2] (just the "x") becomes byte [3..4]
5711        // within the body, then shifted by leaf prefix (2 bytes).
5712        let mut node = tnode("éxé", 0, false);
5713        node.text.inline_overlays.push(InlineOverlay {
5714            start: 1,
5715            end: 2,
5716            style: OverlayOptions {
5717                bold: true,
5718                ..Default::default()
5719            },
5720            properties: Default::default(),
5721            unit: OffsetUnit::Char,
5722        });
5723        let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5724        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5725        let entry = &entries[0];
5726        let bold = entry
5727            .inline_overlays
5728            .iter()
5729            .find(|o| o.style.bold)
5730            .expect("bold overlay carried through");
5731        // Prefix is 2 bytes (two ASCII spaces), char→byte [1..2]
5732        // resolves to body byte [2..3], then shift +2 → [4..5].
5733        let trimmed = entry.text.trim_end_matches('\n');
5734        assert_eq!(bold.start, 4);
5735        assert_eq!(bold.end, 5);
5736        assert_eq!(&trimmed[bold.start..bold.end], "x");
5737    }
5738
5739    #[test]
5740    fn tree_node_segments_concatenate_into_row_text_with_per_segment_overlays() {
5741        let mut node = tnode("", 0, false);
5742        node.text.segments = vec![
5743            fresh_core::text_property::StyledSegment {
5744                text: "AB".to_string(),
5745                style: None,
5746                overlays: vec![],
5747            },
5748            fresh_core::text_property::StyledSegment {
5749                text: " ".to_string(),
5750                style: None,
5751                overlays: vec![],
5752            },
5753            fresh_core::text_property::StyledSegment {
5754                text: "CD".to_string(),
5755                style: Some(OverlayOptions {
5756                    bold: true,
5757                    ..Default::default()
5758                }),
5759                overlays: vec![],
5760            },
5761        ];
5762        let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5763        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5764        let trimmed = entries[0].text.trim_end_matches('\n');
5765        // Leaf row: 2-space prefix + concatenated segments.
5766        assert!(
5767            trimmed.ends_with("AB CD"),
5768            "row should end with concatenated segments, got {trimmed:?}"
5769        );
5770        let bold = entries[0]
5771            .inline_overlays
5772            .iter()
5773            .find(|o| o.style.bold)
5774            .expect("styled segment overlay carried through");
5775        // Bold covers the third segment only ("CD" at byte 5..7
5776        // after 2-byte prefix + "AB " = 3 bytes).
5777        assert_eq!(&trimmed[bold.start..bold.end], "CD");
5778    }
5779
5780    #[test]
5781    fn tree_node_segment_nested_overlay_shifts_to_segment_position() {
5782        // Build a row whose third segment carries a nested overlay
5783        // covering chars [0..3] within itself ("CDE"). The host
5784        // shifts those by the segment's start in the entry; final
5785        // bytes resolve against the assembled text.
5786        let mut node = tnode("", 0, false);
5787        node.text.segments = vec![
5788            fresh_core::text_property::StyledSegment {
5789                text: "AB".to_string(),
5790                style: None,
5791                overlays: vec![],
5792            },
5793            fresh_core::text_property::StyledSegment {
5794                text: " - ".to_string(),
5795                style: None,
5796                overlays: vec![],
5797            },
5798            fresh_core::text_property::StyledSegment {
5799                text: "CDEFG".to_string(),
5800                style: None,
5801                overlays: vec![InlineOverlay {
5802                    start: 0,
5803                    end: 3,
5804                    style: OverlayOptions {
5805                        bold: true,
5806                        ..Default::default()
5807                    },
5808                    properties: Default::default(),
5809                    unit: OffsetUnit::Char,
5810                }],
5811            },
5812        ];
5813        let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5814        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5815        let trimmed = entries[0].text.trim_end_matches('\n');
5816        let bold = entries[0]
5817            .inline_overlays
5818            .iter()
5819            .find(|o| o.style.bold)
5820            .expect("nested overlay carried through");
5821        assert_eq!(&trimmed[bold.start..bold.end], "CDE");
5822    }
5823
5824    #[test]
5825    fn tree_node_segments_with_pad_pad_after_concatenation() {
5826        let mut node = tnode("", 0, false);
5827        node.text.segments = vec![fresh_core::text_property::StyledSegment {
5828            text: "ab".to_string(),
5829            style: None,
5830            overlays: vec![],
5831        }];
5832        node.text.pad_to_chars = Some(5);
5833        let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5834        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5835        let trimmed = entries[0].text.trim_end_matches('\n');
5836        // Two-space leaf prefix + "ab" + three padding spaces = "  ab   ".
5837        assert!(
5838            trimmed.ends_with("ab   "),
5839            "row should be padded after segment concat, got {trimmed:?}"
5840        );
5841    }
5842
5843    #[test]
5844    fn tree_renders_only_top_level_when_nothing_expanded() {
5845        let spec = make_tree(
5846            vec![
5847                tnode("a", 0, true),
5848                tnode("a.0", 1, false),
5849                tnode("a.1", 1, false),
5850                tnode("b", 0, true),
5851                tnode("b.0", 1, false),
5852            ],
5853            vec!["a", "a.0", "a.1", "b", "b.0"],
5854            -1,
5855            10,
5856            vec![], // none expanded
5857            Some("T"),
5858        );
5859        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5860        // Only the two top-level nodes are visible.
5861        assert_eq!(entries.len(), 2);
5862        assert!(entries[0].text.contains('a'));
5863        assert!(entries[1].text.contains('b'));
5864    }
5865
5866    #[test]
5867    fn tree_renders_children_of_expanded_nodes() {
5868        let spec = make_tree(
5869            vec![
5870                tnode("a", 0, true),
5871                tnode("a.0", 1, false),
5872                tnode("a.1", 1, false),
5873                tnode("b", 0, true),
5874                tnode("b.0", 1, false),
5875            ],
5876            vec!["a", "a.0", "a.1", "b", "b.0"],
5877            -1,
5878            10,
5879            vec!["a"],
5880            Some("T"),
5881        );
5882        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5883        // a, a.0, a.1, b — b's child stays hidden.
5884        assert_eq!(entries.len(), 4);
5885    }
5886
5887    #[test]
5888    fn tree_emits_two_hits_per_internal_row_one_per_leaf() {
5889        // a (internal, expanded) + a.0 (leaf) → 2 hits for a (disclosure + body)
5890        // and 1 hit for a.0 (body only).
5891        let spec = make_tree(
5892            vec![tnode("a", 0, true), tnode("a.0", 1, false)],
5893            vec!["a", "a.0"],
5894            -1,
5895            10,
5896            vec!["a"],
5897            Some("T"),
5898        );
5899        let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5900        assert_eq!(hits.len(), 3);
5901        // First hit: disclosure on the internal node.
5902        assert_eq!(hits[0].event_type, "expand");
5903        assert_eq!(hits[0].widget_kind, "tree");
5904        assert_eq!(hits[1].event_type, "select");
5905        assert_eq!(hits[2].event_type, "select");
5906    }
5907
5908    #[test]
5909    fn tree_hits_carry_tree_spec_key_and_per_item_key_in_payload() {
5910        let spec = make_tree(
5911            vec![tnode("only", 0, false)],
5912            vec!["only-key"],
5913            -1,
5914            10,
5915            vec![],
5916            Some("matchTree"),
5917        );
5918        let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5919        assert_eq!(hits[0].widget_key, "matchTree");
5920        assert_eq!(hits[0].payload["key"], "only-key");
5921        assert_eq!(hits[0].payload["index"], 0);
5922    }
5923
5924    #[test]
5925    fn tree_persists_expanded_keys_in_instance_state() {
5926        let spec = make_tree(
5927            vec![tnode("a", 0, true), tnode("a.0", 1, false)],
5928            vec!["a", "a.0"],
5929            -1,
5930            10,
5931            vec!["a"],
5932            Some("T"),
5933        );
5934        let (_, _, state) = render_no_focus(&spec, &HashMap::new());
5935        match state.get("T").unwrap() {
5936            WidgetInstanceState::Tree { expanded_keys, .. } => {
5937                assert!(expanded_keys.contains("a"));
5938            }
5939            _ => unreachable!(),
5940        }
5941    }
5942
5943    #[test]
5944    fn tree_instance_state_overrides_spec_expanded_keys() {
5945        // Previous instance state has b expanded but spec says a.
5946        // Instance state wins (spec is initial-only after first render).
5947        let mut prev = HashMap::new();
5948        prev.insert(
5949            "T".into(),
5950            WidgetInstanceState::Tree {
5951                scroll_offset: 0,
5952                selected_index: -1,
5953                expanded_keys: ["b".to_string()].iter().cloned().collect(),
5954            },
5955        );
5956        let spec = make_tree(
5957            vec![
5958                tnode("a", 0, true),
5959                tnode("a.0", 1, false),
5960                tnode("b", 0, true),
5961                tnode("b.0", 1, false),
5962            ],
5963            vec!["a", "a.0", "b", "b.0"],
5964            -1,
5965            10,
5966            vec!["a"], // initial-only — ignored after first render
5967            Some("T"),
5968        );
5969        let (entries, _hits, _state) = render_no_focus(&spec, &prev);
5970        // Should render: a (collapsed), b, b.0 — three rows. a.0 hidden.
5971        assert_eq!(entries.len(), 3);
5972    }
5973
5974    #[test]
5975    fn tree_selected_row_gets_focused_bg() {
5976        let spec = make_tree(
5977            vec![tnode("a", 0, false), tnode("b", 0, false)],
5978            vec!["a", "b"],
5979            1,
5980            10,
5981            vec![],
5982            Some("T"),
5983        );
5984        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5985        assert!(entries[0].style.is_none());
5986        let style = entries[1].style.as_ref().expect("selected gets style");
5987        assert_eq!(
5988            style.bg.as_ref().and_then(|c| c.as_theme_key()),
5989            Some("ui.popup_selection_bg")
5990        );
5991        assert!(style.extend_to_line_end);
5992    }
5993
5994    #[test]
5995    fn tree_clamps_selection_to_visible_when_selected_node_is_hidden() {
5996        // selected_index = 1 (a.0), but `a` is collapsed → a.0 hidden.
5997        // The renderer falls back to the nearest earlier visible
5998        // node (a, idx 0).
5999        let spec = make_tree(
6000            vec![tnode("a", 0, true), tnode("a.0", 1, false)],
6001            vec!["a", "a.0"],
6002            1,
6003            10,
6004            vec![], // a not expanded
6005            Some("T"),
6006        );
6007        let (_entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
6008        match state.get("T").unwrap() {
6009            WidgetInstanceState::Tree { selected_index, .. } => {
6010                assert_eq!(*selected_index, 0);
6011            }
6012            _ => unreachable!(),
6013        }
6014    }
6015
6016    #[test]
6017    fn tree_scrolls_to_keep_selection_in_visible_window() {
6018        // 6 visible rows total, visible_rows=3, selected at flat
6019        // position 4 → scroll should be 2 (so selected lands at the
6020        // bottom of the window).
6021        let spec = make_tree(
6022            vec![
6023                tnode("0", 0, false),
6024                tnode("1", 0, false),
6025                tnode("2", 0, false),
6026                tnode("3", 0, false),
6027                tnode("4", 0, false),
6028                tnode("5", 0, false),
6029            ],
6030            vec!["k0", "k1", "k2", "k3", "k4", "k5"],
6031            4,
6032            3,
6033            vec![],
6034            Some("T"),
6035        );
6036        let (entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
6037        // Visible window: items 2..5 → 3 rows.
6038        assert_eq!(entries.len(), 3);
6039        match state.get("T").unwrap() {
6040            WidgetInstanceState::Tree { scroll_offset, .. } => assert_eq!(*scroll_offset, 2),
6041            _ => unreachable!(),
6042        }
6043    }
6044
6045    #[test]
6046    fn tree_tabbable_keys_include_tree_with_key() {
6047        let spec = WidgetSpec::Col {
6048            children: vec![
6049                WidgetSpec::Toggle {
6050                    checked: false,
6051                    label: "T".into(),
6052                    focused: false,
6053                    key: Some("toggle".into()),
6054                },
6055                make_tree(
6056                    vec![tnode("a", 0, false)],
6057                    vec!["a"],
6058                    -1,
6059                    10,
6060                    vec![],
6061                    Some("tree"),
6062                ),
6063            ],
6064            key: None,
6065        };
6066        let mut tabbable = Vec::new();
6067        collect_tabbable(&spec, &mut tabbable);
6068        assert_eq!(tabbable, vec!["toggle", "tree"]);
6069    }
6070
6071    // -------------------------------------------------------------
6072    // TextArea
6073    // -------------------------------------------------------------
6074
6075    fn make_text_area(
6076        value: &str,
6077        cursor_byte: i32,
6078        focused: bool,
6079        rows: u32,
6080        field_width: u32,
6081        key: Option<&str>,
6082    ) -> WidgetSpec {
6083        WidgetSpec::Text {
6084            value: value.into(),
6085            cursor_byte,
6086            focused,
6087            label: String::new(),
6088            placeholder: None,
6089            // Force multi-line behaviour even when the test passes
6090            // `rows: 1` — the previous TextArea-specific tests
6091            // exercise the multi-line code path through this
6092            // helper.
6093            rows: rows.max(2),
6094            field_width,
6095            max_visible_chars: 0,
6096            full_width: false,
6097            completions: Vec::new(),
6098            completions_visible_rows: 0,
6099            key: key.map(|s| s.into()),
6100        }
6101    }
6102
6103    #[test]
6104    fn text_area_renders_visible_rows_count() {
6105        // Single line value, but rows=3 → 3 entries (line + 2
6106        // blanks).
6107        let spec = make_text_area("hi", -1, false, 3, 10, Some("ta"));
6108        let prev = HashMap::new();
6109        let out = render_spec(&spec, &prev, "", 80);
6110        assert_eq!(out.entries.len(), 3);
6111    }
6112
6113    #[test]
6114    fn text_area_pads_short_lines_to_field_width() {
6115        let spec = make_text_area("hi", -1, false, 1, 6, Some("ta"));
6116        let prev = HashMap::new();
6117        let out = render_spec(&spec, &prev, "", 80);
6118        // First (only visible) row: "hi" padded to 6 chars → "hi    \n"
6119        let first = &out.entries[0];
6120        assert_eq!(first.text, "hi    \n");
6121    }
6122
6123    #[test]
6124    fn text_area_truncates_long_line_with_ellipsis() {
6125        let spec = make_text_area("abcdefghi", -1, false, 1, 5, Some("ta"));
6126        let prev = HashMap::new();
6127        let out = render_spec(&spec, &prev, "", 80);
6128        // 9 chars trimmed to 5 → "abcd…\n".
6129        assert_eq!(out.entries[0].text, "abcd…\n");
6130    }
6131
6132    #[test]
6133    fn text_area_focused_adds_input_bg_overlay_per_row() {
6134        let spec = make_text_area("a\nb", -1, true, 3, 4, Some("ta"));
6135        let prev = HashMap::new();
6136        let out = render_spec(&spec, &prev, "ta", 80);
6137        for entry in &out.entries {
6138            let has_bg = entry.inline_overlays.iter().any(|o| {
6139                o.style
6140                    .bg
6141                    .as_ref()
6142                    .and_then(|c| c.as_theme_key())
6143                    .map(|k| k == "ui.prompt_bg")
6144                    .unwrap_or(false)
6145            });
6146            assert!(has_bg, "every focused row gets input-bg");
6147        }
6148    }
6149
6150    #[test]
6151    fn text_area_publishes_focus_cursor_at_value_position() {
6152        // value="ab\ncd", cursor at byte 4 (col 1 on line 1, char
6153        // 'd' position).
6154        let spec = make_text_area("ab\ncd", 4, true, 3, 6, Some("ta"));
6155        let prev = HashMap::new();
6156        let out = render_spec(&spec, &prev, "ta", 80);
6157        let fc = out.focus_cursor.expect("focused → cursor published");
6158        // Line 1 is the second visible row → buffer_row 1.
6159        assert_eq!(fc.buffer_row, 1);
6160        // Col 1 on the rendered row.
6161        assert_eq!(fc.byte_in_row, 1);
6162    }
6163
6164    #[test]
6165    fn text_area_label_offsets_cursor_buffer_row() {
6166        // With a label, the editing region starts on row 1, so a
6167        // cursor on line 0 of the value lands on row 1 of the
6168        // buffer.
6169        let spec = WidgetSpec::Text {
6170            value: "hi".into(),
6171            cursor_byte: 1,
6172            focused: true,
6173            label: "Note".into(),
6174            placeholder: None,
6175            rows: 2,
6176            field_width: 6,
6177            max_visible_chars: 0,
6178            full_width: false,
6179            completions: Vec::new(),
6180            completions_visible_rows: 0,
6181            key: Some("ta".into()),
6182        };
6183        let prev = HashMap::new();
6184        let out = render_spec(&spec, &prev, "ta", 80);
6185        // entries[0] is the label row, entries[1..] are content.
6186        assert!(out.entries[0].text.starts_with("Note:"));
6187        let fc = out.focus_cursor.unwrap();
6188        assert_eq!(fc.buffer_row, 1);
6189    }
6190
6191    #[test]
6192    fn text_area_persists_value_and_cursor_in_instance_state() {
6193        let spec = make_text_area("abc", 2, true, 2, 8, Some("ta"));
6194        let prev = HashMap::new();
6195        let out = render_spec(&spec, &prev, "ta", 80);
6196        match out.instance_states.get("ta") {
6197            Some(WidgetInstanceState::Text { editor, .. }) => {
6198                assert_eq!(editor.value(), "abc");
6199                assert_eq!(editor.flat_cursor_byte(), 2);
6200            }
6201            other => panic!("expected Text instance state, got {:?}", other),
6202        }
6203    }
6204
6205    #[test]
6206    fn text_area_instance_state_overrides_spec_value() {
6207        // Plugin's spec says "old" but instance state has "new" —
6208        // the renderer reads from instance state.
6209        let spec = make_text_area("old", 0, true, 2, 8, Some("ta"));
6210        let mut prev = HashMap::new();
6211        let mut editor = crate::primitives::text_edit::TextEdit::with_text("new");
6212        editor.set_cursor_from_flat(3);
6213        prev.insert(
6214            "ta".into(),
6215            WidgetInstanceState::Text {
6216                editor,
6217                scroll: 0,
6218                completions: Vec::new(),
6219                completion_selected_index: 0,
6220                completion_scroll_offset: 0,
6221                completion_navigated: false,
6222            },
6223        );
6224        let out = render_spec(&spec, &prev, "ta", 80);
6225        // The first row should now read "new" (not "old").
6226        assert!(out.entries[0].text.starts_with("new"));
6227    }
6228
6229    #[test]
6230    fn text_area_scroll_clamps_to_keep_cursor_visible() {
6231        // 5-line value, rows=2. Cursor on line 4 (last). On first
6232        // render the renderer should auto-scroll so line 4 is
6233        // visible.
6234        let spec = make_text_area("a\nb\nc\nd\ne", 8, true, 2, 4, Some("ta"));
6235        // byte 8 is on the 5th line (line index 4).
6236        let prev = HashMap::new();
6237        let out = render_spec(&spec, &prev, "ta", 80);
6238        match out.instance_states.get("ta") {
6239            Some(WidgetInstanceState::Text { scroll, .. }) => {
6240                assert_eq!(*scroll, 3, "scroll so lines 3..5 are visible");
6241            }
6242            _ => panic!("expected Text instance state"),
6243        }
6244    }
6245
6246    #[test]
6247    fn text_area_unfocused_empty_shows_placeholder_in_first_row() {
6248        // Test the renderer directly (focused=false). Host-owned
6249        // focus would otherwise auto-focus the only tabbable
6250        // widget — see `text_area_publishes_focus_cursor_at_value_position`
6251        // for the focused path.
6252        let r = render_text_area("", -1, None, false, "", Some("write here"), 2, 12, 0, 80);
6253        assert!(r.entries[0].text.starts_with("write here"));
6254        // Placeholder uses the muted-fg overlay.
6255        let fg = r.entries[0]
6256            .inline_overlays
6257            .iter()
6258            .find_map(|o| o.style.fg.as_ref())
6259            .and_then(|c| c.as_theme_key());
6260        assert_eq!(fg, Some("editor.whitespace_indicator_fg"));
6261    }
6262
6263    #[test]
6264    fn text_area_tabbable_keys_include_text_area_with_key() {
6265        let spec = WidgetSpec::Col {
6266            children: vec![
6267                WidgetSpec::Toggle {
6268                    checked: false,
6269                    label: "T".into(),
6270                    focused: false,
6271                    key: Some("toggle".into()),
6272                },
6273                make_text_area("", -1, false, 3, 10, Some("note")),
6274            ],
6275            key: None,
6276        };
6277        let mut tabbable = Vec::new();
6278        collect_tabbable(&spec, &mut tabbable);
6279        assert_eq!(tabbable, vec!["toggle", "note"]);
6280    }
6281
6282    // -------------------------------------------------------------
6283    // LabeledSection
6284    // -------------------------------------------------------------
6285
6286    fn make_text_input(
6287        value: &str,
6288        cursor_byte: i32,
6289        focused: bool,
6290        full_width: bool,
6291        field_width: u32,
6292        key: Option<&str>,
6293    ) -> WidgetSpec {
6294        WidgetSpec::Text {
6295            value: value.into(),
6296            cursor_byte,
6297            focused,
6298            label: String::new(),
6299            placeholder: None,
6300            rows: 1,
6301            field_width,
6302            max_visible_chars: 0,
6303            full_width,
6304            completions: Vec::new(),
6305            completions_visible_rows: 0,
6306            key: key.map(|s| s.into()),
6307        }
6308    }
6309
6310    #[test]
6311    fn labeled_section_renders_three_rows_with_legend() {
6312        let spec = WidgetSpec::LabeledSection {
6313            label: "Name".into(),
6314            child: Box::new(make_text_input("hi", -1, false, false, 4, Some("n"))),
6315            width_pct: None,
6316            key: None,
6317        };
6318        let prev = HashMap::new();
6319        let out = render_spec(&spec, &prev, "", 20);
6320        // 3 lines: top border, content, bottom border.
6321        assert_eq!(out.entries.len(), 3);
6322        // Top border has legend.
6323        assert!(out.entries[0].text.starts_with("╭─ Name "));
6324        assert!(out.entries[0].text.ends_with("╮\n"));
6325        // Content wrapped with side borders.
6326        assert!(out.entries[1].text.starts_with("│ "));
6327        assert!(out.entries[1].text.ends_with(" │\n"));
6328        // Bottom border is a plain run.
6329        assert!(out.entries[2].text.starts_with("╰"));
6330        assert!(out.entries[2].text.ends_with("╯\n"));
6331    }
6332
6333    #[test]
6334    fn zip_row_blocks_keeps_overlays_on_char_boundaries() {
6335        // Regression for the orchestrator picker panic: a two-pane
6336        // `row(labeledSection, labeledSection)` whose left label is
6337        // long and contains a multi-byte `·`. The column is narrow
6338        // enough that `pad_or_truncate_cols` cuts the label and
6339        // appends a multi-byte `…`. Before the fix, the label's
6340        // byte-unit overlay end was clamped to the *pre*-truncation
6341        // length, leaving it pointing inside the `…` — and the app
6342        // span splitter then sliced `text[a..b]` mid-char and
6343        // panicked. Every emitted overlay offset must land on a char
6344        // boundary of its row text.
6345        let left = WidgetSpec::LabeledSection {
6346            label: "alpha/beta · this project (2)".into(),
6347            child: Box::new(make_text_input("x", -1, false, false, 4, Some("a"))),
6348            width_pct: Some(40),
6349            key: None,
6350        };
6351        let right = WidgetSpec::LabeledSection {
6352            label: "preview".into(),
6353            child: Box::new(make_text_input("y", -1, false, false, 4, Some("b"))),
6354            width_pct: None,
6355            key: None,
6356        };
6357        let spec = WidgetSpec::Row {
6358            wrap: false,
6359            children: vec![left, right],
6360            key: None,
6361        };
6362        let out = render_spec(&spec, &HashMap::new(), "", 40);
6363        for e in &out.entries {
6364            for o in &e.inline_overlays {
6365                assert!(
6366                    e.text.is_char_boundary(o.start.min(e.text.len())),
6367                    "overlay start {} not on a char boundary of {:?}",
6368                    o.start,
6369                    e.text,
6370                );
6371                assert!(
6372                    e.text.is_char_boundary(o.end.min(e.text.len())),
6373                    "overlay end {} not on a char boundary of {:?}",
6374                    o.end,
6375                    e.text,
6376                );
6377            }
6378        }
6379    }
6380
6381    #[test]
6382    fn labeled_section_pads_child_to_inner_width() {
6383        let spec = WidgetSpec::LabeledSection {
6384            label: "".into(),
6385            child: Box::new(make_text_input("hi", -1, false, false, 4, Some("n"))),
6386            width_pct: None,
6387            key: None,
6388        };
6389        let prev = HashMap::new();
6390        // panel_width = 16 → inner_width = 12 → middle row is
6391        // "│ " + 12 cols + " │".
6392        let out = render_spec(&spec, &prev, "", 16);
6393        let middle = &out.entries[1];
6394        // Count display columns including the borders + spaces.
6395        assert_eq!(middle.text.chars().count(), 16 + 1 /* \n */);
6396    }
6397
6398    #[test]
6399    fn labeled_section_text_full_width_fills_inner_area() {
6400        // Inner width = 16 - 4 = 12. With no label on the input,
6401        // 3 cols of overhead (brackets + focus park) →
6402        // effective field_width = 9. The widget is the only
6403        // tabbable so the renderer marks it focused, padding the
6404        // inner region to field_width + 1 = 10 chars.
6405        let spec = WidgetSpec::LabeledSection {
6406            label: "".into(),
6407            child: Box::new(make_text_input("ab", -1, false, true, 0, Some("n"))),
6408            width_pct: None,
6409            key: None,
6410        };
6411        let prev = HashMap::new();
6412        let out = render_spec(&spec, &prev, "", 16);
6413        let middle = &out.entries[1];
6414        // Middle row should be `│ [ab        ] │\n` — 17 chars
6415        // total (16 visible cols + trailing newline). When the
6416        // child fits exactly, the `]` is preserved.
6417        assert_eq!(middle.text.chars().count(), 17, "actual: {:?}", middle.text);
6418        assert!(
6419            middle.text.contains("[ab        ]"),
6420            "actual: {:?}",
6421            middle.text
6422        );
6423    }
6424
6425    #[test]
6426    fn labeled_section_propagates_focus_cursor_with_offsets() {
6427        let spec = WidgetSpec::LabeledSection {
6428            label: "".into(),
6429            child: Box::new(make_text_input("abc", 3, true, false, 4, Some("n"))),
6430            width_pct: None,
6431            key: None,
6432        };
6433        let prev = HashMap::new();
6434        let out = render_spec(&spec, &prev, "n", 20);
6435        let fc = out.focus_cursor.expect("focused child publishes cursor");
6436        // Child renders on the second row (top border = row 0).
6437        assert_eq!(fc.buffer_row, 1);
6438        // Cursor offset includes the left-prefix "│ " byte count
6439        // plus the child's own offset (1 for the opening bracket
6440        // + 3 for "abc"). "│" is 3 bytes in UTF-8 → prefix = 4.
6441        let prefix_bytes = LEFT_BORDER_PREFIX.len() as u32;
6442        assert_eq!(fc.byte_in_row, prefix_bytes + 1 + 3);
6443    }
6444
6445    #[test]
6446    fn labeled_section_includes_child_in_tabbable() {
6447        let spec = WidgetSpec::Col {
6448            children: vec![
6449                WidgetSpec::LabeledSection {
6450                    label: "Name".into(),
6451                    child: Box::new(make_text_input("", -1, false, false, 0, Some("n"))),
6452                    width_pct: None,
6453                    key: None,
6454                },
6455                WidgetSpec::LabeledSection {
6456                    label: "Cmd".into(),
6457                    child: Box::new(make_text_input("", -1, false, false, 0, Some("c"))),
6458                    width_pct: None,
6459                    key: None,
6460                },
6461            ],
6462            key: None,
6463        };
6464        let mut tabbable = Vec::new();
6465        collect_tabbable(&spec, &mut tabbable);
6466        assert_eq!(tabbable, vec!["n", "c"]);
6467    }
6468}