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