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::{TextRole, TextStyleRole};
48
49use crate::link::Link;
50use crate::primitives::{HStack, TextWidget, VStack};
51
52pub const TOAST_BODY_COLLAPSED_LINES: usize = 3;
58
59pub const TOAST_BODY_DISCLOSURE_GAP: f32 = 2.0;
61
62pub const TOAST_DISCLOSURE_ACTION_GAP: f32 = 12.0;
64
65fn 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
90enum BodyState {
91 Fits,
93 Collapsed,
95 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
109pub(crate) struct CollapsibleBody {
112 text: LocalizedString,
113 state: Signal<u8>,
115 on_expand: Option<Rc<dyn Fn()>>,
118 column_id: Option<WidgetId>,
119 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 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 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 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 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 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, ©_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 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 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 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 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 const LINE_H: f32 = 16.0;
320 const WIDTH: f32 = 160.0; 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 t.layout(SizeProposal {
337 width: Some(WIDTH),
338 height: None,
339 });
340 (t, id)
341 }
342
343 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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}