Skip to main content

fresh/widgets/
render.rs

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