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