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