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