1use 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
31const KEY_HELP_KEY_FG: &str = "ui.help_key_fg";
35const KEY_TOGGLE_ON_FG: &str = "ui.tab_active_fg";
36const KEY_FOCUSED_FG: &str = "ui.menu_active_fg";
37const KEY_FOCUSED_BG: &str = "ui.menu_active_bg";
38const KEY_DANGER_FG: &str = "diagnostic.error_fg";
43const KEY_INPUT_BG: &str = "ui.prompt_bg";
44const KEY_PLACEHOLDER_FG: &str = "editor.whitespace_indicator_fg";
49const KEY_SECTION_LABEL_FG: &str = "ui.help_key_fg";
54
55#[derive(Debug, Clone, Copy)]
64pub struct FocusCursor {
65 pub buffer_row: u32,
66 pub byte_in_row: u32,
67}
68
69pub struct RenderOutput {
87 pub entries: Vec<TextPropertyEntry>,
88 pub hits: Vec<HitArea>,
89 pub instance_states: HashMap<String, WidgetInstanceState>,
90 pub focus_key: String,
91 pub tabbable: Vec<String>,
92 pub focus_cursor: Option<FocusCursor>,
93 pub embeds: Vec<EmbedRect>,
98}
99
100#[derive(Debug, Clone, Copy)]
108pub struct EmbedRect {
109 pub window_id: u32,
110 pub buffer_row: u32,
111 pub col_in_row: u32,
112 pub width_cols: u32,
113 pub height_rows: u32,
114}
115
116pub fn render_spec(
126 spec: &WidgetSpec,
127 prev: &HashMap<String, WidgetInstanceState>,
128 prev_focus_key: &str,
129 panel_width: u32,
130) -> RenderOutput {
131 let mut tabbable = Vec::new();
135 collect_tabbable(spec, &mut tabbable);
136 let focus_key = if !prev_focus_key.is_empty() && tabbable.iter().any(|k| k == prev_focus_key) {
137 prev_focus_key.to_string()
138 } else {
139 tabbable.first().cloned().unwrap_or_default()
140 };
141
142 let mut next_state = HashMap::new();
143 let (entries, hits, focus_cursor, embeds) =
144 render_collected(spec, prev, &mut next_state, &focus_key, panel_width);
145 RenderOutput {
146 entries,
147 hits,
148 instance_states: next_state,
149 focus_key,
150 tabbable,
151 focus_cursor,
152 embeds,
153 }
154}
155
156fn labeled_section_width_pct(spec: &WidgetSpec) -> Option<u32> {
173 let WidgetSpec::LabeledSection { width_pct, .. } = spec else {
174 return None;
175 };
176 width_pct.filter(|pct| (1..=100).contains(pct))
177}
178
179fn predicts_block(spec: &WidgetSpec) -> bool {
180 match spec {
181 WidgetSpec::Col { children, .. } => {
182 if children.len() > 1 {
183 return true;
184 }
185 children.first().map(predicts_block).unwrap_or(false)
186 }
187 WidgetSpec::LabeledSection { .. } => true,
188 WidgetSpec::Tree { .. } => true,
189 WidgetSpec::List { .. } => true,
190 WidgetSpec::Text { rows, .. } => *rows > 1,
191 WidgetSpec::WindowEmbed { rows, .. } => *rows > 1,
192 WidgetSpec::Raw { entries, .. } => entries.len() > 1,
193 WidgetSpec::Row { children, .. } => children.iter().any(predicts_block),
194 _ => false,
195 }
196}
197
198enum RowPiece {
202 Inline {
203 entry: TextPropertyEntry,
204 hits: Vec<HitArea>,
205 focus_cursor: Option<FocusCursor>,
210 embeds: Vec<EmbedRect>,
215 },
216 Block {
217 column_width: u32,
222 entries: Vec<TextPropertyEntry>,
223 hits: Vec<HitArea>,
224 focus_cursor: Option<FocusCursor>,
225 embeds: Vec<EmbedRect>,
230 },
231 Flex,
232}
233
234fn strip_trailing_newline(entry: &mut TextPropertyEntry) {
240 if entry.text.ends_with('\n') {
241 entry.text.pop();
242 }
243}
244
245fn ensure_trailing_newline(entry: &mut TextPropertyEntry) {
251 if !entry.text.ends_with('\n') {
252 entry.text.push('\n');
253 }
254}
255
256fn collect_tabbable(spec: &WidgetSpec, out: &mut Vec<String>) {
261 match spec {
262 WidgetSpec::Toggle { key: Some(k), .. }
263 | WidgetSpec::Button { key: Some(k), .. }
264 | WidgetSpec::Text { key: Some(k), .. }
265 | WidgetSpec::Tree { key: Some(k), .. }
266 if !k.is_empty() =>
267 {
268 out.push(k.clone());
269 }
270 WidgetSpec::List {
271 key: Some(k),
272 focusable,
273 ..
274 } if !k.is_empty() && *focusable => {
275 out.push(k.clone());
276 }
277 _ => {}
278 }
279 for c in spec.children() {
280 collect_tabbable(c, out);
281 }
282}
283
284fn render_collected(
296 spec: &WidgetSpec,
297 prev: &HashMap<String, WidgetInstanceState>,
298 next_state: &mut HashMap<String, WidgetInstanceState>,
299 focus_key: &str,
300 panel_width: u32,
301) -> (
302 Vec<TextPropertyEntry>,
303 Vec<HitArea>,
304 Option<FocusCursor>,
305 Vec<EmbedRect>,
306) {
307 let mut entries: Vec<TextPropertyEntry> = Vec::new();
308 let mut hits: Vec<HitArea> = Vec::new();
309 let mut focus_cursor: Option<FocusCursor> = None;
312 let mut embeds: Vec<EmbedRect> = Vec::new();
313 match spec {
314 WidgetSpec::Row { children, .. } => {
315 let block_indices: Vec<usize> = children
340 .iter()
341 .enumerate()
342 .filter(|(_, c)| predicts_block(c))
343 .map(|(i, _)| i)
344 .collect();
345 let block_count = block_indices.len();
346 let mut per_child_width: Vec<u32> = children.iter().map(|_| panel_width).collect();
351 if block_count > 0 {
352 let mut explicit_total: u32 = 0;
353 let mut explicit_count: u32 = 0;
354 for &idx in &block_indices {
355 if let Some(pct) = labeled_section_width_pct(&children[idx]) {
356 let w = (panel_width as u64 * pct as u64 / 100) as u32;
357 per_child_width[idx] = w.max(1);
358 explicit_total = explicit_total.saturating_add(w);
359 explicit_count += 1;
360 }
361 }
362 let remaining = panel_width.saturating_sub(explicit_total);
363 let implicit_count = (block_count as u32).saturating_sub(explicit_count).max(1);
364 let each_implicit = (remaining / implicit_count).max(1);
365 for &idx in &block_indices {
366 if labeled_section_width_pct(&children[idx]).is_none() {
367 per_child_width[idx] = each_implicit;
368 }
369 }
370 }
371 let mut row_pieces: Vec<RowPiece> = Vec::new();
372 for (idx, child) in children.iter().enumerate() {
373 if let WidgetSpec::Spacer { flex: true, .. } = child {
374 row_pieces.push(RowPiece::Flex);
375 continue;
376 }
377 let child_panel_width = per_child_width[idx];
378 let (child_entries, child_hits, child_focus, child_embeds) =
379 render_collected(child, prev, next_state, focus_key, child_panel_width);
380 if child_entries.is_empty() {
381 debug_assert!(child_hits.is_empty(), "empty children produce no hits");
382 continue;
383 }
384 if child_entries.len() == 1 {
385 let mut entry = child_entries.into_iter().next().unwrap();
386 strip_trailing_newline(&mut entry);
391 row_pieces.push(RowPiece::Inline {
392 entry,
393 hits: child_hits,
394 focus_cursor: child_focus,
395 embeds: child_embeds,
396 });
397 } else {
398 row_pieces.push(RowPiece::Block {
399 column_width: child_panel_width,
400 entries: child_entries,
401 hits: child_hits,
402 focus_cursor: child_focus,
403 embeds: child_embeds,
404 });
405 }
406 }
407 let has_blocks = row_pieces
411 .iter()
412 .any(|p| matches!(p, RowPiece::Block { .. }));
413 if has_blocks {
414 zip_row_blocks(
415 row_pieces,
416 panel_width,
417 &mut entries,
418 &mut hits,
419 &mut focus_cursor,
420 &mut embeds,
421 );
422 } else {
423 let inline_natural: usize = row_pieces
425 .iter()
426 .filter_map(|p| match p {
427 RowPiece::Inline { entry, .. } => Some(entry.text.len()),
428 _ => None,
429 })
430 .sum();
431 let flex_count = row_pieces
432 .iter()
433 .filter(|p| matches!(p, RowPiece::Flex))
434 .count();
435 let flex_total = (panel_width as usize).saturating_sub(inline_natural);
436 let (flex_each, flex_extra) = match flex_total.checked_div(flex_count) {
440 Some(each) => (each, flex_total % flex_count),
441 None => (0, 0),
442 };
443
444 let mut acc: Option<TextPropertyEntry> = None;
449 let mut flex_seen = 0usize;
450 for piece in row_pieces {
451 match piece {
452 RowPiece::Inline {
453 mut entry,
454 hits: child_hits,
455 focus_cursor: child_focus,
456 embeds: child_embeds,
457 } => {
458 let inline_shift = match acc.as_ref() {
459 Some(e) => e.text.len(),
460 None => 0,
461 };
462 for mut h in child_hits {
463 h.byte_start += inline_shift;
464 h.byte_end += inline_shift;
465 hits.push(h);
466 }
467 if let Some(mut fc) = child_focus {
468 fc.byte_in_row += inline_shift as u32;
470 focus_cursor = Some(fc);
471 }
472 for mut emb in child_embeds {
473 emb.col_in_row += inline_shift as u32;
479 embeds.push(emb);
480 }
481 match acc.as_mut() {
482 Some(merged) => merge_inline(merged, &mut entry),
483 None => acc = Some(entry),
484 }
485 }
486 RowPiece::Flex => {
487 let n = flex_each + if flex_seen < flex_extra { 1 } else { 0 };
489 flex_seen += 1;
490 if n > 0 {
491 let mut text = String::with_capacity(n);
492 for _ in 0..n {
493 text.push(' ');
494 }
495 let entry = TextPropertyEntry {
496 text,
497 properties: Default::default(),
498 style: None,
499 inline_overlays: Vec::new(),
500 segments: Vec::new(),
501 pad_to_chars: None,
502 truncate_to_chars: None,
503 };
504 match acc.as_mut() {
505 Some(merged) => {
506 let mut e = entry;
507 merge_inline(merged, &mut e);
508 }
509 None => acc = Some(entry),
510 }
511 }
512 }
513 RowPiece::Block { .. } => {
514 debug_assert!(false, "block piece in inline-only Row path");
517 }
518 }
519 }
520 if let Some(mut merged) = acc {
521 ensure_trailing_newline(&mut merged);
522 entries.push(merged);
523 }
524 }
525 }
526 WidgetSpec::Col { children, .. } => {
527 for child in children {
528 let (child_entries, child_hits, child_focus, child_embeds) =
529 render_collected(child, prev, next_state, focus_key, panel_width);
530 let row_offset = entries.len() as u32;
531 for mut h in child_hits {
532 h.buffer_row += row_offset;
533 hits.push(h);
534 }
535 if let Some(mut fc) = child_focus {
536 fc.buffer_row += row_offset;
537 focus_cursor = Some(fc);
538 }
539 for mut emb in child_embeds {
540 emb.buffer_row += row_offset;
541 embeds.push(emb);
542 }
543 entries.extend(child_entries);
544 }
545 }
546 WidgetSpec::HintBar {
547 entries: hint_entries,
548 ..
549 } => {
550 let mut entry = render_hint_bar(hint_entries);
551 ensure_trailing_newline(&mut entry);
552 entries.push(entry);
553 }
557 WidgetSpec::Toggle {
558 checked,
559 label,
560 focused,
561 key,
562 } => {
563 let is_focused = match key.as_deref() {
570 Some(k) if !k.is_empty() => k == focus_key,
571 _ => *focused,
572 };
573 let mut entry = render_toggle(*checked, label, is_focused);
574 let byte_end = entry.text.len();
575 hits.push(HitArea {
576 widget_key: key.clone().unwrap_or_default(),
577 widget_kind: "toggle",
578 buffer_row: 0,
579 byte_start: 0,
580 byte_end,
581 payload: json!({ "checked": !*checked }),
582 event_type: "toggle",
583 });
584 ensure_trailing_newline(&mut entry);
585 entries.push(entry);
586 }
587 WidgetSpec::Button {
588 label,
589 focused,
590 intent,
591 key,
592 } => {
593 let is_focused = match key.as_deref() {
594 Some(k) if !k.is_empty() => k == focus_key,
595 _ => *focused,
596 };
597 let mut entry = render_button(label, is_focused, *intent);
598 let byte_end = entry.text.len();
599 hits.push(HitArea {
600 widget_key: key.clone().unwrap_or_default(),
601 widget_kind: "button",
602 buffer_row: 0,
603 byte_start: 0,
604 byte_end,
605 payload: json!({}),
606 event_type: "activate",
607 });
608 ensure_trailing_newline(&mut entry);
609 entries.push(entry);
610 }
611 WidgetSpec::Spacer { cols, flex, .. } => {
612 let _ = flex;
618 let cols = (*cols).min(4096) as usize;
619 let mut text = String::with_capacity(cols + 1);
620 for _ in 0..cols {
621 text.push(' ');
622 }
623 let mut entry = TextPropertyEntry {
624 text,
625 properties: Default::default(),
626 style: None,
627 inline_overlays: Vec::new(),
628 segments: Vec::new(),
629 pad_to_chars: None,
630 truncate_to_chars: None,
631 };
632 ensure_trailing_newline(&mut entry);
633 entries.push(entry);
634 }
635 WidgetSpec::List {
636 items,
637 item_keys,
638 selected_index,
639 visible_rows,
640 focusable: _,
641 key: list_key,
642 } => {
643 let total = items.len() as u32;
648 let visible = (*visible_rows).max(1);
649 let (prev_scroll, prev_sel) = list_key
650 .as_deref()
651 .and_then(|k| prev.get(k))
652 .and_then(|s| match s {
653 WidgetInstanceState::List {
654 scroll_offset,
655 selected_index,
656 } => Some((*scroll_offset, *selected_index)),
657 _ => None,
658 })
659 .unwrap_or((0, *selected_index));
660 let effective_sel = if prev_sel < 0 || total == 0 {
666 -1
667 } else if (prev_sel as u32) >= total {
668 (total - 1) as i32
669 } else {
670 prev_sel
671 };
672
673 let mut scroll = prev_scroll;
676 if effective_sel >= 0 {
677 let sel = effective_sel as u32;
678 if sel < scroll {
679 scroll = sel;
680 }
681 if sel >= scroll + visible {
682 scroll = sel + 1 - visible;
683 }
684 }
685 let max_scroll = total.saturating_sub(visible);
686 if scroll > max_scroll {
687 scroll = max_scroll;
688 }
689 if let Some(k) = list_key.as_deref() {
692 next_state.insert(
693 k.to_string(),
694 WidgetInstanceState::List {
695 scroll_offset: scroll,
696 selected_index: effective_sel,
697 },
698 );
699 }
700
701 let start = scroll as usize;
707 let end = ((scroll + visible) as usize).min(items.len());
708 for (offset, item) in items[start..end].iter().enumerate() {
709 let i = start + offset;
710 let mut entry = item.clone();
711 entry.normalize_widths();
712 let is_selected = i as i32 == effective_sel;
713 if is_selected {
714 let mut style = entry.style.unwrap_or_default();
715 style.bg = Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG));
716 style.extend_to_line_end = true;
717 entry.style = Some(style);
718 }
719 let byte_end = entry.text.len();
720 ensure_trailing_newline(&mut entry);
721 entries.push(entry);
722 let item_key = item_keys.get(i).cloned().unwrap_or_default();
723 let hit_row = (entries.len() - 1) as u32;
724 hits.push(HitArea {
725 widget_key: item_key.clone(),
726 widget_kind: "list",
727 buffer_row: hit_row,
728 byte_start: 0,
729 byte_end,
730 payload: json!({
731 "index": i as i64,
732 "key": item_key,
733 }),
734 event_type: "select",
735 });
736 }
737 }
738 WidgetSpec::Tree {
739 nodes,
740 item_keys,
741 selected_index,
742 visible_rows,
743 expanded_keys,
744 checkable,
745 key: tree_key,
746 } => {
747 let prev_state = tree_key
750 .as_deref()
751 .filter(|k| !k.is_empty())
752 .and_then(|k| prev.get(k));
753 let (prev_scroll, prev_sel, prev_expanded) = match prev_state {
754 Some(WidgetInstanceState::Tree {
755 scroll_offset,
756 selected_index,
757 expanded_keys,
758 }) => (*scroll_offset, *selected_index, expanded_keys.clone()),
759 _ => {
760 let seeded: HashSet<String> = expanded_keys.iter().cloned().collect();
762 (0, *selected_index, seeded)
763 }
764 };
765
766 let mut ancestor_open: Vec<bool> = Vec::new();
778 let mut visible_indices: Vec<usize> = Vec::with_capacity(nodes.len());
779 for (i, node) in nodes.iter().enumerate() {
780 let depth = node.depth as usize;
781 ancestor_open.truncate(depth);
783 let visible = ancestor_open.iter().all(|open| *open);
784 if visible {
785 visible_indices.push(i);
786 }
787 let key = item_keys.get(i).cloned().unwrap_or_default();
793 let is_open = if node.has_children {
794 !key.is_empty() && prev_expanded.contains(&key)
795 } else {
796 true
797 };
798 ancestor_open.push(is_open);
799 }
800
801 let total_visible = visible_indices.len() as u32;
807 let visible = (*visible_rows).max(1);
808 let clamp_to_visible = |abs: i32| -> i32 {
809 if abs < 0 || nodes.is_empty() {
810 return -1;
811 }
812 let abs = abs.min((nodes.len() as i32) - 1) as usize;
813 if let Ok(_pos) = visible_indices.binary_search(&abs) {
814 return abs as i32;
815 }
816 let earlier = visible_indices.iter().rev().find(|&&v| v <= abs);
819 if let Some(&v) = earlier {
820 return v as i32;
821 }
822 visible_indices.first().map(|&v| v as i32).unwrap_or(-1)
823 };
824 let effective_sel_abs = clamp_to_visible(prev_sel);
825 let sel_visible_pos: i32 = if effective_sel_abs < 0 {
829 -1
830 } else {
831 visible_indices
832 .iter()
833 .position(|&v| v == effective_sel_abs as usize)
834 .map(|p| p as i32)
835 .unwrap_or(-1)
836 };
837
838 let mut scroll = prev_scroll;
841 if sel_visible_pos >= 0 {
842 let sel = sel_visible_pos as u32;
843 if sel < scroll {
844 scroll = sel;
845 }
846 if sel >= scroll + visible {
847 scroll = sel + 1 - visible;
848 }
849 }
850 let max_scroll = total_visible.saturating_sub(visible);
851 if scroll > max_scroll {
852 scroll = max_scroll;
853 }
854
855 if let Some(k) = tree_key.as_deref().filter(|k| !k.is_empty()) {
857 next_state.insert(
858 k.to_string(),
859 WidgetInstanceState::Tree {
860 scroll_offset: scroll,
861 selected_index: effective_sel_abs,
862 expanded_keys: prev_expanded.clone(),
863 },
864 );
865 }
866
867 let start = scroll as usize;
869 let end = ((scroll + visible) as usize).min(visible_indices.len());
870 for &abs_idx in &visible_indices[start..end] {
871 let mut node = nodes[abs_idx].clone();
876 node.text.normalize_widths();
877 let item_key = item_keys.get(abs_idx).cloned().unwrap_or_default();
878 let is_expanded =
879 node.has_children && !item_key.is_empty() && prev_expanded.contains(&item_key);
880 let rendered = render_tree_row(&node, is_expanded, *checkable);
881 let mut entry = rendered.entry;
882 let is_selected = abs_idx as i32 == effective_sel_abs;
883 if is_selected {
884 let mut style = entry.style.unwrap_or_default();
885 style.bg = Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG));
886 style.extend_to_line_end = true;
887 entry.style = Some(style);
888 }
889 let row_byte_end = entry.text.len();
890 ensure_trailing_newline(&mut entry);
891 entries.push(entry);
892 let hit_row = (entries.len() - 1) as u32;
893 let tree_spec_key = tree_key.clone().unwrap_or_default();
903 if let Some(disc_range) = rendered.disclosure_range {
904 hits.push(HitArea {
905 widget_key: tree_spec_key.clone(),
906 widget_kind: "tree",
907 buffer_row: hit_row,
908 byte_start: disc_range.0,
909 byte_end: disc_range.1,
910 payload: json!({
911 "index": abs_idx as i64,
912 "key": item_key.clone(),
913 "expanded": !is_expanded,
914 }),
915 event_type: "expand",
916 });
917 }
918 if let Some(cb_range) = rendered.checkbox_range {
925 let new_checked = !nodes[abs_idx].checked.unwrap_or(false);
926 hits.push(HitArea {
927 widget_key: tree_spec_key.clone(),
928 widget_kind: "tree",
929 buffer_row: hit_row,
930 byte_start: cb_range.0,
931 byte_end: cb_range.1,
932 payload: json!({
933 "index": abs_idx as i64,
934 "key": item_key.clone(),
935 "checked": new_checked,
936 }),
937 event_type: "toggle",
938 });
939 }
940 let body_start = match (rendered.checkbox_range, rendered.disclosure_range) {
944 (Some((_, end)), _) => end + 1, (None, Some((_, end))) => end,
946 (None, None) => 0,
947 };
948 if body_start < row_byte_end {
949 hits.push(HitArea {
950 widget_key: tree_spec_key,
951 widget_kind: "tree",
952 buffer_row: hit_row,
953 byte_start: body_start,
954 byte_end: row_byte_end,
955 payload: json!({
956 "index": abs_idx as i64,
957 "key": item_key,
958 }),
959 event_type: "select",
960 });
961 }
962 }
963 }
964 WidgetSpec::Text {
965 value,
966 cursor_byte,
967 focused,
968 label,
969 placeholder,
970 rows,
971 field_width,
972 max_visible_chars,
973 full_width,
974 key,
975 } => {
976 let is_focused = match key.as_deref() {
977 Some(k) if !k.is_empty() => k == focus_key,
978 _ => *focused,
979 };
980 let (effective_value, effective_cursor_byte, prev_scroll) = match key
984 .as_deref()
985 .filter(|k| !k.is_empty())
986 .and_then(|k| prev.get(k))
987 {
988 Some(WidgetInstanceState::Text {
989 value,
990 cursor_byte,
991 scroll,
992 }) => (value.clone(), *cursor_byte as i32, *scroll),
993 _ => (value.clone(), *cursor_byte, 0),
994 };
995 let effective_cursor = if is_focused {
996 effective_cursor_byte
997 } else {
998 -1
999 };
1000 let multiline = *rows > 1;
1004 let effective_field_width = if *full_width && !multiline {
1018 let label_overhead = if label.is_empty() {
1019 0u32
1020 } else {
1021 label.chars().count() as u32 + 1
1022 };
1023 panel_width
1024 .saturating_sub(label_overhead)
1025 .saturating_sub(3)
1026 .max(1)
1027 } else {
1028 *field_width
1029 };
1030 let new_scroll;
1031 if multiline {
1032 let rendered = render_text_area(
1033 &effective_value,
1034 effective_cursor,
1035 is_focused,
1036 label,
1037 placeholder.as_deref(),
1038 *rows,
1039 effective_field_width,
1040 prev_scroll,
1041 panel_width,
1042 );
1043 new_scroll = rendered.scroll_row;
1044 if let (Some(buffer_row), Some(byte_in_row)) =
1045 (rendered.cursor_buffer_row, rendered.cursor_byte_in_row)
1046 {
1047 focus_cursor = Some(FocusCursor {
1048 buffer_row,
1049 byte_in_row: byte_in_row as u32,
1050 });
1051 }
1052 for mut e in rendered.entries {
1053 ensure_trailing_newline(&mut e);
1054 entries.push(e);
1055 }
1056 } else {
1057 let rendered = render_text_input(
1058 &effective_value,
1059 effective_cursor,
1060 is_focused,
1061 label,
1062 placeholder.as_deref(),
1063 *max_visible_chars,
1064 effective_field_width,
1065 *full_width,
1066 );
1067 new_scroll = 0;
1068 if let Some(byte_in_row) = rendered.cursor_byte_in_entry {
1069 focus_cursor = Some(FocusCursor {
1070 buffer_row: 0,
1071 byte_in_row: byte_in_row as u32,
1072 });
1073 }
1074 let mut entry = rendered.entry;
1075 ensure_trailing_newline(&mut entry);
1076 entries.push(entry);
1077 }
1078 if let Some(k) = key.as_deref().filter(|k| !k.is_empty()) {
1083 let cb = effective_cursor_byte
1084 .max(0)
1085 .min(effective_value.len() as i32) as u32;
1086 next_state.insert(
1087 k.to_string(),
1088 WidgetInstanceState::Text {
1089 value: effective_value.clone(),
1090 cursor_byte: cb,
1091 scroll: new_scroll,
1092 },
1093 );
1094 }
1095 }
1096 WidgetSpec::LabeledSection { label, child, .. } => {
1097 let inner_width = panel_width.saturating_sub(4).max(1);
1100 let (child_entries, child_hits, child_focus, child_embeds) =
1101 render_collected(child, prev, next_state, focus_key, inner_width);
1102
1103 let total_cols = panel_width.max(2) as usize;
1107 entries.push(render_section_top_border(label, total_cols));
1108
1109 for mut child_entry in child_entries {
1114 strip_trailing_newline(&mut child_entry);
1115 let wrapped = wrap_in_side_border(child_entry, inner_width as usize);
1116 let row_offset = entries.len() as u32;
1117 let _ = row_offset;
1122 entries.push(wrapped);
1123 }
1124
1125 let prefix_bytes = LEFT_BORDER_PREFIX.len();
1129 for mut h in child_hits {
1130 h.buffer_row += 1;
1131 h.byte_start += prefix_bytes;
1132 h.byte_end += prefix_bytes;
1133 hits.push(h);
1134 }
1135 if let Some(mut fc) = child_focus {
1136 fc.buffer_row += 1;
1137 fc.byte_in_row += prefix_bytes as u32;
1138 focus_cursor = Some(fc);
1139 }
1140 let prefix_cols = LEFT_BORDER_PREFIX.chars().count() as u32;
1143 for mut emb in child_embeds {
1144 emb.buffer_row += 1;
1145 emb.col_in_row += prefix_cols;
1146 embeds.push(emb);
1147 }
1148
1149 entries.push(render_section_bottom_border(total_cols));
1150 }
1151 WidgetSpec::WindowEmbed {
1152 window_id,
1153 rows: embed_rows,
1154 ..
1155 } => {
1156 let cols = panel_width.max(1) as usize;
1161 for _ in 0..*embed_rows {
1162 let mut text = String::with_capacity(cols + 1);
1163 for _ in 0..cols {
1164 text.push(' ');
1165 }
1166 text.push('\n');
1167 entries.push(TextPropertyEntry {
1168 text,
1169 properties: Default::default(),
1170 style: None,
1171 inline_overlays: Vec::new(),
1172 segments: Vec::new(),
1173 pad_to_chars: None,
1174 truncate_to_chars: None,
1175 });
1176 }
1177 embeds.push(EmbedRect {
1178 window_id: *window_id,
1179 buffer_row: 0,
1180 col_in_row: 0,
1181 width_cols: panel_width,
1182 height_rows: *embed_rows,
1183 });
1184 }
1185 WidgetSpec::Raw {
1186 entries: raw_entries,
1187 ..
1188 } => {
1189 for raw_entry in raw_entries {
1198 let mut e = raw_entry.clone();
1199 e.normalize_widths();
1200 ensure_trailing_newline(&mut e);
1201 entries.push(e);
1202 }
1203 }
1204 }
1205 (entries, hits, focus_cursor, embeds)
1206}
1207
1208const LEFT_BORDER_PREFIX: &str = "│ ";
1213const RIGHT_BORDER_SUFFIX: &str = " │";
1214
1215fn render_section_top_border(label: &str, total_cols: usize) -> TextPropertyEntry {
1226 let mut text = String::new();
1227 let mut overlays: Vec<InlineOverlay> = Vec::new();
1228 text.push('╭');
1229 if label.is_empty() {
1230 for _ in 0..total_cols.saturating_sub(2) {
1231 text.push('─');
1232 }
1233 } else {
1234 let label_cols = label.chars().count();
1239 let used = 1 + 1 + 1 + label_cols + 1; text.push('─');
1241 text.push(' ');
1242 let label_byte_start = text.len();
1243 text.push_str(label);
1244 let label_byte_end = text.len();
1245 text.push(' ');
1246 let remaining = total_cols.saturating_sub(used + 1); for _ in 0..remaining {
1248 text.push('─');
1249 }
1250 overlays.push(InlineOverlay {
1251 start: label_byte_start,
1252 end: label_byte_end,
1253 style: OverlayOptions {
1254 fg: Some(OverlayColorSpec::theme_key(KEY_SECTION_LABEL_FG)),
1255 bold: true,
1256 ..Default::default()
1257 },
1258 properties: Default::default(),
1259 unit: OffsetUnit::Byte,
1260 });
1261 }
1262 text.push('╮');
1263 text.push('\n');
1264 TextPropertyEntry {
1265 text,
1266 properties: Default::default(),
1267 style: None,
1268 inline_overlays: overlays,
1269 segments: Vec::new(),
1270 pad_to_chars: None,
1271 truncate_to_chars: None,
1272 }
1273}
1274
1275fn render_section_bottom_border(total_cols: usize) -> TextPropertyEntry {
1278 let mut text = String::new();
1279 text.push('╰');
1280 for _ in 0..total_cols.saturating_sub(2) {
1281 text.push('─');
1282 }
1283 text.push('╯');
1284 text.push('\n');
1285 TextPropertyEntry {
1286 text,
1287 properties: Default::default(),
1288 style: None,
1289 inline_overlays: Vec::new(),
1290 segments: Vec::new(),
1291 pad_to_chars: None,
1292 truncate_to_chars: None,
1293 }
1294}
1295
1296fn wrap_in_side_border(mut child: TextPropertyEntry, inner_width: usize) -> TextPropertyEntry {
1301 let prefix_bytes = LEFT_BORDER_PREFIX.len();
1302 let cur_cols = child.text.chars().count();
1304 if cur_cols < inner_width {
1305 for _ in 0..(inner_width - cur_cols) {
1306 child.text.push(' ');
1307 }
1308 } else if cur_cols > inner_width {
1309 let indices: Vec<usize> = child.text.char_indices().map(|(i, _)| i).collect();
1312 let byte_cutoff = indices
1313 .get(inner_width)
1314 .copied()
1315 .unwrap_or(child.text.len());
1316 child.text.truncate(byte_cutoff);
1317 child.inline_overlays.retain_mut(|o| {
1320 if o.start >= byte_cutoff {
1321 return false;
1322 }
1323 if o.end > byte_cutoff {
1324 o.end = byte_cutoff;
1325 }
1326 true
1327 });
1328 }
1329
1330 let mut text = String::with_capacity(
1332 LEFT_BORDER_PREFIX.len() + child.text.len() + RIGHT_BORDER_SUFFIX.len() + 1,
1333 );
1334 text.push_str(LEFT_BORDER_PREFIX);
1335 text.push_str(&child.text);
1336 text.push_str(RIGHT_BORDER_SUFFIX);
1337 text.push('\n');
1338
1339 let overlays: Vec<InlineOverlay> = child
1341 .inline_overlays
1342 .into_iter()
1343 .map(|o| InlineOverlay {
1344 start: o.start + prefix_bytes,
1345 end: o.end + prefix_bytes,
1346 style: o.style,
1347 properties: o.properties,
1348 unit: o.unit,
1349 })
1350 .collect();
1351
1352 TextPropertyEntry {
1353 text,
1354 properties: child.properties,
1355 style: child.style,
1356 inline_overlays: overlays,
1357 segments: Vec::new(),
1358 pad_to_chars: None,
1359 truncate_to_chars: None,
1360 }
1361}
1362
1363pub fn render_hint_bar(entries: &[HintEntry]) -> TextPropertyEntry {
1373 let separator = " ";
1374 let mut text = String::new();
1375 let mut overlays = Vec::new();
1376 for (i, entry) in entries.iter().enumerate() {
1377 if i > 0 {
1378 text.push_str(separator);
1379 }
1380 let key_start = text.len();
1381 text.push_str(&entry.keys);
1382 let key_end = text.len();
1383 if key_end > key_start {
1384 overlays.push(InlineOverlay {
1385 start: key_start,
1386 end: key_end,
1387 style: OverlayOptions {
1388 fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
1389 bold: true,
1390 ..Default::default()
1391 },
1392 properties: Default::default(),
1393 unit: OffsetUnit::Byte,
1394 });
1395 }
1396 if !entry.label.is_empty() {
1397 text.push(' ');
1398 text.push_str(&entry.label);
1399 }
1400 }
1401 TextPropertyEntry {
1402 text,
1403 properties: Default::default(),
1404 style: None,
1405 inline_overlays: overlays,
1406 segments: Vec::new(),
1407 pad_to_chars: None,
1408 truncate_to_chars: None,
1409 }
1410}
1411
1412pub fn render_toggle(checked: bool, label: &str, focused: bool) -> TextPropertyEntry {
1420 let glyph = if checked { "[v]" } else { "[ ]" };
1421 let mut text = String::with_capacity(glyph.len() + 1 + label.len());
1422 text.push_str(glyph);
1423 text.push(' ');
1424 text.push_str(label);
1425
1426 let mut overlays = Vec::new();
1427
1428 if checked {
1431 overlays.push(InlineOverlay {
1432 start: 0,
1433 end: glyph.len(),
1434 style: OverlayOptions {
1435 fg: Some(OverlayColorSpec::theme_key(KEY_TOGGLE_ON_FG)),
1436 bold: true,
1437 ..Default::default()
1438 },
1439 properties: Default::default(),
1440 unit: OffsetUnit::Byte,
1441 });
1442 }
1443
1444 if focused {
1446 overlays.push(InlineOverlay {
1447 start: 0,
1448 end: text.len(),
1449 style: OverlayOptions {
1450 fg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_FG)),
1451 bg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG)),
1452 bold: true,
1453 ..Default::default()
1454 },
1455 properties: Default::default(),
1456 unit: OffsetUnit::Byte,
1457 });
1458 }
1459
1460 TextPropertyEntry {
1461 text,
1462 properties: Default::default(),
1463 style: None,
1464 inline_overlays: overlays,
1465 segments: Vec::new(),
1466 pad_to_chars: None,
1467 truncate_to_chars: None,
1468 }
1469}
1470
1471pub fn render_button(label: &str, focused: bool, kind: ButtonKind) -> TextPropertyEntry {
1482 let text = format!("[ {} ]", label);
1483 let mut overlays = Vec::new();
1484
1485 let base_style = match kind {
1486 ButtonKind::Normal => OverlayOptions::default(),
1487 ButtonKind::Primary => OverlayOptions {
1492 fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
1493 bold: true,
1494 ..Default::default()
1495 },
1496 ButtonKind::Danger => OverlayOptions {
1499 fg: Some(OverlayColorSpec::theme_key(KEY_DANGER_FG)),
1500 bold: true,
1501 ..Default::default()
1502 },
1503 };
1504
1505 let style = if focused {
1506 OverlayOptions {
1507 fg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_FG)),
1508 bg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG)),
1509 bold: true,
1510 ..base_style
1511 }
1512 } else {
1513 base_style
1514 };
1515
1516 if style.fg.is_some()
1519 || style.bg.is_some()
1520 || style.bold
1521 || style.italic
1522 || style.underline
1523 || style.strikethrough
1524 {
1525 overlays.push(InlineOverlay {
1526 start: 0,
1527 end: text.len(),
1528 style,
1529 properties: Default::default(),
1530 unit: OffsetUnit::Byte,
1531 });
1532 }
1533
1534 TextPropertyEntry {
1535 text,
1536 properties: Default::default(),
1537 style: None,
1538 inline_overlays: overlays,
1539 segments: Vec::new(),
1540 pad_to_chars: None,
1541 truncate_to_chars: None,
1542 }
1543}
1544
1545pub struct RenderedTreeRow {
1549 pub entry: TextPropertyEntry,
1550 pub disclosure_range: Option<(usize, usize)>,
1553 pub checkbox_range: Option<(usize, usize)>,
1558}
1559
1560pub fn render_tree_row(node: &TreeNode, expanded: bool, checkable: bool) -> RenderedTreeRow {
1578 let indent_cols = (node.depth as usize) * 2;
1579 let disclosure_glyph: &str = if node.has_children {
1580 if expanded {
1581 "▼"
1582 } else {
1583 "▶"
1584 }
1585 } else {
1586 " "
1589 };
1590 let separator: &str = if node.has_children { " " } else { "" };
1595
1596 let checkbox_glyph: Option<&'static str> = if checkable {
1597 match node.checked {
1598 Some(true) => Some("[v]"),
1599 Some(false) => Some("[ ]"),
1600 None => None,
1601 }
1602 } else {
1603 None
1604 };
1605 let checkbox_extra = checkbox_glyph.map(|g| g.len() + 1).unwrap_or(0);
1606
1607 let mut text = String::with_capacity(
1608 indent_cols
1609 + disclosure_glyph.len()
1610 + separator.len()
1611 + checkbox_extra
1612 + node.text.text.len(),
1613 );
1614 for _ in 0..indent_cols {
1615 text.push(' ');
1616 }
1617 let disc_start = text.len();
1618 text.push_str(disclosure_glyph);
1619 let disc_end = text.len();
1620 text.push_str(separator);
1621 let checkbox_range = if let Some(g) = checkbox_glyph {
1622 let cb_start = text.len();
1623 text.push_str(g);
1624 let cb_end = text.len();
1625 text.push(' ');
1626 Some((cb_start, cb_end))
1627 } else {
1628 None
1629 };
1630 let body_start = text.len();
1631 text.push_str(&node.text.text);
1632
1633 let mut overlays: Vec<InlineOverlay> = node
1637 .text
1638 .inline_overlays
1639 .iter()
1640 .map(|o| {
1641 let mut shifted = o.clone();
1642 shifted.start += body_start;
1643 shifted.end += body_start;
1644 shifted
1645 })
1646 .collect();
1647
1648 if node.has_children {
1651 overlays.push(InlineOverlay {
1652 start: disc_start,
1653 end: disc_end,
1654 style: OverlayOptions {
1655 fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
1656 bold: true,
1657 ..Default::default()
1658 },
1659 properties: Default::default(),
1660 unit: OffsetUnit::Byte,
1661 });
1662 }
1663 if let Some((cb_start, cb_end)) = checkbox_range {
1666 let theme_key = match node.checked {
1667 Some(true) => KEY_TOGGLE_ON_FG,
1668 _ => KEY_PLACEHOLDER_FG,
1669 };
1670 overlays.push(InlineOverlay {
1671 start: cb_start,
1672 end: cb_end,
1673 style: OverlayOptions {
1674 fg: Some(OverlayColorSpec::theme_key(theme_key)),
1675 bold: matches!(node.checked, Some(true)),
1676 ..Default::default()
1677 },
1678 properties: Default::default(),
1679 unit: OffsetUnit::Byte,
1680 });
1681 }
1682
1683 let disclosure_range = if node.has_children {
1684 Some((disc_start, disc_end))
1685 } else {
1686 None
1687 };
1688 let entry = TextPropertyEntry {
1689 text,
1690 properties: node.text.properties.clone(),
1694 style: node.text.style.clone(),
1695 inline_overlays: overlays,
1696 segments: Vec::new(),
1701 pad_to_chars: None,
1702 truncate_to_chars: None,
1703 };
1704 RenderedTreeRow {
1705 entry,
1706 disclosure_range,
1707 checkbox_range,
1708 }
1709}
1710
1711pub struct RenderedTextInput {
1715 pub entry: TextPropertyEntry,
1716 pub cursor_byte_in_entry: Option<usize>,
1719}
1720
1721pub fn render_text_input(
1746 value: &str,
1747 cursor_byte: i32,
1748 focused: bool,
1749 label: &str,
1750 placeholder: Option<&str>,
1751 max_visible_chars: u32,
1752 field_width: u32,
1753 full_width: bool,
1754) -> RenderedTextInput {
1755 let show_placeholder = value.is_empty() && placeholder.is_some();
1762
1763 let raw_cursor_byte = if cursor_byte < 0 {
1767 value.len()
1768 } else {
1769 (cursor_byte as usize).min(value.len())
1770 };
1771
1772 let (inner, cursor_in_inner) = if show_placeholder && field_width == 0 {
1776 let inner = placeholder.unwrap_or("").to_string();
1780 let cursor = if focused { Some(0usize) } else { None };
1781 (inner, cursor)
1782 } else if show_placeholder {
1783 let target = field_width as usize;
1790 let pad_extra = if focused || full_width { 1 } else { 0 };
1791 let total_inner = target + pad_extra;
1792 let raw = placeholder.unwrap_or("");
1793 let raw_chars: Vec<char> = raw.chars().collect();
1794 let inner = if raw_chars.len() <= total_inner {
1795 let mut s = raw.to_string();
1796 while s.chars().count() < total_inner {
1797 s.push(' ');
1798 }
1799 s
1800 } else {
1801 let keep = total_inner.saturating_sub(1);
1804 let prefix: String = raw_chars.iter().take(keep).collect();
1805 format!("{}…", prefix)
1806 };
1807 let cursor = if focused { Some(0usize) } else { None };
1808 (inner, cursor)
1809 } else if field_width > 0 {
1810 let target = field_width as usize;
1816 let pad_extra = if focused || full_width { 1 } else { 0 };
1817 let total_inner = target + pad_extra;
1818 let value_chars: Vec<char> = value.chars().collect();
1819 if value_chars.len() <= target {
1820 let mut padded = value.to_string();
1824 while padded.chars().count() < total_inner {
1825 padded.push(' ');
1826 }
1827 (padded, Some(raw_cursor_byte))
1828 } else {
1829 let keep = target - 1;
1833 let drop_chars = value_chars.len() - keep;
1834 let mut dropped_bytes = 0usize;
1835 for ch in value_chars.iter().take(drop_chars) {
1836 dropped_bytes += ch.len_utf8();
1837 }
1838 let tail = &value[dropped_bytes..];
1839 let mut s = String::with_capacity("…".len() + tail.len() + pad_extra);
1840 s.push('…');
1841 s.push_str(tail);
1842 for _ in 0..pad_extra {
1843 s.push(' ');
1844 }
1845 let cursor_in_inner = if raw_cursor_byte < dropped_bytes {
1849 "…".len()
1850 } else {
1851 "…".len() + (raw_cursor_byte - dropped_bytes)
1852 };
1853 (s, Some(cursor_in_inner))
1854 }
1855 } else if max_visible_chars > 0 && value.chars().count() > max_visible_chars as usize {
1856 let chars: Vec<char> = value.chars().collect();
1860 let take = (max_visible_chars as usize).saturating_sub(1);
1861 let start = chars.len().saturating_sub(take);
1862 let tail: String = chars[start..].iter().collect();
1863 let s = format!("…{}", tail);
1864 (s, Some(raw_cursor_byte.min(value.len())))
1865 } else {
1866 let mut s = value.to_string();
1872 if focused {
1873 s.push(' ');
1874 }
1875 (s, Some(raw_cursor_byte))
1876 };
1877
1878 let mut text = String::new();
1880 if !label.is_empty() {
1881 text.push_str(label);
1882 text.push(' ');
1883 }
1884 let bracket_open_byte = text.len();
1885 text.push('[');
1886 let inner_byte_start = text.len();
1887 text.push_str(&inner);
1888 let inner_byte_end = text.len();
1889 text.push(']');
1890 let bracket_close_byte = text.len();
1891
1892 let mut overlays = Vec::new();
1893
1894 if show_placeholder {
1895 overlays.push(InlineOverlay {
1896 start: inner_byte_start,
1897 end: inner_byte_end,
1898 style: OverlayOptions {
1899 fg: Some(OverlayColorSpec::theme_key(KEY_PLACEHOLDER_FG)),
1900 italic: true,
1901 ..Default::default()
1902 },
1903 properties: Default::default(),
1904 unit: OffsetUnit::Byte,
1905 });
1906 }
1907
1908 if focused {
1909 overlays.push(InlineOverlay {
1910 start: bracket_open_byte,
1911 end: bracket_close_byte,
1912 style: OverlayOptions {
1913 bg: Some(OverlayColorSpec::theme_key(KEY_INPUT_BG)),
1914 ..Default::default()
1915 },
1916 properties: Default::default(),
1917 unit: OffsetUnit::Byte,
1918 });
1919 }
1920
1921 let cursor_byte_in_entry = if focused {
1922 cursor_in_inner.map(|c| inner_byte_start + c)
1923 } else {
1924 None
1925 };
1926
1927 RenderedTextInput {
1928 entry: TextPropertyEntry {
1929 text,
1930 properties: Default::default(),
1931 style: None,
1932 inline_overlays: overlays,
1933 segments: Vec::new(),
1934 pad_to_chars: None,
1935 truncate_to_chars: None,
1936 },
1937 cursor_byte_in_entry,
1938 }
1939}
1940
1941pub struct RenderedTextArea {
1944 pub entries: Vec<TextPropertyEntry>,
1949 pub scroll_row: u32,
1953 pub cursor_buffer_row: Option<u32>,
1957 pub cursor_byte_in_row: Option<usize>,
1960}
1961
1962#[allow(clippy::too_many_arguments)]
1989pub fn render_text_area(
1990 value: &str,
1991 cursor_byte: i32,
1992 focused: bool,
1993 label: &str,
1994 placeholder: Option<&str>,
1995 visible_rows: u32,
1996 field_width: u32,
1997 prev_scroll: u32,
1998 panel_width: u32,
1999) -> RenderedTextArea {
2000 let target_width: usize = if field_width > 0 {
2003 field_width as usize
2004 } else if panel_width != u32::MAX && panel_width > 0 {
2005 panel_width as usize
2006 } else {
2007 40
2008 };
2009
2010 let mut lines: Vec<&str> = value.split('\n').collect();
2014 if lines.is_empty() {
2015 lines.push("");
2016 }
2017
2018 let raw_cursor_byte = if cursor_byte < 0 {
2022 value.len()
2023 } else {
2024 (cursor_byte as usize).min(value.len())
2025 };
2026 let (cursor_line, cursor_col) = byte_to_line_col(value, raw_cursor_byte);
2027
2028 let visible_rows_usize = visible_rows.max(1) as usize;
2031 let mut scroll_row = prev_scroll as usize;
2032 if cursor_line < scroll_row {
2033 scroll_row = cursor_line;
2034 } else if cursor_line >= scroll_row + visible_rows_usize {
2035 scroll_row = cursor_line + 1 - visible_rows_usize;
2036 }
2037 let max_scroll = lines.len().saturating_sub(visible_rows_usize);
2039 if scroll_row > max_scroll {
2040 scroll_row = max_scroll;
2041 }
2042
2043 let show_placeholder =
2044 !focused && value.is_empty() && placeholder.is_some() && !placeholder.unwrap().is_empty();
2045
2046 let mut entries: Vec<TextPropertyEntry> = Vec::new();
2047 let mut cursor_buffer_row: Option<u32> = None;
2048 let mut cursor_byte_in_row: Option<usize> = None;
2049
2050 if !label.is_empty() {
2051 let mut text = String::with_capacity(label.len() + 2);
2052 text.push_str(label);
2053 text.push(':');
2054 entries.push(TextPropertyEntry {
2055 text,
2056 properties: Default::default(),
2057 style: None,
2058 inline_overlays: Vec::new(),
2059 segments: Vec::new(),
2060 pad_to_chars: None,
2061 truncate_to_chars: None,
2062 });
2063 }
2064 let label_offset: u32 = entries.len() as u32;
2065
2066 for row_in_view in 0..visible_rows_usize {
2067 let line_idx = scroll_row + row_in_view;
2068 let mut row_text;
2069 let mut overlays: Vec<InlineOverlay> = Vec::new();
2070
2071 if line_idx < lines.len() {
2072 row_text = pad_or_truncate_line(lines[line_idx], target_width);
2073 } else {
2074 row_text = " ".repeat(target_width);
2075 }
2076
2077 if show_placeholder && row_in_view == 0 {
2079 let ph = placeholder.unwrap();
2080 row_text = pad_or_truncate_line(ph, target_width);
2081 overlays.push(InlineOverlay {
2082 start: 0,
2083 end: row_text.len(),
2084 style: OverlayOptions {
2085 fg: Some(OverlayColorSpec::theme_key(KEY_PLACEHOLDER_FG)),
2086 ..Default::default()
2087 },
2088 properties: Default::default(),
2089 unit: OffsetUnit::Byte,
2090 });
2091 }
2092
2093 if focused {
2096 overlays.push(InlineOverlay {
2097 start: 0,
2098 end: row_text.len(),
2099 style: OverlayOptions {
2100 bg: Some(OverlayColorSpec::theme_key(KEY_INPUT_BG)),
2101 ..Default::default()
2102 },
2103 properties: Default::default(),
2104 unit: OffsetUnit::Byte,
2105 });
2106 }
2107
2108 if focused && line_idx == cursor_line && cursor_byte >= 0 {
2110 let col_in_line = cursor_col.min(row_text.len());
2115 cursor_buffer_row = Some(label_offset + row_in_view as u32);
2116 cursor_byte_in_row = Some(col_in_line);
2117 }
2118
2119 entries.push(TextPropertyEntry {
2120 text: row_text,
2121 properties: Default::default(),
2122 style: None,
2123 inline_overlays: overlays,
2124 segments: Vec::new(),
2125 pad_to_chars: None,
2126 truncate_to_chars: None,
2127 });
2128 }
2129
2130 RenderedTextArea {
2131 entries,
2132 scroll_row: scroll_row as u32,
2133 cursor_buffer_row,
2134 cursor_byte_in_row,
2135 }
2136}
2137
2138fn byte_to_line_col(value: &str, byte: usize) -> (usize, usize) {
2140 let byte = byte.min(value.len());
2141 let mut line = 0usize;
2142 let mut line_start = 0usize;
2143 for (i, &b) in value.as_bytes().iter().enumerate().take(byte) {
2144 if b == b'\n' {
2145 line += 1;
2146 line_start = i + 1;
2147 }
2148 }
2149 (line, byte - line_start)
2150}
2151
2152fn pad_or_truncate_line(line: &str, target: usize) -> String {
2158 let chars: Vec<char> = line.chars().collect();
2159 if chars.len() <= target {
2160 let mut out = line.to_string();
2161 let pad = target - chars.len();
2162 for _ in 0..pad {
2163 out.push(' ');
2164 }
2165 out
2166 } else {
2167 let keep = target.saturating_sub(1);
2168 let mut out: String = chars.iter().take(keep).collect();
2169 out.push('…');
2170 out
2171 }
2172}
2173
2174fn merge_inline(merged: &mut TextPropertyEntry, next: &mut TextPropertyEntry) {
2178 let shift = merged.text.len();
2179 merged.text.push_str(&next.text);
2180 for overlay in next.inline_overlays.drain(..) {
2181 merged.inline_overlays.push(InlineOverlay {
2182 start: overlay.start + shift,
2183 end: overlay.end + shift,
2184 style: overlay.style,
2185 properties: overlay.properties,
2186 unit: overlay.unit,
2187 });
2188 }
2189 }
2195
2196fn pad_or_truncate_cols(text: &mut String, cols: usize) {
2201 let cur = text.chars().count();
2202 if cur < cols {
2203 for _ in 0..(cols - cur) {
2204 text.push(' ');
2205 }
2206 } else if cur > cols {
2207 let cutoff = text
2208 .char_indices()
2209 .nth(cols)
2210 .map(|(i, _)| i)
2211 .unwrap_or(text.len());
2212 text.truncate(cutoff);
2213 }
2214}
2215
2216fn zip_row_blocks(
2239 pieces: Vec<RowPiece>,
2240 panel_width: u32,
2241 out_entries: &mut Vec<TextPropertyEntry>,
2242 out_hits: &mut Vec<HitArea>,
2243 out_focus_cursor: &mut Option<FocusCursor>,
2244 out_embeds: &mut Vec<EmbedRect>,
2245) {
2246 let starting_row = out_entries.len() as u32;
2247 let _ = panel_width;
2248
2249 let max_height = pieces
2251 .iter()
2252 .filter_map(|p| match p {
2253 RowPiece::Block { entries, .. } => Some(entries.len()),
2254 _ => None,
2255 })
2256 .max()
2257 .unwrap_or(0);
2258 if max_height == 0 {
2259 return;
2260 }
2261
2262 for row_idx in 0..max_height {
2263 let mut text = String::new();
2264 let mut overlays: Vec<InlineOverlay> = Vec::new();
2265 for piece in &pieces {
2266 match piece {
2267 RowPiece::Inline {
2268 entry,
2269 hits,
2270 focus_cursor,
2271 embeds: inline_embeds,
2272 } => {
2273 let inline_cols = entry.text.chars().count();
2274 let byte_shift = text.len();
2275 let col_shift = text.chars().count() as u32;
2279 if row_idx == 0 {
2280 text.push_str(&entry.text);
2281 for emb in inline_embeds {
2282 out_embeds.push(EmbedRect {
2283 window_id: emb.window_id,
2284 buffer_row: starting_row + emb.buffer_row,
2285 col_in_row: emb.col_in_row + col_shift,
2286 width_cols: emb.width_cols,
2287 height_rows: emb.height_rows,
2288 });
2289 }
2290 for overlay in &entry.inline_overlays {
2291 overlays.push(InlineOverlay {
2292 start: overlay.start + byte_shift,
2293 end: overlay.end + byte_shift,
2294 style: overlay.style.clone(),
2295 properties: overlay.properties.clone(),
2296 unit: overlay.unit,
2297 });
2298 }
2299 for h in hits {
2300 let mut h = h.clone();
2301 h.byte_start += byte_shift;
2302 h.byte_end += byte_shift;
2303 h.buffer_row = starting_row;
2304 out_hits.push(h);
2305 }
2306 if let Some(fc) = focus_cursor {
2307 *out_focus_cursor = Some(FocusCursor {
2308 buffer_row: starting_row,
2309 byte_in_row: fc.byte_in_row + byte_shift as u32,
2310 });
2311 }
2312 } else {
2313 for _ in 0..inline_cols {
2314 text.push(' ');
2315 }
2316 }
2317 }
2318 RowPiece::Flex => {
2319 }
2321 RowPiece::Block {
2322 column_width,
2323 entries,
2324 hits,
2325 focus_cursor,
2326 embeds: block_embeds,
2327 } => {
2328 let block_w = *column_width as usize;
2329 let byte_shift = text.len();
2330 let col_shift = text.chars().count() as u32;
2333 if row_idx == 0 {
2338 for emb in block_embeds {
2339 out_embeds.push(EmbedRect {
2340 window_id: emb.window_id,
2341 buffer_row: starting_row + emb.buffer_row,
2342 col_in_row: emb.col_in_row + col_shift,
2343 width_cols: emb.width_cols,
2344 height_rows: emb.height_rows,
2345 });
2346 }
2347 }
2348 if let Some(line) = entries.get(row_idx) {
2349 let mut line_text = line.text.clone();
2350 if line_text.ends_with('\n') {
2353 line_text.pop();
2354 }
2355 let original_byte_len = line_text.len();
2356 pad_or_truncate_cols(&mut line_text, block_w);
2357 let padded_byte_len = line_text.len();
2358 text.push_str(&line_text);
2359 if let Some(line_style) = &line.style {
2369 overlays.push(InlineOverlay {
2370 start: byte_shift,
2371 end: byte_shift + padded_byte_len,
2372 style: line_style.clone(),
2373 properties: Default::default(),
2374 unit: OffsetUnit::Byte,
2375 });
2376 }
2377 for overlay in &line.inline_overlays {
2378 let new_end = overlay.end.min(original_byte_len);
2382 if overlay.start >= original_byte_len {
2383 continue;
2384 }
2385 overlays.push(InlineOverlay {
2386 start: overlay.start + byte_shift,
2387 end: new_end + byte_shift,
2388 style: overlay.style.clone(),
2389 properties: overlay.properties.clone(),
2390 unit: overlay.unit,
2391 });
2392 }
2393 for h in hits {
2394 if h.buffer_row != row_idx as u32 {
2395 continue;
2396 }
2397 let mut h = h.clone();
2398 h.byte_start += byte_shift;
2399 h.byte_end += byte_shift;
2400 h.buffer_row = starting_row + row_idx as u32;
2401 out_hits.push(h);
2402 }
2403 if let Some(fc) = focus_cursor {
2404 if fc.buffer_row == row_idx as u32 {
2405 *out_focus_cursor = Some(FocusCursor {
2406 buffer_row: starting_row + row_idx as u32,
2407 byte_in_row: fc.byte_in_row + byte_shift as u32,
2408 });
2409 }
2410 }
2411 } else {
2412 for _ in 0..block_w {
2415 text.push(' ');
2416 }
2417 }
2418 }
2419 }
2420 }
2421 text.push('\n');
2422 out_entries.push(TextPropertyEntry {
2423 text,
2424 properties: Default::default(),
2425 style: None,
2426 inline_overlays: overlays,
2427 segments: Vec::new(),
2428 pad_to_chars: None,
2429 truncate_to_chars: None,
2430 });
2431 }
2432}
2433
2434#[cfg(test)]
2435mod tests {
2436 use super::*;
2437
2438 fn render_no_focus(
2443 spec: &WidgetSpec,
2444 prev: &HashMap<String, WidgetInstanceState>,
2445 ) -> (
2446 Vec<TextPropertyEntry>,
2447 Vec<HitArea>,
2448 HashMap<String, WidgetInstanceState>,
2449 ) {
2450 let out = render_spec(spec, prev, "", u32::MAX);
2452 (out.entries, out.hits, out.instance_states)
2453 }
2454
2455 #[test]
2456 fn hint_bar_renders_entries_with_key_overlays() {
2457 let entries = vec![
2458 HintEntry {
2459 keys: "Tab".into(),
2460 label: "next".into(),
2461 },
2462 HintEntry {
2463 keys: "Esc".into(),
2464 label: "close".into(),
2465 },
2466 ];
2467 let entry = render_hint_bar(&entries);
2468 assert_eq!(entry.text, "Tab next Esc close");
2469 assert_eq!(entry.inline_overlays.len(), 2);
2470 assert_eq!(entry.inline_overlays[0].start, 0);
2472 assert_eq!(entry.inline_overlays[0].end, 3);
2473 assert_eq!(entry.inline_overlays[1].start, 10);
2475 assert_eq!(entry.inline_overlays[1].end, 13);
2476 }
2477
2478 #[test]
2479 fn hint_bar_omits_label_when_empty() {
2480 let entries = vec![HintEntry {
2481 keys: "?".into(),
2482 label: "".into(),
2483 }];
2484 let entry = render_hint_bar(&entries);
2485 assert_eq!(entry.text, "?");
2486 }
2487
2488 #[test]
2489 fn col_stacks_children_top_to_bottom() {
2490 let spec = WidgetSpec::Col {
2491 children: vec![
2492 WidgetSpec::HintBar {
2493 entries: vec![HintEntry {
2494 keys: "A".into(),
2495 label: "alpha".into(),
2496 }],
2497 key: None,
2498 },
2499 WidgetSpec::HintBar {
2500 entries: vec![HintEntry {
2501 keys: "B".into(),
2502 label: "beta".into(),
2503 }],
2504 key: None,
2505 },
2506 ],
2507 key: None,
2508 };
2509 let (out, hits, _state) = render_no_focus(&spec, &HashMap::new());
2510 assert_eq!(out.len(), 2);
2511 assert_eq!(out[0].text, "A alpha\n");
2512 assert_eq!(out[1].text, "B beta\n");
2513 assert!(hits.is_empty(), "HintBar emits no hit areas in v1");
2514 }
2515
2516 #[test]
2517 fn raw_passes_through_unchanged() {
2518 let spec = WidgetSpec::Raw {
2519 entries: vec![TextPropertyEntry::text("hello")],
2520 key: None,
2521 };
2522 let (out, hits, _state) = render_no_focus(&spec, &HashMap::new());
2523 assert_eq!(out.len(), 1);
2524 assert_eq!(out[0].text, "hello\n");
2525 assert!(hits.is_empty());
2526 }
2527
2528 #[test]
2529 fn toggle_checked_emits_glyph_overlay() {
2530 let entry = render_toggle(true, "Case", false);
2531 assert_eq!(entry.text, "[v] Case");
2532 assert_eq!(entry.inline_overlays.len(), 1);
2534 assert_eq!(entry.inline_overlays[0].start, 0);
2535 assert_eq!(entry.inline_overlays[0].end, 3);
2536 }
2537
2538 #[test]
2539 fn toggle_unchecked_no_glyph_overlay() {
2540 let entry = render_toggle(false, "Case", false);
2541 assert_eq!(entry.text, "[ ] Case");
2542 assert_eq!(entry.inline_overlays.len(), 0);
2543 }
2544
2545 #[test]
2546 fn toggle_focused_adds_full_entry_overlay() {
2547 let entry = render_toggle(true, "Case", true);
2548 assert_eq!(entry.inline_overlays.len(), 2);
2550 assert_eq!(entry.inline_overlays[1].start, 0);
2552 assert_eq!(entry.inline_overlays[1].end, entry.text.len());
2553 assert!(entry.inline_overlays[1].style.bold);
2554 }
2555
2556 #[test]
2557 fn button_normal_unfocused_has_no_overlay() {
2558 let entry = render_button("Replace All", false, ButtonKind::Normal);
2559 assert_eq!(entry.text, "[ Replace All ]");
2560 assert!(entry.inline_overlays.is_empty());
2561 }
2562
2563 #[test]
2564 fn button_primary_unfocused_is_bold_help_key_fg_with_no_bg() {
2565 let entry = render_button("Submit", false, ButtonKind::Primary);
2570 assert_eq!(entry.inline_overlays.len(), 1);
2571 let style = &entry.inline_overlays[0].style;
2572 assert!(style.bold);
2573 assert_eq!(
2574 style.fg.as_ref().and_then(|c| c.as_theme_key()),
2575 Some("ui.help_key_fg"),
2576 );
2577 assert!(style.bg.is_none(), "unfocused primary must not paint a bg");
2578 }
2579
2580 #[test]
2581 fn button_danger_uses_error_theme_key() {
2582 let entry = render_button("Delete", false, ButtonKind::Danger);
2583 assert_eq!(entry.inline_overlays.len(), 1);
2584 let fg = entry.inline_overlays[0].style.fg.as_ref().unwrap();
2585 assert_eq!(fg.as_theme_key(), Some("diagnostic.error_fg"));
2586 assert!(entry.inline_overlays[0].style.bold);
2587 }
2588
2589 #[test]
2590 fn button_focused_overrides_with_menu_active_keys() {
2591 let entry = render_button("OK", true, ButtonKind::Normal);
2592 let style = &entry.inline_overlays[0].style;
2593 assert_eq!(
2594 style.fg.as_ref().and_then(|c| c.as_theme_key()),
2595 Some("ui.menu_active_fg")
2596 );
2597 assert_eq!(
2598 style.bg.as_ref().and_then(|c| c.as_theme_key()),
2599 Some("ui.menu_active_bg")
2600 );
2601 assert!(style.bold);
2602 }
2603
2604 #[test]
2605 fn flex_spacer_fills_remaining_row_width() {
2606 let spec = WidgetSpec::Row {
2607 children: vec![
2608 WidgetSpec::Toggle {
2609 checked: false,
2610 label: "A".into(),
2611 focused: false,
2612 key: None,
2613 },
2614 WidgetSpec::Spacer {
2615 cols: 0,
2616 flex: true,
2617 key: None,
2618 },
2619 WidgetSpec::Button {
2620 label: "B".into(),
2621 focused: false,
2622 intent: ButtonKind::Normal,
2623 key: None,
2624 },
2625 ],
2626 key: None,
2627 };
2628 let out = render_spec(&spec, &HashMap::new(), "", 30);
2632 assert_eq!(out.entries.len(), 1);
2633 let text = &out.entries[0].text;
2634 assert_eq!(text.len(), 31);
2635 assert!(text.starts_with("[ ] A"));
2636 assert!(text.ends_with("[ B ]\n"));
2637 let button_hit = out.hits.iter().find(|h| h.widget_kind == "button").unwrap();
2638 assert_eq!(button_hit.byte_start, 25);
2639 assert_eq!(button_hit.byte_end, 30);
2640 }
2641
2642 #[test]
2643 fn flex_spacer_with_no_leftover_collapses_to_zero() {
2644 let spec = WidgetSpec::Row {
2645 children: vec![
2646 WidgetSpec::Toggle {
2647 checked: false,
2648 label: "A".into(),
2649 focused: false,
2650 key: None,
2651 },
2652 WidgetSpec::Spacer {
2653 cols: 0,
2654 flex: true,
2655 key: None,
2656 },
2657 WidgetSpec::Toggle {
2658 checked: false,
2659 label: "B".into(),
2660 focused: false,
2661 key: None,
2662 },
2663 ],
2664 key: None,
2665 };
2666 let out = render_spec(&spec, &HashMap::new(), "", 10);
2668 assert_eq!(out.entries[0].text, "[ ] A[ ] B\n");
2669 }
2670
2671 #[test]
2672 fn spacer_in_row_pads_with_spaces() {
2673 let spec = WidgetSpec::Row {
2674 children: vec![
2675 WidgetSpec::Toggle {
2676 checked: false,
2677 label: "A".into(),
2678 focused: false,
2679 key: None,
2680 },
2681 WidgetSpec::Spacer {
2682 cols: 4,
2683 flex: false,
2684 key: None,
2685 },
2686 WidgetSpec::Button {
2687 label: "Go".into(),
2688 focused: false,
2689 intent: ButtonKind::Normal,
2690 key: None,
2691 },
2692 ],
2693 key: None,
2694 };
2695 let (out, _hits, _state) = render_no_focus(&spec, &HashMap::new());
2696 assert_eq!(out.len(), 1);
2697 assert_eq!(out[0].text, "[ ] A [ Go ]\n");
2698 }
2699
2700 #[test]
2701 fn row_collapses_inline_children_with_shifted_overlays() {
2702 let spec = WidgetSpec::Row {
2703 children: vec![
2704 WidgetSpec::HintBar {
2705 entries: vec![HintEntry {
2706 keys: "Tab".into(),
2707 label: "x".into(),
2708 }],
2709 key: None,
2710 },
2711 WidgetSpec::HintBar {
2712 entries: vec![HintEntry {
2713 keys: "Esc".into(),
2714 label: "y".into(),
2715 }],
2716 key: None,
2717 },
2718 ],
2719 key: None,
2720 };
2721 let (out, _hits, _state) = render_no_focus(&spec, &HashMap::new());
2722 assert_eq!(out.len(), 1);
2723 assert_eq!(out[0].text, "Tab xEsc y\n");
2725 assert_eq!(out[0].inline_overlays.len(), 2);
2726 assert_eq!(out[0].inline_overlays[1].start, 5);
2727 assert_eq!(out[0].inline_overlays[1].end, 8);
2728 }
2729
2730 #[test]
2735 fn toggle_emits_hit_area_with_toggle_payload() {
2736 let spec = WidgetSpec::Toggle {
2737 checked: false,
2738 label: "Case".into(),
2739 focused: false,
2740 key: Some("case".into()),
2741 };
2742 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
2743 assert_eq!(hits.len(), 1);
2744 let h = &hits[0];
2745 assert_eq!(h.widget_key, "case");
2746 assert_eq!(h.widget_kind, "toggle");
2747 assert_eq!(h.event_type, "toggle");
2748 assert_eq!(h.buffer_row, 0);
2749 assert_eq!(h.byte_start, 0);
2750 assert_eq!(h.byte_end, "[ ] Case".len());
2751 assert_eq!(h.payload, json!({"checked": true}));
2752 }
2753
2754 #[test]
2755 fn button_emits_hit_area_with_activate_payload() {
2756 let spec = WidgetSpec::Button {
2757 label: "Replace All".into(),
2758 focused: false,
2759 intent: ButtonKind::Primary,
2760 key: Some("replace".into()),
2761 };
2762 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
2763 assert_eq!(hits.len(), 1);
2764 let h = &hits[0];
2765 assert_eq!(h.widget_key, "replace");
2766 assert_eq!(h.widget_kind, "button");
2767 assert_eq!(h.event_type, "activate");
2768 assert_eq!(h.byte_end, "[ Replace All ]".len());
2769 assert_eq!(h.payload, json!({}));
2770 }
2771
2772 #[test]
2773 fn row_inline_collapse_shifts_hit_byte_offsets() {
2774 let spec = WidgetSpec::Row {
2775 children: vec![
2776 WidgetSpec::Toggle {
2777 checked: true,
2778 label: "A".into(),
2779 focused: false,
2780 key: Some("a".into()),
2781 },
2782 WidgetSpec::Spacer {
2783 cols: 2,
2784 flex: false,
2785 key: None,
2786 },
2787 WidgetSpec::Toggle {
2788 checked: false,
2789 label: "B".into(),
2790 focused: false,
2791 key: Some("b".into()),
2792 },
2793 ],
2794 key: None,
2795 };
2796 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
2797 assert_eq!(entries.len(), 1);
2799 assert_eq!(entries[0].text, "[v] A [ ] B\n");
2800 assert_eq!(hits.len(), 2);
2801 assert_eq!(hits[0].widget_key, "a");
2802 assert_eq!(hits[0].buffer_row, 0);
2803 assert_eq!(hits[0].byte_start, 0);
2804 assert_eq!(hits[0].byte_end, 5); assert_eq!(hits[1].widget_key, "b");
2808 assert_eq!(hits[1].buffer_row, 0);
2809 assert_eq!(hits[1].byte_start, 7);
2810 assert_eq!(hits[1].byte_end, 12);
2811 }
2812
2813 #[test]
2814 fn col_stacks_hit_rows() {
2815 let spec = WidgetSpec::Col {
2816 children: vec![
2817 WidgetSpec::Toggle {
2818 checked: false,
2819 label: "row0".into(),
2820 focused: false,
2821 key: Some("k0".into()),
2822 },
2823 WidgetSpec::Toggle {
2824 checked: true,
2825 label: "row1".into(),
2826 focused: false,
2827 key: Some("k1".into()),
2828 },
2829 ],
2830 key: None,
2831 };
2832 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
2833 assert_eq!(hits.len(), 2);
2834 assert_eq!(hits[0].buffer_row, 0);
2835 assert_eq!(hits[1].buffer_row, 1);
2836 }
2837
2838 #[test]
2843 fn collect_tabbable_visits_widgets_with_keys_in_declaration_order() {
2844 let spec = WidgetSpec::Col {
2845 children: vec![
2846 WidgetSpec::HintBar {
2847 entries: vec![],
2848 key: Some("hb".into()),
2849 },
2850 WidgetSpec::Row {
2851 children: vec![
2852 WidgetSpec::Toggle {
2853 checked: false,
2854 label: "T".into(),
2855 focused: false,
2856 key: Some("t".into()),
2857 },
2858 WidgetSpec::Spacer {
2859 cols: 1,
2860 flex: false,
2861 key: None,
2862 },
2863 WidgetSpec::Button {
2864 label: "B".into(),
2865 focused: false,
2866 intent: ButtonKind::Normal,
2867 key: Some("b".into()),
2868 },
2869 ],
2870 key: None,
2871 },
2872 WidgetSpec::Text {
2873 value: "".into(),
2874 cursor_byte: -1,
2875 focused: false,
2876 label: "".into(),
2877 placeholder: None,
2878 rows: 1,
2879 field_width: 0,
2880 max_visible_chars: 0,
2881 full_width: false,
2882 key: Some("ti".into()),
2883 },
2884 WidgetSpec::Toggle {
2885 checked: false,
2886 label: "no key".into(),
2887 focused: false,
2888 key: None,
2889 },
2890 ],
2891 key: None,
2892 };
2893 let mut tabbable = Vec::new();
2894 collect_tabbable(&spec, &mut tabbable);
2895 assert_eq!(tabbable, vec!["t", "b", "ti"]);
2898 }
2899
2900 #[test]
2901 fn first_render_focuses_first_tabbable() {
2902 let spec = WidgetSpec::Row {
2903 children: vec![
2904 WidgetSpec::Toggle {
2905 checked: false,
2906 label: "A".into(),
2907 focused: false,
2908 key: Some("a".into()),
2909 },
2910 WidgetSpec::Toggle {
2911 checked: false,
2912 label: "B".into(),
2913 focused: false,
2914 key: Some("b".into()),
2915 },
2916 ],
2917 key: None,
2918 };
2919 let out = render_spec(&spec, &HashMap::new(), "", u32::MAX);
2920 assert_eq!(out.focus_key, "a");
2921 assert_eq!(out.tabbable, vec!["a", "b"]);
2922 }
2923
2924 #[test]
2925 fn render_preserves_focus_key_across_re_renders() {
2926 let spec = WidgetSpec::Row {
2927 children: vec![
2928 WidgetSpec::Toggle {
2929 checked: false,
2930 label: "A".into(),
2931 focused: false,
2932 key: Some("a".into()),
2933 },
2934 WidgetSpec::Toggle {
2935 checked: false,
2936 label: "B".into(),
2937 focused: false,
2938 key: Some("b".into()),
2939 },
2940 ],
2941 key: None,
2942 };
2943 let out = render_spec(&spec, &HashMap::new(), "b", u32::MAX);
2944 assert_eq!(out.focus_key, "b");
2945 }
2946
2947 #[test]
2948 fn render_clamps_stale_focus_key_to_first_tabbable() {
2949 let spec = WidgetSpec::Toggle {
2953 checked: false,
2954 label: "Only".into(),
2955 focused: false,
2956 key: Some("only".into()),
2957 };
2958 let out = render_spec(&spec, &HashMap::new(), "stale", u32::MAX);
2959 assert_eq!(out.focus_key, "only");
2960 }
2961
2962 #[test]
2963 fn focused_widget_renders_with_focused_styling() {
2964 let spec = WidgetSpec::Row {
2965 children: vec![
2966 WidgetSpec::Toggle {
2967 checked: false,
2968 label: "A".into(),
2969 focused: false,
2970 key: Some("a".into()),
2971 },
2972 WidgetSpec::Toggle {
2973 checked: false,
2974 label: "B".into(),
2975 focused: false,
2976 key: Some("b".into()),
2977 },
2978 ],
2979 key: None,
2980 };
2981 let out = render_spec(&spec, &HashMap::new(), "b", u32::MAX);
2982 assert_eq!(out.entries.len(), 1, "row collapses inline");
2983 let entry = &out.entries[0];
2988 let focused_overlay = entry
2989 .inline_overlays
2990 .iter()
2991 .find(|o| {
2992 o.style.bg.as_ref().and_then(|c| c.as_theme_key()) == Some("ui.menu_active_bg")
2993 })
2994 .expect("focused overlay present on B");
2995 assert_eq!(focused_overlay.start, 5);
2998 assert_eq!(focused_overlay.end, 10);
2999 }
3000
3001 #[test]
3002 fn no_tabbables_yields_empty_focus_key() {
3003 let spec = WidgetSpec::Col {
3004 children: vec![WidgetSpec::HintBar {
3005 entries: vec![],
3006 key: None,
3007 }],
3008 key: None,
3009 };
3010 let out = render_spec(&spec, &HashMap::new(), "", u32::MAX);
3011 assert_eq!(out.focus_key, "");
3012 assert!(out.tabbable.is_empty());
3013 }
3014
3015 #[test]
3020 fn list_emits_one_entry_and_one_hit_per_item() {
3021 let spec = WidgetSpec::List {
3022 items: vec![
3023 TextPropertyEntry::text("alpha"),
3024 TextPropertyEntry::text("beta"),
3025 TextPropertyEntry::text("gamma"),
3026 ],
3027 item_keys: vec!["a".into(), "b".into(), "c".into()],
3028 selected_index: -1,
3029 visible_rows: 10,
3030 focusable: true,
3031 key: None,
3032 };
3033 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
3034 assert_eq!(entries.len(), 3);
3035 assert_eq!(hits.len(), 3);
3036 for (i, h) in hits.iter().enumerate() {
3037 assert_eq!(h.buffer_row, i as u32);
3038 assert_eq!(h.widget_kind, "list");
3039 assert_eq!(h.event_type, "select");
3040 assert_eq!(h.payload["index"], i);
3041 }
3042 assert_eq!(hits[0].widget_key, "a");
3043 assert_eq!(hits[2].widget_key, "c");
3044 }
3045
3046 #[test]
3047 fn list_applies_selection_bg_to_selected_row() {
3048 let spec = WidgetSpec::List {
3049 items: vec![
3050 TextPropertyEntry::text("first"),
3051 TextPropertyEntry::text("second"),
3052 ],
3053 item_keys: vec!["x".into(), "y".into()],
3054 selected_index: 1,
3055 visible_rows: 10,
3056 focusable: true,
3057 key: None,
3058 };
3059 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3060 assert!(entries[0].style.is_none(), "unselected row keeps no style");
3061 let style = entries[1].style.as_ref().expect("selected row gets style");
3062 assert_eq!(
3063 style.bg.as_ref().and_then(|c| c.as_theme_key()),
3064 Some("ui.menu_active_bg"),
3065 );
3066 assert!(style.extend_to_line_end);
3067 }
3068
3069 #[test]
3070 fn list_inside_col_offsets_hit_rows_by_preceding_lines() {
3071 let spec = WidgetSpec::Col {
3072 children: vec![
3073 WidgetSpec::HintBar {
3074 entries: vec![HintEntry {
3075 keys: "h".into(),
3076 label: "header".into(),
3077 }],
3078 key: None,
3079 },
3080 WidgetSpec::List {
3081 items: vec![
3082 TextPropertyEntry::text("row0"),
3083 TextPropertyEntry::text("row1"),
3084 ],
3085 item_keys: vec!["a".into(), "b".into()],
3086 selected_index: -1,
3087 visible_rows: 10,
3088 key: None,
3089 focusable: true,
3090 },
3091 ],
3092 key: None,
3093 };
3094 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
3095 assert_eq!(entries.len(), 3);
3096 assert_eq!(hits.len(), 2);
3097 assert_eq!(hits[0].buffer_row, 1);
3099 assert_eq!(hits[1].buffer_row, 2);
3100 }
3101
3102 #[test]
3103 fn list_payload_includes_absolute_index_and_key() {
3104 let spec = WidgetSpec::List {
3105 items: vec![TextPropertyEntry::text("only")],
3106 item_keys: vec!["match:42".into()],
3107 selected_index: 0,
3108 visible_rows: 10,
3109 focusable: true,
3110 key: None,
3111 };
3112 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
3113 assert_eq!(hits[0].payload["index"], 0);
3114 assert_eq!(hits[0].payload["key"], "match:42");
3115 }
3116
3117 #[test]
3118 fn list_with_missing_key_emits_empty_widget_key() {
3119 let spec = WidgetSpec::List {
3120 items: vec![TextPropertyEntry::text("a"), TextPropertyEntry::text("b")],
3121 item_keys: vec!["only".into()],
3123 selected_index: -1,
3124 visible_rows: 10,
3125 focusable: true,
3126 key: None,
3127 };
3128 let (_, hits, _state) = render_no_focus(&spec, &HashMap::new());
3129 assert_eq!(hits[0].widget_key, "only");
3130 assert_eq!(hits[1].widget_key, "");
3131 }
3132
3133 fn make_list(selected: i32, visible: u32, total: usize, key: Option<&str>) -> WidgetSpec {
3134 let items = (0..total)
3135 .map(|i| TextPropertyEntry::text(format!("row{}", i)))
3136 .collect();
3137 let item_keys = (0..total).map(|i| format!("k{}", i)).collect();
3138 WidgetSpec::List {
3139 items,
3140 item_keys,
3141 selected_index: selected,
3142 visible_rows: visible,
3143 focusable: true,
3144 key: key.map(|s| s.to_string()),
3145 }
3146 }
3147
3148 #[test]
3149 fn list_renders_only_visible_window() {
3150 let spec = make_list(-1, 3, 10, Some("L"));
3151 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
3152 assert_eq!(entries.len(), 3);
3153 assert_eq!(hits.len(), 3);
3154 assert_eq!(hits[0].payload["index"], 0);
3156 assert_eq!(hits[2].payload["index"], 2);
3157 }
3158
3159 #[test]
3160 fn list_scrolls_to_keep_selected_below_window_in_view() {
3161 let spec = make_list(5, 3, 10, Some("L"));
3166 let (_entries, hits, state) = render_no_focus(&spec, &HashMap::new());
3167 assert_eq!(hits.len(), 3);
3169 assert_eq!(hits[0].payload["index"], 3);
3170 assert_eq!(hits[2].payload["index"], 5);
3171 let scroll = match state.get("L").unwrap() {
3172 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
3173 _ => unreachable!(),
3174 };
3175 assert_eq!(scroll, 3);
3176 }
3177
3178 #[test]
3179 fn list_scrolls_to_keep_selected_above_window_in_view() {
3180 let mut prev = HashMap::new();
3186 prev.insert(
3187 "L".into(),
3188 WidgetInstanceState::List {
3189 scroll_offset: 5,
3190 selected_index: 1,
3191 },
3192 );
3193 let spec = make_list(99, 3, 10, Some("L"));
3195 let (_entries, hits, state) = render_no_focus(&spec, &prev);
3196 assert_eq!(hits[0].payload["index"], 1);
3197 let scroll = match state.get("L").unwrap() {
3198 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
3199 _ => unreachable!(),
3200 };
3201 assert_eq!(scroll, 1);
3202 }
3203
3204 #[test]
3205 fn list_scroll_preserved_when_selection_remains_in_view() {
3206 let mut prev = HashMap::new();
3209 prev.insert(
3210 "L".into(),
3211 WidgetInstanceState::List {
3212 scroll_offset: 4,
3213 selected_index: 5,
3214 },
3215 );
3216 let spec = make_list(99, 3, 10, Some("L"));
3217 let (_entries, hits, state) = render_no_focus(&spec, &prev);
3218 assert_eq!(hits[0].payload["index"], 4);
3219 let scroll = match state.get("L").unwrap() {
3220 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
3221 _ => unreachable!(),
3222 };
3223 assert_eq!(scroll, 4);
3224 }
3225
3226 #[test]
3227 fn list_clamps_scroll_to_max_when_dataset_is_smaller_than_old_offset() {
3228 let mut prev = HashMap::new();
3231 prev.insert(
3232 "L".into(),
3233 WidgetInstanceState::List {
3234 scroll_offset: 8,
3235 selected_index: -1,
3236 },
3237 );
3238 let spec = make_list(-1, 3, 5, Some("L"));
3239 let (entries, _hits, state) = render_no_focus(&spec, &prev);
3240 assert_eq!(entries.len(), 3);
3241 let scroll = match state.get("L").unwrap() {
3242 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
3243 _ => unreachable!(),
3244 };
3245 assert_eq!(scroll, 2);
3247 }
3248
3249 #[test]
3250 fn list_does_not_scroll_when_total_smaller_than_visible() {
3251 let spec = make_list(-1, 10, 3, Some("L"));
3252 let (entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
3253 assert_eq!(entries.len(), 3, "all items fit");
3254 let scroll = match state.get("L").unwrap() {
3255 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
3256 _ => unreachable!(),
3257 };
3258 assert_eq!(scroll, 0);
3259 }
3260
3261 #[test]
3262 fn list_without_key_does_not_persist_state() {
3263 let spec = make_list(5, 3, 10, None);
3264 let (_entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
3265 assert!(
3266 state.is_empty(),
3267 "Lists without a `key` opt out of state preservation"
3268 );
3269 }
3270
3271 #[test]
3276 fn text_input_renders_value_in_brackets() {
3277 let entry = render_text_input("hello", -1, false, "", None, 0, 0, false).entry;
3278 assert_eq!(entry.text, "[hello]");
3279 assert!(entry.inline_overlays.is_empty());
3280 }
3281
3282 #[test]
3283 fn text_input_with_label_prefixes_with_label_space() {
3284 let entry = render_text_input("foo", -1, false, "Search:", None, 0, 0, false).entry;
3285 assert_eq!(entry.text, "Search: [foo]");
3286 }
3287
3288 #[test]
3289 fn text_input_focused_adds_input_bg_overlay() {
3290 let entry = render_text_input("x", -1, true, "", None, 0, 0, false).entry;
3291 assert_eq!(entry.inline_overlays.len(), 1);
3293 let bg = entry.inline_overlays[0].style.bg.as_ref().unwrap();
3294 assert_eq!(bg.as_theme_key(), Some("ui.prompt_bg"));
3295 }
3296
3297 #[test]
3298 fn text_input_cursor_byte_in_entry_at_value_position() {
3299 let r = render_text_input("abc", 1, true, "", None, 0, 0, false);
3304 assert_eq!(r.cursor_byte_in_entry, Some(2));
3305 }
3306
3307 #[test]
3308 fn text_input_cursor_at_end_lands_on_padding_space_not_bracket() {
3309 let r = render_text_input("ab", 2, true, "", None, 0, 0, false);
3315 assert_eq!(r.entry.text, "[ab ]");
3316 assert_eq!(r.cursor_byte_in_entry, Some(3));
3317 assert_ne!(r.cursor_byte_in_entry, Some(4), "must not overlap ]");
3318 }
3319
3320 #[test]
3321 fn text_input_unfocused_empty_shows_placeholder_in_muted() {
3322 let entry = render_text_input("", -1, false, "", Some("type here"), 0, 0, false).entry;
3323 assert_eq!(entry.text, "[type here]");
3324 let placeholder_overlay = entry
3326 .inline_overlays
3327 .iter()
3328 .find(|o| o.style.fg.as_ref().and_then(|c| c.as_theme_key()).is_some())
3329 .expect("placeholder fg overlay");
3330 let fg = placeholder_overlay.style.fg.as_ref().unwrap();
3331 assert_eq!(fg.as_theme_key(), Some("editor.whitespace_indicator_fg"));
3332 assert!(placeholder_overlay.style.italic);
3333 }
3334
3335 #[test]
3336 fn text_input_focused_empty_still_shows_placeholder() {
3337 let r = render_text_input("", -1, true, "", Some("type here"), 0, 0, false);
3341 assert_eq!(r.entry.text, "[type here]");
3342 assert_eq!(r.cursor_byte_in_entry, Some(1));
3343 }
3344
3345 #[test]
3346 fn text_input_field_width_pads_short_value_unfocused() {
3347 let r = render_text_input("hi", 2, false, "", None, 0, 10, false);
3350 assert_eq!(r.entry.text, "[hi ]");
3351 }
3352
3353 #[test]
3354 fn text_input_field_width_focused_adds_cursor_park_space() {
3355 let r = render_text_input("0123456789", 10, true, "", None, 0, 10, false);
3359 assert_eq!(r.entry.text, "[0123456789 ]");
3360 assert_eq!(r.cursor_byte_in_entry, Some(11));
3364 assert_ne!(r.cursor_byte_in_entry, Some(12), "must not land on ]");
3365 }
3366
3367 #[test]
3368 fn text_input_field_width_full_width_pads_to_same_size_when_unfocused() {
3369 let r = render_text_input("hi", -1, false, "", None, 0, 10, true);
3373 assert_eq!(r.entry.text, "[hi ]"); }
3375
3376 #[test]
3377 fn text_input_field_width_head_truncates_long_value() {
3378 let r = render_text_input(
3381 "0123456789abcdefghijklmnopqrst",
3382 30,
3383 false,
3384 "",
3385 None,
3386 0,
3387 10,
3388 false,
3389 );
3390 assert!(r.entry.text.contains("…lmnopqrst"));
3391 }
3392
3393 #[test]
3394 fn text_input_field_width_clamps_cursor_in_dropped_prefix() {
3395 let r = render_text_input("abcdefghij", 0, true, "", None, 0, 5, false);
3398 assert_eq!(r.cursor_byte_in_entry, Some(1 + "…".len()));
3403 }
3404
3405 #[test]
3406 fn text_input_truncates_long_value_keeping_tail_visible() {
3407 let value: String = "0123456789abcdefghij".to_string();
3408 let entry = render_text_input(&value, -1, false, "", None, 6, 0, false).entry;
3409 assert_eq!(entry.text, "[…fghij]");
3411 }
3412
3413 #[test]
3414 fn raw_inside_col_offsets_following_hits() {
3415 let spec = WidgetSpec::Col {
3416 children: vec![
3417 WidgetSpec::Raw {
3418 entries: vec![
3419 TextPropertyEntry::text("line0"),
3420 TextPropertyEntry::text("line1"),
3421 TextPropertyEntry::text("line2"),
3422 ],
3423 key: None,
3424 },
3425 WidgetSpec::Toggle {
3426 checked: false,
3427 label: "after raw".into(),
3428 focused: false,
3429 key: Some("post".into()),
3430 },
3431 ],
3432 key: None,
3433 };
3434 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
3435 assert_eq!(entries.len(), 4);
3436 assert_eq!(hits.len(), 1);
3437 assert_eq!(hits[0].buffer_row, 3);
3438 }
3439
3440 fn tnode(text: &str, depth: u32, has_children: bool) -> TreeNode {
3445 TreeNode {
3446 text: TextPropertyEntry::text(text),
3447 depth,
3448 has_children,
3449 checked: None,
3450 }
3451 }
3452
3453 fn make_tree(
3454 nodes: Vec<TreeNode>,
3455 item_keys: Vec<&str>,
3456 selected: i32,
3457 visible: u32,
3458 expanded: Vec<&str>,
3459 key: Option<&str>,
3460 ) -> WidgetSpec {
3461 WidgetSpec::Tree {
3462 nodes,
3463 item_keys: item_keys.iter().map(|s| s.to_string()).collect(),
3464 selected_index: selected,
3465 visible_rows: visible,
3466 expanded_keys: expanded.iter().map(|s| s.to_string()).collect(),
3467 checkable: false,
3468 key: key.map(|s| s.to_string()),
3469 }
3470 }
3471
3472 #[test]
3473 fn tree_row_renders_disclosure_glyph_for_internal_collapsed() {
3474 let r = render_tree_row(&tnode("file.txt", 0, true), false, false);
3475 assert!(r.entry.text.starts_with('\u{25B6}'), "starts with ▶");
3476 assert!(r.entry.text.contains("file.txt"));
3477 assert!(r.disclosure_range.is_some());
3478 }
3479
3480 #[test]
3481 fn tree_row_renders_disclosure_glyph_for_internal_expanded() {
3482 let r = render_tree_row(&tnode("file.txt", 0, true), true, false);
3483 assert!(r.entry.text.starts_with('\u{25BC}'), "starts with ▼");
3484 }
3485
3486 #[test]
3487 fn tree_row_leaf_uses_two_spaces_no_disclosure_hit() {
3488 let r = render_tree_row(&tnode("match", 0, false), false, false);
3489 assert!(r.entry.text.starts_with(" "));
3491 assert!(r.entry.text.contains("match"));
3492 assert!(r.disclosure_range.is_none());
3493 }
3494
3495 #[test]
3496 fn tree_row_indents_by_depth_times_two() {
3497 let r = render_tree_row(&tnode("nested", 2, false), false, false);
3498 assert!(r.entry.text.starts_with(" nested"));
3500 }
3501
3502 #[test]
3503 fn tree_row_shifts_plugin_overlays_by_prefix() {
3504 let mut node = tnode("hello", 1, false);
3505 node.text.inline_overlays.push(InlineOverlay {
3506 start: 0,
3507 end: 5,
3508 style: OverlayOptions {
3509 bold: true,
3510 ..Default::default()
3511 },
3512 properties: Default::default(),
3513 unit: OffsetUnit::Byte,
3514 });
3515 let r = render_tree_row(&node, false, false);
3516 let plugin_overlay = r
3519 .entry
3520 .inline_overlays
3521 .iter()
3522 .find(|o| o.style.bold)
3523 .expect("bold overlay carried through");
3524 assert_eq!(plugin_overlay.start, 4);
3525 assert_eq!(plugin_overlay.end, 9);
3526 }
3527
3528 #[test]
3529 fn tree_row_omits_checkbox_when_not_checkable() {
3530 let mut node = tnode("file.rs", 0, false);
3532 node.checked = Some(true);
3533 let r = render_tree_row(&node, false, false);
3534 assert!(r.checkbox_range.is_none());
3535 assert!(!r.entry.text.contains("[v]"));
3536 assert!(!r.entry.text.contains("[ ]"));
3537 }
3538
3539 #[test]
3540 fn tree_row_omits_checkbox_when_checked_is_none() {
3541 let node = tnode("section", 0, false);
3545 let r = render_tree_row(&node, false, true);
3546 assert!(r.checkbox_range.is_none());
3547 assert!(!r.entry.text.contains("[v]"));
3548 assert!(!r.entry.text.contains("[ ]"));
3549 }
3550
3551 #[test]
3552 fn tree_row_renders_checked_glyph_after_disclosure() {
3553 let mut node = tnode("file.rs", 0, true);
3554 node.checked = Some(true);
3555 let r = render_tree_row(&node, true, true);
3556 assert!(r.checkbox_range.is_some(), "checkbox range emitted");
3557 let (cb_start, cb_end) = r.checkbox_range.unwrap();
3558 assert_eq!(&r.entry.text[cb_start..cb_end], "[v]");
3560 assert!(r.entry.text.contains("[v] file.rs"));
3561 }
3562
3563 #[test]
3564 fn tree_row_renders_unchecked_glyph_for_leaf() {
3565 let mut node = tnode("match-row", 1, false);
3566 node.checked = Some(false);
3567 let r = render_tree_row(&node, false, true);
3568 let (cb_start, cb_end) = r
3569 .checkbox_range
3570 .expect("checkbox range for leaf with checked: Some");
3571 assert_eq!(&r.entry.text[cb_start..cb_end], "[ ]");
3572 assert!(r.entry.text.starts_with(" [ ] match-row"));
3574 }
3575
3576 #[test]
3577 fn tree_row_checkbox_glyph_byte_range_addresses_correct_text() {
3578 let mut node = tnode("path/with/é", 0, true);
3581 node.checked = Some(true);
3582 let r = render_tree_row(&node, false, true);
3583 let (cb_start, cb_end) = r.checkbox_range.unwrap();
3584 assert!(r.entry.text.is_char_boundary(cb_start));
3585 assert!(r.entry.text.is_char_boundary(cb_end));
3586 assert_eq!(&r.entry.text[cb_start..cb_end], "[v]");
3587 }
3588
3589 #[test]
3590 fn tree_node_pad_to_chars_pads_text_before_prefix_offset_shift() {
3591 let mut node = tnode("x", 0, true);
3595 node.text.pad_to_chars = Some(5);
3596 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec!["x"], Some("T"));
3597 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3598 assert_eq!(entries.len(), 1);
3599 let trimmed = entries[0].text.trim_end_matches('\n');
3602 assert!(
3603 trimmed.ends_with("x "),
3604 "row should end with the padded body, got {trimmed:?}"
3605 );
3606 }
3607
3608 #[test]
3609 fn tree_node_truncate_to_chars_cuts_body_before_prefix_offset_shift() {
3610 let mut node = tnode("abcdefghij", 0, false);
3611 node.text.truncate_to_chars = Some(6);
3612 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
3613 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3614 let trimmed = entries[0].text.trim_end_matches('\n');
3615 assert!(
3618 trimmed.ends_with("abc..."),
3619 "row should end with truncated body, got {trimmed:?}"
3620 );
3621 }
3622
3623 #[test]
3624 fn tree_node_char_unit_overlay_resolves_against_padded_text_and_shifts_by_prefix() {
3625 let mut node = tnode("x", 0, false);
3630 node.text.pad_to_chars = Some(5);
3631 node.text.inline_overlays.push(InlineOverlay {
3632 start: 0,
3633 end: 5,
3634 style: OverlayOptions {
3635 bold: true,
3636 ..Default::default()
3637 },
3638 properties: Default::default(),
3639 unit: OffsetUnit::Char,
3640 });
3641 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
3642 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3643 let entry = &entries[0];
3644 let bold = entry
3645 .inline_overlays
3646 .iter()
3647 .find(|o| o.style.bold)
3648 .expect("bold overlay carried through");
3649 assert_eq!(bold.start, 2);
3652 assert_eq!(bold.end, 7);
3653 }
3654
3655 #[test]
3656 fn tree_node_char_unit_overlay_with_multibyte_body_resolves_correctly() {
3657 let mut node = tnode("éxé", 0, false);
3661 node.text.inline_overlays.push(InlineOverlay {
3662 start: 1,
3663 end: 2,
3664 style: OverlayOptions {
3665 bold: true,
3666 ..Default::default()
3667 },
3668 properties: Default::default(),
3669 unit: OffsetUnit::Char,
3670 });
3671 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
3672 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3673 let entry = &entries[0];
3674 let bold = entry
3675 .inline_overlays
3676 .iter()
3677 .find(|o| o.style.bold)
3678 .expect("bold overlay carried through");
3679 let trimmed = entry.text.trim_end_matches('\n');
3682 assert_eq!(bold.start, 4);
3683 assert_eq!(bold.end, 5);
3684 assert_eq!(&trimmed[bold.start..bold.end], "x");
3685 }
3686
3687 #[test]
3688 fn tree_node_segments_concatenate_into_row_text_with_per_segment_overlays() {
3689 let mut node = tnode("", 0, false);
3690 node.text.segments = vec![
3691 fresh_core::text_property::StyledSegment {
3692 text: "AB".to_string(),
3693 style: None,
3694 overlays: vec![],
3695 },
3696 fresh_core::text_property::StyledSegment {
3697 text: " ".to_string(),
3698 style: None,
3699 overlays: vec![],
3700 },
3701 fresh_core::text_property::StyledSegment {
3702 text: "CD".to_string(),
3703 style: Some(OverlayOptions {
3704 bold: true,
3705 ..Default::default()
3706 }),
3707 overlays: vec![],
3708 },
3709 ];
3710 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
3711 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3712 let trimmed = entries[0].text.trim_end_matches('\n');
3713 assert!(
3715 trimmed.ends_with("AB CD"),
3716 "row should end with concatenated segments, got {trimmed:?}"
3717 );
3718 let bold = entries[0]
3719 .inline_overlays
3720 .iter()
3721 .find(|o| o.style.bold)
3722 .expect("styled segment overlay carried through");
3723 assert_eq!(&trimmed[bold.start..bold.end], "CD");
3726 }
3727
3728 #[test]
3729 fn tree_node_segment_nested_overlay_shifts_to_segment_position() {
3730 let mut node = tnode("", 0, false);
3735 node.text.segments = vec![
3736 fresh_core::text_property::StyledSegment {
3737 text: "AB".to_string(),
3738 style: None,
3739 overlays: vec![],
3740 },
3741 fresh_core::text_property::StyledSegment {
3742 text: " - ".to_string(),
3743 style: None,
3744 overlays: vec![],
3745 },
3746 fresh_core::text_property::StyledSegment {
3747 text: "CDEFG".to_string(),
3748 style: None,
3749 overlays: vec![InlineOverlay {
3750 start: 0,
3751 end: 3,
3752 style: OverlayOptions {
3753 bold: true,
3754 ..Default::default()
3755 },
3756 properties: Default::default(),
3757 unit: OffsetUnit::Char,
3758 }],
3759 },
3760 ];
3761 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
3762 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3763 let trimmed = entries[0].text.trim_end_matches('\n');
3764 let bold = entries[0]
3765 .inline_overlays
3766 .iter()
3767 .find(|o| o.style.bold)
3768 .expect("nested overlay carried through");
3769 assert_eq!(&trimmed[bold.start..bold.end], "CDE");
3770 }
3771
3772 #[test]
3773 fn tree_node_segments_with_pad_pad_after_concatenation() {
3774 let mut node = tnode("", 0, false);
3775 node.text.segments = vec![fresh_core::text_property::StyledSegment {
3776 text: "ab".to_string(),
3777 style: None,
3778 overlays: vec![],
3779 }];
3780 node.text.pad_to_chars = Some(5);
3781 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
3782 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3783 let trimmed = entries[0].text.trim_end_matches('\n');
3784 assert!(
3786 trimmed.ends_with("ab "),
3787 "row should be padded after segment concat, got {trimmed:?}"
3788 );
3789 }
3790
3791 #[test]
3792 fn tree_renders_only_top_level_when_nothing_expanded() {
3793 let spec = make_tree(
3794 vec![
3795 tnode("a", 0, true),
3796 tnode("a.0", 1, false),
3797 tnode("a.1", 1, false),
3798 tnode("b", 0, true),
3799 tnode("b.0", 1, false),
3800 ],
3801 vec!["a", "a.0", "a.1", "b", "b.0"],
3802 -1,
3803 10,
3804 vec![], Some("T"),
3806 );
3807 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3808 assert_eq!(entries.len(), 2);
3810 assert!(entries[0].text.contains('a'));
3811 assert!(entries[1].text.contains('b'));
3812 }
3813
3814 #[test]
3815 fn tree_renders_children_of_expanded_nodes() {
3816 let spec = make_tree(
3817 vec![
3818 tnode("a", 0, true),
3819 tnode("a.0", 1, false),
3820 tnode("a.1", 1, false),
3821 tnode("b", 0, true),
3822 tnode("b.0", 1, false),
3823 ],
3824 vec!["a", "a.0", "a.1", "b", "b.0"],
3825 -1,
3826 10,
3827 vec!["a"],
3828 Some("T"),
3829 );
3830 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3831 assert_eq!(entries.len(), 4);
3833 }
3834
3835 #[test]
3836 fn tree_emits_two_hits_per_internal_row_one_per_leaf() {
3837 let spec = make_tree(
3840 vec![tnode("a", 0, true), tnode("a.0", 1, false)],
3841 vec!["a", "a.0"],
3842 -1,
3843 10,
3844 vec!["a"],
3845 Some("T"),
3846 );
3847 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
3848 assert_eq!(hits.len(), 3);
3849 assert_eq!(hits[0].event_type, "expand");
3851 assert_eq!(hits[0].widget_kind, "tree");
3852 assert_eq!(hits[1].event_type, "select");
3853 assert_eq!(hits[2].event_type, "select");
3854 }
3855
3856 #[test]
3857 fn tree_hits_carry_tree_spec_key_and_per_item_key_in_payload() {
3858 let spec = make_tree(
3859 vec![tnode("only", 0, false)],
3860 vec!["only-key"],
3861 -1,
3862 10,
3863 vec![],
3864 Some("matchTree"),
3865 );
3866 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
3867 assert_eq!(hits[0].widget_key, "matchTree");
3868 assert_eq!(hits[0].payload["key"], "only-key");
3869 assert_eq!(hits[0].payload["index"], 0);
3870 }
3871
3872 #[test]
3873 fn tree_persists_expanded_keys_in_instance_state() {
3874 let spec = make_tree(
3875 vec![tnode("a", 0, true), tnode("a.0", 1, false)],
3876 vec!["a", "a.0"],
3877 -1,
3878 10,
3879 vec!["a"],
3880 Some("T"),
3881 );
3882 let (_, _, state) = render_no_focus(&spec, &HashMap::new());
3883 match state.get("T").unwrap() {
3884 WidgetInstanceState::Tree { expanded_keys, .. } => {
3885 assert!(expanded_keys.contains("a"));
3886 }
3887 _ => unreachable!(),
3888 }
3889 }
3890
3891 #[test]
3892 fn tree_instance_state_overrides_spec_expanded_keys() {
3893 let mut prev = HashMap::new();
3896 prev.insert(
3897 "T".into(),
3898 WidgetInstanceState::Tree {
3899 scroll_offset: 0,
3900 selected_index: -1,
3901 expanded_keys: ["b".to_string()].iter().cloned().collect(),
3902 },
3903 );
3904 let spec = make_tree(
3905 vec![
3906 tnode("a", 0, true),
3907 tnode("a.0", 1, false),
3908 tnode("b", 0, true),
3909 tnode("b.0", 1, false),
3910 ],
3911 vec!["a", "a.0", "b", "b.0"],
3912 -1,
3913 10,
3914 vec!["a"], Some("T"),
3916 );
3917 let (entries, _hits, _state) = render_no_focus(&spec, &prev);
3918 assert_eq!(entries.len(), 3);
3920 }
3921
3922 #[test]
3923 fn tree_selected_row_gets_focused_bg() {
3924 let spec = make_tree(
3925 vec![tnode("a", 0, false), tnode("b", 0, false)],
3926 vec!["a", "b"],
3927 1,
3928 10,
3929 vec![],
3930 Some("T"),
3931 );
3932 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
3933 assert!(entries[0].style.is_none());
3934 let style = entries[1].style.as_ref().expect("selected gets style");
3935 assert_eq!(
3936 style.bg.as_ref().and_then(|c| c.as_theme_key()),
3937 Some("ui.menu_active_bg")
3938 );
3939 assert!(style.extend_to_line_end);
3940 }
3941
3942 #[test]
3943 fn tree_clamps_selection_to_visible_when_selected_node_is_hidden() {
3944 let spec = make_tree(
3948 vec![tnode("a", 0, true), tnode("a.0", 1, false)],
3949 vec!["a", "a.0"],
3950 1,
3951 10,
3952 vec![], Some("T"),
3954 );
3955 let (_entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
3956 match state.get("T").unwrap() {
3957 WidgetInstanceState::Tree { selected_index, .. } => {
3958 assert_eq!(*selected_index, 0);
3959 }
3960 _ => unreachable!(),
3961 }
3962 }
3963
3964 #[test]
3965 fn tree_scrolls_to_keep_selection_in_visible_window() {
3966 let spec = make_tree(
3970 vec![
3971 tnode("0", 0, false),
3972 tnode("1", 0, false),
3973 tnode("2", 0, false),
3974 tnode("3", 0, false),
3975 tnode("4", 0, false),
3976 tnode("5", 0, false),
3977 ],
3978 vec!["k0", "k1", "k2", "k3", "k4", "k5"],
3979 4,
3980 3,
3981 vec![],
3982 Some("T"),
3983 );
3984 let (entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
3985 assert_eq!(entries.len(), 3);
3987 match state.get("T").unwrap() {
3988 WidgetInstanceState::Tree { scroll_offset, .. } => assert_eq!(*scroll_offset, 2),
3989 _ => unreachable!(),
3990 }
3991 }
3992
3993 #[test]
3994 fn tree_tabbable_keys_include_tree_with_key() {
3995 let spec = WidgetSpec::Col {
3996 children: vec![
3997 WidgetSpec::Toggle {
3998 checked: false,
3999 label: "T".into(),
4000 focused: false,
4001 key: Some("toggle".into()),
4002 },
4003 make_tree(
4004 vec![tnode("a", 0, false)],
4005 vec!["a"],
4006 -1,
4007 10,
4008 vec![],
4009 Some("tree"),
4010 ),
4011 ],
4012 key: None,
4013 };
4014 let mut tabbable = Vec::new();
4015 collect_tabbable(&spec, &mut tabbable);
4016 assert_eq!(tabbable, vec!["toggle", "tree"]);
4017 }
4018
4019 fn make_text_area(
4024 value: &str,
4025 cursor_byte: i32,
4026 focused: bool,
4027 rows: u32,
4028 field_width: u32,
4029 key: Option<&str>,
4030 ) -> WidgetSpec {
4031 WidgetSpec::Text {
4032 value: value.into(),
4033 cursor_byte,
4034 focused,
4035 label: String::new(),
4036 placeholder: None,
4037 rows: rows.max(2),
4042 field_width,
4043 max_visible_chars: 0,
4044 full_width: false,
4045 key: key.map(|s| s.into()),
4046 }
4047 }
4048
4049 #[test]
4050 fn text_area_renders_visible_rows_count() {
4051 let spec = make_text_area("hi", -1, false, 3, 10, Some("ta"));
4054 let prev = HashMap::new();
4055 let out = render_spec(&spec, &prev, "", 80);
4056 assert_eq!(out.entries.len(), 3);
4057 }
4058
4059 #[test]
4060 fn text_area_pads_short_lines_to_field_width() {
4061 let spec = make_text_area("hi", -1, false, 1, 6, Some("ta"));
4062 let prev = HashMap::new();
4063 let out = render_spec(&spec, &prev, "", 80);
4064 let first = &out.entries[0];
4066 assert_eq!(first.text, "hi \n");
4067 }
4068
4069 #[test]
4070 fn text_area_truncates_long_line_with_ellipsis() {
4071 let spec = make_text_area("abcdefghi", -1, false, 1, 5, Some("ta"));
4072 let prev = HashMap::new();
4073 let out = render_spec(&spec, &prev, "", 80);
4074 assert_eq!(out.entries[0].text, "abcd…\n");
4076 }
4077
4078 #[test]
4079 fn text_area_focused_adds_input_bg_overlay_per_row() {
4080 let spec = make_text_area("a\nb", -1, true, 3, 4, Some("ta"));
4081 let prev = HashMap::new();
4082 let out = render_spec(&spec, &prev, "ta", 80);
4083 for entry in &out.entries {
4084 let has_bg = entry.inline_overlays.iter().any(|o| {
4085 o.style
4086 .bg
4087 .as_ref()
4088 .and_then(|c| c.as_theme_key())
4089 .map(|k| k == "ui.prompt_bg")
4090 .unwrap_or(false)
4091 });
4092 assert!(has_bg, "every focused row gets input-bg");
4093 }
4094 }
4095
4096 #[test]
4097 fn text_area_publishes_focus_cursor_at_value_position() {
4098 let spec = make_text_area("ab\ncd", 4, true, 3, 6, Some("ta"));
4101 let prev = HashMap::new();
4102 let out = render_spec(&spec, &prev, "ta", 80);
4103 let fc = out.focus_cursor.expect("focused → cursor published");
4104 assert_eq!(fc.buffer_row, 1);
4106 assert_eq!(fc.byte_in_row, 1);
4108 }
4109
4110 #[test]
4111 fn text_area_label_offsets_cursor_buffer_row() {
4112 let spec = WidgetSpec::Text {
4116 value: "hi".into(),
4117 cursor_byte: 1,
4118 focused: true,
4119 label: "Note".into(),
4120 placeholder: None,
4121 rows: 2,
4122 field_width: 6,
4123 max_visible_chars: 0,
4124 full_width: false,
4125 key: Some("ta".into()),
4126 };
4127 let prev = HashMap::new();
4128 let out = render_spec(&spec, &prev, "ta", 80);
4129 assert!(out.entries[0].text.starts_with("Note:"));
4131 let fc = out.focus_cursor.unwrap();
4132 assert_eq!(fc.buffer_row, 1);
4133 }
4134
4135 #[test]
4136 fn text_area_persists_value_and_cursor_in_instance_state() {
4137 let spec = make_text_area("abc", 2, true, 2, 8, Some("ta"));
4138 let prev = HashMap::new();
4139 let out = render_spec(&spec, &prev, "ta", 80);
4140 match out.instance_states.get("ta") {
4141 Some(WidgetInstanceState::Text {
4142 value, cursor_byte, ..
4143 }) => {
4144 assert_eq!(value, "abc");
4145 assert_eq!(*cursor_byte, 2);
4146 }
4147 other => panic!("expected Text instance state, got {:?}", other),
4148 }
4149 }
4150
4151 #[test]
4152 fn text_area_instance_state_overrides_spec_value() {
4153 let spec = make_text_area("old", 0, true, 2, 8, Some("ta"));
4156 let mut prev = HashMap::new();
4157 prev.insert(
4158 "ta".into(),
4159 WidgetInstanceState::Text {
4160 value: "new".into(),
4161 cursor_byte: 3,
4162 scroll: 0,
4163 },
4164 );
4165 let out = render_spec(&spec, &prev, "ta", 80);
4166 assert!(out.entries[0].text.starts_with("new"));
4168 }
4169
4170 #[test]
4171 fn text_area_scroll_clamps_to_keep_cursor_visible() {
4172 let spec = make_text_area("a\nb\nc\nd\ne", 8, true, 2, 4, Some("ta"));
4176 let prev = HashMap::new();
4178 let out = render_spec(&spec, &prev, "ta", 80);
4179 match out.instance_states.get("ta") {
4180 Some(WidgetInstanceState::Text { scroll, .. }) => {
4181 assert_eq!(*scroll, 3, "scroll so lines 3..5 are visible");
4182 }
4183 _ => panic!("expected Text instance state"),
4184 }
4185 }
4186
4187 #[test]
4188 fn text_area_unfocused_empty_shows_placeholder_in_first_row() {
4189 let r = render_text_area("", -1, false, "", Some("write here"), 2, 12, 0, 80);
4194 assert!(r.entries[0].text.starts_with("write here"));
4195 let fg = r.entries[0]
4197 .inline_overlays
4198 .iter()
4199 .find_map(|o| o.style.fg.as_ref())
4200 .and_then(|c| c.as_theme_key());
4201 assert_eq!(fg, Some("editor.whitespace_indicator_fg"));
4202 }
4203
4204 #[test]
4205 fn text_area_tabbable_keys_include_text_area_with_key() {
4206 let spec = WidgetSpec::Col {
4207 children: vec![
4208 WidgetSpec::Toggle {
4209 checked: false,
4210 label: "T".into(),
4211 focused: false,
4212 key: Some("toggle".into()),
4213 },
4214 make_text_area("", -1, false, 3, 10, Some("note")),
4215 ],
4216 key: None,
4217 };
4218 let mut tabbable = Vec::new();
4219 collect_tabbable(&spec, &mut tabbable);
4220 assert_eq!(tabbable, vec!["toggle", "note"]);
4221 }
4222
4223 fn make_text_input(
4228 value: &str,
4229 cursor_byte: i32,
4230 focused: bool,
4231 full_width: bool,
4232 field_width: u32,
4233 key: Option<&str>,
4234 ) -> WidgetSpec {
4235 WidgetSpec::Text {
4236 value: value.into(),
4237 cursor_byte,
4238 focused,
4239 label: String::new(),
4240 placeholder: None,
4241 rows: 1,
4242 field_width,
4243 max_visible_chars: 0,
4244 full_width,
4245 key: key.map(|s| s.into()),
4246 }
4247 }
4248
4249 #[test]
4250 fn labeled_section_renders_three_rows_with_legend() {
4251 let spec = WidgetSpec::LabeledSection {
4252 label: "Name".into(),
4253 child: Box::new(make_text_input("hi", -1, false, false, 4, Some("n"))),
4254 width_pct: None,
4255 key: None,
4256 };
4257 let prev = HashMap::new();
4258 let out = render_spec(&spec, &prev, "", 20);
4259 assert_eq!(out.entries.len(), 3);
4261 assert!(out.entries[0].text.starts_with("╭─ Name "));
4263 assert!(out.entries[0].text.ends_with("╮\n"));
4264 assert!(out.entries[1].text.starts_with("│ "));
4266 assert!(out.entries[1].text.ends_with(" │\n"));
4267 assert!(out.entries[2].text.starts_with("╰"));
4269 assert!(out.entries[2].text.ends_with("╯\n"));
4270 }
4271
4272 #[test]
4273 fn labeled_section_pads_child_to_inner_width() {
4274 let spec = WidgetSpec::LabeledSection {
4275 label: "".into(),
4276 child: Box::new(make_text_input("hi", -1, false, false, 4, Some("n"))),
4277 width_pct: None,
4278 key: None,
4279 };
4280 let prev = HashMap::new();
4281 let out = render_spec(&spec, &prev, "", 16);
4284 let middle = &out.entries[1];
4285 assert_eq!(middle.text.chars().count(), 16 + 1 );
4287 }
4288
4289 #[test]
4290 fn labeled_section_text_full_width_fills_inner_area() {
4291 let spec = WidgetSpec::LabeledSection {
4297 label: "".into(),
4298 child: Box::new(make_text_input("ab", -1, false, true, 0, Some("n"))),
4299 width_pct: None,
4300 key: None,
4301 };
4302 let prev = HashMap::new();
4303 let out = render_spec(&spec, &prev, "", 16);
4304 let middle = &out.entries[1];
4305 assert_eq!(middle.text.chars().count(), 17, "actual: {:?}", middle.text);
4309 assert!(
4310 middle.text.contains("[ab ]"),
4311 "actual: {:?}",
4312 middle.text
4313 );
4314 }
4315
4316 #[test]
4317 fn labeled_section_propagates_focus_cursor_with_offsets() {
4318 let spec = WidgetSpec::LabeledSection {
4319 label: "".into(),
4320 child: Box::new(make_text_input("abc", 3, true, false, 4, Some("n"))),
4321 width_pct: None,
4322 key: None,
4323 };
4324 let prev = HashMap::new();
4325 let out = render_spec(&spec, &prev, "n", 20);
4326 let fc = out.focus_cursor.expect("focused child publishes cursor");
4327 assert_eq!(fc.buffer_row, 1);
4329 let prefix_bytes = LEFT_BORDER_PREFIX.len() as u32;
4333 assert_eq!(fc.byte_in_row, prefix_bytes + 1 + 3);
4334 }
4335
4336 #[test]
4337 fn labeled_section_includes_child_in_tabbable() {
4338 let spec = WidgetSpec::Col {
4339 children: vec![
4340 WidgetSpec::LabeledSection {
4341 label: "Name".into(),
4342 child: Box::new(make_text_input("", -1, false, false, 0, Some("n"))),
4343 width_pct: None,
4344 key: None,
4345 },
4346 WidgetSpec::LabeledSection {
4347 label: "Cmd".into(),
4348 child: Box::new(make_text_input("", -1, false, false, 0, Some("c"))),
4349 width_pct: None,
4350 key: None,
4351 },
4352 ],
4353 key: None,
4354 };
4355 let mut tabbable = Vec::new();
4356 collect_tabbable(&spec, &mut tabbable);
4357 assert_eq!(tabbable, vec!["n", "c"]);
4358 }
4359}