1use super::*;
2use crate::KeyMap;
3
4impl Context {
5 pub fn text(&mut self, s: impl Into<String>) -> &mut Self {
16 let content = s.into();
17 let default_fg = self
18 .rollback
19 .text_color_stack
20 .iter()
21 .rev()
22 .find_map(|c| *c)
23 .unwrap_or(self.theme.text);
24 self.commands.push(Command::Text {
25 content,
26 cursor_offset: None,
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.rollback.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, response) = self.begin_widget_interaction(focused);
49
50 let activated = response.clicked || self.consume_activation_keys(focused);
51
52 if activated {
53 if let Err(e) = open_url(&url_str) {
54 eprintln!("[slt] failed to open URL: {e}");
55 }
56 }
57
58 let style = if focused {
59 Style::new()
60 .fg(self.theme.primary)
61 .bg(self.theme.surface_hover)
62 .underline()
63 .bold()
64 } else if response.hovered {
65 Style::new()
66 .fg(self.theme.accent)
67 .bg(self.theme.surface_hover)
68 .underline()
69 } else {
70 Style::new().fg(self.theme.primary).underline()
71 };
72
73 self.commands.push(Command::Link {
74 text: text.into(),
75 url: url_str,
76 style,
77 margin: Margin::default(),
78 constraints: Constraints::default(),
79 });
80 self.rollback.last_text_idx = Some(self.commands.len() - 1);
81 self
82 }
83
84 pub fn timer_display(&mut self, elapsed: std::time::Duration) -> &mut Self {
88 let total_centis = elapsed.as_millis() / 10;
89 let centis = total_centis % 100;
90 let total_seconds = total_centis / 100;
91 let seconds = total_seconds % 60;
92 let minutes = (total_seconds / 60) % 60;
93 let hours = total_seconds / 3600;
94
95 let content = if hours > 0 {
96 format!("{hours:02}:{minutes:02}:{seconds:02}.{centis:02}")
97 } else {
98 format!("{minutes:02}:{seconds:02}.{centis:02}")
99 };
100
101 self.commands.push(Command::Text {
102 content,
103 cursor_offset: None,
104 style: Style::new().fg(self.theme.text),
105 grow: 0,
106 align: Align::Start,
107 wrap: false,
108 truncate: false,
109 margin: Margin::default(),
110 constraints: Constraints::default(),
111 });
112 self.rollback.last_text_idx = Some(self.commands.len() - 1);
113 self
114 }
115
116 pub fn help_from_keymap(&mut self, keymap: &KeyMap) -> Response {
118 let pairs: Vec<(&str, &str)> = keymap
119 .visible_bindings()
120 .map(|binding| (binding.display.as_str(), binding.description.as_str()))
121 .collect();
122 self.help(&pairs)
123 }
124
125 pub fn bold(&mut self) -> &mut Self {
129 self.modify_last_style(|s| s.modifiers |= Modifiers::BOLD);
130 self
131 }
132
133 pub fn dim(&mut self) -> &mut Self {
138 let text_dim = self.theme.text_dim;
139 self.modify_last_style(|s| {
140 s.modifiers |= Modifiers::DIM;
141 if s.fg.is_none() {
142 s.fg = Some(text_dim);
143 }
144 });
145 self
146 }
147
148 pub fn italic(&mut self) -> &mut Self {
150 self.modify_last_style(|s| s.modifiers |= Modifiers::ITALIC);
151 self
152 }
153
154 pub fn underline(&mut self) -> &mut Self {
156 self.modify_last_style(|s| s.modifiers |= Modifiers::UNDERLINE);
157 self
158 }
159
160 pub fn reversed(&mut self) -> &mut Self {
162 self.modify_last_style(|s| s.modifiers |= Modifiers::REVERSED);
163 self
164 }
165
166 pub fn strikethrough(&mut self) -> &mut Self {
168 self.modify_last_style(|s| s.modifiers |= Modifiers::STRIKETHROUGH);
169 self
170 }
171
172 pub fn fg(&mut self, color: Color) -> &mut Self {
174 self.modify_last_style(|s| s.fg = Some(color));
175 self
176 }
177
178 pub fn bg(&mut self, color: Color) -> &mut Self {
180 self.modify_last_style(|s| s.bg = Some(color));
181 self
182 }
183
184 pub fn gradient(&mut self, from: Color, to: Color) -> &mut Self {
186 if let Some(idx) = self.rollback.last_text_idx {
187 let replacement = match &self.commands[idx] {
188 Command::Text {
189 content,
190 style,
191 wrap,
192 align,
193 margin,
194 constraints,
195 ..
196 } => {
197 let chars: Vec<char> = content.chars().collect();
198 let len = chars.len();
199 let denom = len.saturating_sub(1).max(1) as f32;
200 let segments = chars
201 .into_iter()
202 .enumerate()
203 .map(|(i, ch)| {
204 let mut seg_style = *style;
205 seg_style.fg = Some(from.blend(to, i as f32 / denom));
206 (ch.to_string(), seg_style)
207 })
208 .collect();
209
210 Some(Command::RichText {
211 segments,
212 wrap: *wrap,
213 align: *align,
214 margin: *margin,
215 constraints: *constraints,
216 })
217 }
218 _ => None,
219 };
220
221 if let Some(command) = replacement {
222 self.commands[idx] = command;
223 }
224 }
225
226 self
227 }
228
229 pub fn gradient_stops(&mut self, stops: &[(f32, Color)]) -> &mut Self {
254 if stops.is_empty() {
255 return self;
256 }
257 let sorted = Self::sorted_gradient_stops(stops);
258 self.apply_char_gradient(false, |t| Self::sample_gradient_stops(&sorted, t));
259 self
260 }
261
262 pub fn bg_gradient(&mut self, from: Color, to: Color) -> &mut Self {
279 self.apply_char_gradient(true, |t| from.blend(to, t));
280 self
281 }
282
283 pub fn bg_gradient_stops(&mut self, stops: &[(f32, Color)]) -> &mut Self {
303 if stops.is_empty() {
304 return self;
305 }
306 let sorted = Self::sorted_gradient_stops(stops);
307 self.apply_char_gradient(true, |t| Self::sample_gradient_stops(&sorted, t));
308 self
309 }
310
311 fn sorted_gradient_stops(stops: &[(f32, Color)]) -> Vec<(f32, Color)> {
314 let mut sorted: Vec<(f32, Color)> = stops
315 .iter()
316 .map(|(pos, color)| (pos.clamp(0.0, 1.0), *color))
317 .collect();
318 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
319 sorted
320 }
321
322 fn sample_gradient_stops(stops: &[(f32, Color)], t: f32) -> Color {
325 let t = t.clamp(0.0, 1.0);
326 let first = match stops.first() {
328 Some(stop) => *stop,
329 None => return Color::Rgb(0, 0, 0),
330 };
331 let last = *stops.last().unwrap_or(&first);
332 if t <= first.0 {
333 return first.1;
334 }
335 if t >= last.0 {
336 return last.1;
337 }
338 for window in stops.windows(2) {
339 let (p0, c0) = window[0];
340 let (p1, c1) = window[1];
341 if t >= p0 && t <= p1 {
342 let span = p1 - p0;
343 if span <= f32::EPSILON {
344 return c1;
345 }
346 let local = (t - p0) / span;
347 return c1.blend(c0, local);
350 }
351 }
352 last.1
353 }
354
355 fn apply_char_gradient(&mut self, is_bg: bool, color_at: impl Fn(f32) -> Color) {
359 if let Some(idx) = self.rollback.last_text_idx {
360 let replacement = match &self.commands[idx] {
361 Command::Text {
362 content,
363 style,
364 wrap,
365 align,
366 margin,
367 constraints,
368 ..
369 } => {
370 let chars: Vec<char> = content.chars().collect();
371 let len = chars.len();
372 let denom = len.saturating_sub(1).max(1) as f32;
373 let segments = chars
374 .into_iter()
375 .enumerate()
376 .map(|(i, ch)| {
377 let mut seg_style = *style;
378 let color = color_at(i as f32 / denom);
379 if is_bg {
380 seg_style.bg = Some(color);
381 } else {
382 seg_style.fg = Some(color);
383 }
384 (ch.to_string(), seg_style)
385 })
386 .collect();
387
388 Some(Command::RichText {
389 segments,
390 wrap: *wrap,
391 align: *align,
392 margin: *margin,
393 constraints: *constraints,
394 })
395 }
396 _ => None,
397 };
398
399 if let Some(command) = replacement {
400 self.commands[idx] = command;
401 }
402 }
403 }
404
405 pub fn group_hover_fg(&mut self, color: Color) -> &mut Self {
407 let apply_group_style = self
408 .rollback
409 .group_stack
410 .last()
411 .map(|name| self.is_group_hovered(name) || self.is_group_focused(name))
412 .unwrap_or(false);
413 if apply_group_style {
414 self.modify_last_style(|s| s.fg = Some(color));
415 }
416 self
417 }
418
419 pub fn group_hover_bg(&mut self, color: Color) -> &mut Self {
421 let apply_group_style = self
422 .rollback
423 .group_stack
424 .last()
425 .map(|name| self.is_group_hovered(name) || self.is_group_focused(name))
426 .unwrap_or(false);
427 if apply_group_style {
428 self.modify_last_style(|s| s.bg = Some(color));
429 }
430 self
431 }
432
433 pub fn styled(&mut self, s: impl Into<String>, style: Style) -> &mut Self {
438 self.styled_with_cursor(s, style, None)
439 }
440
441 pub(crate) fn styled_with_cursor(
442 &mut self,
443 s: impl Into<String>,
444 style: Style,
445 cursor_offset: Option<usize>,
446 ) -> &mut Self {
447 self.commands.push(Command::Text {
448 content: s.into(),
449 cursor_offset,
450 style,
451 grow: 0,
452 align: Align::Start,
453 wrap: false,
454 truncate: false,
455 margin: Margin::default(),
456 constraints: Constraints::default(),
457 });
458 self.rollback.last_text_idx = Some(self.commands.len() - 1);
459 self
460 }
461
462 pub fn wrap(&mut self) -> &mut Self {
464 if let Some(idx) = self.rollback.last_text_idx {
465 if let Command::Text { wrap, .. } = &mut self.commands[idx] {
466 *wrap = true;
467 }
468 }
469 self
470 }
471
472 pub fn truncate(&mut self) -> &mut Self {
475 if let Some(idx) = self.rollback.last_text_idx {
476 if let Command::Text { truncate, .. } = &mut self.commands[idx] {
477 *truncate = true;
478 }
479 }
480 self
481 }
482
483 fn modify_last_style(&mut self, f: impl FnOnce(&mut Style)) {
484 if let Some(idx) = self.rollback.last_text_idx {
485 match &mut self.commands[idx] {
486 Command::Text { style, .. } | Command::Link { style, .. } => f(style),
487 _ => {}
488 }
489 }
490 }
491
492 fn modify_last_constraints(&mut self, f: impl FnOnce(&mut Constraints)) {
493 if let Some(idx) = self.rollback.last_text_idx {
494 match &mut self.commands[idx] {
495 Command::Text { constraints, .. } | Command::Link { constraints, .. } => {
496 f(constraints)
497 }
498 _ => {}
499 }
500 }
501 }
502
503 fn modify_last_margin(&mut self, f: impl FnOnce(&mut Margin)) {
504 if let Some(idx) = self.rollback.last_text_idx {
505 match &mut self.commands[idx] {
506 Command::Text { margin, .. } | Command::Link { margin, .. } => f(margin),
507 _ => {}
508 }
509 }
510 }
511
512 pub fn grow(&mut self, value: u16) -> &mut Self {
519 if let Some(idx) = self.rollback.last_text_idx {
520 if let Command::Text { grow, .. } = &mut self.commands[idx] {
521 *grow = value;
522 }
523 }
524 self
525 }
526
527 pub fn align(&mut self, align: Align) -> &mut Self {
529 if let Some(idx) = self.rollback.last_text_idx {
530 if let Command::Text {
531 align: text_align, ..
532 } = &mut self.commands[idx]
533 {
534 *text_align = align;
535 }
536 }
537 self
538 }
539
540 pub fn text_center(&mut self) -> &mut Self {
544 self.align(Align::Center)
545 }
546
547 pub fn text_right(&mut self) -> &mut Self {
550 self.align(Align::End)
551 }
552
553 pub fn w(&mut self, value: u32) -> &mut Self {
561 self.modify_last_constraints(|c| {
562 *c = c.w(value);
563 });
564 self
565 }
566
567 pub fn h(&mut self, value: u32) -> &mut Self {
571 self.modify_last_constraints(|c| {
572 *c = c.h(value);
573 });
574 self
575 }
576
577 pub fn min_w(&mut self, value: u32) -> &mut Self {
579 self.modify_last_constraints(|c| c.set_min_width(Some(value)));
580 self
581 }
582
583 pub fn max_w(&mut self, value: u32) -> &mut Self {
585 self.modify_last_constraints(|c| c.set_max_width(Some(value)));
586 self
587 }
588
589 pub fn min_h(&mut self, value: u32) -> &mut Self {
591 self.modify_last_constraints(|c| c.set_min_height(Some(value)));
592 self
593 }
594
595 pub fn max_h(&mut self, value: u32) -> &mut Self {
597 self.modify_last_constraints(|c| c.set_max_height(Some(value)));
598 self
599 }
600
601 pub fn m(&mut self, value: u32) -> &mut Self {
605 self.modify_last_margin(|m| *m = Margin::all(value));
606 self
607 }
608
609 pub fn mx(&mut self, value: u32) -> &mut Self {
611 self.modify_last_margin(|m| {
612 m.left = value;
613 m.right = value;
614 });
615 self
616 }
617
618 pub fn my(&mut self, value: u32) -> &mut Self {
620 self.modify_last_margin(|m| {
621 m.top = value;
622 m.bottom = value;
623 });
624 self
625 }
626
627 pub fn mt(&mut self, value: u32) -> &mut Self {
629 self.modify_last_margin(|m| m.top = value);
630 self
631 }
632
633 pub fn mr(&mut self, value: u32) -> &mut Self {
635 self.modify_last_margin(|m| m.right = value);
636 self
637 }
638
639 pub fn mb(&mut self, value: u32) -> &mut Self {
641 self.modify_last_margin(|m| m.bottom = value);
642 self
643 }
644
645 pub fn ml(&mut self, value: u32) -> &mut Self {
647 self.modify_last_margin(|m| m.left = value);
648 self
649 }
650
651 pub fn spacer(&mut self) -> &mut Self {
655 self.commands.push(Command::Spacer { grow: 1 });
656 self.rollback.last_text_idx = None;
657 self
658 }
659
660 pub fn with_if(&mut self, cond: bool, f: impl FnOnce(&mut Self)) -> &mut Self {
689 if cond {
690 f(self);
691 }
692 self
693 }
694
695 pub fn with(&mut self, f: impl FnOnce(&mut Self)) -> &mut Self {
709 f(self);
710 self
711 }
712}
713
714#[cfg(test)]
715mod gradient_tests {
716 use super::*;
717 use crate::TestBackend;
718
719 #[test]
720 fn gradient_stops_interpolates_fg_across_columns() {
721 let red = Color::Rgb(255, 0, 0);
722 let blue = Color::Rgb(0, 0, 255);
723 let mut backend = TestBackend::new(20, 4);
724 backend.render(|ui| {
725 ui.text("ABC").gradient_stops(&[(0.0, red), (1.0, blue)]);
726 });
727
728 let buf = backend.buffer();
729 assert_eq!(
732 buf.get(0, 0).style.fg,
733 Some(red),
734 "first column should be red"
735 );
736 assert_eq!(
737 buf.get(1, 0).style.fg,
738 Some(Color::Rgb(128, 0, 128)),
739 "middle column should be the halfway blend"
740 );
741 assert_eq!(
742 buf.get(2, 0).style.fg,
743 Some(blue),
744 "last column should be blue"
745 );
746 }
747
748 #[test]
749 fn gradient_stops_unsorted_input_is_sorted() {
750 let red = Color::Rgb(255, 0, 0);
751 let blue = Color::Rgb(0, 0, 255);
752 let mut backend = TestBackend::new(20, 4);
753 backend.render(|ui| {
754 ui.text("ABC").gradient_stops(&[(1.0, blue), (0.0, red)]);
756 });
757
758 let buf = backend.buffer();
759 assert_eq!(buf.get(0, 0).style.fg, Some(red));
760 assert_eq!(buf.get(2, 0).style.fg, Some(blue));
761 }
762
763 #[test]
764 fn gradient_stops_multi_stop_hits_middle_stop_exactly() {
765 let red = Color::Rgb(255, 0, 0);
766 let green = Color::Rgb(0, 255, 0);
767 let blue = Color::Rgb(0, 0, 255);
768 let mut backend = TestBackend::new(20, 4);
769 backend.render(|ui| {
770 ui.text("ABC")
772 .gradient_stops(&[(0.0, red), (0.5, green), (1.0, blue)]);
773 });
774
775 let buf = backend.buffer();
776 assert_eq!(buf.get(0, 0).style.fg, Some(red), "t=0 → first stop");
777 assert_eq!(
778 buf.get(1, 0).style.fg,
779 Some(green),
780 "t=0.5 → middle stop exactly"
781 );
782 assert_eq!(buf.get(2, 0).style.fg, Some(blue), "t=1 → last stop");
783 }
784
785 #[test]
786 fn gradient_stops_single_stop_is_solid() {
787 let cyan = Color::Rgb(0, 200, 200);
788 let mut backend = TestBackend::new(20, 4);
789 backend.render(|ui| {
790 ui.text("ABCD").gradient_stops(&[(0.0, cyan)]);
791 });
792
793 let buf = backend.buffer();
794 for x in 0..4 {
795 assert_eq!(
796 buf.get(x, 0).style.fg,
797 Some(cyan),
798 "every column should be the single solid stop"
799 );
800 }
801 }
802
803 #[test]
804 fn gradient_stops_empty_is_noop() {
805 let mut backend = TestBackend::new(20, 4);
806 backend.render(|ui| {
807 ui.text("HELLO").gradient_stops(&[]);
809 });
810
811 backend.assert_contains("HELLO");
812 }
813
814 #[test]
815 fn bg_gradient_applies_to_background() {
816 let red = Color::Rgb(255, 0, 0);
817 let blue = Color::Rgb(0, 0, 255);
818 let mut backend = TestBackend::new(20, 4);
819 backend.render(|ui| {
820 ui.text("ABC").bg_gradient(red, blue);
821 });
822
823 let buf = backend.buffer();
824 assert_eq!(buf.get(0, 0).style.bg, Some(blue), "first column bg = to");
827 assert_eq!(buf.get(2, 0).style.bg, Some(red), "last column bg = from");
828 assert_eq!(
829 buf.get(1, 0).style.bg,
830 Some(Color::Rgb(128, 0, 128)),
831 "middle column bg = halfway blend"
832 );
833 }
834
835 #[test]
836 fn bg_gradient_stops_interpolates_background() {
837 let red = Color::Rgb(255, 0, 0);
838 let blue = Color::Rgb(0, 0, 255);
839 let mut backend = TestBackend::new(20, 4);
840 backend.render(|ui| {
841 ui.text("ABC").bg_gradient_stops(&[(0.0, red), (1.0, blue)]);
842 });
843
844 let buf = backend.buffer();
845 assert_eq!(buf.get(0, 0).style.bg, Some(red), "first column bg = red");
846 assert_eq!(
847 buf.get(1, 0).style.bg,
848 Some(Color::Rgb(128, 0, 128)),
849 "middle column bg = halfway blend"
850 );
851 assert_eq!(buf.get(2, 0).style.bg, Some(blue), "last column bg = blue");
852 }
853
854 #[test]
855 fn bg_gradient_stops_empty_is_noop() {
856 let mut backend = TestBackend::new(20, 4);
857 backend.render(|ui| {
858 ui.text("WORLD").bg_gradient_stops(&[]);
859 });
860
861 backend.assert_contains("WORLD");
862 }
863}