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 KEY_DANGER_FG: &str = "diagnostic.error_fg";
62const KEY_INPUT_BG: &str = "ui.prompt_bg";
63const KEY_TEXT_INPUT_SELECTION_BG: &str = "ui.text_input_selection_bg";
69const KEY_PLACEHOLDER_FG: &str = "editor.whitespace_indicator_fg";
74const KEY_SECTION_LABEL_FG: &str = "ui.help_key_fg";
79const KEY_COMPLETION_DIM_FG: &str = "ui.menu_disabled_fg";
86const KEY_COMPLETION_SEL_FG: &str = "ui.popup_selection_fg";
91const KEY_COMPLETION_SEL_BG: &str = "ui.popup_selection_bg";
92const KEY_COMPLETION_BORDER_FG: &str = "ui.popup_border_fg";
99
100#[derive(Debug, Clone, Copy)]
109pub struct FocusCursor {
110 pub buffer_row: u32,
111 pub byte_in_row: u32,
112}
113
114pub struct RenderOutput {
132 pub entries: Vec<TextPropertyEntry>,
133 pub hits: Vec<HitArea>,
134 pub instance_states: HashMap<String, WidgetInstanceState>,
135 pub focus_key: String,
136 pub tabbable: Vec<String>,
137 pub focus_cursor: Option<FocusCursor>,
138 pub embeds: Vec<EmbedRect>,
143 pub overlays: Vec<OverlayRow>,
152 pub scroll_regions: Vec<ScrollRegion>,
156}
157
158#[derive(Debug, Clone)]
163pub struct OverlayRow {
164 pub buffer_row: u32,
165 pub entry: TextPropertyEntry,
166}
167
168#[derive(Debug, Clone, Copy)]
176pub struct EmbedRect {
177 pub window_id: u32,
178 pub buffer_row: u32,
179 pub col_in_row: u32,
180 pub width_cols: u32,
181 pub height_rows: u32,
182}
183
184#[derive(Debug, Clone)]
193pub struct ScrollRegion {
194 pub list_key: String,
195 pub buffer_row: u32,
196 pub col_in_row: u32,
197 pub width_cols: u32,
198 pub height_rows: u32,
199 pub total: usize,
200 pub visible: usize,
201 pub scroll: usize,
202}
203
204#[derive(Default)]
209struct CollectedOutput {
210 entries: Vec<TextPropertyEntry>,
211 hits: Vec<HitArea>,
212 focus_cursor: Option<FocusCursor>,
213 embeds: Vec<EmbedRect>,
214 overlays: Vec<OverlayRow>,
215 scroll_regions: Vec<ScrollRegion>,
216}
217
218pub fn render_spec(
228 spec: &WidgetSpec,
229 prev: &HashMap<String, WidgetInstanceState>,
230 prev_focus_key: &str,
231 panel_width: u32,
232) -> RenderOutput {
233 render_spec_inner(spec, prev, prev_focus_key, panel_width, true)
234}
235
236pub fn render_spec_no_autofocus(
242 spec: &WidgetSpec,
243 prev: &HashMap<String, WidgetInstanceState>,
244 focus_key: &str,
245 panel_width: u32,
246) -> RenderOutput {
247 render_spec_inner(spec, prev, focus_key, panel_width, false)
248}
249
250fn render_spec_inner(
251 spec: &WidgetSpec,
252 prev: &HashMap<String, WidgetInstanceState>,
253 prev_focus_key: &str,
254 panel_width: u32,
255 auto_focus_first: bool,
256) -> RenderOutput {
257 let mut tabbable = Vec::new();
261 collect_tabbable(spec, &mut tabbable);
262 let focus_key = if !prev_focus_key.is_empty() && tabbable.iter().any(|k| k == prev_focus_key) {
263 prev_focus_key.to_string()
264 } else if auto_focus_first {
265 tabbable.first().cloned().unwrap_or_default()
266 } else {
267 String::new()
268 };
269
270 let mut next_state = HashMap::new();
271 let collected = render_collected(spec, prev, &mut next_state, &focus_key, panel_width);
272 RenderOutput {
273 entries: collected.entries,
274 hits: collected.hits,
275 instance_states: next_state,
276 focus_key,
277 tabbable,
278 focus_cursor: collected.focus_cursor,
279 embeds: collected.embeds,
280 overlays: collected.overlays,
281 scroll_regions: collected.scroll_regions,
282 }
283}
284
285fn labeled_section_width_pct(spec: &WidgetSpec) -> Option<u32> {
302 let WidgetSpec::LabeledSection { width_pct, .. } = spec else {
303 return None;
304 };
305 width_pct.filter(|pct| (1..=100).contains(pct))
306}
307
308fn predicts_block(spec: &WidgetSpec) -> bool {
309 match spec {
310 WidgetSpec::Col { children, .. } => {
311 if children.len() > 1 {
312 return true;
313 }
314 children.first().map(predicts_block).unwrap_or(false)
315 }
316 WidgetSpec::LabeledSection { .. } => true,
317 WidgetSpec::Tree { .. } => true,
318 WidgetSpec::List { .. } => true,
319 WidgetSpec::Text { rows, .. } => *rows > 1,
320 WidgetSpec::WindowEmbed { rows, .. } => *rows > 1,
321 WidgetSpec::Raw { entries, .. } => entries.len() > 1,
322 WidgetSpec::Row { children, .. } => children.iter().any(predicts_block),
323 _ => false,
324 }
325}
326
327enum RowPiece {
331 Inline {
332 entry: TextPropertyEntry,
333 hits: Vec<HitArea>,
334 focus_cursor: Option<FocusCursor>,
339 embeds: Vec<EmbedRect>,
344 scroll_regions: Vec<ScrollRegion>,
346 },
347 Block {
348 column_width: u32,
353 entries: Vec<TextPropertyEntry>,
354 hits: Vec<HitArea>,
355 focus_cursor: Option<FocusCursor>,
356 embeds: Vec<EmbedRect>,
361 scroll_regions: Vec<ScrollRegion>,
364 },
365 Flex,
366}
367
368fn strip_trailing_newline(entry: &mut TextPropertyEntry) {
374 if entry.text.ends_with('\n') {
375 entry.text.pop();
376 }
377}
378
379fn ensure_trailing_newline(entry: &mut TextPropertyEntry) {
385 if !entry.text.ends_with('\n') {
386 entry.text.push('\n');
387 }
388}
389
390fn collect_tabbable(spec: &WidgetSpec, out: &mut Vec<String>) {
395 match spec {
396 WidgetSpec::Button {
397 key: Some(k),
398 disabled,
399 ..
400 } if !k.is_empty() && !*disabled => {
401 out.push(k.clone());
402 }
403 WidgetSpec::Toggle { key: Some(k), .. }
404 | WidgetSpec::Text { key: Some(k), .. }
405 | WidgetSpec::Tree { key: Some(k), .. }
406 if !k.is_empty() =>
407 {
408 out.push(k.clone());
409 }
410 WidgetSpec::List {
411 key: Some(k),
412 focusable,
413 ..
414 } if !k.is_empty() && *focusable => {
415 out.push(k.clone());
416 }
417 _ => {}
418 }
419 for c in spec.children() {
420 collect_tabbable(c, out);
421 }
422}
423
424fn render_collected(
436 spec: &WidgetSpec,
437 prev: &HashMap<String, WidgetInstanceState>,
438 next_state: &mut HashMap<String, WidgetInstanceState>,
439 focus_key: &str,
440 panel_width: u32,
441) -> CollectedOutput {
442 match spec {
443 WidgetSpec::Row { children, wrap, .. } => {
444 collect_row(children, *wrap, prev, next_state, focus_key, panel_width)
445 }
446 WidgetSpec::Col { children, .. } => {
447 collect_col(children, prev, next_state, focus_key, panel_width)
448 }
449 WidgetSpec::HintBar { entries, .. } => collect_hint_bar(entries),
450 WidgetSpec::Toggle {
451 checked,
452 label,
453 focused,
454 key,
455 } => collect_toggle(*checked, label, *focused, key.as_deref(), focus_key),
456 WidgetSpec::Button {
457 label,
458 focused,
459 intent,
460 key,
461 disabled,
462 } => collect_button(
463 label,
464 *focused,
465 *intent,
466 key.as_deref(),
467 *disabled,
468 focus_key,
469 ),
470 WidgetSpec::Spacer { cols, .. } => collect_spacer(*cols),
471 WidgetSpec::Divider { ch, style, .. } => collect_divider(ch, style.as_ref(), panel_width),
472 WidgetSpec::List {
473 items,
474 item_specs,
475 item_keys,
476 selected_index,
477 visible_rows,
478 key: list_key,
479 ..
480 } => collect_list(
481 items,
482 item_specs,
483 item_keys,
484 *selected_index,
485 *visible_rows,
486 list_key.as_deref(),
487 prev,
488 next_state,
489 focus_key,
490 panel_width,
491 ),
492 WidgetSpec::Tree {
493 nodes,
494 item_keys,
495 selected_index,
496 visible_rows,
497 expanded_keys,
498 checkable,
499 key: tree_key,
500 } => render_widget_tree(
501 nodes,
502 item_keys,
503 *selected_index,
504 *visible_rows,
505 expanded_keys,
506 *checkable,
507 tree_key.as_deref(),
508 prev,
509 next_state,
510 ),
511 WidgetSpec::Text {
512 value,
513 cursor_byte,
514 focused,
515 label,
516 placeholder,
517 rows,
518 field_width,
519 max_visible_chars,
520 full_width,
521 completions: _,
522 completions_visible_rows,
523 key,
524 } => render_widget_text(
525 value,
526 *cursor_byte,
527 *focused,
528 label,
529 placeholder.as_deref(),
530 *rows,
531 *field_width,
532 *max_visible_chars,
533 *full_width,
534 *completions_visible_rows,
535 key.as_deref(),
536 prev,
537 next_state,
538 focus_key,
539 panel_width,
540 ),
541 WidgetSpec::LabeledSection { label, child, .. } => {
542 collect_labeled_section(label, child, prev, next_state, focus_key, panel_width)
543 }
544 WidgetSpec::WindowEmbed {
545 window_id, rows, ..
546 } => collect_window_embed(*window_id, *rows, panel_width),
547 WidgetSpec::Raw { entries, .. } => collect_raw(entries),
548 WidgetSpec::Overlay { child, .. } => {
549 collect_overlay(child, prev, next_state, focus_key, panel_width)
550 }
551 }
552}
553
554#[allow(clippy::too_many_arguments)]
561fn collect_row(
562 children: &[WidgetSpec],
563 wrap: bool,
564 prev: &HashMap<String, WidgetInstanceState>,
565 next_state: &mut HashMap<String, WidgetInstanceState>,
566 focus_key: &str,
567 panel_width: u32,
568) -> CollectedOutput {
569 let mut entries: Vec<TextPropertyEntry> = Vec::new();
570 let mut hits: Vec<HitArea> = Vec::new();
571 let mut focus_cursor: Option<FocusCursor> = None;
572 let mut embeds: Vec<EmbedRect> = Vec::new();
573 let mut overlays: Vec<OverlayRow> = Vec::new();
574 let mut scroll_regions: Vec<ScrollRegion> = Vec::new();
575
576 let block_indices: Vec<usize> = children
601 .iter()
602 .enumerate()
603 .filter(|(_, c)| predicts_block(c))
604 .map(|(i, _)| i)
605 .collect();
606 let block_count = block_indices.len();
607 let mut per_child_width: Vec<u32> = children.iter().map(|_| panel_width).collect();
612 if block_count > 0 {
613 let mut explicit_total: u32 = 0;
614 let mut explicit_count: u32 = 0;
615 for &idx in &block_indices {
616 if let Some(pct) = labeled_section_width_pct(&children[idx]) {
617 let w = (panel_width as u64 * pct as u64 / 100) as u32;
618 per_child_width[idx] = w.max(1);
619 explicit_total = explicit_total.saturating_add(w);
620 explicit_count += 1;
621 }
622 }
623 let remaining = panel_width.saturating_sub(explicit_total);
624 let implicit_count = (block_count as u32).saturating_sub(explicit_count).max(1);
625 let each_implicit = (remaining / implicit_count).max(1);
626 for &idx in &block_indices {
627 if labeled_section_width_pct(&children[idx]).is_none() {
628 per_child_width[idx] = each_implicit;
629 }
630 }
631 }
632 let mut row_pieces: Vec<RowPiece> = Vec::new();
633 for (idx, child) in children.iter().enumerate() {
634 if let WidgetSpec::Spacer { flex: true, .. } = child {
635 row_pieces.push(RowPiece::Flex);
636 continue;
637 }
638 let child_panel_width = per_child_width[idx];
639 let child_out = render_collected(child, prev, next_state, focus_key, child_panel_width);
640 overlays.extend(child_out.overlays);
645 if child_out.entries.is_empty() {
646 debug_assert!(child_out.hits.is_empty(), "empty children produce no hits");
647 continue;
648 }
649 if child_out.entries.len() == 1 {
650 let mut entry = child_out.entries.into_iter().next().unwrap();
651 strip_trailing_newline(&mut entry);
656 row_pieces.push(RowPiece::Inline {
657 entry,
658 hits: child_out.hits,
659 focus_cursor: child_out.focus_cursor,
660 embeds: child_out.embeds,
661 scroll_regions: child_out.scroll_regions,
662 });
663 } else {
664 row_pieces.push(RowPiece::Block {
665 column_width: child_panel_width,
666 entries: child_out.entries,
667 hits: child_out.hits,
668 focus_cursor: child_out.focus_cursor,
669 embeds: child_out.embeds,
670 scroll_regions: child_out.scroll_regions,
671 });
672 }
673 }
674 let has_blocks = row_pieces
678 .iter()
679 .any(|p| matches!(p, RowPiece::Block { .. }));
680 if has_blocks {
681 zip_row_blocks(
682 row_pieces,
683 panel_width,
684 &mut entries,
685 &mut hits,
686 &mut focus_cursor,
687 &mut embeds,
688 &mut scroll_regions,
689 );
690 } else if wrap {
691 assemble_wrapped_row(row_pieces, panel_width, &mut entries, &mut hits);
697 } else {
698 let inline_natural: usize = row_pieces
704 .iter()
705 .filter_map(|p| match p {
706 RowPiece::Inline { entry, .. } => {
707 Some(crate::primitives::display_width::str_width(&entry.text))
708 }
709 _ => None,
710 })
711 .sum();
712 let flex_count = row_pieces
713 .iter()
714 .filter(|p| matches!(p, RowPiece::Flex))
715 .count();
716 let flex_total = (panel_width as usize).saturating_sub(inline_natural);
717 let (flex_each, flex_extra) = match flex_total.checked_div(flex_count) {
721 Some(each) => (each, flex_total % flex_count),
722 None => (0, 0),
723 };
724
725 let mut acc: Option<TextPropertyEntry> = None;
730 let mut flex_seen = 0usize;
731 for piece in row_pieces {
732 match piece {
733 RowPiece::Inline {
734 mut entry,
735 hits: child_hits,
736 focus_cursor: child_focus,
737 embeds: child_embeds,
738 scroll_regions: child_scroll,
739 } => {
740 let inline_shift = match acc.as_ref() {
741 Some(e) => e.text.len(),
742 None => 0,
743 };
744 for mut h in child_hits {
745 h.byte_start += inline_shift;
746 h.byte_end += inline_shift;
747 hits.push(h);
748 }
749 if let Some(mut fc) = child_focus {
750 fc.byte_in_row += inline_shift as u32;
752 focus_cursor = Some(fc);
753 }
754 for mut emb in child_embeds {
755 emb.col_in_row += inline_shift as u32;
761 embeds.push(emb);
762 }
763 for mut sr in child_scroll {
764 sr.col_in_row += inline_shift as u32;
765 scroll_regions.push(sr);
766 }
767 match acc.as_mut() {
768 Some(merged) => merge_inline(merged, &mut entry),
769 None => acc = Some(entry),
770 }
771 }
772 RowPiece::Flex => {
773 let n = flex_each + if flex_seen < flex_extra { 1 } else { 0 };
775 flex_seen += 1;
776 if n > 0 {
777 let mut text = String::with_capacity(n);
778 for _ in 0..n {
779 text.push(' ');
780 }
781 let entry = TextPropertyEntry {
782 text,
783 properties: Default::default(),
784 style: None,
785 inline_overlays: Vec::new(),
786 segments: Vec::new(),
787 pad_to_chars: None,
788 truncate_to_chars: None,
789 };
790 match acc.as_mut() {
791 Some(merged) => {
792 let mut e = entry;
793 merge_inline(merged, &mut e);
794 }
795 None => acc = Some(entry),
796 }
797 }
798 }
799 RowPiece::Block { .. } => {
800 debug_assert!(false, "block piece in inline-only Row path");
803 }
804 }
805 }
806 if let Some(mut merged) = acc {
807 ensure_trailing_newline(&mut merged);
808 entries.push(merged);
809 }
810 }
811
812 CollectedOutput {
813 entries,
814 hits,
815 focus_cursor,
816 embeds,
817 overlays,
818 scroll_regions,
819 }
820}
821
822#[allow(clippy::too_many_arguments)]
823fn collect_col(
824 children: &[WidgetSpec],
825 prev: &HashMap<String, WidgetInstanceState>,
826 next_state: &mut HashMap<String, WidgetInstanceState>,
827 focus_key: &str,
828 panel_width: u32,
829) -> CollectedOutput {
830 let mut entries: Vec<TextPropertyEntry> = Vec::new();
831 let mut hits: Vec<HitArea> = Vec::new();
832 let mut focus_cursor: Option<FocusCursor> = None;
833 let mut embeds: Vec<EmbedRect> = Vec::new();
834 let mut overlays: Vec<OverlayRow> = Vec::new();
835 let mut scroll_regions: Vec<ScrollRegion> = Vec::new();
836
837 for child in children {
838 let is_overlay = matches!(child, WidgetSpec::Overlay { .. });
846 let child_out = render_collected(child, prev, next_state, focus_key, panel_width);
847 let row_offset = entries.len() as u32;
848 if is_overlay {
849 for (i, e) in child_out.entries.into_iter().enumerate() {
856 overlays.push(OverlayRow {
857 buffer_row: row_offset + i as u32,
858 entry: e,
859 });
860 }
861 for mut h in child_out.hits {
862 h.buffer_row += row_offset;
863 hits.push(h);
864 }
865 if let Some(mut fc) = child_out.focus_cursor {
870 fc.buffer_row += row_offset;
871 focus_cursor = Some(fc);
872 }
873 overlays.extend(child_out.overlays);
876 for mut emb in child_out.embeds {
882 emb.buffer_row += row_offset;
883 embeds.push(emb);
884 }
885 for mut sr in child_out.scroll_regions {
886 sr.buffer_row += row_offset;
887 scroll_regions.push(sr);
888 }
889 continue;
890 }
891 for mut h in child_out.hits {
892 h.buffer_row += row_offset;
893 hits.push(h);
894 }
895 if let Some(mut fc) = child_out.focus_cursor {
896 fc.buffer_row += row_offset;
897 focus_cursor = Some(fc);
898 }
899 for mut emb in child_out.embeds {
900 emb.buffer_row += row_offset;
901 embeds.push(emb);
902 }
903 for mut sr in child_out.scroll_regions {
904 sr.buffer_row += row_offset;
905 scroll_regions.push(sr);
906 }
907 overlays.extend(child_out.overlays.into_iter().map(|mut o| {
908 o.buffer_row += row_offset;
909 o
910 }));
911 entries.extend(child_out.entries);
912 }
913
914 CollectedOutput {
915 entries,
916 hits,
917 focus_cursor,
918 embeds,
919 overlays,
920 scroll_regions,
921 }
922}
923
924fn collect_hint_bar(entries: &[HintEntry]) -> CollectedOutput {
925 let mut out = CollectedOutput::default();
926 let mut entry = render_hint_bar(entries);
927 ensure_trailing_newline(&mut entry);
928 out.entries.push(entry);
929 out
933}
934
935fn collect_toggle(
936 checked: bool,
937 label: &str,
938 focused: bool,
939 key: Option<&str>,
940 focus_key: &str,
941) -> CollectedOutput {
942 let mut out = CollectedOutput::default();
943 let is_focused = match key {
950 Some(k) if !k.is_empty() => k == focus_key,
951 _ => focused,
952 };
953 let mut entry = render_toggle(checked, label, is_focused);
954 let byte_end = entry.text.len();
955 out.hits.push(HitArea {
956 widget_key: key.unwrap_or("").to_string(),
957 widget_kind: "toggle",
958 buffer_row: 0,
959 byte_start: 0,
960 byte_end,
961 payload: json!({ "checked": !checked }),
962 event_type: "toggle",
963 });
964 ensure_trailing_newline(&mut entry);
965 out.entries.push(entry);
966 out
967}
968
969#[allow(clippy::too_many_arguments)]
970fn collect_button(
971 label: &str,
972 focused: bool,
973 intent: ButtonKind,
974 key: Option<&str>,
975 disabled: bool,
976 focus_key: &str,
977) -> CollectedOutput {
978 let mut out = CollectedOutput::default();
979 let is_focused = match key {
980 Some(k) if !k.is_empty() && !disabled => k == focus_key,
981 _ => !disabled && focused,
982 };
983 let mut entry = render_button(label, is_focused, intent, disabled);
984 if !disabled {
991 let byte_end = entry.text.len();
992 out.hits.push(HitArea {
993 widget_key: key.unwrap_or("").to_string(),
994 widget_kind: "button",
995 buffer_row: 0,
996 byte_start: 0,
997 byte_end,
998 payload: json!({}),
999 event_type: "activate",
1000 });
1001 }
1002 ensure_trailing_newline(&mut entry);
1003 out.entries.push(entry);
1004 out
1005}
1006
1007fn collect_spacer(cols: u32) -> CollectedOutput {
1008 let mut out = CollectedOutput::default();
1009 let cols = cols.min(4096) as usize;
1015 let mut text = String::with_capacity(cols + 1);
1016 for _ in 0..cols {
1017 text.push(' ');
1018 }
1019 let mut entry = TextPropertyEntry {
1020 text,
1021 properties: Default::default(),
1022 style: None,
1023 inline_overlays: Vec::new(),
1024 segments: Vec::new(),
1025 pad_to_chars: None,
1026 truncate_to_chars: None,
1027 };
1028 ensure_trailing_newline(&mut entry);
1029 out.entries.push(entry);
1030 out
1031}
1032
1033fn collect_divider(ch: &str, style: Option<&OverlayOptions>, panel_width: u32) -> CollectedOutput {
1034 let mut out = CollectedOutput::default();
1035 let glyph = if ch.is_empty() { " " } else { ch };
1041 let cols = (panel_width as usize).min(4096);
1042 let mut text = String::with_capacity(cols * glyph.len() + 1);
1043 for _ in 0..cols {
1044 text.push_str(glyph);
1045 }
1046 let mut entry = TextPropertyEntry {
1047 text,
1048 properties: Default::default(),
1049 style: style.cloned(),
1050 inline_overlays: Vec::new(),
1051 segments: Vec::new(),
1052 pad_to_chars: None,
1053 truncate_to_chars: None,
1054 };
1055 ensure_trailing_newline(&mut entry);
1056 out.entries.push(entry);
1057 out
1058}
1059
1060#[allow(clippy::too_many_arguments)]
1061fn collect_list(
1062 items: &[TextPropertyEntry],
1063 item_specs: &[WidgetSpec],
1064 item_keys: &[String],
1065 selected_index: i32,
1066 visible_rows: u32,
1067 list_key: Option<&str>,
1068 prev: &HashMap<String, WidgetInstanceState>,
1069 next_state: &mut HashMap<String, WidgetInstanceState>,
1070 focus_key: &str,
1071 panel_width: u32,
1072) -> CollectedOutput {
1073 let mut entries: Vec<TextPropertyEntry> = Vec::new();
1074 let mut hits: Vec<HitArea> = Vec::new();
1075 let mut scroll_regions: Vec<ScrollRegion> = Vec::new();
1076
1077 let use_specs = !item_specs.is_empty();
1086 let total = if use_specs {
1087 item_specs.len() as u32
1088 } else {
1089 items.len() as u32
1090 };
1091 let avail_rows = visible_rows.max(1);
1093
1094 let (prev_scroll, prev_sel, prev_user_scrolled) = list_key
1098 .and_then(|k| prev.get(k))
1099 .and_then(|s| match s {
1100 WidgetInstanceState::List {
1101 scroll_offset,
1102 selected_index,
1103 user_scrolled,
1104 ..
1105 } => Some((*scroll_offset, *selected_index, *user_scrolled)),
1106 _ => None,
1107 })
1108 .unwrap_or((0, selected_index, false));
1109 let effective_sel = if prev_sel < 0 || total == 0 {
1114 -1
1115 } else if (prev_sel as u32) >= total {
1116 (total - 1) as i32
1117 } else {
1118 prev_sel
1119 };
1120
1121 let mut rendered_cards: Vec<Vec<TextPropertyEntry>> = Vec::new();
1127 let mut item_height: u32 = 1;
1128 if use_specs {
1129 rendered_cards.reserve(item_specs.len());
1130 for item_spec in item_specs.iter() {
1131 let mut scratch = HashMap::new();
1132 let card_entries =
1133 render_collected(item_spec, prev, &mut scratch, focus_key, panel_width).entries;
1134 item_height = item_height.max((card_entries.len() as u32).max(1));
1135 rendered_cards.push(card_entries);
1136 }
1137 }
1138 let visible_items = if use_specs {
1140 (avail_rows / item_height).max(1)
1141 } else {
1142 avail_rows
1143 };
1144
1145 if use_specs && total > visible_items && panel_width > 1 {
1151 let card_width = panel_width - 1;
1152 rendered_cards.clear();
1153 for item_spec in item_specs.iter() {
1154 let mut scratch = HashMap::new();
1155 let card_entries =
1156 render_collected(item_spec, prev, &mut scratch, focus_key, card_width).entries;
1157 rendered_cards.push(card_entries);
1158 }
1159 }
1160
1161 let mut scroll = prev_scroll;
1168 if effective_sel >= 0 && !prev_user_scrolled {
1169 let sel = effective_sel as u32;
1170 if sel < scroll {
1171 scroll = sel;
1172 }
1173 if sel >= scroll + visible_items {
1174 scroll = sel + 1 - visible_items;
1175 }
1176 }
1177 let max_scroll = total.saturating_sub(visible_items);
1178 if scroll > max_scroll {
1179 scroll = max_scroll;
1180 }
1181 if let Some(k) = list_key {
1184 next_state.insert(
1185 k.to_string(),
1186 WidgetInstanceState::List {
1187 scroll_offset: scroll,
1188 selected_index: effective_sel,
1189 item_height,
1190 user_scrolled: prev_user_scrolled,
1191 },
1192 );
1193 }
1194
1195 let start = scroll as usize;
1196 let end = ((scroll + visible_items) as usize).min(total as usize);
1197 let blank_row = || {
1199 let mut padding = TextPropertyEntry {
1200 text: String::new(),
1201 properties: Default::default(),
1202 style: None,
1203 inline_overlays: Vec::new(),
1204 segments: Vec::new(),
1205 pad_to_chars: None,
1206 truncate_to_chars: None,
1207 };
1208 ensure_trailing_newline(&mut padding);
1209 padding
1210 };
1211 let mark_selected = |entry: &mut TextPropertyEntry| {
1214 let mut style = entry.style.clone().unwrap_or_default();
1215 style.bg = Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG));
1216 style.extend_to_line_end = true;
1217 entry.style = Some(style);
1218 };
1219 let mark_selected_card = |entry: &mut TextPropertyEntry| {
1227 entry.text = entry
1228 .text
1229 .replace('╭', "┏")
1230 .replace('╮', "┓")
1231 .replace('╰', "┗")
1232 .replace('╯', "┛")
1233 .replace('─', "━")
1234 .replace('│', "┃");
1235 let mut style = entry.style.clone().unwrap_or_default();
1236 style.bold = true;
1237 if entry.text.starts_with('┏') || entry.text.starts_with('┗') {
1238 style.fg = Some(OverlayColorSpec::theme_key("ui.popup_border_fg"));
1241 entry.style = Some(style);
1242 } else {
1243 entry.style = Some(style);
1250 let bar = '┃';
1251 let bar_len = bar.len_utf8();
1252 let first = entry.text.find(bar);
1253 let last = entry.text.rfind(bar);
1254 for pos in [first, last].into_iter().flatten().collect::<HashSet<_>>() {
1255 entry.inline_overlays.push(InlineOverlay {
1256 start: pos,
1257 end: pos + bar_len,
1258 style: OverlayOptions {
1259 fg: Some(OverlayColorSpec::theme_key("ui.popup_border_fg")),
1260 bold: true,
1261 ..Default::default()
1262 },
1263 properties: Default::default(),
1264 unit: OffsetUnit::Byte,
1265 });
1266 }
1267 }
1268 };
1269
1270 let rows_emitted: u32 = if use_specs {
1271 let mut emitted = 0u32;
1280 let last = if end < total as usize { end + 1 } else { end };
1281 'cards: for i in start..last {
1282 let is_selected = i as i32 == effective_sel;
1283 let item_key = item_keys.get(i).cloned().unwrap_or_default();
1284 let card = &rendered_cards[i];
1285 for r in 0..item_height as usize {
1286 if emitted >= avail_rows {
1287 break 'cards;
1288 }
1289 let mut entry = card.get(r).cloned().unwrap_or_else(blank_row);
1290 entry.normalize_widths();
1291 if is_selected {
1292 mark_selected_card(&mut entry);
1293 }
1294 let byte_end = entry.text.len();
1295 ensure_trailing_newline(&mut entry);
1296 let hit_row = entries.len() as u32;
1297 entries.push(entry);
1298 hits.push(HitArea {
1299 widget_key: item_key.clone(),
1300 widget_kind: "list",
1301 buffer_row: hit_row,
1302 byte_start: 0,
1303 byte_end,
1304 payload: json!({
1305 "index": i as i64,
1306 "key": item_key,
1307 "list_key": list_key,
1308 }),
1309 event_type: "select",
1310 });
1311 emitted += 1;
1312 }
1313 }
1314 emitted
1315 } else {
1316 for (offset, item) in items[start..end.min(items.len())].iter().enumerate() {
1318 let i = start + offset;
1319 let mut entry = item.clone();
1320 entry.normalize_widths();
1321 if i as i32 == effective_sel {
1322 mark_selected(&mut entry);
1323 }
1324 let byte_end = entry.text.len();
1325 ensure_trailing_newline(&mut entry);
1326 entries.push(entry);
1327 let item_key = item_keys.get(i).cloned().unwrap_or_default();
1328 let hit_row = (entries.len() - 1) as u32;
1329 hits.push(HitArea {
1330 widget_key: item_key.clone(),
1331 widget_kind: "list",
1332 buffer_row: hit_row,
1333 byte_start: 0,
1334 byte_end,
1335 payload: json!({
1336 "index": i as i64,
1337 "key": item_key,
1338 "list_key": list_key,
1343 }),
1344 event_type: "select",
1345 });
1346 }
1347 (end - start) as u32
1348 };
1349
1350 for _ in rows_emitted..avail_rows {
1354 entries.push(blank_row());
1355 }
1356
1357 if total > visible_items {
1361 if let Some(k) = list_key {
1362 scroll_regions.push(ScrollRegion {
1363 list_key: k.to_string(),
1364 buffer_row: 0,
1365 col_in_row: 0,
1366 width_cols: panel_width,
1367 height_rows: avail_rows,
1368 total: total as usize,
1369 visible: visible_items as usize,
1370 scroll: scroll as usize,
1371 });
1372 }
1373 }
1374
1375 CollectedOutput {
1376 entries,
1377 hits,
1378 focus_cursor: None,
1379 embeds: Vec::new(),
1380 overlays: Vec::new(),
1381 scroll_regions,
1382 }
1383}
1384
1385#[allow(clippy::too_many_arguments)]
1386fn collect_labeled_section(
1387 label: &str,
1388 child: &WidgetSpec,
1389 prev: &HashMap<String, WidgetInstanceState>,
1390 next_state: &mut HashMap<String, WidgetInstanceState>,
1391 focus_key: &str,
1392 panel_width: u32,
1393) -> CollectedOutput {
1394 let mut entries: Vec<TextPropertyEntry> = Vec::new();
1395 let mut hits: Vec<HitArea> = Vec::new();
1396 let mut focus_cursor: Option<FocusCursor> = None;
1397 let mut embeds: Vec<EmbedRect> = Vec::new();
1398 let mut overlays: Vec<OverlayRow> = Vec::new();
1399 let mut scroll_regions: Vec<ScrollRegion> = Vec::new();
1400
1401 let inner_width = panel_width.saturating_sub(4).max(1);
1404 let child_out = render_collected(child, prev, next_state, focus_key, inner_width);
1405 overlays.extend(child_out.overlays.into_iter().map(|mut o| {
1415 o.buffer_row += 1;
1416 o
1417 }));
1418
1419 let total_cols = panel_width.max(2) as usize;
1423 entries.push(render_section_top_border(label, total_cols));
1424
1425 for mut child_entry in child_out.entries {
1430 strip_trailing_newline(&mut child_entry);
1431 let wrapped = wrap_in_side_border(child_entry, inner_width as usize);
1432 let row_offset = entries.len() as u32;
1433 let _ = row_offset;
1438 entries.push(wrapped);
1439 }
1440
1441 let prefix_bytes = LEFT_BORDER_PREFIX.len();
1445 for mut h in child_out.hits {
1446 h.buffer_row += 1;
1447 h.byte_start += prefix_bytes;
1448 h.byte_end += prefix_bytes;
1449 hits.push(h);
1450 }
1451 if let Some(mut fc) = child_out.focus_cursor {
1452 fc.buffer_row += 1;
1453 fc.byte_in_row += prefix_bytes as u32;
1454 focus_cursor = Some(fc);
1455 }
1456 let prefix_cols = LEFT_BORDER_PREFIX.chars().count() as u32;
1459 for mut emb in child_out.embeds {
1460 emb.buffer_row += 1;
1461 emb.col_in_row += prefix_cols;
1462 embeds.push(emb);
1463 }
1464 for mut sr in child_out.scroll_regions {
1465 sr.buffer_row += 1;
1466 sr.col_in_row += prefix_cols;
1467 sr.width_cols = inner_width;
1471 scroll_regions.push(sr);
1472 }
1473
1474 entries.push(render_section_bottom_border(total_cols));
1475
1476 CollectedOutput {
1477 entries,
1478 hits,
1479 focus_cursor,
1480 embeds,
1481 overlays,
1482 scroll_regions,
1483 }
1484}
1485
1486fn collect_window_embed(window_id: u32, embed_rows: u32, panel_width: u32) -> CollectedOutput {
1487 let mut out = CollectedOutput::default();
1488 let cols = panel_width.max(1) as usize;
1493 for _ in 0..embed_rows {
1494 let mut text = String::with_capacity(cols + 1);
1495 for _ in 0..cols {
1496 text.push(' ');
1497 }
1498 text.push('\n');
1499 out.entries.push(TextPropertyEntry {
1500 text,
1501 properties: Default::default(),
1502 style: None,
1503 inline_overlays: Vec::new(),
1504 segments: Vec::new(),
1505 pad_to_chars: None,
1506 truncate_to_chars: None,
1507 });
1508 }
1509 out.embeds.push(EmbedRect {
1510 window_id,
1511 buffer_row: 0,
1512 col_in_row: 0,
1513 width_cols: panel_width,
1514 height_rows: embed_rows,
1515 });
1516 out
1517}
1518
1519fn collect_raw(raw_entries: &[TextPropertyEntry]) -> CollectedOutput {
1520 let mut out = CollectedOutput::default();
1521 for raw_entry in raw_entries {
1530 let mut e = raw_entry.clone();
1531 e.normalize_widths();
1532 ensure_trailing_newline(&mut e);
1533 out.entries.push(e);
1534 }
1535 out
1536}
1537
1538#[allow(clippy::too_many_arguments)]
1539fn collect_overlay(
1540 child: &WidgetSpec,
1541 prev: &HashMap<String, WidgetInstanceState>,
1542 next_state: &mut HashMap<String, WidgetInstanceState>,
1543 focus_key: &str,
1544 panel_width: u32,
1545) -> CollectedOutput {
1546 let child_out = render_collected(child, prev, next_state, focus_key, panel_width);
1555 CollectedOutput {
1556 entries: child_out.entries,
1557 hits: child_out.hits,
1558 focus_cursor: child_out.focus_cursor,
1559 embeds: child_out.embeds,
1560 overlays: child_out.overlays,
1561 scroll_regions: child_out.scroll_regions,
1562 }
1563}
1564
1565#[allow(clippy::too_many_arguments)]
1566fn render_widget_text(
1567 value: &str,
1568 cursor_byte: i32,
1569 focused: bool,
1570 label: &str,
1571 placeholder: Option<&str>,
1572 rows: u32,
1573 field_width: u32,
1574 max_visible_chars: u32,
1575 full_width: bool,
1576 completions_visible_rows: u32,
1577 key: Option<&str>,
1578 prev: &HashMap<String, WidgetInstanceState>,
1579 next_state: &mut HashMap<String, WidgetInstanceState>,
1580 focus_key: &str,
1581 panel_width: u32,
1582) -> CollectedOutput {
1583 let mut out = CollectedOutput::default();
1584 let effective_visible_rows = if completions_visible_rows == 0 {
1588 5u32
1589 } else {
1590 completions_visible_rows
1591 };
1592
1593 let is_focused = match key.filter(|k| !k.is_empty()) {
1594 Some(k) => k == focus_key,
1595 None => focused,
1596 };
1597 let multiline = rows > 1;
1605 let mut effective_editor: crate::primitives::text_edit::TextEdit;
1606 let prev_scroll: u32;
1607 let mut prev_completions: Vec<fresh_core::api::CompletionItem> = Vec::new();
1613 let mut prev_completion_idx: usize = 0;
1614 let mut prev_completion_scroll: u32 = 0;
1615 match key.filter(|k| !k.is_empty()).and_then(|k| prev.get(k)) {
1616 Some(WidgetInstanceState::Text {
1617 editor,
1618 scroll,
1619 completions,
1620 completion_selected_index,
1621 completion_scroll_offset,
1622 }) => {
1623 effective_editor = editor.clone();
1624 prev_scroll = *scroll;
1625 prev_completions = completions.clone();
1626 prev_completion_idx = *completion_selected_index;
1627 prev_completion_scroll = *completion_scroll_offset;
1628 }
1629 _ => {
1630 effective_editor = if multiline {
1631 crate::primitives::text_edit::TextEdit::with_text(value)
1632 } else {
1633 crate::primitives::text_edit::TextEdit::single_line_with_text(value)
1634 };
1635 let seed = if cursor_byte < 0 {
1636 value.len()
1637 } else {
1638 (cursor_byte as usize).min(value.len())
1639 };
1640 effective_editor.set_cursor_from_flat(seed);
1641 prev_scroll = 0;
1642 }
1643 }
1644 if !prev_completions.is_empty() {
1648 prev_completion_idx = prev_completion_idx.min(prev_completions.len() - 1);
1649 } else {
1650 prev_completion_idx = 0;
1651 }
1652 let effective_value = effective_editor.value();
1653 let effective_cursor_byte = effective_editor.flat_cursor_byte() as i32;
1654 let effective_cursor = if is_focused {
1655 effective_cursor_byte
1656 } else {
1657 -1
1658 };
1659 let effective_field_width = if full_width && !multiline {
1673 let label_overhead = if label.is_empty() {
1674 0u32
1675 } else {
1676 label.chars().count() as u32 + 1
1677 };
1678 panel_width
1679 .saturating_sub(label_overhead)
1680 .saturating_sub(3)
1681 .max(1)
1682 } else {
1683 field_width
1684 };
1685 let selection_for_render = if is_focused {
1689 effective_editor.selection_flat_range()
1690 } else {
1691 None
1692 };
1693 let new_scroll;
1694 if multiline {
1695 let rendered = render_text_area(
1696 &effective_value,
1697 effective_cursor,
1698 selection_for_render,
1699 is_focused,
1700 label,
1701 placeholder,
1702 rows,
1703 effective_field_width,
1704 prev_scroll,
1705 panel_width,
1706 );
1707 new_scroll = rendered.scroll_row;
1708 if let (Some(buffer_row), Some(byte_in_row)) =
1709 (rendered.cursor_buffer_row, rendered.cursor_byte_in_row)
1710 {
1711 out.focus_cursor = Some(FocusCursor {
1712 buffer_row,
1713 byte_in_row: byte_in_row as u32,
1714 });
1715 }
1716 for (row_idx, mut e) in rendered.entries.into_iter().enumerate() {
1717 if let Some(k) = key.filter(|k| !k.is_empty()) {
1720 out.hits.push(HitArea {
1721 widget_key: k.to_string(),
1722 widget_kind: "text",
1723 buffer_row: row_idx as u32,
1724 byte_start: 0,
1725 byte_end: e.text.len(),
1726 payload: json!({}),
1727 event_type: "focus",
1728 });
1729 }
1730 ensure_trailing_newline(&mut e);
1731 out.entries.push(e);
1732 }
1733 } else {
1734 let rendered = render_text_input(
1735 &effective_value,
1736 effective_cursor,
1737 selection_for_render,
1738 is_focused,
1739 label,
1740 placeholder,
1741 max_visible_chars,
1742 effective_field_width,
1743 full_width,
1744 );
1745 new_scroll = 0;
1746 if let Some(byte_in_row) = rendered.cursor_byte_in_entry {
1747 out.focus_cursor = Some(FocusCursor {
1748 buffer_row: 0,
1749 byte_in_row: byte_in_row as u32,
1750 });
1751 }
1752 let mut entry = rendered.entry;
1753 if let Some(k) = key.filter(|k| !k.is_empty()) {
1759 out.hits.push(HitArea {
1760 widget_key: k.to_string(),
1761 widget_kind: "text",
1762 buffer_row: 0,
1763 byte_start: 0,
1764 byte_end: entry.text.len(),
1765 payload: json!({}),
1766 event_type: "focus",
1767 });
1768 }
1769 ensure_trailing_newline(&mut entry);
1770 out.entries.push(entry);
1771 }
1772 if !prev_completions.is_empty() {
1789 let popup_inner = panel_width as usize;
1798 let popup_total = popup_inner.saturating_add(4); let total = prev_completions.len() as u32;
1800 let visible = effective_visible_rows.max(1).min(total);
1801 let sel = prev_completion_idx as u32;
1815 let mut scroll = prev_completion_scroll;
1816 if sel >= scroll + visible {
1817 scroll = sel + 1 - visible;
1818 }
1819 let max_scroll = total.saturating_sub(visible);
1820 if scroll > max_scroll {
1821 scroll = max_scroll;
1822 }
1823 prev_completion_scroll = scroll;
1824
1825 let mut anchor: u32 = 1;
1838 out.overlays.push(OverlayRow {
1839 buffer_row: anchor,
1840 entry: render_completion_dim_separator_overlay(popup_total),
1841 });
1842 anchor += 1;
1843 let needs_scrollbar = total > visible;
1844 let end = (scroll + visible).min(total) as usize;
1845 for (visible_row, i) in (scroll as usize..end).enumerate() {
1846 let item = &prev_completions[i];
1847 let thumb = if needs_scrollbar {
1848 completion_scrollbar_glyph(visible_row as u32, visible, scroll, total)
1849 } else {
1850 None
1851 };
1852 out.overlays.push(OverlayRow {
1853 buffer_row: anchor,
1854 entry: render_completion_item_overlay(
1855 &item.value,
1856 item.kind.as_deref(),
1857 i == prev_completion_idx,
1858 popup_total,
1859 thumb,
1860 ),
1861 });
1862 anchor += 1;
1863 }
1864 out.overlays.push(OverlayRow {
1865 buffer_row: anchor,
1866 entry: render_completion_bottom_border(popup_total),
1867 });
1868 } else {
1869 prev_completion_scroll = 0;
1870 }
1871 if let Some(k) = key.filter(|k| !k.is_empty()) {
1872 next_state.insert(
1873 k.to_string(),
1874 WidgetInstanceState::Text {
1875 editor: effective_editor.clone(),
1876 scroll: new_scroll,
1877 completions: prev_completions,
1878 completion_selected_index: prev_completion_idx,
1879 completion_scroll_offset: prev_completion_scroll,
1880 },
1881 );
1882 }
1883 out
1884}
1885
1886#[allow(clippy::too_many_arguments)]
1887fn render_widget_tree(
1888 nodes: &[TreeNode],
1889 item_keys: &[String],
1890 selected_index: i32,
1891 visible_rows: u32,
1892 expanded_keys: &[String],
1893 checkable: bool,
1894 tree_key: Option<&str>,
1895 prev: &HashMap<String, WidgetInstanceState>,
1896 next_state: &mut HashMap<String, WidgetInstanceState>,
1897) -> CollectedOutput {
1898 let mut out = CollectedOutput::default();
1899 let prev_state = tree_key.filter(|k| !k.is_empty()).and_then(|k| prev.get(k));
1902 let (prev_scroll, prev_sel, prev_expanded) = match prev_state {
1903 Some(WidgetInstanceState::Tree {
1904 scroll_offset,
1905 selected_index,
1906 expanded_keys,
1907 }) => (*scroll_offset, *selected_index, expanded_keys.clone()),
1908 _ => {
1909 let seeded: HashSet<String> = expanded_keys.iter().cloned().collect();
1911 (0, selected_index, seeded)
1912 }
1913 };
1914
1915 let mut ancestor_open: Vec<bool> = Vec::new();
1927 let mut visible_indices: Vec<usize> = Vec::with_capacity(nodes.len());
1928 for (i, node) in nodes.iter().enumerate() {
1929 let depth = node.depth as usize;
1930 ancestor_open.truncate(depth);
1932 let visible = ancestor_open.iter().all(|open| *open);
1933 if visible {
1934 visible_indices.push(i);
1935 }
1936 let key = item_keys.get(i).cloned().unwrap_or_default();
1942 let is_open = if node.has_children {
1943 !key.is_empty() && prev_expanded.contains(&key)
1944 } else {
1945 true
1946 };
1947 ancestor_open.push(is_open);
1948 }
1949
1950 let total_visible = visible_indices.len() as u32;
1956 let visible = visible_rows.max(1);
1957 let clamp_to_visible = |abs: i32| -> i32 {
1958 if abs < 0 || nodes.is_empty() {
1959 return -1;
1960 }
1961 let abs = abs.min((nodes.len() as i32) - 1) as usize;
1962 if let Ok(_pos) = visible_indices.binary_search(&abs) {
1963 return abs as i32;
1964 }
1965 let earlier = visible_indices.iter().rev().find(|&&v| v <= abs);
1968 if let Some(&v) = earlier {
1969 return v as i32;
1970 }
1971 visible_indices.first().map(|&v| v as i32).unwrap_or(-1)
1972 };
1973 let effective_sel_abs = clamp_to_visible(prev_sel);
1974 let sel_visible_pos: i32 = if effective_sel_abs < 0 {
1978 -1
1979 } else {
1980 visible_indices
1981 .iter()
1982 .position(|&v| v == effective_sel_abs as usize)
1983 .map(|p| p as i32)
1984 .unwrap_or(-1)
1985 };
1986
1987 let mut scroll = prev_scroll;
1990 if sel_visible_pos >= 0 {
1991 let sel = sel_visible_pos as u32;
1992 if sel < scroll {
1993 scroll = sel;
1994 }
1995 if sel >= scroll + visible {
1996 scroll = sel + 1 - visible;
1997 }
1998 }
1999 let max_scroll = total_visible.saturating_sub(visible);
2000 if scroll > max_scroll {
2001 scroll = max_scroll;
2002 }
2003
2004 if let Some(k) = tree_key.filter(|k| !k.is_empty()) {
2006 next_state.insert(
2007 k.to_string(),
2008 WidgetInstanceState::Tree {
2009 scroll_offset: scroll,
2010 selected_index: effective_sel_abs,
2011 expanded_keys: prev_expanded.clone(),
2012 },
2013 );
2014 }
2015
2016 let start = scroll as usize;
2018 let end = ((scroll + visible) as usize).min(visible_indices.len());
2019 for &abs_idx in &visible_indices[start..end] {
2020 let mut node = nodes[abs_idx].clone();
2025 node.text.normalize_widths();
2026 let item_key = item_keys.get(abs_idx).cloned().unwrap_or_default();
2027 let is_expanded =
2028 node.has_children && !item_key.is_empty() && prev_expanded.contains(&item_key);
2029 let rendered = render_tree_row(&node, is_expanded, checkable);
2030 let mut entry = rendered.entry;
2031 let is_selected = abs_idx as i32 == effective_sel_abs;
2032 if is_selected {
2033 let mut style = entry.style.unwrap_or_default();
2034 style.bg = Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG));
2035 style.extend_to_line_end = true;
2036 entry.style = Some(style);
2037 }
2038 let row_byte_end = entry.text.len();
2039 ensure_trailing_newline(&mut entry);
2040 out.entries.push(entry);
2041 let hit_row = (out.entries.len() - 1) as u32;
2042 let tree_spec_key = tree_key.unwrap_or("").to_string();
2052 if let Some(disc_range) = rendered.disclosure_range {
2053 out.hits.push(HitArea {
2054 widget_key: tree_spec_key.clone(),
2055 widget_kind: "tree",
2056 buffer_row: hit_row,
2057 byte_start: disc_range.0,
2058 byte_end: disc_range.1,
2059 payload: json!({
2060 "index": abs_idx as i64,
2061 "key": item_key.clone(),
2062 "expanded": !is_expanded,
2063 }),
2064 event_type: "expand",
2065 });
2066 }
2067 if let Some(cb_range) = rendered.checkbox_range {
2074 let new_checked = !nodes[abs_idx].checked.unwrap_or(false);
2075 out.hits.push(HitArea {
2076 widget_key: tree_spec_key.clone(),
2077 widget_kind: "tree",
2078 buffer_row: hit_row,
2079 byte_start: cb_range.0,
2080 byte_end: cb_range.1,
2081 payload: json!({
2082 "index": abs_idx as i64,
2083 "key": item_key.clone(),
2084 "checked": new_checked,
2085 }),
2086 event_type: "toggle",
2087 });
2088 }
2089 let body_start = match (rendered.checkbox_range, rendered.disclosure_range) {
2093 (Some((_, end)), _) => end + 1, (None, Some((_, end))) => end,
2095 (None, None) => 0,
2096 };
2097 if body_start < row_byte_end {
2098 out.hits.push(HitArea {
2099 widget_key: tree_spec_key,
2100 widget_kind: "tree",
2101 buffer_row: hit_row,
2102 byte_start: body_start,
2103 byte_end: row_byte_end,
2104 payload: json!({
2105 "index": abs_idx as i64,
2106 "key": item_key,
2107 }),
2108 event_type: "select",
2109 });
2110 }
2111 }
2112 out
2113}
2114
2115const LEFT_BORDER_PREFIX: &str = "│ ";
2120const RIGHT_BORDER_SUFFIX: &str = " │";
2121
2122fn render_section_top_border(label: &str, total_cols: usize) -> TextPropertyEntry {
2133 let mut text = String::new();
2134 let mut overlays: Vec<InlineOverlay> = Vec::new();
2135 text.push('╭');
2136 if label.is_empty() {
2137 for _ in 0..total_cols.saturating_sub(2) {
2138 text.push('─');
2139 }
2140 } else {
2141 let label_cols = label.chars().count();
2146 let used = 1 + 1 + 1 + label_cols + 1; text.push('─');
2148 text.push(' ');
2149 let label_byte_start = text.len();
2150 text.push_str(label);
2151 let label_byte_end = text.len();
2152 text.push(' ');
2153 let remaining = total_cols.saturating_sub(used + 1); for _ in 0..remaining {
2155 text.push('─');
2156 }
2157 overlays.push(InlineOverlay {
2158 start: label_byte_start,
2159 end: label_byte_end,
2160 style: OverlayOptions {
2161 fg: Some(OverlayColorSpec::theme_key(KEY_SECTION_LABEL_FG)),
2162 bold: true,
2163 ..Default::default()
2164 },
2165 properties: Default::default(),
2166 unit: OffsetUnit::Byte,
2167 });
2168 }
2169 text.push('╮');
2170 text.push('\n');
2171 TextPropertyEntry {
2172 text,
2173 properties: Default::default(),
2174 style: None,
2175 inline_overlays: overlays,
2176 segments: Vec::new(),
2177 pad_to_chars: None,
2178 truncate_to_chars: None,
2179 }
2180}
2181
2182fn render_section_bottom_border(total_cols: usize) -> TextPropertyEntry {
2185 let mut text = String::new();
2186 text.push('╰');
2187 for _ in 0..total_cols.saturating_sub(2) {
2188 text.push('─');
2189 }
2190 text.push('╯');
2191 text.push('\n');
2192 TextPropertyEntry {
2193 text,
2194 properties: Default::default(),
2195 style: None,
2196 inline_overlays: Vec::new(),
2197 segments: Vec::new(),
2198 pad_to_chars: None,
2199 truncate_to_chars: None,
2200 }
2201}
2202
2203fn render_completion_dim_separator_overlay(total_cols: usize) -> TextPropertyEntry {
2212 let inner = total_cols.saturating_sub(2).max(1);
2213 let mut text = String::with_capacity(total_cols * 4 + 2);
2214 text.push('│');
2215 for _ in 0..inner {
2216 text.push('┄');
2217 }
2218 text.push('│');
2219 text.push('\n');
2220 let left_border_bytes = "│".len();
2228 let dash_bytes = "┄".len() * inner;
2229 let right_border_start = left_border_bytes + dash_bytes;
2230 let right_border_end = right_border_start + "│".len();
2231 let inline_overlays = vec![
2232 InlineOverlay {
2233 start: 0,
2234 end: left_border_bytes,
2235 style: OverlayOptions {
2236 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2237 ..Default::default()
2238 },
2239 properties: Default::default(),
2240 unit: OffsetUnit::Byte,
2241 },
2242 InlineOverlay {
2243 start: left_border_bytes,
2244 end: left_border_bytes + dash_bytes,
2245 style: OverlayOptions {
2246 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_DIM_FG)),
2247 ..Default::default()
2248 },
2249 properties: Default::default(),
2250 unit: OffsetUnit::Byte,
2251 },
2252 InlineOverlay {
2253 start: right_border_start,
2254 end: right_border_end,
2255 style: OverlayOptions {
2256 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2257 ..Default::default()
2258 },
2259 properties: Default::default(),
2260 unit: OffsetUnit::Byte,
2261 },
2262 ];
2263 TextPropertyEntry {
2264 text,
2265 properties: Default::default(),
2266 style: None,
2267 inline_overlays,
2268 segments: Vec::new(),
2269 pad_to_chars: None,
2270 truncate_to_chars: None,
2271 }
2272}
2273
2274fn render_completion_bottom_border(total_cols: usize) -> TextPropertyEntry {
2281 let mut text = String::with_capacity(total_cols * 4 + 2);
2282 text.push('╰');
2283 for _ in 0..total_cols.saturating_sub(2).max(1) {
2284 text.push('─');
2285 }
2286 text.push('╯');
2287 text.push('\n');
2288 TextPropertyEntry {
2294 text,
2295 properties: Default::default(),
2296 style: Some(OverlayOptions {
2297 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2298 ..Default::default()
2299 }),
2300 inline_overlays: Vec::new(),
2301 segments: Vec::new(),
2302 pad_to_chars: None,
2303 truncate_to_chars: None,
2304 }
2305}
2306
2307fn render_completion_item_overlay(
2314 item: &str,
2315 kind: Option<&str>,
2316 selected: bool,
2317 total_cols: usize,
2318 scrollbar: Option<char>,
2319) -> TextPropertyEntry {
2320 let inner = total_cols.saturating_sub(2).max(1);
2321 let body_entry = render_completion_item(item, kind, selected, inner, scrollbar);
2325 let mut text = String::with_capacity(body_entry.text.len() + 8);
2329 text.push('│');
2330 let body_no_nl = body_entry.text.trim_end_matches('\n');
2331 text.push_str(body_no_nl);
2332 text.push('│');
2333 text.push('\n');
2334 let left_border_bytes = "│".len();
2354 let body_no_nl_bytes = body_no_nl.len();
2355 let right_border_start = left_border_bytes + body_no_nl_bytes;
2356 let right_border_end = right_border_start + "│".len();
2357 let mut inline_overlays: Vec<InlineOverlay> = Vec::new();
2358 if selected {
2359 inline_overlays.push(InlineOverlay {
2360 start: left_border_bytes,
2361 end: right_border_start,
2362 style: OverlayOptions {
2363 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_FG)),
2364 bg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_BG)),
2365 ..Default::default()
2366 },
2367 properties: Default::default(),
2368 unit: OffsetUnit::Byte,
2369 });
2370 }
2371 inline_overlays.extend(body_entry.inline_overlays.into_iter().map(|mut io| {
2379 io.start += left_border_bytes;
2380 io.end += left_border_bytes;
2381 io
2382 }));
2383 inline_overlays.push(InlineOverlay {
2384 start: 0,
2385 end: left_border_bytes,
2386 style: OverlayOptions {
2387 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2388 ..Default::default()
2389 },
2390 properties: Default::default(),
2391 unit: OffsetUnit::Byte,
2392 });
2393 inline_overlays.push(InlineOverlay {
2394 start: right_border_start,
2395 end: right_border_end,
2396 style: OverlayOptions {
2397 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2398 ..Default::default()
2399 },
2400 properties: Default::default(),
2401 unit: OffsetUnit::Byte,
2402 });
2403 TextPropertyEntry {
2404 text,
2405 properties: Default::default(),
2406 style: None,
2407 inline_overlays,
2408 segments: Vec::new(),
2409 pad_to_chars: None,
2410 truncate_to_chars: None,
2411 }
2412}
2413
2414fn render_completion_item(
2440 item: &str,
2441 kind: Option<&str>,
2442 selected: bool,
2443 total_cols: usize,
2444 scrollbar: Option<char>,
2445) -> TextPropertyEntry {
2446 let text_budget = total_cols.saturating_sub(2).saturating_sub(1);
2458 let item_chars: Vec<char> = item.chars().collect();
2459 let (visible_item, truncated): (String, bool) = if item_chars.len() <= text_budget {
2460 (item.to_string(), false)
2461 } else {
2462 let keep = text_budget.saturating_sub(1);
2467 let head: String = item_chars.iter().take(keep).collect();
2468 (format!("{}…", head), true)
2469 };
2470 let _ = truncated;
2471 let scrollbar_ch = scrollbar.unwrap_or(' ');
2472 let is_history = kind == Some("history");
2473 let history_marker: char = '↶';
2480 let mut text = String::with_capacity(total_cols * 4 + 2);
2481 text.push(' ');
2482 let marker_start_byte = text.len();
2483 if is_history {
2484 text.push(history_marker);
2485 } else {
2486 text.push(' ');
2487 }
2488 let marker_end_byte = text.len();
2489 let item_start_byte = text.len();
2490 text.push_str(&visible_item);
2491 let item_end_byte = text.len();
2492 let used_cols = 2 + visible_item.chars().count();
2496 let pad_cols = total_cols.saturating_sub(used_cols).saturating_sub(1);
2497 for _ in 0..pad_cols {
2498 text.push(' ');
2499 }
2500 text.push(scrollbar_ch);
2501 text.push('\n');
2502
2503 let body_style = if selected {
2504 Some(OverlayOptions {
2505 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_FG)),
2506 bg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_BG)),
2507 extend_to_line_end: true,
2508 fg_on_collision_only: false,
2509 ..Default::default()
2510 })
2511 } else {
2512 None
2513 };
2514 let mut inline_overlays: Vec<InlineOverlay> = Vec::new();
2515 if is_history {
2520 inline_overlays.push(InlineOverlay {
2521 start: marker_start_byte,
2522 end: marker_end_byte,
2523 style: OverlayOptions {
2524 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2525 ..Default::default()
2526 },
2527 properties: Default::default(),
2528 unit: OffsetUnit::Byte,
2529 });
2530 inline_overlays.push(InlineOverlay {
2531 start: item_start_byte,
2532 end: item_end_byte,
2533 style: OverlayOptions {
2534 italic: true,
2535 ..Default::default()
2536 },
2537 properties: Default::default(),
2538 unit: OffsetUnit::Byte,
2539 });
2540 }
2541 if scrollbar.is_some() {
2547 let total_bytes = text.trim_end_matches('\n').len();
2548 let scrollbar_byte_len = scrollbar_ch.len_utf8();
2549 let start = total_bytes - scrollbar_byte_len;
2550 let end = total_bytes;
2551 inline_overlays.push(InlineOverlay {
2552 start,
2553 end,
2554 style: OverlayOptions {
2555 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_DIM_FG)),
2556 ..Default::default()
2557 },
2558 properties: Default::default(),
2559 unit: OffsetUnit::Byte,
2560 });
2561 }
2562
2563 TextPropertyEntry {
2564 text,
2565 properties: Default::default(),
2566 style: body_style,
2567 inline_overlays,
2568 segments: Vec::new(),
2569 pad_to_chars: None,
2570 truncate_to_chars: None,
2571 }
2572}
2573
2574fn completion_scrollbar_glyph(
2586 visible_row: u32,
2587 visible: u32,
2588 scroll: u32,
2589 total: u32,
2590) -> Option<char> {
2591 if total <= visible || visible == 0 {
2592 return None;
2593 }
2594 let thumb_size = ((visible as f32 * visible as f32) / total as f32).round() as u32;
2598 let thumb_size = thumb_size.max(1).min(visible);
2599 let max_scroll = total - visible;
2600 let thumb_top = if max_scroll == 0 {
2601 0
2602 } else {
2603 ((scroll as f32 / max_scroll as f32) * (visible - thumb_size) as f32).round() as u32
2607 };
2608 if visible_row >= thumb_top && visible_row < thumb_top + thumb_size {
2609 Some('█')
2610 } else {
2611 None
2612 }
2613}
2614
2615fn wrap_in_side_border(mut child: TextPropertyEntry, inner_width: usize) -> TextPropertyEntry {
2620 let prefix_bytes = LEFT_BORDER_PREFIX.len();
2621 let cur_cols = child.text.chars().count();
2623 if cur_cols < inner_width {
2624 for _ in 0..(inner_width - cur_cols) {
2625 child.text.push(' ');
2626 }
2627 } else if cur_cols > inner_width {
2628 let indices: Vec<usize> = child.text.char_indices().map(|(i, _)| i).collect();
2633 let byte_cutoff = indices
2634 .get(inner_width)
2635 .copied()
2636 .unwrap_or(child.text.len());
2637 child.text.truncate(byte_cutoff);
2638 if inner_width >= 2 {
2639 child.text.pop();
2645 child.text.push('…');
2646 }
2647 let byte_cutoff = child.text.len();
2648 child.inline_overlays.retain_mut(|o| {
2651 if o.start >= byte_cutoff {
2652 return false;
2653 }
2654 if o.end > byte_cutoff {
2655 o.end = byte_cutoff;
2656 }
2657 true
2658 });
2659 }
2660
2661 let mut text = String::with_capacity(
2663 LEFT_BORDER_PREFIX.len() + child.text.len() + RIGHT_BORDER_SUFFIX.len() + 1,
2664 );
2665 text.push_str(LEFT_BORDER_PREFIX);
2666 text.push_str(&child.text);
2667 text.push_str(RIGHT_BORDER_SUFFIX);
2668 text.push('\n');
2669
2670 let overlays: Vec<InlineOverlay> = child
2672 .inline_overlays
2673 .into_iter()
2674 .map(|o| InlineOverlay {
2675 start: o.start + prefix_bytes,
2676 end: o.end + prefix_bytes,
2677 style: o.style,
2678 properties: o.properties,
2679 unit: o.unit,
2680 })
2681 .collect();
2682
2683 TextPropertyEntry {
2684 text,
2685 properties: child.properties,
2686 style: child.style,
2687 inline_overlays: overlays,
2688 segments: Vec::new(),
2689 pad_to_chars: None,
2690 truncate_to_chars: None,
2691 }
2692}
2693
2694pub fn render_hint_bar(entries: &[HintEntry]) -> TextPropertyEntry {
2704 let separator = " ";
2705 let mut text = String::new();
2706 let mut overlays = Vec::new();
2707 for (i, entry) in entries.iter().enumerate() {
2708 if i > 0 {
2709 text.push_str(separator);
2710 }
2711 let key_start = text.len();
2712 text.push_str(&entry.keys);
2713 let key_end = text.len();
2714 if key_end > key_start {
2715 overlays.push(InlineOverlay {
2716 start: key_start,
2717 end: key_end,
2718 style: OverlayOptions {
2719 fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
2720 bold: true,
2721 ..Default::default()
2722 },
2723 properties: Default::default(),
2724 unit: OffsetUnit::Byte,
2725 });
2726 }
2727 if !entry.label.is_empty() {
2728 text.push(' ');
2729 text.push_str(&entry.label);
2730 }
2731 }
2732 TextPropertyEntry {
2733 text,
2734 properties: Default::default(),
2735 style: None,
2736 inline_overlays: overlays,
2737 segments: Vec::new(),
2738 pad_to_chars: None,
2739 truncate_to_chars: None,
2740 }
2741}
2742
2743pub fn render_toggle(checked: bool, label: &str, focused: bool) -> TextPropertyEntry {
2752 let glyph = if checked { "[v]" } else { "[ ]" };
2753 let mut text = String::with_capacity(glyph.len() + 1 + label.len());
2754 text.push_str(glyph);
2755 text.push(' ');
2756 text.push_str(label);
2757
2758 let mut overlays = Vec::new();
2759
2760 if checked {
2763 overlays.push(InlineOverlay {
2764 start: 0,
2765 end: glyph.len(),
2766 style: OverlayOptions {
2767 fg: Some(OverlayColorSpec::theme_key(KEY_TOGGLE_ON_FG)),
2768 bold: true,
2769 ..Default::default()
2770 },
2771 properties: Default::default(),
2772 unit: OffsetUnit::Byte,
2773 });
2774 }
2775
2776 if focused {
2778 overlays.push(InlineOverlay {
2779 start: 0,
2780 end: text.len(),
2781 style: OverlayOptions {
2782 fg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_FG)),
2783 bg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG)),
2784 bold: true,
2785 ..Default::default()
2786 },
2787 properties: Default::default(),
2788 unit: OffsetUnit::Byte,
2789 });
2790 }
2791
2792 TextPropertyEntry {
2793 text,
2794 properties: Default::default(),
2795 style: None,
2796 inline_overlays: overlays,
2797 segments: Vec::new(),
2798 pad_to_chars: None,
2799 truncate_to_chars: None,
2800 }
2801}
2802
2803pub fn render_button(
2814 label: &str,
2815 focused: bool,
2816 kind: ButtonKind,
2817 disabled: bool,
2818) -> TextPropertyEntry {
2819 let text = format!("[ {} ]", label);
2820 let mut overlays = Vec::new();
2821
2822 let base_style = if disabled {
2830 OverlayOptions {
2831 fg: Some(OverlayColorSpec::theme_key("ui.menu_disabled_fg")),
2832 ..Default::default()
2833 }
2834 } else {
2835 match kind {
2836 ButtonKind::Normal => OverlayOptions::default(),
2837 ButtonKind::Primary => OverlayOptions {
2842 fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
2843 bold: true,
2844 ..Default::default()
2845 },
2846 ButtonKind::Danger => OverlayOptions {
2849 fg: Some(OverlayColorSpec::theme_key(KEY_DANGER_FG)),
2850 bold: true,
2851 ..Default::default()
2852 },
2853 }
2854 };
2855
2856 let style = if focused && !disabled {
2857 OverlayOptions {
2858 fg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_FG)),
2859 bg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG)),
2860 bold: true,
2861 ..base_style
2862 }
2863 } else {
2864 base_style
2865 };
2866
2867 if style.fg.is_some()
2870 || style.bg.is_some()
2871 || style.bold
2872 || style.italic
2873 || style.underline
2874 || style.strikethrough
2875 {
2876 overlays.push(InlineOverlay {
2877 start: 0,
2878 end: text.len(),
2879 style,
2880 properties: Default::default(),
2881 unit: OffsetUnit::Byte,
2882 });
2883 }
2884
2885 TextPropertyEntry {
2886 text,
2887 properties: Default::default(),
2888 style: None,
2889 inline_overlays: overlays,
2890 segments: Vec::new(),
2891 pad_to_chars: None,
2892 truncate_to_chars: None,
2893 }
2894}
2895
2896pub struct RenderedTreeRow {
2900 pub entry: TextPropertyEntry,
2901 pub disclosure_range: Option<(usize, usize)>,
2904 pub checkbox_range: Option<(usize, usize)>,
2909}
2910
2911pub fn render_tree_row(node: &TreeNode, expanded: bool, checkable: bool) -> RenderedTreeRow {
2929 let indent_cols = (node.depth as usize) * 2;
2930 let disclosure_glyph: &str = if node.has_children {
2931 if expanded {
2932 "▼"
2933 } else {
2934 "▶"
2935 }
2936 } else {
2937 " "
2940 };
2941 let separator: &str = if node.has_children { " " } else { "" };
2946
2947 let checkbox_glyph: Option<&'static str> = if checkable {
2948 match node.checked {
2949 Some(true) => Some("[v]"),
2950 Some(false) => Some("[ ]"),
2951 None => None,
2952 }
2953 } else {
2954 None
2955 };
2956 let checkbox_extra = checkbox_glyph.map(|g| g.len() + 1).unwrap_or(0);
2957
2958 let mut text = String::with_capacity(
2959 indent_cols
2960 + disclosure_glyph.len()
2961 + separator.len()
2962 + checkbox_extra
2963 + node.text.text.len(),
2964 );
2965 for _ in 0..indent_cols {
2966 text.push(' ');
2967 }
2968 let disc_start = text.len();
2969 text.push_str(disclosure_glyph);
2970 let disc_end = text.len();
2971 text.push_str(separator);
2972 let checkbox_range = if let Some(g) = checkbox_glyph {
2973 let cb_start = text.len();
2974 text.push_str(g);
2975 let cb_end = text.len();
2976 text.push(' ');
2977 Some((cb_start, cb_end))
2978 } else {
2979 None
2980 };
2981 let body_start = text.len();
2982 text.push_str(&node.text.text);
2983
2984 let mut overlays: Vec<InlineOverlay> = node
2988 .text
2989 .inline_overlays
2990 .iter()
2991 .map(|o| {
2992 let mut shifted = o.clone();
2993 shifted.start += body_start;
2994 shifted.end += body_start;
2995 shifted
2996 })
2997 .collect();
2998
2999 if node.has_children {
3002 overlays.push(InlineOverlay {
3003 start: disc_start,
3004 end: disc_end,
3005 style: OverlayOptions {
3006 fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
3007 bold: true,
3008 ..Default::default()
3009 },
3010 properties: Default::default(),
3011 unit: OffsetUnit::Byte,
3012 });
3013 }
3014 if let Some((cb_start, cb_end)) = checkbox_range {
3017 let theme_key = match node.checked {
3018 Some(true) => KEY_TOGGLE_ON_FG,
3019 _ => KEY_PLACEHOLDER_FG,
3020 };
3021 overlays.push(InlineOverlay {
3022 start: cb_start,
3023 end: cb_end,
3024 style: OverlayOptions {
3025 fg: Some(OverlayColorSpec::theme_key(theme_key)),
3026 bold: matches!(node.checked, Some(true)),
3027 ..Default::default()
3028 },
3029 properties: Default::default(),
3030 unit: OffsetUnit::Byte,
3031 });
3032 }
3033
3034 let disclosure_range = if node.has_children {
3035 Some((disc_start, disc_end))
3036 } else {
3037 None
3038 };
3039 let entry = TextPropertyEntry {
3040 text,
3041 properties: node.text.properties.clone(),
3045 style: node.text.style.clone(),
3046 inline_overlays: overlays,
3047 segments: Vec::new(),
3052 pad_to_chars: None,
3053 truncate_to_chars: None,
3054 };
3055 RenderedTreeRow {
3056 entry,
3057 disclosure_range,
3058 checkbox_range,
3059 }
3060}
3061
3062pub struct RenderedTextInput {
3066 pub entry: TextPropertyEntry,
3067 pub cursor_byte_in_entry: Option<usize>,
3070}
3071
3072#[allow(clippy::too_many_arguments)]
3097pub fn render_text_input(
3098 value: &str,
3099 cursor_byte: i32,
3100 selection: Option<(usize, usize)>,
3101 focused: bool,
3102 label: &str,
3103 placeholder: Option<&str>,
3104 max_visible_chars: u32,
3105 field_width: u32,
3106 full_width: bool,
3107) -> RenderedTextInput {
3108 let show_placeholder = value.is_empty() && placeholder.is_some();
3115
3116 let raw_cursor_byte = if cursor_byte < 0 {
3120 value.len()
3121 } else {
3122 (cursor_byte as usize).min(value.len())
3123 };
3124
3125 let (inner, cursor_in_inner) = if show_placeholder && field_width == 0 {
3129 let inner = placeholder.unwrap_or("").to_string();
3133 let cursor = if focused { Some(0usize) } else { None };
3134 (inner, cursor)
3135 } else if show_placeholder {
3136 let target = field_width as usize;
3143 let pad_extra = if focused || full_width { 1 } else { 0 };
3144 let total_inner = target + pad_extra;
3145 let raw = placeholder.unwrap_or("");
3146 let raw_chars: Vec<char> = raw.chars().collect();
3147 let inner = if raw_chars.len() <= total_inner {
3148 let mut s = raw.to_string();
3149 while s.chars().count() < total_inner {
3150 s.push(' ');
3151 }
3152 s
3153 } else {
3154 let keep = total_inner.saturating_sub(1);
3157 let prefix: String = raw_chars.iter().take(keep).collect();
3158 format!("{}…", prefix)
3159 };
3160 let cursor = if focused { Some(0usize) } else { None };
3161 (inner, cursor)
3162 } else if field_width > 0 {
3163 let target = field_width as usize;
3169 let pad_extra = if focused || full_width { 1 } else { 0 };
3170 let total_inner = target + pad_extra;
3171 let value_chars: Vec<char> = value.chars().collect();
3172 if value_chars.len() <= target {
3173 let mut padded = value.to_string();
3177 while padded.chars().count() < total_inner {
3178 padded.push(' ');
3179 }
3180 (padded, Some(raw_cursor_byte))
3181 } else {
3182 let keep = target - 1;
3186 let drop_chars = value_chars.len() - keep;
3187 let mut dropped_bytes = 0usize;
3188 for ch in value_chars.iter().take(drop_chars) {
3189 dropped_bytes += ch.len_utf8();
3190 }
3191 let tail = &value[dropped_bytes..];
3192 let mut s = String::with_capacity("…".len() + tail.len() + pad_extra);
3193 s.push('…');
3194 s.push_str(tail);
3195 for _ in 0..pad_extra {
3196 s.push(' ');
3197 }
3198 let cursor_in_inner = if raw_cursor_byte < dropped_bytes {
3202 "…".len()
3203 } else {
3204 "…".len() + (raw_cursor_byte - dropped_bytes)
3205 };
3206 (s, Some(cursor_in_inner))
3207 }
3208 } else if max_visible_chars > 0 && value.chars().count() > max_visible_chars as usize {
3209 let chars: Vec<char> = value.chars().collect();
3213 let take = (max_visible_chars as usize).saturating_sub(1);
3214 let start = chars.len().saturating_sub(take);
3215 let tail: String = chars[start..].iter().collect();
3216 let s = format!("…{}", tail);
3217 (s, Some(raw_cursor_byte.min(value.len())))
3218 } else {
3219 let mut s = value.to_string();
3225 if focused {
3226 s.push(' ');
3227 }
3228 (s, Some(raw_cursor_byte))
3229 };
3230
3231 let mut text = String::new();
3233 if !label.is_empty() {
3234 text.push_str(label);
3235 text.push(' ');
3236 }
3237 let bracket_open_byte = text.len();
3238 text.push('[');
3239 let inner_byte_start = text.len();
3240 text.push_str(&inner);
3241 let inner_byte_end = text.len();
3242 text.push(']');
3243 let bracket_close_byte = text.len();
3244
3245 let mut overlays = Vec::new();
3246
3247 if show_placeholder {
3248 overlays.push(InlineOverlay {
3249 start: inner_byte_start,
3250 end: inner_byte_end,
3251 style: OverlayOptions {
3252 fg: Some(OverlayColorSpec::theme_key(KEY_PLACEHOLDER_FG)),
3253 italic: true,
3254 ..Default::default()
3255 },
3256 properties: Default::default(),
3257 unit: OffsetUnit::Byte,
3258 });
3259 }
3260
3261 if focused {
3262 overlays.push(InlineOverlay {
3263 start: bracket_open_byte,
3264 end: bracket_close_byte,
3265 style: OverlayOptions {
3266 bg: Some(OverlayColorSpec::theme_key(KEY_INPUT_BG)),
3267 ..Default::default()
3268 },
3269 properties: Default::default(),
3270 unit: OffsetUnit::Byte,
3271 });
3272 }
3273
3274 let inner_is_truncated = inner.starts_with('…');
3283 if focused && !inner_is_truncated {
3284 if let Some((sel_start, sel_end)) = selection {
3285 let visible_value_len = value.len();
3289 let s = sel_start.min(sel_end).min(visible_value_len);
3290 let e = sel_start.max(sel_end).min(visible_value_len);
3291 if e > s {
3292 overlays.push(InlineOverlay {
3293 start: inner_byte_start + s,
3294 end: inner_byte_start + e,
3295 style: OverlayOptions {
3296 bg: Some(OverlayColorSpec::theme_key(KEY_TEXT_INPUT_SELECTION_BG)),
3297 ..Default::default()
3298 },
3299 properties: Default::default(),
3300 unit: OffsetUnit::Byte,
3301 });
3302 }
3303 }
3304 }
3305
3306 let cursor_byte_in_entry = if focused {
3307 cursor_in_inner.map(|c| inner_byte_start + c)
3308 } else {
3309 None
3310 };
3311
3312 RenderedTextInput {
3313 entry: TextPropertyEntry {
3314 text,
3315 properties: Default::default(),
3316 style: None,
3317 inline_overlays: overlays,
3318 segments: Vec::new(),
3319 pad_to_chars: None,
3320 truncate_to_chars: None,
3321 },
3322 cursor_byte_in_entry,
3323 }
3324}
3325
3326pub struct RenderedTextArea {
3329 pub entries: Vec<TextPropertyEntry>,
3334 pub scroll_row: u32,
3338 pub cursor_buffer_row: Option<u32>,
3342 pub cursor_byte_in_row: Option<usize>,
3345}
3346
3347#[allow(clippy::too_many_arguments)]
3374pub fn render_text_area(
3375 value: &str,
3376 cursor_byte: i32,
3377 selection: Option<(usize, usize)>,
3378 focused: bool,
3379 label: &str,
3380 placeholder: Option<&str>,
3381 visible_rows: u32,
3382 field_width: u32,
3383 prev_scroll: u32,
3384 panel_width: u32,
3385) -> RenderedTextArea {
3386 let target_width: usize = if field_width > 0 {
3389 field_width as usize
3390 } else if panel_width != u32::MAX && panel_width > 0 {
3391 panel_width as usize
3392 } else {
3393 40
3394 };
3395
3396 let mut lines: Vec<&str> = value.split('\n').collect();
3400 if lines.is_empty() {
3401 lines.push("");
3402 }
3403
3404 let raw_cursor_byte = if cursor_byte < 0 {
3408 value.len()
3409 } else {
3410 (cursor_byte as usize).min(value.len())
3411 };
3412 let (cursor_line, cursor_col) = byte_to_line_col(value, raw_cursor_byte);
3413
3414 let selection_lc: Option<((usize, usize), (usize, usize))> = selection.and_then(|(a, b)| {
3419 let lo = a.min(b);
3420 let hi = a.max(b);
3421 if hi <= lo || hi > value.len() {
3422 return None;
3423 }
3424 Some((byte_to_line_col(value, lo), byte_to_line_col(value, hi)))
3425 });
3426
3427 let visible_rows_usize = visible_rows.max(1) as usize;
3430 let mut scroll_row = prev_scroll as usize;
3431 if cursor_line < scroll_row {
3432 scroll_row = cursor_line;
3433 } else if cursor_line >= scroll_row + visible_rows_usize {
3434 scroll_row = cursor_line + 1 - visible_rows_usize;
3435 }
3436 let max_scroll = lines.len().saturating_sub(visible_rows_usize);
3438 if scroll_row > max_scroll {
3439 scroll_row = max_scroll;
3440 }
3441
3442 let show_placeholder =
3443 !focused && value.is_empty() && placeholder.is_some() && !placeholder.unwrap().is_empty();
3444
3445 let mut entries: Vec<TextPropertyEntry> = Vec::new();
3446 let mut cursor_buffer_row: Option<u32> = None;
3447 let mut cursor_byte_in_row: Option<usize> = None;
3448
3449 if !label.is_empty() {
3450 let mut text = String::with_capacity(label.len() + 2);
3451 text.push_str(label);
3452 text.push(':');
3453 entries.push(TextPropertyEntry {
3454 text,
3455 properties: Default::default(),
3456 style: None,
3457 inline_overlays: Vec::new(),
3458 segments: Vec::new(),
3459 pad_to_chars: None,
3460 truncate_to_chars: None,
3461 });
3462 }
3463 let label_offset: u32 = entries.len() as u32;
3464
3465 for row_in_view in 0..visible_rows_usize {
3466 let line_idx = scroll_row + row_in_view;
3467 let mut row_text;
3468 let mut overlays: Vec<InlineOverlay> = Vec::new();
3469
3470 if line_idx < lines.len() {
3471 row_text = pad_or_truncate_line(lines[line_idx], target_width);
3472 } else {
3473 row_text = " ".repeat(target_width);
3474 }
3475
3476 if show_placeholder && row_in_view == 0 {
3478 let ph = placeholder.unwrap();
3479 row_text = pad_or_truncate_line(ph, target_width);
3480 overlays.push(InlineOverlay {
3481 start: 0,
3482 end: row_text.len(),
3483 style: OverlayOptions {
3484 fg: Some(OverlayColorSpec::theme_key(KEY_PLACEHOLDER_FG)),
3485 ..Default::default()
3486 },
3487 properties: Default::default(),
3488 unit: OffsetUnit::Byte,
3489 });
3490 }
3491
3492 if focused {
3495 overlays.push(InlineOverlay {
3496 start: 0,
3497 end: row_text.len(),
3498 style: OverlayOptions {
3499 bg: Some(OverlayColorSpec::theme_key(KEY_INPUT_BG)),
3500 ..Default::default()
3501 },
3502 properties: Default::default(),
3503 unit: OffsetUnit::Byte,
3504 });
3505 }
3506
3507 if focused {
3511 if let Some(((sl, sc), (el, ec))) = selection_lc {
3512 if line_idx >= sl && line_idx <= el {
3513 let line_text_len = if line_idx < lines.len() {
3514 lines[line_idx].len()
3515 } else {
3516 0
3517 };
3518 let row_start = if line_idx == sl { sc } else { 0 };
3519 let row_end = if line_idx == el { ec } else { line_text_len };
3520 let s = row_start.min(line_text_len);
3521 let e = row_end.min(line_text_len);
3522 if e > s {
3523 overlays.push(InlineOverlay {
3524 start: s,
3525 end: e,
3526 style: OverlayOptions {
3527 bg: Some(OverlayColorSpec::theme_key(KEY_TEXT_INPUT_SELECTION_BG)),
3528 ..Default::default()
3529 },
3530 properties: Default::default(),
3531 unit: OffsetUnit::Byte,
3532 });
3533 }
3534 }
3535 }
3536 }
3537
3538 if focused && line_idx == cursor_line && cursor_byte >= 0 {
3540 let col_in_line = cursor_col.min(row_text.len());
3545 cursor_buffer_row = Some(label_offset + row_in_view as u32);
3546 cursor_byte_in_row = Some(col_in_line);
3547 }
3548
3549 entries.push(TextPropertyEntry {
3550 text: row_text,
3551 properties: Default::default(),
3552 style: None,
3553 inline_overlays: overlays,
3554 segments: Vec::new(),
3555 pad_to_chars: None,
3556 truncate_to_chars: None,
3557 });
3558 }
3559
3560 RenderedTextArea {
3561 entries,
3562 scroll_row: scroll_row as u32,
3563 cursor_buffer_row,
3564 cursor_byte_in_row,
3565 }
3566}
3567
3568fn byte_to_line_col(value: &str, byte: usize) -> (usize, usize) {
3570 let byte = byte.min(value.len());
3571 let mut line = 0usize;
3572 let mut line_start = 0usize;
3573 for (i, &b) in value.as_bytes().iter().enumerate().take(byte) {
3574 if b == b'\n' {
3575 line += 1;
3576 line_start = i + 1;
3577 }
3578 }
3579 (line, byte - line_start)
3580}
3581
3582fn pad_or_truncate_line(line: &str, target: usize) -> String {
3588 let chars: Vec<char> = line.chars().collect();
3589 if chars.len() <= target {
3590 let mut out = line.to_string();
3591 let pad = target - chars.len();
3592 for _ in 0..pad {
3593 out.push(' ');
3594 }
3595 out
3596 } else {
3597 let keep = target.saturating_sub(1);
3598 let mut out: String = chars.iter().take(keep).collect();
3599 out.push('…');
3600 out
3601 }
3602}
3603
3604fn assemble_wrapped_row(
3612 pieces: Vec<RowPiece>,
3613 panel_width: u32,
3614 entries: &mut Vec<TextPropertyEntry>,
3615 hits: &mut Vec<HitArea>,
3616) {
3617 use crate::primitives::display_width::str_width;
3618 let max_w = panel_width as usize;
3619 let mut acc: Option<TextPropertyEntry> = None;
3620 let mut row: u32 = 0;
3621 let flush = |acc: &mut Option<TextPropertyEntry>, entries: &mut Vec<TextPropertyEntry>| {
3624 if let Some(mut merged) = acc.take() {
3625 ensure_trailing_newline(&mut merged);
3626 entries.push(merged);
3627 }
3628 };
3629 for piece in pieces {
3630 let RowPiece::Inline {
3631 mut entry,
3632 hits: child_hits,
3633 ..
3634 } = piece
3635 else {
3636 continue;
3638 };
3639 let is_blank = entry.text.trim().is_empty();
3640 let piece_w = str_width(&entry.text);
3641 let acc_w = acc.as_ref().map(|e| str_width(&e.text)).unwrap_or(0);
3642 if acc.is_some() && acc_w + piece_w > max_w {
3644 flush(&mut acc, entries);
3645 row += 1;
3646 }
3647 if acc.is_none() && is_blank {
3649 continue;
3650 }
3651 let shift = acc.as_ref().map(|e| e.text.len()).unwrap_or(0);
3652 for mut h in child_hits {
3653 h.byte_start += shift;
3654 h.byte_end += shift;
3655 h.buffer_row = row;
3656 hits.push(h);
3657 }
3658 match acc.as_mut() {
3659 Some(merged) => merge_inline(merged, &mut entry),
3660 None => acc = Some(entry),
3661 }
3662 }
3663 flush(&mut acc, entries);
3664}
3665
3666fn merge_inline(merged: &mut TextPropertyEntry, next: &mut TextPropertyEntry) {
3670 let shift = merged.text.len();
3671 merged.text.push_str(&next.text);
3672 for overlay in next.inline_overlays.drain(..) {
3673 merged.inline_overlays.push(InlineOverlay {
3674 start: overlay.start + shift,
3675 end: overlay.end + shift,
3676 style: overlay.style,
3677 properties: overlay.properties,
3678 unit: overlay.unit,
3679 });
3680 }
3681 }
3687
3688fn pad_or_truncate_cols(text: &mut String, cols: usize) {
3699 let cur = text.chars().count();
3700 if cur < cols {
3701 for _ in 0..(cols - cur) {
3702 text.push(' ');
3703 }
3704 } else if cur > cols {
3705 let cutoff = text
3708 .char_indices()
3709 .nth(cols)
3710 .map(|(i, _)| i)
3711 .unwrap_or(text.len());
3712 text.truncate(cutoff);
3713 if cols >= 2 {
3714 text.pop();
3717 text.push('…');
3718 }
3719 }
3720}
3721
3722fn snap_down_to_char_boundary(s: &str, idx: usize) -> usize {
3728 let mut i = idx.min(s.len());
3729 while i > 0 && !s.is_char_boundary(i) {
3730 i -= 1;
3731 }
3732 i
3733}
3734
3735fn zip_row_blocks(
3758 pieces: Vec<RowPiece>,
3759 panel_width: u32,
3760 out_entries: &mut Vec<TextPropertyEntry>,
3761 out_hits: &mut Vec<HitArea>,
3762 out_focus_cursor: &mut Option<FocusCursor>,
3763 out_embeds: &mut Vec<EmbedRect>,
3764 out_scroll: &mut Vec<ScrollRegion>,
3765) {
3766 let starting_row = out_entries.len() as u32;
3767 let _ = panel_width;
3768
3769 let max_height = pieces
3771 .iter()
3772 .filter_map(|p| match p {
3773 RowPiece::Block { entries, .. } => Some(entries.len()),
3774 _ => None,
3775 })
3776 .max()
3777 .unwrap_or(0);
3778 if max_height == 0 {
3779 return;
3780 }
3781
3782 for row_idx in 0..max_height {
3783 let mut text = String::new();
3784 let mut overlays: Vec<InlineOverlay> = Vec::new();
3785 for piece in &pieces {
3786 match piece {
3787 RowPiece::Inline {
3788 entry,
3789 hits,
3790 focus_cursor,
3791 embeds: inline_embeds,
3792 scroll_regions: inline_scroll,
3793 } => {
3794 let inline_cols = entry.text.chars().count();
3795 let byte_shift = text.len();
3796 let col_shift = text.chars().count() as u32;
3800 if row_idx == 0 {
3801 text.push_str(&entry.text);
3802 for emb in inline_embeds {
3803 out_embeds.push(EmbedRect {
3804 window_id: emb.window_id,
3805 buffer_row: starting_row + emb.buffer_row,
3806 col_in_row: emb.col_in_row + col_shift,
3807 width_cols: emb.width_cols,
3808 height_rows: emb.height_rows,
3809 });
3810 }
3811 for sr in inline_scroll {
3812 let mut sr = sr.clone();
3813 sr.buffer_row += starting_row;
3814 sr.col_in_row += col_shift;
3815 out_scroll.push(sr);
3816 }
3817 for overlay in &entry.inline_overlays {
3818 overlays.push(InlineOverlay {
3819 start: overlay.start + byte_shift,
3820 end: overlay.end + byte_shift,
3821 style: overlay.style.clone(),
3822 properties: overlay.properties.clone(),
3823 unit: overlay.unit,
3824 });
3825 }
3826 for h in hits {
3827 let mut h = h.clone();
3828 h.byte_start += byte_shift;
3829 h.byte_end += byte_shift;
3830 h.buffer_row = starting_row;
3831 out_hits.push(h);
3832 }
3833 if let Some(fc) = focus_cursor {
3834 *out_focus_cursor = Some(FocusCursor {
3835 buffer_row: starting_row,
3836 byte_in_row: fc.byte_in_row + byte_shift as u32,
3837 });
3838 }
3839 } else {
3840 for _ in 0..inline_cols {
3841 text.push(' ');
3842 }
3843 }
3844 }
3845 RowPiece::Flex => {
3846 }
3848 RowPiece::Block {
3849 column_width,
3850 entries,
3851 hits,
3852 focus_cursor,
3853 embeds: block_embeds,
3854 scroll_regions: block_scroll,
3855 } => {
3856 let block_w = *column_width as usize;
3857 let byte_shift = text.len();
3858 let col_shift = text.chars().count() as u32;
3861 if row_idx == 0 {
3866 for emb in block_embeds {
3867 out_embeds.push(EmbedRect {
3868 window_id: emb.window_id,
3869 buffer_row: starting_row + emb.buffer_row,
3870 col_in_row: emb.col_in_row + col_shift,
3871 width_cols: emb.width_cols,
3872 height_rows: emb.height_rows,
3873 });
3874 }
3875 for sr in block_scroll {
3876 let mut sr = sr.clone();
3877 sr.buffer_row += starting_row;
3878 sr.col_in_row += col_shift;
3879 out_scroll.push(sr);
3880 }
3881 }
3882 if let Some(line) = entries.get(row_idx) {
3883 let mut line_text = line.text.clone();
3884 if line_text.ends_with('\n') {
3887 line_text.pop();
3888 }
3889 pad_or_truncate_cols(&mut line_text, block_w);
3890 let padded_byte_len = line_text.len();
3891 text.push_str(&line_text);
3892 if let Some(line_style) = &line.style {
3902 overlays.push(InlineOverlay {
3903 start: byte_shift,
3904 end: byte_shift + padded_byte_len,
3905 style: line_style.clone(),
3906 properties: Default::default(),
3907 unit: OffsetUnit::Byte,
3908 });
3909 }
3910 for overlay in &line.inline_overlays {
3911 let start = snap_down_to_char_boundary(&line_text, overlay.start);
3920 let end = snap_down_to_char_boundary(&line_text, overlay.end);
3921 if start >= end {
3922 continue;
3923 }
3924 overlays.push(InlineOverlay {
3925 start: start + byte_shift,
3926 end: end + byte_shift,
3927 style: overlay.style.clone(),
3928 properties: overlay.properties.clone(),
3929 unit: overlay.unit,
3930 });
3931 }
3932 for h in hits {
3933 if h.buffer_row != row_idx as u32 {
3934 continue;
3935 }
3936 let mut h = h.clone();
3937 h.byte_start += byte_shift;
3938 h.byte_end += byte_shift;
3939 h.buffer_row = starting_row + row_idx as u32;
3940 out_hits.push(h);
3941 }
3942 if let Some(fc) = focus_cursor {
3943 if fc.buffer_row == row_idx as u32 {
3944 *out_focus_cursor = Some(FocusCursor {
3945 buffer_row: starting_row + row_idx as u32,
3946 byte_in_row: fc.byte_in_row + byte_shift as u32,
3947 });
3948 }
3949 }
3950 } else {
3951 for _ in 0..block_w {
3954 text.push(' ');
3955 }
3956 }
3957 }
3958 }
3959 }
3960 text.push('\n');
3961 out_entries.push(TextPropertyEntry {
3962 text,
3963 properties: Default::default(),
3964 style: None,
3965 inline_overlays: overlays,
3966 segments: Vec::new(),
3967 pad_to_chars: None,
3968 truncate_to_chars: None,
3969 });
3970 }
3971}
3972
3973#[cfg(test)]
3974mod tests {
3975 use super::*;
3976
3977 fn render_no_focus(
3982 spec: &WidgetSpec,
3983 prev: &HashMap<String, WidgetInstanceState>,
3984 ) -> (
3985 Vec<TextPropertyEntry>,
3986 Vec<HitArea>,
3987 HashMap<String, WidgetInstanceState>,
3988 ) {
3989 let out = render_spec(spec, prev, "", u32::MAX);
3991 (out.entries, out.hits, out.instance_states)
3992 }
3993
3994 #[test]
3995 fn hint_bar_renders_entries_with_key_overlays() {
3996 let entries = vec![
3997 HintEntry {
3998 keys: "Tab".into(),
3999 label: "next".into(),
4000 },
4001 HintEntry {
4002 keys: "Esc".into(),
4003 label: "close".into(),
4004 },
4005 ];
4006 let entry = render_hint_bar(&entries);
4007 assert_eq!(entry.text, "Tab next Esc close");
4008 assert_eq!(entry.inline_overlays.len(), 2);
4009 assert_eq!(entry.inline_overlays[0].start, 0);
4011 assert_eq!(entry.inline_overlays[0].end, 3);
4012 assert_eq!(entry.inline_overlays[1].start, 10);
4014 assert_eq!(entry.inline_overlays[1].end, 13);
4015 }
4016
4017 #[test]
4018 fn hint_bar_omits_label_when_empty() {
4019 let entries = vec![HintEntry {
4020 keys: "?".into(),
4021 label: "".into(),
4022 }];
4023 let entry = render_hint_bar(&entries);
4024 assert_eq!(entry.text, "?");
4025 }
4026
4027 #[test]
4028 fn col_stacks_children_top_to_bottom() {
4029 let spec = WidgetSpec::Col {
4030 children: vec![
4031 WidgetSpec::HintBar {
4032 entries: vec![HintEntry {
4033 keys: "A".into(),
4034 label: "alpha".into(),
4035 }],
4036 key: None,
4037 },
4038 WidgetSpec::HintBar {
4039 entries: vec![HintEntry {
4040 keys: "B".into(),
4041 label: "beta".into(),
4042 }],
4043 key: None,
4044 },
4045 ],
4046 key: None,
4047 };
4048 let (out, hits, _state) = render_no_focus(&spec, &HashMap::new());
4049 assert_eq!(out.len(), 2);
4050 assert_eq!(out[0].text, "A alpha\n");
4051 assert_eq!(out[1].text, "B beta\n");
4052 assert!(hits.is_empty(), "HintBar emits no hit areas in v1");
4053 }
4054
4055 #[test]
4056 fn raw_passes_through_unchanged() {
4057 let spec = WidgetSpec::Raw {
4058 entries: vec![TextPropertyEntry::text("hello")],
4059 key: None,
4060 };
4061 let (out, hits, _state) = render_no_focus(&spec, &HashMap::new());
4062 assert_eq!(out.len(), 1);
4063 assert_eq!(out[0].text, "hello\n");
4064 assert!(hits.is_empty());
4065 }
4066
4067 #[test]
4068 fn toggle_checked_emits_glyph_overlay() {
4069 let entry = render_toggle(true, "Case", false);
4070 assert_eq!(entry.text, "[v] Case");
4071 assert_eq!(entry.inline_overlays.len(), 1);
4073 assert_eq!(entry.inline_overlays[0].start, 0);
4074 assert_eq!(entry.inline_overlays[0].end, 3);
4075 }
4076
4077 #[test]
4078 fn toggle_unchecked_no_glyph_overlay() {
4079 let entry = render_toggle(false, "Case", false);
4080 assert_eq!(entry.text, "[ ] Case");
4081 assert_eq!(entry.inline_overlays.len(), 0);
4082 }
4083
4084 #[test]
4085 fn toggle_focused_adds_full_entry_overlay() {
4086 let entry = render_toggle(true, "Case", true);
4087 assert_eq!(entry.inline_overlays.len(), 2);
4089 assert_eq!(entry.inline_overlays[1].start, 0);
4091 assert_eq!(entry.inline_overlays[1].end, entry.text.len());
4092 assert!(entry.inline_overlays[1].style.bold);
4093 }
4094
4095 #[test]
4096 fn button_normal_unfocused_has_no_overlay() {
4097 let entry = render_button("Replace All", false, ButtonKind::Normal, false);
4098 assert_eq!(entry.text, "[ Replace All ]");
4099 assert!(entry.inline_overlays.is_empty());
4100 }
4101
4102 #[test]
4103 fn button_primary_unfocused_is_bold_help_key_fg_with_no_bg() {
4104 let entry = render_button("Submit", false, ButtonKind::Primary, false);
4109 assert_eq!(entry.inline_overlays.len(), 1);
4110 let style = &entry.inline_overlays[0].style;
4111 assert!(style.bold);
4112 assert_eq!(
4113 style.fg.as_ref().and_then(|c| c.as_theme_key()),
4114 Some("ui.help_key_fg"),
4115 );
4116 assert!(style.bg.is_none(), "unfocused primary must not paint a bg");
4117 }
4118
4119 #[test]
4120 fn button_danger_uses_error_theme_key() {
4121 let entry = render_button("Delete", false, ButtonKind::Danger, false);
4122 assert_eq!(entry.inline_overlays.len(), 1);
4123 let fg = entry.inline_overlays[0].style.fg.as_ref().unwrap();
4124 assert_eq!(fg.as_theme_key(), Some("diagnostic.error_fg"));
4125 assert!(entry.inline_overlays[0].style.bold);
4126 }
4127
4128 #[test]
4129 fn button_focused_overrides_with_popup_selection_keys() {
4130 let entry = render_button("OK", true, ButtonKind::Normal, false);
4137 let style = &entry.inline_overlays[0].style;
4138 assert_eq!(
4139 style.fg.as_ref().and_then(|c| c.as_theme_key()),
4140 Some("ui.popup_selection_fg")
4141 );
4142 assert_eq!(
4143 style.bg.as_ref().and_then(|c| c.as_theme_key()),
4144 Some("ui.popup_selection_bg")
4145 );
4146 assert!(style.bold);
4147 }
4148
4149 #[test]
4150 fn flex_spacer_fills_remaining_row_width() {
4151 let spec = WidgetSpec::Row {
4152 wrap: false,
4153 children: vec![
4154 WidgetSpec::Toggle {
4155 checked: false,
4156 label: "A".into(),
4157 focused: false,
4158 key: None,
4159 },
4160 WidgetSpec::Spacer {
4161 cols: 0,
4162 flex: true,
4163 key: None,
4164 },
4165 WidgetSpec::Button {
4166 label: "B".into(),
4167 focused: false,
4168 intent: ButtonKind::Normal,
4169 key: None,
4170 disabled: false,
4171 },
4172 ],
4173 key: None,
4174 };
4175 let out = render_spec(&spec, &HashMap::new(), "", 30);
4179 assert_eq!(out.entries.len(), 1);
4180 let text = &out.entries[0].text;
4181 assert_eq!(text.len(), 31);
4182 assert!(text.starts_with("[ ] A"));
4183 assert!(text.ends_with("[ B ]\n"));
4184 let button_hit = out.hits.iter().find(|h| h.widget_kind == "button").unwrap();
4185 assert_eq!(button_hit.byte_start, 25);
4186 assert_eq!(button_hit.byte_end, 30);
4187 }
4188
4189 #[test]
4190 fn flex_spacer_with_no_leftover_collapses_to_zero() {
4191 let spec = WidgetSpec::Row {
4192 wrap: false,
4193 children: vec![
4194 WidgetSpec::Toggle {
4195 checked: false,
4196 label: "A".into(),
4197 focused: false,
4198 key: None,
4199 },
4200 WidgetSpec::Spacer {
4201 cols: 0,
4202 flex: true,
4203 key: None,
4204 },
4205 WidgetSpec::Toggle {
4206 checked: false,
4207 label: "B".into(),
4208 focused: false,
4209 key: None,
4210 },
4211 ],
4212 key: None,
4213 };
4214 let out = render_spec(&spec, &HashMap::new(), "", 10);
4216 assert_eq!(out.entries[0].text, "[ ] A[ ] B\n");
4217 }
4218
4219 #[test]
4220 fn spacer_in_row_pads_with_spaces() {
4221 let spec = WidgetSpec::Row {
4222 wrap: false,
4223 children: vec![
4224 WidgetSpec::Toggle {
4225 checked: false,
4226 label: "A".into(),
4227 focused: false,
4228 key: None,
4229 },
4230 WidgetSpec::Spacer {
4231 cols: 4,
4232 flex: false,
4233 key: None,
4234 },
4235 WidgetSpec::Button {
4236 label: "Go".into(),
4237 focused: false,
4238 intent: ButtonKind::Normal,
4239 key: None,
4240 disabled: false,
4241 },
4242 ],
4243 key: None,
4244 };
4245 let (out, _hits, _state) = render_no_focus(&spec, &HashMap::new());
4246 assert_eq!(out.len(), 1);
4247 assert_eq!(out[0].text, "[ ] A [ Go ]\n");
4248 }
4249
4250 #[test]
4251 fn row_collapses_inline_children_with_shifted_overlays() {
4252 let spec = WidgetSpec::Row {
4253 wrap: false,
4254 children: vec![
4255 WidgetSpec::HintBar {
4256 entries: vec![HintEntry {
4257 keys: "Tab".into(),
4258 label: "x".into(),
4259 }],
4260 key: None,
4261 },
4262 WidgetSpec::HintBar {
4263 entries: vec![HintEntry {
4264 keys: "Esc".into(),
4265 label: "y".into(),
4266 }],
4267 key: None,
4268 },
4269 ],
4270 key: None,
4271 };
4272 let (out, _hits, _state) = render_no_focus(&spec, &HashMap::new());
4273 assert_eq!(out.len(), 1);
4274 assert_eq!(out[0].text, "Tab xEsc y\n");
4276 assert_eq!(out[0].inline_overlays.len(), 2);
4277 assert_eq!(out[0].inline_overlays[1].start, 5);
4278 assert_eq!(out[0].inline_overlays[1].end, 8);
4279 }
4280
4281 #[test]
4286 fn toggle_emits_hit_area_with_toggle_payload() {
4287 let spec = WidgetSpec::Toggle {
4288 checked: false,
4289 label: "Case".into(),
4290 focused: false,
4291 key: Some("case".into()),
4292 };
4293 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4294 assert_eq!(hits.len(), 1);
4295 let h = &hits[0];
4296 assert_eq!(h.widget_key, "case");
4297 assert_eq!(h.widget_kind, "toggle");
4298 assert_eq!(h.event_type, "toggle");
4299 assert_eq!(h.buffer_row, 0);
4300 assert_eq!(h.byte_start, 0);
4301 assert_eq!(h.byte_end, "[ ] Case".len());
4302 assert_eq!(h.payload, json!({"checked": true}));
4303 }
4304
4305 #[test]
4306 fn button_emits_hit_area_with_activate_payload() {
4307 let spec = WidgetSpec::Button {
4308 label: "Replace All".into(),
4309 focused: false,
4310 intent: ButtonKind::Primary,
4311 key: Some("replace".into()),
4312 disabled: false,
4313 };
4314 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4315 assert_eq!(hits.len(), 1);
4316 let h = &hits[0];
4317 assert_eq!(h.widget_key, "replace");
4318 assert_eq!(h.widget_kind, "button");
4319 assert_eq!(h.event_type, "activate");
4320 assert_eq!(h.byte_end, "[ Replace All ]".len());
4321 assert_eq!(h.payload, json!({}));
4322 }
4323
4324 #[test]
4325 fn disabled_button_omits_hit_area_and_skips_tabbable() {
4326 let spec = WidgetSpec::Row {
4327 wrap: false,
4328 children: vec![
4329 WidgetSpec::Button {
4330 label: "Archive".into(),
4331 focused: false,
4332 intent: ButtonKind::Normal,
4333 key: Some("archive".into()),
4334 disabled: true,
4335 },
4336 WidgetSpec::Button {
4337 label: "Cancel".into(),
4338 focused: false,
4339 intent: ButtonKind::Normal,
4340 key: Some("cancel".into()),
4341 disabled: false,
4342 },
4343 ],
4344 key: None,
4345 };
4346 let out = render_spec(&spec, &HashMap::new(), "", 30);
4347 assert_eq!(
4348 out.hits
4349 .iter()
4350 .filter(|h| h.widget_kind == "button")
4351 .count(),
4352 1,
4353 "disabled button should not emit a hit area"
4354 );
4355 assert_eq!(
4356 out.tabbable,
4357 vec!["cancel".to_string()],
4358 "disabled button must drop out of the Tab cycle"
4359 );
4360 }
4361
4362 #[test]
4363 fn disabled_button_uses_menu_disabled_fg_overlay() {
4364 let entry = render_button("Archive", false, ButtonKind::Danger, true);
4365 assert_eq!(entry.inline_overlays.len(), 1);
4366 let style = &entry.inline_overlays[0].style;
4367 assert_eq!(
4368 style.fg.as_ref().and_then(|c| c.as_theme_key()),
4369 Some("ui.menu_disabled_fg"),
4370 "disabled overrides Danger fg with the muted theme key"
4371 );
4372 assert!(
4373 !style.bold,
4374 "disabled buttons drop the intent's bold emphasis"
4375 );
4376 assert!(style.bg.is_none(), "disabled buttons paint no bg");
4377 }
4378
4379 #[test]
4380 fn row_inline_collapse_shifts_hit_byte_offsets() {
4381 let spec = WidgetSpec::Row {
4382 wrap: false,
4383 children: vec![
4384 WidgetSpec::Toggle {
4385 checked: true,
4386 label: "A".into(),
4387 focused: false,
4388 key: Some("a".into()),
4389 },
4390 WidgetSpec::Spacer {
4391 cols: 2,
4392 flex: false,
4393 key: None,
4394 },
4395 WidgetSpec::Toggle {
4396 checked: false,
4397 label: "B".into(),
4398 focused: false,
4399 key: Some("b".into()),
4400 },
4401 ],
4402 key: None,
4403 };
4404 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4405 assert_eq!(entries.len(), 1);
4407 assert_eq!(entries[0].text, "[v] A [ ] B\n");
4408 assert_eq!(hits.len(), 2);
4409 assert_eq!(hits[0].widget_key, "a");
4410 assert_eq!(hits[0].buffer_row, 0);
4411 assert_eq!(hits[0].byte_start, 0);
4412 assert_eq!(hits[0].byte_end, 5); assert_eq!(hits[1].widget_key, "b");
4416 assert_eq!(hits[1].buffer_row, 0);
4417 assert_eq!(hits[1].byte_start, 7);
4418 assert_eq!(hits[1].byte_end, 12);
4419 }
4420
4421 #[test]
4422 fn col_stacks_hit_rows() {
4423 let spec = WidgetSpec::Col {
4424 children: vec![
4425 WidgetSpec::Toggle {
4426 checked: false,
4427 label: "row0".into(),
4428 focused: false,
4429 key: Some("k0".into()),
4430 },
4431 WidgetSpec::Toggle {
4432 checked: true,
4433 label: "row1".into(),
4434 focused: false,
4435 key: Some("k1".into()),
4436 },
4437 ],
4438 key: None,
4439 };
4440 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4441 assert_eq!(hits.len(), 2);
4442 assert_eq!(hits[0].buffer_row, 0);
4443 assert_eq!(hits[1].buffer_row, 1);
4444 }
4445
4446 #[test]
4451 fn collect_tabbable_visits_widgets_with_keys_in_declaration_order() {
4452 let spec = WidgetSpec::Col {
4453 children: vec![
4454 WidgetSpec::HintBar {
4455 entries: vec![],
4456 key: Some("hb".into()),
4457 },
4458 WidgetSpec::Row {
4459 wrap: false,
4460 children: vec![
4461 WidgetSpec::Toggle {
4462 checked: false,
4463 label: "T".into(),
4464 focused: false,
4465 key: Some("t".into()),
4466 },
4467 WidgetSpec::Spacer {
4468 cols: 1,
4469 flex: false,
4470 key: None,
4471 },
4472 WidgetSpec::Button {
4473 label: "B".into(),
4474 focused: false,
4475 intent: ButtonKind::Normal,
4476 key: Some("b".into()),
4477 disabled: false,
4478 },
4479 ],
4480 key: None,
4481 },
4482 WidgetSpec::Text {
4483 value: "".into(),
4484 cursor_byte: -1,
4485 focused: false,
4486 label: "".into(),
4487 placeholder: None,
4488 rows: 1,
4489 field_width: 0,
4490 max_visible_chars: 0,
4491 full_width: false,
4492 completions: Vec::new(),
4493 completions_visible_rows: 0,
4494 key: Some("ti".into()),
4495 },
4496 WidgetSpec::Toggle {
4497 checked: false,
4498 label: "no key".into(),
4499 focused: false,
4500 key: None,
4501 },
4502 ],
4503 key: None,
4504 };
4505 let mut tabbable = Vec::new();
4506 collect_tabbable(&spec, &mut tabbable);
4507 assert_eq!(tabbable, vec!["t", "b", "ti"]);
4510 }
4511
4512 #[test]
4513 fn first_render_focuses_first_tabbable() {
4514 let spec = WidgetSpec::Row {
4515 wrap: false,
4516 children: vec![
4517 WidgetSpec::Toggle {
4518 checked: false,
4519 label: "A".into(),
4520 focused: false,
4521 key: Some("a".into()),
4522 },
4523 WidgetSpec::Toggle {
4524 checked: false,
4525 label: "B".into(),
4526 focused: false,
4527 key: Some("b".into()),
4528 },
4529 ],
4530 key: None,
4531 };
4532 let out = render_spec(&spec, &HashMap::new(), "", u32::MAX);
4533 assert_eq!(out.focus_key, "a");
4534 assert_eq!(out.tabbable, vec!["a", "b"]);
4535 }
4536
4537 #[test]
4538 fn render_preserves_focus_key_across_re_renders() {
4539 let spec = WidgetSpec::Row {
4540 wrap: false,
4541 children: vec![
4542 WidgetSpec::Toggle {
4543 checked: false,
4544 label: "A".into(),
4545 focused: false,
4546 key: Some("a".into()),
4547 },
4548 WidgetSpec::Toggle {
4549 checked: false,
4550 label: "B".into(),
4551 focused: false,
4552 key: Some("b".into()),
4553 },
4554 ],
4555 key: None,
4556 };
4557 let out = render_spec(&spec, &HashMap::new(), "b", u32::MAX);
4558 assert_eq!(out.focus_key, "b");
4559 }
4560
4561 #[test]
4562 fn render_clamps_stale_focus_key_to_first_tabbable() {
4563 let spec = WidgetSpec::Toggle {
4567 checked: false,
4568 label: "Only".into(),
4569 focused: false,
4570 key: Some("only".into()),
4571 };
4572 let out = render_spec(&spec, &HashMap::new(), "stale", u32::MAX);
4573 assert_eq!(out.focus_key, "only");
4574 }
4575
4576 #[test]
4577 fn focused_widget_renders_with_focused_styling() {
4578 let spec = WidgetSpec::Row {
4579 wrap: false,
4580 children: vec![
4581 WidgetSpec::Toggle {
4582 checked: false,
4583 label: "A".into(),
4584 focused: false,
4585 key: Some("a".into()),
4586 },
4587 WidgetSpec::Toggle {
4588 checked: false,
4589 label: "B".into(),
4590 focused: false,
4591 key: Some("b".into()),
4592 },
4593 ],
4594 key: None,
4595 };
4596 let out = render_spec(&spec, &HashMap::new(), "b", u32::MAX);
4597 assert_eq!(out.entries.len(), 1, "row collapses inline");
4598 let entry = &out.entries[0];
4604 let focused_overlay = entry
4605 .inline_overlays
4606 .iter()
4607 .find(|o| {
4608 o.style.bg.as_ref().and_then(|c| c.as_theme_key()) == Some("ui.popup_selection_bg")
4609 })
4610 .expect("focused overlay present on B");
4611 assert_eq!(focused_overlay.start, 5);
4614 assert_eq!(focused_overlay.end, 10);
4615 }
4616
4617 #[test]
4618 fn no_tabbables_yields_empty_focus_key() {
4619 let spec = WidgetSpec::Col {
4620 children: vec![WidgetSpec::HintBar {
4621 entries: vec![],
4622 key: None,
4623 }],
4624 key: None,
4625 };
4626 let out = render_spec(&spec, &HashMap::new(), "", u32::MAX);
4627 assert_eq!(out.focus_key, "");
4628 assert!(out.tabbable.is_empty());
4629 }
4630
4631 #[test]
4636 fn list_emits_one_entry_and_one_hit_per_item() {
4637 let spec = WidgetSpec::List {
4638 items: vec![
4639 TextPropertyEntry::text("alpha"),
4640 TextPropertyEntry::text("beta"),
4641 TextPropertyEntry::text("gamma"),
4642 ],
4643 item_specs: vec![],
4644 item_keys: vec!["a".into(), "b".into(), "c".into()],
4645 selected_index: -1,
4646 visible_rows: 10,
4647 focusable: true,
4648 key: None,
4649 };
4650 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4651 assert_eq!(entries.len(), 10);
4657 assert_eq!(hits.len(), 3);
4660 for (i, h) in hits.iter().enumerate() {
4661 assert_eq!(h.buffer_row, i as u32);
4662 assert_eq!(h.widget_kind, "list");
4663 assert_eq!(h.event_type, "select");
4664 assert_eq!(h.payload["index"], i);
4665 }
4666 assert_eq!(hits[0].widget_key, "a");
4667 assert_eq!(hits[2].widget_key, "c");
4668 }
4669
4670 #[test]
4671 fn list_item_specs_render_multirow_cards_in_item_units() {
4672 let card = |body: &str| WidgetSpec::LabeledSection {
4675 label: String::new(),
4676 child: Box::new(WidgetSpec::Raw {
4677 entries: vec![TextPropertyEntry::text(body)],
4678 key: None,
4679 }),
4680 width_pct: None,
4681 key: None,
4682 };
4683 let spec = WidgetSpec::List {
4684 items: vec![],
4685 item_specs: vec![card("aaa"), card("bbb")],
4686 item_keys: vec!["a".into(), "b".into()],
4687 selected_index: 1,
4688 visible_rows: 12,
4690 focusable: true,
4691 key: Some("cards".into()),
4692 };
4693 let out = render_spec(&spec, &HashMap::new(), "", 40);
4696 let (entries, hits) = (out.entries, out.hits);
4697 assert_eq!(entries.len(), 12);
4699 assert_eq!(hits.len(), 6, "3 rows per card * 2 cards");
4702 assert!(hits[0..3]
4703 .iter()
4704 .all(|h| h.payload["index"] == 0 && h.widget_key == "a"));
4705 assert!(hits[3..6]
4706 .iter()
4707 .all(|h| h.payload["index"] == 1 && h.widget_key == "b"));
4708 for r in 0..3 {
4713 assert!(
4714 !entries[r].text.contains('┓') && !entries[r].text.contains('┃'),
4715 "unselected card row {r} should keep the light border"
4716 );
4717 assert!(entries[r].style.as_ref().map_or(true, |s| s.bg.is_none()));
4718 }
4719 let heavy = (3..6).any(|r| {
4722 entries[r].text.contains('┏')
4723 || entries[r].text.contains('┗')
4724 || entries[r].text.contains('┃')
4725 });
4726 assert!(heavy, "selected card should use a heavy box border");
4727 for r in 3..6 {
4728 let style = entries[r].style.as_ref();
4729 assert!(
4730 style.map(|s| s.bold).unwrap_or(false),
4731 "row {r} of the selected card should be bold"
4732 );
4733 assert!(
4734 style.and_then(|s| s.bg.as_ref()).is_none(),
4735 "row {r} of the selected card should NOT use a background band"
4736 );
4737 }
4738 assert!(entries[0].text.starts_with('╭'));
4740 assert!(entries[2].text.starts_with('╰'));
4741 }
4742
4743 #[test]
4744 fn selected_card_accent_frames_all_four_sides() {
4745 let card = |body: &str| WidgetSpec::LabeledSection {
4752 label: String::new(),
4753 child: Box::new(WidgetSpec::Raw {
4754 entries: vec![TextPropertyEntry::text(body)],
4755 key: None,
4756 }),
4757 width_pct: None,
4758 key: None,
4759 };
4760 let spec = WidgetSpec::List {
4761 items: vec![],
4762 item_specs: vec![card("aaa"), card("bbb")],
4763 item_keys: vec!["a".into(), "b".into()],
4764 selected_index: 1,
4765 visible_rows: 12,
4766 focusable: true,
4767 key: Some("cards".into()),
4768 };
4769 let out = render_spec(&spec, &HashMap::new(), "", 40);
4770 let entries = out.entries;
4771 let accent_is = |c: &OverlayColorSpec| matches!(c, OverlayColorSpec::ThemeKey(k) if k == "ui.popup_border_fg");
4773 for r in [3usize, 5] {
4775 let fg = entries[r].style.as_ref().and_then(|s| s.fg.as_ref());
4776 assert!(
4777 fg.map(accent_is).unwrap_or(false),
4778 "row {r} (top/bottom border) should carry the accent fg"
4779 );
4780 }
4781 let body = &entries[4];
4785 assert!(
4786 body.text.contains('┃'),
4787 "selected card body row should have heavy side borders: {:?}",
4788 body.text
4789 );
4790 assert!(
4791 body.style.as_ref().and_then(|s| s.fg.as_ref()).is_none(),
4792 "body row must not set a whole-row fg (would repaint the text)"
4793 );
4794 let bar_overlays: Vec<_> = body
4795 .inline_overlays
4796 .iter()
4797 .filter(|o| o.style.fg.as_ref().map(accent_is).unwrap_or(false))
4798 .collect();
4799 assert_eq!(
4800 bar_overlays.len(),
4801 2,
4802 "both the leading and trailing ┃ should be accent-tinted: {:?}",
4803 body.inline_overlays
4804 );
4805 for o in bar_overlays {
4807 assert_eq!(o.end - o.start, '┃'.len_utf8());
4808 assert_eq!(&body.text[o.start..o.end], "┃");
4809 }
4810 }
4811
4812 #[test]
4813 fn list_applies_selection_bg_to_selected_row() {
4814 let spec = WidgetSpec::List {
4815 items: vec![
4816 TextPropertyEntry::text("first"),
4817 TextPropertyEntry::text("second"),
4818 ],
4819 item_specs: vec![],
4820 item_keys: vec!["x".into(), "y".into()],
4821 selected_index: 1,
4822 visible_rows: 10,
4823 focusable: true,
4824 key: None,
4825 };
4826 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
4827 assert!(entries[0].style.is_none(), "unselected row keeps no style");
4828 let style = entries[1].style.as_ref().expect("selected row gets style");
4829 assert_eq!(
4830 style.bg.as_ref().and_then(|c| c.as_theme_key()),
4831 Some("ui.popup_selection_bg"),
4832 );
4833 assert!(style.extend_to_line_end);
4834 }
4835
4836 #[test]
4837 fn list_inside_col_offsets_hit_rows_by_preceding_lines() {
4838 let spec = WidgetSpec::Col {
4839 children: vec![
4840 WidgetSpec::HintBar {
4841 entries: vec![HintEntry {
4842 keys: "h".into(),
4843 label: "header".into(),
4844 }],
4845 key: None,
4846 },
4847 WidgetSpec::List {
4848 items: vec![
4849 TextPropertyEntry::text("row0"),
4850 TextPropertyEntry::text("row1"),
4851 ],
4852 item_specs: vec![],
4853 item_keys: vec!["a".into(), "b".into()],
4854 selected_index: -1,
4855 visible_rows: 10,
4856 key: None,
4857 focusable: true,
4858 },
4859 ],
4860 key: None,
4861 };
4862 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4863 assert_eq!(entries.len(), 11);
4866 assert_eq!(hits.len(), 2);
4869 assert_eq!(hits[0].buffer_row, 1);
4871 assert_eq!(hits[1].buffer_row, 2);
4872 }
4873
4874 #[test]
4875 fn list_payload_includes_absolute_index_and_key() {
4876 let spec = WidgetSpec::List {
4877 items: vec![TextPropertyEntry::text("only")],
4878 item_specs: vec![],
4879 item_keys: vec!["match:42".into()],
4880 selected_index: 0,
4881 visible_rows: 10,
4882 focusable: true,
4883 key: None,
4884 };
4885 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4886 assert_eq!(hits[0].payload["index"], 0);
4887 assert_eq!(hits[0].payload["key"], "match:42");
4888 }
4889
4890 #[test]
4891 fn list_hit_payload_carries_list_key() {
4892 let spec = make_list(-1, 10, 2, Some("mylist"));
4898 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4899 assert_eq!(hits.len(), 2);
4900 assert_eq!(hits[0].payload["list_key"], "mylist");
4901 assert_eq!(hits[1].payload["list_key"], "mylist");
4902 }
4903
4904 #[test]
4905 fn list_hit_payload_list_key_is_null_when_keyless() {
4906 let spec = make_list(-1, 10, 1, None);
4909 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4910 assert!(hits[0].payload["list_key"].is_null());
4911 }
4912
4913 #[test]
4914 fn list_with_missing_key_emits_empty_widget_key() {
4915 let spec = WidgetSpec::List {
4916 items: vec![TextPropertyEntry::text("a"), TextPropertyEntry::text("b")],
4917 item_specs: vec![],
4919 item_keys: vec!["only".into()],
4920 selected_index: -1,
4921 visible_rows: 10,
4922 focusable: true,
4923 key: None,
4924 };
4925 let (_, hits, _state) = render_no_focus(&spec, &HashMap::new());
4926 assert_eq!(hits[0].widget_key, "only");
4927 assert_eq!(hits[1].widget_key, "");
4928 }
4929
4930 fn make_list(selected: i32, visible: u32, total: usize, key: Option<&str>) -> WidgetSpec {
4931 let items = (0..total)
4932 .map(|i| TextPropertyEntry::text(format!("row{}", i)))
4933 .collect();
4934 let item_keys = (0..total).map(|i| format!("k{}", i)).collect();
4935 WidgetSpec::List {
4936 items,
4937 item_specs: vec![],
4938 item_keys,
4939 selected_index: selected,
4940 visible_rows: visible,
4941 focusable: true,
4942 key: key.map(|s| s.to_string()),
4943 }
4944 }
4945
4946 #[test]
4947 fn list_renders_only_visible_window() {
4948 let spec = make_list(-1, 3, 10, Some("L"));
4949 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4950 assert_eq!(entries.len(), 3);
4951 assert_eq!(hits.len(), 3);
4952 assert_eq!(hits[0].payload["index"], 0);
4954 assert_eq!(hits[2].payload["index"], 2);
4955 }
4956
4957 #[test]
4958 fn list_scrolls_to_keep_selected_below_window_in_view() {
4959 let spec = make_list(5, 3, 10, Some("L"));
4964 let (_entries, hits, state) = render_no_focus(&spec, &HashMap::new());
4965 assert_eq!(hits.len(), 3);
4967 assert_eq!(hits[0].payload["index"], 3);
4968 assert_eq!(hits[2].payload["index"], 5);
4969 let scroll = match state.get("L").unwrap() {
4970 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
4971 _ => unreachable!(),
4972 };
4973 assert_eq!(scroll, 3);
4974 }
4975
4976 #[test]
4977 fn list_scrolls_to_keep_selected_above_window_in_view() {
4978 let mut prev = HashMap::new();
4984 prev.insert(
4985 "L".into(),
4986 WidgetInstanceState::List {
4987 scroll_offset: 5,
4988 selected_index: 1,
4989 item_height: 1,
4990 user_scrolled: false,
4991 },
4992 );
4993 let spec = make_list(99, 3, 10, Some("L"));
4995 let (_entries, hits, state) = render_no_focus(&spec, &prev);
4996 assert_eq!(hits[0].payload["index"], 1);
4997 let scroll = match state.get("L").unwrap() {
4998 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
4999 _ => unreachable!(),
5000 };
5001 assert_eq!(scroll, 1);
5002 }
5003
5004 #[test]
5005 fn list_scroll_preserved_when_selection_remains_in_view() {
5006 let mut prev = HashMap::new();
5009 prev.insert(
5010 "L".into(),
5011 WidgetInstanceState::List {
5012 scroll_offset: 4,
5013 selected_index: 5,
5014 item_height: 1,
5015 user_scrolled: false,
5016 },
5017 );
5018 let spec = make_list(99, 3, 10, Some("L"));
5019 let (_entries, hits, state) = render_no_focus(&spec, &prev);
5020 assert_eq!(hits[0].payload["index"], 4);
5021 let scroll = match state.get("L").unwrap() {
5022 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5023 _ => unreachable!(),
5024 };
5025 assert_eq!(scroll, 4);
5026 }
5027
5028 #[test]
5029 fn list_clamps_scroll_to_max_when_dataset_is_smaller_than_old_offset() {
5030 let mut prev = HashMap::new();
5033 prev.insert(
5034 "L".into(),
5035 WidgetInstanceState::List {
5036 scroll_offset: 8,
5037 selected_index: -1,
5038 item_height: 1,
5039 user_scrolled: false,
5040 },
5041 );
5042 let spec = make_list(-1, 3, 5, Some("L"));
5043 let (entries, _hits, state) = render_no_focus(&spec, &prev);
5044 assert_eq!(entries.len(), 3);
5045 let scroll = match state.get("L").unwrap() {
5046 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5047 _ => unreachable!(),
5048 };
5049 assert_eq!(scroll, 2);
5051 }
5052
5053 #[test]
5054 fn list_does_not_scroll_when_total_smaller_than_visible() {
5055 let spec = make_list(-1, 10, 3, Some("L"));
5056 let (entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
5057 assert_eq!(entries.len(), 10);
5062 let scroll = match state.get("L").unwrap() {
5063 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
5064 _ => unreachable!(),
5065 };
5066 assert_eq!(scroll, 0);
5067 }
5068
5069 #[test]
5070 fn list_without_key_does_not_persist_state() {
5071 let spec = make_list(5, 3, 10, None);
5072 let (_entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
5073 assert!(
5074 state.is_empty(),
5075 "Lists without a `key` opt out of state preservation"
5076 );
5077 }
5078
5079 #[test]
5084 fn text_input_renders_value_in_brackets() {
5085 let entry = render_text_input("hello", -1, None, false, "", None, 0, 0, false).entry;
5086 assert_eq!(entry.text, "[hello]");
5087 assert!(entry.inline_overlays.is_empty());
5088 }
5089
5090 #[test]
5091 fn text_input_with_label_prefixes_with_label_space() {
5092 let entry = render_text_input("foo", -1, None, false, "Search:", None, 0, 0, false).entry;
5093 assert_eq!(entry.text, "Search: [foo]");
5094 }
5095
5096 #[test]
5097 fn text_input_focused_adds_input_bg_overlay() {
5098 let entry = render_text_input("x", -1, None, true, "", None, 0, 0, false).entry;
5099 assert_eq!(entry.inline_overlays.len(), 1);
5101 let bg = entry.inline_overlays[0].style.bg.as_ref().unwrap();
5102 assert_eq!(bg.as_theme_key(), Some("ui.prompt_bg"));
5103 }
5104
5105 #[test]
5106 fn text_input_focused_with_selection_adds_selection_bg_overlay() {
5107 let entry =
5110 render_text_input("hello world", 5, Some((0, 5)), true, "", None, 0, 0, false).entry;
5111 let sel = entry
5114 .inline_overlays
5115 .iter()
5116 .find(|o| {
5117 o.style.bg.as_ref().and_then(|c| c.as_theme_key())
5118 == Some("ui.text_input_selection_bg")
5119 })
5120 .expect("selection overlay present");
5121 assert_eq!(sel.start, 1);
5122 assert_eq!(sel.end, 6);
5123 }
5124
5125 #[test]
5126 fn text_input_unfocused_skips_selection_overlay() {
5127 let entry =
5130 render_text_input("hello", -1, Some((0, 5)), false, "", None, 0, 0, false).entry;
5131 let has_sel_overlay = entry.inline_overlays.iter().any(|o| {
5132 o.style.bg.as_ref().and_then(|c| c.as_theme_key()) == Some("ui.text_input_selection_bg")
5133 });
5134 assert!(!has_sel_overlay);
5135 }
5136
5137 #[test]
5138 fn text_area_focused_with_selection_emits_per_row_overlays() {
5139 let r = render_text_area("abcd\nefgh", 8, Some((2, 8)), true, "", None, 2, 0, 0, 80);
5143 let row0 = &r.entries[0];
5146 let row1 = &r.entries[1];
5147 let sel0 = row0
5148 .inline_overlays
5149 .iter()
5150 .find(|o| {
5151 o.style.bg.as_ref().and_then(|c| c.as_theme_key())
5152 == Some("ui.text_input_selection_bg")
5153 })
5154 .expect("row 0 selection overlay");
5155 assert_eq!((sel0.start, sel0.end), (2, 4));
5156 let sel1 = row1
5157 .inline_overlays
5158 .iter()
5159 .find(|o| {
5160 o.style.bg.as_ref().and_then(|c| c.as_theme_key())
5161 == Some("ui.text_input_selection_bg")
5162 })
5163 .expect("row 1 selection overlay");
5164 assert_eq!((sel1.start, sel1.end), (0, 3));
5165 }
5166
5167 #[test]
5168 fn text_input_cursor_byte_in_entry_at_value_position() {
5169 let r = render_text_input("abc", 1, None, true, "", None, 0, 0, false);
5174 assert_eq!(r.cursor_byte_in_entry, Some(2));
5175 }
5176
5177 #[test]
5178 fn text_input_cursor_at_end_lands_on_padding_space_not_bracket() {
5179 let r = render_text_input("ab", 2, None, true, "", None, 0, 0, false);
5185 assert_eq!(r.entry.text, "[ab ]");
5186 assert_eq!(r.cursor_byte_in_entry, Some(3));
5187 assert_ne!(r.cursor_byte_in_entry, Some(4), "must not overlap ]");
5188 }
5189
5190 #[test]
5191 fn text_input_unfocused_empty_shows_placeholder_in_muted() {
5192 let entry =
5193 render_text_input("", -1, None, false, "", Some("type here"), 0, 0, false).entry;
5194 assert_eq!(entry.text, "[type here]");
5195 let placeholder_overlay = entry
5197 .inline_overlays
5198 .iter()
5199 .find(|o| o.style.fg.as_ref().and_then(|c| c.as_theme_key()).is_some())
5200 .expect("placeholder fg overlay");
5201 let fg = placeholder_overlay.style.fg.as_ref().unwrap();
5202 assert_eq!(fg.as_theme_key(), Some("editor.whitespace_indicator_fg"));
5203 assert!(placeholder_overlay.style.italic);
5204 }
5205
5206 #[test]
5207 fn text_input_focused_empty_still_shows_placeholder() {
5208 let r = render_text_input("", -1, None, true, "", Some("type here"), 0, 0, false);
5212 assert_eq!(r.entry.text, "[type here]");
5213 assert_eq!(r.cursor_byte_in_entry, Some(1));
5214 }
5215
5216 #[test]
5217 fn text_input_field_width_pads_short_value_unfocused() {
5218 let r = render_text_input("hi", 2, None, false, "", None, 0, 10, false);
5221 assert_eq!(r.entry.text, "[hi ]");
5222 }
5223
5224 #[test]
5225 fn text_input_field_width_focused_adds_cursor_park_space() {
5226 let r = render_text_input("0123456789", 10, None, true, "", None, 0, 10, false);
5230 assert_eq!(r.entry.text, "[0123456789 ]");
5231 assert_eq!(r.cursor_byte_in_entry, Some(11));
5235 assert_ne!(r.cursor_byte_in_entry, Some(12), "must not land on ]");
5236 }
5237
5238 #[test]
5239 fn text_input_field_width_full_width_pads_to_same_size_when_unfocused() {
5240 let r = render_text_input("hi", -1, None, false, "", None, 0, 10, true);
5244 assert_eq!(r.entry.text, "[hi ]"); }
5246
5247 #[test]
5248 fn text_input_field_width_head_truncates_long_value() {
5249 let r = render_text_input(
5252 "0123456789abcdefghijklmnopqrst",
5253 30,
5254 None,
5255 false,
5256 "",
5257 None,
5258 0,
5259 10,
5260 false,
5261 );
5262 assert!(r.entry.text.contains("…lmnopqrst"));
5263 }
5264
5265 #[test]
5266 fn text_input_field_width_clamps_cursor_in_dropped_prefix() {
5267 let r = render_text_input("abcdefghij", 0, None, true, "", None, 0, 5, false);
5270 assert_eq!(r.cursor_byte_in_entry, Some(1 + "…".len()));
5275 }
5276
5277 #[test]
5278 fn text_input_truncates_long_value_keeping_tail_visible() {
5279 let value: String = "0123456789abcdefghij".to_string();
5280 let entry = render_text_input(&value, -1, None, false, "", None, 6, 0, false).entry;
5281 assert_eq!(entry.text, "[…fghij]");
5283 }
5284
5285 #[test]
5286 fn raw_inside_col_offsets_following_hits() {
5287 let spec = WidgetSpec::Col {
5288 children: vec![
5289 WidgetSpec::Raw {
5290 entries: vec![
5291 TextPropertyEntry::text("line0"),
5292 TextPropertyEntry::text("line1"),
5293 TextPropertyEntry::text("line2"),
5294 ],
5295 key: None,
5296 },
5297 WidgetSpec::Toggle {
5298 checked: false,
5299 label: "after raw".into(),
5300 focused: false,
5301 key: Some("post".into()),
5302 },
5303 ],
5304 key: None,
5305 };
5306 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5307 assert_eq!(entries.len(), 4);
5308 assert_eq!(hits.len(), 1);
5309 assert_eq!(hits[0].buffer_row, 3);
5310 }
5311
5312 fn tnode(text: &str, depth: u32, has_children: bool) -> TreeNode {
5317 TreeNode {
5318 text: TextPropertyEntry::text(text),
5319 depth,
5320 has_children,
5321 checked: None,
5322 }
5323 }
5324
5325 fn make_tree(
5326 nodes: Vec<TreeNode>,
5327 item_keys: Vec<&str>,
5328 selected: i32,
5329 visible: u32,
5330 expanded: Vec<&str>,
5331 key: Option<&str>,
5332 ) -> WidgetSpec {
5333 WidgetSpec::Tree {
5334 nodes,
5335 item_keys: item_keys.iter().map(|s| s.to_string()).collect(),
5336 selected_index: selected,
5337 visible_rows: visible,
5338 expanded_keys: expanded.iter().map(|s| s.to_string()).collect(),
5339 checkable: false,
5340 key: key.map(|s| s.to_string()),
5341 }
5342 }
5343
5344 #[test]
5345 fn tree_row_renders_disclosure_glyph_for_internal_collapsed() {
5346 let r = render_tree_row(&tnode("file.txt", 0, true), false, false);
5347 assert!(r.entry.text.starts_with('\u{25B6}'), "starts with ▶");
5348 assert!(r.entry.text.contains("file.txt"));
5349 assert!(r.disclosure_range.is_some());
5350 }
5351
5352 #[test]
5353 fn tree_row_renders_disclosure_glyph_for_internal_expanded() {
5354 let r = render_tree_row(&tnode("file.txt", 0, true), true, false);
5355 assert!(r.entry.text.starts_with('\u{25BC}'), "starts with ▼");
5356 }
5357
5358 #[test]
5359 fn tree_row_leaf_uses_two_spaces_no_disclosure_hit() {
5360 let r = render_tree_row(&tnode("match", 0, false), false, false);
5361 assert!(r.entry.text.starts_with(" "));
5363 assert!(r.entry.text.contains("match"));
5364 assert!(r.disclosure_range.is_none());
5365 }
5366
5367 #[test]
5368 fn tree_row_indents_by_depth_times_two() {
5369 let r = render_tree_row(&tnode("nested", 2, false), false, false);
5370 assert!(r.entry.text.starts_with(" nested"));
5372 }
5373
5374 #[test]
5375 fn tree_row_shifts_plugin_overlays_by_prefix() {
5376 let mut node = tnode("hello", 1, false);
5377 node.text.inline_overlays.push(InlineOverlay {
5378 start: 0,
5379 end: 5,
5380 style: OverlayOptions {
5381 bold: true,
5382 ..Default::default()
5383 },
5384 properties: Default::default(),
5385 unit: OffsetUnit::Byte,
5386 });
5387 let r = render_tree_row(&node, false, false);
5388 let plugin_overlay = r
5391 .entry
5392 .inline_overlays
5393 .iter()
5394 .find(|o| o.style.bold)
5395 .expect("bold overlay carried through");
5396 assert_eq!(plugin_overlay.start, 4);
5397 assert_eq!(plugin_overlay.end, 9);
5398 }
5399
5400 #[test]
5401 fn tree_row_omits_checkbox_when_not_checkable() {
5402 let mut node = tnode("file.rs", 0, false);
5404 node.checked = Some(true);
5405 let r = render_tree_row(&node, false, false);
5406 assert!(r.checkbox_range.is_none());
5407 assert!(!r.entry.text.contains("[v]"));
5408 assert!(!r.entry.text.contains("[ ]"));
5409 }
5410
5411 #[test]
5412 fn tree_row_omits_checkbox_when_checked_is_none() {
5413 let node = tnode("section", 0, false);
5417 let r = render_tree_row(&node, false, true);
5418 assert!(r.checkbox_range.is_none());
5419 assert!(!r.entry.text.contains("[v]"));
5420 assert!(!r.entry.text.contains("[ ]"));
5421 }
5422
5423 #[test]
5424 fn tree_row_renders_checked_glyph_after_disclosure() {
5425 let mut node = tnode("file.rs", 0, true);
5426 node.checked = Some(true);
5427 let r = render_tree_row(&node, true, true);
5428 assert!(r.checkbox_range.is_some(), "checkbox range emitted");
5429 let (cb_start, cb_end) = r.checkbox_range.unwrap();
5430 assert_eq!(&r.entry.text[cb_start..cb_end], "[v]");
5432 assert!(r.entry.text.contains("[v] file.rs"));
5433 }
5434
5435 #[test]
5436 fn tree_row_renders_unchecked_glyph_for_leaf() {
5437 let mut node = tnode("match-row", 1, false);
5438 node.checked = Some(false);
5439 let r = render_tree_row(&node, false, true);
5440 let (cb_start, cb_end) = r
5441 .checkbox_range
5442 .expect("checkbox range for leaf with checked: Some");
5443 assert_eq!(&r.entry.text[cb_start..cb_end], "[ ]");
5444 assert!(r.entry.text.starts_with(" [ ] match-row"));
5446 }
5447
5448 #[test]
5449 fn tree_row_checkbox_glyph_byte_range_addresses_correct_text() {
5450 let mut node = tnode("path/with/é", 0, true);
5453 node.checked = Some(true);
5454 let r = render_tree_row(&node, false, true);
5455 let (cb_start, cb_end) = r.checkbox_range.unwrap();
5456 assert!(r.entry.text.is_char_boundary(cb_start));
5457 assert!(r.entry.text.is_char_boundary(cb_end));
5458 assert_eq!(&r.entry.text[cb_start..cb_end], "[v]");
5459 }
5460
5461 #[test]
5462 fn tree_node_pad_to_chars_pads_text_before_prefix_offset_shift() {
5463 let mut node = tnode("x", 0, true);
5467 node.text.pad_to_chars = Some(5);
5468 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec!["x"], Some("T"));
5469 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5470 assert_eq!(entries.len(), 1);
5471 let trimmed = entries[0].text.trim_end_matches('\n');
5474 assert!(
5475 trimmed.ends_with("x "),
5476 "row should end with the padded body, got {trimmed:?}"
5477 );
5478 }
5479
5480 #[test]
5481 fn tree_node_truncate_to_chars_cuts_body_before_prefix_offset_shift() {
5482 let mut node = tnode("abcdefghij", 0, false);
5483 node.text.truncate_to_chars = Some(6);
5484 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5485 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5486 let trimmed = entries[0].text.trim_end_matches('\n');
5487 assert!(
5490 trimmed.ends_with("abc..."),
5491 "row should end with truncated body, got {trimmed:?}"
5492 );
5493 }
5494
5495 #[test]
5496 fn tree_node_char_unit_overlay_resolves_against_padded_text_and_shifts_by_prefix() {
5497 let mut node = tnode("x", 0, false);
5502 node.text.pad_to_chars = Some(5);
5503 node.text.inline_overlays.push(InlineOverlay {
5504 start: 0,
5505 end: 5,
5506 style: OverlayOptions {
5507 bold: true,
5508 ..Default::default()
5509 },
5510 properties: Default::default(),
5511 unit: OffsetUnit::Char,
5512 });
5513 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5514 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5515 let entry = &entries[0];
5516 let bold = entry
5517 .inline_overlays
5518 .iter()
5519 .find(|o| o.style.bold)
5520 .expect("bold overlay carried through");
5521 assert_eq!(bold.start, 2);
5524 assert_eq!(bold.end, 7);
5525 }
5526
5527 #[test]
5528 fn tree_node_char_unit_overlay_with_multibyte_body_resolves_correctly() {
5529 let mut node = tnode("éxé", 0, false);
5533 node.text.inline_overlays.push(InlineOverlay {
5534 start: 1,
5535 end: 2,
5536 style: OverlayOptions {
5537 bold: true,
5538 ..Default::default()
5539 },
5540 properties: Default::default(),
5541 unit: OffsetUnit::Char,
5542 });
5543 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5544 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5545 let entry = &entries[0];
5546 let bold = entry
5547 .inline_overlays
5548 .iter()
5549 .find(|o| o.style.bold)
5550 .expect("bold overlay carried through");
5551 let trimmed = entry.text.trim_end_matches('\n');
5554 assert_eq!(bold.start, 4);
5555 assert_eq!(bold.end, 5);
5556 assert_eq!(&trimmed[bold.start..bold.end], "x");
5557 }
5558
5559 #[test]
5560 fn tree_node_segments_concatenate_into_row_text_with_per_segment_overlays() {
5561 let mut node = tnode("", 0, false);
5562 node.text.segments = vec![
5563 fresh_core::text_property::StyledSegment {
5564 text: "AB".to_string(),
5565 style: None,
5566 overlays: vec![],
5567 },
5568 fresh_core::text_property::StyledSegment {
5569 text: " ".to_string(),
5570 style: None,
5571 overlays: vec![],
5572 },
5573 fresh_core::text_property::StyledSegment {
5574 text: "CD".to_string(),
5575 style: Some(OverlayOptions {
5576 bold: true,
5577 ..Default::default()
5578 }),
5579 overlays: vec![],
5580 },
5581 ];
5582 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5583 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5584 let trimmed = entries[0].text.trim_end_matches('\n');
5585 assert!(
5587 trimmed.ends_with("AB CD"),
5588 "row should end with concatenated segments, got {trimmed:?}"
5589 );
5590 let bold = entries[0]
5591 .inline_overlays
5592 .iter()
5593 .find(|o| o.style.bold)
5594 .expect("styled segment overlay carried through");
5595 assert_eq!(&trimmed[bold.start..bold.end], "CD");
5598 }
5599
5600 #[test]
5601 fn tree_node_segment_nested_overlay_shifts_to_segment_position() {
5602 let mut node = tnode("", 0, false);
5607 node.text.segments = vec![
5608 fresh_core::text_property::StyledSegment {
5609 text: "AB".to_string(),
5610 style: None,
5611 overlays: vec![],
5612 },
5613 fresh_core::text_property::StyledSegment {
5614 text: " - ".to_string(),
5615 style: None,
5616 overlays: vec![],
5617 },
5618 fresh_core::text_property::StyledSegment {
5619 text: "CDEFG".to_string(),
5620 style: None,
5621 overlays: vec![InlineOverlay {
5622 start: 0,
5623 end: 3,
5624 style: OverlayOptions {
5625 bold: true,
5626 ..Default::default()
5627 },
5628 properties: Default::default(),
5629 unit: OffsetUnit::Char,
5630 }],
5631 },
5632 ];
5633 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5634 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5635 let trimmed = entries[0].text.trim_end_matches('\n');
5636 let bold = entries[0]
5637 .inline_overlays
5638 .iter()
5639 .find(|o| o.style.bold)
5640 .expect("nested overlay carried through");
5641 assert_eq!(&trimmed[bold.start..bold.end], "CDE");
5642 }
5643
5644 #[test]
5645 fn tree_node_segments_with_pad_pad_after_concatenation() {
5646 let mut node = tnode("", 0, false);
5647 node.text.segments = vec![fresh_core::text_property::StyledSegment {
5648 text: "ab".to_string(),
5649 style: None,
5650 overlays: vec![],
5651 }];
5652 node.text.pad_to_chars = Some(5);
5653 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5654 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5655 let trimmed = entries[0].text.trim_end_matches('\n');
5656 assert!(
5658 trimmed.ends_with("ab "),
5659 "row should be padded after segment concat, got {trimmed:?}"
5660 );
5661 }
5662
5663 #[test]
5664 fn tree_renders_only_top_level_when_nothing_expanded() {
5665 let spec = make_tree(
5666 vec![
5667 tnode("a", 0, true),
5668 tnode("a.0", 1, false),
5669 tnode("a.1", 1, false),
5670 tnode("b", 0, true),
5671 tnode("b.0", 1, false),
5672 ],
5673 vec!["a", "a.0", "a.1", "b", "b.0"],
5674 -1,
5675 10,
5676 vec![], Some("T"),
5678 );
5679 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5680 assert_eq!(entries.len(), 2);
5682 assert!(entries[0].text.contains('a'));
5683 assert!(entries[1].text.contains('b'));
5684 }
5685
5686 #[test]
5687 fn tree_renders_children_of_expanded_nodes() {
5688 let spec = make_tree(
5689 vec![
5690 tnode("a", 0, true),
5691 tnode("a.0", 1, false),
5692 tnode("a.1", 1, false),
5693 tnode("b", 0, true),
5694 tnode("b.0", 1, false),
5695 ],
5696 vec!["a", "a.0", "a.1", "b", "b.0"],
5697 -1,
5698 10,
5699 vec!["a"],
5700 Some("T"),
5701 );
5702 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5703 assert_eq!(entries.len(), 4);
5705 }
5706
5707 #[test]
5708 fn tree_emits_two_hits_per_internal_row_one_per_leaf() {
5709 let spec = make_tree(
5712 vec![tnode("a", 0, true), tnode("a.0", 1, false)],
5713 vec!["a", "a.0"],
5714 -1,
5715 10,
5716 vec!["a"],
5717 Some("T"),
5718 );
5719 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5720 assert_eq!(hits.len(), 3);
5721 assert_eq!(hits[0].event_type, "expand");
5723 assert_eq!(hits[0].widget_kind, "tree");
5724 assert_eq!(hits[1].event_type, "select");
5725 assert_eq!(hits[2].event_type, "select");
5726 }
5727
5728 #[test]
5729 fn tree_hits_carry_tree_spec_key_and_per_item_key_in_payload() {
5730 let spec = make_tree(
5731 vec![tnode("only", 0, false)],
5732 vec!["only-key"],
5733 -1,
5734 10,
5735 vec![],
5736 Some("matchTree"),
5737 );
5738 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5739 assert_eq!(hits[0].widget_key, "matchTree");
5740 assert_eq!(hits[0].payload["key"], "only-key");
5741 assert_eq!(hits[0].payload["index"], 0);
5742 }
5743
5744 #[test]
5745 fn tree_persists_expanded_keys_in_instance_state() {
5746 let spec = make_tree(
5747 vec![tnode("a", 0, true), tnode("a.0", 1, false)],
5748 vec!["a", "a.0"],
5749 -1,
5750 10,
5751 vec!["a"],
5752 Some("T"),
5753 );
5754 let (_, _, state) = render_no_focus(&spec, &HashMap::new());
5755 match state.get("T").unwrap() {
5756 WidgetInstanceState::Tree { expanded_keys, .. } => {
5757 assert!(expanded_keys.contains("a"));
5758 }
5759 _ => unreachable!(),
5760 }
5761 }
5762
5763 #[test]
5764 fn tree_instance_state_overrides_spec_expanded_keys() {
5765 let mut prev = HashMap::new();
5768 prev.insert(
5769 "T".into(),
5770 WidgetInstanceState::Tree {
5771 scroll_offset: 0,
5772 selected_index: -1,
5773 expanded_keys: ["b".to_string()].iter().cloned().collect(),
5774 },
5775 );
5776 let spec = make_tree(
5777 vec![
5778 tnode("a", 0, true),
5779 tnode("a.0", 1, false),
5780 tnode("b", 0, true),
5781 tnode("b.0", 1, false),
5782 ],
5783 vec!["a", "a.0", "b", "b.0"],
5784 -1,
5785 10,
5786 vec!["a"], Some("T"),
5788 );
5789 let (entries, _hits, _state) = render_no_focus(&spec, &prev);
5790 assert_eq!(entries.len(), 3);
5792 }
5793
5794 #[test]
5795 fn tree_selected_row_gets_focused_bg() {
5796 let spec = make_tree(
5797 vec![tnode("a", 0, false), tnode("b", 0, false)],
5798 vec!["a", "b"],
5799 1,
5800 10,
5801 vec![],
5802 Some("T"),
5803 );
5804 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5805 assert!(entries[0].style.is_none());
5806 let style = entries[1].style.as_ref().expect("selected gets style");
5807 assert_eq!(
5808 style.bg.as_ref().and_then(|c| c.as_theme_key()),
5809 Some("ui.popup_selection_bg")
5810 );
5811 assert!(style.extend_to_line_end);
5812 }
5813
5814 #[test]
5815 fn tree_clamps_selection_to_visible_when_selected_node_is_hidden() {
5816 let spec = make_tree(
5820 vec![tnode("a", 0, true), tnode("a.0", 1, false)],
5821 vec!["a", "a.0"],
5822 1,
5823 10,
5824 vec![], Some("T"),
5826 );
5827 let (_entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
5828 match state.get("T").unwrap() {
5829 WidgetInstanceState::Tree { selected_index, .. } => {
5830 assert_eq!(*selected_index, 0);
5831 }
5832 _ => unreachable!(),
5833 }
5834 }
5835
5836 #[test]
5837 fn tree_scrolls_to_keep_selection_in_visible_window() {
5838 let spec = make_tree(
5842 vec![
5843 tnode("0", 0, false),
5844 tnode("1", 0, false),
5845 tnode("2", 0, false),
5846 tnode("3", 0, false),
5847 tnode("4", 0, false),
5848 tnode("5", 0, false),
5849 ],
5850 vec!["k0", "k1", "k2", "k3", "k4", "k5"],
5851 4,
5852 3,
5853 vec![],
5854 Some("T"),
5855 );
5856 let (entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
5857 assert_eq!(entries.len(), 3);
5859 match state.get("T").unwrap() {
5860 WidgetInstanceState::Tree { scroll_offset, .. } => assert_eq!(*scroll_offset, 2),
5861 _ => unreachable!(),
5862 }
5863 }
5864
5865 #[test]
5866 fn tree_tabbable_keys_include_tree_with_key() {
5867 let spec = WidgetSpec::Col {
5868 children: vec![
5869 WidgetSpec::Toggle {
5870 checked: false,
5871 label: "T".into(),
5872 focused: false,
5873 key: Some("toggle".into()),
5874 },
5875 make_tree(
5876 vec![tnode("a", 0, false)],
5877 vec!["a"],
5878 -1,
5879 10,
5880 vec![],
5881 Some("tree"),
5882 ),
5883 ],
5884 key: None,
5885 };
5886 let mut tabbable = Vec::new();
5887 collect_tabbable(&spec, &mut tabbable);
5888 assert_eq!(tabbable, vec!["toggle", "tree"]);
5889 }
5890
5891 fn make_text_area(
5896 value: &str,
5897 cursor_byte: i32,
5898 focused: bool,
5899 rows: u32,
5900 field_width: u32,
5901 key: Option<&str>,
5902 ) -> WidgetSpec {
5903 WidgetSpec::Text {
5904 value: value.into(),
5905 cursor_byte,
5906 focused,
5907 label: String::new(),
5908 placeholder: None,
5909 rows: rows.max(2),
5914 field_width,
5915 max_visible_chars: 0,
5916 full_width: false,
5917 completions: Vec::new(),
5918 completions_visible_rows: 0,
5919 key: key.map(|s| s.into()),
5920 }
5921 }
5922
5923 #[test]
5924 fn text_area_renders_visible_rows_count() {
5925 let spec = make_text_area("hi", -1, false, 3, 10, Some("ta"));
5928 let prev = HashMap::new();
5929 let out = render_spec(&spec, &prev, "", 80);
5930 assert_eq!(out.entries.len(), 3);
5931 }
5932
5933 #[test]
5934 fn text_area_pads_short_lines_to_field_width() {
5935 let spec = make_text_area("hi", -1, false, 1, 6, Some("ta"));
5936 let prev = HashMap::new();
5937 let out = render_spec(&spec, &prev, "", 80);
5938 let first = &out.entries[0];
5940 assert_eq!(first.text, "hi \n");
5941 }
5942
5943 #[test]
5944 fn text_area_truncates_long_line_with_ellipsis() {
5945 let spec = make_text_area("abcdefghi", -1, false, 1, 5, Some("ta"));
5946 let prev = HashMap::new();
5947 let out = render_spec(&spec, &prev, "", 80);
5948 assert_eq!(out.entries[0].text, "abcd…\n");
5950 }
5951
5952 #[test]
5953 fn text_area_focused_adds_input_bg_overlay_per_row() {
5954 let spec = make_text_area("a\nb", -1, true, 3, 4, Some("ta"));
5955 let prev = HashMap::new();
5956 let out = render_spec(&spec, &prev, "ta", 80);
5957 for entry in &out.entries {
5958 let has_bg = entry.inline_overlays.iter().any(|o| {
5959 o.style
5960 .bg
5961 .as_ref()
5962 .and_then(|c| c.as_theme_key())
5963 .map(|k| k == "ui.prompt_bg")
5964 .unwrap_or(false)
5965 });
5966 assert!(has_bg, "every focused row gets input-bg");
5967 }
5968 }
5969
5970 #[test]
5971 fn text_area_publishes_focus_cursor_at_value_position() {
5972 let spec = make_text_area("ab\ncd", 4, true, 3, 6, Some("ta"));
5975 let prev = HashMap::new();
5976 let out = render_spec(&spec, &prev, "ta", 80);
5977 let fc = out.focus_cursor.expect("focused → cursor published");
5978 assert_eq!(fc.buffer_row, 1);
5980 assert_eq!(fc.byte_in_row, 1);
5982 }
5983
5984 #[test]
5985 fn text_area_label_offsets_cursor_buffer_row() {
5986 let spec = WidgetSpec::Text {
5990 value: "hi".into(),
5991 cursor_byte: 1,
5992 focused: true,
5993 label: "Note".into(),
5994 placeholder: None,
5995 rows: 2,
5996 field_width: 6,
5997 max_visible_chars: 0,
5998 full_width: false,
5999 completions: Vec::new(),
6000 completions_visible_rows: 0,
6001 key: Some("ta".into()),
6002 };
6003 let prev = HashMap::new();
6004 let out = render_spec(&spec, &prev, "ta", 80);
6005 assert!(out.entries[0].text.starts_with("Note:"));
6007 let fc = out.focus_cursor.unwrap();
6008 assert_eq!(fc.buffer_row, 1);
6009 }
6010
6011 #[test]
6012 fn text_area_persists_value_and_cursor_in_instance_state() {
6013 let spec = make_text_area("abc", 2, true, 2, 8, Some("ta"));
6014 let prev = HashMap::new();
6015 let out = render_spec(&spec, &prev, "ta", 80);
6016 match out.instance_states.get("ta") {
6017 Some(WidgetInstanceState::Text { editor, .. }) => {
6018 assert_eq!(editor.value(), "abc");
6019 assert_eq!(editor.flat_cursor_byte(), 2);
6020 }
6021 other => panic!("expected Text instance state, got {:?}", other),
6022 }
6023 }
6024
6025 #[test]
6026 fn text_area_instance_state_overrides_spec_value() {
6027 let spec = make_text_area("old", 0, true, 2, 8, Some("ta"));
6030 let mut prev = HashMap::new();
6031 let mut editor = crate::primitives::text_edit::TextEdit::with_text("new");
6032 editor.set_cursor_from_flat(3);
6033 prev.insert(
6034 "ta".into(),
6035 WidgetInstanceState::Text {
6036 editor,
6037 scroll: 0,
6038 completions: Vec::new(),
6039 completion_selected_index: 0,
6040 completion_scroll_offset: 0,
6041 },
6042 );
6043 let out = render_spec(&spec, &prev, "ta", 80);
6044 assert!(out.entries[0].text.starts_with("new"));
6046 }
6047
6048 #[test]
6049 fn text_area_scroll_clamps_to_keep_cursor_visible() {
6050 let spec = make_text_area("a\nb\nc\nd\ne", 8, true, 2, 4, Some("ta"));
6054 let prev = HashMap::new();
6056 let out = render_spec(&spec, &prev, "ta", 80);
6057 match out.instance_states.get("ta") {
6058 Some(WidgetInstanceState::Text { scroll, .. }) => {
6059 assert_eq!(*scroll, 3, "scroll so lines 3..5 are visible");
6060 }
6061 _ => panic!("expected Text instance state"),
6062 }
6063 }
6064
6065 #[test]
6066 fn text_area_unfocused_empty_shows_placeholder_in_first_row() {
6067 let r = render_text_area("", -1, None, false, "", Some("write here"), 2, 12, 0, 80);
6072 assert!(r.entries[0].text.starts_with("write here"));
6073 let fg = r.entries[0]
6075 .inline_overlays
6076 .iter()
6077 .find_map(|o| o.style.fg.as_ref())
6078 .and_then(|c| c.as_theme_key());
6079 assert_eq!(fg, Some("editor.whitespace_indicator_fg"));
6080 }
6081
6082 #[test]
6083 fn text_area_tabbable_keys_include_text_area_with_key() {
6084 let spec = WidgetSpec::Col {
6085 children: vec![
6086 WidgetSpec::Toggle {
6087 checked: false,
6088 label: "T".into(),
6089 focused: false,
6090 key: Some("toggle".into()),
6091 },
6092 make_text_area("", -1, false, 3, 10, Some("note")),
6093 ],
6094 key: None,
6095 };
6096 let mut tabbable = Vec::new();
6097 collect_tabbable(&spec, &mut tabbable);
6098 assert_eq!(tabbable, vec!["toggle", "note"]);
6099 }
6100
6101 fn make_text_input(
6106 value: &str,
6107 cursor_byte: i32,
6108 focused: bool,
6109 full_width: bool,
6110 field_width: u32,
6111 key: Option<&str>,
6112 ) -> WidgetSpec {
6113 WidgetSpec::Text {
6114 value: value.into(),
6115 cursor_byte,
6116 focused,
6117 label: String::new(),
6118 placeholder: None,
6119 rows: 1,
6120 field_width,
6121 max_visible_chars: 0,
6122 full_width,
6123 completions: Vec::new(),
6124 completions_visible_rows: 0,
6125 key: key.map(|s| s.into()),
6126 }
6127 }
6128
6129 #[test]
6130 fn labeled_section_renders_three_rows_with_legend() {
6131 let spec = WidgetSpec::LabeledSection {
6132 label: "Name".into(),
6133 child: Box::new(make_text_input("hi", -1, false, false, 4, Some("n"))),
6134 width_pct: None,
6135 key: None,
6136 };
6137 let prev = HashMap::new();
6138 let out = render_spec(&spec, &prev, "", 20);
6139 assert_eq!(out.entries.len(), 3);
6141 assert!(out.entries[0].text.starts_with("╭─ Name "));
6143 assert!(out.entries[0].text.ends_with("╮\n"));
6144 assert!(out.entries[1].text.starts_with("│ "));
6146 assert!(out.entries[1].text.ends_with(" │\n"));
6147 assert!(out.entries[2].text.starts_with("╰"));
6149 assert!(out.entries[2].text.ends_with("╯\n"));
6150 }
6151
6152 #[test]
6153 fn zip_row_blocks_keeps_overlays_on_char_boundaries() {
6154 let left = WidgetSpec::LabeledSection {
6165 label: "alpha/beta · this project (2)".into(),
6166 child: Box::new(make_text_input("x", -1, false, false, 4, Some("a"))),
6167 width_pct: Some(40),
6168 key: None,
6169 };
6170 let right = WidgetSpec::LabeledSection {
6171 label: "preview".into(),
6172 child: Box::new(make_text_input("y", -1, false, false, 4, Some("b"))),
6173 width_pct: None,
6174 key: None,
6175 };
6176 let spec = WidgetSpec::Row {
6177 wrap: false,
6178 children: vec![left, right],
6179 key: None,
6180 };
6181 let out = render_spec(&spec, &HashMap::new(), "", 40);
6182 for e in &out.entries {
6183 for o in &e.inline_overlays {
6184 assert!(
6185 e.text.is_char_boundary(o.start.min(e.text.len())),
6186 "overlay start {} not on a char boundary of {:?}",
6187 o.start,
6188 e.text,
6189 );
6190 assert!(
6191 e.text.is_char_boundary(o.end.min(e.text.len())),
6192 "overlay end {} not on a char boundary of {:?}",
6193 o.end,
6194 e.text,
6195 );
6196 }
6197 }
6198 }
6199
6200 #[test]
6201 fn labeled_section_pads_child_to_inner_width() {
6202 let spec = WidgetSpec::LabeledSection {
6203 label: "".into(),
6204 child: Box::new(make_text_input("hi", -1, false, false, 4, Some("n"))),
6205 width_pct: None,
6206 key: None,
6207 };
6208 let prev = HashMap::new();
6209 let out = render_spec(&spec, &prev, "", 16);
6212 let middle = &out.entries[1];
6213 assert_eq!(middle.text.chars().count(), 16 + 1 );
6215 }
6216
6217 #[test]
6218 fn labeled_section_text_full_width_fills_inner_area() {
6219 let spec = WidgetSpec::LabeledSection {
6225 label: "".into(),
6226 child: Box::new(make_text_input("ab", -1, false, true, 0, Some("n"))),
6227 width_pct: None,
6228 key: None,
6229 };
6230 let prev = HashMap::new();
6231 let out = render_spec(&spec, &prev, "", 16);
6232 let middle = &out.entries[1];
6233 assert_eq!(middle.text.chars().count(), 17, "actual: {:?}", middle.text);
6237 assert!(
6238 middle.text.contains("[ab ]"),
6239 "actual: {:?}",
6240 middle.text
6241 );
6242 }
6243
6244 #[test]
6245 fn labeled_section_propagates_focus_cursor_with_offsets() {
6246 let spec = WidgetSpec::LabeledSection {
6247 label: "".into(),
6248 child: Box::new(make_text_input("abc", 3, true, false, 4, Some("n"))),
6249 width_pct: None,
6250 key: None,
6251 };
6252 let prev = HashMap::new();
6253 let out = render_spec(&spec, &prev, "n", 20);
6254 let fc = out.focus_cursor.expect("focused child publishes cursor");
6255 assert_eq!(fc.buffer_row, 1);
6257 let prefix_bytes = LEFT_BORDER_PREFIX.len() as u32;
6261 assert_eq!(fc.byte_in_row, prefix_bytes + 1 + 3);
6262 }
6263
6264 #[test]
6265 fn labeled_section_includes_child_in_tabbable() {
6266 let spec = WidgetSpec::Col {
6267 children: vec![
6268 WidgetSpec::LabeledSection {
6269 label: "Name".into(),
6270 child: Box::new(make_text_input("", -1, false, false, 0, Some("n"))),
6271 width_pct: None,
6272 key: None,
6273 },
6274 WidgetSpec::LabeledSection {
6275 label: "Cmd".into(),
6276 child: Box::new(make_text_input("", -1, false, false, 0, Some("c"))),
6277 width_pct: None,
6278 key: None,
6279 },
6280 ],
6281 key: None,
6282 };
6283 let mut tabbable = Vec::new();
6284 collect_tabbable(&spec, &mut tabbable);
6285 assert_eq!(tabbable, vec!["n", "c"]);
6286 }
6287}