Skip to main content

fresh/widgets/
render.rs

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