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