1use super::*;
2use crate::KeyMap;
3
4impl Context {
5 pub fn text(&mut self, s: impl Into<String>) -> &mut Self {
18 let content = s.into();
19 let default_fg = self
20 .text_color_stack
21 .iter()
22 .rev()
23 .find_map(|c| *c)
24 .unwrap_or(self.theme.text);
25 self.commands.push(Command::Text {
26 content,
27 style: Style::new().fg(default_fg),
28 grow: 0,
29 align: Align::Start,
30 wrap: false,
31 truncate: false,
32 margin: Margin::default(),
33 constraints: Constraints::default(),
34 });
35 self.last_text_idx = Some(self.commands.len() - 1);
36 self
37 }
38
39 #[allow(clippy::print_stderr)]
45 pub fn link(&mut self, text: impl Into<String>, url: impl Into<String>) -> &mut Self {
46 let url_str = url.into();
47 let focused = self.register_focusable();
48 let interaction_id = self.next_interaction_id();
49 let response = self.response_for(interaction_id);
50
51 let mut activated = response.clicked;
52 if focused {
53 for (i, event) in self.events.iter().enumerate() {
54 if let Event::Key(key) = event {
55 if key.kind != KeyEventKind::Press {
56 continue;
57 }
58 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
59 activated = true;
60 self.consumed[i] = true;
61 }
62 }
63 }
64 }
65
66 if activated {
67 if let Err(e) = open_url(&url_str) {
68 eprintln!("[slt] failed to open URL: {e}");
69 }
70 }
71
72 let style = if focused {
73 Style::new()
74 .fg(self.theme.primary)
75 .bg(self.theme.surface_hover)
76 .underline()
77 .bold()
78 } else if response.hovered {
79 Style::new()
80 .fg(self.theme.accent)
81 .bg(self.theme.surface_hover)
82 .underline()
83 } else {
84 Style::new().fg(self.theme.primary).underline()
85 };
86
87 self.commands.push(Command::Link {
88 text: text.into(),
89 url: url_str,
90 style,
91 margin: Margin::default(),
92 constraints: Constraints::default(),
93 });
94 self.last_text_idx = Some(self.commands.len() - 1);
95 self
96 }
97
98 #[deprecated(since = "0.15.4", note = "use ui.text(s).wrap() instead")]
106 pub fn text_wrap(&mut self, s: impl Into<String>) -> &mut Self {
107 let content = s.into();
108 let default_fg = self
109 .text_color_stack
110 .iter()
111 .rev()
112 .find_map(|c| *c)
113 .unwrap_or(self.theme.text);
114 self.commands.push(Command::Text {
115 content,
116 style: Style::new().fg(default_fg),
117 grow: 0,
118 align: Align::Start,
119 wrap: true,
120 truncate: false,
121 margin: Margin::default(),
122 constraints: Constraints::default(),
123 });
124 self.last_text_idx = Some(self.commands.len() - 1);
125 self
126 }
127
128 pub fn timer_display(&mut self, elapsed: std::time::Duration) -> &mut Self {
132 let total_centis = elapsed.as_millis() / 10;
133 let centis = total_centis % 100;
134 let total_seconds = total_centis / 100;
135 let seconds = total_seconds % 60;
136 let minutes = (total_seconds / 60) % 60;
137 let hours = total_seconds / 3600;
138
139 let content = if hours > 0 {
140 format!("{hours:02}:{minutes:02}:{seconds:02}.{centis:02}")
141 } else {
142 format!("{minutes:02}:{seconds:02}.{centis:02}")
143 };
144
145 self.commands.push(Command::Text {
146 content,
147 style: Style::new().fg(self.theme.text),
148 grow: 0,
149 align: Align::Start,
150 wrap: false,
151 truncate: false,
152 margin: Margin::default(),
153 constraints: Constraints::default(),
154 });
155 self.last_text_idx = Some(self.commands.len() - 1);
156 self
157 }
158
159 pub fn help_from_keymap(&mut self, keymap: &KeyMap) -> Response {
161 let pairs: Vec<(&str, &str)> = keymap
162 .visible_bindings()
163 .map(|binding| (binding.display.as_str(), binding.description.as_str()))
164 .collect();
165 self.help(&pairs)
166 }
167
168 pub fn bold(&mut self) -> &mut Self {
172 self.modify_last_style(|s| s.modifiers |= Modifiers::BOLD);
173 self
174 }
175
176 pub fn dim(&mut self) -> &mut Self {
181 let text_dim = self.theme.text_dim;
182 self.modify_last_style(|s| {
183 s.modifiers |= Modifiers::DIM;
184 if s.fg.is_none() {
185 s.fg = Some(text_dim);
186 }
187 });
188 self
189 }
190
191 pub fn italic(&mut self) -> &mut Self {
193 self.modify_last_style(|s| s.modifiers |= Modifiers::ITALIC);
194 self
195 }
196
197 pub fn underline(&mut self) -> &mut Self {
199 self.modify_last_style(|s| s.modifiers |= Modifiers::UNDERLINE);
200 self
201 }
202
203 pub fn reversed(&mut self) -> &mut Self {
205 self.modify_last_style(|s| s.modifiers |= Modifiers::REVERSED);
206 self
207 }
208
209 pub fn strikethrough(&mut self) -> &mut Self {
211 self.modify_last_style(|s| s.modifiers |= Modifiers::STRIKETHROUGH);
212 self
213 }
214
215 pub fn fg(&mut self, color: Color) -> &mut Self {
217 self.modify_last_style(|s| s.fg = Some(color));
218 self
219 }
220
221 pub fn bg(&mut self, color: Color) -> &mut Self {
223 self.modify_last_style(|s| s.bg = Some(color));
224 self
225 }
226
227 pub fn gradient(&mut self, from: Color, to: Color) -> &mut Self {
229 if let Some(idx) = self.last_text_idx {
230 let replacement = match &self.commands[idx] {
231 Command::Text {
232 content,
233 style,
234 wrap,
235 align,
236 margin,
237 constraints,
238 ..
239 } => {
240 let chars: Vec<char> = content.chars().collect();
241 let len = chars.len();
242 let denom = len.saturating_sub(1).max(1) as f32;
243 let segments = chars
244 .into_iter()
245 .enumerate()
246 .map(|(i, ch)| {
247 let mut seg_style = *style;
248 seg_style.fg = Some(from.blend(to, i as f32 / denom));
249 (ch.to_string(), seg_style)
250 })
251 .collect();
252
253 Some(Command::RichText {
254 segments,
255 wrap: *wrap,
256 align: *align,
257 margin: *margin,
258 constraints: *constraints,
259 })
260 }
261 _ => None,
262 };
263
264 if let Some(command) = replacement {
265 self.commands[idx] = command;
266 }
267 }
268
269 self
270 }
271
272 pub fn group_hover_fg(&mut self, color: Color) -> &mut Self {
274 let apply_group_style = self
275 .group_stack
276 .last()
277 .map(|name| self.is_group_hovered(name) || self.is_group_focused(name))
278 .unwrap_or(false);
279 if apply_group_style {
280 self.modify_last_style(|s| s.fg = Some(color));
281 }
282 self
283 }
284
285 pub fn group_hover_bg(&mut self, color: Color) -> &mut Self {
287 let apply_group_style = self
288 .group_stack
289 .last()
290 .map(|name| self.is_group_hovered(name) || self.is_group_focused(name))
291 .unwrap_or(false);
292 if apply_group_style {
293 self.modify_last_style(|s| s.bg = Some(color));
294 }
295 self
296 }
297
298 pub fn styled(&mut self, s: impl Into<String>, style: Style) -> &mut Self {
303 self.commands.push(Command::Text {
304 content: s.into(),
305 style,
306 grow: 0,
307 align: Align::Start,
308 wrap: false,
309 truncate: false,
310 margin: Margin::default(),
311 constraints: Constraints::default(),
312 });
313 self.last_text_idx = Some(self.commands.len() - 1);
314 self
315 }
316
317 pub fn big_text(&mut self, s: impl Into<String>) -> Response {
319 let text = s.into();
320 let glyphs: Vec<[u8; 8]> = text.chars().map(glyph_8x8).collect();
321 let total_width = (glyphs.len() as u32).saturating_mul(8);
322 let on_color = self.theme.primary;
323
324 self.container().w(total_width).h(4).draw(move |buf, rect| {
325 if rect.width == 0 || rect.height == 0 {
326 return;
327 }
328
329 for (glyph_idx, glyph) in glyphs.iter().enumerate() {
330 let base_x = rect.x + (glyph_idx as u32) * 8;
331 if base_x >= rect.right() {
332 break;
333 }
334
335 for pair in 0..4usize {
336 let y = rect.y + pair as u32;
337 if y >= rect.bottom() {
338 continue;
339 }
340
341 let upper = glyph[pair * 2];
342 let lower = glyph[pair * 2 + 1];
343
344 for bit in 0..8u32 {
345 let x = base_x + bit;
346 if x >= rect.right() {
347 break;
348 }
349
350 let mask = 1u8 << (bit as u8);
351 let upper_on = (upper & mask) != 0;
352 let lower_on = (lower & mask) != 0;
353 let (ch, fg, bg) = match (upper_on, lower_on) {
354 (true, true) => ('█', on_color, on_color),
355 (true, false) => ('▀', on_color, Color::Reset),
356 (false, true) => ('▄', on_color, Color::Reset),
357 (false, false) => (' ', Color::Reset, Color::Reset),
358 };
359 buf.set_char(x, y, ch, Style::new().fg(fg).bg(bg));
360 }
361 }
362 }
363 });
364
365 Response::none()
366 }
367
368 pub fn image(&mut self, img: &HalfBlockImage) -> Response {
390 let width = img.width;
391 let height = img.height;
392
393 let _ = self.container().w(width).h(height).gap(0).col(|ui| {
394 for row in 0..height {
395 let _ = ui.container().gap(0).row(|ui| {
396 for col in 0..width {
397 let idx = (row * width + col) as usize;
398 if let Some(&(upper, lower)) = img.pixels.get(idx) {
399 ui.styled("▀", Style::new().fg(upper).bg(lower));
400 }
401 }
402 });
403 }
404 });
405
406 Response::none()
407 }
408
409 pub fn kitty_image(
425 &mut self,
426 rgba: &[u8],
427 pixel_width: u32,
428 pixel_height: u32,
429 cols: u32,
430 rows: u32,
431 ) -> Response {
432 let rgba_data = normalize_rgba(rgba, pixel_width, pixel_height);
433 let content_hash = crate::buffer::hash_rgba(&rgba_data);
434 let rgba_arc = std::sync::Arc::new(rgba_data);
435 let sw = pixel_width;
436 let sh = pixel_height;
437
438 self.container().w(cols).h(rows).draw(move |buf, rect| {
439 if rect.width == 0 || rect.height == 0 {
440 return;
441 }
442 buf.kitty_place(crate::buffer::KittyPlacement {
443 content_hash,
444 rgba: rgba_arc.clone(),
445 src_width: sw,
446 src_height: sh,
447 x: rect.x,
448 y: rect.y,
449 cols: rect.width,
450 rows: rect.height,
451 crop_y: 0,
452 crop_h: 0,
453 });
454 });
455 Response::none()
456 }
457
458 pub fn kitty_image_fit(
468 &mut self,
469 rgba: &[u8],
470 src_width: u32,
471 src_height: u32,
472 cols: u32,
473 ) -> Response {
474 #[cfg(feature = "crossterm")]
475 let (cell_w, cell_h) = crate::terminal::cell_pixel_size();
476 #[cfg(not(feature = "crossterm"))]
477 let (cell_w, cell_h) = (8u32, 16u32);
478
479 let rows = if src_width == 0 {
480 1
481 } else {
482 ((cols as f64 * src_height as f64 * cell_w as f64) / (src_width as f64 * cell_h as f64))
483 .ceil()
484 .max(1.0) as u32
485 };
486 let rgba_data = normalize_rgba(rgba, src_width, src_height);
487 let content_hash = crate::buffer::hash_rgba(&rgba_data);
488 let rgba_arc = std::sync::Arc::new(rgba_data);
489 let sw = src_width;
490 let sh = src_height;
491
492 self.container().w(cols).h(rows).draw(move |buf, rect| {
493 if rect.width == 0 || rect.height == 0 {
494 return;
495 }
496 buf.kitty_place(crate::buffer::KittyPlacement {
497 content_hash,
498 rgba: rgba_arc.clone(),
499 src_width: sw,
500 src_height: sh,
501 x: rect.x,
502 y: rect.y,
503 cols: rect.width,
504 rows: rect.height,
505 crop_y: 0,
506 crop_h: 0,
507 });
508 });
509 Response::none()
510 }
511
512 #[cfg(feature = "crossterm")]
531 pub fn sixel_image(
532 &mut self,
533 rgba: &[u8],
534 pixel_w: u32,
535 pixel_h: u32,
536 cols: u32,
537 rows: u32,
538 ) -> Response {
539 let sixel_supported = self.is_real_terminal && terminal_supports_sixel();
540 if !sixel_supported {
541 self.container().w(cols).h(rows).draw(|buf, rect| {
542 if rect.width == 0 || rect.height == 0 {
543 return;
544 }
545 buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
546 });
547 return Response::none();
548 }
549
550 let rgba = normalize_rgba(rgba, pixel_w, pixel_h);
551 let encoded = crate::sixel::encode_sixel(&rgba, pixel_w, pixel_h, 256);
552
553 if encoded.is_empty() {
554 self.container().w(cols).h(rows).draw(|buf, rect| {
555 if rect.width == 0 || rect.height == 0 {
556 return;
557 }
558 buf.set_string(rect.x, rect.y, "[sixel empty]", Style::new());
559 });
560 return Response::none();
561 }
562
563 self.container().w(cols).h(rows).draw(move |buf, rect| {
564 if rect.width == 0 || rect.height == 0 {
565 return;
566 }
567 buf.raw_sequence(rect.x, rect.y, encoded);
568 });
569 Response::none()
570 }
571
572 #[cfg(not(feature = "crossterm"))]
574 pub fn sixel_image(
575 &mut self,
576 _rgba: &[u8],
577 _pixel_w: u32,
578 _pixel_h: u32,
579 cols: u32,
580 rows: u32,
581 ) -> Response {
582 self.container().w(cols).h(rows).draw(|buf, rect| {
583 if rect.width == 0 || rect.height == 0 {
584 return;
585 }
586 buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
587 });
588 Response::none()
589 }
590
591 pub fn streaming_text(&mut self, state: &mut StreamingTextState) -> Response {
607 if state.streaming {
608 state.cursor_tick = state.cursor_tick.wrapping_add(1);
609 state.cursor_visible = (state.cursor_tick / 8) % 2 == 0;
610 }
611
612 if state.content.is_empty() && state.streaming {
613 let cursor = if state.cursor_visible { "▌" } else { " " };
614 let primary = self.theme.primary;
615 self.text(cursor).fg(primary);
616 return Response::none();
617 }
618
619 if !state.content.is_empty() {
620 if state.streaming && state.cursor_visible {
621 self.text(format!("{}▌", state.content)).wrap();
622 } else {
623 self.text(&state.content).wrap();
624 }
625 }
626
627 Response::none()
628 }
629
630 pub fn streaming_markdown(
648 &mut self,
649 state: &mut crate::widgets::StreamingMarkdownState,
650 ) -> Response {
651 if state.streaming {
652 state.cursor_tick = state.cursor_tick.wrapping_add(1);
653 state.cursor_visible = (state.cursor_tick / 8) % 2 == 0;
654 }
655
656 if state.content.is_empty() && state.streaming {
657 let cursor = if state.cursor_visible { "▌" } else { " " };
658 let primary = self.theme.primary;
659 self.text(cursor).fg(primary);
660 return Response::none();
661 }
662
663 let show_cursor = state.streaming && state.cursor_visible;
664 let trailing_newline = state.content.ends_with('\n');
665 let lines: Vec<&str> = state.content.lines().collect();
666 let last_line_index = lines.len().saturating_sub(1);
667
668 self.commands.push(Command::BeginContainer {
669 direction: Direction::Column,
670 gap: 0,
671 align: Align::Start,
672 align_self: None,
673 justify: Justify::Start,
674 border: None,
675 border_sides: BorderSides::all(),
676 border_style: Style::new().fg(self.theme.border),
677 bg_color: None,
678 padding: Padding::default(),
679 margin: Margin::default(),
680 constraints: Constraints::default(),
681 title: None,
682 grow: 0,
683 group_name: None,
684 });
685 self.interaction_count += 1;
686
687 let text_style = Style::new().fg(self.theme.text);
688 let bold_style = Style::new().fg(self.theme.text).bold();
689 let code_style = Style::new().fg(self.theme.accent);
690 let border_style = Style::new().fg(self.theme.border).dim();
691
692 let mut in_code_block = false;
693 let mut code_block_lang = String::new();
694
695 for (idx, line) in lines.iter().enumerate() {
696 let line = *line;
697 let trimmed = line.trim();
698 let append_cursor = show_cursor && !trailing_newline && idx == last_line_index;
699 let cursor = if append_cursor { "▌" } else { "" };
700
701 if in_code_block {
702 if trimmed.starts_with("```") {
703 in_code_block = false;
704 code_block_lang.clear();
705 let mut line = String::from(" └────");
706 line.push_str(cursor);
707 self.styled(line, border_style);
708 } else {
709 self.line(|ui| {
710 ui.text(" ");
711 render_highlighted_line(ui, line);
712 if !cursor.is_empty() {
713 ui.styled(cursor, Style::new().fg(ui.theme.primary));
714 }
715 });
716 }
717 continue;
718 }
719
720 if trimmed.is_empty() {
721 if append_cursor {
722 self.styled("▌", Style::new().fg(self.theme.primary));
723 } else {
724 self.text(" ");
725 }
726 continue;
727 }
728
729 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
730 let mut line = "─".repeat(40);
731 line.push_str(cursor);
732 self.styled(line, border_style);
733 continue;
734 }
735
736 if let Some(heading) = trimmed.strip_prefix("### ") {
737 let mut line = String::with_capacity(heading.len() + cursor.len());
738 line.push_str(heading);
739 line.push_str(cursor);
740 self.styled(line, Style::new().bold().fg(self.theme.accent));
741 continue;
742 }
743
744 if let Some(heading) = trimmed.strip_prefix("## ") {
745 let mut line = String::with_capacity(heading.len() + cursor.len());
746 line.push_str(heading);
747 line.push_str(cursor);
748 self.styled(line, Style::new().bold().fg(self.theme.secondary));
749 continue;
750 }
751
752 if let Some(heading) = trimmed.strip_prefix("# ") {
753 let mut line = String::with_capacity(heading.len() + cursor.len());
754 line.push_str(heading);
755 line.push_str(cursor);
756 self.styled(line, Style::new().bold().fg(self.theme.primary));
757 continue;
758 }
759
760 if let Some(code) = trimmed.strip_prefix("```") {
761 in_code_block = true;
762 code_block_lang = code.trim().to_string();
763 let label = if code_block_lang.is_empty() {
764 "code".to_string()
765 } else {
766 let mut label = String::from("code:");
767 label.push_str(&code_block_lang);
768 label
769 };
770 let mut line = String::with_capacity(5 + label.len() + cursor.len());
771 line.push_str(" ┌─");
772 line.push_str(&label);
773 line.push('─');
774 line.push_str(cursor);
775 self.styled(line, border_style);
776 continue;
777 }
778
779 if let Some(item) = trimmed
780 .strip_prefix("- ")
781 .or_else(|| trimmed.strip_prefix("* "))
782 {
783 let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
784 if segs.len() <= 1 {
785 let mut line = String::with_capacity(4 + item.len() + cursor.len());
786 line.push_str(" • ");
787 line.push_str(item);
788 line.push_str(cursor);
789 self.styled(line, text_style);
790 } else {
791 self.line(|ui| {
792 ui.styled(" • ", text_style);
793 for (s, st) in segs {
794 ui.styled(s, st);
795 }
796 if append_cursor {
797 ui.styled("▌", Style::new().fg(ui.theme.primary));
798 }
799 });
800 }
801 continue;
802 }
803
804 if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
805 let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
806 if parts.len() == 2 {
807 let segs =
808 Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
809 if segs.len() <= 1 {
810 let mut line = String::with_capacity(
811 4 + parts[0].len() + parts[1].len() + cursor.len(),
812 );
813 line.push_str(" ");
814 line.push_str(parts[0]);
815 line.push_str(". ");
816 line.push_str(parts[1]);
817 line.push_str(cursor);
818 self.styled(line, text_style);
819 } else {
820 self.line(|ui| {
821 let mut prefix = String::with_capacity(4 + parts[0].len());
822 prefix.push_str(" ");
823 prefix.push_str(parts[0]);
824 prefix.push_str(". ");
825 ui.styled(prefix, text_style);
826 for (s, st) in segs {
827 ui.styled(s, st);
828 }
829 if append_cursor {
830 ui.styled("▌", Style::new().fg(ui.theme.primary));
831 }
832 });
833 }
834 } else {
835 let mut line = String::with_capacity(trimmed.len() + cursor.len());
836 line.push_str(trimmed);
837 line.push_str(cursor);
838 self.styled(line, text_style);
839 }
840 continue;
841 }
842
843 let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
844 if segs.len() <= 1 {
845 let mut line = String::with_capacity(trimmed.len() + cursor.len());
846 line.push_str(trimmed);
847 line.push_str(cursor);
848 self.styled(line, text_style);
849 } else {
850 self.line(|ui| {
851 for (s, st) in segs {
852 ui.styled(s, st);
853 }
854 if append_cursor {
855 ui.styled("▌", Style::new().fg(ui.theme.primary));
856 }
857 });
858 }
859 }
860
861 if show_cursor && trailing_newline {
862 if in_code_block {
863 self.styled(" ▌", code_style);
864 } else {
865 self.styled("▌", Style::new().fg(self.theme.primary));
866 }
867 }
868
869 state.in_code_block = in_code_block;
870 state.code_block_lang = code_block_lang;
871
872 self.commands.push(Command::EndContainer);
873 self.last_text_idx = None;
874 Response::none()
875 }
876
877 pub fn tool_approval(&mut self, state: &mut ToolApprovalState) -> Response {
892 let old_action = state.action;
893 let theme = self.theme;
894 let _ = self.bordered(Border::Rounded).col(|ui| {
895 let _ = ui.row(|ui| {
896 ui.text("⚡").fg(theme.warning);
897 ui.text(&state.tool_name).bold().fg(theme.primary);
898 });
899 ui.text(&state.description).dim();
900
901 if state.action == ApprovalAction::Pending {
902 let _ = ui.row(|ui| {
903 if ui.button("✓ Approve").clicked {
904 state.action = ApprovalAction::Approved;
905 }
906 if ui.button("✗ Reject").clicked {
907 state.action = ApprovalAction::Rejected;
908 }
909 });
910 } else {
911 let (label, color) = match state.action {
912 ApprovalAction::Approved => ("✓ Approved", theme.success),
913 ApprovalAction::Rejected => ("✗ Rejected", theme.error),
914 ApprovalAction::Pending => unreachable!(),
915 };
916 ui.text(label).fg(color).bold();
917 }
918 });
919
920 Response {
921 changed: state.action != old_action,
922 ..Response::none()
923 }
924 }
925
926 pub fn context_bar(&mut self, items: &[ContextItem]) -> Response {
939 if items.is_empty() {
940 return Response::none();
941 }
942
943 let theme = self.theme;
944 let total: usize = items.iter().map(|item| item.tokens).sum();
945
946 let _ = self.container().row(|ui| {
947 ui.text("📎").dim();
948 for item in items {
949 let token_count = format_token_count(item.tokens);
950 let mut line = String::with_capacity(item.label.len() + token_count.len() + 3);
951 line.push_str(&item.label);
952 line.push_str(" (");
953 line.push_str(&token_count);
954 line.push(')');
955 ui.text(line).fg(theme.secondary);
956 }
957 ui.spacer();
958 let total_text = format_token_count(total);
959 let mut line = String::with_capacity(2 + total_text.len());
960 line.push_str("Σ ");
961 line.push_str(&total_text);
962 ui.text(line).dim();
963 });
964
965 Response::none()
966 }
967
968 pub fn alert(&mut self, message: &str, level: crate::widgets::AlertLevel) -> Response {
970 use crate::widgets::AlertLevel;
971
972 let theme = self.theme;
973 let (icon, color) = match level {
974 AlertLevel::Info => ("ℹ", theme.accent),
975 AlertLevel::Success => ("✓", theme.success),
976 AlertLevel::Warning => ("⚠", theme.warning),
977 AlertLevel::Error => ("✕", theme.error),
978 };
979
980 let focused = self.register_focusable();
981 let key_dismiss = focused && (self.key_code(KeyCode::Enter) || self.key('x'));
982
983 let mut response = self.container().col(|ui| {
984 ui.line(|ui| {
985 let mut icon_text = String::with_capacity(icon.len() + 2);
986 icon_text.push(' ');
987 icon_text.push_str(icon);
988 icon_text.push(' ');
989 ui.text(icon_text).fg(color).bold();
990 ui.text(message).grow(1);
991 ui.text(" [×] ").dim();
992 });
993 });
994 response.focused = focused;
995 if key_dismiss {
996 response.clicked = true;
997 }
998
999 response
1000 }
1001
1002 pub fn confirm(&mut self, question: &str, result: &mut bool) -> Response {
1016 let focused = self.register_focusable();
1017 let mut is_yes = *result;
1018 let mut clicked = false;
1019
1020 if focused {
1021 let mut consumed_indices = Vec::new();
1022 for (i, event) in self.events.iter().enumerate() {
1023 if let Event::Key(key) = event {
1024 if key.kind != KeyEventKind::Press {
1025 continue;
1026 }
1027
1028 match key.code {
1029 KeyCode::Char('y') => {
1030 is_yes = true;
1031 *result = true;
1032 clicked = true;
1033 consumed_indices.push(i);
1034 }
1035 KeyCode::Char('n') => {
1036 is_yes = false;
1037 *result = false;
1038 clicked = true;
1039 consumed_indices.push(i);
1040 }
1041 KeyCode::Tab | KeyCode::BackTab | KeyCode::Left | KeyCode::Right => {
1042 is_yes = !is_yes;
1043 *result = is_yes;
1044 consumed_indices.push(i);
1045 }
1046 KeyCode::Enter => {
1047 *result = is_yes;
1048 clicked = true;
1049 consumed_indices.push(i);
1050 }
1051 _ => {}
1052 }
1053 }
1054 }
1055
1056 for idx in consumed_indices {
1057 self.consumed[idx] = true;
1058 }
1059 }
1060
1061 let yes_style = if is_yes {
1062 if focused {
1063 Style::new().fg(self.theme.bg).bg(self.theme.success).bold()
1064 } else {
1065 Style::new().fg(self.theme.success).bold()
1066 }
1067 } else {
1068 Style::new().fg(self.theme.text_dim)
1069 };
1070 let no_style = if !is_yes {
1071 if focused {
1072 Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
1073 } else {
1074 Style::new().fg(self.theme.error).bold()
1075 }
1076 } else {
1077 Style::new().fg(self.theme.text_dim)
1078 };
1079
1080 let q_width = UnicodeWidthStr::width(question) as u32;
1081 let mut response = self.row(|ui| {
1082 ui.text(question);
1083 ui.text(" ");
1084 ui.styled("[Yes]", yes_style);
1085 ui.text(" ");
1086 ui.styled("[No]", no_style);
1087 });
1088
1089 if !clicked && response.clicked {
1090 if let Some((mx, _)) = self.click_pos {
1091 let yes_start = response.rect.x + q_width + 1;
1092 let yes_end = yes_start + 5;
1093 let no_start = yes_end + 1;
1094 if mx >= yes_start && mx < yes_end {
1095 is_yes = true;
1096 *result = true;
1097 clicked = true;
1098 } else if mx >= no_start {
1099 is_yes = false;
1100 *result = false;
1101 clicked = true;
1102 }
1103 }
1104 }
1105
1106 response.focused = focused;
1107 response.clicked = clicked;
1108 response.changed = clicked;
1109 let _ = is_yes;
1110 response
1111 }
1112
1113 pub fn breadcrumb(&mut self, segments: &[&str]) -> Option<usize> {
1115 self.breadcrumb_with(segments, " › ")
1116 }
1117
1118 pub fn breadcrumb_with(&mut self, segments: &[&str], separator: &str) -> Option<usize> {
1120 let theme = self.theme;
1121 let last_idx = segments.len().saturating_sub(1);
1122 let mut clicked_idx: Option<usize> = None;
1123
1124 let _ = self.row(|ui| {
1125 for (i, segment) in segments.iter().enumerate() {
1126 let is_last = i == last_idx;
1127 if is_last {
1128 ui.text(*segment).bold();
1129 } else {
1130 let focused = ui.register_focusable();
1131 let pressed = focused && (ui.key_code(KeyCode::Enter) || ui.key(' '));
1132 let resp = ui.interaction();
1133 let color = if resp.hovered || focused {
1134 theme.accent
1135 } else {
1136 theme.primary
1137 };
1138 ui.text(*segment).fg(color).underline();
1139 if resp.clicked || pressed {
1140 clicked_idx = Some(i);
1141 }
1142 ui.text(separator).dim();
1143 }
1144 }
1145 });
1146
1147 clicked_idx
1148 }
1149
1150 pub fn accordion(
1152 &mut self,
1153 title: &str,
1154 open: &mut bool,
1155 f: impl FnOnce(&mut Context),
1156 ) -> Response {
1157 let theme = self.theme;
1158 let focused = self.register_focusable();
1159 let old_open = *open;
1160
1161 if focused && self.key_code(KeyCode::Enter) {
1162 *open = !*open;
1163 }
1164
1165 let icon = if *open { "▾" } else { "▸" };
1166 let title_color = if focused { theme.primary } else { theme.text };
1167
1168 let mut response = self.container().col(|ui| {
1169 ui.line(|ui| {
1170 ui.text(icon).fg(title_color);
1171 let mut title_text = String::with_capacity(1 + title.len());
1172 title_text.push(' ');
1173 title_text.push_str(title);
1174 ui.text(title_text).bold().fg(title_color);
1175 });
1176 });
1177
1178 if response.clicked {
1179 *open = !*open;
1180 }
1181
1182 if *open {
1183 let _ = self.container().pl(2).col(f);
1184 }
1185
1186 response.focused = focused;
1187 response.changed = *open != old_open;
1188 response
1189 }
1190
1191 pub fn definition_list(&mut self, items: &[(&str, &str)]) -> Response {
1193 let max_key_width = items
1194 .iter()
1195 .map(|(k, _)| unicode_width::UnicodeWidthStr::width(*k))
1196 .max()
1197 .unwrap_or(0);
1198
1199 let _ = self.col(|ui| {
1200 for (key, value) in items {
1201 ui.line(|ui| {
1202 let padded = format!("{:>width$}", key, width = max_key_width);
1203 ui.text(padded).dim();
1204 ui.text(" ");
1205 ui.text(*value);
1206 });
1207 }
1208 });
1209
1210 Response::none()
1211 }
1212
1213 pub fn divider_text(&mut self, label: &str) -> Response {
1215 let w = self.width();
1216 let label_len = unicode_width::UnicodeWidthStr::width(label) as u32;
1217 let pad = 1u32;
1218 let left_len = 4u32;
1219 let right_len = w.saturating_sub(left_len + pad + label_len + pad);
1220 let left: String = "─".repeat(left_len as usize);
1221 let right: String = "─".repeat(right_len as usize);
1222 let theme = self.theme;
1223 self.line(|ui| {
1224 ui.text(&left).fg(theme.border);
1225 let mut label_text = String::with_capacity(label.len() + 2);
1226 label_text.push(' ');
1227 label_text.push_str(label);
1228 label_text.push(' ');
1229 ui.text(label_text).fg(theme.text);
1230 ui.text(&right).fg(theme.border);
1231 });
1232
1233 Response::none()
1234 }
1235
1236 pub fn badge(&mut self, label: &str) -> Response {
1238 let theme = self.theme;
1239 self.badge_colored(label, theme.primary)
1240 }
1241
1242 pub fn badge_colored(&mut self, label: &str, color: Color) -> Response {
1244 let fg = Color::contrast_fg(color);
1245 let mut label_text = String::with_capacity(label.len() + 2);
1246 label_text.push(' ');
1247 label_text.push_str(label);
1248 label_text.push(' ');
1249 self.text(label_text).fg(fg).bg(color);
1250
1251 Response::none()
1252 }
1253
1254 pub fn key_hint(&mut self, key: &str) -> Response {
1256 let theme = self.theme;
1257 let mut key_text = String::with_capacity(key.len() + 2);
1258 key_text.push(' ');
1259 key_text.push_str(key);
1260 key_text.push(' ');
1261 self.text(key_text).reversed().fg(theme.text_dim);
1262
1263 Response::none()
1264 }
1265
1266 pub fn stat(&mut self, label: &str, value: &str) -> Response {
1268 let _ = self.col(|ui| {
1269 ui.text(label).dim();
1270 ui.text(value).bold();
1271 });
1272
1273 Response::none()
1274 }
1275
1276 pub fn stat_colored(&mut self, label: &str, value: &str, color: Color) -> Response {
1278 let _ = self.col(|ui| {
1279 ui.text(label).dim();
1280 ui.text(value).bold().fg(color);
1281 });
1282
1283 Response::none()
1284 }
1285
1286 pub fn stat_trend(
1288 &mut self,
1289 label: &str,
1290 value: &str,
1291 trend: crate::widgets::Trend,
1292 ) -> Response {
1293 let theme = self.theme;
1294 let (arrow, color) = match trend {
1295 crate::widgets::Trend::Up => ("↑", theme.success),
1296 crate::widgets::Trend::Down => ("↓", theme.error),
1297 };
1298 let _ = self.col(|ui| {
1299 ui.text(label).dim();
1300 ui.line(|ui| {
1301 ui.text(value).bold();
1302 let mut arrow_text = String::with_capacity(1 + arrow.len());
1303 arrow_text.push(' ');
1304 arrow_text.push_str(arrow);
1305 ui.text(arrow_text).fg(color);
1306 });
1307 });
1308
1309 Response::none()
1310 }
1311
1312 pub fn empty_state(&mut self, title: &str, description: &str) -> Response {
1314 let _ = self.container().center().col(|ui| {
1315 ui.text(title).align(Align::Center);
1316 ui.text(description).dim().align(Align::Center);
1317 });
1318
1319 Response::none()
1320 }
1321
1322 pub fn empty_state_action(
1324 &mut self,
1325 title: &str,
1326 description: &str,
1327 action_label: &str,
1328 ) -> Response {
1329 let mut clicked = false;
1330 let _ = self.container().center().col(|ui| {
1331 ui.text(title).align(Align::Center);
1332 ui.text(description).dim().align(Align::Center);
1333 if ui.button(action_label).clicked {
1334 clicked = true;
1335 }
1336 });
1337
1338 Response {
1339 clicked,
1340 changed: clicked,
1341 ..Response::none()
1342 }
1343 }
1344
1345 pub fn code_block(&mut self, code: &str) -> Response {
1347 self.code_block_lang(code, "")
1348 }
1349
1350 pub fn code_block_lang(&mut self, code: &str, lang: &str) -> Response {
1352 let theme = self.theme;
1353 let highlighted: Option<Vec<Vec<(String, Style)>>> =
1354 crate::syntax::highlight_code(code, lang, &theme);
1355 let _ = self
1356 .bordered(Border::Rounded)
1357 .bg(theme.surface)
1358 .pad(1)
1359 .col(|ui| {
1360 if let Some(ref lines) = highlighted {
1361 render_tree_sitter_lines(ui, lines);
1362 } else {
1363 for line in code.lines() {
1364 render_highlighted_line(ui, line);
1365 }
1366 }
1367 });
1368
1369 Response::none()
1370 }
1371
1372 pub fn code_block_numbered(&mut self, code: &str) -> Response {
1374 self.code_block_numbered_lang(code, "")
1375 }
1376
1377 pub fn code_block_numbered_lang(&mut self, code: &str, lang: &str) -> Response {
1379 let lines: Vec<&str> = code.lines().collect();
1380 let gutter_w = format!("{}", lines.len()).len();
1381 let theme = self.theme;
1382 let highlighted: Option<Vec<Vec<(String, Style)>>> =
1383 crate::syntax::highlight_code(code, lang, &theme);
1384 let _ = self
1385 .bordered(Border::Rounded)
1386 .bg(theme.surface)
1387 .pad(1)
1388 .col(|ui| {
1389 if let Some(ref hl_lines) = highlighted {
1390 for (i, segs) in hl_lines.iter().enumerate() {
1391 ui.line(|ui| {
1392 ui.text(format!("{:>gutter_w$} │ ", i + 1))
1393 .fg(theme.text_dim);
1394 for (text, style) in segs {
1395 ui.styled(text, *style);
1396 }
1397 });
1398 }
1399 } else {
1400 for (i, line) in lines.iter().enumerate() {
1401 ui.line(|ui| {
1402 ui.text(format!("{:>gutter_w$} │ ", i + 1))
1403 .fg(theme.text_dim);
1404 render_highlighted_line(ui, line);
1405 });
1406 }
1407 }
1408 });
1409
1410 Response::none()
1411 }
1412
1413 pub fn wrap(&mut self) -> &mut Self {
1415 if let Some(idx) = self.last_text_idx {
1416 if let Command::Text { wrap, .. } = &mut self.commands[idx] {
1417 *wrap = true;
1418 }
1419 }
1420 self
1421 }
1422
1423 pub fn truncate(&mut self) -> &mut Self {
1426 if let Some(idx) = self.last_text_idx {
1427 if let Command::Text { truncate, .. } = &mut self.commands[idx] {
1428 *truncate = true;
1429 }
1430 }
1431 self
1432 }
1433
1434 fn modify_last_style(&mut self, f: impl FnOnce(&mut Style)) {
1435 if let Some(idx) = self.last_text_idx {
1436 match &mut self.commands[idx] {
1437 Command::Text { style, .. } | Command::Link { style, .. } => f(style),
1438 _ => {}
1439 }
1440 }
1441 }
1442
1443 fn modify_last_constraints(&mut self, f: impl FnOnce(&mut Constraints)) {
1444 if let Some(idx) = self.last_text_idx {
1445 match &mut self.commands[idx] {
1446 Command::Text { constraints, .. } | Command::Link { constraints, .. } => {
1447 f(constraints)
1448 }
1449 _ => {}
1450 }
1451 }
1452 }
1453
1454 fn modify_last_margin(&mut self, f: impl FnOnce(&mut Margin)) {
1455 if let Some(idx) = self.last_text_idx {
1456 match &mut self.commands[idx] {
1457 Command::Text { margin, .. } | Command::Link { margin, .. } => f(margin),
1458 _ => {}
1459 }
1460 }
1461 }
1462
1463 pub fn screen(&mut self, name: &str, screens: &ScreenState, f: impl FnOnce(&mut Context)) {
1467 if screens.current() == name {
1468 f(self);
1469 }
1470 }
1471
1472 pub fn col(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1488 self.push_container(Direction::Column, 0, f)
1489 }
1490
1491 pub fn col_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
1495 self.push_container(Direction::Column, gap, f)
1496 }
1497
1498 pub fn row(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1515 self.push_container(Direction::Row, 0, f)
1516 }
1517
1518 pub fn row_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
1522 self.push_container(Direction::Row, gap, f)
1523 }
1524
1525 pub fn line(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
1542 let _ = self.push_container(Direction::Row, 0, f);
1543 self
1544 }
1545
1546 pub fn line_wrap(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
1565 let start = self.commands.len();
1566 f(self);
1567 let mut segments: Vec<(String, Style)> = Vec::new();
1568 for cmd in self.commands.drain(start..) {
1569 match cmd {
1570 Command::Text { content, style, .. } => {
1571 segments.push((content, style));
1572 }
1573 Command::Link { text, style, .. } => {
1574 segments.push((text, style));
1577 }
1578 _ => {}
1579 }
1580 }
1581 self.commands.push(Command::RichText {
1582 segments,
1583 wrap: true,
1584 align: Align::Start,
1585 margin: Margin::default(),
1586 constraints: Constraints::default(),
1587 });
1588 self.last_text_idx = None;
1589 self
1590 }
1591
1592 pub fn modal(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1601 let interaction_id = self.next_interaction_id();
1602 self.commands.push(Command::BeginOverlay { modal: true });
1603 self.overlay_depth += 1;
1604 self.modal_active = true;
1605 self.modal_focus_start = self.focus_count;
1606 f(self);
1607 self.modal_focus_count = self.focus_count.saturating_sub(self.modal_focus_start);
1608 self.overlay_depth = self.overlay_depth.saturating_sub(1);
1609 self.commands.push(Command::EndOverlay);
1610 self.last_text_idx = None;
1611 self.response_for(interaction_id)
1612 }
1613
1614 pub fn overlay(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1616 let interaction_id = self.next_interaction_id();
1617 self.commands.push(Command::BeginOverlay { modal: false });
1618 self.overlay_depth += 1;
1619 f(self);
1620 self.overlay_depth = self.overlay_depth.saturating_sub(1);
1621 self.commands.push(Command::EndOverlay);
1622 self.last_text_idx = None;
1623 self.response_for(interaction_id)
1624 }
1625
1626 pub fn tooltip(&mut self, text: impl Into<String>) {
1634 let tooltip_text = text.into();
1635 if tooltip_text.is_empty() {
1636 return;
1637 }
1638 let last_interaction_id = self.interaction_count.saturating_sub(1);
1639 let last_response = self.response_for(last_interaction_id);
1640 if !last_response.hovered || last_response.rect.width == 0 || last_response.rect.height == 0
1641 {
1642 return;
1643 }
1644 let lines = wrap_tooltip_text(&tooltip_text, 38);
1645 self.pending_tooltips.push(PendingTooltip {
1646 anchor_rect: last_response.rect,
1647 lines,
1648 });
1649 }
1650
1651 pub(crate) fn emit_pending_tooltips(&mut self) {
1652 let tooltips = std::mem::take(&mut self.pending_tooltips);
1653 if tooltips.is_empty() {
1654 return;
1655 }
1656 let area_w = self.area_width;
1657 let area_h = self.area_height;
1658 let surface = self.theme.surface;
1659 let border_color = self.theme.border;
1660 let text_color = self.theme.surface_text;
1661
1662 for tooltip in tooltips {
1663 let content_w = tooltip
1664 .lines
1665 .iter()
1666 .map(|l| UnicodeWidthStr::width(l.as_str()) as u32)
1667 .max()
1668 .unwrap_or(0);
1669 let box_w = content_w.saturating_add(4).min(area_w);
1670 let box_h = (tooltip.lines.len() as u32).saturating_add(4).min(area_h);
1671
1672 let tooltip_x = tooltip.anchor_rect.x.min(area_w.saturating_sub(box_w));
1673 let below_y = tooltip.anchor_rect.bottom();
1674 let tooltip_y = if below_y.saturating_add(box_h) <= area_h {
1675 below_y
1676 } else {
1677 tooltip.anchor_rect.y.saturating_sub(box_h)
1678 };
1679
1680 let lines = tooltip.lines;
1681 let _ = self.overlay(|ui| {
1682 let _ = ui.container().w(area_w).h(area_h).col(|ui| {
1683 let _ = ui
1684 .container()
1685 .ml(tooltip_x)
1686 .mt(tooltip_y)
1687 .max_w(box_w)
1688 .border(Border::Rounded)
1689 .border_fg(border_color)
1690 .bg(surface)
1691 .p(1)
1692 .col(|ui| {
1693 for line in &lines {
1694 ui.text(line.as_str()).fg(text_color);
1695 }
1696 });
1697 });
1698 });
1699 }
1700 }
1701
1702 pub fn group(&mut self, name: &str) -> ContainerBuilder<'_> {
1710 self.group_count = self.group_count.saturating_add(1);
1711 self.group_stack.push(name.to_string());
1712 self.container().group_name(name.to_string())
1713 }
1714
1715 pub fn container(&mut self) -> ContainerBuilder<'_> {
1736 let border = self.theme.border;
1737 ContainerBuilder {
1738 ctx: self,
1739 gap: 0,
1740 row_gap: None,
1741 col_gap: None,
1742 align: Align::Start,
1743 align_self_value: None,
1744 justify: Justify::Start,
1745 border: None,
1746 border_sides: BorderSides::all(),
1747 border_style: Style::new().fg(border),
1748 bg: None,
1749 text_color: None,
1750 dark_bg: None,
1751 dark_border_style: None,
1752 group_hover_bg: None,
1753 group_hover_border_style: None,
1754 group_name: None,
1755 padding: Padding::default(),
1756 margin: Margin::default(),
1757 constraints: Constraints::default(),
1758 title: None,
1759 grow: 0,
1760 scroll_offset: None,
1761 }
1762 }
1763
1764 pub fn scrollable(&mut self, state: &mut ScrollState) -> ContainerBuilder<'_> {
1783 let index = self.scroll_count;
1784 self.scroll_count += 1;
1785 if let Some(&(ch, vh)) = self.prev_scroll_infos.get(index) {
1786 state.set_bounds(ch, vh);
1787 let max = ch.saturating_sub(vh) as usize;
1788 state.offset = state.offset.min(max);
1789 }
1790
1791 let next_id = self.interaction_count;
1792 if let Some(rect) = self.prev_hit_map.get(next_id).copied() {
1793 let inner_rects: Vec<Rect> = self
1794 .prev_scroll_rects
1795 .iter()
1796 .enumerate()
1797 .filter(|&(j, sr)| {
1798 j != index
1799 && sr.width > 0
1800 && sr.height > 0
1801 && sr.x >= rect.x
1802 && sr.right() <= rect.right()
1803 && sr.y >= rect.y
1804 && sr.bottom() <= rect.bottom()
1805 })
1806 .map(|(_, sr)| *sr)
1807 .collect();
1808 self.auto_scroll_nested(&rect, state, &inner_rects);
1809 }
1810
1811 self.container().scroll_offset(state.offset as u32)
1812 }
1813
1814 pub fn scrollbar(&mut self, state: &ScrollState) {
1834 let vh = state.viewport_height();
1835 let ch = state.content_height();
1836 if vh == 0 || ch <= vh {
1837 return;
1838 }
1839
1840 let track_height = vh;
1841 let thumb_height = ((vh as f64 * vh as f64 / ch as f64).ceil() as u32).max(1);
1842 let max_offset = ch.saturating_sub(vh);
1843 let thumb_pos = if max_offset == 0 {
1844 0
1845 } else {
1846 ((state.offset as f64 / max_offset as f64) * (track_height - thumb_height) as f64)
1847 .round() as u32
1848 };
1849
1850 let theme = self.theme;
1851 let track_char = '│';
1852 let thumb_char = '█';
1853
1854 let _ = self.container().w(1).h(track_height).col(|ui| {
1855 for i in 0..track_height {
1856 if i >= thumb_pos && i < thumb_pos + thumb_height {
1857 ui.styled(thumb_char.to_string(), Style::new().fg(theme.primary));
1858 } else {
1859 ui.styled(
1860 track_char.to_string(),
1861 Style::new().fg(theme.text_dim).dim(),
1862 );
1863 }
1864 }
1865 });
1866 }
1867
1868 fn auto_scroll_nested(
1869 &mut self,
1870 rect: &Rect,
1871 state: &mut ScrollState,
1872 inner_scroll_rects: &[Rect],
1873 ) {
1874 let mut to_consume: Vec<usize> = Vec::new();
1875
1876 for (i, event) in self.events.iter().enumerate() {
1877 if self.consumed[i] {
1878 continue;
1879 }
1880 if let Event::Mouse(mouse) = event {
1881 let in_bounds = mouse.x >= rect.x
1882 && mouse.x < rect.right()
1883 && mouse.y >= rect.y
1884 && mouse.y < rect.bottom();
1885 if !in_bounds {
1886 continue;
1887 }
1888 let in_inner = inner_scroll_rects.iter().any(|sr| {
1889 mouse.x >= sr.x
1890 && mouse.x < sr.right()
1891 && mouse.y >= sr.y
1892 && mouse.y < sr.bottom()
1893 });
1894 if in_inner {
1895 continue;
1896 }
1897 let delta = self.scroll_lines_per_event as usize;
1898 match mouse.kind {
1899 MouseKind::ScrollUp => {
1900 state.scroll_up(delta);
1901 to_consume.push(i);
1902 }
1903 MouseKind::ScrollDown => {
1904 state.scroll_down(delta);
1905 to_consume.push(i);
1906 }
1907 MouseKind::Drag(MouseButton::Left) => {}
1908 _ => {}
1909 }
1910 }
1911 }
1912
1913 for i in to_consume {
1914 self.consumed[i] = true;
1915 }
1916 }
1917
1918 pub fn bordered(&mut self, border: Border) -> ContainerBuilder<'_> {
1922 self.container()
1923 .border(border)
1924 .border_sides(BorderSides::all())
1925 }
1926
1927 fn push_container(
1928 &mut self,
1929 direction: Direction,
1930 gap: u32,
1931 f: impl FnOnce(&mut Context),
1932 ) -> Response {
1933 let interaction_id = self.next_interaction_id();
1934 let border = self.theme.border;
1935
1936 self.commands.push(Command::BeginContainer {
1937 direction,
1938 gap,
1939 align: Align::Start,
1940 align_self: None,
1941 justify: Justify::Start,
1942 border: None,
1943 border_sides: BorderSides::all(),
1944 border_style: Style::new().fg(border),
1945 bg_color: None,
1946 padding: Padding::default(),
1947 margin: Margin::default(),
1948 constraints: Constraints::default(),
1949 title: None,
1950 grow: 0,
1951 group_name: None,
1952 });
1953 self.text_color_stack.push(None);
1954 f(self);
1955 self.text_color_stack.pop();
1956 self.commands.push(Command::EndContainer);
1957 self.last_text_idx = None;
1958
1959 self.response_for(interaction_id)
1960 }
1961
1962 pub(super) fn response_for(&self, interaction_id: usize) -> Response {
1963 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1964 return Response::none();
1965 }
1966 if let Some(rect) = self.prev_hit_map.get(interaction_id) {
1967 let clicked = self
1968 .click_pos
1969 .map(|(mx, my)| {
1970 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1971 })
1972 .unwrap_or(false);
1973 let hovered = self
1974 .mouse_pos
1975 .map(|(mx, my)| {
1976 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1977 })
1978 .unwrap_or(false);
1979 Response {
1980 clicked,
1981 hovered,
1982 changed: false,
1983 focused: false,
1984 rect: *rect,
1985 }
1986 } else {
1987 Response::none()
1988 }
1989 }
1990
1991 pub fn is_group_hovered(&self, name: &str) -> bool {
1993 if let Some(pos) = self.mouse_pos {
1994 self.prev_group_rects.iter().any(|(n, rect)| {
1995 n == name
1996 && pos.0 >= rect.x
1997 && pos.0 < rect.x + rect.width
1998 && pos.1 >= rect.y
1999 && pos.1 < rect.y + rect.height
2000 })
2001 } else {
2002 false
2003 }
2004 }
2005
2006 pub fn is_group_focused(&self, name: &str) -> bool {
2008 if self.prev_focus_count == 0 {
2009 return false;
2010 }
2011 let focused_index = self.focus_index % self.prev_focus_count;
2012 self.prev_focus_groups
2013 .get(focused_index)
2014 .and_then(|group| group.as_deref())
2015 .map(|group| group == name)
2016 .unwrap_or(false)
2017 }
2018
2019 pub fn grow(&mut self, value: u16) -> &mut Self {
2024 if let Some(idx) = self.last_text_idx {
2025 if let Command::Text { grow, .. } = &mut self.commands[idx] {
2026 *grow = value;
2027 }
2028 }
2029 self
2030 }
2031
2032 pub fn align(&mut self, align: Align) -> &mut Self {
2034 if let Some(idx) = self.last_text_idx {
2035 if let Command::Text {
2036 align: text_align, ..
2037 } = &mut self.commands[idx]
2038 {
2039 *text_align = align;
2040 }
2041 }
2042 self
2043 }
2044
2045 pub fn text_center(&mut self) -> &mut Self {
2049 self.align(Align::Center)
2050 }
2051
2052 pub fn text_right(&mut self) -> &mut Self {
2055 self.align(Align::End)
2056 }
2057
2058 pub fn w(&mut self, value: u32) -> &mut Self {
2065 self.modify_last_constraints(|c| {
2066 c.min_width = Some(value);
2067 c.max_width = Some(value);
2068 });
2069 self
2070 }
2071
2072 pub fn h(&mut self, value: u32) -> &mut Self {
2076 self.modify_last_constraints(|c| {
2077 c.min_height = Some(value);
2078 c.max_height = Some(value);
2079 });
2080 self
2081 }
2082
2083 pub fn min_w(&mut self, value: u32) -> &mut Self {
2085 self.modify_last_constraints(|c| c.min_width = Some(value));
2086 self
2087 }
2088
2089 pub fn max_w(&mut self, value: u32) -> &mut Self {
2091 self.modify_last_constraints(|c| c.max_width = Some(value));
2092 self
2093 }
2094
2095 pub fn min_h(&mut self, value: u32) -> &mut Self {
2097 self.modify_last_constraints(|c| c.min_height = Some(value));
2098 self
2099 }
2100
2101 pub fn max_h(&mut self, value: u32) -> &mut Self {
2103 self.modify_last_constraints(|c| c.max_height = Some(value));
2104 self
2105 }
2106
2107 pub fn m(&mut self, value: u32) -> &mut Self {
2111 self.modify_last_margin(|m| *m = Margin::all(value));
2112 self
2113 }
2114
2115 pub fn mx(&mut self, value: u32) -> &mut Self {
2117 self.modify_last_margin(|m| {
2118 m.left = value;
2119 m.right = value;
2120 });
2121 self
2122 }
2123
2124 pub fn my(&mut self, value: u32) -> &mut Self {
2126 self.modify_last_margin(|m| {
2127 m.top = value;
2128 m.bottom = value;
2129 });
2130 self
2131 }
2132
2133 pub fn mt(&mut self, value: u32) -> &mut Self {
2135 self.modify_last_margin(|m| m.top = value);
2136 self
2137 }
2138
2139 pub fn mr(&mut self, value: u32) -> &mut Self {
2141 self.modify_last_margin(|m| m.right = value);
2142 self
2143 }
2144
2145 pub fn mb(&mut self, value: u32) -> &mut Self {
2147 self.modify_last_margin(|m| m.bottom = value);
2148 self
2149 }
2150
2151 pub fn ml(&mut self, value: u32) -> &mut Self {
2153 self.modify_last_margin(|m| m.left = value);
2154 self
2155 }
2156
2157 pub fn spacer(&mut self) -> &mut Self {
2161 self.commands.push(Command::Spacer { grow: 1 });
2162 self.last_text_idx = None;
2163 self
2164 }
2165
2166 pub fn form(
2170 &mut self,
2171 state: &mut FormState,
2172 f: impl FnOnce(&mut Context, &mut FormState),
2173 ) -> &mut Self {
2174 let _ = self.col(|ui| {
2175 f(ui, state);
2176 });
2177 self
2178 }
2179
2180 pub fn form_field(&mut self, field: &mut FormField) -> &mut Self {
2184 let _ = self.col(|ui| {
2185 ui.styled(field.label.clone(), Style::new().bold().fg(ui.theme.text));
2186 let _ = ui.text_input(&mut field.input);
2187 if let Some(error) = field.error.as_deref() {
2188 ui.styled(error.to_string(), Style::new().dim().fg(ui.theme.error));
2189 }
2190 });
2191 self
2192 }
2193
2194 pub fn form_submit(&mut self, label: impl Into<String>) -> Response {
2198 self.button(label)
2199 }
2200}
2201
2202fn wrap_tooltip_text(text: &str, max_width: usize) -> Vec<String> {
2203 let max_width = max_width.max(1);
2204 let mut lines = Vec::new();
2205
2206 for paragraph in text.lines() {
2207 if paragraph.trim().is_empty() {
2208 lines.push(String::new());
2209 continue;
2210 }
2211
2212 let mut current = String::new();
2213 let mut current_width = 0usize;
2214
2215 for word in paragraph.split_whitespace() {
2216 for chunk in split_word_for_width(word, max_width) {
2217 let chunk_width = UnicodeWidthStr::width(chunk.as_str());
2218
2219 if current.is_empty() {
2220 current = chunk;
2221 current_width = chunk_width;
2222 continue;
2223 }
2224
2225 if current_width + 1 + chunk_width <= max_width {
2226 current.push(' ');
2227 current.push_str(&chunk);
2228 current_width += 1 + chunk_width;
2229 } else {
2230 lines.push(std::mem::take(&mut current));
2231 current = chunk;
2232 current_width = chunk_width;
2233 }
2234 }
2235 }
2236
2237 if !current.is_empty() {
2238 lines.push(current);
2239 }
2240 }
2241
2242 if lines.is_empty() {
2243 lines.push(String::new());
2244 }
2245
2246 lines
2247}
2248
2249fn split_word_for_width(word: &str, max_width: usize) -> Vec<String> {
2250 let mut chunks = Vec::new();
2251 let mut current = String::new();
2252 let mut current_width = 0usize;
2253
2254 for ch in word.chars() {
2255 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
2256 if !current.is_empty() && current_width + ch_width > max_width {
2257 chunks.push(std::mem::take(&mut current));
2258 current_width = 0;
2259 }
2260 current.push(ch);
2261 current_width += ch_width;
2262
2263 if current_width >= max_width {
2264 chunks.push(std::mem::take(&mut current));
2265 current_width = 0;
2266 }
2267 }
2268
2269 if !current.is_empty() {
2270 chunks.push(current);
2271 }
2272
2273 if chunks.is_empty() {
2274 chunks.push(String::new());
2275 }
2276
2277 chunks
2278}
2279
2280fn glyph_8x8(ch: char) -> [u8; 8] {
2281 if ch.is_ascii() {
2282 let code = ch as u8;
2283 if (32..=126).contains(&code) {
2284 return FONT_8X8_PRINTABLE[(code - 32) as usize];
2285 }
2286 }
2287
2288 FONT_8X8_PRINTABLE[(b'?' - 32) as usize]
2289}
2290
2291const FONT_8X8_PRINTABLE: [[u8; 8]; 95] = [
2292 [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
2293 [0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00],
2294 [0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
2295 [0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00],
2296 [0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00],
2297 [0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00],
2298 [0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00],
2299 [0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00],
2300 [0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00],
2301 [0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00],
2302 [0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00],
2303 [0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00],
2304 [0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06],
2305 [0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00],
2306 [0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00],
2307 [0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00],
2308 [0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00],
2309 [0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00],
2310 [0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00],
2311 [0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00],
2312 [0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00],
2313 [0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00],
2314 [0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00],
2315 [0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00],
2316 [0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00],
2317 [0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00],
2318 [0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00],
2319 [0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06],
2320 [0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00],
2321 [0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00],
2322 [0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00],
2323 [0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00],
2324 [0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00],
2325 [0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00],
2326 [0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00],
2327 [0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00],
2328 [0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00],
2329 [0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00],
2330 [0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00],
2331 [0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00],
2332 [0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00],
2333 [0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00],
2334 [0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00],
2335 [0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00],
2336 [0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00],
2337 [0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00],
2338 [0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00],
2339 [0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00],
2340 [0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00],
2341 [0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00],
2342 [0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00],
2343 [0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00],
2344 [0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00],
2345 [0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00],
2346 [0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00],
2347 [0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00],
2348 [0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00],
2349 [0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00],
2350 [0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00],
2351 [0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00],
2352 [0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00],
2353 [0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00],
2354 [0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00],
2355 [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF],
2356 [0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00],
2357 [0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00],
2358 [0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00],
2359 [0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00],
2360 [0x38, 0x30, 0x30, 0x3E, 0x33, 0x33, 0x6E, 0x00],
2361 [0x00, 0x00, 0x1E, 0x33, 0x3F, 0x03, 0x1E, 0x00],
2362 [0x1C, 0x36, 0x06, 0x0F, 0x06, 0x06, 0x0F, 0x00],
2363 [0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F],
2364 [0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00],
2365 [0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00],
2366 [0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E],
2367 [0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00],
2368 [0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00],
2369 [0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00],
2370 [0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00],
2371 [0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00],
2372 [0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F],
2373 [0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78],
2374 [0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00],
2375 [0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00],
2376 [0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00],
2377 [0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00],
2378 [0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00],
2379 [0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00],
2380 [0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00],
2381 [0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F],
2382 [0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00],
2383 [0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00],
2384 [0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00],
2385 [0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00],
2386 [0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
2387];
2388
2389const KEYWORDS: &[&str] = &[
2390 "fn",
2391 "let",
2392 "mut",
2393 "pub",
2394 "use",
2395 "impl",
2396 "struct",
2397 "enum",
2398 "trait",
2399 "type",
2400 "const",
2401 "static",
2402 "if",
2403 "else",
2404 "match",
2405 "for",
2406 "while",
2407 "loop",
2408 "return",
2409 "break",
2410 "continue",
2411 "where",
2412 "self",
2413 "super",
2414 "crate",
2415 "mod",
2416 "async",
2417 "await",
2418 "move",
2419 "ref",
2420 "in",
2421 "as",
2422 "true",
2423 "false",
2424 "Some",
2425 "None",
2426 "Ok",
2427 "Err",
2428 "Self",
2429 "def",
2430 "class",
2431 "import",
2432 "from",
2433 "pass",
2434 "lambda",
2435 "yield",
2436 "with",
2437 "try",
2438 "except",
2439 "raise",
2440 "finally",
2441 "elif",
2442 "del",
2443 "global",
2444 "nonlocal",
2445 "assert",
2446 "is",
2447 "not",
2448 "and",
2449 "or",
2450 "function",
2451 "var",
2452 "const",
2453 "export",
2454 "default",
2455 "switch",
2456 "case",
2457 "throw",
2458 "catch",
2459 "typeof",
2460 "instanceof",
2461 "new",
2462 "delete",
2463 "void",
2464 "this",
2465 "null",
2466 "undefined",
2467 "func",
2468 "package",
2469 "defer",
2470 "go",
2471 "chan",
2472 "select",
2473 "range",
2474 "map",
2475 "interface",
2476 "fallthrough",
2477 "nil",
2478];
2479
2480fn render_tree_sitter_lines(ui: &mut Context, lines: &[Vec<(String, crate::style::Style)>]) {
2481 for segs in lines {
2482 if segs.is_empty() {
2483 ui.text(" ");
2484 } else {
2485 ui.line(|ui| {
2486 for (text, style) in segs {
2487 ui.styled(text, *style);
2488 }
2489 });
2490 }
2491 }
2492}
2493
2494fn render_highlighted_line(ui: &mut Context, line: &str) {
2495 let theme = ui.theme;
2496 let is_light = matches!(
2497 theme.bg,
2498 Color::Reset | Color::White | Color::Rgb(255, 255, 255)
2499 );
2500 let keyword_color = if is_light {
2501 Color::Rgb(166, 38, 164)
2502 } else {
2503 Color::Rgb(198, 120, 221)
2504 };
2505 let string_color = if is_light {
2506 Color::Rgb(80, 161, 79)
2507 } else {
2508 Color::Rgb(152, 195, 121)
2509 };
2510 let comment_color = theme.text_dim;
2511 let number_color = if is_light {
2512 Color::Rgb(152, 104, 1)
2513 } else {
2514 Color::Rgb(209, 154, 102)
2515 };
2516 let fn_color = if is_light {
2517 Color::Rgb(64, 120, 242)
2518 } else {
2519 Color::Rgb(97, 175, 239)
2520 };
2521 let macro_color = if is_light {
2522 Color::Rgb(1, 132, 188)
2523 } else {
2524 Color::Rgb(86, 182, 194)
2525 };
2526
2527 let trimmed = line.trim_start();
2528 let indent = &line[..line.len() - trimmed.len()];
2529 if !indent.is_empty() {
2530 ui.text(indent);
2531 }
2532
2533 if trimmed.starts_with("//") {
2534 ui.text(trimmed).fg(comment_color).italic();
2535 return;
2536 }
2537
2538 let mut pos = 0;
2539
2540 while pos < trimmed.len() {
2541 let ch = trimmed.as_bytes()[pos];
2542
2543 if ch == b'"' {
2544 if let Some(end) = trimmed[pos + 1..].find('"') {
2545 let s = &trimmed[pos..pos + end + 2];
2546 ui.text(s).fg(string_color);
2547 pos += end + 2;
2548 continue;
2549 }
2550 }
2551
2552 if ch.is_ascii_digit() && (pos == 0 || !trimmed.as_bytes()[pos - 1].is_ascii_alphanumeric())
2553 {
2554 let end = trimmed[pos..]
2555 .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '_')
2556 .map_or(trimmed.len(), |e| pos + e);
2557 ui.text(&trimmed[pos..end]).fg(number_color);
2558 pos = end;
2559 continue;
2560 }
2561
2562 if ch.is_ascii_alphabetic() || ch == b'_' {
2563 let end = trimmed[pos..]
2564 .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
2565 .map_or(trimmed.len(), |e| pos + e);
2566 let word = &trimmed[pos..end];
2567
2568 if end < trimmed.len() && trimmed.as_bytes()[end] == b'!' {
2569 ui.text(&trimmed[pos..end + 1]).fg(macro_color);
2570 pos = end + 1;
2571 } else if end < trimmed.len()
2572 && trimmed.as_bytes()[end] == b'('
2573 && !KEYWORDS.contains(&word)
2574 {
2575 ui.text(word).fg(fn_color);
2576 pos = end;
2577 } else if KEYWORDS.contains(&word) {
2578 ui.text(word).fg(keyword_color);
2579 pos = end;
2580 } else {
2581 ui.text(word);
2582 pos = end;
2583 }
2584 continue;
2585 }
2586
2587 let end = trimmed[pos..]
2588 .find(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '"')
2589 .map_or(trimmed.len(), |e| pos + e);
2590 ui.text(&trimmed[pos..end]);
2591 pos = end;
2592 }
2593}
2594
2595fn normalize_rgba(data: &[u8], width: u32, height: u32) -> Vec<u8> {
2596 let expected = (width as usize) * (height as usize) * 4;
2597 if data.len() >= expected {
2598 return data[..expected].to_vec();
2599 }
2600 let mut buf = Vec::with_capacity(expected);
2601 buf.extend_from_slice(data);
2602 buf.resize(expected, 0);
2603 buf
2604}
2605
2606#[cfg(feature = "crossterm")]
2607fn terminal_supports_sixel() -> bool {
2608 let force = std::env::var("SLT_FORCE_SIXEL")
2609 .ok()
2610 .map(|v| v.to_ascii_lowercase())
2611 .unwrap_or_default();
2612 if matches!(force.as_str(), "1" | "true" | "yes" | "on") {
2613 return true;
2614 }
2615
2616 let term = std::env::var("TERM")
2617 .ok()
2618 .map(|v| v.to_ascii_lowercase())
2619 .unwrap_or_default();
2620 let term_program = std::env::var("TERM_PROGRAM")
2621 .ok()
2622 .map(|v| v.to_ascii_lowercase())
2623 .unwrap_or_default();
2624
2625 term.contains("sixel")
2626 || term.contains("mlterm")
2627 || term.contains("xterm")
2628 || term.contains("foot")
2629 || term_program.contains("foot")
2630}
2631
2632#[cfg(test)]
2633mod tests {
2634 use super::*;
2635 use crate::TestBackend;
2636 use std::time::Duration;
2637
2638 #[test]
2639 fn gradient_text_renders_content() {
2640 let mut backend = TestBackend::new(20, 4);
2641 backend.render(|ui| {
2642 ui.text("ABCD").gradient(Color::Red, Color::Blue);
2643 });
2644
2645 backend.assert_contains("ABCD");
2646 }
2647
2648 #[test]
2649 fn big_text_renders_half_block_grid() {
2650 let mut backend = TestBackend::new(16, 4);
2651 backend.render(|ui| {
2652 let _ = ui.big_text("A");
2653 });
2654
2655 let output = backend.to_string();
2656 assert!(
2658 output.contains('▀') || output.contains('▄') || output.contains('█'),
2659 "output should contain half-block glyphs: {output:?}"
2660 );
2661 }
2662
2663 #[test]
2664 fn timer_display_formats_minutes_seconds_centis() {
2665 let mut backend = TestBackend::new(20, 4);
2666 backend.render(|ui| {
2667 ui.timer_display(Duration::from_secs(83) + Duration::from_millis(450));
2668 });
2669
2670 backend.assert_contains("01:23.45");
2671 }
2672}