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.inherited_text_fg();
18        self.commands.push(Command::Text {
19            content,
20            cursor_offset: None,
21            style: Style::new().fg(default_fg),
22            grow: 0,
23            align: Align::Start,
24            wrap: false,
25            truncate: false,
26            margin: Margin::default(),
27            constraints: Constraints::default(),
28        });
29        self.rollback.last_text_idx = Some(self.commands.len() - 1);
30        self
31    }
32
33    /// Render a clickable hyperlink.
34    ///
35    /// The link is interactive: clicking it (or pressing Enter/Space when
36    /// focused) opens the URL in the system browser. OSC 8 is also emitted
37    /// for terminals that support native hyperlinks.
38    #[allow(clippy::print_stderr)]
39    pub fn link(&mut self, text: impl Into<String>, url: impl Into<String>) -> &mut Self {
40        let url_str = url.into();
41        let focused = self.register_focusable();
42        let (_interaction_id, response) = self.begin_widget_interaction(focused);
43
44        let activated = response.clicked || self.consume_activation_keys(focused);
45
46        if activated && let Err(e) = open_url(&url_str) {
47            eprintln!("[slt] failed to open URL: {e}");
48        }
49
50        let style = if focused {
51            Style::new()
52                .fg(self.theme.primary)
53                .bg(self.theme.surface_hover)
54                .underline()
55                .bold()
56        } else if response.hovered {
57            Style::new()
58                .fg(self.theme.accent)
59                .bg(self.theme.surface_hover)
60                .underline()
61        } else {
62            Style::new().fg(self.theme.primary).underline()
63        };
64
65        self.commands.push(Command::Link {
66            text: text.into(),
67            url: url_str,
68            style,
69            wrap: false,
70            margin: Margin::default(),
71            constraints: Constraints::default(),
72        });
73        self.rollback.last_text_idx = Some(self.commands.len() - 1);
74        self
75    }
76
77    /// Render an elapsed time display.
78    ///
79    /// Formats as `HH:MM:SS.CC` when hours are non-zero, otherwise `MM:SS.CC`.
80    pub fn timer_display(&mut self, elapsed: std::time::Duration) -> &mut Self {
81        let total_centis = elapsed.as_millis() / 10;
82        let centis = total_centis % 100;
83        let total_seconds = total_centis / 100;
84        let seconds = total_seconds % 60;
85        let minutes = (total_seconds / 60) % 60;
86        let hours = total_seconds / 3600;
87
88        let content = if hours > 0 {
89            format!("{hours:02}:{minutes:02}:{seconds:02}.{centis:02}")
90        } else {
91            format!("{minutes:02}:{seconds:02}.{centis:02}")
92        };
93
94        self.commands.push(Command::Text {
95            content,
96            cursor_offset: None,
97            style: Style::new().fg(self.theme.text),
98            grow: 0,
99            align: Align::Start,
100            wrap: false,
101            truncate: false,
102            margin: Margin::default(),
103            constraints: Constraints::default(),
104        });
105        self.rollback.last_text_idx = Some(self.commands.len() - 1);
106        self
107    }
108
109    /// Render help bar from a KeyMap. Shows visible bindings as key-description pairs.
110    pub fn help_from_keymap(&mut self, keymap: &KeyMap) -> Response {
111        let pairs: Vec<(&str, &str)> = keymap
112            .visible_bindings()
113            .map(|binding| (binding.display.as_str(), binding.description.as_str()))
114            .collect();
115        self.help(&pairs)
116    }
117
118    // ── style chain (applies to last text) ───────────────────────────
119
120    /// Apply bold to the last rendered text element.
121    pub fn bold(&mut self) -> &mut Self {
122        self.modify_last_style(|s| s.modifiers |= Modifiers::BOLD);
123        self
124    }
125
126    /// Apply dim styling to the last rendered text element.
127    ///
128    /// Also sets the foreground color to the theme's `text_dim` color if no
129    /// explicit foreground has been set.
130    pub fn dim(&mut self) -> &mut Self {
131        let text_dim = self.theme.text_dim;
132        let inherited_fg = self.inherited_text_fg();
133        if let Some(idx) = self.rollback.last_text_idx {
134            match &mut self.commands[idx] {
135                Command::Text { style, .. } => {
136                    style.modifiers |= Modifiers::DIM;
137                    if style.fg.is_none() || style.fg == Some(inherited_fg) {
138                        style.fg = Some(text_dim);
139                    }
140                }
141                Command::Link { style, .. } => {
142                    style.modifiers |= Modifiers::DIM;
143                }
144                Command::RichText { segments, .. } => {
145                    let all_inherited = segments
146                        .iter()
147                        .all(|(_, style)| style.fg.is_none() || style.fg == Some(inherited_fg));
148                    for (_, style) in segments {
149                        style.modifiers |= Modifiers::DIM;
150                        if all_inherited {
151                            style.fg = Some(text_dim);
152                        }
153                    }
154                }
155                _ => {}
156            }
157        }
158        self
159    }
160
161    /// Apply italic to the last rendered text element.
162    pub fn italic(&mut self) -> &mut Self {
163        self.modify_last_style(|s| s.modifiers |= Modifiers::ITALIC);
164        self
165    }
166
167    /// Apply underline to the last rendered text element.
168    pub fn underline(&mut self) -> &mut Self {
169        self.modify_last_style(|s| s.modifiers |= Modifiers::UNDERLINE);
170        self
171    }
172
173    /// Apply reverse-video to the last rendered text element.
174    pub fn reversed(&mut self) -> &mut Self {
175        self.modify_last_style(|s| s.modifiers |= Modifiers::REVERSED);
176        self
177    }
178
179    /// Apply strikethrough to the last rendered text element.
180    pub fn strikethrough(&mut self) -> &mut Self {
181        self.modify_last_style(|s| s.modifiers |= Modifiers::STRIKETHROUGH);
182        self
183    }
184
185    /// Set the foreground color of the last rendered text element.
186    pub fn fg(&mut self, color: Color) -> &mut Self {
187        self.modify_last_style(|s| s.fg = Some(color));
188        self
189    }
190
191    /// Set the background color of the last rendered text element.
192    pub fn bg(&mut self, color: Color) -> &mut Self {
193        self.modify_last_style(|s| s.bg = Some(color));
194        self
195    }
196
197    /// Apply a per-character foreground gradient to the last rendered text.
198    pub fn gradient(&mut self, from: Color, to: Color) -> &mut Self {
199        self.apply_char_gradient(false, |t| to.blend_f64(from, t));
200        self
201    }
202
203    /// Apply a per-character multi-stop foreground gradient to the last text.
204    ///
205    /// `stops` is a slice of `(position, color)` pairs where `position` lies in
206    /// `0.0..=1.0`. Stops do not need to be pre-sorted. The text is colored by
207    /// linearly interpolating between adjacent stops across its displayed
208    /// columns, using the same column-mapping and clamping as [`gradient`].
209    ///
210    /// - An empty slice is a no-op (the text keeps its current style).
211    /// - A single stop produces a solid color.
212    ///
213    /// [`gradient`]: Self::gradient
214    ///
215    /// # Example
216    ///
217    /// ```no_run
218    /// # slt::run(|ui: &mut slt::Context| {
219    /// use slt::Color;
220    /// ui.text("rainbow").gradient_stops_f64(&[
221    ///     (0.0, Color::Red),
222    ///     (0.5, Color::Yellow),
223    ///     (1.0, Color::Green),
224    /// ]);
225    /// # });
226    /// ```
227    pub fn gradient_stops_f64(&mut self, stops: &[(f64, Color)]) -> &mut Self {
228        if stops.is_empty() {
229            return self;
230        }
231        let sorted = Self::sorted_gradient_stops(stops);
232        self.apply_char_gradient(false, |t| Self::sample_gradient_stops(&sorted, t));
233        self
234    }
235
236    /// Deprecated `f32` alias for [`gradient_stops_f64`](Self::gradient_stops_f64).
237    #[deprecated(
238        since = "0.22.2",
239        note = "use Context::gradient_stops_f64() to keep public float APIs on f64"
240    )]
241    pub fn gradient_stops(&mut self, stops: &[(f32, Color)]) -> &mut Self {
242        let stops: Vec<(f64, Color)> = stops
243            .iter()
244            .map(|(pos, color)| (f64::from(*pos), *color))
245            .collect();
246        self.gradient_stops_f64(&stops)
247    }
248
249    /// Apply a per-character background gradient to the last rendered text.
250    ///
251    /// The two-stop background analogue of [`gradient`]. Colors the cell
252    /// background instead of the foreground, using identical column-mapping and
253    /// clamping so width handling stays consistent.
254    ///
255    /// [`gradient`]: Self::gradient
256    ///
257    /// # Example
258    ///
259    /// ```no_run
260    /// # slt::run(|ui: &mut slt::Context| {
261    /// use slt::Color;
262    /// ui.text("banner").bg_gradient(Color::Blue, Color::Magenta);
263    /// # });
264    /// ```
265    pub fn bg_gradient(&mut self, from: Color, to: Color) -> &mut Self {
266        self.apply_char_gradient(true, |t| to.blend_f64(from, t));
267        self
268    }
269
270    /// Apply a per-character multi-stop background gradient to the last text.
271    ///
272    /// The background analogue of [`gradient_stops`]: identical stop handling
273    /// (positions in `0.0..=1.0`, unsorted-safe, empty = no-op, single stop =
274    /// solid) but applied to the cell background instead of the foreground.
275    ///
276    /// [`gradient_stops`]: Self::gradient_stops
277    ///
278    /// # Example
279    ///
280    /// ```no_run
281    /// # slt::run(|ui: &mut slt::Context| {
282    /// use slt::Color;
283    /// ui.text("header").bg_gradient_stops_f64(&[
284    ///     (0.0, Color::Blue),
285    ///     (1.0, Color::Magenta),
286    /// ]);
287    /// # });
288    /// ```
289    pub fn bg_gradient_stops_f64(&mut self, stops: &[(f64, Color)]) -> &mut Self {
290        if stops.is_empty() {
291            return self;
292        }
293        let sorted = Self::sorted_gradient_stops(stops);
294        self.apply_char_gradient(true, |t| Self::sample_gradient_stops(&sorted, t));
295        self
296    }
297
298    /// Deprecated `f32` alias for [`bg_gradient_stops_f64`](Self::bg_gradient_stops_f64).
299    #[deprecated(
300        since = "0.22.2",
301        note = "use Context::bg_gradient_stops_f64() to keep public float APIs on f64"
302    )]
303    pub fn bg_gradient_stops(&mut self, stops: &[(f32, Color)]) -> &mut Self {
304        let stops: Vec<(f64, Color)> = stops
305            .iter()
306            .map(|(pos, color)| (f64::from(*pos), *color))
307            .collect();
308        self.bg_gradient_stops_f64(&stops)
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: &[(f64, Color)]) -> Vec<(f64, Color)> {
314        let mut sorted: Vec<(f64, Color)> = stops
315            .iter()
316            .map(|(pos, color)| {
317                let pos = if pos.is_finite() {
318                    pos.clamp(0.0, 1.0)
319                } else {
320                    0.0
321                };
322                (pos, *color)
323            })
324            .collect();
325        sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
326        sorted
327    }
328
329    /// Sample the color at position `t` (in `0.0..=1.0`) from pre-sorted,
330    /// non-empty `stops`, linearly interpolating between the bracketing stops.
331    fn sample_gradient_stops(stops: &[(f64, Color)], t: f64) -> Color {
332        let t = if t.is_finite() {
333            t.clamp(0.0, 1.0)
334        } else {
335            0.0
336        };
337        // Non-empty is guaranteed by callers; fall back defensively otherwise.
338        let first = match stops.first() {
339            Some(stop) => *stop,
340            None => return Color::Rgb(0, 0, 0),
341        };
342        let last = *stops.last().unwrap_or(&first);
343        if t <= first.0 {
344            return first.1;
345        }
346        if t >= last.0 {
347            return last.1;
348        }
349        for window in stops.windows(2) {
350            let (p0, c0) = window[0];
351            let (p1, c1) = window[1];
352            if t >= p0 && t <= p1 {
353                let span = p1 - p0;
354                if span <= f64::EPSILON {
355                    return c1;
356                }
357                let local = (t - p0) / span;
358                return c1.blend_f64(c0, local);
359            }
360        }
361        last.1
362    }
363
364    /// Replace the last `Text` command with a `RichText` gradient, mapping each
365    /// grapheme's starting cell to a position in `0.0..=1.0` exactly like
366    /// [`gradient`](Self::gradient). `is_bg` selects background vs foreground.
367    fn apply_char_gradient(&mut self, is_bg: bool, color_at: impl Fn(f64) -> Color) {
368        if let Some(idx) = self.rollback.last_text_idx {
369            let replacement = match &self.commands[idx] {
370                Command::Text {
371                    content,
372                    style,
373                    wrap,
374                    align,
375                    margin,
376                    constraints,
377                    ..
378                } => {
379                    let graphemes: Vec<&str> = content.graphemes(true).collect();
380                    let last_start = graphemes
381                        .iter()
382                        .take(graphemes.len().saturating_sub(1))
383                        .map(|grapheme| UnicodeWidthStr::width(*grapheme))
384                        .sum::<usize>();
385                    let denom = last_start.max(1) as f64;
386                    let mut cell = 0usize;
387                    let segments = graphemes
388                        .into_iter()
389                        .map(|grapheme| {
390                            let mut seg_style = *style;
391                            let color = color_at(cell as f64 / denom);
392                            if is_bg {
393                                seg_style.bg = Some(color);
394                            } else {
395                                seg_style.fg = Some(color);
396                            }
397                            cell = cell.saturating_add(UnicodeWidthStr::width(grapheme));
398                            (grapheme.to_string(), seg_style)
399                        })
400                        .collect();
401
402                    Some(Command::RichText {
403                        segments,
404                        wrap: *wrap,
405                        align: *align,
406                        margin: *margin,
407                        constraints: *constraints,
408                    })
409                }
410                _ => None,
411            };
412
413            if let Some(command) = replacement {
414                self.commands[idx] = command;
415            }
416        }
417    }
418
419    /// Set foreground color when the current group is hovered or focused.
420    pub fn group_hover_fg(&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.fg = Some(color));
429        }
430        self
431    }
432
433    /// Set background color when the current group is hovered or focused.
434    pub fn group_hover_bg(&mut self, color: Color) -> &mut Self {
435        let apply_group_style = self
436            .rollback
437            .group_stack
438            .last()
439            .map(|name| self.is_group_hovered(name) || self.is_group_focused(name))
440            .unwrap_or(false);
441        if apply_group_style {
442            self.modify_last_style(|s| s.bg = Some(color));
443        }
444        self
445    }
446
447    /// Render a text element with an explicit [`Style`] applied immediately.
448    ///
449    /// Equivalent to calling `text(s)` followed by style-chain methods, but
450    /// more concise when you already have a `Style` value.
451    pub fn styled(&mut self, s: impl Into<String>, style: Style) -> &mut Self {
452        self.styled_with_cursor(s, style, None)
453    }
454
455    pub(crate) fn styled_with_cursor(
456        &mut self,
457        s: impl Into<String>,
458        style: Style,
459        cursor_offset: Option<usize>,
460    ) -> &mut Self {
461        self.commands.push(Command::Text {
462            content: s.into(),
463            cursor_offset,
464            style,
465            grow: 0,
466            align: Align::Start,
467            wrap: false,
468            truncate: false,
469            margin: Margin::default(),
470            constraints: Constraints::default(),
471        });
472        self.rollback.last_text_idx = Some(self.commands.len() - 1);
473        self
474    }
475
476    /// Enable word-boundary wrapping on the last rendered text element.
477    pub fn wrap(&mut self) -> &mut Self {
478        if let Some(idx) = self.rollback.last_text_idx {
479            match &mut self.commands[idx] {
480                Command::Text { wrap, .. }
481                | Command::Link { wrap, .. }
482                | Command::RichText { wrap, .. } => *wrap = true,
483                _ => {}
484            }
485        }
486        self
487    }
488
489    /// Truncate the last rendered text with `…` when it exceeds its allocated width.
490    /// Use with `.w()` to set a fixed width, or let the parent container constrain it.
491    pub fn truncate(&mut self) -> &mut Self {
492        if let Some(idx) = self.rollback.last_text_idx
493            && let Command::Text { truncate, .. } = &mut self.commands[idx]
494        {
495            *truncate = true;
496        }
497        self
498    }
499
500    fn modify_last_style(&mut self, mut f: impl FnMut(&mut Style)) {
501        if let Some(idx) = self.rollback.last_text_idx {
502            match &mut self.commands[idx] {
503                Command::Text { style, .. } | Command::Link { style, .. } => f(style),
504                Command::RichText { segments, .. } => {
505                    for (_, style) in segments {
506                        f(style);
507                    }
508                }
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                Command::RichText { constraints, .. } => f(constraints),
521                _ => {}
522            }
523        }
524    }
525
526    fn modify_last_margin(&mut self, f: impl FnOnce(&mut Margin)) {
527        if let Some(idx) = self.rollback.last_text_idx {
528            match &mut self.commands[idx] {
529                Command::Text { margin, .. } | Command::Link { margin, .. } => f(margin),
530                Command::RichText { margin, .. } => f(margin),
531                _ => {}
532            }
533        }
534    }
535
536    // ── containers ───────────────────────────────────────────────────
537
538    /// Set the flex-grow factor of the last rendered text element.
539    ///
540    /// A value of `1` causes the element to expand and fill remaining space
541    /// along the main axis.
542    pub fn grow(&mut self, value: u16) -> &mut Self {
543        if let Some(idx) = self.rollback.last_text_idx
544            && let Command::Text { grow, .. } = &mut self.commands[idx]
545        {
546            *grow = value;
547        }
548        self
549    }
550
551    /// Set the text alignment of the last rendered text element.
552    pub fn align(&mut self, align: Align) -> &mut Self {
553        if let Some(idx) = self.rollback.last_text_idx {
554            match &mut self.commands[idx] {
555                Command::Text {
556                    align: text_align, ..
557                }
558                | Command::RichText {
559                    align: text_align, ..
560                } => *text_align = align,
561                _ => {}
562            }
563        }
564        self
565    }
566
567    /// Center-align the last rendered text element horizontally.
568    /// Shorthand for `.align(Align::Center)`. Requires the text to have
569    /// a width constraint (via `.w()` or parent container) to be visible.
570    pub fn text_center(&mut self) -> &mut Self {
571        self.align(Align::Center)
572    }
573
574    /// Right-align the last rendered text element horizontally.
575    /// Shorthand for `.align(Align::End)`.
576    pub fn text_right(&mut self) -> &mut Self {
577        self.align(Align::End)
578    }
579
580    // ── size constraints on last text/link ──────────────────────────
581
582    /// Set a fixed width on the last rendered text or link element.
583    ///
584    /// Sets the [`WidthSpec`](crate::WidthSpec) to `Fixed(value)`, making the
585    /// element occupy exactly that many columns (padded with spaces or
586    /// truncated).
587    pub fn w(&mut self, value: u32) -> &mut Self {
588        self.modify_last_constraints(|c| {
589            *c = c.w(value);
590        });
591        self
592    }
593
594    /// Set a fixed height on the last rendered text or link element.
595    ///
596    /// Sets the [`HeightSpec`](crate::HeightSpec) to `Fixed(value)`.
597    pub fn h(&mut self, value: u32) -> &mut Self {
598        self.modify_last_constraints(|c| {
599            *c = c.h(value);
600        });
601        self
602    }
603
604    /// Set the minimum width on the last rendered text or link element.
605    pub fn min_w(&mut self, value: u32) -> &mut Self {
606        self.modify_last_constraints(|c| c.set_min_width(Some(value)));
607        self
608    }
609
610    /// Set the maximum width on the last rendered text or link element.
611    pub fn max_w(&mut self, value: u32) -> &mut Self {
612        self.modify_last_constraints(|c| c.set_max_width(Some(value)));
613        self
614    }
615
616    /// Set the minimum height on the last rendered text or link element.
617    pub fn min_h(&mut self, value: u32) -> &mut Self {
618        self.modify_last_constraints(|c| c.set_min_height(Some(value)));
619        self
620    }
621
622    /// Set the maximum height on the last rendered text or link element.
623    pub fn max_h(&mut self, value: u32) -> &mut Self {
624        self.modify_last_constraints(|c| c.set_max_height(Some(value)));
625        self
626    }
627
628    // ── margin on last text/link ────────────────────────────────────
629
630    /// Set uniform margin on all sides of the last rendered text or link element.
631    pub fn m(&mut self, value: u32) -> &mut Self {
632        self.modify_last_margin(|m| *m = Margin::all(value));
633        self
634    }
635
636    /// Set horizontal margin (left + right) on the last rendered text or link.
637    pub fn mx(&mut self, value: u32) -> &mut Self {
638        self.modify_last_margin(|m| {
639            m.left = value;
640            m.right = value;
641        });
642        self
643    }
644
645    /// Set vertical margin (top + bottom) on the last rendered text or link.
646    pub fn my(&mut self, value: u32) -> &mut Self {
647        self.modify_last_margin(|m| {
648            m.top = value;
649            m.bottom = value;
650        });
651        self
652    }
653
654    /// Set top margin on the last rendered text or link element.
655    pub fn mt(&mut self, value: u32) -> &mut Self {
656        self.modify_last_margin(|m| m.top = value);
657        self
658    }
659
660    /// Set right margin on the last rendered text or link element.
661    pub fn mr(&mut self, value: u32) -> &mut Self {
662        self.modify_last_margin(|m| m.right = value);
663        self
664    }
665
666    /// Set bottom margin on the last rendered text or link element.
667    pub fn mb(&mut self, value: u32) -> &mut Self {
668        self.modify_last_margin(|m| m.bottom = value);
669        self
670    }
671
672    /// Set left margin on the last rendered text or link element.
673    pub fn ml(&mut self, value: u32) -> &mut Self {
674        self.modify_last_margin(|m| m.left = value);
675        self
676    }
677
678    /// Render an invisible spacer that expands to fill available space.
679    ///
680    /// Useful for pushing siblings to opposite ends of a row or column.
681    pub fn spacer(&mut self) -> &mut Self {
682        self.commands.push(Command::Spacer { grow: 1 });
683        self.rollback.last_text_idx = None;
684        self
685    }
686
687    // ── conditional / grouped style helpers ─────────────────────────
688
689    /// Apply `f` only if `cond` is true. Returns `self` so chaining continues.
690    ///
691    /// Use this to attach a block of style modifiers to the last rendered text
692    /// without breaking the fluent chain. The closure receives the same
693    /// `&mut Context`, so any style-chain method (`.bold()`, `.fg()`, etc.)
694    /// applies to the most recent text element.
695    ///
696    /// Zero allocation: the closure is inlined and skipped entirely when
697    /// `cond` is `false`.
698    ///
699    /// # Example
700    ///
701    /// ```no_run
702    /// # slt::run(|ui: &mut slt::Context| {
703    /// use slt::Color;
704    /// let is_error = true;
705    /// let is_selected = false;
706    /// ui.text("Status")
707    ///     .with_if(is_error, |t| {
708    ///         t.bold().fg(Color::Red);
709    ///     })
710    ///     .with_if(is_selected, |t| {
711    ///         t.bg(Color::DarkGray);
712    ///     });
713    /// # });
714    /// ```
715    pub fn with_if(&mut self, cond: bool, f: impl FnOnce(&mut Self)) -> &mut Self {
716        if cond {
717            f(self);
718        }
719        self
720    }
721
722    /// Apply `f` unconditionally. Useful for factoring out a block of modifier
723    /// calls that should always run, while keeping the fluent chain intact.
724    ///
725    /// # Example
726    ///
727    /// ```no_run
728    /// # slt::run(|ui: &mut slt::Context| {
729    /// use slt::Color;
730    /// ui.text("hi").with(|t| {
731    ///     t.bold().fg(Color::Cyan);
732    /// });
733    /// # });
734    /// ```
735    pub fn with(&mut self, f: impl FnOnce(&mut Self)) -> &mut Self {
736        f(self);
737        self
738    }
739
740    fn inherited_text_fg(&self) -> Color {
741        self.rollback
742            .text_color_stack
743            .iter()
744            .rev()
745            .find_map(|color| *color)
746            .unwrap_or(self.theme.text)
747    }
748}
749
750#[cfg(test)]
751mod gradient_tests {
752    use super::*;
753    use crate::TestBackend;
754
755    #[test]
756    fn gradient_stops_interpolates_fg_across_columns() {
757        let red = Color::Rgb(255, 0, 0);
758        let blue = Color::Rgb(0, 0, 255);
759        let mut backend = TestBackend::new(20, 4);
760        backend.render(|ui| {
761            ui.text("ABC")
762                .gradient_stops_f64(&[(0.0, red), (1.0, blue)]);
763        });
764
765        let buf = backend.buffer();
766        // i=0 → t=0 → stop at 0.0 (red); i=2 → t=1 → stop at 1.0 (blue);
767        // i=1 → t=0.5 → halfway blend.
768        assert_eq!(
769            buf.get(0, 0).style.fg,
770            Some(red),
771            "first column should be red"
772        );
773        assert_eq!(
774            buf.get(1, 0).style.fg,
775            Some(Color::Rgb(128, 0, 128)),
776            "middle column should be the halfway blend"
777        );
778        assert_eq!(
779            buf.get(2, 0).style.fg,
780            Some(blue),
781            "last column should be blue"
782        );
783    }
784
785    #[test]
786    fn two_stop_gradient_uses_documented_endpoint_order() {
787        let red = Color::Rgb(255, 0, 0);
788        let blue = Color::Rgb(0, 0, 255);
789        let mut backend = TestBackend::new(20, 4);
790        backend.render(|ui| {
791            ui.text("ABC").gradient(red, blue);
792        });
793
794        let buf = backend.buffer();
795        assert_eq!(buf.get(0, 0).style.fg, Some(red));
796        assert_eq!(buf.get(1, 0).style.fg, Some(Color::Rgb(128, 0, 128)));
797        assert_eq!(buf.get(2, 0).style.fg, Some(blue));
798    }
799
800    #[test]
801    fn gradient_stops_unsorted_input_is_sorted() {
802        let red = Color::Rgb(255, 0, 0);
803        let blue = Color::Rgb(0, 0, 255);
804        let mut backend = TestBackend::new(20, 4);
805        backend.render(|ui| {
806            // Deliberately out of order — must behave identically to sorted.
807            ui.text("ABC")
808                .gradient_stops_f64(&[(1.0, blue), (0.0, red)]);
809        });
810
811        let buf = backend.buffer();
812        assert_eq!(buf.get(0, 0).style.fg, Some(red));
813        assert_eq!(buf.get(2, 0).style.fg, Some(blue));
814    }
815
816    #[test]
817    fn gradient_stops_multi_stop_hits_middle_stop_exactly() {
818        let red = Color::Rgb(255, 0, 0);
819        let green = Color::Rgb(0, 255, 0);
820        let blue = Color::Rgb(0, 0, 255);
821        let mut backend = TestBackend::new(20, 4);
822        backend.render(|ui| {
823            // len=3, denom=2 → columns map to t = 0.0, 0.5, 1.0.
824            ui.text("ABC")
825                .gradient_stops_f64(&[(0.0, red), (0.5, green), (1.0, blue)]);
826        });
827
828        let buf = backend.buffer();
829        assert_eq!(buf.get(0, 0).style.fg, Some(red), "t=0 → first stop");
830        assert_eq!(
831            buf.get(1, 0).style.fg,
832            Some(green),
833            "t=0.5 → middle stop exactly"
834        );
835        assert_eq!(buf.get(2, 0).style.fg, Some(blue), "t=1 → last stop");
836    }
837
838    #[test]
839    fn gradient_stops_single_stop_is_solid() {
840        let cyan = Color::Rgb(0, 200, 200);
841        let mut backend = TestBackend::new(20, 4);
842        backend.render(|ui| {
843            ui.text("ABCD").gradient_stops_f64(&[(0.0, cyan)]);
844        });
845
846        let buf = backend.buffer();
847        for x in 0..4 {
848            assert_eq!(
849                buf.get(x, 0).style.fg,
850                Some(cyan),
851                "every column should be the single solid stop"
852            );
853        }
854    }
855
856    #[test]
857    fn gradient_stops_empty_is_noop() {
858        let mut backend = TestBackend::new(20, 4);
859        backend.render(|ui| {
860            // Empty slice must not panic and must leave content intact.
861            ui.text("HELLO").gradient_stops_f64(&[]);
862        });
863
864        backend.assert_contains("HELLO");
865    }
866
867    #[test]
868    fn bg_gradient_applies_to_background() {
869        let red = Color::Rgb(255, 0, 0);
870        let blue = Color::Rgb(0, 0, 255);
871        let mut backend = TestBackend::new(20, 4);
872        backend.render(|ui| {
873            ui.text("ABC").bg_gradient(red, blue);
874        });
875
876        let buf = backend.buffer();
877        assert_eq!(buf.get(0, 0).style.bg, Some(red), "first column bg = from");
878        assert_eq!(buf.get(2, 0).style.bg, Some(blue), "last column bg = to");
879        assert_eq!(
880            buf.get(1, 0).style.bg,
881            Some(Color::Rgb(128, 0, 128)),
882            "middle column bg = halfway blend"
883        );
884    }
885
886    #[test]
887    fn bg_gradient_stops_interpolates_background() {
888        let red = Color::Rgb(255, 0, 0);
889        let blue = Color::Rgb(0, 0, 255);
890        let mut backend = TestBackend::new(20, 4);
891        backend.render(|ui| {
892            ui.text("ABC")
893                .bg_gradient_stops_f64(&[(0.0, red), (1.0, blue)]);
894        });
895
896        let buf = backend.buffer();
897        assert_eq!(buf.get(0, 0).style.bg, Some(red), "first column bg = red");
898        assert_eq!(
899            buf.get(1, 0).style.bg,
900            Some(Color::Rgb(128, 0, 128)),
901            "middle column bg = halfway blend"
902        );
903        assert_eq!(buf.get(2, 0).style.bg, Some(blue), "last column bg = blue");
904    }
905
906    #[test]
907    fn bg_gradient_stops_empty_is_noop() {
908        let mut backend = TestBackend::new(20, 4);
909        backend.render(|ui| {
910            ui.text("WORLD").bg_gradient_stops_f64(&[]);
911        });
912
913        backend.assert_contains("WORLD");
914    }
915
916    #[test]
917    fn style_and_constraints_after_gradient_update_rich_text() {
918        let mut backend = TestBackend::new(20, 4);
919        backend.render(|ui| {
920            ui.text("ABC")
921                .gradient(Color::Red, Color::Blue)
922                .bold()
923                .fg(Color::Green)
924                .bg(Color::Black)
925                .w(8)
926                .m(1)
927                .align(Align::End);
928        });
929
930        let buf = backend.buffer();
931        let (start_x, y) = backend.find_text("ABC").expect("rendered gradient text");
932        for x in start_x..start_x + 3 {
933            let cell = buf.get(x, y);
934            assert_eq!(cell.style.fg, Some(Color::Green));
935            assert_eq!(cell.style.bg, Some(Color::Black));
936            assert!(cell.style.modifiers.contains(Modifiers::BOLD));
937        }
938    }
939
940    #[test]
941    fn dim_uses_theme_color_only_for_inherited_foreground() {
942        let theme = Theme::dark();
943        let mut backend = TestBackend::new(20, 4);
944        backend.render(|ui| {
945            ui.set_theme(theme);
946            ui.text("inherited").dim();
947            ui.text("explicit").fg(Color::Red).dim();
948        });
949
950        let buf = backend.buffer();
951        assert_eq!(buf.get(0, 0).style.fg, Some(theme.text_dim));
952        assert_eq!(buf.get(0, 1).style.fg, Some(Color::Red));
953        assert!(buf.get(0, 0).style.modifiers.contains(Modifiers::DIM));
954        assert!(buf.get(0, 1).style.modifiers.contains(Modifiers::DIM));
955    }
956
957    #[test]
958    fn gradient_positions_follow_grapheme_cell_width() {
959        let mut backend = TestBackend::new(20, 4);
960        backend.render(|ui| {
961            ui.text("界A").gradient(Color::Red, Color::Blue);
962        });
963
964        let buf = backend.buffer();
965        assert_eq!(buf.get(0, 0).style.fg, Some(Color::Red));
966        assert_eq!(buf.get(2, 0).style.fg, Some(Color::Blue));
967    }
968}