From 8ca2c3099311d91355396d88d12b6d289fee4eb8 Mon Sep 17 00:00:00 2001 From: Maciek <108873945+rozwader@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:44:45 +0200 Subject: [PATCH] feat: implemented disabling animations (#762) * feat: implemented disabling animations * fix: home page and notification toasts animating weirdly Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com> --- .../oneclient_app/src/components/button.rs | 8 +- .../oneclient_app/src/components/icons.rs | 11 ++- packages/oneclient_app/src/components/mod.rs | 4 +- .../src/components/recents_row.rs | 76 +++++-------------- .../oneclient_app/src/components/toasts.rs | 41 ++++++++-- .../oneclient_app/src/components/toggle.rs | 1 + .../oneclient_app/src/layout/root_layout.rs | 2 + packages/oneclient_app/src/lib.rs | 1 + packages/oneclient_app/src/motion.rs | 29 +++++++ packages/oneclient_app/src/theme/mod.rs | 1 + .../oneclient_app/src/view/app/home/index.rs | 3 +- .../src/view/app/settings/appearance.rs | 20 +++-- .../src/view/onboarding/preferences.rs | 8 +- .../oneclient_core/src/settings/launcher.rs | 2 + 14 files changed, 128 insertions(+), 79 deletions(-) create mode 100644 packages/oneclient_app/src/motion.rs diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs index bb2fb314..a4235be9 100644 --- a/packages/oneclient_app/src/components/button.rs +++ b/packages/oneclient_app/src/components/button.rs @@ -4,7 +4,7 @@ use std::borrow::Cow; use freya::prelude::*; use crate::{ - components::{Icon, IconType}, + components::{Icon, IconTint, IconType}, theme::colors, }; @@ -273,6 +273,12 @@ impl Component for Button { palette.disabled_foreground }; + // Child icons would otherwise inherit this through a `styled` event that lands a couple of + // frames after the button paints, so hand it straight to them instead + let mut icon_tint = use_state(|| foreground); + icon_tint.set_if_modified(foreground); + use_hook(|| provide_context(IconTint(icon_tint))); + let border_color = if !enabled() { palette.border } else if pressing() { diff --git a/packages/oneclient_app/src/components/icons.rs b/packages/oneclient_app/src/components/icons.rs index 820ca5ec..4eab4960 100644 --- a/packages/oneclient_app/src/components/icons.rs +++ b/packages/oneclient_app/src/components/icons.rs @@ -3,6 +3,9 @@ use oneclient_content::packages::ProviderId; use crate::{AppAssets, theme::colors}; +#[derive(Clone, Copy, PartialEq)] +pub struct IconTint(pub State); + #[derive(PartialEq)] pub struct Icon { pub icon: IconType, @@ -49,12 +52,18 @@ impl Component for Icon { fn render(&self) -> impl IntoElement { let path = self.icon.path(); let bytes = use_memo(move || AppAssets::get_bytes(path).unwrap_or_default()); + let tint = use_hook(try_consume_context::); + + let color = self + .color + .or_else(|| tint.map(|tint| *tint.0.read())) + .unwrap_or_else(colors::fg_primary); SvgViewer::new((path, bytes.read().cloned())) .show_loader(false) .width(Size::px(self.size_px)) .height(Size::px(self.size_px)) - .map(self.color, |svg, color| svg.color(color)) + .color(color) .map(self.rotation, |svg, degrees| svg.rotation(degrees)) } } diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index cf8a40f1..bdb3061f 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -50,7 +50,7 @@ pub use context_menu::ContextMenu; pub use dropdown::Dropdown; pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt}; pub use file_drop::FileDropOverlay; -pub use icons::{Icon, IconType}; +pub use icons::{Icon, IconTint, IconType}; pub use java_install_manager::JavaInstallManager; pub use generic_prompt::GenericPromptOverlay; pub use java_prompt::JavaPromptOverlay; @@ -78,6 +78,6 @@ pub use status_bar::StatusBar; pub use tab_bar::{TabBar, TabItem}; pub use text_input::{TextInput, validate_number}; pub use toasts::Toasts; -pub use toggle::{toggle, toggle_controlled, toggle_disabled}; +pub use toggle::{toggle, toggle_controlled}; pub use update_prompt::UpdatePromptOverlay; pub use version_card::VersionCard; diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs index 0a7951fc..9b735dcf 100644 --- a/packages/oneclient_app/src/components/recents_row.rs +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -6,6 +6,7 @@ use oneclient_core::clusters::Cluster; use crate::components::{ART_PREVIEW_EDGE, DynamicArt, Icon, IconType}; use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters}; use crate::routes::Route; +use crate::theme; use crate::theme::colors; use crate::ui::{border_all, border_all_color}; use crate::utils::sort_clusters_for_home; @@ -26,12 +27,18 @@ pub struct RecentsRow; impl Component for RecentsRow { fn render(&self) -> impl IntoElement { let clusters_query = use_clusters(); - let mut visible_slots = use_state(|| 1_usize); + let platform = Platform::get(); + let root_size = platform.root_size; + let scale_factor = platform.scale_factor; let clusters = settled_or_loading(&clusters_query).unwrap_or_default(); let sorted: Vec = sort_clusters_for_home(clusters); - let slots = *visible_slots.read(); + + let slots = recent_card_slots_for_window( + root_size.read().width, + *scale_factor.read() as f32, + ); // Budget off cluster count not slot count an offset-only change makes the layout // engine reuse a card's cached area freezing survivors at pre-resize widths @@ -52,12 +59,6 @@ impl Component for RecentsRow { .width(Size::fill()) .height(Size::px(ROW_HEIGHT_PX)) .content(Content::Flex) - .on_sized(move |event: Event| { - let next = recent_card_slots_for_width(event.data().area.width()); - if next != *visible_slots.peek() { - *visible_slots.write() = next; - } - }) .horizontal() .spacing(CARD_GAP_PX) .children(display.iter().enumerate().map(|(index, cluster)| { @@ -293,6 +294,15 @@ impl Component for OtherVersionsTile { } } +fn recent_card_slots_for_window(window_width_px: f32, scale_factor: f32) -> usize { + let scale = if scale_factor > 0. && scale_factor.is_finite() { + scale_factor + } else { + 1. + }; + recent_card_slots_for_width(window_width_px / scale - theme::HOME_PADDING_PX * 2.) +} + /// `n` cards occupy `n` gaps (n-1 between cards one before the tile) so `n * (MIN + GAP) + MORE` /// must fit /// Always returns at least 1 the card carries no `min_width` and just renders narrower @@ -305,53 +315,3 @@ fn recent_card_slots_for_width(row_width_px: f32) -> usize { let slot = MIN_CARD_WIDTH_PX + CARD_GAP_PX; (available / slot).floor().max(1.0) as usize } - -#[cfg(test)] -mod tests { - use super::*; - - fn row_fits(slots: usize, row_width_px: f32) -> bool { - let cards = slots as f32 * MIN_CARD_WIDTH_PX; - let gaps = slots as f32 * CARD_GAP_PX; - cards + gaps + MORE_TILE_WIDTH_PX <= row_width_px - } - - #[test] - fn slots_fill_the_row_without_overflowing_it() { - for width in (200..3200).step_by(4) { - let width = width as f32; - let slots = recent_card_slots_for_width(width); - - assert!(slots >= 1, "{width}px left no room for a card"); - if slots > 1 { - assert!(row_fits(slots, width), "{slots} cards overflow {width}px"); - } - assert!( - !row_fits(slots + 1, width), - "{width}px had room for {} cards", - slots + 1 - ); - } - } - - #[test] - fn every_item_settles_when_the_intro_ends() { - for clusters in 0..24 { - let items = intro_items(clusters); - for index in 0..=clusters { - assert_eq!( - stagger_eased(1.0, index, items), - 1.0, - "item {index} of {clusters} never finished rising" - ); - } - } - } - - #[test] - fn the_intro_runs_the_same_length_whatever_fits_on_screen() { - assert_eq!(intro_items(3), intro_items(3)); - assert_eq!(intro_ms(intro_items(30)), intro_ms(INTRO_ITEMS)); - assert_eq!(intro_ms(intro_items(0)), CARD_MS); - } -} diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs index 3ede3ce2..a3cbaa91 100644 --- a/packages/oneclient_app/src/components/toasts.rs +++ b/packages/oneclient_app/src/components/toasts.rs @@ -13,13 +13,15 @@ use crate::{ ui::{divider}, components::{Button, ButtonSize, Icon, IconType, progress_track}, hooks::{use_dispatch, use_notifications_snapshot}, + motion::use_animations_enabled, notifications::{InboxEntry, NotificationActionKind}, theme::colors, }; const TOAST_W: f32 = 300.; const TRAVEL: f32 = 320.; -const TTL_MS: f32 = 5000.; +const TTL_MS: f32 = crate::notifications::MESSAGE_TOAST_TTL.as_millis() as f32; +const TTL_STEP: std::time::Duration = std::time::Duration::from_millis(100); /// Below the navbar a smaller offset let the startup progress toast cover the nav links const TOP_PX: f32 = crate::theme::NAVBAR_HEIGHT_PX + 12.; @@ -126,22 +128,41 @@ impl Component for ToastCard { AnimNum::new(0., 1.).time(260).ease(Ease::Out) }); + let animations = use_animations_enabled(); 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 mut wall_pct = use_state(|| 100.); 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.)); + let remaining = remaining_pct(*from_pct.peek(), *run_start.peek()); + from_pct.set(remaining); + wall_pct.set(remaining); } else { run_start.set(Instant::now()); } was_paused.set(paused); } + let mut ticker = use_state(|| None::); + use_side_effect_with_deps(&(animations, paused), move |&(animations, paused)| { + if animations || paused { + ticker.set(None); + return; + } + let handle = spawn(async move { + let platform = Platform::get(); + loop { + wall_pct.set(remaining_pct(*from_pct.peek(), *run_start.peek())); + platform.send(UserEvent::RequestRedraw); + tokio::time::sleep(TTL_STEP).await; + } + }); + ticker.set(Some(handle.owned())); + }); + let ttl_bar_anim = use_animation_with_dependencies(&(paused, *from_pct.peek()), |conf, deps| { let &(paused, from) = deps; @@ -160,9 +181,12 @@ impl Component for ToastCard { let offset = slide.read().value(); let opacity = fade.read().value(); - let ttl_pct = ttl_bar_anim.read().value(); + let ttl_pct = if animations { + ttl_bar_anim.read().value() + } else { + *wall_pct.read() + }; - // A card can be unmounted while hovered 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) = @@ -217,6 +241,11 @@ impl Component for ToastCard { } } +fn remaining_pct(from_pct: f32, run_start: Instant) -> f32 { + let elapsed_pct = run_start.elapsed().as_secs_f32() * 1000. / TTL_MS * 100.; + (from_pct - elapsed_pct).clamp(0., 100.) +} + fn toast_shell(entry: &InboxEntry) -> Rect { let border_color = match entry.level { Level::Error => colors::danger(), diff --git a/packages/oneclient_app/src/components/toggle.rs b/packages/oneclient_app/src/components/toggle.rs index 0ada0323..cdb6ad63 100644 --- a/packages/oneclient_app/src/components/toggle.rs +++ b/packages/oneclient_app/src/components/toggle.rs @@ -18,6 +18,7 @@ pub fn toggle(value: State) -> impl IntoElement { } } +#[allow(dead_code)] pub fn toggle_disabled(value: State) -> impl IntoElement { Switch { value, diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 7489dcaf..06f0c851 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -8,6 +8,7 @@ use crate::components::{ }; use crate::hooks::{SplashState, use_provide_splash}; use crate::layout::HomeArtPrefetch; +use crate::motion::AnimationClockDriver; use crate::routes::Route; use crate::theme; use crate::theme::colors; @@ -75,6 +76,7 @@ impl Component for RootLayout { .child(PackageUpdatePopup) .child(StatusBar) .child(SplashCurtain) + .child(AnimationClockDriver) .child(HomeArtPrefetch) } } diff --git a/packages/oneclient_app/src/lib.rs b/packages/oneclient_app/src/lib.rs index cb3c2d34..9882f478 100644 --- a/packages/oneclient_app/src/lib.rs +++ b/packages/oneclient_app/src/lib.rs @@ -12,6 +12,7 @@ mod launcher; pub mod state; mod transfer; mod layout; +mod motion; mod notifications; pub mod platform; pub mod recovery; diff --git a/packages/oneclient_app/src/motion.rs b/packages/oneclient_app/src/motion.rs new file mode 100644 index 00000000..85b76547 --- /dev/null +++ b/packages/oneclient_app/src/motion.rs @@ -0,0 +1,29 @@ +use freya::prelude::*; + +use crate::hooks::use_settings_snapshot; + +#[derive(PartialEq)] +pub struct AnimationClockDriver; + +impl Component for AnimationClockDriver { + fn render(&self) -> impl IntoElement { + let enabled = use_settings_snapshot().settings.animations_enabled; + let clock = AnimationClock::get(); + + use_side_effect_with_deps(&enabled, move |&enabled| { + if enabled { + clock.enable(); + } else { + clock.disable(); + } + }); + + rect().into_element() + } +} + +/// Read from the setting rather than the clock so a component re-renders when the preference is +/// toggled, and so it never reads the one frame of stale speed before the driver above applies it +pub fn use_animations_enabled() -> bool { + use_settings_snapshot().settings.animations_enabled +} diff --git a/packages/oneclient_app/src/theme/mod.rs b/packages/oneclient_app/src/theme/mod.rs index 11a09bd2..771d4be5 100644 --- a/packages/oneclient_app/src/theme/mod.rs +++ b/packages/oneclient_app/src/theme/mod.rs @@ -5,6 +5,7 @@ use crate::AppAssets; pub mod colors; pub const NAVBAR_HEIGHT_PX: f32 = 80.; +pub const HOME_PADDING_PX: f32 = 48.; pub const DEFAULT_FONT: &str = "Poppins"; pub const MONO_FONT: &str = "JetBrains Mono"; diff --git a/packages/oneclient_app/src/view/app/home/index.rs b/packages/oneclient_app/src/view/app/home/index.rs index 2155621f..2ccb85ff 100644 --- a/packages/oneclient_app/src/view/app/home/index.rs +++ b/packages/oneclient_app/src/view/app/home/index.rs @@ -1,6 +1,7 @@ use freya::prelude::*; use crate::components::{ActiveClusterPanel, RecentsRow}; +use crate::theme; #[derive(PartialEq)] pub struct Home; @@ -13,7 +14,7 @@ impl Component for Home { .height(Size::fill()) .content(Content::Flex) .overflow(Overflow::Clip) - .padding(48.) + .padding(theme::HOME_PADDING_PX) .main_align(Alignment::Center) .child( rect() diff --git a/packages/oneclient_app/src/view/app/settings/appearance.rs b/packages/oneclient_app/src/view/app/settings/appearance.rs index 70faffa5..accfc8b6 100644 --- a/packages/oneclient_app/src/view/app/settings/appearance.rs +++ b/packages/oneclient_app/src/view/app/settings/appearance.rs @@ -1,7 +1,7 @@ use freya::prelude::*; use super::settings_page; -use crate::components::{Icon, IconType, toggle, toggle_disabled}; +use crate::components::{Icon, IconType, toggle}; use crate::hooks::{use_dispatch, use_settings_snapshot}; use crate::theme::colors; use crate::ui::border_all_color; @@ -50,7 +50,6 @@ pub struct SettingsAppearance; impl Component for SettingsAppearance { fn render(&self) -> impl IntoElement { let selected_theme = use_state(|| 0usize); - let animations_on = use_state(|| true); let settings = use_settings_snapshot().settings; let dispatch = use_dispatch(); @@ -60,15 +59,22 @@ impl Component for SettingsAppearance { move || v }); + let animations_on = use_state({ + let v = settings.animations_enabled; + move || v + }); + let mut first = use_state(|| true); use_side_effect(move || { - let enabled = *dynamic_bg.read(); + let parallax = *dynamic_bg.read(); + let animations = *animations_on.read(); if *first.peek() { first.set(false); return; } let mut next = settings.clone(); - next.dynamic_background_enabled = enabled; + next.dynamic_background_enabled = parallax; + next.animations_enabled = animations; dispatch.set_settings(next); }); @@ -249,10 +255,10 @@ fn dynamic_background_row(enabled: State) -> impl IntoElement { } fn animations_row(animations_on: State) -> impl IntoElement { - settings_row_disabled( + settings_row( IconType::Play, "Animations", - "Toggle all animations in the launcher. May cause buggy behavior.", - toggle_disabled(animations_on), + "Disable all launcher animations and transitions.", + toggle(animations_on), ) } diff --git a/packages/oneclient_app/src/view/onboarding/preferences.rs b/packages/oneclient_app/src/view/onboarding/preferences.rs index f2a9f590..e5c461cd 100644 --- a/packages/oneclient_app/src/view/onboarding/preferences.rs +++ b/packages/oneclient_app/src/view/onboarding/preferences.rs @@ -18,7 +18,7 @@ impl Component for OnboardingPreferences { let mut reduce_motion = use_onboarding_selection().reduce_motion; let animations_on = use_state({ - let v = !*reduce_motion.peek(); + let v = settings.animations_enabled; move || v }); use_side_effect(move || { @@ -32,13 +32,15 @@ impl Component for OnboardingPreferences { let mut first = use_state(|| true); use_side_effect(move || { - let enabled = *parallax_on.read(); + let parallax = *parallax_on.read(); + let animations = *animations_on.read(); if *first.peek() { first.set(false); return; } let mut next = settings.clone(); - next.dynamic_background_enabled = enabled; + next.dynamic_background_enabled = parallax; + next.animations_enabled = animations; dispatch.set_settings(next); }); diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs index 702ad598..77119c86 100644 --- a/packages/oneclient_core/src/settings/launcher.rs +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -33,6 +33,7 @@ pub struct LauncherSettings { pub global_game_settings: GameSettingsProfile, pub allow_parallel_running_clusters: bool, pub dynamic_background_enabled: bool, + pub animations_enabled: bool, pub view_states: BTreeMap, pub seen_onboarding: bool, pub accepted_tos_version: u32, @@ -68,6 +69,7 @@ impl Default for LauncherSettings { global_game_settings: GameSettingsProfile::default_global_profile(), allow_parallel_running_clusters: false, dynamic_background_enabled: true, + animations_enabled: true, view_states: BTreeMap::new(), seen_onboarding: false, accepted_tos_version: 0,