Skip to main content

slt/context/widgets_display/
text.rs

1use super::*;
2use crate::KeyMap;
3
4impl Context {
5    /// Render a text element. Returns `&mut Self` for style chaining.
6    ///
7    /// # Example
8    ///
9    /// ```no_run
10    /// # slt::run(|ui: &mut slt::Context| {
11    /// use slt::Color;
12    /// ui.text("hello").bold().fg(Color::Cyan);
13    /// # });
14    /// ```
15    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    /// Render a clickable hyperlink.
40    ///
41    /// The link is interactive: clicking it (or pressing Enter/Space when
42    /// focused) opens the URL in the system browser. OSC 8 is also emitted
43    /// for terminals that support native hyperlinks.
44    #[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    /// Render an elapsed time display.
83    ///
84    /// Formats as `HH:MM:SS.CC` when hours are non-zero, otherwise `MM:SS.CC`.
85    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    /// Render help bar from a KeyMap. Shows visible bindings as key-description pairs.
115    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    // ── style chain (applies to last text) ───────────────────────────
124
125    /// Apply bold to the last rendered text element.
126    pub fn bold(&mut self) -> &mut Self {
127        self.modify_last_style(|s| s.modifiers |= Modifiers::BOLD);
128        self
129    }
130
131    /// Apply dim styling to the last rendered text element.
132    ///
133    /// Also sets the foreground color to the theme's `text_dim` color if no
134    /// explicit foreground has been set.
135    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    /// Apply italic to the last rendered text element.
147    pub fn italic(&mut self) -> &mut Self {
148        self.modify_last_style(|s| s.modifiers |= Modifiers::ITALIC);
149        self
150    }
151
152    /// Apply underline to the last rendered text element.
153    pub fn underline(&mut self) -> &mut Self {
154        self.modify_last_style(|s| s.modifiers |= Modifiers::UNDERLINE);
155        self
156    }
157
158    /// Apply reverse-video to the last rendered text element.
159    pub fn reversed(&mut self) -> &mut Self {
160        self.modify_last_style(|s| s.modifiers |= Modifiers::REVERSED);
161        self
162    }
163
164    /// Apply strikethrough to the last rendered text element.
165    pub fn strikethrough(&mut self) -> &mut Self {
166        self.modify_last_style(|s| s.modifiers |= Modifiers::STRIKETHROUGH);
167        self
168    }
169
170    /// Set the foreground color of the last rendered text element.
171    pub fn fg(&mut self, color: Color) -> &mut Self {
172        self.modify_last_style(|s| s.fg = Some(color));
173        self
174    }
175
176    /// Set the background color of the last rendered text element.
177    pub fn bg(&mut self, color: Color) -> &mut Self {
178        self.modify_last_style(|s| s.bg = Some(color));
179        self
180    }
181
182    /// Apply a per-character foreground gradient to the last rendered text.
183    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 f64;
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_f64(to, i as f64 / 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    /// Apply a per-character multi-stop foreground gradient to the last text.
228    ///
229    /// `stops` is a slice of `(position, color)` pairs where `position` lies in
230    /// `0.0..=1.0`. Stops do not need to be pre-sorted. The text is colored by
231    /// linearly interpolating between adjacent stops across its displayed
232    /// columns, using the same column-mapping and clamping as [`gradient`].
233    ///
234    /// - An empty slice is a no-op (the text keeps its current style).
235    /// - A single stop produces a solid color.
236    ///
237    /// [`gradient`]: Self::gradient
238    ///
239    /// # Example
240    ///
241    /// ```no_run
242    /// # slt::run(|ui: &mut slt::Context| {
243    /// use slt::Color;
244    /// ui.text("rainbow").gradient_stops_f64(&[
245    ///     (0.0, Color::Red),
246    ///     (0.5, Color::Yellow),
247    ///     (1.0, Color::Green),
248    /// ]);
249    /// # });
250    /// ```
251    pub fn gradient_stops_f64(&mut self, stops: &[(f64, 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    /// Deprecated `f32` alias for [`gradient_stops_f64`](Self::gradient_stops_f64).
261    #[deprecated(
262        since = "0.22.2",
263        note = "use Context::gradient_stops_f64() to keep public float APIs on f64"
264    )]
265    pub fn gradient_stops(&mut self, stops: &[(f32, Color)]) -> &mut Self {
266        let stops: Vec<(f64, Color)> = stops
267            .iter()
268            .map(|(pos, color)| (f64::from(*pos), *color))
269            .collect();
270        self.gradient_stops_f64(&stops)
271    }
272
273    /// Apply a per-character background gradient to the last rendered text.
274    ///
275    /// The two-stop background analogue of [`gradient`]. Colors the cell
276    /// background instead of the foreground, using identical column-mapping and
277    /// clamping so width handling stays consistent.
278    ///
279    /// [`gradient`]: Self::gradient
280    ///
281    /// # Example
282    ///
283    /// ```no_run
284    /// # slt::run(|ui: &mut slt::Context| {
285    /// use slt::Color;
286    /// ui.text("banner").bg_gradient(Color::Blue, Color::Magenta);
287    /// # });
288    /// ```
289    pub fn bg_gradient(&mut self, from: Color, to: Color) -> &mut Self {
290        self.apply_char_gradient(true, |t| from.blend_f64(to, t));
291        self
292    }
293
294    /// Apply a per-character multi-stop background gradient to the last text.
295    ///
296    /// The background analogue of [`gradient_stops`]: identical stop handling
297    /// (positions in `0.0..=1.0`, unsorted-safe, empty = no-op, single stop =
298    /// solid) but applied to the cell background instead of the foreground.
299    ///
300    /// [`gradient_stops`]: Self::gradient_stops
301    ///
302    /// # Example
303    ///
304    /// ```no_run
305    /// # slt::run(|ui: &mut slt::Context| {
306    /// use slt::Color;
307    /// ui.text("header").bg_gradient_stops_f64(&[
308    ///     (0.0, Color::Blue),
309    ///     (1.0, Color::Magenta),
310    /// ]);
311    /// # });
312    /// ```
313    pub fn bg_gradient_stops_f64(&mut self, stops: &[(f64, Color)]) -> &mut Self {
314        if stops.is_empty() {
315            return self;
316        }
317        let sorted = Self::sorted_gradient_stops(stops);
318        self.apply_char_gradient(true, |t| Self::sample_gradient_stops(&sorted, t));
319        self
320    }
321
322    /// Deprecated `f32` alias for [`bg_gradient_stops_f64`](Self::bg_gradient_stops_f64).
323    #[deprecated(
324        since = "0.22.2",
325        note = "use Context::bg_gradient_stops_f64() to keep public float APIs on f64"
326    )]
327    pub fn bg_gradient_stops(&mut self, stops: &[(f32, Color)]) -> &mut Self {
328        let stops: Vec<(f64, Color)> = stops
329            .iter()
330            .map(|(pos, color)| (f64::from(*pos), *color))
331            .collect();
332        self.bg_gradient_stops_f64(&stops)
333    }
334
335    /// Return `stops` sorted ascending by clamped position. Positions are
336    /// clamped into `0.0..=1.0` so out-of-range inputs degrade gracefully.
337    fn sorted_gradient_stops(stops: &[(f64, Color)]) -> Vec<(f64, Color)> {
338        let mut sorted: Vec<(f64, Color)> = stops
339            .iter()
340            .map(|(pos, color)| (pos.clamp(0.0, 1.0), *color))
341            .collect();
342        sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
343        sorted
344    }
345
346    /// Sample the color at position `t` (in `0.0..=1.0`) from pre-sorted,
347    /// non-empty `stops`, linearly interpolating between the bracketing stops.
348    fn sample_gradient_stops(stops: &[(f64, Color)], t: f64) -> Color {
349        let t = t.clamp(0.0, 1.0);
350        // Non-empty is guaranteed by callers; fall back defensively otherwise.
351        let first = match stops.first() {
352            Some(stop) => *stop,
353            None => return Color::Rgb(0, 0, 0),
354        };
355        let last = *stops.last().unwrap_or(&first);
356        if t <= first.0 {
357            return first.1;
358        }
359        if t >= last.0 {
360            return last.1;
361        }
362        for window in stops.windows(2) {
363            let (p0, c0) = window[0];
364            let (p1, c1) = window[1];
365            if t >= p0 && t <= p1 {
366                let span = p1 - p0;
367                if span <= f64::EPSILON {
368                    return c1;
369                }
370                let local = (t - p0) / span;
371                return c1.blend_f64(c0, local);
372            }
373        }
374        last.1
375    }
376
377    /// Replace the last `Text` command with a `RichText` gradient, mapping each
378    /// character's column to a position in `0.0..=1.0` exactly like
379    /// [`gradient`](Self::gradient). `is_bg` selects background vs foreground.
380    fn apply_char_gradient(&mut self, is_bg: bool, color_at: impl Fn(f64) -> Color) {
381        if let Some(idx) = self.rollback.last_text_idx {
382            let replacement = match &self.commands[idx] {
383                Command::Text {
384                    content,
385                    style,
386                    wrap,
387                    align,
388                    margin,
389                    constraints,
390                    ..
391                } => {
392                    let chars: Vec<char> = content.chars().collect();
393                    let len = chars.len();
394                    let denom = len.saturating_sub(1).max(1) as f64;
395                    let segments = chars
396                        .into_iter()
397                        .enumerate()
398                        .map(|(i, ch)| {
399                            let mut seg_style = *style;
400                            let color = color_at(i as f64 / denom);
401                            if is_bg {
402                                seg_style.bg = Some(color);
403                            } else {
404                                seg_style.fg = Some(color);
405                            }
406                            (ch.to_string(), seg_style)
407                        })
408                        .collect();
409
410                    Some(Command::RichText {
411                        segments,
412                        wrap: *wrap,
413                        align: *align,
414                        margin: *margin,
415                        constraints: *constraints,
416                    })
417                }
418                _ => None,
419            };
420
421            if let Some(command) = replacement {
422                self.commands[idx] = command;
423            }
424        }
425    }
426
427    /// Set foreground color when the current group is hovered or focused.
428    pub fn group_hover_fg(&mut self, color: Color) -> &mut Self {
429        let apply_group_style = self
430            .rollback
431            .group_stack
432            .last()
433            .map(|name| self.is_group_hovered(name) || self.is_group_focused(name))
434            .unwrap_or(false);
435        if apply_group_style {
436            self.modify_last_style(|s| s.fg = Some(color));
437        }
438        self
439    }
440
441    /// Set background color when the current group is hovered or focused.
442    pub fn group_hover_bg(&mut self, color: Color) -> &mut Self {
443        let apply_group_style = self
444            .rollback
445            .group_stack
446            .last()
447            .map(|name| self.is_group_hovered(name) || self.is_group_focused(name))
448            .unwrap_or(false);
449        if apply_group_style {
450            self.modify_last_style(|s| s.bg = Some(color));
451        }
452        self
453    }
454
455    /// Render a text element with an explicit [`Style`] applied immediately.
456    ///
457    /// Equivalent to calling `text(s)` followed by style-chain methods, but
458    /// more concise when you already have a `Style` value.
459    pub fn styled(&mut self, s: impl Into<String>, style: Style) -> &mut Self {
460        self.styled_with_cursor(s, style, None)
461    }
462
463    pub(crate) fn styled_with_cursor(
464        &mut self,
465        s: impl Into<String>,
466        style: Style,
467        cursor_offset: Option<usize>,
468    ) -> &mut Self {
469        self.commands.push(Command::Text {
470            content: s.into(),
471            cursor_offset,
472            style,
473            grow: 0,
474            align: Align::Start,
475            wrap: false,
476            truncate: false,
477            margin: Margin::default(),
478            constraints: Constraints::default(),
479        });
480        self.rollback.last_text_idx = Some(self.commands.len() - 1);
481        self
482    }
483
484    /// Enable word-boundary wrapping on the last rendered text element.
485    pub fn wrap(&mut self) -> &mut Self {
486        if let Some(idx) = self.rollback.last_text_idx
487            && let Command::Text { wrap, .. } = &mut self.commands[idx]
488        {
489            *wrap = true;
490        }
491        self
492    }
493
494    /// Truncate the last rendered text with `…` when it exceeds its allocated width.
495    /// Use with `.w()` to set a fixed width, or let the parent container constrain it.
496    pub fn truncate(&mut self) -> &mut Self {
497        if let Some(idx) = self.rollback.last_text_idx
498            && let Command::Text { truncate, .. } = &mut self.commands[idx]
499        {
500            *truncate = true;
501        }
502        self
503    }
504
505    fn modify_last_style(&mut self, f: impl FnOnce(&mut Style)) {
506        if let Some(idx) = self.rollback.last_text_idx {
507            match &mut self.commands[idx] {
508                Command::Text { style, .. } | Command::Link { style, .. } => f(style),
509                _ => {}
510            }
511        }
512    }
513
514    fn modify_last_constraints(&mut self, f: impl FnOnce(&mut Constraints)) {
515        if let Some(idx) = self.rollback.last_text_idx {
516            match &mut self.commands[idx] {
517                Command::Text { constraints, .. } | Command::Link { constraints, .. } => {
518                    f(constraints)
519                }
520                _ => {}
521            }
522        }
523    }
524
525    fn modify_last_margin(&mut self, f: impl FnOnce(&mut Margin)) {
526        if let Some(idx) = self.rollback.last_text_idx {
527            match &mut self.commands[idx] {
528                Command::Text { margin, .. } | Command::Link { margin, .. } => f(margin),
529                _ => {}
530            }
531        }
532    }
533
534    // ── containers ───────────────────────────────────────────────────
535
536    /// Set the flex-grow factor of the last rendered text element.
537    ///
538    /// A value of `1` causes the element to expand and fill remaining space
539    /// along the main axis.
540    pub fn grow(&mut self, value: u16) -> &mut Self {
541        if let Some(idx) = self.rollback.last_text_idx
542            && let Command::Text { grow, .. } = &mut self.commands[idx]
543        {
544            *grow = value;
545        }
546        self
547    }
548
549    /// Set the text alignment of the last rendered text element.
550    pub fn align(&mut self, align: Align) -> &mut Self {
551        if let Some(idx) = self.rollback.last_text_idx
552            && let Command::Text {
553                align: text_align, ..
554            } = &mut self.commands[idx]
555        {
556            *text_align = align;
557        }
558        self
559    }
560
561    /// Center-align the last rendered text element horizontally.
562    /// Shorthand for `.align(Align::Center)`. Requires the text to have
563    /// a width constraint (via `.w()` or parent container) to be visible.
564    pub fn text_center(&mut self) -> &mut Self {
565        self.align(Align::Center)
566    }
567
568    /// Right-align the last rendered text element horizontally.
569    /// Shorthand for `.align(Align::End)`.
570    pub fn text_right(&mut self) -> &mut Self {
571        self.align(Align::End)
572    }
573
574    // ── size constraints on last text/link ──────────────────────────
575
576    /// Set a fixed width on the last rendered text or link element.
577    ///
578    /// Sets the [`WidthSpec`](crate::WidthSpec) to `Fixed(value)`, making the
579    /// element occupy exactly that many columns (padded with spaces or
580    /// truncated).
581    pub fn w(&mut self, value: u32) -> &mut Self {
582        self.modify_last_constraints(|c| {
583            *c = c.w(value);
584        });
585        self
586    }
587
588    /// Set a fixed height on the last rendered text or link element.
589    ///
590    /// Sets the [`HeightSpec`](crate::HeightSpec) to `Fixed(value)`.
591    pub fn h(&mut self, value: u32) -> &mut Self {
592        self.modify_last_constraints(|c| {
593            *c = c.h(value);
594        });
595        self
596    }
597
598    /// Set the minimum width on the last rendered text or link element.
599    pub fn min_w(&mut self, value: u32) -> &mut Self {
600        self.modify_last_constraints(|c| c.set_min_width(Some(value)));
601        self
602    }
603
604    /// Set the maximum width on the last rendered text or link element.
605    pub fn max_w(&mut self, value: u32) -> &mut Self {
606        self.modify_last_constraints(|c| c.set_max_width(Some(value)));
607        self
608    }
609
610    /// Set the minimum height on the last rendered text or link element.
611    pub fn min_h(&mut self, value: u32) -> &mut Self {
612        self.modify_last_constraints(|c| c.set_min_height(Some(value)));
613        self
614    }
615
616    /// Set the maximum height on the last rendered text or link element.
617    pub fn max_h(&mut self, value: u32) -> &mut Self {
618        self.modify_last_constraints(|c| c.set_max_height(Some(value)));
619        self
620    }
621
622    // ── margin on last text/link ────────────────────────────────────
623
624    /// Set uniform margin on all sides of the last rendered text or link element.
625    pub fn m(&mut self, value: u32) -> &mut Self {
626        self.modify_last_margin(|m| *m = Margin::all(value));
627        self
628    }
629
630    /// Set horizontal margin (left + right) on the last rendered text or link.
631    pub fn mx(&mut self, value: u32) -> &mut Self {
632        self.modify_last_margin(|m| {
633            m.left = value;
634            m.right = value;
635        });
636        self
637    }
638
639    /// Set vertical margin (top + bottom) on the last rendered text or link.
640    pub fn my(&mut self, value: u32) -> &mut Self {
641        self.modify_last_margin(|m| {
642            m.top = value;
643            m.bottom = value;
644        });
645        self
646    }
647
648    /// Set top margin on the last rendered text or link element.
649    pub fn mt(&mut self, value: u32) -> &mut Self {
650        self.modify_last_margin(|m| m.top = value);
651        self
652    }
653
654    /// Set right margin on the last rendered text or link element.
655    pub fn mr(&mut self, value: u32) -> &mut Self {
656        self.modify_last_margin(|m| m.right = value);
657        self
658    }
659
660    /// Set bottom margin on the last rendered text or link element.
661    pub fn mb(&mut self, value: u32) -> &mut Self {
662        self.modify_last_margin(|m| m.bottom = value);
663        self
664    }
665
666    /// Set left margin on the last rendered text or link element.
667    pub fn ml(&mut self, value: u32) -> &mut Self {
668        self.modify_last_margin(|m| m.left = value);
669        self
670    }
671
672    /// Render an invisible spacer that expands to fill available space.
673    ///
674    /// Useful for pushing siblings to opposite ends of a row or column.
675    pub fn spacer(&mut self) -> &mut Self {
676        self.commands.push(Command::Spacer { grow: 1 });
677        self.rollback.last_text_idx = None;
678        self
679    }
680
681    // ── conditional / grouped style helpers ─────────────────────────
682
683    /// Apply `f` only if `cond` is true. Returns `self` so chaining continues.
684    ///
685    /// Use this to attach a block of style modifiers to the last rendered text
686    /// without breaking the fluent chain. The closure receives the same
687    /// `&mut Context`, so any style-chain method (`.bold()`, `.fg()`, etc.)
688    /// applies to the most recent text element.
689    ///
690    /// Zero allocation: the closure is inlined and skipped entirely when
691    /// `cond` is `false`.
692    ///
693    /// # Example
694    ///
695    /// ```no_run
696    /// # slt::run(|ui: &mut slt::Context| {
697    /// use slt::Color;
698    /// let is_error = true;
699    /// let is_selected = false;
700    /// ui.text("Status")
701    ///     .with_if(is_error, |t| {
702    ///         t.bold().fg(Color::Red);
703    ///     })
704    ///     .with_if(is_selected, |t| {
705    ///         t.bg(Color::DarkGray);
706    ///     });
707    /// # });
708    /// ```
709    pub fn with_if(&mut self, cond: bool, f: impl FnOnce(&mut Self)) -> &mut Self {
710        if cond {
711            f(self);
712        }
713        self
714    }
715
716    /// Apply `f` unconditionally. Useful for factoring out a block of modifier
717    /// calls that should always run, while keeping the fluent chain intact.
718    ///
719    /// # Example
720    ///
721    /// ```no_run
722    /// # slt::run(|ui: &mut slt::Context| {
723    /// use slt::Color;
724    /// ui.text("hi").with(|t| {
725    ///     t.bold().fg(Color::Cyan);
726    /// });
727    /// # });
728    /// ```
729    pub fn with(&mut self, f: impl FnOnce(&mut Self)) -> &mut Self {
730        f(self);
731        self
732    }
733}
734
735#[cfg(test)]
736mod gradient_tests {
737    use super::*;
738    use crate::TestBackend;
739
740    #[test]
741    fn gradient_stops_interpolates_fg_across_columns() {
742        let red = Color::Rgb(255, 0, 0);
743        let blue = Color::Rgb(0, 0, 255);
744        let mut backend = TestBackend::new(20, 4);
745        backend.render(|ui| {
746            ui.text("ABC")
747                .gradient_stops_f64(&[(0.0, red), (1.0, blue)]);
748        });
749
750        let buf = backend.buffer();
751        // i=0 → t=0 → stop at 0.0 (red); i=2 → t=1 → stop at 1.0 (blue);
752        // i=1 → t=0.5 → halfway blend.
753        assert_eq!(
754            buf.get(0, 0).style.fg,
755            Some(red),
756            "first column should be red"
757        );
758        assert_eq!(
759            buf.get(1, 0).style.fg,
760            Some(Color::Rgb(128, 0, 128)),
761            "middle column should be the halfway blend"
762        );
763        assert_eq!(
764            buf.get(2, 0).style.fg,
765            Some(blue),
766            "last column should be blue"
767        );
768    }
769
770    #[test]
771    fn gradient_stops_unsorted_input_is_sorted() {
772        let red = Color::Rgb(255, 0, 0);
773        let blue = Color::Rgb(0, 0, 255);
774        let mut backend = TestBackend::new(20, 4);
775        backend.render(|ui| {
776            // Deliberately out of order — must behave identically to sorted.
777            ui.text("ABC")
778                .gradient_stops_f64(&[(1.0, blue), (0.0, red)]);
779        });
780
781        let buf = backend.buffer();
782        assert_eq!(buf.get(0, 0).style.fg, Some(red));
783        assert_eq!(buf.get(2, 0).style.fg, Some(blue));
784    }
785
786    #[test]
787    fn gradient_stops_multi_stop_hits_middle_stop_exactly() {
788        let red = Color::Rgb(255, 0, 0);
789        let green = Color::Rgb(0, 255, 0);
790        let blue = Color::Rgb(0, 0, 255);
791        let mut backend = TestBackend::new(20, 4);
792        backend.render(|ui| {
793            // len=3, denom=2 → columns map to t = 0.0, 0.5, 1.0.
794            ui.text("ABC")
795                .gradient_stops_f64(&[(0.0, red), (0.5, green), (1.0, blue)]);
796        });
797
798        let buf = backend.buffer();
799        assert_eq!(buf.get(0, 0).style.fg, Some(red), "t=0 → first stop");
800        assert_eq!(
801            buf.get(1, 0).style.fg,
802            Some(green),
803            "t=0.5 → middle stop exactly"
804        );
805        assert_eq!(buf.get(2, 0).style.fg, Some(blue), "t=1 → last stop");
806    }
807
808    #[test]
809    fn gradient_stops_single_stop_is_solid() {
810        let cyan = Color::Rgb(0, 200, 200);
811        let mut backend = TestBackend::new(20, 4);
812        backend.render(|ui| {
813            ui.text("ABCD").gradient_stops_f64(&[(0.0, cyan)]);
814        });
815
816        let buf = backend.buffer();
817        for x in 0..4 {
818            assert_eq!(
819                buf.get(x, 0).style.fg,
820                Some(cyan),
821                "every column should be the single solid stop"
822            );
823        }
824    }
825
826    #[test]
827    fn gradient_stops_empty_is_noop() {
828        let mut backend = TestBackend::new(20, 4);
829        backend.render(|ui| {
830            // Empty slice must not panic and must leave content intact.
831            ui.text("HELLO").gradient_stops_f64(&[]);
832        });
833
834        backend.assert_contains("HELLO");
835    }
836
837    #[test]
838    fn bg_gradient_applies_to_background() {
839        let red = Color::Rgb(255, 0, 0);
840        let blue = Color::Rgb(0, 0, 255);
841        let mut backend = TestBackend::new(20, 4);
842        backend.render(|ui| {
843            ui.text("ABC").bg_gradient(red, blue);
844        });
845
846        let buf = backend.buffer();
847        // bg_gradient mirrors gradient(): from.blend_f64(to, t) yields `to` at t=0.
848        // (blue), at t=1 that is `from` (red). Foreground stays untouched.
849        assert_eq!(buf.get(0, 0).style.bg, Some(blue), "first column bg = to");
850        assert_eq!(buf.get(2, 0).style.bg, Some(red), "last column bg = from");
851        assert_eq!(
852            buf.get(1, 0).style.bg,
853            Some(Color::Rgb(128, 0, 128)),
854            "middle column bg = halfway blend"
855        );
856    }
857
858    #[test]
859    fn bg_gradient_stops_interpolates_background() {
860        let red = Color::Rgb(255, 0, 0);
861        let blue = Color::Rgb(0, 0, 255);
862        let mut backend = TestBackend::new(20, 4);
863        backend.render(|ui| {
864            ui.text("ABC")
865                .bg_gradient_stops_f64(&[(0.0, red), (1.0, blue)]);
866        });
867
868        let buf = backend.buffer();
869        assert_eq!(buf.get(0, 0).style.bg, Some(red), "first column bg = red");
870        assert_eq!(
871            buf.get(1, 0).style.bg,
872            Some(Color::Rgb(128, 0, 128)),
873            "middle column bg = halfway blend"
874        );
875        assert_eq!(buf.get(2, 0).style.bg, Some(blue), "last column bg = blue");
876    }
877
878    #[test]
879    fn bg_gradient_stops_empty_is_noop() {
880        let mut backend = TestBackend::new(20, 4);
881        backend.render(|ui| {
882            ui.text("WORLD").bg_gradient_stops_f64(&[]);
883        });
884
885        backend.assert_contains("WORLD");
886    }
887}