From 61493a86b7a3a131c55c5c2bf2b46da69cba1042 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Tue, 1 Sep 2026 14:21:41 +0530 Subject: [PATCH 01/40] refactor(crate): separate toolkit library from application binary --- Cargo.toml | 39 ++++++++++++++++++++++++++++----------- src/lib.rs | 24 ------------------------ src/main.rs | 24 +++++++++++++++++++++++- src/theme.rs | 6 ++++-- src/ui/chrome.rs | 3 ++- src/widgets/control.rs | 2 +- src/widgets/header_bar.rs | 3 ++- src/widgets/list_row.rs | 6 ++++-- src/widgets/mod.rs | 17 ++++++++++------- 9 files changed, 74 insertions(+), 50 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index aaf0535..0248dd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,22 +12,39 @@ keywords = [""] exclude = [""] [dependencies] -async-trait.workspace = true -bottles-core.workspace = true -iced = { version = "0.14", features = ["advanced", "canvas", "svg", "image-without-codecs", "tokio", "linux-theme-detection"] } -next-config.workspace = true +async-trait = { workspace = true, optional = true } +bottles-core = { workspace = true, optional = true } +iced = { version = "0.14", features = ["advanced", "canvas", "svg", "image-without-codecs"] } +next-config = { workspace = true, optional = true } rust-embed = { version = "8.12.0", features = ["include-exclude"] } -uuid.workspace = true -directories.workspace = true -serde.workspace = true -tokio-util = { workspace = true, features = ["rt"] } -url.workspace = true +uuid = { workspace = true, optional = true } +directories = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +tokio-util = { workspace = true, features = ["rt"], optional = true } +url = { workspace = true, optional = true } [dev-dependencies] futures-lite.workspace = true serde_json.workspace = true [features] -default = ["fvs"] +default = ["application", "fvs"] +application = [ + "dep:async-trait", + "dep:bottles-core", + "dep:directories", + "dep:next-config", + "dep:serde", + "dep:tokio-util", + "dep:url", + "dep:uuid", + "iced/linux-theme-detection", + "iced/tokio", +] debug = ["iced/hot"] -fvs = ["bottles-core/fvs"] +fvs = ["application", "bottles-core/fvs"] + +[[bin]] +name = "next-ui" +path = "src/main.rs" +required-features = ["application"] diff --git a/src/lib.rs b/src/lib.rs index a22131c..2e40e7d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,28 +1,4 @@ -mod app; -mod classic; pub mod icons; -mod onboarding; -mod operation; pub mod theme; pub mod ui; pub mod widgets; - -pub(crate) use app::Experience; - -pub fn run() -> iced::Result { - iced::application(app::App::new, app::App::update, app::App::view) - .title("Bottles Next") - .theme(app::App::theme) - .subscription(app::App::subscription) - .style(|_, theme| theme::application(theme)) - .window(iced::window::Settings { - size: iced::Size::new(1600.0, 1000.0), - position: iced::window::Position::Centered, - min_size: Some(iced::Size::new(720.0, 600.0)), - decorations: false, - transparent: true, - exit_on_close_request: false, - ..Default::default() - }) - .run() -} diff --git a/src/main.rs b/src/main.rs index 55774c9..8655304 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,25 @@ +mod app; +mod classic; +mod onboarding; +mod operation; + +pub(crate) use app::Experience; +pub(crate) use next_ui::{icons, theme, ui, widgets}; + fn main() -> iced::Result { - next_ui::run() + iced::application(app::App::new, app::App::update, app::App::view) + .title("Bottles Next") + .theme(app::App::theme) + .subscription(app::App::subscription) + .style(|_, theme| theme::application(theme)) + .window(iced::window::Settings { + size: iced::Size::new(1600.0, 1000.0), + position: iced::window::Position::Centered, + min_size: Some(iced::Size::new(720.0, 600.0)), + decorations: false, + transparent: true, + exit_on_close_request: false, + ..Default::default() + }) + .run() } diff --git a/src/theme.rs b/src/theme.rs index 5409ad4..32c4cf5 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -149,7 +149,8 @@ pub fn light() -> Theme { ) } -pub(crate) fn for_mode(mode: Mode) -> Theme { +#[doc(hidden)] +pub fn for_mode(mode: Mode) -> Theme { match mode { Mode::Light => light(), Mode::Dark | Mode::None => dark(), @@ -171,7 +172,8 @@ pub(crate) fn muted(theme: &Theme) -> Color { theme.extended_palette().primary.base.color } -pub(crate) fn window_color(theme: &Theme) -> Color { +#[doc(hidden)] +pub fn window_color(theme: &Theme) -> Color { theme.extended_palette().primary.weak.color } diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs index 91d6583..f0cbab3 100644 --- a/src/ui/chrome.rs +++ b/src/ui/chrome.rs @@ -19,7 +19,8 @@ const PANEL_INSET: [f32; 2] = [6.0, 8.0]; const WINDOW_CONTROL_INSET: [f32; 2] = [22.0, 20.0]; pub(crate) const WINDOW_CONTROL_SIZE: f32 = 32.0; -pub(crate) const WINDOW_CONTROL_AT_START: bool = cfg!(target_os = "macos"); +#[doc(hidden)] +pub const WINDOW_CONTROL_AT_START: bool = cfg!(target_os = "macos"); #[derive(Debug, Clone, Copy)] pub enum Action { diff --git a/src/widgets/control.rs b/src/widgets/control.rs index 13ff28f..1470c09 100644 --- a/src/widgets/control.rs +++ b/src/widgets/control.rs @@ -658,7 +658,7 @@ impl Operation for FocusFirst { } } -pub(crate) fn event_cursor(event: &Event, cursor: mouse::Cursor) -> mouse::Cursor { +pub fn event_cursor(event: &Event, cursor: mouse::Cursor) -> mouse::Cursor { match event { Event::Touch( touch::Event::FingerPressed { position, .. } diff --git a/src/widgets/header_bar.rs b/src/widgets/header_bar.rs index fb0dba8..2b3a0c5 100644 --- a/src/widgets/header_bar.rs +++ b/src/widgets/header_bar.rs @@ -33,7 +33,8 @@ impl<'a, Message> HeaderBar<'a, Message> { } } - pub(crate) fn without_window_control(on_drag: Message) -> Self { + #[doc(hidden)] + pub fn without_window_control(on_drag: Message) -> Self { Self { reserve_window_control: false, ..Self::new(on_drag) diff --git a/src/widgets/list_row.rs b/src/widgets/list_row.rs index cc8e973..1401f0c 100644 --- a/src/widgets/list_row.rs +++ b/src/widgets/list_row.rs @@ -9,7 +9,8 @@ use super::{ text::TextExt as _, }; -pub(crate) const BODY_SIZE: f32 = 16.0; +#[doc(hidden)] +pub const BODY_SIZE: f32 = 16.0; pub(crate) const STANDARD_PADDING: Padding = Padding { top: spacing::MD, right: spacing::MD, @@ -40,7 +41,8 @@ pub(crate) struct Content<'a, Message> { pub(crate) disclosure_index: Option, } -pub(crate) fn labels<'a, Message: 'a>( +#[doc(hidden)] +pub fn labels<'a, Message: 'a>( title: impl IntoFragment<'a>, description: impl IntoFragment<'a>, ) -> Element<'a, Message> { diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 6c47ad1..16df761 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -4,18 +4,21 @@ mod menu; mod style; mod surface; +pub(crate) use control::Control; #[cfg(test)] pub(crate) use control::Interaction; -pub(crate) use control::{Control, event_cursor}; +#[doc(hidden)] +pub use control::event_cursor; use iced::{ContentFit, Point, Rectangle, Size, Theme, advanced::svg::Renderer as _}; -pub(crate) mod spacing { - pub(crate) const XS: f32 = 6.0; - pub(crate) const SM: f32 = 12.0; - pub(crate) const MD: f32 = 18.0; - pub(crate) const LG: f32 = 24.0; - pub(crate) const XG: f32 = 32.0; +#[doc(hidden)] +pub mod spacing { + pub const XS: f32 = 6.0; + pub const SM: f32 = 12.0; + pub const MD: f32 = 18.0; + pub const LG: f32 = 24.0; + pub const XG: f32 = 32.0; } fn reconcile_index( From 01a789dcc59183e69501a1156aca22b1e06cd5c3 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Tue, 1 Sep 2026 18:29:15 +0530 Subject: [PATCH 02/40] refactor(theme): centralize semantic surfaces --- examples/gallery.rs | 27 +++++-- src/app.rs | 33 ++++++--- src/classic/bottles.rs | 13 ++-- src/classic/layout.rs | 58 +++++++++++++-- src/classic/mod.rs | 24 ++++-- src/classic/settings.rs | 21 ++++-- src/icons.rs | 65 ++++++++++++++++ src/onboarding.rs | 24 +++--- src/theme.rs | 139 +++++++++++++++++++++++++++-------- src/widgets/button.rs | 53 +++++++------ src/widgets/card.rs | 7 +- src/widgets/control.rs | 2 +- src/widgets/drop_target.rs | 2 +- src/widgets/info_card.rs | 19 +++-- src/widgets/list_row.rs | 12 +-- src/widgets/menu.rs | 9 +-- src/widgets/mod.rs | 2 +- src/widgets/popover.rs | 6 +- src/widgets/progress_ring.rs | 56 +++++++++++--- src/widgets/row_group.rs | 12 +-- src/widgets/search.rs | 28 ++++--- src/widgets/selector_row.rs | 97 ++++++++++++++++++------ src/widgets/status_bar.rs | 10 +-- src/widgets/style.rs | 9 ++- src/widgets/surface.rs | 51 +++++++------ src/widgets/switcher.rs | 59 +++++++++++---- src/widgets/tabs.rs | 63 +++++++++++++--- src/widgets/text_row.rs | 18 ++--- src/widgets/title.rs | 4 +- 29 files changed, 674 insertions(+), 249 deletions(-) diff --git a/examples/gallery.rs b/examples/gallery.rs index 66a1a1d..0af03bd 100644 --- a/examples/gallery.rs +++ b/examples/gallery.rs @@ -11,7 +11,11 @@ use next_ui::widgets::{ info_row, picker_row, popover, row_group, search, selector_row, status_bar, switcher_row, tabs, text_row, title, }; -use next_ui::{icons::Icon, theme, ui::chrome}; +use next_ui::{ + icons::Icon, + theme::{self, Motion}, + ui::chrome, +}; const SELECTOR_OPTIONS: &[&str] = &["Option 1", "Option 2", "Option 3"]; const EMPTY_OPTIONS: &[&str] = &[]; @@ -223,7 +227,8 @@ impl Gallery { .map(|(index, label)| tabs::Tab::new(index, label)), Some(self.selected_tab), Message::TabSelected, - ); + ) + .motion(Motion::Full); let search = column![ search::Search::new( @@ -300,9 +305,11 @@ impl Gallery { selector_row::SelectorRow::new("Selector Name", SELECTOR_OPTIONS, selected,) .on_selected(Message::OptionSelected) .placeholder("Placeholder") - .icon(Icon::Person), + .icon(Icon::Person) + .motion(Motion::Full), selector_row::SelectorRow::new("Empty selector", EMPTY_OPTIONS, None) - .placeholder("No options available"), + .placeholder("No options available") + .motion(Motion::Full), action_row::ActionRow::new("Title", action_row::State::Ready(Message::Noop)) .description("Description"), action_row::ActionRow::new("Unavailable action", action_row::State::Disabled) @@ -312,7 +319,8 @@ impl Gallery { .icon(Icon::Timer), switcher_row::SwitcherRow::new("Title", self.switched_on) .on_toggle(Message::Switched) - .description("Description"), + .description("Description") + .motion(Motion::Full), cycle_row::CycleRow::new("DLSS Level", DLSS_LEVELS[self.value]) .on_previous_maybe((self.value > 0).then_some(Message::Previous)) .on_next_maybe((self.value + 1 < DLSS_LEVELS.len()).then_some(Message::Next),), @@ -322,7 +330,8 @@ impl Gallery { expander_row::ExpanderRow::with_header( switcher_row::SwitcherRow::new("FSR", self.switched_on) .on_toggle(Message::Switched) - .description("FidelityFX Super Resolution"), + .description("FidelityFX Super Resolution") + .motion(Motion::Full), ) .columns(2) .add( @@ -345,7 +354,8 @@ impl Gallery { .row( switcher_row::SwitcherRow::new("DLSS", self.switched_on) .on_toggle(Message::Switched) - .description("Deep Learning Super Sampling"), + .description("Deep Learning Super Sampling") + .motion(Motion::Full), ) .row( picker_row::PickerRow::new("Shader directory") @@ -360,7 +370,8 @@ impl Gallery { expander_row::ExpanderRow::with_header( switcher_row::SwitcherRow::new("FSR", self.group_switched_on) .on_toggle(Message::GroupSwitched) - .description("FidelityFX Super Resolution"), + .description("FidelityFX Super Resolution") + .motion(Motion::Full), ) .columns(2) .add( diff --git a/src/app.rs b/src/app.rs index 116fc07..27e3d87 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,7 +10,8 @@ use next_config::Config; use serde::{Deserialize, Serialize}; use crate::{ - classic, onboarding, theme, + classic, onboarding, + theme::{self, Motion}, ui::chrome, widgets::{ button::{Button, ButtonKind}, @@ -58,6 +59,8 @@ type AppResult = Result; pub struct AppConfig { #[serde(default)] pub experience: Option, + #[serde(default)] + pub reduce_motion: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -69,6 +72,7 @@ pub enum Experience { pub(crate) struct App { phase: Phase, theme: Theme, + motion: Motion, } enum Phase { @@ -177,6 +181,7 @@ impl App { Self { phase: Phase::Booting, theme: theme::for_mode(ThemeMode::default()), + motion: Motion::Full, }, Task::batch([ Task::perform(boot(), AppMessage::Booted), @@ -383,10 +388,11 @@ impl App { fn finish_boot(&mut self, boot: Boot) -> Task { let Boot { config, core } = boot; + self.motion = Motion::from(config.reduce_motion); match config.experience { None => { - let state = onboarding::State::new(core.addons().clone()); + let state = onboarding::State::new(core.addons().clone(), self.motion); self.phase = Phase::Onboarding { core, state: Box::new(state), @@ -407,7 +413,7 @@ impl App { } *saving = Some(experience); *notice = None; - save_experience(experience) + save_experience(experience, self.motion) } Phase::Workspace { workspace, @@ -460,7 +466,7 @@ impl App { unreachable!("the switch target came from a workspace") }; *transition = WorkspaceTransition::Saving(target); - save_experience(target) + save_experience(target, self.motion) } fn finish_experience_save( @@ -523,7 +529,7 @@ impl App { fn open_workspace(&mut self, core: Arc, experience: Experience) -> Task { let (workspace, task) = match experience { Experience::Classic => { - let (state, task) = classic::State::new(core.as_ref()); + let (state, task) = classic::State::new(core.as_ref(), self.motion); ( Workspace::Classic(Box::new(state)), task.map(|message| { @@ -581,8 +587,8 @@ impl App { } } -fn save_experience(experience: Experience) -> Task { - Task::perform(save_config(experience), move |result| { +fn save_experience(experience: Experience, motion: Motion) -> Task { + Task::perform(save_config(experience, motion), move |result| { AppMessage::ExperienceSaved { experience, result } }) } @@ -613,16 +619,17 @@ async fn load_config_from(path: &Path) -> AppResult { } } -async fn save_config(experience: Experience) -> AppResult<()> { +async fn save_config(experience: Experience, motion: Motion) -> AppResult<()> { let path = config_path()?; - save_config_to(&path, experience).await + save_config_to(&path, experience, motion).await } -async fn save_config_to(path: &Path, experience: Experience) -> AppResult<()> { +async fn save_config_to(path: &Path, experience: Experience, motion: Motion) -> AppResult<()> { next_config::save( path, &AppConfig { experience: Some(experience), + reduce_motion: motion.is_reduced(), }, ) .await @@ -814,6 +821,7 @@ mod tests { let config = futures_lite::future::block_on(load_config_from(&path)).unwrap(); assert_eq!(config.experience, None); + assert!(!config.reduce_motion); } #[test] @@ -821,9 +829,12 @@ mod tests { let path = std::env::temp_dir().join(format!("next-ui-{}.toml", uuid::Uuid::new_v4())); futures_lite::future::block_on(async { - save_config_to(&path, Experience::Classic).await.unwrap(); + save_config_to(&path, Experience::Classic, Motion::Reduced) + .await + .unwrap(); let config = load_config_from(&path).await.unwrap(); assert_eq!(config.experience, Some(Experience::Classic)); + assert!(config.reduce_motion); }); std::fs::remove_file(path).unwrap(); diff --git a/src/classic/bottles.rs b/src/classic/bottles.rs index 6d152b1..cfc2d60 100644 --- a/src/classic/bottles.rs +++ b/src/classic/bottles.rs @@ -14,6 +14,7 @@ use uuid::Uuid; use crate::{ icons::Icon, operation, + theme::Motion, widgets::{ action_row::{ActionRow, State as ActionRowState}, artwork_card::{ArtworkCard, CardAction}, @@ -228,7 +229,7 @@ impl State { .into() } - pub fn creation_view(&self) -> Element<'_, Message> { + pub fn creation_view(&self, motion: Motion) -> Element<'_, Message> { let creating = self.is_creating(); let name = TextRow::new("Bottle Name", &self.bottle_name).icon(Icon::Person); let name = if creating { @@ -237,7 +238,8 @@ impl State { name.on_input(Message::BottleNameChanged) }; let runner = SelectorRow::new("Runner", &self.runners, self.selected_runner.as_ref()) - .icon(Icon::Run); + .icon(Icon::Run) + .motion(motion); let runner = if creating { runner } else { @@ -246,9 +248,10 @@ impl State { let content = column![ name, runner, - SelectorRow::new("Purpose", &PURPOSES, Some(&PURPOSES[0])), + SelectorRow::new("Purpose", &PURPOSES, Some(&PURPOSES[0])).motion(motion), SelectorRow::new("Architecture", &ARCHITECTURES, Some(&ARCHITECTURES[0])) - .icon(Icon::Chip), + .icon(Icon::Chip) + .motion(motion), PickerRow::new("Use Recipe").description("Choose the location"), ] .spacing(12); @@ -297,7 +300,7 @@ fn new_program_target<'a>() -> DropTarget<'a, Message> { .align_y(Center) .style(|theme: &Theme| { container::Style::default() - .background(theme.extended_palette().background.weak.color) + .background(crate::theme::colors(theme).surface) .border(iced::Border::default().rounded(ICON_CONTAINER_SIZE / 2.0)) }); let labels = column![ diff --git a/src/classic/layout.rs b/src/classic/layout.rs index bfd9a48..044f0f9 100644 --- a/src/classic/layout.rs +++ b/src/classic/layout.rs @@ -12,7 +12,7 @@ use iced::{ }; use crate::{ - theme, + theme::{self, Motion}, ui::chrome::WINDOW_CONTROL_AT_START, widgets::{event_cursor, header_bar::HeaderBar, spacing}, }; @@ -100,24 +100,33 @@ impl Side { pub(super) fn navigation_split<'a, Message: 'a>( parent: PaneContext, show_detail: bool, + motion: Motion, master: impl Fn(PaneContext) -> Element<'a, Message> + 'a, detail: impl Fn(PaneContext) -> Element<'a, Message> + 'a, ) -> Element<'a, Message> { - adaptive_split(Kind::Navigation(parent), show_detail, master, detail) + adaptive_split( + Kind::Navigation(parent), + show_detail, + motion, + master, + detail, + ) } pub(super) fn side_panel<'a, Message: 'a>( side: Side, open: bool, + motion: Motion, base: impl Fn(PaneContext) -> Element<'a, Message> + 'a, panel: impl Fn(PaneContext) -> Element<'a, Message> + 'a, ) -> Element<'a, Message> { - adaptive_split(Kind::SidePanel(side), open, base, panel) + adaptive_split(Kind::SidePanel(side), open, motion, base, panel) } fn adaptive_split<'a, Message: 'a>( kind: Kind, show_second: bool, + motion: Motion, first: impl Fn(PaneContext) -> Element<'a, Message> + 'a, second: impl Fn(PaneContext) -> Element<'a, Message> + 'a, ) -> Element<'a, Message> { @@ -131,6 +140,7 @@ fn adaptive_split<'a, Message: 'a>( show_second, wide, kind, + motion, }) }) .into() @@ -141,6 +151,7 @@ struct AnimatedSplit<'a, Message> { show_second: bool, wide: bool, kind: Kind, + motion: Motion, } impl<'a, Message> AnimatedSplit<'a, Message> { @@ -165,8 +176,8 @@ impl State { self.transition.interpolate(0.0, 1.0, now) } - fn sync(&mut self, show_second: bool, wide: bool, kind: Kind, now: Instant) { - if !wide && matches!(kind, Kind::SidePanel(_)) { + fn sync(&mut self, show_second: bool, wide: bool, kind: Kind, now: Instant, motion: Motion) { + if motion.is_reduced() || (!wide && matches!(kind, Kind::SidePanel(_))) { *self = Self::new(show_second); } else if self.transition.value() != show_second { self.transition.go_mut(show_second, now); @@ -194,6 +205,7 @@ impl Widget for AnimatedSplit<'_, Messa self.wide, self.kind, Instant::now(), + self.motion, ); } @@ -680,12 +692,18 @@ mod tests { let now = Instant::now(); let mut state = State::new(false); - state.sync(true, false, Kind::SidePanel(Side::Start), now); + state.sync(true, false, Kind::SidePanel(Side::Start), now, Motion::Full); assert!(state.transition.value()); assert!(!state.transition.is_animating(now)); assert_eq!(state.progress(now), 1.0); - state.sync(false, false, Kind::SidePanel(Side::Start), now); + state.sync( + false, + false, + Kind::SidePanel(Side::Start), + now, + Motion::Full, + ); assert!(!state.transition.value()); assert!(!state.transition.is_animating(now)); assert_eq!(state.progress(now), 0.0); @@ -696,10 +714,34 @@ mod tests { let now = Instant::now(); let mut state = State::new(false); - state.sync(true, false, Kind::Navigation(root_context()), now); + state.sync( + true, + false, + Kind::Navigation(root_context()), + now, + Motion::Full, + ); assert!(state.transition.value()); assert!(state.transition.is_animating(now)); assert_eq!(state.progress(now), 0.0); } + + #[test] + fn reduced_motion_snaps_navigation_changes() { + let now = Instant::now(); + let mut state = State::new(false); + + state.sync( + true, + false, + Kind::Navigation(root_context()), + now, + Motion::Reduced, + ); + + assert!(state.transition.value()); + assert!(!state.transition.is_animating(now)); + assert_eq!(state.progress(now), 1.0); + } } diff --git a/src/classic/mod.rs b/src/classic/mod.rs index 94d8718..50285ef 100644 --- a/src/classic/mod.rs +++ b/src/classic/mod.rs @@ -17,7 +17,7 @@ mod snapshots; use crate::{ Experience, icons::Icon, - theme, + theme::{self, Motion}, ui::chrome, widgets::{ action_row::{ActionRow, State as ActionRowState}, @@ -147,6 +147,7 @@ pub struct State { accounts: accounts::State, settings: settings::State, draining: bool, + motion: Motion, } #[derive(Clone)] @@ -205,7 +206,7 @@ impl Message { } impl State { - pub fn new(core: &Bottles) -> (Self, Task) { + pub fn new(core: &Bottles, motion: Motion) -> (Self, Task) { let bottle_manager = core.bottles().clone(); let profiles_manager = core.profiles().clone(); let read_model = ReadModel::new(&bottle_manager, &profiles_manager); @@ -226,6 +227,7 @@ impl State { accounts: accounts::State::default(), settings: settings::State::new(), draining: false, + motion, }; (state, library_boot) @@ -502,10 +504,12 @@ impl State { Panel::NewBottle => Side::Start, }, self.panel_open, + self.motion, |base_context| { navigation_split( base_context, matches!(self.route, Route::Bottle { .. }), + self.motion, |context| self.primary_page(context), |context| self.detail_page(context), ) @@ -547,7 +551,8 @@ impl State { ], Some(self.primary_tab()), Message::PrimaryTabSelected, - ); + ) + .motion(self.motion); let header = context .header(Message::Window(chrome::Action::Drag)) .start(header_button("Add bottle", Icon::Plus, Message::AddBottle)) @@ -685,7 +690,10 @@ impl State { .then_some(Message::Bottles(bottles::Message::CreateBottle)), ), ); - let content = self.bottles.creation_view().map(Message::Bottles); + let content = self + .bottles + .creation_view(self.motion) + .map(Message::Bottles); column![header, scroll_panel(content)] .width(Fill) @@ -712,7 +720,8 @@ impl State { _ => DetailTab::Programs, }), Message::DetailTabSelected, - ); + ) + .motion(self.motion); let mut header = context.header(Message::Window(chrome::Action::Drag)); if context.is_standalone() { @@ -758,7 +767,10 @@ impl State { column![].into() } } - DetailTab::Settings => self.settings.view(&settings_ctx).map(Message::Settings), + DetailTab::Settings => self + .settings + .view(&settings_ctx, self.motion) + .map(Message::Settings), #[cfg(feature = "fvs")] DetailTab::Snapshots => self.snapshots.view().map(Message::Snapshots), }; diff --git a/src/classic/settings.rs b/src/classic/settings.rs index 16447c3..5d0e41d 100644 --- a/src/classic/settings.rs +++ b/src/classic/settings.rs @@ -16,6 +16,7 @@ use iced::{ use crate::widgets::info_card::{InfoCard, Kind}; use crate::{ icons::Icon, + theme::Motion, widgets::{ action_row::{ActionRow, State as ActionRowState}, cycle_row::CycleRow, @@ -117,7 +118,7 @@ impl State { match message {} } - pub fn view<'a>(&'a self, ctx: &Context<'a>) -> Element<'a, Message> { + pub fn view<'a>(&'a self, ctx: &Context<'a>, motion: Motion) -> Element<'a, Message> { let Some(state) = ctx.bottle_state else { return column![].into(); }; @@ -171,20 +172,25 @@ impl State { .title("Graphics") .columns(columns) .row( - SwitcherRow::new("DLSS", false).description("Deep Learning Super Sampling"), + SwitcherRow::new("DLSS", false) + .description("Deep Learning Super Sampling") + .motion(motion), ) .row( SwitcherRow::new("vkBasalt", false) - .description("Add post-processing effects"), + .description("Add post-processing effects") + .motion(motion), ) .row( SwitcherRow::new("Discrete GPU", false) - .description("Force use your dedicated GPU"), + .description("Force use your dedicated GPU") + .motion(motion), ) .expander( ExpanderRow::with_header( SwitcherRow::new("FSR", false) - .description("FidelityFX Super Resolution"), + .description("FidelityFX Super Resolution") + .motion(motion), ) .columns(2) .add( @@ -200,6 +206,7 @@ impl State { graphics.row( SwitcherRow::new("Gamescope", wrappers.gamescope.enabled) .description("Use the SteamOS compositor") + .motion(motion) .on_toggle(Message::ToggleGamescope), ) }; @@ -207,7 +214,8 @@ impl State { let graphics = graphics.expander( ExpanderRow::with_header( SwitcherRow::new("Display Settings", false) - .description("Resolution and other options"), + .description("Resolution and other options") + .motion(motion), ) .add(unavailable()) .content_enabled(false), @@ -219,6 +227,7 @@ impl State { graphics.row( SwitcherRow::new("MangoHud", wrappers.mangohud.enabled) .description("Show a performance overlay") + .motion(motion) .on_toggle(Message::ToggleMangoHud), ) }; diff --git a/src/icons.rs b/src/icons.rs index 114b32c..6ade16f 100644 --- a/src/icons.rs +++ b/src/icons.rs @@ -93,3 +93,68 @@ impl Icon { #[folder = "assets"] #[include = "icons/*.svg"] struct Assets; + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + + const ALL: [Icon; 29] = [ + Icon::Arrow, + Icon::Bottles, + Icon::Checkmark, + Icon::Chip, + Icon::Computer, + Icon::Controller, + Icon::Cross, + Icon::Custom, + Icon::Disk, + Icon::DoubleCheckmark, + Icon::DownCaret, + Icon::EllipsisVertical, + Icon::Error, + Icon::Folder, + Icon::Gear, + Icon::HollowGear, + Icon::Info, + Icon::Lightning, + Icon::Pencil, + Icon::Person, + Icon::Play, + Icon::Plus, + Icon::Power, + Icon::Run, + Icon::Search, + Icon::Stop, + Icon::Timer, + Icon::Wand, + Icon::Warning, + ]; + + #[test] + fn every_typed_icon_resolves_and_every_embedded_icon_is_typed() { + let declared: BTreeSet<_> = ALL + .into_iter() + .map(|icon| { + let path = format!("icons/{}.svg", icon.name()); + assert!( + Assets::get(&path).is_some(), + "missing embedded icon: {path}" + ); + path + }) + .collect(); + let embedded: BTreeSet<_> = Assets::iter() + .filter(|path| path.starts_with("icons/") && path.ends_with(".svg")) + .map(|path| path.into_owned()) + .collect(); + + assert_eq!( + declared.len(), + ALL.len(), + "two Icon variants share an asset" + ); + assert_eq!(declared, embedded); + } +} diff --git a/src/onboarding.rs b/src/onboarding.rs index 44044a8..42be6d2 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -7,7 +7,7 @@ use crate::{ Experience, icons::Icon, operation::{self, Event as OperationEvent, Outcome}, - theme, + theme::{self, Motion}, ui::chrome, widgets::{ action_row::{ActionRow, State as RowState}, @@ -119,6 +119,7 @@ pub struct State { download_generation: u64, setup_phase: SetupPhase, catalog_error: Option>, + motion: Motion, } #[derive(Clone)] @@ -136,7 +137,7 @@ pub enum Message { } impl State { - pub fn new(addons: Addons) -> Self { + pub fn new(addons: Addons, motion: Motion) -> Self { Self { step: Step::Welcome, experience: Experience::Classic, @@ -145,6 +146,7 @@ impl State { download_generation: 0, setup_phase: SetupPhase::Idle, catalog_error: None, + motion, } } @@ -369,7 +371,8 @@ impl State { if matches!(self.setup_phase, SetupPhase::Preparing(_)) { group = group.row( ActionRow::new("Resource catalog", RowState::Progress(0.0)) - .description("Preparing"), + .description("Preparing") + .motion(self.motion), ); } @@ -385,10 +388,13 @@ impl State { group = match &item.state { DownloadState::Running(_) => group.row( ActionRow::new(&item.label, RowState::Progress(item.progress)) - .description(description), + .description(description) + .motion(self.motion), ), DownloadState::Succeeded => group.row( - ActionRow::new(&item.label, RowState::Progress(1.0)).description(description), + ActionRow::new(&item.label, RowState::Progress(1.0)) + .description(description) + .motion(self.motion), ), DownloadState::Pending | DownloadState::Cancelled @@ -689,7 +695,7 @@ fn onboarding_title<'a>(title: &'a str, subtitle: &'a str) -> Element<'a, Messag ..iced::Font::DEFAULT }) .style(|theme: &Theme| text::Style { - color: Some(theme.palette().primary), + color: Some(crate::theme::colors(theme).accent), }), text(subtitle).size(16).medium().muted(), ] @@ -700,7 +706,7 @@ fn onboarding_title<'a>(title: &'a str, subtitle: &'a str) -> Element<'a, Messag fn error_icon<'a>() -> svg::Svg<'a> { Icon::Error.view().style(|theme: &Theme, _| svg::Style { - color: Some(theme.palette().danger), + color: Some(crate::theme::colors(theme).danger), }) } @@ -712,7 +718,7 @@ fn tutorial_view<'a>(index: usize) -> Element<'a, Message> { .width(160) .height(160) .style(|theme: &Theme, _| svg::Style { - color: Some(theme.palette().primary), + color: Some(crate::theme::colors(theme).accent), }); let text_block = column![ text(step.title) @@ -722,7 +728,7 @@ fn tutorial_view<'a>(index: usize) -> Element<'a, Message> { ..iced::Font::DEFAULT }) .style(|theme: &Theme| text::Style { - color: Some(theme.palette().primary), + color: Some(crate::theme::colors(theme).accent), }), text(body).body().muted() ] diff --git a/src/theme.rs b/src/theme.rs index 32c4cf5..66b0e9c 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,5 +1,6 @@ +//! Semantic colors for the toolkit. use iced::{ - Background, Border, Color, Theme, + Background, Border, Color, Theme as IcedTheme, theme::{ Mode, Palette, Style as ApplicationStyle, palette::{ @@ -45,7 +46,52 @@ const TEXT_LIGHT: Color = Color::from_rgb8(36, 28, 31); const ACCENT_LIGHT: Color = Color::from_rgb8(168, 76, 104); const ACCENT_MUTED_LIGHT: Color = Color::from_rgb8(196, 150, 164); -pub fn dark() -> Theme { +/// Semantic colors used by toolkit widgets. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Colors { + pub background: Color, + pub deep_background: Color, + pub surface: Color, + pub panel: Color, + pub border: Color, + pub hover: Color, + pub selection: Color, + pub window: Color, + pub window_border: Color, + pub surface_deep: Color, + pub text: Color, + pub muted: Color, + pub accent: Color, + pub accent_muted: Color, + pub focus: Color, + pub scrim: Color, + pub danger: Color, + pub danger_surface: Color, + pub warning_surface: Color, + pub success_surface: Color, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Motion { + #[default] + Full, + Reduced, +} + +impl Motion { + pub const fn is_reduced(self) -> bool { + matches!(self, Self::Reduced) + } +} + +impl From for Motion { + fn from(reduced: bool) -> Self { + if reduced { Self::Reduced } else { Self::Full } + } +} + +/// Builds the dark Iced appearance used by the toolkit. +pub fn dark() -> IcedTheme { custom( "Bottles Next", Palette { @@ -97,7 +143,8 @@ pub fn dark() -> Theme { ) } -pub fn light() -> Theme { +/// Builds the light Iced appearance used by the toolkit. +pub fn light() -> IcedTheme { custom( "Bottles Next Light", Palette { @@ -149,64 +196,92 @@ pub fn light() -> Theme { ) } +pub fn colors(theme: &IcedTheme) -> Colors { + let extended = theme.extended_palette(); + let text = theme.palette().text; + let scrim = if extended.is_dark { + extended.background.weakest.color + } else { + text + } + .scale_alpha(171.0 / 255.0); + + Colors { + background: extended.background.base.color, + deep_background: extended.background.weakest.color, + surface: extended.background.weak.color, + panel: extended.background.weaker.color, + border: extended.background.neutral.color, + hover: extended.background.strong.color, + selection: extended.background.stronger.color, + window: extended.primary.weak.color, + window_border: extended.background.strongest.color, + surface_deep: extended.secondary.strong.color, + text, + muted: extended.secondary.weak.text, + accent: theme.palette().primary, + accent_muted: extended.primary.strong.color, + focus: theme.palette().primary, + scrim, + danger: theme.palette().danger, + danger_surface: theme.palette().danger, + warning_surface: theme.palette().warning, + success_surface: theme.palette().success, + } +} + #[doc(hidden)] -pub fn for_mode(mode: Mode) -> Theme { +pub fn for_mode(mode: Mode) -> IcedTheme { match mode { Mode::Light => light(), Mode::Dark | Mode::None => dark(), } } -pub(crate) fn hint(theme: &Theme) -> Pair { - theme.extended_palette().primary.weak +pub(crate) fn hint(theme: &IcedTheme) -> Pair { + let colors = colors(theme); + pair(colors.window, colors.text) } -pub(crate) fn deep_surface(theme: &Theme) -> Pair { - Pair { - color: theme.extended_palette().secondary.strong.color, - text: theme.palette().text, - } +pub(crate) fn deep_surface(theme: &IcedTheme) -> Pair { + let colors = colors(theme); + pair(colors.surface_deep, colors.text) } -pub(crate) fn muted(theme: &Theme) -> Color { - theme.extended_palette().primary.base.color +pub(crate) fn muted(theme: &IcedTheme) -> Color { + colors(theme).muted } #[doc(hidden)] -pub fn window_color(theme: &Theme) -> Color { - theme.extended_palette().primary.weak.color +pub fn window_color(theme: &IcedTheme) -> Color { + colors(theme).window } -pub(crate) fn window(theme: &Theme) -> container::Style { +pub(crate) fn window(theme: &IcedTheme) -> container::Style { container::Style { background: Some(Background::Color(window_color(theme))), border: Border::default() .rounded(6) - .color(theme.extended_palette().background.strongest.color) + .color(colors(theme).window_border) .width(1), ..container::Style::default() } } -pub(crate) fn scrim(theme: &Theme) -> Color { - let color = if theme.extended_palette().is_dark { - theme.extended_palette().background.weakest.color - } else { - theme.palette().text - }; - - color.scale_alpha(171.0 / 255.0) +pub(crate) fn scrim(theme: &IcedTheme) -> Color { + colors(theme).scrim } -pub fn application(theme: &Theme) -> ApplicationStyle { +pub fn application(theme: &IcedTheme) -> ApplicationStyle { ApplicationStyle { background_color: Color::TRANSPARENT, - text_color: theme.palette().text, + text_color: colors(theme).text, } } -pub fn panel(theme: &Theme) -> container::Style { - surface(theme.extended_palette().background.weaker) +pub fn panel(theme: &IcedTheme) -> container::Style { + let colors = colors(theme); + surface(pair(colors.background, colors.text)) } pub(crate) fn surface(colors: Pair) -> container::Style { @@ -216,7 +291,7 @@ pub(crate) fn surface(colors: Pair) -> container::Style { .border(Border::default().rounded(6)) } -pub fn scrollbar(theme: &Theme, status: scrollable::Status) -> scrollable::Style { +pub fn scrollbar(theme: &IcedTheme, status: scrollable::Status) -> scrollable::Style { let mut style = scrollable::default(theme, status); let rail = scrollable::Rail { background: None, @@ -236,8 +311,8 @@ pub(crate) const fn info() -> Pair { pair(INFO, WHITE) } -fn custom(name: &'static str, palette: Palette, extended: Extended) -> Theme { - Theme::custom_with_fn(name, palette, move |_| extended) +fn custom(name: &'static str, palette: Palette, extended: Extended) -> IcedTheme { + IcedTheme::custom_with_fn(name, palette, move |_| extended) } const fn pair(color: Color, text: Color) -> Pair { diff --git a/src/widgets/button.rs b/src/widgets/button.rs index 63748f4..9fc1b0b 100644 --- a/src/widgets/button.rs +++ b/src/widgets/button.rs @@ -228,9 +228,9 @@ fn icon_element<'a, Message: 'a>( .rotation(rotation) .style(move |theme: &Theme, _| svg::Style { color: Some(if disabled { - theme.extended_palette().secondary.weak.text + crate::theme::colors(theme).muted } else if kind == ButtonKind::Primary { - theme.extended_palette().primary.base.color + crate::theme::colors(theme).muted } else { colors(theme, false, false, false, kind).text }), @@ -244,6 +244,7 @@ fn appearance( shape: Shape, kind: ButtonKind, ) -> iced::widget::button::Style { + let semantic = crate::theme::colors(theme); let colors = colors( theme, !state.sensitive, @@ -255,13 +256,9 @@ fn appearance( iced::widget::button::Style { background: if kind == ButtonKind::Transparent { if state.pressed { - Some(Background::Color( - theme.extended_palette().background.stronger.color, - )) + Some(Background::Color(semantic.selection)) } else if state.focused && !state.hovered { - Some(Background::Color( - theme.extended_palette().background.strong.color, - )) + Some(Background::Color(semantic.hover)) } else { None } @@ -278,19 +275,22 @@ fn appearance( } fn colors(theme: &Theme, disabled: bool, active: bool, pressed: bool, kind: ButtonKind) -> Pair { - let palette = theme.extended_palette(); + let colors = crate::theme::colors(theme); if disabled { return Pair { - color: palette.background.weaker.color, - text: palette.secondary.weak.text, + color: colors.background, + text: colors.muted, }; } match kind { ButtonKind::Primary => { if pressed { - palette.background.weakest + Pair { + color: colors.deep_background, + text: colors.text, + } } else if active { crate::theme::deep_surface(theme) } else { @@ -299,30 +299,39 @@ fn colors(theme: &Theme, disabled: bool, active: bool, pressed: bool, kind: Butt } ButtonKind::Secondary => { if pressed { - palette.secondary.weak + Pair { + color: colors.background, + text: colors.muted, + } } else if active { - palette.secondary.strong + Pair { + color: colors.surface_deep, + text: colors.muted, + } } else { - palette.secondary.base + Pair { + color: colors.deep_background, + text: colors.muted, + } } } ButtonKind::Surface => { let background = if pressed { - palette.background.stronger + colors.selection } else if active { - palette.background.strong + colors.hover } else { - palette.background.weak + colors.surface }; Pair { - color: background.color, - text: palette.secondary.base.text, + color: background, + text: colors.muted, } } ButtonKind::Transparent => Pair { - color: palette.background.base.color, - text: palette.secondary.weak.text, + color: colors.background, + text: colors.muted, }, } } diff --git a/src/widgets/card.rs b/src/widgets/card.rs index ea8a30b..7c3c023 100644 --- a/src/widgets/card.rs +++ b/src/widgets/card.rs @@ -5,7 +5,7 @@ use iced::{ use super::{ spacing, - surface::{Kind as SurfaceKind, Surface}, + surface::{Surface, SurfaceRole}, text::TextExt as _, }; @@ -47,7 +47,7 @@ impl<'a, Message> Card<'a, Message> { impl<'a, Message: 'a> From> for Element<'a, Message> { fn from(card: Card<'a, Message>) -> Self { Surface::new( - SurfaceKind::Card, + SurfaceRole::Card, container(card.content) .padding(card.padding) .width(card.width) @@ -83,8 +83,7 @@ pub(crate) fn image_content<'a, Message: 'a>( .width(Fill) .height(BANNER_HEIGHT) .style(|theme: &Theme| { - container::Style::default() - .background(theme.extended_palette().background.neutral.color) + container::Style::default().background(crate::theme::colors(theme).border) }) .into(), }; diff --git a/src/widgets/control.rs b/src/widgets/control.rs index 1470c09..25d069e 100644 --- a/src/widgets/control.rs +++ b/src/widgets/control.rs @@ -290,7 +290,7 @@ impl<'a, Message> Control<'a, Message> { height: Length::Shrink, padding: Padding::ZERO, style: Box::new(|theme, _| Style { - text_color: theme.palette().text, + text_color: crate::theme::colors(theme).text, ..Style::default() }), } diff --git a/src/widgets/drop_target.rs b/src/widgets/drop_target.rs index 8e5a4a9..7f2b8f7 100644 --- a/src/widgets/drop_target.rs +++ b/src/widgets/drop_target.rs @@ -108,7 +108,7 @@ impl canvas::Program for Outline { }, ..canvas::Stroke::default() .with_width(BORDER_WIDTH) - .with_color(theme.extended_palette().background.neutral.color) + .with_color(crate::theme::colors(theme).border) }, ); diff --git a/src/widgets/info_card.rs b/src/widgets/info_card.rs index f5ec6c4..47375ca 100644 --- a/src/widgets/info_card.rs +++ b/src/widgets/info_card.rs @@ -1,5 +1,5 @@ use iced::{ - Center, Element, Length, Theme, + Center, Color, Element, Length, Theme, theme::palette::Pair, widget::{column, container, row, svg, text, text::Fragment, text::IntoFragment}, }; @@ -95,13 +95,22 @@ fn icon<'a>(kind: Kind) -> svg::Svg<'a> { } fn colors(theme: &Theme, kind: Kind) -> Pair { - let palette = theme.extended_palette(); + let colors = theme::colors(theme); match kind { Kind::Hint => theme::hint(theme), Kind::Info => theme::info(), - Kind::Error => palette.danger.base, - Kind::Warning => palette.warning.base, - Kind::Success => palette.success.base, + Kind::Error => Pair { + color: colors.danger_surface, + text: Color::WHITE, + }, + Kind::Warning => Pair { + color: colors.warning_surface, + text: Color::WHITE, + }, + Kind::Success => Pair { + color: colors.success_surface, + text: Color::WHITE, + }, } } diff --git a/src/widgets/list_row.rs b/src/widgets/list_row.rs index 1401f0c..75112a2 100644 --- a/src/widgets/list_row.rs +++ b/src/widgets/list_row.rs @@ -177,20 +177,20 @@ impl<'a, Message: Clone + 'a> From> for Element<'a, Message } pub(crate) fn style(theme: &Theme, state: State) -> Style { - let palette = theme.extended_palette(); + let colors = crate::theme::colors(theme); let color = if state.pressed { - palette.background.stronger.color + colors.selection } else if state.selected || state.expanded || state.focus_within { - palette.background.neutral.color + colors.border } else if state.hovered { - palette.background.strong.color + colors.hover } else { - palette.background.weak.color + colors.surface }; Style { background: Some(Background::Color(color)), - text_color: theme.palette().text, + text_color: colors.text, border: Border::default().rounded(6), foreground: (!state.sensitive).then_some(Background::Color(crate::theme::scrim(theme))), ..Style::default() diff --git a/src/widgets/menu.rs b/src/widgets/menu.rs index 1622a63..82f0485 100644 --- a/src/widgets/menu.rs +++ b/src/widgets/menu.rs @@ -69,17 +69,16 @@ pub(super) fn footer<'a, Message: Clone + 'a>( } fn row_style(theme: &Theme, state: State) -> button::Style { + let colors = crate::theme::colors(theme); let highlighted = state.actionable && (state.keyboard_highlighted || state.hovered || state.pressed || state.focused); button::Style { - background: highlighted.then_some(Background::Color( - theme.extended_palette().background.stronger.color, - )), + background: highlighted.then_some(Background::Color(colors.selection)), text_color: if highlighted { - theme.palette().text + colors.text } else { - theme.extended_palette().secondary.weak.text + colors.muted }, border: Border::default().rounded(6), ..button::Style::default() diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 16df761..297d4ff 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -46,7 +46,7 @@ fn draw_caret(renderer: &mut iced::Renderer, theme: &Theme, slot: Rectangle, exp renderer.draw_svg( iced::advanced::svg::Svg { handle, - color: Some(theme.extended_palette().secondary.weak.text), + color: Some(crate::theme::colors(theme).muted), rotation: (std::f32::consts::PI * expansion).into(), opacity: 1.0, }, diff --git a/src/widgets/popover.rs b/src/widgets/popover.rs index b37d32e..ef1cc51 100644 --- a/src/widgets/popover.rs +++ b/src/widgets/popover.rs @@ -14,7 +14,7 @@ use super::{ button::{Button, ButtonKind}, menu::{item as menu_item, row_content}, spacing, - surface::{Kind as SurfaceKind, Surface}, + surface::{Surface, SurfaceRole}, }; const WIDTH: f32 = 240.0; @@ -48,7 +48,7 @@ impl<'a, Message: Clone + 'a> From> for Element<'a, Message Element::new(PopoverWidget { trigger: popover.trigger, - panel: Surface::new(SurfaceKind::Overlay, content).into(), + panel: Surface::new(SurfaceRole::Overlay, content).into(), }) } } @@ -130,7 +130,7 @@ fn item_row<'a, Message: Clone + 'a>(item: PopoverItem<'a, Message>) -> Element< if item.selected { content = content.push(Icon::Checkmark.view().width(16).height(16).style( |theme: &Theme, _| svg::Style { - color: Some(theme.palette().primary), + color: Some(crate::theme::colors(theme).accent), }, )); } diff --git a/src/widgets/progress_ring.rs b/src/widgets/progress_ring.rs index 712357a..acc7943 100644 --- a/src/widgets/progress_ring.rs +++ b/src/widgets/progress_ring.rs @@ -9,7 +9,10 @@ use iced::{ window, }; -use crate::icons::SIZE; +use crate::{ + icons::SIZE, + theme::{self, Motion}, +}; const STROKE_WIDTH: f32 = 2.5; const START_ANGLE: f32 = -PI / 2.0; @@ -17,6 +20,7 @@ const START_ANGLE: f32 = -PI / 2.0; /// Circular percentage indicator, e.g. for an in-flight download or account link. pub struct ProgressRing { progress: f32, + motion: Motion, } impl ProgressRing { @@ -24,14 +28,21 @@ impl ProgressRing { pub fn new(progress: f32) -> Self { Self { progress: progress.clamp(0.0, 1.0), + motion: Motion::default(), } } + + pub fn motion(mut self, motion: Motion) -> Self { + self.motion = motion; + self + } } impl<'a, Message: 'a> From for Element<'a, Message> { fn from(ring: ProgressRing) -> Self { canvas::Canvas::new(AnimatedRing { progress: ring.progress, + motion: ring.motion, }) .width(SIZE) .height(SIZE) @@ -41,6 +52,7 @@ impl<'a, Message: 'a> From for Element<'a, Message> { struct AnimatedRing { progress: f32, + motion: Motion, } #[derive(Debug, Default)] @@ -49,7 +61,12 @@ struct RingState { } impl RingState { - fn sync(&mut self, progress: f32, now: Instant) -> bool { + fn sync(&mut self, progress: f32, now: Instant, motion: Motion) -> bool { + if motion.is_reduced() { + self.animation = None; + return false; + } + let animation = self .animation .get_or_insert_with(|| Animation::new(progress).quick().easing(Easing::EaseOut)); @@ -73,7 +90,7 @@ impl canvas::Program for AnimatedRing { _cursor: mouse::Cursor, ) -> Option> { if let Event::Window(window::Event::RedrawRequested(now)) = event - && state.sync(self.progress, *now) + && state.sync(self.progress, *now, self.motion) { Some(Action::request_redraw()) } else { @@ -89,20 +106,24 @@ impl canvas::Program for AnimatedRing { bounds: Rectangle, _cursor: mouse::Cursor, ) -> Vec { - let progress = state.animation.as_ref().map_or(self.progress, |animation| { - animation.interpolate_with(|value| value, Instant::now()) - }); + let progress = if self.motion.is_reduced() { + self.progress + } else { + state.animation.as_ref().map_or(self.progress, |animation| { + animation.interpolate_with(|value| value, Instant::now()) + }) + }; let mut frame = canvas::Frame::new(renderer, bounds.size()); let center = Point::new(bounds.width / 2.0, bounds.height / 2.0); let radius = bounds.width.min(bounds.height) / 2.0 - STROKE_WIDTH / 2.0; - let palette = theme.extended_palette(); + let colors = theme::colors(theme); let track = canvas::Path::circle(center, radius); frame.stroke( &track, canvas::Stroke::default() .with_width(STROKE_WIDTH) - .with_color(palette.background.neutral.color), + .with_color(colors.border), ); if progress >= 1.0 { @@ -117,7 +138,7 @@ impl canvas::Program for AnimatedRing { &checkmark, canvas::Stroke::default() .with_width(STROKE_WIDTH) - .with_color(theme.palette().primary) + .with_color(colors.accent) .with_line_cap(canvas::LineCap::Round) .with_line_join(canvas::LineJoin::Round), ); @@ -135,7 +156,7 @@ impl canvas::Program for AnimatedRing { &arc, canvas::Stroke::default() .with_width(STROKE_WIDTH) - .with_color(theme.palette().primary) + .with_color(colors.accent) .with_line_cap(canvas::LineCap::Round), ); } @@ -143,3 +164,18 @@ impl canvas::Program for AnimatedRing { vec![frame.into_geometry()] } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reduced_motion_does_not_retain_a_ring_animation() { + let mut state = RingState { + animation: Some(Animation::new(0.0)), + }; + + assert!(!state.sync(0.5, Instant::now(), Motion::Reduced)); + assert!(state.animation.is_none()); + } +} diff --git a/src/widgets/row_group.rs b/src/widgets/row_group.rs index 1979afa..9481a2a 100644 --- a/src/widgets/row_group.rs +++ b/src/widgets/row_group.rs @@ -15,7 +15,7 @@ use super::{ expander_row::{ExpanderRow, header_row}, list_row::{Content as RowContent, ListRow}, spacing, - surface::{Kind as SurfaceKind, Surface}, + surface::{Surface, SurfaceRole}, text::TextExt as _, }; @@ -173,7 +173,7 @@ fn group_line<'a, Message: Clone + 'a>( requested_columns.min(columns) }; let body: Element<'a, Message> = Surface::new( - SurfaceKind::Panel, + SurfaceRole::Panel, container( content .into_iter() @@ -252,7 +252,7 @@ fn expander_header_style( fn disabled_subtree_style(theme: &Theme, state: ControlState) -> ControlStyle { ControlStyle { - text_color: theme.palette().text, + text_color: crate::theme::colors(theme).text, border: Border::default().rounded(RADIUS), foreground: (!state.sensitive).then_some(Background::Color(crate::theme::scrim(theme))), ..ControlStyle::default() @@ -515,9 +515,9 @@ impl Widget for GroupLine<'_, Message> ) { let state = tree.state.downcast_ref::(); let children: Vec<_> = layout.children().collect(); - let palette = theme.extended_palette(); - let color = palette.background.neutral.color; - let background = palette.background.base.color; + let colors = crate::theme::colors(theme); + let color = colors.panel; + let background = colors.background; for index in &state.open { let expansion = &self.expansions[*index]; diff --git a/src/widgets/search.rs b/src/widgets/search.rs index 07e4950..f7d9721 100644 --- a/src/widgets/search.rs +++ b/src/widgets/search.rs @@ -19,7 +19,7 @@ use super::{ control::descendant_is_focused, menu::{footer as panel_footer, item as menu_item, row_content}, reconcile_index, spacing, - surface::{Kind as SurfaceKind, scoped_overlay}, + surface::{SurfaceRole, scoped_overlay}, text::TextExt as _, }; @@ -226,9 +226,9 @@ fn result_row<'a, Message: Clone + 'a>( fn status_row<'a, Message: 'a>(label: &'a str, error: bool) -> Element<'a, Message> { container(text(label).label().style(move |theme: &Theme| text::Style { color: Some(if error { - theme.extended_palette().danger.base.color + crate::theme::colors(theme).danger } else { - theme.extended_palette().secondary.weak.text + crate::theme::colors(theme).muted }), })) .width(Fill) @@ -625,9 +625,9 @@ impl Widget for SearchPanel<'_, Message cursor: mouse::Cursor, viewport: &Rectangle, ) { - let theme = SurfaceKind::Overlay.draw_background(renderer, theme, layout.bounds()); + let theme = SurfaceRole::Overlay.draw_background(renderer, theme, layout.bounds()); let style = renderer::Style { - text_color: theme.palette().text, + text_color: crate::theme::colors(&theme).text, }; for ((child, tree), layout) in self @@ -658,7 +658,7 @@ impl Widget for SearchPanel<'_, Message viewport, translation, ) - .map(|content| scoped_overlay(SurfaceKind::Overlay, content)) + .map(|content| scoped_overlay(SurfaceRole::Overlay, content)) } } @@ -688,18 +688,22 @@ mod tests { } fn search_style(theme: &Theme) -> container::Style { - crate::theme::surface(theme.extended_palette().background.neutral) + let colors = crate::theme::colors(theme); + crate::theme::surface(iced::theme::palette::Pair { + color: colors.border, + text: colors.text, + }) } fn input_style(theme: &Theme, _: text_input::Status) -> text_input::Style { - let colors = theme.extended_palette(); + let colors = crate::theme::colors(theme); text_input::Style { background: Background::Color(Color::TRANSPARENT), border: Border::default(), - icon: colors.secondary.weak.text, - placeholder: colors.secondary.weak.text, - value: theme.palette().text, - selection: theme.palette().primary, + icon: colors.muted, + placeholder: colors.muted, + value: colors.text, + selection: colors.accent, } } diff --git a/src/widgets/selector_row.rs b/src/widgets/selector_row.rs index 8f624f0..cddf27d 100644 --- a/src/widgets/selector_row.rs +++ b/src/widgets/selector_row.rs @@ -13,7 +13,10 @@ use iced::{ window, }; -use crate::icons::Icon; +use crate::{ + icons::Icon, + theme::{self, Motion}, +}; use super::{ control::{Interaction, Outcome}, @@ -39,6 +42,7 @@ pub struct SelectorRow<'a, T, Message> { label: Box String + 'a>, key: Option String + 'a>>, icon: Option, + motion: Motion, } impl<'a, T: ToString, Message> SelectorRow<'a, T, Message> { @@ -52,6 +56,7 @@ impl<'a, T: ToString, Message> SelectorRow<'a, T, Message> { label: Box::new(ToString::to_string), key: None, icon: None, + motion: Motion::default(), } } @@ -84,6 +89,11 @@ impl<'a, T: ToString, Message> SelectorRow<'a, T, Message> { self.on_selected = on_selected.map(|on_selected| Box::new(on_selected) as _); self } + + pub fn motion(mut self, motion: Motion) -> Self { + self.motion = motion; + self + } } impl<'a, T, Message> From> for Element<'a, Message> @@ -101,6 +111,7 @@ where label, key, icon, + motion, } = selector; let selected = selected.and_then(|selected| options.iter().position(|option| option == selected)); @@ -127,6 +138,7 @@ where on_selected, selected, keys, + motion, }) } } @@ -162,6 +174,7 @@ struct Selector<'a, Message> { on_selected: Option Message + 'a>>, selected: Option, keys: Vec, + motion: Motion, } impl Selector<'_, Message> { @@ -200,9 +213,13 @@ impl State { self.expansion.value() } - fn set_open(&mut self, open: bool, now: Instant) { + fn set_open(&mut self, open: bool, now: Instant, motion: Motion) { if self.is_open() != open { - self.expansion.go_mut(open, now); + if motion.is_reduced() { + self.expansion = Animation::new(open); + } else { + self.expansion.go_mut(open, now); + } if !open { for option in &mut self.options { @@ -212,6 +229,12 @@ impl State { } } + fn sync_motion(&mut self, now: Instant, motion: Motion) { + if motion.is_reduced() && self.expansion.is_animating(now) { + self.expansion = Animation::new(self.is_open()); + } + } + fn expansion(&self, now: Instant) -> f32 { self.expansion.interpolate(0.0, 1.0, now) } @@ -255,9 +278,10 @@ impl Widget for Selector<'_, Message> { } state.keys.clone_from(&self.keys); + state.sync_motion(Instant::now(), self.motion); if !self.is_enabled() { - state.set_open(false, Instant::now()); + state.set_open(false, Instant::now(), self.motion); state.highlighted = None; state.header = Interaction::default(); } else { @@ -334,7 +358,8 @@ impl Widget for Selector<'_, Message> { shell: &mut Shell<'_, Message>, _viewport: &Rectangle, ) { - if let Event::Window(window::Event::RedrawRequested(now)) = event + if !self.motion.is_reduced() + && let Event::Window(window::Event::RedrawRequested(now)) = event && tree .state .downcast_ref::() @@ -393,18 +418,18 @@ impl Widget for Selector<'_, Message> { if pointer_captured { operation::Focusable::focus(&mut state.header); } else if state.is_open() { - state.set_open(false, Instant::now()); + state.set_open(false, Instant::now(), self.motion); } } if header_outcome == Outcome::Activated { - state.set_open(!state.is_open(), Instant::now()); + state.set_open(!state.is_open(), Instant::now(), self.motion); state.highlighted = self.selected.or(Some(0)); } else if let Some(index) = selected { if let Some(on_selected) = &self.on_selected { shell.publish(on_selected(index)); } - state.set_open(false, Instant::now()); + state.set_open(false, Instant::now(), self.motion); state.highlighted = Some(index); } } @@ -427,7 +452,7 @@ impl Widget for Selector<'_, Message> { } else { self.selected.unwrap_or(0) }); - state.set_open(true, Instant::now()); + state.set_open(true, Instant::now(), self.motion); shell.capture_event(); } } @@ -441,18 +466,18 @@ impl Widget for Selector<'_, Message> { } else { self.selected.unwrap_or(last) }); - state.set_open(true, Instant::now()); + state.set_open(true, Instant::now(), self.motion); shell.capture_event(); } } keyboard::Key::Named(key::Named::Home) if last.is_some() => { - state.set_open(true, Instant::now()); + state.set_open(true, Instant::now(), self.motion); state.highlighted = Some(0); shell.capture_event(); } keyboard::Key::Named(key::Named::End) => { if let Some(last) = last { - state.set_open(true, Instant::now()); + state.set_open(true, Instant::now(), self.motion); state.highlighted = Some(last); shell.capture_event(); } @@ -463,17 +488,17 @@ impl Widget for Selector<'_, Message> { if let Some(on_selected) = &self.on_selected { shell.publish(on_selected(index)); } - state.set_open(false, Instant::now()); + state.set_open(false, Instant::now(), self.motion); } } else { - state.set_open(true, Instant::now()); + state.set_open(true, Instant::now(), self.motion); state.highlighted = self.selected.or(Some(0)); } shell.capture_event(); } keyboard::Key::Named(key::Named::Escape) if state.is_open() => { - state.set_open(false, Instant::now()); + state.set_open(false, Instant::now(), self.motion); shell.capture_event(); } _ => {} @@ -531,6 +556,7 @@ impl Widget for Selector<'_, Message> { viewport: &Rectangle, ) { let state = tree.state.downcast_ref::(); + let colors = theme::colors(theme); let bounds = layout.bounds(); let expansion = state.expansion(Instant::now()); let mut control_state = state.header.state(self.is_enabled(), true, bounds, cursor); @@ -601,7 +627,7 @@ impl Widget for Selector<'_, Message> { shadow: Shadow::default(), snap: true, }, - theme.extended_palette().background.stronger.color, + colors.selection, ); for (index, option_layout) in options.into_iter().enumerate() { @@ -620,9 +646,7 @@ impl Widget for Selector<'_, Message> { shadow: Shadow::default(), snap: true, }, - Background::Color( - theme.extended_palette().background.stronger.color, - ), + Background::Color(colors.selection), ); } @@ -632,9 +656,9 @@ impl Widget for Selector<'_, Message> { theme, &renderer::Style { text_color: if is_highlighted { - theme.palette().text + colors.text } else { - theme.extended_palette().secondary.base.text + colors.muted }, }, option_layout, @@ -677,3 +701,34 @@ fn layout_parts<'a>(layout: Layout<'a>) -> (Layout<'a>, Vec>) { (header, options) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reduced_motion_snaps_selector_expansion() { + let now = Instant::now(); + let mut state = State::default(); + + state.set_open(true, now, Motion::Reduced); + + assert!(state.is_open()); + assert_eq!(state.expansion(now), 1.0); + assert!(!state.expansion.is_animating(now)); + } + + #[test] + fn changing_to_reduced_motion_finishes_an_active_expansion() { + let now = Instant::now(); + let mut state = State::default(); + state.set_open(true, now, Motion::Full); + assert!(state.expansion.is_animating(now)); + + state.sync_motion(now, Motion::Reduced); + + assert!(state.is_open()); + assert_eq!(state.expansion(now), 1.0); + assert!(!state.expansion.is_animating(now)); + } +} diff --git a/src/widgets/status_bar.rs b/src/widgets/status_bar.rs index 253bf97..3231e6c 100644 --- a/src/widgets/status_bar.rs +++ b/src/widgets/status_bar.rs @@ -110,9 +110,9 @@ impl<'a, Message: 'static> From> for Element<'a, Message> { .size(TEXT_SIZE) .style(move |theme: &Theme| text::Style { color: Some(if status.state == BottleStatus::Failed { - theme.palette().danger + theme::colors(theme).danger } else { - theme.extended_palette().secondary.weak.text + theme::colors(theme).muted }), }), ] @@ -150,7 +150,7 @@ impl<'a, Message: 'static> From> for Element<'a, Message> { .width(Fill) .clip(true) .style(|theme: &Theme| { - container::background(theme.extended_palette().background.weaker.color) + container::background(theme::colors(theme).background) .border(iced::Border::default().rounded(iced::border::bottom(6))) }) .into() @@ -162,7 +162,7 @@ fn status_icon<'a>(icon: Icon, danger: bool) -> svg::Svg<'a> { if danger { icon.style(|theme: &Theme, _| svg::Style { - color: Some(theme.palette().danger), + color: Some(theme::colors(theme).danger), }) } else { icon @@ -614,7 +614,7 @@ impl Widget for StatusWidget<' snap: true, ..renderer::Quad::default() }, - Background::Color(theme.extended_palette().background.neutral.color), + Background::Color(theme::colors(theme).border), ); } diff --git a/src/widgets/style.rs b/src/widgets/style.rs index 8dbbd53..45b1186 100644 --- a/src/widgets/style.rs +++ b/src/widgets/style.rs @@ -6,17 +6,18 @@ use iced::{ use super::control::State; pub(crate) fn action(theme: &Theme, state: State) -> button::Style { + let colors = crate::theme::colors(theme); let background = if state.pressed { - Some(theme.extended_palette().background.stronger.color) + Some(colors.selection) } else if state.hovered || state.focused { - Some(theme.extended_palette().background.strong.color) + Some(colors.hover) } else { None }; button::Style { background: background.map(Background::Color), - text_color: theme.palette().text, + text_color: colors.text, border: Border::default().rounded(6), ..button::Style::default() } @@ -24,6 +25,6 @@ pub(crate) fn action(theme: &Theme, state: State) -> button::Style { pub(crate) fn muted_text(theme: &Theme) -> text::Style { text::Style { - color: Some(theme.extended_palette().secondary.weak.text), + color: Some(crate::theme::colors(theme).muted), } } diff --git a/src/widgets/surface.rs b/src/widgets/surface.rs index 6e6a001..5a83d7d 100644 --- a/src/widgets/surface.rs +++ b/src/widgets/surface.rs @@ -10,19 +10,22 @@ use iced::{ }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum Kind { +pub(super) enum SurfaceRole { Panel, Card, Overlay, } -impl Kind { +impl SurfaceRole { fn colors(self, theme: &Theme) -> Pair { - let background = theme.extended_palette().background; + let colors = theme::colors(theme); - match self { - Self::Panel | Self::Overlay => background.neutral, - Self::Card => background.weak, + Pair { + color: match self { + Self::Panel | Self::Overlay => colors.border, + Self::Card => colors.surface, + }, + text: colors.text, } } @@ -42,16 +45,20 @@ impl Kind { pub(super) fn scoped_theme(self, theme: &Theme) -> Theme { let colors = self.colors(theme); + let semantic = theme::colors(theme); let mut palette = theme.palette(); let mut extended = *theme.extended_palette(); palette.background = colors.color; palette.text = colors.text; extended.background.base = colors; - extended.background.strong = match self { - Self::Panel => extended.background.strong, - Self::Card => extended.background.neutral, - Self::Overlay => extended.background.stronger, + extended.background.strong = Pair { + color: match self { + Self::Panel => semantic.hover, + Self::Card => semantic.border, + Self::Overlay => semantic.selection, + }, + text: semantic.text, }; Theme::custom_with_fn(self.name(), palette, |_| extended) @@ -67,14 +74,14 @@ impl Kind { } pub(super) struct Surface<'a, Message> { - kind: Kind, + role: SurfaceRole, content: Element<'a, Message>, } impl<'a, Message> Surface<'a, Message> { - pub(super) fn new(kind: Kind, content: impl Into>) -> Self { + pub(super) fn new(role: SurfaceRole, content: impl Into>) -> Self { Self { - kind, + role, content: content.into(), } } @@ -165,14 +172,14 @@ impl Widget for Surface<'_, Message> { return; } - let scoped_theme = self.kind.draw_background(renderer, theme, layout.bounds()); + let scoped_theme = self.role.draw_background(renderer, theme, layout.bounds()); self.content.as_widget().draw( tree, renderer, &scoped_theme, &renderer::Style { - text_color: scoped_theme.palette().text, + text_color: theme::colors(&scoped_theme).text, }, layout, cursor, @@ -191,7 +198,7 @@ impl Widget for Surface<'_, Message> { self.content .as_widget_mut() .overlay(tree, layout, renderer, viewport, translation) - .map(|content| scoped_overlay(self.kind, content)) + .map(|content| scoped_overlay(self.role, content)) } } @@ -202,15 +209,15 @@ impl<'a, Message: 'a> From> for Element<'a, Message> { } struct ScopedOverlay<'a, Message> { - kind: Kind, + role: SurfaceRole, content: overlay::Element<'a, Message, Theme, iced::Renderer>, } pub(super) fn scoped_overlay<'a, Message: 'a>( - kind: Kind, + role: SurfaceRole, content: overlay::Element<'a, Message, Theme, iced::Renderer>, ) -> overlay::Element<'a, Message, Theme, iced::Renderer> { - overlay::Element::new(Box::new(ScopedOverlay { kind, content })) + overlay::Element::new(Box::new(ScopedOverlay { role, content })) } impl overlay::Overlay for ScopedOverlay<'_, Message> { @@ -226,13 +233,13 @@ impl overlay::Overlay for ScopedOverlay layout: Layout<'_>, cursor: mouse::Cursor, ) { - let scoped_theme = self.kind.scoped_theme(theme); + let scoped_theme = self.role.scoped_theme(theme); self.content.as_overlay().draw( renderer, &scoped_theme, &renderer::Style { - text_color: scoped_theme.palette().text, + text_color: theme::colors(&scoped_theme).text, }, layout, cursor, @@ -283,6 +290,6 @@ impl overlay::Overlay for ScopedOverlay self.content .as_overlay_mut() .overlay(layout, renderer) - .map(|content| scoped_overlay(self.kind, content)) + .map(|content| scoped_overlay(self.role, content)) } } diff --git a/src/widgets/switcher.rs b/src/widgets/switcher.rs index 76b3b3b..2adb759 100644 --- a/src/widgets/switcher.rs +++ b/src/widgets/switcher.rs @@ -9,6 +9,7 @@ use iced::{ }; use super::control::{Control, State}; +use crate::theme::{self, Motion}; const WIDTH: f32 = 52.0; const HEIGHT: f32 = 32.0; @@ -17,6 +18,7 @@ const KNOB: f32 = 24.0; pub struct Switcher<'a, Message> { is_on: bool, on_toggle: Option Message + 'a>>, + motion: Motion, } impl<'a, Message> Switcher<'a, Message> { @@ -24,6 +26,7 @@ impl<'a, Message> Switcher<'a, Message> { Self { is_on, on_toggle: None, + motion: Motion::default(), } } @@ -36,6 +39,11 @@ impl<'a, Message> Switcher<'a, Message> { self.on_toggle = on_toggle.map(|on_toggle| Box::new(on_toggle) as _); self } + + pub fn motion(mut self, motion: Motion) -> Self { + self.motion = motion; + self + } } impl<'a, Message: Clone + 'a> From> for Element<'a, Message> { @@ -44,6 +52,7 @@ impl<'a, Message: Clone + 'a> From> for Element<'a, Messag let knob = canvas::Canvas::new(AnimatedKnob { is_on: switcher.is_on, enabled: active, + motion: switcher.motion, }) .width(Fill) .height(Fill); @@ -65,6 +74,7 @@ impl<'a, Message: Clone + 'a> From> for Element<'a, Messag struct AnimatedKnob { is_on: bool, enabled: bool, + motion: Motion, } #[derive(Debug, Default)] @@ -73,7 +83,12 @@ struct KnobState { } impl KnobState { - fn sync(&mut self, is_on: bool, now: Instant) -> bool { + fn sync(&mut self, is_on: bool, now: Instant, motion: Motion) -> bool { + if motion.is_reduced() { + self.animation = None; + return false; + } + let animation = self .animation .get_or_insert_with(|| Animation::new(is_on).very_quick().easing(Easing::EaseOut)); @@ -97,7 +112,7 @@ impl canvas::Program for AnimatedKnob { _cursor: mouse::Cursor, ) -> Option> { if let Event::Window(window::Event::RedrawRequested(now)) = event - && state.sync(self.is_on, *now) + && state.sync(self.is_on, *now, self.motion) { Some(Action::request_redraw()) } else { @@ -113,19 +128,20 @@ impl canvas::Program for AnimatedKnob { bounds: Rectangle, _cursor: mouse::Cursor, ) -> Vec { - let progress = state.animation.as_ref().map_or_else( - || if self.is_on { 1.0 } else { 0.0 }, - |animation| animation.interpolate(0.0, 1.0, Instant::now()), - ); + let target = if self.is_on { 1.0 } else { 0.0 }; + let progress = if self.motion.is_reduced() { + target + } else { + state.animation.as_ref().map_or(target, |animation| { + animation.interpolate(0.0, 1.0, Instant::now()) + }) + }; let diameter = bounds.height; + let colors = theme::colors(theme); let color = if self.enabled { - palette::mix( - theme.extended_palette().background.stronger.color, - theme.palette().primary, - progress, - ) + palette::mix(colors.selection, colors.accent, progress) } else { - theme.extended_palette().secondary.weak.text + colors.muted }; let mut frame = canvas::Frame::new(renderer, bounds.size()); @@ -146,10 +162,23 @@ impl canvas::Program for AnimatedKnob { fn track_style(theme: &Theme, _state: State) -> button::Style { button::Style { - background: Some(Background::Color( - theme.extended_palette().background.weaker.color, - )), + background: Some(Background::Color(theme::colors(theme).background)), border: Border::default().rounded(HEIGHT / 2.0), ..button::Style::default() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reduced_motion_does_not_retain_a_knob_animation() { + let mut state = KnobState { + animation: Some(Animation::new(false)), + }; + + assert!(!state.sync(true, Instant::now(), Motion::Reduced)); + assert!(state.animation.is_none()); + } +} diff --git a/src/widgets/tabs.rs b/src/widgets/tabs.rs index adcccfc..7d833d5 100644 --- a/src/widgets/tabs.rs +++ b/src/widgets/tabs.rs @@ -15,6 +15,7 @@ use super::{ spacing, text::TextExt as _, }; +use crate::theme::{self, Motion}; pub struct Tab<'a, T> { value: T, @@ -41,6 +42,7 @@ pub struct Tabs<'a, T, Message> { tabs: Vec>, selected: Option, on_select: Box Message + 'a>, + motion: Motion, } impl<'a, T, Message> Tabs<'a, T, Message> { @@ -53,8 +55,14 @@ impl<'a, T, Message> Tabs<'a, T, Message> { tabs: tabs.into_iter().collect(), selected, on_select: Box::new(on_select), + motion: Motion::default(), } } + + pub fn motion(mut self, motion: Motion) -> Self { + self.motion = motion; + self + } } impl<'a, T, Message> From> for Element<'a, Message> @@ -67,6 +75,7 @@ where tabs, selected, on_select, + motion, } = tabs; let labels = tabs.iter().map(|tab| tab.label).collect(); let selected_index = selected @@ -93,6 +102,7 @@ where canvas::Canvas::new(TabIndicator { selected_index, labels, + motion, }) .width(Fill) .height(Fill), @@ -107,6 +117,7 @@ const INDICATOR_HEIGHT: f32 = 3.0; struct TabIndicator<'a> { selected_index: Option, labels: Vec<&'a str>, + motion: Motion, } #[derive(Debug, Default)] @@ -116,7 +127,19 @@ struct IndicatorState { } impl IndicatorState { - fn sync(&mut self, selected_index: Option, labels: &[&str], now: Instant) -> bool { + fn sync( + &mut self, + selected_index: Option, + labels: &[&str], + now: Instant, + motion: Motion, + ) -> bool { + if motion.is_reduced() { + self.labels = labels.iter().map(|label| (*label).to_owned()).collect(); + self.animation = None; + return false; + } + if !self .labels .iter() @@ -165,7 +188,7 @@ impl canvas::Program for TabIndicator<'_> { _ => Instant::now(), }; - if state.sync(self.selected_index, &self.labels, now) { + if state.sync(self.selected_index, &self.labels, now, self.motion) { Some(Action::request_redraw()) } else { None @@ -188,7 +211,12 @@ impl canvas::Program for TabIndicator<'_> { } let now = Instant::now(); - let center = state.animation.as_ref().map_or_else( + let animation = if self.motion.is_reduced() { + None + } else { + state.animation.as_ref() + }; + let center = animation.map_or_else( || tab_center(renderer, &self.labels, selected_index), |animation| { animation.interpolate_with( @@ -197,7 +225,7 @@ impl canvas::Program for TabIndicator<'_> { ) }, ); - let width = state.animation.as_ref().map_or_else( + let width = animation.map_or_else( || tab_width(renderer, self.labels[selected_index]), |animation| { animation.interpolate_with( @@ -211,7 +239,7 @@ impl canvas::Program for TabIndicator<'_> { frame.fill_rectangle( Point::new(center - width / 2.0, bounds.height - INDICATOR_HEIGHT), Size::new(width, INDICATOR_HEIGHT), - theme.palette().primary, + theme::colors(theme).accent, ); vec![frame.into_geometry()] @@ -255,16 +283,31 @@ fn label_width(renderer: &Renderer, label: &str) -> f32 { } fn tab_style(theme: &Theme, state: State) -> button::Style { + let colors = theme::colors(theme); + button::Style { - background: (state.focused && !state.hovered && !state.pressed).then_some( - Background::Color(theme.extended_palette().background.strong.color), - ), + background: (state.focused && !state.hovered && !state.pressed) + .then_some(Background::Color(colors.hover)), text_color: if state.selected || state.hovered || state.pressed || state.focused { - theme.palette().text + colors.text } else { - theme.extended_palette().secondary.weak.text + colors.muted }, border: Border::default().rounded(6), ..button::Style::default() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reduced_motion_snaps_the_indicator() { + let mut state = IndicatorState::default(); + let now = Instant::now(); + + assert!(!state.sync(Some(1), &["One", "Two"], now, Motion::Reduced)); + assert!(state.animation.is_none()); + } +} diff --git a/src/widgets/text_row.rs b/src/widgets/text_row.rs index 2703b20..032a0e6 100644 --- a/src/widgets/text_row.rs +++ b/src/widgets/text_row.rs @@ -114,7 +114,7 @@ impl<'a, Message: Clone + 'a> From> for ListRow<'a, Message text(error) .size(list_row::BODY_SIZE) .style(|theme: &Theme| text::Style { - color: Some(theme.palette().danger), + color: Some(crate::theme::colors(theme).danger), }), ); } @@ -132,19 +132,19 @@ impl<'a, Message: Clone + 'a> From> for ListRow<'a, Message } fn input_style(theme: &Theme, error: bool) -> text_input::Style { - let muted = theme.extended_palette().secondary.base.text; + let colors = crate::theme::colors(theme); text_input::Style { background: Background::Color(Color::TRANSPARENT), border: Border::default().color(if error { - theme.palette().danger + colors.danger } else { Color::TRANSPARENT }), - icon: muted, - placeholder: muted, - value: muted, - selection: theme.palette().primary, + icon: colors.muted, + placeholder: colors.muted, + value: colors.muted, + selection: colors.accent, } } @@ -154,9 +154,9 @@ fn icon_view<'a, Message: 'a>(icon: Icon, size: f32, error: bool) -> Element<'a, .height(size) .style(move |theme: &Theme, _| svg::Style { color: Some(if error { - theme.palette().danger + crate::theme::colors(theme).danger } else { - theme.extended_palette().secondary.base.text + crate::theme::colors(theme).muted }), }) .into() diff --git a/src/widgets/title.rs b/src/widgets/title.rs index 39cf09b..83a47a8 100644 --- a/src/widgets/title.rs +++ b/src/widgets/title.rs @@ -48,9 +48,9 @@ impl<'a, Message: 'a> From> for Element<'a, Message> { content = content.push(text(label).size(detail_size).style(move |theme: &Theme| { text::Style { color: Some(if is_status { - theme.extended_palette().primary.strong.color + crate::theme::colors(theme).accent_muted } else { - theme.extended_palette().secondary.base.text + crate::theme::colors(theme).muted }), } })); From a7386455a432dc09eca0aa6e122a7c47d65a41b0 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Tue, 1 Sep 2026 18:44:52 +0530 Subject: [PATCH 03/40] refactor(widgets): centralize action and row mechanics --- examples/gallery.rs | 10 +- src/classic/mod.rs | 44 ++++- src/onboarding.rs | 2 +- src/widgets/button.rs | 14 +- src/widgets/control.rs | 359 ++++++++++++++++++++++++------------ src/widgets/cycle_row.rs | 6 +- src/widgets/dialog.rs | 8 +- src/widgets/drop_target.rs | 4 +- src/widgets/list_row.rs | 12 +- src/widgets/menu.rs | 8 +- src/widgets/mod.rs | 4 +- src/widgets/row_group.rs | 16 +- src/widgets/search.rs | 8 +- src/widgets/selector_row.rs | 7 +- src/widgets/style.rs | 4 +- src/widgets/switcher.rs | 57 ++---- src/widgets/tabs.rs | 126 +++++-------- 17 files changed, 395 insertions(+), 294 deletions(-) diff --git a/examples/gallery.rs b/examples/gallery.rs index 0af03bd..db4f40f 100644 --- a/examples/gallery.rs +++ b/examples/gallery.rs @@ -19,7 +19,11 @@ use next_ui::{ const SELECTOR_OPTIONS: &[&str] = &["Option 1", "Option 2", "Option 3"]; const EMPTY_OPTIONS: &[&str] = &[]; -const TAB_LABELS: &[&str] = &["Bottles", "Library", "Settings"]; +const GALLERY_TABS: &[(&str, &str)] = &[ + ("bottles", "Bottles"), + ("library", "Library"), + ("settings", "Settings"), +]; const DLSS_LEVELS: &[&str] = &["Off", "Quality", "Balanced", "Performance"]; const SEARCH_CATALOG: &[(&str, &str, Icon)] = &[ ("Epic Games Store", "Install", Icon::Arrow), @@ -221,10 +225,10 @@ impl Gallery { .spacing(18); let tabs = tabs::Tabs::new( - TAB_LABELS + GALLERY_TABS .iter() .enumerate() - .map(|(index, label)| tabs::Tab::new(index, label)), + .map(|(index, (id, label))| tabs::Tab::new(tabs::TabId::new(*id), index, label)), Some(self.selected_tab), Message::TabSelected, ) diff --git a/src/classic/mod.rs b/src/classic/mod.rs index 50285ef..5060640 100644 --- a/src/classic/mod.rs +++ b/src/classic/mod.rs @@ -24,7 +24,7 @@ use crate::{ button::{Button, ButtonKind}, dialog::Dialog, row_group::RowGroup, - tabs::{Tab, Tabs}, + tabs::{Tab, TabId, Tabs}, text_row::TextRow, title::Title, }, @@ -546,8 +546,16 @@ impl State { fn primary_page(&self, context: PaneContext) -> Element<'_, Message> { let tabs = Tabs::new( [ - Tab::new(PrimaryTab::Bottles, "Bottles"), - Tab::new(PrimaryTab::Library, "Library"), + Tab::new( + TabId::new("primary.bottles"), + PrimaryTab::Bottles, + "Bottles", + ), + Tab::new( + TabId::new("primary.library"), + PrimaryTab::Library, + "Library", + ), ], Some(self.primary_tab()), Message::PrimaryTabSelected, @@ -704,14 +712,34 @@ impl State { fn detail_page(&self, context: PaneContext) -> Element<'_, Message> { #[cfg(feature = "fvs")] let detail_tabs = [ - Tab::new(DetailTab::Programs, "Programs"), - Tab::new(DetailTab::Settings, "Settings"), - Tab::new(DetailTab::Snapshots, "Snapshots"), + Tab::new( + TabId::new("detail.programs"), + DetailTab::Programs, + "Programs", + ), + Tab::new( + TabId::new("detail.settings"), + DetailTab::Settings, + "Settings", + ), + Tab::new( + TabId::new("detail.snapshots"), + DetailTab::Snapshots, + "Snapshots", + ), ]; #[cfg(not(feature = "fvs"))] let detail_tabs = [ - Tab::new(DetailTab::Programs, "Programs"), - Tab::new(DetailTab::Settings, "Settings"), + Tab::new( + TabId::new("detail.programs"), + DetailTab::Programs, + "Programs", + ), + Tab::new( + TabId::new("detail.settings"), + DetailTab::Settings, + "Settings", + ), ]; let tabs = Tabs::new( detail_tabs, diff --git a/src/onboarding.rs b/src/onboarding.rs index 42be6d2..df9b03c 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -746,7 +746,7 @@ fn tutorial_view<'a>(index: usize) -> Element<'a, Message> { } fn onboarding_button<'a>(label: &'a str) -> Button<'a, Message> { - Button::new(text(label).label()).kind(ButtonKind::Primary) + Button::custom(text(label).label()).kind(ButtonKind::Primary) } fn onboarding_button_with_icon<'a>(label: &'a str) -> Button<'a, Message> { diff --git a/src/widgets/button.rs b/src/widgets/button.rs index 9fc1b0b..15be2e0 100644 --- a/src/widgets/button.rs +++ b/src/widgets/button.rs @@ -7,7 +7,7 @@ use iced::{ use crate::icons::{self, Icon}; use super::{ - control::{Control, State}, + control::{ActionState, ActionSurface}, spacing, text::TextExt as _, }; @@ -44,7 +44,11 @@ pub struct Button<'a, Message> { } impl<'a, Message> Button<'a, Message> { - pub fn new(content: impl Into>) -> Self { + pub fn new(label: impl text::IntoFragment<'a>) -> Self { + Self::custom(text(label)) + } + + pub fn custom(content: impl Into>) -> Self { Self { content: content.into(), tooltip: None, @@ -65,7 +69,7 @@ impl<'a, Message> Button<'a, Message> { icon: Some(icon), shape: Shape::IconOnly, tooltip: Some(tooltip), - ..Self::new(text("")) + ..Self::custom(text("")) } } @@ -176,7 +180,7 @@ impl<'a, Message: Clone + 'a> From> for Element<'a, Message> let shape = button.shape; let kind = button.kind; - let mut control = Control::new(content) + let mut control = ActionSurface::new(content) .sensitive(!disabled) .on_press_maybe(button.on_press.filter(|_| !button.loading)) .style(move |theme, status| appearance(theme, status, shape, kind)); @@ -240,7 +244,7 @@ fn icon_element<'a, Message: 'a>( fn appearance( theme: &Theme, - state: State, + state: ActionState, shape: Shape, kind: ButtonKind, ) -> iced::widget::button::Style { diff --git a/src/widgets/control.rs b/src/widgets/control.rs index 25d069e..1028ae4 100644 --- a/src/widgets/control.rs +++ b/src/widgets/control.rs @@ -11,13 +11,13 @@ use iced::{ }; use std::{cell::Cell, rc::Rc}; -/// A one-shot activation event from a [`Control`] to its composite owner. +/// A one-shot activation event from an [`ActionSurface`] to its composite owner. /// It does not own persistent widget state. pub(crate) type ActivationSignal = Rc>; /// The independent states used to resolve a control's appearance. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct State { +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct ActionState { pub(crate) sensitive: bool, pub(crate) actionable: bool, pub(crate) hovered: bool, @@ -29,9 +29,9 @@ pub(crate) struct State { pub(crate) keyboard_highlighted: bool, } -/// The complete appearance of a [`Control`]. +/// The complete appearance of an [`ActionSurface`]. #[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct Style { +pub(crate) struct ActionStyle { pub(crate) background: Option, pub(crate) text_color: Color, pub(crate) border: iced::Border, @@ -41,7 +41,7 @@ pub(crate) struct Style { pub(crate) foreground: Option, } -impl Default for Style { +impl Default for ActionStyle { fn default() -> Self { Self { background: None, @@ -54,7 +54,7 @@ impl Default for Style { } } -impl From for Style { +impl From for ActionStyle { fn from(style: button::Style) -> Self { Self { background: style.background, @@ -75,16 +75,35 @@ pub(crate) enum Outcome { Activated, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PressOrigin { + Mouse(mouse::Button), + Touch(touch::Finger), + Key(key::Named), +} + /// Transient input and focus state shared by controls and composite widgets. #[derive(Debug, Default)] pub(crate) struct Interaction { - pressed: bool, + press: Option, focused: bool, hovered: bool, descendant_focused: bool, } impl Interaction { + fn sync_enabled(&mut self, sensitive: bool, actionable: bool) { + if !sensitive { + self.press = None; + self.focused = false; + self.hovered = false; + self.descendant_focused = false; + } else if !actionable { + self.press = None; + self.focused = false; + } + } + pub(crate) fn update( &mut self, event: &Event, @@ -92,16 +111,16 @@ impl Interaction { cursor: mouse::Cursor, sensitive: bool, actionable: bool, - child_captured: bool, shell: &mut Shell<'_, Message>, ) -> Outcome { - let previous = (self.pressed, self.focused, self.hovered); + let child_captured = shell.is_event_captured(); + let previous = (self.press, self.focused, self.hovered); let pointer = event_cursor(event, cursor); let hovered = sensitive && pointer.is_over(bounds); let mut outcome = Outcome::Ignored; - if !sensitive { - self.pressed = false; + if !sensitive || matches!(event, Event::Window(window::Event::Unfocused)) { + self.press = None; self.focused = false; self.hovered = false; self.descendant_focused = false; @@ -113,71 +132,57 @@ impl Interaction { } if !actionable { - self.pressed = false; + self.press = None; self.focused = false; } else if child_captured { + if self + .press + .is_some_and(|press| event_origin(event) == Some(press)) + { + self.press = None; + } + if matches!( - event, - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerPressed { .. }) + press_started(event), + Some(PressOrigin::Mouse(_) | PressOrigin::Touch(_)) ) { self.focused = false; - self.pressed = false; - } else if matches!( - event, - Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) - | Event::Touch( - touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. } - ) - ) { - self.pressed = false; } - } else { - outcome = match event { - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerPressed { .. }) => { - self.focused = false; - - if hovered { - self.pressed = true; - Outcome::Captured - } else { - Outcome::Ignored - } - } - Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerLifted { .. }) - if self.pressed => - { - self.pressed = false; - - if hovered { - Outcome::Activated - } else { - Outcome::Captured - } - } - Event::Touch(touch::Event::FingerLost { .. }) => { - self.pressed = false; - Outcome::Ignored - } - Event::Keyboard(keyboard::Event::KeyPressed { - key: keyboard::Key::Named(key::Named::Enter | key::Named::Space), - repeat: false, - .. - }) if self.focused => { - self.pressed = true; - Outcome::Captured - } - Event::Keyboard(keyboard::Event::KeyReleased { - key: keyboard::Key::Named(key::Named::Enter | key::Named::Space), - .. - }) if self.focused && self.pressed => { - self.pressed = false; - Outcome::Activated + } else if let Some(press) = press_started(event) { + if matches!(press, PressOrigin::Mouse(_) | PressOrigin::Touch(_)) { + self.focused = false; + + if matches!(self.press, Some(PressOrigin::Key(_))) { + self.press = None; } - _ => Outcome::Ignored, + } + + let can_start = match press { + PressOrigin::Key(_) => self.focused, + PressOrigin::Mouse(_) | PressOrigin::Touch(_) => hovered, }; + + if can_start && self.press.is_none() { + self.press = Some(press); + outcome = Outcome::Captured; + } + } else if let Some(release) = press_released(event) { + if self.press == Some(release) { + self.press = None; + let released_over_action = match release { + PressOrigin::Mouse(_) | PressOrigin::Touch(_) => hovered, + PressOrigin::Key(_) => self.focused, + }; + outcome = if released_over_action { + Outcome::Activated + } else { + Outcome::Captured + }; + } + } else if let Event::Touch(touch::Event::FingerLost { id, .. }) = event + && self.press == Some(PressOrigin::Touch(*id)) + { + self.press = None; } if matches!( @@ -193,7 +198,7 @@ impl Interaction { } if !matches!(event, Event::Window(window::Event::RedrawRequested(_))) - && previous != (self.pressed, self.focused, self.hovered) + && previous != (self.press, self.focused, self.hovered) { shell.request_redraw(); } @@ -207,7 +212,7 @@ impl Interaction { actionable: bool, bounds: Rectangle, cursor: mouse::Cursor, - ) -> State { + ) -> ActionState { let focused = sensitive && actionable && self.focused; let hovered = sensitive && if cursor == mouse::Cursor::Unavailable { @@ -216,11 +221,11 @@ impl Interaction { cursor.is_over(bounds) }; - State { + ActionState { sensitive, actionable, hovered, - pressed: sensitive && actionable && self.pressed, + pressed: sensitive && actionable && self.press.is_some(), focused, focus_within: sensitive && (focused || self.descendant_focused), selected: false, @@ -259,12 +264,14 @@ impl operation::Focusable for Interaction { fn unfocus(&mut self) { self.focused = false; - self.pressed = false; + self.press = None; } } /// A single-child control with shared input, focus, and styling behavior. -pub(crate) struct Control<'a, Message> { +type StyleFn<'a> = dyn Fn(&Theme, ActionState) -> ActionStyle + 'a; + +pub(crate) struct ActionSurface<'a, Message> { content: Element<'a, Message>, on_press: Option, activation: Option, @@ -274,10 +281,10 @@ pub(crate) struct Control<'a, Message> { width: Length, height: Length, padding: Padding, - style: Box Style + 'a>, + style: Box>, } -impl<'a, Message> Control<'a, Message> { +impl<'a, Message> ActionSurface<'a, Message> { pub(crate) fn new(content: impl Into>) -> Self { Self { content: content.into(), @@ -289,9 +296,9 @@ impl<'a, Message> Control<'a, Message> { width: Length::Shrink, height: Length::Shrink, padding: Padding::ZERO, - style: Box::new(|theme, _| Style { + style: Box::new(|theme, _| ActionStyle { text_color: crate::theme::colors(theme).text, - ..Style::default() + ..ActionStyle::default() }), } } @@ -341,9 +348,9 @@ impl<'a, Message> Control<'a, Message> { self } - pub(crate) fn style(mut self, style: impl Fn(&Theme, State) -> S + 'a) -> Self + pub(crate) fn style(mut self, style: impl Fn(&Theme, ActionState) -> S + 'a) -> Self where - S: Into