use std::cell::Cell;
use std::rc::Rc;
use teksilo_canvas::{Rect, Size, SizeProposal};
use teksilo_core::build_context::BuildContext;
use teksilo_core::signal::Signal;
use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
use teksilo_i18n::{LocalizedString, tr_widget};
use teksilo_platform::clipboard::ClipboardHandle;
use teksilo_tokens::{TextRole, TextStyleRole};
use crate::link::Link;
use crate::primitives::{HStack, TextWidget, VStack};
pub const TOAST_BODY_COLLAPSED_LINES: usize = 3;
pub const TOAST_BODY_DISCLOSURE_GAP: f32 = 2.0;
pub const TOAST_DISCLOSURE_ACTION_GAP: f32 = 12.0;
fn copy_to_clipboard(
ctx: &mut teksilo_core::widget::EventContext,
text: &str,
copied: &Signal<bool>,
) {
let ok = ctx
.app_state::<ClipboardHandle>()
.map(|cb| cb.set_text(text).is_ok())
.unwrap_or(false);
if ok {
copied.set(true);
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum BodyState {
Fits,
Collapsed,
Expanded,
}
impl BodyState {
fn as_u8(self) -> u8 {
match self {
Self::Fits => 0,
Self::Collapsed => 1,
Self::Expanded => 2,
}
}
}
pub(crate) struct CollapsibleBody {
text: LocalizedString,
state: Signal<u8>,
on_expand: Option<Rc<dyn Fn()>>,
column_id: Option<WidgetId>,
last_overflowing: Cell<Option<bool>>,
}
impl std::fmt::Debug for CollapsibleBody {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CollapsibleBody")
.field("text", &self.text)
.field("state", &self.state.get())
.finish()
}
}
impl CollapsibleBody {
pub(crate) fn new(text: LocalizedString, state: Signal<u8>) -> Self {
Self {
text,
state,
on_expand: None,
column_id: None,
last_overflowing: Cell::new(None),
}
}
pub(crate) fn on_expand(mut self, f: impl Fn() + 'static) -> Self {
self.on_expand = Some(Rc::new(f));
self
}
}
impl Widget for CollapsibleBody {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let state = self.state.clone();
let clamped = ctx.add(
TextWidget::new(self.text.clone())
.style(TextStyleRole::Body)
.color(TextRole::Secondary)
.max_lines(TOAST_BODY_COLLAPSED_LINES),
);
let full = ctx.add(
TextWidget::new(self.text.clone())
.style(TextStyleRole::Body)
.color(TextRole::Secondary),
);
ctx.visible_when(clamped, state.map(|s| *s != BodyState::Expanded.as_u8()));
ctx.visible_when(full, state.map(|s| *s == BodyState::Expanded.as_u8()));
let expand_state = state.clone();
let on_expand = self.on_expand.clone();
let show_more = ctx.add(Link::new(tr_widget!(toast_show_more())).on_activate_fn(
move |_| {
expand_state.set(BodyState::Expanded.as_u8());
if let Some(f) = &on_expand {
f();
}
},
));
let collapse_state = state.clone();
let show_less = ctx.add(
Link::new(tr_widget!(toast_show_less()))
.on_activate_fn(move |_| collapse_state.set(BodyState::Collapsed.as_u8())),
);
ctx.visible_when(show_more, state.map(|s| *s == BodyState::Collapsed.as_u8()));
ctx.visible_when(show_less, state.map(|s| *s == BodyState::Expanded.as_u8()));
let copied = ctx.signal(false);
let copy_text = self.text.clone();
let copied_flag = copied.clone();
let copy = ctx.add(Link::new(tr_widget!(toast_copy_body())).on_activate_fn(
move |ctx: &mut teksilo_core::widget::EventContext| {
copy_to_clipboard(ctx, ©_text.resolve_now(), &copied_flag);
},
));
let recopy_text = self.text.clone();
let recopy_flag = copied.clone();
let copied_label = ctx.add(Link::new(tr_widget!(toast_body_copied())).on_activate_fn(
move |ctx: &mut teksilo_core::widget::EventContext| {
copy_to_clipboard(ctx, &recopy_text.resolve_now(), &recopy_flag);
},
));
ctx.visible_when(copy, copied.map(|c| !*c));
ctx.visible_when(copied_label, copied.clone());
let disclosure = ctx.add(
HStack::new()
.spacing(TOAST_DISCLOSURE_ACTION_GAP)
.add_child(show_more)
.add_child(show_less)
.add_child(copy)
.add_child(copied_label),
);
ctx.visible_when(disclosure, state.map(|s| *s != BodyState::Fits.as_u8()));
let column = ctx.add(
VStack::new()
.spacing(TOAST_BODY_DISCLOSURE_GAP)
.add_child(clamped)
.add_child(full)
.add_child(disclosure),
);
self.column_id = Some(column);
vec![column]
}
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
if let (Some(width), Some(backend)) = (proposal.width, ctx.text_backend)
&& width > 0.0
{
let text = self.text.resolve_now();
let style = TextStyleRole::Body.resolve(&ctx.theme.typography);
let layout = backend
.borrow_mut()
.layout_paragraph(&text, &style, width + 0.5, None);
let overflowing = layout.line_count > TOAST_BODY_COLLAPSED_LINES;
if self.last_overflowing.get() != Some(overflowing) {
self.last_overflowing.set(Some(overflowing));
let current = self.state.get();
let next = if overflowing {
if current == BodyState::Expanded.as_u8() {
current
} else {
BodyState::Collapsed.as_u8()
}
} else {
BodyState::Fits.as_u8()
};
if next != current {
self.state.set(next);
}
}
}
self.column_id
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or(Size::ZERO)
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
}
fn children(&self) -> Vec<WidgetId> {
self.column_id.into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::rc::Rc;
use teksilo_canvas::text_backend::MockTextBackend;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_core::window::NoopWindowOps;
use teksilo_i18n::lit;
const LINE_H: f32 = 16.0;
const WIDTH: f32 = 160.0;
fn tree() -> WidgetTree {
WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())))
}
fn lay_out(text: &str, state: Signal<u8>) -> (WidgetTree, WidgetId) {
let mut t = tree();
let id = t.add(CollapsibleBody::new(lit!(text.to_string()), state));
t.layout(SizeProposal {
width: Some(WIDTH),
height: None,
});
t.layout(SizeProposal {
width: Some(WIDTH),
height: None,
});
(t, id)
}
#[test]
fn a_body_that_fits_gets_no_disclosure_row() {
let state = Signal::new(BodyState::Fits.as_u8());
let (t, id) = lay_out("short body", state.clone());
assert_eq!(state.get(), BodyState::Fits.as_u8());
assert!(
(t.bounds(id).height - LINE_H).abs() < 0.5,
"one line of text and nothing else; got {}",
t.bounds(id).height
);
}
#[test]
fn a_long_body_is_clamped_and_offers_to_unfold() {
let long = "aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk llll mmmm nnnn";
let state = Signal::new(BodyState::Fits.as_u8());
let (t, id) = lay_out(long, state.clone());
assert_eq!(
state.get(),
BodyState::Collapsed.as_u8(),
"the measurement must have found more than {TOAST_BODY_COLLAPSED_LINES} lines"
);
let clamped_height = t.bounds(id).height;
let text_ceiling = TOAST_BODY_COLLAPSED_LINES as f32 * LINE_H;
assert!(
clamped_height > text_ceiling,
"the disclosure row must add height; got {clamped_height}"
);
assert!(
clamped_height < text_ceiling + 2.0 * LINE_H,
"…but only a row's worth — a clamped body is not allowed to grow; got {clamped_height}"
);
}
#[test]
fn unfolding_shows_the_whole_body() {
let long = "aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk llll mmmm nnnn";
let state = Signal::new(BodyState::Fits.as_u8());
let (mut t, id) = lay_out(long, state.clone());
let clamped_height = t.bounds(id).height;
state.set(BodyState::Expanded.as_u8());
t.layout(SizeProposal {
width: Some(WIDTH),
height: None,
});
assert!(
t.bounds(id).height > clamped_height,
"unfolding must reveal more than the clamp showed ({} vs {})",
t.bounds(id).height,
clamped_height
);
}
#[test]
fn a_relayout_does_not_refold_an_unfolded_body() {
let long = "aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk llll mmmm nnnn";
let state = Signal::new(BodyState::Fits.as_u8());
let (mut t, _id) = lay_out(long, state.clone());
state.set(BodyState::Expanded.as_u8());
for _ in 0..3 {
t.layout(SizeProposal {
width: Some(WIDTH),
height: None,
});
}
assert_eq!(
state.get(),
BodyState::Expanded.as_u8(),
"the layout-time probe must leave an unfolded body alone"
);
}
fn ctx_with_memory_clipboard(
tree: &mut WidgetTree,
) -> teksilo_platform::clipboard::ClipboardHandle {
use std::any::TypeId;
use std::collections::HashMap;
use teksilo_core::event_source::TreeAppContext;
use teksilo_platform::clipboard::MemoryClipboard;
let handle = ClipboardHandle::new(MemoryClipboard::new());
let mut registry: HashMap<TypeId, Box<dyn std::any::Any>> = HashMap::new();
registry.insert(TypeId::of::<ClipboardHandle>(), Box::new(handle.clone()));
tree.set_app_context(Rc::new(TreeAppContext::empty().with_app_state(registry)));
handle
}
#[test]
fn copy_puts_the_unclamped_body_on_the_clipboard() {
let long = "aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk llll mmmm nnnn";
let mut t = tree();
let clipboard = ctx_with_memory_clipboard(&mut t);
let copied = Signal::new(false);
t.run_with_event_context(&mut NoopWindowOps, |ctx| {
copy_to_clipboard(ctx, long, &copied)
});
assert_eq!(clipboard.get_text().unwrap_or_default(), long);
assert!(copied.get(), "the row must switch to its confirmed label");
}
#[test]
fn a_failed_copy_does_not_claim_to_have_copied() {
let mut t = tree();
let copied = Signal::new(false);
t.run_with_event_context(&mut NoopWindowOps, |ctx| {
copy_to_clipboard(ctx, "anything", &copied)
});
assert!(
!copied.get(),
"with no ClipboardHandle registered there is nothing to confirm"
);
}
#[test]
fn a_steady_state_stops_writing_the_signal() {
let long = "aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk llll mmmm nnnn";
let state = Signal::new(BodyState::Fits.as_u8());
let (mut t, _id) = lay_out(long, state.clone());
assert_eq!(state.get(), BodyState::Collapsed.as_u8());
let writes = Rc::new(Cell::new(0usize));
let w = writes.clone();
let _handle = state.observe(move |_| w.set(w.get() + 1));
for _ in 0..5 {
t.layout(SizeProposal {
width: Some(WIDTH),
height: None,
});
}
assert_eq!(
writes.get(),
0,
"a settled body must not keep rewriting its state signal"
);
}
}