From f3d526c0c412f72f78dd4045022c91a72ac8fdb8 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:06:10 +0200 Subject: [PATCH] feat(ui): toasts pause on hover + bunch of small general tweaks --- packages/oneclient_app/src/bridge/commands.rs | 3 +- packages/oneclient_app/src/bridge/runtime.rs | 75 ++++++++++++--- .../src/components/account_switcher.rs | 5 +- .../oneclient_app/src/components/button.rs | 7 ++ .../src/components/notifications.rs | 29 ++---- .../oneclient_app/src/components/toasts.rs | 94 ++++++++++++++++--- packages/oneclient_app/src/hooks/dispatch.rs | 8 +- .../src/view/app/analytics/charts.rs | 2 +- 8 files changed, 168 insertions(+), 55 deletions(-) diff --git a/packages/oneclient_app/src/bridge/commands.rs b/packages/oneclient_app/src/bridge/commands.rs index 49cdcf87..bed82dc9 100644 --- a/packages/oneclient_app/src/bridge/commands.rs +++ b/packages/oneclient_app/src/bridge/commands.rs @@ -85,7 +85,8 @@ pub enum BridgeCommand { CloseAccountSwitcher, ClearNotificationInbox, DismissToast(u64), - BumpToast(u64), + PauseToasts, + ResumeToasts, MarkNotificationRead(u64), DismissNotification(u64), AnswerPrompt(UserChoice), diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs index ab8233a4..2b40be24 100644 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ b/packages/oneclient_app/src/bridge/runtime.rs @@ -33,8 +33,10 @@ impl CoreBridgeRuntime { let mut notification_center_open = false; let mut pending_prompt: Option = None; - let mut armed_toasts: std::collections::HashMap = + let mut armed_toasts: std::collections::HashMap = std::collections::HashMap::new(); + // Hovering any toast pauses every toast's timer, including ones that arrive while hovering. + let mut toasts_paused = false; let mut game_flush_deadline: Option = None; const GAME_LOG_FLUSH: std::time::Duration = std::time::Duration::from_millis(120); @@ -79,8 +81,8 @@ impl CoreBridgeRuntime { loop { let next_deadline = armed_toasts .values() + .filter_map(ToastTimer::deadline) .min() - .copied() .into_iter() .chain(game_flush_deadline) .min(); @@ -96,7 +98,7 @@ impl CoreBridgeRuntime { let now = tokio::time::Instant::now(); let due: Vec = armed_toasts .iter() - .filter(|(_, deadline)| **deadline <= now) + .filter(|(_, timer)| timer.deadline().is_some_and(|deadline| deadline <= now)) .map(|(id, _)| *id) .collect(); let toasts_expired = !due.is_empty(); @@ -120,7 +122,7 @@ impl CoreBridgeRuntime { } publish(&self.snapshots_tx, &snapshots); if toasts_expired { - reconcile_toasts(&snapshots, &inbox, &mut armed_toasts); + reconcile_toasts(&snapshots, &inbox, &mut armed_toasts, toasts_paused); } } notification = notifier_rx.recv() => { @@ -200,7 +202,7 @@ impl CoreBridgeRuntime { publish(&self.snapshots_tx, &snapshots); game_flush_deadline = None; if touched_engine { - reconcile_toasts(&snapshots, &inbox, &mut armed_toasts); + reconcile_toasts(&snapshots, &inbox, &mut armed_toasts, toasts_paused); } } else if saw_game_log && game_flush_deadline.is_none() { game_flush_deadline = Some(tokio::time::Instant::now() + GAME_LOG_FLUSH); @@ -209,10 +211,17 @@ impl CoreBridgeRuntime { command = self.commands_rx.recv() => { let Some(command) = command else { break }; match command { - BridgeCommand::BumpToast(entry_id) => { - if armed_toasts.contains_key(&entry_id) { - armed_toasts - .insert(entry_id, tokio::time::Instant::now() + MESSAGE_TOAST_TTL); + BridgeCommand::PauseToasts => { + toasts_paused = true; + for timer in armed_toasts.values_mut() { + timer.pause(); + } + continue; + } + BridgeCommand::ResumeToasts => { + toasts_paused = false; + for timer in armed_toasts.values_mut() { + timer.resume(); } continue; } @@ -240,7 +249,7 @@ impl CoreBridgeRuntime { &pending_prompt, ); publish(&self.snapshots_tx, &snapshots); - reconcile_toasts(&snapshots, &inbox, &mut armed_toasts); + reconcile_toasts(&snapshots, &inbox, &mut armed_toasts, toasts_paused); } } } @@ -551,7 +560,7 @@ impl CoreBridgeRuntime { BridgeCommand::DismissToast(entry_id) => { notification_engine.dismiss_toast(inbox, entry_id); } - BridgeCommand::BumpToast(_) => {} + BridgeCommand::PauseToasts | BridgeCommand::ResumeToasts => {} BridgeCommand::MarkNotificationRead(entry_id) => { notification_engine.mark_read(inbox, entry_id); } @@ -922,10 +931,48 @@ fn sync_notifications( snapshots.notifications = engine.snapshot(inbox, center_open, pending_view); } +/// A toast's time-to-live, which hovering the toast pauses and un-hovering resumes. +#[derive(Clone, Copy)] +enum ToastTimer { + Running(tokio::time::Instant), + Paused(std::time::Duration), +} + +impl ToastTimer { + fn armed(paused: bool) -> Self { + if paused { + Self::Paused(MESSAGE_TOAST_TTL) + } else { + Self::Running(tokio::time::Instant::now() + MESSAGE_TOAST_TTL) + } + } + + /// The instant this toast expires, or `None` while paused. + fn deadline(&self) -> Option { + match self { + Self::Running(deadline) => Some(*deadline), + Self::Paused(_) => None, + } + } + + fn pause(&mut self) { + if let Self::Running(deadline) = *self { + *self = Self::Paused(deadline.saturating_duration_since(tokio::time::Instant::now())); + } + } + + fn resume(&mut self) { + if let Self::Paused(remaining) = *self { + *self = Self::Running(tokio::time::Instant::now() + remaining); + } + } +} + fn reconcile_toasts( snapshots: &BridgeSnapshot, inbox: &[InboxEntry], - armed: &mut std::collections::HashMap, + armed: &mut std::collections::HashMap, + paused: bool, ) { let want: std::collections::HashSet = snapshots .notifications @@ -936,9 +983,7 @@ fn reconcile_toasts( .collect(); for id in &want { - armed - .entry(*id) - .or_insert_with(|| tokio::time::Instant::now() + MESSAGE_TOAST_TTL); + armed.entry(*id).or_insert_with(|| ToastTimer::armed(paused)); } armed.retain(|id, _| want.contains(id)); diff --git a/packages/oneclient_app/src/components/account_switcher.rs b/packages/oneclient_app/src/components/account_switcher.rs index 5725d8af..50d56779 100644 --- a/packages/oneclient_app/src/components/account_switcher.rs +++ b/packages/oneclient_app/src/components/account_switcher.rs @@ -161,9 +161,10 @@ impl Component for AccountRow { let mut hovered = use_state(|| false); let switch = move |_| { - if !active { - set_default.mutate(SetDefaultAccountKeys { id: Some(id) }); + if active { + return; } + set_default.mutate(SetDefaultAccountKeys { id: Some(id) }); dispatch.close_account_switcher(); }; diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs index 61a3d4e7..3b74b866 100644 --- a/packages/oneclient_app/src/components/button.rs +++ b/packages/oneclient_app/src/components/button.rs @@ -26,6 +26,9 @@ pub enum ButtonSize { Icon, } +/// How much a disabled button is faded out. +const DISABLED_OPACITY: f32 = 0.5; + struct ButtonColors { background: Color, hover_background: Color, @@ -290,6 +293,10 @@ impl Component for Button { rect = rect.background(background).color(foreground); + if !enabled() { + rect = rect.opacity(DISABLED_OPACITY); + } + if let Some(border) = border_color { rect = rect.border( Border::new() diff --git a/packages/oneclient_app/src/components/notifications.rs b/packages/oneclient_app/src/components/notifications.rs index a7190388..923e819c 100644 --- a/packages/oneclient_app/src/components/notifications.rs +++ b/packages/oneclient_app/src/components/notifications.rs @@ -1,12 +1,10 @@ use freya::{ animation::{AnimNum, Ease, OnCreation, use_animation}, prelude::*, - router::RouterContext, }; use oneclient_core::notification::NotificationLevel; use crate::{ - Route, components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea}, hooks::{use_dispatch, use_notifications_snapshot}, notifications::{InboxEntry, NotificationActionKind, TransferStats}, @@ -498,37 +496,26 @@ struct Footer; impl Component for Footer { fn render(&self) -> impl IntoElement { let dispatch = use_dispatch(); - let clear_dispatch = dispatch.clone(); - let open_settings = move |_| { - dispatch.close_notification_center(); - let _ = RouterContext::get().push(Route::SettingsAppearance {}); - }; + let is_empty = use_notifications_snapshot().inbox.is_empty(); rect() .horizontal() .width(Size::fill()) .content(Content::Flex) .cross_align(Alignment::Center) - .main_align(Alignment::SpaceBetween) .child( Button::new() .ghost() .small() - .on_press(move |_| clear_dispatch.clear_notification_inbox()) - .child( - Icon::new(IconType::Trash01) - .size(16.) - .color(colors::fg_primary()), - ) + .disabled(is_empty) + .on_press(move |_| dispatch.clear_notification_inbox()) + .child(Icon::new(IconType::Trash01).size(16.).color(if is_empty { + colors::fg_primary_disabled() + } else { + colors::fg_primary() + })) .text("Clear all"), ) - .child( - Button::new() - .ghost() - .icon() - .on_press(open_settings) - .child(Icon::new(IconType::Settings02).size(18.)), - ) } } diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs index e50366b4..5fc46a60 100644 --- a/packages/oneclient_app/src/components/toasts.rs +++ b/packages/oneclient_app/src/components/toasts.rs @@ -1,5 +1,10 @@ +use std::{cell::Cell, rc::Rc, time::Instant}; + use freya::{ - animation::{AnimNum, Ease, Function, OnCreation, use_animation}, + animation::{ + AnimNum, Ease, Function, OnChange, OnCreation, use_animation, + use_animation_with_dependencies, + }, prelude::*, }; use oneclient_core::notification::NotificationLevel; @@ -51,6 +56,8 @@ pub struct Toasts; impl Component for Toasts { fn render(&self) -> impl IntoElement { let snapshot = use_notifications_snapshot(); + // How many toasts the pointer is over; while any is hovered, every toast's timer pauses. + let hovering = use_state(|| 0usize); let entries: Vec = snapshot .active_toast_entry_ids @@ -73,6 +80,7 @@ impl Component for Toasts { stack = stack.child( ToastCard { entry, + hovering, key: DiffKey::None, } .key(id), @@ -86,6 +94,8 @@ impl Component for Toasts { #[derive(PartialEq)] struct ToastCard { entry: InboxEntry, + /// Shared across every toast: how many of them the pointer is currently over. + hovering: State, key: DiffKey, } @@ -114,18 +124,62 @@ impl Component for ToastCard { AnimNum::new(0., 1.).time(260).ease(Ease::Out) }); - let mut ttl_bar_anim = use_animation(|conf| { - conf.on_creation(OnCreation::Run); - AnimNum::new(100., 0.).time(TTL_MS as u64) - }); + // Hovering any toast freezes this bar where it is; leaving runs out the remaining TTL. + let paused = *self.hovering.read() > 0; + let mut was_paused = use_state(|| false); + let mut from_pct = use_state(|| 100.); + let mut run_start = use_state(Instant::now); - let bump_enter = dispatch.clone(); - let bump_move = dispatch.clone(); + if paused != *was_paused.peek() { + if paused { + let elapsed_pct = run_start.peek().elapsed().as_secs_f32() * 1000. / TTL_MS * 100.; + let remaining = *from_pct.peek() - elapsed_pct; + from_pct.set(remaining.clamp(0., 100.)); + } else { + run_start.set(Instant::now()); + } + was_paused.set(paused); + } + + let ttl_bar_anim = + use_animation_with_dependencies(&(paused, *from_pct.peek()), |conf, deps| { + let &(paused, from) = deps; + conf.on_creation(if paused { + OnCreation::Nothing + } else { + OnCreation::Run + }); + conf.on_change(if paused { + OnChange::Reset + } else { + OnChange::Rerun + }); + AnimNum::new(from, 0.).time(((TTL_MS * from / 100.) as u64).max(1)) + }); let offset = slide.read().value(); let opacity = fade.read().value(); let ttl_pct = ttl_bar_anim.read().value(); + // A card can be unmounted while hovered (dismissing it), which emits no `out` event. + let mut hovering = self.hovering; + let hover_flag = use_hook(|| Rc::new(Cell::new(false))); + let (over_flag, out_flag, drop_flag) = + (hover_flag.clone(), hover_flag.clone(), hover_flag.clone()); + let over_dispatch = dispatch.clone(); + let out_dispatch = dispatch.clone(); + let drop_dispatch = dispatch.clone(); + + use_drop(move || { + if drop_flag.replace(false) { + let count = hovering.peek().saturating_sub(1); + hovering.set(count); + if count == 0 { + drop_dispatch.resume_toasts(); + } + } + }); + let card = match variant { Variant::Progress => progress_card(&entry), Variant::Action => action_card(&entry, dispatch.clone(), id, ttl_pct), @@ -136,13 +190,27 @@ impl Component for ToastCard { .width(Size::px(TOAST_W)) .offset_x(offset) .opacity(opacity) - .on_pointer_enter(move |_| { - bump_enter.bump_toast(id); - ttl_bar_anim.start(); + // `over`/`out` rather than `enter`/`leave`: the latter are exclusive to the deepest + // node, so moving onto the close button would count as leaving the toast. + .on_pointer_over(move |_| { + if over_flag.replace(true) { + return; + } + let count = *hovering.peek() + 1; + hovering.set(count); + if count == 1 { + over_dispatch.pause_toasts(); + } }) - .on_pointer_move(move |_| { - bump_move.bump_toast(id); - ttl_bar_anim.start(); + .on_pointer_out(move |_| { + if !out_flag.replace(false) { + return; + } + let count = hovering.peek().saturating_sub(1); + hovering.set(count); + if count == 0 { + out_dispatch.resume_toasts(); + } }) .child(card) } diff --git a/packages/oneclient_app/src/hooks/dispatch.rs b/packages/oneclient_app/src/hooks/dispatch.rs index 00a895aa..7583c4a5 100644 --- a/packages/oneclient_app/src/hooks/dispatch.rs +++ b/packages/oneclient_app/src/hooks/dispatch.rs @@ -166,8 +166,12 @@ impl BridgeDispatch { self.send(BridgeCommand::DismissToast(entry_id)); } - pub fn bump_toast(&self, entry_id: u64) { - self.send(BridgeCommand::BumpToast(entry_id)); + pub fn pause_toasts(&self) { + self.send(BridgeCommand::PauseToasts); + } + + pub fn resume_toasts(&self) { + self.send(BridgeCommand::ResumeToasts); } pub fn mark_notification_read(&self, entry_id: u64) { diff --git a/packages/oneclient_app/src/view/app/analytics/charts.rs b/packages/oneclient_app/src/view/app/analytics/charts.rs index 7157d66e..7eb925a3 100644 --- a/packages/oneclient_app/src/view/app/analytics/charts.rs +++ b/packages/oneclient_app/src/view/app/analytics/charts.rs @@ -42,7 +42,7 @@ impl Component for WhenChart { let control = SegmentedControl::new(mode) .height(30.) - .segment(Segment::new(WhenMode::Weekday).label("Weekday")) + .segment(Segment::new(WhenMode::Weekday).label("Day")) .segment(Segment::new(WhenMode::Hour).label("Hour")) .into_element();