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>
This commit is contained in:
Maciek 2026-08-26 12:44:45 +02:00 • committed by GitHub
parent 795d80870b
commit 8ca2c30993
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 128 additions and 79 deletions

View file

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

View file

@ -3,6 +3,9 @@ use oneclient_content::packages::ProviderId;
use crate::{AppAssets, theme::colors};
#[derive(Clone, Copy, PartialEq)]
pub struct IconTint(pub State<Color>);
#[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::<IconTint>);
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))
}
}

View file

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

View file

@ -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<Cluster> = 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<SizedEventData>| {
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);
}
}

View file

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

View file

@ -18,6 +18,7 @@ pub fn toggle(value: State<bool>) -> impl IntoElement {
}
}
#[allow(dead_code)]
pub fn toggle_disabled(value: State<bool>) -> impl IntoElement {
Switch {
value,

View file

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

View file

@ -12,6 +12,7 @@ mod launcher;
pub mod state;
mod transfer;
mod layout;
mod motion;
mod notifications;
pub mod platform;
pub mod recovery;

View file

@ -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
}

View file

@ -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";

View file

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

View file

@ -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<bool>) -> impl IntoElement {
}
fn animations_row(animations_on: State<bool>) -> 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),
)
}

View file

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

View file

@ -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<String, ViewState>,
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,