feat(ui): toasts pause on hover + bunch of small general tweaks

This commit is contained in:
LynithDev 2026-07-25 21:06:10 +02:00
parent 430e8ea28f
commit f3d526c0c4
8 changed files with 168 additions and 55 deletions

View file

@ -85,7 +85,8 @@ pub enum BridgeCommand {
CloseAccountSwitcher,
ClearNotificationInbox,
DismissToast(u64),
BumpToast(u64),
PauseToasts,
ResumeToasts,
MarkNotificationRead(u64),
DismissNotification(u64),
AnswerPrompt(UserChoice),

View file

@ -33,8 +33,10 @@ impl CoreBridgeRuntime {
let mut notification_center_open = false;
let mut pending_prompt: Option<PendingPrompt> = None;
let mut armed_toasts: std::collections::HashMap<u64, tokio::time::Instant> =
let mut armed_toasts: std::collections::HashMap<u64, ToastTimer> =
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<tokio::time::Instant> = 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<u64> = 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<tokio::time::Instant> {
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<u64, tokio::time::Instant>,
armed: &mut std::collections::HashMap<u64, ToastTimer>,
paused: bool,
) {
let want: std::collections::HashSet<u64> = 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));

View file

@ -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();
};

View file

@ -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()

View file

@ -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.)),
)
}
}

View file

@ -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<InboxEntry> = 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<usize>,
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)
}

View file

@ -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) {

View file

@ -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();