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.help_key_fg";
45const KEY_FOCUSED_FG: &str = "ui.popup_selection_fg";
56const KEY_FOCUSED_BG: &str = "ui.popup_selection_bg";
57const FOCUS_MARKER: &str = "▸ ";
69const FOCUS_GUTTER_BLANK: &str = " ";
73
74fn focus_gutter_prefix(focused: bool) -> &'static str {
80 if !marker_gutter_enabled() {
81 ""
82 } else if focused {
83 FOCUS_MARKER
84 } else {
85 FOCUS_GUTTER_BLANK
86 }
87}
88const KEY_DANGER_FG: &str = "diagnostic.error_fg";
93const KEY_INPUT_BG: &str = "ui.prompt_bg";
94const KEY_TEXT_INPUT_SELECTION_BG: &str = "ui.text_input_selection_bg";
100const KEY_PLACEHOLDER_FG: &str = "editor.whitespace_indicator_fg";
105const KEY_SECTION_LABEL_FG: &str = "ui.help_key_fg";
110const KEY_COMPLETION_DIM_FG: &str = "ui.menu_disabled_fg";
117const KEY_COMPLETION_SEL_FG: &str = "ui.popup_selection_fg";
122const KEY_COMPLETION_SEL_BG: &str = "ui.popup_selection_bg";
123const KEY_COMPLETION_FG: &str = "ui.popup_text_fg";
128const KEY_COMPLETION_BORDER_FG: &str = "ui.popup_border_fg";
135
136#[derive(Debug, Clone, Copy)]
145pub struct FocusCursor {
146 pub buffer_row: u32,
147 pub byte_in_row: u32,
148}
149
150pub struct RenderOutput {
168 pub entries: Vec<TextPropertyEntry>,
169 pub hits: Vec<HitArea>,
170 pub instance_states: HashMap<String, WidgetInstanceState>,
171 pub focus_key: String,
172 pub tabbable: Vec<String>,
173 pub focus_cursor: Option<FocusCursor>,
174 pub embeds: Vec<EmbedRect>,
179 pub overlays: Vec<OverlayRow>,
188 pub scroll_regions: Vec<ScrollRegion>,
192}
193
194#[derive(Debug, Clone)]
199pub struct OverlayRow {
200 pub buffer_row: u32,
201 pub entry: TextPropertyEntry,
202}
203
204#[derive(Debug, Clone, Copy)]
212pub struct EmbedRect {
213 pub window_id: u32,
214 pub buffer_row: u32,
215 pub col_in_row: u32,
216 pub width_cols: u32,
217 pub height_rows: u32,
218}
219
220#[derive(Debug, Clone)]
229pub struct ScrollRegion {
230 pub list_key: String,
231 pub buffer_row: u32,
232 pub col_in_row: u32,
233 pub width_cols: u32,
234 pub height_rows: u32,
235 pub total: usize,
236 pub visible: usize,
237 pub scroll: usize,
238}
239
240#[derive(Default)]
245struct CollectedOutput {
246 entries: Vec<TextPropertyEntry>,
247 hits: Vec<HitArea>,
248 focus_cursor: Option<FocusCursor>,
249 embeds: Vec<EmbedRect>,
250 overlays: Vec<OverlayRow>,
251 scroll_regions: Vec<ScrollRegion>,
252}
253
254pub fn render_spec(
264 spec: &WidgetSpec,
265 prev: &HashMap<String, WidgetInstanceState>,
266 prev_focus_key: &str,
267 panel_width: u32,
268) -> RenderOutput {
269 let _guard = MarkerGutterGuard::set(false);
270 render_spec_inner(spec, prev, prev_focus_key, panel_width, true)
271}
272
273thread_local! {
288 static MARKER_GUTTER: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
289}
290
291fn marker_gutter_enabled() -> bool {
292 MARKER_GUTTER.with(|c| c.get())
293}
294
295struct MarkerGutterGuard(bool);
300impl MarkerGutterGuard {
301 fn set(enabled: bool) -> Self {
302 let prev = MARKER_GUTTER.with(|c| c.replace(enabled));
303 MarkerGutterGuard(prev)
304 }
305}
306impl Drop for MarkerGutterGuard {
307 fn drop(&mut self) {
308 MARKER_GUTTER.with(|c| c.set(self.0));
309 }
310}
311
312pub fn render_spec_with_marker(
318 spec: &WidgetSpec,
319 prev: &HashMap<String, WidgetInstanceState>,
320 prev_focus_key: &str,
321 panel_width: u32,
322) -> RenderOutput {
323 let _guard = MarkerGutterGuard::set(true);
324 render_spec_inner(spec, prev, prev_focus_key, panel_width, true)
325}
326
327pub fn render_spec_no_autofocus(
333 spec: &WidgetSpec,
334 prev: &HashMap<String, WidgetInstanceState>,
335 focus_key: &str,
336 panel_width: u32,
337) -> RenderOutput {
338 let _guard = MarkerGutterGuard::set(false);
339 render_spec_inner(spec, prev, focus_key, panel_width, false)
340}
341
342fn render_spec_inner(
343 spec: &WidgetSpec,
344 prev: &HashMap<String, WidgetInstanceState>,
345 prev_focus_key: &str,
346 panel_width: u32,
347 auto_focus_first: bool,
348) -> RenderOutput {
349 let mut tabbable = Vec::new();
353 collect_tabbable(spec, &mut tabbable);
354 let focus_key = if !prev_focus_key.is_empty() && tabbable.iter().any(|k| k == prev_focus_key) {
355 prev_focus_key.to_string()
356 } else if auto_focus_first {
357 tabbable.first().cloned().unwrap_or_default()
358 } else {
359 String::new()
360 };
361
362 let mut next_state = HashMap::new();
363 let collected = render_collected(spec, prev, &mut next_state, &focus_key, panel_width);
364 RenderOutput {
365 entries: collected.entries,
366 hits: collected.hits,
367 instance_states: next_state,
368 focus_key,
369 tabbable,
370 focus_cursor: collected.focus_cursor,
371 embeds: collected.embeds,
372 overlays: collected.overlays,
373 scroll_regions: collected.scroll_regions,
374 }
375}
376
377fn labeled_section_width_pct(spec: &WidgetSpec) -> Option<u32> {
394 let WidgetSpec::LabeledSection { width_pct, .. } = spec else {
395 return None;
396 };
397 width_pct.filter(|pct| (1..=100).contains(pct))
398}
399
400fn predicts_block(spec: &WidgetSpec) -> bool {
401 match spec {
402 WidgetSpec::Col { children, .. } => {
403 if children.len() > 1 {
404 return true;
405 }
406 children.first().map(predicts_block).unwrap_or(false)
407 }
408 WidgetSpec::LabeledSection { .. } => true,
409 WidgetSpec::Tree { .. } => true,
410 WidgetSpec::List { .. } => true,
411 WidgetSpec::Text { rows, .. } => *rows > 1,
412 WidgetSpec::WindowEmbed { rows, .. } => *rows > 1,
413 WidgetSpec::Raw { entries, .. } => entries.len() > 1,
414 WidgetSpec::Row { children, .. } => children.iter().any(predicts_block),
415 _ => false,
416 }
417}
418
419enum RowPiece {
423 Inline {
424 entry: TextPropertyEntry,
425 hits: Vec<HitArea>,
426 focus_cursor: Option<FocusCursor>,
431 embeds: Vec<EmbedRect>,
436 scroll_regions: Vec<ScrollRegion>,
438 },
439 Block {
440 column_width: u32,
445 entries: Vec<TextPropertyEntry>,
446 hits: Vec<HitArea>,
447 focus_cursor: Option<FocusCursor>,
448 embeds: Vec<EmbedRect>,
453 scroll_regions: Vec<ScrollRegion>,
456 },
457 Flex,
458}
459
460fn strip_trailing_newline(entry: &mut TextPropertyEntry) {
466 if entry.text.ends_with('\n') {
467 entry.text.pop();
468 }
469}
470
471fn ensure_trailing_newline(entry: &mut TextPropertyEntry) {
477 if !entry.text.ends_with('\n') {
478 entry.text.push('\n');
479 }
480}
481
482fn collect_tabbable(spec: &WidgetSpec, out: &mut Vec<String>) {
487 match spec {
488 WidgetSpec::Button {
489 key: Some(k),
490 disabled,
491 focusable,
492 ..
493 } if !k.is_empty() && !*disabled && *focusable => {
494 out.push(k.clone());
495 }
496 WidgetSpec::Toggle { key: Some(k), .. }
497 | WidgetSpec::Text { key: Some(k), .. }
498 | WidgetSpec::Tree { key: Some(k), .. }
499 if !k.is_empty() =>
500 {
501 out.push(k.clone());
502 }
503 WidgetSpec::List {
504 key: Some(k),
505 focusable,
506 ..
507 } if !k.is_empty() && *focusable => {
508 out.push(k.clone());
509 }
510 _ => {}
511 }
512 for c in spec.children() {
513 collect_tabbable(c, out);
514 }
515}
516
517fn render_collected(
529 spec: &WidgetSpec,
530 prev: &HashMap<String, WidgetInstanceState>,
531 next_state: &mut HashMap<String, WidgetInstanceState>,
532 focus_key: &str,
533 panel_width: u32,
534) -> CollectedOutput {
535 match spec {
536 WidgetSpec::Row { children, wrap, .. } => {
537 collect_row(children, *wrap, prev, next_state, focus_key, panel_width)
538 }
539 WidgetSpec::Col { children, .. } => {
540 collect_col(children, prev, next_state, focus_key, panel_width)
541 }
542 WidgetSpec::HintBar { entries, .. } => collect_hint_bar(entries),
543 WidgetSpec::Toggle {
544 checked,
545 label,
546 focused,
547 key,
548 } => collect_toggle(*checked, label, *focused, key.as_deref(), focus_key),
549 WidgetSpec::Button {
550 label,
551 focused,
552 intent,
553 key,
554 disabled,
555 ..
556 } => collect_button(
557 label,
558 *focused,
559 *intent,
560 key.as_deref(),
561 *disabled,
562 focus_key,
563 ),
564 WidgetSpec::Spacer { cols, .. } => collect_spacer(*cols),
565 WidgetSpec::Divider { ch, style, .. } => collect_divider(ch, style.as_ref(), panel_width),
566 WidgetSpec::List {
567 items,
568 item_specs,
569 item_keys,
570 selected_index,
571 visible_rows,
572 key: list_key,
573 ..
574 } => collect_list(
575 items,
576 item_specs,
577 item_keys,
578 *selected_index,
579 *visible_rows,
580 list_key.as_deref(),
581 prev,
582 next_state,
583 focus_key,
584 panel_width,
585 ),
586 WidgetSpec::Tree {
587 nodes,
588 item_keys,
589 selected_index,
590 visible_rows,
591 expanded_keys,
592 checkable,
593 key: tree_key,
594 } => render_widget_tree(
595 nodes,
596 item_keys,
597 *selected_index,
598 *visible_rows,
599 expanded_keys,
600 *checkable,
601 tree_key.as_deref(),
602 prev,
603 next_state,
604 ),
605 WidgetSpec::Text {
606 value,
607 cursor_byte,
608 focused,
609 label,
610 placeholder,
611 rows,
612 field_width,
613 max_visible_chars,
614 full_width,
615 completions: _,
616 completions_visible_rows,
617 key,
618 } => render_widget_text(
619 value,
620 *cursor_byte,
621 *focused,
622 label,
623 placeholder.as_deref(),
624 *rows,
625 *field_width,
626 *max_visible_chars,
627 *full_width,
628 *completions_visible_rows,
629 key.as_deref(),
630 prev,
631 next_state,
632 focus_key,
633 panel_width,
634 ),
635 WidgetSpec::LabeledSection { label, child, .. } => {
636 collect_labeled_section(label, child, prev, next_state, focus_key, panel_width)
637 }
638 WidgetSpec::WindowEmbed {
639 window_id, rows, ..
640 } => collect_window_embed(*window_id, *rows, panel_width),
641 WidgetSpec::Raw { entries, .. } => collect_raw(entries),
642 WidgetSpec::Overlay { child, .. } => {
643 collect_overlay(child, prev, next_state, focus_key, panel_width)
644 }
645 }
646}
647
648#[allow(clippy::too_many_arguments)]
655fn collect_row(
656 children: &[WidgetSpec],
657 wrap: bool,
658 prev: &HashMap<String, WidgetInstanceState>,
659 next_state: &mut HashMap<String, WidgetInstanceState>,
660 focus_key: &str,
661 panel_width: u32,
662) -> CollectedOutput {
663 let mut entries: Vec<TextPropertyEntry> = Vec::new();
664 let mut hits: Vec<HitArea> = Vec::new();
665 let mut focus_cursor: Option<FocusCursor> = None;
666 let mut embeds: Vec<EmbedRect> = Vec::new();
667 let mut overlays: Vec<OverlayRow> = Vec::new();
668 let mut scroll_regions: Vec<ScrollRegion> = Vec::new();
669
670 let block_indices: Vec<usize> = children
695 .iter()
696 .enumerate()
697 .filter(|(_, c)| predicts_block(c))
698 .map(|(i, _)| i)
699 .collect();
700 let block_count = block_indices.len();
701 let mut per_child_width: Vec<u32> = children.iter().map(|_| panel_width).collect();
706 if block_count > 0 {
707 let mut explicit_total: u32 = 0;
708 let mut explicit_count: u32 = 0;
709 for &idx in &block_indices {
710 if let Some(pct) = labeled_section_width_pct(&children[idx]) {
711 let w = (panel_width as u64 * pct as u64 / 100) as u32;
712 per_child_width[idx] = w.max(1);
713 explicit_total = explicit_total.saturating_add(w);
714 explicit_count += 1;
715 }
716 }
717 let remaining = panel_width.saturating_sub(explicit_total);
718 let implicit_count = (block_count as u32).saturating_sub(explicit_count).max(1);
719 let each_implicit = (remaining / implicit_count).max(1);
720 for &idx in &block_indices {
721 if labeled_section_width_pct(&children[idx]).is_none() {
722 per_child_width[idx] = each_implicit;
723 }
724 }
725 }
726 let mut row_pieces: Vec<RowPiece> = Vec::new();
727 for (idx, child) in children.iter().enumerate() {
728 if let WidgetSpec::Spacer { flex: true, .. } = child {
729 row_pieces.push(RowPiece::Flex);
730 continue;
731 }
732 let child_panel_width = per_child_width[idx];
733 let child_out = render_collected(child, prev, next_state, focus_key, child_panel_width);
734 overlays.extend(child_out.overlays);
739 if child_out.entries.is_empty() {
740 debug_assert!(child_out.hits.is_empty(), "empty children produce no hits");
741 continue;
742 }
743 if child_out.entries.len() == 1 {
744 let mut entry = child_out.entries.into_iter().next().unwrap();
745 strip_trailing_newline(&mut entry);
750 row_pieces.push(RowPiece::Inline {
751 entry,
752 hits: child_out.hits,
753 focus_cursor: child_out.focus_cursor,
754 embeds: child_out.embeds,
755 scroll_regions: child_out.scroll_regions,
756 });
757 } else {
758 row_pieces.push(RowPiece::Block {
759 column_width: child_panel_width,
760 entries: child_out.entries,
761 hits: child_out.hits,
762 focus_cursor: child_out.focus_cursor,
763 embeds: child_out.embeds,
764 scroll_regions: child_out.scroll_regions,
765 });
766 }
767 }
768 let has_blocks = row_pieces
772 .iter()
773 .any(|p| matches!(p, RowPiece::Block { .. }));
774 if has_blocks {
775 zip_row_blocks(
776 row_pieces,
777 panel_width,
778 &mut entries,
779 &mut hits,
780 &mut focus_cursor,
781 &mut embeds,
782 &mut scroll_regions,
783 );
784 } else if wrap {
785 assemble_wrapped_row(row_pieces, panel_width, &mut entries, &mut hits);
791 } else {
792 let inline_natural: usize = row_pieces
798 .iter()
799 .filter_map(|p| match p {
800 RowPiece::Inline { entry, .. } => {
801 Some(crate::primitives::display_width::str_width(&entry.text))
802 }
803 _ => None,
804 })
805 .sum();
806 let flex_count = row_pieces
807 .iter()
808 .filter(|p| matches!(p, RowPiece::Flex))
809 .count();
810 let flex_total = (panel_width as usize).saturating_sub(inline_natural);
811 let (flex_each, flex_extra) = match flex_total.checked_div(flex_count) {
815 Some(each) => (each, flex_total % flex_count),
816 None => (0, 0),
817 };
818
819 let mut acc: Option<TextPropertyEntry> = None;
824 let mut flex_seen = 0usize;
825 for piece in row_pieces {
826 match piece {
827 RowPiece::Inline {
828 mut entry,
829 hits: child_hits,
830 focus_cursor: child_focus,
831 embeds: child_embeds,
832 scroll_regions: child_scroll,
833 } => {
834 let inline_shift = match acc.as_ref() {
835 Some(e) => e.text.len(),
836 None => 0,
837 };
838 for mut h in child_hits {
839 h.byte_start += inline_shift;
840 h.byte_end += inline_shift;
841 hits.push(h);
842 }
843 if let Some(mut fc) = child_focus {
844 fc.byte_in_row += inline_shift as u32;
846 focus_cursor = Some(fc);
847 }
848 for mut emb in child_embeds {
849 emb.col_in_row += inline_shift as u32;
855 embeds.push(emb);
856 }
857 for mut sr in child_scroll {
858 sr.col_in_row += inline_shift as u32;
859 scroll_regions.push(sr);
860 }
861 match acc.as_mut() {
862 Some(merged) => merge_inline(merged, &mut entry),
863 None => acc = Some(entry),
864 }
865 }
866 RowPiece::Flex => {
867 let n = flex_each + if flex_seen < flex_extra { 1 } else { 0 };
869 flex_seen += 1;
870 if n > 0 {
871 let mut text = String::with_capacity(n);
872 for _ in 0..n {
873 text.push(' ');
874 }
875 let entry = TextPropertyEntry {
876 text,
877 properties: Default::default(),
878 style: None,
879 inline_overlays: Vec::new(),
880 segments: Vec::new(),
881 pad_to_chars: None,
882 truncate_to_chars: None,
883 };
884 match acc.as_mut() {
885 Some(merged) => {
886 let mut e = entry;
887 merge_inline(merged, &mut e);
888 }
889 None => acc = Some(entry),
890 }
891 }
892 }
893 RowPiece::Block { .. } => {
894 debug_assert!(false, "block piece in inline-only Row path");
897 }
898 }
899 }
900 if let Some(mut merged) = acc {
901 ensure_trailing_newline(&mut merged);
902 entries.push(merged);
903 }
904 }
905
906 CollectedOutput {
907 entries,
908 hits,
909 focus_cursor,
910 embeds,
911 overlays,
912 scroll_regions,
913 }
914}
915
916#[allow(clippy::too_many_arguments)]
917fn collect_col(
918 children: &[WidgetSpec],
919 prev: &HashMap<String, WidgetInstanceState>,
920 next_state: &mut HashMap<String, WidgetInstanceState>,
921 focus_key: &str,
922 panel_width: u32,
923) -> CollectedOutput {
924 let mut entries: Vec<TextPropertyEntry> = Vec::new();
925 let mut hits: Vec<HitArea> = Vec::new();
926 let mut focus_cursor: Option<FocusCursor> = None;
927 let mut embeds: Vec<EmbedRect> = Vec::new();
928 let mut overlays: Vec<OverlayRow> = Vec::new();
929 let mut scroll_regions: Vec<ScrollRegion> = Vec::new();
930
931 for child in children {
932 let is_overlay = matches!(child, WidgetSpec::Overlay { .. });
940 let child_out = render_collected(child, prev, next_state, focus_key, panel_width);
941 let row_offset = entries.len() as u32;
942 if is_overlay {
943 for (i, e) in child_out.entries.into_iter().enumerate() {
950 overlays.push(OverlayRow {
951 buffer_row: row_offset + i as u32,
952 entry: e,
953 });
954 }
955 for mut h in child_out.hits {
956 h.buffer_row += row_offset;
957 hits.push(h);
958 }
959 if let Some(mut fc) = child_out.focus_cursor {
964 fc.buffer_row += row_offset;
965 focus_cursor = Some(fc);
966 }
967 overlays.extend(child_out.overlays);
970 for mut emb in child_out.embeds {
976 emb.buffer_row += row_offset;
977 embeds.push(emb);
978 }
979 for mut sr in child_out.scroll_regions {
980 sr.buffer_row += row_offset;
981 scroll_regions.push(sr);
982 }
983 continue;
984 }
985 for mut h in child_out.hits {
986 h.buffer_row += row_offset;
987 hits.push(h);
988 }
989 if let Some(mut fc) = child_out.focus_cursor {
990 fc.buffer_row += row_offset;
991 focus_cursor = Some(fc);
992 }
993 for mut emb in child_out.embeds {
994 emb.buffer_row += row_offset;
995 embeds.push(emb);
996 }
997 for mut sr in child_out.scroll_regions {
998 sr.buffer_row += row_offset;
999 scroll_regions.push(sr);
1000 }
1001 overlays.extend(child_out.overlays.into_iter().map(|mut o| {
1002 o.buffer_row += row_offset;
1003 o
1004 }));
1005 entries.extend(child_out.entries);
1006 }
1007
1008 CollectedOutput {
1009 entries,
1010 hits,
1011 focus_cursor,
1012 embeds,
1013 overlays,
1014 scroll_regions,
1015 }
1016}
1017
1018fn collect_hint_bar(entries: &[HintEntry]) -> CollectedOutput {
1019 let mut out = CollectedOutput::default();
1020 let mut entry = render_hint_bar(entries);
1021 ensure_trailing_newline(&mut entry);
1022 out.entries.push(entry);
1023 out
1027}
1028
1029fn collect_toggle(
1030 checked: bool,
1031 label: &str,
1032 focused: bool,
1033 key: Option<&str>,
1034 focus_key: &str,
1035) -> CollectedOutput {
1036 let mut out = CollectedOutput::default();
1037 let is_focused = match key {
1044 Some(k) if !k.is_empty() => k == focus_key,
1045 _ => focused,
1046 };
1047 let mut entry = render_toggle(checked, label, is_focused);
1048 let byte_end = entry.text.len();
1049 out.hits.push(HitArea {
1050 widget_key: key.unwrap_or("").to_string(),
1051 widget_kind: "toggle",
1052 buffer_row: 0,
1053 byte_start: 0,
1054 byte_end,
1055 payload: json!({ "checked": !checked }),
1056 event_type: "toggle",
1057 });
1058 ensure_trailing_newline(&mut entry);
1059 out.entries.push(entry);
1060 out
1061}
1062
1063#[allow(clippy::too_many_arguments)]
1064fn collect_button(
1065 label: &str,
1066 focused: bool,
1067 intent: ButtonKind,
1068 key: Option<&str>,
1069 disabled: bool,
1070 focus_key: &str,
1071) -> CollectedOutput {
1072 let mut out = CollectedOutput::default();
1073 let is_focused = match key {
1074 Some(k) if !k.is_empty() && !disabled => k == focus_key,
1075 _ => !disabled && focused,
1076 };
1077 let mut entry = render_button(label, is_focused, intent, disabled);
1078 if !disabled {
1085 let byte_end = entry.text.len();
1086 out.hits.push(HitArea {
1087 widget_key: key.unwrap_or("").to_string(),
1088 widget_kind: "button",
1089 buffer_row: 0,
1090 byte_start: 0,
1091 byte_end,
1092 payload: json!({}),
1093 event_type: "activate",
1094 });
1095 }
1096 ensure_trailing_newline(&mut entry);
1097 out.entries.push(entry);
1098 out
1099}
1100
1101fn collect_spacer(cols: u32) -> CollectedOutput {
1102 let mut out = CollectedOutput::default();
1103 let cols = cols.min(4096) as usize;
1109 let mut text = String::with_capacity(cols + 1);
1110 for _ in 0..cols {
1111 text.push(' ');
1112 }
1113 let mut entry = TextPropertyEntry {
1114 text,
1115 properties: Default::default(),
1116 style: None,
1117 inline_overlays: Vec::new(),
1118 segments: Vec::new(),
1119 pad_to_chars: None,
1120 truncate_to_chars: None,
1121 };
1122 ensure_trailing_newline(&mut entry);
1123 out.entries.push(entry);
1124 out
1125}
1126
1127fn collect_divider(ch: &str, style: Option<&OverlayOptions>, panel_width: u32) -> CollectedOutput {
1128 let mut out = CollectedOutput::default();
1129 let glyph = if ch.is_empty() { " " } else { ch };
1135 let cols = (panel_width as usize).min(4096);
1136 let mut text = String::with_capacity(cols * glyph.len() + 1);
1137 for _ in 0..cols {
1138 text.push_str(glyph);
1139 }
1140 let mut entry = TextPropertyEntry {
1141 text,
1142 properties: Default::default(),
1143 style: style.cloned(),
1144 inline_overlays: Vec::new(),
1145 segments: Vec::new(),
1146 pad_to_chars: None,
1147 truncate_to_chars: None,
1148 };
1149 ensure_trailing_newline(&mut entry);
1150 out.entries.push(entry);
1151 out
1152}
1153
1154#[allow(clippy::too_many_arguments)]
1155fn collect_list(
1156 items: &[TextPropertyEntry],
1157 item_specs: &[WidgetSpec],
1158 item_keys: &[String],
1159 selected_index: i32,
1160 visible_rows: u32,
1161 list_key: Option<&str>,
1162 prev: &HashMap<String, WidgetInstanceState>,
1163 next_state: &mut HashMap<String, WidgetInstanceState>,
1164 focus_key: &str,
1165 panel_width: u32,
1166) -> CollectedOutput {
1167 let mut entries: Vec<TextPropertyEntry> = Vec::new();
1168 let mut hits: Vec<HitArea> = Vec::new();
1169 let mut scroll_regions: Vec<ScrollRegion> = Vec::new();
1170
1171 let use_specs = !item_specs.is_empty();
1180 let total = if use_specs {
1181 item_specs.len() as u32
1182 } else {
1183 items.len() as u32
1184 };
1185 let avail_rows = visible_rows.max(1);
1187
1188 let (prev_scroll, prev_sel, prev_user_scrolled) = list_key
1192 .and_then(|k| prev.get(k))
1193 .and_then(|s| match s {
1194 WidgetInstanceState::List {
1195 scroll_offset,
1196 selected_index,
1197 user_scrolled,
1198 ..
1199 } => Some((*scroll_offset, *selected_index, *user_scrolled)),
1200 _ => None,
1201 })
1202 .unwrap_or((0, selected_index, false));
1203 let effective_sel = if prev_sel < 0 || total == 0 {
1208 -1
1209 } else if (prev_sel as u32) >= total {
1210 (total - 1) as i32
1211 } else {
1212 prev_sel
1213 };
1214
1215 let mut rendered_cards: Vec<Vec<TextPropertyEntry>> = Vec::new();
1221 let mut item_height: u32 = 1;
1222 if use_specs {
1223 rendered_cards.reserve(item_specs.len());
1224 for item_spec in item_specs.iter() {
1225 let mut scratch = HashMap::new();
1226 let card_entries =
1227 render_collected(item_spec, prev, &mut scratch, focus_key, panel_width).entries;
1228 item_height = item_height.max((card_entries.len() as u32).max(1));
1229 rendered_cards.push(card_entries);
1230 }
1231 }
1232 let visible_items = if use_specs {
1234 (avail_rows / item_height).max(1)
1235 } else {
1236 avail_rows
1237 };
1238
1239 if use_specs && total > visible_items && panel_width > 1 {
1245 let card_width = panel_width - 1;
1246 rendered_cards.clear();
1247 for item_spec in item_specs.iter() {
1248 let mut scratch = HashMap::new();
1249 let card_entries =
1250 render_collected(item_spec, prev, &mut scratch, focus_key, card_width).entries;
1251 rendered_cards.push(card_entries);
1252 }
1253 }
1254
1255 let mut scroll = prev_scroll;
1262 if effective_sel >= 0 && !prev_user_scrolled {
1263 let sel = effective_sel as u32;
1264 if sel < scroll {
1265 scroll = sel;
1266 }
1267 if sel >= scroll + visible_items {
1268 scroll = sel + 1 - visible_items;
1269 }
1270 }
1271 let max_scroll = total.saturating_sub(visible_items);
1272 if scroll > max_scroll {
1273 scroll = max_scroll;
1274 }
1275 if let Some(k) = list_key {
1278 next_state.insert(
1279 k.to_string(),
1280 WidgetInstanceState::List {
1281 scroll_offset: scroll,
1282 selected_index: effective_sel,
1283 item_height,
1284 user_scrolled: prev_user_scrolled,
1285 },
1286 );
1287 }
1288
1289 let start = scroll as usize;
1290 let end = ((scroll + visible_items) as usize).min(total as usize);
1291 let blank_row = || {
1293 let mut padding = TextPropertyEntry {
1294 text: String::new(),
1295 properties: Default::default(),
1296 style: None,
1297 inline_overlays: Vec::new(),
1298 segments: Vec::new(),
1299 pad_to_chars: None,
1300 truncate_to_chars: None,
1301 };
1302 ensure_trailing_newline(&mut padding);
1303 padding
1304 };
1305 let mark_selected = |entry: &mut TextPropertyEntry| {
1308 let mut style = entry.style.clone().unwrap_or_default();
1309 style.bg = Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG));
1310 style.extend_to_line_end = true;
1311 entry.style = Some(style);
1312 };
1313 let mark_selected_card = |entry: &mut TextPropertyEntry| {
1321 entry.text = entry
1322 .text
1323 .replace('╭', "┏")
1324 .replace('╮', "┓")
1325 .replace('╰', "┗")
1326 .replace('╯', "┛")
1327 .replace('─', "━")
1328 .replace('│', "┃");
1329 let mut style = entry.style.clone().unwrap_or_default();
1330 style.bold = true;
1331 if entry.text.starts_with('┏') || entry.text.starts_with('┗') {
1332 style.fg = Some(OverlayColorSpec::theme_key("ui.popup_border_fg"));
1335 entry.style = Some(style);
1336 } else {
1337 entry.style = Some(style);
1344 let bar = '┃';
1345 let bar_len = bar.len_utf8();
1346 let first = entry.text.find(bar);
1347 let last = entry.text.rfind(bar);
1348 for pos in [first, last].into_iter().flatten().collect::<HashSet<_>>() {
1349 entry.inline_overlays.push(InlineOverlay {
1350 start: pos,
1351 end: pos + bar_len,
1352 style: OverlayOptions {
1353 fg: Some(OverlayColorSpec::theme_key("ui.popup_border_fg")),
1354 bold: true,
1355 ..Default::default()
1356 },
1357 properties: Default::default(),
1358 unit: OffsetUnit::Byte,
1359 });
1360 }
1361 }
1362 };
1363
1364 let rows_emitted: u32 = if use_specs {
1365 let mut emitted = 0u32;
1374 let last = if end < total as usize { end + 1 } else { end };
1375 'cards: for i in start..last {
1376 let is_selected = i as i32 == effective_sel;
1377 let item_key = item_keys.get(i).cloned().unwrap_or_default();
1378 let card = &rendered_cards[i];
1379 for r in 0..item_height as usize {
1380 if emitted >= avail_rows {
1381 break 'cards;
1382 }
1383 let mut entry = card.get(r).cloned().unwrap_or_else(blank_row);
1384 entry.normalize_widths();
1385 if is_selected {
1386 mark_selected_card(&mut entry);
1387 }
1388 let byte_end = entry.text.len();
1389 ensure_trailing_newline(&mut entry);
1390 let hit_row = entries.len() as u32;
1391 entries.push(entry);
1392 hits.push(HitArea {
1393 widget_key: item_key.clone(),
1394 widget_kind: "list",
1395 buffer_row: hit_row,
1396 byte_start: 0,
1397 byte_end,
1398 payload: json!({
1399 "index": i as i64,
1400 "key": item_key,
1401 "list_key": list_key,
1402 }),
1403 event_type: "select",
1404 });
1405 emitted += 1;
1406 }
1407 }
1408 emitted
1409 } else {
1410 for (offset, item) in items[start..end.min(items.len())].iter().enumerate() {
1412 let i = start + offset;
1413 let mut entry = item.clone();
1414 entry.normalize_widths();
1415 if i as i32 == effective_sel {
1416 mark_selected(&mut entry);
1417 }
1418 let byte_end = entry.text.len();
1419 ensure_trailing_newline(&mut entry);
1420 entries.push(entry);
1421 let item_key = item_keys.get(i).cloned().unwrap_or_default();
1422 let hit_row = (entries.len() - 1) as u32;
1423 hits.push(HitArea {
1424 widget_key: item_key.clone(),
1425 widget_kind: "list",
1426 buffer_row: hit_row,
1427 byte_start: 0,
1428 byte_end,
1429 payload: json!({
1430 "index": i as i64,
1431 "key": item_key,
1432 "list_key": list_key,
1437 }),
1438 event_type: "select",
1439 });
1440 }
1441 (end - start) as u32
1442 };
1443
1444 for _ in rows_emitted..avail_rows {
1448 entries.push(blank_row());
1449 }
1450
1451 if total > visible_items {
1455 if let Some(k) = list_key {
1456 scroll_regions.push(ScrollRegion {
1457 list_key: k.to_string(),
1458 buffer_row: 0,
1459 col_in_row: 0,
1460 width_cols: panel_width,
1461 height_rows: avail_rows,
1462 total: total as usize,
1463 visible: visible_items as usize,
1464 scroll: scroll as usize,
1465 });
1466 }
1467 }
1468
1469 CollectedOutput {
1470 entries,
1471 hits,
1472 focus_cursor: None,
1473 embeds: Vec::new(),
1474 overlays: Vec::new(),
1475 scroll_regions,
1476 }
1477}
1478
1479#[allow(clippy::too_many_arguments)]
1480fn collect_labeled_section(
1481 label: &str,
1482 child: &WidgetSpec,
1483 prev: &HashMap<String, WidgetInstanceState>,
1484 next_state: &mut HashMap<String, WidgetInstanceState>,
1485 focus_key: &str,
1486 panel_width: u32,
1487) -> CollectedOutput {
1488 let mut entries: Vec<TextPropertyEntry> = Vec::new();
1489 let mut hits: Vec<HitArea> = Vec::new();
1490 let mut focus_cursor: Option<FocusCursor> = None;
1491 let mut embeds: Vec<EmbedRect> = Vec::new();
1492 let mut overlays: Vec<OverlayRow> = Vec::new();
1493 let mut scroll_regions: Vec<ScrollRegion> = Vec::new();
1494
1495 let inner_width = panel_width.saturating_sub(4).max(1);
1498 let child_out = render_collected(child, prev, next_state, focus_key, inner_width);
1499 overlays.extend(child_out.overlays.into_iter().map(|mut o| {
1509 o.buffer_row += 1;
1510 o
1511 }));
1512
1513 let total_cols = panel_width.max(2) as usize;
1517 entries.push(render_section_top_border(label, total_cols));
1518
1519 for mut child_entry in child_out.entries {
1524 strip_trailing_newline(&mut child_entry);
1525 let wrapped = wrap_in_side_border(child_entry, inner_width as usize);
1526 let row_offset = entries.len() as u32;
1527 let _ = row_offset;
1532 entries.push(wrapped);
1533 }
1534
1535 let prefix_bytes = LEFT_BORDER_PREFIX.len();
1539 for mut h in child_out.hits {
1540 h.buffer_row += 1;
1541 h.byte_start += prefix_bytes;
1542 h.byte_end += prefix_bytes;
1543 hits.push(h);
1544 }
1545 if let Some(mut fc) = child_out.focus_cursor {
1546 fc.buffer_row += 1;
1547 fc.byte_in_row += prefix_bytes as u32;
1548 focus_cursor = Some(fc);
1549 }
1550 let prefix_cols = LEFT_BORDER_PREFIX.chars().count() as u32;
1553 for mut emb in child_out.embeds {
1554 emb.buffer_row += 1;
1555 emb.col_in_row += prefix_cols;
1556 embeds.push(emb);
1557 }
1558 for mut sr in child_out.scroll_regions {
1559 sr.buffer_row += 1;
1560 sr.col_in_row += prefix_cols;
1561 sr.width_cols = inner_width;
1565 scroll_regions.push(sr);
1566 }
1567
1568 entries.push(render_section_bottom_border(total_cols));
1569
1570 CollectedOutput {
1571 entries,
1572 hits,
1573 focus_cursor,
1574 embeds,
1575 overlays,
1576 scroll_regions,
1577 }
1578}
1579
1580fn collect_window_embed(window_id: u32, embed_rows: u32, panel_width: u32) -> CollectedOutput {
1581 let mut out = CollectedOutput::default();
1582 let cols = panel_width.max(1) as usize;
1587 for _ in 0..embed_rows {
1588 let mut text = String::with_capacity(cols + 1);
1589 for _ in 0..cols {
1590 text.push(' ');
1591 }
1592 text.push('\n');
1593 out.entries.push(TextPropertyEntry {
1594 text,
1595 properties: Default::default(),
1596 style: None,
1597 inline_overlays: Vec::new(),
1598 segments: Vec::new(),
1599 pad_to_chars: None,
1600 truncate_to_chars: None,
1601 });
1602 }
1603 out.embeds.push(EmbedRect {
1604 window_id,
1605 buffer_row: 0,
1606 col_in_row: 0,
1607 width_cols: panel_width,
1608 height_rows: embed_rows,
1609 });
1610 out
1611}
1612
1613fn collect_raw(raw_entries: &[TextPropertyEntry]) -> CollectedOutput {
1614 let mut out = CollectedOutput::default();
1615 for raw_entry in raw_entries {
1624 let mut e = raw_entry.clone();
1625 e.normalize_widths();
1626 ensure_trailing_newline(&mut e);
1627 out.entries.push(e);
1628 }
1629 out
1630}
1631
1632#[allow(clippy::too_many_arguments)]
1633fn collect_overlay(
1634 child: &WidgetSpec,
1635 prev: &HashMap<String, WidgetInstanceState>,
1636 next_state: &mut HashMap<String, WidgetInstanceState>,
1637 focus_key: &str,
1638 panel_width: u32,
1639) -> CollectedOutput {
1640 let child_out = render_collected(child, prev, next_state, focus_key, panel_width);
1649 CollectedOutput {
1650 entries: child_out.entries,
1651 hits: child_out.hits,
1652 focus_cursor: child_out.focus_cursor,
1653 embeds: child_out.embeds,
1654 overlays: child_out.overlays,
1655 scroll_regions: child_out.scroll_regions,
1656 }
1657}
1658
1659#[allow(clippy::too_many_arguments)]
1660fn render_widget_text(
1661 value: &str,
1662 cursor_byte: i32,
1663 focused: bool,
1664 label: &str,
1665 placeholder: Option<&str>,
1666 rows: u32,
1667 field_width: u32,
1668 max_visible_chars: u32,
1669 full_width: bool,
1670 completions_visible_rows: u32,
1671 key: Option<&str>,
1672 prev: &HashMap<String, WidgetInstanceState>,
1673 next_state: &mut HashMap<String, WidgetInstanceState>,
1674 focus_key: &str,
1675 panel_width: u32,
1676) -> CollectedOutput {
1677 let mut out = CollectedOutput::default();
1678 let effective_visible_rows = if completions_visible_rows == 0 {
1682 5u32
1683 } else {
1684 completions_visible_rows
1685 };
1686
1687 let is_focused = match key.filter(|k| !k.is_empty()) {
1688 Some(k) => k == focus_key,
1689 None => focused,
1690 };
1691 let multiline = rows > 1;
1699 let mut effective_editor: crate::primitives::text_edit::TextEdit;
1700 let prev_scroll: u32;
1701 let mut prev_completions: Vec<fresh_core::api::CompletionItem> = Vec::new();
1707 let mut prev_completion_idx: usize = 0;
1708 let mut prev_completion_scroll: u32 = 0;
1709 let mut prev_completion_navigated = false;
1710 match key.filter(|k| !k.is_empty()).and_then(|k| prev.get(k)) {
1711 Some(WidgetInstanceState::Text {
1712 editor,
1713 scroll,
1714 completions,
1715 completion_selected_index,
1716 completion_scroll_offset,
1717 completion_navigated,
1718 }) => {
1719 effective_editor = editor.clone();
1720 prev_scroll = *scroll;
1721 prev_completions = completions.clone();
1722 prev_completion_idx = *completion_selected_index;
1723 prev_completion_scroll = *completion_scroll_offset;
1724 prev_completion_navigated = *completion_navigated;
1725 }
1726 _ => {
1727 effective_editor = if multiline {
1728 crate::primitives::text_edit::TextEdit::with_text(value)
1729 } else {
1730 crate::primitives::text_edit::TextEdit::single_line_with_text(value)
1731 };
1732 let seed = if cursor_byte < 0 {
1733 value.len()
1734 } else {
1735 (cursor_byte as usize).min(value.len())
1736 };
1737 effective_editor.set_cursor_from_flat(seed);
1738 prev_scroll = 0;
1739 }
1740 }
1741 if !prev_completions.is_empty() {
1745 prev_completion_idx = prev_completion_idx.min(prev_completions.len() - 1);
1746 } else {
1747 prev_completion_idx = 0;
1748 }
1749 let effective_value = effective_editor.value();
1750 let effective_cursor_byte = effective_editor.flat_cursor_byte() as i32;
1751 let effective_cursor = if is_focused {
1752 effective_cursor_byte
1753 } else {
1754 -1
1755 };
1756 let effective_field_width = if full_width && !multiline {
1770 let label_overhead = if label.is_empty() {
1771 0u32
1772 } else {
1773 label.chars().count() as u32 + 1
1774 };
1775 let marker_reserve = if marker_gutter_enabled() { 2 } else { 0 };
1782 panel_width
1783 .saturating_sub(label_overhead)
1784 .saturating_sub(3)
1785 .saturating_sub(marker_reserve)
1786 .max(1)
1787 } else {
1788 field_width
1789 };
1790 let selection_for_render = if is_focused {
1794 effective_editor.selection_flat_range()
1795 } else {
1796 None
1797 };
1798 let new_scroll;
1799 if multiline {
1800 let rendered = render_text_area(
1801 &effective_value,
1802 effective_cursor,
1803 selection_for_render,
1804 is_focused,
1805 label,
1806 placeholder,
1807 rows,
1808 effective_field_width,
1809 prev_scroll,
1810 panel_width,
1811 );
1812 new_scroll = rendered.scroll_row;
1813 if let (Some(buffer_row), Some(byte_in_row)) =
1814 (rendered.cursor_buffer_row, rendered.cursor_byte_in_row)
1815 {
1816 out.focus_cursor = Some(FocusCursor {
1817 buffer_row,
1818 byte_in_row: byte_in_row as u32,
1819 });
1820 }
1821 for (row_idx, mut e) in rendered.entries.into_iter().enumerate() {
1822 if let Some(k) = key.filter(|k| !k.is_empty()) {
1825 out.hits.push(HitArea {
1826 widget_key: k.to_string(),
1827 widget_kind: "text",
1828 buffer_row: row_idx as u32,
1829 byte_start: 0,
1830 byte_end: e.text.len(),
1831 payload: json!({}),
1832 event_type: "focus",
1833 });
1834 }
1835 ensure_trailing_newline(&mut e);
1836 out.entries.push(e);
1837 }
1838 } else {
1839 let rendered = render_text_input(
1840 &effective_value,
1841 effective_cursor,
1842 selection_for_render,
1843 is_focused,
1844 label,
1845 placeholder,
1846 max_visible_chars,
1847 effective_field_width,
1848 full_width,
1849 );
1850 new_scroll = 0;
1851 let mut entry = rendered.entry;
1852 let gutter = focus_gutter_prefix(is_focused);
1864 let marker_bytes = gutter.len();
1865 let mut cursor_in_row = rendered.cursor_byte_in_entry;
1866 if marker_bytes > 0 {
1867 entry.text.insert_str(0, gutter);
1868 for ov in entry.inline_overlays.iter_mut() {
1869 ov.start += marker_bytes;
1870 ov.end += marker_bytes;
1871 }
1872 cursor_in_row = cursor_in_row.map(|c| c + marker_bytes);
1873 }
1874 if let Some(byte_in_row) = cursor_in_row {
1875 out.focus_cursor = Some(FocusCursor {
1876 buffer_row: 0,
1877 byte_in_row: byte_in_row as u32,
1878 });
1879 }
1880 if let Some(k) = key.filter(|k| !k.is_empty()) {
1886 out.hits.push(HitArea {
1887 widget_key: k.to_string(),
1888 widget_kind: "text",
1889 buffer_row: 0,
1890 byte_start: 0,
1891 byte_end: entry.text.len(),
1892 payload: json!({}),
1893 event_type: "focus",
1894 });
1895 }
1896 ensure_trailing_newline(&mut entry);
1897 out.entries.push(entry);
1898 }
1899 if !prev_completions.is_empty() {
1916 let popup_inner = panel_width as usize;
1925 let popup_total = popup_inner.saturating_add(4); let total = prev_completions.len() as u32;
1927 let visible = effective_visible_rows.max(1).min(total);
1928 let sel = prev_completion_idx as u32;
1942 let mut scroll = prev_completion_scroll;
1943 if sel >= scroll + visible {
1944 scroll = sel + 1 - visible;
1945 }
1946 let max_scroll = total.saturating_sub(visible);
1947 if scroll > max_scroll {
1948 scroll = max_scroll;
1949 }
1950 prev_completion_scroll = scroll;
1951
1952 let mut anchor: u32 = 1;
1965 out.overlays.push(OverlayRow {
1966 buffer_row: anchor,
1967 entry: render_completion_dim_separator_overlay(popup_total),
1968 });
1969 anchor += 1;
1970 let needs_scrollbar = total > visible;
1971 let end = (scroll + visible).min(total) as usize;
1972 for (visible_row, i) in (scroll as usize..end).enumerate() {
1973 let item = &prev_completions[i];
1974 let thumb = if needs_scrollbar {
1975 completion_scrollbar_glyph(visible_row as u32, visible, scroll, total)
1976 } else {
1977 None
1978 };
1979 out.overlays.push(OverlayRow {
1980 buffer_row: anchor,
1981 entry: render_completion_item_overlay(
1982 &item.value,
1983 item.kind.as_deref(),
1984 prev_completion_navigated && i == prev_completion_idx,
1989 popup_total,
1990 thumb,
1991 ),
1992 });
1993 anchor += 1;
1994 }
1995 out.overlays.push(OverlayRow {
1996 buffer_row: anchor,
1997 entry: render_completion_bottom_border(popup_total),
1998 });
1999 } else {
2000 prev_completion_scroll = 0;
2001 }
2002 if let Some(k) = key.filter(|k| !k.is_empty()) {
2003 next_state.insert(
2004 k.to_string(),
2005 WidgetInstanceState::Text {
2006 editor: effective_editor.clone(),
2007 scroll: new_scroll,
2008 completions: prev_completions,
2009 completion_selected_index: prev_completion_idx,
2010 completion_scroll_offset: prev_completion_scroll,
2011 completion_navigated: prev_completion_navigated,
2012 },
2013 );
2014 }
2015 out
2016}
2017
2018#[allow(clippy::too_many_arguments)]
2019fn render_widget_tree(
2020 nodes: &[TreeNode],
2021 item_keys: &[String],
2022 selected_index: i32,
2023 visible_rows: u32,
2024 expanded_keys: &[String],
2025 checkable: bool,
2026 tree_key: Option<&str>,
2027 prev: &HashMap<String, WidgetInstanceState>,
2028 next_state: &mut HashMap<String, WidgetInstanceState>,
2029) -> CollectedOutput {
2030 let mut out = CollectedOutput::default();
2031 let prev_state = tree_key.filter(|k| !k.is_empty()).and_then(|k| prev.get(k));
2034 let (prev_scroll, prev_sel, prev_expanded) = match prev_state {
2035 Some(WidgetInstanceState::Tree {
2036 scroll_offset,
2037 selected_index,
2038 expanded_keys,
2039 }) => (*scroll_offset, *selected_index, expanded_keys.clone()),
2040 _ => {
2041 let seeded: HashSet<String> = expanded_keys.iter().cloned().collect();
2043 (0, selected_index, seeded)
2044 }
2045 };
2046
2047 let mut ancestor_open: Vec<bool> = Vec::new();
2059 let mut visible_indices: Vec<usize> = Vec::with_capacity(nodes.len());
2060 for (i, node) in nodes.iter().enumerate() {
2061 let depth = node.depth as usize;
2062 ancestor_open.truncate(depth);
2064 let visible = ancestor_open.iter().all(|open| *open);
2065 if visible {
2066 visible_indices.push(i);
2067 }
2068 let key = item_keys.get(i).cloned().unwrap_or_default();
2074 let is_open = if node.has_children {
2075 !key.is_empty() && prev_expanded.contains(&key)
2076 } else {
2077 true
2078 };
2079 ancestor_open.push(is_open);
2080 }
2081
2082 let total_visible = visible_indices.len() as u32;
2088 let visible = visible_rows.max(1);
2089 let clamp_to_visible = |abs: i32| -> i32 {
2090 if abs < 0 || nodes.is_empty() {
2091 return -1;
2092 }
2093 let abs = abs.min((nodes.len() as i32) - 1) as usize;
2094 if let Ok(_pos) = visible_indices.binary_search(&abs) {
2095 return abs as i32;
2096 }
2097 let earlier = visible_indices.iter().rev().find(|&&v| v <= abs);
2100 if let Some(&v) = earlier {
2101 return v as i32;
2102 }
2103 visible_indices.first().map(|&v| v as i32).unwrap_or(-1)
2104 };
2105 let effective_sel_abs = clamp_to_visible(prev_sel);
2106 let sel_visible_pos: i32 = if effective_sel_abs < 0 {
2110 -1
2111 } else {
2112 visible_indices
2113 .iter()
2114 .position(|&v| v == effective_sel_abs as usize)
2115 .map(|p| p as i32)
2116 .unwrap_or(-1)
2117 };
2118
2119 let mut scroll = prev_scroll;
2122 if sel_visible_pos >= 0 {
2123 let sel = sel_visible_pos as u32;
2124 if sel < scroll {
2125 scroll = sel;
2126 }
2127 if sel >= scroll + visible {
2128 scroll = sel + 1 - visible;
2129 }
2130 }
2131 let max_scroll = total_visible.saturating_sub(visible);
2132 if scroll > max_scroll {
2133 scroll = max_scroll;
2134 }
2135
2136 if let Some(k) = tree_key.filter(|k| !k.is_empty()) {
2138 next_state.insert(
2139 k.to_string(),
2140 WidgetInstanceState::Tree {
2141 scroll_offset: scroll,
2142 selected_index: effective_sel_abs,
2143 expanded_keys: prev_expanded.clone(),
2144 },
2145 );
2146 }
2147
2148 let start = scroll as usize;
2150 let end = ((scroll + visible) as usize).min(visible_indices.len());
2151 for &abs_idx in &visible_indices[start..end] {
2152 let mut node = nodes[abs_idx].clone();
2157 node.text.normalize_widths();
2158 let item_key = item_keys.get(abs_idx).cloned().unwrap_or_default();
2159 let is_expanded =
2160 node.has_children && !item_key.is_empty() && prev_expanded.contains(&item_key);
2161 let rendered = render_tree_row(&node, is_expanded, checkable);
2162 let mut entry = rendered.entry;
2163 let is_selected = abs_idx as i32 == effective_sel_abs;
2164 if is_selected {
2165 let mut style = entry.style.unwrap_or_default();
2166 style.bg = Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG));
2167 style.extend_to_line_end = true;
2168 entry.style = Some(style);
2169 }
2170 let row_byte_end = entry.text.len();
2171 ensure_trailing_newline(&mut entry);
2172 out.entries.push(entry);
2173 let hit_row = (out.entries.len() - 1) as u32;
2174 let tree_spec_key = tree_key.unwrap_or("").to_string();
2184 if let Some(disc_range) = rendered.disclosure_range {
2185 out.hits.push(HitArea {
2186 widget_key: tree_spec_key.clone(),
2187 widget_kind: "tree",
2188 buffer_row: hit_row,
2189 byte_start: disc_range.0,
2190 byte_end: disc_range.1,
2191 payload: json!({
2192 "index": abs_idx as i64,
2193 "key": item_key.clone(),
2194 "expanded": !is_expanded,
2195 }),
2196 event_type: "expand",
2197 });
2198 }
2199 if let Some(cb_range) = rendered.checkbox_range {
2206 let new_checked = !nodes[abs_idx].checked.unwrap_or(false);
2207 out.hits.push(HitArea {
2208 widget_key: tree_spec_key.clone(),
2209 widget_kind: "tree",
2210 buffer_row: hit_row,
2211 byte_start: cb_range.0,
2212 byte_end: cb_range.1,
2213 payload: json!({
2214 "index": abs_idx as i64,
2215 "key": item_key.clone(),
2216 "checked": new_checked,
2217 }),
2218 event_type: "toggle",
2219 });
2220 }
2221 let body_start = match (rendered.checkbox_range, rendered.disclosure_range) {
2225 (Some((_, end)), _) => end + 1, (None, Some((_, end))) => end,
2227 (None, None) => 0,
2228 };
2229 if body_start < row_byte_end {
2230 out.hits.push(HitArea {
2231 widget_key: tree_spec_key,
2232 widget_kind: "tree",
2233 buffer_row: hit_row,
2234 byte_start: body_start,
2235 byte_end: row_byte_end,
2236 payload: json!({
2237 "index": abs_idx as i64,
2238 "key": item_key,
2239 }),
2240 event_type: "select",
2241 });
2242 }
2243 }
2244 out
2245}
2246
2247const LEFT_BORDER_PREFIX: &str = "│ ";
2252const RIGHT_BORDER_SUFFIX: &str = " │";
2253
2254fn render_section_top_border(label: &str, total_cols: usize) -> TextPropertyEntry {
2265 let mut text = String::new();
2266 let mut overlays: Vec<InlineOverlay> = Vec::new();
2267 text.push('╭');
2268 if label.is_empty() {
2269 for _ in 0..total_cols.saturating_sub(2) {
2270 text.push('─');
2271 }
2272 } else {
2273 let label_cols = label.chars().count();
2278 let used = 1 + 1 + 1 + label_cols + 1; text.push('─');
2280 text.push(' ');
2281 let label_byte_start = text.len();
2282 text.push_str(label);
2283 let label_byte_end = text.len();
2284 text.push(' ');
2285 let remaining = total_cols.saturating_sub(used + 1); for _ in 0..remaining {
2287 text.push('─');
2288 }
2289 overlays.push(InlineOverlay {
2290 start: label_byte_start,
2291 end: label_byte_end,
2292 style: OverlayOptions {
2293 fg: Some(OverlayColorSpec::theme_key(KEY_SECTION_LABEL_FG)),
2294 bold: true,
2295 ..Default::default()
2296 },
2297 properties: Default::default(),
2298 unit: OffsetUnit::Byte,
2299 });
2300 }
2301 text.push('╮');
2302 text.push('\n');
2303 TextPropertyEntry {
2304 text,
2305 properties: Default::default(),
2306 style: None,
2307 inline_overlays: overlays,
2308 segments: Vec::new(),
2309 pad_to_chars: None,
2310 truncate_to_chars: None,
2311 }
2312}
2313
2314fn render_section_bottom_border(total_cols: usize) -> TextPropertyEntry {
2317 let mut text = String::new();
2318 text.push('╰');
2319 for _ in 0..total_cols.saturating_sub(2) {
2320 text.push('─');
2321 }
2322 text.push('╯');
2323 text.push('\n');
2324 TextPropertyEntry {
2325 text,
2326 properties: Default::default(),
2327 style: None,
2328 inline_overlays: Vec::new(),
2329 segments: Vec::new(),
2330 pad_to_chars: None,
2331 truncate_to_chars: None,
2332 }
2333}
2334
2335fn render_completion_dim_separator_overlay(total_cols: usize) -> TextPropertyEntry {
2344 let inner = total_cols.saturating_sub(2).max(1);
2345 let mut text = String::with_capacity(total_cols * 4 + 2);
2346 text.push('│');
2347 for _ in 0..inner {
2348 text.push('┄');
2349 }
2350 text.push('│');
2351 text.push('\n');
2352 let left_border_bytes = "│".len();
2360 let dash_bytes = "┄".len() * inner;
2361 let right_border_start = left_border_bytes + dash_bytes;
2362 let right_border_end = right_border_start + "│".len();
2363 let inline_overlays = vec![
2364 InlineOverlay {
2365 start: 0,
2366 end: left_border_bytes,
2367 style: OverlayOptions {
2368 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2369 ..Default::default()
2370 },
2371 properties: Default::default(),
2372 unit: OffsetUnit::Byte,
2373 },
2374 InlineOverlay {
2375 start: left_border_bytes,
2376 end: left_border_bytes + dash_bytes,
2377 style: OverlayOptions {
2378 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_DIM_FG)),
2379 ..Default::default()
2380 },
2381 properties: Default::default(),
2382 unit: OffsetUnit::Byte,
2383 },
2384 InlineOverlay {
2385 start: right_border_start,
2386 end: right_border_end,
2387 style: OverlayOptions {
2388 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2389 ..Default::default()
2390 },
2391 properties: Default::default(),
2392 unit: OffsetUnit::Byte,
2393 },
2394 ];
2395 TextPropertyEntry {
2396 text,
2397 properties: Default::default(),
2398 style: None,
2399 inline_overlays,
2400 segments: Vec::new(),
2401 pad_to_chars: None,
2402 truncate_to_chars: None,
2403 }
2404}
2405
2406fn render_completion_bottom_border(total_cols: usize) -> TextPropertyEntry {
2413 let mut text = String::with_capacity(total_cols * 4 + 2);
2414 text.push('╰');
2415 for _ in 0..total_cols.saturating_sub(2).max(1) {
2416 text.push('─');
2417 }
2418 text.push('╯');
2419 text.push('\n');
2420 TextPropertyEntry {
2426 text,
2427 properties: Default::default(),
2428 style: Some(OverlayOptions {
2429 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2430 ..Default::default()
2431 }),
2432 inline_overlays: Vec::new(),
2433 segments: Vec::new(),
2434 pad_to_chars: None,
2435 truncate_to_chars: None,
2436 }
2437}
2438
2439fn render_completion_item_overlay(
2446 item: &str,
2447 kind: Option<&str>,
2448 selected: bool,
2449 total_cols: usize,
2450 scrollbar: Option<char>,
2451) -> TextPropertyEntry {
2452 let inner = total_cols.saturating_sub(2).max(1);
2453 let body_entry = render_completion_item(item, kind, selected, inner, scrollbar);
2457 let mut text = String::with_capacity(body_entry.text.len() + 8);
2461 text.push('│');
2462 let body_no_nl = body_entry.text.trim_end_matches('\n');
2463 text.push_str(body_no_nl);
2464 text.push('│');
2465 text.push('\n');
2466 let left_border_bytes = "│".len();
2486 let body_no_nl_bytes = body_no_nl.len();
2487 let right_border_start = left_border_bytes + body_no_nl_bytes;
2488 let right_border_end = right_border_start + "│".len();
2489 let mut inline_overlays: Vec<InlineOverlay> = Vec::new();
2490 if selected {
2491 inline_overlays.push(InlineOverlay {
2492 start: left_border_bytes,
2493 end: right_border_start,
2494 style: OverlayOptions {
2495 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_FG)),
2496 bg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_BG)),
2497 ..Default::default()
2498 },
2499 properties: Default::default(),
2500 unit: OffsetUnit::Byte,
2501 });
2502 }
2503 inline_overlays.extend(body_entry.inline_overlays.into_iter().map(|mut io| {
2511 io.start += left_border_bytes;
2512 io.end += left_border_bytes;
2513 io
2514 }));
2515 inline_overlays.push(InlineOverlay {
2516 start: 0,
2517 end: left_border_bytes,
2518 style: OverlayOptions {
2519 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2520 ..Default::default()
2521 },
2522 properties: Default::default(),
2523 unit: OffsetUnit::Byte,
2524 });
2525 inline_overlays.push(InlineOverlay {
2526 start: right_border_start,
2527 end: right_border_end,
2528 style: OverlayOptions {
2529 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2530 ..Default::default()
2531 },
2532 properties: Default::default(),
2533 unit: OffsetUnit::Byte,
2534 });
2535 TextPropertyEntry {
2536 text,
2537 properties: Default::default(),
2538 style: None,
2539 inline_overlays,
2540 segments: Vec::new(),
2541 pad_to_chars: None,
2542 truncate_to_chars: None,
2543 }
2544}
2545
2546fn render_completion_item(
2572 item: &str,
2573 kind: Option<&str>,
2574 selected: bool,
2575 total_cols: usize,
2576 scrollbar: Option<char>,
2577) -> TextPropertyEntry {
2578 let lead = if marker_gutter_enabled() { 2 } else { 0 };
2594 let text_budget = total_cols.saturating_sub(2 + lead).saturating_sub(1);
2598 let item_chars: Vec<char> = item.chars().collect();
2599 let (visible_item, truncated): (String, bool) = if item_chars.len() <= text_budget {
2600 (item.to_string(), false)
2601 } else {
2602 let keep = text_budget.saturating_sub(1);
2607 let head: String = item_chars.iter().take(keep).collect();
2608 (format!("{}…", head), true)
2609 };
2610 let _ = truncated;
2611 let scrollbar_ch = scrollbar.unwrap_or(' ');
2612 let is_history = kind == Some("history");
2613 let history_marker: char = '↶';
2620 let mut text = String::with_capacity(total_cols * 4 + 2);
2621 for _ in 0..lead {
2626 text.push(' ');
2627 }
2628 text.push(' ');
2629 let marker_start_byte = text.len();
2630 if is_history {
2631 text.push(history_marker);
2632 } else {
2633 text.push(' ');
2634 }
2635 let marker_end_byte = text.len();
2636 let item_start_byte = text.len();
2637 text.push_str(&visible_item);
2638 let item_end_byte = text.len();
2639 let used_cols = 2 + lead + visible_item.chars().count();
2643 let pad_cols = total_cols.saturating_sub(used_cols).saturating_sub(1);
2644 for _ in 0..pad_cols {
2645 text.push(' ');
2646 }
2647 text.push(scrollbar_ch);
2648 text.push('\n');
2649
2650 let body_style = if selected {
2651 Some(OverlayOptions {
2652 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_FG)),
2653 bg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_BG)),
2654 extend_to_line_end: true,
2655 fg_on_collision_only: false,
2656 ..Default::default()
2657 })
2658 } else {
2659 Some(OverlayOptions {
2664 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_FG)),
2665 extend_to_line_end: true,
2666 fg_on_collision_only: false,
2667 ..Default::default()
2668 })
2669 };
2670 let mut inline_overlays: Vec<InlineOverlay> = Vec::new();
2671 if is_history {
2676 inline_overlays.push(InlineOverlay {
2677 start: marker_start_byte,
2678 end: marker_end_byte,
2679 style: OverlayOptions {
2680 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2681 ..Default::default()
2682 },
2683 properties: Default::default(),
2684 unit: OffsetUnit::Byte,
2685 });
2686 inline_overlays.push(InlineOverlay {
2687 start: item_start_byte,
2688 end: item_end_byte,
2689 style: OverlayOptions {
2690 italic: true,
2691 ..Default::default()
2692 },
2693 properties: Default::default(),
2694 unit: OffsetUnit::Byte,
2695 });
2696 }
2697 if scrollbar.is_some() {
2703 let total_bytes = text.trim_end_matches('\n').len();
2704 let scrollbar_byte_len = scrollbar_ch.len_utf8();
2705 let start = total_bytes - scrollbar_byte_len;
2706 let end = total_bytes;
2707 inline_overlays.push(InlineOverlay {
2708 start,
2709 end,
2710 style: OverlayOptions {
2711 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_DIM_FG)),
2712 ..Default::default()
2713 },
2714 properties: Default::default(),
2715 unit: OffsetUnit::Byte,
2716 });
2717 }
2718
2719 TextPropertyEntry {
2720 text,
2721 properties: Default::default(),
2722 style: body_style,
2723 inline_overlays,
2724 segments: Vec::new(),
2725 pad_to_chars: None,
2726 truncate_to_chars: None,
2727 }
2728}
2729
2730fn completion_scrollbar_glyph(
2742 visible_row: u32,
2743 visible: u32,
2744 scroll: u32,
2745 total: u32,
2746) -> Option<char> {
2747 if total <= visible || visible == 0 {
2748 return None;
2749 }
2750 let thumb_size = ((visible as f32 * visible as f32) / total as f32).round() as u32;
2754 let thumb_size = thumb_size.max(1).min(visible);
2755 let max_scroll = total - visible;
2756 let thumb_top = if max_scroll == 0 {
2757 0
2758 } else {
2759 ((scroll as f32 / max_scroll as f32) * (visible - thumb_size) as f32).round() as u32
2763 };
2764 if visible_row >= thumb_top && visible_row < thumb_top + thumb_size {
2765 Some('█')
2766 } else {
2767 None
2768 }
2769}
2770
2771fn wrap_in_side_border(mut child: TextPropertyEntry, inner_width: usize) -> TextPropertyEntry {
2776 let prefix_bytes = LEFT_BORDER_PREFIX.len();
2777 let cur_cols = child.text.chars().count();
2779 if cur_cols < inner_width {
2780 for _ in 0..(inner_width - cur_cols) {
2781 child.text.push(' ');
2782 }
2783 } else if cur_cols > inner_width {
2784 let indices: Vec<usize> = child.text.char_indices().map(|(i, _)| i).collect();
2789 let byte_cutoff = indices
2790 .get(inner_width)
2791 .copied()
2792 .unwrap_or(child.text.len());
2793 child.text.truncate(byte_cutoff);
2794 if inner_width >= 2 {
2795 child.text.pop();
2801 child.text.push('…');
2802 }
2803 let byte_cutoff = child.text.len();
2804 child.inline_overlays.retain_mut(|o| {
2807 if o.start >= byte_cutoff {
2808 return false;
2809 }
2810 if o.end > byte_cutoff {
2811 o.end = byte_cutoff;
2812 }
2813 true
2814 });
2815 }
2816
2817 let mut text = String::with_capacity(
2819 LEFT_BORDER_PREFIX.len() + child.text.len() + RIGHT_BORDER_SUFFIX.len() + 1,
2820 );
2821 text.push_str(LEFT_BORDER_PREFIX);
2822 text.push_str(&child.text);
2823 text.push_str(RIGHT_BORDER_SUFFIX);
2824 text.push('\n');
2825
2826 let overlays: Vec<InlineOverlay> = child
2828 .inline_overlays
2829 .into_iter()
2830 .map(|o| InlineOverlay {
2831 start: o.start + prefix_bytes,
2832 end: o.end + prefix_bytes,
2833 style: o.style,
2834 properties: o.properties,
2835 unit: o.unit,
2836 })
2837 .collect();
2838
2839 TextPropertyEntry {
2840 text,
2841 properties: child.properties,
2842 style: child.style,
2843 inline_overlays: overlays,
2844 segments: Vec::new(),
2845 pad_to_chars: None,
2846 truncate_to_chars: None,
2847 }
2848}
2849
2850pub fn render_hint_bar(entries: &[HintEntry]) -> TextPropertyEntry {
2860 let separator = " ";
2861 let mut text = String::new();
2862 let mut overlays = Vec::new();
2863 for (i, entry) in entries.iter().enumerate() {
2864 if i > 0 {
2865 text.push_str(separator);
2866 }
2867 let key_start = text.len();
2868 text.push_str(&entry.keys);
2869 let key_end = text.len();
2870 if key_end > key_start {
2871 overlays.push(InlineOverlay {
2872 start: key_start,
2873 end: key_end,
2874 style: OverlayOptions {
2875 fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
2876 bold: true,
2877 ..Default::default()
2878 },
2879 properties: Default::default(),
2880 unit: OffsetUnit::Byte,
2881 });
2882 }
2883 if !entry.label.is_empty() {
2884 text.push(' ');
2885 text.push_str(&entry.label);
2886 }
2887 }
2888 TextPropertyEntry {
2889 text,
2890 properties: Default::default(),
2891 style: None,
2892 inline_overlays: overlays,
2893 segments: Vec::new(),
2894 pad_to_chars: None,
2895 truncate_to_chars: None,
2896 }
2897}
2898
2899pub fn render_toggle(checked: bool, label: &str, focused: bool) -> TextPropertyEntry {
2908 let glyph = if checked { "[v]" } else { "[ ]" };
2909 let marker = focus_gutter_prefix(focused);
2915 let mut text = String::with_capacity(marker.len() + glyph.len() + 1 + label.len());
2916 text.push_str(marker);
2917 let glyph_start = text.len();
2918 text.push_str(glyph);
2919 text.push(' ');
2920 text.push_str(label);
2921
2922 let mut overlays = Vec::new();
2923
2924 if checked {
2927 overlays.push(InlineOverlay {
2928 start: glyph_start,
2929 end: glyph_start + glyph.len(),
2930 style: OverlayOptions {
2931 fg: Some(OverlayColorSpec::theme_key(KEY_TOGGLE_ON_FG)),
2932 bold: true,
2933 ..Default::default()
2934 },
2935 properties: Default::default(),
2936 unit: OffsetUnit::Byte,
2937 });
2938 }
2939
2940 if focused {
2942 overlays.push(InlineOverlay {
2943 start: 0,
2944 end: text.len(),
2945 style: OverlayOptions {
2946 fg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_FG)),
2947 bg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG)),
2948 bold: true,
2949 ..Default::default()
2950 },
2951 properties: Default::default(),
2952 unit: OffsetUnit::Byte,
2953 });
2954 }
2955
2956 TextPropertyEntry {
2957 text,
2958 properties: Default::default(),
2959 style: None,
2960 inline_overlays: overlays,
2961 segments: Vec::new(),
2962 pad_to_chars: None,
2963 truncate_to_chars: None,
2964 }
2965}
2966
2967pub fn render_button(
2978 label: &str,
2979 focused: bool,
2980 kind: ButtonKind,
2981 disabled: bool,
2982) -> TextPropertyEntry {
2983 let marker = focus_gutter_prefix(focused && !disabled);
2993 let text = format!("{}[ {} ]", marker, label);
2994 let mut overlays = Vec::new();
2995
2996 let base_style = if disabled {
3004 OverlayOptions {
3005 fg: Some(OverlayColorSpec::theme_key("ui.menu_disabled_fg")),
3006 ..Default::default()
3007 }
3008 } else {
3009 match kind {
3010 ButtonKind::Normal => OverlayOptions::default(),
3011 ButtonKind::Primary => OverlayOptions {
3016 fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
3017 bold: true,
3018 ..Default::default()
3019 },
3020 ButtonKind::Danger => OverlayOptions {
3023 fg: Some(OverlayColorSpec::theme_key(KEY_DANGER_FG)),
3024 bold: true,
3025 ..Default::default()
3026 },
3027 }
3028 };
3029
3030 let style = if focused && !disabled {
3031 OverlayOptions {
3032 fg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_FG)),
3033 bg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG)),
3034 bold: true,
3035 ..base_style
3036 }
3037 } else {
3038 base_style
3039 };
3040
3041 if style.fg.is_some()
3044 || style.bg.is_some()
3045 || style.bold
3046 || style.italic
3047 || style.underline
3048 || style.strikethrough
3049 {
3050 overlays.push(InlineOverlay {
3051 start: 0,
3052 end: text.len(),
3053 style,
3054 properties: Default::default(),
3055 unit: OffsetUnit::Byte,
3056 });
3057 }
3058
3059 TextPropertyEntry {
3060 text,
3061 properties: Default::default(),
3062 style: None,
3063 inline_overlays: overlays,
3064 segments: Vec::new(),
3065 pad_to_chars: None,
3066 truncate_to_chars: None,
3067 }
3068}
3069
3070pub struct RenderedTreeRow {
3074 pub entry: TextPropertyEntry,
3075 pub disclosure_range: Option<(usize, usize)>,
3078 pub checkbox_range: Option<(usize, usize)>,
3083}
3084
3085pub fn render_tree_row(node: &TreeNode, expanded: bool, checkable: bool) -> RenderedTreeRow {
3103 let indent_cols = (node.depth as usize) * 2;
3104 let disclosure_glyph: &str = if node.has_children {
3105 if expanded {
3106 "▼"
3107 } else {
3108 "▶"
3109 }
3110 } else {
3111 " "
3114 };
3115 let separator: &str = if node.has_children { " " } else { "" };
3120
3121 let checkbox_glyph: Option<&'static str> = if checkable {
3122 match node.checked {
3123 Some(true) => Some("[v]"),
3124 Some(false) => Some("[ ]"),
3125 None => None,
3126 }
3127 } else {
3128 None
3129 };
3130 let checkbox_extra = checkbox_glyph.map(|g| g.len() + 1).unwrap_or(0);
3131
3132 let mut text = String::with_capacity(
3133 indent_cols
3134 + disclosure_glyph.len()
3135 + separator.len()
3136 + checkbox_extra
3137 + node.text.text.len(),
3138 );
3139 for _ in 0..indent_cols {
3140 text.push(' ');
3141 }
3142 let disc_start = text.len();
3143 text.push_str(disclosure_glyph);
3144 let disc_end = text.len();
3145 text.push_str(separator);
3146 let checkbox_range = if let Some(g) = checkbox_glyph {
3147 let cb_start = text.len();
3148 text.push_str(g);
3149 let cb_end = text.len();
3150 text.push(' ');
3151 Some((cb_start, cb_end))
3152 } else {
3153 None
3154 };
3155 let body_start = text.len();
3156 text.push_str(&node.text.text);
3157
3158 let mut overlays: Vec<InlineOverlay> = node
3162 .text
3163 .inline_overlays
3164 .iter()
3165 .map(|o| {
3166 let mut shifted = o.clone();
3167 shifted.start += body_start;
3168 shifted.end += body_start;
3169 shifted
3170 })
3171 .collect();
3172
3173 if node.has_children {
3176 overlays.push(InlineOverlay {
3177 start: disc_start,
3178 end: disc_end,
3179 style: OverlayOptions {
3180 fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
3181 bold: true,
3182 ..Default::default()
3183 },
3184 properties: Default::default(),
3185 unit: OffsetUnit::Byte,
3186 });
3187 }
3188 if let Some((cb_start, cb_end)) = checkbox_range {
3191 let theme_key = match node.checked {
3192 Some(true) => KEY_TOGGLE_ON_FG,
3193 _ => KEY_PLACEHOLDER_FG,
3194 };
3195 overlays.push(InlineOverlay {
3196 start: cb_start,
3197 end: cb_end,
3198 style: OverlayOptions {
3199 fg: Some(OverlayColorSpec::theme_key(theme_key)),
3200 bold: matches!(node.checked, Some(true)),
3201 ..Default::default()
3202 },
3203 properties: Default::default(),
3204 unit: OffsetUnit::Byte,
3205 });
3206 }
3207
3208 let disclosure_range = if node.has_children {
3209 Some((disc_start, disc_end))
3210 } else {
3211 None
3212 };
3213 let entry = TextPropertyEntry {
3214 text,
3215 properties: node.text.properties.clone(),
3219 style: node.text.style.clone(),
3220 inline_overlays: overlays,
3221 segments: Vec::new(),
3226 pad_to_chars: None,
3227 truncate_to_chars: None,
3228 };
3229 RenderedTreeRow {
3230 entry,
3231 disclosure_range,
3232 checkbox_range,
3233 }
3234}
3235
3236pub struct RenderedTextInput {
3240 pub entry: TextPropertyEntry,
3241 pub cursor_byte_in_entry: Option<usize>,
3244}
3245
3246#[allow(clippy::too_many_arguments)]
3271pub fn render_text_input(
3272 value: &str,
3273 cursor_byte: i32,
3274 selection: Option<(usize, usize)>,
3275 focused: bool,
3276 label: &str,
3277 placeholder: Option<&str>,
3278 max_visible_chars: u32,
3279 field_width: u32,
3280 full_width: bool,
3281) -> RenderedTextInput {
3282 let show_placeholder = value.is_empty() && placeholder.is_some();
3289
3290 let raw_cursor_byte = if cursor_byte < 0 {
3294 value.len()
3295 } else {
3296 (cursor_byte as usize).min(value.len())
3297 };
3298
3299 let (inner, cursor_in_inner) = if show_placeholder && field_width == 0 {
3303 let inner = placeholder.unwrap_or("").to_string();
3307 let cursor = if focused { Some(0usize) } else { None };
3308 (inner, cursor)
3309 } else if show_placeholder {
3310 let target = field_width as usize;
3317 let pad_extra = if focused || full_width { 1 } else { 0 };
3318 let total_inner = target + pad_extra;
3319 let raw = placeholder.unwrap_or("");
3320 let raw_chars: Vec<char> = raw.chars().collect();
3321 let inner = if raw_chars.len() <= total_inner {
3322 let mut s = raw.to_string();
3323 while s.chars().count() < total_inner {
3324 s.push(' ');
3325 }
3326 s
3327 } else {
3328 let keep = total_inner.saturating_sub(1);
3331 let prefix: String = raw_chars.iter().take(keep).collect();
3332 format!("{}…", prefix)
3333 };
3334 let cursor = if focused { Some(0usize) } else { None };
3335 (inner, cursor)
3336 } else if field_width > 0 {
3337 let target = field_width as usize;
3343 let pad_extra = if focused || full_width { 1 } else { 0 };
3344 let total_inner = target + pad_extra;
3345 let value_chars: Vec<char> = value.chars().collect();
3346 if value_chars.len() <= target {
3347 let mut padded = value.to_string();
3351 while padded.chars().count() < total_inner {
3352 padded.push(' ');
3353 }
3354 (padded, Some(raw_cursor_byte))
3355 } else {
3356 let keep = target - 1;
3360 let drop_chars = value_chars.len() - keep;
3361 let mut dropped_bytes = 0usize;
3362 for ch in value_chars.iter().take(drop_chars) {
3363 dropped_bytes += ch.len_utf8();
3364 }
3365 let tail = &value[dropped_bytes..];
3366 let mut s = String::with_capacity("…".len() + tail.len() + pad_extra);
3367 s.push('…');
3368 s.push_str(tail);
3369 for _ in 0..pad_extra {
3370 s.push(' ');
3371 }
3372 let cursor_in_inner = if raw_cursor_byte < dropped_bytes {
3376 "…".len()
3377 } else {
3378 "…".len() + (raw_cursor_byte - dropped_bytes)
3379 };
3380 (s, Some(cursor_in_inner))
3381 }
3382 } else if max_visible_chars > 0 && value.chars().count() > max_visible_chars as usize {
3383 let chars: Vec<char> = value.chars().collect();
3387 let take = (max_visible_chars as usize).saturating_sub(1);
3388 let start = chars.len().saturating_sub(take);
3389 let tail: String = chars[start..].iter().collect();
3390 let s = format!("…{}", tail);
3391 (s, Some(raw_cursor_byte.min(value.len())))
3392 } else {
3393 let mut s = value.to_string();
3399 if focused {
3400 s.push(' ');
3401 }
3402 (s, Some(raw_cursor_byte))
3403 };
3404
3405 let mut text = String::new();
3407 if !label.is_empty() {
3408 text.push_str(label);
3409 text.push(' ');
3410 }
3411 let bracket_open_byte = text.len();
3412 text.push('[');
3413 let inner_byte_start = text.len();
3414 text.push_str(&inner);
3415 let inner_byte_end = text.len();
3416 text.push(']');
3417 let bracket_close_byte = text.len();
3418
3419 let mut overlays = Vec::new();
3420
3421 if show_placeholder {
3422 overlays.push(InlineOverlay {
3423 start: inner_byte_start,
3424 end: inner_byte_end,
3425 style: OverlayOptions {
3426 fg: Some(OverlayColorSpec::theme_key(KEY_PLACEHOLDER_FG)),
3427 italic: true,
3428 ..Default::default()
3429 },
3430 properties: Default::default(),
3431 unit: OffsetUnit::Byte,
3432 });
3433 }
3434
3435 if focused {
3436 overlays.push(InlineOverlay {
3437 start: bracket_open_byte,
3438 end: bracket_close_byte,
3439 style: OverlayOptions {
3440 bg: Some(OverlayColorSpec::theme_key(KEY_INPUT_BG)),
3441 ..Default::default()
3442 },
3443 properties: Default::default(),
3444 unit: OffsetUnit::Byte,
3445 });
3446 }
3447
3448 let inner_is_truncated = inner.starts_with('…');
3457 if focused && !inner_is_truncated {
3458 if let Some((sel_start, sel_end)) = selection {
3459 let visible_value_len = value.len();
3463 let s = sel_start.min(sel_end).min(visible_value_len);
3464 let e = sel_start.max(sel_end).min(visible_value_len);
3465 if e > s {
3466 overlays.push(InlineOverlay {
3467 start: inner_byte_start + s,
3468 end: inner_byte_start + e,
3469 style: OverlayOptions {
3470 bg: Some(OverlayColorSpec::theme_key(KEY_TEXT_INPUT_SELECTION_BG)),
3471 ..Default::default()
3472 },
3473 properties: Default::default(),
3474 unit: OffsetUnit::Byte,
3475 });
3476 }
3477 }
3478 }
3479
3480 let cursor_byte_in_entry = if focused {
3481 cursor_in_inner.map(|c| inner_byte_start + c)
3482 } else {
3483 None
3484 };
3485
3486 RenderedTextInput {
3487 entry: TextPropertyEntry {
3488 text,
3489 properties: Default::default(),
3490 style: None,
3491 inline_overlays: overlays,
3492 segments: Vec::new(),
3493 pad_to_chars: None,
3494 truncate_to_chars: None,
3495 },
3496 cursor_byte_in_entry,
3497 }
3498}
3499
3500pub struct RenderedTextArea {
3503 pub entries: Vec<TextPropertyEntry>,
3508 pub scroll_row: u32,
3512 pub cursor_buffer_row: Option<u32>,
3516 pub cursor_byte_in_row: Option<usize>,
3519}
3520
3521#[allow(clippy::too_many_arguments)]
3548pub fn render_text_area(
3549 value: &str,
3550 cursor_byte: i32,
3551 selection: Option<(usize, usize)>,
3552 focused: bool,
3553 label: &str,
3554 placeholder: Option<&str>,
3555 visible_rows: u32,
3556 field_width: u32,
3557 prev_scroll: u32,
3558 panel_width: u32,
3559) -> RenderedTextArea {
3560 let target_width: usize = if field_width > 0 {
3563 field_width as usize
3564 } else if panel_width != u32::MAX && panel_width > 0 {
3565 panel_width as usize
3566 } else {
3567 40
3568 };
3569
3570 let mut lines: Vec<&str> = value.split('\n').collect();
3574 if lines.is_empty() {
3575 lines.push("");
3576 }
3577
3578 let raw_cursor_byte = if cursor_byte < 0 {
3582 value.len()
3583 } else {
3584 (cursor_byte as usize).min(value.len())
3585 };
3586 let (cursor_line, cursor_col) = byte_to_line_col(value, raw_cursor_byte);
3587
3588 let selection_lc: Option<((usize, usize), (usize, usize))> = selection.and_then(|(a, b)| {
3593 let lo = a.min(b);
3594 let hi = a.max(b);
3595 if hi <= lo || hi > value.len() {
3596 return None;
3597 }
3598 Some((byte_to_line_col(value, lo), byte_to_line_col(value, hi)))
3599 });
3600
3601 let visible_rows_usize = visible_rows.max(1) as usize;
3604 let mut scroll_row = prev_scroll as usize;
3605 if cursor_line < scroll_row {
3606 scroll_row = cursor_line;
3607 } else if cursor_line >= scroll_row + visible_rows_usize {
3608 scroll_row = cursor_line + 1 - visible_rows_usize;
3609 }
3610 let max_scroll = lines.len().saturating_sub(visible_rows_usize);
3612 if scroll_row > max_scroll {
3613 scroll_row = max_scroll;
3614 }
3615
3616 let show_placeholder =
3617 !focused && value.is_empty() && placeholder.is_some() && !placeholder.unwrap().is_empty();
3618
3619 let mut entries: Vec<TextPropertyEntry> = Vec::new();
3620 let mut cursor_buffer_row: Option<u32> = None;
3621 let mut cursor_byte_in_row: Option<usize> = None;
3622
3623 if !label.is_empty() {
3624 let mut text = String::with_capacity(label.len() + 2);
3625 text.push_str(label);
3626 text.push(':');
3627 entries.push(TextPropertyEntry {
3628 text,
3629 properties: Default::default(),
3630 style: None,
3631 inline_overlays: Vec::new(),
3632 segments: Vec::new(),
3633 pad_to_chars: None,
3634 truncate_to_chars: None,
3635 });
3636 }
3637 let label_offset: u32 = entries.len() as u32;
3638
3639 for row_in_view in 0..visible_rows_usize {
3640 let line_idx = scroll_row + row_in_view;
3641 let mut row_text;
3642 let mut overlays: Vec<InlineOverlay> = Vec::new();
3643
3644 if line_idx < lines.len() {
3645 row_text = pad_or_truncate_line(lines[line_idx], target_width);
3646 } else {
3647 row_text = " ".repeat(target_width);
3648 }
3649
3650 if show_placeholder && row_in_view == 0 {
3652 let ph = placeholder.unwrap();
3653 row_text = pad_or_truncate_line(ph, target_width);
3654 overlays.push(InlineOverlay {
3655 start: 0,
3656 end: row_text.len(),
3657 style: OverlayOptions {
3658 fg: Some(OverlayColorSpec::theme_key(KEY_PLACEHOLDER_FG)),
3659 ..Default::default()
3660 },
3661 properties: Default::default(),
3662 unit: OffsetUnit::Byte,
3663 });
3664 }
3665
3666 if focused {
3669 overlays.push(InlineOverlay {
3670 start: 0,
3671 end: row_text.len(),
3672 style: OverlayOptions {
3673 bg: Some(OverlayColorSpec::theme_key(KEY_INPUT_BG)),
3674 ..Default::default()
3675 },
3676 properties: Default::default(),
3677 unit: OffsetUnit::Byte,
3678 });
3679 }
3680
3681 if focused {
3685 if let Some(((sl, sc), (el, ec))) = selection_lc {
3686 if line_idx >= sl && line_idx <= el {
3687 let line_text_len = if line_idx < lines.len() {
3688 lines[line_idx].len()
3689 } else {
3690 0
3691 };
3692 let row_start = if line_idx == sl { sc } else { 0 };
3693 let row_end = if line_idx == el { ec } else { line_text_len };
3694 let s = row_start.min(line_text_len);
3695 let e = row_end.min(line_text_len);
3696 if e > s {
3697 overlays.push(InlineOverlay {
3698 start: s,
3699 end: e,
3700 style: OverlayOptions {
3701 bg: Some(OverlayColorSpec::theme_key(KEY_TEXT_INPUT_SELECTION_BG)),
3702 ..Default::default()
3703 },
3704 properties: Default::default(),
3705 unit: OffsetUnit::Byte,
3706 });
3707 }
3708 }
3709 }
3710 }
3711
3712 if focused && line_idx == cursor_line && cursor_byte >= 0 {
3714 let col_in_line = cursor_col.min(row_text.len());
3719 cursor_buffer_row = Some(label_offset + row_in_view as u32);
3720 cursor_byte_in_row = Some(col_in_line);
3721 }
3722
3723 entries.push(TextPropertyEntry {
3724 text: row_text,
3725 properties: Default::default(),
3726 style: None,
3727 inline_overlays: overlays,
3728 segments: Vec::new(),
3729 pad_to_chars: None,
3730 truncate_to_chars: None,
3731 });
3732 }
3733
3734 RenderedTextArea {
3735 entries,
3736 scroll_row: scroll_row as u32,
3737 cursor_buffer_row,
3738 cursor_byte_in_row,
3739 }
3740}
3741
3742fn byte_to_line_col(value: &str, byte: usize) -> (usize, usize) {
3744 let byte = byte.min(value.len());
3745 let mut line = 0usize;
3746 let mut line_start = 0usize;
3747 for (i, &b) in value.as_bytes().iter().enumerate().take(byte) {
3748 if b == b'\n' {
3749 line += 1;
3750 line_start = i + 1;
3751 }
3752 }
3753 (line, byte - line_start)
3754}
3755
3756fn pad_or_truncate_line(line: &str, target: usize) -> String {
3762 let chars: Vec<char> = line.chars().collect();
3763 if chars.len() <= target {
3764 let mut out = line.to_string();
3765 let pad = target - chars.len();
3766 for _ in 0..pad {
3767 out.push(' ');
3768 }
3769 out
3770 } else {
3771 let keep = target.saturating_sub(1);
3772 let mut out: String = chars.iter().take(keep).collect();
3773 out.push('…');
3774 out
3775 }
3776}
3777
3778fn assemble_wrapped_row(
3786 pieces: Vec<RowPiece>,
3787 panel_width: u32,
3788 entries: &mut Vec<TextPropertyEntry>,
3789 hits: &mut Vec<HitArea>,
3790) {
3791 use crate::primitives::display_width::str_width;
3792 let max_w = panel_width as usize;
3793 let mut acc: Option<TextPropertyEntry> = None;
3794 let mut row: u32 = 0;
3795 let flush = |acc: &mut Option<TextPropertyEntry>, entries: &mut Vec<TextPropertyEntry>| {
3798 if let Some(mut merged) = acc.take() {
3799 ensure_trailing_newline(&mut merged);
3800 entries.push(merged);
3801 }
3802 };
3803 for piece in pieces {
3804 let RowPiece::Inline {
3805 mut entry,
3806 hits: child_hits,
3807 ..
3808 } = piece
3809 else {
3810 continue;
3812 };
3813 let is_blank = entry.text.trim().is_empty();
3814 let piece_w = str_width(&entry.text);
3815 let acc_w = acc.as_ref().map(|e| str_width(&e.text)).unwrap_or(0);
3816 if acc.is_some() && acc_w + piece_w > max_w {
3818 flush(&mut acc, entries);
3819 row += 1;
3820 }
3821 if acc.is_none() && is_blank {
3823 continue;
3824 }
3825 let shift = acc.as_ref().map(|e| e.text.len()).unwrap_or(0);
3826 for mut h in child_hits {
3827 h.byte_start += shift;
3828 h.byte_end += shift;
3829 h.buffer_row = row;
3830 hits.push(h);
3831 }
3832 match acc.as_mut() {
3833 Some(merged) => merge_inline(merged, &mut entry),
3834 None => acc = Some(entry),
3835 }
3836 }
3837 flush(&mut acc, entries);
3838}
3839
3840fn merge_inline(merged: &mut TextPropertyEntry, next: &mut TextPropertyEntry) {
3844 let shift = merged.text.len();
3845 merged.text.push_str(&next.text);
3846 for overlay in next.inline_overlays.drain(..) {
3847 merged.inline_overlays.push(InlineOverlay {
3848 start: overlay.start + shift,
3849 end: overlay.end + shift,
3850 style: overlay.style,
3851 properties: overlay.properties,
3852 unit: overlay.unit,
3853 });
3854 }
3855 }
3861
3862fn pad_or_truncate_cols(text: &mut String, cols: usize) {
3873 let cur = text.chars().count();
3874 if cur < cols {
3875 for _ in 0..(cols - cur) {
3876 text.push(' ');
3877 }
3878 } else if cur > cols {
3879 let cutoff = text
3882 .char_indices()
3883 .nth(cols)
3884 .map(|(i, _)| i)
3885 .unwrap_or(text.len());
3886 text.truncate(cutoff);
3887 if cols >= 2 {
3888 text.pop();
3891 text.push('…');
3892 }
3893 }
3894}
3895
3896fn snap_down_to_char_boundary(s: &str, idx: usize) -> usize {
3902 let mut i = idx.min(s.len());
3903 while i > 0 && !s.is_char_boundary(i) {
3904 i -= 1;
3905 }
3906 i
3907}
3908
3909fn zip_row_blocks(
3932 pieces: Vec<RowPiece>,
3933 panel_width: u32,
3934 out_entries: &mut Vec<TextPropertyEntry>,
3935 out_hits: &mut Vec<HitArea>,
3936 out_focus_cursor: &mut Option<FocusCursor>,
3937 out_embeds: &mut Vec<EmbedRect>,
3938 out_scroll: &mut Vec<ScrollRegion>,
3939) {
3940 let starting_row = out_entries.len() as u32;
3941 let _ = panel_width;
3942
3943 let max_height = pieces
3945 .iter()
3946 .filter_map(|p| match p {
3947 RowPiece::Block { entries, .. } => Some(entries.len()),
3948 _ => None,
3949 })
3950 .max()
3951 .unwrap_or(0);
3952 if max_height == 0 {
3953 return;
3954 }
3955
3956 for row_idx in 0..max_height {
3957 let mut text = String::new();
3958 let mut overlays: Vec<InlineOverlay> = Vec::new();
3959 for piece in &pieces {
3960 match piece {
3961 RowPiece::Inline {
3962 entry,
3963 hits,
3964 focus_cursor,
3965 embeds: inline_embeds,
3966 scroll_regions: inline_scroll,
3967 } => {
3968 let inline_cols = entry.text.chars().count();
3969 let byte_shift = text.len();
3970 let col_shift = text.chars().count() as u32;
3974 if row_idx == 0 {
3975 text.push_str(&entry.text);
3976 for emb in inline_embeds {
3977 out_embeds.push(EmbedRect {
3978 window_id: emb.window_id,
3979 buffer_row: starting_row + emb.buffer_row,
3980 col_in_row: emb.col_in_row + col_shift,
3981 width_cols: emb.width_cols,
3982 height_rows: emb.height_rows,
3983 });
3984 }
3985 for sr in inline_scroll {
3986 let mut sr = sr.clone();
3987 sr.buffer_row += starting_row;
3988 sr.col_in_row += col_shift;
3989 out_scroll.push(sr);
3990 }
3991 for overlay in &entry.inline_overlays {
3992 overlays.push(InlineOverlay {
3993 start: overlay.start + byte_shift,
3994 end: overlay.end + byte_shift,
3995 style: overlay.style.clone(),
3996 properties: overlay.properties.clone(),
3997 unit: overlay.unit,
3998 });
3999 }
4000 for h in hits {
4001 let mut h = h.clone();
4002 h.byte_start += byte_shift;
4003 h.byte_end += byte_shift;
4004 h.buffer_row = starting_row;
4005 out_hits.push(h);
4006 }
4007 if let Some(fc) = focus_cursor {
4008 *out_focus_cursor = Some(FocusCursor {
4009 buffer_row: starting_row,
4010 byte_in_row: fc.byte_in_row + byte_shift as u32,
4011 });
4012 }
4013 } else {
4014 for _ in 0..inline_cols {
4015 text.push(' ');
4016 }
4017 }
4018 }
4019 RowPiece::Flex => {
4020 }
4022 RowPiece::Block {
4023 column_width,
4024 entries,
4025 hits,
4026 focus_cursor,
4027 embeds: block_embeds,
4028 scroll_regions: block_scroll,
4029 } => {
4030 let block_w = *column_width as usize;
4031 let byte_shift = text.len();
4032 let col_shift = text.chars().count() as u32;
4035 if row_idx == 0 {
4040 for emb in block_embeds {
4041 out_embeds.push(EmbedRect {
4042 window_id: emb.window_id,
4043 buffer_row: starting_row + emb.buffer_row,
4044 col_in_row: emb.col_in_row + col_shift,
4045 width_cols: emb.width_cols,
4046 height_rows: emb.height_rows,
4047 });
4048 }
4049 for sr in block_scroll {
4050 let mut sr = sr.clone();
4051 sr.buffer_row += starting_row;
4052 sr.col_in_row += col_shift;
4053 out_scroll.push(sr);
4054 }
4055 }
4056 if let Some(line) = entries.get(row_idx) {
4057 let mut line_text = line.text.clone();
4058 if line_text.ends_with('\n') {
4061 line_text.pop();
4062 }
4063 pad_or_truncate_cols(&mut line_text, block_w);
4064 let padded_byte_len = line_text.len();
4065 text.push_str(&line_text);
4066 if let Some(line_style) = &line.style {
4076 overlays.push(InlineOverlay {
4077 start: byte_shift,
4078 end: byte_shift + padded_byte_len,
4079 style: line_style.clone(),
4080 properties: Default::default(),
4081 unit: OffsetUnit::Byte,
4082 });
4083 }
4084 for overlay in &line.inline_overlays {
4085 let start = snap_down_to_char_boundary(&line_text, overlay.start);
4094 let end = snap_down_to_char_boundary(&line_text, overlay.end);
4095 if start >= end {
4096 continue;
4097 }
4098 overlays.push(InlineOverlay {
4099 start: start + byte_shift,
4100 end: end + byte_shift,
4101 style: overlay.style.clone(),
4102 properties: overlay.properties.clone(),
4103 unit: overlay.unit,
4104 });
4105 }
4106 for h in hits {
4107 if h.buffer_row != row_idx as u32 {
4108 continue;
4109 }
4110 let mut h = h.clone();
4111 h.byte_start += byte_shift;
4112 h.byte_end += byte_shift;
4113 h.buffer_row = starting_row + row_idx as u32;
4114 out_hits.push(h);
4115 }
4116 if let Some(fc) = focus_cursor {
4117 if fc.buffer_row == row_idx as u32 {
4118 *out_focus_cursor = Some(FocusCursor {
4119 buffer_row: starting_row + row_idx as u32,
4120 byte_in_row: fc.byte_in_row + byte_shift as u32,
4121 });
4122 }
4123 }
4124 } else {
4125 for _ in 0..block_w {
4128 text.push(' ');
4129 }
4130 }
4131 }
4132 }
4133 }
4134 text.push('\n');
4135 out_entries.push(TextPropertyEntry {
4136 text,
4137 properties: Default::default(),
4138 style: None,
4139 inline_overlays: overlays,
4140 segments: Vec::new(),
4141 pad_to_chars: None,
4142 truncate_to_chars: None,
4143 });
4144 }
4145}
4146
4147#[cfg(test)]
4148mod tests {
4149 use super::*;
4150
4151 fn render_no_focus(
4156 spec: &WidgetSpec,
4157 prev: &HashMap<String, WidgetInstanceState>,
4158 ) -> (
4159 Vec<TextPropertyEntry>,
4160 Vec<HitArea>,
4161 HashMap<String, WidgetInstanceState>,
4162 ) {
4163 let out = render_spec(spec, prev, "", u32::MAX);
4165 (out.entries, out.hits, out.instance_states)
4166 }
4167
4168 #[test]
4169 fn hint_bar_renders_entries_with_key_overlays() {
4170 let entries = vec![
4171 HintEntry {
4172 keys: "Tab".into(),
4173 label: "next".into(),
4174 },
4175 HintEntry {
4176 keys: "Esc".into(),
4177 label: "close".into(),
4178 },
4179 ];
4180 let entry = render_hint_bar(&entries);
4181 assert_eq!(entry.text, "Tab next Esc close");
4182 assert_eq!(entry.inline_overlays.len(), 2);
4183 assert_eq!(entry.inline_overlays[0].start, 0);
4185 assert_eq!(entry.inline_overlays[0].end, 3);
4186 assert_eq!(entry.inline_overlays[1].start, 10);
4188 assert_eq!(entry.inline_overlays[1].end, 13);
4189 }
4190
4191 #[test]
4192 fn hint_bar_omits_label_when_empty() {
4193 let entries = vec![HintEntry {
4194 keys: "?".into(),
4195 label: "".into(),
4196 }];
4197 let entry = render_hint_bar(&entries);
4198 assert_eq!(entry.text, "?");
4199 }
4200
4201 #[test]
4202 fn col_stacks_children_top_to_bottom() {
4203 let spec = WidgetSpec::Col {
4204 children: vec![
4205 WidgetSpec::HintBar {
4206 entries: vec![HintEntry {
4207 keys: "A".into(),
4208 label: "alpha".into(),
4209 }],
4210 key: None,
4211 },
4212 WidgetSpec::HintBar {
4213 entries: vec![HintEntry {
4214 keys: "B".into(),
4215 label: "beta".into(),
4216 }],
4217 key: None,
4218 },
4219 ],
4220 key: None,
4221 };
4222 let (out, hits, _state) = render_no_focus(&spec, &HashMap::new());
4223 assert_eq!(out.len(), 2);
4224 assert_eq!(out[0].text, "A alpha\n");
4225 assert_eq!(out[1].text, "B beta\n");
4226 assert!(hits.is_empty(), "HintBar emits no hit areas in v1");
4227 }
4228
4229 #[test]
4230 fn raw_passes_through_unchanged() {
4231 let spec = WidgetSpec::Raw {
4232 entries: vec![TextPropertyEntry::text("hello")],
4233 key: None,
4234 };
4235 let (out, hits, _state) = render_no_focus(&spec, &HashMap::new());
4236 assert_eq!(out.len(), 1);
4237 assert_eq!(out[0].text, "hello\n");
4238 assert!(hits.is_empty());
4239 }
4240
4241 #[test]
4242 fn toggle_checked_emits_glyph_overlay() {
4243 let entry = render_toggle(true, "Case", false);
4244 assert_eq!(entry.text, "[v] Case");
4245 assert_eq!(entry.inline_overlays.len(), 1);
4247 assert_eq!(entry.inline_overlays[0].start, 0);
4248 assert_eq!(entry.inline_overlays[0].end, 3);
4249 }
4250
4251 #[test]
4252 fn toggle_unchecked_no_glyph_overlay() {
4253 let entry = render_toggle(false, "Case", false);
4254 assert_eq!(entry.text, "[ ] Case");
4255 assert_eq!(entry.inline_overlays.len(), 0);
4256 }
4257
4258 #[test]
4259 fn toggle_focused_adds_full_entry_overlay() {
4260 let entry = render_toggle(true, "Case", true);
4261 assert_eq!(entry.inline_overlays.len(), 2);
4263 assert_eq!(entry.inline_overlays[1].start, 0);
4265 assert_eq!(entry.inline_overlays[1].end, entry.text.len());
4266 assert!(entry.inline_overlays[1].style.bold);
4267 }
4268
4269 #[test]
4270 fn button_normal_unfocused_has_no_overlay() {
4271 let entry = render_button("Replace All", false, ButtonKind::Normal, false);
4272 assert_eq!(entry.text, "[ Replace All ]");
4273 assert!(entry.inline_overlays.is_empty());
4274 }
4275
4276 #[test]
4277 fn button_primary_unfocused_is_bold_help_key_fg_with_no_bg() {
4278 let entry = render_button("Submit", false, ButtonKind::Primary, false);
4283 assert_eq!(entry.inline_overlays.len(), 1);
4284 let style = &entry.inline_overlays[0].style;
4285 assert!(style.bold);
4286 assert_eq!(
4287 style.fg.as_ref().and_then(|c| c.as_theme_key()),
4288 Some("ui.help_key_fg"),
4289 );
4290 assert!(style.bg.is_none(), "unfocused primary must not paint a bg");
4291 }
4292
4293 #[test]
4294 fn button_danger_uses_error_theme_key() {
4295 let entry = render_button("Delete", false, ButtonKind::Danger, false);
4296 assert_eq!(entry.inline_overlays.len(), 1);
4297 let fg = entry.inline_overlays[0].style.fg.as_ref().unwrap();
4298 assert_eq!(fg.as_theme_key(), Some("diagnostic.error_fg"));
4299 assert!(entry.inline_overlays[0].style.bold);
4300 }
4301
4302 #[test]
4303 fn button_focused_overrides_with_popup_selection_keys() {
4304 let entry = render_button("OK", true, ButtonKind::Normal, false);
4311 let style = &entry.inline_overlays[0].style;
4312 assert_eq!(
4313 style.fg.as_ref().and_then(|c| c.as_theme_key()),
4314 Some("ui.popup_selection_fg")
4315 );
4316 assert_eq!(
4317 style.bg.as_ref().and_then(|c| c.as_theme_key()),
4318 Some("ui.popup_selection_bg")
4319 );
4320 assert!(style.bold);
4321 }
4322
4323 #[test]
4324 fn flex_spacer_fills_remaining_row_width() {
4325 let spec = WidgetSpec::Row {
4326 wrap: false,
4327 children: vec![
4328 WidgetSpec::Toggle {
4329 checked: false,
4330 label: "A".into(),
4331 focused: false,
4332 key: None,
4333 },
4334 WidgetSpec::Spacer {
4335 cols: 0,
4336 flex: true,
4337 key: None,
4338 },
4339 WidgetSpec::Button {
4340 label: "B".into(),
4341 focused: false,
4342 intent: ButtonKind::Normal,
4343 key: None,
4344 disabled: false,
4345 focusable: true,
4346 },
4347 ],
4348 key: None,
4349 };
4350 let out = render_spec(&spec, &HashMap::new(), "", 30);
4354 assert_eq!(out.entries.len(), 1);
4355 let text = &out.entries[0].text;
4356 assert_eq!(text.len(), 31);
4357 assert!(text.starts_with("[ ] A"));
4358 assert!(text.ends_with("[ B ]\n"));
4359 let button_hit = out.hits.iter().find(|h| h.widget_kind == "button").unwrap();
4360 assert_eq!(button_hit.byte_start, 25);
4361 assert_eq!(button_hit.byte_end, 30);
4362 }
4363
4364 #[test]
4365 fn flex_spacer_with_no_leftover_collapses_to_zero() {
4366 let spec = WidgetSpec::Row {
4367 wrap: false,
4368 children: vec![
4369 WidgetSpec::Toggle {
4370 checked: false,
4371 label: "A".into(),
4372 focused: false,
4373 key: None,
4374 },
4375 WidgetSpec::Spacer {
4376 cols: 0,
4377 flex: true,
4378 key: None,
4379 },
4380 WidgetSpec::Toggle {
4381 checked: false,
4382 label: "B".into(),
4383 focused: false,
4384 key: None,
4385 },
4386 ],
4387 key: None,
4388 };
4389 let out = render_spec(&spec, &HashMap::new(), "", 10);
4391 assert_eq!(out.entries[0].text, "[ ] A[ ] B\n");
4392 }
4393
4394 #[test]
4395 fn spacer_in_row_pads_with_spaces() {
4396 let spec = WidgetSpec::Row {
4397 wrap: false,
4398 children: vec![
4399 WidgetSpec::Toggle {
4400 checked: false,
4401 label: "A".into(),
4402 focused: false,
4403 key: None,
4404 },
4405 WidgetSpec::Spacer {
4406 cols: 4,
4407 flex: false,
4408 key: None,
4409 },
4410 WidgetSpec::Button {
4411 label: "Go".into(),
4412 focused: false,
4413 intent: ButtonKind::Normal,
4414 key: None,
4415 disabled: false,
4416 focusable: true,
4417 },
4418 ],
4419 key: None,
4420 };
4421 let (out, _hits, _state) = render_no_focus(&spec, &HashMap::new());
4422 assert_eq!(out.len(), 1);
4423 assert_eq!(out[0].text, "[ ] A [ Go ]\n");
4424 }
4425
4426 #[test]
4427 fn row_collapses_inline_children_with_shifted_overlays() {
4428 let spec = WidgetSpec::Row {
4429 wrap: false,
4430 children: vec![
4431 WidgetSpec::HintBar {
4432 entries: vec![HintEntry {
4433 keys: "Tab".into(),
4434 label: "x".into(),
4435 }],
4436 key: None,
4437 },
4438 WidgetSpec::HintBar {
4439 entries: vec![HintEntry {
4440 keys: "Esc".into(),
4441 label: "y".into(),
4442 }],
4443 key: None,
4444 },
4445 ],
4446 key: None,
4447 };
4448 let (out, _hits, _state) = render_no_focus(&spec, &HashMap::new());
4449 assert_eq!(out.len(), 1);
4450 assert_eq!(out[0].text, "Tab xEsc y\n");
4452 assert_eq!(out[0].inline_overlays.len(), 2);
4453 assert_eq!(out[0].inline_overlays[1].start, 5);
4454 assert_eq!(out[0].inline_overlays[1].end, 8);
4455 }
4456
4457 #[test]
4462 fn toggle_emits_hit_area_with_toggle_payload() {
4463 let spec = WidgetSpec::Toggle {
4464 checked: false,
4465 label: "Case".into(),
4466 focused: false,
4467 key: Some("case".into()),
4468 };
4469 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4470 assert_eq!(hits.len(), 1);
4471 let h = &hits[0];
4472 assert_eq!(h.widget_key, "case");
4473 assert_eq!(h.widget_kind, "toggle");
4474 assert_eq!(h.event_type, "toggle");
4475 assert_eq!(h.buffer_row, 0);
4476 assert_eq!(h.byte_start, 0);
4477 assert_eq!(h.byte_end, "[ ] Case".len());
4478 assert_eq!(h.payload, json!({"checked": true}));
4479 }
4480
4481 #[test]
4482 fn button_emits_hit_area_with_activate_payload() {
4483 let spec = WidgetSpec::Button {
4484 label: "Replace All".into(),
4485 focused: false,
4486 intent: ButtonKind::Primary,
4487 key: Some("replace".into()),
4488 disabled: false,
4489 focusable: true,
4490 };
4491 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4492 assert_eq!(hits.len(), 1);
4493 let h = &hits[0];
4494 assert_eq!(h.widget_key, "replace");
4495 assert_eq!(h.widget_kind, "button");
4496 assert_eq!(h.event_type, "activate");
4497 assert_eq!(h.byte_end, "[ Replace All ]".len());
4498 assert_eq!(h.payload, json!({}));
4499 }
4500
4501 #[test]
4502 fn disabled_button_omits_hit_area_and_skips_tabbable() {
4503 let spec = WidgetSpec::Row {
4504 wrap: false,
4505 children: vec![
4506 WidgetSpec::Button {
4507 label: "Archive".into(),
4508 focused: false,
4509 intent: ButtonKind::Normal,
4510 key: Some("archive".into()),
4511 disabled: true,
4512 focusable: true,
4513 },
4514 WidgetSpec::Button {
4515 label: "Cancel".into(),
4516 focused: false,
4517 intent: ButtonKind::Normal,
4518 key: Some("cancel".into()),
4519 disabled: false,
4520 focusable: true,
4521 },
4522 ],
4523 key: None,
4524 };
4525 let out = render_spec(&spec, &HashMap::new(), "", 30);
4526 assert_eq!(
4527 out.hits
4528 .iter()
4529 .filter(|h| h.widget_kind == "button")
4530 .count(),
4531 1,
4532 "disabled button should not emit a hit area"
4533 );
4534 assert_eq!(
4535 out.tabbable,
4536 vec!["cancel".to_string()],
4537 "disabled button must drop out of the Tab cycle"
4538 );
4539 }
4540
4541 #[test]
4542 fn disabled_button_uses_menu_disabled_fg_overlay() {
4543 let entry = render_button("Archive", false, ButtonKind::Danger, true);
4544 assert_eq!(entry.inline_overlays.len(), 1);
4545 let style = &entry.inline_overlays[0].style;
4546 assert_eq!(
4547 style.fg.as_ref().and_then(|c| c.as_theme_key()),
4548 Some("ui.menu_disabled_fg"),
4549 "disabled overrides Danger fg with the muted theme key"
4550 );
4551 assert!(
4552 !style.bold,
4553 "disabled buttons drop the intent's bold emphasis"
4554 );
4555 assert!(style.bg.is_none(), "disabled buttons paint no bg");
4556 }
4557
4558 #[test]
4559 fn row_inline_collapse_shifts_hit_byte_offsets() {
4560 let spec = WidgetSpec::Row {
4561 wrap: false,
4562 children: vec![
4563 WidgetSpec::Toggle {
4564 checked: true,
4565 label: "A".into(),
4566 focused: false,
4567 key: Some("a".into()),
4568 },
4569 WidgetSpec::Spacer {
4570 cols: 2,
4571 flex: false,
4572 key: None,
4573 },
4574 WidgetSpec::Toggle {
4575 checked: false,
4576 label: "B".into(),
4577 focused: false,
4578 key: Some("b".into()),
4579 },
4580 ],
4581 key: None,
4582 };
4583 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4584 assert_eq!(entries.len(), 1);
4586 assert_eq!(entries[0].text, "[v] A [ ] B\n");
4587 assert_eq!(hits.len(), 2);
4588 assert_eq!(hits[0].widget_key, "a");
4589 assert_eq!(hits[0].buffer_row, 0);
4590 assert_eq!(hits[0].byte_start, 0);
4591 assert_eq!(hits[0].byte_end, 5); assert_eq!(hits[1].widget_key, "b");
4595 assert_eq!(hits[1].buffer_row, 0);
4596 assert_eq!(hits[1].byte_start, 7);
4597 assert_eq!(hits[1].byte_end, 12);
4598 }
4599
4600 #[test]
4601 fn col_stacks_hit_rows() {
4602 let spec = WidgetSpec::Col {
4603 children: vec![
4604 WidgetSpec::Toggle {
4605 checked: false,
4606 label: "row0".into(),
4607 focused: false,
4608 key: Some("k0".into()),
4609 },
4610 WidgetSpec::Toggle {
4611 checked: true,
4612 label: "row1".into(),
4613 focused: false,
4614 key: Some("k1".into()),
4615 },
4616 ],
4617 key: None,
4618 };
4619 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4620 assert_eq!(hits.len(), 2);
4621 assert_eq!(hits[0].buffer_row, 0);
4622 assert_eq!(hits[1].buffer_row, 1);
4623 }
4624
4625 #[test]
4630 fn collect_tabbable_visits_widgets_with_keys_in_declaration_order() {
4631 let spec = WidgetSpec::Col {
4632 children: vec![
4633 WidgetSpec::HintBar {
4634 entries: vec![],
4635 key: Some("hb".into()),
4636 },
4637 WidgetSpec::Row {
4638 wrap: false,
4639 children: vec![
4640 WidgetSpec::Toggle {
4641 checked: false,
4642 label: "T".into(),
4643 focused: false,
4644 key: Some("t".into()),
4645 },
4646 WidgetSpec::Spacer {
4647 cols: 1,
4648 flex: false,
4649 key: None,
4650 },
4651 WidgetSpec::Button {
4652 label: "B".into(),
4653 focused: false,
4654 intent: ButtonKind::Normal,
4655 key: Some("b".into()),
4656 disabled: false,
4657 focusable: true,
4658 },
4659 ],
4660 key: None,
4661 },
4662 WidgetSpec::Text {
4663 value: "".into(),
4664 cursor_byte: -1,
4665 focused: false,
4666 label: "".into(),
4667 placeholder: None,
4668 rows: 1,
4669 field_width: 0,
4670 max_visible_chars: 0,
4671 full_width: false,
4672 completions: Vec::new(),
4673 completions_visible_rows: 0,
4674 key: Some("ti".into()),
4675 },
4676 WidgetSpec::Toggle {
4677 checked: false,
4678 label: "no key".into(),
4679 focused: false,
4680 key: None,
4681 },
4682 ],
4683 key: None,
4684 };
4685 let mut tabbable = Vec::new();
4686 collect_tabbable(&spec, &mut tabbable);
4687 assert_eq!(tabbable, vec!["t", "b", "ti"]);
4690 }
4691
4692 #[test]
4693 fn first_render_focuses_first_tabbable() {
4694 let spec = WidgetSpec::Row {
4695 wrap: false,
4696 children: vec![
4697 WidgetSpec::Toggle {
4698 checked: false,
4699 label: "A".into(),
4700 focused: false,
4701 key: Some("a".into()),
4702 },
4703 WidgetSpec::Toggle {
4704 checked: false,
4705 label: "B".into(),
4706 focused: false,
4707 key: Some("b".into()),
4708 },
4709 ],
4710 key: None,
4711 };
4712 let out = render_spec(&spec, &HashMap::new(), "", u32::MAX);
4713 assert_eq!(out.focus_key, "a");
4714 assert_eq!(out.tabbable, vec!["a", "b"]);
4715 }
4716
4717 #[test]
4718 fn render_preserves_focus_key_across_re_renders() {
4719 let spec = WidgetSpec::Row {
4720 wrap: false,
4721 children: vec![
4722 WidgetSpec::Toggle {
4723 checked: false,
4724 label: "A".into(),
4725 focused: false,
4726 key: Some("a".into()),
4727 },
4728 WidgetSpec::Toggle {
4729 checked: false,
4730 label: "B".into(),
4731 focused: false,
4732 key: Some("b".into()),
4733 },
4734 ],
4735 key: None,
4736 };
4737 let out = render_spec(&spec, &HashMap::new(), "b", u32::MAX);
4738 assert_eq!(out.focus_key, "b");
4739 }
4740
4741 #[test]
4742 fn render_clamps_stale_focus_key_to_first_tabbable() {
4743 let spec = WidgetSpec::Toggle {
4747 checked: false,
4748 label: "Only".into(),
4749 focused: false,
4750 key: Some("only".into()),
4751 };
4752 let out = render_spec(&spec, &HashMap::new(), "stale", u32::MAX);
4753 assert_eq!(out.focus_key, "only");
4754 }
4755
4756 #[test]
4757 fn focused_widget_renders_with_focused_styling() {
4758 let spec = WidgetSpec::Row {
4759 wrap: false,
4760 children: vec![
4761 WidgetSpec::Toggle {
4762 checked: false,
4763 label: "A".into(),
4764 focused: false,
4765 key: Some("a".into()),
4766 },
4767 WidgetSpec::Toggle {
4768 checked: false,
4769 label: "B".into(),
4770 focused: false,
4771 key: Some("b".into()),
4772 },
4773 ],
4774 key: None,
4775 };
4776 let out = render_spec(&spec, &HashMap::new(), "b", u32::MAX);
4777 assert_eq!(out.entries.len(), 1, "row collapses inline");
4778 let entry = &out.entries[0];
4784 let focused_overlay = entry
4785 .inline_overlays
4786 .iter()
4787 .find(|o| {
4788 o.style.bg.as_ref().and_then(|c| c.as_theme_key()) == Some("ui.popup_selection_bg")
4789 })
4790 .expect("focused overlay present on B");
4791 assert_eq!(focused_overlay.start, 5);
4794 assert_eq!(focused_overlay.end, 10);
4795 }
4796
4797 #[test]
4798 fn no_tabbables_yields_empty_focus_key() {
4799 let spec = WidgetSpec::Col {
4800 children: vec![WidgetSpec::HintBar {
4801 entries: vec![],
4802 key: None,
4803 }],
4804 key: None,
4805 };
4806 let out = render_spec(&spec, &HashMap::new(), "", u32::MAX);
4807 assert_eq!(out.focus_key, "");
4808 assert!(out.tabbable.is_empty());
4809 }
4810
4811 #[test]
4816 fn list_emits_one_entry_and_one_hit_per_item() {
4817 let spec = WidgetSpec::List {
4818 items: vec![
4819 TextPropertyEntry::text("alpha"),
4820 TextPropertyEntry::text("beta"),
4821 TextPropertyEntry::text("gamma"),
4822 ],
4823 item_specs: vec![],
4824 item_keys: vec!["a".into(), "b".into(), "c".into()],
4825 selected_index: -1,
4826 visible_rows: 10,
4827 focusable: true,
4828 key: None,
4829 };
4830 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4831 assert_eq!(entries.len(), 10);
4837 assert_eq!(hits.len(), 3);
4840 for (i, h) in hits.iter().enumerate() {
4841 assert_eq!(h.buffer_row, i as u32);
4842 assert_eq!(h.widget_kind, "list");
4843 assert_eq!(h.event_type, "select");
4844 assert_eq!(h.payload["index"], i);
4845 }
4846 assert_eq!(hits[0].widget_key, "a");
4847 assert_eq!(hits[2].widget_key, "c");
4848 }
4849
4850 #[test]
4851 fn list_item_specs_render_multirow_cards_in_item_units() {
4852 let card = |body: &str| WidgetSpec::LabeledSection {
4855 label: String::new(),
4856 child: Box::new(WidgetSpec::Raw {
4857 entries: vec![TextPropertyEntry::text(body)],
4858 key: None,
4859 }),
4860 width_pct: None,
4861 key: None,
4862 };
4863 let spec = WidgetSpec::List {
4864 items: vec![],
4865 item_specs: vec![card("aaa"), card("bbb")],
4866 item_keys: vec!["a".into(), "b".into()],
4867 selected_index: 1,
4868 visible_rows: 12,
4870 focusable: true,
4871 key: Some("cards".into()),
4872 };
4873 let out = render_spec(&spec, &HashMap::new(), "", 40);
4876 let (entries, hits) = (out.entries, out.hits);
4877 assert_eq!(entries.len(), 12);
4879 assert_eq!(hits.len(), 6, "3 rows per card * 2 cards");
4882 assert!(hits[0..3]
4883 .iter()
4884 .all(|h| h.payload["index"] == 0 && h.widget_key == "a"));
4885 assert!(hits[3..6]
4886 .iter()
4887 .all(|h| h.payload["index"] == 1 && h.widget_key == "b"));
4888 for r in 0..3 {
4893 assert!(
4894 !entries[r].text.contains('┓') && !entries[r].text.contains('┃'),
4895 "unselected card row {r} should keep the light border"
4896 );
4897 assert!(entries[r].style.as_ref().map_or(true, |s| s.bg.is_none()));
4898 }
4899 let heavy = (3..6).any(|r| {
4902 entries[r].text.contains('┏')
4903 || entries[r].text.contains('┗')
4904 || entries[r].text.contains('┃')
4905 });
4906 assert!(heavy, "selected card should use a heavy box border");
4907 for r in 3..6 {
4908 let style = entries[r].style.as_ref();
4909 assert!(
4910 style.map(|s| s.bold).unwrap_or(false),
4911 "row {r} of the selected card should be bold"
4912 );
4913 assert!(
4914 style.and_then(|s| s.bg.as_ref()).is_none(),
4915 "row {r} of the selected card should NOT use a background band"
4916 );
4917 }
4918 assert!(entries[0].text.starts_with('╭'));
4920 assert!(entries[2].text.starts_with('╰'));
4921 }
4922
4923 #[test]
4924 fn selected_card_accent_frames_all_four_sides() {
4925 let card = |body: &str| WidgetSpec::LabeledSection {
4932 label: String::new(),
4933 child: Box::new(WidgetSpec::Raw {
4934 entries: vec![TextPropertyEntry::text(body)],
4935 key: None,
4936 }),
4937 width_pct: None,
4938 key: None,
4939 };
4940 let spec = WidgetSpec::List {
4941 items: vec![],
4942 item_specs: vec![card("aaa"), card("bbb")],
4943 item_keys: vec!["a".into(), "b".into()],
4944 selected_index: 1,
4945 visible_rows: 12,
4946 focusable: true,
4947 key: Some("cards".into()),
4948 };
4949 let out = render_spec(&spec, &HashMap::new(), "", 40);
4950 let entries = out.entries;
4951 let accent_is = |c: &OverlayColorSpec| matches!(c, OverlayColorSpec::ThemeKey(k) if k == "ui.popup_border_fg");
4953 for r in [3usize, 5] {
4955 let fg = entries[r].style.as_ref().and_then(|s| s.fg.as_ref());
4956 assert!(
4957 fg.map(accent_is).unwrap_or(false),
4958 "row {r} (top/bottom border) should carry the accent fg"
4959 );
4960 }
4961 let body = &entries[4];
4965 assert!(
4966 body.text.contains('┃'),
4967 "selected card body row should have heavy side borders: {:?}",
4968 body.text
4969 );
4970 assert!(
4971 body.style.as_ref().and_then(|s| s.fg.as_ref()).is_none(),
4972 "body row must not set a whole-row fg (would repaint the text)"
4973 );
4974 let bar_overlays: Vec<_> = body
4975 .inline_overlays
4976 .iter()
4977 .filter(|o| o.style.fg.as_ref().map(accent_is).unwrap_or(false))
4978 .collect();
4979 assert_eq!(
4980 bar_overlays.len(),
4981 2,
4982 "both the leading and trailing ┃ should be accent-tinted: {:?}",
4983 body.inline_overlays
4984 );
4985 for o in bar_overlays {
4987 assert_eq!(o.end - o.start, '┃'.len_utf8());
4988 assert_eq!(&body.text[o.start..o.end], "┃");
4989 }
4990 }
4991
4992 #[test]
4993 fn list_applies_selection_bg_to_selected_row() {
4994 let spec = WidgetSpec::List {
4995 items: vec![
4996 TextPropertyEntry::text("first"),
4997 TextPropertyEntry::text("second"),
4998 ],
4999 item_specs: vec![],
5000 item_keys: vec!["x".into(), "y".into()],
5001 selected_index: 1,
5002 visible_rows: 10,
5003 focusable: true,
5004 key: None,
5005 };
5006 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5007 assert!(entries[0].style.is_none(), "unselected row keeps no style");
5008 let style = entries[1].style.as_ref().expect("selected row gets style");
5009 assert_eq!(
5010 style.bg.as_ref().and_then(|c| c.as_theme_key()),
5011 Some("ui.popup_selection_bg"),
5012 );
5013 assert!(style.extend_to_line_end);
5014 }
5015
5016 #[test]
5017 fn list_inside_col_offsets_hit_rows_by_preceding_lines() {
5018 let spec = WidgetSpec::Col {
5019 children: vec![
5020 WidgetSpec::HintBar {
5021 entries: vec![HintEntry {
5022 keys: "h".into(),
5023 label: "header".into(),
5024 }],
5025 key: None,
5026 },
5027 WidgetSpec::List {
5028 items: vec![
5029 TextPropertyEntry::text("row0"),
5030 TextPropertyEntry::text("row1"),
5031 ],
5032 item_specs: vec![],
5033 item_keys: vec!["a".into(), "b".into()],
5034 selected_index: -1,
5035 visible_rows: 10,
5036 key: None,
5037 focusable: true,
5038 },
5039 ],
5040 key: None,
5041 };
5042 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5043 assert_eq!(entries.len(), 11);
5046 assert_eq!(hits.len(), 2);
5049 assert_eq!(hits[0].buffer_row, 1);
5051 assert_eq!(hits[1].buffer_row, 2);
5052 }
5053
5054 #[test]
5055 fn list_payload_includes_absolute_index_and_key() {
5056 let spec = WidgetSpec::List {
5057 items: vec![TextPropertyEntry::text("only")],
5058 item_specs: vec![],
5059 item_keys: vec!["match:42".into()],
5060 selected_index: 0,
5061 visible_rows: 10,
5062 focusable: true,
5063 key: None,
5064 };
5065 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5066 assert_eq!(hits[0].payload["index"], 0);
5067 assert_eq!(hits[0].payload["key"], "match:42");
5068 }
5069
5070 #[test]
5071 fn list_hit_payload_carries_list_key() {
5072 let spec = make_list(-1, 10, 2, Some("mylist"));
5078 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5079 assert_eq!(hits.len(), 2);
5080 assert_eq!(hits[0].payload["list_key"], "mylist");
5081 assert_eq!(hits[1].payload["list_key"], "mylist");
5082 }
5083
5084 #[test]
5085 fn list_hit_payload_list_key_is_null_when_keyless() {
5086 let spec = make_list(-1, 10, 1, None);
5089 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5090 assert!(hits[0].payload["list_key"].is_null());
5091 }
5092
5093 #[test]
5094 fn list_with_missing_key_emits_empty_widget_key() {
5095 let spec = WidgetSpec::List {
5096 items: vec![TextPropertyEntry::text("a"), TextPropertyEntry::text("b")],
5097 item_specs: vec![],
5099 item_keys: vec!["only".into()],
5100 selected_index: -1,
5101 visible_rows: 10,
5102 focusable: true,
5103 key: None,
5104 };
5105 let (_, hits, _state) = render_no_focus(&spec, &HashMap::new());
5106 assert_eq!(hits[0].widget_key, "only");
5107 assert_eq!(hits[1].widget_key, "");
5108 }
5109
5110 fn make_list(selected: i32, visible: u32, total: usize, key: Option<&str>) -> WidgetSpec {
5111 let items = (0..total)
5112 .map(|i| TextPropertyEntry::text(format!("row{}", i)))
5113 .collect();
5114 let item_keys = (0..total).map(|i| format!("k{}", i)).collect();
5115 WidgetSpec::List {
5116 items,
5117 item_specs: vec![],
5118 item_keys,
5119 selected_index: selected,
5120 visible_rows: visible,
5121 focusable: true,
5122 key: key.map(|s| s.to_string()),
5123 }
5124 }
5125
5126 #[test]
5127 fn list_renders_only_visible_window() {
5128 let spec = make_list(-1, 3, 10, Some("L"));
5129 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5130 assert_eq!(entries.len(), 3);
5131 assert_eq!(hits.len(), 3);
5132 assert_eq!(hits[0].payload["index"], 0);
5134 assert_eq!(hits[2].payload["index"], 2);
5135 }
5136
5137 #[test]
5138 fn list_scrolls_to_keep_selected_below_window_in_view() {
5139 let spec = make_list(5, 3, 10, Some("L"));
5144 let (_entries, hits, state) = render_no_focus(&spec, &HashMap::new());
5145 assert_eq!(hits.len(), 3);
5147 assert_eq!(hits[0].payload["index"], 3);
5148 assert_eq!(hits[2].payload["index"], 5);
5149 let scroll = match state.get("L").unwrap() {
5150 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5151 _ => unreachable!(),
5152 };
5153 assert_eq!(scroll, 3);
5154 }
5155
5156 #[test]
5157 fn list_scrolls_to_keep_selected_above_window_in_view() {
5158 let mut prev = HashMap::new();
5164 prev.insert(
5165 "L".into(),
5166 WidgetInstanceState::List {
5167 scroll_offset: 5,
5168 selected_index: 1,
5169 item_height: 1,
5170 user_scrolled: false,
5171 },
5172 );
5173 let spec = make_list(99, 3, 10, Some("L"));
5175 let (_entries, hits, state) = render_no_focus(&spec, &prev);
5176 assert_eq!(hits[0].payload["index"], 1);
5177 let scroll = match state.get("L").unwrap() {
5178 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5179 _ => unreachable!(),
5180 };
5181 assert_eq!(scroll, 1);
5182 }
5183
5184 #[test]
5185 fn list_scroll_preserved_when_selection_remains_in_view() {
5186 let mut prev = HashMap::new();
5189 prev.insert(
5190 "L".into(),
5191 WidgetInstanceState::List {
5192 scroll_offset: 4,
5193 selected_index: 5,
5194 item_height: 1,
5195 user_scrolled: false,
5196 },
5197 );
5198 let spec = make_list(99, 3, 10, Some("L"));
5199 let (_entries, hits, state) = render_no_focus(&spec, &prev);
5200 assert_eq!(hits[0].payload["index"], 4);
5201 let scroll = match state.get("L").unwrap() {
5202 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5203 _ => unreachable!(),
5204 };
5205 assert_eq!(scroll, 4);
5206 }
5207
5208 #[test]
5209 fn list_clamps_scroll_to_max_when_dataset_is_smaller_than_old_offset() {
5210 let mut prev = HashMap::new();
5213 prev.insert(
5214 "L".into(),
5215 WidgetInstanceState::List {
5216 scroll_offset: 8,
5217 selected_index: -1,
5218 item_height: 1,
5219 user_scrolled: false,
5220 },
5221 );
5222 let spec = make_list(-1, 3, 5, Some("L"));
5223 let (entries, _hits, state) = render_no_focus(&spec, &prev);
5224 assert_eq!(entries.len(), 3);
5225 let scroll = match state.get("L").unwrap() {
5226 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5227 _ => unreachable!(),
5228 };
5229 assert_eq!(scroll, 2);
5231 }
5232
5233 #[test]
5234 fn list_does_not_scroll_when_total_smaller_than_visible() {
5235 let spec = make_list(-1, 10, 3, Some("L"));
5236 let (entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
5237 assert_eq!(entries.len(), 10);
5242 let scroll = match state.get("L").unwrap() {
5243 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5244 _ => unreachable!(),
5245 };
5246 assert_eq!(scroll, 0);
5247 }
5248
5249 #[test]
5250 fn list_without_key_does_not_persist_state() {
5251 let spec = make_list(5, 3, 10, None);
5252 let (_entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
5253 assert!(
5254 state.is_empty(),
5255 "Lists without a `key` opt out of state preservation"
5256 );
5257 }
5258
5259 #[test]
5264 fn text_input_renders_value_in_brackets() {
5265 let entry = render_text_input("hello", -1, None, false, "", None, 0, 0, false).entry;
5266 assert_eq!(entry.text, "[hello]");
5267 assert!(entry.inline_overlays.is_empty());
5268 }
5269
5270 #[test]
5271 fn text_input_with_label_prefixes_with_label_space() {
5272 let entry = render_text_input("foo", -1, None, false, "Search:", None, 0, 0, false).entry;
5273 assert_eq!(entry.text, "Search: [foo]");
5274 }
5275
5276 #[test]
5277 fn text_input_focused_adds_input_bg_overlay() {
5278 let entry = render_text_input("x", -1, None, true, "", None, 0, 0, false).entry;
5279 assert_eq!(entry.inline_overlays.len(), 1);
5281 let bg = entry.inline_overlays[0].style.bg.as_ref().unwrap();
5282 assert_eq!(bg.as_theme_key(), Some("ui.prompt_bg"));
5283 }
5284
5285 #[test]
5286 fn text_input_focused_with_selection_adds_selection_bg_overlay() {
5287 let entry =
5290 render_text_input("hello world", 5, Some((0, 5)), true, "", None, 0, 0, false).entry;
5291 let sel = entry
5294 .inline_overlays
5295 .iter()
5296 .find(|o| {
5297 o.style.bg.as_ref().and_then(|c| c.as_theme_key())
5298 == Some("ui.text_input_selection_bg")
5299 })
5300 .expect("selection overlay present");
5301 assert_eq!(sel.start, 1);
5302 assert_eq!(sel.end, 6);
5303 }
5304
5305 #[test]
5306 fn text_input_unfocused_skips_selection_overlay() {
5307 let entry =
5310 render_text_input("hello", -1, Some((0, 5)), false, "", None, 0, 0, false).entry;
5311 let has_sel_overlay = entry.inline_overlays.iter().any(|o| {
5312 o.style.bg.as_ref().and_then(|c| c.as_theme_key()) == Some("ui.text_input_selection_bg")
5313 });
5314 assert!(!has_sel_overlay);
5315 }
5316
5317 #[test]
5318 fn text_area_focused_with_selection_emits_per_row_overlays() {
5319 let r = render_text_area("abcd\nefgh", 8, Some((2, 8)), true, "", None, 2, 0, 0, 80);
5323 let row0 = &r.entries[0];
5326 let row1 = &r.entries[1];
5327 let sel0 = row0
5328 .inline_overlays
5329 .iter()
5330 .find(|o| {
5331 o.style.bg.as_ref().and_then(|c| c.as_theme_key())
5332 == Some("ui.text_input_selection_bg")
5333 })
5334 .expect("row 0 selection overlay");
5335 assert_eq!((sel0.start, sel0.end), (2, 4));
5336 let sel1 = row1
5337 .inline_overlays
5338 .iter()
5339 .find(|o| {
5340 o.style.bg.as_ref().and_then(|c| c.as_theme_key())
5341 == Some("ui.text_input_selection_bg")
5342 })
5343 .expect("row 1 selection overlay");
5344 assert_eq!((sel1.start, sel1.end), (0, 3));
5345 }
5346
5347 #[test]
5348 fn text_input_cursor_byte_in_entry_at_value_position() {
5349 let r = render_text_input("abc", 1, None, true, "", None, 0, 0, false);
5354 assert_eq!(r.cursor_byte_in_entry, Some(2));
5355 }
5356
5357 #[test]
5358 fn text_input_cursor_at_end_lands_on_padding_space_not_bracket() {
5359 let r = render_text_input("ab", 2, None, true, "", None, 0, 0, false);
5365 assert_eq!(r.entry.text, "[ab ]");
5366 assert_eq!(r.cursor_byte_in_entry, Some(3));
5367 assert_ne!(r.cursor_byte_in_entry, Some(4), "must not overlap ]");
5368 }
5369
5370 #[test]
5371 fn text_input_unfocused_empty_shows_placeholder_in_muted() {
5372 let entry =
5373 render_text_input("", -1, None, false, "", Some("type here"), 0, 0, false).entry;
5374 assert_eq!(entry.text, "[type here]");
5375 let placeholder_overlay = entry
5377 .inline_overlays
5378 .iter()
5379 .find(|o| o.style.fg.as_ref().and_then(|c| c.as_theme_key()).is_some())
5380 .expect("placeholder fg overlay");
5381 let fg = placeholder_overlay.style.fg.as_ref().unwrap();
5382 assert_eq!(fg.as_theme_key(), Some("editor.whitespace_indicator_fg"));
5383 assert!(placeholder_overlay.style.italic);
5384 }
5385
5386 #[test]
5387 fn text_input_focused_empty_still_shows_placeholder() {
5388 let r = render_text_input("", -1, None, true, "", Some("type here"), 0, 0, false);
5392 assert_eq!(r.entry.text, "[type here]");
5393 assert_eq!(r.cursor_byte_in_entry, Some(1));
5394 }
5395
5396 #[test]
5397 fn text_input_field_width_pads_short_value_unfocused() {
5398 let r = render_text_input("hi", 2, None, false, "", None, 0, 10, false);
5401 assert_eq!(r.entry.text, "[hi ]");
5402 }
5403
5404 #[test]
5405 fn text_input_field_width_focused_adds_cursor_park_space() {
5406 let r = render_text_input("0123456789", 10, None, true, "", None, 0, 10, false);
5410 assert_eq!(r.entry.text, "[0123456789 ]");
5411 assert_eq!(r.cursor_byte_in_entry, Some(11));
5415 assert_ne!(r.cursor_byte_in_entry, Some(12), "must not land on ]");
5416 }
5417
5418 #[test]
5419 fn text_input_field_width_full_width_pads_to_same_size_when_unfocused() {
5420 let r = render_text_input("hi", -1, None, false, "", None, 0, 10, true);
5424 assert_eq!(r.entry.text, "[hi ]"); }
5426
5427 #[test]
5428 fn text_input_field_width_head_truncates_long_value() {
5429 let r = render_text_input(
5432 "0123456789abcdefghijklmnopqrst",
5433 30,
5434 None,
5435 false,
5436 "",
5437 None,
5438 0,
5439 10,
5440 false,
5441 );
5442 assert!(r.entry.text.contains("…lmnopqrst"));
5443 }
5444
5445 #[test]
5446 fn text_input_field_width_clamps_cursor_in_dropped_prefix() {
5447 let r = render_text_input("abcdefghij", 0, None, true, "", None, 0, 5, false);
5450 assert_eq!(r.cursor_byte_in_entry, Some(1 + "…".len()));
5455 }
5456
5457 #[test]
5458 fn text_input_truncates_long_value_keeping_tail_visible() {
5459 let value: String = "0123456789abcdefghij".to_string();
5460 let entry = render_text_input(&value, -1, None, false, "", None, 6, 0, false).entry;
5461 assert_eq!(entry.text, "[…fghij]");
5463 }
5464
5465 #[test]
5466 fn raw_inside_col_offsets_following_hits() {
5467 let spec = WidgetSpec::Col {
5468 children: vec![
5469 WidgetSpec::Raw {
5470 entries: vec![
5471 TextPropertyEntry::text("line0"),
5472 TextPropertyEntry::text("line1"),
5473 TextPropertyEntry::text("line2"),
5474 ],
5475 key: None,
5476 },
5477 WidgetSpec::Toggle {
5478 checked: false,
5479 label: "after raw".into(),
5480 focused: false,
5481 key: Some("post".into()),
5482 },
5483 ],
5484 key: None,
5485 };
5486 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5487 assert_eq!(entries.len(), 4);
5488 assert_eq!(hits.len(), 1);
5489 assert_eq!(hits[0].buffer_row, 3);
5490 }
5491
5492 fn tnode(text: &str, depth: u32, has_children: bool) -> TreeNode {
5497 TreeNode {
5498 text: TextPropertyEntry::text(text),
5499 depth,
5500 has_children,
5501 checked: None,
5502 }
5503 }
5504
5505 fn make_tree(
5506 nodes: Vec<TreeNode>,
5507 item_keys: Vec<&str>,
5508 selected: i32,
5509 visible: u32,
5510 expanded: Vec<&str>,
5511 key: Option<&str>,
5512 ) -> WidgetSpec {
5513 WidgetSpec::Tree {
5514 nodes,
5515 item_keys: item_keys.iter().map(|s| s.to_string()).collect(),
5516 selected_index: selected,
5517 visible_rows: visible,
5518 expanded_keys: expanded.iter().map(|s| s.to_string()).collect(),
5519 checkable: false,
5520 key: key.map(|s| s.to_string()),
5521 }
5522 }
5523
5524 #[test]
5525 fn tree_row_renders_disclosure_glyph_for_internal_collapsed() {
5526 let r = render_tree_row(&tnode("file.txt", 0, true), false, false);
5527 assert!(r.entry.text.starts_with('\u{25B6}'), "starts with ▶");
5528 assert!(r.entry.text.contains("file.txt"));
5529 assert!(r.disclosure_range.is_some());
5530 }
5531
5532 #[test]
5533 fn tree_row_renders_disclosure_glyph_for_internal_expanded() {
5534 let r = render_tree_row(&tnode("file.txt", 0, true), true, false);
5535 assert!(r.entry.text.starts_with('\u{25BC}'), "starts with ▼");
5536 }
5537
5538 #[test]
5539 fn tree_row_leaf_uses_two_spaces_no_disclosure_hit() {
5540 let r = render_tree_row(&tnode("match", 0, false), false, false);
5541 assert!(r.entry.text.starts_with(" "));
5543 assert!(r.entry.text.contains("match"));
5544 assert!(r.disclosure_range.is_none());
5545 }
5546
5547 #[test]
5548 fn tree_row_indents_by_depth_times_two() {
5549 let r = render_tree_row(&tnode("nested", 2, false), false, false);
5550 assert!(r.entry.text.starts_with(" nested"));
5552 }
5553
5554 #[test]
5555 fn tree_row_shifts_plugin_overlays_by_prefix() {
5556 let mut node = tnode("hello", 1, false);
5557 node.text.inline_overlays.push(InlineOverlay {
5558 start: 0,
5559 end: 5,
5560 style: OverlayOptions {
5561 bold: true,
5562 ..Default::default()
5563 },
5564 properties: Default::default(),
5565 unit: OffsetUnit::Byte,
5566 });
5567 let r = render_tree_row(&node, false, false);
5568 let plugin_overlay = r
5571 .entry
5572 .inline_overlays
5573 .iter()
5574 .find(|o| o.style.bold)
5575 .expect("bold overlay carried through");
5576 assert_eq!(plugin_overlay.start, 4);
5577 assert_eq!(plugin_overlay.end, 9);
5578 }
5579
5580 #[test]
5581 fn tree_row_omits_checkbox_when_not_checkable() {
5582 let mut node = tnode("file.rs", 0, false);
5584 node.checked = Some(true);
5585 let r = render_tree_row(&node, false, false);
5586 assert!(r.checkbox_range.is_none());
5587 assert!(!r.entry.text.contains("[v]"));
5588 assert!(!r.entry.text.contains("[ ]"));
5589 }
5590
5591 #[test]
5592 fn tree_row_omits_checkbox_when_checked_is_none() {
5593 let node = tnode("section", 0, false);
5597 let r = render_tree_row(&node, false, true);
5598 assert!(r.checkbox_range.is_none());
5599 assert!(!r.entry.text.contains("[v]"));
5600 assert!(!r.entry.text.contains("[ ]"));
5601 }
5602
5603 #[test]
5604 fn tree_row_renders_checked_glyph_after_disclosure() {
5605 let mut node = tnode("file.rs", 0, true);
5606 node.checked = Some(true);
5607 let r = render_tree_row(&node, true, true);
5608 assert!(r.checkbox_range.is_some(), "checkbox range emitted");
5609 let (cb_start, cb_end) = r.checkbox_range.unwrap();
5610 assert_eq!(&r.entry.text[cb_start..cb_end], "[v]");
5612 assert!(r.entry.text.contains("[v] file.rs"));
5613 }
5614
5615 #[test]
5616 fn tree_row_renders_unchecked_glyph_for_leaf() {
5617 let mut node = tnode("match-row", 1, false);
5618 node.checked = Some(false);
5619 let r = render_tree_row(&node, false, true);
5620 let (cb_start, cb_end) = r
5621 .checkbox_range
5622 .expect("checkbox range for leaf with checked: Some");
5623 assert_eq!(&r.entry.text[cb_start..cb_end], "[ ]");
5624 assert!(r.entry.text.starts_with(" [ ] match-row"));
5626 }
5627
5628 #[test]
5629 fn tree_row_checkbox_glyph_byte_range_addresses_correct_text() {
5630 let mut node = tnode("path/with/é", 0, true);
5633 node.checked = Some(true);
5634 let r = render_tree_row(&node, false, true);
5635 let (cb_start, cb_end) = r.checkbox_range.unwrap();
5636 assert!(r.entry.text.is_char_boundary(cb_start));
5637 assert!(r.entry.text.is_char_boundary(cb_end));
5638 assert_eq!(&r.entry.text[cb_start..cb_end], "[v]");
5639 }
5640
5641 #[test]
5642 fn tree_node_pad_to_chars_pads_text_before_prefix_offset_shift() {
5643 let mut node = tnode("x", 0, true);
5647 node.text.pad_to_chars = Some(5);
5648 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec!["x"], Some("T"));
5649 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5650 assert_eq!(entries.len(), 1);
5651 let trimmed = entries[0].text.trim_end_matches('\n');
5654 assert!(
5655 trimmed.ends_with("x "),
5656 "row should end with the padded body, got {trimmed:?}"
5657 );
5658 }
5659
5660 #[test]
5661 fn tree_node_truncate_to_chars_cuts_body_before_prefix_offset_shift() {
5662 let mut node = tnode("abcdefghij", 0, false);
5663 node.text.truncate_to_chars = Some(6);
5664 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5665 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5666 let trimmed = entries[0].text.trim_end_matches('\n');
5667 assert!(
5670 trimmed.ends_with("abc..."),
5671 "row should end with truncated body, got {trimmed:?}"
5672 );
5673 }
5674
5675 #[test]
5676 fn tree_node_char_unit_overlay_resolves_against_padded_text_and_shifts_by_prefix() {
5677 let mut node = tnode("x", 0, false);
5682 node.text.pad_to_chars = Some(5);
5683 node.text.inline_overlays.push(InlineOverlay {
5684 start: 0,
5685 end: 5,
5686 style: OverlayOptions {
5687 bold: true,
5688 ..Default::default()
5689 },
5690 properties: Default::default(),
5691 unit: OffsetUnit::Char,
5692 });
5693 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5694 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5695 let entry = &entries[0];
5696 let bold = entry
5697 .inline_overlays
5698 .iter()
5699 .find(|o| o.style.bold)
5700 .expect("bold overlay carried through");
5701 assert_eq!(bold.start, 2);
5704 assert_eq!(bold.end, 7);
5705 }
5706
5707 #[test]
5708 fn tree_node_char_unit_overlay_with_multibyte_body_resolves_correctly() {
5709 let mut node = tnode("éxé", 0, false);
5713 node.text.inline_overlays.push(InlineOverlay {
5714 start: 1,
5715 end: 2,
5716 style: OverlayOptions {
5717 bold: true,
5718 ..Default::default()
5719 },
5720 properties: Default::default(),
5721 unit: OffsetUnit::Char,
5722 });
5723 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5724 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5725 let entry = &entries[0];
5726 let bold = entry
5727 .inline_overlays
5728 .iter()
5729 .find(|o| o.style.bold)
5730 .expect("bold overlay carried through");
5731 let trimmed = entry.text.trim_end_matches('\n');
5734 assert_eq!(bold.start, 4);
5735 assert_eq!(bold.end, 5);
5736 assert_eq!(&trimmed[bold.start..bold.end], "x");
5737 }
5738
5739 #[test]
5740 fn tree_node_segments_concatenate_into_row_text_with_per_segment_overlays() {
5741 let mut node = tnode("", 0, false);
5742 node.text.segments = vec![
5743 fresh_core::text_property::StyledSegment {
5744 text: "AB".to_string(),
5745 style: None,
5746 overlays: vec![],
5747 },
5748 fresh_core::text_property::StyledSegment {
5749 text: " ".to_string(),
5750 style: None,
5751 overlays: vec![],
5752 },
5753 fresh_core::text_property::StyledSegment {
5754 text: "CD".to_string(),
5755 style: Some(OverlayOptions {
5756 bold: true,
5757 ..Default::default()
5758 }),
5759 overlays: vec![],
5760 },
5761 ];
5762 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5763 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5764 let trimmed = entries[0].text.trim_end_matches('\n');
5765 assert!(
5767 trimmed.ends_with("AB CD"),
5768 "row should end with concatenated segments, got {trimmed:?}"
5769 );
5770 let bold = entries[0]
5771 .inline_overlays
5772 .iter()
5773 .find(|o| o.style.bold)
5774 .expect("styled segment overlay carried through");
5775 assert_eq!(&trimmed[bold.start..bold.end], "CD");
5778 }
5779
5780 #[test]
5781 fn tree_node_segment_nested_overlay_shifts_to_segment_position() {
5782 let mut node = tnode("", 0, false);
5787 node.text.segments = vec![
5788 fresh_core::text_property::StyledSegment {
5789 text: "AB".to_string(),
5790 style: None,
5791 overlays: vec![],
5792 },
5793 fresh_core::text_property::StyledSegment {
5794 text: " - ".to_string(),
5795 style: None,
5796 overlays: vec![],
5797 },
5798 fresh_core::text_property::StyledSegment {
5799 text: "CDEFG".to_string(),
5800 style: None,
5801 overlays: vec![InlineOverlay {
5802 start: 0,
5803 end: 3,
5804 style: OverlayOptions {
5805 bold: true,
5806 ..Default::default()
5807 },
5808 properties: Default::default(),
5809 unit: OffsetUnit::Char,
5810 }],
5811 },
5812 ];
5813 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5814 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5815 let trimmed = entries[0].text.trim_end_matches('\n');
5816 let bold = entries[0]
5817 .inline_overlays
5818 .iter()
5819 .find(|o| o.style.bold)
5820 .expect("nested overlay carried through");
5821 assert_eq!(&trimmed[bold.start..bold.end], "CDE");
5822 }
5823
5824 #[test]
5825 fn tree_node_segments_with_pad_pad_after_concatenation() {
5826 let mut node = tnode("", 0, false);
5827 node.text.segments = vec![fresh_core::text_property::StyledSegment {
5828 text: "ab".to_string(),
5829 style: None,
5830 overlays: vec![],
5831 }];
5832 node.text.pad_to_chars = Some(5);
5833 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5834 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5835 let trimmed = entries[0].text.trim_end_matches('\n');
5836 assert!(
5838 trimmed.ends_with("ab "),
5839 "row should be padded after segment concat, got {trimmed:?}"
5840 );
5841 }
5842
5843 #[test]
5844 fn tree_renders_only_top_level_when_nothing_expanded() {
5845 let spec = make_tree(
5846 vec![
5847 tnode("a", 0, true),
5848 tnode("a.0", 1, false),
5849 tnode("a.1", 1, false),
5850 tnode("b", 0, true),
5851 tnode("b.0", 1, false),
5852 ],
5853 vec!["a", "a.0", "a.1", "b", "b.0"],
5854 -1,
5855 10,
5856 vec![], Some("T"),
5858 );
5859 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5860 assert_eq!(entries.len(), 2);
5862 assert!(entries[0].text.contains('a'));
5863 assert!(entries[1].text.contains('b'));
5864 }
5865
5866 #[test]
5867 fn tree_renders_children_of_expanded_nodes() {
5868 let spec = make_tree(
5869 vec![
5870 tnode("a", 0, true),
5871 tnode("a.0", 1, false),
5872 tnode("a.1", 1, false),
5873 tnode("b", 0, true),
5874 tnode("b.0", 1, false),
5875 ],
5876 vec!["a", "a.0", "a.1", "b", "b.0"],
5877 -1,
5878 10,
5879 vec!["a"],
5880 Some("T"),
5881 );
5882 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5883 assert_eq!(entries.len(), 4);
5885 }
5886
5887 #[test]
5888 fn tree_emits_two_hits_per_internal_row_one_per_leaf() {
5889 let spec = make_tree(
5892 vec![tnode("a", 0, true), tnode("a.0", 1, false)],
5893 vec!["a", "a.0"],
5894 -1,
5895 10,
5896 vec!["a"],
5897 Some("T"),
5898 );
5899 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5900 assert_eq!(hits.len(), 3);
5901 assert_eq!(hits[0].event_type, "expand");
5903 assert_eq!(hits[0].widget_kind, "tree");
5904 assert_eq!(hits[1].event_type, "select");
5905 assert_eq!(hits[2].event_type, "select");
5906 }
5907
5908 #[test]
5909 fn tree_hits_carry_tree_spec_key_and_per_item_key_in_payload() {
5910 let spec = make_tree(
5911 vec![tnode("only", 0, false)],
5912 vec!["only-key"],
5913 -1,
5914 10,
5915 vec![],
5916 Some("matchTree"),
5917 );
5918 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5919 assert_eq!(hits[0].widget_key, "matchTree");
5920 assert_eq!(hits[0].payload["key"], "only-key");
5921 assert_eq!(hits[0].payload["index"], 0);
5922 }
5923
5924 #[test]
5925 fn tree_persists_expanded_keys_in_instance_state() {
5926 let spec = make_tree(
5927 vec![tnode("a", 0, true), tnode("a.0", 1, false)],
5928 vec!["a", "a.0"],
5929 -1,
5930 10,
5931 vec!["a"],
5932 Some("T"),
5933 );
5934 let (_, _, state) = render_no_focus(&spec, &HashMap::new());
5935 match state.get("T").unwrap() {
5936 WidgetInstanceState::Tree { expanded_keys, .. } => {
5937 assert!(expanded_keys.contains("a"));
5938 }
5939 _ => unreachable!(),
5940 }
5941 }
5942
5943 #[test]
5944 fn tree_instance_state_overrides_spec_expanded_keys() {
5945 let mut prev = HashMap::new();
5948 prev.insert(
5949 "T".into(),
5950 WidgetInstanceState::Tree {
5951 scroll_offset: 0,
5952 selected_index: -1,
5953 expanded_keys: ["b".to_string()].iter().cloned().collect(),
5954 },
5955 );
5956 let spec = make_tree(
5957 vec![
5958 tnode("a", 0, true),
5959 tnode("a.0", 1, false),
5960 tnode("b", 0, true),
5961 tnode("b.0", 1, false),
5962 ],
5963 vec!["a", "a.0", "b", "b.0"],
5964 -1,
5965 10,
5966 vec!["a"], Some("T"),
5968 );
5969 let (entries, _hits, _state) = render_no_focus(&spec, &prev);
5970 assert_eq!(entries.len(), 3);
5972 }
5973
5974 #[test]
5975 fn tree_selected_row_gets_focused_bg() {
5976 let spec = make_tree(
5977 vec![tnode("a", 0, false), tnode("b", 0, false)],
5978 vec!["a", "b"],
5979 1,
5980 10,
5981 vec![],
5982 Some("T"),
5983 );
5984 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5985 assert!(entries[0].style.is_none());
5986 let style = entries[1].style.as_ref().expect("selected gets style");
5987 assert_eq!(
5988 style.bg.as_ref().and_then(|c| c.as_theme_key()),
5989 Some("ui.popup_selection_bg")
5990 );
5991 assert!(style.extend_to_line_end);
5992 }
5993
5994 #[test]
5995 fn tree_clamps_selection_to_visible_when_selected_node_is_hidden() {
5996 let spec = make_tree(
6000 vec![tnode("a", 0, true), tnode("a.0", 1, false)],
6001 vec!["a", "a.0"],
6002 1,
6003 10,
6004 vec![], Some("T"),
6006 );
6007 let (_entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
6008 match state.get("T").unwrap() {
6009 WidgetInstanceState::Tree { selected_index, .. } => {
6010 assert_eq!(*selected_index, 0);
6011 }
6012 _ => unreachable!(),
6013 }
6014 }
6015
6016 #[test]
6017 fn tree_scrolls_to_keep_selection_in_visible_window() {
6018 let spec = make_tree(
6022 vec![
6023 tnode("0", 0, false),
6024 tnode("1", 0, false),
6025 tnode("2", 0, false),
6026 tnode("3", 0, false),
6027 tnode("4", 0, false),
6028 tnode("5", 0, false),
6029 ],
6030 vec!["k0", "k1", "k2", "k3", "k4", "k5"],
6031 4,
6032 3,
6033 vec![],
6034 Some("T"),
6035 );
6036 let (entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
6037 assert_eq!(entries.len(), 3);
6039 match state.get("T").unwrap() {
6040 WidgetInstanceState::Tree { scroll_offset, .. } => assert_eq!(*scroll_offset, 2),
6041 _ => unreachable!(),
6042 }
6043 }
6044
6045 #[test]
6046 fn tree_tabbable_keys_include_tree_with_key() {
6047 let spec = WidgetSpec::Col {
6048 children: vec![
6049 WidgetSpec::Toggle {
6050 checked: false,
6051 label: "T".into(),
6052 focused: false,
6053 key: Some("toggle".into()),
6054 },
6055 make_tree(
6056 vec![tnode("a", 0, false)],
6057 vec!["a"],
6058 -1,
6059 10,
6060 vec![],
6061 Some("tree"),
6062 ),
6063 ],
6064 key: None,
6065 };
6066 let mut tabbable = Vec::new();
6067 collect_tabbable(&spec, &mut tabbable);
6068 assert_eq!(tabbable, vec!["toggle", "tree"]);
6069 }
6070
6071 fn make_text_area(
6076 value: &str,
6077 cursor_byte: i32,
6078 focused: bool,
6079 rows: u32,
6080 field_width: u32,
6081 key: Option<&str>,
6082 ) -> WidgetSpec {
6083 WidgetSpec::Text {
6084 value: value.into(),
6085 cursor_byte,
6086 focused,
6087 label: String::new(),
6088 placeholder: None,
6089 rows: rows.max(2),
6094 field_width,
6095 max_visible_chars: 0,
6096 full_width: false,
6097 completions: Vec::new(),
6098 completions_visible_rows: 0,
6099 key: key.map(|s| s.into()),
6100 }
6101 }
6102
6103 #[test]
6104 fn text_area_renders_visible_rows_count() {
6105 let spec = make_text_area("hi", -1, false, 3, 10, Some("ta"));
6108 let prev = HashMap::new();
6109 let out = render_spec(&spec, &prev, "", 80);
6110 assert_eq!(out.entries.len(), 3);
6111 }
6112
6113 #[test]
6114 fn text_area_pads_short_lines_to_field_width() {
6115 let spec = make_text_area("hi", -1, false, 1, 6, Some("ta"));
6116 let prev = HashMap::new();
6117 let out = render_spec(&spec, &prev, "", 80);
6118 let first = &out.entries[0];
6120 assert_eq!(first.text, "hi \n");
6121 }
6122
6123 #[test]
6124 fn text_area_truncates_long_line_with_ellipsis() {
6125 let spec = make_text_area("abcdefghi", -1, false, 1, 5, Some("ta"));
6126 let prev = HashMap::new();
6127 let out = render_spec(&spec, &prev, "", 80);
6128 assert_eq!(out.entries[0].text, "abcd…\n");
6130 }
6131
6132 #[test]
6133 fn text_area_focused_adds_input_bg_overlay_per_row() {
6134 let spec = make_text_area("a\nb", -1, true, 3, 4, Some("ta"));
6135 let prev = HashMap::new();
6136 let out = render_spec(&spec, &prev, "ta", 80);
6137 for entry in &out.entries {
6138 let has_bg = entry.inline_overlays.iter().any(|o| {
6139 o.style
6140 .bg
6141 .as_ref()
6142 .and_then(|c| c.as_theme_key())
6143 .map(|k| k == "ui.prompt_bg")
6144 .unwrap_or(false)
6145 });
6146 assert!(has_bg, "every focused row gets input-bg");
6147 }
6148 }
6149
6150 #[test]
6151 fn text_area_publishes_focus_cursor_at_value_position() {
6152 let spec = make_text_area("ab\ncd", 4, true, 3, 6, Some("ta"));
6155 let prev = HashMap::new();
6156 let out = render_spec(&spec, &prev, "ta", 80);
6157 let fc = out.focus_cursor.expect("focused → cursor published");
6158 assert_eq!(fc.buffer_row, 1);
6160 assert_eq!(fc.byte_in_row, 1);
6162 }
6163
6164 #[test]
6165 fn text_area_label_offsets_cursor_buffer_row() {
6166 let spec = WidgetSpec::Text {
6170 value: "hi".into(),
6171 cursor_byte: 1,
6172 focused: true,
6173 label: "Note".into(),
6174 placeholder: None,
6175 rows: 2,
6176 field_width: 6,
6177 max_visible_chars: 0,
6178 full_width: false,
6179 completions: Vec::new(),
6180 completions_visible_rows: 0,
6181 key: Some("ta".into()),
6182 };
6183 let prev = HashMap::new();
6184 let out = render_spec(&spec, &prev, "ta", 80);
6185 assert!(out.entries[0].text.starts_with("Note:"));
6187 let fc = out.focus_cursor.unwrap();
6188 assert_eq!(fc.buffer_row, 1);
6189 }
6190
6191 #[test]
6192 fn text_area_persists_value_and_cursor_in_instance_state() {
6193 let spec = make_text_area("abc", 2, true, 2, 8, Some("ta"));
6194 let prev = HashMap::new();
6195 let out = render_spec(&spec, &prev, "ta", 80);
6196 match out.instance_states.get("ta") {
6197 Some(WidgetInstanceState::Text { editor, .. }) => {
6198 assert_eq!(editor.value(), "abc");
6199 assert_eq!(editor.flat_cursor_byte(), 2);
6200 }
6201 other => panic!("expected Text instance state, got {:?}", other),
6202 }
6203 }
6204
6205 #[test]
6206 fn text_area_instance_state_overrides_spec_value() {
6207 let spec = make_text_area("old", 0, true, 2, 8, Some("ta"));
6210 let mut prev = HashMap::new();
6211 let mut editor = crate::primitives::text_edit::TextEdit::with_text("new");
6212 editor.set_cursor_from_flat(3);
6213 prev.insert(
6214 "ta".into(),
6215 WidgetInstanceState::Text {
6216 editor,
6217 scroll: 0,
6218 completions: Vec::new(),
6219 completion_selected_index: 0,
6220 completion_scroll_offset: 0,
6221 completion_navigated: false,
6222 },
6223 );
6224 let out = render_spec(&spec, &prev, "ta", 80);
6225 assert!(out.entries[0].text.starts_with("new"));
6227 }
6228
6229 #[test]
6230 fn text_area_scroll_clamps_to_keep_cursor_visible() {
6231 let spec = make_text_area("a\nb\nc\nd\ne", 8, true, 2, 4, Some("ta"));
6235 let prev = HashMap::new();
6237 let out = render_spec(&spec, &prev, "ta", 80);
6238 match out.instance_states.get("ta") {
6239 Some(WidgetInstanceState::Text { scroll, .. }) => {
6240 assert_eq!(*scroll, 3, "scroll so lines 3..5 are visible");
6241 }
6242 _ => panic!("expected Text instance state"),
6243 }
6244 }
6245
6246 #[test]
6247 fn text_area_unfocused_empty_shows_placeholder_in_first_row() {
6248 let r = render_text_area("", -1, None, false, "", Some("write here"), 2, 12, 0, 80);
6253 assert!(r.entries[0].text.starts_with("write here"));
6254 let fg = r.entries[0]
6256 .inline_overlays
6257 .iter()
6258 .find_map(|o| o.style.fg.as_ref())
6259 .and_then(|c| c.as_theme_key());
6260 assert_eq!(fg, Some("editor.whitespace_indicator_fg"));
6261 }
6262
6263 #[test]
6264 fn text_area_tabbable_keys_include_text_area_with_key() {
6265 let spec = WidgetSpec::Col {
6266 children: vec![
6267 WidgetSpec::Toggle {
6268 checked: false,
6269 label: "T".into(),
6270 focused: false,
6271 key: Some("toggle".into()),
6272 },
6273 make_text_area("", -1, false, 3, 10, Some("note")),
6274 ],
6275 key: None,
6276 };
6277 let mut tabbable = Vec::new();
6278 collect_tabbable(&spec, &mut tabbable);
6279 assert_eq!(tabbable, vec!["toggle", "note"]);
6280 }
6281
6282 fn make_text_input(
6287 value: &str,
6288 cursor_byte: i32,
6289 focused: bool,
6290 full_width: bool,
6291 field_width: u32,
6292 key: Option<&str>,
6293 ) -> WidgetSpec {
6294 WidgetSpec::Text {
6295 value: value.into(),
6296 cursor_byte,
6297 focused,
6298 label: String::new(),
6299 placeholder: None,
6300 rows: 1,
6301 field_width,
6302 max_visible_chars: 0,
6303 full_width,
6304 completions: Vec::new(),
6305 completions_visible_rows: 0,
6306 key: key.map(|s| s.into()),
6307 }
6308 }
6309
6310 #[test]
6311 fn labeled_section_renders_three_rows_with_legend() {
6312 let spec = WidgetSpec::LabeledSection {
6313 label: "Name".into(),
6314 child: Box::new(make_text_input("hi", -1, false, false, 4, Some("n"))),
6315 width_pct: None,
6316 key: None,
6317 };
6318 let prev = HashMap::new();
6319 let out = render_spec(&spec, &prev, "", 20);
6320 assert_eq!(out.entries.len(), 3);
6322 assert!(out.entries[0].text.starts_with("╭─ Name "));
6324 assert!(out.entries[0].text.ends_with("╮\n"));
6325 assert!(out.entries[1].text.starts_with("│ "));
6327 assert!(out.entries[1].text.ends_with(" │\n"));
6328 assert!(out.entries[2].text.starts_with("╰"));
6330 assert!(out.entries[2].text.ends_with("╯\n"));
6331 }
6332
6333 #[test]
6334 fn zip_row_blocks_keeps_overlays_on_char_boundaries() {
6335 let left = WidgetSpec::LabeledSection {
6346 label: "alpha/beta · this project (2)".into(),
6347 child: Box::new(make_text_input("x", -1, false, false, 4, Some("a"))),
6348 width_pct: Some(40),
6349 key: None,
6350 };
6351 let right = WidgetSpec::LabeledSection {
6352 label: "preview".into(),
6353 child: Box::new(make_text_input("y", -1, false, false, 4, Some("b"))),
6354 width_pct: None,
6355 key: None,
6356 };
6357 let spec = WidgetSpec::Row {
6358 wrap: false,
6359 children: vec![left, right],
6360 key: None,
6361 };
6362 let out = render_spec(&spec, &HashMap::new(), "", 40);
6363 for e in &out.entries {
6364 for o in &e.inline_overlays {
6365 assert!(
6366 e.text.is_char_boundary(o.start.min(e.text.len())),
6367 "overlay start {} not on a char boundary of {:?}",
6368 o.start,
6369 e.text,
6370 );
6371 assert!(
6372 e.text.is_char_boundary(o.end.min(e.text.len())),
6373 "overlay end {} not on a char boundary of {:?}",
6374 o.end,
6375 e.text,
6376 );
6377 }
6378 }
6379 }
6380
6381 #[test]
6382 fn labeled_section_pads_child_to_inner_width() {
6383 let spec = WidgetSpec::LabeledSection {
6384 label: "".into(),
6385 child: Box::new(make_text_input("hi", -1, false, false, 4, Some("n"))),
6386 width_pct: None,
6387 key: None,
6388 };
6389 let prev = HashMap::new();
6390 let out = render_spec(&spec, &prev, "", 16);
6393 let middle = &out.entries[1];
6394 assert_eq!(middle.text.chars().count(), 16 + 1 );
6396 }
6397
6398 #[test]
6399 fn labeled_section_text_full_width_fills_inner_area() {
6400 let spec = WidgetSpec::LabeledSection {
6406 label: "".into(),
6407 child: Box::new(make_text_input("ab", -1, false, true, 0, Some("n"))),
6408 width_pct: None,
6409 key: None,
6410 };
6411 let prev = HashMap::new();
6412 let out = render_spec(&spec, &prev, "", 16);
6413 let middle = &out.entries[1];
6414 assert_eq!(middle.text.chars().count(), 17, "actual: {:?}", middle.text);
6418 assert!(
6419 middle.text.contains("[ab ]"),
6420 "actual: {:?}",
6421 middle.text
6422 );
6423 }
6424
6425 #[test]
6426 fn labeled_section_propagates_focus_cursor_with_offsets() {
6427 let spec = WidgetSpec::LabeledSection {
6428 label: "".into(),
6429 child: Box::new(make_text_input("abc", 3, true, false, 4, Some("n"))),
6430 width_pct: None,
6431 key: None,
6432 };
6433 let prev = HashMap::new();
6434 let out = render_spec(&spec, &prev, "n", 20);
6435 let fc = out.focus_cursor.expect("focused child publishes cursor");
6436 assert_eq!(fc.buffer_row, 1);
6438 let prefix_bytes = LEFT_BORDER_PREFIX.len() as u32;
6442 assert_eq!(fc.byte_in_row, prefix_bytes + 1 + 3);
6443 }
6444
6445 #[test]
6446 fn labeled_section_includes_child_in_tabbable() {
6447 let spec = WidgetSpec::Col {
6448 children: vec![
6449 WidgetSpec::LabeledSection {
6450 label: "Name".into(),
6451 child: Box::new(make_text_input("", -1, false, false, 0, Some("n"))),
6452 width_pct: None,
6453 key: None,
6454 },
6455 WidgetSpec::LabeledSection {
6456 label: "Cmd".into(),
6457 child: Box::new(make_text_input("", -1, false, false, 0, Some("c"))),
6458 width_pct: None,
6459 key: None,
6460 },
6461 ],
6462 key: None,
6463 };
6464 let mut tabbable = Vec::new();
6465 collect_tabbable(&spec, &mut tabbable);
6466 assert_eq!(tabbable, vec!["n", "c"]);
6467 }
6468}