Another project
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): panel and toast widgets

Lewis: May this revision serve well! <lu5a@proton.me>

+786
+391
crates/bone-ui/src/widgets/panel.rs
··· 1 + use crate::frame::{FrameCtx, InteractDeclaration}; 2 + use crate::hit_test::Sense; 3 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 4 + use crate::strings::StringKey; 5 + use crate::theme::{Border, Step12, StrokeWidth, SurfaceLevel}; 6 + use crate::widget_id::{WidgetId, WidgetKey}; 7 + 8 + use super::keys::{TakeKey, take_key}; 9 + use super::paint::{GlyphMark, LabelText, WidgetPaint}; 10 + use super::visuals::push_focus_ring; 11 + 12 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 13 + pub enum PanelVariant { 14 + Plain, 15 + Card, 16 + } 17 + 18 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] 19 + pub struct PanelState { 20 + pub collapsed: bool, 21 + } 22 + 23 + impl PanelState { 24 + #[must_use] 25 + pub const fn open() -> Self { 26 + Self { collapsed: false } 27 + } 28 + 29 + #[must_use] 30 + pub const fn collapsed() -> Self { 31 + Self { collapsed: true } 32 + } 33 + } 34 + 35 + #[derive(Copy, Clone, Debug, PartialEq)] 36 + pub struct PanelTitlebar { 37 + pub label: StringKey, 38 + pub height: LayoutPx, 39 + pub collapsible: bool, 40 + } 41 + 42 + #[derive(Debug, PartialEq)] 43 + pub struct Panel<'state> { 44 + pub id: WidgetId, 45 + pub rect: LayoutRect, 46 + pub variant: PanelVariant, 47 + pub titlebar: Option<PanelTitlebar>, 48 + pub state: &'state mut PanelState, 49 + } 50 + 51 + impl<'state> Panel<'state> { 52 + #[must_use] 53 + pub fn new(id: WidgetId, rect: LayoutRect, state: &'state mut PanelState) -> Self { 54 + Self { 55 + id, 56 + rect, 57 + variant: PanelVariant::Plain, 58 + titlebar: None, 59 + state, 60 + } 61 + } 62 + 63 + #[must_use] 64 + pub fn variant(self, variant: PanelVariant) -> Self { 65 + Self { variant, ..self } 66 + } 67 + 68 + #[must_use] 69 + pub fn titlebar(self, titlebar: PanelTitlebar) -> Self { 70 + Self { 71 + titlebar: Some(titlebar), 72 + ..self 73 + } 74 + } 75 + } 76 + 77 + #[derive(Clone, Debug, PartialEq)] 78 + pub struct PanelResponse { 79 + pub body_rect: Option<LayoutRect>, 80 + pub paint: Vec<WidgetPaint>, 81 + } 82 + 83 + #[must_use] 84 + pub fn show_panel(ctx: &mut FrameCtx<'_>, panel: Panel<'_>) -> PanelResponse { 85 + let Panel { 86 + id, 87 + rect, 88 + variant, 89 + titlebar, 90 + state, 91 + } = panel; 92 + let mut paint = panel_surface(ctx, rect, variant); 93 + let body_origin_y = match titlebar { 94 + None => rect.origin.y, 95 + Some(bar) => { 96 + paint.extend(draw_titlebar(ctx, id, rect, bar, state)); 97 + LayoutPx::new(rect.origin.y.value() + bar.height.value()) 98 + } 99 + }; 100 + let body_rect = if state.collapsed { 101 + None 102 + } else { 103 + Some(LayoutRect::new( 104 + LayoutPos::new(rect.origin.x, body_origin_y), 105 + LayoutSize::new( 106 + rect.size.width, 107 + LayoutPx::saturating_nonneg( 108 + rect.size.height.value() - (body_origin_y.value() - rect.origin.y.value()), 109 + ), 110 + ), 111 + )) 112 + }; 113 + PanelResponse { body_rect, paint } 114 + } 115 + 116 + fn panel_surface(ctx: &FrameCtx<'_>, rect: LayoutRect, variant: PanelVariant) -> Vec<WidgetPaint> { 117 + let neutral = ctx.theme.colors.neutral; 118 + let (fill, border) = match variant { 119 + PanelVariant::Plain => (ctx.theme.colors.surface(SurfaceLevel::L0), None), 120 + PanelVariant::Card => ( 121 + ctx.theme.colors.surface(SurfaceLevel::L1), 122 + Some(Border { 123 + width: StrokeWidth::HAIRLINE, 124 + color: neutral.step(Step12::SUBTLE_BORDER), 125 + }), 126 + ), 127 + }; 128 + vec![WidgetPaint::Surface { 129 + rect, 130 + fill, 131 + border, 132 + radius: ctx.theme.radius.sm, 133 + elevation: None, 134 + }] 135 + } 136 + 137 + fn draw_titlebar( 138 + ctx: &mut FrameCtx<'_>, 139 + id: WidgetId, 140 + panel_rect: LayoutRect, 141 + bar: PanelTitlebar, 142 + state: &mut PanelState, 143 + ) -> Vec<WidgetPaint> { 144 + let title_rect = LayoutRect::new( 145 + panel_rect.origin, 146 + LayoutSize::new(panel_rect.size.width, bar.height), 147 + ); 148 + let mut paint = vec![WidgetPaint::Surface { 149 + rect: title_rect, 150 + fill: ctx.theme.colors.neutral.step(Step12::SUBTLE_BG), 151 + border: Some(Border { 152 + width: StrokeWidth::HAIRLINE, 153 + color: ctx.theme.colors.neutral.step(Step12::SUBTLE_BORDER), 154 + }), 155 + radius: ctx.theme.radius.sm, 156 + elevation: None, 157 + }]; 158 + let toggle_id = id.child(WidgetKey::new("titlebar")); 159 + let toggle_rect = title_rect; 160 + if bar.collapsible { 161 + let interaction = ctx.interact( 162 + InteractDeclaration::new(toggle_id, toggle_rect, Sense::INTERACTIVE).focusable(true), 163 + ); 164 + let live_focused = ctx.is_focused(toggle_id); 165 + if interaction.click() { 166 + state.collapsed = !state.collapsed; 167 + } 168 + if live_focused { 169 + let activated = take_key( 170 + ctx.input, 171 + &[ 172 + TakeKey::named(crate::input::NamedKey::Enter), 173 + TakeKey::named(crate::input::NamedKey::Space), 174 + ], 175 + ); 176 + if activated.is_some() { 177 + state.collapsed = !state.collapsed; 178 + } 179 + } 180 + let chevron_rect = chevron_rect(title_rect); 181 + paint.push(WidgetPaint::Mark { 182 + rect: chevron_rect, 183 + kind: if state.collapsed { 184 + GlyphMark::DisclosureClosed 185 + } else { 186 + GlyphMark::DisclosureOpen 187 + }, 188 + color: ctx.theme.colors.text_secondary(), 189 + }); 190 + push_focus_ring( 191 + ctx, 192 + &mut paint, 193 + title_rect, 194 + ctx.theme.radius.sm, 195 + live_focused, 196 + ); 197 + } 198 + paint.push(WidgetPaint::Label { 199 + rect: label_rect(title_rect, bar.collapsible), 200 + text: LabelText::Key(bar.label), 201 + color: ctx.theme.colors.text_primary(), 202 + role: ctx.theme.typography.title, 203 + }); 204 + paint 205 + } 206 + 207 + const CHEVRON_PX: f32 = 14.0; 208 + const CHEVRON_GAP: f32 = 6.0; 209 + 210 + fn chevron_rect(title: LayoutRect) -> LayoutRect { 211 + let pad = (title.size.height.value() - CHEVRON_PX).max(0.0) / 2.0; 212 + LayoutRect::new( 213 + LayoutPos::new( 214 + LayoutPx::new(title.origin.x.value() + pad), 215 + LayoutPx::new(title.origin.y.value() + pad), 216 + ), 217 + LayoutSize::new(LayoutPx::new(CHEVRON_PX), LayoutPx::new(CHEVRON_PX)), 218 + ) 219 + } 220 + 221 + fn label_rect(title: LayoutRect, leave_room_for_chevron: bool) -> LayoutRect { 222 + if !leave_room_for_chevron { 223 + return title; 224 + } 225 + let trim = LayoutPx::new(CHEVRON_PX + CHEVRON_GAP); 226 + let new_x = LayoutPx::new(title.origin.x.value() + trim.value()); 227 + let width = LayoutPx::saturating_nonneg(title.size.width.value() - trim.value()); 228 + LayoutRect::new( 229 + LayoutPos::new(new_x, title.origin.y), 230 + LayoutSize::new(width, title.size.height), 231 + ) 232 + } 233 + 234 + #[cfg(test)] 235 + mod tests { 236 + use std::sync::Arc; 237 + 238 + use super::{Panel, PanelState, PanelTitlebar, PanelVariant, show_panel}; 239 + use crate::focus::FocusManager; 240 + use crate::frame::FrameCtx; 241 + use crate::hit_test::{HitFrame, HitState, resolve}; 242 + use crate::hotkey::HotkeyTable; 243 + use crate::input::{ 244 + FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample, 245 + }; 246 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 247 + use crate::strings::{StringKey, StringTable}; 248 + use crate::theme::Theme; 249 + use crate::widget_id::{WidgetId, WidgetKey}; 250 + 251 + fn panel_id() -> WidgetId { 252 + WidgetId::ROOT.child(WidgetKey::new("panel")) 253 + } 254 + 255 + fn panel_rect() -> LayoutRect { 256 + LayoutRect::new( 257 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 258 + LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(120.0)), 259 + ) 260 + } 261 + 262 + const TITLE: StringKey = StringKey::new("panel.title"); 263 + 264 + fn titlebar(collapsible: bool) -> PanelTitlebar { 265 + PanelTitlebar { 266 + label: TITLE, 267 + height: LayoutPx::new(28.0), 268 + collapsible, 269 + } 270 + } 271 + 272 + fn render( 273 + state: &mut PanelState, 274 + focus: &mut FocusManager, 275 + snap: &mut InputSnapshot, 276 + prev: &HitState, 277 + bar: Option<PanelTitlebar>, 278 + ) -> (super::PanelResponse, HitState) { 279 + let theme = Arc::new(Theme::light()); 280 + let table = HotkeyTable::new(); 281 + let mut hits = HitFrame::new(); 282 + let response = { 283 + let mut ctx = FrameCtx::new( 284 + theme, 285 + snap, 286 + focus, 287 + &table, 288 + StringTable::empty(), 289 + &mut hits, 290 + prev, 291 + ); 292 + let mut p = Panel::new(panel_id(), panel_rect(), state).variant(PanelVariant::Card); 293 + if let Some(t) = bar { 294 + p = p.titlebar(t); 295 + } 296 + show_panel(&mut ctx, p) 297 + }; 298 + let next = resolve(prev, &hits, snap, focus.focused()); 299 + (response, next) 300 + } 301 + 302 + #[test] 303 + fn open_panel_with_titlebar_returns_body_rect_below_bar() { 304 + let mut state = PanelState::open(); 305 + let mut focus = FocusManager::new(); 306 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 307 + let prev = HitState::new(); 308 + let (response, _) = render( 309 + &mut state, 310 + &mut focus, 311 + &mut snap, 312 + &prev, 313 + Some(titlebar(true)), 314 + ); 315 + let Some(body) = response.body_rect else { 316 + panic!("expected body rect"); 317 + }; 318 + assert!(body.origin.y.value() >= 28.0); 319 + assert!(body.size.height.value() <= 120.0 - 28.0 + 0.001); 320 + } 321 + 322 + #[test] 323 + fn collapsed_panel_omits_body_rect() { 324 + let mut state = PanelState::collapsed(); 325 + let mut focus = FocusManager::new(); 326 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 327 + let prev = HitState::new(); 328 + let (response, _) = render( 329 + &mut state, 330 + &mut focus, 331 + &mut snap, 332 + &prev, 333 + Some(titlebar(true)), 334 + ); 335 + assert!(response.body_rect.is_none()); 336 + } 337 + 338 + #[test] 339 + fn click_titlebar_toggles_collapsed() { 340 + let mut state = PanelState::open(); 341 + let mut focus = FocusManager::new(); 342 + let mut prev = HitState::new(); 343 + let title_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(14.0)); 344 + [press(title_pos), release(title_pos), idle(title_pos)] 345 + .into_iter() 346 + .for_each(|mut snap| { 347 + let (_, next) = render( 348 + &mut state, 349 + &mut focus, 350 + &mut snap, 351 + &prev, 352 + Some(titlebar(true)), 353 + ); 354 + prev = next; 355 + }); 356 + assert!(state.collapsed); 357 + } 358 + 359 + #[test] 360 + fn no_titlebar_returns_body_equal_to_panel() { 361 + let mut state = PanelState::open(); 362 + let mut focus = FocusManager::new(); 363 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 364 + let prev = HitState::new(); 365 + let (response, _) = render(&mut state, &mut focus, &mut snap, &prev, None); 366 + let Some(body) = response.body_rect else { 367 + panic!("expected body"); 368 + }; 369 + assert_eq!(body, panel_rect()); 370 + } 371 + 372 + fn press(pos: LayoutPos) -> InputSnapshot { 373 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 374 + s.pointer = Some(PointerSample::new(pos)); 375 + s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 376 + s 377 + } 378 + 379 + fn release(pos: LayoutPos) -> InputSnapshot { 380 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 381 + s.pointer = Some(PointerSample::new(pos)); 382 + s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 383 + s 384 + } 385 + 386 + fn idle(pos: LayoutPos) -> InputSnapshot { 387 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 388 + s.pointer = Some(PointerSample::new(pos)); 389 + s 390 + } 391 + }
+395
crates/bone-ui/src/widgets/toast.rs
··· 1 + use core::time::Duration; 2 + 3 + use crate::frame::{FrameCtx, InteractDeclaration}; 4 + use crate::hit_test::Sense; 5 + use crate::input::FrameInstant; 6 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 7 + use crate::strings::StringKey; 8 + use crate::theme::{Border, Color, Step12, StrokeWidth}; 9 + use crate::widget_id::{WidgetId, WidgetKey}; 10 + 11 + use super::paint::{GlyphMark, LabelText, WidgetPaint}; 12 + 13 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 14 + pub enum ToastKind { 15 + Info, 16 + Success, 17 + Warning, 18 + Danger, 19 + } 20 + 21 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] 22 + pub struct ToastState { 23 + pub spawned_at: Option<FrameInstant>, 24 + pub dismissed: bool, 25 + } 26 + 27 + impl ToastState { 28 + #[must_use] 29 + pub const fn fresh() -> Self { 30 + Self { 31 + spawned_at: None, 32 + dismissed: false, 33 + } 34 + } 35 + } 36 + 37 + #[derive(Debug, PartialEq)] 38 + pub struct Toast<'state> { 39 + pub id: WidgetId, 40 + pub rect: LayoutRect, 41 + pub kind: ToastKind, 42 + pub message: StringKey, 43 + pub dismissible: bool, 44 + pub ttl: Duration, 45 + pub state: &'state mut ToastState, 46 + } 47 + 48 + impl<'state> Toast<'state> { 49 + #[must_use] 50 + pub fn new( 51 + id: WidgetId, 52 + rect: LayoutRect, 53 + kind: ToastKind, 54 + message: StringKey, 55 + state: &'state mut ToastState, 56 + ) -> Self { 57 + Self { 58 + id, 59 + rect, 60 + kind, 61 + message, 62 + dismissible: true, 63 + ttl: Duration::from_secs(4), 64 + state, 65 + } 66 + } 67 + 68 + #[must_use] 69 + pub fn ttl(self, ttl: Duration) -> Self { 70 + Self { ttl, ..self } 71 + } 72 + 73 + #[must_use] 74 + pub fn dismissible(self, dismissible: bool) -> Self { 75 + Self { 76 + dismissible, 77 + ..self 78 + } 79 + } 80 + } 81 + 82 + #[derive(Clone, Debug, PartialEq)] 83 + pub struct ToastResponse { 84 + pub visible: bool, 85 + pub dismissed_now: bool, 86 + pub paint: Vec<WidgetPaint>, 87 + } 88 + 89 + const CLOSE_PX: f32 = 14.0; 90 + const CLOSE_GAP: f32 = 8.0; 91 + const TOAST_PADDING: f32 = 12.0; 92 + 93 + #[must_use] 94 + pub fn show_toast(ctx: &mut FrameCtx<'_>, toast: Toast<'_>) -> ToastResponse { 95 + let Toast { 96 + id, 97 + rect, 98 + kind, 99 + message, 100 + dismissible, 101 + ttl, 102 + state, 103 + } = toast; 104 + let now = ctx.input.frame; 105 + if state.spawned_at.is_none() { 106 + state.spawned_at = Some(now); 107 + } 108 + let was_dismissed = state.dismissed; 109 + let aged_out = state.spawned_at.is_some_and(|t| now.since(t) >= ttl); 110 + if aged_out { 111 + state.dismissed = true; 112 + } 113 + let mut paint = Vec::new(); 114 + if was_dismissed { 115 + return ToastResponse { 116 + visible: false, 117 + dismissed_now: false, 118 + paint, 119 + }; 120 + } 121 + if aged_out { 122 + return ToastResponse { 123 + visible: false, 124 + dismissed_now: true, 125 + paint, 126 + }; 127 + } 128 + paint.push(WidgetPaint::Surface { 129 + rect, 130 + fill: surface_fill(ctx, kind), 131 + border: Some(Border { 132 + width: StrokeWidth::HAIRLINE, 133 + color: border_color(ctx, kind), 134 + }), 135 + radius: ctx.theme.radius.md, 136 + elevation: Some(ctx.theme.elevation.level1), 137 + }); 138 + paint.push(WidgetPaint::Mark { 139 + rect: leading_mark_rect(rect), 140 + kind: kind_glyph(kind), 141 + color: leading_mark_color(ctx, kind), 142 + }); 143 + paint.push(WidgetPaint::Label { 144 + rect: message_rect(rect, dismissible), 145 + text: LabelText::Key(message), 146 + color: ctx.theme.colors.text_primary(), 147 + role: ctx.theme.typography.body, 148 + }); 149 + let mut dismissed_now = false; 150 + if dismissible { 151 + let close_id = id.child(WidgetKey::new("close")); 152 + let close_rect = close_button_rect(rect); 153 + let interaction = ctx.interact(InteractDeclaration::new( 154 + close_id, 155 + close_rect, 156 + Sense::INTERACTIVE, 157 + )); 158 + if interaction.click() { 159 + state.dismissed = true; 160 + dismissed_now = true; 161 + } 162 + paint.push(WidgetPaint::Surface { 163 + rect: close_rect, 164 + fill: if interaction.hover() { 165 + ctx.theme.colors.neutral.step(Step12::HOVER_BG) 166 + } else { 167 + Color::TRANSPARENT 168 + }, 169 + border: None, 170 + radius: ctx.theme.radius.sm, 171 + elevation: None, 172 + }); 173 + paint.push(WidgetPaint::Mark { 174 + rect: close_rect, 175 + kind: GlyphMark::Close, 176 + color: ctx.theme.colors.text_secondary(), 177 + }); 178 + } 179 + ToastResponse { 180 + visible: true, 181 + dismissed_now, 182 + paint, 183 + } 184 + } 185 + 186 + fn surface_fill(ctx: &FrameCtx<'_>, kind: ToastKind) -> Color { 187 + let scale = match kind { 188 + ToastKind::Info => ctx.theme.colors.info, 189 + ToastKind::Success => ctx.theme.colors.success, 190 + ToastKind::Warning => ctx.theme.colors.warning, 191 + ToastKind::Danger => ctx.theme.colors.danger, 192 + }; 193 + scale.step(Step12::SUBTLE_BG) 194 + } 195 + 196 + fn border_color(ctx: &FrameCtx<'_>, kind: ToastKind) -> Color { 197 + let scale = match kind { 198 + ToastKind::Info => ctx.theme.colors.info, 199 + ToastKind::Success => ctx.theme.colors.success, 200 + ToastKind::Warning => ctx.theme.colors.warning, 201 + ToastKind::Danger => ctx.theme.colors.danger, 202 + }; 203 + scale.step(Step12::BORDER) 204 + } 205 + 206 + fn leading_mark_color(ctx: &FrameCtx<'_>, kind: ToastKind) -> Color { 207 + let scale = match kind { 208 + ToastKind::Info => ctx.theme.colors.info, 209 + ToastKind::Success => ctx.theme.colors.success, 210 + ToastKind::Warning => ctx.theme.colors.warning, 211 + ToastKind::Danger => ctx.theme.colors.danger, 212 + }; 213 + scale.step(Step12::SOLID) 214 + } 215 + 216 + fn kind_glyph(kind: ToastKind) -> GlyphMark { 217 + match kind { 218 + ToastKind::Info | ToastKind::Success => GlyphMark::Checkmark, 219 + ToastKind::Warning => GlyphMark::Indeterminate, 220 + ToastKind::Danger => GlyphMark::Close, 221 + } 222 + } 223 + 224 + fn leading_mark_rect(toast: LayoutRect) -> LayoutRect { 225 + let pad = (toast.size.height.value() - CLOSE_PX) / 2.0; 226 + LayoutRect::new( 227 + LayoutPos::new( 228 + LayoutPx::new(toast.origin.x.value() + TOAST_PADDING), 229 + LayoutPx::new(toast.origin.y.value() + pad), 230 + ), 231 + LayoutSize::new(LayoutPx::new(CLOSE_PX), LayoutPx::new(CLOSE_PX)), 232 + ) 233 + } 234 + 235 + fn message_rect(toast: LayoutRect, has_close: bool) -> LayoutRect { 236 + let leading = TOAST_PADDING + CLOSE_PX + CLOSE_GAP; 237 + let trailing = if has_close { 238 + TOAST_PADDING + CLOSE_PX 239 + } else { 240 + TOAST_PADDING 241 + }; 242 + LayoutRect::new( 243 + LayoutPos::new( 244 + LayoutPx::new(toast.origin.x.value() + leading), 245 + toast.origin.y, 246 + ), 247 + LayoutSize::new( 248 + LayoutPx::saturating_nonneg(toast.size.width.value() - leading - trailing), 249 + toast.size.height, 250 + ), 251 + ) 252 + } 253 + 254 + fn close_button_rect(toast: LayoutRect) -> LayoutRect { 255 + let pad = (toast.size.height.value() - CLOSE_PX) / 2.0; 256 + LayoutRect::new( 257 + LayoutPos::new( 258 + LayoutPx::new( 259 + toast.origin.x.value() + toast.size.width.value() - CLOSE_PX - TOAST_PADDING, 260 + ), 261 + LayoutPx::new(toast.origin.y.value() + pad), 262 + ), 263 + LayoutSize::new(LayoutPx::new(CLOSE_PX), LayoutPx::new(CLOSE_PX)), 264 + ) 265 + } 266 + 267 + #[cfg(test)] 268 + mod tests { 269 + use core::time::Duration; 270 + use std::sync::Arc; 271 + 272 + use super::{Toast, ToastKind, ToastState, show_toast}; 273 + use crate::focus::FocusManager; 274 + use crate::frame::FrameCtx; 275 + use crate::hit_test::{HitFrame, HitState, resolve}; 276 + use crate::hotkey::HotkeyTable; 277 + use crate::input::{ 278 + FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample, 279 + }; 280 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 281 + use crate::strings::{StringKey, StringTable}; 282 + use crate::theme::Theme; 283 + use crate::widget_id::{WidgetId, WidgetKey}; 284 + 285 + fn toast_id() -> WidgetId { 286 + WidgetId::ROOT.child(WidgetKey::new("toast")) 287 + } 288 + 289 + fn rect() -> LayoutRect { 290 + LayoutRect::new( 291 + LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(560.0)), 292 + LayoutSize::new(LayoutPx::new(360.0), LayoutPx::new(48.0)), 293 + ) 294 + } 295 + 296 + fn render( 297 + state: &mut ToastState, 298 + focus: &mut FocusManager, 299 + snap: &mut InputSnapshot, 300 + prev: &HitState, 301 + ttl_ms: u64, 302 + ) -> (super::ToastResponse, HitState) { 303 + let theme = Arc::new(Theme::light()); 304 + let table = HotkeyTable::new(); 305 + let mut hits = HitFrame::new(); 306 + let response = { 307 + let mut ctx = FrameCtx::new( 308 + theme, 309 + snap, 310 + focus, 311 + &table, 312 + StringTable::empty(), 313 + &mut hits, 314 + prev, 315 + ); 316 + show_toast( 317 + &mut ctx, 318 + Toast::new( 319 + toast_id(), 320 + rect(), 321 + ToastKind::Info, 322 + StringKey::new("toast.msg"), 323 + state, 324 + ) 325 + .ttl(Duration::from_millis(ttl_ms)), 326 + ) 327 + }; 328 + let next = resolve(prev, &hits, snap, focus.focused()); 329 + (response, next) 330 + } 331 + 332 + #[test] 333 + fn first_frame_seeds_spawn_time() { 334 + let mut state = ToastState::fresh(); 335 + let mut focus = FocusManager::new(); 336 + let mut snap = InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(100))); 337 + let prev = HitState::new(); 338 + let _ = render(&mut state, &mut focus, &mut snap, &prev, 4000); 339 + assert_eq!( 340 + state.spawned_at, 341 + Some(FrameInstant::from_duration(Duration::from_millis(100))), 342 + ); 343 + } 344 + 345 + #[test] 346 + fn toast_auto_dismisses_after_ttl() { 347 + let mut state = ToastState::fresh(); 348 + let mut focus = FocusManager::new(); 349 + let prev = HitState::new(); 350 + let mut snap = InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(0))); 351 + let _ = render(&mut state, &mut focus, &mut snap, &prev, 1000); 352 + let mut snap_late = 353 + InputSnapshot::idle(FrameInstant::from_duration(Duration::from_secs(2))); 354 + let (response, _) = render(&mut state, &mut focus, &mut snap_late, &prev, 1000); 355 + assert!(state.dismissed); 356 + assert!(!response.visible); 357 + } 358 + 359 + #[test] 360 + fn click_close_dismisses_toast() { 361 + let mut state = ToastState::fresh(); 362 + let mut focus = FocusManager::new(); 363 + let mut prev = HitState::new(); 364 + let close_x = rect().origin.x.value() + rect().size.width.value() - 12.0 - 7.0; 365 + let close_y = rect().origin.y.value() + rect().size.height.value() / 2.0; 366 + let close_pos = LayoutPos::new(LayoutPx::new(close_x), LayoutPx::new(close_y)); 367 + [press(close_pos), release(close_pos), idle(close_pos)] 368 + .into_iter() 369 + .for_each(|mut snap| { 370 + let (_, next) = render(&mut state, &mut focus, &mut snap, &prev, 4000); 371 + prev = next; 372 + }); 373 + assert!(state.dismissed); 374 + } 375 + 376 + fn press(pos: LayoutPos) -> InputSnapshot { 377 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 378 + s.pointer = Some(PointerSample::new(pos)); 379 + s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 380 + s 381 + } 382 + 383 + fn release(pos: LayoutPos) -> InputSnapshot { 384 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 385 + s.pointer = Some(PointerSample::new(pos)); 386 + s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 387 + s 388 + } 389 + 390 + fn idle(pos: LayoutPos) -> InputSnapshot { 391 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 392 + s.pointer = Some(PointerSample::new(pos)); 393 + s 394 + } 395 + }