feat(ui): toasts pause on hover + bunch of small general tweaks
This commit is contained in:
parent
430e8ea28f
commit
f3d526c0c4
8 changed files with 168 additions and 55 deletions
|
|
@ -85,7 +85,8 @@ pub enum BridgeCommand {
|
|||
CloseAccountSwitcher,
|
||||
ClearNotificationInbox,
|
||||
DismissToast(u64),
|
||||
BumpToast(u64),
|
||||
PauseToasts,
|
||||
ResumeToasts,
|
||||
MarkNotificationRead(u64),
|
||||
DismissNotification(u64),
|
||||
AnswerPrompt(UserChoice),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue