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