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