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