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 {
269 let apply_group_style = self
270 .group_stack
271 .last()
272 .map(|name| self.is_group_hovered(name) || self.is_group_focused(name))
273 .unwrap_or(false);
274 if apply_group_style {
275 self.modify_last_style(|s| s.fg = Some(color));
276 }
277 self
278 }
279
280 pub fn group_hover_bg(&mut self, color: Color) -> &mut Self {
281 let apply_group_style = self
282 .group_stack
283 .last()
284 .map(|name| self.is_group_hovered(name) || self.is_group_focused(name))
285 .unwrap_or(false);
286 if apply_group_style {
287 self.modify_last_style(|s| s.bg = Some(color));
288 }
289 self
290 }
291
292 pub fn styled(&mut self, s: impl Into<String>, style: Style) -> &mut Self {
297 self.commands.push(Command::Text {
298 content: s.into(),
299 style,
300 grow: 0,
301 align: Align::Start,
302 wrap: false,
303 truncate: false,
304 margin: Margin::default(),
305 constraints: Constraints::default(),
306 });
307 self.last_text_idx = Some(self.commands.len() - 1);
308 self
309 }
310
311 pub fn big_text(&mut self, s: impl Into<String>) -> Response {
313 let text = s.into();
314 let glyphs: Vec<[u8; 8]> = text.chars().map(glyph_8x8).collect();
315 let total_width = (glyphs.len() as u32).saturating_mul(8);
316 let on_color = self.theme.primary;
317
318 self.container().w(total_width).h(4).draw(move |buf, rect| {
319 if rect.width == 0 || rect.height == 0 {
320 return;
321 }
322
323 for (glyph_idx, glyph) in glyphs.iter().enumerate() {
324 let base_x = rect.x + (glyph_idx as u32) * 8;
325 if base_x >= rect.right() {
326 break;
327 }
328
329 for pair in 0..4usize {
330 let y = rect.y + pair as u32;
331 if y >= rect.bottom() {
332 continue;
333 }
334
335 let upper = glyph[pair * 2];
336 let lower = glyph[pair * 2 + 1];
337
338 for bit in 0..8u32 {
339 let x = base_x + bit;
340 if x >= rect.right() {
341 break;
342 }
343
344 let mask = 1u8 << (bit as u8);
345 let upper_on = (upper & mask) != 0;
346 let lower_on = (lower & mask) != 0;
347 let (ch, fg, bg) = match (upper_on, lower_on) {
348 (true, true) => ('█', on_color, on_color),
349 (true, false) => ('▀', on_color, Color::Reset),
350 (false, true) => ('▄', on_color, Color::Reset),
351 (false, false) => (' ', Color::Reset, Color::Reset),
352 };
353 buf.set_char(x, y, ch, Style::new().fg(fg).bg(bg));
354 }
355 }
356 }
357 });
358
359 Response::none()
360 }
361
362 pub fn image(&mut self, img: &HalfBlockImage) -> Response {
384 let width = img.width;
385 let height = img.height;
386
387 let _ = self.container().w(width).h(height).gap(0).col(|ui| {
388 for row in 0..height {
389 let _ = ui.container().gap(0).row(|ui| {
390 for col in 0..width {
391 let idx = (row * width + col) as usize;
392 if let Some(&(upper, lower)) = img.pixels.get(idx) {
393 ui.styled("▀", Style::new().fg(upper).bg(lower));
394 }
395 }
396 });
397 }
398 });
399
400 Response::none()
401 }
402
403 pub fn kitty_image(
419 &mut self,
420 rgba: &[u8],
421 pixel_width: u32,
422 pixel_height: u32,
423 cols: u32,
424 rows: u32,
425 ) -> Response {
426 let rgba = normalize_rgba(rgba, pixel_width, pixel_height);
427 let encoded = base64_encode(&rgba);
428 let pw = pixel_width;
429 let ph = pixel_height;
430 let c = cols;
431 let r = rows;
432
433 self.container().w(cols).h(rows).draw(move |buf, rect| {
434 let chunks = split_base64(&encoded, 4096);
435 let mut all_sequences = String::new();
436
437 for (i, chunk) in chunks.iter().enumerate() {
438 let more = if i < chunks.len() - 1 { 1 } else { 0 };
439 if i == 0 {
440 all_sequences.push_str(&format!(
441 "\x1b_Ga=T,f=32,s={},v={},c={},r={},C=1,q=2,m={};{}\x1b\\",
442 pw, ph, c, r, more, chunk
443 ));
444 } else {
445 all_sequences.push_str(&format!("\x1b_Gm={};{}\x1b\\", more, chunk));
446 }
447 }
448
449 buf.raw_sequence(rect.x, rect.y, all_sequences);
450 });
451 Response::none()
452 }
453
454 pub fn kitty_image_fit(
463 &mut self,
464 rgba: &[u8],
465 src_width: u32,
466 src_height: u32,
467 cols: u32,
468 ) -> Response {
469 let rows = if src_width == 0 {
470 1
471 } else {
472 ((cols as f64 * src_height as f64 * 8.0) / (src_width as f64 * 16.0))
473 .ceil()
474 .max(1.0) as u32
475 };
476 let rgba = normalize_rgba(rgba, src_width, src_height);
477 let sw = src_width;
478 let sh = src_height;
479 let c = cols;
480 let r = rows;
481
482 self.container().w(cols).h(rows).draw(move |buf, rect| {
483 if rect.width == 0 || rect.height == 0 {
484 return;
485 }
486 let encoded = base64_encode(&rgba);
487 let chunks = split_base64(&encoded, 4096);
488 let mut seq = String::new();
489 for (i, chunk) in chunks.iter().enumerate() {
490 let more = if i < chunks.len() - 1 { 1 } else { 0 };
491 if i == 0 {
492 seq.push_str(&format!(
493 "\x1b_Ga=T,f=32,s={},v={},c={},r={},C=1,q=2,m={};{}\x1b\\",
494 sw, sh, c, r, more, chunk
495 ));
496 } else {
497 seq.push_str(&format!("\x1b_Gm={};{}\x1b\\", more, chunk));
498 }
499 }
500 buf.raw_sequence(rect.x, rect.y, seq);
501 });
502 Response::none()
503 }
504
505 pub fn sixel_image(
506 &mut self,
507 rgba: &[u8],
508 pixel_w: u32,
509 pixel_h: u32,
510 cols: u32,
511 rows: u32,
512 ) -> Response {
513 let sixel_supported = self.is_real_terminal && terminal_supports_sixel();
514 if !sixel_supported {
515 self.container().w(cols).h(rows).draw(|buf, rect| {
516 if rect.width == 0 || rect.height == 0 {
517 return;
518 }
519 buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
520 });
521 return Response::none();
522 }
523
524 #[cfg(not(feature = "crossterm"))]
525 {
526 self.container().w(cols).h(rows).draw(|buf, rect| {
527 if rect.width == 0 || rect.height == 0 {
528 return;
529 }
530 buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
531 });
532 return Response::none();
533 }
534
535 #[cfg(feature = "crossterm")]
536 let rgba = normalize_rgba(rgba, pixel_w, pixel_h);
537 #[cfg(feature = "crossterm")]
538 let encoded = crate::sixel::encode_sixel(&rgba, pixel_w, pixel_h, 256);
539
540 #[cfg(feature = "crossterm")]
541 if encoded.is_empty() {
542 self.container().w(cols).h(rows).draw(|buf, rect| {
543 if rect.width == 0 || rect.height == 0 {
544 return;
545 }
546 buf.set_string(rect.x, rect.y, "[sixel empty]", Style::new());
547 });
548 return Response::none();
549 }
550
551 #[cfg(feature = "crossterm")]
552 self.container().w(cols).h(rows).draw(move |buf, rect| {
553 if rect.width == 0 || rect.height == 0 {
554 return;
555 }
556 buf.raw_sequence(rect.x, rect.y, encoded);
557 });
558 Response::none()
559 }
560
561 pub fn streaming_text(&mut self, state: &mut StreamingTextState) -> Response {
577 if state.streaming {
578 state.cursor_tick = state.cursor_tick.wrapping_add(1);
579 state.cursor_visible = (state.cursor_tick / 8) % 2 == 0;
580 }
581
582 if state.content.is_empty() && state.streaming {
583 let cursor = if state.cursor_visible { "▌" } else { " " };
584 let primary = self.theme.primary;
585 self.text(cursor).fg(primary);
586 return Response::none();
587 }
588
589 if !state.content.is_empty() {
590 if state.streaming && state.cursor_visible {
591 self.text_wrap(format!("{}▌", state.content));
592 } else {
593 self.text_wrap(&state.content);
594 }
595 }
596
597 Response::none()
598 }
599
600 pub fn streaming_markdown(
618 &mut self,
619 state: &mut crate::widgets::StreamingMarkdownState,
620 ) -> Response {
621 if state.streaming {
622 state.cursor_tick = state.cursor_tick.wrapping_add(1);
623 state.cursor_visible = (state.cursor_tick / 8) % 2 == 0;
624 }
625
626 if state.content.is_empty() && state.streaming {
627 let cursor = if state.cursor_visible { "▌" } else { " " };
628 let primary = self.theme.primary;
629 self.text(cursor).fg(primary);
630 return Response::none();
631 }
632
633 let show_cursor = state.streaming && state.cursor_visible;
634 let trailing_newline = state.content.ends_with('\n');
635 let lines: Vec<&str> = state.content.lines().collect();
636 let last_line_index = lines.len().saturating_sub(1);
637
638 self.commands.push(Command::BeginContainer {
639 direction: Direction::Column,
640 gap: 0,
641 align: Align::Start,
642 align_self: None,
643 justify: Justify::Start,
644 border: None,
645 border_sides: BorderSides::all(),
646 border_style: Style::new().fg(self.theme.border),
647 bg_color: None,
648 padding: Padding::default(),
649 margin: Margin::default(),
650 constraints: Constraints::default(),
651 title: None,
652 grow: 0,
653 group_name: None,
654 });
655 self.interaction_count += 1;
656
657 let text_style = Style::new().fg(self.theme.text);
658 let bold_style = Style::new().fg(self.theme.text).bold();
659 let code_style = Style::new().fg(self.theme.accent);
660 let border_style = Style::new().fg(self.theme.border).dim();
661
662 let mut in_code_block = false;
663 let mut code_block_lang = String::new();
664
665 for (idx, line) in lines.iter().enumerate() {
666 let line = *line;
667 let trimmed = line.trim();
668 let append_cursor = show_cursor && !trailing_newline && idx == last_line_index;
669 let cursor = if append_cursor { "▌" } else { "" };
670
671 if in_code_block {
672 if trimmed.starts_with("```") {
673 in_code_block = false;
674 code_block_lang.clear();
675 let mut line = String::from(" └────");
676 line.push_str(cursor);
677 self.styled(line, border_style);
678 } else {
679 let mut line_text = String::with_capacity(2 + line.len() + cursor.len());
680 line_text.push_str(" ");
681 line_text.push_str(line);
682 line_text.push_str(cursor);
683 self.styled(line_text, code_style);
684 }
685 continue;
686 }
687
688 if trimmed.is_empty() {
689 if append_cursor {
690 self.styled("▌", Style::new().fg(self.theme.primary));
691 } else {
692 self.text(" ");
693 }
694 continue;
695 }
696
697 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
698 let mut line = "─".repeat(40);
699 line.push_str(cursor);
700 self.styled(line, border_style);
701 continue;
702 }
703
704 if let Some(heading) = trimmed.strip_prefix("### ") {
705 let mut line = String::with_capacity(heading.len() + cursor.len());
706 line.push_str(heading);
707 line.push_str(cursor);
708 self.styled(line, Style::new().bold().fg(self.theme.accent));
709 continue;
710 }
711
712 if let Some(heading) = trimmed.strip_prefix("## ") {
713 let mut line = String::with_capacity(heading.len() + cursor.len());
714 line.push_str(heading);
715 line.push_str(cursor);
716 self.styled(line, Style::new().bold().fg(self.theme.secondary));
717 continue;
718 }
719
720 if let Some(heading) = trimmed.strip_prefix("# ") {
721 let mut line = String::with_capacity(heading.len() + cursor.len());
722 line.push_str(heading);
723 line.push_str(cursor);
724 self.styled(line, Style::new().bold().fg(self.theme.primary));
725 continue;
726 }
727
728 if let Some(code) = trimmed.strip_prefix("```") {
729 in_code_block = true;
730 code_block_lang = code.trim().to_string();
731 let label = if code_block_lang.is_empty() {
732 "code".to_string()
733 } else {
734 let mut label = String::from("code:");
735 label.push_str(&code_block_lang);
736 label
737 };
738 let mut line = String::with_capacity(5 + label.len() + cursor.len());
739 line.push_str(" ┌─");
740 line.push_str(&label);
741 line.push('─');
742 line.push_str(cursor);
743 self.styled(line, border_style);
744 continue;
745 }
746
747 if let Some(item) = trimmed
748 .strip_prefix("- ")
749 .or_else(|| trimmed.strip_prefix("* "))
750 {
751 let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
752 if segs.len() <= 1 {
753 let mut line = String::with_capacity(4 + item.len() + cursor.len());
754 line.push_str(" • ");
755 line.push_str(item);
756 line.push_str(cursor);
757 self.styled(line, text_style);
758 } else {
759 self.line(|ui| {
760 ui.styled(" • ", text_style);
761 for (s, st) in segs {
762 ui.styled(s, st);
763 }
764 if append_cursor {
765 ui.styled("▌", Style::new().fg(ui.theme.primary));
766 }
767 });
768 }
769 continue;
770 }
771
772 if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
773 let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
774 if parts.len() == 2 {
775 let segs =
776 Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
777 if segs.len() <= 1 {
778 let mut line = String::with_capacity(
779 4 + parts[0].len() + parts[1].len() + cursor.len(),
780 );
781 line.push_str(" ");
782 line.push_str(parts[0]);
783 line.push_str(". ");
784 line.push_str(parts[1]);
785 line.push_str(cursor);
786 self.styled(line, text_style);
787 } else {
788 self.line(|ui| {
789 let mut prefix = String::with_capacity(4 + parts[0].len());
790 prefix.push_str(" ");
791 prefix.push_str(parts[0]);
792 prefix.push_str(". ");
793 ui.styled(prefix, text_style);
794 for (s, st) in segs {
795 ui.styled(s, st);
796 }
797 if append_cursor {
798 ui.styled("▌", Style::new().fg(ui.theme.primary));
799 }
800 });
801 }
802 } else {
803 let mut line = String::with_capacity(trimmed.len() + cursor.len());
804 line.push_str(trimmed);
805 line.push_str(cursor);
806 self.styled(line, text_style);
807 }
808 continue;
809 }
810
811 let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
812 if segs.len() <= 1 {
813 let mut line = String::with_capacity(trimmed.len() + cursor.len());
814 line.push_str(trimmed);
815 line.push_str(cursor);
816 self.styled(line, text_style);
817 } else {
818 self.line(|ui| {
819 for (s, st) in segs {
820 ui.styled(s, st);
821 }
822 if append_cursor {
823 ui.styled("▌", Style::new().fg(ui.theme.primary));
824 }
825 });
826 }
827 }
828
829 if show_cursor && trailing_newline {
830 if in_code_block {
831 self.styled(" ▌", code_style);
832 } else {
833 self.styled("▌", Style::new().fg(self.theme.primary));
834 }
835 }
836
837 state.in_code_block = in_code_block;
838 state.code_block_lang = code_block_lang;
839
840 self.commands.push(Command::EndContainer);
841 self.last_text_idx = None;
842 Response::none()
843 }
844
845 pub fn tool_approval(&mut self, state: &mut ToolApprovalState) -> Response {
860 let old_action = state.action;
861 let theme = self.theme;
862 let _ = self.bordered(Border::Rounded).col(|ui| {
863 let _ = ui.row(|ui| {
864 ui.text("⚡").fg(theme.warning);
865 ui.text(&state.tool_name).bold().fg(theme.primary);
866 });
867 ui.text(&state.description).dim();
868
869 if state.action == ApprovalAction::Pending {
870 let _ = ui.row(|ui| {
871 if ui.button("✓ Approve").clicked {
872 state.action = ApprovalAction::Approved;
873 }
874 if ui.button("✗ Reject").clicked {
875 state.action = ApprovalAction::Rejected;
876 }
877 });
878 } else {
879 let (label, color) = match state.action {
880 ApprovalAction::Approved => ("✓ Approved", theme.success),
881 ApprovalAction::Rejected => ("✗ Rejected", theme.error),
882 ApprovalAction::Pending => unreachable!(),
883 };
884 ui.text(label).fg(color).bold();
885 }
886 });
887
888 Response {
889 changed: state.action != old_action,
890 ..Response::none()
891 }
892 }
893
894 pub fn context_bar(&mut self, items: &[ContextItem]) -> Response {
907 if items.is_empty() {
908 return Response::none();
909 }
910
911 let theme = self.theme;
912 let total: usize = items.iter().map(|item| item.tokens).sum();
913
914 let _ = self.container().row(|ui| {
915 ui.text("📎").dim();
916 for item in items {
917 let token_count = format_token_count(item.tokens);
918 let mut line = String::with_capacity(item.label.len() + token_count.len() + 3);
919 line.push_str(&item.label);
920 line.push_str(" (");
921 line.push_str(&token_count);
922 line.push(')');
923 ui.text(line).fg(theme.secondary);
924 }
925 ui.spacer();
926 let total_text = format_token_count(total);
927 let mut line = String::with_capacity(2 + total_text.len());
928 line.push_str("Σ ");
929 line.push_str(&total_text);
930 ui.text(line).dim();
931 });
932
933 Response::none()
934 }
935
936 pub fn alert(&mut self, message: &str, level: crate::widgets::AlertLevel) -> Response {
937 use crate::widgets::AlertLevel;
938
939 let theme = self.theme;
940 let (icon, color) = match level {
941 AlertLevel::Info => ("ℹ", theme.accent),
942 AlertLevel::Success => ("✓", theme.success),
943 AlertLevel::Warning => ("⚠", theme.warning),
944 AlertLevel::Error => ("✕", theme.error),
945 };
946
947 let focused = self.register_focusable();
948 let key_dismiss = focused && (self.key_code(KeyCode::Enter) || self.key('x'));
949
950 let mut response = self.container().col(|ui| {
951 ui.line(|ui| {
952 let mut icon_text = String::with_capacity(icon.len() + 2);
953 icon_text.push(' ');
954 icon_text.push_str(icon);
955 icon_text.push(' ');
956 ui.text(icon_text).fg(color).bold();
957 ui.text(message).grow(1);
958 ui.text(" [×] ").dim();
959 });
960 });
961 response.focused = focused;
962 if key_dismiss {
963 response.clicked = true;
964 }
965
966 response
967 }
968
969 pub fn confirm(&mut self, question: &str, result: &mut bool) -> Response {
983 let focused = self.register_focusable();
984 let mut is_yes = *result;
985 let mut clicked = false;
986
987 if focused {
988 let mut consumed_indices = Vec::new();
989 for (i, event) in self.events.iter().enumerate() {
990 if let Event::Key(key) = event {
991 if key.kind != KeyEventKind::Press {
992 continue;
993 }
994
995 match key.code {
996 KeyCode::Char('y') => {
997 is_yes = true;
998 *result = true;
999 clicked = true;
1000 consumed_indices.push(i);
1001 }
1002 KeyCode::Char('n') => {
1003 is_yes = false;
1004 *result = false;
1005 clicked = true;
1006 consumed_indices.push(i);
1007 }
1008 KeyCode::Tab | KeyCode::BackTab | KeyCode::Left | KeyCode::Right => {
1009 is_yes = !is_yes;
1010 *result = is_yes;
1011 consumed_indices.push(i);
1012 }
1013 KeyCode::Enter => {
1014 *result = is_yes;
1015 clicked = true;
1016 consumed_indices.push(i);
1017 }
1018 _ => {}
1019 }
1020 }
1021 }
1022
1023 for idx in consumed_indices {
1024 self.consumed[idx] = true;
1025 }
1026 }
1027
1028 let yes_style = if is_yes {
1029 if focused {
1030 Style::new().fg(self.theme.bg).bg(self.theme.success).bold()
1031 } else {
1032 Style::new().fg(self.theme.success).bold()
1033 }
1034 } else {
1035 Style::new().fg(self.theme.text_dim)
1036 };
1037 let no_style = if !is_yes {
1038 if focused {
1039 Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
1040 } else {
1041 Style::new().fg(self.theme.error).bold()
1042 }
1043 } else {
1044 Style::new().fg(self.theme.text_dim)
1045 };
1046
1047 let q_width = UnicodeWidthStr::width(question) as u32;
1048 let mut response = self.row(|ui| {
1049 ui.text(question);
1050 ui.text(" ");
1051 ui.styled("[Yes]", yes_style);
1052 ui.text(" ");
1053 ui.styled("[No]", no_style);
1054 });
1055
1056 if !clicked && response.clicked {
1057 if let Some((mx, _)) = self.click_pos {
1058 let yes_start = response.rect.x + q_width + 1;
1059 let yes_end = yes_start + 5;
1060 let no_start = yes_end + 1;
1061 if mx >= yes_start && mx < yes_end {
1062 is_yes = true;
1063 *result = true;
1064 clicked = true;
1065 } else if mx >= no_start {
1066 is_yes = false;
1067 *result = false;
1068 clicked = true;
1069 }
1070 }
1071 }
1072
1073 response.focused = focused;
1074 response.clicked = clicked;
1075 response.changed = clicked;
1076 let _ = is_yes;
1077 response
1078 }
1079
1080 pub fn breadcrumb(&mut self, segments: &[&str]) -> Option<usize> {
1081 self.breadcrumb_with(segments, " › ")
1082 }
1083
1084 pub fn breadcrumb_with(&mut self, segments: &[&str], separator: &str) -> Option<usize> {
1085 let theme = self.theme;
1086 let last_idx = segments.len().saturating_sub(1);
1087 let mut clicked_idx: Option<usize> = None;
1088
1089 let _ = self.row(|ui| {
1090 for (i, segment) in segments.iter().enumerate() {
1091 let is_last = i == last_idx;
1092 if is_last {
1093 ui.text(*segment).bold();
1094 } else {
1095 let focused = ui.register_focusable();
1096 let pressed = focused && (ui.key_code(KeyCode::Enter) || ui.key(' '));
1097 let resp = ui.interaction();
1098 let color = if resp.hovered || focused {
1099 theme.accent
1100 } else {
1101 theme.primary
1102 };
1103 ui.text(*segment).fg(color).underline();
1104 if resp.clicked || pressed {
1105 clicked_idx = Some(i);
1106 }
1107 ui.text(separator).dim();
1108 }
1109 }
1110 });
1111
1112 clicked_idx
1113 }
1114
1115 pub fn accordion(
1116 &mut self,
1117 title: &str,
1118 open: &mut bool,
1119 f: impl FnOnce(&mut Context),
1120 ) -> Response {
1121 let theme = self.theme;
1122 let focused = self.register_focusable();
1123 let old_open = *open;
1124
1125 if focused && self.key_code(KeyCode::Enter) {
1126 *open = !*open;
1127 }
1128
1129 let icon = if *open { "▾" } else { "▸" };
1130 let title_color = if focused { theme.primary } else { theme.text };
1131
1132 let mut response = self.container().col(|ui| {
1133 ui.line(|ui| {
1134 ui.text(icon).fg(title_color);
1135 let mut title_text = String::with_capacity(1 + title.len());
1136 title_text.push(' ');
1137 title_text.push_str(title);
1138 ui.text(title_text).bold().fg(title_color);
1139 });
1140 });
1141
1142 if response.clicked {
1143 *open = !*open;
1144 }
1145
1146 if *open {
1147 let _ = self.container().pl(2).col(f);
1148 }
1149
1150 response.focused = focused;
1151 response.changed = *open != old_open;
1152 response
1153 }
1154
1155 pub fn definition_list(&mut self, items: &[(&str, &str)]) -> Response {
1156 let max_key_width = items
1157 .iter()
1158 .map(|(k, _)| unicode_width::UnicodeWidthStr::width(*k))
1159 .max()
1160 .unwrap_or(0);
1161
1162 let _ = self.col(|ui| {
1163 for (key, value) in items {
1164 ui.line(|ui| {
1165 let padded = format!("{:>width$}", key, width = max_key_width);
1166 ui.text(padded).dim();
1167 ui.text(" ");
1168 ui.text(*value);
1169 });
1170 }
1171 });
1172
1173 Response::none()
1174 }
1175
1176 pub fn divider_text(&mut self, label: &str) -> Response {
1177 let w = self.width();
1178 let label_len = unicode_width::UnicodeWidthStr::width(label) as u32;
1179 let pad = 1u32;
1180 let left_len = 4u32;
1181 let right_len = w.saturating_sub(left_len + pad + label_len + pad);
1182 let left: String = "─".repeat(left_len as usize);
1183 let right: String = "─".repeat(right_len as usize);
1184 let theme = self.theme;
1185 self.line(|ui| {
1186 ui.text(&left).fg(theme.border);
1187 let mut label_text = String::with_capacity(label.len() + 2);
1188 label_text.push(' ');
1189 label_text.push_str(label);
1190 label_text.push(' ');
1191 ui.text(label_text).fg(theme.text);
1192 ui.text(&right).fg(theme.border);
1193 });
1194
1195 Response::none()
1196 }
1197
1198 pub fn badge(&mut self, label: &str) -> Response {
1199 let theme = self.theme;
1200 self.badge_colored(label, theme.primary)
1201 }
1202
1203 pub fn badge_colored(&mut self, label: &str, color: Color) -> Response {
1204 let fg = Color::contrast_fg(color);
1205 let mut label_text = String::with_capacity(label.len() + 2);
1206 label_text.push(' ');
1207 label_text.push_str(label);
1208 label_text.push(' ');
1209 self.text(label_text).fg(fg).bg(color);
1210
1211 Response::none()
1212 }
1213
1214 pub fn key_hint(&mut self, key: &str) -> Response {
1215 let theme = self.theme;
1216 let mut key_text = String::with_capacity(key.len() + 2);
1217 key_text.push(' ');
1218 key_text.push_str(key);
1219 key_text.push(' ');
1220 self.text(key_text).reversed().fg(theme.text_dim);
1221
1222 Response::none()
1223 }
1224
1225 pub fn stat(&mut self, label: &str, value: &str) -> Response {
1226 let _ = self.col(|ui| {
1227 ui.text(label).dim();
1228 ui.text(value).bold();
1229 });
1230
1231 Response::none()
1232 }
1233
1234 pub fn stat_colored(&mut self, label: &str, value: &str, color: Color) -> Response {
1235 let _ = self.col(|ui| {
1236 ui.text(label).dim();
1237 ui.text(value).bold().fg(color);
1238 });
1239
1240 Response::none()
1241 }
1242
1243 pub fn stat_trend(
1244 &mut self,
1245 label: &str,
1246 value: &str,
1247 trend: crate::widgets::Trend,
1248 ) -> Response {
1249 let theme = self.theme;
1250 let (arrow, color) = match trend {
1251 crate::widgets::Trend::Up => ("↑", theme.success),
1252 crate::widgets::Trend::Down => ("↓", theme.error),
1253 };
1254 let _ = self.col(|ui| {
1255 ui.text(label).dim();
1256 ui.line(|ui| {
1257 ui.text(value).bold();
1258 let mut arrow_text = String::with_capacity(1 + arrow.len());
1259 arrow_text.push(' ');
1260 arrow_text.push_str(arrow);
1261 ui.text(arrow_text).fg(color);
1262 });
1263 });
1264
1265 Response::none()
1266 }
1267
1268 pub fn empty_state(&mut self, title: &str, description: &str) -> Response {
1269 let _ = self.container().center().col(|ui| {
1270 ui.text(title).align(Align::Center);
1271 ui.text(description).dim().align(Align::Center);
1272 });
1273
1274 Response::none()
1275 }
1276
1277 pub fn empty_state_action(
1278 &mut self,
1279 title: &str,
1280 description: &str,
1281 action_label: &str,
1282 ) -> Response {
1283 let mut clicked = false;
1284 let _ = self.container().center().col(|ui| {
1285 ui.text(title).align(Align::Center);
1286 ui.text(description).dim().align(Align::Center);
1287 if ui.button(action_label).clicked {
1288 clicked = true;
1289 }
1290 });
1291
1292 Response {
1293 clicked,
1294 changed: clicked,
1295 ..Response::none()
1296 }
1297 }
1298
1299 pub fn code_block(&mut self, code: &str) -> Response {
1300 let theme = self.theme;
1301 let _ = self
1302 .bordered(Border::Rounded)
1303 .bg(theme.surface)
1304 .pad(1)
1305 .col(|ui| {
1306 for line in code.lines() {
1307 render_highlighted_line(ui, line);
1308 }
1309 });
1310
1311 Response::none()
1312 }
1313
1314 pub fn code_block_numbered(&mut self, code: &str) -> Response {
1315 let lines: Vec<&str> = code.lines().collect();
1316 let gutter_w = format!("{}", lines.len()).len();
1317 let theme = self.theme;
1318 let _ = self
1319 .bordered(Border::Rounded)
1320 .bg(theme.surface)
1321 .pad(1)
1322 .col(|ui| {
1323 for (i, line) in lines.iter().enumerate() {
1324 ui.line(|ui| {
1325 ui.text(format!("{:>gutter_w$} │ ", i + 1))
1326 .fg(theme.text_dim);
1327 render_highlighted_line(ui, line);
1328 });
1329 }
1330 });
1331
1332 Response::none()
1333 }
1334
1335 pub fn wrap(&mut self) -> &mut Self {
1337 if let Some(idx) = self.last_text_idx {
1338 if let Command::Text { wrap, .. } = &mut self.commands[idx] {
1339 *wrap = true;
1340 }
1341 }
1342 self
1343 }
1344
1345 pub fn truncate(&mut self) -> &mut Self {
1348 if let Some(idx) = self.last_text_idx {
1349 if let Command::Text { truncate, .. } = &mut self.commands[idx] {
1350 *truncate = true;
1351 }
1352 }
1353 self
1354 }
1355
1356 fn modify_last_style(&mut self, f: impl FnOnce(&mut Style)) {
1357 if let Some(idx) = self.last_text_idx {
1358 match &mut self.commands[idx] {
1359 Command::Text { style, .. } | Command::Link { style, .. } => f(style),
1360 _ => {}
1361 }
1362 }
1363 }
1364
1365 fn modify_last_constraints(&mut self, f: impl FnOnce(&mut Constraints)) {
1366 if let Some(idx) = self.last_text_idx {
1367 match &mut self.commands[idx] {
1368 Command::Text { constraints, .. } | Command::Link { constraints, .. } => {
1369 f(constraints)
1370 }
1371 _ => {}
1372 }
1373 }
1374 }
1375
1376 fn modify_last_margin(&mut self, f: impl FnOnce(&mut Margin)) {
1377 if let Some(idx) = self.last_text_idx {
1378 match &mut self.commands[idx] {
1379 Command::Text { margin, .. } | Command::Link { margin, .. } => f(margin),
1380 _ => {}
1381 }
1382 }
1383 }
1384
1385 pub fn screen(&mut self, name: &str, screens: &ScreenState, f: impl FnOnce(&mut Context)) {
1388 if screens.current() == name {
1389 f(self);
1390 }
1391 }
1392
1393 pub fn col(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1409 self.push_container(Direction::Column, 0, f)
1410 }
1411
1412 pub fn col_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
1416 self.push_container(Direction::Column, gap, f)
1417 }
1418
1419 pub fn row(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1436 self.push_container(Direction::Row, 0, f)
1437 }
1438
1439 pub fn row_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
1443 self.push_container(Direction::Row, gap, f)
1444 }
1445
1446 pub fn line(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
1463 let _ = self.push_container(Direction::Row, 0, f);
1464 self
1465 }
1466
1467 pub fn line_wrap(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
1486 let start = self.commands.len();
1487 f(self);
1488 let mut segments: Vec<(String, Style)> = Vec::new();
1489 for cmd in self.commands.drain(start..) {
1490 if let Command::Text { content, style, .. } = cmd {
1491 segments.push((content, style));
1492 }
1493 }
1494 self.commands.push(Command::RichText {
1495 segments,
1496 wrap: true,
1497 align: Align::Start,
1498 margin: Margin::default(),
1499 constraints: Constraints::default(),
1500 });
1501 self.last_text_idx = None;
1502 self
1503 }
1504
1505 pub fn modal(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1514 let interaction_id = self.next_interaction_id();
1515 self.commands.push(Command::BeginOverlay { modal: true });
1516 self.overlay_depth += 1;
1517 self.modal_active = true;
1518 self.modal_focus_start = self.focus_count;
1519 f(self);
1520 self.modal_focus_count = self.focus_count.saturating_sub(self.modal_focus_start);
1521 self.overlay_depth = self.overlay_depth.saturating_sub(1);
1522 self.commands.push(Command::EndOverlay);
1523 self.last_text_idx = None;
1524 self.response_for(interaction_id)
1525 }
1526
1527 pub fn overlay(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1529 let interaction_id = self.next_interaction_id();
1530 self.commands.push(Command::BeginOverlay { modal: false });
1531 self.overlay_depth += 1;
1532 f(self);
1533 self.overlay_depth = self.overlay_depth.saturating_sub(1);
1534 self.commands.push(Command::EndOverlay);
1535 self.last_text_idx = None;
1536 self.response_for(interaction_id)
1537 }
1538
1539 pub fn tooltip(&mut self, text: impl Into<String>) {
1547 let tooltip_text = text.into();
1548 if tooltip_text.is_empty() {
1549 return;
1550 }
1551 let last_interaction_id = self.interaction_count.saturating_sub(1);
1552 let last_response = self.response_for(last_interaction_id);
1553 if !last_response.hovered || last_response.rect.width == 0 || last_response.rect.height == 0
1554 {
1555 return;
1556 }
1557 let lines = wrap_tooltip_text(&tooltip_text, 38);
1558 self.pending_tooltips.push(PendingTooltip {
1559 anchor_rect: last_response.rect,
1560 lines,
1561 });
1562 }
1563
1564 pub(crate) fn emit_pending_tooltips(&mut self) {
1565 let tooltips = std::mem::take(&mut self.pending_tooltips);
1566 if tooltips.is_empty() {
1567 return;
1568 }
1569 let area_w = self.area_width;
1570 let area_h = self.area_height;
1571 let surface = self.theme.surface;
1572 let border_color = self.theme.border;
1573 let text_color = self.theme.surface_text;
1574
1575 for tooltip in tooltips {
1576 let content_w = tooltip
1577 .lines
1578 .iter()
1579 .map(|l| UnicodeWidthStr::width(l.as_str()) as u32)
1580 .max()
1581 .unwrap_or(0);
1582 let box_w = content_w.saturating_add(4).min(area_w);
1583 let box_h = (tooltip.lines.len() as u32).saturating_add(4).min(area_h);
1584
1585 let tooltip_x = tooltip.anchor_rect.x.min(area_w.saturating_sub(box_w));
1586 let below_y = tooltip.anchor_rect.bottom();
1587 let tooltip_y = if below_y.saturating_add(box_h) <= area_h {
1588 below_y
1589 } else {
1590 tooltip.anchor_rect.y.saturating_sub(box_h)
1591 };
1592
1593 let lines = tooltip.lines;
1594 let _ = self.overlay(|ui| {
1595 let _ = ui.container().w(area_w).h(area_h).col(|ui| {
1596 let _ = ui
1597 .container()
1598 .ml(tooltip_x)
1599 .mt(tooltip_y)
1600 .max_w(box_w)
1601 .border(Border::Rounded)
1602 .border_fg(border_color)
1603 .bg(surface)
1604 .p(1)
1605 .col(|ui| {
1606 for line in &lines {
1607 ui.text(line.as_str()).fg(text_color);
1608 }
1609 });
1610 });
1611 });
1612 }
1613 }
1614
1615 pub fn group(&mut self, name: &str) -> ContainerBuilder<'_> {
1623 self.group_count = self.group_count.saturating_add(1);
1624 self.group_stack.push(name.to_string());
1625 self.container().group_name(name.to_string())
1626 }
1627
1628 pub fn container(&mut self) -> ContainerBuilder<'_> {
1649 let border = self.theme.border;
1650 ContainerBuilder {
1651 ctx: self,
1652 gap: 0,
1653 row_gap: None,
1654 col_gap: None,
1655 align: Align::Start,
1656 align_self_value: None,
1657 justify: Justify::Start,
1658 border: None,
1659 border_sides: BorderSides::all(),
1660 border_style: Style::new().fg(border),
1661 bg: None,
1662 text_color: None,
1663 dark_bg: None,
1664 dark_border_style: None,
1665 group_hover_bg: None,
1666 group_hover_border_style: None,
1667 group_name: None,
1668 padding: Padding::default(),
1669 margin: Margin::default(),
1670 constraints: Constraints::default(),
1671 title: None,
1672 grow: 0,
1673 scroll_offset: None,
1674 }
1675 }
1676
1677 pub fn scrollable(&mut self, state: &mut ScrollState) -> ContainerBuilder<'_> {
1696 let index = self.scroll_count;
1697 self.scroll_count += 1;
1698 if let Some(&(ch, vh)) = self.prev_scroll_infos.get(index) {
1699 state.set_bounds(ch, vh);
1700 let max = ch.saturating_sub(vh) as usize;
1701 state.offset = state.offset.min(max);
1702 }
1703
1704 let next_id = self.interaction_count;
1705 if let Some(rect) = self.prev_hit_map.get(next_id).copied() {
1706 let inner_rects: Vec<Rect> = self
1707 .prev_scroll_rects
1708 .iter()
1709 .enumerate()
1710 .filter(|&(j, sr)| {
1711 j != index
1712 && sr.width > 0
1713 && sr.height > 0
1714 && sr.x >= rect.x
1715 && sr.right() <= rect.right()
1716 && sr.y >= rect.y
1717 && sr.bottom() <= rect.bottom()
1718 })
1719 .map(|(_, sr)| *sr)
1720 .collect();
1721 self.auto_scroll_nested(&rect, state, &inner_rects);
1722 }
1723
1724 self.container().scroll_offset(state.offset as u32)
1725 }
1726
1727 pub fn scrollbar(&mut self, state: &ScrollState) {
1747 let vh = state.viewport_height();
1748 let ch = state.content_height();
1749 if vh == 0 || ch <= vh {
1750 return;
1751 }
1752
1753 let track_height = vh;
1754 let thumb_height = ((vh as f64 * vh as f64 / ch as f64).ceil() as u32).max(1);
1755 let max_offset = ch.saturating_sub(vh);
1756 let thumb_pos = if max_offset == 0 {
1757 0
1758 } else {
1759 ((state.offset as f64 / max_offset as f64) * (track_height - thumb_height) as f64)
1760 .round() as u32
1761 };
1762
1763 let theme = self.theme;
1764 let track_char = '│';
1765 let thumb_char = '█';
1766
1767 let _ = self.container().w(1).h(track_height).col(|ui| {
1768 for i in 0..track_height {
1769 if i >= thumb_pos && i < thumb_pos + thumb_height {
1770 ui.styled(thumb_char.to_string(), Style::new().fg(theme.primary));
1771 } else {
1772 ui.styled(
1773 track_char.to_string(),
1774 Style::new().fg(theme.text_dim).dim(),
1775 );
1776 }
1777 }
1778 });
1779 }
1780
1781 fn auto_scroll_nested(
1782 &mut self,
1783 rect: &Rect,
1784 state: &mut ScrollState,
1785 inner_scroll_rects: &[Rect],
1786 ) {
1787 let mut to_consume: Vec<usize> = Vec::new();
1788
1789 for (i, event) in self.events.iter().enumerate() {
1790 if self.consumed[i] {
1791 continue;
1792 }
1793 if let Event::Mouse(mouse) = event {
1794 let in_bounds = mouse.x >= rect.x
1795 && mouse.x < rect.right()
1796 && mouse.y >= rect.y
1797 && mouse.y < rect.bottom();
1798 if !in_bounds {
1799 continue;
1800 }
1801 let in_inner = inner_scroll_rects.iter().any(|sr| {
1802 mouse.x >= sr.x
1803 && mouse.x < sr.right()
1804 && mouse.y >= sr.y
1805 && mouse.y < sr.bottom()
1806 });
1807 if in_inner {
1808 continue;
1809 }
1810 match mouse.kind {
1811 MouseKind::ScrollUp => {
1812 state.scroll_up(1);
1813 to_consume.push(i);
1814 }
1815 MouseKind::ScrollDown => {
1816 state.scroll_down(1);
1817 to_consume.push(i);
1818 }
1819 MouseKind::Drag(MouseButton::Left) => {}
1820 _ => {}
1821 }
1822 }
1823 }
1824
1825 for i in to_consume {
1826 self.consumed[i] = true;
1827 }
1828 }
1829
1830 pub fn bordered(&mut self, border: Border) -> ContainerBuilder<'_> {
1834 self.container()
1835 .border(border)
1836 .border_sides(BorderSides::all())
1837 }
1838
1839 fn push_container(
1840 &mut self,
1841 direction: Direction,
1842 gap: u32,
1843 f: impl FnOnce(&mut Context),
1844 ) -> Response {
1845 let interaction_id = self.next_interaction_id();
1846 let border = self.theme.border;
1847
1848 self.commands.push(Command::BeginContainer {
1849 direction,
1850 gap,
1851 align: Align::Start,
1852 align_self: None,
1853 justify: Justify::Start,
1854 border: None,
1855 border_sides: BorderSides::all(),
1856 border_style: Style::new().fg(border),
1857 bg_color: None,
1858 padding: Padding::default(),
1859 margin: Margin::default(),
1860 constraints: Constraints::default(),
1861 title: None,
1862 grow: 0,
1863 group_name: None,
1864 });
1865 self.text_color_stack.push(None);
1866 f(self);
1867 self.text_color_stack.pop();
1868 self.commands.push(Command::EndContainer);
1869 self.last_text_idx = None;
1870
1871 self.response_for(interaction_id)
1872 }
1873
1874 pub(super) fn response_for(&self, interaction_id: usize) -> Response {
1875 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1876 return Response::none();
1877 }
1878 if let Some(rect) = self.prev_hit_map.get(interaction_id) {
1879 let clicked = self
1880 .click_pos
1881 .map(|(mx, my)| {
1882 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1883 })
1884 .unwrap_or(false);
1885 let hovered = self
1886 .mouse_pos
1887 .map(|(mx, my)| {
1888 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1889 })
1890 .unwrap_or(false);
1891 Response {
1892 clicked,
1893 hovered,
1894 changed: false,
1895 focused: false,
1896 rect: *rect,
1897 }
1898 } else {
1899 Response::none()
1900 }
1901 }
1902
1903 pub fn is_group_hovered(&self, name: &str) -> bool {
1905 if let Some(pos) = self.mouse_pos {
1906 self.prev_group_rects.iter().any(|(n, rect)| {
1907 n == name
1908 && pos.0 >= rect.x
1909 && pos.0 < rect.x + rect.width
1910 && pos.1 >= rect.y
1911 && pos.1 < rect.y + rect.height
1912 })
1913 } else {
1914 false
1915 }
1916 }
1917
1918 pub fn is_group_focused(&self, name: &str) -> bool {
1920 if self.prev_focus_count == 0 {
1921 return false;
1922 }
1923 let focused_index = self.focus_index % self.prev_focus_count;
1924 self.prev_focus_groups
1925 .get(focused_index)
1926 .and_then(|group| group.as_deref())
1927 .map(|group| group == name)
1928 .unwrap_or(false)
1929 }
1930
1931 pub fn grow(&mut self, value: u16) -> &mut Self {
1936 if let Some(idx) = self.last_text_idx {
1937 if let Command::Text { grow, .. } = &mut self.commands[idx] {
1938 *grow = value;
1939 }
1940 }
1941 self
1942 }
1943
1944 pub fn align(&mut self, align: Align) -> &mut Self {
1946 if let Some(idx) = self.last_text_idx {
1947 if let Command::Text {
1948 align: text_align, ..
1949 } = &mut self.commands[idx]
1950 {
1951 *text_align = align;
1952 }
1953 }
1954 self
1955 }
1956
1957 pub fn text_center(&mut self) -> &mut Self {
1961 self.align(Align::Center)
1962 }
1963
1964 pub fn text_right(&mut self) -> &mut Self {
1967 self.align(Align::End)
1968 }
1969
1970 pub fn w(&mut self, value: u32) -> &mut Self {
1977 self.modify_last_constraints(|c| {
1978 c.min_width = Some(value);
1979 c.max_width = Some(value);
1980 });
1981 self
1982 }
1983
1984 pub fn h(&mut self, value: u32) -> &mut Self {
1988 self.modify_last_constraints(|c| {
1989 c.min_height = Some(value);
1990 c.max_height = Some(value);
1991 });
1992 self
1993 }
1994
1995 pub fn min_w(&mut self, value: u32) -> &mut Self {
1997 self.modify_last_constraints(|c| c.min_width = Some(value));
1998 self
1999 }
2000
2001 pub fn max_w(&mut self, value: u32) -> &mut Self {
2003 self.modify_last_constraints(|c| c.max_width = Some(value));
2004 self
2005 }
2006
2007 pub fn min_h(&mut self, value: u32) -> &mut Self {
2009 self.modify_last_constraints(|c| c.min_height = Some(value));
2010 self
2011 }
2012
2013 pub fn max_h(&mut self, value: u32) -> &mut Self {
2015 self.modify_last_constraints(|c| c.max_height = Some(value));
2016 self
2017 }
2018
2019 pub fn m(&mut self, value: u32) -> &mut Self {
2023 self.modify_last_margin(|m| *m = Margin::all(value));
2024 self
2025 }
2026
2027 pub fn mx(&mut self, value: u32) -> &mut Self {
2029 self.modify_last_margin(|m| {
2030 m.left = value;
2031 m.right = value;
2032 });
2033 self
2034 }
2035
2036 pub fn my(&mut self, value: u32) -> &mut Self {
2038 self.modify_last_margin(|m| {
2039 m.top = value;
2040 m.bottom = value;
2041 });
2042 self
2043 }
2044
2045 pub fn mt(&mut self, value: u32) -> &mut Self {
2047 self.modify_last_margin(|m| m.top = value);
2048 self
2049 }
2050
2051 pub fn mr(&mut self, value: u32) -> &mut Self {
2053 self.modify_last_margin(|m| m.right = value);
2054 self
2055 }
2056
2057 pub fn mb(&mut self, value: u32) -> &mut Self {
2059 self.modify_last_margin(|m| m.bottom = value);
2060 self
2061 }
2062
2063 pub fn ml(&mut self, value: u32) -> &mut Self {
2065 self.modify_last_margin(|m| m.left = value);
2066 self
2067 }
2068
2069 pub fn spacer(&mut self) -> &mut Self {
2073 self.commands.push(Command::Spacer { grow: 1 });
2074 self.last_text_idx = None;
2075 self
2076 }
2077
2078 pub fn form(
2082 &mut self,
2083 state: &mut FormState,
2084 f: impl FnOnce(&mut Context, &mut FormState),
2085 ) -> &mut Self {
2086 let _ = self.col(|ui| {
2087 f(ui, state);
2088 });
2089 self
2090 }
2091
2092 pub fn form_field(&mut self, field: &mut FormField) -> &mut Self {
2096 let _ = self.col(|ui| {
2097 ui.styled(field.label.clone(), Style::new().bold().fg(ui.theme.text));
2098 let _ = ui.text_input(&mut field.input);
2099 if let Some(error) = field.error.as_deref() {
2100 ui.styled(error.to_string(), Style::new().dim().fg(ui.theme.error));
2101 }
2102 });
2103 self
2104 }
2105
2106 pub fn form_submit(&mut self, label: impl Into<String>) -> Response {
2110 self.button(label)
2111 }
2112}
2113
2114fn wrap_tooltip_text(text: &str, max_width: usize) -> Vec<String> {
2115 let max_width = max_width.max(1);
2116 let mut lines = Vec::new();
2117
2118 for paragraph in text.lines() {
2119 if paragraph.trim().is_empty() {
2120 lines.push(String::new());
2121 continue;
2122 }
2123
2124 let mut current = String::new();
2125 let mut current_width = 0usize;
2126
2127 for word in paragraph.split_whitespace() {
2128 for chunk in split_word_for_width(word, max_width) {
2129 let chunk_width = UnicodeWidthStr::width(chunk.as_str());
2130
2131 if current.is_empty() {
2132 current = chunk;
2133 current_width = chunk_width;
2134 continue;
2135 }
2136
2137 if current_width + 1 + chunk_width <= max_width {
2138 current.push(' ');
2139 current.push_str(&chunk);
2140 current_width += 1 + chunk_width;
2141 } else {
2142 lines.push(std::mem::take(&mut current));
2143 current = chunk;
2144 current_width = chunk_width;
2145 }
2146 }
2147 }
2148
2149 if !current.is_empty() {
2150 lines.push(current);
2151 }
2152 }
2153
2154 if lines.is_empty() {
2155 lines.push(String::new());
2156 }
2157
2158 lines
2159}
2160
2161fn split_word_for_width(word: &str, max_width: usize) -> Vec<String> {
2162 let mut chunks = Vec::new();
2163 let mut current = String::new();
2164 let mut current_width = 0usize;
2165
2166 for ch in word.chars() {
2167 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
2168 if !current.is_empty() && current_width + ch_width > max_width {
2169 chunks.push(std::mem::take(&mut current));
2170 current_width = 0;
2171 }
2172 current.push(ch);
2173 current_width += ch_width;
2174
2175 if current_width >= max_width {
2176 chunks.push(std::mem::take(&mut current));
2177 current_width = 0;
2178 }
2179 }
2180
2181 if !current.is_empty() {
2182 chunks.push(current);
2183 }
2184
2185 if chunks.is_empty() {
2186 chunks.push(String::new());
2187 }
2188
2189 chunks
2190}
2191
2192fn glyph_8x8(ch: char) -> [u8; 8] {
2193 if ch.is_ascii() {
2194 let code = ch as u8;
2195 if (32..=126).contains(&code) {
2196 return FONT_8X8_PRINTABLE[(code - 32) as usize];
2197 }
2198 }
2199
2200 FONT_8X8_PRINTABLE[(b'?' - 32) as usize]
2201}
2202
2203const FONT_8X8_PRINTABLE: [[u8; 8]; 95] = [
2204 [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
2205 [0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00],
2206 [0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
2207 [0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00],
2208 [0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00],
2209 [0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00],
2210 [0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00],
2211 [0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00],
2212 [0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00],
2213 [0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00],
2214 [0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00],
2215 [0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00],
2216 [0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06],
2217 [0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00],
2218 [0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00],
2219 [0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00],
2220 [0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00],
2221 [0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00],
2222 [0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00],
2223 [0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00],
2224 [0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00],
2225 [0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00],
2226 [0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00],
2227 [0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00],
2228 [0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00],
2229 [0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00],
2230 [0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00],
2231 [0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06],
2232 [0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00],
2233 [0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00],
2234 [0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00],
2235 [0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00],
2236 [0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00],
2237 [0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00],
2238 [0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00],
2239 [0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00],
2240 [0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00],
2241 [0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00],
2242 [0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00],
2243 [0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00],
2244 [0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00],
2245 [0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00],
2246 [0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00],
2247 [0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00],
2248 [0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00],
2249 [0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00],
2250 [0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00],
2251 [0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00],
2252 [0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00],
2253 [0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00],
2254 [0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00],
2255 [0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00],
2256 [0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00],
2257 [0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00],
2258 [0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00],
2259 [0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00],
2260 [0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00],
2261 [0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00],
2262 [0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00],
2263 [0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00],
2264 [0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00],
2265 [0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00],
2266 [0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00],
2267 [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF],
2268 [0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00],
2269 [0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00],
2270 [0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00],
2271 [0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00],
2272 [0x38, 0x30, 0x30, 0x3E, 0x33, 0x33, 0x6E, 0x00],
2273 [0x00, 0x00, 0x1E, 0x33, 0x3F, 0x03, 0x1E, 0x00],
2274 [0x1C, 0x36, 0x06, 0x0F, 0x06, 0x06, 0x0F, 0x00],
2275 [0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F],
2276 [0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00],
2277 [0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00],
2278 [0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E],
2279 [0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00],
2280 [0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00],
2281 [0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00],
2282 [0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00],
2283 [0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00],
2284 [0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F],
2285 [0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78],
2286 [0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00],
2287 [0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00],
2288 [0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00],
2289 [0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00],
2290 [0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00],
2291 [0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00],
2292 [0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00],
2293 [0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F],
2294 [0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00],
2295 [0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00],
2296 [0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00],
2297 [0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00],
2298 [0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
2299];
2300
2301const KEYWORDS: &[&str] = &[
2302 "fn",
2303 "let",
2304 "mut",
2305 "pub",
2306 "use",
2307 "impl",
2308 "struct",
2309 "enum",
2310 "trait",
2311 "type",
2312 "const",
2313 "static",
2314 "if",
2315 "else",
2316 "match",
2317 "for",
2318 "while",
2319 "loop",
2320 "return",
2321 "break",
2322 "continue",
2323 "where",
2324 "self",
2325 "super",
2326 "crate",
2327 "mod",
2328 "async",
2329 "await",
2330 "move",
2331 "ref",
2332 "in",
2333 "as",
2334 "true",
2335 "false",
2336 "Some",
2337 "None",
2338 "Ok",
2339 "Err",
2340 "Self",
2341 "def",
2342 "class",
2343 "import",
2344 "from",
2345 "pass",
2346 "lambda",
2347 "yield",
2348 "with",
2349 "try",
2350 "except",
2351 "raise",
2352 "finally",
2353 "elif",
2354 "del",
2355 "global",
2356 "nonlocal",
2357 "assert",
2358 "is",
2359 "not",
2360 "and",
2361 "or",
2362 "function",
2363 "var",
2364 "const",
2365 "export",
2366 "default",
2367 "switch",
2368 "case",
2369 "throw",
2370 "catch",
2371 "typeof",
2372 "instanceof",
2373 "new",
2374 "delete",
2375 "void",
2376 "this",
2377 "null",
2378 "undefined",
2379 "func",
2380 "package",
2381 "defer",
2382 "go",
2383 "chan",
2384 "select",
2385 "range",
2386 "map",
2387 "interface",
2388 "fallthrough",
2389 "nil",
2390];
2391
2392fn render_highlighted_line(ui: &mut Context, line: &str) {
2393 let theme = ui.theme;
2394 let is_light = matches!(
2395 theme.bg,
2396 Color::Reset | Color::White | Color::Rgb(255, 255, 255)
2397 );
2398 let keyword_color = if is_light {
2399 Color::Rgb(166, 38, 164)
2400 } else {
2401 Color::Rgb(198, 120, 221)
2402 };
2403 let string_color = if is_light {
2404 Color::Rgb(80, 161, 79)
2405 } else {
2406 Color::Rgb(152, 195, 121)
2407 };
2408 let comment_color = theme.text_dim;
2409 let number_color = if is_light {
2410 Color::Rgb(152, 104, 1)
2411 } else {
2412 Color::Rgb(209, 154, 102)
2413 };
2414 let fn_color = if is_light {
2415 Color::Rgb(64, 120, 242)
2416 } else {
2417 Color::Rgb(97, 175, 239)
2418 };
2419 let macro_color = if is_light {
2420 Color::Rgb(1, 132, 188)
2421 } else {
2422 Color::Rgb(86, 182, 194)
2423 };
2424
2425 let trimmed = line.trim_start();
2426 let indent = &line[..line.len() - trimmed.len()];
2427 if !indent.is_empty() {
2428 ui.text(indent);
2429 }
2430
2431 if trimmed.starts_with("//") {
2432 ui.text(trimmed).fg(comment_color).italic();
2433 return;
2434 }
2435
2436 let mut pos = 0;
2437
2438 while pos < trimmed.len() {
2439 let ch = trimmed.as_bytes()[pos];
2440
2441 if ch == b'"' {
2442 if let Some(end) = trimmed[pos + 1..].find('"') {
2443 let s = &trimmed[pos..pos + end + 2];
2444 ui.text(s).fg(string_color);
2445 pos += end + 2;
2446 continue;
2447 }
2448 }
2449
2450 if ch.is_ascii_digit() && (pos == 0 || !trimmed.as_bytes()[pos - 1].is_ascii_alphanumeric())
2451 {
2452 let end = trimmed[pos..]
2453 .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '_')
2454 .map_or(trimmed.len(), |e| pos + e);
2455 ui.text(&trimmed[pos..end]).fg(number_color);
2456 pos = end;
2457 continue;
2458 }
2459
2460 if ch.is_ascii_alphabetic() || ch == b'_' {
2461 let end = trimmed[pos..]
2462 .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
2463 .map_or(trimmed.len(), |e| pos + e);
2464 let word = &trimmed[pos..end];
2465
2466 if end < trimmed.len() && trimmed.as_bytes()[end] == b'!' {
2467 ui.text(&trimmed[pos..end + 1]).fg(macro_color);
2468 pos = end + 1;
2469 } else if end < trimmed.len()
2470 && trimmed.as_bytes()[end] == b'('
2471 && !KEYWORDS.contains(&word)
2472 {
2473 ui.text(word).fg(fn_color);
2474 pos = end;
2475 } else if KEYWORDS.contains(&word) {
2476 ui.text(word).fg(keyword_color);
2477 pos = end;
2478 } else {
2479 ui.text(word);
2480 pos = end;
2481 }
2482 continue;
2483 }
2484
2485 let end = trimmed[pos..]
2486 .find(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '"')
2487 .map_or(trimmed.len(), |e| pos + e);
2488 ui.text(&trimmed[pos..end]);
2489 pos = end;
2490 }
2491}
2492
2493fn normalize_rgba(data: &[u8], width: u32, height: u32) -> Vec<u8> {
2494 let expected = (width as usize) * (height as usize) * 4;
2495 if data.len() >= expected {
2496 return data[..expected].to_vec();
2497 }
2498 let mut buf = Vec::with_capacity(expected);
2499 buf.extend_from_slice(data);
2500 buf.resize(expected, 0);
2501 buf
2502}
2503
2504fn base64_encode(data: &[u8]) -> String {
2505 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2506 let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
2507 for chunk in data.chunks(3) {
2508 let b0 = chunk[0] as u32;
2509 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
2510 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
2511 let triple = (b0 << 16) | (b1 << 8) | b2;
2512 result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
2513 result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
2514 if chunk.len() > 1 {
2515 result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
2516 } else {
2517 result.push('=');
2518 }
2519 if chunk.len() > 2 {
2520 result.push(CHARS[(triple & 0x3F) as usize] as char);
2521 } else {
2522 result.push('=');
2523 }
2524 }
2525 result
2526}
2527
2528fn split_base64(encoded: &str, chunk_size: usize) -> Vec<&str> {
2529 let mut chunks = Vec::new();
2530 let bytes = encoded.as_bytes();
2531 let mut offset = 0;
2532 while offset < bytes.len() {
2533 let end = (offset + chunk_size).min(bytes.len());
2534 chunks.push(&encoded[offset..end]);
2535 offset = end;
2536 }
2537 if chunks.is_empty() {
2538 chunks.push("");
2539 }
2540 chunks
2541}
2542
2543fn terminal_supports_sixel() -> bool {
2544 let force = std::env::var("SLT_FORCE_SIXEL")
2545 .ok()
2546 .map(|v| v.to_ascii_lowercase())
2547 .unwrap_or_default();
2548 if matches!(force.as_str(), "1" | "true" | "yes" | "on") {
2549 return true;
2550 }
2551
2552 let term = std::env::var("TERM")
2553 .ok()
2554 .map(|v| v.to_ascii_lowercase())
2555 .unwrap_or_default();
2556 let term_program = std::env::var("TERM_PROGRAM")
2557 .ok()
2558 .map(|v| v.to_ascii_lowercase())
2559 .unwrap_or_default();
2560
2561 term.contains("sixel")
2562 || term.contains("mlterm")
2563 || term.contains("xterm")
2564 || term.contains("foot")
2565 || term_program.contains("foot")
2566}
2567
2568#[cfg(test)]
2569mod tests {
2570 use super::*;
2571 use crate::TestBackend;
2572 use std::time::Duration;
2573
2574 #[test]
2575 fn gradient_text_renders_content() {
2576 let mut backend = TestBackend::new(20, 4);
2577 backend.render(|ui| {
2578 ui.text("ABCD").gradient(Color::Red, Color::Blue);
2579 });
2580
2581 backend.assert_contains("ABCD");
2582 }
2583
2584 #[test]
2585 fn big_text_renders_half_block_grid() {
2586 let mut backend = TestBackend::new(16, 4);
2587 backend.render(|ui| {
2588 let _ = ui.big_text("A");
2589 });
2590
2591 let output = backend.to_string();
2592 assert!(
2594 output.contains('▀') || output.contains('▄') || output.contains('█'),
2595 "output should contain half-block glyphs: {output:?}"
2596 );
2597 }
2598
2599 #[test]
2600 fn timer_display_formats_minutes_seconds_centis() {
2601 let mut backend = TestBackend::new(20, 4);
2602 backend.render(|ui| {
2603 ui.timer_display(Duration::from_secs(83) + Duration::from_millis(450));
2604 });
2605
2606 backend.assert_contains("01:23.45");
2607 }
2608}