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";
35const KEY_TOGGLE_ON_FG: &str = "ui.tab_active_fg";
36const KEY_FOCUSED_FG: &str = "ui.menu_active_fg";
37const KEY_FOCUSED_BG: &str = "ui.menu_active_bg";
38// `ui.status_error_indicator_fg` defaults to white (designed as
39// the text-on-red status badge), so using it as a standalone fg
40// renders invisible against the panel bg. The diagnostic.error_fg
41// key is the canonical "red text" theme slot.
42const KEY_DANGER_FG: &str = "diagnostic.error_fg";
43const KEY_INPUT_BG: &str = "ui.prompt_bg";
44// Placeholder text uses the whitespace-indicator key — a dimmer
45// grey than `ui.menu_disabled_fg` (themes ship ~RGB(70,70,70)
46// vs ~RGB(100,100,100) for disabled menu items), so hint copy
47// reads as background guidance rather than a half-active value.
48const KEY_PLACEHOLDER_FG: &str = "editor.whitespace_indicator_fg";
49// Section-legend tint. `ui.help_key_fg` is the same key the
50// hint-bar uses to highlight keys against panel bg, so we know
51// it's tuned for readability against the same surface a
52// LabeledSection sits on.
53const KEY_SECTION_LABEL_FG: &str = "ui.help_key_fg";
54
55/// Where the host should place the buffer's hardware cursor — the
56/// terminal's blinking caret — when a `TextInput` is focused. Built
57/// by the renderer; the dispatcher translates `(buffer_row,
58/// byte_in_row)` to an absolute byte position in the virtual buffer
59/// and sets the panel buffer's primary cursor there. When a
60/// non-text widget is focused (Toggle / Button / List) or the
61/// panel has no tabbable widgets, this is `None` and the host
62/// hides the cursor entirely.
63#[derive(Debug, Clone, Copy)]
64pub struct FocusCursor {
65    pub buffer_row: u32,
66    pub byte_in_row: u32,
67}
68
69/// What a single render of a `WidgetSpec` produces.
70///
71/// * `entries` — the bytes for `set_virtual_buffer_content`.
72/// * `hits` — click rectangles for the `WidgetRegistry` so a later
73///   `mouse_click` dispatches a semantic `widget_event`.
74/// * `instance_states` — next-tick widget instance state (List
75///   scroll offsets / selection, TextInput value+cursor, …).
76/// * `focus_key` — currently focused widget key, clamped to a
77///   tabbable that exists in the spec (or `""` when there are no
78///   tabbables).
79/// * `tabbable` — focusable widget keys collected in declaration
80///   order. The Tab-cycle command finds the current `focus_key`'s
81///   index in this list to advance it.
82/// * `focus_cursor` — when a `TextInput` is focused, where the
83///   terminal cursor should land. Replaces the previous
84///   "overlay-as-cursor" hack — the actual hardware cursor blinks
85///   at the right byte, with no theme-color guesswork.
86pub struct RenderOutput {
87    pub entries: Vec<TextPropertyEntry>,
88    pub hits: Vec<HitArea>,
89    pub instance_states: HashMap<String, WidgetInstanceState>,
90    pub focus_key: String,
91    pub tabbable: Vec<String>,
92    pub focus_cursor: Option<FocusCursor>,
93    /// Rectangles reserved by `WindowEmbed` widgets. Each entry
94    /// names a window id and the cell range (relative to the
95    /// rendered panel's inner area) the host should paint that
96    /// window into after laying down the regular entries.
97    pub embeds: Vec<EmbedRect>,
98}
99
100/// A rectangle reserved by a `WindowEmbed` widget. All
101/// coordinates are in display **columns** (not bytes), so the
102/// host can map straight to screen cells via `inner.x +
103/// col_in_row`. `width_cols` is the column count; `height_rows`
104/// matches the spec's `rows`. The host's floating-panel render
105/// walks these and invokes the per-window paint path scoped to
106/// the rect.
107#[derive(Debug, Clone, Copy)]
108pub struct EmbedRect {
109    pub window_id: u32,
110    pub buffer_row: u32,
111    pub col_in_row: u32,
112    pub width_cols: u32,
113    pub height_rows: u32,
114}
115
116/// Render a spec to a [`RenderOutput`].
117///
118/// `prev` is the previous render's instance state (or empty on
119/// first mount). `prev_focus_key` is the previous render's focus
120/// key (or `""`); the renderer keeps it if it matches a tabbable in
121/// the new spec, otherwise falls back to the first tabbable.
122/// `panel_width` is the buffer's column width — used by `Row` to
123/// size flex `Spacer`s. Pass `u32::MAX` to disable flex (children
124/// won't be padded).
125pub fn render_spec(
126    spec: &WidgetSpec,
127    prev: &HashMap<String, WidgetInstanceState>,
128    prev_focus_key: &str,
129    panel_width: u32,
130) -> RenderOutput {
131    // Walk the spec to collect tabbable keys, then resolve the
132    // active focus key. This must happen before the entry pass so
133    // that widget arms know whether they're focused.
134    let mut tabbable = Vec::new();
135    collect_tabbable(spec, &mut tabbable);
136    let focus_key = if !prev_focus_key.is_empty() && tabbable.iter().any(|k| k == prev_focus_key) {
137        prev_focus_key.to_string()
138    } else {
139        tabbable.first().cloned().unwrap_or_default()
140    };
141
142    let mut next_state = HashMap::new();
143    let (entries, hits, focus_cursor, embeds) =
144        render_collected(spec, prev, &mut next_state, &focus_key, panel_width);
145    RenderOutput {
146        entries,
147        hits,
148        instance_states: next_state,
149        focus_key,
150        tabbable,
151        focus_cursor,
152        embeds,
153    }
154}
155
156/// Predict whether a `WidgetSpec` will render as a multi-line
157/// (Block) child of a Row, without doing the actual render. The
158/// Row's layout uses this up-front to decide whether a child
159/// should get its full `panel_width` (inline path) or a smaller
160/// per-column budget (horizontal-zip path).
161///
162/// Slightly conservative — a `Col` with one inline child is
163/// predicted inline (matches its actual one-line render); a `Row`
164/// containing any block descendant is predicted block (so nested
165/// rows participate in the zip correctly).
166/// Extract the `width_pct` declaration of a Row child, if any
167/// and in-range (1..=100). Currently only `LabeledSection`
168/// carries this — other block kinds (Col, Tree, List,
169/// multi-line Text, Raw) participate in the equal-split path.
170/// Out-of-range (0, > 100, or unset) collapses to `None` so
171/// callers don't have to re-check.
172fn labeled_section_width_pct(spec: &WidgetSpec) -> Option<u32> {
173    let WidgetSpec::LabeledSection { width_pct, .. } = spec else {
174        return None;
175    };
176    width_pct.filter(|pct| (1..=100).contains(pct))
177}
178
179fn predicts_block(spec: &WidgetSpec) -> bool {
180    match spec {
181        WidgetSpec::Col { children, .. } => {
182            if children.len() > 1 {
183                return true;
184            }
185            children.first().map(predicts_block).unwrap_or(false)
186        }
187        WidgetSpec::LabeledSection { .. } => true,
188        WidgetSpec::Tree { .. } => true,
189        WidgetSpec::List { .. } => true,
190        WidgetSpec::Text { rows, .. } => *rows > 1,
191        WidgetSpec::WindowEmbed { rows, .. } => *rows > 1,
192        WidgetSpec::Raw { entries, .. } => entries.len() > 1,
193        WidgetSpec::Row { children, .. } => children.iter().any(predicts_block),
194        _ => false,
195    }
196}
197
198/// One position in a Row's two-pass layout. Used internally to
199/// defer flex-spacer sizing until after we know all the inline
200/// children's natural widths.
201enum RowPiece {
202    Inline {
203        entry: TextPropertyEntry,
204        hits: Vec<HitArea>,
205        /// Some when this inline child was a focused TextInput.
206        /// `byte_in_row` is the cursor's offset within the *child's*
207        /// text — the Row collapse pass shifts it by the merged
208        /// inline_shift before publishing.
209        focus_cursor: Option<FocusCursor>,
210        /// Embed rects propagated up from this inline child.
211        /// Inlines collapse to row 0, so embeds inside them are
212        /// pinned to that row. Rare but worth carrying through
213        /// rather than dropping.
214        embeds: Vec<EmbedRect>,
215    },
216    Block {
217        /// Allocated column width for the zip path. May differ
218        /// from the entries' natural widths (each block was
219        /// rendered with this as its `panel_width`, so the
220        /// entries should already fit).
221        column_width: u32,
222        entries: Vec<TextPropertyEntry>,
223        hits: Vec<HitArea>,
224        focus_cursor: Option<FocusCursor>,
225        /// Embed rects propagated up from this block child.
226        /// Their `buffer_row` is already relative to the block's
227        /// own row 0; the zip pass shifts row by `starting_row`
228        /// and byte_in_row by the block's `byte_shift`.
229        embeds: Vec<EmbedRect>,
230    },
231    Flex,
232}
233
234/// Strip a trailing `'\n'` from `entry.text` if present (overlays /
235/// hits aren't affected because the newline is at the very end and
236/// no overlay should span it). Used to prepare an inline-rendered
237/// child for Row inline-collapse, where individual newlines would
238/// split the merged row across multiple buffer lines.
239fn strip_trailing_newline(entry: &mut TextPropertyEntry) {
240    if entry.text.ends_with('\n') {
241        entry.text.pop();
242    }
243}
244
245/// Append a single trailing newline to `entry.text` if it doesn't
246/// already end with one. Each top-level entry needs to end with
247/// `\n` so it occupies its own line in the underlying virtual
248/// buffer (the buffer's line model is byte-driven; without `\n`
249/// adjacent entries concatenate into one logical line).
250fn ensure_trailing_newline(entry: &mut TextPropertyEntry) {
251    if !entry.text.ends_with('\n') {
252        entry.text.push('\n');
253    }
254}
255
256/// Walk a spec tree and append tabbable widget keys (`Toggle`,
257/// `Button`, `TextInput`, `List`, `Tree` with a non-empty `key`) in
258/// declaration order. Layout containers (`Row`, `Col`) recurse;
259/// `Raw`, `Spacer`, `HintBar` skip.
260fn collect_tabbable(spec: &WidgetSpec, out: &mut Vec<String>) {
261    match spec {
262        WidgetSpec::Toggle { key: Some(k), .. }
263        | WidgetSpec::Button { key: Some(k), .. }
264        | WidgetSpec::Text { key: Some(k), .. }
265        | WidgetSpec::Tree { key: Some(k), .. }
266            if !k.is_empty() =>
267        {
268            out.push(k.clone());
269        }
270        WidgetSpec::List {
271            key: Some(k),
272            focusable,
273            ..
274        } if !k.is_empty() && *focusable => {
275            out.push(k.clone());
276        }
277        _ => {}
278    }
279    for c in spec.children() {
280        collect_tabbable(c, out);
281    }
282}
283
284/// Internal renderer. Returns the entries and the hit areas
285/// produced by `spec` *as if* it were rendered at row 0; callers
286/// (Col, Row block path) shift `buffer_row` upward by their own
287/// row offset before forwarding. `prev` is read-only previous
288/// instance state; `next_state` accumulates the post-render state
289/// the host should persist. `focus_key` is the panel's currently
290/// focused widget key — widget arms compare against their own
291/// `key` to decide whether to render with focus styling, ignoring
292/// the spec's `focused` field. (Plugin-passed `focused` is the
293/// initial-only hint that becomes redundant once the host's focus
294/// key takes over.)
295fn render_collected(
296    spec: &WidgetSpec,
297    prev: &HashMap<String, WidgetInstanceState>,
298    next_state: &mut HashMap<String, WidgetInstanceState>,
299    focus_key: &str,
300    panel_width: u32,
301) -> (
302    Vec<TextPropertyEntry>,
303    Vec<HitArea>,
304    Option<FocusCursor>,
305    Vec<EmbedRect>,
306) {
307    let mut entries: Vec<TextPropertyEntry> = Vec::new();
308    let mut hits: Vec<HitArea> = Vec::new();
309    // At most one TextInput is focused per panel, so the cursor
310    // position bubbles up through containers as a single Option.
311    let mut focus_cursor: Option<FocusCursor> = None;
312    let mut embeds: Vec<EmbedRect> = Vec::new();
313    match spec {
314        WidgetSpec::Row { children, .. } => {
315            // Two-pass layout for Row:
316            //  1. Walk children, render each. Track flex spacers
317            //     by index in the accumulator; their text starts
318            //     empty and grows in pass 2.
319            //  2. Compute leftover width = panel_width - sum of
320            //     non-flex widths; distribute evenly across flex
321            //     slots; expand each flex spacer's text + shift
322            //     subsequent overlays / hits accordingly.
323            //
324            // When ≥1 child is multi-line (a `Block`), the
325            // assembly switches to a per-line zip instead of
326            // the inline-collapse path — each block gets a
327            // column budget and the layout walks block lines
328            // left-to-right. See [the Phase 1b note in
329            // docs/internal/orchestrator-open-dialog-and-lifecycle.md]
330            // for the rationale.
331            //
332            // Width allocation for the zip path: blocks share
333            // `panel_width`. Children with a `width_pct`
334            // declaration get their explicit share first
335            // (`panel_width * pct / 100`); the remainder splits
336            // equally among blocks without an explicit width.
337            // Inline children render at full `panel_width` (they
338            // collapse to a single line so width is a soft cap).
339            let block_indices: Vec<usize> = children
340                .iter()
341                .enumerate()
342                .filter(|(_, c)| predicts_block(c))
343                .map(|(i, _)| i)
344                .collect();
345            let block_count = block_indices.len();
346            // Per-child target width, aligned with `children`.
347            // For non-block children the value is unused; for
348            // blocks it's the panel_width passed to that child's
349            // render.
350            let mut per_child_width: Vec<u32> = children.iter().map(|_| panel_width).collect();
351            if block_count > 0 {
352                let mut explicit_total: u32 = 0;
353                let mut explicit_count: u32 = 0;
354                for &idx in &block_indices {
355                    if let Some(pct) = labeled_section_width_pct(&children[idx]) {
356                        let w = (panel_width as u64 * pct as u64 / 100) as u32;
357                        per_child_width[idx] = w.max(1);
358                        explicit_total = explicit_total.saturating_add(w);
359                        explicit_count += 1;
360                    }
361                }
362                let remaining = panel_width.saturating_sub(explicit_total);
363                let implicit_count = (block_count as u32).saturating_sub(explicit_count).max(1);
364                let each_implicit = (remaining / implicit_count).max(1);
365                for &idx in &block_indices {
366                    if labeled_section_width_pct(&children[idx]).is_none() {
367                        per_child_width[idx] = each_implicit;
368                    }
369                }
370            }
371            let mut row_pieces: Vec<RowPiece> = Vec::new();
372            for (idx, child) in children.iter().enumerate() {
373                if let WidgetSpec::Spacer { flex: true, .. } = child {
374                    row_pieces.push(RowPiece::Flex);
375                    continue;
376                }
377                let child_panel_width = per_child_width[idx];
378                let (child_entries, child_hits, child_focus, child_embeds) =
379                    render_collected(child, prev, next_state, focus_key, child_panel_width);
380                if child_entries.is_empty() {
381                    debug_assert!(child_hits.is_empty(), "empty children produce no hits");
382                    continue;
383                }
384                if child_entries.len() == 1 {
385                    let mut entry = child_entries.into_iter().next().unwrap();
386                    // Inline children can't carry their own newlines
387                    // — that would split the merged Row across
388                    // buffer lines. The Row's final merged entry
389                    // gets exactly one newline appended below.
390                    strip_trailing_newline(&mut entry);
391                    row_pieces.push(RowPiece::Inline {
392                        entry,
393                        hits: child_hits,
394                        focus_cursor: child_focus,
395                        embeds: child_embeds,
396                    });
397                } else {
398                    row_pieces.push(RowPiece::Block {
399                        column_width: child_panel_width,
400                        entries: child_entries,
401                        hits: child_hits,
402                        focus_cursor: child_focus,
403                        embeds: child_embeds,
404                    });
405                }
406            }
407            // If any Block pieces survived classification, take
408            // the horizontal-zip path; otherwise fall through to
409            // the original inline-collapse assembly.
410            let has_blocks = row_pieces
411                .iter()
412                .any(|p| matches!(p, RowPiece::Block { .. }));
413            if has_blocks {
414                zip_row_blocks(
415                    row_pieces,
416                    panel_width,
417                    &mut entries,
418                    &mut hits,
419                    &mut focus_cursor,
420                    &mut embeds,
421                );
422            } else {
423                // Compute flex sizing.
424                let inline_natural: usize = row_pieces
425                    .iter()
426                    .filter_map(|p| match p {
427                        RowPiece::Inline { entry, .. } => Some(entry.text.len()),
428                        _ => None,
429                    })
430                    .sum();
431                let flex_count = row_pieces
432                    .iter()
433                    .filter(|p| matches!(p, RowPiece::Flex))
434                    .count();
435                let flex_total = (panel_width as usize).saturating_sub(inline_natural);
436                // Distribute leftover evenly. With multiple flex slots,
437                // the leftover bytes spread as evenly as possible (any
438                // remainder lands in the first slot).
439                let (flex_each, flex_extra) = match flex_total.checked_div(flex_count) {
440                    Some(each) => (each, flex_total % flex_count),
441                    None => (0, 0),
442                };
443
444                // Pass 2: assemble. Accumulate inline pieces (with
445                // collapsed flex spacers) into one entry; flush block
446                // pieces. Track byte-shift so child hits' offsets stay
447                // correct.
448                let mut acc: Option<TextPropertyEntry> = None;
449                let mut flex_seen = 0usize;
450                for piece in row_pieces {
451                    match piece {
452                        RowPiece::Inline {
453                            mut entry,
454                            hits: child_hits,
455                            focus_cursor: child_focus,
456                            embeds: child_embeds,
457                        } => {
458                            let inline_shift = match acc.as_ref() {
459                                Some(e) => e.text.len(),
460                                None => 0,
461                            };
462                            for mut h in child_hits {
463                                h.byte_start += inline_shift;
464                                h.byte_end += inline_shift;
465                                hits.push(h);
466                            }
467                            if let Some(mut fc) = child_focus {
468                                // buffer_row stays 0 — caller shifts.
469                                fc.byte_in_row += inline_shift as u32;
470                                focus_cursor = Some(fc);
471                            }
472                            for mut emb in child_embeds {
473                                // Inline shift is in bytes; for ASCII
474                                // inline content this matches columns,
475                                // which is the only case that lands here
476                                // in practice (single-row embeds are
477                                // rare).
478                                emb.col_in_row += inline_shift as u32;
479                                embeds.push(emb);
480                            }
481                            match acc.as_mut() {
482                                Some(merged) => merge_inline(merged, &mut entry),
483                                None => acc = Some(entry),
484                            }
485                        }
486                        RowPiece::Flex => {
487                            // Materialize the flex spacer as N spaces.
488                            let n = flex_each + if flex_seen < flex_extra { 1 } else { 0 };
489                            flex_seen += 1;
490                            if n > 0 {
491                                let mut text = String::with_capacity(n);
492                                for _ in 0..n {
493                                    text.push(' ');
494                                }
495                                let entry = TextPropertyEntry {
496                                    text,
497                                    properties: Default::default(),
498                                    style: None,
499                                    inline_overlays: Vec::new(),
500                                    segments: Vec::new(),
501                                    pad_to_chars: None,
502                                    truncate_to_chars: None,
503                                };
504                                match acc.as_mut() {
505                                    Some(merged) => {
506                                        let mut e = entry;
507                                        merge_inline(merged, &mut e);
508                                    }
509                                    None => acc = Some(entry),
510                                }
511                            }
512                        }
513                        RowPiece::Block { .. } => {
514                            // Unreachable in the inline-only path —
515                            // `has_blocks` was false here.
516                            debug_assert!(false, "block piece in inline-only Row path");
517                        }
518                    }
519                }
520                if let Some(mut merged) = acc {
521                    ensure_trailing_newline(&mut merged);
522                    entries.push(merged);
523                }
524            }
525        }
526        WidgetSpec::Col { children, .. } => {
527            for child in children {
528                let (child_entries, child_hits, child_focus, child_embeds) =
529                    render_collected(child, prev, next_state, focus_key, panel_width);
530                let row_offset = entries.len() as u32;
531                for mut h in child_hits {
532                    h.buffer_row += row_offset;
533                    hits.push(h);
534                }
535                if let Some(mut fc) = child_focus {
536                    fc.buffer_row += row_offset;
537                    focus_cursor = Some(fc);
538                }
539                for mut emb in child_embeds {
540                    emb.buffer_row += row_offset;
541                    embeds.push(emb);
542                }
543                entries.extend(child_entries);
544            }
545        }
546        WidgetSpec::HintBar {
547            entries: hint_entries,
548            ..
549        } => {
550            let mut entry = render_hint_bar(hint_entries);
551            ensure_trailing_newline(&mut entry);
552            entries.push(entry);
553            // No hits — HintBar is read-only in v1. (When the
554            // keymap layer arrives, individual entries become
555            // clickable command targets.)
556        }
557        WidgetSpec::Toggle {
558            checked,
559            label,
560            focused,
561            key,
562        } => {
563            // Host-managed focus overrides the spec's `focused`
564            // when this widget has a key and is the panel's focused
565            // widget. Plugin-passed `focused` is ignored when the
566            // host owns focus (i.e. the panel has any tabbable
567            // widgets); without it, the renderer falls back to the
568            // spec value (legacy path).
569            let is_focused = match key.as_deref() {
570                Some(k) if !k.is_empty() => k == focus_key,
571                _ => *focused,
572            };
573            let mut entry = render_toggle(*checked, label, is_focused);
574            let byte_end = entry.text.len();
575            hits.push(HitArea {
576                widget_key: key.clone().unwrap_or_default(),
577                widget_kind: "toggle",
578                buffer_row: 0,
579                byte_start: 0,
580                byte_end,
581                payload: json!({ "checked": !*checked }),
582                event_type: "toggle",
583            });
584            ensure_trailing_newline(&mut entry);
585            entries.push(entry);
586        }
587        WidgetSpec::Button {
588            label,
589            focused,
590            intent,
591            key,
592        } => {
593            let is_focused = match key.as_deref() {
594                Some(k) if !k.is_empty() => k == focus_key,
595                _ => *focused,
596            };
597            let mut entry = render_button(label, is_focused, *intent);
598            let byte_end = entry.text.len();
599            hits.push(HitArea {
600                widget_key: key.clone().unwrap_or_default(),
601                widget_kind: "button",
602                buffer_row: 0,
603                byte_start: 0,
604                byte_end,
605                payload: json!({}),
606                event_type: "activate",
607            });
608            ensure_trailing_newline(&mut entry);
609            entries.push(entry);
610        }
611        WidgetSpec::Spacer { cols, flex, .. } => {
612            // Top-level / Col context: flex Spacers don't fill at
613            // this level (no Row to absorb their flexibility), so
614            // they fall back to `cols`. Row uses a separate code
615            // path that sees the Spacer spec directly and handles
616            // flex sizing — see RowPiece::Flex.
617            let _ = flex;
618            let cols = (*cols).min(4096) as usize;
619            let mut text = String::with_capacity(cols + 1);
620            for _ in 0..cols {
621                text.push(' ');
622            }
623            let mut entry = TextPropertyEntry {
624                text,
625                properties: Default::default(),
626                style: None,
627                inline_overlays: Vec::new(),
628                segments: Vec::new(),
629                pad_to_chars: None,
630                truncate_to_chars: None,
631            };
632            ensure_trailing_newline(&mut entry);
633            entries.push(entry);
634        }
635        WidgetSpec::List {
636            items,
637            item_keys,
638            selected_index,
639            visible_rows,
640            focusable: _,
641            key: list_key,
642        } => {
643            // Look up host-owned scroll + selected index from prev
644            // state (becomes authoritative after first render).
645            // Spec's `selected_index` is initial-only on first
646            // mount; subsequent updates read instance state.
647            let total = items.len() as u32;
648            let visible = (*visible_rows).max(1);
649            let (prev_scroll, prev_sel) = list_key
650                .as_deref()
651                .and_then(|k| prev.get(k))
652                .and_then(|s| match s {
653                    WidgetInstanceState::List {
654                        scroll_offset,
655                        selected_index,
656                    } => Some((*scroll_offset, *selected_index)),
657                    _ => None,
658                })
659                .unwrap_or((0, *selected_index));
660            // Clamp the previous selection to the current dataset
661            // size — items may have shrunk between renders (e.g.
662            // search results changed). Out-of-range selections
663            // collapse to the last item, or -1 if the list is
664            // now empty.
665            let effective_sel = if prev_sel < 0 || total == 0 {
666                -1
667            } else if (prev_sel as u32) >= total {
668                (total - 1) as i32
669            } else {
670                prev_sel
671            };
672
673            // Compute scroll: auto-clamp to keep selection in view
674            // and never extend past the dataset end.
675            let mut scroll = prev_scroll;
676            if effective_sel >= 0 {
677                let sel = effective_sel as u32;
678                if sel < scroll {
679                    scroll = sel;
680                }
681                if sel >= scroll + visible {
682                    scroll = sel + 1 - visible;
683                }
684            }
685            let max_scroll = total.saturating_sub(visible);
686            if scroll > max_scroll {
687                scroll = max_scroll;
688            }
689            // Persist scroll + selection for the next render.
690            // Lists without a `key` lose state across updates.
691            if let Some(k) = list_key.as_deref() {
692                next_state.insert(
693                    k.to_string(),
694                    WidgetInstanceState::List {
695                        scroll_offset: scroll,
696                        selected_index: effective_sel,
697                    },
698                );
699            }
700
701            // Render the visible window, emitting one entry + one
702            // hit area per visible item. Selected row gets the
703            // menu_active_bg + extend_to_line_end style. Hit-area
704            // payload uses the *absolute* item index so the plugin
705            // never needs to translate window-relative coordinates.
706            let start = scroll as usize;
707            let end = ((scroll + visible) as usize).min(items.len());
708            for (offset, item) in items[start..end].iter().enumerate() {
709                let i = start + offset;
710                let mut entry = item.clone();
711                entry.normalize_widths();
712                let is_selected = i as i32 == effective_sel;
713                if is_selected {
714                    let mut style = entry.style.unwrap_or_default();
715                    style.bg = Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG));
716                    style.extend_to_line_end = true;
717                    entry.style = Some(style);
718                }
719                let byte_end = entry.text.len();
720                ensure_trailing_newline(&mut entry);
721                entries.push(entry);
722                let item_key = item_keys.get(i).cloned().unwrap_or_default();
723                let hit_row = (entries.len() - 1) as u32;
724                hits.push(HitArea {
725                    widget_key: item_key.clone(),
726                    widget_kind: "list",
727                    buffer_row: hit_row,
728                    byte_start: 0,
729                    byte_end,
730                    payload: json!({
731                        "index": i as i64,
732                        "key": item_key,
733                    }),
734                    event_type: "select",
735                });
736            }
737        }
738        WidgetSpec::Tree {
739            nodes,
740            item_keys,
741            selected_index,
742            visible_rows,
743            expanded_keys,
744            checkable,
745            key: tree_key,
746        } => {
747            // Look up host-owned instance state (scroll, selection,
748            // expanded set). Spec values are initial-only.
749            let prev_state = tree_key
750                .as_deref()
751                .filter(|k| !k.is_empty())
752                .and_then(|k| prev.get(k));
753            let (prev_scroll, prev_sel, prev_expanded) = match prev_state {
754                Some(WidgetInstanceState::Tree {
755                    scroll_offset,
756                    selected_index,
757                    expanded_keys,
758                }) => (*scroll_offset, *selected_index, expanded_keys.clone()),
759                _ => {
760                    // First render: seed expanded_keys from spec.
761                    let seeded: HashSet<String> = expanded_keys.iter().cloned().collect();
762                    (0, *selected_index, seeded)
763                }
764            };
765
766            // Compute the visible (un-collapsed) flat slice of the
767            // full `nodes` list. A node at depth d is visible iff
768            // every ancestor (the most recent earlier node at depth
769            // d-1, that node's most recent earlier at d-2, etc.) is
770            // expanded. Walk linearly tracking ancestor expansion at
771            // each depth — set ancestor[d] = is_expanded(node) when
772            // we visit a node at depth d, and consider a node
773            // visible iff ancestor[0..node.depth] are all true.
774            //
775            // O(N * max_depth) — fine; trees in this editor are
776            // shallow (filesystem trees, search-results trees).
777            let mut ancestor_open: Vec<bool> = Vec::new();
778            let mut visible_indices: Vec<usize> = Vec::with_capacity(nodes.len());
779            for (i, node) in nodes.iter().enumerate() {
780                let depth = node.depth as usize;
781                // Truncate the ancestor stack to this node's depth.
782                ancestor_open.truncate(depth);
783                let visible = ancestor_open.iter().all(|open| *open);
784                if visible {
785                    visible_indices.push(i);
786                }
787                // Push this node's own openness onto the stack so
788                // descendants see it. The node is "open" iff it has
789                // children AND its key is in expanded_keys; leaves
790                // act like open nodes (their nonexistent descendants
791                // can't be hidden anyway).
792                let key = item_keys.get(i).cloned().unwrap_or_default();
793                let is_open = if node.has_children {
794                    !key.is_empty() && prev_expanded.contains(&key)
795                } else {
796                    true
797                };
798                ancestor_open.push(is_open);
799            }
800
801            // Clamp the previous selection to a visible index. The
802            // selected_index in the spec/instance state references
803            // the *absolute* `nodes` index; if that node is now
804            // hidden (parent collapsed), find the closest visible
805            // node at-or-before it. If no visible nodes, -1.
806            let total_visible = visible_indices.len() as u32;
807            let visible = (*visible_rows).max(1);
808            let clamp_to_visible = |abs: i32| -> i32 {
809                if abs < 0 || nodes.is_empty() {
810                    return -1;
811                }
812                let abs = abs.min((nodes.len() as i32) - 1) as usize;
813                if let Ok(_pos) = visible_indices.binary_search(&abs) {
814                    return abs as i32;
815                }
816                // Not visible — fall back to the nearest earlier
817                // visible node, else the first visible node, else -1.
818                let earlier = visible_indices.iter().rev().find(|&&v| v <= abs);
819                if let Some(&v) = earlier {
820                    return v as i32;
821                }
822                visible_indices.first().map(|&v| v as i32).unwrap_or(-1)
823            };
824            let effective_sel_abs = clamp_to_visible(prev_sel);
825            // Find the position of the selected absolute index in
826            // visible_indices — that's its "visible-window position"
827            // used for scroll math.
828            let sel_visible_pos: i32 = if effective_sel_abs < 0 {
829                -1
830            } else {
831                visible_indices
832                    .iter()
833                    .position(|&v| v == effective_sel_abs as usize)
834                    .map(|p| p as i32)
835                    .unwrap_or(-1)
836            };
837
838            // Compute scroll: same auto-clamp logic as List, but
839            // operating on the visible-windowed indices.
840            let mut scroll = prev_scroll;
841            if sel_visible_pos >= 0 {
842                let sel = sel_visible_pos as u32;
843                if sel < scroll {
844                    scroll = sel;
845                }
846                if sel >= scroll + visible {
847                    scroll = sel + 1 - visible;
848                }
849            }
850            let max_scroll = total_visible.saturating_sub(visible);
851            if scroll > max_scroll {
852                scroll = max_scroll;
853            }
854
855            // Persist instance state.
856            if let Some(k) = tree_key.as_deref().filter(|k| !k.is_empty()) {
857                next_state.insert(
858                    k.to_string(),
859                    WidgetInstanceState::Tree {
860                        scroll_offset: scroll,
861                        selected_index: effective_sel_abs,
862                        expanded_keys: prev_expanded.clone(),
863                    },
864                );
865            }
866
867            // Render the visible window.
868            let start = scroll as usize;
869            let end = ((scroll + visible) as usize).min(visible_indices.len());
870            for &abs_idx in &visible_indices[start..end] {
871                // Apply pad/truncate hints and convert any char-unit
872                // overlays to byte offsets *before* the disclosure
873                // prefix is prepended; render_tree_row then byte-shifts
874                // the (now byte-unit) overlays uniformly.
875                let mut node = nodes[abs_idx].clone();
876                node.text.normalize_widths();
877                let item_key = item_keys.get(abs_idx).cloned().unwrap_or_default();
878                let is_expanded =
879                    node.has_children && !item_key.is_empty() && prev_expanded.contains(&item_key);
880                let rendered = render_tree_row(&node, is_expanded, *checkable);
881                let mut entry = rendered.entry;
882                let is_selected = abs_idx as i32 == effective_sel_abs;
883                if is_selected {
884                    let mut style = entry.style.unwrap_or_default();
885                    style.bg = Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG));
886                    style.extend_to_line_end = true;
887                    entry.style = Some(style);
888                }
889                let row_byte_end = entry.text.len();
890                ensure_trailing_newline(&mut entry);
891                entries.push(entry);
892                let hit_row = (entries.len() - 1) as u32;
893                // Disclosure hit (only when has_children) — fires
894                // `expand`. The host toggles instance-state
895                // `expanded_keys` and re-renders before firing the
896                // event; the plugin only listens if it cares about
897                // expansion changes.
898                // Tree hits use the *tree's* spec key for
899                // `widget_key` (so click-to-focus works the same
900                // as Toggle/Button — the tree is tabbable). The
901                // per-row key travels in the payload.
902                let tree_spec_key = tree_key.clone().unwrap_or_default();
903                if let Some(disc_range) = rendered.disclosure_range {
904                    hits.push(HitArea {
905                        widget_key: tree_spec_key.clone(),
906                        widget_kind: "tree",
907                        buffer_row: hit_row,
908                        byte_start: disc_range.0,
909                        byte_end: disc_range.1,
910                        payload: json!({
911                            "index": abs_idx as i64,
912                            "key": item_key.clone(),
913                            "expanded": !is_expanded,
914                        }),
915                        event_type: "expand",
916                    });
917                }
918                // Checkbox hit (when the parent Tree is checkable
919                // *and* this node has Some(_) checked) — fires
920                // `toggle` with the *new* checked value. The host
921                // does not mutate the spec; the plugin owns the
922                // truth and pushes the new state back via
923                // `WidgetMutation::SetCheckedKeys`.
924                if let Some(cb_range) = rendered.checkbox_range {
925                    let new_checked = !nodes[abs_idx].checked.unwrap_or(false);
926                    hits.push(HitArea {
927                        widget_key: tree_spec_key.clone(),
928                        widget_kind: "tree",
929                        buffer_row: hit_row,
930                        byte_start: cb_range.0,
931                        byte_end: cb_range.1,
932                        payload: json!({
933                            "index": abs_idx as i64,
934                            "key": item_key.clone(),
935                            "checked": new_checked,
936                        }),
937                        event_type: "toggle",
938                    });
939                }
940                // Row body hit — fires `select`. Spans whatever's
941                // left of the row text after the disclosure +
942                // checkbox prefix.
943                let body_start = match (rendered.checkbox_range, rendered.disclosure_range) {
944                    (Some((_, end)), _) => end + 1, // +1 for the trailing space after [v]
945                    (None, Some((_, end))) => end,
946                    (None, None) => 0,
947                };
948                if body_start < row_byte_end {
949                    hits.push(HitArea {
950                        widget_key: tree_spec_key,
951                        widget_kind: "tree",
952                        buffer_row: hit_row,
953                        byte_start: body_start,
954                        byte_end: row_byte_end,
955                        payload: json!({
956                            "index": abs_idx as i64,
957                            "key": item_key,
958                        }),
959                        event_type: "select",
960                    });
961                }
962            }
963        }
964        WidgetSpec::Text {
965            value,
966            cursor_byte,
967            focused,
968            label,
969            placeholder,
970            rows,
971            field_width,
972            max_visible_chars,
973            full_width,
974            key,
975        } => {
976            let is_focused = match key.as_deref() {
977                Some(k) if !k.is_empty() => k == focus_key,
978                _ => *focused,
979            };
980            // Host-owned value/cursor (+ scroll, multi-line only):
981            // read instance state if it exists; else seed from spec
982            // on first render. See WidgetInstanceState::Text doc.
983            let (effective_value, effective_cursor_byte, prev_scroll) = match key
984                .as_deref()
985                .filter(|k| !k.is_empty())
986                .and_then(|k| prev.get(k))
987            {
988                Some(WidgetInstanceState::Text {
989                    value,
990                    cursor_byte,
991                    scroll,
992                }) => (value.clone(), *cursor_byte as i32, *scroll),
993                _ => (value.clone(), *cursor_byte, 0),
994            };
995            let effective_cursor = if is_focused {
996                effective_cursor_byte
997            } else {
998                -1
999            };
1000            // `rows == 0` shouldn't happen because of serde's
1001            // default = 1, but if it slips through (raw struct
1002            // construction in tests, etc.) treat it as single-line.
1003            let multiline = *rows > 1;
1004            // When `full_width` is requested, override the
1005            // plugin-supplied `field_width` with the slice of
1006            // `panel_width` remaining after the label prefix,
1007            // the two surrounding `[` / `]` brackets, and one
1008            // trailing column reserved for the cursor-park space
1009            // `render_text_input` appends when focused. Reserving
1010            // unconditionally costs an unfocused field one
1011            // trailing space but keeps the rendered width stable
1012            // across the focus transition — without it the field
1013            // would overflow the parent on focus. For multi-line
1014            // we don't need the focus reservation but keep the
1015            // same calculation for symmetry; `render_text_area`
1016            // already fills the panel width by default.
1017            let effective_field_width = if *full_width && !multiline {
1018                let label_overhead = if label.is_empty() {
1019                    0u32
1020                } else {
1021                    label.chars().count() as u32 + 1
1022                };
1023                panel_width
1024                    .saturating_sub(label_overhead)
1025                    .saturating_sub(3)
1026                    .max(1)
1027            } else {
1028                *field_width
1029            };
1030            let new_scroll;
1031            if multiline {
1032                let rendered = render_text_area(
1033                    &effective_value,
1034                    effective_cursor,
1035                    is_focused,
1036                    label,
1037                    placeholder.as_deref(),
1038                    *rows,
1039                    effective_field_width,
1040                    prev_scroll,
1041                    panel_width,
1042                );
1043                new_scroll = rendered.scroll_row;
1044                if let (Some(buffer_row), Some(byte_in_row)) =
1045                    (rendered.cursor_buffer_row, rendered.cursor_byte_in_row)
1046                {
1047                    focus_cursor = Some(FocusCursor {
1048                        buffer_row,
1049                        byte_in_row: byte_in_row as u32,
1050                    });
1051                }
1052                for mut e in rendered.entries {
1053                    ensure_trailing_newline(&mut e);
1054                    entries.push(e);
1055                }
1056            } else {
1057                let rendered = render_text_input(
1058                    &effective_value,
1059                    effective_cursor,
1060                    is_focused,
1061                    label,
1062                    placeholder.as_deref(),
1063                    *max_visible_chars,
1064                    effective_field_width,
1065                    *full_width,
1066                );
1067                new_scroll = 0;
1068                if let Some(byte_in_row) = rendered.cursor_byte_in_entry {
1069                    focus_cursor = Some(FocusCursor {
1070                        buffer_row: 0,
1071                        byte_in_row: byte_in_row as u32,
1072                    });
1073                }
1074                let mut entry = rendered.entry;
1075                ensure_trailing_newline(&mut entry);
1076                entries.push(entry);
1077            }
1078            // Persist instance state for next render. Cursor clamps
1079            // into `[0, value.len()]`; `scroll` carries the
1080            // renderer's auto-clamped first-visible-row for
1081            // multi-line, or `0` for single-line.
1082            if let Some(k) = key.as_deref().filter(|k| !k.is_empty()) {
1083                let cb = effective_cursor_byte
1084                    .max(0)
1085                    .min(effective_value.len() as i32) as u32;
1086                next_state.insert(
1087                    k.to_string(),
1088                    WidgetInstanceState::Text {
1089                        value: effective_value.clone(),
1090                        cursor_byte: cb,
1091                        scroll: new_scroll,
1092                    },
1093                );
1094            }
1095        }
1096        WidgetSpec::LabeledSection { label, child, .. } => {
1097            // Inner area: 1 column of border + 1 column of
1098            // padding on each side ⇒ 4 columns of chrome.
1099            let inner_width = panel_width.saturating_sub(4).max(1);
1100            let (child_entries, child_hits, child_focus, child_embeds) =
1101                render_collected(child, prev, next_state, focus_key, inner_width);
1102
1103            // Render the top border with the label embedded as a
1104            // legend: `╭─ <label> ─...─╮`. When the label is empty,
1105            // produce a plain `╭─...─╮` bar.
1106            let total_cols = panel_width.max(2) as usize;
1107            entries.push(render_section_top_border(label, total_cols));
1108
1109            // Render each child row wrapped with the side borders
1110            // and one column of padding. Pad/truncate the child
1111            // text to exactly `inner_width` so the right border
1112            // lines up regardless of the child's natural width.
1113            for mut child_entry in child_entries {
1114                strip_trailing_newline(&mut child_entry);
1115                let wrapped = wrap_in_side_border(child_entry, inner_width as usize);
1116                let row_offset = entries.len() as u32;
1117                // Shift hits/focus emitted by the child by 1 row
1118                // (top border) and by the left-border prefix
1119                // ("│ " — 4 bytes for the box-drawing char + 1
1120                // for the space).
1121                let _ = row_offset;
1122                entries.push(wrapped);
1123            }
1124
1125            // The child's hit areas were rendered with row 0 at
1126            // the *first child line*; shift them by 1 (top
1127            // border) and by the left-border byte prefix.
1128            let prefix_bytes = LEFT_BORDER_PREFIX.len();
1129            for mut h in child_hits {
1130                h.buffer_row += 1;
1131                h.byte_start += prefix_bytes;
1132                h.byte_end += prefix_bytes;
1133                hits.push(h);
1134            }
1135            if let Some(mut fc) = child_focus {
1136                fc.buffer_row += 1;
1137                fc.byte_in_row += prefix_bytes as u32;
1138                focus_cursor = Some(fc);
1139            }
1140            // Embeds are column-addressed; the `│ ` prefix is
1141            // 4 UTF-8 bytes but only 2 display columns wide.
1142            let prefix_cols = LEFT_BORDER_PREFIX.chars().count() as u32;
1143            for mut emb in child_embeds {
1144                emb.buffer_row += 1;
1145                emb.col_in_row += prefix_cols;
1146                embeds.push(emb);
1147            }
1148
1149            entries.push(render_section_bottom_border(total_cols));
1150        }
1151        WidgetSpec::WindowEmbed {
1152            window_id,
1153            rows: embed_rows,
1154            ..
1155        } => {
1156            // Emit `rows` blank lines of `panel_width` width so
1157            // layout reserves the rectangle. The host paint
1158            // path overlays the native window render on top of
1159            // these blanks after the rest of the panel paints.
1160            let cols = panel_width.max(1) as usize;
1161            for _ in 0..*embed_rows {
1162                let mut text = String::with_capacity(cols + 1);
1163                for _ in 0..cols {
1164                    text.push(' ');
1165                }
1166                text.push('\n');
1167                entries.push(TextPropertyEntry {
1168                    text,
1169                    properties: Default::default(),
1170                    style: None,
1171                    inline_overlays: Vec::new(),
1172                    segments: Vec::new(),
1173                    pad_to_chars: None,
1174                    truncate_to_chars: None,
1175                });
1176            }
1177            embeds.push(EmbedRect {
1178                window_id: *window_id,
1179                buffer_row: 0,
1180                col_in_row: 0,
1181                width_cols: panel_width,
1182                height_rows: *embed_rows,
1183            });
1184        }
1185        WidgetSpec::Raw {
1186            entries: raw_entries,
1187            ..
1188        } => {
1189            // Raw is the migration escape hatch: the plugin's own
1190            // bytes flow through unchanged. The plugin still owns
1191            // mouse clicks within Raw regions (via the existing
1192            // `mouse_click` hook); the widget runtime intentionally
1193            // emits no hit areas here. We *do* ensure each Raw
1194            // entry ends with a newline so it occupies its own
1195            // buffer line — plugins that already include `\n` are
1196            // unaffected.
1197            for raw_entry in raw_entries {
1198                let mut e = raw_entry.clone();
1199                e.normalize_widths();
1200                ensure_trailing_newline(&mut e);
1201                entries.push(e);
1202            }
1203        }
1204    }
1205    (entries, hits, focus_cursor, embeds)
1206}
1207
1208// =========================================================================
1209// LabeledSection helpers.
1210// =========================================================================
1211
1212const LEFT_BORDER_PREFIX: &str = "│ ";
1213const RIGHT_BORDER_SUFFIX: &str = " │";
1214
1215/// Build the top border row for a `LabeledSection`.
1216///
1217/// Output (with label "Session name", total_cols = 30):
1218///
1219/// ```text
1220/// ╭─ Session name ─────────────╮
1221/// ```
1222///
1223/// When `label` is empty the legend separators collapse and the
1224/// border is one unbroken `─` run.
1225fn render_section_top_border(label: &str, total_cols: usize) -> TextPropertyEntry {
1226    let mut text = String::new();
1227    let mut overlays: Vec<InlineOverlay> = Vec::new();
1228    text.push('╭');
1229    if label.is_empty() {
1230        for _ in 0..total_cols.saturating_sub(2) {
1231            text.push('─');
1232        }
1233    } else {
1234        // `╭─ label ─...─╮`. Capture the byte range of `label`
1235        // (after the leading `─ ` and before the trailing ` `)
1236        // so the renderer can paint it in a distinct fg, marking
1237        // it as the section caption rather than border chrome.
1238        let label_cols = label.chars().count();
1239        let used = 1 + 1 + 1 + label_cols + 1; // ╭ ─ ` ` label ` `
1240        text.push('─');
1241        text.push(' ');
1242        let label_byte_start = text.len();
1243        text.push_str(label);
1244        let label_byte_end = text.len();
1245        text.push(' ');
1246        let remaining = total_cols.saturating_sub(used + 1); // -1 for `╮`
1247        for _ in 0..remaining {
1248            text.push('─');
1249        }
1250        overlays.push(InlineOverlay {
1251            start: label_byte_start,
1252            end: label_byte_end,
1253            style: OverlayOptions {
1254                fg: Some(OverlayColorSpec::theme_key(KEY_SECTION_LABEL_FG)),
1255                bold: true,
1256                ..Default::default()
1257            },
1258            properties: Default::default(),
1259            unit: OffsetUnit::Byte,
1260        });
1261    }
1262    text.push('╮');
1263    text.push('\n');
1264    TextPropertyEntry {
1265        text,
1266        properties: Default::default(),
1267        style: None,
1268        inline_overlays: overlays,
1269        segments: Vec::new(),
1270        pad_to_chars: None,
1271        truncate_to_chars: None,
1272    }
1273}
1274
1275/// Build the bottom border row: `╰──...──╯` spanning `total_cols`
1276/// display columns.
1277fn render_section_bottom_border(total_cols: usize) -> TextPropertyEntry {
1278    let mut text = String::new();
1279    text.push('╰');
1280    for _ in 0..total_cols.saturating_sub(2) {
1281        text.push('─');
1282    }
1283    text.push('╯');
1284    text.push('\n');
1285    TextPropertyEntry {
1286        text,
1287        properties: Default::default(),
1288        style: None,
1289        inline_overlays: Vec::new(),
1290        segments: Vec::new(),
1291        pad_to_chars: None,
1292        truncate_to_chars: None,
1293    }
1294}
1295
1296/// Wrap a single child row with `│ ... │` and pad / truncate the
1297/// child text to fit exactly `inner_width` display columns.
1298/// Inline overlays are byte-shifted by the left-prefix length so
1299/// they keep aligning with the right characters.
1300fn wrap_in_side_border(mut child: TextPropertyEntry, inner_width: usize) -> TextPropertyEntry {
1301    let prefix_bytes = LEFT_BORDER_PREFIX.len();
1302    // Pad / truncate `child.text` to `inner_width` display cols.
1303    let cur_cols = child.text.chars().count();
1304    if cur_cols < inner_width {
1305        for _ in 0..(inner_width - cur_cols) {
1306            child.text.push(' ');
1307        }
1308    } else if cur_cols > inner_width {
1309        // Tail-truncate at the codepoint boundary corresponding
1310        // to `inner_width` chars.
1311        let indices: Vec<usize> = child.text.char_indices().map(|(i, _)| i).collect();
1312        let byte_cutoff = indices
1313            .get(inner_width)
1314            .copied()
1315            .unwrap_or(child.text.len());
1316        child.text.truncate(byte_cutoff);
1317        // Drop any overlay that would now reference past the
1318        // truncation point; clamp the rest.
1319        child.inline_overlays.retain_mut(|o| {
1320            if o.start >= byte_cutoff {
1321                return false;
1322            }
1323            if o.end > byte_cutoff {
1324                o.end = byte_cutoff;
1325            }
1326            true
1327        });
1328    }
1329
1330    // Compose final text: `│ ` + child + ` │\n`.
1331    let mut text = String::with_capacity(
1332        LEFT_BORDER_PREFIX.len() + child.text.len() + RIGHT_BORDER_SUFFIX.len() + 1,
1333    );
1334    text.push_str(LEFT_BORDER_PREFIX);
1335    text.push_str(&child.text);
1336    text.push_str(RIGHT_BORDER_SUFFIX);
1337    text.push('\n');
1338
1339    // Shift child overlays by the left-prefix byte count.
1340    let overlays: Vec<InlineOverlay> = child
1341        .inline_overlays
1342        .into_iter()
1343        .map(|o| InlineOverlay {
1344            start: o.start + prefix_bytes,
1345            end: o.end + prefix_bytes,
1346            style: o.style,
1347            properties: o.properties,
1348            unit: o.unit,
1349        })
1350        .collect();
1351
1352    TextPropertyEntry {
1353        text,
1354        properties: child.properties,
1355        style: child.style,
1356        inline_overlays: overlays,
1357        segments: Vec::new(),
1358        pad_to_chars: None,
1359        truncate_to_chars: None,
1360    }
1361}
1362
1363/// Render a HintBar into a single `TextPropertyEntry`.
1364///
1365/// Layout: `<keys> <label>  <keys> <label>  …`. The key portion of
1366/// each entry is highlighted with the `ui.help_key_fg` theme key;
1367/// labels use the buffer's default foreground.
1368///
1369/// This replaces the per-plugin hand-rolled footer at e.g.
1370/// `crates/fresh-editor/plugins/search_replace.ts:535–541`,
1371/// `audit_mode.ts:1068–1158`, `pkg.ts:2136–2145`.
1372pub fn render_hint_bar(entries: &[HintEntry]) -> TextPropertyEntry {
1373    let separator = "  ";
1374    let mut text = String::new();
1375    let mut overlays = Vec::new();
1376    for (i, entry) in entries.iter().enumerate() {
1377        if i > 0 {
1378            text.push_str(separator);
1379        }
1380        let key_start = text.len();
1381        text.push_str(&entry.keys);
1382        let key_end = text.len();
1383        if key_end > key_start {
1384            overlays.push(InlineOverlay {
1385                start: key_start,
1386                end: key_end,
1387                style: OverlayOptions {
1388                    fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
1389                    bold: true,
1390                    ..Default::default()
1391                },
1392                properties: Default::default(),
1393                unit: OffsetUnit::Byte,
1394            });
1395        }
1396        if !entry.label.is_empty() {
1397            text.push(' ');
1398            text.push_str(&entry.label);
1399        }
1400    }
1401    TextPropertyEntry {
1402        text,
1403        properties: Default::default(),
1404        style: None,
1405        inline_overlays: overlays,
1406        segments: Vec::new(),
1407        pad_to_chars: None,
1408        truncate_to_chars: None,
1409    }
1410}
1411
1412/// Render a `Toggle` to a single `TextPropertyEntry`.
1413///
1414/// Layout: `[v] label` when checked, `[ ] label` when not. The check
1415/// glyph is colored via `ui.tab_active_fg` when checked (no override
1416/// when unchecked). When focused, the entire entry is given a focused
1417/// fg/bg pair (`ui.menu_active_fg`/`ui.menu_active_bg`) plus bold —
1418/// matching the Settings UI's selected-control affordance.
1419pub fn render_toggle(checked: bool, label: &str, focused: bool) -> TextPropertyEntry {
1420    let glyph = if checked { "[v]" } else { "[ ]" };
1421    let mut text = String::with_capacity(glyph.len() + 1 + label.len());
1422    text.push_str(glyph);
1423    text.push(' ');
1424    text.push_str(label);
1425
1426    let mut overlays = Vec::new();
1427
1428    // Check-glyph color (only when checked — leaves default fg
1429    // when unchecked, which is what plugins do today).
1430    if checked {
1431        overlays.push(InlineOverlay {
1432            start: 0,
1433            end: glyph.len(),
1434            style: OverlayOptions {
1435                fg: Some(OverlayColorSpec::theme_key(KEY_TOGGLE_ON_FG)),
1436                bold: true,
1437                ..Default::default()
1438            },
1439            properties: Default::default(),
1440            unit: OffsetUnit::Byte,
1441        });
1442    }
1443
1444    // Focused: full-entry fg/bg + bold.
1445    if focused {
1446        overlays.push(InlineOverlay {
1447            start: 0,
1448            end: text.len(),
1449            style: OverlayOptions {
1450                fg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_FG)),
1451                bg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG)),
1452                bold: true,
1453                ..Default::default()
1454            },
1455            properties: Default::default(),
1456            unit: OffsetUnit::Byte,
1457        });
1458    }
1459
1460    TextPropertyEntry {
1461        text,
1462        properties: Default::default(),
1463        style: None,
1464        inline_overlays: overlays,
1465        segments: Vec::new(),
1466        pad_to_chars: None,
1467        truncate_to_chars: None,
1468    }
1469}
1470
1471/// Render a `Button` to a single `TextPropertyEntry`.
1472///
1473/// Layout: `[ Label ]` (with explicit space padding so the label
1474/// is visually inset from the brackets). Styling depends on `kind`
1475/// and `focused`:
1476///
1477/// * `Normal`  — default fg; focused → fg/bg flip + bold.
1478/// * `Primary` — bold; focused → fg/bg flip.
1479/// * `Danger`  — red fg (theme `ui.status_error_indicator_fg`);
1480///   focused → bold.
1481pub fn render_button(label: &str, focused: bool, kind: ButtonKind) -> TextPropertyEntry {
1482    let text = format!("[ {} ]", label);
1483    let mut overlays = Vec::new();
1484
1485    let base_style = match kind {
1486        ButtonKind::Normal => OverlayOptions::default(),
1487        // Primary marks the affirmative action with a bold,
1488        // strong fg drawn directly on the surrounding surface —
1489        // no opinionated bg. Focus is the only state that paints
1490        // a backing color (handled below).
1491        ButtonKind::Primary => OverlayOptions {
1492            fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
1493            bold: true,
1494            ..Default::default()
1495        },
1496        // Danger gets the error fg, bold, on the surrounding
1497        // surface — same fg-only treatment as Primary.
1498        ButtonKind::Danger => OverlayOptions {
1499            fg: Some(OverlayColorSpec::theme_key(KEY_DANGER_FG)),
1500            bold: true,
1501            ..Default::default()
1502        },
1503    };
1504
1505    let style = if focused {
1506        OverlayOptions {
1507            fg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_FG)),
1508            bg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG)),
1509            bold: true,
1510            ..base_style
1511        }
1512    } else {
1513        base_style
1514    };
1515
1516    // Only emit an overlay if the style is non-default — keeps the
1517    // serialized entry tight.
1518    if style.fg.is_some()
1519        || style.bg.is_some()
1520        || style.bold
1521        || style.italic
1522        || style.underline
1523        || style.strikethrough
1524    {
1525        overlays.push(InlineOverlay {
1526            start: 0,
1527            end: text.len(),
1528            style,
1529            properties: Default::default(),
1530            unit: OffsetUnit::Byte,
1531        });
1532    }
1533
1534    TextPropertyEntry {
1535        text,
1536        properties: Default::default(),
1537        style: None,
1538        inline_overlays: overlays,
1539        segments: Vec::new(),
1540        pad_to_chars: None,
1541        truncate_to_chars: None,
1542    }
1543}
1544
1545/// Output of `render_tree_row` — the rendered entry plus the byte
1546/// range covered by the disclosure glyph (when present) so the
1547/// caller can emit a separate hit area for click-to-expand.
1548pub struct RenderedTreeRow {
1549    pub entry: TextPropertyEntry,
1550    /// Byte range within `entry.text` of the disclosure glyph
1551    /// (`▶`/`▼`). `None` for leaf nodes (no glyph rendered).
1552    pub disclosure_range: Option<(usize, usize)>,
1553    /// Byte range within `entry.text` of the checkbox glyph
1554    /// (`[v]` / `[ ]`). `None` when the parent Tree is not
1555    /// `checkable`, or when this node has `checked: None`. The
1556    /// caller emits a `toggle` hit area over this range.
1557    pub checkbox_range: Option<(usize, usize)>,
1558}
1559
1560/// Render a single `TreeNode` row.
1561///
1562/// Layout: `<indent><disclosure><space>[<checkbox><space>]<node-text>`
1563/// where:
1564/// * `indent` = `depth * 2` spaces.
1565/// * `disclosure` = `▶` (collapsed) / `▼` (expanded) for internal
1566///   nodes; two spaces (alignment) for leaves.
1567/// * `checkbox` = `[v]` (checked) / `[ ]` (unchecked) when the
1568///   parent Tree opted into `checkable: true` *and* this node has
1569///   `checked: Some(_)`; otherwise omitted entirely.
1570/// * `<node-text>` is the plugin's pre-rendered row content, with
1571///   its inline overlays byte-shifted by the prefix length.
1572///
1573/// The disclosure glyph is colored with `ui.help_key_fg`; the
1574/// checkbox glyph reuses `ui.tab_active_fg` (the same key the
1575/// `Toggle` widget uses for its checked-state glyph) so it reads
1576/// as a control surface against the row's text.
1577pub fn render_tree_row(node: &TreeNode, expanded: bool, checkable: bool) -> RenderedTreeRow {
1578    let indent_cols = (node.depth as usize) * 2;
1579    let disclosure_glyph: &str = if node.has_children {
1580        if expanded {
1581            "▼"
1582        } else {
1583            "▶"
1584        }
1585    } else {
1586        // Two spaces — same display width as the glyph plus space,
1587        // keeping leaf rows aligned with their internal siblings.
1588        "  "
1589    };
1590    // `disclosure_glyph` (▶/▼) is 1 column wide; we want the row
1591    // text to start at the same column whether or not the row is
1592    // a leaf. With glyph + one separator space, that's 2 cols. The
1593    // leaf branch uses two literal spaces for the same width.
1594    let separator: &str = if node.has_children { " " } else { "" };
1595
1596    let checkbox_glyph: Option<&'static str> = if checkable {
1597        match node.checked {
1598            Some(true) => Some("[v]"),
1599            Some(false) => Some("[ ]"),
1600            None => None,
1601        }
1602    } else {
1603        None
1604    };
1605    let checkbox_extra = checkbox_glyph.map(|g| g.len() + 1).unwrap_or(0);
1606
1607    let mut text = String::with_capacity(
1608        indent_cols
1609            + disclosure_glyph.len()
1610            + separator.len()
1611            + checkbox_extra
1612            + node.text.text.len(),
1613    );
1614    for _ in 0..indent_cols {
1615        text.push(' ');
1616    }
1617    let disc_start = text.len();
1618    text.push_str(disclosure_glyph);
1619    let disc_end = text.len();
1620    text.push_str(separator);
1621    let checkbox_range = if let Some(g) = checkbox_glyph {
1622        let cb_start = text.len();
1623        text.push_str(g);
1624        let cb_end = text.len();
1625        text.push(' ');
1626        Some((cb_start, cb_end))
1627    } else {
1628        None
1629    };
1630    let body_start = text.len();
1631    text.push_str(&node.text.text);
1632
1633    // Carry over the plugin's inline overlays, shifted right by
1634    // `body_start` so they land on the correct bytes after the
1635    // prefix.
1636    let mut overlays: Vec<InlineOverlay> = node
1637        .text
1638        .inline_overlays
1639        .iter()
1640        .map(|o| {
1641            let mut shifted = o.clone();
1642            shifted.start += body_start;
1643            shifted.end += body_start;
1644            shifted
1645        })
1646        .collect();
1647
1648    // Disclosure glyph color — only on internal nodes, where the
1649    // glyph is a real character (not just two spaces).
1650    if node.has_children {
1651        overlays.push(InlineOverlay {
1652            start: disc_start,
1653            end: disc_end,
1654            style: OverlayOptions {
1655                fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
1656                bold: true,
1657                ..Default::default()
1658            },
1659            properties: Default::default(),
1660            unit: OffsetUnit::Byte,
1661        });
1662    }
1663    // Checkbox glyph color — bright for checked, dim for unchecked,
1664    // matching the Toggle widget's convention.
1665    if let Some((cb_start, cb_end)) = checkbox_range {
1666        let theme_key = match node.checked {
1667            Some(true) => KEY_TOGGLE_ON_FG,
1668            _ => KEY_PLACEHOLDER_FG,
1669        };
1670        overlays.push(InlineOverlay {
1671            start: cb_start,
1672            end: cb_end,
1673            style: OverlayOptions {
1674                fg: Some(OverlayColorSpec::theme_key(theme_key)),
1675                bold: matches!(node.checked, Some(true)),
1676                ..Default::default()
1677            },
1678            properties: Default::default(),
1679            unit: OffsetUnit::Byte,
1680        });
1681    }
1682
1683    let disclosure_range = if node.has_children {
1684        Some((disc_start, disc_end))
1685    } else {
1686        None
1687    };
1688    let entry = TextPropertyEntry {
1689        text,
1690        // The plugin's own row-level properties (e.g. file-row
1691        // metadata) carry through unchanged so existing
1692        // mouse_click handlers still see them.
1693        properties: node.text.properties.clone(),
1694        style: node.text.style.clone(),
1695        inline_overlays: overlays,
1696        // segments / pad / truncate hints are consumed by the
1697        // caller before render_tree_row is invoked (see
1698        // normalize_widths in the Tree match arm). The output
1699        // entry's text is already final, so these are cleared.
1700        segments: Vec::new(),
1701        pad_to_chars: None,
1702        truncate_to_chars: None,
1703    };
1704    RenderedTreeRow {
1705        entry,
1706        disclosure_range,
1707        checkbox_range,
1708    }
1709}
1710
1711/// Output of `render_text_input` — the rendered entry plus the
1712/// byte offset within `entry.text` where the host should place the
1713/// hardware cursor when this input is focused.
1714pub struct RenderedTextInput {
1715    pub entry: TextPropertyEntry,
1716    /// Byte offset within `entry.text` where the cursor lands.
1717    /// When the input is unfocused or has no cursor, `None`.
1718    pub cursor_byte_in_entry: Option<usize>,
1719}
1720
1721/// Render a `TextInput`.
1722///
1723/// Layout: `Label: [<inner>]` (or `[<inner>]` with no label).
1724/// `<inner>` is exactly `field_width` chars wide when
1725/// `field_width > 0` — short values pad with trailing spaces, long
1726/// values head-truncate with `…` so the cursor (typically near the
1727/// tail) stays visible. With `field_width == 0` the input grows
1728/// with the value (legacy behaviour, also used by tests).
1729///
1730/// Placeholder: when unfocused and empty, the placeholder string
1731/// is shown in `ui.menu_disabled_fg`. Focused inputs always show
1732/// their (possibly empty) value, never the placeholder.
1733///
1734/// Focused-bg: the bracketed region gets `ui.prompt_bg` so the
1735/// field visually reads as the active editing target.
1736///
1737/// **No cursor overlay**: this renderer does not paint the cursor
1738/// itself — it returns the byte offset where the host should drop
1739/// the *real* hardware cursor (the terminal's blinking caret). The
1740/// dispatcher uses that offset to position
1741/// `SplitViewState::cursors.primary` and flip `show_cursors=true`
1742/// on the panel buffer. Result: the cursor is always visible
1743/// regardless of theme contrast, blinks correctly, and matches
1744/// every other text-input field in the editor.
1745pub fn render_text_input(
1746    value: &str,
1747    cursor_byte: i32,
1748    focused: bool,
1749    label: &str,
1750    placeholder: Option<&str>,
1751    max_visible_chars: u32,
1752    field_width: u32,
1753    full_width: bool,
1754) -> RenderedTextInput {
1755    // Placeholder visibility: the value-empty state, regardless of
1756    // focus. The placeholder remains in the field until the user
1757    // types something — a focused-empty input still shows the
1758    // hint. The cursor (when focused) sits on top of the
1759    // placeholder's first char, which is the natural way the
1760    // user "overwrites" the hint as they type.
1761    let show_placeholder = value.is_empty() && placeholder.is_some();
1762
1763    // Compute the user-cursor's char position within `value`. We
1764    // operate in bytes here, which is correct for the cursor on
1765    // ASCII; multibyte chars resolve via is_char_boundary checks.
1766    let raw_cursor_byte = if cursor_byte < 0 {
1767        value.len()
1768    } else {
1769        (cursor_byte as usize).min(value.len())
1770    };
1771
1772    // Build `<inner>` plus the byte offset of the cursor *within*
1773    // `<inner>` (not yet including `[`/label offsets). This is the
1774    // single place where field-width truncation/padding lives.
1775    let (inner, cursor_in_inner) = if show_placeholder && field_width == 0 {
1776        // No constant width: render the placeholder as-is. Cursor
1777        // (when focused) parks at byte 0 of the placeholder so
1778        // the first typed char replaces it.
1779        let inner = placeholder.unwrap_or("").to_string();
1780        let cursor = if focused { Some(0usize) } else { None };
1781        (inner, cursor)
1782    } else if show_placeholder {
1783        // Constant-width placeholder: pad / truncate the hint to
1784        // the same total_inner width the value would occupy, so
1785        // the bracketed field has a stable visual size whether
1786        // the user has typed yet or not. Same `pad_extra = 1`
1787        // rule as the value path (under `full_width`) so the
1788        // closing bracket doesn't shift on focus.
1789        let target = field_width as usize;
1790        let pad_extra = if focused || full_width { 1 } else { 0 };
1791        let total_inner = target + pad_extra;
1792        let raw = placeholder.unwrap_or("");
1793        let raw_chars: Vec<char> = raw.chars().collect();
1794        let inner = if raw_chars.len() <= total_inner {
1795            let mut s = raw.to_string();
1796            while s.chars().count() < total_inner {
1797                s.push(' ');
1798            }
1799            s
1800        } else {
1801            // Tail-truncate the placeholder with `…` so a long
1802            // hint doesn't bleed past the field.
1803            let keep = total_inner.saturating_sub(1);
1804            let prefix: String = raw_chars.iter().take(keep).collect();
1805            format!("{}…", prefix)
1806        };
1807        let cursor = if focused { Some(0usize) } else { None };
1808        (inner, cursor)
1809    } else if field_width > 0 {
1810        // Constant-width. Visible value occupies `target` chars;
1811        // when focused (or when the caller asked for `full_width`,
1812        // which stabilises the visual width across focus
1813        // transitions) we add one trailing pad space so the cursor
1814        // never lands on the closing bracket.
1815        let target = field_width as usize;
1816        let pad_extra = if focused || full_width { 1 } else { 0 };
1817        let total_inner = target + pad_extra;
1818        let value_chars: Vec<char> = value.chars().collect();
1819        if value_chars.len() <= target {
1820            // Short or exact-fit value: pad with trailing spaces
1821            // to total_inner. Cursor at byte k of value lands at
1822            // byte k of inner.
1823            let mut padded = value.to_string();
1824            while padded.chars().count() < total_inner {
1825                padded.push(' ');
1826            }
1827            (padded, Some(raw_cursor_byte))
1828        } else {
1829            // Long value: head-truncate to fit `target - 1` value
1830            // chars + 1 ellipsis. When focused, append a trailing
1831            // pad space (cursor parks there at end-of-value).
1832            let keep = target - 1;
1833            let drop_chars = value_chars.len() - keep;
1834            let mut dropped_bytes = 0usize;
1835            for ch in value_chars.iter().take(drop_chars) {
1836                dropped_bytes += ch.len_utf8();
1837            }
1838            let tail = &value[dropped_bytes..];
1839            let mut s = String::with_capacity("…".len() + tail.len() + pad_extra);
1840            s.push('…');
1841            s.push_str(tail);
1842            for _ in 0..pad_extra {
1843                s.push(' ');
1844            }
1845            // Cursor: if it sits in the dropped prefix, clamp to
1846            // right after the `…` glyph; otherwise translate
1847            // through the truncation.
1848            let cursor_in_inner = if raw_cursor_byte < dropped_bytes {
1849                "…".len()
1850            } else {
1851                "…".len() + (raw_cursor_byte - dropped_bytes)
1852            };
1853            (s, Some(cursor_in_inner))
1854        }
1855    } else if max_visible_chars > 0 && value.chars().count() > max_visible_chars as usize {
1856        // Legacy max_visible_chars path: tail-truncate with `…`
1857        // (drops the *tail*, not the head — matches the original
1858        // cursor-invisible v1 behaviour for callers still using it).
1859        let chars: Vec<char> = value.chars().collect();
1860        let take = (max_visible_chars as usize).saturating_sub(1);
1861        let start = chars.len().saturating_sub(take);
1862        let tail: String = chars[start..].iter().collect();
1863        let s = format!("…{}", tail);
1864        (s, Some(raw_cursor_byte.min(value.len())))
1865    } else {
1866        // No fixed width and no truncation: render the value as-is.
1867        // When focused we still need somewhere for the cursor to
1868        // land at end-of-value — append a trailing space so the
1869        // cursor sits on it instead of overlapping the closing
1870        // bracket.
1871        let mut s = value.to_string();
1872        if focused {
1873            s.push(' ');
1874        }
1875        (s, Some(raw_cursor_byte))
1876    };
1877
1878    // Compose the final text: optional label, `[`, inner, `]`.
1879    let mut text = String::new();
1880    if !label.is_empty() {
1881        text.push_str(label);
1882        text.push(' ');
1883    }
1884    let bracket_open_byte = text.len();
1885    text.push('[');
1886    let inner_byte_start = text.len();
1887    text.push_str(&inner);
1888    let inner_byte_end = text.len();
1889    text.push(']');
1890    let bracket_close_byte = text.len();
1891
1892    let mut overlays = Vec::new();
1893
1894    if show_placeholder {
1895        overlays.push(InlineOverlay {
1896            start: inner_byte_start,
1897            end: inner_byte_end,
1898            style: OverlayOptions {
1899                fg: Some(OverlayColorSpec::theme_key(KEY_PLACEHOLDER_FG)),
1900                italic: true,
1901                ..Default::default()
1902            },
1903            properties: Default::default(),
1904            unit: OffsetUnit::Byte,
1905        });
1906    }
1907
1908    if focused {
1909        overlays.push(InlineOverlay {
1910            start: bracket_open_byte,
1911            end: bracket_close_byte,
1912            style: OverlayOptions {
1913                bg: Some(OverlayColorSpec::theme_key(KEY_INPUT_BG)),
1914                ..Default::default()
1915            },
1916            properties: Default::default(),
1917            unit: OffsetUnit::Byte,
1918        });
1919    }
1920
1921    let cursor_byte_in_entry = if focused {
1922        cursor_in_inner.map(|c| inner_byte_start + c)
1923    } else {
1924        None
1925    };
1926
1927    RenderedTextInput {
1928        entry: TextPropertyEntry {
1929            text,
1930            properties: Default::default(),
1931            style: None,
1932            inline_overlays: overlays,
1933            segments: Vec::new(),
1934            pad_to_chars: None,
1935            truncate_to_chars: None,
1936        },
1937        cursor_byte_in_entry,
1938    }
1939}
1940
1941/// Output of `render_text_area`. One entry per visible row of the
1942/// editing region, plus optionally one preceding label row.
1943pub struct RenderedTextArea {
1944    /// The label row (if any) followed by `visible_rows` rows of
1945    /// editing content. Empty `value` lines are rendered as blank
1946    /// padded rows so the widget always occupies its full visual
1947    /// height.
1948    pub entries: Vec<TextPropertyEntry>,
1949    /// Auto-clamped scroll row (first visible line of `value`)
1950    /// after this render. Persisted into instance state by the
1951    /// caller.
1952    pub scroll_row: u32,
1953    /// Buffer row (within `entries`) where the host should drop
1954    /// the hardware cursor when focused. `None` when unfocused or
1955    /// when `value` is empty and the placeholder is showing.
1956    pub cursor_buffer_row: Option<u32>,
1957    /// Byte offset within the cursor's row text where the cursor
1958    /// lands. Pairs with `cursor_buffer_row`.
1959    pub cursor_byte_in_row: Option<usize>,
1960}
1961
1962/// Render a multi-line `TextArea`.
1963///
1964/// Layout:
1965/// * If `label` is non-empty, one `Label:` row precedes the editing
1966///   region.
1967/// * Then exactly `visible_rows` rows of editing content. Lines of
1968///   `value` between `[scroll_row, scroll_row + visible_rows)` are
1969///   rendered; rows beyond the value are blanks (padded so the
1970///   editing region's input-bg block keeps its rectangular shape).
1971/// * The editing region uses `field_width` columns when set; `0`
1972///   means "use up to `panel_width`". Long lines are truncated with
1973///   `…` at the right when they exceed the field width — this is
1974///   different from `TextInput`'s head-truncation, because the
1975///   cursor is no longer pinned to end-of-value (it can be
1976///   anywhere within multi-line content).
1977/// * When focused, every visible content row gets the
1978///   `ui.prompt_bg` overlay extended to the field width so the
1979///   editing region reads as a single block.
1980/// * Placeholder: shown on the *first* row only when unfocused and
1981///   `value` is empty.
1982///
1983/// Cursor: returns the visible row index (relative to `entries`)
1984/// and byte offset within that row's text. The auto-clamp policy:
1985/// keep the cursor's line in view by adjusting `scroll_row` when
1986/// the cursor's line falls outside `[scroll_row, scroll_row +
1987/// visible_rows)`.
1988#[allow(clippy::too_many_arguments)]
1989pub fn render_text_area(
1990    value: &str,
1991    cursor_byte: i32,
1992    focused: bool,
1993    label: &str,
1994    placeholder: Option<&str>,
1995    visible_rows: u32,
1996    field_width: u32,
1997    prev_scroll: u32,
1998    panel_width: u32,
1999) -> RenderedTextArea {
2000    // Resolve effective field width: caller's value if set, else
2001    // `panel_width` (or a small default if the panel is unsized).
2002    let target_width: usize = if field_width > 0 {
2003        field_width as usize
2004    } else if panel_width != u32::MAX && panel_width > 0 {
2005        panel_width as usize
2006    } else {
2007        40
2008    };
2009
2010    // Split value into lines (without the `\n`). Empty value still
2011    // produces one (empty) line — matching how a single-line
2012    // editor would treat an empty buffer.
2013    let mut lines: Vec<&str> = value.split('\n').collect();
2014    if lines.is_empty() {
2015        lines.push("");
2016    }
2017
2018    // Cursor → (line_index, byte_in_line). When `cursor_byte` is
2019    // negative (no cursor), we still compute a line for scroll
2020    // bookkeeping but don't emit a focus_cursor.
2021    let raw_cursor_byte = if cursor_byte < 0 {
2022        value.len()
2023    } else {
2024        (cursor_byte as usize).min(value.len())
2025    };
2026    let (cursor_line, cursor_col) = byte_to_line_col(value, raw_cursor_byte);
2027
2028    // Auto-clamp scroll: keep cursor's line in [scroll_row,
2029    // scroll_row + visible_rows). On first render, prev_scroll == 0.
2030    let visible_rows_usize = visible_rows.max(1) as usize;
2031    let mut scroll_row = prev_scroll as usize;
2032    if cursor_line < scroll_row {
2033        scroll_row = cursor_line;
2034    } else if cursor_line >= scroll_row + visible_rows_usize {
2035        scroll_row = cursor_line + 1 - visible_rows_usize;
2036    }
2037    // Don't scroll past the last line.
2038    let max_scroll = lines.len().saturating_sub(visible_rows_usize);
2039    if scroll_row > max_scroll {
2040        scroll_row = max_scroll;
2041    }
2042
2043    let show_placeholder =
2044        !focused && value.is_empty() && placeholder.is_some() && !placeholder.unwrap().is_empty();
2045
2046    let mut entries: Vec<TextPropertyEntry> = Vec::new();
2047    let mut cursor_buffer_row: Option<u32> = None;
2048    let mut cursor_byte_in_row: Option<usize> = None;
2049
2050    if !label.is_empty() {
2051        let mut text = String::with_capacity(label.len() + 2);
2052        text.push_str(label);
2053        text.push(':');
2054        entries.push(TextPropertyEntry {
2055            text,
2056            properties: Default::default(),
2057            style: None,
2058            inline_overlays: Vec::new(),
2059            segments: Vec::new(),
2060            pad_to_chars: None,
2061            truncate_to_chars: None,
2062        });
2063    }
2064    let label_offset: u32 = entries.len() as u32;
2065
2066    for row_in_view in 0..visible_rows_usize {
2067        let line_idx = scroll_row + row_in_view;
2068        let mut row_text;
2069        let mut overlays: Vec<InlineOverlay> = Vec::new();
2070
2071        if line_idx < lines.len() {
2072            row_text = pad_or_truncate_line(lines[line_idx], target_width);
2073        } else {
2074            row_text = " ".repeat(target_width);
2075        }
2076
2077        // Placeholder shows on the first row only.
2078        if show_placeholder && row_in_view == 0 {
2079            let ph = placeholder.unwrap();
2080            row_text = pad_or_truncate_line(ph, target_width);
2081            overlays.push(InlineOverlay {
2082                start: 0,
2083                end: row_text.len(),
2084                style: OverlayOptions {
2085                    fg: Some(OverlayColorSpec::theme_key(KEY_PLACEHOLDER_FG)),
2086                    ..Default::default()
2087                },
2088                properties: Default::default(),
2089                unit: OffsetUnit::Byte,
2090            });
2091        }
2092
2093        // Focused-bg covers the full row width — the editing
2094        // region reads as a single block.
2095        if focused {
2096            overlays.push(InlineOverlay {
2097                start: 0,
2098                end: row_text.len(),
2099                style: OverlayOptions {
2100                    bg: Some(OverlayColorSpec::theme_key(KEY_INPUT_BG)),
2101                    ..Default::default()
2102                },
2103                properties: Default::default(),
2104                unit: OffsetUnit::Byte,
2105            });
2106        }
2107
2108        // Drop the cursor on this row if it matches.
2109        if focused && line_idx == cursor_line && cursor_byte >= 0 {
2110            // The cursor's byte column on its line. If the line was
2111            // truncated, the cursor may have shifted past the
2112            // visible region — clamp to the last visible byte so
2113            // the hardware cursor stays in the row.
2114            let col_in_line = cursor_col.min(row_text.len());
2115            cursor_buffer_row = Some(label_offset + row_in_view as u32);
2116            cursor_byte_in_row = Some(col_in_line);
2117        }
2118
2119        entries.push(TextPropertyEntry {
2120            text: row_text,
2121            properties: Default::default(),
2122            style: None,
2123            inline_overlays: overlays,
2124            segments: Vec::new(),
2125            pad_to_chars: None,
2126            truncate_to_chars: None,
2127        });
2128    }
2129
2130    RenderedTextArea {
2131        entries,
2132        scroll_row: scroll_row as u32,
2133        cursor_buffer_row,
2134        cursor_byte_in_row,
2135    }
2136}
2137
2138/// Translate a byte offset in `value` to (line_index, byte_in_line).
2139fn byte_to_line_col(value: &str, byte: usize) -> (usize, usize) {
2140    let byte = byte.min(value.len());
2141    let mut line = 0usize;
2142    let mut line_start = 0usize;
2143    for (i, &b) in value.as_bytes().iter().enumerate().take(byte) {
2144        if b == b'\n' {
2145            line += 1;
2146            line_start = i + 1;
2147        }
2148    }
2149    (line, byte - line_start)
2150}
2151
2152/// Pad `line` with trailing spaces to `target` chars, or
2153/// tail-truncate with `…` if it overflows. Operates on chars to keep
2154/// the visual width predictable for ASCII; multibyte chars count as
2155/// one char each (terminal column width != char count for CJK, but
2156/// that's an acceptable v1 limitation matching `TextInput`).
2157fn pad_or_truncate_line(line: &str, target: usize) -> String {
2158    let chars: Vec<char> = line.chars().collect();
2159    if chars.len() <= target {
2160        let mut out = line.to_string();
2161        let pad = target - chars.len();
2162        for _ in 0..pad {
2163            out.push(' ');
2164        }
2165        out
2166    } else {
2167        let keep = target.saturating_sub(1);
2168        let mut out: String = chars.iter().take(keep).collect();
2169        out.push('…');
2170        out
2171    }
2172}
2173
2174/// Merge `next` into `merged` for the inline-row collapse path.
2175/// `next`'s overlays are byte-shifted to account for the merged
2176/// text length so far.
2177fn merge_inline(merged: &mut TextPropertyEntry, next: &mut TextPropertyEntry) {
2178    let shift = merged.text.len();
2179    merged.text.push_str(&next.text);
2180    for overlay in next.inline_overlays.drain(..) {
2181        merged.inline_overlays.push(InlineOverlay {
2182            start: overlay.start + shift,
2183            end: overlay.end + shift,
2184            style: overlay.style,
2185            properties: overlay.properties,
2186            unit: overlay.unit,
2187        });
2188    }
2189    // `style` and `properties` from `next` are dropped — Row inline
2190    // collapse only preserves inline_overlays. Whole-entry style on
2191    // an inline-row child has no meaningful semantics here; if a
2192    // plugin needs whole-line styling it should produce a Col with
2193    // the styled child as its sole element.
2194}
2195
2196/// Pad / truncate `text` to exactly `cols` display columns, in
2197/// place. Uses char count as the display-width approximation —
2198/// good for ASCII; wide-char-aware width would need
2199/// `unicode-width`, but no current caller relies on that.
2200fn pad_or_truncate_cols(text: &mut String, cols: usize) {
2201    let cur = text.chars().count();
2202    if cur < cols {
2203        for _ in 0..(cols - cur) {
2204            text.push(' ');
2205        }
2206    } else if cur > cols {
2207        let cutoff = text
2208            .char_indices()
2209            .nth(cols)
2210            .map(|(i, _)| i)
2211            .unwrap_or(text.len());
2212        text.truncate(cutoff);
2213    }
2214}
2215
2216/// Horizontal-zip pass for a Row that contains ≥1 multi-line
2217/// (Block) child. Each block has already been rendered with its
2218/// per-column budget (`block_width`); this helper walks the
2219/// row's pieces left-to-right per visual row and stitches them
2220/// into one merged line at a time.
2221///
2222/// Layout rules:
2223///   * Inline pieces sit at row 0 and become `chars().count()`
2224///     spaces on subsequent rows (so the right-hand block stays
2225///     aligned with its column).
2226///   * Block pieces contribute their `entries[row]` (or a blank
2227///     row of `block_width` spaces past their height).
2228///   * Flex pieces are intentionally a no-op in the block path —
2229///     `row(block, flexSpacer(), block)` is a rare shape and we
2230///     skip honouring flex here to keep the budget arithmetic
2231///     simple. Plugins that need a fixed gap should use
2232///     `spacer(n)` instead.
2233///
2234/// Hits and focus cursors get shifted by both the buffer-row
2235/// offset (which output line we're on) and the per-piece
2236/// byte-column offset (where in the merged text the piece
2237/// starts).
2238fn zip_row_blocks(
2239    pieces: Vec<RowPiece>,
2240    panel_width: u32,
2241    out_entries: &mut Vec<TextPropertyEntry>,
2242    out_hits: &mut Vec<HitArea>,
2243    out_focus_cursor: &mut Option<FocusCursor>,
2244    out_embeds: &mut Vec<EmbedRect>,
2245) {
2246    let starting_row = out_entries.len() as u32;
2247    let _ = panel_width;
2248
2249    // Compute the merged height = max(block.entries.len()).
2250    let max_height = pieces
2251        .iter()
2252        .filter_map(|p| match p {
2253            RowPiece::Block { entries, .. } => Some(entries.len()),
2254            _ => None,
2255        })
2256        .max()
2257        .unwrap_or(0);
2258    if max_height == 0 {
2259        return;
2260    }
2261
2262    for row_idx in 0..max_height {
2263        let mut text = String::new();
2264        let mut overlays: Vec<InlineOverlay> = Vec::new();
2265        for piece in &pieces {
2266            match piece {
2267                RowPiece::Inline {
2268                    entry,
2269                    hits,
2270                    focus_cursor,
2271                    embeds: inline_embeds,
2272                } => {
2273                    let inline_cols = entry.text.chars().count();
2274                    let byte_shift = text.len();
2275                    // Cumulative column width to the left of this
2276                    // piece, for embed positioning. Embeds are
2277                    // column-addressed, not byte-addressed.
2278                    let col_shift = text.chars().count() as u32;
2279                    if row_idx == 0 {
2280                        text.push_str(&entry.text);
2281                        for emb in inline_embeds {
2282                            out_embeds.push(EmbedRect {
2283                                window_id: emb.window_id,
2284                                buffer_row: starting_row + emb.buffer_row,
2285                                col_in_row: emb.col_in_row + col_shift,
2286                                width_cols: emb.width_cols,
2287                                height_rows: emb.height_rows,
2288                            });
2289                        }
2290                        for overlay in &entry.inline_overlays {
2291                            overlays.push(InlineOverlay {
2292                                start: overlay.start + byte_shift,
2293                                end: overlay.end + byte_shift,
2294                                style: overlay.style.clone(),
2295                                properties: overlay.properties.clone(),
2296                                unit: overlay.unit,
2297                            });
2298                        }
2299                        for h in hits {
2300                            let mut h = h.clone();
2301                            h.byte_start += byte_shift;
2302                            h.byte_end += byte_shift;
2303                            h.buffer_row = starting_row;
2304                            out_hits.push(h);
2305                        }
2306                        if let Some(fc) = focus_cursor {
2307                            *out_focus_cursor = Some(FocusCursor {
2308                                buffer_row: starting_row,
2309                                byte_in_row: fc.byte_in_row + byte_shift as u32,
2310                            });
2311                        }
2312                    } else {
2313                        for _ in 0..inline_cols {
2314                            text.push(' ');
2315                        }
2316                    }
2317                }
2318                RowPiece::Flex => {
2319                    // Skipped — see fn doc.
2320                }
2321                RowPiece::Block {
2322                    column_width,
2323                    entries,
2324                    hits,
2325                    focus_cursor,
2326                    embeds: block_embeds,
2327                } => {
2328                    let block_w = *column_width as usize;
2329                    let byte_shift = text.len();
2330                    // Cumulative column width to the left of this
2331                    // block, for embed positioning.
2332                    let col_shift = text.chars().count() as u32;
2333                    // Emit each embed exactly once, on the row
2334                    // where its top edge lands. The embed's
2335                    // buffer_row is relative to the block's row
2336                    // 0; absolute = starting_row + that.
2337                    if row_idx == 0 {
2338                        for emb in block_embeds {
2339                            out_embeds.push(EmbedRect {
2340                                window_id: emb.window_id,
2341                                buffer_row: starting_row + emb.buffer_row,
2342                                col_in_row: emb.col_in_row + col_shift,
2343                                width_cols: emb.width_cols,
2344                                height_rows: emb.height_rows,
2345                            });
2346                        }
2347                    }
2348                    if let Some(line) = entries.get(row_idx) {
2349                        let mut line_text = line.text.clone();
2350                        // Strip the entry's trailing newline so it
2351                        // doesn't split our merged line.
2352                        if line_text.ends_with('\n') {
2353                            line_text.pop();
2354                        }
2355                        let original_byte_len = line_text.len();
2356                        pad_or_truncate_cols(&mut line_text, block_w);
2357                        let padded_byte_len = line_text.len();
2358                        text.push_str(&line_text);
2359                        // Convert the entry's whole-line `style`
2360                        // into an inline overlay covering the
2361                        // block's column in the merged row. This is
2362                        // what carries through the list widget's
2363                        // selected-row bg (and any other
2364                        // whole-entry styling on individual block
2365                        // lines) — without it, the picker's
2366                        // selection highlight disappears in the
2367                        // zipped output.
2368                        if let Some(line_style) = &line.style {
2369                            overlays.push(InlineOverlay {
2370                                start: byte_shift,
2371                                end: byte_shift + padded_byte_len,
2372                                style: line_style.clone(),
2373                                properties: Default::default(),
2374                                unit: OffsetUnit::Byte,
2375                            });
2376                        }
2377                        for overlay in &line.inline_overlays {
2378                            // Overlays whose end exceeds the
2379                            // truncated byte length get clamped to
2380                            // the truncation point.
2381                            let new_end = overlay.end.min(original_byte_len);
2382                            if overlay.start >= original_byte_len {
2383                                continue;
2384                            }
2385                            overlays.push(InlineOverlay {
2386                                start: overlay.start + byte_shift,
2387                                end: new_end + byte_shift,
2388                                style: overlay.style.clone(),
2389                                properties: overlay.properties.clone(),
2390                                unit: overlay.unit,
2391                            });
2392                        }
2393                        for h in hits {
2394                            if h.buffer_row != row_idx as u32 {
2395                                continue;
2396                            }
2397                            let mut h = h.clone();
2398                            h.byte_start += byte_shift;
2399                            h.byte_end += byte_shift;
2400                            h.buffer_row = starting_row + row_idx as u32;
2401                            out_hits.push(h);
2402                        }
2403                        if let Some(fc) = focus_cursor {
2404                            if fc.buffer_row == row_idx as u32 {
2405                                *out_focus_cursor = Some(FocusCursor {
2406                                    buffer_row: starting_row + row_idx as u32,
2407                                    byte_in_row: fc.byte_in_row + byte_shift as u32,
2408                                });
2409                            }
2410                        }
2411                    } else {
2412                        // Past this block's height — emit a blank
2413                        // column of `block_w` spaces.
2414                        for _ in 0..block_w {
2415                            text.push(' ');
2416                        }
2417                    }
2418                }
2419            }
2420        }
2421        text.push('\n');
2422        out_entries.push(TextPropertyEntry {
2423            text,
2424            properties: Default::default(),
2425            style: None,
2426            inline_overlays: overlays,
2427            segments: Vec::new(),
2428            pad_to_chars: None,
2429            truncate_to_chars: None,
2430        });
2431    }
2432}
2433
2434#[cfg(test)]
2435mod tests {
2436    use super::*;
2437
2438    /// Most existing tests don't care about the new focus_key /
2439    /// tabbable fields. Wrap the no-focus-needed render path so
2440    /// they keep destructuring a 3-tuple; new tests destructure
2441    /// `RenderOutput` directly.
2442    fn render_no_focus(
2443        spec: &WidgetSpec,
2444        prev: &HashMap<String, WidgetInstanceState>,
2445    ) -> (
2446        Vec<TextPropertyEntry>,
2447        Vec<HitArea>,
2448        HashMap<String, WidgetInstanceState>,
2449    ) {
2450        // u32::MAX disables flex sizing (no leftover to distribute).
2451        let out = render_spec(spec, prev, "", u32::MAX);
2452        (out.entries, out.hits, out.instance_states)
2453    }
2454
2455    #[test]
2456    fn hint_bar_renders_entries_with_key_overlays() {
2457        let entries = vec![
2458            HintEntry {
2459                keys: "Tab".into(),
2460                label: "next".into(),
2461            },
2462            HintEntry {
2463                keys: "Esc".into(),
2464                label: "close".into(),
2465            },
2466        ];
2467        let entry = render_hint_bar(&entries);
2468        assert_eq!(entry.text, "Tab next  Esc close");
2469        assert_eq!(entry.inline_overlays.len(), 2);
2470        // First overlay covers "Tab" (bytes 0..3).
2471        assert_eq!(entry.inline_overlays[0].start, 0);
2472        assert_eq!(entry.inline_overlays[0].end, 3);
2473        // Second overlay covers "Esc" (bytes 10..13).
2474        assert_eq!(entry.inline_overlays[1].start, 10);
2475        assert_eq!(entry.inline_overlays[1].end, 13);
2476    }
2477
2478    #[test]
2479    fn hint_bar_omits_label_when_empty() {
2480        let entries = vec![HintEntry {
2481            keys: "?".into(),
2482            label: "".into(),
2483        }];
2484        let entry = render_hint_bar(&entries);
2485        assert_eq!(entry.text, "?");
2486    }
2487
2488    #[test]
2489    fn col_stacks_children_top_to_bottom() {
2490        let spec = WidgetSpec::Col {
2491            children: vec![
2492                WidgetSpec::HintBar {
2493                    entries: vec![HintEntry {
2494                        keys: "A".into(),
2495                        label: "alpha".into(),
2496                    }],
2497                    key: None,
2498                },
2499                WidgetSpec::HintBar {
2500                    entries: vec![HintEntry {
2501                        keys: "B".into(),
2502                        label: "beta".into(),
2503                    }],
2504                    key: None,
2505                },
2506            ],
2507            key: None,
2508        };
2509        let (out, hits, _state) = render_no_focus(&spec, &HashMap::new());
2510        assert_eq!(out.len(), 2);
2511        assert_eq!(out[0].text, "A alpha\n");
2512        assert_eq!(out[1].text, "B beta\n");
2513        assert!(hits.is_empty(), "HintBar emits no hit areas in v1");
2514    }
2515
2516    #[test]
2517    fn raw_passes_through_unchanged() {
2518        let spec = WidgetSpec::Raw {
2519            entries: vec![TextPropertyEntry::text("hello")],
2520            key: None,
2521        };
2522        let (out, hits, _state) = render_no_focus(&spec, &HashMap::new());
2523        assert_eq!(out.len(), 1);
2524        assert_eq!(out[0].text, "hello\n");
2525        assert!(hits.is_empty());
2526    }
2527
2528    #[test]
2529    fn toggle_checked_emits_glyph_overlay() {
2530        let entry = render_toggle(true, "Case", false);
2531        assert_eq!(entry.text, "[v] Case");
2532        // One overlay for the glyph, no focused overlay.
2533        assert_eq!(entry.inline_overlays.len(), 1);
2534        assert_eq!(entry.inline_overlays[0].start, 0);
2535        assert_eq!(entry.inline_overlays[0].end, 3);
2536    }
2537
2538    #[test]
2539    fn toggle_unchecked_no_glyph_overlay() {
2540        let entry = render_toggle(false, "Case", false);
2541        assert_eq!(entry.text, "[ ] Case");
2542        assert_eq!(entry.inline_overlays.len(), 0);
2543    }
2544
2545    #[test]
2546    fn toggle_focused_adds_full_entry_overlay() {
2547        let entry = render_toggle(true, "Case", true);
2548        // Glyph overlay + focused overlay.
2549        assert_eq!(entry.inline_overlays.len(), 2);
2550        // Focused overlay spans the full entry.
2551        assert_eq!(entry.inline_overlays[1].start, 0);
2552        assert_eq!(entry.inline_overlays[1].end, entry.text.len());
2553        assert!(entry.inline_overlays[1].style.bold);
2554    }
2555
2556    #[test]
2557    fn button_normal_unfocused_has_no_overlay() {
2558        let entry = render_button("Replace All", false, ButtonKind::Normal);
2559        assert_eq!(entry.text, "[ Replace All ]");
2560        assert!(entry.inline_overlays.is_empty());
2561    }
2562
2563    #[test]
2564    fn button_primary_unfocused_is_bold_help_key_fg_with_no_bg() {
2565        // Primary marks the "good" action with a bold, strong fg
2566        // on the surrounding surface. Only the focused state
2567        // paints a backing colour — verified in
2568        // `button_focused_overrides_with_menu_active_keys`.
2569        let entry = render_button("Submit", false, ButtonKind::Primary);
2570        assert_eq!(entry.inline_overlays.len(), 1);
2571        let style = &entry.inline_overlays[0].style;
2572        assert!(style.bold);
2573        assert_eq!(
2574            style.fg.as_ref().and_then(|c| c.as_theme_key()),
2575            Some("ui.help_key_fg"),
2576        );
2577        assert!(style.bg.is_none(), "unfocused primary must not paint a bg");
2578    }
2579
2580    #[test]
2581    fn button_danger_uses_error_theme_key() {
2582        let entry = render_button("Delete", false, ButtonKind::Danger);
2583        assert_eq!(entry.inline_overlays.len(), 1);
2584        let fg = entry.inline_overlays[0].style.fg.as_ref().unwrap();
2585        assert_eq!(fg.as_theme_key(), Some("diagnostic.error_fg"));
2586        assert!(entry.inline_overlays[0].style.bold);
2587    }
2588
2589    #[test]
2590    fn button_focused_overrides_with_menu_active_keys() {
2591        let entry = render_button("OK", true, ButtonKind::Normal);
2592        let style = &entry.inline_overlays[0].style;
2593        assert_eq!(
2594            style.fg.as_ref().and_then(|c| c.as_theme_key()),
2595            Some("ui.menu_active_fg")
2596        );
2597        assert_eq!(
2598            style.bg.as_ref().and_then(|c| c.as_theme_key()),
2599            Some("ui.menu_active_bg")
2600        );
2601        assert!(style.bold);
2602    }
2603
2604    #[test]
2605    fn flex_spacer_fills_remaining_row_width() {
2606        let spec = WidgetSpec::Row {
2607            children: vec![
2608                WidgetSpec::Toggle {
2609                    checked: false,
2610                    label: "A".into(),
2611                    focused: false,
2612                    key: None,
2613                },
2614                WidgetSpec::Spacer {
2615                    cols: 0,
2616                    flex: true,
2617                    key: None,
2618                },
2619                WidgetSpec::Button {
2620                    label: "B".into(),
2621                    focused: false,
2622                    intent: ButtonKind::Normal,
2623                    key: None,
2624                },
2625            ],
2626            key: None,
2627        };
2628        // Toggle "[ ] A" = 5 bytes; Button "[ B ]" = 5 bytes;
2629        // panel_width = 30 → flex fills 20 spaces. Plus a trailing
2630        // newline added by the Row's terminator.
2631        let out = render_spec(&spec, &HashMap::new(), "", 30);
2632        assert_eq!(out.entries.len(), 1);
2633        let text = &out.entries[0].text;
2634        assert_eq!(text.len(), 31);
2635        assert!(text.starts_with("[ ] A"));
2636        assert!(text.ends_with("[ B ]\n"));
2637        let button_hit = out.hits.iter().find(|h| h.widget_kind == "button").unwrap();
2638        assert_eq!(button_hit.byte_start, 25);
2639        assert_eq!(button_hit.byte_end, 30);
2640    }
2641
2642    #[test]
2643    fn flex_spacer_with_no_leftover_collapses_to_zero() {
2644        let spec = WidgetSpec::Row {
2645            children: vec![
2646                WidgetSpec::Toggle {
2647                    checked: false,
2648                    label: "A".into(),
2649                    focused: false,
2650                    key: None,
2651                },
2652                WidgetSpec::Spacer {
2653                    cols: 0,
2654                    flex: true,
2655                    key: None,
2656                },
2657                WidgetSpec::Toggle {
2658                    checked: false,
2659                    label: "B".into(),
2660                    focused: false,
2661                    key: None,
2662                },
2663            ],
2664            key: None,
2665        };
2666        // Both toggles use 5+5=10 bytes; panel_width=10 → flex=0.
2667        let out = render_spec(&spec, &HashMap::new(), "", 10);
2668        assert_eq!(out.entries[0].text, "[ ] A[ ] B\n");
2669    }
2670
2671    #[test]
2672    fn spacer_in_row_pads_with_spaces() {
2673        let spec = WidgetSpec::Row {
2674            children: vec![
2675                WidgetSpec::Toggle {
2676                    checked: false,
2677                    label: "A".into(),
2678                    focused: false,
2679                    key: None,
2680                },
2681                WidgetSpec::Spacer {
2682                    cols: 4,
2683                    flex: false,
2684                    key: None,
2685                },
2686                WidgetSpec::Button {
2687                    label: "Go".into(),
2688                    focused: false,
2689                    intent: ButtonKind::Normal,
2690                    key: None,
2691                },
2692            ],
2693            key: None,
2694        };
2695        let (out, _hits, _state) = render_no_focus(&spec, &HashMap::new());
2696        assert_eq!(out.len(), 1);
2697        assert_eq!(out[0].text, "[ ] A    [ Go ]\n");
2698    }
2699
2700    #[test]
2701    fn row_collapses_inline_children_with_shifted_overlays() {
2702        let spec = WidgetSpec::Row {
2703            children: vec![
2704                WidgetSpec::HintBar {
2705                    entries: vec![HintEntry {
2706                        keys: "Tab".into(),
2707                        label: "x".into(),
2708                    }],
2709                    key: None,
2710                },
2711                WidgetSpec::HintBar {
2712                    entries: vec![HintEntry {
2713                        keys: "Esc".into(),
2714                        label: "y".into(),
2715                    }],
2716                    key: None,
2717                },
2718            ],
2719            key: None,
2720        };
2721        let (out, _hits, _state) = render_no_focus(&spec, &HashMap::new());
2722        assert_eq!(out.len(), 1);
2723        // Two adjacent HintBars are concatenated; the second's overlay shifts.
2724        assert_eq!(out[0].text, "Tab xEsc y\n");
2725        assert_eq!(out[0].inline_overlays.len(), 2);
2726        assert_eq!(out[0].inline_overlays[1].start, 5);
2727        assert_eq!(out[0].inline_overlays[1].end, 8);
2728    }
2729
2730    // -------------------------------------------------------------
2731    // Hit-area tests
2732    // -------------------------------------------------------------
2733
2734    #[test]
2735    fn toggle_emits_hit_area_with_toggle_payload() {
2736        let spec = WidgetSpec::Toggle {
2737            checked: false,
2738            label: "Case".into(),
2739            focused: false,
2740            key: Some("case".into()),
2741        };
2742        let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
2743        assert_eq!(hits.len(), 1);
2744        let h = &hits[0];
2745        assert_eq!(h.widget_key, "case");
2746        assert_eq!(h.widget_kind, "toggle");
2747        assert_eq!(h.event_type, "toggle");
2748        assert_eq!(h.buffer_row, 0);
2749        assert_eq!(h.byte_start, 0);
2750        assert_eq!(h.byte_end, "[ ] Case".len());
2751        assert_eq!(h.payload, json!({"checked": true}));
2752    }
2753
2754    #[test]
2755    fn button_emits_hit_area_with_activate_payload() {
2756        let spec = WidgetSpec::Button {
2757            label: "Replace All".into(),
2758            focused: false,
2759            intent: ButtonKind::Primary,
2760            key: Some("replace".into()),
2761        };
2762        let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
2763        assert_eq!(hits.len(), 1);
2764        let h = &hits[0];
2765        assert_eq!(h.widget_key, "replace");
2766        assert_eq!(h.widget_kind, "button");
2767        assert_eq!(h.event_type, "activate");
2768        assert_eq!(h.byte_end, "[ Replace All ]".len());
2769        assert_eq!(h.payload, json!({}));
2770    }
2771
2772    #[test]
2773    fn row_inline_collapse_shifts_hit_byte_offsets() {
2774        let spec = WidgetSpec::Row {
2775            children: vec![
2776                WidgetSpec::Toggle {
2777                    checked: true,
2778                    label: "A".into(),
2779                    focused: false,
2780                    key: Some("a".into()),
2781                },
2782                WidgetSpec::Spacer {
2783                    cols: 2,
2784                    flex: false,
2785                    key: None,
2786                },
2787                WidgetSpec::Toggle {
2788                    checked: false,
2789                    label: "B".into(),
2790                    focused: false,
2791                    key: Some("b".into()),
2792                },
2793            ],
2794            key: None,
2795        };
2796        let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
2797        // One merged row with text "[v] A  [ ] B"
2798        assert_eq!(entries.len(), 1);
2799        assert_eq!(entries[0].text, "[v] A  [ ] B\n");
2800        assert_eq!(hits.len(), 2);
2801        assert_eq!(hits[0].widget_key, "a");
2802        assert_eq!(hits[0].buffer_row, 0);
2803        assert_eq!(hits[0].byte_start, 0);
2804        assert_eq!(hits[0].byte_end, 5); // "[v] A".len()
2805                                         // Second toggle shifts past first toggle ("[v] A".len() = 5)
2806                                         // + spacer ("  ".len() = 2) = 7.
2807        assert_eq!(hits[1].widget_key, "b");
2808        assert_eq!(hits[1].buffer_row, 0);
2809        assert_eq!(hits[1].byte_start, 7);
2810        assert_eq!(hits[1].byte_end, 12);
2811    }
2812
2813    #[test]
2814    fn col_stacks_hit_rows() {
2815        let spec = WidgetSpec::Col {
2816            children: vec![
2817                WidgetSpec::Toggle {
2818                    checked: false,
2819                    label: "row0".into(),
2820                    focused: false,
2821                    key: Some("k0".into()),
2822                },
2823                WidgetSpec::Toggle {
2824                    checked: true,
2825                    label: "row1".into(),
2826                    focused: false,
2827                    key: Some("k1".into()),
2828                },
2829            ],
2830            key: None,
2831        };
2832        let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
2833        assert_eq!(hits.len(), 2);
2834        assert_eq!(hits[0].buffer_row, 0);
2835        assert_eq!(hits[1].buffer_row, 1);
2836    }
2837
2838    // -------------------------------------------------------------
2839    // Focus management
2840    // -------------------------------------------------------------
2841
2842    #[test]
2843    fn collect_tabbable_visits_widgets_with_keys_in_declaration_order() {
2844        let spec = WidgetSpec::Col {
2845            children: vec![
2846                WidgetSpec::HintBar {
2847                    entries: vec![],
2848                    key: Some("hb".into()),
2849                },
2850                WidgetSpec::Row {
2851                    children: vec![
2852                        WidgetSpec::Toggle {
2853                            checked: false,
2854                            label: "T".into(),
2855                            focused: false,
2856                            key: Some("t".into()),
2857                        },
2858                        WidgetSpec::Spacer {
2859                            cols: 1,
2860                            flex: false,
2861                            key: None,
2862                        },
2863                        WidgetSpec::Button {
2864                            label: "B".into(),
2865                            focused: false,
2866                            intent: ButtonKind::Normal,
2867                            key: Some("b".into()),
2868                        },
2869                    ],
2870                    key: None,
2871                },
2872                WidgetSpec::Text {
2873                    value: "".into(),
2874                    cursor_byte: -1,
2875                    focused: false,
2876                    label: "".into(),
2877                    placeholder: None,
2878                    rows: 1,
2879                    field_width: 0,
2880                    max_visible_chars: 0,
2881                    full_width: false,
2882                    key: Some("ti".into()),
2883                },
2884                WidgetSpec::Toggle {
2885                    checked: false,
2886                    label: "no key".into(),
2887                    focused: false,
2888                    key: None,
2889                },
2890            ],
2891            key: None,
2892        };
2893        let mut tabbable = Vec::new();
2894        collect_tabbable(&spec, &mut tabbable);
2895        // HintBar without a key isn't tabbable; tabbables are
2896        // Toggle/Button/TextInput/List with non-empty keys.
2897        assert_eq!(tabbable, vec!["t", "b", "ti"]);
2898    }
2899
2900    #[test]
2901    fn first_render_focuses_first_tabbable() {
2902        let spec = WidgetSpec::Row {
2903            children: vec![
2904                WidgetSpec::Toggle {
2905                    checked: false,
2906                    label: "A".into(),
2907                    focused: false,
2908                    key: Some("a".into()),
2909                },
2910                WidgetSpec::Toggle {
2911                    checked: false,
2912                    label: "B".into(),
2913                    focused: false,
2914                    key: Some("b".into()),
2915                },
2916            ],
2917            key: None,
2918        };
2919        let out = render_spec(&spec, &HashMap::new(), "", u32::MAX);
2920        assert_eq!(out.focus_key, "a");
2921        assert_eq!(out.tabbable, vec!["a", "b"]);
2922    }
2923
2924    #[test]
2925    fn render_preserves_focus_key_across_re_renders() {
2926        let spec = WidgetSpec::Row {
2927            children: vec![
2928                WidgetSpec::Toggle {
2929                    checked: false,
2930                    label: "A".into(),
2931                    focused: false,
2932                    key: Some("a".into()),
2933                },
2934                WidgetSpec::Toggle {
2935                    checked: false,
2936                    label: "B".into(),
2937                    focused: false,
2938                    key: Some("b".into()),
2939                },
2940            ],
2941            key: None,
2942        };
2943        let out = render_spec(&spec, &HashMap::new(), "b", u32::MAX);
2944        assert_eq!(out.focus_key, "b");
2945    }
2946
2947    #[test]
2948    fn render_clamps_stale_focus_key_to_first_tabbable() {
2949        // Previous render focused "stale", but the new spec doesn't
2950        // have any widget with that key — fall back to the first
2951        // tabbable.
2952        let spec = WidgetSpec::Toggle {
2953            checked: false,
2954            label: "Only".into(),
2955            focused: false,
2956            key: Some("only".into()),
2957        };
2958        let out = render_spec(&spec, &HashMap::new(), "stale", u32::MAX);
2959        assert_eq!(out.focus_key, "only");
2960    }
2961
2962    #[test]
2963    fn focused_widget_renders_with_focused_styling() {
2964        let spec = WidgetSpec::Row {
2965            children: vec![
2966                WidgetSpec::Toggle {
2967                    checked: false,
2968                    label: "A".into(),
2969                    focused: false,
2970                    key: Some("a".into()),
2971                },
2972                WidgetSpec::Toggle {
2973                    checked: false,
2974                    label: "B".into(),
2975                    focused: false,
2976                    key: Some("b".into()),
2977                },
2978            ],
2979            key: None,
2980        };
2981        let out = render_spec(&spec, &HashMap::new(), "b", u32::MAX);
2982        assert_eq!(out.entries.len(), 1, "row collapses inline");
2983        // Two overlays expected from the focused B: one for B's
2984        // glyph (none, since unchecked) — actually unchecked emits
2985        // no glyph overlay. So only the focused-style overlay.
2986        // Find the focused overlay by its menu_active_bg key.
2987        let entry = &out.entries[0];
2988        let focused_overlay = entry
2989            .inline_overlays
2990            .iter()
2991            .find(|o| {
2992                o.style.bg.as_ref().and_then(|c| c.as_theme_key()) == Some("ui.menu_active_bg")
2993            })
2994            .expect("focused overlay present on B");
2995        // B's text is "[ ] B", starting after "[ ] A".len()==5 + spacer 0 (no spacer here).
2996        // Inline collapse: A is "[ ] A" then immediately "[ ] B" = 10 bytes.
2997        assert_eq!(focused_overlay.start, 5);
2998        assert_eq!(focused_overlay.end, 10);
2999    }
3000
3001    #[test]
3002    fn no_tabbables_yields_empty_focus_key() {
3003        let spec = WidgetSpec::Col {
3004            children: vec![WidgetSpec::HintBar {
3005                entries: vec![],
3006                key: None,
3007            }],
3008            key: None,
3009        };
3010        let out = render_spec(&spec, &HashMap::new(), "", u32::MAX);
3011        assert_eq!(out.focus_key, "");
3012        assert!(out.tabbable.is_empty());
3013    }
3014
3015    // -------------------------------------------------------------
3016    // List
3017    // -------------------------------------------------------------
3018
3019    #[test]
3020    fn list_emits_one_entry_and_one_hit_per_item() {
3021        let spec = WidgetSpec::List {
3022            items: vec![
3023                TextPropertyEntry::text("alpha"),
3024                TextPropertyEntry::text("beta"),
3025                TextPropertyEntry::text("gamma"),
3026            ],
3027            item_keys: vec!["a".into(), "b".into(), "c".into()],
3028            selected_index: -1,
3029            visible_rows: 10,
3030            focusable: true,
3031            key: None,
3032        };
3033        let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
3034        assert_eq!(entries.len(), 3);
3035        assert_eq!(hits.len(), 3);
3036        for (i, h) in hits.iter().enumerate() {
3037            assert_eq!(h.buffer_row, i as u32);
3038            assert_eq!(h.widget_kind, "list");
3039            assert_eq!(h.event_type, "select");
3040            assert_eq!(h.payload["index"], i);
3041        }
3042        assert_eq!(hits[0].widget_key, "a");
3043        assert_eq!(hits[2].widget_key, "c");
3044    }
3045
3046    #[test]
3047    fn list_applies_selection_bg_to_selected_row() {
3048        let spec = WidgetSpec::List {
3049            items: vec![
3050                TextPropertyEntry::text("first"),
3051                TextPropertyEntry::text("second"),
3052            ],
3053            item_keys: vec!["x".into(), "y".into()],
3054            selected_index: 1,
3055            visible_rows: 10,
3056            focusable: true,
3057            key: None,
3058        };
3059        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3060        assert!(entries[0].style.is_none(), "unselected row keeps no style");
3061        let style = entries[1].style.as_ref().expect("selected row gets style");
3062        assert_eq!(
3063            style.bg.as_ref().and_then(|c| c.as_theme_key()),
3064            Some("ui.menu_active_bg"),
3065        );
3066        assert!(style.extend_to_line_end);
3067    }
3068
3069    #[test]
3070    fn list_inside_col_offsets_hit_rows_by_preceding_lines() {
3071        let spec = WidgetSpec::Col {
3072            children: vec![
3073                WidgetSpec::HintBar {
3074                    entries: vec![HintEntry {
3075                        keys: "h".into(),
3076                        label: "header".into(),
3077                    }],
3078                    key: None,
3079                },
3080                WidgetSpec::List {
3081                    items: vec![
3082                        TextPropertyEntry::text("row0"),
3083                        TextPropertyEntry::text("row1"),
3084                    ],
3085                    item_keys: vec!["a".into(), "b".into()],
3086                    selected_index: -1,
3087                    visible_rows: 10,
3088                    key: None,
3089                    focusable: true,
3090                },
3091            ],
3092            key: None,
3093        };
3094        let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
3095        assert_eq!(entries.len(), 3);
3096        assert_eq!(hits.len(), 2);
3097        // List rows land at buffer_row 1 and 2 (after the HintBar).
3098        assert_eq!(hits[0].buffer_row, 1);
3099        assert_eq!(hits[1].buffer_row, 2);
3100    }
3101
3102    #[test]
3103    fn list_payload_includes_absolute_index_and_key() {
3104        let spec = WidgetSpec::List {
3105            items: vec![TextPropertyEntry::text("only")],
3106            item_keys: vec!["match:42".into()],
3107            selected_index: 0,
3108            visible_rows: 10,
3109            focusable: true,
3110            key: None,
3111        };
3112        let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
3113        assert_eq!(hits[0].payload["index"], 0);
3114        assert_eq!(hits[0].payload["key"], "match:42");
3115    }
3116
3117    #[test]
3118    fn list_with_missing_key_emits_empty_widget_key() {
3119        let spec = WidgetSpec::List {
3120            items: vec![TextPropertyEntry::text("a"), TextPropertyEntry::text("b")],
3121            // Only one key for two items — second hit gets an empty key.
3122            item_keys: vec!["only".into()],
3123            selected_index: -1,
3124            visible_rows: 10,
3125            focusable: true,
3126            key: None,
3127        };
3128        let (_, hits, _state) = render_no_focus(&spec, &HashMap::new());
3129        assert_eq!(hits[0].widget_key, "only");
3130        assert_eq!(hits[1].widget_key, "");
3131    }
3132
3133    fn make_list(selected: i32, visible: u32, total: usize, key: Option<&str>) -> WidgetSpec {
3134        let items = (0..total)
3135            .map(|i| TextPropertyEntry::text(format!("row{}", i)))
3136            .collect();
3137        let item_keys = (0..total).map(|i| format!("k{}", i)).collect();
3138        WidgetSpec::List {
3139            items,
3140            item_keys,
3141            selected_index: selected,
3142            visible_rows: visible,
3143            focusable: true,
3144            key: key.map(|s| s.to_string()),
3145        }
3146    }
3147
3148    #[test]
3149    fn list_renders_only_visible_window() {
3150        let spec = make_list(-1, 3, 10, Some("L"));
3151        let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
3152        assert_eq!(entries.len(), 3);
3153        assert_eq!(hits.len(), 3);
3154        // First three items, absolute indices 0..2.
3155        assert_eq!(hits[0].payload["index"], 0);
3156        assert_eq!(hits[2].payload["index"], 2);
3157    }
3158
3159    #[test]
3160    fn list_scrolls_to_keep_selected_below_window_in_view() {
3161        // 10 items, visible=3, select index 5: scroll should be 3
3162        // (so selected lands at the bottom of the window). On
3163        // *first* render (empty prev), the spec's selected_index
3164        // seeds instance state.
3165        let spec = make_list(5, 3, 10, Some("L"));
3166        let (_entries, hits, state) = render_no_focus(&spec, &HashMap::new());
3167        // Visible window is items 3..6 → hits index 3, 4, 5.
3168        assert_eq!(hits.len(), 3);
3169        assert_eq!(hits[0].payload["index"], 3);
3170        assert_eq!(hits[2].payload["index"], 5);
3171        let scroll = match state.get("L").unwrap() {
3172            WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
3173            _ => unreachable!(),
3174        };
3175        assert_eq!(scroll, 3);
3176    }
3177
3178    #[test]
3179    fn list_scrolls_to_keep_selected_above_window_in_view() {
3180        // Previous render scrolled to 5 with selection at 5; user
3181        // pressed Up enough times that select_move set instance
3182        // state's selection to 1; renderer should scroll back up
3183        // to 1. (Spec's selected_index is initial-only; instance
3184        // state is authoritative once present.)
3185        let mut prev = HashMap::new();
3186        prev.insert(
3187            "L".into(),
3188            WidgetInstanceState::List {
3189                scroll_offset: 5,
3190                selected_index: 1,
3191            },
3192        );
3193        // Spec's selected_index doesn't matter (instance state wins).
3194        let spec = make_list(99, 3, 10, Some("L"));
3195        let (_entries, hits, state) = render_no_focus(&spec, &prev);
3196        assert_eq!(hits[0].payload["index"], 1);
3197        let scroll = match state.get("L").unwrap() {
3198            WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
3199            _ => unreachable!(),
3200        };
3201        assert_eq!(scroll, 1);
3202    }
3203
3204    #[test]
3205    fn list_scroll_preserved_when_selection_remains_in_view() {
3206        // Previous render scrolled to 4 with selection at 4; user
3207        // moved selection to 5 (still in window 4..6); scroll stays.
3208        let mut prev = HashMap::new();
3209        prev.insert(
3210            "L".into(),
3211            WidgetInstanceState::List {
3212                scroll_offset: 4,
3213                selected_index: 5,
3214            },
3215        );
3216        let spec = make_list(99, 3, 10, Some("L"));
3217        let (_entries, hits, state) = render_no_focus(&spec, &prev);
3218        assert_eq!(hits[0].payload["index"], 4);
3219        let scroll = match state.get("L").unwrap() {
3220            WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
3221            _ => unreachable!(),
3222        };
3223        assert_eq!(scroll, 4);
3224    }
3225
3226    #[test]
3227    fn list_clamps_scroll_to_max_when_dataset_is_smaller_than_old_offset() {
3228        // Previous scroll past the end of a now-shorter dataset
3229        // clamps to max_scroll = total - visible.
3230        let mut prev = HashMap::new();
3231        prev.insert(
3232            "L".into(),
3233            WidgetInstanceState::List {
3234                scroll_offset: 8,
3235                selected_index: -1,
3236            },
3237        );
3238        let spec = make_list(-1, 3, 5, Some("L"));
3239        let (entries, _hits, state) = render_no_focus(&spec, &prev);
3240        assert_eq!(entries.len(), 3);
3241        let scroll = match state.get("L").unwrap() {
3242            WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
3243            _ => unreachable!(),
3244        };
3245        // total=5, visible=3 → max=2.
3246        assert_eq!(scroll, 2);
3247    }
3248
3249    #[test]
3250    fn list_does_not_scroll_when_total_smaller_than_visible() {
3251        let spec = make_list(-1, 10, 3, Some("L"));
3252        let (entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
3253        assert_eq!(entries.len(), 3, "all items fit");
3254        let scroll = match state.get("L").unwrap() {
3255            WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
3256            _ => unreachable!(),
3257        };
3258        assert_eq!(scroll, 0);
3259    }
3260
3261    #[test]
3262    fn list_without_key_does_not_persist_state() {
3263        let spec = make_list(5, 3, 10, None);
3264        let (_entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
3265        assert!(
3266            state.is_empty(),
3267            "Lists without a `key` opt out of state preservation"
3268        );
3269    }
3270
3271    // -------------------------------------------------------------
3272    // TextInput
3273    // -------------------------------------------------------------
3274
3275    #[test]
3276    fn text_input_renders_value_in_brackets() {
3277        let entry = render_text_input("hello", -1, false, "", None, 0, 0, false).entry;
3278        assert_eq!(entry.text, "[hello]");
3279        assert!(entry.inline_overlays.is_empty());
3280    }
3281
3282    #[test]
3283    fn text_input_with_label_prefixes_with_label_space() {
3284        let entry = render_text_input("foo", -1, false, "Search:", None, 0, 0, false).entry;
3285        assert_eq!(entry.text, "Search: [foo]");
3286    }
3287
3288    #[test]
3289    fn text_input_focused_adds_input_bg_overlay() {
3290        let entry = render_text_input("x", -1, true, "", None, 0, 0, false).entry;
3291        // Focused → input-bg overlay (no cursor since cursor_byte < 0).
3292        assert_eq!(entry.inline_overlays.len(), 1);
3293        let bg = entry.inline_overlays[0].style.bg.as_ref().unwrap();
3294        assert_eq!(bg.as_theme_key(), Some("ui.prompt_bg"));
3295    }
3296
3297    #[test]
3298    fn text_input_cursor_byte_in_entry_at_value_position() {
3299        // Cursor mid-value: returned byte points at the position
3300        // *within entry.text*. text = "[abc ]" (focused → trailing
3301        // pad space). 'a' at byte 1, 'b' at 2, 'c' at 3 — so a
3302        // cursor at value-byte 1 lands at entry-byte 2.
3303        let r = render_text_input("abc", 1, true, "", None, 0, 0, false);
3304        assert_eq!(r.cursor_byte_in_entry, Some(2));
3305    }
3306
3307    #[test]
3308    fn text_input_cursor_at_end_lands_on_padding_space_not_bracket() {
3309        // Cursor at end-of-value: with focused + no field_width,
3310        // a trailing pad space is appended so the cursor never
3311        // overlaps the closing bracket. text = "[ab ]" → cursor
3312        // at value-byte 2 lands at entry-byte 3 (the space), not
3313        // at byte 4 (the `]`).
3314        let r = render_text_input("ab", 2, true, "", None, 0, 0, false);
3315        assert_eq!(r.entry.text, "[ab ]");
3316        assert_eq!(r.cursor_byte_in_entry, Some(3));
3317        assert_ne!(r.cursor_byte_in_entry, Some(4), "must not overlap ]");
3318    }
3319
3320    #[test]
3321    fn text_input_unfocused_empty_shows_placeholder_in_muted() {
3322        let entry = render_text_input("", -1, false, "", Some("type here"), 0, 0, false).entry;
3323        assert_eq!(entry.text, "[type here]");
3324        // Placeholder gets a muted-fg italic overlay.
3325        let placeholder_overlay = entry
3326            .inline_overlays
3327            .iter()
3328            .find(|o| o.style.fg.as_ref().and_then(|c| c.as_theme_key()).is_some())
3329            .expect("placeholder fg overlay");
3330        let fg = placeholder_overlay.style.fg.as_ref().unwrap();
3331        assert_eq!(fg.as_theme_key(), Some("editor.whitespace_indicator_fg"));
3332        assert!(placeholder_overlay.style.italic);
3333    }
3334
3335    #[test]
3336    fn text_input_focused_empty_still_shows_placeholder() {
3337        // New behaviour: placeholder remains visible while focused
3338        // until the user types something. Cursor parks at byte 0
3339        // of the placeholder so the first keystroke replaces it.
3340        let r = render_text_input("", -1, true, "", Some("type here"), 0, 0, false);
3341        assert_eq!(r.entry.text, "[type here]");
3342        assert_eq!(r.cursor_byte_in_entry, Some(1));
3343    }
3344
3345    #[test]
3346    fn text_input_field_width_pads_short_value_unfocused() {
3347        // field_width=10, unfocused, not full_width → inner is 10
3348        // chars (no extra cursor-park pad).
3349        let r = render_text_input("hi", 2, false, "", None, 0, 10, false);
3350        assert_eq!(r.entry.text, "[hi        ]");
3351    }
3352
3353    #[test]
3354    fn text_input_field_width_focused_adds_cursor_park_space() {
3355        // field_width=10, focused, value fills exactly 10 → inner
3356        // is 11 chars (10 + 1 cursor-park space) so the cursor at
3357        // end-of-value never lands on `]`.
3358        let r = render_text_input("0123456789", 10, true, "", None, 0, 10, false);
3359        assert_eq!(r.entry.text, "[0123456789 ]");
3360        // Cursor at byte 10 of value → byte 10 of inner → byte 11
3361        // of entry.text (after `[`). That's the cursor-park space,
3362        // not `]` (which lives at byte 12).
3363        assert_eq!(r.cursor_byte_in_entry, Some(11));
3364        assert_ne!(r.cursor_byte_in_entry, Some(12), "must not land on ]");
3365    }
3366
3367    #[test]
3368    fn text_input_field_width_full_width_pads_to_same_size_when_unfocused() {
3369        // full_width=true makes the inner reserve the cursor-park
3370        // space whether or not the input is focused, so the field
3371        // doesn't "jump" wider on focus.
3372        let r = render_text_input("hi", -1, false, "", None, 0, 10, true);
3373        assert_eq!(r.entry.text, "[hi         ]"); // 10 + 1 trailing pad
3374    }
3375
3376    #[test]
3377    fn text_input_field_width_head_truncates_long_value() {
3378        // 30-char value, field_width=10, unfocused → keep last 9
3379        // chars + `…`; no pad space.
3380        let r = render_text_input(
3381            "0123456789abcdefghijklmnopqrst",
3382            30,
3383            false,
3384            "",
3385            None,
3386            0,
3387            10,
3388            false,
3389        );
3390        assert!(r.entry.text.contains("…lmnopqrst"));
3391    }
3392
3393    #[test]
3394    fn text_input_field_width_clamps_cursor_in_dropped_prefix() {
3395        // Long value, field_width=5, focused, cursor at byte 0 (in
3396        // dropped prefix) → clamped to right after the `…`.
3397        let r = render_text_input("abcdefghij", 0, true, "", None, 0, 5, false);
3398        // Inner = `…fghij ` (1 ellipsis + 4 tail chars + 1 pad).
3399        // Cursor at "right after `…`" = byte 3 of inner (3 = `…`'s
3400        // UTF-8 byte length). entry.text has `[` before, so
3401        // absolute byte = 1 + 3 = 4.
3402        assert_eq!(r.cursor_byte_in_entry, Some(1 + "…".len()));
3403    }
3404
3405    #[test]
3406    fn text_input_truncates_long_value_keeping_tail_visible() {
3407        let value: String = "0123456789abcdefghij".to_string();
3408        let entry = render_text_input(&value, -1, false, "", None, 6, 0, false).entry;
3409        // Tail-truncated to "…fghij" (max=6, take=5 chars).
3410        assert_eq!(entry.text, "[…fghij]");
3411    }
3412
3413    #[test]
3414    fn raw_inside_col_offsets_following_hits() {
3415        let spec = WidgetSpec::Col {
3416            children: vec![
3417                WidgetSpec::Raw {
3418                    entries: vec![
3419                        TextPropertyEntry::text("line0"),
3420                        TextPropertyEntry::text("line1"),
3421                        TextPropertyEntry::text("line2"),
3422                    ],
3423                    key: None,
3424                },
3425                WidgetSpec::Toggle {
3426                    checked: false,
3427                    label: "after raw".into(),
3428                    focused: false,
3429                    key: Some("post".into()),
3430                },
3431            ],
3432            key: None,
3433        };
3434        let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
3435        assert_eq!(entries.len(), 4);
3436        assert_eq!(hits.len(), 1);
3437        assert_eq!(hits[0].buffer_row, 3);
3438    }
3439
3440    // -------------------------------------------------------------
3441    // Tree
3442    // -------------------------------------------------------------
3443
3444    fn tnode(text: &str, depth: u32, has_children: bool) -> TreeNode {
3445        TreeNode {
3446            text: TextPropertyEntry::text(text),
3447            depth,
3448            has_children,
3449            checked: None,
3450        }
3451    }
3452
3453    fn make_tree(
3454        nodes: Vec<TreeNode>,
3455        item_keys: Vec<&str>,
3456        selected: i32,
3457        visible: u32,
3458        expanded: Vec<&str>,
3459        key: Option<&str>,
3460    ) -> WidgetSpec {
3461        WidgetSpec::Tree {
3462            nodes,
3463            item_keys: item_keys.iter().map(|s| s.to_string()).collect(),
3464            selected_index: selected,
3465            visible_rows: visible,
3466            expanded_keys: expanded.iter().map(|s| s.to_string()).collect(),
3467            checkable: false,
3468            key: key.map(|s| s.to_string()),
3469        }
3470    }
3471
3472    #[test]
3473    fn tree_row_renders_disclosure_glyph_for_internal_collapsed() {
3474        let r = render_tree_row(&tnode("file.txt", 0, true), false, false);
3475        assert!(r.entry.text.starts_with('\u{25B6}'), "starts with ▶");
3476        assert!(r.entry.text.contains("file.txt"));
3477        assert!(r.disclosure_range.is_some());
3478    }
3479
3480    #[test]
3481    fn tree_row_renders_disclosure_glyph_for_internal_expanded() {
3482        let r = render_tree_row(&tnode("file.txt", 0, true), true, false);
3483        assert!(r.entry.text.starts_with('\u{25BC}'), "starts with ▼");
3484    }
3485
3486    #[test]
3487    fn tree_row_leaf_uses_two_spaces_no_disclosure_hit() {
3488        let r = render_tree_row(&tnode("match", 0, false), false, false);
3489        // No glyph, just spaces for alignment.
3490        assert!(r.entry.text.starts_with("  "));
3491        assert!(r.entry.text.contains("match"));
3492        assert!(r.disclosure_range.is_none());
3493    }
3494
3495    #[test]
3496    fn tree_row_indents_by_depth_times_two() {
3497        let r = render_tree_row(&tnode("nested", 2, false), false, false);
3498        // depth=2 → 4 leading spaces, then 2 alignment spaces, then "nested".
3499        assert!(r.entry.text.starts_with("      nested"));
3500    }
3501
3502    #[test]
3503    fn tree_row_shifts_plugin_overlays_by_prefix() {
3504        let mut node = tnode("hello", 1, false);
3505        node.text.inline_overlays.push(InlineOverlay {
3506            start: 0,
3507            end: 5,
3508            style: OverlayOptions {
3509                bold: true,
3510                ..Default::default()
3511            },
3512            properties: Default::default(),
3513            unit: OffsetUnit::Byte,
3514        });
3515        let r = render_tree_row(&node, false, false);
3516        // depth=1 → 2 indent + 2 alignment = 4 prefix bytes (ASCII).
3517        // The plugin's [0..5] becomes [4..9].
3518        let plugin_overlay = r
3519            .entry
3520            .inline_overlays
3521            .iter()
3522            .find(|o| o.style.bold)
3523            .expect("bold overlay carried through");
3524        assert_eq!(plugin_overlay.start, 4);
3525        assert_eq!(plugin_overlay.end, 9);
3526    }
3527
3528    #[test]
3529    fn tree_row_omits_checkbox_when_not_checkable() {
3530        // Even with `checked: Some(_)`, no glyph if `checkable: false`.
3531        let mut node = tnode("file.rs", 0, false);
3532        node.checked = Some(true);
3533        let r = render_tree_row(&node, false, false);
3534        assert!(r.checkbox_range.is_none());
3535        assert!(!r.entry.text.contains("[v]"));
3536        assert!(!r.entry.text.contains("[ ]"));
3537    }
3538
3539    #[test]
3540    fn tree_row_omits_checkbox_when_checked_is_none() {
3541        // `checkable: true` but `checked: None` → still no glyph.
3542        // Lets a checkable tree mix non-checkbox-bearing nodes
3543        // (e.g. a separator or header) with checkbox rows.
3544        let node = tnode("section", 0, false);
3545        let r = render_tree_row(&node, false, true);
3546        assert!(r.checkbox_range.is_none());
3547        assert!(!r.entry.text.contains("[v]"));
3548        assert!(!r.entry.text.contains("[ ]"));
3549    }
3550
3551    #[test]
3552    fn tree_row_renders_checked_glyph_after_disclosure() {
3553        let mut node = tnode("file.rs", 0, true);
3554        node.checked = Some(true);
3555        let r = render_tree_row(&node, true, true);
3556        assert!(r.checkbox_range.is_some(), "checkbox range emitted");
3557        let (cb_start, cb_end) = r.checkbox_range.unwrap();
3558        // Layout: ▼(3 bytes UTF-8) + " " + [v] + " " + body
3559        assert_eq!(&r.entry.text[cb_start..cb_end], "[v]");
3560        assert!(r.entry.text.contains("[v] file.rs"));
3561    }
3562
3563    #[test]
3564    fn tree_row_renders_unchecked_glyph_for_leaf() {
3565        let mut node = tnode("match-row", 1, false);
3566        node.checked = Some(false);
3567        let r = render_tree_row(&node, false, true);
3568        let (cb_start, cb_end) = r
3569            .checkbox_range
3570            .expect("checkbox range for leaf with checked: Some");
3571        assert_eq!(&r.entry.text[cb_start..cb_end], "[ ]");
3572        // depth=1 → 2-space indent; leaf-alignment → 2 spaces; then `[ ]` + " ".
3573        assert!(r.entry.text.starts_with("    [ ] match-row"));
3574    }
3575
3576    #[test]
3577    fn tree_row_checkbox_glyph_byte_range_addresses_correct_text() {
3578        // Sanity: byte_start..byte_end must extract the glyph
3579        // verbatim (no UTF-8 boundary issues from the disclosure).
3580        let mut node = tnode("path/with/é", 0, true);
3581        node.checked = Some(true);
3582        let r = render_tree_row(&node, false, true);
3583        let (cb_start, cb_end) = r.checkbox_range.unwrap();
3584        assert!(r.entry.text.is_char_boundary(cb_start));
3585        assert!(r.entry.text.is_char_boundary(cb_end));
3586        assert_eq!(&r.entry.text[cb_start..cb_end], "[v]");
3587    }
3588
3589    #[test]
3590    fn tree_node_pad_to_chars_pads_text_before_prefix_offset_shift() {
3591        // depth=0 prefix is "▶ " (1 codepoint glyph + 1 space).
3592        // Plugin sends body "x" with pad_to_chars=5; renderer pads
3593        // body to "x    " then prepends prefix.
3594        let mut node = tnode("x", 0, true);
3595        node.text.pad_to_chars = Some(5);
3596        let spec = make_tree(vec![node], vec!["x"], -1, 10, vec!["x"], Some("T"));
3597        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3598        assert_eq!(entries.len(), 1);
3599        // The full row is prefix + padded body + trailing newline.
3600        // Body region must be "x    " (5 columns).
3601        let trimmed = entries[0].text.trim_end_matches('\n');
3602        assert!(
3603            trimmed.ends_with("x    "),
3604            "row should end with the padded body, got {trimmed:?}"
3605        );
3606    }
3607
3608    #[test]
3609    fn tree_node_truncate_to_chars_cuts_body_before_prefix_offset_shift() {
3610        let mut node = tnode("abcdefghij", 0, false);
3611        node.text.truncate_to_chars = Some(6);
3612        let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
3613        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3614        let trimmed = entries[0].text.trim_end_matches('\n');
3615        // With budget=6, truncation produces "abc..." (3 head chars
3616        // + ellipsis), then prefix is prepended.
3617        assert!(
3618            trimmed.ends_with("abc..."),
3619            "row should end with truncated body, got {trimmed:?}"
3620        );
3621    }
3622
3623    #[test]
3624    fn tree_node_char_unit_overlay_resolves_against_padded_text_and_shifts_by_prefix() {
3625        // Body text "x" padded to 5 codepoints — the host pads to
3626        // "x    " before resolving overlays. A char-unit overlay at
3627        // [0..5] must end up covering the full padded body in bytes,
3628        // shifted right by the prefix length.
3629        let mut node = tnode("x", 0, false);
3630        node.text.pad_to_chars = Some(5);
3631        node.text.inline_overlays.push(InlineOverlay {
3632            start: 0,
3633            end: 5,
3634            style: OverlayOptions {
3635                bold: true,
3636                ..Default::default()
3637            },
3638            properties: Default::default(),
3639            unit: OffsetUnit::Char,
3640        });
3641        let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
3642        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3643        let entry = &entries[0];
3644        let bold = entry
3645            .inline_overlays
3646            .iter()
3647            .find(|o| o.style.bold)
3648            .expect("bold overlay carried through");
3649        // depth=0, leaf → prefix is two spaces (no glyph). Body
3650        // starts at byte 2 and is 5 bytes (ASCII pad), so [2..7].
3651        assert_eq!(bold.start, 2);
3652        assert_eq!(bold.end, 7);
3653    }
3654
3655    #[test]
3656    fn tree_node_char_unit_overlay_with_multibyte_body_resolves_correctly() {
3657        // Body text "éxé" — 3 codepoints, 5 bytes. A char-unit
3658        // overlay at [1..2] (just the "x") becomes byte [3..4]
3659        // within the body, then shifted by leaf prefix (2 bytes).
3660        let mut node = tnode("éxé", 0, false);
3661        node.text.inline_overlays.push(InlineOverlay {
3662            start: 1,
3663            end: 2,
3664            style: OverlayOptions {
3665                bold: true,
3666                ..Default::default()
3667            },
3668            properties: Default::default(),
3669            unit: OffsetUnit::Char,
3670        });
3671        let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
3672        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3673        let entry = &entries[0];
3674        let bold = entry
3675            .inline_overlays
3676            .iter()
3677            .find(|o| o.style.bold)
3678            .expect("bold overlay carried through");
3679        // Prefix is 2 bytes (two ASCII spaces), char→byte [1..2]
3680        // resolves to body byte [2..3], then shift +2 → [4..5].
3681        let trimmed = entry.text.trim_end_matches('\n');
3682        assert_eq!(bold.start, 4);
3683        assert_eq!(bold.end, 5);
3684        assert_eq!(&trimmed[bold.start..bold.end], "x");
3685    }
3686
3687    #[test]
3688    fn tree_node_segments_concatenate_into_row_text_with_per_segment_overlays() {
3689        let mut node = tnode("", 0, false);
3690        node.text.segments = vec![
3691            fresh_core::text_property::StyledSegment {
3692                text: "AB".to_string(),
3693                style: None,
3694                overlays: vec![],
3695            },
3696            fresh_core::text_property::StyledSegment {
3697                text: " ".to_string(),
3698                style: None,
3699                overlays: vec![],
3700            },
3701            fresh_core::text_property::StyledSegment {
3702                text: "CD".to_string(),
3703                style: Some(OverlayOptions {
3704                    bold: true,
3705                    ..Default::default()
3706                }),
3707                overlays: vec![],
3708            },
3709        ];
3710        let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
3711        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3712        let trimmed = entries[0].text.trim_end_matches('\n');
3713        // Leaf row: 2-space prefix + concatenated segments.
3714        assert!(
3715            trimmed.ends_with("AB CD"),
3716            "row should end with concatenated segments, got {trimmed:?}"
3717        );
3718        let bold = entries[0]
3719            .inline_overlays
3720            .iter()
3721            .find(|o| o.style.bold)
3722            .expect("styled segment overlay carried through");
3723        // Bold covers the third segment only ("CD" at byte 5..7
3724        // after 2-byte prefix + "AB " = 3 bytes).
3725        assert_eq!(&trimmed[bold.start..bold.end], "CD");
3726    }
3727
3728    #[test]
3729    fn tree_node_segment_nested_overlay_shifts_to_segment_position() {
3730        // Build a row whose third segment carries a nested overlay
3731        // covering chars [0..3] within itself ("CDE"). The host
3732        // shifts those by the segment's start in the entry; final
3733        // bytes resolve against the assembled text.
3734        let mut node = tnode("", 0, false);
3735        node.text.segments = vec![
3736            fresh_core::text_property::StyledSegment {
3737                text: "AB".to_string(),
3738                style: None,
3739                overlays: vec![],
3740            },
3741            fresh_core::text_property::StyledSegment {
3742                text: " - ".to_string(),
3743                style: None,
3744                overlays: vec![],
3745            },
3746            fresh_core::text_property::StyledSegment {
3747                text: "CDEFG".to_string(),
3748                style: None,
3749                overlays: vec![InlineOverlay {
3750                    start: 0,
3751                    end: 3,
3752                    style: OverlayOptions {
3753                        bold: true,
3754                        ..Default::default()
3755                    },
3756                    properties: Default::default(),
3757                    unit: OffsetUnit::Char,
3758                }],
3759            },
3760        ];
3761        let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
3762        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3763        let trimmed = entries[0].text.trim_end_matches('\n');
3764        let bold = entries[0]
3765            .inline_overlays
3766            .iter()
3767            .find(|o| o.style.bold)
3768            .expect("nested overlay carried through");
3769        assert_eq!(&trimmed[bold.start..bold.end], "CDE");
3770    }
3771
3772    #[test]
3773    fn tree_node_segments_with_pad_pad_after_concatenation() {
3774        let mut node = tnode("", 0, false);
3775        node.text.segments = vec![fresh_core::text_property::StyledSegment {
3776            text: "ab".to_string(),
3777            style: None,
3778            overlays: vec![],
3779        }];
3780        node.text.pad_to_chars = Some(5);
3781        let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
3782        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3783        let trimmed = entries[0].text.trim_end_matches('\n');
3784        // Two-space leaf prefix + "ab" + three padding spaces = "  ab   ".
3785        assert!(
3786            trimmed.ends_with("ab   "),
3787            "row should be padded after segment concat, got {trimmed:?}"
3788        );
3789    }
3790
3791    #[test]
3792    fn tree_renders_only_top_level_when_nothing_expanded() {
3793        let spec = make_tree(
3794            vec![
3795                tnode("a", 0, true),
3796                tnode("a.0", 1, false),
3797                tnode("a.1", 1, false),
3798                tnode("b", 0, true),
3799                tnode("b.0", 1, false),
3800            ],
3801            vec!["a", "a.0", "a.1", "b", "b.0"],
3802            -1,
3803            10,
3804            vec![], // none expanded
3805            Some("T"),
3806        );
3807        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3808        // Only the two top-level nodes are visible.
3809        assert_eq!(entries.len(), 2);
3810        assert!(entries[0].text.contains('a'));
3811        assert!(entries[1].text.contains('b'));
3812    }
3813
3814    #[test]
3815    fn tree_renders_children_of_expanded_nodes() {
3816        let spec = make_tree(
3817            vec![
3818                tnode("a", 0, true),
3819                tnode("a.0", 1, false),
3820                tnode("a.1", 1, false),
3821                tnode("b", 0, true),
3822                tnode("b.0", 1, false),
3823            ],
3824            vec!["a", "a.0", "a.1", "b", "b.0"],
3825            -1,
3826            10,
3827            vec!["a"],
3828            Some("T"),
3829        );
3830        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3831        // a, a.0, a.1, b — b's child stays hidden.
3832        assert_eq!(entries.len(), 4);
3833    }
3834
3835    #[test]
3836    fn tree_emits_two_hits_per_internal_row_one_per_leaf() {
3837        // a (internal, expanded) + a.0 (leaf) → 2 hits for a (disclosure + body)
3838        // and 1 hit for a.0 (body only).
3839        let spec = make_tree(
3840            vec![tnode("a", 0, true), tnode("a.0", 1, false)],
3841            vec!["a", "a.0"],
3842            -1,
3843            10,
3844            vec!["a"],
3845            Some("T"),
3846        );
3847        let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
3848        assert_eq!(hits.len(), 3);
3849        // First hit: disclosure on the internal node.
3850        assert_eq!(hits[0].event_type, "expand");
3851        assert_eq!(hits[0].widget_kind, "tree");
3852        assert_eq!(hits[1].event_type, "select");
3853        assert_eq!(hits[2].event_type, "select");
3854    }
3855
3856    #[test]
3857    fn tree_hits_carry_tree_spec_key_and_per_item_key_in_payload() {
3858        let spec = make_tree(
3859            vec![tnode("only", 0, false)],
3860            vec!["only-key"],
3861            -1,
3862            10,
3863            vec![],
3864            Some("matchTree"),
3865        );
3866        let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
3867        assert_eq!(hits[0].widget_key, "matchTree");
3868        assert_eq!(hits[0].payload["key"], "only-key");
3869        assert_eq!(hits[0].payload["index"], 0);
3870    }
3871
3872    #[test]
3873    fn tree_persists_expanded_keys_in_instance_state() {
3874        let spec = make_tree(
3875            vec![tnode("a", 0, true), tnode("a.0", 1, false)],
3876            vec!["a", "a.0"],
3877            -1,
3878            10,
3879            vec!["a"],
3880            Some("T"),
3881        );
3882        let (_, _, state) = render_no_focus(&spec, &HashMap::new());
3883        match state.get("T").unwrap() {
3884            WidgetInstanceState::Tree { expanded_keys, .. } => {
3885                assert!(expanded_keys.contains("a"));
3886            }
3887            _ => unreachable!(),
3888        }
3889    }
3890
3891    #[test]
3892    fn tree_instance_state_overrides_spec_expanded_keys() {
3893        // Previous instance state has b expanded but spec says a.
3894        // Instance state wins (spec is initial-only after first render).
3895        let mut prev = HashMap::new();
3896        prev.insert(
3897            "T".into(),
3898            WidgetInstanceState::Tree {
3899                scroll_offset: 0,
3900                selected_index: -1,
3901                expanded_keys: ["b".to_string()].iter().cloned().collect(),
3902            },
3903        );
3904        let spec = make_tree(
3905            vec![
3906                tnode("a", 0, true),
3907                tnode("a.0", 1, false),
3908                tnode("b", 0, true),
3909                tnode("b.0", 1, false),
3910            ],
3911            vec!["a", "a.0", "b", "b.0"],
3912            -1,
3913            10,
3914            vec!["a"], // initial-only — ignored after first render
3915            Some("T"),
3916        );
3917        let (entries, _hits, _state) = render_no_focus(&spec, &prev);
3918        // Should render: a (collapsed), b, b.0 — three rows. a.0 hidden.
3919        assert_eq!(entries.len(), 3);
3920    }
3921
3922    #[test]
3923    fn tree_selected_row_gets_focused_bg() {
3924        let spec = make_tree(
3925            vec![tnode("a", 0, false), tnode("b", 0, false)],
3926            vec!["a", "b"],
3927            1,
3928            10,
3929            vec![],
3930            Some("T"),
3931        );
3932        let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3933        assert!(entries[0].style.is_none());
3934        let style = entries[1].style.as_ref().expect("selected gets style");
3935        assert_eq!(
3936            style.bg.as_ref().and_then(|c| c.as_theme_key()),
3937            Some("ui.menu_active_bg")
3938        );
3939        assert!(style.extend_to_line_end);
3940    }
3941
3942    #[test]
3943    fn tree_clamps_selection_to_visible_when_selected_node_is_hidden() {
3944        // selected_index = 1 (a.0), but `a` is collapsed → a.0 hidden.
3945        // The renderer falls back to the nearest earlier visible
3946        // node (a, idx 0).
3947        let spec = make_tree(
3948            vec![tnode("a", 0, true), tnode("a.0", 1, false)],
3949            vec!["a", "a.0"],
3950            1,
3951            10,
3952            vec![], // a not expanded
3953            Some("T"),
3954        );
3955        let (_entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
3956        match state.get("T").unwrap() {
3957            WidgetInstanceState::Tree { selected_index, .. } => {
3958                assert_eq!(*selected_index, 0);
3959            }
3960            _ => unreachable!(),
3961        }
3962    }
3963
3964    #[test]
3965    fn tree_scrolls_to_keep_selection_in_visible_window() {
3966        // 6 visible rows total, visible_rows=3, selected at flat
3967        // position 4 → scroll should be 2 (so selected lands at the
3968        // bottom of the window).
3969        let spec = make_tree(
3970            vec![
3971                tnode("0", 0, false),
3972                tnode("1", 0, false),
3973                tnode("2", 0, false),
3974                tnode("3", 0, false),
3975                tnode("4", 0, false),
3976                tnode("5", 0, false),
3977            ],
3978            vec!["k0", "k1", "k2", "k3", "k4", "k5"],
3979            4,
3980            3,
3981            vec![],
3982            Some("T"),
3983        );
3984        let (entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
3985        // Visible window: items 2..5 → 3 rows.
3986        assert_eq!(entries.len(), 3);
3987        match state.get("T").unwrap() {
3988            WidgetInstanceState::Tree { scroll_offset, .. } => assert_eq!(*scroll_offset, 2),
3989            _ => unreachable!(),
3990        }
3991    }
3992
3993    #[test]
3994    fn tree_tabbable_keys_include_tree_with_key() {
3995        let spec = WidgetSpec::Col {
3996            children: vec![
3997                WidgetSpec::Toggle {
3998                    checked: false,
3999                    label: "T".into(),
4000                    focused: false,
4001                    key: Some("toggle".into()),
4002                },
4003                make_tree(
4004                    vec![tnode("a", 0, false)],
4005                    vec!["a"],
4006                    -1,
4007                    10,
4008                    vec![],
4009                    Some("tree"),
4010                ),
4011            ],
4012            key: None,
4013        };
4014        let mut tabbable = Vec::new();
4015        collect_tabbable(&spec, &mut tabbable);
4016        assert_eq!(tabbable, vec!["toggle", "tree"]);
4017    }
4018
4019    // -------------------------------------------------------------
4020    // TextArea
4021    // -------------------------------------------------------------
4022
4023    fn make_text_area(
4024        value: &str,
4025        cursor_byte: i32,
4026        focused: bool,
4027        rows: u32,
4028        field_width: u32,
4029        key: Option<&str>,
4030    ) -> WidgetSpec {
4031        WidgetSpec::Text {
4032            value: value.into(),
4033            cursor_byte,
4034            focused,
4035            label: String::new(),
4036            placeholder: None,
4037            // Force multi-line behaviour even when the test passes
4038            // `rows: 1` — the previous TextArea-specific tests
4039            // exercise the multi-line code path through this
4040            // helper.
4041            rows: rows.max(2),
4042            field_width,
4043            max_visible_chars: 0,
4044            full_width: false,
4045            key: key.map(|s| s.into()),
4046        }
4047    }
4048
4049    #[test]
4050    fn text_area_renders_visible_rows_count() {
4051        // Single line value, but rows=3 → 3 entries (line + 2
4052        // blanks).
4053        let spec = make_text_area("hi", -1, false, 3, 10, Some("ta"));
4054        let prev = HashMap::new();
4055        let out = render_spec(&spec, &prev, "", 80);
4056        assert_eq!(out.entries.len(), 3);
4057    }
4058
4059    #[test]
4060    fn text_area_pads_short_lines_to_field_width() {
4061        let spec = make_text_area("hi", -1, false, 1, 6, Some("ta"));
4062        let prev = HashMap::new();
4063        let out = render_spec(&spec, &prev, "", 80);
4064        // First (only visible) row: "hi" padded to 6 chars → "hi    \n"
4065        let first = &out.entries[0];
4066        assert_eq!(first.text, "hi    \n");
4067    }
4068
4069    #[test]
4070    fn text_area_truncates_long_line_with_ellipsis() {
4071        let spec = make_text_area("abcdefghi", -1, false, 1, 5, Some("ta"));
4072        let prev = HashMap::new();
4073        let out = render_spec(&spec, &prev, "", 80);
4074        // 9 chars trimmed to 5 → "abcd…\n".
4075        assert_eq!(out.entries[0].text, "abcd…\n");
4076    }
4077
4078    #[test]
4079    fn text_area_focused_adds_input_bg_overlay_per_row() {
4080        let spec = make_text_area("a\nb", -1, true, 3, 4, Some("ta"));
4081        let prev = HashMap::new();
4082        let out = render_spec(&spec, &prev, "ta", 80);
4083        for entry in &out.entries {
4084            let has_bg = entry.inline_overlays.iter().any(|o| {
4085                o.style
4086                    .bg
4087                    .as_ref()
4088                    .and_then(|c| c.as_theme_key())
4089                    .map(|k| k == "ui.prompt_bg")
4090                    .unwrap_or(false)
4091            });
4092            assert!(has_bg, "every focused row gets input-bg");
4093        }
4094    }
4095
4096    #[test]
4097    fn text_area_publishes_focus_cursor_at_value_position() {
4098        // value="ab\ncd", cursor at byte 4 (col 1 on line 1, char
4099        // 'd' position).
4100        let spec = make_text_area("ab\ncd", 4, true, 3, 6, Some("ta"));
4101        let prev = HashMap::new();
4102        let out = render_spec(&spec, &prev, "ta", 80);
4103        let fc = out.focus_cursor.expect("focused → cursor published");
4104        // Line 1 is the second visible row → buffer_row 1.
4105        assert_eq!(fc.buffer_row, 1);
4106        // Col 1 on the rendered row.
4107        assert_eq!(fc.byte_in_row, 1);
4108    }
4109
4110    #[test]
4111    fn text_area_label_offsets_cursor_buffer_row() {
4112        // With a label, the editing region starts on row 1, so a
4113        // cursor on line 0 of the value lands on row 1 of the
4114        // buffer.
4115        let spec = WidgetSpec::Text {
4116            value: "hi".into(),
4117            cursor_byte: 1,
4118            focused: true,
4119            label: "Note".into(),
4120            placeholder: None,
4121            rows: 2,
4122            field_width: 6,
4123            max_visible_chars: 0,
4124            full_width: false,
4125            key: Some("ta".into()),
4126        };
4127        let prev = HashMap::new();
4128        let out = render_spec(&spec, &prev, "ta", 80);
4129        // entries[0] is the label row, entries[1..] are content.
4130        assert!(out.entries[0].text.starts_with("Note:"));
4131        let fc = out.focus_cursor.unwrap();
4132        assert_eq!(fc.buffer_row, 1);
4133    }
4134
4135    #[test]
4136    fn text_area_persists_value_and_cursor_in_instance_state() {
4137        let spec = make_text_area("abc", 2, true, 2, 8, Some("ta"));
4138        let prev = HashMap::new();
4139        let out = render_spec(&spec, &prev, "ta", 80);
4140        match out.instance_states.get("ta") {
4141            Some(WidgetInstanceState::Text {
4142                value, cursor_byte, ..
4143            }) => {
4144                assert_eq!(value, "abc");
4145                assert_eq!(*cursor_byte, 2);
4146            }
4147            other => panic!("expected Text instance state, got {:?}", other),
4148        }
4149    }
4150
4151    #[test]
4152    fn text_area_instance_state_overrides_spec_value() {
4153        // Plugin's spec says "old" but instance state has "new" —
4154        // the renderer reads from instance state.
4155        let spec = make_text_area("old", 0, true, 2, 8, Some("ta"));
4156        let mut prev = HashMap::new();
4157        prev.insert(
4158            "ta".into(),
4159            WidgetInstanceState::Text {
4160                value: "new".into(),
4161                cursor_byte: 3,
4162                scroll: 0,
4163            },
4164        );
4165        let out = render_spec(&spec, &prev, "ta", 80);
4166        // The first row should now read "new" (not "old").
4167        assert!(out.entries[0].text.starts_with("new"));
4168    }
4169
4170    #[test]
4171    fn text_area_scroll_clamps_to_keep_cursor_visible() {
4172        // 5-line value, rows=2. Cursor on line 4 (last). On first
4173        // render the renderer should auto-scroll so line 4 is
4174        // visible.
4175        let spec = make_text_area("a\nb\nc\nd\ne", 8, true, 2, 4, Some("ta"));
4176        // byte 8 is on the 5th line (line index 4).
4177        let prev = HashMap::new();
4178        let out = render_spec(&spec, &prev, "ta", 80);
4179        match out.instance_states.get("ta") {
4180            Some(WidgetInstanceState::Text { scroll, .. }) => {
4181                assert_eq!(*scroll, 3, "scroll so lines 3..5 are visible");
4182            }
4183            _ => panic!("expected Text instance state"),
4184        }
4185    }
4186
4187    #[test]
4188    fn text_area_unfocused_empty_shows_placeholder_in_first_row() {
4189        // Test the renderer directly (focused=false). Host-owned
4190        // focus would otherwise auto-focus the only tabbable
4191        // widget — see `text_area_publishes_focus_cursor_at_value_position`
4192        // for the focused path.
4193        let r = render_text_area("", -1, false, "", Some("write here"), 2, 12, 0, 80);
4194        assert!(r.entries[0].text.starts_with("write here"));
4195        // Placeholder uses the muted-fg overlay.
4196        let fg = r.entries[0]
4197            .inline_overlays
4198            .iter()
4199            .find_map(|o| o.style.fg.as_ref())
4200            .and_then(|c| c.as_theme_key());
4201        assert_eq!(fg, Some("editor.whitespace_indicator_fg"));
4202    }
4203
4204    #[test]
4205    fn text_area_tabbable_keys_include_text_area_with_key() {
4206        let spec = WidgetSpec::Col {
4207            children: vec![
4208                WidgetSpec::Toggle {
4209                    checked: false,
4210                    label: "T".into(),
4211                    focused: false,
4212                    key: Some("toggle".into()),
4213                },
4214                make_text_area("", -1, false, 3, 10, Some("note")),
4215            ],
4216            key: None,
4217        };
4218        let mut tabbable = Vec::new();
4219        collect_tabbable(&spec, &mut tabbable);
4220        assert_eq!(tabbable, vec!["toggle", "note"]);
4221    }
4222
4223    // -------------------------------------------------------------
4224    // LabeledSection
4225    // -------------------------------------------------------------
4226
4227    fn make_text_input(
4228        value: &str,
4229        cursor_byte: i32,
4230        focused: bool,
4231        full_width: bool,
4232        field_width: u32,
4233        key: Option<&str>,
4234    ) -> WidgetSpec {
4235        WidgetSpec::Text {
4236            value: value.into(),
4237            cursor_byte,
4238            focused,
4239            label: String::new(),
4240            placeholder: None,
4241            rows: 1,
4242            field_width,
4243            max_visible_chars: 0,
4244            full_width,
4245            key: key.map(|s| s.into()),
4246        }
4247    }
4248
4249    #[test]
4250    fn labeled_section_renders_three_rows_with_legend() {
4251        let spec = WidgetSpec::LabeledSection {
4252            label: "Name".into(),
4253            child: Box::new(make_text_input("hi", -1, false, false, 4, Some("n"))),
4254            width_pct: None,
4255            key: None,
4256        };
4257        let prev = HashMap::new();
4258        let out = render_spec(&spec, &prev, "", 20);
4259        // 3 lines: top border, content, bottom border.
4260        assert_eq!(out.entries.len(), 3);
4261        // Top border has legend.
4262        assert!(out.entries[0].text.starts_with("╭─ Name "));
4263        assert!(out.entries[0].text.ends_with("╮\n"));
4264        // Content wrapped with side borders.
4265        assert!(out.entries[1].text.starts_with("│ "));
4266        assert!(out.entries[1].text.ends_with(" │\n"));
4267        // Bottom border is a plain run.
4268        assert!(out.entries[2].text.starts_with("╰"));
4269        assert!(out.entries[2].text.ends_with("╯\n"));
4270    }
4271
4272    #[test]
4273    fn labeled_section_pads_child_to_inner_width() {
4274        let spec = WidgetSpec::LabeledSection {
4275            label: "".into(),
4276            child: Box::new(make_text_input("hi", -1, false, false, 4, Some("n"))),
4277            width_pct: None,
4278            key: None,
4279        };
4280        let prev = HashMap::new();
4281        // panel_width = 16 → inner_width = 12 → middle row is
4282        // "│ " + 12 cols + " │".
4283        let out = render_spec(&spec, &prev, "", 16);
4284        let middle = &out.entries[1];
4285        // Count display columns including the borders + spaces.
4286        assert_eq!(middle.text.chars().count(), 16 + 1 /* \n */);
4287    }
4288
4289    #[test]
4290    fn labeled_section_text_full_width_fills_inner_area() {
4291        // Inner width = 16 - 4 = 12. With no label on the input,
4292        // 3 cols of overhead (brackets + focus park) →
4293        // effective field_width = 9. The widget is the only
4294        // tabbable so the renderer marks it focused, padding the
4295        // inner region to field_width + 1 = 10 chars.
4296        let spec = WidgetSpec::LabeledSection {
4297            label: "".into(),
4298            child: Box::new(make_text_input("ab", -1, false, true, 0, Some("n"))),
4299            width_pct: None,
4300            key: None,
4301        };
4302        let prev = HashMap::new();
4303        let out = render_spec(&spec, &prev, "", 16);
4304        let middle = &out.entries[1];
4305        // Middle row should be `│ [ab        ] │\n` — 17 chars
4306        // total (16 visible cols + trailing newline). When the
4307        // child fits exactly, the `]` is preserved.
4308        assert_eq!(middle.text.chars().count(), 17, "actual: {:?}", middle.text);
4309        assert!(
4310            middle.text.contains("[ab        ]"),
4311            "actual: {:?}",
4312            middle.text
4313        );
4314    }
4315
4316    #[test]
4317    fn labeled_section_propagates_focus_cursor_with_offsets() {
4318        let spec = WidgetSpec::LabeledSection {
4319            label: "".into(),
4320            child: Box::new(make_text_input("abc", 3, true, false, 4, Some("n"))),
4321            width_pct: None,
4322            key: None,
4323        };
4324        let prev = HashMap::new();
4325        let out = render_spec(&spec, &prev, "n", 20);
4326        let fc = out.focus_cursor.expect("focused child publishes cursor");
4327        // Child renders on the second row (top border = row 0).
4328        assert_eq!(fc.buffer_row, 1);
4329        // Cursor offset includes the left-prefix "│ " byte count
4330        // plus the child's own offset (1 for the opening bracket
4331        // + 3 for "abc"). "│" is 3 bytes in UTF-8 → prefix = 4.
4332        let prefix_bytes = LEFT_BORDER_PREFIX.len() as u32;
4333        assert_eq!(fc.byte_in_row, prefix_bytes + 1 + 3);
4334    }
4335
4336    #[test]
4337    fn labeled_section_includes_child_in_tabbable() {
4338        let spec = WidgetSpec::Col {
4339            children: vec![
4340                WidgetSpec::LabeledSection {
4341                    label: "Name".into(),
4342                    child: Box::new(make_text_input("", -1, false, false, 0, Some("n"))),
4343                    width_pct: None,
4344                    key: None,
4345                },
4346                WidgetSpec::LabeledSection {
4347                    label: "Cmd".into(),
4348                    child: Box::new(make_text_input("", -1, false, false, 0, Some("c"))),
4349                    width_pct: None,
4350                    key: None,
4351                },
4352            ],
4353            key: None,
4354        };
4355        let mut tabbable = Vec::new();
4356        collect_tabbable(&spec, &mut tabbable);
4357        assert_eq!(tabbable, vec!["n", "c"]);
4358    }
4359}