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