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 per_child_width = allocate_row_child_widths(children, panel_width);
695 let mut row_pieces: Vec<RowPiece> = Vec::new();
696 for (idx, child) in children.iter().enumerate() {
697 if let WidgetSpec::Spacer { flex: true, .. } = child {
698 row_pieces.push(RowPiece::Flex);
699 continue;
700 }
701 let child_panel_width = per_child_width[idx];
702 let child_out = render_collected(child, prev, next_state, focus_key, child_panel_width);
703 overlays.extend(child_out.overlays);
708 if child_out.entries.is_empty() {
709 debug_assert!(child_out.hits.is_empty(), "empty children produce no hits");
710 continue;
711 }
712 if child_out.entries.len() == 1 {
713 let mut entry = child_out.entries.into_iter().next().unwrap();
714 strip_trailing_newline(&mut entry);
719 row_pieces.push(RowPiece::Inline {
720 entry,
721 hits: child_out.hits,
722 focus_cursor: child_out.focus_cursor,
723 embeds: child_out.embeds,
724 scroll_regions: child_out.scroll_regions,
725 });
726 } else {
727 row_pieces.push(RowPiece::Block {
728 column_width: child_panel_width,
729 entries: child_out.entries,
730 hits: child_out.hits,
731 focus_cursor: child_out.focus_cursor,
732 embeds: child_out.embeds,
733 scroll_regions: child_out.scroll_regions,
734 });
735 }
736 }
737 let has_blocks = row_pieces
741 .iter()
742 .any(|p| matches!(p, RowPiece::Block { .. }));
743 if has_blocks {
744 zip_row_blocks(
745 row_pieces,
746 panel_width,
747 &mut entries,
748 &mut hits,
749 &mut focus_cursor,
750 &mut embeds,
751 &mut scroll_regions,
752 );
753 } else if wrap {
754 assemble_wrapped_row(row_pieces, panel_width, &mut entries, &mut hits);
760 } else {
761 assemble_inline_row(
762 row_pieces,
763 panel_width,
764 &mut entries,
765 &mut hits,
766 &mut focus_cursor,
767 &mut embeds,
768 &mut scroll_regions,
769 );
770 }
771
772 CollectedOutput {
773 entries,
774 hits,
775 focus_cursor,
776 embeds,
777 overlays,
778 scroll_regions,
779 }
780}
781
782fn allocate_row_child_widths(children: &[WidgetSpec], panel_width: u32) -> Vec<u32> {
790 let block_indices: Vec<usize> = children
791 .iter()
792 .enumerate()
793 .filter(|(_, c)| predicts_block(c))
794 .map(|(i, _)| i)
795 .collect();
796 let block_count = block_indices.len();
797 let mut per_child_width: Vec<u32> = children.iter().map(|_| panel_width).collect();
798 if block_count == 0 {
799 return per_child_width;
800 }
801 let mut explicit_total: u32 = 0;
802 let mut explicit_count: u32 = 0;
803 for &idx in &block_indices {
804 if let Some(pct) = labeled_section_width_pct(&children[idx]) {
805 let w = (panel_width as u64 * pct as u64 / 100) as u32;
806 per_child_width[idx] = w.max(1);
807 explicit_total = explicit_total.saturating_add(w);
808 explicit_count += 1;
809 }
810 }
811 let remaining = panel_width.saturating_sub(explicit_total);
812 let implicit_count = (block_count as u32).saturating_sub(explicit_count).max(1);
813 let each_implicit = (remaining / implicit_count).max(1);
814 for &idx in &block_indices {
815 if labeled_section_width_pct(&children[idx]).is_none() {
816 per_child_width[idx] = each_implicit;
817 }
818 }
819 per_child_width
820}
821
822fn assemble_inline_row(
830 pieces: Vec<RowPiece>,
831 panel_width: u32,
832 entries: &mut Vec<TextPropertyEntry>,
833 hits: &mut Vec<HitArea>,
834 focus_cursor: &mut Option<FocusCursor>,
835 embeds: &mut Vec<EmbedRect>,
836 scroll_regions: &mut Vec<ScrollRegion>,
837) {
838 let inline_natural: usize = pieces
844 .iter()
845 .filter_map(|p| match p {
846 RowPiece::Inline { entry, .. } => {
847 Some(crate::primitives::display_width::str_width(&entry.text))
848 }
849 _ => None,
850 })
851 .sum();
852 let flex_count = pieces
853 .iter()
854 .filter(|p| matches!(p, RowPiece::Flex))
855 .count();
856 let flex_total = (panel_width as usize).saturating_sub(inline_natural);
857 let (flex_each, flex_extra) = match flex_total.checked_div(flex_count) {
861 Some(each) => (each, flex_total % flex_count),
862 None => (0, 0),
863 };
864
865 let mut acc: Option<TextPropertyEntry> = None;
870 let mut flex_seen = 0usize;
871 for piece in pieces {
872 match piece {
873 RowPiece::Inline {
874 mut entry,
875 hits: child_hits,
876 focus_cursor: child_focus,
877 embeds: child_embeds,
878 scroll_regions: child_scroll,
879 } => {
880 let inline_shift = match acc.as_ref() {
881 Some(e) => e.text.len(),
882 None => 0,
883 };
884 for mut h in child_hits {
885 h.byte_start += inline_shift;
886 h.byte_end += inline_shift;
887 hits.push(h);
888 }
889 if let Some(mut fc) = child_focus {
890 fc.byte_in_row += inline_shift as u32;
892 *focus_cursor = Some(fc);
893 }
894 for mut emb in child_embeds {
895 emb.col_in_row += inline_shift as u32;
901 embeds.push(emb);
902 }
903 for mut sr in child_scroll {
904 sr.col_in_row += inline_shift as u32;
905 scroll_regions.push(sr);
906 }
907 match acc.as_mut() {
908 Some(merged) => merge_inline(merged, &mut entry),
909 None => acc = Some(entry),
910 }
911 }
912 RowPiece::Flex => {
913 let n = flex_each + if flex_seen < flex_extra { 1 } else { 0 };
915 flex_seen += 1;
916 if n > 0 {
917 let mut text = String::with_capacity(n);
918 for _ in 0..n {
919 text.push(' ');
920 }
921 let entry = TextPropertyEntry {
922 text,
923 properties: Default::default(),
924 style: None,
925 inline_overlays: Vec::new(),
926 segments: Vec::new(),
927 pad_to_chars: None,
928 truncate_to_chars: None,
929 };
930 match acc.as_mut() {
931 Some(merged) => {
932 let mut e = entry;
933 merge_inline(merged, &mut e);
934 }
935 None => acc = Some(entry),
936 }
937 }
938 }
939 RowPiece::Block { .. } => {
940 debug_assert!(false, "block piece in inline-only Row path");
943 }
944 }
945 }
946 if let Some(mut merged) = acc {
947 ensure_trailing_newline(&mut merged);
948 entries.push(merged);
949 }
950}
951
952#[allow(clippy::too_many_arguments)]
953fn collect_col(
954 children: &[WidgetSpec],
955 prev: &HashMap<String, WidgetInstanceState>,
956 next_state: &mut HashMap<String, WidgetInstanceState>,
957 focus_key: &str,
958 panel_width: u32,
959) -> CollectedOutput {
960 let mut entries: Vec<TextPropertyEntry> = Vec::new();
961 let mut hits: Vec<HitArea> = Vec::new();
962 let mut focus_cursor: Option<FocusCursor> = None;
963 let mut embeds: Vec<EmbedRect> = Vec::new();
964 let mut overlays: Vec<OverlayRow> = Vec::new();
965 let mut scroll_regions: Vec<ScrollRegion> = Vec::new();
966
967 for child in children {
968 let is_overlay = matches!(child, WidgetSpec::Overlay { .. });
976 let child_out = render_collected(child, prev, next_state, focus_key, panel_width);
977 let row_offset = entries.len() as u32;
978 if is_overlay {
979 for (i, e) in child_out.entries.into_iter().enumerate() {
986 overlays.push(OverlayRow {
987 buffer_row: row_offset + i as u32,
988 entry: e,
989 });
990 }
991 for mut h in child_out.hits {
992 h.buffer_row += row_offset;
993 hits.push(h);
994 }
995 if let Some(mut fc) = child_out.focus_cursor {
1000 fc.buffer_row += row_offset;
1001 focus_cursor = Some(fc);
1002 }
1003 overlays.extend(child_out.overlays);
1006 for mut emb in child_out.embeds {
1012 emb.buffer_row += row_offset;
1013 embeds.push(emb);
1014 }
1015 for mut sr in child_out.scroll_regions {
1016 sr.buffer_row += row_offset;
1017 scroll_regions.push(sr);
1018 }
1019 continue;
1020 }
1021 for mut h in child_out.hits {
1022 h.buffer_row += row_offset;
1023 hits.push(h);
1024 }
1025 if let Some(mut fc) = child_out.focus_cursor {
1026 fc.buffer_row += row_offset;
1027 focus_cursor = Some(fc);
1028 }
1029 for mut emb in child_out.embeds {
1030 emb.buffer_row += row_offset;
1031 embeds.push(emb);
1032 }
1033 for mut sr in child_out.scroll_regions {
1034 sr.buffer_row += row_offset;
1035 scroll_regions.push(sr);
1036 }
1037 overlays.extend(child_out.overlays.into_iter().map(|mut o| {
1038 o.buffer_row += row_offset;
1039 o
1040 }));
1041 entries.extend(child_out.entries);
1042 }
1043
1044 CollectedOutput {
1045 entries,
1046 hits,
1047 focus_cursor,
1048 embeds,
1049 overlays,
1050 scroll_regions,
1051 }
1052}
1053
1054fn collect_hint_bar(entries: &[HintEntry]) -> CollectedOutput {
1055 let mut out = CollectedOutput::default();
1056 let mut entry = render_hint_bar(entries);
1057 ensure_trailing_newline(&mut entry);
1058 out.entries.push(entry);
1059 out
1063}
1064
1065fn collect_toggle(
1066 checked: bool,
1067 label: &str,
1068 focused: bool,
1069 key: Option<&str>,
1070 focus_key: &str,
1071) -> CollectedOutput {
1072 let mut out = CollectedOutput::default();
1073 let is_focused = match key {
1080 Some(k) if !k.is_empty() => k == focus_key,
1081 _ => focused,
1082 };
1083 let mut entry = render_toggle(checked, label, is_focused);
1084 let byte_end = entry.text.len();
1085 out.hits.push(HitArea {
1086 widget_key: key.unwrap_or("").to_string(),
1087 widget_kind: "toggle",
1088 buffer_row: 0,
1089 byte_start: 0,
1090 byte_end,
1091 payload: json!({ "checked": !checked }),
1092 event_type: "toggle",
1093 });
1094 ensure_trailing_newline(&mut entry);
1095 out.entries.push(entry);
1096 out
1097}
1098
1099#[allow(clippy::too_many_arguments)]
1100fn collect_button(
1101 label: &str,
1102 focused: bool,
1103 intent: ButtonKind,
1104 key: Option<&str>,
1105 disabled: bool,
1106 focus_key: &str,
1107) -> CollectedOutput {
1108 let mut out = CollectedOutput::default();
1109 let is_focused = match key {
1110 Some(k) if !k.is_empty() && !disabled => k == focus_key,
1111 _ => !disabled && focused,
1112 };
1113 let mut entry = render_button(label, is_focused, intent, disabled);
1114 if !disabled {
1121 let byte_end = entry.text.len();
1122 out.hits.push(HitArea {
1123 widget_key: key.unwrap_or("").to_string(),
1124 widget_kind: "button",
1125 buffer_row: 0,
1126 byte_start: 0,
1127 byte_end,
1128 payload: json!({}),
1129 event_type: "activate",
1130 });
1131 }
1132 ensure_trailing_newline(&mut entry);
1133 out.entries.push(entry);
1134 out
1135}
1136
1137fn collect_spacer(cols: u32) -> CollectedOutput {
1138 let mut out = CollectedOutput::default();
1139 let cols = cols.min(4096) as usize;
1145 let mut text = String::with_capacity(cols + 1);
1146 for _ in 0..cols {
1147 text.push(' ');
1148 }
1149 let mut entry = TextPropertyEntry {
1150 text,
1151 properties: Default::default(),
1152 style: None,
1153 inline_overlays: Vec::new(),
1154 segments: Vec::new(),
1155 pad_to_chars: None,
1156 truncate_to_chars: None,
1157 };
1158 ensure_trailing_newline(&mut entry);
1159 out.entries.push(entry);
1160 out
1161}
1162
1163fn collect_divider(ch: &str, style: Option<&OverlayOptions>, panel_width: u32) -> CollectedOutput {
1164 let mut out = CollectedOutput::default();
1165 let glyph = if ch.is_empty() { " " } else { ch };
1171 let cols = (panel_width as usize).min(4096);
1172 let mut text = String::with_capacity(cols * glyph.len() + 1);
1173 for _ in 0..cols {
1174 text.push_str(glyph);
1175 }
1176 let mut entry = TextPropertyEntry {
1177 text,
1178 properties: Default::default(),
1179 style: style.cloned(),
1180 inline_overlays: Vec::new(),
1181 segments: Vec::new(),
1182 pad_to_chars: None,
1183 truncate_to_chars: None,
1184 };
1185 ensure_trailing_newline(&mut entry);
1186 out.entries.push(entry);
1187 out
1188}
1189
1190fn render_list_cards(
1195 item_specs: &[WidgetSpec],
1196 prev: &HashMap<String, WidgetInstanceState>,
1197 focus_key: &str,
1198 width: u32,
1199) -> (Vec<Vec<TextPropertyEntry>>, u32) {
1200 let mut rendered_cards: Vec<Vec<TextPropertyEntry>> = Vec::with_capacity(item_specs.len());
1201 let mut item_height: u32 = 1;
1202 for item_spec in item_specs.iter() {
1203 let mut scratch = HashMap::new();
1204 let card_entries =
1205 render_collected(item_spec, prev, &mut scratch, focus_key, width).entries;
1206 item_height = item_height.max((card_entries.len() as u32).max(1));
1207 rendered_cards.push(card_entries);
1208 }
1209 (rendered_cards, item_height)
1210}
1211
1212fn blank_list_row() -> TextPropertyEntry {
1215 let mut padding = TextPropertyEntry {
1216 text: String::new(),
1217 properties: Default::default(),
1218 style: None,
1219 inline_overlays: Vec::new(),
1220 segments: Vec::new(),
1221 pad_to_chars: None,
1222 truncate_to_chars: None,
1223 };
1224 ensure_trailing_newline(&mut padding);
1225 padding
1226}
1227
1228fn mark_list_row_selected(entry: &mut TextPropertyEntry) {
1231 let mut style = entry.style.clone().unwrap_or_default();
1232 style.bg = Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG));
1233 style.extend_to_line_end = true;
1234 entry.style = Some(style);
1235}
1236
1237fn mark_list_card_selected(entry: &mut TextPropertyEntry) {
1245 entry.text = entry
1246 .text
1247 .replace('╭', "┏")
1248 .replace('╮', "┓")
1249 .replace('╰', "┗")
1250 .replace('╯', "┛")
1251 .replace('─', "━")
1252 .replace('│', "┃");
1253 let mut style = entry.style.clone().unwrap_or_default();
1254 style.bold = true;
1255 if entry.text.starts_with('┏') || entry.text.starts_with('┗') {
1256 style.fg = Some(OverlayColorSpec::theme_key("ui.popup_border_fg"));
1259 entry.style = Some(style);
1260 } else {
1261 entry.style = Some(style);
1268 let bar = '┃';
1269 let bar_len = bar.len_utf8();
1270 let first = entry.text.find(bar);
1271 let last = entry.text.rfind(bar);
1272 for pos in [first, last].into_iter().flatten().collect::<HashSet<_>>() {
1273 entry.inline_overlays.push(InlineOverlay {
1274 start: pos,
1275 end: pos + bar_len,
1276 style: OverlayOptions {
1277 fg: Some(OverlayColorSpec::theme_key("ui.popup_border_fg")),
1278 bold: true,
1279 ..Default::default()
1280 },
1281 properties: Default::default(),
1282 unit: OffsetUnit::Byte,
1283 });
1284 }
1285 }
1286}
1287
1288struct ListLayout {
1293 total: u32,
1295 effective_sel: i32,
1297 scroll: u32,
1299 visible_items: u32,
1301 item_height: u32,
1303 rendered_cards: Vec<Vec<TextPropertyEntry>>,
1305 user_scrolled: bool,
1307}
1308
1309#[allow(clippy::too_many_arguments)]
1313fn plan_list_layout(
1314 items_len: usize,
1315 item_specs: &[WidgetSpec],
1316 selected_index: i32,
1317 visible_rows: u32,
1318 list_key: Option<&str>,
1319 prev: &HashMap<String, WidgetInstanceState>,
1320 focus_key: &str,
1321 panel_width: u32,
1322) -> ListLayout {
1323 let use_specs = !item_specs.is_empty();
1324 let total = if use_specs {
1325 item_specs.len() as u32
1326 } else {
1327 items_len as u32
1328 };
1329 let avail_rows = visible_rows.max(1);
1331
1332 let (prev_scroll, prev_sel, user_scrolled) = list_key
1336 .and_then(|k| prev.get(k))
1337 .and_then(|s| match s {
1338 WidgetInstanceState::List {
1339 scroll_offset,
1340 selected_index,
1341 user_scrolled,
1342 ..
1343 } => Some((*scroll_offset, *selected_index, *user_scrolled)),
1344 _ => None,
1345 })
1346 .unwrap_or((0, selected_index, false));
1347 let effective_sel = if prev_sel < 0 || total == 0 {
1351 -1
1352 } else if (prev_sel as u32) >= total {
1353 (total - 1) as i32
1354 } else {
1355 prev_sel
1356 };
1357
1358 let mut rendered_cards: Vec<Vec<TextPropertyEntry>> = Vec::new();
1364 let mut item_height: u32 = 1;
1365 if use_specs {
1366 (rendered_cards, item_height) = render_list_cards(item_specs, prev, focus_key, panel_width);
1367 }
1368 let visible_items = if use_specs {
1370 (avail_rows / item_height).max(1)
1371 } else {
1372 avail_rows
1373 };
1374
1375 if use_specs && total > visible_items && panel_width > 1 {
1381 (rendered_cards, _) = render_list_cards(item_specs, prev, focus_key, panel_width - 1);
1382 }
1383
1384 let mut scroll = prev_scroll;
1391 if effective_sel >= 0 && !user_scrolled {
1392 let sel = effective_sel as u32;
1393 if sel < scroll {
1394 scroll = sel;
1395 }
1396 if sel >= scroll + visible_items {
1397 scroll = sel + 1 - visible_items;
1398 }
1399 }
1400 let max_scroll = total.saturating_sub(visible_items);
1401 if scroll > max_scroll {
1402 scroll = max_scroll;
1403 }
1404
1405 ListLayout {
1406 total,
1407 effective_sel,
1408 scroll,
1409 visible_items,
1410 item_height,
1411 rendered_cards,
1412 user_scrolled,
1413 }
1414}
1415
1416#[allow(clippy::too_many_arguments)]
1417fn collect_list(
1418 items: &[TextPropertyEntry],
1419 item_specs: &[WidgetSpec],
1420 item_keys: &[String],
1421 selected_index: i32,
1422 visible_rows: u32,
1423 list_key: Option<&str>,
1424 prev: &HashMap<String, WidgetInstanceState>,
1425 next_state: &mut HashMap<String, WidgetInstanceState>,
1426 focus_key: &str,
1427 panel_width: u32,
1428) -> CollectedOutput {
1429 let mut entries: Vec<TextPropertyEntry> = Vec::new();
1430 let mut hits: Vec<HitArea> = Vec::new();
1431 let mut scroll_regions: Vec<ScrollRegion> = Vec::new();
1432
1433 let use_specs = !item_specs.is_empty();
1442 let avail_rows = visible_rows.max(1);
1444 let ListLayout {
1445 total,
1446 effective_sel,
1447 scroll,
1448 visible_items,
1449 item_height,
1450 rendered_cards,
1451 user_scrolled,
1452 } = plan_list_layout(
1453 items.len(),
1454 item_specs,
1455 selected_index,
1456 visible_rows,
1457 list_key,
1458 prev,
1459 focus_key,
1460 panel_width,
1461 );
1462
1463 if let Some(k) = list_key {
1466 next_state.insert(
1467 k.to_string(),
1468 WidgetInstanceState::List {
1469 scroll_offset: scroll,
1470 selected_index: effective_sel,
1471 item_height,
1472 user_scrolled,
1473 },
1474 );
1475 }
1476
1477 let start = scroll as usize;
1478 let end = ((scroll + visible_items) as usize).min(total as usize);
1479
1480 let rows_emitted: u32 = if use_specs {
1481 let mut emitted = 0u32;
1490 let last = if end < total as usize { end + 1 } else { end };
1491 'cards: for i in start..last {
1492 let is_selected = i as i32 == effective_sel;
1493 let item_key = item_keys.get(i).cloned().unwrap_or_default();
1494 let card = &rendered_cards[i];
1495 for r in 0..item_height as usize {
1496 if emitted >= avail_rows {
1497 break 'cards;
1498 }
1499 let mut entry = card.get(r).cloned().unwrap_or_else(blank_list_row);
1500 entry.normalize_widths();
1501 if is_selected {
1502 mark_list_card_selected(&mut entry);
1503 }
1504 let byte_end = entry.text.len();
1505 ensure_trailing_newline(&mut entry);
1506 let hit_row = entries.len() as u32;
1507 entries.push(entry);
1508 hits.push(HitArea {
1509 widget_key: item_key.clone(),
1510 widget_kind: "list",
1511 buffer_row: hit_row,
1512 byte_start: 0,
1513 byte_end,
1514 payload: json!({
1515 "index": i as i64,
1516 "key": item_key,
1517 "list_key": list_key,
1518 }),
1519 event_type: "select",
1520 });
1521 emitted += 1;
1522 }
1523 }
1524 emitted
1525 } else {
1526 for (offset, item) in items[start..end.min(items.len())].iter().enumerate() {
1528 let i = start + offset;
1529 let mut entry = item.clone();
1530 entry.normalize_widths();
1531 if i as i32 == effective_sel {
1532 mark_list_row_selected(&mut entry);
1533 }
1534 let byte_end = entry.text.len();
1535 ensure_trailing_newline(&mut entry);
1536 entries.push(entry);
1537 let item_key = item_keys.get(i).cloned().unwrap_or_default();
1538 let hit_row = (entries.len() - 1) as u32;
1539 hits.push(HitArea {
1540 widget_key: item_key.clone(),
1541 widget_kind: "list",
1542 buffer_row: hit_row,
1543 byte_start: 0,
1544 byte_end,
1545 payload: json!({
1546 "index": i as i64,
1547 "key": item_key,
1548 "list_key": list_key,
1553 }),
1554 event_type: "select",
1555 });
1556 }
1557 (end - start) as u32
1558 };
1559
1560 for _ in rows_emitted..avail_rows {
1564 entries.push(blank_list_row());
1565 }
1566
1567 if total > visible_items {
1571 if let Some(k) = list_key {
1572 scroll_regions.push(ScrollRegion {
1573 list_key: k.to_string(),
1574 buffer_row: 0,
1575 col_in_row: 0,
1576 width_cols: panel_width,
1577 height_rows: avail_rows,
1578 total: total as usize,
1579 visible: visible_items as usize,
1580 scroll: scroll as usize,
1581 });
1582 }
1583 }
1584
1585 CollectedOutput {
1586 entries,
1587 hits,
1588 focus_cursor: None,
1589 embeds: Vec::new(),
1590 overlays: Vec::new(),
1591 scroll_regions,
1592 }
1593}
1594
1595#[allow(clippy::too_many_arguments)]
1596fn collect_labeled_section(
1597 label: &str,
1598 child: &WidgetSpec,
1599 prev: &HashMap<String, WidgetInstanceState>,
1600 next_state: &mut HashMap<String, WidgetInstanceState>,
1601 focus_key: &str,
1602 panel_width: u32,
1603) -> CollectedOutput {
1604 let mut entries: Vec<TextPropertyEntry> = Vec::new();
1605 let mut hits: Vec<HitArea> = Vec::new();
1606 let mut focus_cursor: Option<FocusCursor> = None;
1607 let mut embeds: Vec<EmbedRect> = Vec::new();
1608 let mut overlays: Vec<OverlayRow> = Vec::new();
1609 let mut scroll_regions: Vec<ScrollRegion> = Vec::new();
1610
1611 let inner_width = panel_width.saturating_sub(4).max(1);
1614 let child_out = render_collected(child, prev, next_state, focus_key, inner_width);
1615 overlays.extend(child_out.overlays.into_iter().map(|mut o| {
1625 o.buffer_row += 1;
1626 o
1627 }));
1628
1629 let total_cols = panel_width.max(2) as usize;
1633 entries.push(render_section_top_border(label, total_cols));
1634
1635 for mut child_entry in child_out.entries {
1640 strip_trailing_newline(&mut child_entry);
1641 let wrapped = wrap_in_side_border(child_entry, inner_width as usize);
1642 let row_offset = entries.len() as u32;
1643 let _ = row_offset;
1648 entries.push(wrapped);
1649 }
1650
1651 let prefix_bytes = LEFT_BORDER_PREFIX.len();
1655 for mut h in child_out.hits {
1656 h.buffer_row += 1;
1657 h.byte_start += prefix_bytes;
1658 h.byte_end += prefix_bytes;
1659 hits.push(h);
1660 }
1661 if let Some(mut fc) = child_out.focus_cursor {
1662 fc.buffer_row += 1;
1663 fc.byte_in_row += prefix_bytes as u32;
1664 focus_cursor = Some(fc);
1665 }
1666 let prefix_cols = LEFT_BORDER_PREFIX.chars().count() as u32;
1669 for mut emb in child_out.embeds {
1670 emb.buffer_row += 1;
1671 emb.col_in_row += prefix_cols;
1672 embeds.push(emb);
1673 }
1674 for mut sr in child_out.scroll_regions {
1675 sr.buffer_row += 1;
1676 sr.col_in_row += prefix_cols;
1677 sr.width_cols = inner_width;
1681 scroll_regions.push(sr);
1682 }
1683
1684 entries.push(render_section_bottom_border(total_cols));
1685
1686 CollectedOutput {
1687 entries,
1688 hits,
1689 focus_cursor,
1690 embeds,
1691 overlays,
1692 scroll_regions,
1693 }
1694}
1695
1696fn collect_window_embed(window_id: u32, embed_rows: u32, panel_width: u32) -> CollectedOutput {
1697 let mut out = CollectedOutput::default();
1698 let cols = panel_width.max(1) as usize;
1703 for _ in 0..embed_rows {
1704 let mut text = String::with_capacity(cols + 1);
1705 for _ in 0..cols {
1706 text.push(' ');
1707 }
1708 text.push('\n');
1709 out.entries.push(TextPropertyEntry {
1710 text,
1711 properties: Default::default(),
1712 style: None,
1713 inline_overlays: Vec::new(),
1714 segments: Vec::new(),
1715 pad_to_chars: None,
1716 truncate_to_chars: None,
1717 });
1718 }
1719 out.embeds.push(EmbedRect {
1720 window_id,
1721 buffer_row: 0,
1722 col_in_row: 0,
1723 width_cols: panel_width,
1724 height_rows: embed_rows,
1725 });
1726 out
1727}
1728
1729fn collect_raw(raw_entries: &[TextPropertyEntry]) -> CollectedOutput {
1730 let mut out = CollectedOutput::default();
1731 for raw_entry in raw_entries {
1740 let mut e = raw_entry.clone();
1741 e.normalize_widths();
1742 ensure_trailing_newline(&mut e);
1743 out.entries.push(e);
1744 }
1745 out
1746}
1747
1748#[allow(clippy::too_many_arguments)]
1749fn collect_overlay(
1750 child: &WidgetSpec,
1751 prev: &HashMap<String, WidgetInstanceState>,
1752 next_state: &mut HashMap<String, WidgetInstanceState>,
1753 focus_key: &str,
1754 panel_width: u32,
1755) -> CollectedOutput {
1756 let child_out = render_collected(child, prev, next_state, focus_key, panel_width);
1765 CollectedOutput {
1766 entries: child_out.entries,
1767 hits: child_out.hits,
1768 focus_cursor: child_out.focus_cursor,
1769 embeds: child_out.embeds,
1770 overlays: child_out.overlays,
1771 scroll_regions: child_out.scroll_regions,
1772 }
1773}
1774
1775fn effective_text_field_width(
1785 full_width: bool,
1786 multiline: bool,
1787 label: &str,
1788 panel_width: u32,
1789 field_width: u32,
1790) -> u32 {
1791 if !full_width || multiline {
1792 return field_width;
1793 }
1794 let label_overhead = if label.is_empty() {
1795 0u32
1796 } else {
1797 label.chars().count() as u32 + 1
1798 };
1799 let marker_reserve = if marker_gutter_enabled() { 2 } else { 0 };
1800 panel_width
1801 .saturating_sub(label_overhead)
1802 .saturating_sub(3)
1803 .saturating_sub(marker_reserve)
1804 .max(1)
1805}
1806
1807fn emit_completion_overlays(
1825 out: &mut CollectedOutput,
1826 completions: &[fresh_core::api::CompletionItem],
1827 visible_rows: u32,
1828 panel_width: u32,
1829 selected_idx: usize,
1830 navigated: bool,
1831 prev_scroll: u32,
1832) -> u32 {
1833 if completions.is_empty() {
1834 return 0;
1835 }
1836 let popup_total = (panel_width as usize).saturating_add(4); let total = completions.len() as u32;
1838 let visible = visible_rows.max(1).min(total);
1839 let sel = selected_idx as u32;
1840 let mut scroll = prev_scroll;
1841 if sel >= scroll + visible {
1842 scroll = sel + 1 - visible;
1843 }
1844 let max_scroll = total.saturating_sub(visible);
1845 if scroll > max_scroll {
1846 scroll = max_scroll;
1847 }
1848
1849 let mut anchor: u32 = 1;
1850 out.overlays.push(OverlayRow {
1851 buffer_row: anchor,
1852 entry: render_completion_dim_separator_overlay(popup_total),
1853 });
1854 anchor += 1;
1855 let needs_scrollbar = total > visible;
1856 let end = (scroll + visible).min(total) as usize;
1857 for (visible_row, i) in (scroll as usize..end).enumerate() {
1858 let item = &completions[i];
1859 let thumb = if needs_scrollbar {
1860 completion_scrollbar_glyph(visible_row as u32, visible, scroll, total)
1861 } else {
1862 None
1863 };
1864 out.overlays.push(OverlayRow {
1865 buffer_row: anchor,
1866 entry: render_completion_item_overlay(
1867 &item.value,
1868 item.kind.as_deref(),
1869 navigated && i == selected_idx,
1874 popup_total,
1875 thumb,
1876 ),
1877 });
1878 anchor += 1;
1879 }
1880 out.overlays.push(OverlayRow {
1881 buffer_row: anchor,
1882 entry: render_completion_bottom_border(popup_total),
1883 });
1884 scroll
1885}
1886
1887#[allow(clippy::too_many_arguments)]
1888fn render_widget_text(
1889 value: &str,
1890 cursor_byte: i32,
1891 focused: bool,
1892 label: &str,
1893 placeholder: Option<&str>,
1894 rows: u32,
1895 field_width: u32,
1896 max_visible_chars: u32,
1897 full_width: bool,
1898 completions_visible_rows: u32,
1899 key: Option<&str>,
1900 prev: &HashMap<String, WidgetInstanceState>,
1901 next_state: &mut HashMap<String, WidgetInstanceState>,
1902 focus_key: &str,
1903 panel_width: u32,
1904) -> CollectedOutput {
1905 let mut out = CollectedOutput::default();
1906 let effective_visible_rows = if completions_visible_rows == 0 {
1910 5u32
1911 } else {
1912 completions_visible_rows
1913 };
1914
1915 let is_focused = match key.filter(|k| !k.is_empty()) {
1916 Some(k) => k == focus_key,
1917 None => focused,
1918 };
1919 let multiline = rows > 1;
1927 let mut effective_editor: crate::primitives::text_edit::TextEdit;
1928 let prev_scroll: u32;
1929 let mut prev_completions: Vec<fresh_core::api::CompletionItem> = Vec::new();
1935 let mut prev_completion_idx: usize = 0;
1936 let mut prev_completion_scroll: u32 = 0;
1937 let mut prev_completion_navigated = false;
1938 match key.filter(|k| !k.is_empty()).and_then(|k| prev.get(k)) {
1939 Some(WidgetInstanceState::Text {
1940 editor,
1941 scroll,
1942 completions,
1943 completion_selected_index,
1944 completion_scroll_offset,
1945 completion_navigated,
1946 }) => {
1947 effective_editor = editor.clone();
1948 prev_scroll = *scroll;
1949 prev_completions = completions.clone();
1950 prev_completion_idx = *completion_selected_index;
1951 prev_completion_scroll = *completion_scroll_offset;
1952 prev_completion_navigated = *completion_navigated;
1953 }
1954 _ => {
1955 effective_editor = if multiline {
1956 crate::primitives::text_edit::TextEdit::with_text(value)
1957 } else {
1958 crate::primitives::text_edit::TextEdit::single_line_with_text(value)
1959 };
1960 let seed = if cursor_byte < 0 {
1961 value.len()
1962 } else {
1963 (cursor_byte as usize).min(value.len())
1964 };
1965 effective_editor.set_cursor_from_flat(seed);
1966 prev_scroll = 0;
1967 }
1968 }
1969 if !prev_completions.is_empty() {
1973 prev_completion_idx = prev_completion_idx.min(prev_completions.len() - 1);
1974 } else {
1975 prev_completion_idx = 0;
1976 }
1977 let effective_value = effective_editor.value();
1978 let effective_cursor_byte = effective_editor.flat_cursor_byte() as i32;
1979 let effective_cursor = if is_focused {
1980 effective_cursor_byte
1981 } else {
1982 -1
1983 };
1984 let effective_field_width =
1985 effective_text_field_width(full_width, multiline, label, panel_width, field_width);
1986 let selection_for_render = if is_focused {
1990 effective_editor.selection_flat_range()
1991 } else {
1992 None
1993 };
1994 let new_scroll;
1995 if multiline {
1996 let rendered = render_text_area(
1997 &effective_value,
1998 effective_cursor,
1999 selection_for_render,
2000 is_focused,
2001 label,
2002 placeholder,
2003 rows,
2004 effective_field_width,
2005 prev_scroll,
2006 panel_width,
2007 );
2008 new_scroll = rendered.scroll_row;
2009 if let (Some(buffer_row), Some(byte_in_row)) =
2010 (rendered.cursor_buffer_row, rendered.cursor_byte_in_row)
2011 {
2012 out.focus_cursor = Some(FocusCursor {
2013 buffer_row,
2014 byte_in_row: byte_in_row as u32,
2015 });
2016 }
2017 for (row_idx, mut e) in rendered.entries.into_iter().enumerate() {
2018 if let Some(k) = key.filter(|k| !k.is_empty()) {
2021 out.hits.push(HitArea {
2022 widget_key: k.to_string(),
2023 widget_kind: "text",
2024 buffer_row: row_idx as u32,
2025 byte_start: 0,
2026 byte_end: e.text.len(),
2027 payload: json!({}),
2028 event_type: "focus",
2029 });
2030 }
2031 ensure_trailing_newline(&mut e);
2032 out.entries.push(e);
2033 }
2034 } else {
2035 let rendered = render_text_input(
2036 &effective_value,
2037 effective_cursor,
2038 selection_for_render,
2039 is_focused,
2040 label,
2041 placeholder,
2042 max_visible_chars,
2043 effective_field_width,
2044 full_width,
2045 );
2046 new_scroll = 0;
2047 let mut entry = rendered.entry;
2048 let gutter = focus_gutter_prefix(is_focused);
2060 let marker_bytes = gutter.len();
2061 let mut cursor_in_row = rendered.cursor_byte_in_entry;
2062 if marker_bytes > 0 {
2063 entry.text.insert_str(0, gutter);
2064 for ov in entry.inline_overlays.iter_mut() {
2065 ov.start += marker_bytes;
2066 ov.end += marker_bytes;
2067 }
2068 cursor_in_row = cursor_in_row.map(|c| c + marker_bytes);
2069 }
2070 if let Some(byte_in_row) = cursor_in_row {
2071 out.focus_cursor = Some(FocusCursor {
2072 buffer_row: 0,
2073 byte_in_row: byte_in_row as u32,
2074 });
2075 }
2076 if let Some(k) = key.filter(|k| !k.is_empty()) {
2082 out.hits.push(HitArea {
2083 widget_key: k.to_string(),
2084 widget_kind: "text",
2085 buffer_row: 0,
2086 byte_start: 0,
2087 byte_end: entry.text.len(),
2088 payload: json!({}),
2089 event_type: "focus",
2090 });
2091 }
2092 ensure_trailing_newline(&mut entry);
2093 out.entries.push(entry);
2094 }
2095 prev_completion_scroll = emit_completion_overlays(
2099 &mut out,
2100 &prev_completions,
2101 effective_visible_rows,
2102 panel_width,
2103 prev_completion_idx,
2104 prev_completion_navigated,
2105 prev_completion_scroll,
2106 );
2107 if let Some(k) = key.filter(|k| !k.is_empty()) {
2113 next_state.insert(
2114 k.to_string(),
2115 WidgetInstanceState::Text {
2116 editor: effective_editor.clone(),
2117 scroll: new_scroll,
2118 completions: prev_completions,
2119 completion_selected_index: prev_completion_idx,
2120 completion_scroll_offset: prev_completion_scroll,
2121 completion_navigated: prev_completion_navigated,
2122 },
2123 );
2124 }
2125 out
2126}
2127
2128#[allow(clippy::too_many_arguments)]
2129fn render_widget_tree(
2130 nodes: &[TreeNode],
2131 item_keys: &[String],
2132 selected_index: i32,
2133 visible_rows: u32,
2134 expanded_keys: &[String],
2135 checkable: bool,
2136 tree_key: Option<&str>,
2137 prev: &HashMap<String, WidgetInstanceState>,
2138 next_state: &mut HashMap<String, WidgetInstanceState>,
2139) -> CollectedOutput {
2140 let mut out = CollectedOutput::default();
2141 let prev_state = tree_key.filter(|k| !k.is_empty()).and_then(|k| prev.get(k));
2144 let (prev_scroll, prev_sel, prev_expanded) = match prev_state {
2145 Some(WidgetInstanceState::Tree {
2146 scroll_offset,
2147 selected_index,
2148 expanded_keys,
2149 }) => (*scroll_offset, *selected_index, expanded_keys.clone()),
2150 _ => {
2151 let seeded: HashSet<String> = expanded_keys.iter().cloned().collect();
2153 (0, selected_index, seeded)
2154 }
2155 };
2156
2157 let mut ancestor_open: Vec<bool> = Vec::new();
2169 let mut visible_indices: Vec<usize> = Vec::with_capacity(nodes.len());
2170 for (i, node) in nodes.iter().enumerate() {
2171 let depth = node.depth as usize;
2172 ancestor_open.truncate(depth);
2174 let visible = ancestor_open.iter().all(|open| *open);
2175 if visible {
2176 visible_indices.push(i);
2177 }
2178 let key = item_keys.get(i).cloned().unwrap_or_default();
2184 let is_open = if node.has_children {
2185 !key.is_empty() && prev_expanded.contains(&key)
2186 } else {
2187 true
2188 };
2189 ancestor_open.push(is_open);
2190 }
2191
2192 let total_visible = visible_indices.len() as u32;
2198 let visible = visible_rows.max(1);
2199 let clamp_to_visible = |abs: i32| -> i32 {
2200 if abs < 0 || nodes.is_empty() {
2201 return -1;
2202 }
2203 let abs = abs.min((nodes.len() as i32) - 1) as usize;
2204 if let Ok(_pos) = visible_indices.binary_search(&abs) {
2205 return abs as i32;
2206 }
2207 let earlier = visible_indices.iter().rev().find(|&&v| v <= abs);
2210 if let Some(&v) = earlier {
2211 return v as i32;
2212 }
2213 visible_indices.first().map(|&v| v as i32).unwrap_or(-1)
2214 };
2215 let effective_sel_abs = clamp_to_visible(prev_sel);
2216 let sel_visible_pos: i32 = if effective_sel_abs < 0 {
2220 -1
2221 } else {
2222 visible_indices
2223 .iter()
2224 .position(|&v| v == effective_sel_abs as usize)
2225 .map(|p| p as i32)
2226 .unwrap_or(-1)
2227 };
2228
2229 let mut scroll = prev_scroll;
2232 if sel_visible_pos >= 0 {
2233 let sel = sel_visible_pos as u32;
2234 if sel < scroll {
2235 scroll = sel;
2236 }
2237 if sel >= scroll + visible {
2238 scroll = sel + 1 - visible;
2239 }
2240 }
2241 let max_scroll = total_visible.saturating_sub(visible);
2242 if scroll > max_scroll {
2243 scroll = max_scroll;
2244 }
2245
2246 if let Some(k) = tree_key.filter(|k| !k.is_empty()) {
2248 next_state.insert(
2249 k.to_string(),
2250 WidgetInstanceState::Tree {
2251 scroll_offset: scroll,
2252 selected_index: effective_sel_abs,
2253 expanded_keys: prev_expanded.clone(),
2254 },
2255 );
2256 }
2257
2258 let start = scroll as usize;
2260 let end = ((scroll + visible) as usize).min(visible_indices.len());
2261 for &abs_idx in &visible_indices[start..end] {
2262 let mut node = nodes[abs_idx].clone();
2267 node.text.normalize_widths();
2268 let item_key = item_keys.get(abs_idx).cloned().unwrap_or_default();
2269 let is_expanded =
2270 node.has_children && !item_key.is_empty() && prev_expanded.contains(&item_key);
2271 let rendered = render_tree_row(&node, is_expanded, checkable);
2272 let mut entry = rendered.entry;
2273 let is_selected = abs_idx as i32 == effective_sel_abs;
2274 if is_selected {
2275 let mut style = entry.style.unwrap_or_default();
2276 style.bg = Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG));
2277 style.extend_to_line_end = true;
2278 entry.style = Some(style);
2279 }
2280 let row_byte_end = entry.text.len();
2281 ensure_trailing_newline(&mut entry);
2282 out.entries.push(entry);
2283 let hit_row = (out.entries.len() - 1) as u32;
2284 let tree_spec_key = tree_key.unwrap_or("").to_string();
2294 if let Some(disc_range) = rendered.disclosure_range {
2295 out.hits.push(HitArea {
2296 widget_key: tree_spec_key.clone(),
2297 widget_kind: "tree",
2298 buffer_row: hit_row,
2299 byte_start: disc_range.0,
2300 byte_end: disc_range.1,
2301 payload: json!({
2302 "index": abs_idx as i64,
2303 "key": item_key.clone(),
2304 "expanded": !is_expanded,
2305 }),
2306 event_type: "expand",
2307 });
2308 }
2309 if let Some(cb_range) = rendered.checkbox_range {
2316 let new_checked = !nodes[abs_idx].checked.unwrap_or(false);
2317 out.hits.push(HitArea {
2318 widget_key: tree_spec_key.clone(),
2319 widget_kind: "tree",
2320 buffer_row: hit_row,
2321 byte_start: cb_range.0,
2322 byte_end: cb_range.1,
2323 payload: json!({
2324 "index": abs_idx as i64,
2325 "key": item_key.clone(),
2326 "checked": new_checked,
2327 }),
2328 event_type: "toggle",
2329 });
2330 }
2331 let body_start = match (rendered.checkbox_range, rendered.disclosure_range) {
2335 (Some((_, end)), _) => end + 1, (None, Some((_, end))) => end,
2337 (None, None) => 0,
2338 };
2339 if body_start < row_byte_end {
2340 out.hits.push(HitArea {
2341 widget_key: tree_spec_key,
2342 widget_kind: "tree",
2343 buffer_row: hit_row,
2344 byte_start: body_start,
2345 byte_end: row_byte_end,
2346 payload: json!({
2347 "index": abs_idx as i64,
2348 "key": item_key,
2349 }),
2350 event_type: "select",
2351 });
2352 }
2353 }
2354 out
2355}
2356
2357const LEFT_BORDER_PREFIX: &str = "│ ";
2362const RIGHT_BORDER_SUFFIX: &str = " │";
2363
2364fn render_section_top_border(label: &str, total_cols: usize) -> TextPropertyEntry {
2375 let mut text = String::new();
2376 let mut overlays: Vec<InlineOverlay> = Vec::new();
2377 text.push('╭');
2378 if label.is_empty() {
2379 for _ in 0..total_cols.saturating_sub(2) {
2380 text.push('─');
2381 }
2382 } else {
2383 let label_cols = label.chars().count();
2388 let used = 1 + 1 + 1 + label_cols + 1; text.push('─');
2390 text.push(' ');
2391 let label_byte_start = text.len();
2392 text.push_str(label);
2393 let label_byte_end = text.len();
2394 text.push(' ');
2395 let remaining = total_cols.saturating_sub(used + 1); for _ in 0..remaining {
2397 text.push('─');
2398 }
2399 overlays.push(InlineOverlay {
2400 start: label_byte_start,
2401 end: label_byte_end,
2402 style: OverlayOptions {
2403 fg: Some(OverlayColorSpec::theme_key(KEY_SECTION_LABEL_FG)),
2404 bold: true,
2405 ..Default::default()
2406 },
2407 properties: Default::default(),
2408 unit: OffsetUnit::Byte,
2409 });
2410 }
2411 text.push('╮');
2412 text.push('\n');
2413 TextPropertyEntry {
2414 text,
2415 properties: Default::default(),
2416 style: None,
2417 inline_overlays: overlays,
2418 segments: Vec::new(),
2419 pad_to_chars: None,
2420 truncate_to_chars: None,
2421 }
2422}
2423
2424fn render_section_bottom_border(total_cols: usize) -> TextPropertyEntry {
2427 let mut text = String::new();
2428 text.push('╰');
2429 for _ in 0..total_cols.saturating_sub(2) {
2430 text.push('─');
2431 }
2432 text.push('╯');
2433 text.push('\n');
2434 TextPropertyEntry {
2435 text,
2436 properties: Default::default(),
2437 style: None,
2438 inline_overlays: Vec::new(),
2439 segments: Vec::new(),
2440 pad_to_chars: None,
2441 truncate_to_chars: None,
2442 }
2443}
2444
2445fn render_completion_dim_separator_overlay(total_cols: usize) -> TextPropertyEntry {
2454 let inner = total_cols.saturating_sub(2).max(1);
2455 let mut text = String::with_capacity(total_cols * 4 + 2);
2456 text.push('│');
2457 for _ in 0..inner {
2458 text.push('┄');
2459 }
2460 text.push('│');
2461 text.push('\n');
2462 let left_border_bytes = "│".len();
2470 let dash_bytes = "┄".len() * inner;
2471 let right_border_start = left_border_bytes + dash_bytes;
2472 let right_border_end = right_border_start + "│".len();
2473 let inline_overlays = vec![
2474 InlineOverlay {
2475 start: 0,
2476 end: left_border_bytes,
2477 style: OverlayOptions {
2478 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2479 ..Default::default()
2480 },
2481 properties: Default::default(),
2482 unit: OffsetUnit::Byte,
2483 },
2484 InlineOverlay {
2485 start: left_border_bytes,
2486 end: left_border_bytes + dash_bytes,
2487 style: OverlayOptions {
2488 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_DIM_FG)),
2489 ..Default::default()
2490 },
2491 properties: Default::default(),
2492 unit: OffsetUnit::Byte,
2493 },
2494 InlineOverlay {
2495 start: right_border_start,
2496 end: right_border_end,
2497 style: OverlayOptions {
2498 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2499 ..Default::default()
2500 },
2501 properties: Default::default(),
2502 unit: OffsetUnit::Byte,
2503 },
2504 ];
2505 TextPropertyEntry {
2506 text,
2507 properties: Default::default(),
2508 style: None,
2509 inline_overlays,
2510 segments: Vec::new(),
2511 pad_to_chars: None,
2512 truncate_to_chars: None,
2513 }
2514}
2515
2516fn render_completion_bottom_border(total_cols: usize) -> TextPropertyEntry {
2523 let mut text = String::with_capacity(total_cols * 4 + 2);
2524 text.push('╰');
2525 for _ in 0..total_cols.saturating_sub(2).max(1) {
2526 text.push('─');
2527 }
2528 text.push('╯');
2529 text.push('\n');
2530 TextPropertyEntry {
2536 text,
2537 properties: Default::default(),
2538 style: Some(OverlayOptions {
2539 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2540 ..Default::default()
2541 }),
2542 inline_overlays: Vec::new(),
2543 segments: Vec::new(),
2544 pad_to_chars: None,
2545 truncate_to_chars: None,
2546 }
2547}
2548
2549fn render_completion_item_overlay(
2556 item: &str,
2557 kind: Option<&str>,
2558 selected: bool,
2559 total_cols: usize,
2560 scrollbar: Option<char>,
2561) -> TextPropertyEntry {
2562 let inner = total_cols.saturating_sub(2).max(1);
2563 let body_entry = render_completion_item(item, kind, selected, inner, scrollbar);
2567 let mut text = String::with_capacity(body_entry.text.len() + 8);
2571 text.push('│');
2572 let body_no_nl = body_entry.text.trim_end_matches('\n');
2573 text.push_str(body_no_nl);
2574 text.push('│');
2575 text.push('\n');
2576 let left_border_bytes = "│".len();
2596 let body_no_nl_bytes = body_no_nl.len();
2597 let right_border_start = left_border_bytes + body_no_nl_bytes;
2598 let right_border_end = right_border_start + "│".len();
2599 let mut inline_overlays: Vec<InlineOverlay> = Vec::new();
2600 if selected {
2601 inline_overlays.push(InlineOverlay {
2602 start: left_border_bytes,
2603 end: right_border_start,
2604 style: OverlayOptions {
2605 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_FG)),
2606 bg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_BG)),
2607 ..Default::default()
2608 },
2609 properties: Default::default(),
2610 unit: OffsetUnit::Byte,
2611 });
2612 }
2613 inline_overlays.extend(body_entry.inline_overlays.into_iter().map(|mut io| {
2621 io.start += left_border_bytes;
2622 io.end += left_border_bytes;
2623 io
2624 }));
2625 inline_overlays.push(InlineOverlay {
2626 start: 0,
2627 end: left_border_bytes,
2628 style: OverlayOptions {
2629 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2630 ..Default::default()
2631 },
2632 properties: Default::default(),
2633 unit: OffsetUnit::Byte,
2634 });
2635 inline_overlays.push(InlineOverlay {
2636 start: right_border_start,
2637 end: right_border_end,
2638 style: OverlayOptions {
2639 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2640 ..Default::default()
2641 },
2642 properties: Default::default(),
2643 unit: OffsetUnit::Byte,
2644 });
2645 TextPropertyEntry {
2646 text,
2647 properties: Default::default(),
2648 style: None,
2649 inline_overlays,
2650 segments: Vec::new(),
2651 pad_to_chars: None,
2652 truncate_to_chars: None,
2653 }
2654}
2655
2656fn render_completion_item(
2682 item: &str,
2683 kind: Option<&str>,
2684 selected: bool,
2685 total_cols: usize,
2686 scrollbar: Option<char>,
2687) -> TextPropertyEntry {
2688 let lead = if marker_gutter_enabled() { 2 } else { 0 };
2704 let text_budget = total_cols.saturating_sub(2 + lead).saturating_sub(1);
2708 let item_chars: Vec<char> = item.chars().collect();
2709 let (visible_item, truncated): (String, bool) = if item_chars.len() <= text_budget {
2710 (item.to_string(), false)
2711 } else {
2712 let keep = text_budget.saturating_sub(1);
2717 let head: String = item_chars.iter().take(keep).collect();
2718 (format!("{}…", head), true)
2719 };
2720 let _ = truncated;
2721 let scrollbar_ch = scrollbar.unwrap_or(' ');
2722 let is_history = kind == Some("history");
2723 let history_marker: char = '↶';
2730 let mut text = String::with_capacity(total_cols * 4 + 2);
2731 for _ in 0..lead {
2736 text.push(' ');
2737 }
2738 text.push(' ');
2739 let marker_start_byte = text.len();
2740 if is_history {
2741 text.push(history_marker);
2742 } else {
2743 text.push(' ');
2744 }
2745 let marker_end_byte = text.len();
2746 let item_start_byte = text.len();
2747 text.push_str(&visible_item);
2748 let item_end_byte = text.len();
2749 let used_cols = 2 + lead + visible_item.chars().count();
2753 let pad_cols = total_cols.saturating_sub(used_cols).saturating_sub(1);
2754 for _ in 0..pad_cols {
2755 text.push(' ');
2756 }
2757 text.push(scrollbar_ch);
2758 text.push('\n');
2759
2760 let body_style = if selected {
2761 Some(OverlayOptions {
2762 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_FG)),
2763 bg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_BG)),
2764 extend_to_line_end: true,
2765 fg_on_collision_only: false,
2766 ..Default::default()
2767 })
2768 } else {
2769 Some(OverlayOptions {
2774 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_FG)),
2775 extend_to_line_end: true,
2776 fg_on_collision_only: false,
2777 ..Default::default()
2778 })
2779 };
2780 let mut inline_overlays: Vec<InlineOverlay> = Vec::new();
2781 if is_history {
2786 inline_overlays.push(InlineOverlay {
2787 start: marker_start_byte,
2788 end: marker_end_byte,
2789 style: OverlayOptions {
2790 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2791 ..Default::default()
2792 },
2793 properties: Default::default(),
2794 unit: OffsetUnit::Byte,
2795 });
2796 inline_overlays.push(InlineOverlay {
2797 start: item_start_byte,
2798 end: item_end_byte,
2799 style: OverlayOptions {
2800 italic: true,
2801 ..Default::default()
2802 },
2803 properties: Default::default(),
2804 unit: OffsetUnit::Byte,
2805 });
2806 }
2807 if scrollbar.is_some() {
2813 let total_bytes = text.trim_end_matches('\n').len();
2814 let scrollbar_byte_len = scrollbar_ch.len_utf8();
2815 let start = total_bytes - scrollbar_byte_len;
2816 let end = total_bytes;
2817 inline_overlays.push(InlineOverlay {
2818 start,
2819 end,
2820 style: OverlayOptions {
2821 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_DIM_FG)),
2822 ..Default::default()
2823 },
2824 properties: Default::default(),
2825 unit: OffsetUnit::Byte,
2826 });
2827 }
2828
2829 TextPropertyEntry {
2830 text,
2831 properties: Default::default(),
2832 style: body_style,
2833 inline_overlays,
2834 segments: Vec::new(),
2835 pad_to_chars: None,
2836 truncate_to_chars: None,
2837 }
2838}
2839
2840fn completion_scrollbar_glyph(
2852 visible_row: u32,
2853 visible: u32,
2854 scroll: u32,
2855 total: u32,
2856) -> Option<char> {
2857 if total <= visible || visible == 0 {
2858 return None;
2859 }
2860 let thumb_size = ((visible as f32 * visible as f32) / total as f32).round() as u32;
2864 let thumb_size = thumb_size.max(1).min(visible);
2865 let max_scroll = total - visible;
2866 let thumb_top = if max_scroll == 0 {
2867 0
2868 } else {
2869 ((scroll as f32 / max_scroll as f32) * (visible - thumb_size) as f32).round() as u32
2873 };
2874 if visible_row >= thumb_top && visible_row < thumb_top + thumb_size {
2875 Some('█')
2876 } else {
2877 None
2878 }
2879}
2880
2881fn wrap_in_side_border(mut child: TextPropertyEntry, inner_width: usize) -> TextPropertyEntry {
2886 let prefix_bytes = LEFT_BORDER_PREFIX.len();
2887 let cur_cols = child.text.chars().count();
2889 if cur_cols < inner_width {
2890 for _ in 0..(inner_width - cur_cols) {
2891 child.text.push(' ');
2892 }
2893 } else if cur_cols > inner_width {
2894 let indices: Vec<usize> = child.text.char_indices().map(|(i, _)| i).collect();
2899 let byte_cutoff = indices
2900 .get(inner_width)
2901 .copied()
2902 .unwrap_or(child.text.len());
2903 child.text.truncate(byte_cutoff);
2904 if inner_width >= 2 {
2905 child.text.pop();
2911 child.text.push('…');
2912 }
2913 let byte_cutoff = child.text.len();
2914 child.inline_overlays.retain_mut(|o| {
2917 if o.start >= byte_cutoff {
2918 return false;
2919 }
2920 if o.end > byte_cutoff {
2921 o.end = byte_cutoff;
2922 }
2923 true
2924 });
2925 }
2926
2927 let mut text = String::with_capacity(
2929 LEFT_BORDER_PREFIX.len() + child.text.len() + RIGHT_BORDER_SUFFIX.len() + 1,
2930 );
2931 text.push_str(LEFT_BORDER_PREFIX);
2932 text.push_str(&child.text);
2933 text.push_str(RIGHT_BORDER_SUFFIX);
2934 text.push('\n');
2935
2936 let overlays: Vec<InlineOverlay> = child
2938 .inline_overlays
2939 .into_iter()
2940 .map(|o| InlineOverlay {
2941 start: o.start + prefix_bytes,
2942 end: o.end + prefix_bytes,
2943 style: o.style,
2944 properties: o.properties,
2945 unit: o.unit,
2946 })
2947 .collect();
2948
2949 TextPropertyEntry {
2950 text,
2951 properties: child.properties,
2952 style: child.style,
2953 inline_overlays: overlays,
2954 segments: Vec::new(),
2955 pad_to_chars: None,
2956 truncate_to_chars: None,
2957 }
2958}
2959
2960pub fn render_hint_bar(entries: &[HintEntry]) -> TextPropertyEntry {
2970 let separator = " ";
2971 let mut text = String::new();
2972 let mut overlays = Vec::new();
2973 for (i, entry) in entries.iter().enumerate() {
2974 if i > 0 {
2975 text.push_str(separator);
2976 }
2977 let key_start = text.len();
2978 text.push_str(&entry.keys);
2979 let key_end = text.len();
2980 if key_end > key_start {
2981 overlays.push(InlineOverlay {
2982 start: key_start,
2983 end: key_end,
2984 style: OverlayOptions {
2985 fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
2986 bold: true,
2987 ..Default::default()
2988 },
2989 properties: Default::default(),
2990 unit: OffsetUnit::Byte,
2991 });
2992 }
2993 if !entry.label.is_empty() {
2994 text.push(' ');
2995 text.push_str(&entry.label);
2996 }
2997 }
2998 TextPropertyEntry {
2999 text,
3000 properties: Default::default(),
3001 style: None,
3002 inline_overlays: overlays,
3003 segments: Vec::new(),
3004 pad_to_chars: None,
3005 truncate_to_chars: None,
3006 }
3007}
3008
3009pub fn render_toggle(checked: bool, label: &str, focused: bool) -> TextPropertyEntry {
3018 let glyph = if checked { "[v]" } else { "[ ]" };
3019 let marker = focus_gutter_prefix(focused);
3025 let mut text = String::with_capacity(marker.len() + glyph.len() + 1 + label.len());
3026 text.push_str(marker);
3027 let glyph_start = text.len();
3028 text.push_str(glyph);
3029 text.push(' ');
3030 text.push_str(label);
3031
3032 let mut overlays = Vec::new();
3033
3034 if checked {
3037 overlays.push(InlineOverlay {
3038 start: glyph_start,
3039 end: glyph_start + glyph.len(),
3040 style: OverlayOptions {
3041 fg: Some(OverlayColorSpec::theme_key(KEY_TOGGLE_ON_FG)),
3042 bold: true,
3043 ..Default::default()
3044 },
3045 properties: Default::default(),
3046 unit: OffsetUnit::Byte,
3047 });
3048 }
3049
3050 if focused {
3052 overlays.push(InlineOverlay {
3053 start: 0,
3054 end: text.len(),
3055 style: OverlayOptions {
3056 fg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_FG)),
3057 bg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG)),
3058 bold: true,
3059 ..Default::default()
3060 },
3061 properties: Default::default(),
3062 unit: OffsetUnit::Byte,
3063 });
3064 }
3065
3066 TextPropertyEntry {
3067 text,
3068 properties: Default::default(),
3069 style: None,
3070 inline_overlays: overlays,
3071 segments: Vec::new(),
3072 pad_to_chars: None,
3073 truncate_to_chars: None,
3074 }
3075}
3076
3077pub fn render_button(
3088 label: &str,
3089 focused: bool,
3090 kind: ButtonKind,
3091 disabled: bool,
3092) -> TextPropertyEntry {
3093 let marker = focus_gutter_prefix(focused && !disabled);
3103 let text = format!("{}[ {} ]", marker, label);
3104 let mut overlays = Vec::new();
3105
3106 let base_style = if disabled {
3114 OverlayOptions {
3115 fg: Some(OverlayColorSpec::theme_key("ui.menu_disabled_fg")),
3116 ..Default::default()
3117 }
3118 } else {
3119 match kind {
3120 ButtonKind::Normal => OverlayOptions::default(),
3121 ButtonKind::Primary => OverlayOptions {
3126 fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
3127 bold: true,
3128 ..Default::default()
3129 },
3130 ButtonKind::Danger => OverlayOptions {
3133 fg: Some(OverlayColorSpec::theme_key(KEY_DANGER_FG)),
3134 bold: true,
3135 ..Default::default()
3136 },
3137 }
3138 };
3139
3140 let style = if focused && !disabled {
3141 OverlayOptions {
3142 fg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_FG)),
3143 bg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG)),
3144 bold: true,
3145 ..base_style
3146 }
3147 } else {
3148 base_style
3149 };
3150
3151 if style.fg.is_some()
3154 || style.bg.is_some()
3155 || style.bold
3156 || style.italic
3157 || style.underline
3158 || style.strikethrough
3159 {
3160 overlays.push(InlineOverlay {
3161 start: 0,
3162 end: text.len(),
3163 style,
3164 properties: Default::default(),
3165 unit: OffsetUnit::Byte,
3166 });
3167 }
3168
3169 TextPropertyEntry {
3170 text,
3171 properties: Default::default(),
3172 style: None,
3173 inline_overlays: overlays,
3174 segments: Vec::new(),
3175 pad_to_chars: None,
3176 truncate_to_chars: None,
3177 }
3178}
3179
3180pub struct RenderedTreeRow {
3184 pub entry: TextPropertyEntry,
3185 pub disclosure_range: Option<(usize, usize)>,
3188 pub checkbox_range: Option<(usize, usize)>,
3193}
3194
3195pub fn render_tree_row(node: &TreeNode, expanded: bool, checkable: bool) -> RenderedTreeRow {
3213 let indent_cols = (node.depth as usize) * 2;
3214 let disclosure_glyph: &str = if node.has_children {
3215 if expanded {
3216 "▼"
3217 } else {
3218 "▶"
3219 }
3220 } else {
3221 " "
3224 };
3225 let separator: &str = if node.has_children { " " } else { "" };
3230
3231 let checkbox_glyph: Option<&'static str> = if checkable {
3232 match node.checked {
3233 Some(true) => Some("[v]"),
3234 Some(false) => Some("[ ]"),
3235 None => None,
3236 }
3237 } else {
3238 None
3239 };
3240 let checkbox_extra = checkbox_glyph.map(|g| g.len() + 1).unwrap_or(0);
3241
3242 let mut text = String::with_capacity(
3243 indent_cols
3244 + disclosure_glyph.len()
3245 + separator.len()
3246 + checkbox_extra
3247 + node.text.text.len(),
3248 );
3249 for _ in 0..indent_cols {
3250 text.push(' ');
3251 }
3252 let disc_start = text.len();
3253 text.push_str(disclosure_glyph);
3254 let disc_end = text.len();
3255 text.push_str(separator);
3256 let checkbox_range = if let Some(g) = checkbox_glyph {
3257 let cb_start = text.len();
3258 text.push_str(g);
3259 let cb_end = text.len();
3260 text.push(' ');
3261 Some((cb_start, cb_end))
3262 } else {
3263 None
3264 };
3265 let body_start = text.len();
3266 text.push_str(&node.text.text);
3267
3268 let mut overlays: Vec<InlineOverlay> = node
3272 .text
3273 .inline_overlays
3274 .iter()
3275 .map(|o| {
3276 let mut shifted = o.clone();
3277 shifted.start += body_start;
3278 shifted.end += body_start;
3279 shifted
3280 })
3281 .collect();
3282
3283 if node.has_children {
3286 overlays.push(InlineOverlay {
3287 start: disc_start,
3288 end: disc_end,
3289 style: OverlayOptions {
3290 fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
3291 bold: true,
3292 ..Default::default()
3293 },
3294 properties: Default::default(),
3295 unit: OffsetUnit::Byte,
3296 });
3297 }
3298 if let Some((cb_start, cb_end)) = checkbox_range {
3301 let theme_key = match node.checked {
3302 Some(true) => KEY_TOGGLE_ON_FG,
3303 _ => KEY_PLACEHOLDER_FG,
3304 };
3305 overlays.push(InlineOverlay {
3306 start: cb_start,
3307 end: cb_end,
3308 style: OverlayOptions {
3309 fg: Some(OverlayColorSpec::theme_key(theme_key)),
3310 bold: matches!(node.checked, Some(true)),
3311 ..Default::default()
3312 },
3313 properties: Default::default(),
3314 unit: OffsetUnit::Byte,
3315 });
3316 }
3317
3318 let disclosure_range = if node.has_children {
3319 Some((disc_start, disc_end))
3320 } else {
3321 None
3322 };
3323 let entry = TextPropertyEntry {
3324 text,
3325 properties: node.text.properties.clone(),
3329 style: node.text.style.clone(),
3330 inline_overlays: overlays,
3331 segments: Vec::new(),
3336 pad_to_chars: None,
3337 truncate_to_chars: None,
3338 };
3339 RenderedTreeRow {
3340 entry,
3341 disclosure_range,
3342 checkbox_range,
3343 }
3344}
3345
3346pub struct RenderedTextInput {
3350 pub entry: TextPropertyEntry,
3351 pub cursor_byte_in_entry: Option<usize>,
3354}
3355
3356#[allow(clippy::too_many_arguments)]
3381pub fn render_text_input(
3382 value: &str,
3383 cursor_byte: i32,
3384 selection: Option<(usize, usize)>,
3385 focused: bool,
3386 label: &str,
3387 placeholder: Option<&str>,
3388 max_visible_chars: u32,
3389 field_width: u32,
3390 full_width: bool,
3391) -> RenderedTextInput {
3392 let show_placeholder = value.is_empty() && placeholder.is_some();
3399
3400 let raw_cursor_byte = if cursor_byte < 0 {
3404 value.len()
3405 } else {
3406 (cursor_byte as usize).min(value.len())
3407 };
3408
3409 let (inner, cursor_in_inner) = if show_placeholder && field_width == 0 {
3413 let inner = placeholder.unwrap_or("").to_string();
3417 let cursor = if focused { Some(0usize) } else { None };
3418 (inner, cursor)
3419 } else if show_placeholder {
3420 let target = field_width as usize;
3427 let pad_extra = if focused || full_width { 1 } else { 0 };
3428 let total_inner = target + pad_extra;
3429 let raw = placeholder.unwrap_or("");
3430 let raw_chars: Vec<char> = raw.chars().collect();
3431 let inner = if raw_chars.len() <= total_inner {
3432 let mut s = raw.to_string();
3433 while s.chars().count() < total_inner {
3434 s.push(' ');
3435 }
3436 s
3437 } else {
3438 let keep = total_inner.saturating_sub(1);
3441 let prefix: String = raw_chars.iter().take(keep).collect();
3442 format!("{}…", prefix)
3443 };
3444 let cursor = if focused { Some(0usize) } else { None };
3445 (inner, cursor)
3446 } else if field_width > 0 {
3447 let target = field_width as usize;
3453 let pad_extra = if focused || full_width { 1 } else { 0 };
3454 let total_inner = target + pad_extra;
3455 let value_chars: Vec<char> = value.chars().collect();
3456 if value_chars.len() <= target {
3457 let mut padded = value.to_string();
3461 while padded.chars().count() < total_inner {
3462 padded.push(' ');
3463 }
3464 (padded, Some(raw_cursor_byte))
3465 } else {
3466 let keep = target - 1;
3470 let drop_chars = value_chars.len() - keep;
3471 let mut dropped_bytes = 0usize;
3472 for ch in value_chars.iter().take(drop_chars) {
3473 dropped_bytes += ch.len_utf8();
3474 }
3475 let tail = &value[dropped_bytes..];
3476 let mut s = String::with_capacity("…".len() + tail.len() + pad_extra);
3477 s.push('…');
3478 s.push_str(tail);
3479 for _ in 0..pad_extra {
3480 s.push(' ');
3481 }
3482 let cursor_in_inner = if raw_cursor_byte < dropped_bytes {
3486 "…".len()
3487 } else {
3488 "…".len() + (raw_cursor_byte - dropped_bytes)
3489 };
3490 (s, Some(cursor_in_inner))
3491 }
3492 } else if max_visible_chars > 0 && value.chars().count() > max_visible_chars as usize {
3493 let chars: Vec<char> = value.chars().collect();
3497 let take = (max_visible_chars as usize).saturating_sub(1);
3498 let start = chars.len().saturating_sub(take);
3499 let tail: String = chars[start..].iter().collect();
3500 let s = format!("…{}", tail);
3501 (s, Some(raw_cursor_byte.min(value.len())))
3502 } else {
3503 let mut s = value.to_string();
3509 if focused {
3510 s.push(' ');
3511 }
3512 (s, Some(raw_cursor_byte))
3513 };
3514
3515 let mut text = String::new();
3517 if !label.is_empty() {
3518 text.push_str(label);
3519 text.push(' ');
3520 }
3521 let bracket_open_byte = text.len();
3522 text.push('[');
3523 let inner_byte_start = text.len();
3524 text.push_str(&inner);
3525 let inner_byte_end = text.len();
3526 text.push(']');
3527 let bracket_close_byte = text.len();
3528
3529 let mut overlays = Vec::new();
3530
3531 if show_placeholder {
3532 overlays.push(InlineOverlay {
3533 start: inner_byte_start,
3534 end: inner_byte_end,
3535 style: OverlayOptions {
3536 fg: Some(OverlayColorSpec::theme_key(KEY_PLACEHOLDER_FG)),
3537 italic: true,
3538 ..Default::default()
3539 },
3540 properties: Default::default(),
3541 unit: OffsetUnit::Byte,
3542 });
3543 }
3544
3545 if focused {
3546 overlays.push(InlineOverlay {
3547 start: bracket_open_byte,
3548 end: bracket_close_byte,
3549 style: OverlayOptions {
3550 bg: Some(OverlayColorSpec::theme_key(KEY_INPUT_BG)),
3551 ..Default::default()
3552 },
3553 properties: Default::default(),
3554 unit: OffsetUnit::Byte,
3555 });
3556 }
3557
3558 let inner_is_truncated = inner.starts_with('…');
3567 if focused && !inner_is_truncated {
3568 if let Some((sel_start, sel_end)) = selection {
3569 let visible_value_len = value.len();
3573 let s = sel_start.min(sel_end).min(visible_value_len);
3574 let e = sel_start.max(sel_end).min(visible_value_len);
3575 if e > s {
3576 overlays.push(InlineOverlay {
3577 start: inner_byte_start + s,
3578 end: inner_byte_start + e,
3579 style: OverlayOptions {
3580 bg: Some(OverlayColorSpec::theme_key(KEY_TEXT_INPUT_SELECTION_BG)),
3581 ..Default::default()
3582 },
3583 properties: Default::default(),
3584 unit: OffsetUnit::Byte,
3585 });
3586 }
3587 }
3588 }
3589
3590 let cursor_byte_in_entry = if focused {
3591 cursor_in_inner.map(|c| inner_byte_start + c)
3592 } else {
3593 None
3594 };
3595
3596 RenderedTextInput {
3597 entry: TextPropertyEntry {
3598 text,
3599 properties: Default::default(),
3600 style: None,
3601 inline_overlays: overlays,
3602 segments: Vec::new(),
3603 pad_to_chars: None,
3604 truncate_to_chars: None,
3605 },
3606 cursor_byte_in_entry,
3607 }
3608}
3609
3610pub struct RenderedTextArea {
3613 pub entries: Vec<TextPropertyEntry>,
3618 pub scroll_row: u32,
3622 pub cursor_buffer_row: Option<u32>,
3626 pub cursor_byte_in_row: Option<usize>,
3629}
3630
3631#[allow(clippy::too_many_arguments)]
3658pub fn render_text_area(
3659 value: &str,
3660 cursor_byte: i32,
3661 selection: Option<(usize, usize)>,
3662 focused: bool,
3663 label: &str,
3664 placeholder: Option<&str>,
3665 visible_rows: u32,
3666 field_width: u32,
3667 prev_scroll: u32,
3668 panel_width: u32,
3669) -> RenderedTextArea {
3670 let target_width: usize = if field_width > 0 {
3673 field_width as usize
3674 } else if panel_width != u32::MAX && panel_width > 0 {
3675 panel_width as usize
3676 } else {
3677 40
3678 };
3679
3680 let mut lines: Vec<&str> = value.split('\n').collect();
3684 if lines.is_empty() {
3685 lines.push("");
3686 }
3687
3688 let raw_cursor_byte = if cursor_byte < 0 {
3692 value.len()
3693 } else {
3694 (cursor_byte as usize).min(value.len())
3695 };
3696 let (cursor_line, cursor_col) = byte_to_line_col(value, raw_cursor_byte);
3697
3698 let selection_lc: Option<((usize, usize), (usize, usize))> = selection.and_then(|(a, b)| {
3703 let lo = a.min(b);
3704 let hi = a.max(b);
3705 if hi <= lo || hi > value.len() {
3706 return None;
3707 }
3708 Some((byte_to_line_col(value, lo), byte_to_line_col(value, hi)))
3709 });
3710
3711 let visible_rows_usize = visible_rows.max(1) as usize;
3714 let mut scroll_row = prev_scroll as usize;
3715 if cursor_line < scroll_row {
3716 scroll_row = cursor_line;
3717 } else if cursor_line >= scroll_row + visible_rows_usize {
3718 scroll_row = cursor_line + 1 - visible_rows_usize;
3719 }
3720 let max_scroll = lines.len().saturating_sub(visible_rows_usize);
3722 if scroll_row > max_scroll {
3723 scroll_row = max_scroll;
3724 }
3725
3726 let show_placeholder =
3727 !focused && value.is_empty() && placeholder.is_some() && !placeholder.unwrap().is_empty();
3728
3729 let mut entries: Vec<TextPropertyEntry> = Vec::new();
3730 let mut cursor_buffer_row: Option<u32> = None;
3731 let mut cursor_byte_in_row: Option<usize> = None;
3732
3733 if !label.is_empty() {
3734 let mut text = String::with_capacity(label.len() + 2);
3735 text.push_str(label);
3736 text.push(':');
3737 entries.push(TextPropertyEntry {
3738 text,
3739 properties: Default::default(),
3740 style: None,
3741 inline_overlays: Vec::new(),
3742 segments: Vec::new(),
3743 pad_to_chars: None,
3744 truncate_to_chars: None,
3745 });
3746 }
3747 let label_offset: u32 = entries.len() as u32;
3748
3749 for row_in_view in 0..visible_rows_usize {
3750 let line_idx = scroll_row + row_in_view;
3751 let mut row_text;
3752 let mut overlays: Vec<InlineOverlay> = Vec::new();
3753
3754 if line_idx < lines.len() {
3755 row_text = pad_or_truncate_line(lines[line_idx], target_width);
3756 } else {
3757 row_text = " ".repeat(target_width);
3758 }
3759
3760 if show_placeholder && row_in_view == 0 {
3762 let ph = placeholder.unwrap();
3763 row_text = pad_or_truncate_line(ph, target_width);
3764 overlays.push(InlineOverlay {
3765 start: 0,
3766 end: row_text.len(),
3767 style: OverlayOptions {
3768 fg: Some(OverlayColorSpec::theme_key(KEY_PLACEHOLDER_FG)),
3769 ..Default::default()
3770 },
3771 properties: Default::default(),
3772 unit: OffsetUnit::Byte,
3773 });
3774 }
3775
3776 if focused {
3779 overlays.push(InlineOverlay {
3780 start: 0,
3781 end: row_text.len(),
3782 style: OverlayOptions {
3783 bg: Some(OverlayColorSpec::theme_key(KEY_INPUT_BG)),
3784 ..Default::default()
3785 },
3786 properties: Default::default(),
3787 unit: OffsetUnit::Byte,
3788 });
3789 }
3790
3791 if focused {
3795 if let Some(((sl, sc), (el, ec))) = selection_lc {
3796 if line_idx >= sl && line_idx <= el {
3797 let line_text_len = if line_idx < lines.len() {
3798 lines[line_idx].len()
3799 } else {
3800 0
3801 };
3802 let row_start = if line_idx == sl { sc } else { 0 };
3803 let row_end = if line_idx == el { ec } else { line_text_len };
3804 let s = row_start.min(line_text_len);
3805 let e = row_end.min(line_text_len);
3806 if e > s {
3807 overlays.push(InlineOverlay {
3808 start: s,
3809 end: e,
3810 style: OverlayOptions {
3811 bg: Some(OverlayColorSpec::theme_key(KEY_TEXT_INPUT_SELECTION_BG)),
3812 ..Default::default()
3813 },
3814 properties: Default::default(),
3815 unit: OffsetUnit::Byte,
3816 });
3817 }
3818 }
3819 }
3820 }
3821
3822 if focused && line_idx == cursor_line && cursor_byte >= 0 {
3824 let col_in_line = cursor_col.min(row_text.len());
3829 cursor_buffer_row = Some(label_offset + row_in_view as u32);
3830 cursor_byte_in_row = Some(col_in_line);
3831 }
3832
3833 entries.push(TextPropertyEntry {
3834 text: row_text,
3835 properties: Default::default(),
3836 style: None,
3837 inline_overlays: overlays,
3838 segments: Vec::new(),
3839 pad_to_chars: None,
3840 truncate_to_chars: None,
3841 });
3842 }
3843
3844 RenderedTextArea {
3845 entries,
3846 scroll_row: scroll_row as u32,
3847 cursor_buffer_row,
3848 cursor_byte_in_row,
3849 }
3850}
3851
3852fn byte_to_line_col(value: &str, byte: usize) -> (usize, usize) {
3854 let byte = byte.min(value.len());
3855 let mut line = 0usize;
3856 let mut line_start = 0usize;
3857 for (i, &b) in value.as_bytes().iter().enumerate().take(byte) {
3858 if b == b'\n' {
3859 line += 1;
3860 line_start = i + 1;
3861 }
3862 }
3863 (line, byte - line_start)
3864}
3865
3866fn pad_or_truncate_line(line: &str, target: usize) -> String {
3872 let chars: Vec<char> = line.chars().collect();
3873 if chars.len() <= target {
3874 let mut out = line.to_string();
3875 let pad = target - chars.len();
3876 for _ in 0..pad {
3877 out.push(' ');
3878 }
3879 out
3880 } else {
3881 let keep = target.saturating_sub(1);
3882 let mut out: String = chars.iter().take(keep).collect();
3883 out.push('…');
3884 out
3885 }
3886}
3887
3888fn assemble_wrapped_row(
3896 pieces: Vec<RowPiece>,
3897 panel_width: u32,
3898 entries: &mut Vec<TextPropertyEntry>,
3899 hits: &mut Vec<HitArea>,
3900) {
3901 use crate::primitives::display_width::str_width;
3902 let max_w = panel_width as usize;
3903 let mut acc: Option<TextPropertyEntry> = None;
3904 let mut row: u32 = 0;
3905 let flush = |acc: &mut Option<TextPropertyEntry>, entries: &mut Vec<TextPropertyEntry>| {
3908 if let Some(mut merged) = acc.take() {
3909 ensure_trailing_newline(&mut merged);
3910 entries.push(merged);
3911 }
3912 };
3913 for piece in pieces {
3914 let RowPiece::Inline {
3915 mut entry,
3916 hits: child_hits,
3917 ..
3918 } = piece
3919 else {
3920 continue;
3922 };
3923 let is_blank = entry.text.trim().is_empty();
3924 let piece_w = str_width(&entry.text);
3925 let acc_w = acc.as_ref().map(|e| str_width(&e.text)).unwrap_or(0);
3926 if acc.is_some() && acc_w + piece_w > max_w {
3928 flush(&mut acc, entries);
3929 row += 1;
3930 }
3931 if acc.is_none() && is_blank {
3933 continue;
3934 }
3935 let shift = acc.as_ref().map(|e| e.text.len()).unwrap_or(0);
3936 for mut h in child_hits {
3937 h.byte_start += shift;
3938 h.byte_end += shift;
3939 h.buffer_row = row;
3940 hits.push(h);
3941 }
3942 match acc.as_mut() {
3943 Some(merged) => merge_inline(merged, &mut entry),
3944 None => acc = Some(entry),
3945 }
3946 }
3947 flush(&mut acc, entries);
3948}
3949
3950fn merge_inline(merged: &mut TextPropertyEntry, next: &mut TextPropertyEntry) {
3954 let shift = merged.text.len();
3955 merged.text.push_str(&next.text);
3956 for overlay in next.inline_overlays.drain(..) {
3957 merged.inline_overlays.push(InlineOverlay {
3958 start: overlay.start + shift,
3959 end: overlay.end + shift,
3960 style: overlay.style,
3961 properties: overlay.properties,
3962 unit: overlay.unit,
3963 });
3964 }
3965 }
3971
3972fn pad_or_truncate_cols(text: &mut String, cols: usize) {
3983 let cur = text.chars().count();
3984 if cur < cols {
3985 for _ in 0..(cols - cur) {
3986 text.push(' ');
3987 }
3988 } else if cur > cols {
3989 let cutoff = text
3992 .char_indices()
3993 .nth(cols)
3994 .map(|(i, _)| i)
3995 .unwrap_or(text.len());
3996 text.truncate(cutoff);
3997 if cols >= 2 {
3998 text.pop();
4001 text.push('…');
4002 }
4003 }
4004}
4005
4006fn snap_down_to_char_boundary(s: &str, idx: usize) -> usize {
4012 let mut i = idx.min(s.len());
4013 while i > 0 && !s.is_char_boundary(i) {
4014 i -= 1;
4015 }
4016 i
4017}
4018
4019fn zip_row_blocks(
4042 pieces: Vec<RowPiece>,
4043 panel_width: u32,
4044 out_entries: &mut Vec<TextPropertyEntry>,
4045 out_hits: &mut Vec<HitArea>,
4046 out_focus_cursor: &mut Option<FocusCursor>,
4047 out_embeds: &mut Vec<EmbedRect>,
4048 out_scroll: &mut Vec<ScrollRegion>,
4049) {
4050 let starting_row = out_entries.len() as u32;
4051 let _ = panel_width;
4052
4053 let max_height = pieces
4055 .iter()
4056 .filter_map(|p| match p {
4057 RowPiece::Block { entries, .. } => Some(entries.len()),
4058 _ => None,
4059 })
4060 .max()
4061 .unwrap_or(0);
4062 if max_height == 0 {
4063 return;
4064 }
4065
4066 for row_idx in 0..max_height {
4067 let mut text = String::new();
4068 let mut overlays: Vec<InlineOverlay> = Vec::new();
4069 for piece in &pieces {
4070 match piece {
4071 RowPiece::Inline {
4072 entry,
4073 hits,
4074 focus_cursor,
4075 embeds: inline_embeds,
4076 scroll_regions: inline_scroll,
4077 } => {
4078 let inline_cols = entry.text.chars().count();
4079 let byte_shift = text.len();
4080 let col_shift = text.chars().count() as u32;
4084 if row_idx == 0 {
4085 text.push_str(&entry.text);
4086 for emb in inline_embeds {
4087 out_embeds.push(EmbedRect {
4088 window_id: emb.window_id,
4089 buffer_row: starting_row + emb.buffer_row,
4090 col_in_row: emb.col_in_row + col_shift,
4091 width_cols: emb.width_cols,
4092 height_rows: emb.height_rows,
4093 });
4094 }
4095 for sr in inline_scroll {
4096 let mut sr = sr.clone();
4097 sr.buffer_row += starting_row;
4098 sr.col_in_row += col_shift;
4099 out_scroll.push(sr);
4100 }
4101 for overlay in &entry.inline_overlays {
4102 overlays.push(InlineOverlay {
4103 start: overlay.start + byte_shift,
4104 end: overlay.end + byte_shift,
4105 style: overlay.style.clone(),
4106 properties: overlay.properties.clone(),
4107 unit: overlay.unit,
4108 });
4109 }
4110 for h in hits {
4111 let mut h = h.clone();
4112 h.byte_start += byte_shift;
4113 h.byte_end += byte_shift;
4114 h.buffer_row = starting_row;
4115 out_hits.push(h);
4116 }
4117 if let Some(fc) = focus_cursor {
4118 *out_focus_cursor = Some(FocusCursor {
4119 buffer_row: starting_row,
4120 byte_in_row: fc.byte_in_row + byte_shift as u32,
4121 });
4122 }
4123 } else {
4124 for _ in 0..inline_cols {
4125 text.push(' ');
4126 }
4127 }
4128 }
4129 RowPiece::Flex => {
4130 }
4132 RowPiece::Block {
4133 column_width,
4134 entries,
4135 hits,
4136 focus_cursor,
4137 embeds: block_embeds,
4138 scroll_regions: block_scroll,
4139 } => {
4140 let block_w = *column_width as usize;
4141 let byte_shift = text.len();
4142 let col_shift = text.chars().count() as u32;
4145 if row_idx == 0 {
4150 for emb in block_embeds {
4151 out_embeds.push(EmbedRect {
4152 window_id: emb.window_id,
4153 buffer_row: starting_row + emb.buffer_row,
4154 col_in_row: emb.col_in_row + col_shift,
4155 width_cols: emb.width_cols,
4156 height_rows: emb.height_rows,
4157 });
4158 }
4159 for sr in block_scroll {
4160 let mut sr = sr.clone();
4161 sr.buffer_row += starting_row;
4162 sr.col_in_row += col_shift;
4163 out_scroll.push(sr);
4164 }
4165 }
4166 if let Some(line) = entries.get(row_idx) {
4167 let mut line_text = line.text.clone();
4168 if line_text.ends_with('\n') {
4171 line_text.pop();
4172 }
4173 pad_or_truncate_cols(&mut line_text, block_w);
4174 let padded_byte_len = line_text.len();
4175 text.push_str(&line_text);
4176 if let Some(line_style) = &line.style {
4186 overlays.push(InlineOverlay {
4187 start: byte_shift,
4188 end: byte_shift + padded_byte_len,
4189 style: line_style.clone(),
4190 properties: Default::default(),
4191 unit: OffsetUnit::Byte,
4192 });
4193 }
4194 for overlay in &line.inline_overlays {
4195 let start = snap_down_to_char_boundary(&line_text, overlay.start);
4204 let end = snap_down_to_char_boundary(&line_text, overlay.end);
4205 if start >= end {
4206 continue;
4207 }
4208 overlays.push(InlineOverlay {
4209 start: start + byte_shift,
4210 end: end + byte_shift,
4211 style: overlay.style.clone(),
4212 properties: overlay.properties.clone(),
4213 unit: overlay.unit,
4214 });
4215 }
4216 for h in hits {
4217 if h.buffer_row != row_idx as u32 {
4218 continue;
4219 }
4220 let mut h = h.clone();
4221 h.byte_start += byte_shift;
4222 h.byte_end += byte_shift;
4223 h.buffer_row = starting_row + row_idx as u32;
4224 out_hits.push(h);
4225 }
4226 if let Some(fc) = focus_cursor {
4227 if fc.buffer_row == row_idx as u32 {
4228 *out_focus_cursor = Some(FocusCursor {
4229 buffer_row: starting_row + row_idx as u32,
4230 byte_in_row: fc.byte_in_row + byte_shift as u32,
4231 });
4232 }
4233 }
4234 } else {
4235 for _ in 0..block_w {
4238 text.push(' ');
4239 }
4240 }
4241 }
4242 }
4243 }
4244 text.push('\n');
4245 out_entries.push(TextPropertyEntry {
4246 text,
4247 properties: Default::default(),
4248 style: None,
4249 inline_overlays: overlays,
4250 segments: Vec::new(),
4251 pad_to_chars: None,
4252 truncate_to_chars: None,
4253 });
4254 }
4255}
4256
4257#[cfg(test)]
4258mod tests {
4259 use super::*;
4260
4261 fn render_no_focus(
4266 spec: &WidgetSpec,
4267 prev: &HashMap<String, WidgetInstanceState>,
4268 ) -> (
4269 Vec<TextPropertyEntry>,
4270 Vec<HitArea>,
4271 HashMap<String, WidgetInstanceState>,
4272 ) {
4273 let out = render_spec(spec, prev, "", u32::MAX);
4275 (out.entries, out.hits, out.instance_states)
4276 }
4277
4278 #[test]
4279 fn hint_bar_renders_entries_with_key_overlays() {
4280 let entries = vec![
4281 HintEntry {
4282 keys: "Tab".into(),
4283 label: "next".into(),
4284 },
4285 HintEntry {
4286 keys: "Esc".into(),
4287 label: "close".into(),
4288 },
4289 ];
4290 let entry = render_hint_bar(&entries);
4291 assert_eq!(entry.text, "Tab next Esc close");
4292 assert_eq!(entry.inline_overlays.len(), 2);
4293 assert_eq!(entry.inline_overlays[0].start, 0);
4295 assert_eq!(entry.inline_overlays[0].end, 3);
4296 assert_eq!(entry.inline_overlays[1].start, 10);
4298 assert_eq!(entry.inline_overlays[1].end, 13);
4299 }
4300
4301 #[test]
4302 fn hint_bar_omits_label_when_empty() {
4303 let entries = vec![HintEntry {
4304 keys: "?".into(),
4305 label: "".into(),
4306 }];
4307 let entry = render_hint_bar(&entries);
4308 assert_eq!(entry.text, "?");
4309 }
4310
4311 #[test]
4312 fn col_stacks_children_top_to_bottom() {
4313 let spec = WidgetSpec::Col {
4314 children: vec![
4315 WidgetSpec::HintBar {
4316 entries: vec![HintEntry {
4317 keys: "A".into(),
4318 label: "alpha".into(),
4319 }],
4320 key: None,
4321 },
4322 WidgetSpec::HintBar {
4323 entries: vec![HintEntry {
4324 keys: "B".into(),
4325 label: "beta".into(),
4326 }],
4327 key: None,
4328 },
4329 ],
4330 key: None,
4331 };
4332 let (out, hits, _state) = render_no_focus(&spec, &HashMap::new());
4333 assert_eq!(out.len(), 2);
4334 assert_eq!(out[0].text, "A alpha\n");
4335 assert_eq!(out[1].text, "B beta\n");
4336 assert!(hits.is_empty(), "HintBar emits no hit areas in v1");
4337 }
4338
4339 #[test]
4340 fn raw_passes_through_unchanged() {
4341 let spec = WidgetSpec::Raw {
4342 entries: vec![TextPropertyEntry::text("hello")],
4343 key: None,
4344 };
4345 let (out, hits, _state) = render_no_focus(&spec, &HashMap::new());
4346 assert_eq!(out.len(), 1);
4347 assert_eq!(out[0].text, "hello\n");
4348 assert!(hits.is_empty());
4349 }
4350
4351 #[test]
4352 fn toggle_checked_emits_glyph_overlay() {
4353 let entry = render_toggle(true, "Case", false);
4354 assert_eq!(entry.text, "[v] Case");
4355 assert_eq!(entry.inline_overlays.len(), 1);
4357 assert_eq!(entry.inline_overlays[0].start, 0);
4358 assert_eq!(entry.inline_overlays[0].end, 3);
4359 }
4360
4361 #[test]
4362 fn toggle_unchecked_no_glyph_overlay() {
4363 let entry = render_toggle(false, "Case", false);
4364 assert_eq!(entry.text, "[ ] Case");
4365 assert_eq!(entry.inline_overlays.len(), 0);
4366 }
4367
4368 #[test]
4369 fn toggle_focused_adds_full_entry_overlay() {
4370 let entry = render_toggle(true, "Case", true);
4371 assert_eq!(entry.inline_overlays.len(), 2);
4373 assert_eq!(entry.inline_overlays[1].start, 0);
4375 assert_eq!(entry.inline_overlays[1].end, entry.text.len());
4376 assert!(entry.inline_overlays[1].style.bold);
4377 }
4378
4379 #[test]
4380 fn button_normal_unfocused_has_no_overlay() {
4381 let entry = render_button("Replace All", false, ButtonKind::Normal, false);
4382 assert_eq!(entry.text, "[ Replace All ]");
4383 assert!(entry.inline_overlays.is_empty());
4384 }
4385
4386 #[test]
4387 fn button_primary_unfocused_is_bold_help_key_fg_with_no_bg() {
4388 let entry = render_button("Submit", false, ButtonKind::Primary, false);
4393 assert_eq!(entry.inline_overlays.len(), 1);
4394 let style = &entry.inline_overlays[0].style;
4395 assert!(style.bold);
4396 assert_eq!(
4397 style.fg.as_ref().and_then(|c| c.as_theme_key()),
4398 Some("ui.help_key_fg"),
4399 );
4400 assert!(style.bg.is_none(), "unfocused primary must not paint a bg");
4401 }
4402
4403 #[test]
4404 fn button_danger_uses_error_theme_key() {
4405 let entry = render_button("Delete", false, ButtonKind::Danger, false);
4406 assert_eq!(entry.inline_overlays.len(), 1);
4407 let fg = entry.inline_overlays[0].style.fg.as_ref().unwrap();
4408 assert_eq!(fg.as_theme_key(), Some("diagnostic.error_fg"));
4409 assert!(entry.inline_overlays[0].style.bold);
4410 }
4411
4412 #[test]
4413 fn button_focused_overrides_with_popup_selection_keys() {
4414 let entry = render_button("OK", true, ButtonKind::Normal, false);
4421 let style = &entry.inline_overlays[0].style;
4422 assert_eq!(
4423 style.fg.as_ref().and_then(|c| c.as_theme_key()),
4424 Some("ui.popup_selection_fg")
4425 );
4426 assert_eq!(
4427 style.bg.as_ref().and_then(|c| c.as_theme_key()),
4428 Some("ui.popup_selection_bg")
4429 );
4430 assert!(style.bold);
4431 }
4432
4433 #[test]
4434 fn flex_spacer_fills_remaining_row_width() {
4435 let spec = WidgetSpec::Row {
4436 wrap: false,
4437 children: vec![
4438 WidgetSpec::Toggle {
4439 checked: false,
4440 label: "A".into(),
4441 focused: false,
4442 key: None,
4443 },
4444 WidgetSpec::Spacer {
4445 cols: 0,
4446 flex: true,
4447 key: None,
4448 },
4449 WidgetSpec::Button {
4450 label: "B".into(),
4451 focused: false,
4452 intent: ButtonKind::Normal,
4453 key: None,
4454 disabled: false,
4455 focusable: true,
4456 },
4457 ],
4458 key: None,
4459 };
4460 let out = render_spec(&spec, &HashMap::new(), "", 30);
4464 assert_eq!(out.entries.len(), 1);
4465 let text = &out.entries[0].text;
4466 assert_eq!(text.len(), 31);
4467 assert!(text.starts_with("[ ] A"));
4468 assert!(text.ends_with("[ B ]\n"));
4469 let button_hit = out.hits.iter().find(|h| h.widget_kind == "button").unwrap();
4470 assert_eq!(button_hit.byte_start, 25);
4471 assert_eq!(button_hit.byte_end, 30);
4472 }
4473
4474 #[test]
4475 fn flex_spacer_with_no_leftover_collapses_to_zero() {
4476 let spec = WidgetSpec::Row {
4477 wrap: false,
4478 children: vec![
4479 WidgetSpec::Toggle {
4480 checked: false,
4481 label: "A".into(),
4482 focused: false,
4483 key: None,
4484 },
4485 WidgetSpec::Spacer {
4486 cols: 0,
4487 flex: true,
4488 key: None,
4489 },
4490 WidgetSpec::Toggle {
4491 checked: false,
4492 label: "B".into(),
4493 focused: false,
4494 key: None,
4495 },
4496 ],
4497 key: None,
4498 };
4499 let out = render_spec(&spec, &HashMap::new(), "", 10);
4501 assert_eq!(out.entries[0].text, "[ ] A[ ] B\n");
4502 }
4503
4504 #[test]
4505 fn spacer_in_row_pads_with_spaces() {
4506 let spec = WidgetSpec::Row {
4507 wrap: false,
4508 children: vec![
4509 WidgetSpec::Toggle {
4510 checked: false,
4511 label: "A".into(),
4512 focused: false,
4513 key: None,
4514 },
4515 WidgetSpec::Spacer {
4516 cols: 4,
4517 flex: false,
4518 key: None,
4519 },
4520 WidgetSpec::Button {
4521 label: "Go".into(),
4522 focused: false,
4523 intent: ButtonKind::Normal,
4524 key: None,
4525 disabled: false,
4526 focusable: true,
4527 },
4528 ],
4529 key: None,
4530 };
4531 let (out, _hits, _state) = render_no_focus(&spec, &HashMap::new());
4532 assert_eq!(out.len(), 1);
4533 assert_eq!(out[0].text, "[ ] A [ Go ]\n");
4534 }
4535
4536 #[test]
4537 fn row_collapses_inline_children_with_shifted_overlays() {
4538 let spec = WidgetSpec::Row {
4539 wrap: false,
4540 children: vec![
4541 WidgetSpec::HintBar {
4542 entries: vec![HintEntry {
4543 keys: "Tab".into(),
4544 label: "x".into(),
4545 }],
4546 key: None,
4547 },
4548 WidgetSpec::HintBar {
4549 entries: vec![HintEntry {
4550 keys: "Esc".into(),
4551 label: "y".into(),
4552 }],
4553 key: None,
4554 },
4555 ],
4556 key: None,
4557 };
4558 let (out, _hits, _state) = render_no_focus(&spec, &HashMap::new());
4559 assert_eq!(out.len(), 1);
4560 assert_eq!(out[0].text, "Tab xEsc y\n");
4562 assert_eq!(out[0].inline_overlays.len(), 2);
4563 assert_eq!(out[0].inline_overlays[1].start, 5);
4564 assert_eq!(out[0].inline_overlays[1].end, 8);
4565 }
4566
4567 #[test]
4572 fn toggle_emits_hit_area_with_toggle_payload() {
4573 let spec = WidgetSpec::Toggle {
4574 checked: false,
4575 label: "Case".into(),
4576 focused: false,
4577 key: Some("case".into()),
4578 };
4579 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4580 assert_eq!(hits.len(), 1);
4581 let h = &hits[0];
4582 assert_eq!(h.widget_key, "case");
4583 assert_eq!(h.widget_kind, "toggle");
4584 assert_eq!(h.event_type, "toggle");
4585 assert_eq!(h.buffer_row, 0);
4586 assert_eq!(h.byte_start, 0);
4587 assert_eq!(h.byte_end, "[ ] Case".len());
4588 assert_eq!(h.payload, json!({"checked": true}));
4589 }
4590
4591 #[test]
4592 fn button_emits_hit_area_with_activate_payload() {
4593 let spec = WidgetSpec::Button {
4594 label: "Replace All".into(),
4595 focused: false,
4596 intent: ButtonKind::Primary,
4597 key: Some("replace".into()),
4598 disabled: false,
4599 focusable: true,
4600 };
4601 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4602 assert_eq!(hits.len(), 1);
4603 let h = &hits[0];
4604 assert_eq!(h.widget_key, "replace");
4605 assert_eq!(h.widget_kind, "button");
4606 assert_eq!(h.event_type, "activate");
4607 assert_eq!(h.byte_end, "[ Replace All ]".len());
4608 assert_eq!(h.payload, json!({}));
4609 }
4610
4611 #[test]
4612 fn disabled_button_omits_hit_area_and_skips_tabbable() {
4613 let spec = WidgetSpec::Row {
4614 wrap: false,
4615 children: vec![
4616 WidgetSpec::Button {
4617 label: "Archive".into(),
4618 focused: false,
4619 intent: ButtonKind::Normal,
4620 key: Some("archive".into()),
4621 disabled: true,
4622 focusable: true,
4623 },
4624 WidgetSpec::Button {
4625 label: "Cancel".into(),
4626 focused: false,
4627 intent: ButtonKind::Normal,
4628 key: Some("cancel".into()),
4629 disabled: false,
4630 focusable: true,
4631 },
4632 ],
4633 key: None,
4634 };
4635 let out = render_spec(&spec, &HashMap::new(), "", 30);
4636 assert_eq!(
4637 out.hits
4638 .iter()
4639 .filter(|h| h.widget_kind == "button")
4640 .count(),
4641 1,
4642 "disabled button should not emit a hit area"
4643 );
4644 assert_eq!(
4645 out.tabbable,
4646 vec!["cancel".to_string()],
4647 "disabled button must drop out of the Tab cycle"
4648 );
4649 }
4650
4651 #[test]
4652 fn disabled_button_uses_menu_disabled_fg_overlay() {
4653 let entry = render_button("Archive", false, ButtonKind::Danger, true);
4654 assert_eq!(entry.inline_overlays.len(), 1);
4655 let style = &entry.inline_overlays[0].style;
4656 assert_eq!(
4657 style.fg.as_ref().and_then(|c| c.as_theme_key()),
4658 Some("ui.menu_disabled_fg"),
4659 "disabled overrides Danger fg with the muted theme key"
4660 );
4661 assert!(
4662 !style.bold,
4663 "disabled buttons drop the intent's bold emphasis"
4664 );
4665 assert!(style.bg.is_none(), "disabled buttons paint no bg");
4666 }
4667
4668 #[test]
4669 fn row_inline_collapse_shifts_hit_byte_offsets() {
4670 let spec = WidgetSpec::Row {
4671 wrap: false,
4672 children: vec![
4673 WidgetSpec::Toggle {
4674 checked: true,
4675 label: "A".into(),
4676 focused: false,
4677 key: Some("a".into()),
4678 },
4679 WidgetSpec::Spacer {
4680 cols: 2,
4681 flex: false,
4682 key: None,
4683 },
4684 WidgetSpec::Toggle {
4685 checked: false,
4686 label: "B".into(),
4687 focused: false,
4688 key: Some("b".into()),
4689 },
4690 ],
4691 key: None,
4692 };
4693 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4694 assert_eq!(entries.len(), 1);
4696 assert_eq!(entries[0].text, "[v] A [ ] B\n");
4697 assert_eq!(hits.len(), 2);
4698 assert_eq!(hits[0].widget_key, "a");
4699 assert_eq!(hits[0].buffer_row, 0);
4700 assert_eq!(hits[0].byte_start, 0);
4701 assert_eq!(hits[0].byte_end, 5); assert_eq!(hits[1].widget_key, "b");
4705 assert_eq!(hits[1].buffer_row, 0);
4706 assert_eq!(hits[1].byte_start, 7);
4707 assert_eq!(hits[1].byte_end, 12);
4708 }
4709
4710 #[test]
4711 fn col_stacks_hit_rows() {
4712 let spec = WidgetSpec::Col {
4713 children: vec![
4714 WidgetSpec::Toggle {
4715 checked: false,
4716 label: "row0".into(),
4717 focused: false,
4718 key: Some("k0".into()),
4719 },
4720 WidgetSpec::Toggle {
4721 checked: true,
4722 label: "row1".into(),
4723 focused: false,
4724 key: Some("k1".into()),
4725 },
4726 ],
4727 key: None,
4728 };
4729 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4730 assert_eq!(hits.len(), 2);
4731 assert_eq!(hits[0].buffer_row, 0);
4732 assert_eq!(hits[1].buffer_row, 1);
4733 }
4734
4735 #[test]
4740 fn collect_tabbable_visits_widgets_with_keys_in_declaration_order() {
4741 let spec = WidgetSpec::Col {
4742 children: vec![
4743 WidgetSpec::HintBar {
4744 entries: vec![],
4745 key: Some("hb".into()),
4746 },
4747 WidgetSpec::Row {
4748 wrap: false,
4749 children: vec![
4750 WidgetSpec::Toggle {
4751 checked: false,
4752 label: "T".into(),
4753 focused: false,
4754 key: Some("t".into()),
4755 },
4756 WidgetSpec::Spacer {
4757 cols: 1,
4758 flex: false,
4759 key: None,
4760 },
4761 WidgetSpec::Button {
4762 label: "B".into(),
4763 focused: false,
4764 intent: ButtonKind::Normal,
4765 key: Some("b".into()),
4766 disabled: false,
4767 focusable: true,
4768 },
4769 ],
4770 key: None,
4771 },
4772 WidgetSpec::Text {
4773 value: "".into(),
4774 cursor_byte: -1,
4775 focused: false,
4776 label: "".into(),
4777 placeholder: None,
4778 rows: 1,
4779 field_width: 0,
4780 max_visible_chars: 0,
4781 full_width: false,
4782 completions: Vec::new(),
4783 completions_visible_rows: 0,
4784 key: Some("ti".into()),
4785 },
4786 WidgetSpec::Toggle {
4787 checked: false,
4788 label: "no key".into(),
4789 focused: false,
4790 key: None,
4791 },
4792 ],
4793 key: None,
4794 };
4795 let mut tabbable = Vec::new();
4796 collect_tabbable(&spec, &mut tabbable);
4797 assert_eq!(tabbable, vec!["t", "b", "ti"]);
4800 }
4801
4802 #[test]
4803 fn first_render_focuses_first_tabbable() {
4804 let spec = WidgetSpec::Row {
4805 wrap: false,
4806 children: vec![
4807 WidgetSpec::Toggle {
4808 checked: false,
4809 label: "A".into(),
4810 focused: false,
4811 key: Some("a".into()),
4812 },
4813 WidgetSpec::Toggle {
4814 checked: false,
4815 label: "B".into(),
4816 focused: false,
4817 key: Some("b".into()),
4818 },
4819 ],
4820 key: None,
4821 };
4822 let out = render_spec(&spec, &HashMap::new(), "", u32::MAX);
4823 assert_eq!(out.focus_key, "a");
4824 assert_eq!(out.tabbable, vec!["a", "b"]);
4825 }
4826
4827 #[test]
4828 fn render_preserves_focus_key_across_re_renders() {
4829 let spec = WidgetSpec::Row {
4830 wrap: false,
4831 children: vec![
4832 WidgetSpec::Toggle {
4833 checked: false,
4834 label: "A".into(),
4835 focused: false,
4836 key: Some("a".into()),
4837 },
4838 WidgetSpec::Toggle {
4839 checked: false,
4840 label: "B".into(),
4841 focused: false,
4842 key: Some("b".into()),
4843 },
4844 ],
4845 key: None,
4846 };
4847 let out = render_spec(&spec, &HashMap::new(), "b", u32::MAX);
4848 assert_eq!(out.focus_key, "b");
4849 }
4850
4851 #[test]
4852 fn render_clamps_stale_focus_key_to_first_tabbable() {
4853 let spec = WidgetSpec::Toggle {
4857 checked: false,
4858 label: "Only".into(),
4859 focused: false,
4860 key: Some("only".into()),
4861 };
4862 let out = render_spec(&spec, &HashMap::new(), "stale", u32::MAX);
4863 assert_eq!(out.focus_key, "only");
4864 }
4865
4866 #[test]
4867 fn focused_widget_renders_with_focused_styling() {
4868 let spec = WidgetSpec::Row {
4869 wrap: false,
4870 children: vec![
4871 WidgetSpec::Toggle {
4872 checked: false,
4873 label: "A".into(),
4874 focused: false,
4875 key: Some("a".into()),
4876 },
4877 WidgetSpec::Toggle {
4878 checked: false,
4879 label: "B".into(),
4880 focused: false,
4881 key: Some("b".into()),
4882 },
4883 ],
4884 key: None,
4885 };
4886 let out = render_spec(&spec, &HashMap::new(), "b", u32::MAX);
4887 assert_eq!(out.entries.len(), 1, "row collapses inline");
4888 let entry = &out.entries[0];
4894 let focused_overlay = entry
4895 .inline_overlays
4896 .iter()
4897 .find(|o| {
4898 o.style.bg.as_ref().and_then(|c| c.as_theme_key()) == Some("ui.popup_selection_bg")
4899 })
4900 .expect("focused overlay present on B");
4901 assert_eq!(focused_overlay.start, 5);
4904 assert_eq!(focused_overlay.end, 10);
4905 }
4906
4907 #[test]
4908 fn no_tabbables_yields_empty_focus_key() {
4909 let spec = WidgetSpec::Col {
4910 children: vec![WidgetSpec::HintBar {
4911 entries: vec![],
4912 key: None,
4913 }],
4914 key: None,
4915 };
4916 let out = render_spec(&spec, &HashMap::new(), "", u32::MAX);
4917 assert_eq!(out.focus_key, "");
4918 assert!(out.tabbable.is_empty());
4919 }
4920
4921 #[test]
4926 fn list_emits_one_entry_and_one_hit_per_item() {
4927 let spec = WidgetSpec::List {
4928 items: vec![
4929 TextPropertyEntry::text("alpha"),
4930 TextPropertyEntry::text("beta"),
4931 TextPropertyEntry::text("gamma"),
4932 ],
4933 item_specs: vec![],
4934 item_keys: vec!["a".into(), "b".into(), "c".into()],
4935 selected_index: -1,
4936 visible_rows: 10,
4937 focusable: true,
4938 key: None,
4939 };
4940 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4941 assert_eq!(entries.len(), 10);
4947 assert_eq!(hits.len(), 3);
4950 for (i, h) in hits.iter().enumerate() {
4951 assert_eq!(h.buffer_row, i as u32);
4952 assert_eq!(h.widget_kind, "list");
4953 assert_eq!(h.event_type, "select");
4954 assert_eq!(h.payload["index"], i);
4955 }
4956 assert_eq!(hits[0].widget_key, "a");
4957 assert_eq!(hits[2].widget_key, "c");
4958 }
4959
4960 #[test]
4961 fn list_item_specs_render_multirow_cards_in_item_units() {
4962 let card = |body: &str| WidgetSpec::LabeledSection {
4965 label: String::new(),
4966 child: Box::new(WidgetSpec::Raw {
4967 entries: vec![TextPropertyEntry::text(body)],
4968 key: None,
4969 }),
4970 width_pct: None,
4971 key: None,
4972 };
4973 let spec = WidgetSpec::List {
4974 items: vec![],
4975 item_specs: vec![card("aaa"), card("bbb")],
4976 item_keys: vec!["a".into(), "b".into()],
4977 selected_index: 1,
4978 visible_rows: 12,
4980 focusable: true,
4981 key: Some("cards".into()),
4982 };
4983 let out = render_spec(&spec, &HashMap::new(), "", 40);
4986 let (entries, hits) = (out.entries, out.hits);
4987 assert_eq!(entries.len(), 12);
4989 assert_eq!(hits.len(), 6, "3 rows per card * 2 cards");
4992 assert!(hits[0..3]
4993 .iter()
4994 .all(|h| h.payload["index"] == 0 && h.widget_key == "a"));
4995 assert!(hits[3..6]
4996 .iter()
4997 .all(|h| h.payload["index"] == 1 && h.widget_key == "b"));
4998 for r in 0..3 {
5003 assert!(
5004 !entries[r].text.contains('┓') && !entries[r].text.contains('┃'),
5005 "unselected card row {r} should keep the light border"
5006 );
5007 assert!(entries[r].style.as_ref().map_or(true, |s| s.bg.is_none()));
5008 }
5009 let heavy = (3..6).any(|r| {
5012 entries[r].text.contains('┏')
5013 || entries[r].text.contains('┗')
5014 || entries[r].text.contains('┃')
5015 });
5016 assert!(heavy, "selected card should use a heavy box border");
5017 for r in 3..6 {
5018 let style = entries[r].style.as_ref();
5019 assert!(
5020 style.map(|s| s.bold).unwrap_or(false),
5021 "row {r} of the selected card should be bold"
5022 );
5023 assert!(
5024 style.and_then(|s| s.bg.as_ref()).is_none(),
5025 "row {r} of the selected card should NOT use a background band"
5026 );
5027 }
5028 assert!(entries[0].text.starts_with('╭'));
5030 assert!(entries[2].text.starts_with('╰'));
5031 }
5032
5033 #[test]
5034 fn selected_card_accent_frames_all_four_sides() {
5035 let card = |body: &str| WidgetSpec::LabeledSection {
5042 label: String::new(),
5043 child: Box::new(WidgetSpec::Raw {
5044 entries: vec![TextPropertyEntry::text(body)],
5045 key: None,
5046 }),
5047 width_pct: None,
5048 key: None,
5049 };
5050 let spec = WidgetSpec::List {
5051 items: vec![],
5052 item_specs: vec![card("aaa"), card("bbb")],
5053 item_keys: vec!["a".into(), "b".into()],
5054 selected_index: 1,
5055 visible_rows: 12,
5056 focusable: true,
5057 key: Some("cards".into()),
5058 };
5059 let out = render_spec(&spec, &HashMap::new(), "", 40);
5060 let entries = out.entries;
5061 let accent_is = |c: &OverlayColorSpec| matches!(c, OverlayColorSpec::ThemeKey(k) if k == "ui.popup_border_fg");
5063 for r in [3usize, 5] {
5065 let fg = entries[r].style.as_ref().and_then(|s| s.fg.as_ref());
5066 assert!(
5067 fg.map(accent_is).unwrap_or(false),
5068 "row {r} (top/bottom border) should carry the accent fg"
5069 );
5070 }
5071 let body = &entries[4];
5075 assert!(
5076 body.text.contains('┃'),
5077 "selected card body row should have heavy side borders: {:?}",
5078 body.text
5079 );
5080 assert!(
5081 body.style.as_ref().and_then(|s| s.fg.as_ref()).is_none(),
5082 "body row must not set a whole-row fg (would repaint the text)"
5083 );
5084 let bar_overlays: Vec<_> = body
5085 .inline_overlays
5086 .iter()
5087 .filter(|o| o.style.fg.as_ref().map(accent_is).unwrap_or(false))
5088 .collect();
5089 assert_eq!(
5090 bar_overlays.len(),
5091 2,
5092 "both the leading and trailing ┃ should be accent-tinted: {:?}",
5093 body.inline_overlays
5094 );
5095 for o in bar_overlays {
5097 assert_eq!(o.end - o.start, '┃'.len_utf8());
5098 assert_eq!(&body.text[o.start..o.end], "┃");
5099 }
5100 }
5101
5102 #[test]
5103 fn list_applies_selection_bg_to_selected_row() {
5104 let spec = WidgetSpec::List {
5105 items: vec![
5106 TextPropertyEntry::text("first"),
5107 TextPropertyEntry::text("second"),
5108 ],
5109 item_specs: vec![],
5110 item_keys: vec!["x".into(), "y".into()],
5111 selected_index: 1,
5112 visible_rows: 10,
5113 focusable: true,
5114 key: None,
5115 };
5116 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5117 assert!(entries[0].style.is_none(), "unselected row keeps no style");
5118 let style = entries[1].style.as_ref().expect("selected row gets style");
5119 assert_eq!(
5120 style.bg.as_ref().and_then(|c| c.as_theme_key()),
5121 Some("ui.popup_selection_bg"),
5122 );
5123 assert!(style.extend_to_line_end);
5124 }
5125
5126 #[test]
5127 fn list_inside_col_offsets_hit_rows_by_preceding_lines() {
5128 let spec = WidgetSpec::Col {
5129 children: vec![
5130 WidgetSpec::HintBar {
5131 entries: vec![HintEntry {
5132 keys: "h".into(),
5133 label: "header".into(),
5134 }],
5135 key: None,
5136 },
5137 WidgetSpec::List {
5138 items: vec![
5139 TextPropertyEntry::text("row0"),
5140 TextPropertyEntry::text("row1"),
5141 ],
5142 item_specs: vec![],
5143 item_keys: vec!["a".into(), "b".into()],
5144 selected_index: -1,
5145 visible_rows: 10,
5146 key: None,
5147 focusable: true,
5148 },
5149 ],
5150 key: None,
5151 };
5152 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5153 assert_eq!(entries.len(), 11);
5156 assert_eq!(hits.len(), 2);
5159 assert_eq!(hits[0].buffer_row, 1);
5161 assert_eq!(hits[1].buffer_row, 2);
5162 }
5163
5164 #[test]
5165 fn list_payload_includes_absolute_index_and_key() {
5166 let spec = WidgetSpec::List {
5167 items: vec![TextPropertyEntry::text("only")],
5168 item_specs: vec![],
5169 item_keys: vec!["match:42".into()],
5170 selected_index: 0,
5171 visible_rows: 10,
5172 focusable: true,
5173 key: None,
5174 };
5175 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5176 assert_eq!(hits[0].payload["index"], 0);
5177 assert_eq!(hits[0].payload["key"], "match:42");
5178 }
5179
5180 #[test]
5181 fn list_hit_payload_carries_list_key() {
5182 let spec = make_list(-1, 10, 2, Some("mylist"));
5188 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5189 assert_eq!(hits.len(), 2);
5190 assert_eq!(hits[0].payload["list_key"], "mylist");
5191 assert_eq!(hits[1].payload["list_key"], "mylist");
5192 }
5193
5194 #[test]
5195 fn list_hit_payload_list_key_is_null_when_keyless() {
5196 let spec = make_list(-1, 10, 1, None);
5199 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5200 assert!(hits[0].payload["list_key"].is_null());
5201 }
5202
5203 #[test]
5204 fn list_with_missing_key_emits_empty_widget_key() {
5205 let spec = WidgetSpec::List {
5206 items: vec![TextPropertyEntry::text("a"), TextPropertyEntry::text("b")],
5207 item_specs: vec![],
5209 item_keys: vec!["only".into()],
5210 selected_index: -1,
5211 visible_rows: 10,
5212 focusable: true,
5213 key: None,
5214 };
5215 let (_, hits, _state) = render_no_focus(&spec, &HashMap::new());
5216 assert_eq!(hits[0].widget_key, "only");
5217 assert_eq!(hits[1].widget_key, "");
5218 }
5219
5220 fn make_list(selected: i32, visible: u32, total: usize, key: Option<&str>) -> WidgetSpec {
5221 let items = (0..total)
5222 .map(|i| TextPropertyEntry::text(format!("row{}", i)))
5223 .collect();
5224 let item_keys = (0..total).map(|i| format!("k{}", i)).collect();
5225 WidgetSpec::List {
5226 items,
5227 item_specs: vec![],
5228 item_keys,
5229 selected_index: selected,
5230 visible_rows: visible,
5231 focusable: true,
5232 key: key.map(|s| s.to_string()),
5233 }
5234 }
5235
5236 #[test]
5237 fn list_renders_only_visible_window() {
5238 let spec = make_list(-1, 3, 10, Some("L"));
5239 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5240 assert_eq!(entries.len(), 3);
5241 assert_eq!(hits.len(), 3);
5242 assert_eq!(hits[0].payload["index"], 0);
5244 assert_eq!(hits[2].payload["index"], 2);
5245 }
5246
5247 #[test]
5248 fn list_scrolls_to_keep_selected_below_window_in_view() {
5249 let spec = make_list(5, 3, 10, Some("L"));
5254 let (_entries, hits, state) = render_no_focus(&spec, &HashMap::new());
5255 assert_eq!(hits.len(), 3);
5257 assert_eq!(hits[0].payload["index"], 3);
5258 assert_eq!(hits[2].payload["index"], 5);
5259 let scroll = match state.get("L").unwrap() {
5260 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5261 _ => unreachable!(),
5262 };
5263 assert_eq!(scroll, 3);
5264 }
5265
5266 #[test]
5267 fn list_scrolls_to_keep_selected_above_window_in_view() {
5268 let mut prev = HashMap::new();
5274 prev.insert(
5275 "L".into(),
5276 WidgetInstanceState::List {
5277 scroll_offset: 5,
5278 selected_index: 1,
5279 item_height: 1,
5280 user_scrolled: false,
5281 },
5282 );
5283 let spec = make_list(99, 3, 10, Some("L"));
5285 let (_entries, hits, state) = render_no_focus(&spec, &prev);
5286 assert_eq!(hits[0].payload["index"], 1);
5287 let scroll = match state.get("L").unwrap() {
5288 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5289 _ => unreachable!(),
5290 };
5291 assert_eq!(scroll, 1);
5292 }
5293
5294 #[test]
5295 fn list_scroll_preserved_when_selection_remains_in_view() {
5296 let mut prev = HashMap::new();
5299 prev.insert(
5300 "L".into(),
5301 WidgetInstanceState::List {
5302 scroll_offset: 4,
5303 selected_index: 5,
5304 item_height: 1,
5305 user_scrolled: false,
5306 },
5307 );
5308 let spec = make_list(99, 3, 10, Some("L"));
5309 let (_entries, hits, state) = render_no_focus(&spec, &prev);
5310 assert_eq!(hits[0].payload["index"], 4);
5311 let scroll = match state.get("L").unwrap() {
5312 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5313 _ => unreachable!(),
5314 };
5315 assert_eq!(scroll, 4);
5316 }
5317
5318 #[test]
5319 fn list_clamps_scroll_to_max_when_dataset_is_smaller_than_old_offset() {
5320 let mut prev = HashMap::new();
5323 prev.insert(
5324 "L".into(),
5325 WidgetInstanceState::List {
5326 scroll_offset: 8,
5327 selected_index: -1,
5328 item_height: 1,
5329 user_scrolled: false,
5330 },
5331 );
5332 let spec = make_list(-1, 3, 5, Some("L"));
5333 let (entries, _hits, state) = render_no_focus(&spec, &prev);
5334 assert_eq!(entries.len(), 3);
5335 let scroll = match state.get("L").unwrap() {
5336 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5337 _ => unreachable!(),
5338 };
5339 assert_eq!(scroll, 2);
5341 }
5342
5343 #[test]
5344 fn list_does_not_scroll_when_total_smaller_than_visible() {
5345 let spec = make_list(-1, 10, 3, Some("L"));
5346 let (entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
5347 assert_eq!(entries.len(), 10);
5352 let scroll = match state.get("L").unwrap() {
5353 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5354 _ => unreachable!(),
5355 };
5356 assert_eq!(scroll, 0);
5357 }
5358
5359 #[test]
5360 fn list_without_key_does_not_persist_state() {
5361 let spec = make_list(5, 3, 10, None);
5362 let (_entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
5363 assert!(
5364 state.is_empty(),
5365 "Lists without a `key` opt out of state preservation"
5366 );
5367 }
5368
5369 #[test]
5374 fn text_input_renders_value_in_brackets() {
5375 let entry = render_text_input("hello", -1, None, false, "", None, 0, 0, false).entry;
5376 assert_eq!(entry.text, "[hello]");
5377 assert!(entry.inline_overlays.is_empty());
5378 }
5379
5380 #[test]
5381 fn text_input_with_label_prefixes_with_label_space() {
5382 let entry = render_text_input("foo", -1, None, false, "Search:", None, 0, 0, false).entry;
5383 assert_eq!(entry.text, "Search: [foo]");
5384 }
5385
5386 #[test]
5387 fn text_input_focused_adds_input_bg_overlay() {
5388 let entry = render_text_input("x", -1, None, true, "", None, 0, 0, false).entry;
5389 assert_eq!(entry.inline_overlays.len(), 1);
5391 let bg = entry.inline_overlays[0].style.bg.as_ref().unwrap();
5392 assert_eq!(bg.as_theme_key(), Some("ui.prompt_bg"));
5393 }
5394
5395 #[test]
5396 fn text_input_focused_with_selection_adds_selection_bg_overlay() {
5397 let entry =
5400 render_text_input("hello world", 5, Some((0, 5)), true, "", None, 0, 0, false).entry;
5401 let sel = entry
5404 .inline_overlays
5405 .iter()
5406 .find(|o| {
5407 o.style.bg.as_ref().and_then(|c| c.as_theme_key())
5408 == Some("ui.text_input_selection_bg")
5409 })
5410 .expect("selection overlay present");
5411 assert_eq!(sel.start, 1);
5412 assert_eq!(sel.end, 6);
5413 }
5414
5415 #[test]
5416 fn text_input_unfocused_skips_selection_overlay() {
5417 let entry =
5420 render_text_input("hello", -1, Some((0, 5)), false, "", None, 0, 0, false).entry;
5421 let has_sel_overlay = entry.inline_overlays.iter().any(|o| {
5422 o.style.bg.as_ref().and_then(|c| c.as_theme_key()) == Some("ui.text_input_selection_bg")
5423 });
5424 assert!(!has_sel_overlay);
5425 }
5426
5427 #[test]
5428 fn text_area_focused_with_selection_emits_per_row_overlays() {
5429 let r = render_text_area("abcd\nefgh", 8, Some((2, 8)), true, "", None, 2, 0, 0, 80);
5433 let row0 = &r.entries[0];
5436 let row1 = &r.entries[1];
5437 let sel0 = row0
5438 .inline_overlays
5439 .iter()
5440 .find(|o| {
5441 o.style.bg.as_ref().and_then(|c| c.as_theme_key())
5442 == Some("ui.text_input_selection_bg")
5443 })
5444 .expect("row 0 selection overlay");
5445 assert_eq!((sel0.start, sel0.end), (2, 4));
5446 let sel1 = row1
5447 .inline_overlays
5448 .iter()
5449 .find(|o| {
5450 o.style.bg.as_ref().and_then(|c| c.as_theme_key())
5451 == Some("ui.text_input_selection_bg")
5452 })
5453 .expect("row 1 selection overlay");
5454 assert_eq!((sel1.start, sel1.end), (0, 3));
5455 }
5456
5457 #[test]
5458 fn text_input_cursor_byte_in_entry_at_value_position() {
5459 let r = render_text_input("abc", 1, None, true, "", None, 0, 0, false);
5464 assert_eq!(r.cursor_byte_in_entry, Some(2));
5465 }
5466
5467 #[test]
5468 fn text_input_cursor_at_end_lands_on_padding_space_not_bracket() {
5469 let r = render_text_input("ab", 2, None, true, "", None, 0, 0, false);
5475 assert_eq!(r.entry.text, "[ab ]");
5476 assert_eq!(r.cursor_byte_in_entry, Some(3));
5477 assert_ne!(r.cursor_byte_in_entry, Some(4), "must not overlap ]");
5478 }
5479
5480 #[test]
5481 fn text_input_unfocused_empty_shows_placeholder_in_muted() {
5482 let entry =
5483 render_text_input("", -1, None, false, "", Some("type here"), 0, 0, false).entry;
5484 assert_eq!(entry.text, "[type here]");
5485 let placeholder_overlay = entry
5487 .inline_overlays
5488 .iter()
5489 .find(|o| o.style.fg.as_ref().and_then(|c| c.as_theme_key()).is_some())
5490 .expect("placeholder fg overlay");
5491 let fg = placeholder_overlay.style.fg.as_ref().unwrap();
5492 assert_eq!(fg.as_theme_key(), Some("editor.whitespace_indicator_fg"));
5493 assert!(placeholder_overlay.style.italic);
5494 }
5495
5496 #[test]
5497 fn text_input_focused_empty_still_shows_placeholder() {
5498 let r = render_text_input("", -1, None, true, "", Some("type here"), 0, 0, false);
5502 assert_eq!(r.entry.text, "[type here]");
5503 assert_eq!(r.cursor_byte_in_entry, Some(1));
5504 }
5505
5506 #[test]
5507 fn text_input_field_width_pads_short_value_unfocused() {
5508 let r = render_text_input("hi", 2, None, false, "", None, 0, 10, false);
5511 assert_eq!(r.entry.text, "[hi ]");
5512 }
5513
5514 #[test]
5515 fn text_input_field_width_focused_adds_cursor_park_space() {
5516 let r = render_text_input("0123456789", 10, None, true, "", None, 0, 10, false);
5520 assert_eq!(r.entry.text, "[0123456789 ]");
5521 assert_eq!(r.cursor_byte_in_entry, Some(11));
5525 assert_ne!(r.cursor_byte_in_entry, Some(12), "must not land on ]");
5526 }
5527
5528 #[test]
5529 fn text_input_field_width_full_width_pads_to_same_size_when_unfocused() {
5530 let r = render_text_input("hi", -1, None, false, "", None, 0, 10, true);
5534 assert_eq!(r.entry.text, "[hi ]"); }
5536
5537 #[test]
5538 fn text_input_field_width_head_truncates_long_value() {
5539 let r = render_text_input(
5542 "0123456789abcdefghijklmnopqrst",
5543 30,
5544 None,
5545 false,
5546 "",
5547 None,
5548 0,
5549 10,
5550 false,
5551 );
5552 assert!(r.entry.text.contains("…lmnopqrst"));
5553 }
5554
5555 #[test]
5556 fn text_input_field_width_clamps_cursor_in_dropped_prefix() {
5557 let r = render_text_input("abcdefghij", 0, None, true, "", None, 0, 5, false);
5560 assert_eq!(r.cursor_byte_in_entry, Some(1 + "…".len()));
5565 }
5566
5567 #[test]
5568 fn text_input_truncates_long_value_keeping_tail_visible() {
5569 let value: String = "0123456789abcdefghij".to_string();
5570 let entry = render_text_input(&value, -1, None, false, "", None, 6, 0, false).entry;
5571 assert_eq!(entry.text, "[…fghij]");
5573 }
5574
5575 #[test]
5576 fn raw_inside_col_offsets_following_hits() {
5577 let spec = WidgetSpec::Col {
5578 children: vec![
5579 WidgetSpec::Raw {
5580 entries: vec![
5581 TextPropertyEntry::text("line0"),
5582 TextPropertyEntry::text("line1"),
5583 TextPropertyEntry::text("line2"),
5584 ],
5585 key: None,
5586 },
5587 WidgetSpec::Toggle {
5588 checked: false,
5589 label: "after raw".into(),
5590 focused: false,
5591 key: Some("post".into()),
5592 },
5593 ],
5594 key: None,
5595 };
5596 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5597 assert_eq!(entries.len(), 4);
5598 assert_eq!(hits.len(), 1);
5599 assert_eq!(hits[0].buffer_row, 3);
5600 }
5601
5602 fn tnode(text: &str, depth: u32, has_children: bool) -> TreeNode {
5607 TreeNode {
5608 text: TextPropertyEntry::text(text),
5609 depth,
5610 has_children,
5611 checked: None,
5612 }
5613 }
5614
5615 fn make_tree(
5616 nodes: Vec<TreeNode>,
5617 item_keys: Vec<&str>,
5618 selected: i32,
5619 visible: u32,
5620 expanded: Vec<&str>,
5621 key: Option<&str>,
5622 ) -> WidgetSpec {
5623 WidgetSpec::Tree {
5624 nodes,
5625 item_keys: item_keys.iter().map(|s| s.to_string()).collect(),
5626 selected_index: selected,
5627 visible_rows: visible,
5628 expanded_keys: expanded.iter().map(|s| s.to_string()).collect(),
5629 checkable: false,
5630 key: key.map(|s| s.to_string()),
5631 }
5632 }
5633
5634 #[test]
5635 fn tree_row_renders_disclosure_glyph_for_internal_collapsed() {
5636 let r = render_tree_row(&tnode("file.txt", 0, true), false, false);
5637 assert!(r.entry.text.starts_with('\u{25B6}'), "starts with ▶");
5638 assert!(r.entry.text.contains("file.txt"));
5639 assert!(r.disclosure_range.is_some());
5640 }
5641
5642 #[test]
5643 fn tree_row_renders_disclosure_glyph_for_internal_expanded() {
5644 let r = render_tree_row(&tnode("file.txt", 0, true), true, false);
5645 assert!(r.entry.text.starts_with('\u{25BC}'), "starts with ▼");
5646 }
5647
5648 #[test]
5649 fn tree_row_leaf_uses_two_spaces_no_disclosure_hit() {
5650 let r = render_tree_row(&tnode("match", 0, false), false, false);
5651 assert!(r.entry.text.starts_with(" "));
5653 assert!(r.entry.text.contains("match"));
5654 assert!(r.disclosure_range.is_none());
5655 }
5656
5657 #[test]
5658 fn tree_row_indents_by_depth_times_two() {
5659 let r = render_tree_row(&tnode("nested", 2, false), false, false);
5660 assert!(r.entry.text.starts_with(" nested"));
5662 }
5663
5664 #[test]
5665 fn tree_row_shifts_plugin_overlays_by_prefix() {
5666 let mut node = tnode("hello", 1, false);
5667 node.text.inline_overlays.push(InlineOverlay {
5668 start: 0,
5669 end: 5,
5670 style: OverlayOptions {
5671 bold: true,
5672 ..Default::default()
5673 },
5674 properties: Default::default(),
5675 unit: OffsetUnit::Byte,
5676 });
5677 let r = render_tree_row(&node, false, false);
5678 let plugin_overlay = r
5681 .entry
5682 .inline_overlays
5683 .iter()
5684 .find(|o| o.style.bold)
5685 .expect("bold overlay carried through");
5686 assert_eq!(plugin_overlay.start, 4);
5687 assert_eq!(plugin_overlay.end, 9);
5688 }
5689
5690 #[test]
5691 fn tree_row_omits_checkbox_when_not_checkable() {
5692 let mut node = tnode("file.rs", 0, false);
5694 node.checked = Some(true);
5695 let r = render_tree_row(&node, false, false);
5696 assert!(r.checkbox_range.is_none());
5697 assert!(!r.entry.text.contains("[v]"));
5698 assert!(!r.entry.text.contains("[ ]"));
5699 }
5700
5701 #[test]
5702 fn tree_row_omits_checkbox_when_checked_is_none() {
5703 let node = tnode("section", 0, false);
5707 let r = render_tree_row(&node, false, true);
5708 assert!(r.checkbox_range.is_none());
5709 assert!(!r.entry.text.contains("[v]"));
5710 assert!(!r.entry.text.contains("[ ]"));
5711 }
5712
5713 #[test]
5714 fn tree_row_renders_checked_glyph_after_disclosure() {
5715 let mut node = tnode("file.rs", 0, true);
5716 node.checked = Some(true);
5717 let r = render_tree_row(&node, true, true);
5718 assert!(r.checkbox_range.is_some(), "checkbox range emitted");
5719 let (cb_start, cb_end) = r.checkbox_range.unwrap();
5720 assert_eq!(&r.entry.text[cb_start..cb_end], "[v]");
5722 assert!(r.entry.text.contains("[v] file.rs"));
5723 }
5724
5725 #[test]
5726 fn tree_row_renders_unchecked_glyph_for_leaf() {
5727 let mut node = tnode("match-row", 1, false);
5728 node.checked = Some(false);
5729 let r = render_tree_row(&node, false, true);
5730 let (cb_start, cb_end) = r
5731 .checkbox_range
5732 .expect("checkbox range for leaf with checked: Some");
5733 assert_eq!(&r.entry.text[cb_start..cb_end], "[ ]");
5734 assert!(r.entry.text.starts_with(" [ ] match-row"));
5736 }
5737
5738 #[test]
5739 fn tree_row_checkbox_glyph_byte_range_addresses_correct_text() {
5740 let mut node = tnode("path/with/é", 0, true);
5743 node.checked = Some(true);
5744 let r = render_tree_row(&node, false, true);
5745 let (cb_start, cb_end) = r.checkbox_range.unwrap();
5746 assert!(r.entry.text.is_char_boundary(cb_start));
5747 assert!(r.entry.text.is_char_boundary(cb_end));
5748 assert_eq!(&r.entry.text[cb_start..cb_end], "[v]");
5749 }
5750
5751 #[test]
5752 fn tree_node_pad_to_chars_pads_text_before_prefix_offset_shift() {
5753 let mut node = tnode("x", 0, true);
5757 node.text.pad_to_chars = Some(5);
5758 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec!["x"], Some("T"));
5759 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5760 assert_eq!(entries.len(), 1);
5761 let trimmed = entries[0].text.trim_end_matches('\n');
5764 assert!(
5765 trimmed.ends_with("x "),
5766 "row should end with the padded body, got {trimmed:?}"
5767 );
5768 }
5769
5770 #[test]
5771 fn tree_node_truncate_to_chars_cuts_body_before_prefix_offset_shift() {
5772 let mut node = tnode("abcdefghij", 0, false);
5773 node.text.truncate_to_chars = Some(6);
5774 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5775 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5776 let trimmed = entries[0].text.trim_end_matches('\n');
5777 assert!(
5780 trimmed.ends_with("abc..."),
5781 "row should end with truncated body, got {trimmed:?}"
5782 );
5783 }
5784
5785 #[test]
5786 fn tree_node_char_unit_overlay_resolves_against_padded_text_and_shifts_by_prefix() {
5787 let mut node = tnode("x", 0, false);
5792 node.text.pad_to_chars = Some(5);
5793 node.text.inline_overlays.push(InlineOverlay {
5794 start: 0,
5795 end: 5,
5796 style: OverlayOptions {
5797 bold: true,
5798 ..Default::default()
5799 },
5800 properties: Default::default(),
5801 unit: OffsetUnit::Char,
5802 });
5803 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5804 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5805 let entry = &entries[0];
5806 let bold = entry
5807 .inline_overlays
5808 .iter()
5809 .find(|o| o.style.bold)
5810 .expect("bold overlay carried through");
5811 assert_eq!(bold.start, 2);
5814 assert_eq!(bold.end, 7);
5815 }
5816
5817 #[test]
5818 fn tree_node_char_unit_overlay_with_multibyte_body_resolves_correctly() {
5819 let mut node = tnode("éxé", 0, false);
5823 node.text.inline_overlays.push(InlineOverlay {
5824 start: 1,
5825 end: 2,
5826 style: OverlayOptions {
5827 bold: true,
5828 ..Default::default()
5829 },
5830 properties: Default::default(),
5831 unit: OffsetUnit::Char,
5832 });
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 entry = &entries[0];
5836 let bold = entry
5837 .inline_overlays
5838 .iter()
5839 .find(|o| o.style.bold)
5840 .expect("bold overlay carried through");
5841 let trimmed = entry.text.trim_end_matches('\n');
5844 assert_eq!(bold.start, 4);
5845 assert_eq!(bold.end, 5);
5846 assert_eq!(&trimmed[bold.start..bold.end], "x");
5847 }
5848
5849 #[test]
5850 fn tree_node_segments_concatenate_into_row_text_with_per_segment_overlays() {
5851 let mut node = tnode("", 0, false);
5852 node.text.segments = vec![
5853 fresh_core::text_property::StyledSegment {
5854 text: "AB".to_string(),
5855 style: None,
5856 overlays: vec![],
5857 },
5858 fresh_core::text_property::StyledSegment {
5859 text: " ".to_string(),
5860 style: None,
5861 overlays: vec![],
5862 },
5863 fresh_core::text_property::StyledSegment {
5864 text: "CD".to_string(),
5865 style: Some(OverlayOptions {
5866 bold: true,
5867 ..Default::default()
5868 }),
5869 overlays: vec![],
5870 },
5871 ];
5872 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5873 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5874 let trimmed = entries[0].text.trim_end_matches('\n');
5875 assert!(
5877 trimmed.ends_with("AB CD"),
5878 "row should end with concatenated segments, got {trimmed:?}"
5879 );
5880 let bold = entries[0]
5881 .inline_overlays
5882 .iter()
5883 .find(|o| o.style.bold)
5884 .expect("styled segment overlay carried through");
5885 assert_eq!(&trimmed[bold.start..bold.end], "CD");
5888 }
5889
5890 #[test]
5891 fn tree_node_segment_nested_overlay_shifts_to_segment_position() {
5892 let mut node = tnode("", 0, false);
5897 node.text.segments = vec![
5898 fresh_core::text_property::StyledSegment {
5899 text: "AB".to_string(),
5900 style: None,
5901 overlays: vec![],
5902 },
5903 fresh_core::text_property::StyledSegment {
5904 text: " - ".to_string(),
5905 style: None,
5906 overlays: vec![],
5907 },
5908 fresh_core::text_property::StyledSegment {
5909 text: "CDEFG".to_string(),
5910 style: None,
5911 overlays: vec![InlineOverlay {
5912 start: 0,
5913 end: 3,
5914 style: OverlayOptions {
5915 bold: true,
5916 ..Default::default()
5917 },
5918 properties: Default::default(),
5919 unit: OffsetUnit::Char,
5920 }],
5921 },
5922 ];
5923 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5924 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5925 let trimmed = entries[0].text.trim_end_matches('\n');
5926 let bold = entries[0]
5927 .inline_overlays
5928 .iter()
5929 .find(|o| o.style.bold)
5930 .expect("nested overlay carried through");
5931 assert_eq!(&trimmed[bold.start..bold.end], "CDE");
5932 }
5933
5934 #[test]
5935 fn tree_node_segments_with_pad_pad_after_concatenation() {
5936 let mut node = tnode("", 0, false);
5937 node.text.segments = vec![fresh_core::text_property::StyledSegment {
5938 text: "ab".to_string(),
5939 style: None,
5940 overlays: vec![],
5941 }];
5942 node.text.pad_to_chars = Some(5);
5943 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5944 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5945 let trimmed = entries[0].text.trim_end_matches('\n');
5946 assert!(
5948 trimmed.ends_with("ab "),
5949 "row should be padded after segment concat, got {trimmed:?}"
5950 );
5951 }
5952
5953 #[test]
5954 fn tree_renders_only_top_level_when_nothing_expanded() {
5955 let spec = make_tree(
5956 vec![
5957 tnode("a", 0, true),
5958 tnode("a.0", 1, false),
5959 tnode("a.1", 1, false),
5960 tnode("b", 0, true),
5961 tnode("b.0", 1, false),
5962 ],
5963 vec!["a", "a.0", "a.1", "b", "b.0"],
5964 -1,
5965 10,
5966 vec![], Some("T"),
5968 );
5969 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5970 assert_eq!(entries.len(), 2);
5972 assert!(entries[0].text.contains('a'));
5973 assert!(entries[1].text.contains('b'));
5974 }
5975
5976 #[test]
5977 fn tree_renders_children_of_expanded_nodes() {
5978 let spec = make_tree(
5979 vec![
5980 tnode("a", 0, true),
5981 tnode("a.0", 1, false),
5982 tnode("a.1", 1, false),
5983 tnode("b", 0, true),
5984 tnode("b.0", 1, false),
5985 ],
5986 vec!["a", "a.0", "a.1", "b", "b.0"],
5987 -1,
5988 10,
5989 vec!["a"],
5990 Some("T"),
5991 );
5992 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5993 assert_eq!(entries.len(), 4);
5995 }
5996
5997 #[test]
5998 fn tree_emits_two_hits_per_internal_row_one_per_leaf() {
5999 let spec = make_tree(
6002 vec![tnode("a", 0, true), tnode("a.0", 1, false)],
6003 vec!["a", "a.0"],
6004 -1,
6005 10,
6006 vec!["a"],
6007 Some("T"),
6008 );
6009 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
6010 assert_eq!(hits.len(), 3);
6011 assert_eq!(hits[0].event_type, "expand");
6013 assert_eq!(hits[0].widget_kind, "tree");
6014 assert_eq!(hits[1].event_type, "select");
6015 assert_eq!(hits[2].event_type, "select");
6016 }
6017
6018 #[test]
6019 fn tree_hits_carry_tree_spec_key_and_per_item_key_in_payload() {
6020 let spec = make_tree(
6021 vec![tnode("only", 0, false)],
6022 vec!["only-key"],
6023 -1,
6024 10,
6025 vec![],
6026 Some("matchTree"),
6027 );
6028 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
6029 assert_eq!(hits[0].widget_key, "matchTree");
6030 assert_eq!(hits[0].payload["key"], "only-key");
6031 assert_eq!(hits[0].payload["index"], 0);
6032 }
6033
6034 #[test]
6035 fn tree_persists_expanded_keys_in_instance_state() {
6036 let spec = make_tree(
6037 vec![tnode("a", 0, true), tnode("a.0", 1, false)],
6038 vec!["a", "a.0"],
6039 -1,
6040 10,
6041 vec!["a"],
6042 Some("T"),
6043 );
6044 let (_, _, state) = render_no_focus(&spec, &HashMap::new());
6045 match state.get("T").unwrap() {
6046 WidgetInstanceState::Tree { expanded_keys, .. } => {
6047 assert!(expanded_keys.contains("a"));
6048 }
6049 _ => unreachable!(),
6050 }
6051 }
6052
6053 #[test]
6054 fn tree_instance_state_overrides_spec_expanded_keys() {
6055 let mut prev = HashMap::new();
6058 prev.insert(
6059 "T".into(),
6060 WidgetInstanceState::Tree {
6061 scroll_offset: 0,
6062 selected_index: -1,
6063 expanded_keys: ["b".to_string()].iter().cloned().collect(),
6064 },
6065 );
6066 let spec = make_tree(
6067 vec![
6068 tnode("a", 0, true),
6069 tnode("a.0", 1, false),
6070 tnode("b", 0, true),
6071 tnode("b.0", 1, false),
6072 ],
6073 vec!["a", "a.0", "b", "b.0"],
6074 -1,
6075 10,
6076 vec!["a"], Some("T"),
6078 );
6079 let (entries, _hits, _state) = render_no_focus(&spec, &prev);
6080 assert_eq!(entries.len(), 3);
6082 }
6083
6084 #[test]
6085 fn tree_selected_row_gets_focused_bg() {
6086 let spec = make_tree(
6087 vec![tnode("a", 0, false), tnode("b", 0, false)],
6088 vec!["a", "b"],
6089 1,
6090 10,
6091 vec![],
6092 Some("T"),
6093 );
6094 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
6095 assert!(entries[0].style.is_none());
6096 let style = entries[1].style.as_ref().expect("selected gets style");
6097 assert_eq!(
6098 style.bg.as_ref().and_then(|c| c.as_theme_key()),
6099 Some("ui.popup_selection_bg")
6100 );
6101 assert!(style.extend_to_line_end);
6102 }
6103
6104 #[test]
6105 fn tree_clamps_selection_to_visible_when_selected_node_is_hidden() {
6106 let spec = make_tree(
6110 vec![tnode("a", 0, true), tnode("a.0", 1, false)],
6111 vec!["a", "a.0"],
6112 1,
6113 10,
6114 vec![], Some("T"),
6116 );
6117 let (_entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
6118 match state.get("T").unwrap() {
6119 WidgetInstanceState::Tree { selected_index, .. } => {
6120 assert_eq!(*selected_index, 0);
6121 }
6122 _ => unreachable!(),
6123 }
6124 }
6125
6126 #[test]
6127 fn tree_scrolls_to_keep_selection_in_visible_window() {
6128 let spec = make_tree(
6132 vec![
6133 tnode("0", 0, false),
6134 tnode("1", 0, false),
6135 tnode("2", 0, false),
6136 tnode("3", 0, false),
6137 tnode("4", 0, false),
6138 tnode("5", 0, false),
6139 ],
6140 vec!["k0", "k1", "k2", "k3", "k4", "k5"],
6141 4,
6142 3,
6143 vec![],
6144 Some("T"),
6145 );
6146 let (entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
6147 assert_eq!(entries.len(), 3);
6149 match state.get("T").unwrap() {
6150 WidgetInstanceState::Tree { scroll_offset, .. } => assert_eq!(*scroll_offset, 2),
6151 _ => unreachable!(),
6152 }
6153 }
6154
6155 #[test]
6156 fn tree_tabbable_keys_include_tree_with_key() {
6157 let spec = WidgetSpec::Col {
6158 children: vec![
6159 WidgetSpec::Toggle {
6160 checked: false,
6161 label: "T".into(),
6162 focused: false,
6163 key: Some("toggle".into()),
6164 },
6165 make_tree(
6166 vec![tnode("a", 0, false)],
6167 vec!["a"],
6168 -1,
6169 10,
6170 vec![],
6171 Some("tree"),
6172 ),
6173 ],
6174 key: None,
6175 };
6176 let mut tabbable = Vec::new();
6177 collect_tabbable(&spec, &mut tabbable);
6178 assert_eq!(tabbable, vec!["toggle", "tree"]);
6179 }
6180
6181 fn make_text_area(
6186 value: &str,
6187 cursor_byte: i32,
6188 focused: bool,
6189 rows: u32,
6190 field_width: u32,
6191 key: Option<&str>,
6192 ) -> WidgetSpec {
6193 WidgetSpec::Text {
6194 value: value.into(),
6195 cursor_byte,
6196 focused,
6197 label: String::new(),
6198 placeholder: None,
6199 rows: rows.max(2),
6204 field_width,
6205 max_visible_chars: 0,
6206 full_width: false,
6207 completions: Vec::new(),
6208 completions_visible_rows: 0,
6209 key: key.map(|s| s.into()),
6210 }
6211 }
6212
6213 #[test]
6214 fn text_area_renders_visible_rows_count() {
6215 let spec = make_text_area("hi", -1, false, 3, 10, Some("ta"));
6218 let prev = HashMap::new();
6219 let out = render_spec(&spec, &prev, "", 80);
6220 assert_eq!(out.entries.len(), 3);
6221 }
6222
6223 #[test]
6224 fn text_area_pads_short_lines_to_field_width() {
6225 let spec = make_text_area("hi", -1, false, 1, 6, Some("ta"));
6226 let prev = HashMap::new();
6227 let out = render_spec(&spec, &prev, "", 80);
6228 let first = &out.entries[0];
6230 assert_eq!(first.text, "hi \n");
6231 }
6232
6233 #[test]
6234 fn text_area_truncates_long_line_with_ellipsis() {
6235 let spec = make_text_area("abcdefghi", -1, false, 1, 5, Some("ta"));
6236 let prev = HashMap::new();
6237 let out = render_spec(&spec, &prev, "", 80);
6238 assert_eq!(out.entries[0].text, "abcd…\n");
6240 }
6241
6242 #[test]
6243 fn text_area_focused_adds_input_bg_overlay_per_row() {
6244 let spec = make_text_area("a\nb", -1, true, 3, 4, Some("ta"));
6245 let prev = HashMap::new();
6246 let out = render_spec(&spec, &prev, "ta", 80);
6247 for entry in &out.entries {
6248 let has_bg = entry.inline_overlays.iter().any(|o| {
6249 o.style
6250 .bg
6251 .as_ref()
6252 .and_then(|c| c.as_theme_key())
6253 .map(|k| k == "ui.prompt_bg")
6254 .unwrap_or(false)
6255 });
6256 assert!(has_bg, "every focused row gets input-bg");
6257 }
6258 }
6259
6260 #[test]
6261 fn text_area_publishes_focus_cursor_at_value_position() {
6262 let spec = make_text_area("ab\ncd", 4, true, 3, 6, Some("ta"));
6265 let prev = HashMap::new();
6266 let out = render_spec(&spec, &prev, "ta", 80);
6267 let fc = out.focus_cursor.expect("focused → cursor published");
6268 assert_eq!(fc.buffer_row, 1);
6270 assert_eq!(fc.byte_in_row, 1);
6272 }
6273
6274 #[test]
6275 fn text_area_label_offsets_cursor_buffer_row() {
6276 let spec = WidgetSpec::Text {
6280 value: "hi".into(),
6281 cursor_byte: 1,
6282 focused: true,
6283 label: "Note".into(),
6284 placeholder: None,
6285 rows: 2,
6286 field_width: 6,
6287 max_visible_chars: 0,
6288 full_width: false,
6289 completions: Vec::new(),
6290 completions_visible_rows: 0,
6291 key: Some("ta".into()),
6292 };
6293 let prev = HashMap::new();
6294 let out = render_spec(&spec, &prev, "ta", 80);
6295 assert!(out.entries[0].text.starts_with("Note:"));
6297 let fc = out.focus_cursor.unwrap();
6298 assert_eq!(fc.buffer_row, 1);
6299 }
6300
6301 #[test]
6302 fn text_area_persists_value_and_cursor_in_instance_state() {
6303 let spec = make_text_area("abc", 2, true, 2, 8, Some("ta"));
6304 let prev = HashMap::new();
6305 let out = render_spec(&spec, &prev, "ta", 80);
6306 match out.instance_states.get("ta") {
6307 Some(WidgetInstanceState::Text { editor, .. }) => {
6308 assert_eq!(editor.value(), "abc");
6309 assert_eq!(editor.flat_cursor_byte(), 2);
6310 }
6311 other => panic!("expected Text instance state, got {:?}", other),
6312 }
6313 }
6314
6315 #[test]
6316 fn text_area_instance_state_overrides_spec_value() {
6317 let spec = make_text_area("old", 0, true, 2, 8, Some("ta"));
6320 let mut prev = HashMap::new();
6321 let mut editor = crate::primitives::text_edit::TextEdit::with_text("new");
6322 editor.set_cursor_from_flat(3);
6323 prev.insert(
6324 "ta".into(),
6325 WidgetInstanceState::Text {
6326 editor,
6327 scroll: 0,
6328 completions: Vec::new(),
6329 completion_selected_index: 0,
6330 completion_scroll_offset: 0,
6331 completion_navigated: false,
6332 },
6333 );
6334 let out = render_spec(&spec, &prev, "ta", 80);
6335 assert!(out.entries[0].text.starts_with("new"));
6337 }
6338
6339 #[test]
6340 fn text_area_scroll_clamps_to_keep_cursor_visible() {
6341 let spec = make_text_area("a\nb\nc\nd\ne", 8, true, 2, 4, Some("ta"));
6345 let prev = HashMap::new();
6347 let out = render_spec(&spec, &prev, "ta", 80);
6348 match out.instance_states.get("ta") {
6349 Some(WidgetInstanceState::Text { scroll, .. }) => {
6350 assert_eq!(*scroll, 3, "scroll so lines 3..5 are visible");
6351 }
6352 _ => panic!("expected Text instance state"),
6353 }
6354 }
6355
6356 #[test]
6357 fn text_area_unfocused_empty_shows_placeholder_in_first_row() {
6358 let r = render_text_area("", -1, None, false, "", Some("write here"), 2, 12, 0, 80);
6363 assert!(r.entries[0].text.starts_with("write here"));
6364 let fg = r.entries[0]
6366 .inline_overlays
6367 .iter()
6368 .find_map(|o| o.style.fg.as_ref())
6369 .and_then(|c| c.as_theme_key());
6370 assert_eq!(fg, Some("editor.whitespace_indicator_fg"));
6371 }
6372
6373 #[test]
6374 fn text_area_tabbable_keys_include_text_area_with_key() {
6375 let spec = WidgetSpec::Col {
6376 children: vec![
6377 WidgetSpec::Toggle {
6378 checked: false,
6379 label: "T".into(),
6380 focused: false,
6381 key: Some("toggle".into()),
6382 },
6383 make_text_area("", -1, false, 3, 10, Some("note")),
6384 ],
6385 key: None,
6386 };
6387 let mut tabbable = Vec::new();
6388 collect_tabbable(&spec, &mut tabbable);
6389 assert_eq!(tabbable, vec!["toggle", "note"]);
6390 }
6391
6392 fn make_text_input(
6397 value: &str,
6398 cursor_byte: i32,
6399 focused: bool,
6400 full_width: bool,
6401 field_width: u32,
6402 key: Option<&str>,
6403 ) -> WidgetSpec {
6404 WidgetSpec::Text {
6405 value: value.into(),
6406 cursor_byte,
6407 focused,
6408 label: String::new(),
6409 placeholder: None,
6410 rows: 1,
6411 field_width,
6412 max_visible_chars: 0,
6413 full_width,
6414 completions: Vec::new(),
6415 completions_visible_rows: 0,
6416 key: key.map(|s| s.into()),
6417 }
6418 }
6419
6420 #[test]
6421 fn labeled_section_renders_three_rows_with_legend() {
6422 let spec = WidgetSpec::LabeledSection {
6423 label: "Name".into(),
6424 child: Box::new(make_text_input("hi", -1, false, false, 4, Some("n"))),
6425 width_pct: None,
6426 key: None,
6427 };
6428 let prev = HashMap::new();
6429 let out = render_spec(&spec, &prev, "", 20);
6430 assert_eq!(out.entries.len(), 3);
6432 assert!(out.entries[0].text.starts_with("╭─ Name "));
6434 assert!(out.entries[0].text.ends_with("╮\n"));
6435 assert!(out.entries[1].text.starts_with("│ "));
6437 assert!(out.entries[1].text.ends_with(" │\n"));
6438 assert!(out.entries[2].text.starts_with("╰"));
6440 assert!(out.entries[2].text.ends_with("╯\n"));
6441 }
6442
6443 #[test]
6444 fn zip_row_blocks_keeps_overlays_on_char_boundaries() {
6445 let left = WidgetSpec::LabeledSection {
6456 label: "alpha/beta · this project (2)".into(),
6457 child: Box::new(make_text_input("x", -1, false, false, 4, Some("a"))),
6458 width_pct: Some(40),
6459 key: None,
6460 };
6461 let right = WidgetSpec::LabeledSection {
6462 label: "preview".into(),
6463 child: Box::new(make_text_input("y", -1, false, false, 4, Some("b"))),
6464 width_pct: None,
6465 key: None,
6466 };
6467 let spec = WidgetSpec::Row {
6468 wrap: false,
6469 children: vec![left, right],
6470 key: None,
6471 };
6472 let out = render_spec(&spec, &HashMap::new(), "", 40);
6473 for e in &out.entries {
6474 for o in &e.inline_overlays {
6475 assert!(
6476 e.text.is_char_boundary(o.start.min(e.text.len())),
6477 "overlay start {} not on a char boundary of {:?}",
6478 o.start,
6479 e.text,
6480 );
6481 assert!(
6482 e.text.is_char_boundary(o.end.min(e.text.len())),
6483 "overlay end {} not on a char boundary of {:?}",
6484 o.end,
6485 e.text,
6486 );
6487 }
6488 }
6489 }
6490
6491 #[test]
6492 fn labeled_section_pads_child_to_inner_width() {
6493 let spec = WidgetSpec::LabeledSection {
6494 label: "".into(),
6495 child: Box::new(make_text_input("hi", -1, false, false, 4, Some("n"))),
6496 width_pct: None,
6497 key: None,
6498 };
6499 let prev = HashMap::new();
6500 let out = render_spec(&spec, &prev, "", 16);
6503 let middle = &out.entries[1];
6504 assert_eq!(middle.text.chars().count(), 16 + 1 );
6506 }
6507
6508 #[test]
6509 fn labeled_section_text_full_width_fills_inner_area() {
6510 let spec = WidgetSpec::LabeledSection {
6516 label: "".into(),
6517 child: Box::new(make_text_input("ab", -1, false, true, 0, Some("n"))),
6518 width_pct: None,
6519 key: None,
6520 };
6521 let prev = HashMap::new();
6522 let out = render_spec(&spec, &prev, "", 16);
6523 let middle = &out.entries[1];
6524 assert_eq!(middle.text.chars().count(), 17, "actual: {:?}", middle.text);
6528 assert!(
6529 middle.text.contains("[ab ]"),
6530 "actual: {:?}",
6531 middle.text
6532 );
6533 }
6534
6535 #[test]
6536 fn labeled_section_propagates_focus_cursor_with_offsets() {
6537 let spec = WidgetSpec::LabeledSection {
6538 label: "".into(),
6539 child: Box::new(make_text_input("abc", 3, true, false, 4, Some("n"))),
6540 width_pct: None,
6541 key: None,
6542 };
6543 let prev = HashMap::new();
6544 let out = render_spec(&spec, &prev, "n", 20);
6545 let fc = out.focus_cursor.expect("focused child publishes cursor");
6546 assert_eq!(fc.buffer_row, 1);
6548 let prefix_bytes = LEFT_BORDER_PREFIX.len() as u32;
6552 assert_eq!(fc.byte_in_row, prefix_bytes + 1 + 3);
6553 }
6554
6555 #[test]
6556 fn labeled_section_includes_child_in_tabbable() {
6557 let spec = WidgetSpec::Col {
6558 children: vec![
6559 WidgetSpec::LabeledSection {
6560 label: "Name".into(),
6561 child: Box::new(make_text_input("", -1, false, false, 0, Some("n"))),
6562 width_pct: None,
6563 key: None,
6564 },
6565 WidgetSpec::LabeledSection {
6566 label: "Cmd".into(),
6567 child: Box::new(make_text_input("", -1, false, false, 0, Some("c"))),
6568 width_pct: None,
6569 key: None,
6570 },
6571 ],
6572 key: None,
6573 };
6574 let mut tabbable = Vec::new();
6575 collect_tabbable(&spec, &mut tabbable);
6576 assert_eq!(tabbable, vec!["n", "c"]);
6577 }
6578}