Skip to main content

teksilo_widgets/toast/
body.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `CollapsibleBody` — a toast body that clamps to a few lines and offers to unfold.
5//!
6//! # Why a toast body needs a ceiling
7//!
8//! A title is one line by construction ([`ToastSurface`](super::surface::ToastSurface)
9//! builds it `.single_line()`), but a body is whatever the app hands over — and apps hand
10//! over error text. A formatted `anyhow` chain carries every context frame and every
11//! absolute path in it; rendered at toast width that is easily eight or ten lines, and a
12//! toast that tall stops being a notification and becomes a dialog nobody agreed to open.
13//!
14//! So the body clamps to [`TOAST_BODY_COLLAPSED_LINES`] and, *only when there is more to
15//! see*, grows a thin disclosure row. Short bodies — the overwhelming majority — are
16//! untouched and gain no chrome.
17//!
18//! # How "is there more to see" is answered
19//!
20//! By measuring, in [`Widget::layout_response`], at the width the body will actually be
21//! given. That is the only place the real content width is known: it is the proposal this
22//! widget receives, already net of the glyph, the close button, the padding and the gaps.
23//! Deriving it any other way would mean re-deriving the chrome's arithmetic somewhere
24//! else and keeping the two in step forever.
25//!
26//! The measured verdict lands in a `Signal`, which is what drives the disclosure row's
27//! visibility — and writing a signal from layout is the thing to be careful about, since
28//! it schedules another pass. Two properties make it safe:
29//!
30//!  * **Transition-guarded.** The signal is written only when the verdict *changes*,
31//!    tracked in a `Cell`. A steady state writes nothing, so there is no loop. This is
32//!    the same discipline `BuildContext::activation_signal` documents for itself.
33//!  * **No feedback.** The disclosure row sits *below* the text in a `VStack`, so showing
34//!    it changes the body's height but never its width — and width is the only input to
35//!    the measurement. The verdict therefore cannot flip as a result of acting on it.
36
37use std::cell::Cell;
38use std::rc::Rc;
39
40use teksilo_canvas::{Rect, Size, SizeProposal};
41use teksilo_core::build_context::BuildContext;
42use teksilo_core::signal::Signal;
43use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
44use teksilo_core::widget_id::WidgetId;
45use teksilo_i18n::{LocalizedString, tr_widget};
46use teksilo_platform::clipboard::ClipboardHandle;
47use teksilo_tokens::{InputTokens, TextRole, TextStyleRole};
48
49use crate::link::Link;
50use crate::primitives::{HStack, TextWidget, VStack};
51use teksilo_core::styles::density::spacing;
52
53/// How many lines of body text a toast shows before offering to unfold.
54///
55/// Three is the point where a body still reads as a caption rather than a paragraph, and
56/// it comfortably fits the two-sentence bodies most apps write — those never see the
57/// disclosure row at all.
58pub const TOAST_BODY_COLLAPSED_LINES: usize = 3;
59
60/// Vertical gap between the body text and its disclosure row.
61pub const TOAST_BODY_DISCLOSURE_GAP: f32 = 2.0;
62
63/// [`TOAST_BODY_DISCLOSURE_GAP`] scaled by the density's `spacing_factor`
64/// (1.00 / 1.15 / 1.30).
65pub fn toast_body_disclosure_gap(tokens: &InputTokens) -> f32 {
66    spacing(TOAST_BODY_DISCLOSURE_GAP, tokens)
67}
68
69/// Horizontal gap between the disclosure row's actions.
70pub const TOAST_DISCLOSURE_ACTION_GAP: f32 = 12.0;
71
72/// [`TOAST_DISCLOSURE_ACTION_GAP`] scaled by the density's `spacing_factor`
73/// (1.00 / 1.15 / 1.30).
74pub fn toast_disclosure_action_gap(tokens: &InputTokens) -> f32 {
75    spacing(TOAST_DISCLOSURE_ACTION_GAP, tokens)
76}
77
78/// Put `text` on the system clipboard and flip `copied` so the row can say so.
79///
80/// A failed `set_text` leaves `copied` false rather than claiming success — the platform
81/// returns an error when there is no clipboard to talk to (a headless session, a
82/// compositor that denied access), and a "Copied" that did not happen is worse than no
83/// feedback at all.
84fn copy_to_clipboard(
85    ctx: &mut teksilo_core::widget::EventContext,
86    text: &str,
87    copied: &Signal<bool>,
88) {
89    let ok = ctx
90        .app_state::<ClipboardHandle>()
91        .map(|cb| cb.set_text(text).is_ok())
92        .unwrap_or(false);
93    if ok {
94        copied.set(true);
95    }
96}
97
98/// What the body is currently doing. One signal rather than an `expanded` /
99/// `overflowing` pair, because every visibility below is then a plain `.map()` off it —
100/// no signal-combining, and no way to represent the impossible state "expanded but there
101/// was never anything to expand".
102#[derive(Clone, Copy, PartialEq, Eq, Debug)]
103enum BodyState {
104    /// Fits within the clamp. No disclosure row.
105    Fits,
106    /// Clamped, with more to see.
107    Collapsed,
108    /// Showing everything.
109    Expanded,
110}
111
112impl BodyState {
113    fn as_u8(self) -> u8 {
114        match self {
115            Self::Fits => 0,
116            Self::Collapsed => 1,
117            Self::Expanded => 2,
118        }
119    }
120}
121
122/// A toast body: clamped text plus a disclosure row that appears only when clamping
123/// actually hid something.
124pub(crate) struct CollapsibleBody {
125    text: LocalizedString,
126    /// `BodyState::as_u8` — a plain scalar so `.map()` projections stay `Copy`-cheap.
127    state: Signal<u8>,
128    /// Run when the reader unfolds. The toast uses it to cancel auto-dismiss — see
129    /// [`ToastRegistry::cancel_auto_dismiss`](crate::toast::registry::ToastRegistry).
130    on_expand: Option<Rc<dyn Fn()>>,
131    column_id: Option<WidgetId>,
132    /// Last verdict written to `state`, so a steady state writes nothing. See the module
133    /// docs on why this guard is what makes a layout-time signal write safe.
134    last_overflowing: Cell<Option<bool>>,
135}
136
137impl std::fmt::Debug for CollapsibleBody {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("CollapsibleBody")
140            .field("text", &self.text)
141            .field("state", &self.state.get())
142            .finish()
143    }
144}
145
146impl CollapsibleBody {
147    /// `state` is supplied by the caller rather than created here, and that is load
148    /// bearing: `ToastHost` builds a fresh `ToastSurface` — hence a fresh body — every
149    /// time the live set changes, so a widget-owned signal would re-fold the toast the
150    /// moment any *other* toast arrived. The entry owns it instead
151    /// (`LiveEntry::body_state`) and it outlives every rebuild.
152    pub(crate) fn new(text: LocalizedString, state: Signal<u8>) -> Self {
153        Self {
154            text,
155            state,
156            on_expand: None,
157            column_id: None,
158            last_overflowing: Cell::new(None),
159        }
160    }
161
162    /// Run `f` when the reader unfolds the body.
163    pub(crate) fn on_expand(mut self, f: impl Fn() + 'static) -> Self {
164        self.on_expand = Some(Rc::new(f));
165        self
166    }
167}
168
169impl Widget for CollapsibleBody {
170    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
171        let state = self.state.clone();
172
173        // Two text widgets rather than one with a reactive `max_lines`: `TextWidget`
174        // takes a plain `usize` there, and swapping visibility is the pattern the rest of
175        // the toolkit already uses for disclosure (see `Accordion`'s paired chevrons).
176        // The cost is one extra measured layout of the same string.
177        let clamped = ctx.add(
178            TextWidget::new(self.text.clone())
179                .style(TextStyleRole::Body)
180                .color(TextRole::Secondary)
181                .max_lines(TOAST_BODY_COLLAPSED_LINES),
182        );
183        let full = ctx.add(
184            TextWidget::new(self.text.clone())
185                .style(TextStyleRole::Body)
186                .color(TextRole::Secondary),
187        );
188        ctx.visible_when(clamped, state.map(|s| *s != BodyState::Expanded.as_u8()));
189        ctx.visible_when(full, state.map(|s| *s == BodyState::Expanded.as_u8()));
190
191        // A `Link`, not a `Button`: this is a low-weight reveal inside a notification,
192        // and a button's chrome would compete with any real action the toast carries.
193        let expand_state = state.clone();
194        let on_expand = self.on_expand.clone();
195        let show_more = ctx.add(Link::new(tr_widget!(toast_show_more())).on_activate_fn(
196            move |_| {
197                expand_state.set(BodyState::Expanded.as_u8());
198                if let Some(f) = &on_expand {
199                    f();
200                }
201            },
202        ));
203        let collapse_state = state.clone();
204        let show_less = ctx.add(
205            Link::new(tr_widget!(toast_show_less()))
206                .on_activate_fn(move |_| collapse_state.set(BodyState::Collapsed.as_u8())),
207        );
208        ctx.visible_when(show_more, state.map(|s| *s == BodyState::Collapsed.as_u8()));
209        ctx.visible_when(show_less, state.map(|s| *s == BodyState::Expanded.as_u8()));
210
211        // Copy. A body long enough to be clamped is, in practice, an error chain — the
212        // exact text someone wants in a bug report or a search box, and the exact text
213        // that is miserable to retype. Reading it and *keeping* it are the two things you
214        // want from a truncated error, so the affordances sit together.
215        //
216        // Confirmation is a label swap rather than a timed revert or a nested toast:
217        // "Copied" needs no timer to be honest, and the link stays live so a second click
218        // still works (a reader who copied, scrolled away, and came back should not have
219        // to guess whether it took).
220        let copied = ctx.signal(false);
221        let copy_text = self.text.clone();
222        let copied_flag = copied.clone();
223        let copy = ctx.add(Link::new(tr_widget!(toast_copy_body())).on_activate_fn(
224            move |ctx: &mut teksilo_core::widget::EventContext| {
225                copy_to_clipboard(ctx, &copy_text.resolve_now(), &copied_flag);
226            },
227        ));
228        let recopy_text = self.text.clone();
229        let recopy_flag = copied.clone();
230        let copied_label = ctx.add(Link::new(tr_widget!(toast_body_copied())).on_activate_fn(
231            move |ctx: &mut teksilo_core::widget::EventContext| {
232                copy_to_clipboard(ctx, &recopy_text.resolve_now(), &recopy_flag);
233            },
234        ));
235        ctx.visible_when(copy, copied.map(|c| !*c));
236        ctx.visible_when(copied_label, copied.clone());
237
238        let disclosure = ctx.add(
239            HStack::new()
240                .spacing(toast_disclosure_action_gap(&ctx.theme().input))
241                .child(show_more)
242                .child(show_less)
243                .child(copy)
244                .child(copied_label),
245        );
246        // The whole row rides on the clamp: a body short enough to be fully visible gains
247        // no chrome at all, which is the overwhelming majority of toasts.
248        ctx.visible_when(disclosure, state.map(|s| *s != BodyState::Fits.as_u8()));
249
250        let column = ctx.add(
251            VStack::new()
252                .spacing(toast_body_disclosure_gap(&ctx.theme().input))
253                .child(clamped)
254                .child(full)
255                .child(disclosure),
256        );
257        self.column_id = Some(column);
258        vec![column]
259    }
260
261    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
262        // Measure the *unclamped* text at the width this body is being given. `max_lines:
263        // None` on purpose — asking the clamped question would answer itself.
264        if let (Some(width), Some(backend)) = (proposal.width, ctx.text_backend)
265            && width > 0.0
266        {
267            let text = self.text.resolve_now();
268            let style = TextStyleRole::Body.resolve(&ctx.theme.typography);
269            // The same +0.5 epsilon `TextWidget` applies, so both land on one
270            // `TypesetterBridge` cache key rather than two that disagree at the margin.
271            let layout = backend
272                .borrow_mut()
273                .layout_paragraph(&text, &style, width + 0.5, None);
274            let overflowing = layout.line_count > TOAST_BODY_COLLAPSED_LINES;
275
276            if self.last_overflowing.get() != Some(overflowing) {
277                self.last_overflowing.set(Some(overflowing));
278                // Never override a reader who has already unfolded this: only the
279                // shrinking direction is theirs to lose.
280                let current = self.state.get();
281                let next = if overflowing {
282                    if current == BodyState::Expanded.as_u8() {
283                        current
284                    } else {
285                        BodyState::Collapsed.as_u8()
286                    }
287                } else {
288                    BodyState::Fits.as_u8()
289                };
290                if next != current {
291                    self.state.set(next);
292                }
293            }
294        }
295
296        self.column_id
297            .and_then(|id| ctx.child_size(id, proposal))
298            .unwrap_or(Size::ZERO)
299            .into()
300    }
301
302    fn place_children(
303        &self,
304        bounds: Rect,
305        _proposal: SizeProposal,
306        children: &mut [WidgetPlacement],
307        _ctx: &LayoutContext,
308    ) {
309        for child in children.iter_mut() {
310            child.origin = bounds.origin();
311            child.size = bounds.size();
312        }
313    }
314
315    fn children(&self) -> Vec<WidgetId> {
316        self.column_id.into_iter().collect()
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use std::cell::RefCell;
324    use std::rc::Rc;
325    use teksilo_canvas::text_backend::MockTextBackend;
326    use teksilo_core::widget_tree::WidgetTree;
327    use teksilo_core::window::NoopWindowOps;
328    use teksilo_i18n::lit;
329
330    /// `MockTextBackend` wraps on whole words at 8px/char and 16px lines, so the line
331    /// count below is arithmetic rather than a guess.
332    const LINE_H: f32 = 16.0;
333    const WIDTH: f32 = 160.0; // 20 characters per line
334
335    fn tree() -> WidgetTree {
336        WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())))
337    }
338
339    fn lay_out(text: &str, state: Signal<u8>) -> (WidgetTree, WidgetId) {
340        let mut t = tree();
341        let id = t.add(CollapsibleBody::new(lit!(text.to_string()), state));
342        t.layout(SizeProposal {
343            width: Some(WIDTH),
344            height: None,
345        });
346        // A second pass: the first one's measurement is what *decides* the state, and the
347        // disclosure row is laid out against the decision. The real app gets this for
348        // free — the signal write schedules the next pass.
349        t.layout(SizeProposal {
350            width: Some(WIDTH),
351            height: None,
352        });
353        (t, id)
354    }
355
356    /// The common case must gain nothing: no clamp, no disclosure row, no extra height.
357    #[test]
358    fn a_body_that_fits_gets_no_disclosure_row() {
359        let state = Signal::new(BodyState::Fits.as_u8());
360        let (t, id) = lay_out("short body", state.clone());
361
362        assert_eq!(state.get(), BodyState::Fits.as_u8());
363        assert!(
364            (t.bounds(id).height - LINE_H).abs() < 0.5,
365            "one line of text and nothing else; got {}",
366            t.bounds(id).height
367        );
368    }
369
370    /// A long body is clamped, and the clamp is what bounds the toast's height. Without
371    /// it a formatted `anyhow` chain — every context frame and every absolute path —
372    /// grows the toast without limit.
373    #[test]
374    fn a_long_body_is_clamped_and_offers_to_unfold() {
375        let long = "aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk llll mmmm nnnn";
376        let state = Signal::new(BodyState::Fits.as_u8());
377        let (t, id) = lay_out(long, state.clone());
378
379        assert_eq!(
380            state.get(),
381            BodyState::Collapsed.as_u8(),
382            "the measurement must have found more than {TOAST_BODY_COLLAPSED_LINES} lines"
383        );
384
385        let clamped_height = t.bounds(id).height;
386        let text_ceiling = TOAST_BODY_COLLAPSED_LINES as f32 * LINE_H;
387        assert!(
388            clamped_height > text_ceiling,
389            "the disclosure row must add height; got {clamped_height}"
390        );
391        assert!(
392            clamped_height < text_ceiling + 2.0 * LINE_H,
393            "…but only a row's worth — a clamped body is not allowed to grow; got {clamped_height}"
394        );
395    }
396
397    /// Unfolding shows everything. Driven through the signal rather than a synthetic
398    /// click, because the signal IS the widget's contract with its host.
399    #[test]
400    fn unfolding_shows_the_whole_body() {
401        let long = "aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk llll mmmm nnnn";
402        let state = Signal::new(BodyState::Fits.as_u8());
403        let (mut t, id) = lay_out(long, state.clone());
404        let clamped_height = t.bounds(id).height;
405
406        state.set(BodyState::Expanded.as_u8());
407        t.layout(SizeProposal {
408            width: Some(WIDTH),
409            height: None,
410        });
411
412        assert!(
413            t.bounds(id).height > clamped_height,
414            "unfolding must reveal more than the clamp showed ({} vs {})",
415            t.bounds(id).height,
416            clamped_height
417        );
418    }
419
420    /// The re-measure must not fold a body the reader just opened. This is the shape a
421    /// resize or a locale change takes: another layout pass over an already-expanded body.
422    #[test]
423    fn a_relayout_does_not_refold_an_unfolded_body() {
424        let long = "aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk llll mmmm nnnn";
425        let state = Signal::new(BodyState::Fits.as_u8());
426        let (mut t, _id) = lay_out(long, state.clone());
427
428        state.set(BodyState::Expanded.as_u8());
429        for _ in 0..3 {
430            t.layout(SizeProposal {
431                width: Some(WIDTH),
432                height: None,
433            });
434        }
435
436        assert_eq!(
437            state.get(),
438            BodyState::Expanded.as_u8(),
439            "the layout-time probe must leave an unfolded body alone"
440        );
441    }
442
443    fn ctx_with_memory_clipboard(
444        tree: &mut WidgetTree,
445    ) -> teksilo_platform::clipboard::ClipboardHandle {
446        use std::any::TypeId;
447        use std::collections::HashMap;
448        use teksilo_core::event_source::TreeAppContext;
449        use teksilo_platform::clipboard::MemoryClipboard;
450        let handle = ClipboardHandle::new(MemoryClipboard::new());
451        let mut registry: HashMap<TypeId, Box<dyn std::any::Any>> = HashMap::new();
452        registry.insert(TypeId::of::<ClipboardHandle>(), Box::new(handle.clone()));
453        tree.set_app_context(Rc::new(TreeAppContext::empty().with_app_state(registry)));
454        handle
455    }
456
457    /// Copy puts the **whole** body on the clipboard, not the three lines on screen.
458    /// A clamped error chain is precisely the text someone needs to paste somewhere, and
459    /// pasting the visible truncation would be worse than useless.
460    #[test]
461    fn copy_puts_the_unclamped_body_on_the_clipboard() {
462        let long = "aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk llll mmmm nnnn";
463        let mut t = tree();
464        let clipboard = ctx_with_memory_clipboard(&mut t);
465        let copied = Signal::new(false);
466
467        // Exercise the same helper the link's handler calls. Driving the Link itself
468        // would test teksilo's hit-testing, not this widget's contract.
469        t.run_with_event_context(&mut NoopWindowOps, |ctx| {
470            copy_to_clipboard(ctx, long, &copied)
471        });
472
473        assert_eq!(clipboard.get_text().unwrap_or_default(), long);
474        assert!(copied.get(), "the row must switch to its confirmed label");
475    }
476
477    /// No clipboard (headless, or a compositor that said no) must not claim success.
478    #[test]
479    fn a_failed_copy_does_not_claim_to_have_copied() {
480        let mut t = tree();
481        let copied = Signal::new(false);
482        t.run_with_event_context(&mut NoopWindowOps, |ctx| {
483            copy_to_clipboard(ctx, "anything", &copied)
484        });
485        assert!(
486            !copied.get(),
487            "with no ClipboardHandle registered there is nothing to confirm"
488        );
489    }
490
491    /// The guard that makes a layout-time signal write safe: a steady state must stop
492    /// writing. If it did not, every pass would schedule another one forever.
493    #[test]
494    fn a_steady_state_stops_writing_the_signal() {
495        let long = "aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk llll mmmm nnnn";
496        let state = Signal::new(BodyState::Fits.as_u8());
497        let (mut t, _id) = lay_out(long, state.clone());
498        assert_eq!(state.get(), BodyState::Collapsed.as_u8());
499
500        // Count notifications across further passes: a settled body must emit none.
501        let writes = Rc::new(Cell::new(0usize));
502        let w = writes.clone();
503        let _handle = state.observe(move |_| w.set(w.get() + 1));
504
505        for _ in 0..5 {
506            t.layout(SizeProposal {
507                width: Some(WIDTH),
508                height: None,
509            });
510        }
511
512        assert_eq!(
513            writes.get(),
514            0,
515            "a settled body must not keep rewriting its state signal"
516        );
517    }
518}