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