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"));
1239 }
1240 entry.style = Some(style);
1241 };
1242
1243 let rows_emitted: u32 = if use_specs {
1244 let mut emitted = 0u32;
1253 let last = if end < total as usize { end + 1 } else { end };
1254 'cards: for i in start..last {
1255 let is_selected = i as i32 == effective_sel;
1256 let item_key = item_keys.get(i).cloned().unwrap_or_default();
1257 let card = &rendered_cards[i];
1258 for r in 0..item_height as usize {
1259 if emitted >= avail_rows {
1260 break 'cards;
1261 }
1262 let mut entry = card.get(r).cloned().unwrap_or_else(blank_row);
1263 entry.normalize_widths();
1264 if is_selected {
1265 mark_selected_card(&mut entry);
1266 }
1267 let byte_end = entry.text.len();
1268 ensure_trailing_newline(&mut entry);
1269 let hit_row = entries.len() as u32;
1270 entries.push(entry);
1271 hits.push(HitArea {
1272 widget_key: item_key.clone(),
1273 widget_kind: "list",
1274 buffer_row: hit_row,
1275 byte_start: 0,
1276 byte_end,
1277 payload: json!({
1278 "index": i as i64,
1279 "key": item_key,
1280 "list_key": list_key,
1281 }),
1282 event_type: "select",
1283 });
1284 emitted += 1;
1285 }
1286 }
1287 emitted
1288 } else {
1289 for (offset, item) in items[start..end.min(items.len())].iter().enumerate() {
1291 let i = start + offset;
1292 let mut entry = item.clone();
1293 entry.normalize_widths();
1294 if i as i32 == effective_sel {
1295 mark_selected(&mut entry);
1296 }
1297 let byte_end = entry.text.len();
1298 ensure_trailing_newline(&mut entry);
1299 entries.push(entry);
1300 let item_key = item_keys.get(i).cloned().unwrap_or_default();
1301 let hit_row = (entries.len() - 1) as u32;
1302 hits.push(HitArea {
1303 widget_key: item_key.clone(),
1304 widget_kind: "list",
1305 buffer_row: hit_row,
1306 byte_start: 0,
1307 byte_end,
1308 payload: json!({
1309 "index": i as i64,
1310 "key": item_key,
1311 "list_key": list_key,
1316 }),
1317 event_type: "select",
1318 });
1319 }
1320 (end - start) as u32
1321 };
1322
1323 for _ in rows_emitted..avail_rows {
1327 entries.push(blank_row());
1328 }
1329
1330 if total > visible_items {
1334 if let Some(k) = list_key {
1335 scroll_regions.push(ScrollRegion {
1336 list_key: k.to_string(),
1337 buffer_row: 0,
1338 col_in_row: 0,
1339 width_cols: panel_width,
1340 height_rows: avail_rows,
1341 total: total as usize,
1342 visible: visible_items as usize,
1343 scroll: scroll as usize,
1344 });
1345 }
1346 }
1347
1348 CollectedOutput {
1349 entries,
1350 hits,
1351 focus_cursor: None,
1352 embeds: Vec::new(),
1353 overlays: Vec::new(),
1354 scroll_regions,
1355 }
1356}
1357
1358#[allow(clippy::too_many_arguments)]
1359fn collect_labeled_section(
1360 label: &str,
1361 child: &WidgetSpec,
1362 prev: &HashMap<String, WidgetInstanceState>,
1363 next_state: &mut HashMap<String, WidgetInstanceState>,
1364 focus_key: &str,
1365 panel_width: u32,
1366) -> CollectedOutput {
1367 let mut entries: Vec<TextPropertyEntry> = Vec::new();
1368 let mut hits: Vec<HitArea> = Vec::new();
1369 let mut focus_cursor: Option<FocusCursor> = None;
1370 let mut embeds: Vec<EmbedRect> = Vec::new();
1371 let mut overlays: Vec<OverlayRow> = Vec::new();
1372 let mut scroll_regions: Vec<ScrollRegion> = Vec::new();
1373
1374 let inner_width = panel_width.saturating_sub(4).max(1);
1377 let child_out = render_collected(child, prev, next_state, focus_key, inner_width);
1378 overlays.extend(child_out.overlays.into_iter().map(|mut o| {
1388 o.buffer_row += 1;
1389 o
1390 }));
1391
1392 let total_cols = panel_width.max(2) as usize;
1396 entries.push(render_section_top_border(label, total_cols));
1397
1398 for mut child_entry in child_out.entries {
1403 strip_trailing_newline(&mut child_entry);
1404 let wrapped = wrap_in_side_border(child_entry, inner_width as usize);
1405 let row_offset = entries.len() as u32;
1406 let _ = row_offset;
1411 entries.push(wrapped);
1412 }
1413
1414 let prefix_bytes = LEFT_BORDER_PREFIX.len();
1418 for mut h in child_out.hits {
1419 h.buffer_row += 1;
1420 h.byte_start += prefix_bytes;
1421 h.byte_end += prefix_bytes;
1422 hits.push(h);
1423 }
1424 if let Some(mut fc) = child_out.focus_cursor {
1425 fc.buffer_row += 1;
1426 fc.byte_in_row += prefix_bytes as u32;
1427 focus_cursor = Some(fc);
1428 }
1429 let prefix_cols = LEFT_BORDER_PREFIX.chars().count() as u32;
1432 for mut emb in child_out.embeds {
1433 emb.buffer_row += 1;
1434 emb.col_in_row += prefix_cols;
1435 embeds.push(emb);
1436 }
1437 for mut sr in child_out.scroll_regions {
1438 sr.buffer_row += 1;
1439 sr.col_in_row += prefix_cols;
1440 sr.width_cols = inner_width;
1444 scroll_regions.push(sr);
1445 }
1446
1447 entries.push(render_section_bottom_border(total_cols));
1448
1449 CollectedOutput {
1450 entries,
1451 hits,
1452 focus_cursor,
1453 embeds,
1454 overlays,
1455 scroll_regions,
1456 }
1457}
1458
1459fn collect_window_embed(window_id: u32, embed_rows: u32, panel_width: u32) -> CollectedOutput {
1460 let mut out = CollectedOutput::default();
1461 let cols = panel_width.max(1) as usize;
1466 for _ in 0..embed_rows {
1467 let mut text = String::with_capacity(cols + 1);
1468 for _ in 0..cols {
1469 text.push(' ');
1470 }
1471 text.push('\n');
1472 out.entries.push(TextPropertyEntry {
1473 text,
1474 properties: Default::default(),
1475 style: None,
1476 inline_overlays: Vec::new(),
1477 segments: Vec::new(),
1478 pad_to_chars: None,
1479 truncate_to_chars: None,
1480 });
1481 }
1482 out.embeds.push(EmbedRect {
1483 window_id,
1484 buffer_row: 0,
1485 col_in_row: 0,
1486 width_cols: panel_width,
1487 height_rows: embed_rows,
1488 });
1489 out
1490}
1491
1492fn collect_raw(raw_entries: &[TextPropertyEntry]) -> CollectedOutput {
1493 let mut out = CollectedOutput::default();
1494 for raw_entry in raw_entries {
1503 let mut e = raw_entry.clone();
1504 e.normalize_widths();
1505 ensure_trailing_newline(&mut e);
1506 out.entries.push(e);
1507 }
1508 out
1509}
1510
1511#[allow(clippy::too_many_arguments)]
1512fn collect_overlay(
1513 child: &WidgetSpec,
1514 prev: &HashMap<String, WidgetInstanceState>,
1515 next_state: &mut HashMap<String, WidgetInstanceState>,
1516 focus_key: &str,
1517 panel_width: u32,
1518) -> CollectedOutput {
1519 let child_out = render_collected(child, prev, next_state, focus_key, panel_width);
1528 CollectedOutput {
1529 entries: child_out.entries,
1530 hits: child_out.hits,
1531 focus_cursor: child_out.focus_cursor,
1532 embeds: child_out.embeds,
1533 overlays: child_out.overlays,
1534 scroll_regions: child_out.scroll_regions,
1535 }
1536}
1537
1538#[allow(clippy::too_many_arguments)]
1539fn render_widget_text(
1540 value: &str,
1541 cursor_byte: i32,
1542 focused: bool,
1543 label: &str,
1544 placeholder: Option<&str>,
1545 rows: u32,
1546 field_width: u32,
1547 max_visible_chars: u32,
1548 full_width: bool,
1549 completions_visible_rows: u32,
1550 key: Option<&str>,
1551 prev: &HashMap<String, WidgetInstanceState>,
1552 next_state: &mut HashMap<String, WidgetInstanceState>,
1553 focus_key: &str,
1554 panel_width: u32,
1555) -> CollectedOutput {
1556 let mut out = CollectedOutput::default();
1557 let effective_visible_rows = if completions_visible_rows == 0 {
1561 5u32
1562 } else {
1563 completions_visible_rows
1564 };
1565
1566 let is_focused = match key.filter(|k| !k.is_empty()) {
1567 Some(k) => k == focus_key,
1568 None => focused,
1569 };
1570 let multiline = rows > 1;
1578 let mut effective_editor: crate::primitives::text_edit::TextEdit;
1579 let prev_scroll: u32;
1580 let mut prev_completions: Vec<fresh_core::api::CompletionItem> = Vec::new();
1586 let mut prev_completion_idx: usize = 0;
1587 let mut prev_completion_scroll: u32 = 0;
1588 match key.filter(|k| !k.is_empty()).and_then(|k| prev.get(k)) {
1589 Some(WidgetInstanceState::Text {
1590 editor,
1591 scroll,
1592 completions,
1593 completion_selected_index,
1594 completion_scroll_offset,
1595 }) => {
1596 effective_editor = editor.clone();
1597 prev_scroll = *scroll;
1598 prev_completions = completions.clone();
1599 prev_completion_idx = *completion_selected_index;
1600 prev_completion_scroll = *completion_scroll_offset;
1601 }
1602 _ => {
1603 effective_editor = if multiline {
1604 crate::primitives::text_edit::TextEdit::with_text(value)
1605 } else {
1606 crate::primitives::text_edit::TextEdit::single_line_with_text(value)
1607 };
1608 let seed = if cursor_byte < 0 {
1609 value.len()
1610 } else {
1611 (cursor_byte as usize).min(value.len())
1612 };
1613 effective_editor.set_cursor_from_flat(seed);
1614 prev_scroll = 0;
1615 }
1616 }
1617 if !prev_completions.is_empty() {
1621 prev_completion_idx = prev_completion_idx.min(prev_completions.len() - 1);
1622 } else {
1623 prev_completion_idx = 0;
1624 }
1625 let effective_value = effective_editor.value();
1626 let effective_cursor_byte = effective_editor.flat_cursor_byte() as i32;
1627 let effective_cursor = if is_focused {
1628 effective_cursor_byte
1629 } else {
1630 -1
1631 };
1632 let effective_field_width = if full_width && !multiline {
1646 let label_overhead = if label.is_empty() {
1647 0u32
1648 } else {
1649 label.chars().count() as u32 + 1
1650 };
1651 panel_width
1652 .saturating_sub(label_overhead)
1653 .saturating_sub(3)
1654 .max(1)
1655 } else {
1656 field_width
1657 };
1658 let selection_for_render = if is_focused {
1662 effective_editor.selection_flat_range()
1663 } else {
1664 None
1665 };
1666 let new_scroll;
1667 if multiline {
1668 let rendered = render_text_area(
1669 &effective_value,
1670 effective_cursor,
1671 selection_for_render,
1672 is_focused,
1673 label,
1674 placeholder,
1675 rows,
1676 effective_field_width,
1677 prev_scroll,
1678 panel_width,
1679 );
1680 new_scroll = rendered.scroll_row;
1681 if let (Some(buffer_row), Some(byte_in_row)) =
1682 (rendered.cursor_buffer_row, rendered.cursor_byte_in_row)
1683 {
1684 out.focus_cursor = Some(FocusCursor {
1685 buffer_row,
1686 byte_in_row: byte_in_row as u32,
1687 });
1688 }
1689 for (row_idx, mut e) in rendered.entries.into_iter().enumerate() {
1690 if let Some(k) = key.filter(|k| !k.is_empty()) {
1693 out.hits.push(HitArea {
1694 widget_key: k.to_string(),
1695 widget_kind: "text",
1696 buffer_row: row_idx as u32,
1697 byte_start: 0,
1698 byte_end: e.text.len(),
1699 payload: json!({}),
1700 event_type: "focus",
1701 });
1702 }
1703 ensure_trailing_newline(&mut e);
1704 out.entries.push(e);
1705 }
1706 } else {
1707 let rendered = render_text_input(
1708 &effective_value,
1709 effective_cursor,
1710 selection_for_render,
1711 is_focused,
1712 label,
1713 placeholder,
1714 max_visible_chars,
1715 effective_field_width,
1716 full_width,
1717 );
1718 new_scroll = 0;
1719 if let Some(byte_in_row) = rendered.cursor_byte_in_entry {
1720 out.focus_cursor = Some(FocusCursor {
1721 buffer_row: 0,
1722 byte_in_row: byte_in_row as u32,
1723 });
1724 }
1725 let mut entry = rendered.entry;
1726 if let Some(k) = key.filter(|k| !k.is_empty()) {
1732 out.hits.push(HitArea {
1733 widget_key: k.to_string(),
1734 widget_kind: "text",
1735 buffer_row: 0,
1736 byte_start: 0,
1737 byte_end: entry.text.len(),
1738 payload: json!({}),
1739 event_type: "focus",
1740 });
1741 }
1742 ensure_trailing_newline(&mut entry);
1743 out.entries.push(entry);
1744 }
1745 if !prev_completions.is_empty() {
1762 let popup_inner = panel_width as usize;
1771 let popup_total = popup_inner.saturating_add(4); let total = prev_completions.len() as u32;
1773 let visible = effective_visible_rows.max(1).min(total);
1774 let sel = prev_completion_idx as u32;
1788 let mut scroll = prev_completion_scroll;
1789 if sel >= scroll + visible {
1790 scroll = sel + 1 - visible;
1791 }
1792 let max_scroll = total.saturating_sub(visible);
1793 if scroll > max_scroll {
1794 scroll = max_scroll;
1795 }
1796 prev_completion_scroll = scroll;
1797
1798 let mut anchor: u32 = 1;
1811 out.overlays.push(OverlayRow {
1812 buffer_row: anchor,
1813 entry: render_completion_dim_separator_overlay(popup_total),
1814 });
1815 anchor += 1;
1816 let needs_scrollbar = total > visible;
1817 let end = (scroll + visible).min(total) as usize;
1818 for (visible_row, i) in (scroll as usize..end).enumerate() {
1819 let item = &prev_completions[i];
1820 let thumb = if needs_scrollbar {
1821 completion_scrollbar_glyph(visible_row as u32, visible, scroll, total)
1822 } else {
1823 None
1824 };
1825 out.overlays.push(OverlayRow {
1826 buffer_row: anchor,
1827 entry: render_completion_item_overlay(
1828 &item.value,
1829 item.kind.as_deref(),
1830 i == prev_completion_idx,
1831 popup_total,
1832 thumb,
1833 ),
1834 });
1835 anchor += 1;
1836 }
1837 out.overlays.push(OverlayRow {
1838 buffer_row: anchor,
1839 entry: render_completion_bottom_border(popup_total),
1840 });
1841 } else {
1842 prev_completion_scroll = 0;
1843 }
1844 if let Some(k) = key.filter(|k| !k.is_empty()) {
1845 next_state.insert(
1846 k.to_string(),
1847 WidgetInstanceState::Text {
1848 editor: effective_editor.clone(),
1849 scroll: new_scroll,
1850 completions: prev_completions,
1851 completion_selected_index: prev_completion_idx,
1852 completion_scroll_offset: prev_completion_scroll,
1853 },
1854 );
1855 }
1856 out
1857}
1858
1859#[allow(clippy::too_many_arguments)]
1860fn render_widget_tree(
1861 nodes: &[TreeNode],
1862 item_keys: &[String],
1863 selected_index: i32,
1864 visible_rows: u32,
1865 expanded_keys: &[String],
1866 checkable: bool,
1867 tree_key: Option<&str>,
1868 prev: &HashMap<String, WidgetInstanceState>,
1869 next_state: &mut HashMap<String, WidgetInstanceState>,
1870) -> CollectedOutput {
1871 let mut out = CollectedOutput::default();
1872 let prev_state = tree_key.filter(|k| !k.is_empty()).and_then(|k| prev.get(k));
1875 let (prev_scroll, prev_sel, prev_expanded) = match prev_state {
1876 Some(WidgetInstanceState::Tree {
1877 scroll_offset,
1878 selected_index,
1879 expanded_keys,
1880 }) => (*scroll_offset, *selected_index, expanded_keys.clone()),
1881 _ => {
1882 let seeded: HashSet<String> = expanded_keys.iter().cloned().collect();
1884 (0, selected_index, seeded)
1885 }
1886 };
1887
1888 let mut ancestor_open: Vec<bool> = Vec::new();
1900 let mut visible_indices: Vec<usize> = Vec::with_capacity(nodes.len());
1901 for (i, node) in nodes.iter().enumerate() {
1902 let depth = node.depth as usize;
1903 ancestor_open.truncate(depth);
1905 let visible = ancestor_open.iter().all(|open| *open);
1906 if visible {
1907 visible_indices.push(i);
1908 }
1909 let key = item_keys.get(i).cloned().unwrap_or_default();
1915 let is_open = if node.has_children {
1916 !key.is_empty() && prev_expanded.contains(&key)
1917 } else {
1918 true
1919 };
1920 ancestor_open.push(is_open);
1921 }
1922
1923 let total_visible = visible_indices.len() as u32;
1929 let visible = visible_rows.max(1);
1930 let clamp_to_visible = |abs: i32| -> i32 {
1931 if abs < 0 || nodes.is_empty() {
1932 return -1;
1933 }
1934 let abs = abs.min((nodes.len() as i32) - 1) as usize;
1935 if let Ok(_pos) = visible_indices.binary_search(&abs) {
1936 return abs as i32;
1937 }
1938 let earlier = visible_indices.iter().rev().find(|&&v| v <= abs);
1941 if let Some(&v) = earlier {
1942 return v as i32;
1943 }
1944 visible_indices.first().map(|&v| v as i32).unwrap_or(-1)
1945 };
1946 let effective_sel_abs = clamp_to_visible(prev_sel);
1947 let sel_visible_pos: i32 = if effective_sel_abs < 0 {
1951 -1
1952 } else {
1953 visible_indices
1954 .iter()
1955 .position(|&v| v == effective_sel_abs as usize)
1956 .map(|p| p as i32)
1957 .unwrap_or(-1)
1958 };
1959
1960 let mut scroll = prev_scroll;
1963 if sel_visible_pos >= 0 {
1964 let sel = sel_visible_pos as u32;
1965 if sel < scroll {
1966 scroll = sel;
1967 }
1968 if sel >= scroll + visible {
1969 scroll = sel + 1 - visible;
1970 }
1971 }
1972 let max_scroll = total_visible.saturating_sub(visible);
1973 if scroll > max_scroll {
1974 scroll = max_scroll;
1975 }
1976
1977 if let Some(k) = tree_key.filter(|k| !k.is_empty()) {
1979 next_state.insert(
1980 k.to_string(),
1981 WidgetInstanceState::Tree {
1982 scroll_offset: scroll,
1983 selected_index: effective_sel_abs,
1984 expanded_keys: prev_expanded.clone(),
1985 },
1986 );
1987 }
1988
1989 let start = scroll as usize;
1991 let end = ((scroll + visible) as usize).min(visible_indices.len());
1992 for &abs_idx in &visible_indices[start..end] {
1993 let mut node = nodes[abs_idx].clone();
1998 node.text.normalize_widths();
1999 let item_key = item_keys.get(abs_idx).cloned().unwrap_or_default();
2000 let is_expanded =
2001 node.has_children && !item_key.is_empty() && prev_expanded.contains(&item_key);
2002 let rendered = render_tree_row(&node, is_expanded, checkable);
2003 let mut entry = rendered.entry;
2004 let is_selected = abs_idx as i32 == effective_sel_abs;
2005 if is_selected {
2006 let mut style = entry.style.unwrap_or_default();
2007 style.bg = Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG));
2008 style.extend_to_line_end = true;
2009 entry.style = Some(style);
2010 }
2011 let row_byte_end = entry.text.len();
2012 ensure_trailing_newline(&mut entry);
2013 out.entries.push(entry);
2014 let hit_row = (out.entries.len() - 1) as u32;
2015 let tree_spec_key = tree_key.unwrap_or("").to_string();
2025 if let Some(disc_range) = rendered.disclosure_range {
2026 out.hits.push(HitArea {
2027 widget_key: tree_spec_key.clone(),
2028 widget_kind: "tree",
2029 buffer_row: hit_row,
2030 byte_start: disc_range.0,
2031 byte_end: disc_range.1,
2032 payload: json!({
2033 "index": abs_idx as i64,
2034 "key": item_key.clone(),
2035 "expanded": !is_expanded,
2036 }),
2037 event_type: "expand",
2038 });
2039 }
2040 if let Some(cb_range) = rendered.checkbox_range {
2047 let new_checked = !nodes[abs_idx].checked.unwrap_or(false);
2048 out.hits.push(HitArea {
2049 widget_key: tree_spec_key.clone(),
2050 widget_kind: "tree",
2051 buffer_row: hit_row,
2052 byte_start: cb_range.0,
2053 byte_end: cb_range.1,
2054 payload: json!({
2055 "index": abs_idx as i64,
2056 "key": item_key.clone(),
2057 "checked": new_checked,
2058 }),
2059 event_type: "toggle",
2060 });
2061 }
2062 let body_start = match (rendered.checkbox_range, rendered.disclosure_range) {
2066 (Some((_, end)), _) => end + 1, (None, Some((_, end))) => end,
2068 (None, None) => 0,
2069 };
2070 if body_start < row_byte_end {
2071 out.hits.push(HitArea {
2072 widget_key: tree_spec_key,
2073 widget_kind: "tree",
2074 buffer_row: hit_row,
2075 byte_start: body_start,
2076 byte_end: row_byte_end,
2077 payload: json!({
2078 "index": abs_idx as i64,
2079 "key": item_key,
2080 }),
2081 event_type: "select",
2082 });
2083 }
2084 }
2085 out
2086}
2087
2088const LEFT_BORDER_PREFIX: &str = "│ ";
2093const RIGHT_BORDER_SUFFIX: &str = " │";
2094
2095fn render_section_top_border(label: &str, total_cols: usize) -> TextPropertyEntry {
2106 let mut text = String::new();
2107 let mut overlays: Vec<InlineOverlay> = Vec::new();
2108 text.push('╭');
2109 if label.is_empty() {
2110 for _ in 0..total_cols.saturating_sub(2) {
2111 text.push('─');
2112 }
2113 } else {
2114 let label_cols = label.chars().count();
2119 let used = 1 + 1 + 1 + label_cols + 1; text.push('─');
2121 text.push(' ');
2122 let label_byte_start = text.len();
2123 text.push_str(label);
2124 let label_byte_end = text.len();
2125 text.push(' ');
2126 let remaining = total_cols.saturating_sub(used + 1); for _ in 0..remaining {
2128 text.push('─');
2129 }
2130 overlays.push(InlineOverlay {
2131 start: label_byte_start,
2132 end: label_byte_end,
2133 style: OverlayOptions {
2134 fg: Some(OverlayColorSpec::theme_key(KEY_SECTION_LABEL_FG)),
2135 bold: true,
2136 ..Default::default()
2137 },
2138 properties: Default::default(),
2139 unit: OffsetUnit::Byte,
2140 });
2141 }
2142 text.push('╮');
2143 text.push('\n');
2144 TextPropertyEntry {
2145 text,
2146 properties: Default::default(),
2147 style: None,
2148 inline_overlays: overlays,
2149 segments: Vec::new(),
2150 pad_to_chars: None,
2151 truncate_to_chars: None,
2152 }
2153}
2154
2155fn render_section_bottom_border(total_cols: usize) -> TextPropertyEntry {
2158 let mut text = String::new();
2159 text.push('╰');
2160 for _ in 0..total_cols.saturating_sub(2) {
2161 text.push('─');
2162 }
2163 text.push('╯');
2164 text.push('\n');
2165 TextPropertyEntry {
2166 text,
2167 properties: Default::default(),
2168 style: None,
2169 inline_overlays: Vec::new(),
2170 segments: Vec::new(),
2171 pad_to_chars: None,
2172 truncate_to_chars: None,
2173 }
2174}
2175
2176fn render_completion_dim_separator_overlay(total_cols: usize) -> TextPropertyEntry {
2185 let inner = total_cols.saturating_sub(2).max(1);
2186 let mut text = String::with_capacity(total_cols * 4 + 2);
2187 text.push('│');
2188 for _ in 0..inner {
2189 text.push('┄');
2190 }
2191 text.push('│');
2192 text.push('\n');
2193 let left_border_bytes = "│".len();
2201 let dash_bytes = "┄".len() * inner;
2202 let right_border_start = left_border_bytes + dash_bytes;
2203 let right_border_end = right_border_start + "│".len();
2204 let inline_overlays = vec![
2205 InlineOverlay {
2206 start: 0,
2207 end: left_border_bytes,
2208 style: OverlayOptions {
2209 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2210 ..Default::default()
2211 },
2212 properties: Default::default(),
2213 unit: OffsetUnit::Byte,
2214 },
2215 InlineOverlay {
2216 start: left_border_bytes,
2217 end: left_border_bytes + dash_bytes,
2218 style: OverlayOptions {
2219 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_DIM_FG)),
2220 ..Default::default()
2221 },
2222 properties: Default::default(),
2223 unit: OffsetUnit::Byte,
2224 },
2225 InlineOverlay {
2226 start: right_border_start,
2227 end: right_border_end,
2228 style: OverlayOptions {
2229 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2230 ..Default::default()
2231 },
2232 properties: Default::default(),
2233 unit: OffsetUnit::Byte,
2234 },
2235 ];
2236 TextPropertyEntry {
2237 text,
2238 properties: Default::default(),
2239 style: None,
2240 inline_overlays,
2241 segments: Vec::new(),
2242 pad_to_chars: None,
2243 truncate_to_chars: None,
2244 }
2245}
2246
2247fn render_completion_bottom_border(total_cols: usize) -> TextPropertyEntry {
2254 let mut text = String::with_capacity(total_cols * 4 + 2);
2255 text.push('╰');
2256 for _ in 0..total_cols.saturating_sub(2).max(1) {
2257 text.push('─');
2258 }
2259 text.push('╯');
2260 text.push('\n');
2261 TextPropertyEntry {
2267 text,
2268 properties: Default::default(),
2269 style: Some(OverlayOptions {
2270 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2271 ..Default::default()
2272 }),
2273 inline_overlays: Vec::new(),
2274 segments: Vec::new(),
2275 pad_to_chars: None,
2276 truncate_to_chars: None,
2277 }
2278}
2279
2280fn render_completion_item_overlay(
2287 item: &str,
2288 kind: Option<&str>,
2289 selected: bool,
2290 total_cols: usize,
2291 scrollbar: Option<char>,
2292) -> TextPropertyEntry {
2293 let inner = total_cols.saturating_sub(2).max(1);
2294 let body_entry = render_completion_item(item, kind, selected, inner, scrollbar);
2298 let mut text = String::with_capacity(body_entry.text.len() + 8);
2302 text.push('│');
2303 let body_no_nl = body_entry.text.trim_end_matches('\n');
2304 text.push_str(body_no_nl);
2305 text.push('│');
2306 text.push('\n');
2307 let left_border_bytes = "│".len();
2327 let body_no_nl_bytes = body_no_nl.len();
2328 let right_border_start = left_border_bytes + body_no_nl_bytes;
2329 let right_border_end = right_border_start + "│".len();
2330 let mut inline_overlays: Vec<InlineOverlay> = Vec::new();
2331 if selected {
2332 inline_overlays.push(InlineOverlay {
2333 start: left_border_bytes,
2334 end: right_border_start,
2335 style: OverlayOptions {
2336 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_FG)),
2337 bg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_BG)),
2338 ..Default::default()
2339 },
2340 properties: Default::default(),
2341 unit: OffsetUnit::Byte,
2342 });
2343 }
2344 inline_overlays.extend(body_entry.inline_overlays.into_iter().map(|mut io| {
2352 io.start += left_border_bytes;
2353 io.end += left_border_bytes;
2354 io
2355 }));
2356 inline_overlays.push(InlineOverlay {
2357 start: 0,
2358 end: left_border_bytes,
2359 style: OverlayOptions {
2360 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2361 ..Default::default()
2362 },
2363 properties: Default::default(),
2364 unit: OffsetUnit::Byte,
2365 });
2366 inline_overlays.push(InlineOverlay {
2367 start: right_border_start,
2368 end: right_border_end,
2369 style: OverlayOptions {
2370 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2371 ..Default::default()
2372 },
2373 properties: Default::default(),
2374 unit: OffsetUnit::Byte,
2375 });
2376 TextPropertyEntry {
2377 text,
2378 properties: Default::default(),
2379 style: None,
2380 inline_overlays,
2381 segments: Vec::new(),
2382 pad_to_chars: None,
2383 truncate_to_chars: None,
2384 }
2385}
2386
2387fn render_completion_item(
2413 item: &str,
2414 kind: Option<&str>,
2415 selected: bool,
2416 total_cols: usize,
2417 scrollbar: Option<char>,
2418) -> TextPropertyEntry {
2419 let text_budget = total_cols.saturating_sub(2).saturating_sub(1);
2431 let item_chars: Vec<char> = item.chars().collect();
2432 let (visible_item, truncated): (String, bool) = if item_chars.len() <= text_budget {
2433 (item.to_string(), false)
2434 } else {
2435 let keep = text_budget.saturating_sub(1);
2440 let head: String = item_chars.iter().take(keep).collect();
2441 (format!("{}…", head), true)
2442 };
2443 let _ = truncated;
2444 let scrollbar_ch = scrollbar.unwrap_or(' ');
2445 let is_history = kind == Some("history");
2446 let history_marker: char = '↶';
2453 let mut text = String::with_capacity(total_cols * 4 + 2);
2454 text.push(' ');
2455 let marker_start_byte = text.len();
2456 if is_history {
2457 text.push(history_marker);
2458 } else {
2459 text.push(' ');
2460 }
2461 let marker_end_byte = text.len();
2462 let item_start_byte = text.len();
2463 text.push_str(&visible_item);
2464 let item_end_byte = text.len();
2465 let used_cols = 2 + visible_item.chars().count();
2469 let pad_cols = total_cols.saturating_sub(used_cols).saturating_sub(1);
2470 for _ in 0..pad_cols {
2471 text.push(' ');
2472 }
2473 text.push(scrollbar_ch);
2474 text.push('\n');
2475
2476 let body_style = if selected {
2477 Some(OverlayOptions {
2478 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_FG)),
2479 bg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_SEL_BG)),
2480 extend_to_line_end: true,
2481 fg_on_collision_only: false,
2482 ..Default::default()
2483 })
2484 } else {
2485 None
2486 };
2487 let mut inline_overlays: Vec<InlineOverlay> = Vec::new();
2488 if is_history {
2493 inline_overlays.push(InlineOverlay {
2494 start: marker_start_byte,
2495 end: marker_end_byte,
2496 style: OverlayOptions {
2497 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_BORDER_FG)),
2498 ..Default::default()
2499 },
2500 properties: Default::default(),
2501 unit: OffsetUnit::Byte,
2502 });
2503 inline_overlays.push(InlineOverlay {
2504 start: item_start_byte,
2505 end: item_end_byte,
2506 style: OverlayOptions {
2507 italic: true,
2508 ..Default::default()
2509 },
2510 properties: Default::default(),
2511 unit: OffsetUnit::Byte,
2512 });
2513 }
2514 if scrollbar.is_some() {
2520 let total_bytes = text.trim_end_matches('\n').len();
2521 let scrollbar_byte_len = scrollbar_ch.len_utf8();
2522 let start = total_bytes - scrollbar_byte_len;
2523 let end = total_bytes;
2524 inline_overlays.push(InlineOverlay {
2525 start,
2526 end,
2527 style: OverlayOptions {
2528 fg: Some(OverlayColorSpec::theme_key(KEY_COMPLETION_DIM_FG)),
2529 ..Default::default()
2530 },
2531 properties: Default::default(),
2532 unit: OffsetUnit::Byte,
2533 });
2534 }
2535
2536 TextPropertyEntry {
2537 text,
2538 properties: Default::default(),
2539 style: body_style,
2540 inline_overlays,
2541 segments: Vec::new(),
2542 pad_to_chars: None,
2543 truncate_to_chars: None,
2544 }
2545}
2546
2547fn completion_scrollbar_glyph(
2559 visible_row: u32,
2560 visible: u32,
2561 scroll: u32,
2562 total: u32,
2563) -> Option<char> {
2564 if total <= visible || visible == 0 {
2565 return None;
2566 }
2567 let thumb_size = ((visible as f32 * visible as f32) / total as f32).round() as u32;
2571 let thumb_size = thumb_size.max(1).min(visible);
2572 let max_scroll = total - visible;
2573 let thumb_top = if max_scroll == 0 {
2574 0
2575 } else {
2576 ((scroll as f32 / max_scroll as f32) * (visible - thumb_size) as f32).round() as u32
2580 };
2581 if visible_row >= thumb_top && visible_row < thumb_top + thumb_size {
2582 Some('█')
2583 } else {
2584 None
2585 }
2586}
2587
2588fn wrap_in_side_border(mut child: TextPropertyEntry, inner_width: usize) -> TextPropertyEntry {
2593 let prefix_bytes = LEFT_BORDER_PREFIX.len();
2594 let cur_cols = child.text.chars().count();
2596 if cur_cols < inner_width {
2597 for _ in 0..(inner_width - cur_cols) {
2598 child.text.push(' ');
2599 }
2600 } else if cur_cols > inner_width {
2601 let indices: Vec<usize> = child.text.char_indices().map(|(i, _)| i).collect();
2606 let byte_cutoff = indices
2607 .get(inner_width)
2608 .copied()
2609 .unwrap_or(child.text.len());
2610 child.text.truncate(byte_cutoff);
2611 if inner_width >= 2 {
2612 child.text.pop();
2618 child.text.push('…');
2619 }
2620 let byte_cutoff = child.text.len();
2621 child.inline_overlays.retain_mut(|o| {
2624 if o.start >= byte_cutoff {
2625 return false;
2626 }
2627 if o.end > byte_cutoff {
2628 o.end = byte_cutoff;
2629 }
2630 true
2631 });
2632 }
2633
2634 let mut text = String::with_capacity(
2636 LEFT_BORDER_PREFIX.len() + child.text.len() + RIGHT_BORDER_SUFFIX.len() + 1,
2637 );
2638 text.push_str(LEFT_BORDER_PREFIX);
2639 text.push_str(&child.text);
2640 text.push_str(RIGHT_BORDER_SUFFIX);
2641 text.push('\n');
2642
2643 let overlays: Vec<InlineOverlay> = child
2645 .inline_overlays
2646 .into_iter()
2647 .map(|o| InlineOverlay {
2648 start: o.start + prefix_bytes,
2649 end: o.end + prefix_bytes,
2650 style: o.style,
2651 properties: o.properties,
2652 unit: o.unit,
2653 })
2654 .collect();
2655
2656 TextPropertyEntry {
2657 text,
2658 properties: child.properties,
2659 style: child.style,
2660 inline_overlays: overlays,
2661 segments: Vec::new(),
2662 pad_to_chars: None,
2663 truncate_to_chars: None,
2664 }
2665}
2666
2667pub fn render_hint_bar(entries: &[HintEntry]) -> TextPropertyEntry {
2677 let separator = " ";
2678 let mut text = String::new();
2679 let mut overlays = Vec::new();
2680 for (i, entry) in entries.iter().enumerate() {
2681 if i > 0 {
2682 text.push_str(separator);
2683 }
2684 let key_start = text.len();
2685 text.push_str(&entry.keys);
2686 let key_end = text.len();
2687 if key_end > key_start {
2688 overlays.push(InlineOverlay {
2689 start: key_start,
2690 end: key_end,
2691 style: OverlayOptions {
2692 fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
2693 bold: true,
2694 ..Default::default()
2695 },
2696 properties: Default::default(),
2697 unit: OffsetUnit::Byte,
2698 });
2699 }
2700 if !entry.label.is_empty() {
2701 text.push(' ');
2702 text.push_str(&entry.label);
2703 }
2704 }
2705 TextPropertyEntry {
2706 text,
2707 properties: Default::default(),
2708 style: None,
2709 inline_overlays: overlays,
2710 segments: Vec::new(),
2711 pad_to_chars: None,
2712 truncate_to_chars: None,
2713 }
2714}
2715
2716pub fn render_toggle(checked: bool, label: &str, focused: bool) -> TextPropertyEntry {
2725 let glyph = if checked { "[v]" } else { "[ ]" };
2726 let mut text = String::with_capacity(glyph.len() + 1 + label.len());
2727 text.push_str(glyph);
2728 text.push(' ');
2729 text.push_str(label);
2730
2731 let mut overlays = Vec::new();
2732
2733 if checked {
2736 overlays.push(InlineOverlay {
2737 start: 0,
2738 end: glyph.len(),
2739 style: OverlayOptions {
2740 fg: Some(OverlayColorSpec::theme_key(KEY_TOGGLE_ON_FG)),
2741 bold: true,
2742 ..Default::default()
2743 },
2744 properties: Default::default(),
2745 unit: OffsetUnit::Byte,
2746 });
2747 }
2748
2749 if focused {
2751 overlays.push(InlineOverlay {
2752 start: 0,
2753 end: text.len(),
2754 style: OverlayOptions {
2755 fg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_FG)),
2756 bg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG)),
2757 bold: true,
2758 ..Default::default()
2759 },
2760 properties: Default::default(),
2761 unit: OffsetUnit::Byte,
2762 });
2763 }
2764
2765 TextPropertyEntry {
2766 text,
2767 properties: Default::default(),
2768 style: None,
2769 inline_overlays: overlays,
2770 segments: Vec::new(),
2771 pad_to_chars: None,
2772 truncate_to_chars: None,
2773 }
2774}
2775
2776pub fn render_button(
2787 label: &str,
2788 focused: bool,
2789 kind: ButtonKind,
2790 disabled: bool,
2791) -> TextPropertyEntry {
2792 let text = format!("[ {} ]", label);
2793 let mut overlays = Vec::new();
2794
2795 let base_style = if disabled {
2803 OverlayOptions {
2804 fg: Some(OverlayColorSpec::theme_key("ui.menu_disabled_fg")),
2805 ..Default::default()
2806 }
2807 } else {
2808 match kind {
2809 ButtonKind::Normal => OverlayOptions::default(),
2810 ButtonKind::Primary => OverlayOptions {
2815 fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
2816 bold: true,
2817 ..Default::default()
2818 },
2819 ButtonKind::Danger => OverlayOptions {
2822 fg: Some(OverlayColorSpec::theme_key(KEY_DANGER_FG)),
2823 bold: true,
2824 ..Default::default()
2825 },
2826 }
2827 };
2828
2829 let style = if focused && !disabled {
2830 OverlayOptions {
2831 fg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_FG)),
2832 bg: Some(OverlayColorSpec::theme_key(KEY_FOCUSED_BG)),
2833 bold: true,
2834 ..base_style
2835 }
2836 } else {
2837 base_style
2838 };
2839
2840 if style.fg.is_some()
2843 || style.bg.is_some()
2844 || style.bold
2845 || style.italic
2846 || style.underline
2847 || style.strikethrough
2848 {
2849 overlays.push(InlineOverlay {
2850 start: 0,
2851 end: text.len(),
2852 style,
2853 properties: Default::default(),
2854 unit: OffsetUnit::Byte,
2855 });
2856 }
2857
2858 TextPropertyEntry {
2859 text,
2860 properties: Default::default(),
2861 style: None,
2862 inline_overlays: overlays,
2863 segments: Vec::new(),
2864 pad_to_chars: None,
2865 truncate_to_chars: None,
2866 }
2867}
2868
2869pub struct RenderedTreeRow {
2873 pub entry: TextPropertyEntry,
2874 pub disclosure_range: Option<(usize, usize)>,
2877 pub checkbox_range: Option<(usize, usize)>,
2882}
2883
2884pub fn render_tree_row(node: &TreeNode, expanded: bool, checkable: bool) -> RenderedTreeRow {
2902 let indent_cols = (node.depth as usize) * 2;
2903 let disclosure_glyph: &str = if node.has_children {
2904 if expanded {
2905 "▼"
2906 } else {
2907 "▶"
2908 }
2909 } else {
2910 " "
2913 };
2914 let separator: &str = if node.has_children { " " } else { "" };
2919
2920 let checkbox_glyph: Option<&'static str> = if checkable {
2921 match node.checked {
2922 Some(true) => Some("[v]"),
2923 Some(false) => Some("[ ]"),
2924 None => None,
2925 }
2926 } else {
2927 None
2928 };
2929 let checkbox_extra = checkbox_glyph.map(|g| g.len() + 1).unwrap_or(0);
2930
2931 let mut text = String::with_capacity(
2932 indent_cols
2933 + disclosure_glyph.len()
2934 + separator.len()
2935 + checkbox_extra
2936 + node.text.text.len(),
2937 );
2938 for _ in 0..indent_cols {
2939 text.push(' ');
2940 }
2941 let disc_start = text.len();
2942 text.push_str(disclosure_glyph);
2943 let disc_end = text.len();
2944 text.push_str(separator);
2945 let checkbox_range = if let Some(g) = checkbox_glyph {
2946 let cb_start = text.len();
2947 text.push_str(g);
2948 let cb_end = text.len();
2949 text.push(' ');
2950 Some((cb_start, cb_end))
2951 } else {
2952 None
2953 };
2954 let body_start = text.len();
2955 text.push_str(&node.text.text);
2956
2957 let mut overlays: Vec<InlineOverlay> = node
2961 .text
2962 .inline_overlays
2963 .iter()
2964 .map(|o| {
2965 let mut shifted = o.clone();
2966 shifted.start += body_start;
2967 shifted.end += body_start;
2968 shifted
2969 })
2970 .collect();
2971
2972 if node.has_children {
2975 overlays.push(InlineOverlay {
2976 start: disc_start,
2977 end: disc_end,
2978 style: OverlayOptions {
2979 fg: Some(OverlayColorSpec::theme_key(KEY_HELP_KEY_FG)),
2980 bold: true,
2981 ..Default::default()
2982 },
2983 properties: Default::default(),
2984 unit: OffsetUnit::Byte,
2985 });
2986 }
2987 if let Some((cb_start, cb_end)) = checkbox_range {
2990 let theme_key = match node.checked {
2991 Some(true) => KEY_TOGGLE_ON_FG,
2992 _ => KEY_PLACEHOLDER_FG,
2993 };
2994 overlays.push(InlineOverlay {
2995 start: cb_start,
2996 end: cb_end,
2997 style: OverlayOptions {
2998 fg: Some(OverlayColorSpec::theme_key(theme_key)),
2999 bold: matches!(node.checked, Some(true)),
3000 ..Default::default()
3001 },
3002 properties: Default::default(),
3003 unit: OffsetUnit::Byte,
3004 });
3005 }
3006
3007 let disclosure_range = if node.has_children {
3008 Some((disc_start, disc_end))
3009 } else {
3010 None
3011 };
3012 let entry = TextPropertyEntry {
3013 text,
3014 properties: node.text.properties.clone(),
3018 style: node.text.style.clone(),
3019 inline_overlays: overlays,
3020 segments: Vec::new(),
3025 pad_to_chars: None,
3026 truncate_to_chars: None,
3027 };
3028 RenderedTreeRow {
3029 entry,
3030 disclosure_range,
3031 checkbox_range,
3032 }
3033}
3034
3035pub struct RenderedTextInput {
3039 pub entry: TextPropertyEntry,
3040 pub cursor_byte_in_entry: Option<usize>,
3043}
3044
3045#[allow(clippy::too_many_arguments)]
3070pub fn render_text_input(
3071 value: &str,
3072 cursor_byte: i32,
3073 selection: Option<(usize, usize)>,
3074 focused: bool,
3075 label: &str,
3076 placeholder: Option<&str>,
3077 max_visible_chars: u32,
3078 field_width: u32,
3079 full_width: bool,
3080) -> RenderedTextInput {
3081 let show_placeholder = value.is_empty() && placeholder.is_some();
3088
3089 let raw_cursor_byte = if cursor_byte < 0 {
3093 value.len()
3094 } else {
3095 (cursor_byte as usize).min(value.len())
3096 };
3097
3098 let (inner, cursor_in_inner) = if show_placeholder && field_width == 0 {
3102 let inner = placeholder.unwrap_or("").to_string();
3106 let cursor = if focused { Some(0usize) } else { None };
3107 (inner, cursor)
3108 } else if show_placeholder {
3109 let target = field_width as usize;
3116 let pad_extra = if focused || full_width { 1 } else { 0 };
3117 let total_inner = target + pad_extra;
3118 let raw = placeholder.unwrap_or("");
3119 let raw_chars: Vec<char> = raw.chars().collect();
3120 let inner = if raw_chars.len() <= total_inner {
3121 let mut s = raw.to_string();
3122 while s.chars().count() < total_inner {
3123 s.push(' ');
3124 }
3125 s
3126 } else {
3127 let keep = total_inner.saturating_sub(1);
3130 let prefix: String = raw_chars.iter().take(keep).collect();
3131 format!("{}…", prefix)
3132 };
3133 let cursor = if focused { Some(0usize) } else { None };
3134 (inner, cursor)
3135 } else if field_width > 0 {
3136 let target = field_width as usize;
3142 let pad_extra = if focused || full_width { 1 } else { 0 };
3143 let total_inner = target + pad_extra;
3144 let value_chars: Vec<char> = value.chars().collect();
3145 if value_chars.len() <= target {
3146 let mut padded = value.to_string();
3150 while padded.chars().count() < total_inner {
3151 padded.push(' ');
3152 }
3153 (padded, Some(raw_cursor_byte))
3154 } else {
3155 let keep = target - 1;
3159 let drop_chars = value_chars.len() - keep;
3160 let mut dropped_bytes = 0usize;
3161 for ch in value_chars.iter().take(drop_chars) {
3162 dropped_bytes += ch.len_utf8();
3163 }
3164 let tail = &value[dropped_bytes..];
3165 let mut s = String::with_capacity("…".len() + tail.len() + pad_extra);
3166 s.push('…');
3167 s.push_str(tail);
3168 for _ in 0..pad_extra {
3169 s.push(' ');
3170 }
3171 let cursor_in_inner = if raw_cursor_byte < dropped_bytes {
3175 "…".len()
3176 } else {
3177 "…".len() + (raw_cursor_byte - dropped_bytes)
3178 };
3179 (s, Some(cursor_in_inner))
3180 }
3181 } else if max_visible_chars > 0 && value.chars().count() > max_visible_chars as usize {
3182 let chars: Vec<char> = value.chars().collect();
3186 let take = (max_visible_chars as usize).saturating_sub(1);
3187 let start = chars.len().saturating_sub(take);
3188 let tail: String = chars[start..].iter().collect();
3189 let s = format!("…{}", tail);
3190 (s, Some(raw_cursor_byte.min(value.len())))
3191 } else {
3192 let mut s = value.to_string();
3198 if focused {
3199 s.push(' ');
3200 }
3201 (s, Some(raw_cursor_byte))
3202 };
3203
3204 let mut text = String::new();
3206 if !label.is_empty() {
3207 text.push_str(label);
3208 text.push(' ');
3209 }
3210 let bracket_open_byte = text.len();
3211 text.push('[');
3212 let inner_byte_start = text.len();
3213 text.push_str(&inner);
3214 let inner_byte_end = text.len();
3215 text.push(']');
3216 let bracket_close_byte = text.len();
3217
3218 let mut overlays = Vec::new();
3219
3220 if show_placeholder {
3221 overlays.push(InlineOverlay {
3222 start: inner_byte_start,
3223 end: inner_byte_end,
3224 style: OverlayOptions {
3225 fg: Some(OverlayColorSpec::theme_key(KEY_PLACEHOLDER_FG)),
3226 italic: true,
3227 ..Default::default()
3228 },
3229 properties: Default::default(),
3230 unit: OffsetUnit::Byte,
3231 });
3232 }
3233
3234 if focused {
3235 overlays.push(InlineOverlay {
3236 start: bracket_open_byte,
3237 end: bracket_close_byte,
3238 style: OverlayOptions {
3239 bg: Some(OverlayColorSpec::theme_key(KEY_INPUT_BG)),
3240 ..Default::default()
3241 },
3242 properties: Default::default(),
3243 unit: OffsetUnit::Byte,
3244 });
3245 }
3246
3247 let inner_is_truncated = inner.starts_with('…');
3256 if focused && !inner_is_truncated {
3257 if let Some((sel_start, sel_end)) = selection {
3258 let visible_value_len = value.len();
3262 let s = sel_start.min(sel_end).min(visible_value_len);
3263 let e = sel_start.max(sel_end).min(visible_value_len);
3264 if e > s {
3265 overlays.push(InlineOverlay {
3266 start: inner_byte_start + s,
3267 end: inner_byte_start + e,
3268 style: OverlayOptions {
3269 bg: Some(OverlayColorSpec::theme_key(KEY_TEXT_INPUT_SELECTION_BG)),
3270 ..Default::default()
3271 },
3272 properties: Default::default(),
3273 unit: OffsetUnit::Byte,
3274 });
3275 }
3276 }
3277 }
3278
3279 let cursor_byte_in_entry = if focused {
3280 cursor_in_inner.map(|c| inner_byte_start + c)
3281 } else {
3282 None
3283 };
3284
3285 RenderedTextInput {
3286 entry: TextPropertyEntry {
3287 text,
3288 properties: Default::default(),
3289 style: None,
3290 inline_overlays: overlays,
3291 segments: Vec::new(),
3292 pad_to_chars: None,
3293 truncate_to_chars: None,
3294 },
3295 cursor_byte_in_entry,
3296 }
3297}
3298
3299pub struct RenderedTextArea {
3302 pub entries: Vec<TextPropertyEntry>,
3307 pub scroll_row: u32,
3311 pub cursor_buffer_row: Option<u32>,
3315 pub cursor_byte_in_row: Option<usize>,
3318}
3319
3320#[allow(clippy::too_many_arguments)]
3347pub fn render_text_area(
3348 value: &str,
3349 cursor_byte: i32,
3350 selection: Option<(usize, usize)>,
3351 focused: bool,
3352 label: &str,
3353 placeholder: Option<&str>,
3354 visible_rows: u32,
3355 field_width: u32,
3356 prev_scroll: u32,
3357 panel_width: u32,
3358) -> RenderedTextArea {
3359 let target_width: usize = if field_width > 0 {
3362 field_width as usize
3363 } else if panel_width != u32::MAX && panel_width > 0 {
3364 panel_width as usize
3365 } else {
3366 40
3367 };
3368
3369 let mut lines: Vec<&str> = value.split('\n').collect();
3373 if lines.is_empty() {
3374 lines.push("");
3375 }
3376
3377 let raw_cursor_byte = if cursor_byte < 0 {
3381 value.len()
3382 } else {
3383 (cursor_byte as usize).min(value.len())
3384 };
3385 let (cursor_line, cursor_col) = byte_to_line_col(value, raw_cursor_byte);
3386
3387 let selection_lc: Option<((usize, usize), (usize, usize))> = selection.and_then(|(a, b)| {
3392 let lo = a.min(b);
3393 let hi = a.max(b);
3394 if hi <= lo || hi > value.len() {
3395 return None;
3396 }
3397 Some((byte_to_line_col(value, lo), byte_to_line_col(value, hi)))
3398 });
3399
3400 let visible_rows_usize = visible_rows.max(1) as usize;
3403 let mut scroll_row = prev_scroll as usize;
3404 if cursor_line < scroll_row {
3405 scroll_row = cursor_line;
3406 } else if cursor_line >= scroll_row + visible_rows_usize {
3407 scroll_row = cursor_line + 1 - visible_rows_usize;
3408 }
3409 let max_scroll = lines.len().saturating_sub(visible_rows_usize);
3411 if scroll_row > max_scroll {
3412 scroll_row = max_scroll;
3413 }
3414
3415 let show_placeholder =
3416 !focused && value.is_empty() && placeholder.is_some() && !placeholder.unwrap().is_empty();
3417
3418 let mut entries: Vec<TextPropertyEntry> = Vec::new();
3419 let mut cursor_buffer_row: Option<u32> = None;
3420 let mut cursor_byte_in_row: Option<usize> = None;
3421
3422 if !label.is_empty() {
3423 let mut text = String::with_capacity(label.len() + 2);
3424 text.push_str(label);
3425 text.push(':');
3426 entries.push(TextPropertyEntry {
3427 text,
3428 properties: Default::default(),
3429 style: None,
3430 inline_overlays: Vec::new(),
3431 segments: Vec::new(),
3432 pad_to_chars: None,
3433 truncate_to_chars: None,
3434 });
3435 }
3436 let label_offset: u32 = entries.len() as u32;
3437
3438 for row_in_view in 0..visible_rows_usize {
3439 let line_idx = scroll_row + row_in_view;
3440 let mut row_text;
3441 let mut overlays: Vec<InlineOverlay> = Vec::new();
3442
3443 if line_idx < lines.len() {
3444 row_text = pad_or_truncate_line(lines[line_idx], target_width);
3445 } else {
3446 row_text = " ".repeat(target_width);
3447 }
3448
3449 if show_placeholder && row_in_view == 0 {
3451 let ph = placeholder.unwrap();
3452 row_text = pad_or_truncate_line(ph, target_width);
3453 overlays.push(InlineOverlay {
3454 start: 0,
3455 end: row_text.len(),
3456 style: OverlayOptions {
3457 fg: Some(OverlayColorSpec::theme_key(KEY_PLACEHOLDER_FG)),
3458 ..Default::default()
3459 },
3460 properties: Default::default(),
3461 unit: OffsetUnit::Byte,
3462 });
3463 }
3464
3465 if focused {
3468 overlays.push(InlineOverlay {
3469 start: 0,
3470 end: row_text.len(),
3471 style: OverlayOptions {
3472 bg: Some(OverlayColorSpec::theme_key(KEY_INPUT_BG)),
3473 ..Default::default()
3474 },
3475 properties: Default::default(),
3476 unit: OffsetUnit::Byte,
3477 });
3478 }
3479
3480 if focused {
3484 if let Some(((sl, sc), (el, ec))) = selection_lc {
3485 if line_idx >= sl && line_idx <= el {
3486 let line_text_len = if line_idx < lines.len() {
3487 lines[line_idx].len()
3488 } else {
3489 0
3490 };
3491 let row_start = if line_idx == sl { sc } else { 0 };
3492 let row_end = if line_idx == el { ec } else { line_text_len };
3493 let s = row_start.min(line_text_len);
3494 let e = row_end.min(line_text_len);
3495 if e > s {
3496 overlays.push(InlineOverlay {
3497 start: s,
3498 end: e,
3499 style: OverlayOptions {
3500 bg: Some(OverlayColorSpec::theme_key(KEY_TEXT_INPUT_SELECTION_BG)),
3501 ..Default::default()
3502 },
3503 properties: Default::default(),
3504 unit: OffsetUnit::Byte,
3505 });
3506 }
3507 }
3508 }
3509 }
3510
3511 if focused && line_idx == cursor_line && cursor_byte >= 0 {
3513 let col_in_line = cursor_col.min(row_text.len());
3518 cursor_buffer_row = Some(label_offset + row_in_view as u32);
3519 cursor_byte_in_row = Some(col_in_line);
3520 }
3521
3522 entries.push(TextPropertyEntry {
3523 text: row_text,
3524 properties: Default::default(),
3525 style: None,
3526 inline_overlays: overlays,
3527 segments: Vec::new(),
3528 pad_to_chars: None,
3529 truncate_to_chars: None,
3530 });
3531 }
3532
3533 RenderedTextArea {
3534 entries,
3535 scroll_row: scroll_row as u32,
3536 cursor_buffer_row,
3537 cursor_byte_in_row,
3538 }
3539}
3540
3541fn byte_to_line_col(value: &str, byte: usize) -> (usize, usize) {
3543 let byte = byte.min(value.len());
3544 let mut line = 0usize;
3545 let mut line_start = 0usize;
3546 for (i, &b) in value.as_bytes().iter().enumerate().take(byte) {
3547 if b == b'\n' {
3548 line += 1;
3549 line_start = i + 1;
3550 }
3551 }
3552 (line, byte - line_start)
3553}
3554
3555fn pad_or_truncate_line(line: &str, target: usize) -> String {
3561 let chars: Vec<char> = line.chars().collect();
3562 if chars.len() <= target {
3563 let mut out = line.to_string();
3564 let pad = target - chars.len();
3565 for _ in 0..pad {
3566 out.push(' ');
3567 }
3568 out
3569 } else {
3570 let keep = target.saturating_sub(1);
3571 let mut out: String = chars.iter().take(keep).collect();
3572 out.push('…');
3573 out
3574 }
3575}
3576
3577fn assemble_wrapped_row(
3585 pieces: Vec<RowPiece>,
3586 panel_width: u32,
3587 entries: &mut Vec<TextPropertyEntry>,
3588 hits: &mut Vec<HitArea>,
3589) {
3590 use crate::primitives::display_width::str_width;
3591 let max_w = panel_width as usize;
3592 let mut acc: Option<TextPropertyEntry> = None;
3593 let mut row: u32 = 0;
3594 let flush = |acc: &mut Option<TextPropertyEntry>, entries: &mut Vec<TextPropertyEntry>| {
3597 if let Some(mut merged) = acc.take() {
3598 ensure_trailing_newline(&mut merged);
3599 entries.push(merged);
3600 }
3601 };
3602 for piece in pieces {
3603 let RowPiece::Inline {
3604 mut entry,
3605 hits: child_hits,
3606 ..
3607 } = piece
3608 else {
3609 continue;
3611 };
3612 let is_blank = entry.text.trim().is_empty();
3613 let piece_w = str_width(&entry.text);
3614 let acc_w = acc.as_ref().map(|e| str_width(&e.text)).unwrap_or(0);
3615 if acc.is_some() && acc_w + piece_w > max_w {
3617 flush(&mut acc, entries);
3618 row += 1;
3619 }
3620 if acc.is_none() && is_blank {
3622 continue;
3623 }
3624 let shift = acc.as_ref().map(|e| e.text.len()).unwrap_or(0);
3625 for mut h in child_hits {
3626 h.byte_start += shift;
3627 h.byte_end += shift;
3628 h.buffer_row = row;
3629 hits.push(h);
3630 }
3631 match acc.as_mut() {
3632 Some(merged) => merge_inline(merged, &mut entry),
3633 None => acc = Some(entry),
3634 }
3635 }
3636 flush(&mut acc, entries);
3637}
3638
3639fn merge_inline(merged: &mut TextPropertyEntry, next: &mut TextPropertyEntry) {
3643 let shift = merged.text.len();
3644 merged.text.push_str(&next.text);
3645 for overlay in next.inline_overlays.drain(..) {
3646 merged.inline_overlays.push(InlineOverlay {
3647 start: overlay.start + shift,
3648 end: overlay.end + shift,
3649 style: overlay.style,
3650 properties: overlay.properties,
3651 unit: overlay.unit,
3652 });
3653 }
3654 }
3660
3661fn pad_or_truncate_cols(text: &mut String, cols: usize) {
3672 let cur = text.chars().count();
3673 if cur < cols {
3674 for _ in 0..(cols - cur) {
3675 text.push(' ');
3676 }
3677 } else if cur > cols {
3678 let cutoff = text
3681 .char_indices()
3682 .nth(cols)
3683 .map(|(i, _)| i)
3684 .unwrap_or(text.len());
3685 text.truncate(cutoff);
3686 if cols >= 2 {
3687 text.pop();
3690 text.push('…');
3691 }
3692 }
3693}
3694
3695fn snap_down_to_char_boundary(s: &str, idx: usize) -> usize {
3701 let mut i = idx.min(s.len());
3702 while i > 0 && !s.is_char_boundary(i) {
3703 i -= 1;
3704 }
3705 i
3706}
3707
3708fn zip_row_blocks(
3731 pieces: Vec<RowPiece>,
3732 panel_width: u32,
3733 out_entries: &mut Vec<TextPropertyEntry>,
3734 out_hits: &mut Vec<HitArea>,
3735 out_focus_cursor: &mut Option<FocusCursor>,
3736 out_embeds: &mut Vec<EmbedRect>,
3737 out_scroll: &mut Vec<ScrollRegion>,
3738) {
3739 let starting_row = out_entries.len() as u32;
3740 let _ = panel_width;
3741
3742 let max_height = pieces
3744 .iter()
3745 .filter_map(|p| match p {
3746 RowPiece::Block { entries, .. } => Some(entries.len()),
3747 _ => None,
3748 })
3749 .max()
3750 .unwrap_or(0);
3751 if max_height == 0 {
3752 return;
3753 }
3754
3755 for row_idx in 0..max_height {
3756 let mut text = String::new();
3757 let mut overlays: Vec<InlineOverlay> = Vec::new();
3758 for piece in &pieces {
3759 match piece {
3760 RowPiece::Inline {
3761 entry,
3762 hits,
3763 focus_cursor,
3764 embeds: inline_embeds,
3765 scroll_regions: inline_scroll,
3766 } => {
3767 let inline_cols = entry.text.chars().count();
3768 let byte_shift = text.len();
3769 let col_shift = text.chars().count() as u32;
3773 if row_idx == 0 {
3774 text.push_str(&entry.text);
3775 for emb in inline_embeds {
3776 out_embeds.push(EmbedRect {
3777 window_id: emb.window_id,
3778 buffer_row: starting_row + emb.buffer_row,
3779 col_in_row: emb.col_in_row + col_shift,
3780 width_cols: emb.width_cols,
3781 height_rows: emb.height_rows,
3782 });
3783 }
3784 for sr in inline_scroll {
3785 let mut sr = sr.clone();
3786 sr.buffer_row += starting_row;
3787 sr.col_in_row += col_shift;
3788 out_scroll.push(sr);
3789 }
3790 for overlay in &entry.inline_overlays {
3791 overlays.push(InlineOverlay {
3792 start: overlay.start + byte_shift,
3793 end: overlay.end + byte_shift,
3794 style: overlay.style.clone(),
3795 properties: overlay.properties.clone(),
3796 unit: overlay.unit,
3797 });
3798 }
3799 for h in hits {
3800 let mut h = h.clone();
3801 h.byte_start += byte_shift;
3802 h.byte_end += byte_shift;
3803 h.buffer_row = starting_row;
3804 out_hits.push(h);
3805 }
3806 if let Some(fc) = focus_cursor {
3807 *out_focus_cursor = Some(FocusCursor {
3808 buffer_row: starting_row,
3809 byte_in_row: fc.byte_in_row + byte_shift as u32,
3810 });
3811 }
3812 } else {
3813 for _ in 0..inline_cols {
3814 text.push(' ');
3815 }
3816 }
3817 }
3818 RowPiece::Flex => {
3819 }
3821 RowPiece::Block {
3822 column_width,
3823 entries,
3824 hits,
3825 focus_cursor,
3826 embeds: block_embeds,
3827 scroll_regions: block_scroll,
3828 } => {
3829 let block_w = *column_width as usize;
3830 let byte_shift = text.len();
3831 let col_shift = text.chars().count() as u32;
3834 if row_idx == 0 {
3839 for emb in block_embeds {
3840 out_embeds.push(EmbedRect {
3841 window_id: emb.window_id,
3842 buffer_row: starting_row + emb.buffer_row,
3843 col_in_row: emb.col_in_row + col_shift,
3844 width_cols: emb.width_cols,
3845 height_rows: emb.height_rows,
3846 });
3847 }
3848 for sr in block_scroll {
3849 let mut sr = sr.clone();
3850 sr.buffer_row += starting_row;
3851 sr.col_in_row += col_shift;
3852 out_scroll.push(sr);
3853 }
3854 }
3855 if let Some(line) = entries.get(row_idx) {
3856 let mut line_text = line.text.clone();
3857 if line_text.ends_with('\n') {
3860 line_text.pop();
3861 }
3862 pad_or_truncate_cols(&mut line_text, block_w);
3863 let padded_byte_len = line_text.len();
3864 text.push_str(&line_text);
3865 if let Some(line_style) = &line.style {
3875 overlays.push(InlineOverlay {
3876 start: byte_shift,
3877 end: byte_shift + padded_byte_len,
3878 style: line_style.clone(),
3879 properties: Default::default(),
3880 unit: OffsetUnit::Byte,
3881 });
3882 }
3883 for overlay in &line.inline_overlays {
3884 let start = snap_down_to_char_boundary(&line_text, overlay.start);
3893 let end = snap_down_to_char_boundary(&line_text, overlay.end);
3894 if start >= end {
3895 continue;
3896 }
3897 overlays.push(InlineOverlay {
3898 start: start + byte_shift,
3899 end: end + byte_shift,
3900 style: overlay.style.clone(),
3901 properties: overlay.properties.clone(),
3902 unit: overlay.unit,
3903 });
3904 }
3905 for h in hits {
3906 if h.buffer_row != row_idx as u32 {
3907 continue;
3908 }
3909 let mut h = h.clone();
3910 h.byte_start += byte_shift;
3911 h.byte_end += byte_shift;
3912 h.buffer_row = starting_row + row_idx as u32;
3913 out_hits.push(h);
3914 }
3915 if let Some(fc) = focus_cursor {
3916 if fc.buffer_row == row_idx as u32 {
3917 *out_focus_cursor = Some(FocusCursor {
3918 buffer_row: starting_row + row_idx as u32,
3919 byte_in_row: fc.byte_in_row + byte_shift as u32,
3920 });
3921 }
3922 }
3923 } else {
3924 for _ in 0..block_w {
3927 text.push(' ');
3928 }
3929 }
3930 }
3931 }
3932 }
3933 text.push('\n');
3934 out_entries.push(TextPropertyEntry {
3935 text,
3936 properties: Default::default(),
3937 style: None,
3938 inline_overlays: overlays,
3939 segments: Vec::new(),
3940 pad_to_chars: None,
3941 truncate_to_chars: None,
3942 });
3943 }
3944}
3945
3946#[cfg(test)]
3947mod tests {
3948 use super::*;
3949
3950 fn render_no_focus(
3955 spec: &WidgetSpec,
3956 prev: &HashMap<String, WidgetInstanceState>,
3957 ) -> (
3958 Vec<TextPropertyEntry>,
3959 Vec<HitArea>,
3960 HashMap<String, WidgetInstanceState>,
3961 ) {
3962 let out = render_spec(spec, prev, "", u32::MAX);
3964 (out.entries, out.hits, out.instance_states)
3965 }
3966
3967 #[test]
3968 fn hint_bar_renders_entries_with_key_overlays() {
3969 let entries = vec![
3970 HintEntry {
3971 keys: "Tab".into(),
3972 label: "next".into(),
3973 },
3974 HintEntry {
3975 keys: "Esc".into(),
3976 label: "close".into(),
3977 },
3978 ];
3979 let entry = render_hint_bar(&entries);
3980 assert_eq!(entry.text, "Tab next Esc close");
3981 assert_eq!(entry.inline_overlays.len(), 2);
3982 assert_eq!(entry.inline_overlays[0].start, 0);
3984 assert_eq!(entry.inline_overlays[0].end, 3);
3985 assert_eq!(entry.inline_overlays[1].start, 10);
3987 assert_eq!(entry.inline_overlays[1].end, 13);
3988 }
3989
3990 #[test]
3991 fn hint_bar_omits_label_when_empty() {
3992 let entries = vec![HintEntry {
3993 keys: "?".into(),
3994 label: "".into(),
3995 }];
3996 let entry = render_hint_bar(&entries);
3997 assert_eq!(entry.text, "?");
3998 }
3999
4000 #[test]
4001 fn col_stacks_children_top_to_bottom() {
4002 let spec = WidgetSpec::Col {
4003 children: vec![
4004 WidgetSpec::HintBar {
4005 entries: vec![HintEntry {
4006 keys: "A".into(),
4007 label: "alpha".into(),
4008 }],
4009 key: None,
4010 },
4011 WidgetSpec::HintBar {
4012 entries: vec![HintEntry {
4013 keys: "B".into(),
4014 label: "beta".into(),
4015 }],
4016 key: None,
4017 },
4018 ],
4019 key: None,
4020 };
4021 let (out, hits, _state) = render_no_focus(&spec, &HashMap::new());
4022 assert_eq!(out.len(), 2);
4023 assert_eq!(out[0].text, "A alpha\n");
4024 assert_eq!(out[1].text, "B beta\n");
4025 assert!(hits.is_empty(), "HintBar emits no hit areas in v1");
4026 }
4027
4028 #[test]
4029 fn raw_passes_through_unchanged() {
4030 let spec = WidgetSpec::Raw {
4031 entries: vec![TextPropertyEntry::text("hello")],
4032 key: None,
4033 };
4034 let (out, hits, _state) = render_no_focus(&spec, &HashMap::new());
4035 assert_eq!(out.len(), 1);
4036 assert_eq!(out[0].text, "hello\n");
4037 assert!(hits.is_empty());
4038 }
4039
4040 #[test]
4041 fn toggle_checked_emits_glyph_overlay() {
4042 let entry = render_toggle(true, "Case", false);
4043 assert_eq!(entry.text, "[v] Case");
4044 assert_eq!(entry.inline_overlays.len(), 1);
4046 assert_eq!(entry.inline_overlays[0].start, 0);
4047 assert_eq!(entry.inline_overlays[0].end, 3);
4048 }
4049
4050 #[test]
4051 fn toggle_unchecked_no_glyph_overlay() {
4052 let entry = render_toggle(false, "Case", false);
4053 assert_eq!(entry.text, "[ ] Case");
4054 assert_eq!(entry.inline_overlays.len(), 0);
4055 }
4056
4057 #[test]
4058 fn toggle_focused_adds_full_entry_overlay() {
4059 let entry = render_toggle(true, "Case", true);
4060 assert_eq!(entry.inline_overlays.len(), 2);
4062 assert_eq!(entry.inline_overlays[1].start, 0);
4064 assert_eq!(entry.inline_overlays[1].end, entry.text.len());
4065 assert!(entry.inline_overlays[1].style.bold);
4066 }
4067
4068 #[test]
4069 fn button_normal_unfocused_has_no_overlay() {
4070 let entry = render_button("Replace All", false, ButtonKind::Normal, false);
4071 assert_eq!(entry.text, "[ Replace All ]");
4072 assert!(entry.inline_overlays.is_empty());
4073 }
4074
4075 #[test]
4076 fn button_primary_unfocused_is_bold_help_key_fg_with_no_bg() {
4077 let entry = render_button("Submit", false, ButtonKind::Primary, false);
4082 assert_eq!(entry.inline_overlays.len(), 1);
4083 let style = &entry.inline_overlays[0].style;
4084 assert!(style.bold);
4085 assert_eq!(
4086 style.fg.as_ref().and_then(|c| c.as_theme_key()),
4087 Some("ui.help_key_fg"),
4088 );
4089 assert!(style.bg.is_none(), "unfocused primary must not paint a bg");
4090 }
4091
4092 #[test]
4093 fn button_danger_uses_error_theme_key() {
4094 let entry = render_button("Delete", false, ButtonKind::Danger, false);
4095 assert_eq!(entry.inline_overlays.len(), 1);
4096 let fg = entry.inline_overlays[0].style.fg.as_ref().unwrap();
4097 assert_eq!(fg.as_theme_key(), Some("diagnostic.error_fg"));
4098 assert!(entry.inline_overlays[0].style.bold);
4099 }
4100
4101 #[test]
4102 fn button_focused_overrides_with_popup_selection_keys() {
4103 let entry = render_button("OK", true, ButtonKind::Normal, false);
4110 let style = &entry.inline_overlays[0].style;
4111 assert_eq!(
4112 style.fg.as_ref().and_then(|c| c.as_theme_key()),
4113 Some("ui.popup_selection_fg")
4114 );
4115 assert_eq!(
4116 style.bg.as_ref().and_then(|c| c.as_theme_key()),
4117 Some("ui.popup_selection_bg")
4118 );
4119 assert!(style.bold);
4120 }
4121
4122 #[test]
4123 fn flex_spacer_fills_remaining_row_width() {
4124 let spec = WidgetSpec::Row {
4125 wrap: false,
4126 children: vec![
4127 WidgetSpec::Toggle {
4128 checked: false,
4129 label: "A".into(),
4130 focused: false,
4131 key: None,
4132 },
4133 WidgetSpec::Spacer {
4134 cols: 0,
4135 flex: true,
4136 key: None,
4137 },
4138 WidgetSpec::Button {
4139 label: "B".into(),
4140 focused: false,
4141 intent: ButtonKind::Normal,
4142 key: None,
4143 disabled: false,
4144 },
4145 ],
4146 key: None,
4147 };
4148 let out = render_spec(&spec, &HashMap::new(), "", 30);
4152 assert_eq!(out.entries.len(), 1);
4153 let text = &out.entries[0].text;
4154 assert_eq!(text.len(), 31);
4155 assert!(text.starts_with("[ ] A"));
4156 assert!(text.ends_with("[ B ]\n"));
4157 let button_hit = out.hits.iter().find(|h| h.widget_kind == "button").unwrap();
4158 assert_eq!(button_hit.byte_start, 25);
4159 assert_eq!(button_hit.byte_end, 30);
4160 }
4161
4162 #[test]
4163 fn flex_spacer_with_no_leftover_collapses_to_zero() {
4164 let spec = WidgetSpec::Row {
4165 wrap: false,
4166 children: vec![
4167 WidgetSpec::Toggle {
4168 checked: false,
4169 label: "A".into(),
4170 focused: false,
4171 key: None,
4172 },
4173 WidgetSpec::Spacer {
4174 cols: 0,
4175 flex: true,
4176 key: None,
4177 },
4178 WidgetSpec::Toggle {
4179 checked: false,
4180 label: "B".into(),
4181 focused: false,
4182 key: None,
4183 },
4184 ],
4185 key: None,
4186 };
4187 let out = render_spec(&spec, &HashMap::new(), "", 10);
4189 assert_eq!(out.entries[0].text, "[ ] A[ ] B\n");
4190 }
4191
4192 #[test]
4193 fn spacer_in_row_pads_with_spaces() {
4194 let spec = WidgetSpec::Row {
4195 wrap: false,
4196 children: vec![
4197 WidgetSpec::Toggle {
4198 checked: false,
4199 label: "A".into(),
4200 focused: false,
4201 key: None,
4202 },
4203 WidgetSpec::Spacer {
4204 cols: 4,
4205 flex: false,
4206 key: None,
4207 },
4208 WidgetSpec::Button {
4209 label: "Go".into(),
4210 focused: false,
4211 intent: ButtonKind::Normal,
4212 key: None,
4213 disabled: false,
4214 },
4215 ],
4216 key: None,
4217 };
4218 let (out, _hits, _state) = render_no_focus(&spec, &HashMap::new());
4219 assert_eq!(out.len(), 1);
4220 assert_eq!(out[0].text, "[ ] A [ Go ]\n");
4221 }
4222
4223 #[test]
4224 fn row_collapses_inline_children_with_shifted_overlays() {
4225 let spec = WidgetSpec::Row {
4226 wrap: false,
4227 children: vec![
4228 WidgetSpec::HintBar {
4229 entries: vec![HintEntry {
4230 keys: "Tab".into(),
4231 label: "x".into(),
4232 }],
4233 key: None,
4234 },
4235 WidgetSpec::HintBar {
4236 entries: vec![HintEntry {
4237 keys: "Esc".into(),
4238 label: "y".into(),
4239 }],
4240 key: None,
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, "Tab xEsc y\n");
4249 assert_eq!(out[0].inline_overlays.len(), 2);
4250 assert_eq!(out[0].inline_overlays[1].start, 5);
4251 assert_eq!(out[0].inline_overlays[1].end, 8);
4252 }
4253
4254 #[test]
4259 fn toggle_emits_hit_area_with_toggle_payload() {
4260 let spec = WidgetSpec::Toggle {
4261 checked: false,
4262 label: "Case".into(),
4263 focused: false,
4264 key: Some("case".into()),
4265 };
4266 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4267 assert_eq!(hits.len(), 1);
4268 let h = &hits[0];
4269 assert_eq!(h.widget_key, "case");
4270 assert_eq!(h.widget_kind, "toggle");
4271 assert_eq!(h.event_type, "toggle");
4272 assert_eq!(h.buffer_row, 0);
4273 assert_eq!(h.byte_start, 0);
4274 assert_eq!(h.byte_end, "[ ] Case".len());
4275 assert_eq!(h.payload, json!({"checked": true}));
4276 }
4277
4278 #[test]
4279 fn button_emits_hit_area_with_activate_payload() {
4280 let spec = WidgetSpec::Button {
4281 label: "Replace All".into(),
4282 focused: false,
4283 intent: ButtonKind::Primary,
4284 key: Some("replace".into()),
4285 disabled: false,
4286 };
4287 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4288 assert_eq!(hits.len(), 1);
4289 let h = &hits[0];
4290 assert_eq!(h.widget_key, "replace");
4291 assert_eq!(h.widget_kind, "button");
4292 assert_eq!(h.event_type, "activate");
4293 assert_eq!(h.byte_end, "[ Replace All ]".len());
4294 assert_eq!(h.payload, json!({}));
4295 }
4296
4297 #[test]
4298 fn disabled_button_omits_hit_area_and_skips_tabbable() {
4299 let spec = WidgetSpec::Row {
4300 wrap: false,
4301 children: vec![
4302 WidgetSpec::Button {
4303 label: "Archive".into(),
4304 focused: false,
4305 intent: ButtonKind::Normal,
4306 key: Some("archive".into()),
4307 disabled: true,
4308 },
4309 WidgetSpec::Button {
4310 label: "Cancel".into(),
4311 focused: false,
4312 intent: ButtonKind::Normal,
4313 key: Some("cancel".into()),
4314 disabled: false,
4315 },
4316 ],
4317 key: None,
4318 };
4319 let out = render_spec(&spec, &HashMap::new(), "", 30);
4320 assert_eq!(
4321 out.hits
4322 .iter()
4323 .filter(|h| h.widget_kind == "button")
4324 .count(),
4325 1,
4326 "disabled button should not emit a hit area"
4327 );
4328 assert_eq!(
4329 out.tabbable,
4330 vec!["cancel".to_string()],
4331 "disabled button must drop out of the Tab cycle"
4332 );
4333 }
4334
4335 #[test]
4336 fn disabled_button_uses_menu_disabled_fg_overlay() {
4337 let entry = render_button("Archive", false, ButtonKind::Danger, true);
4338 assert_eq!(entry.inline_overlays.len(), 1);
4339 let style = &entry.inline_overlays[0].style;
4340 assert_eq!(
4341 style.fg.as_ref().and_then(|c| c.as_theme_key()),
4342 Some("ui.menu_disabled_fg"),
4343 "disabled overrides Danger fg with the muted theme key"
4344 );
4345 assert!(
4346 !style.bold,
4347 "disabled buttons drop the intent's bold emphasis"
4348 );
4349 assert!(style.bg.is_none(), "disabled buttons paint no bg");
4350 }
4351
4352 #[test]
4353 fn row_inline_collapse_shifts_hit_byte_offsets() {
4354 let spec = WidgetSpec::Row {
4355 wrap: false,
4356 children: vec![
4357 WidgetSpec::Toggle {
4358 checked: true,
4359 label: "A".into(),
4360 focused: false,
4361 key: Some("a".into()),
4362 },
4363 WidgetSpec::Spacer {
4364 cols: 2,
4365 flex: false,
4366 key: None,
4367 },
4368 WidgetSpec::Toggle {
4369 checked: false,
4370 label: "B".into(),
4371 focused: false,
4372 key: Some("b".into()),
4373 },
4374 ],
4375 key: None,
4376 };
4377 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4378 assert_eq!(entries.len(), 1);
4380 assert_eq!(entries[0].text, "[v] A [ ] B\n");
4381 assert_eq!(hits.len(), 2);
4382 assert_eq!(hits[0].widget_key, "a");
4383 assert_eq!(hits[0].buffer_row, 0);
4384 assert_eq!(hits[0].byte_start, 0);
4385 assert_eq!(hits[0].byte_end, 5); assert_eq!(hits[1].widget_key, "b");
4389 assert_eq!(hits[1].buffer_row, 0);
4390 assert_eq!(hits[1].byte_start, 7);
4391 assert_eq!(hits[1].byte_end, 12);
4392 }
4393
4394 #[test]
4395 fn col_stacks_hit_rows() {
4396 let spec = WidgetSpec::Col {
4397 children: vec![
4398 WidgetSpec::Toggle {
4399 checked: false,
4400 label: "row0".into(),
4401 focused: false,
4402 key: Some("k0".into()),
4403 },
4404 WidgetSpec::Toggle {
4405 checked: true,
4406 label: "row1".into(),
4407 focused: false,
4408 key: Some("k1".into()),
4409 },
4410 ],
4411 key: None,
4412 };
4413 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4414 assert_eq!(hits.len(), 2);
4415 assert_eq!(hits[0].buffer_row, 0);
4416 assert_eq!(hits[1].buffer_row, 1);
4417 }
4418
4419 #[test]
4424 fn collect_tabbable_visits_widgets_with_keys_in_declaration_order() {
4425 let spec = WidgetSpec::Col {
4426 children: vec![
4427 WidgetSpec::HintBar {
4428 entries: vec![],
4429 key: Some("hb".into()),
4430 },
4431 WidgetSpec::Row {
4432 wrap: false,
4433 children: vec![
4434 WidgetSpec::Toggle {
4435 checked: false,
4436 label: "T".into(),
4437 focused: false,
4438 key: Some("t".into()),
4439 },
4440 WidgetSpec::Spacer {
4441 cols: 1,
4442 flex: false,
4443 key: None,
4444 },
4445 WidgetSpec::Button {
4446 label: "B".into(),
4447 focused: false,
4448 intent: ButtonKind::Normal,
4449 key: Some("b".into()),
4450 disabled: false,
4451 },
4452 ],
4453 key: None,
4454 },
4455 WidgetSpec::Text {
4456 value: "".into(),
4457 cursor_byte: -1,
4458 focused: false,
4459 label: "".into(),
4460 placeholder: None,
4461 rows: 1,
4462 field_width: 0,
4463 max_visible_chars: 0,
4464 full_width: false,
4465 completions: Vec::new(),
4466 completions_visible_rows: 0,
4467 key: Some("ti".into()),
4468 },
4469 WidgetSpec::Toggle {
4470 checked: false,
4471 label: "no key".into(),
4472 focused: false,
4473 key: None,
4474 },
4475 ],
4476 key: None,
4477 };
4478 let mut tabbable = Vec::new();
4479 collect_tabbable(&spec, &mut tabbable);
4480 assert_eq!(tabbable, vec!["t", "b", "ti"]);
4483 }
4484
4485 #[test]
4486 fn first_render_focuses_first_tabbable() {
4487 let spec = WidgetSpec::Row {
4488 wrap: false,
4489 children: vec![
4490 WidgetSpec::Toggle {
4491 checked: false,
4492 label: "A".into(),
4493 focused: false,
4494 key: Some("a".into()),
4495 },
4496 WidgetSpec::Toggle {
4497 checked: false,
4498 label: "B".into(),
4499 focused: false,
4500 key: Some("b".into()),
4501 },
4502 ],
4503 key: None,
4504 };
4505 let out = render_spec(&spec, &HashMap::new(), "", u32::MAX);
4506 assert_eq!(out.focus_key, "a");
4507 assert_eq!(out.tabbable, vec!["a", "b"]);
4508 }
4509
4510 #[test]
4511 fn render_preserves_focus_key_across_re_renders() {
4512 let spec = WidgetSpec::Row {
4513 wrap: false,
4514 children: vec![
4515 WidgetSpec::Toggle {
4516 checked: false,
4517 label: "A".into(),
4518 focused: false,
4519 key: Some("a".into()),
4520 },
4521 WidgetSpec::Toggle {
4522 checked: false,
4523 label: "B".into(),
4524 focused: false,
4525 key: Some("b".into()),
4526 },
4527 ],
4528 key: None,
4529 };
4530 let out = render_spec(&spec, &HashMap::new(), "b", u32::MAX);
4531 assert_eq!(out.focus_key, "b");
4532 }
4533
4534 #[test]
4535 fn render_clamps_stale_focus_key_to_first_tabbable() {
4536 let spec = WidgetSpec::Toggle {
4540 checked: false,
4541 label: "Only".into(),
4542 focused: false,
4543 key: Some("only".into()),
4544 };
4545 let out = render_spec(&spec, &HashMap::new(), "stale", u32::MAX);
4546 assert_eq!(out.focus_key, "only");
4547 }
4548
4549 #[test]
4550 fn focused_widget_renders_with_focused_styling() {
4551 let spec = WidgetSpec::Row {
4552 wrap: false,
4553 children: vec![
4554 WidgetSpec::Toggle {
4555 checked: false,
4556 label: "A".into(),
4557 focused: false,
4558 key: Some("a".into()),
4559 },
4560 WidgetSpec::Toggle {
4561 checked: false,
4562 label: "B".into(),
4563 focused: false,
4564 key: Some("b".into()),
4565 },
4566 ],
4567 key: None,
4568 };
4569 let out = render_spec(&spec, &HashMap::new(), "b", u32::MAX);
4570 assert_eq!(out.entries.len(), 1, "row collapses inline");
4571 let entry = &out.entries[0];
4577 let focused_overlay = entry
4578 .inline_overlays
4579 .iter()
4580 .find(|o| {
4581 o.style.bg.as_ref().and_then(|c| c.as_theme_key()) == Some("ui.popup_selection_bg")
4582 })
4583 .expect("focused overlay present on B");
4584 assert_eq!(focused_overlay.start, 5);
4587 assert_eq!(focused_overlay.end, 10);
4588 }
4589
4590 #[test]
4591 fn no_tabbables_yields_empty_focus_key() {
4592 let spec = WidgetSpec::Col {
4593 children: vec![WidgetSpec::HintBar {
4594 entries: vec![],
4595 key: None,
4596 }],
4597 key: None,
4598 };
4599 let out = render_spec(&spec, &HashMap::new(), "", u32::MAX);
4600 assert_eq!(out.focus_key, "");
4601 assert!(out.tabbable.is_empty());
4602 }
4603
4604 #[test]
4609 fn list_emits_one_entry_and_one_hit_per_item() {
4610 let spec = WidgetSpec::List {
4611 items: vec![
4612 TextPropertyEntry::text("alpha"),
4613 TextPropertyEntry::text("beta"),
4614 TextPropertyEntry::text("gamma"),
4615 ],
4616 item_specs: vec![],
4617 item_keys: vec!["a".into(), "b".into(), "c".into()],
4618 selected_index: -1,
4619 visible_rows: 10,
4620 focusable: true,
4621 key: None,
4622 };
4623 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4624 assert_eq!(entries.len(), 10);
4630 assert_eq!(hits.len(), 3);
4633 for (i, h) in hits.iter().enumerate() {
4634 assert_eq!(h.buffer_row, i as u32);
4635 assert_eq!(h.widget_kind, "list");
4636 assert_eq!(h.event_type, "select");
4637 assert_eq!(h.payload["index"], i);
4638 }
4639 assert_eq!(hits[0].widget_key, "a");
4640 assert_eq!(hits[2].widget_key, "c");
4641 }
4642
4643 #[test]
4644 fn list_item_specs_render_multirow_cards_in_item_units() {
4645 let card = |body: &str| WidgetSpec::LabeledSection {
4648 label: String::new(),
4649 child: Box::new(WidgetSpec::Raw {
4650 entries: vec![TextPropertyEntry::text(body)],
4651 key: None,
4652 }),
4653 width_pct: None,
4654 key: None,
4655 };
4656 let spec = WidgetSpec::List {
4657 items: vec![],
4658 item_specs: vec![card("aaa"), card("bbb")],
4659 item_keys: vec!["a".into(), "b".into()],
4660 selected_index: 1,
4661 visible_rows: 12,
4663 focusable: true,
4664 key: Some("cards".into()),
4665 };
4666 let out = render_spec(&spec, &HashMap::new(), "", 40);
4669 let (entries, hits) = (out.entries, out.hits);
4670 assert_eq!(entries.len(), 12);
4672 assert_eq!(hits.len(), 6, "3 rows per card * 2 cards");
4675 assert!(hits[0..3]
4676 .iter()
4677 .all(|h| h.payload["index"] == 0 && h.widget_key == "a"));
4678 assert!(hits[3..6]
4679 .iter()
4680 .all(|h| h.payload["index"] == 1 && h.widget_key == "b"));
4681 for r in 0..3 {
4686 assert!(
4687 !entries[r].text.contains('┓') && !entries[r].text.contains('┃'),
4688 "unselected card row {r} should keep the light border"
4689 );
4690 assert!(entries[r].style.as_ref().map_or(true, |s| s.bg.is_none()));
4691 }
4692 let heavy = (3..6).any(|r| {
4695 entries[r].text.contains('┏')
4696 || entries[r].text.contains('┗')
4697 || entries[r].text.contains('┃')
4698 });
4699 assert!(heavy, "selected card should use a heavy box border");
4700 for r in 3..6 {
4701 let style = entries[r].style.as_ref();
4702 assert!(
4703 style.map(|s| s.bold).unwrap_or(false),
4704 "row {r} of the selected card should be bold"
4705 );
4706 assert!(
4707 style.and_then(|s| s.bg.as_ref()).is_none(),
4708 "row {r} of the selected card should NOT use a background band"
4709 );
4710 }
4711 assert!(entries[0].text.starts_with('╭'));
4713 assert!(entries[2].text.starts_with('╰'));
4714 }
4715
4716 #[test]
4717 fn list_applies_selection_bg_to_selected_row() {
4718 let spec = WidgetSpec::List {
4719 items: vec![
4720 TextPropertyEntry::text("first"),
4721 TextPropertyEntry::text("second"),
4722 ],
4723 item_specs: vec![],
4724 item_keys: vec!["x".into(), "y".into()],
4725 selected_index: 1,
4726 visible_rows: 10,
4727 focusable: true,
4728 key: None,
4729 };
4730 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
4731 assert!(entries[0].style.is_none(), "unselected row keeps no style");
4732 let style = entries[1].style.as_ref().expect("selected row gets style");
4733 assert_eq!(
4734 style.bg.as_ref().and_then(|c| c.as_theme_key()),
4735 Some("ui.popup_selection_bg"),
4736 );
4737 assert!(style.extend_to_line_end);
4738 }
4739
4740 #[test]
4741 fn list_inside_col_offsets_hit_rows_by_preceding_lines() {
4742 let spec = WidgetSpec::Col {
4743 children: vec![
4744 WidgetSpec::HintBar {
4745 entries: vec![HintEntry {
4746 keys: "h".into(),
4747 label: "header".into(),
4748 }],
4749 key: None,
4750 },
4751 WidgetSpec::List {
4752 items: vec![
4753 TextPropertyEntry::text("row0"),
4754 TextPropertyEntry::text("row1"),
4755 ],
4756 item_specs: vec![],
4757 item_keys: vec!["a".into(), "b".into()],
4758 selected_index: -1,
4759 visible_rows: 10,
4760 key: None,
4761 focusable: true,
4762 },
4763 ],
4764 key: None,
4765 };
4766 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4767 assert_eq!(entries.len(), 11);
4770 assert_eq!(hits.len(), 2);
4773 assert_eq!(hits[0].buffer_row, 1);
4775 assert_eq!(hits[1].buffer_row, 2);
4776 }
4777
4778 #[test]
4779 fn list_payload_includes_absolute_index_and_key() {
4780 let spec = WidgetSpec::List {
4781 items: vec![TextPropertyEntry::text("only")],
4782 item_specs: vec![],
4783 item_keys: vec!["match:42".into()],
4784 selected_index: 0,
4785 visible_rows: 10,
4786 focusable: true,
4787 key: None,
4788 };
4789 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4790 assert_eq!(hits[0].payload["index"], 0);
4791 assert_eq!(hits[0].payload["key"], "match:42");
4792 }
4793
4794 #[test]
4795 fn list_hit_payload_carries_list_key() {
4796 let spec = make_list(-1, 10, 2, Some("mylist"));
4802 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4803 assert_eq!(hits.len(), 2);
4804 assert_eq!(hits[0].payload["list_key"], "mylist");
4805 assert_eq!(hits[1].payload["list_key"], "mylist");
4806 }
4807
4808 #[test]
4809 fn list_hit_payload_list_key_is_null_when_keyless() {
4810 let spec = make_list(-1, 10, 1, None);
4813 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4814 assert!(hits[0].payload["list_key"].is_null());
4815 }
4816
4817 #[test]
4818 fn list_with_missing_key_emits_empty_widget_key() {
4819 let spec = WidgetSpec::List {
4820 items: vec![TextPropertyEntry::text("a"), TextPropertyEntry::text("b")],
4821 item_specs: vec![],
4823 item_keys: vec!["only".into()],
4824 selected_index: -1,
4825 visible_rows: 10,
4826 focusable: true,
4827 key: None,
4828 };
4829 let (_, hits, _state) = render_no_focus(&spec, &HashMap::new());
4830 assert_eq!(hits[0].widget_key, "only");
4831 assert_eq!(hits[1].widget_key, "");
4832 }
4833
4834 fn make_list(selected: i32, visible: u32, total: usize, key: Option<&str>) -> WidgetSpec {
4835 let items = (0..total)
4836 .map(|i| TextPropertyEntry::text(format!("row{}", i)))
4837 .collect();
4838 let item_keys = (0..total).map(|i| format!("k{}", i)).collect();
4839 WidgetSpec::List {
4840 items,
4841 item_specs: vec![],
4842 item_keys,
4843 selected_index: selected,
4844 visible_rows: visible,
4845 focusable: true,
4846 key: key.map(|s| s.to_string()),
4847 }
4848 }
4849
4850 #[test]
4851 fn list_renders_only_visible_window() {
4852 let spec = make_list(-1, 3, 10, Some("L"));
4853 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
4854 assert_eq!(entries.len(), 3);
4855 assert_eq!(hits.len(), 3);
4856 assert_eq!(hits[0].payload["index"], 0);
4858 assert_eq!(hits[2].payload["index"], 2);
4859 }
4860
4861 #[test]
4862 fn list_scrolls_to_keep_selected_below_window_in_view() {
4863 let spec = make_list(5, 3, 10, Some("L"));
4868 let (_entries, hits, state) = render_no_focus(&spec, &HashMap::new());
4869 assert_eq!(hits.len(), 3);
4871 assert_eq!(hits[0].payload["index"], 3);
4872 assert_eq!(hits[2].payload["index"], 5);
4873 let scroll = match state.get("L").unwrap() {
4874 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
4875 _ => unreachable!(),
4876 };
4877 assert_eq!(scroll, 3);
4878 }
4879
4880 #[test]
4881 fn list_scrolls_to_keep_selected_above_window_in_view() {
4882 let mut prev = HashMap::new();
4888 prev.insert(
4889 "L".into(),
4890 WidgetInstanceState::List {
4891 scroll_offset: 5,
4892 selected_index: 1,
4893 item_height: 1,
4894 user_scrolled: false,
4895 },
4896 );
4897 let spec = make_list(99, 3, 10, Some("L"));
4899 let (_entries, hits, state) = render_no_focus(&spec, &prev);
4900 assert_eq!(hits[0].payload["index"], 1);
4901 let scroll = match state.get("L").unwrap() {
4902 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
4903 _ => unreachable!(),
4904 };
4905 assert_eq!(scroll, 1);
4906 }
4907
4908 #[test]
4909 fn list_scroll_preserved_when_selection_remains_in_view() {
4910 let mut prev = HashMap::new();
4913 prev.insert(
4914 "L".into(),
4915 WidgetInstanceState::List {
4916 scroll_offset: 4,
4917 selected_index: 5,
4918 item_height: 1,
4919 user_scrolled: false,
4920 },
4921 );
4922 let spec = make_list(99, 3, 10, Some("L"));
4923 let (_entries, hits, state) = render_no_focus(&spec, &prev);
4924 assert_eq!(hits[0].payload["index"], 4);
4925 let scroll = match state.get("L").unwrap() {
4926 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
4927 _ => unreachable!(),
4928 };
4929 assert_eq!(scroll, 4);
4930 }
4931
4932 #[test]
4933 fn list_clamps_scroll_to_max_when_dataset_is_smaller_than_old_offset() {
4934 let mut prev = HashMap::new();
4937 prev.insert(
4938 "L".into(),
4939 WidgetInstanceState::List {
4940 scroll_offset: 8,
4941 selected_index: -1,
4942 item_height: 1,
4943 user_scrolled: false,
4944 },
4945 );
4946 let spec = make_list(-1, 3, 5, Some("L"));
4947 let (entries, _hits, state) = render_no_focus(&spec, &prev);
4948 assert_eq!(entries.len(), 3);
4949 let scroll = match state.get("L").unwrap() {
4950 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
4951 _ => unreachable!(),
4952 };
4953 assert_eq!(scroll, 2);
4955 }
4956
4957 #[test]
4958 fn list_does_not_scroll_when_total_smaller_than_visible() {
4959 let spec = make_list(-1, 10, 3, Some("L"));
4960 let (entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
4961 assert_eq!(entries.len(), 10);
4966 let scroll = match state.get("L").unwrap() {
4967 WidgetInstanceState::List { scroll_offset, .. } => *scroll_offset,
4968 _ => unreachable!(),
4969 };
4970 assert_eq!(scroll, 0);
4971 }
4972
4973 #[test]
4974 fn list_without_key_does_not_persist_state() {
4975 let spec = make_list(5, 3, 10, None);
4976 let (_entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
4977 assert!(
4978 state.is_empty(),
4979 "Lists without a `key` opt out of state preservation"
4980 );
4981 }
4982
4983 #[test]
4988 fn text_input_renders_value_in_brackets() {
4989 let entry = render_text_input("hello", -1, None, false, "", None, 0, 0, false).entry;
4990 assert_eq!(entry.text, "[hello]");
4991 assert!(entry.inline_overlays.is_empty());
4992 }
4993
4994 #[test]
4995 fn text_input_with_label_prefixes_with_label_space() {
4996 let entry = render_text_input("foo", -1, None, false, "Search:", None, 0, 0, false).entry;
4997 assert_eq!(entry.text, "Search: [foo]");
4998 }
4999
5000 #[test]
5001 fn text_input_focused_adds_input_bg_overlay() {
5002 let entry = render_text_input("x", -1, None, true, "", None, 0, 0, false).entry;
5003 assert_eq!(entry.inline_overlays.len(), 1);
5005 let bg = entry.inline_overlays[0].style.bg.as_ref().unwrap();
5006 assert_eq!(bg.as_theme_key(), Some("ui.prompt_bg"));
5007 }
5008
5009 #[test]
5010 fn text_input_focused_with_selection_adds_selection_bg_overlay() {
5011 let entry =
5014 render_text_input("hello world", 5, Some((0, 5)), true, "", None, 0, 0, false).entry;
5015 let sel = entry
5018 .inline_overlays
5019 .iter()
5020 .find(|o| {
5021 o.style.bg.as_ref().and_then(|c| c.as_theme_key())
5022 == Some("ui.text_input_selection_bg")
5023 })
5024 .expect("selection overlay present");
5025 assert_eq!(sel.start, 1);
5026 assert_eq!(sel.end, 6);
5027 }
5028
5029 #[test]
5030 fn text_input_unfocused_skips_selection_overlay() {
5031 let entry =
5034 render_text_input("hello", -1, Some((0, 5)), false, "", None, 0, 0, false).entry;
5035 let has_sel_overlay = entry.inline_overlays.iter().any(|o| {
5036 o.style.bg.as_ref().and_then(|c| c.as_theme_key()) == Some("ui.text_input_selection_bg")
5037 });
5038 assert!(!has_sel_overlay);
5039 }
5040
5041 #[test]
5042 fn text_area_focused_with_selection_emits_per_row_overlays() {
5043 let r = render_text_area("abcd\nefgh", 8, Some((2, 8)), true, "", None, 2, 0, 0, 80);
5047 let row0 = &r.entries[0];
5050 let row1 = &r.entries[1];
5051 let sel0 = row0
5052 .inline_overlays
5053 .iter()
5054 .find(|o| {
5055 o.style.bg.as_ref().and_then(|c| c.as_theme_key())
5056 == Some("ui.text_input_selection_bg")
5057 })
5058 .expect("row 0 selection overlay");
5059 assert_eq!((sel0.start, sel0.end), (2, 4));
5060 let sel1 = row1
5061 .inline_overlays
5062 .iter()
5063 .find(|o| {
5064 o.style.bg.as_ref().and_then(|c| c.as_theme_key())
5065 == Some("ui.text_input_selection_bg")
5066 })
5067 .expect("row 1 selection overlay");
5068 assert_eq!((sel1.start, sel1.end), (0, 3));
5069 }
5070
5071 #[test]
5072 fn text_input_cursor_byte_in_entry_at_value_position() {
5073 let r = render_text_input("abc", 1, None, true, "", None, 0, 0, false);
5078 assert_eq!(r.cursor_byte_in_entry, Some(2));
5079 }
5080
5081 #[test]
5082 fn text_input_cursor_at_end_lands_on_padding_space_not_bracket() {
5083 let r = render_text_input("ab", 2, None, true, "", None, 0, 0, false);
5089 assert_eq!(r.entry.text, "[ab ]");
5090 assert_eq!(r.cursor_byte_in_entry, Some(3));
5091 assert_ne!(r.cursor_byte_in_entry, Some(4), "must not overlap ]");
5092 }
5093
5094 #[test]
5095 fn text_input_unfocused_empty_shows_placeholder_in_muted() {
5096 let entry =
5097 render_text_input("", -1, None, false, "", Some("type here"), 0, 0, false).entry;
5098 assert_eq!(entry.text, "[type here]");
5099 let placeholder_overlay = entry
5101 .inline_overlays
5102 .iter()
5103 .find(|o| o.style.fg.as_ref().and_then(|c| c.as_theme_key()).is_some())
5104 .expect("placeholder fg overlay");
5105 let fg = placeholder_overlay.style.fg.as_ref().unwrap();
5106 assert_eq!(fg.as_theme_key(), Some("editor.whitespace_indicator_fg"));
5107 assert!(placeholder_overlay.style.italic);
5108 }
5109
5110 #[test]
5111 fn text_input_focused_empty_still_shows_placeholder() {
5112 let r = render_text_input("", -1, None, true, "", Some("type here"), 0, 0, false);
5116 assert_eq!(r.entry.text, "[type here]");
5117 assert_eq!(r.cursor_byte_in_entry, Some(1));
5118 }
5119
5120 #[test]
5121 fn text_input_field_width_pads_short_value_unfocused() {
5122 let r = render_text_input("hi", 2, None, false, "", None, 0, 10, false);
5125 assert_eq!(r.entry.text, "[hi ]");
5126 }
5127
5128 #[test]
5129 fn text_input_field_width_focused_adds_cursor_park_space() {
5130 let r = render_text_input("0123456789", 10, None, true, "", None, 0, 10, false);
5134 assert_eq!(r.entry.text, "[0123456789 ]");
5135 assert_eq!(r.cursor_byte_in_entry, Some(11));
5139 assert_ne!(r.cursor_byte_in_entry, Some(12), "must not land on ]");
5140 }
5141
5142 #[test]
5143 fn text_input_field_width_full_width_pads_to_same_size_when_unfocused() {
5144 let r = render_text_input("hi", -1, None, false, "", None, 0, 10, true);
5148 assert_eq!(r.entry.text, "[hi ]"); }
5150
5151 #[test]
5152 fn text_input_field_width_head_truncates_long_value() {
5153 let r = render_text_input(
5156 "0123456789abcdefghijklmnopqrst",
5157 30,
5158 None,
5159 false,
5160 "",
5161 None,
5162 0,
5163 10,
5164 false,
5165 );
5166 assert!(r.entry.text.contains("…lmnopqrst"));
5167 }
5168
5169 #[test]
5170 fn text_input_field_width_clamps_cursor_in_dropped_prefix() {
5171 let r = render_text_input("abcdefghij", 0, None, true, "", None, 0, 5, false);
5174 assert_eq!(r.cursor_byte_in_entry, Some(1 + "…".len()));
5179 }
5180
5181 #[test]
5182 fn text_input_truncates_long_value_keeping_tail_visible() {
5183 let value: String = "0123456789abcdefghij".to_string();
5184 let entry = render_text_input(&value, -1, None, false, "", None, 6, 0, false).entry;
5185 assert_eq!(entry.text, "[…fghij]");
5187 }
5188
5189 #[test]
5190 fn raw_inside_col_offsets_following_hits() {
5191 let spec = WidgetSpec::Col {
5192 children: vec![
5193 WidgetSpec::Raw {
5194 entries: vec![
5195 TextPropertyEntry::text("line0"),
5196 TextPropertyEntry::text("line1"),
5197 TextPropertyEntry::text("line2"),
5198 ],
5199 key: None,
5200 },
5201 WidgetSpec::Toggle {
5202 checked: false,
5203 label: "after raw".into(),
5204 focused: false,
5205 key: Some("post".into()),
5206 },
5207 ],
5208 key: None,
5209 };
5210 let (entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5211 assert_eq!(entries.len(), 4);
5212 assert_eq!(hits.len(), 1);
5213 assert_eq!(hits[0].buffer_row, 3);
5214 }
5215
5216 fn tnode(text: &str, depth: u32, has_children: bool) -> TreeNode {
5221 TreeNode {
5222 text: TextPropertyEntry::text(text),
5223 depth,
5224 has_children,
5225 checked: None,
5226 }
5227 }
5228
5229 fn make_tree(
5230 nodes: Vec<TreeNode>,
5231 item_keys: Vec<&str>,
5232 selected: i32,
5233 visible: u32,
5234 expanded: Vec<&str>,
5235 key: Option<&str>,
5236 ) -> WidgetSpec {
5237 WidgetSpec::Tree {
5238 nodes,
5239 item_keys: item_keys.iter().map(|s| s.to_string()).collect(),
5240 selected_index: selected,
5241 visible_rows: visible,
5242 expanded_keys: expanded.iter().map(|s| s.to_string()).collect(),
5243 checkable: false,
5244 key: key.map(|s| s.to_string()),
5245 }
5246 }
5247
5248 #[test]
5249 fn tree_row_renders_disclosure_glyph_for_internal_collapsed() {
5250 let r = render_tree_row(&tnode("file.txt", 0, true), false, false);
5251 assert!(r.entry.text.starts_with('\u{25B6}'), "starts with ▶");
5252 assert!(r.entry.text.contains("file.txt"));
5253 assert!(r.disclosure_range.is_some());
5254 }
5255
5256 #[test]
5257 fn tree_row_renders_disclosure_glyph_for_internal_expanded() {
5258 let r = render_tree_row(&tnode("file.txt", 0, true), true, false);
5259 assert!(r.entry.text.starts_with('\u{25BC}'), "starts with ▼");
5260 }
5261
5262 #[test]
5263 fn tree_row_leaf_uses_two_spaces_no_disclosure_hit() {
5264 let r = render_tree_row(&tnode("match", 0, false), false, false);
5265 assert!(r.entry.text.starts_with(" "));
5267 assert!(r.entry.text.contains("match"));
5268 assert!(r.disclosure_range.is_none());
5269 }
5270
5271 #[test]
5272 fn tree_row_indents_by_depth_times_two() {
5273 let r = render_tree_row(&tnode("nested", 2, false), false, false);
5274 assert!(r.entry.text.starts_with(" nested"));
5276 }
5277
5278 #[test]
5279 fn tree_row_shifts_plugin_overlays_by_prefix() {
5280 let mut node = tnode("hello", 1, false);
5281 node.text.inline_overlays.push(InlineOverlay {
5282 start: 0,
5283 end: 5,
5284 style: OverlayOptions {
5285 bold: true,
5286 ..Default::default()
5287 },
5288 properties: Default::default(),
5289 unit: OffsetUnit::Byte,
5290 });
5291 let r = render_tree_row(&node, false, false);
5292 let plugin_overlay = r
5295 .entry
5296 .inline_overlays
5297 .iter()
5298 .find(|o| o.style.bold)
5299 .expect("bold overlay carried through");
5300 assert_eq!(plugin_overlay.start, 4);
5301 assert_eq!(plugin_overlay.end, 9);
5302 }
5303
5304 #[test]
5305 fn tree_row_omits_checkbox_when_not_checkable() {
5306 let mut node = tnode("file.rs", 0, false);
5308 node.checked = Some(true);
5309 let r = render_tree_row(&node, false, false);
5310 assert!(r.checkbox_range.is_none());
5311 assert!(!r.entry.text.contains("[v]"));
5312 assert!(!r.entry.text.contains("[ ]"));
5313 }
5314
5315 #[test]
5316 fn tree_row_omits_checkbox_when_checked_is_none() {
5317 let node = tnode("section", 0, false);
5321 let r = render_tree_row(&node, false, true);
5322 assert!(r.checkbox_range.is_none());
5323 assert!(!r.entry.text.contains("[v]"));
5324 assert!(!r.entry.text.contains("[ ]"));
5325 }
5326
5327 #[test]
5328 fn tree_row_renders_checked_glyph_after_disclosure() {
5329 let mut node = tnode("file.rs", 0, true);
5330 node.checked = Some(true);
5331 let r = render_tree_row(&node, true, true);
5332 assert!(r.checkbox_range.is_some(), "checkbox range emitted");
5333 let (cb_start, cb_end) = r.checkbox_range.unwrap();
5334 assert_eq!(&r.entry.text[cb_start..cb_end], "[v]");
5336 assert!(r.entry.text.contains("[v] file.rs"));
5337 }
5338
5339 #[test]
5340 fn tree_row_renders_unchecked_glyph_for_leaf() {
5341 let mut node = tnode("match-row", 1, false);
5342 node.checked = Some(false);
5343 let r = render_tree_row(&node, false, true);
5344 let (cb_start, cb_end) = r
5345 .checkbox_range
5346 .expect("checkbox range for leaf with checked: Some");
5347 assert_eq!(&r.entry.text[cb_start..cb_end], "[ ]");
5348 assert!(r.entry.text.starts_with(" [ ] match-row"));
5350 }
5351
5352 #[test]
5353 fn tree_row_checkbox_glyph_byte_range_addresses_correct_text() {
5354 let mut node = tnode("path/with/é", 0, true);
5357 node.checked = Some(true);
5358 let r = render_tree_row(&node, false, true);
5359 let (cb_start, cb_end) = r.checkbox_range.unwrap();
5360 assert!(r.entry.text.is_char_boundary(cb_start));
5361 assert!(r.entry.text.is_char_boundary(cb_end));
5362 assert_eq!(&r.entry.text[cb_start..cb_end], "[v]");
5363 }
5364
5365 #[test]
5366 fn tree_node_pad_to_chars_pads_text_before_prefix_offset_shift() {
5367 let mut node = tnode("x", 0, true);
5371 node.text.pad_to_chars = Some(5);
5372 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec!["x"], Some("T"));
5373 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5374 assert_eq!(entries.len(), 1);
5375 let trimmed = entries[0].text.trim_end_matches('\n');
5378 assert!(
5379 trimmed.ends_with("x "),
5380 "row should end with the padded body, got {trimmed:?}"
5381 );
5382 }
5383
5384 #[test]
5385 fn tree_node_truncate_to_chars_cuts_body_before_prefix_offset_shift() {
5386 let mut node = tnode("abcdefghij", 0, false);
5387 node.text.truncate_to_chars = Some(6);
5388 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5389 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5390 let trimmed = entries[0].text.trim_end_matches('\n');
5391 assert!(
5394 trimmed.ends_with("abc..."),
5395 "row should end with truncated body, got {trimmed:?}"
5396 );
5397 }
5398
5399 #[test]
5400 fn tree_node_char_unit_overlay_resolves_against_padded_text_and_shifts_by_prefix() {
5401 let mut node = tnode("x", 0, false);
5406 node.text.pad_to_chars = Some(5);
5407 node.text.inline_overlays.push(InlineOverlay {
5408 start: 0,
5409 end: 5,
5410 style: OverlayOptions {
5411 bold: true,
5412 ..Default::default()
5413 },
5414 properties: Default::default(),
5415 unit: OffsetUnit::Char,
5416 });
5417 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5418 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5419 let entry = &entries[0];
5420 let bold = entry
5421 .inline_overlays
5422 .iter()
5423 .find(|o| o.style.bold)
5424 .expect("bold overlay carried through");
5425 assert_eq!(bold.start, 2);
5428 assert_eq!(bold.end, 7);
5429 }
5430
5431 #[test]
5432 fn tree_node_char_unit_overlay_with_multibyte_body_resolves_correctly() {
5433 let mut node = tnode("éxé", 0, false);
5437 node.text.inline_overlays.push(InlineOverlay {
5438 start: 1,
5439 end: 2,
5440 style: OverlayOptions {
5441 bold: true,
5442 ..Default::default()
5443 },
5444 properties: Default::default(),
5445 unit: OffsetUnit::Char,
5446 });
5447 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5448 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5449 let entry = &entries[0];
5450 let bold = entry
5451 .inline_overlays
5452 .iter()
5453 .find(|o| o.style.bold)
5454 .expect("bold overlay carried through");
5455 let trimmed = entry.text.trim_end_matches('\n');
5458 assert_eq!(bold.start, 4);
5459 assert_eq!(bold.end, 5);
5460 assert_eq!(&trimmed[bold.start..bold.end], "x");
5461 }
5462
5463 #[test]
5464 fn tree_node_segments_concatenate_into_row_text_with_per_segment_overlays() {
5465 let mut node = tnode("", 0, false);
5466 node.text.segments = vec![
5467 fresh_core::text_property::StyledSegment {
5468 text: "AB".to_string(),
5469 style: None,
5470 overlays: vec![],
5471 },
5472 fresh_core::text_property::StyledSegment {
5473 text: " ".to_string(),
5474 style: None,
5475 overlays: vec![],
5476 },
5477 fresh_core::text_property::StyledSegment {
5478 text: "CD".to_string(),
5479 style: Some(OverlayOptions {
5480 bold: true,
5481 ..Default::default()
5482 }),
5483 overlays: vec![],
5484 },
5485 ];
5486 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5487 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5488 let trimmed = entries[0].text.trim_end_matches('\n');
5489 assert!(
5491 trimmed.ends_with("AB CD"),
5492 "row should end with concatenated segments, got {trimmed:?}"
5493 );
5494 let bold = entries[0]
5495 .inline_overlays
5496 .iter()
5497 .find(|o| o.style.bold)
5498 .expect("styled segment overlay carried through");
5499 assert_eq!(&trimmed[bold.start..bold.end], "CD");
5502 }
5503
5504 #[test]
5505 fn tree_node_segment_nested_overlay_shifts_to_segment_position() {
5506 let mut node = tnode("", 0, false);
5511 node.text.segments = vec![
5512 fresh_core::text_property::StyledSegment {
5513 text: "AB".to_string(),
5514 style: None,
5515 overlays: vec![],
5516 },
5517 fresh_core::text_property::StyledSegment {
5518 text: " - ".to_string(),
5519 style: None,
5520 overlays: vec![],
5521 },
5522 fresh_core::text_property::StyledSegment {
5523 text: "CDEFG".to_string(),
5524 style: None,
5525 overlays: vec![InlineOverlay {
5526 start: 0,
5527 end: 3,
5528 style: OverlayOptions {
5529 bold: true,
5530 ..Default::default()
5531 },
5532 properties: Default::default(),
5533 unit: OffsetUnit::Char,
5534 }],
5535 },
5536 ];
5537 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5538 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5539 let trimmed = entries[0].text.trim_end_matches('\n');
5540 let bold = entries[0]
5541 .inline_overlays
5542 .iter()
5543 .find(|o| o.style.bold)
5544 .expect("nested overlay carried through");
5545 assert_eq!(&trimmed[bold.start..bold.end], "CDE");
5546 }
5547
5548 #[test]
5549 fn tree_node_segments_with_pad_pad_after_concatenation() {
5550 let mut node = tnode("", 0, false);
5551 node.text.segments = vec![fresh_core::text_property::StyledSegment {
5552 text: "ab".to_string(),
5553 style: None,
5554 overlays: vec![],
5555 }];
5556 node.text.pad_to_chars = Some(5);
5557 let spec = make_tree(vec![node], vec!["x"], -1, 10, vec![], Some("T"));
5558 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5559 let trimmed = entries[0].text.trim_end_matches('\n');
5560 assert!(
5562 trimmed.ends_with("ab "),
5563 "row should be padded after segment concat, got {trimmed:?}"
5564 );
5565 }
5566
5567 #[test]
5568 fn tree_renders_only_top_level_when_nothing_expanded() {
5569 let spec = make_tree(
5570 vec![
5571 tnode("a", 0, true),
5572 tnode("a.0", 1, false),
5573 tnode("a.1", 1, false),
5574 tnode("b", 0, true),
5575 tnode("b.0", 1, false),
5576 ],
5577 vec!["a", "a.0", "a.1", "b", "b.0"],
5578 -1,
5579 10,
5580 vec![], Some("T"),
5582 );
5583 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5584 assert_eq!(entries.len(), 2);
5586 assert!(entries[0].text.contains('a'));
5587 assert!(entries[1].text.contains('b'));
5588 }
5589
5590 #[test]
5591 fn tree_renders_children_of_expanded_nodes() {
5592 let spec = make_tree(
5593 vec![
5594 tnode("a", 0, true),
5595 tnode("a.0", 1, false),
5596 tnode("a.1", 1, false),
5597 tnode("b", 0, true),
5598 tnode("b.0", 1, false),
5599 ],
5600 vec!["a", "a.0", "a.1", "b", "b.0"],
5601 -1,
5602 10,
5603 vec!["a"],
5604 Some("T"),
5605 );
5606 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5607 assert_eq!(entries.len(), 4);
5609 }
5610
5611 #[test]
5612 fn tree_emits_two_hits_per_internal_row_one_per_leaf() {
5613 let spec = make_tree(
5616 vec![tnode("a", 0, true), tnode("a.0", 1, false)],
5617 vec!["a", "a.0"],
5618 -1,
5619 10,
5620 vec!["a"],
5621 Some("T"),
5622 );
5623 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5624 assert_eq!(hits.len(), 3);
5625 assert_eq!(hits[0].event_type, "expand");
5627 assert_eq!(hits[0].widget_kind, "tree");
5628 assert_eq!(hits[1].event_type, "select");
5629 assert_eq!(hits[2].event_type, "select");
5630 }
5631
5632 #[test]
5633 fn tree_hits_carry_tree_spec_key_and_per_item_key_in_payload() {
5634 let spec = make_tree(
5635 vec![tnode("only", 0, false)],
5636 vec!["only-key"],
5637 -1,
5638 10,
5639 vec![],
5640 Some("matchTree"),
5641 );
5642 let (_entries, hits, _state) = render_no_focus(&spec, &HashMap::new());
5643 assert_eq!(hits[0].widget_key, "matchTree");
5644 assert_eq!(hits[0].payload["key"], "only-key");
5645 assert_eq!(hits[0].payload["index"], 0);
5646 }
5647
5648 #[test]
5649 fn tree_persists_expanded_keys_in_instance_state() {
5650 let spec = make_tree(
5651 vec![tnode("a", 0, true), tnode("a.0", 1, false)],
5652 vec!["a", "a.0"],
5653 -1,
5654 10,
5655 vec!["a"],
5656 Some("T"),
5657 );
5658 let (_, _, state) = render_no_focus(&spec, &HashMap::new());
5659 match state.get("T").unwrap() {
5660 WidgetInstanceState::Tree { expanded_keys, .. } => {
5661 assert!(expanded_keys.contains("a"));
5662 }
5663 _ => unreachable!(),
5664 }
5665 }
5666
5667 #[test]
5668 fn tree_instance_state_overrides_spec_expanded_keys() {
5669 let mut prev = HashMap::new();
5672 prev.insert(
5673 "T".into(),
5674 WidgetInstanceState::Tree {
5675 scroll_offset: 0,
5676 selected_index: -1,
5677 expanded_keys: ["b".to_string()].iter().cloned().collect(),
5678 },
5679 );
5680 let spec = make_tree(
5681 vec![
5682 tnode("a", 0, true),
5683 tnode("a.0", 1, false),
5684 tnode("b", 0, true),
5685 tnode("b.0", 1, false),
5686 ],
5687 vec!["a", "a.0", "b", "b.0"],
5688 -1,
5689 10,
5690 vec!["a"], Some("T"),
5692 );
5693 let (entries, _hits, _state) = render_no_focus(&spec, &prev);
5694 assert_eq!(entries.len(), 3);
5696 }
5697
5698 #[test]
5699 fn tree_selected_row_gets_focused_bg() {
5700 let spec = make_tree(
5701 vec![tnode("a", 0, false), tnode("b", 0, false)],
5702 vec!["a", "b"],
5703 1,
5704 10,
5705 vec![],
5706 Some("T"),
5707 );
5708 let (entries, _hits, _state) = render_no_focus(&spec, &HashMap::new());
5709 assert!(entries[0].style.is_none());
5710 let style = entries[1].style.as_ref().expect("selected gets style");
5711 assert_eq!(
5712 style.bg.as_ref().and_then(|c| c.as_theme_key()),
5713 Some("ui.popup_selection_bg")
5714 );
5715 assert!(style.extend_to_line_end);
5716 }
5717
5718 #[test]
5719 fn tree_clamps_selection_to_visible_when_selected_node_is_hidden() {
5720 let spec = make_tree(
5724 vec![tnode("a", 0, true), tnode("a.0", 1, false)],
5725 vec!["a", "a.0"],
5726 1,
5727 10,
5728 vec![], Some("T"),
5730 );
5731 let (_entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
5732 match state.get("T").unwrap() {
5733 WidgetInstanceState::Tree { selected_index, .. } => {
5734 assert_eq!(*selected_index, 0);
5735 }
5736 _ => unreachable!(),
5737 }
5738 }
5739
5740 #[test]
5741 fn tree_scrolls_to_keep_selection_in_visible_window() {
5742 let spec = make_tree(
5746 vec![
5747 tnode("0", 0, false),
5748 tnode("1", 0, false),
5749 tnode("2", 0, false),
5750 tnode("3", 0, false),
5751 tnode("4", 0, false),
5752 tnode("5", 0, false),
5753 ],
5754 vec!["k0", "k1", "k2", "k3", "k4", "k5"],
5755 4,
5756 3,
5757 vec![],
5758 Some("T"),
5759 );
5760 let (entries, _hits, state) = render_no_focus(&spec, &HashMap::new());
5761 assert_eq!(entries.len(), 3);
5763 match state.get("T").unwrap() {
5764 WidgetInstanceState::Tree { scroll_offset, .. } => assert_eq!(*scroll_offset, 2),
5765 _ => unreachable!(),
5766 }
5767 }
5768
5769 #[test]
5770 fn tree_tabbable_keys_include_tree_with_key() {
5771 let spec = WidgetSpec::Col {
5772 children: vec![
5773 WidgetSpec::Toggle {
5774 checked: false,
5775 label: "T".into(),
5776 focused: false,
5777 key: Some("toggle".into()),
5778 },
5779 make_tree(
5780 vec![tnode("a", 0, false)],
5781 vec!["a"],
5782 -1,
5783 10,
5784 vec![],
5785 Some("tree"),
5786 ),
5787 ],
5788 key: None,
5789 };
5790 let mut tabbable = Vec::new();
5791 collect_tabbable(&spec, &mut tabbable);
5792 assert_eq!(tabbable, vec!["toggle", "tree"]);
5793 }
5794
5795 fn make_text_area(
5800 value: &str,
5801 cursor_byte: i32,
5802 focused: bool,
5803 rows: u32,
5804 field_width: u32,
5805 key: Option<&str>,
5806 ) -> WidgetSpec {
5807 WidgetSpec::Text {
5808 value: value.into(),
5809 cursor_byte,
5810 focused,
5811 label: String::new(),
5812 placeholder: None,
5813 rows: rows.max(2),
5818 field_width,
5819 max_visible_chars: 0,
5820 full_width: false,
5821 completions: Vec::new(),
5822 completions_visible_rows: 0,
5823 key: key.map(|s| s.into()),
5824 }
5825 }
5826
5827 #[test]
5828 fn text_area_renders_visible_rows_count() {
5829 let spec = make_text_area("hi", -1, false, 3, 10, Some("ta"));
5832 let prev = HashMap::new();
5833 let out = render_spec(&spec, &prev, "", 80);
5834 assert_eq!(out.entries.len(), 3);
5835 }
5836
5837 #[test]
5838 fn text_area_pads_short_lines_to_field_width() {
5839 let spec = make_text_area("hi", -1, false, 1, 6, Some("ta"));
5840 let prev = HashMap::new();
5841 let out = render_spec(&spec, &prev, "", 80);
5842 let first = &out.entries[0];
5844 assert_eq!(first.text, "hi \n");
5845 }
5846
5847 #[test]
5848 fn text_area_truncates_long_line_with_ellipsis() {
5849 let spec = make_text_area("abcdefghi", -1, false, 1, 5, Some("ta"));
5850 let prev = HashMap::new();
5851 let out = render_spec(&spec, &prev, "", 80);
5852 assert_eq!(out.entries[0].text, "abcd…\n");
5854 }
5855
5856 #[test]
5857 fn text_area_focused_adds_input_bg_overlay_per_row() {
5858 let spec = make_text_area("a\nb", -1, true, 3, 4, Some("ta"));
5859 let prev = HashMap::new();
5860 let out = render_spec(&spec, &prev, "ta", 80);
5861 for entry in &out.entries {
5862 let has_bg = entry.inline_overlays.iter().any(|o| {
5863 o.style
5864 .bg
5865 .as_ref()
5866 .and_then(|c| c.as_theme_key())
5867 .map(|k| k == "ui.prompt_bg")
5868 .unwrap_or(false)
5869 });
5870 assert!(has_bg, "every focused row gets input-bg");
5871 }
5872 }
5873
5874 #[test]
5875 fn text_area_publishes_focus_cursor_at_value_position() {
5876 let spec = make_text_area("ab\ncd", 4, true, 3, 6, Some("ta"));
5879 let prev = HashMap::new();
5880 let out = render_spec(&spec, &prev, "ta", 80);
5881 let fc = out.focus_cursor.expect("focused → cursor published");
5882 assert_eq!(fc.buffer_row, 1);
5884 assert_eq!(fc.byte_in_row, 1);
5886 }
5887
5888 #[test]
5889 fn text_area_label_offsets_cursor_buffer_row() {
5890 let spec = WidgetSpec::Text {
5894 value: "hi".into(),
5895 cursor_byte: 1,
5896 focused: true,
5897 label: "Note".into(),
5898 placeholder: None,
5899 rows: 2,
5900 field_width: 6,
5901 max_visible_chars: 0,
5902 full_width: false,
5903 completions: Vec::new(),
5904 completions_visible_rows: 0,
5905 key: Some("ta".into()),
5906 };
5907 let prev = HashMap::new();
5908 let out = render_spec(&spec, &prev, "ta", 80);
5909 assert!(out.entries[0].text.starts_with("Note:"));
5911 let fc = out.focus_cursor.unwrap();
5912 assert_eq!(fc.buffer_row, 1);
5913 }
5914
5915 #[test]
5916 fn text_area_persists_value_and_cursor_in_instance_state() {
5917 let spec = make_text_area("abc", 2, true, 2, 8, Some("ta"));
5918 let prev = HashMap::new();
5919 let out = render_spec(&spec, &prev, "ta", 80);
5920 match out.instance_states.get("ta") {
5921 Some(WidgetInstanceState::Text { editor, .. }) => {
5922 assert_eq!(editor.value(), "abc");
5923 assert_eq!(editor.flat_cursor_byte(), 2);
5924 }
5925 other => panic!("expected Text instance state, got {:?}", other),
5926 }
5927 }
5928
5929 #[test]
5930 fn text_area_instance_state_overrides_spec_value() {
5931 let spec = make_text_area("old", 0, true, 2, 8, Some("ta"));
5934 let mut prev = HashMap::new();
5935 let mut editor = crate::primitives::text_edit::TextEdit::with_text("new");
5936 editor.set_cursor_from_flat(3);
5937 prev.insert(
5938 "ta".into(),
5939 WidgetInstanceState::Text {
5940 editor,
5941 scroll: 0,
5942 completions: Vec::new(),
5943 completion_selected_index: 0,
5944 completion_scroll_offset: 0,
5945 },
5946 );
5947 let out = render_spec(&spec, &prev, "ta", 80);
5948 assert!(out.entries[0].text.starts_with("new"));
5950 }
5951
5952 #[test]
5953 fn text_area_scroll_clamps_to_keep_cursor_visible() {
5954 let spec = make_text_area("a\nb\nc\nd\ne", 8, true, 2, 4, Some("ta"));
5958 let prev = HashMap::new();
5960 let out = render_spec(&spec, &prev, "ta", 80);
5961 match out.instance_states.get("ta") {
5962 Some(WidgetInstanceState::Text { scroll, .. }) => {
5963 assert_eq!(*scroll, 3, "scroll so lines 3..5 are visible");
5964 }
5965 _ => panic!("expected Text instance state"),
5966 }
5967 }
5968
5969 #[test]
5970 fn text_area_unfocused_empty_shows_placeholder_in_first_row() {
5971 let r = render_text_area("", -1, None, false, "", Some("write here"), 2, 12, 0, 80);
5976 assert!(r.entries[0].text.starts_with("write here"));
5977 let fg = r.entries[0]
5979 .inline_overlays
5980 .iter()
5981 .find_map(|o| o.style.fg.as_ref())
5982 .and_then(|c| c.as_theme_key());
5983 assert_eq!(fg, Some("editor.whitespace_indicator_fg"));
5984 }
5985
5986 #[test]
5987 fn text_area_tabbable_keys_include_text_area_with_key() {
5988 let spec = WidgetSpec::Col {
5989 children: vec![
5990 WidgetSpec::Toggle {
5991 checked: false,
5992 label: "T".into(),
5993 focused: false,
5994 key: Some("toggle".into()),
5995 },
5996 make_text_area("", -1, false, 3, 10, Some("note")),
5997 ],
5998 key: None,
5999 };
6000 let mut tabbable = Vec::new();
6001 collect_tabbable(&spec, &mut tabbable);
6002 assert_eq!(tabbable, vec!["toggle", "note"]);
6003 }
6004
6005 fn make_text_input(
6010 value: &str,
6011 cursor_byte: i32,
6012 focused: bool,
6013 full_width: bool,
6014 field_width: u32,
6015 key: Option<&str>,
6016 ) -> WidgetSpec {
6017 WidgetSpec::Text {
6018 value: value.into(),
6019 cursor_byte,
6020 focused,
6021 label: String::new(),
6022 placeholder: None,
6023 rows: 1,
6024 field_width,
6025 max_visible_chars: 0,
6026 full_width,
6027 completions: Vec::new(),
6028 completions_visible_rows: 0,
6029 key: key.map(|s| s.into()),
6030 }
6031 }
6032
6033 #[test]
6034 fn labeled_section_renders_three_rows_with_legend() {
6035 let spec = WidgetSpec::LabeledSection {
6036 label: "Name".into(),
6037 child: Box::new(make_text_input("hi", -1, false, false, 4, Some("n"))),
6038 width_pct: None,
6039 key: None,
6040 };
6041 let prev = HashMap::new();
6042 let out = render_spec(&spec, &prev, "", 20);
6043 assert_eq!(out.entries.len(), 3);
6045 assert!(out.entries[0].text.starts_with("╭─ Name "));
6047 assert!(out.entries[0].text.ends_with("╮\n"));
6048 assert!(out.entries[1].text.starts_with("│ "));
6050 assert!(out.entries[1].text.ends_with(" │\n"));
6051 assert!(out.entries[2].text.starts_with("╰"));
6053 assert!(out.entries[2].text.ends_with("╯\n"));
6054 }
6055
6056 #[test]
6057 fn zip_row_blocks_keeps_overlays_on_char_boundaries() {
6058 let left = WidgetSpec::LabeledSection {
6069 label: "alpha/beta · this project (2)".into(),
6070 child: Box::new(make_text_input("x", -1, false, false, 4, Some("a"))),
6071 width_pct: Some(40),
6072 key: None,
6073 };
6074 let right = WidgetSpec::LabeledSection {
6075 label: "preview".into(),
6076 child: Box::new(make_text_input("y", -1, false, false, 4, Some("b"))),
6077 width_pct: None,
6078 key: None,
6079 };
6080 let spec = WidgetSpec::Row {
6081 wrap: false,
6082 children: vec![left, right],
6083 key: None,
6084 };
6085 let out = render_spec(&spec, &HashMap::new(), "", 40);
6086 for e in &out.entries {
6087 for o in &e.inline_overlays {
6088 assert!(
6089 e.text.is_char_boundary(o.start.min(e.text.len())),
6090 "overlay start {} not on a char boundary of {:?}",
6091 o.start,
6092 e.text,
6093 );
6094 assert!(
6095 e.text.is_char_boundary(o.end.min(e.text.len())),
6096 "overlay end {} not on a char boundary of {:?}",
6097 o.end,
6098 e.text,
6099 );
6100 }
6101 }
6102 }
6103
6104 #[test]
6105 fn labeled_section_pads_child_to_inner_width() {
6106 let spec = WidgetSpec::LabeledSection {
6107 label: "".into(),
6108 child: Box::new(make_text_input("hi", -1, false, false, 4, Some("n"))),
6109 width_pct: None,
6110 key: None,
6111 };
6112 let prev = HashMap::new();
6113 let out = render_spec(&spec, &prev, "", 16);
6116 let middle = &out.entries[1];
6117 assert_eq!(middle.text.chars().count(), 16 + 1 );
6119 }
6120
6121 #[test]
6122 fn labeled_section_text_full_width_fills_inner_area() {
6123 let spec = WidgetSpec::LabeledSection {
6129 label: "".into(),
6130 child: Box::new(make_text_input("ab", -1, false, true, 0, Some("n"))),
6131 width_pct: None,
6132 key: None,
6133 };
6134 let prev = HashMap::new();
6135 let out = render_spec(&spec, &prev, "", 16);
6136 let middle = &out.entries[1];
6137 assert_eq!(middle.text.chars().count(), 17, "actual: {:?}", middle.text);
6141 assert!(
6142 middle.text.contains("[ab ]"),
6143 "actual: {:?}",
6144 middle.text
6145 );
6146 }
6147
6148 #[test]
6149 fn labeled_section_propagates_focus_cursor_with_offsets() {
6150 let spec = WidgetSpec::LabeledSection {
6151 label: "".into(),
6152 child: Box::new(make_text_input("abc", 3, true, false, 4, Some("n"))),
6153 width_pct: None,
6154 key: None,
6155 };
6156 let prev = HashMap::new();
6157 let out = render_spec(&spec, &prev, "n", 20);
6158 let fc = out.focus_cursor.expect("focused child publishes cursor");
6159 assert_eq!(fc.buffer_row, 1);
6161 let prefix_bytes = LEFT_BORDER_PREFIX.len() as u32;
6165 assert_eq!(fc.byte_in_row, prefix_bytes + 1 + 3);
6166 }
6167
6168 #[test]
6169 fn labeled_section_includes_child_in_tabbable() {
6170 let spec = WidgetSpec::Col {
6171 children: vec![
6172 WidgetSpec::LabeledSection {
6173 label: "Name".into(),
6174 child: Box::new(make_text_input("", -1, false, false, 0, Some("n"))),
6175 width_pct: None,
6176 key: None,
6177 },
6178 WidgetSpec::LabeledSection {
6179 label: "Cmd".into(),
6180 child: Box::new(make_text_input("", -1, false, false, 0, Some("c"))),
6181 width_pct: None,
6182 key: None,
6183 },
6184 ],
6185 key: None,
6186 };
6187 let mut tabbable = Vec::new();
6188 collect_tabbable(&spec, &mut tabbable);
6189 assert_eq!(tabbable, vec!["n", "c"]);
6190 }
6191}