1use 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
53pub const TOAST_BODY_COLLAPSED_LINES: usize = 3;
59
60pub const TOAST_BODY_DISCLOSURE_GAP: f32 = 2.0;
62
63pub fn toast_body_disclosure_gap(tokens: &InputTokens) -> f32 {
66 spacing(TOAST_BODY_DISCLOSURE_GAP, tokens)
67}
68
69pub const TOAST_DISCLOSURE_ACTION_GAP: f32 = 12.0;
71
72pub fn toast_disclosure_action_gap(tokens: &InputTokens) -> f32 {
75 spacing(TOAST_DISCLOSURE_ACTION_GAP, tokens)
76}
77
78fn 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
103enum BodyState {
104 Fits,
106 Collapsed,
108 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
122pub(crate) struct CollapsibleBody {
125 text: LocalizedString,
126 state: Signal<u8>,
128 on_expand: Option<Rc<dyn Fn()>>,
131 column_id: Option<WidgetId>,
132 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 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 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 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 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 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, ©_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 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 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 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 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 const LINE_H: f32 = 16.0;
333 const WIDTH: f32 = 160.0; 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 t.layout(SizeProposal {
350 width: Some(WIDTH),
351 height: None,
352 });
353 (t, id)
354 }
355
356 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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}