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 help_from_keymap(&mut self, keymap: &KeyMap) -> Response {
126 let pairs: Vec<(&str, &str)> = keymap
127 .visible_bindings()
128 .map(|binding| (binding.display.as_str(), binding.description.as_str()))
129 .collect();
130 self.help(&pairs)
131 }
132
133 pub fn bold(&mut self) -> &mut Self {
137 self.modify_last_style(|s| s.modifiers |= Modifiers::BOLD);
138 self
139 }
140
141 pub fn dim(&mut self) -> &mut Self {
146 let text_dim = self.theme.text_dim;
147 self.modify_last_style(|s| {
148 s.modifiers |= Modifiers::DIM;
149 if s.fg.is_none() {
150 s.fg = Some(text_dim);
151 }
152 });
153 self
154 }
155
156 pub fn italic(&mut self) -> &mut Self {
158 self.modify_last_style(|s| s.modifiers |= Modifiers::ITALIC);
159 self
160 }
161
162 pub fn underline(&mut self) -> &mut Self {
164 self.modify_last_style(|s| s.modifiers |= Modifiers::UNDERLINE);
165 self
166 }
167
168 pub fn reversed(&mut self) -> &mut Self {
170 self.modify_last_style(|s| s.modifiers |= Modifiers::REVERSED);
171 self
172 }
173
174 pub fn strikethrough(&mut self) -> &mut Self {
176 self.modify_last_style(|s| s.modifiers |= Modifiers::STRIKETHROUGH);
177 self
178 }
179
180 pub fn fg(&mut self, color: Color) -> &mut Self {
182 self.modify_last_style(|s| s.fg = Some(color));
183 self
184 }
185
186 pub fn bg(&mut self, color: Color) -> &mut Self {
188 self.modify_last_style(|s| s.bg = Some(color));
189 self
190 }
191
192 pub fn group_hover_fg(&mut self, color: Color) -> &mut Self {
193 let apply_group_style = self
194 .group_stack
195 .last()
196 .map(|name| self.is_group_hovered(name) || self.is_group_focused(name))
197 .unwrap_or(false);
198 if apply_group_style {
199 self.modify_last_style(|s| s.fg = Some(color));
200 }
201 self
202 }
203
204 pub fn group_hover_bg(&mut self, color: Color) -> &mut Self {
205 let apply_group_style = self
206 .group_stack
207 .last()
208 .map(|name| self.is_group_hovered(name) || self.is_group_focused(name))
209 .unwrap_or(false);
210 if apply_group_style {
211 self.modify_last_style(|s| s.bg = Some(color));
212 }
213 self
214 }
215
216 pub fn styled(&mut self, s: impl Into<String>, style: Style) -> &mut Self {
221 self.commands.push(Command::Text {
222 content: s.into(),
223 style,
224 grow: 0,
225 align: Align::Start,
226 wrap: false,
227 truncate: false,
228 margin: Margin::default(),
229 constraints: Constraints::default(),
230 });
231 self.last_text_idx = Some(self.commands.len() - 1);
232 self
233 }
234
235 pub fn image(&mut self, img: &HalfBlockImage) -> Response {
257 let width = img.width;
258 let height = img.height;
259
260 let _ = self.container().w(width).h(height).gap(0).col(|ui| {
261 for row in 0..height {
262 let _ = ui.container().gap(0).row(|ui| {
263 for col in 0..width {
264 let idx = (row * width + col) as usize;
265 if let Some(&(upper, lower)) = img.pixels.get(idx) {
266 ui.styled("▀", Style::new().fg(upper).bg(lower));
267 }
268 }
269 });
270 }
271 });
272
273 Response::none()
274 }
275
276 pub fn kitty_image(
292 &mut self,
293 rgba: &[u8],
294 pixel_width: u32,
295 pixel_height: u32,
296 cols: u32,
297 rows: u32,
298 ) -> Response {
299 let rgba = normalize_rgba(rgba, pixel_width, pixel_height);
300 let encoded = base64_encode(&rgba);
301 let pw = pixel_width;
302 let ph = pixel_height;
303 let c = cols;
304 let r = rows;
305
306 self.container().w(cols).h(rows).draw(move |buf, rect| {
307 let chunks = split_base64(&encoded, 4096);
308 let mut all_sequences = String::new();
309
310 for (i, chunk) in chunks.iter().enumerate() {
311 let more = if i < chunks.len() - 1 { 1 } else { 0 };
312 if i == 0 {
313 all_sequences.push_str(&format!(
314 "\x1b_Ga=T,f=32,s={},v={},c={},r={},C=1,q=2,m={};{}\x1b\\",
315 pw, ph, c, r, more, chunk
316 ));
317 } else {
318 all_sequences.push_str(&format!("\x1b_Gm={};{}\x1b\\", more, chunk));
319 }
320 }
321
322 buf.raw_sequence(rect.x, rect.y, all_sequences);
323 });
324 Response::none()
325 }
326
327 pub fn kitty_image_fit(
336 &mut self,
337 rgba: &[u8],
338 src_width: u32,
339 src_height: u32,
340 cols: u32,
341 ) -> Response {
342 let rows = if src_width == 0 {
343 1
344 } else {
345 ((cols as f64 * src_height as f64 * 8.0) / (src_width as f64 * 16.0))
346 .ceil()
347 .max(1.0) as u32
348 };
349 let rgba = normalize_rgba(rgba, src_width, src_height);
350 let sw = src_width;
351 let sh = src_height;
352 let c = cols;
353 let r = rows;
354
355 self.container().w(cols).h(rows).draw(move |buf, rect| {
356 if rect.width == 0 || rect.height == 0 {
357 return;
358 }
359 let encoded = base64_encode(&rgba);
360 let chunks = split_base64(&encoded, 4096);
361 let mut seq = String::new();
362 for (i, chunk) in chunks.iter().enumerate() {
363 let more = if i < chunks.len() - 1 { 1 } else { 0 };
364 if i == 0 {
365 seq.push_str(&format!(
366 "\x1b_Ga=T,f=32,s={},v={},c={},r={},C=1,q=2,m={};{}\x1b\\",
367 sw, sh, c, r, more, chunk
368 ));
369 } else {
370 seq.push_str(&format!("\x1b_Gm={};{}\x1b\\", more, chunk));
371 }
372 }
373 buf.raw_sequence(rect.x, rect.y, seq);
374 });
375 Response::none()
376 }
377
378 pub fn sixel_image(
379 &mut self,
380 rgba: &[u8],
381 pixel_w: u32,
382 pixel_h: u32,
383 cols: u32,
384 rows: u32,
385 ) -> Response {
386 let sixel_supported = self.is_real_terminal && terminal_supports_sixel();
387 if !sixel_supported {
388 self.container().w(cols).h(rows).draw(|buf, rect| {
389 if rect.width == 0 || rect.height == 0 {
390 return;
391 }
392 buf.set_string(rect.x, rect.y, "[sixel unsupported]", Style::new());
393 });
394 return Response::none();
395 }
396
397 let rgba = normalize_rgba(rgba, pixel_w, pixel_h);
398 let encoded = crate::sixel::encode_sixel(&rgba, pixel_w, pixel_h, 256);
399 if encoded.is_empty() {
400 self.container().w(cols).h(rows).draw(|buf, rect| {
401 if rect.width == 0 || rect.height == 0 {
402 return;
403 }
404 buf.set_string(rect.x, rect.y, "[sixel empty]", Style::new());
405 });
406 return Response::none();
407 }
408
409 self.container().w(cols).h(rows).draw(move |buf, rect| {
410 if rect.width == 0 || rect.height == 0 {
411 return;
412 }
413 buf.raw_sequence(rect.x, rect.y, encoded);
414 });
415 Response::none()
416 }
417
418 pub fn streaming_text(&mut self, state: &mut StreamingTextState) -> Response {
434 if state.streaming {
435 state.cursor_tick = state.cursor_tick.wrapping_add(1);
436 state.cursor_visible = (state.cursor_tick / 8) % 2 == 0;
437 }
438
439 if state.content.is_empty() && state.streaming {
440 let cursor = if state.cursor_visible { "▌" } else { " " };
441 let primary = self.theme.primary;
442 self.text(cursor).fg(primary);
443 return Response::none();
444 }
445
446 if !state.content.is_empty() {
447 if state.streaming && state.cursor_visible {
448 self.text_wrap(format!("{}▌", state.content));
449 } else {
450 self.text_wrap(&state.content);
451 }
452 }
453
454 Response::none()
455 }
456
457 pub fn streaming_markdown(
475 &mut self,
476 state: &mut crate::widgets::StreamingMarkdownState,
477 ) -> Response {
478 if state.streaming {
479 state.cursor_tick = state.cursor_tick.wrapping_add(1);
480 state.cursor_visible = (state.cursor_tick / 8) % 2 == 0;
481 }
482
483 if state.content.is_empty() && state.streaming {
484 let cursor = if state.cursor_visible { "▌" } else { " " };
485 let primary = self.theme.primary;
486 self.text(cursor).fg(primary);
487 return Response::none();
488 }
489
490 let show_cursor = state.streaming && state.cursor_visible;
491 let trailing_newline = state.content.ends_with('\n');
492 let lines: Vec<&str> = state.content.lines().collect();
493 let last_line_index = lines.len().saturating_sub(1);
494
495 self.commands.push(Command::BeginContainer {
496 direction: Direction::Column,
497 gap: 0,
498 align: Align::Start,
499 align_self: None,
500 justify: Justify::Start,
501 border: None,
502 border_sides: BorderSides::all(),
503 border_style: Style::new().fg(self.theme.border),
504 bg_color: None,
505 padding: Padding::default(),
506 margin: Margin::default(),
507 constraints: Constraints::default(),
508 title: None,
509 grow: 0,
510 group_name: None,
511 });
512 self.interaction_count += 1;
513
514 let text_style = Style::new().fg(self.theme.text);
515 let bold_style = Style::new().fg(self.theme.text).bold();
516 let code_style = Style::new().fg(self.theme.accent);
517 let border_style = Style::new().fg(self.theme.border).dim();
518
519 let mut in_code_block = false;
520 let mut code_block_lang = String::new();
521
522 for (idx, line) in lines.iter().enumerate() {
523 let line = *line;
524 let trimmed = line.trim();
525 let append_cursor = show_cursor && !trailing_newline && idx == last_line_index;
526 let cursor = if append_cursor { "▌" } else { "" };
527
528 if in_code_block {
529 if trimmed.starts_with("```") {
530 in_code_block = false;
531 code_block_lang.clear();
532 let mut line = String::from(" └────");
533 line.push_str(cursor);
534 self.styled(line, border_style);
535 } else {
536 let mut line_text = String::with_capacity(2 + line.len() + cursor.len());
537 line_text.push_str(" ");
538 line_text.push_str(line);
539 line_text.push_str(cursor);
540 self.styled(line_text, code_style);
541 }
542 continue;
543 }
544
545 if trimmed.is_empty() {
546 if append_cursor {
547 self.styled("▌", Style::new().fg(self.theme.primary));
548 } else {
549 self.text(" ");
550 }
551 continue;
552 }
553
554 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
555 let mut line = "─".repeat(40);
556 line.push_str(cursor);
557 self.styled(line, border_style);
558 continue;
559 }
560
561 if let Some(heading) = trimmed.strip_prefix("### ") {
562 let mut line = String::with_capacity(heading.len() + cursor.len());
563 line.push_str(heading);
564 line.push_str(cursor);
565 self.styled(line, Style::new().bold().fg(self.theme.accent));
566 continue;
567 }
568
569 if let Some(heading) = trimmed.strip_prefix("## ") {
570 let mut line = String::with_capacity(heading.len() + cursor.len());
571 line.push_str(heading);
572 line.push_str(cursor);
573 self.styled(line, Style::new().bold().fg(self.theme.secondary));
574 continue;
575 }
576
577 if let Some(heading) = trimmed.strip_prefix("# ") {
578 let mut line = String::with_capacity(heading.len() + cursor.len());
579 line.push_str(heading);
580 line.push_str(cursor);
581 self.styled(line, Style::new().bold().fg(self.theme.primary));
582 continue;
583 }
584
585 if let Some(code) = trimmed.strip_prefix("```") {
586 in_code_block = true;
587 code_block_lang = code.trim().to_string();
588 let label = if code_block_lang.is_empty() {
589 "code".to_string()
590 } else {
591 let mut label = String::from("code:");
592 label.push_str(&code_block_lang);
593 label
594 };
595 let mut line = String::with_capacity(5 + label.len() + cursor.len());
596 line.push_str(" ┌─");
597 line.push_str(&label);
598 line.push('─');
599 line.push_str(cursor);
600 self.styled(line, border_style);
601 continue;
602 }
603
604 if let Some(item) = trimmed
605 .strip_prefix("- ")
606 .or_else(|| trimmed.strip_prefix("* "))
607 {
608 let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
609 if segs.len() <= 1 {
610 let mut line = String::with_capacity(4 + item.len() + cursor.len());
611 line.push_str(" • ");
612 line.push_str(item);
613 line.push_str(cursor);
614 self.styled(line, text_style);
615 } else {
616 self.line(|ui| {
617 ui.styled(" • ", text_style);
618 for (s, st) in segs {
619 ui.styled(s, st);
620 }
621 if append_cursor {
622 ui.styled("▌", Style::new().fg(ui.theme.primary));
623 }
624 });
625 }
626 continue;
627 }
628
629 if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
630 let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
631 if parts.len() == 2 {
632 let segs =
633 Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
634 if segs.len() <= 1 {
635 let mut line = String::with_capacity(
636 4 + parts[0].len() + parts[1].len() + cursor.len(),
637 );
638 line.push_str(" ");
639 line.push_str(parts[0]);
640 line.push_str(". ");
641 line.push_str(parts[1]);
642 line.push_str(cursor);
643 self.styled(line, text_style);
644 } else {
645 self.line(|ui| {
646 let mut prefix = String::with_capacity(4 + parts[0].len());
647 prefix.push_str(" ");
648 prefix.push_str(parts[0]);
649 prefix.push_str(". ");
650 ui.styled(prefix, text_style);
651 for (s, st) in segs {
652 ui.styled(s, st);
653 }
654 if append_cursor {
655 ui.styled("▌", Style::new().fg(ui.theme.primary));
656 }
657 });
658 }
659 } else {
660 let mut line = String::with_capacity(trimmed.len() + cursor.len());
661 line.push_str(trimmed);
662 line.push_str(cursor);
663 self.styled(line, text_style);
664 }
665 continue;
666 }
667
668 let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
669 if segs.len() <= 1 {
670 let mut line = String::with_capacity(trimmed.len() + cursor.len());
671 line.push_str(trimmed);
672 line.push_str(cursor);
673 self.styled(line, text_style);
674 } else {
675 self.line(|ui| {
676 for (s, st) in segs {
677 ui.styled(s, st);
678 }
679 if append_cursor {
680 ui.styled("▌", Style::new().fg(ui.theme.primary));
681 }
682 });
683 }
684 }
685
686 if show_cursor && trailing_newline {
687 if in_code_block {
688 self.styled(" ▌", code_style);
689 } else {
690 self.styled("▌", Style::new().fg(self.theme.primary));
691 }
692 }
693
694 state.in_code_block = in_code_block;
695 state.code_block_lang = code_block_lang;
696
697 self.commands.push(Command::EndContainer);
698 self.last_text_idx = None;
699 Response::none()
700 }
701
702 pub fn tool_approval(&mut self, state: &mut ToolApprovalState) -> Response {
717 let old_action = state.action;
718 let theme = self.theme;
719 let _ = self.bordered(Border::Rounded).col(|ui| {
720 let _ = ui.row(|ui| {
721 ui.text("⚡").fg(theme.warning);
722 ui.text(&state.tool_name).bold().fg(theme.primary);
723 });
724 ui.text(&state.description).dim();
725
726 if state.action == ApprovalAction::Pending {
727 let _ = ui.row(|ui| {
728 if ui.button("✓ Approve").clicked {
729 state.action = ApprovalAction::Approved;
730 }
731 if ui.button("✗ Reject").clicked {
732 state.action = ApprovalAction::Rejected;
733 }
734 });
735 } else {
736 let (label, color) = match state.action {
737 ApprovalAction::Approved => ("✓ Approved", theme.success),
738 ApprovalAction::Rejected => ("✗ Rejected", theme.error),
739 ApprovalAction::Pending => unreachable!(),
740 };
741 ui.text(label).fg(color).bold();
742 }
743 });
744
745 Response {
746 changed: state.action != old_action,
747 ..Response::none()
748 }
749 }
750
751 pub fn context_bar(&mut self, items: &[ContextItem]) -> Response {
764 if items.is_empty() {
765 return Response::none();
766 }
767
768 let theme = self.theme;
769 let total: usize = items.iter().map(|item| item.tokens).sum();
770
771 let _ = self.container().row(|ui| {
772 ui.text("📎").dim();
773 for item in items {
774 let token_count = format_token_count(item.tokens);
775 let mut line = String::with_capacity(item.label.len() + token_count.len() + 3);
776 line.push_str(&item.label);
777 line.push_str(" (");
778 line.push_str(&token_count);
779 line.push(')');
780 ui.text(line).fg(theme.secondary);
781 }
782 ui.spacer();
783 let total_text = format_token_count(total);
784 let mut line = String::with_capacity(2 + total_text.len());
785 line.push_str("Σ ");
786 line.push_str(&total_text);
787 ui.text(line).dim();
788 });
789
790 Response::none()
791 }
792
793 pub fn alert(&mut self, message: &str, level: crate::widgets::AlertLevel) -> Response {
794 use crate::widgets::AlertLevel;
795
796 let theme = self.theme;
797 let (icon, color) = match level {
798 AlertLevel::Info => ("ℹ", theme.accent),
799 AlertLevel::Success => ("✓", theme.success),
800 AlertLevel::Warning => ("⚠", theme.warning),
801 AlertLevel::Error => ("✕", theme.error),
802 };
803
804 let focused = self.register_focusable();
805 let key_dismiss = focused && (self.key_code(KeyCode::Enter) || self.key('x'));
806
807 let mut response = self.container().col(|ui| {
808 ui.line(|ui| {
809 let mut icon_text = String::with_capacity(icon.len() + 2);
810 icon_text.push(' ');
811 icon_text.push_str(icon);
812 icon_text.push(' ');
813 ui.text(icon_text).fg(color).bold();
814 ui.text(message).grow(1);
815 ui.text(" [×] ").dim();
816 });
817 });
818 response.focused = focused;
819 if key_dismiss {
820 response.clicked = true;
821 }
822
823 response
824 }
825
826 pub fn confirm(&mut self, question: &str, result: &mut bool) -> Response {
840 let focused = self.register_focusable();
841 let mut is_yes = *result;
842 let mut clicked = false;
843
844 if focused {
845 let mut consumed_indices = Vec::new();
846 for (i, event) in self.events.iter().enumerate() {
847 if let Event::Key(key) = event {
848 if key.kind != KeyEventKind::Press {
849 continue;
850 }
851
852 match key.code {
853 KeyCode::Char('y') => {
854 is_yes = true;
855 *result = true;
856 clicked = true;
857 consumed_indices.push(i);
858 }
859 KeyCode::Char('n') => {
860 is_yes = false;
861 *result = false;
862 clicked = true;
863 consumed_indices.push(i);
864 }
865 KeyCode::Tab | KeyCode::BackTab | KeyCode::Left | KeyCode::Right => {
866 is_yes = !is_yes;
867 *result = is_yes;
868 consumed_indices.push(i);
869 }
870 KeyCode::Enter => {
871 *result = is_yes;
872 clicked = true;
873 consumed_indices.push(i);
874 }
875 _ => {}
876 }
877 }
878 }
879
880 for idx in consumed_indices {
881 self.consumed[idx] = true;
882 }
883 }
884
885 let yes_style = if is_yes {
886 if focused {
887 Style::new().fg(self.theme.bg).bg(self.theme.success).bold()
888 } else {
889 Style::new().fg(self.theme.success).bold()
890 }
891 } else {
892 Style::new().fg(self.theme.text_dim)
893 };
894 let no_style = if !is_yes {
895 if focused {
896 Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
897 } else {
898 Style::new().fg(self.theme.error).bold()
899 }
900 } else {
901 Style::new().fg(self.theme.text_dim)
902 };
903
904 let q_width = UnicodeWidthStr::width(question) as u32;
905 let mut response = self.row(|ui| {
906 ui.text(question);
907 ui.text(" ");
908 ui.styled("[Yes]", yes_style);
909 ui.text(" ");
910 ui.styled("[No]", no_style);
911 });
912
913 if !clicked && response.clicked {
914 if let Some((mx, _)) = self.click_pos {
915 let yes_start = response.rect.x + q_width + 1;
916 let yes_end = yes_start + 5;
917 let no_start = yes_end + 1;
918 if mx >= yes_start && mx < yes_end {
919 is_yes = true;
920 *result = true;
921 clicked = true;
922 } else if mx >= no_start {
923 is_yes = false;
924 *result = false;
925 clicked = true;
926 }
927 }
928 }
929
930 response.focused = focused;
931 response.clicked = clicked;
932 response.changed = clicked;
933 let _ = is_yes;
934 response
935 }
936
937 pub fn breadcrumb(&mut self, segments: &[&str]) -> Option<usize> {
938 self.breadcrumb_with(segments, " › ")
939 }
940
941 pub fn breadcrumb_with(&mut self, segments: &[&str], separator: &str) -> Option<usize> {
942 let theme = self.theme;
943 let last_idx = segments.len().saturating_sub(1);
944 let mut clicked_idx: Option<usize> = None;
945
946 let _ = self.row(|ui| {
947 for (i, segment) in segments.iter().enumerate() {
948 let is_last = i == last_idx;
949 if is_last {
950 ui.text(*segment).bold();
951 } else {
952 let focused = ui.register_focusable();
953 let pressed = focused && (ui.key_code(KeyCode::Enter) || ui.key(' '));
954 let resp = ui.interaction();
955 let color = if resp.hovered || focused {
956 theme.accent
957 } else {
958 theme.primary
959 };
960 ui.text(*segment).fg(color).underline();
961 if resp.clicked || pressed {
962 clicked_idx = Some(i);
963 }
964 ui.text(separator).dim();
965 }
966 }
967 });
968
969 clicked_idx
970 }
971
972 pub fn accordion(
973 &mut self,
974 title: &str,
975 open: &mut bool,
976 f: impl FnOnce(&mut Context),
977 ) -> Response {
978 let theme = self.theme;
979 let focused = self.register_focusable();
980 let old_open = *open;
981
982 if focused && self.key_code(KeyCode::Enter) {
983 *open = !*open;
984 }
985
986 let icon = if *open { "▾" } else { "▸" };
987 let title_color = if focused { theme.primary } else { theme.text };
988
989 let mut response = self.container().col(|ui| {
990 ui.line(|ui| {
991 ui.text(icon).fg(title_color);
992 let mut title_text = String::with_capacity(1 + title.len());
993 title_text.push(' ');
994 title_text.push_str(title);
995 ui.text(title_text).bold().fg(title_color);
996 });
997 });
998
999 if response.clicked {
1000 *open = !*open;
1001 }
1002
1003 if *open {
1004 let _ = self.container().pl(2).col(f);
1005 }
1006
1007 response.focused = focused;
1008 response.changed = *open != old_open;
1009 response
1010 }
1011
1012 pub fn definition_list(&mut self, items: &[(&str, &str)]) -> Response {
1013 let max_key_width = items
1014 .iter()
1015 .map(|(k, _)| unicode_width::UnicodeWidthStr::width(*k))
1016 .max()
1017 .unwrap_or(0);
1018
1019 let _ = self.col(|ui| {
1020 for (key, value) in items {
1021 ui.line(|ui| {
1022 let padded = format!("{:>width$}", key, width = max_key_width);
1023 ui.text(padded).dim();
1024 ui.text(" ");
1025 ui.text(*value);
1026 });
1027 }
1028 });
1029
1030 Response::none()
1031 }
1032
1033 pub fn divider_text(&mut self, label: &str) -> Response {
1034 let w = self.width();
1035 let label_len = unicode_width::UnicodeWidthStr::width(label) as u32;
1036 let pad = 1u32;
1037 let left_len = 4u32;
1038 let right_len = w.saturating_sub(left_len + pad + label_len + pad);
1039 let left: String = "─".repeat(left_len as usize);
1040 let right: String = "─".repeat(right_len as usize);
1041 let theme = self.theme;
1042 self.line(|ui| {
1043 ui.text(&left).fg(theme.border);
1044 let mut label_text = String::with_capacity(label.len() + 2);
1045 label_text.push(' ');
1046 label_text.push_str(label);
1047 label_text.push(' ');
1048 ui.text(label_text).fg(theme.text);
1049 ui.text(&right).fg(theme.border);
1050 });
1051
1052 Response::none()
1053 }
1054
1055 pub fn badge(&mut self, label: &str) -> Response {
1056 let theme = self.theme;
1057 self.badge_colored(label, theme.primary)
1058 }
1059
1060 pub fn badge_colored(&mut self, label: &str, color: Color) -> Response {
1061 let fg = Color::contrast_fg(color);
1062 let mut label_text = String::with_capacity(label.len() + 2);
1063 label_text.push(' ');
1064 label_text.push_str(label);
1065 label_text.push(' ');
1066 self.text(label_text).fg(fg).bg(color);
1067
1068 Response::none()
1069 }
1070
1071 pub fn key_hint(&mut self, key: &str) -> Response {
1072 let theme = self.theme;
1073 let mut key_text = String::with_capacity(key.len() + 2);
1074 key_text.push(' ');
1075 key_text.push_str(key);
1076 key_text.push(' ');
1077 self.text(key_text).reversed().fg(theme.text_dim);
1078
1079 Response::none()
1080 }
1081
1082 pub fn stat(&mut self, label: &str, value: &str) -> Response {
1083 let _ = self.col(|ui| {
1084 ui.text(label).dim();
1085 ui.text(value).bold();
1086 });
1087
1088 Response::none()
1089 }
1090
1091 pub fn stat_colored(&mut self, label: &str, value: &str, color: Color) -> Response {
1092 let _ = self.col(|ui| {
1093 ui.text(label).dim();
1094 ui.text(value).bold().fg(color);
1095 });
1096
1097 Response::none()
1098 }
1099
1100 pub fn stat_trend(
1101 &mut self,
1102 label: &str,
1103 value: &str,
1104 trend: crate::widgets::Trend,
1105 ) -> Response {
1106 let theme = self.theme;
1107 let (arrow, color) = match trend {
1108 crate::widgets::Trend::Up => ("↑", theme.success),
1109 crate::widgets::Trend::Down => ("↓", theme.error),
1110 };
1111 let _ = self.col(|ui| {
1112 ui.text(label).dim();
1113 ui.line(|ui| {
1114 ui.text(value).bold();
1115 let mut arrow_text = String::with_capacity(1 + arrow.len());
1116 arrow_text.push(' ');
1117 arrow_text.push_str(arrow);
1118 ui.text(arrow_text).fg(color);
1119 });
1120 });
1121
1122 Response::none()
1123 }
1124
1125 pub fn empty_state(&mut self, title: &str, description: &str) -> Response {
1126 let _ = self.container().center().col(|ui| {
1127 ui.text(title).align(Align::Center);
1128 ui.text(description).dim().align(Align::Center);
1129 });
1130
1131 Response::none()
1132 }
1133
1134 pub fn empty_state_action(
1135 &mut self,
1136 title: &str,
1137 description: &str,
1138 action_label: &str,
1139 ) -> Response {
1140 let mut clicked = false;
1141 let _ = self.container().center().col(|ui| {
1142 ui.text(title).align(Align::Center);
1143 ui.text(description).dim().align(Align::Center);
1144 if ui.button(action_label).clicked {
1145 clicked = true;
1146 }
1147 });
1148
1149 Response {
1150 clicked,
1151 changed: clicked,
1152 ..Response::none()
1153 }
1154 }
1155
1156 pub fn code_block(&mut self, code: &str) -> Response {
1157 let theme = self.theme;
1158 let _ = self
1159 .bordered(Border::Rounded)
1160 .bg(theme.surface)
1161 .pad(1)
1162 .col(|ui| {
1163 for line in code.lines() {
1164 render_highlighted_line(ui, line);
1165 }
1166 });
1167
1168 Response::none()
1169 }
1170
1171 pub fn code_block_numbered(&mut self, code: &str) -> Response {
1172 let lines: Vec<&str> = code.lines().collect();
1173 let gutter_w = format!("{}", lines.len()).len();
1174 let theme = self.theme;
1175 let _ = self
1176 .bordered(Border::Rounded)
1177 .bg(theme.surface)
1178 .pad(1)
1179 .col(|ui| {
1180 for (i, line) in lines.iter().enumerate() {
1181 ui.line(|ui| {
1182 ui.text(format!("{:>gutter_w$} │ ", i + 1))
1183 .fg(theme.text_dim);
1184 render_highlighted_line(ui, line);
1185 });
1186 }
1187 });
1188
1189 Response::none()
1190 }
1191
1192 pub fn wrap(&mut self) -> &mut Self {
1194 if let Some(idx) = self.last_text_idx {
1195 if let Command::Text { wrap, .. } = &mut self.commands[idx] {
1196 *wrap = true;
1197 }
1198 }
1199 self
1200 }
1201
1202 pub fn truncate(&mut self) -> &mut Self {
1205 if let Some(idx) = self.last_text_idx {
1206 if let Command::Text { truncate, .. } = &mut self.commands[idx] {
1207 *truncate = true;
1208 }
1209 }
1210 self
1211 }
1212
1213 fn modify_last_style(&mut self, f: impl FnOnce(&mut Style)) {
1214 if let Some(idx) = self.last_text_idx {
1215 match &mut self.commands[idx] {
1216 Command::Text { style, .. } | Command::Link { style, .. } => f(style),
1217 _ => {}
1218 }
1219 }
1220 }
1221
1222 fn modify_last_constraints(&mut self, f: impl FnOnce(&mut Constraints)) {
1223 if let Some(idx) = self.last_text_idx {
1224 match &mut self.commands[idx] {
1225 Command::Text { constraints, .. } | Command::Link { constraints, .. } => {
1226 f(constraints)
1227 }
1228 _ => {}
1229 }
1230 }
1231 }
1232
1233 fn modify_last_margin(&mut self, f: impl FnOnce(&mut Margin)) {
1234 if let Some(idx) = self.last_text_idx {
1235 match &mut self.commands[idx] {
1236 Command::Text { margin, .. } | Command::Link { margin, .. } => f(margin),
1237 _ => {}
1238 }
1239 }
1240 }
1241
1242 pub fn screen(&mut self, name: &str, screens: &ScreenState, f: impl FnOnce(&mut Context)) {
1245 if screens.current() == name {
1246 f(self);
1247 }
1248 }
1249
1250 pub fn col(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1266 self.push_container(Direction::Column, 0, f)
1267 }
1268
1269 pub fn col_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
1273 self.push_container(Direction::Column, gap, f)
1274 }
1275
1276 pub fn row(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1293 self.push_container(Direction::Row, 0, f)
1294 }
1295
1296 pub fn row_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
1300 self.push_container(Direction::Row, gap, f)
1301 }
1302
1303 pub fn line(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
1320 let _ = self.push_container(Direction::Row, 0, f);
1321 self
1322 }
1323
1324 pub fn line_wrap(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
1343 let start = self.commands.len();
1344 f(self);
1345 let mut segments: Vec<(String, Style)> = Vec::new();
1346 for cmd in self.commands.drain(start..) {
1347 if let Command::Text { content, style, .. } = cmd {
1348 segments.push((content, style));
1349 }
1350 }
1351 self.commands.push(Command::RichText {
1352 segments,
1353 wrap: true,
1354 align: Align::Start,
1355 margin: Margin::default(),
1356 constraints: Constraints::default(),
1357 });
1358 self.last_text_idx = None;
1359 self
1360 }
1361
1362 pub fn modal(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1371 let interaction_id = self.next_interaction_id();
1372 self.commands.push(Command::BeginOverlay { modal: true });
1373 self.overlay_depth += 1;
1374 self.modal_active = true;
1375 self.modal_focus_start = self.focus_count;
1376 f(self);
1377 self.modal_focus_count = self.focus_count.saturating_sub(self.modal_focus_start);
1378 self.overlay_depth = self.overlay_depth.saturating_sub(1);
1379 self.commands.push(Command::EndOverlay);
1380 self.last_text_idx = None;
1381 self.response_for(interaction_id)
1382 }
1383
1384 pub fn overlay(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1386 let interaction_id = self.next_interaction_id();
1387 self.commands.push(Command::BeginOverlay { modal: false });
1388 self.overlay_depth += 1;
1389 f(self);
1390 self.overlay_depth = self.overlay_depth.saturating_sub(1);
1391 self.commands.push(Command::EndOverlay);
1392 self.last_text_idx = None;
1393 self.response_for(interaction_id)
1394 }
1395
1396 pub fn tooltip(&mut self, text: impl Into<String>) {
1404 let tooltip_text = text.into();
1405 if tooltip_text.is_empty() {
1406 return;
1407 }
1408 let last_interaction_id = self.interaction_count.saturating_sub(1);
1409 let last_response = self.response_for(last_interaction_id);
1410 if !last_response.hovered || last_response.rect.width == 0 || last_response.rect.height == 0
1411 {
1412 return;
1413 }
1414 let lines = wrap_tooltip_text(&tooltip_text, 38);
1415 self.pending_tooltips.push(PendingTooltip {
1416 anchor_rect: last_response.rect,
1417 lines,
1418 });
1419 }
1420
1421 pub(crate) fn emit_pending_tooltips(&mut self) {
1422 let tooltips = std::mem::take(&mut self.pending_tooltips);
1423 if tooltips.is_empty() {
1424 return;
1425 }
1426 let area_w = self.area_width;
1427 let area_h = self.area_height;
1428 let surface = self.theme.surface;
1429 let border_color = self.theme.border;
1430 let text_color = self.theme.surface_text;
1431
1432 for tooltip in tooltips {
1433 let content_w = tooltip
1434 .lines
1435 .iter()
1436 .map(|l| UnicodeWidthStr::width(l.as_str()) as u32)
1437 .max()
1438 .unwrap_or(0);
1439 let box_w = content_w.saturating_add(4).min(area_w);
1440 let box_h = (tooltip.lines.len() as u32).saturating_add(4).min(area_h);
1441
1442 let tooltip_x = tooltip.anchor_rect.x.min(area_w.saturating_sub(box_w));
1443 let below_y = tooltip.anchor_rect.bottom();
1444 let tooltip_y = if below_y.saturating_add(box_h) <= area_h {
1445 below_y
1446 } else {
1447 tooltip.anchor_rect.y.saturating_sub(box_h)
1448 };
1449
1450 let lines = tooltip.lines;
1451 let _ = self.overlay(|ui| {
1452 let _ = ui.container().w(area_w).h(area_h).col(|ui| {
1453 let _ = ui
1454 .container()
1455 .ml(tooltip_x)
1456 .mt(tooltip_y)
1457 .max_w(box_w)
1458 .border(Border::Rounded)
1459 .border_fg(border_color)
1460 .bg(surface)
1461 .p(1)
1462 .col(|ui| {
1463 for line in &lines {
1464 ui.text(line.as_str()).fg(text_color);
1465 }
1466 });
1467 });
1468 });
1469 }
1470 }
1471
1472 pub fn group(&mut self, name: &str) -> ContainerBuilder<'_> {
1480 self.group_count = self.group_count.saturating_add(1);
1481 self.group_stack.push(name.to_string());
1482 self.container().group_name(name.to_string())
1483 }
1484
1485 pub fn container(&mut self) -> ContainerBuilder<'_> {
1506 let border = self.theme.border;
1507 ContainerBuilder {
1508 ctx: self,
1509 gap: 0,
1510 row_gap: None,
1511 col_gap: None,
1512 align: Align::Start,
1513 align_self_value: None,
1514 justify: Justify::Start,
1515 border: None,
1516 border_sides: BorderSides::all(),
1517 border_style: Style::new().fg(border),
1518 bg: None,
1519 text_color: None,
1520 dark_bg: None,
1521 dark_border_style: None,
1522 group_hover_bg: None,
1523 group_hover_border_style: None,
1524 group_name: None,
1525 padding: Padding::default(),
1526 margin: Margin::default(),
1527 constraints: Constraints::default(),
1528 title: None,
1529 grow: 0,
1530 scroll_offset: None,
1531 }
1532 }
1533
1534 pub fn scrollable(&mut self, state: &mut ScrollState) -> ContainerBuilder<'_> {
1553 let index = self.scroll_count;
1554 self.scroll_count += 1;
1555 if let Some(&(ch, vh)) = self.prev_scroll_infos.get(index) {
1556 state.set_bounds(ch, vh);
1557 let max = ch.saturating_sub(vh) as usize;
1558 state.offset = state.offset.min(max);
1559 }
1560
1561 let next_id = self.interaction_count;
1562 if let Some(rect) = self.prev_hit_map.get(next_id).copied() {
1563 let inner_rects: Vec<Rect> = self
1564 .prev_scroll_rects
1565 .iter()
1566 .enumerate()
1567 .filter(|&(j, sr)| {
1568 j != index
1569 && sr.width > 0
1570 && sr.height > 0
1571 && sr.x >= rect.x
1572 && sr.right() <= rect.right()
1573 && sr.y >= rect.y
1574 && sr.bottom() <= rect.bottom()
1575 })
1576 .map(|(_, sr)| *sr)
1577 .collect();
1578 self.auto_scroll_nested(&rect, state, &inner_rects);
1579 }
1580
1581 self.container().scroll_offset(state.offset as u32)
1582 }
1583
1584 pub fn scrollbar(&mut self, state: &ScrollState) {
1604 let vh = state.viewport_height();
1605 let ch = state.content_height();
1606 if vh == 0 || ch <= vh {
1607 return;
1608 }
1609
1610 let track_height = vh;
1611 let thumb_height = ((vh as f64 * vh as f64 / ch as f64).ceil() as u32).max(1);
1612 let max_offset = ch.saturating_sub(vh);
1613 let thumb_pos = if max_offset == 0 {
1614 0
1615 } else {
1616 ((state.offset as f64 / max_offset as f64) * (track_height - thumb_height) as f64)
1617 .round() as u32
1618 };
1619
1620 let theme = self.theme;
1621 let track_char = '│';
1622 let thumb_char = '█';
1623
1624 let _ = self.container().w(1).h(track_height).col(|ui| {
1625 for i in 0..track_height {
1626 if i >= thumb_pos && i < thumb_pos + thumb_height {
1627 ui.styled(thumb_char.to_string(), Style::new().fg(theme.primary));
1628 } else {
1629 ui.styled(
1630 track_char.to_string(),
1631 Style::new().fg(theme.text_dim).dim(),
1632 );
1633 }
1634 }
1635 });
1636 }
1637
1638 fn auto_scroll_nested(
1639 &mut self,
1640 rect: &Rect,
1641 state: &mut ScrollState,
1642 inner_scroll_rects: &[Rect],
1643 ) {
1644 let mut to_consume: Vec<usize> = Vec::new();
1645
1646 for (i, event) in self.events.iter().enumerate() {
1647 if self.consumed[i] {
1648 continue;
1649 }
1650 if let Event::Mouse(mouse) = event {
1651 let in_bounds = mouse.x >= rect.x
1652 && mouse.x < rect.right()
1653 && mouse.y >= rect.y
1654 && mouse.y < rect.bottom();
1655 if !in_bounds {
1656 continue;
1657 }
1658 let in_inner = inner_scroll_rects.iter().any(|sr| {
1659 mouse.x >= sr.x
1660 && mouse.x < sr.right()
1661 && mouse.y >= sr.y
1662 && mouse.y < sr.bottom()
1663 });
1664 if in_inner {
1665 continue;
1666 }
1667 match mouse.kind {
1668 MouseKind::ScrollUp => {
1669 state.scroll_up(1);
1670 to_consume.push(i);
1671 }
1672 MouseKind::ScrollDown => {
1673 state.scroll_down(1);
1674 to_consume.push(i);
1675 }
1676 MouseKind::Drag(MouseButton::Left) => {}
1677 _ => {}
1678 }
1679 }
1680 }
1681
1682 for i in to_consume {
1683 self.consumed[i] = true;
1684 }
1685 }
1686
1687 pub fn bordered(&mut self, border: Border) -> ContainerBuilder<'_> {
1691 self.container()
1692 .border(border)
1693 .border_sides(BorderSides::all())
1694 }
1695
1696 fn push_container(
1697 &mut self,
1698 direction: Direction,
1699 gap: u32,
1700 f: impl FnOnce(&mut Context),
1701 ) -> Response {
1702 let interaction_id = self.next_interaction_id();
1703 let border = self.theme.border;
1704
1705 self.commands.push(Command::BeginContainer {
1706 direction,
1707 gap,
1708 align: Align::Start,
1709 align_self: None,
1710 justify: Justify::Start,
1711 border: None,
1712 border_sides: BorderSides::all(),
1713 border_style: Style::new().fg(border),
1714 bg_color: None,
1715 padding: Padding::default(),
1716 margin: Margin::default(),
1717 constraints: Constraints::default(),
1718 title: None,
1719 grow: 0,
1720 group_name: None,
1721 });
1722 self.text_color_stack.push(None);
1723 f(self);
1724 self.text_color_stack.pop();
1725 self.commands.push(Command::EndContainer);
1726 self.last_text_idx = None;
1727
1728 self.response_for(interaction_id)
1729 }
1730
1731 pub(super) fn response_for(&self, interaction_id: usize) -> Response {
1732 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1733 return Response::none();
1734 }
1735 if let Some(rect) = self.prev_hit_map.get(interaction_id) {
1736 let clicked = self
1737 .click_pos
1738 .map(|(mx, my)| {
1739 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1740 })
1741 .unwrap_or(false);
1742 let hovered = self
1743 .mouse_pos
1744 .map(|(mx, my)| {
1745 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1746 })
1747 .unwrap_or(false);
1748 Response {
1749 clicked,
1750 hovered,
1751 changed: false,
1752 focused: false,
1753 rect: *rect,
1754 }
1755 } else {
1756 Response::none()
1757 }
1758 }
1759
1760 pub fn is_group_hovered(&self, name: &str) -> bool {
1762 if let Some(pos) = self.mouse_pos {
1763 self.prev_group_rects.iter().any(|(n, rect)| {
1764 n == name
1765 && pos.0 >= rect.x
1766 && pos.0 < rect.x + rect.width
1767 && pos.1 >= rect.y
1768 && pos.1 < rect.y + rect.height
1769 })
1770 } else {
1771 false
1772 }
1773 }
1774
1775 pub fn is_group_focused(&self, name: &str) -> bool {
1777 if self.prev_focus_count == 0 {
1778 return false;
1779 }
1780 let focused_index = self.focus_index % self.prev_focus_count;
1781 self.prev_focus_groups
1782 .get(focused_index)
1783 .and_then(|group| group.as_deref())
1784 .map(|group| group == name)
1785 .unwrap_or(false)
1786 }
1787
1788 pub fn grow(&mut self, value: u16) -> &mut Self {
1793 if let Some(idx) = self.last_text_idx {
1794 if let Command::Text { grow, .. } = &mut self.commands[idx] {
1795 *grow = value;
1796 }
1797 }
1798 self
1799 }
1800
1801 pub fn align(&mut self, align: Align) -> &mut Self {
1803 if let Some(idx) = self.last_text_idx {
1804 if let Command::Text {
1805 align: text_align, ..
1806 } = &mut self.commands[idx]
1807 {
1808 *text_align = align;
1809 }
1810 }
1811 self
1812 }
1813
1814 pub fn text_center(&mut self) -> &mut Self {
1818 self.align(Align::Center)
1819 }
1820
1821 pub fn text_right(&mut self) -> &mut Self {
1824 self.align(Align::End)
1825 }
1826
1827 pub fn w(&mut self, value: u32) -> &mut Self {
1834 self.modify_last_constraints(|c| {
1835 c.min_width = Some(value);
1836 c.max_width = Some(value);
1837 });
1838 self
1839 }
1840
1841 pub fn h(&mut self, value: u32) -> &mut Self {
1845 self.modify_last_constraints(|c| {
1846 c.min_height = Some(value);
1847 c.max_height = Some(value);
1848 });
1849 self
1850 }
1851
1852 pub fn min_w(&mut self, value: u32) -> &mut Self {
1854 self.modify_last_constraints(|c| c.min_width = Some(value));
1855 self
1856 }
1857
1858 pub fn max_w(&mut self, value: u32) -> &mut Self {
1860 self.modify_last_constraints(|c| c.max_width = Some(value));
1861 self
1862 }
1863
1864 pub fn min_h(&mut self, value: u32) -> &mut Self {
1866 self.modify_last_constraints(|c| c.min_height = Some(value));
1867 self
1868 }
1869
1870 pub fn max_h(&mut self, value: u32) -> &mut Self {
1872 self.modify_last_constraints(|c| c.max_height = Some(value));
1873 self
1874 }
1875
1876 pub fn m(&mut self, value: u32) -> &mut Self {
1880 self.modify_last_margin(|m| *m = Margin::all(value));
1881 self
1882 }
1883
1884 pub fn mx(&mut self, value: u32) -> &mut Self {
1886 self.modify_last_margin(|m| {
1887 m.left = value;
1888 m.right = value;
1889 });
1890 self
1891 }
1892
1893 pub fn my(&mut self, value: u32) -> &mut Self {
1895 self.modify_last_margin(|m| {
1896 m.top = value;
1897 m.bottom = value;
1898 });
1899 self
1900 }
1901
1902 pub fn mt(&mut self, value: u32) -> &mut Self {
1904 self.modify_last_margin(|m| m.top = value);
1905 self
1906 }
1907
1908 pub fn mr(&mut self, value: u32) -> &mut Self {
1910 self.modify_last_margin(|m| m.right = value);
1911 self
1912 }
1913
1914 pub fn mb(&mut self, value: u32) -> &mut Self {
1916 self.modify_last_margin(|m| m.bottom = value);
1917 self
1918 }
1919
1920 pub fn ml(&mut self, value: u32) -> &mut Self {
1922 self.modify_last_margin(|m| m.left = value);
1923 self
1924 }
1925
1926 pub fn spacer(&mut self) -> &mut Self {
1930 self.commands.push(Command::Spacer { grow: 1 });
1931 self.last_text_idx = None;
1932 self
1933 }
1934
1935 pub fn form(
1939 &mut self,
1940 state: &mut FormState,
1941 f: impl FnOnce(&mut Context, &mut FormState),
1942 ) -> &mut Self {
1943 let _ = self.col(|ui| {
1944 f(ui, state);
1945 });
1946 self
1947 }
1948
1949 pub fn form_field(&mut self, field: &mut FormField) -> &mut Self {
1953 let _ = self.col(|ui| {
1954 ui.styled(field.label.clone(), Style::new().bold().fg(ui.theme.text));
1955 let _ = ui.text_input(&mut field.input);
1956 if let Some(error) = field.error.as_deref() {
1957 ui.styled(error.to_string(), Style::new().dim().fg(ui.theme.error));
1958 }
1959 });
1960 self
1961 }
1962
1963 pub fn form_submit(&mut self, label: impl Into<String>) -> Response {
1967 self.button(label)
1968 }
1969}
1970
1971fn wrap_tooltip_text(text: &str, max_width: usize) -> Vec<String> {
1972 let max_width = max_width.max(1);
1973 let mut lines = Vec::new();
1974
1975 for paragraph in text.lines() {
1976 if paragraph.trim().is_empty() {
1977 lines.push(String::new());
1978 continue;
1979 }
1980
1981 let mut current = String::new();
1982 let mut current_width = 0usize;
1983
1984 for word in paragraph.split_whitespace() {
1985 for chunk in split_word_for_width(word, max_width) {
1986 let chunk_width = UnicodeWidthStr::width(chunk.as_str());
1987
1988 if current.is_empty() {
1989 current = chunk;
1990 current_width = chunk_width;
1991 continue;
1992 }
1993
1994 if current_width + 1 + chunk_width <= max_width {
1995 current.push(' ');
1996 current.push_str(&chunk);
1997 current_width += 1 + chunk_width;
1998 } else {
1999 lines.push(std::mem::take(&mut current));
2000 current = chunk;
2001 current_width = chunk_width;
2002 }
2003 }
2004 }
2005
2006 if !current.is_empty() {
2007 lines.push(current);
2008 }
2009 }
2010
2011 if lines.is_empty() {
2012 lines.push(String::new());
2013 }
2014
2015 lines
2016}
2017
2018fn split_word_for_width(word: &str, max_width: usize) -> Vec<String> {
2019 let mut chunks = Vec::new();
2020 let mut current = String::new();
2021 let mut current_width = 0usize;
2022
2023 for ch in word.chars() {
2024 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
2025 if !current.is_empty() && current_width + ch_width > max_width {
2026 chunks.push(std::mem::take(&mut current));
2027 current_width = 0;
2028 }
2029 current.push(ch);
2030 current_width += ch_width;
2031
2032 if current_width >= max_width {
2033 chunks.push(std::mem::take(&mut current));
2034 current_width = 0;
2035 }
2036 }
2037
2038 if !current.is_empty() {
2039 chunks.push(current);
2040 }
2041
2042 if chunks.is_empty() {
2043 chunks.push(String::new());
2044 }
2045
2046 chunks
2047}
2048
2049const KEYWORDS: &[&str] = &[
2050 "fn",
2051 "let",
2052 "mut",
2053 "pub",
2054 "use",
2055 "impl",
2056 "struct",
2057 "enum",
2058 "trait",
2059 "type",
2060 "const",
2061 "static",
2062 "if",
2063 "else",
2064 "match",
2065 "for",
2066 "while",
2067 "loop",
2068 "return",
2069 "break",
2070 "continue",
2071 "where",
2072 "self",
2073 "super",
2074 "crate",
2075 "mod",
2076 "async",
2077 "await",
2078 "move",
2079 "ref",
2080 "in",
2081 "as",
2082 "true",
2083 "false",
2084 "Some",
2085 "None",
2086 "Ok",
2087 "Err",
2088 "Self",
2089 "def",
2090 "class",
2091 "import",
2092 "from",
2093 "pass",
2094 "lambda",
2095 "yield",
2096 "with",
2097 "try",
2098 "except",
2099 "raise",
2100 "finally",
2101 "elif",
2102 "del",
2103 "global",
2104 "nonlocal",
2105 "assert",
2106 "is",
2107 "not",
2108 "and",
2109 "or",
2110 "function",
2111 "var",
2112 "const",
2113 "export",
2114 "default",
2115 "switch",
2116 "case",
2117 "throw",
2118 "catch",
2119 "typeof",
2120 "instanceof",
2121 "new",
2122 "delete",
2123 "void",
2124 "this",
2125 "null",
2126 "undefined",
2127 "func",
2128 "package",
2129 "defer",
2130 "go",
2131 "chan",
2132 "select",
2133 "range",
2134 "map",
2135 "interface",
2136 "fallthrough",
2137 "nil",
2138];
2139
2140fn render_highlighted_line(ui: &mut Context, line: &str) {
2141 let theme = ui.theme;
2142 let is_light = matches!(
2143 theme.bg,
2144 Color::Reset | Color::White | Color::Rgb(255, 255, 255)
2145 );
2146 let keyword_color = if is_light {
2147 Color::Rgb(166, 38, 164)
2148 } else {
2149 Color::Rgb(198, 120, 221)
2150 };
2151 let string_color = if is_light {
2152 Color::Rgb(80, 161, 79)
2153 } else {
2154 Color::Rgb(152, 195, 121)
2155 };
2156 let comment_color = theme.text_dim;
2157 let number_color = if is_light {
2158 Color::Rgb(152, 104, 1)
2159 } else {
2160 Color::Rgb(209, 154, 102)
2161 };
2162 let fn_color = if is_light {
2163 Color::Rgb(64, 120, 242)
2164 } else {
2165 Color::Rgb(97, 175, 239)
2166 };
2167 let macro_color = if is_light {
2168 Color::Rgb(1, 132, 188)
2169 } else {
2170 Color::Rgb(86, 182, 194)
2171 };
2172
2173 let trimmed = line.trim_start();
2174 let indent = &line[..line.len() - trimmed.len()];
2175 if !indent.is_empty() {
2176 ui.text(indent);
2177 }
2178
2179 if trimmed.starts_with("//") {
2180 ui.text(trimmed).fg(comment_color).italic();
2181 return;
2182 }
2183
2184 let mut pos = 0;
2185
2186 while pos < trimmed.len() {
2187 let ch = trimmed.as_bytes()[pos];
2188
2189 if ch == b'"' {
2190 if let Some(end) = trimmed[pos + 1..].find('"') {
2191 let s = &trimmed[pos..pos + end + 2];
2192 ui.text(s).fg(string_color);
2193 pos += end + 2;
2194 continue;
2195 }
2196 }
2197
2198 if ch.is_ascii_digit() && (pos == 0 || !trimmed.as_bytes()[pos - 1].is_ascii_alphanumeric())
2199 {
2200 let end = trimmed[pos..]
2201 .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '_')
2202 .map_or(trimmed.len(), |e| pos + e);
2203 ui.text(&trimmed[pos..end]).fg(number_color);
2204 pos = end;
2205 continue;
2206 }
2207
2208 if ch.is_ascii_alphabetic() || ch == b'_' {
2209 let end = trimmed[pos..]
2210 .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
2211 .map_or(trimmed.len(), |e| pos + e);
2212 let word = &trimmed[pos..end];
2213
2214 if end < trimmed.len() && trimmed.as_bytes()[end] == b'!' {
2215 ui.text(&trimmed[pos..end + 1]).fg(macro_color);
2216 pos = end + 1;
2217 } else if end < trimmed.len()
2218 && trimmed.as_bytes()[end] == b'('
2219 && !KEYWORDS.contains(&word)
2220 {
2221 ui.text(word).fg(fn_color);
2222 pos = end;
2223 } else if KEYWORDS.contains(&word) {
2224 ui.text(word).fg(keyword_color);
2225 pos = end;
2226 } else {
2227 ui.text(word);
2228 pos = end;
2229 }
2230 continue;
2231 }
2232
2233 let end = trimmed[pos..]
2234 .find(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '"')
2235 .map_or(trimmed.len(), |e| pos + e);
2236 ui.text(&trimmed[pos..end]);
2237 pos = end;
2238 }
2239}
2240
2241fn normalize_rgba(data: &[u8], width: u32, height: u32) -> Vec<u8> {
2242 let expected = (width as usize) * (height as usize) * 4;
2243 if data.len() >= expected {
2244 return data[..expected].to_vec();
2245 }
2246 let mut buf = Vec::with_capacity(expected);
2247 buf.extend_from_slice(data);
2248 buf.resize(expected, 0);
2249 buf
2250}
2251
2252fn base64_encode(data: &[u8]) -> String {
2253 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2254 let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
2255 for chunk in data.chunks(3) {
2256 let b0 = chunk[0] as u32;
2257 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
2258 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
2259 let triple = (b0 << 16) | (b1 << 8) | b2;
2260 result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
2261 result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
2262 if chunk.len() > 1 {
2263 result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
2264 } else {
2265 result.push('=');
2266 }
2267 if chunk.len() > 2 {
2268 result.push(CHARS[(triple & 0x3F) as usize] as char);
2269 } else {
2270 result.push('=');
2271 }
2272 }
2273 result
2274}
2275
2276fn split_base64(encoded: &str, chunk_size: usize) -> Vec<&str> {
2277 let mut chunks = Vec::new();
2278 let bytes = encoded.as_bytes();
2279 let mut offset = 0;
2280 while offset < bytes.len() {
2281 let end = (offset + chunk_size).min(bytes.len());
2282 chunks.push(&encoded[offset..end]);
2283 offset = end;
2284 }
2285 if chunks.is_empty() {
2286 chunks.push("");
2287 }
2288 chunks
2289}
2290
2291fn terminal_supports_sixel() -> bool {
2292 let force = std::env::var("SLT_FORCE_SIXEL")
2293 .ok()
2294 .map(|v| v.to_ascii_lowercase())
2295 .unwrap_or_default();
2296 if matches!(force.as_str(), "1" | "true" | "yes" | "on") {
2297 return true;
2298 }
2299
2300 let term = std::env::var("TERM")
2301 .ok()
2302 .map(|v| v.to_ascii_lowercase())
2303 .unwrap_or_default();
2304 let term_program = std::env::var("TERM_PROGRAM")
2305 .ok()
2306 .map(|v| v.to_ascii_lowercase())
2307 .unwrap_or_default();
2308
2309 term.contains("sixel")
2310 || term.contains("mlterm")
2311 || term.contains("xterm")
2312 || term.contains("foot")
2313 || term_program.contains("foot")
2314}