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 {
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    /// Render an elapsed time display.
85    ///
86    /// Formats as `HH:MM:SS.CC` when hours are non-zero, otherwise `MM:SS.CC`.
87    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    /// Render help bar from a KeyMap. Shows visible bindings as key-description pairs.
117    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    // ── style chain (applies to last text) ───────────────────────────
126
127    /// Apply bold to the last rendered text element.
128    pub fn bold(&mut self) -> &mut Self {
129        self.modify_last_style(|s| s.modifiers |= Modifiers::BOLD);
130        self
131    }
132
133    /// Apply dim styling to the last rendered text element.
134    ///
135    /// Also sets the foreground color to the theme's `text_dim` color if no
136    /// explicit foreground has been set.
137    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    /// Apply italic to the last rendered text element.
149    pub fn italic(&mut self) -> &mut Self {
150        self.modify_last_style(|s| s.modifiers |= Modifiers::ITALIC);
151        self
152    }
153
154    /// Apply underline to the last rendered text element.
155    pub fn underline(&mut self) -> &mut Self {
156        self.modify_last_style(|s| s.modifiers |= Modifiers::UNDERLINE);
157        self
158    }
159
160    /// Apply reverse-video to the last rendered text element.
161    pub fn reversed(&mut self) -> &mut Self {
162        self.modify_last_style(|s| s.modifiers |= Modifiers::REVERSED);
163        self
164    }
165
166    /// Apply strikethrough to the last rendered text element.
167    pub fn strikethrough(&mut self) -> &mut Self {
168        self.modify_last_style(|s| s.modifiers |= Modifiers::STRIKETHROUGH);
169        self
170    }
171
172    /// Set the foreground color of the last rendered text element.
173    pub fn fg(&mut self, color: Color) -> &mut Self {
174        self.modify_last_style(|s| s.fg = Some(color));
175        self
176    }
177
178    /// Set the background color of the last rendered text element.
179    pub fn bg(&mut self, color: Color) -> &mut Self {
180        self.modify_last_style(|s| s.bg = Some(color));
181        self
182    }
183
184    /// Apply a per-character foreground gradient to the last rendered text.
185    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    /// Apply a per-character multi-stop foreground gradient to the last text.
230    ///
231    /// `stops` is a slice of `(position, color)` pairs where `position` lies in
232    /// `0.0..=1.0`. Stops do not need to be pre-sorted. The text is colored by
233    /// linearly interpolating between adjacent stops across its displayed
234    /// columns, using the same column-mapping and clamping as [`gradient`].
235    ///
236    /// - An empty slice is a no-op (the text keeps its current style).
237    /// - A single stop produces a solid color.
238    ///
239    /// [`gradient`]: Self::gradient
240    ///
241    /// # Example
242    ///
243    /// ```no_run
244    /// # slt::run(|ui: &mut slt::Context| {
245    /// use slt::Color;
246    /// ui.text("rainbow").gradient_stops(&[
247    ///     (0.0, Color::Red),
248    ///     (0.5, Color::Yellow),
249    ///     (1.0, Color::Green),
250    /// ]);
251    /// # });
252    /// ```
253    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    /// Apply a per-character background gradient to the last rendered text.
263    ///
264    /// The two-stop background analogue of [`gradient`]. Colors the cell
265    /// background instead of the foreground, using identical column-mapping and
266    /// clamping so width handling stays consistent.
267    ///
268    /// [`gradient`]: Self::gradient
269    ///
270    /// # Example
271    ///
272    /// ```no_run
273    /// # slt::run(|ui: &mut slt::Context| {
274    /// use slt::Color;
275    /// ui.text("banner").bg_gradient(Color::Blue, Color::Magenta);
276    /// # });
277    /// ```
278    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    /// Apply a per-character multi-stop background gradient to the last text.
284    ///
285    /// The background analogue of [`gradient_stops`]: identical stop handling
286    /// (positions in `0.0..=1.0`, unsorted-safe, empty = no-op, single stop =
287    /// solid) but applied to the cell background instead of the foreground.
288    ///
289    /// [`gradient_stops`]: Self::gradient_stops
290    ///
291    /// # Example
292    ///
293    /// ```no_run
294    /// # slt::run(|ui: &mut slt::Context| {
295    /// use slt::Color;
296    /// ui.text("header").bg_gradient_stops(&[
297    ///     (0.0, Color::Blue),
298    ///     (1.0, Color::Magenta),
299    /// ]);
300    /// # });
301    /// ```
302    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    /// Return `stops` sorted ascending by clamped position. Positions are
312    /// clamped into `0.0..=1.0` so out-of-range inputs degrade gracefully.
313    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    /// Sample the color at position `t` (in `0.0..=1.0`) from pre-sorted,
323    /// non-empty `stops`, linearly interpolating between the bracketing stops.
324    fn sample_gradient_stops(stops: &[(f32, Color)], t: f32) -> Color {
325        let t = t.clamp(0.0, 1.0);
326        // Non-empty is guaranteed by callers; fall back defensively otherwise.
327        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                // blend(self, other, alpha) = self*alpha + other*(1-alpha):
348                // c1.blend(c0, local) yields c0 at local=0 and c1 at local=1.
349                return c1.blend(c0, local);
350            }
351        }
352        last.1
353    }
354
355    /// Replace the last `Text` command with a `RichText` gradient, mapping each
356    /// character's column to a position in `0.0..=1.0` exactly like
357    /// [`gradient`](Self::gradient). `is_bg` selects background vs foreground.
358    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    /// Set foreground color when the current group is hovered or focused.
406    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    /// Set background color when the current group is hovered or focused.
420    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    /// Render a text element with an explicit [`Style`] applied immediately.
434    ///
435    /// Equivalent to calling `text(s)` followed by style-chain methods, but
436    /// more concise when you already have a `Style` value.
437    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    /// Enable word-boundary wrapping on the last rendered text element.
463    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    /// Truncate the last rendered text with `…` when it exceeds its allocated width.
473    /// Use with `.w()` to set a fixed width, or let the parent container constrain it.
474    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    // ── containers ───────────────────────────────────────────────────
513
514    /// Set the flex-grow factor of the last rendered text element.
515    ///
516    /// A value of `1` causes the element to expand and fill remaining space
517    /// along the main axis.
518    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    /// Set the text alignment of the last rendered text element.
528    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    /// Center-align the last rendered text element horizontally.
541    /// Shorthand for `.align(Align::Center)`. Requires the text to have
542    /// a width constraint (via `.w()` or parent container) to be visible.
543    pub fn text_center(&mut self) -> &mut Self {
544        self.align(Align::Center)
545    }
546
547    /// Right-align the last rendered text element horizontally.
548    /// Shorthand for `.align(Align::End)`.
549    pub fn text_right(&mut self) -> &mut Self {
550        self.align(Align::End)
551    }
552
553    // ── size constraints on last text/link ──────────────────────────
554
555    /// Set a fixed width on the last rendered text or link element.
556    ///
557    /// Sets the [`WidthSpec`](crate::WidthSpec) to `Fixed(value)`, making the
558    /// element occupy exactly that many columns (padded with spaces or
559    /// truncated).
560    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    /// Set a fixed height on the last rendered text or link element.
568    ///
569    /// Sets the [`HeightSpec`](crate::HeightSpec) to `Fixed(value)`.
570    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    /// Set the minimum width on the last rendered text or link element.
578    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    /// Set the maximum width on the last rendered text or link element.
584    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    /// Set the minimum height on the last rendered text or link element.
590    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    /// Set the maximum height on the last rendered text or link element.
596    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    // ── margin on last text/link ────────────────────────────────────
602
603    /// Set uniform margin on all sides of the last rendered text or link element.
604    pub fn m(&mut self, value: u32) -> &mut Self {
605        self.modify_last_margin(|m| *m = Margin::all(value));
606        self
607    }
608
609    /// Set horizontal margin (left + right) on the last rendered text or link.
610    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    /// Set vertical margin (top + bottom) on the last rendered text or link.
619    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    /// Set top margin on the last rendered text or link element.
628    pub fn mt(&mut self, value: u32) -> &mut Self {
629        self.modify_last_margin(|m| m.top = value);
630        self
631    }
632
633    /// Set right margin on the last rendered text or link element.
634    pub fn mr(&mut self, value: u32) -> &mut Self {
635        self.modify_last_margin(|m| m.right = value);
636        self
637    }
638
639    /// Set bottom margin on the last rendered text or link element.
640    pub fn mb(&mut self, value: u32) -> &mut Self {
641        self.modify_last_margin(|m| m.bottom = value);
642        self
643    }
644
645    /// Set left margin on the last rendered text or link element.
646    pub fn ml(&mut self, value: u32) -> &mut Self {
647        self.modify_last_margin(|m| m.left = value);
648        self
649    }
650
651    /// Render an invisible spacer that expands to fill available space.
652    ///
653    /// Useful for pushing siblings to opposite ends of a row or column.
654    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    // ── conditional / grouped style helpers ─────────────────────────
661
662    /// Apply `f` only if `cond` is true. Returns `self` so chaining continues.
663    ///
664    /// Use this to attach a block of style modifiers to the last rendered text
665    /// without breaking the fluent chain. The closure receives the same
666    /// `&mut Context`, so any style-chain method (`.bold()`, `.fg()`, etc.)
667    /// applies to the most recent text element.
668    ///
669    /// Zero allocation: the closure is inlined and skipped entirely when
670    /// `cond` is `false`.
671    ///
672    /// # Example
673    ///
674    /// ```no_run
675    /// # slt::run(|ui: &mut slt::Context| {
676    /// use slt::Color;
677    /// let is_error = true;
678    /// let is_selected = false;
679    /// ui.text("Status")
680    ///     .with_if(is_error, |t| {
681    ///         t.bold().fg(Color::Red);
682    ///     })
683    ///     .with_if(is_selected, |t| {
684    ///         t.bg(Color::DarkGray);
685    ///     });
686    /// # });
687    /// ```
688    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    /// Apply `f` unconditionally. Useful for factoring out a block of modifier
696    /// calls that should always run, while keeping the fluent chain intact.
697    ///
698    /// # Example
699    ///
700    /// ```no_run
701    /// # slt::run(|ui: &mut slt::Context| {
702    /// use slt::Color;
703    /// ui.text("hi").with(|t| {
704    ///     t.bold().fg(Color::Cyan);
705    /// });
706    /// # });
707    /// ```
708    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        // i=0 → t=0 → stop at 0.0 (red); i=2 → t=1 → stop at 1.0 (blue);
730        // i=1 → t=0.5 → halfway blend.
731        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            // Deliberately out of order — must behave identically to sorted.
755            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            // len=3, denom=2 → columns map to t = 0.0, 0.5, 1.0.
771            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            // Empty slice must not panic and must leave content intact.
808            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        // bg_gradient mirrors gradient(): from.blend(to, t) — at t=0 that is `to`
825        // (blue), at t=1 that is `from` (red). Foreground stays untouched.
826        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}