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