Another project
0

Configure Feed

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

at main 14 kB View raw
1use core::time::Duration; 2 3use bone_types::IconId; 4 5use crate::a11y::{AccessNode, Role}; 6use crate::frame::{FrameCtx, InteractDeclaration}; 7use crate::hit_test::Sense; 8use crate::input::{FrameInstant, NamedKey}; 9use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 10use crate::strings::StringKey; 11use crate::theme::{Border, Color, Step12, StrokeWidth}; 12use crate::widget_id::{WidgetId, WidgetKey}; 13 14use super::keys::{TakeKey, take_key}; 15use super::paint::{GlyphMark, IconTint, LabelText, WidgetPaint}; 16use super::visuals::push_focus_ring; 17 18#[derive(Copy, Clone, Debug, PartialEq, Eq)] 19pub enum ToastKind { 20 Info, 21 Success, 22 Warning, 23 Danger, 24} 25 26#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] 27pub struct ToastState { 28 pub spawned_at: Option<FrameInstant>, 29 pub dismissed: bool, 30} 31 32impl ToastState { 33 #[must_use] 34 pub const fn fresh() -> Self { 35 Self { 36 spawned_at: None, 37 dismissed: false, 38 } 39 } 40} 41 42#[derive(Debug, PartialEq)] 43pub struct Toast<'state> { 44 pub id: WidgetId, 45 pub rect: LayoutRect, 46 pub kind: ToastKind, 47 pub message: StringKey, 48 pub dismissible: bool, 49 pub ttl: Duration, 50 pub state: &'state mut ToastState, 51} 52 53impl<'state> Toast<'state> { 54 #[must_use] 55 pub fn new( 56 id: WidgetId, 57 rect: LayoutRect, 58 kind: ToastKind, 59 message: StringKey, 60 state: &'state mut ToastState, 61 ) -> Self { 62 Self { 63 id, 64 rect, 65 kind, 66 message, 67 dismissible: true, 68 ttl: Duration::from_secs(4), 69 state, 70 } 71 } 72 73 #[must_use] 74 pub fn ttl(self, ttl: Duration) -> Self { 75 Self { ttl, ..self } 76 } 77 78 #[must_use] 79 pub fn dismissible(self, dismissible: bool) -> Self { 80 Self { 81 dismissible, 82 ..self 83 } 84 } 85} 86 87#[derive(Clone, Debug, PartialEq)] 88pub struct ToastResponse { 89 pub visible: bool, 90 pub dismissed_now: bool, 91 pub paint: Vec<WidgetPaint>, 92} 93 94const CLOSE_PX: f32 = 14.0; 95const CLOSE_GAP: f32 = 8.0; 96const TOAST_PADDING: f32 = 12.0; 97 98#[must_use] 99pub fn show_toast(ctx: &mut FrameCtx<'_>, toast: Toast<'_>) -> ToastResponse { 100 let Toast { 101 id, 102 rect, 103 kind, 104 message, 105 dismissible, 106 ttl, 107 state, 108 } = toast; 109 let now = ctx.input.frame; 110 if state.spawned_at.is_none() { 111 state.spawned_at = Some(now); 112 } 113 let was_dismissed = state.dismissed; 114 let aged_out = state.spawned_at.is_some_and(|t| now.since(t) >= ttl); 115 if aged_out { 116 state.dismissed = true; 117 } 118 let mut paint = Vec::new(); 119 if was_dismissed { 120 return ToastResponse { 121 visible: false, 122 dismissed_now: false, 123 paint, 124 }; 125 } 126 if aged_out { 127 return ToastResponse { 128 visible: false, 129 dismissed_now: true, 130 paint, 131 }; 132 } 133 ctx.a11y 134 .push(id, rect, AccessNode::new(Role::Alert).with_label(message)); 135 paint.push(WidgetPaint::Surface { 136 rect, 137 fill: surface_fill(ctx, kind), 138 border: Some(Border { 139 width: StrokeWidth::HAIRLINE, 140 color: border_color(ctx, kind), 141 }), 142 radius: ctx.theme().radius.md, 143 elevation: Some(ctx.theme().elevation.level1), 144 }); 145 paint.push(leading_mark( 146 leading_mark_rect(rect), 147 kind, 148 leading_mark_color(ctx, kind), 149 )); 150 paint.push(WidgetPaint::Label { 151 rect: message_rect(rect, dismissible), 152 text: LabelText::Key(message), 153 color: ctx.theme().colors.text_primary(), 154 role: ctx.theme().typography.body, 155 }); 156 let dismissed_now = dismissible && draw_close_button(ctx, id, rect, state, &mut paint); 157 ToastResponse { 158 visible: true, 159 dismissed_now, 160 paint, 161 } 162} 163 164fn draw_close_button( 165 ctx: &mut FrameCtx<'_>, 166 id: WidgetId, 167 rect: LayoutRect, 168 state: &mut ToastState, 169 paint: &mut Vec<WidgetPaint>, 170) -> bool { 171 let close_id = id.child(WidgetKey::new("close")); 172 let close_rect = close_button_rect(rect); 173 let interaction = ctx.interact( 174 InteractDeclaration::new(close_id, close_rect, Sense::INTERACTIVE) 175 .focusable(true) 176 .a11y(AccessNode::new(Role::Button).with_label(StringKey::new("toast.close"))), 177 ); 178 let live_focused = ctx.is_focused(close_id); 179 let key_activated = live_focused 180 && take_key( 181 ctx.input, 182 &[ 183 TakeKey::named(NamedKey::Enter), 184 TakeKey::named(NamedKey::Space), 185 ], 186 ) 187 .is_some(); 188 let dismissed_now = interaction.click() || key_activated; 189 if dismissed_now { 190 state.dismissed = true; 191 } 192 paint.push(WidgetPaint::Surface { 193 rect: close_rect, 194 fill: if interaction.hover() { 195 ctx.theme().colors.neutral.step(Step12::HOVER_BG) 196 } else { 197 Color::TRANSPARENT 198 }, 199 border: None, 200 radius: ctx.theme().radius.sm, 201 elevation: None, 202 }); 203 paint.push(WidgetPaint::Icon { 204 rect: close_rect, 205 icon: IconId::Cross, 206 tint: IconTint::Solid(ctx.theme().colors.text_secondary()), 207 }); 208 push_focus_ring(ctx, paint, close_rect, ctx.theme().radius.sm, live_focused); 209 dismissed_now 210} 211 212fn surface_fill(ctx: &FrameCtx<'_>, kind: ToastKind) -> Color { 213 let scale = match kind { 214 ToastKind::Info => ctx.theme().colors.info, 215 ToastKind::Success => ctx.theme().colors.success, 216 ToastKind::Warning => ctx.theme().colors.warning, 217 ToastKind::Danger => ctx.theme().colors.danger, 218 }; 219 scale.step(Step12::SUBTLE_BG) 220} 221 222fn border_color(ctx: &FrameCtx<'_>, kind: ToastKind) -> Color { 223 let scale = match kind { 224 ToastKind::Info => ctx.theme().colors.info, 225 ToastKind::Success => ctx.theme().colors.success, 226 ToastKind::Warning => ctx.theme().colors.warning, 227 ToastKind::Danger => ctx.theme().colors.danger, 228 }; 229 scale.step(Step12::BORDER) 230} 231 232fn leading_mark_color(ctx: &FrameCtx<'_>, kind: ToastKind) -> Color { 233 let scale = match kind { 234 ToastKind::Info => ctx.theme().colors.info, 235 ToastKind::Success => ctx.theme().colors.success, 236 ToastKind::Warning => ctx.theme().colors.warning, 237 ToastKind::Danger => ctx.theme().colors.danger, 238 }; 239 scale.step(Step12::SOLID) 240} 241 242fn leading_mark(rect: LayoutRect, kind: ToastKind, color: Color) -> WidgetPaint { 243 let icon = match kind { 244 ToastKind::Info | ToastKind::Success => Some(IconId::Check), 245 ToastKind::Danger => Some(IconId::Cross), 246 ToastKind::Warning => None, 247 }; 248 match icon { 249 Some(icon) => WidgetPaint::Icon { 250 rect, 251 icon, 252 tint: IconTint::Solid(color), 253 }, 254 None => WidgetPaint::Mark { 255 rect, 256 kind: GlyphMark::Indeterminate, 257 color, 258 }, 259 } 260} 261 262fn leading_mark_rect(toast: LayoutRect) -> LayoutRect { 263 let pad = (toast.size.height.value() - CLOSE_PX) / 2.0; 264 LayoutRect::new( 265 LayoutPos::new( 266 LayoutPx::new(toast.origin.x.value() + TOAST_PADDING), 267 LayoutPx::new(toast.origin.y.value() + pad), 268 ), 269 LayoutSize::new(LayoutPx::new(CLOSE_PX), LayoutPx::new(CLOSE_PX)), 270 ) 271} 272 273fn message_rect(toast: LayoutRect, has_close: bool) -> LayoutRect { 274 let leading = TOAST_PADDING + CLOSE_PX + CLOSE_GAP; 275 let trailing = if has_close { 276 TOAST_PADDING + CLOSE_PX 277 } else { 278 TOAST_PADDING 279 }; 280 LayoutRect::new( 281 LayoutPos::new( 282 LayoutPx::new(toast.origin.x.value() + leading), 283 toast.origin.y, 284 ), 285 LayoutSize::new( 286 LayoutPx::saturating_nonneg(toast.size.width.value() - leading - trailing), 287 toast.size.height, 288 ), 289 ) 290} 291 292fn close_button_rect(toast: LayoutRect) -> LayoutRect { 293 let pad = (toast.size.height.value() - CLOSE_PX) / 2.0; 294 LayoutRect::new( 295 LayoutPos::new( 296 LayoutPx::new( 297 toast.origin.x.value() + toast.size.width.value() - CLOSE_PX - TOAST_PADDING, 298 ), 299 LayoutPx::new(toast.origin.y.value() + pad), 300 ), 301 LayoutSize::new(LayoutPx::new(CLOSE_PX), LayoutPx::new(CLOSE_PX)), 302 ) 303} 304 305#[cfg(test)] 306mod tests { 307 use core::time::Duration; 308 use std::sync::Arc; 309 310 use super::{Toast, ToastKind, ToastState, show_toast}; 311 use crate::focus::FocusManager; 312 use crate::frame::FrameCtx; 313 use crate::hit_test::{HitFrame, HitState, resolve}; 314 use crate::hotkey::HotkeyTable; 315 use crate::input::{ 316 FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample, 317 }; 318 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 319 use crate::strings::{StringKey, StringTable}; 320 use crate::theme::Theme; 321 use crate::widget_id::{WidgetId, WidgetKey}; 322 323 fn toast_id() -> WidgetId { 324 WidgetId::ROOT.child(WidgetKey::new("toast")) 325 } 326 327 fn rect() -> LayoutRect { 328 LayoutRect::new( 329 LayoutPos::new(LayoutPx::new(20.0), LayoutPx::new(560.0)), 330 LayoutSize::new(LayoutPx::new(360.0), LayoutPx::new(48.0)), 331 ) 332 } 333 334 fn render( 335 state: &mut ToastState, 336 focus: &mut FocusManager, 337 snap: &mut InputSnapshot, 338 prev: &HitState, 339 ttl_ms: u64, 340 ) -> (super::ToastResponse, HitState) { 341 let theme = Arc::new(Theme::light()); 342 let table = HotkeyTable::new(); 343 let mut hits = HitFrame::new(); 344 let response = { 345 let mut shaper = bone_text::Shaper::new(); 346 let mut a11y = crate::a11y::AccessTreeBuilder::new(); 347 let mut ctx = FrameCtx::new( 348 theme, 349 snap, 350 focus, 351 &table, 352 StringTable::empty(), 353 &mut hits, 354 prev, 355 &mut a11y, 356 &mut shaper, 357 ); 358 show_toast( 359 &mut ctx, 360 Toast::new( 361 toast_id(), 362 rect(), 363 ToastKind::Info, 364 StringKey::new("toast.msg"), 365 state, 366 ) 367 .ttl(Duration::from_millis(ttl_ms)), 368 ) 369 }; 370 let next = resolve(prev, &hits, snap, focus.focused()); 371 (response, next) 372 } 373 374 #[test] 375 fn first_frame_seeds_spawn_time() { 376 let mut state = ToastState::fresh(); 377 let mut focus = FocusManager::new(); 378 let mut snap = InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(100))); 379 let prev = HitState::new(); 380 let _ = render(&mut state, &mut focus, &mut snap, &prev, 4000); 381 assert_eq!( 382 state.spawned_at, 383 Some(FrameInstant::from_duration(Duration::from_millis(100))), 384 ); 385 } 386 387 #[test] 388 fn toast_auto_dismisses_after_ttl() { 389 let mut state = ToastState::fresh(); 390 let mut focus = FocusManager::new(); 391 let prev = HitState::new(); 392 let mut snap = InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(0))); 393 let _ = render(&mut state, &mut focus, &mut snap, &prev, 1000); 394 let mut snap_late = 395 InputSnapshot::idle(FrameInstant::from_duration(Duration::from_secs(2))); 396 let (response, _) = render(&mut state, &mut focus, &mut snap_late, &prev, 1000); 397 assert!(state.dismissed); 398 assert!(!response.visible); 399 } 400 401 #[test] 402 fn enter_on_focused_close_dismisses_toast() { 403 use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey}; 404 405 let close_id = toast_id().child(WidgetKey::new("close")); 406 let mut state = ToastState::fresh(); 407 let mut focus = FocusManager::new(); 408 let prev = HitState::new(); 409 focus.request_focus(close_id); 410 let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 411 let _ = render(&mut state, &mut focus, &mut warm, &prev, 4000); 412 assert_eq!(focus.focused(), Some(close_id)); 413 414 let mut enter = InputSnapshot::idle(FrameInstant::ZERO); 415 enter.keys_pressed.push(KeyEvent::new( 416 KeyCode::Named(NamedKey::Enter), 417 ModifierMask::NONE, 418 )); 419 let _ = render(&mut state, &mut focus, &mut enter, &prev, 4000); 420 assert!( 421 state.dismissed, 422 "Enter on focused close button must dismiss" 423 ); 424 } 425 426 #[test] 427 fn click_close_dismisses_toast() { 428 let mut state = ToastState::fresh(); 429 let mut focus = FocusManager::new(); 430 let mut prev = HitState::new(); 431 let close_x = rect().origin.x.value() + rect().size.width.value() - 12.0 - 7.0; 432 let close_y = rect().origin.y.value() + rect().size.height.value() / 2.0; 433 let close_pos = LayoutPos::new(LayoutPx::new(close_x), LayoutPx::new(close_y)); 434 [press(close_pos), release(close_pos), idle(close_pos)] 435 .into_iter() 436 .for_each(|mut snap| { 437 let (_, next) = render(&mut state, &mut focus, &mut snap, &prev, 4000); 438 prev = next; 439 }); 440 assert!(state.dismissed); 441 } 442 443 fn press(pos: LayoutPos) -> InputSnapshot { 444 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 445 s.pointer = Some(PointerSample::new(pos)); 446 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 447 s 448 } 449 450 fn release(pos: LayoutPos) -> InputSnapshot { 451 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 452 s.pointer = Some(PointerSample::new(pos)); 453 s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 454 s 455 } 456 457 fn idle(pos: LayoutPos) -> InputSnapshot { 458 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 459 s.pointer = Some(PointerSample::new(pos)); 460 s 461 } 462}