From 072d26ed79663a6bf322d57391594959895887e2 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 26 Aug 2026 00:28:26 +0200 Subject: [PATCH 1/4] feat(gui): drag the divider to resize the two panes The panes were a fixed 3:2, which is a guess about somebody else's screen. On a narrow window the right pane's tab strip runs out of room and clips the last tab - "Diagnostics (2)" was the one that prompted this - and there was nothing to do about it: the space between the lists was a gap, not a handle. It is a handle now. Six pixels, full height, the toolbars' muted line at rest and the panel border's burgundy while held, so a drag in progress is visible rather than inferred. The ratio is clamped to 0.15..0.85 in both the live drag and the settings parser. A pane dragged to zero takes the divider with it, and then neither pane can be recovered - so an out-of-range value in `settings.ini` reads as the default rather than as a preference somebody chose, the same policy `drag_scroll_speed` already applies. It is written to `settings.ini` on RELEASE, not on every pointer move: the drag emits a message per frame and the settings are a file. Nothing new was needed to carry it. `app.cursor` and `app.window` were already tracked, `mouse_area` was already imported, and `PointerAt` was already ambient - which matters, because resizing the furniture must not disarm a two-click confirmation armed on a row. Tested through the real message loop: the pointer alone does not resize, a grab makes it follow, both clamps hold at the edges, release stops the drag and hands the value to the preferences, and a move after release is inert again. --- crates/eidos-gui/src/main.rs | 41 +++++++++++++++++++++++++++ crates/eidos-gui/src/modinfo.rs | 27 ++++++++++++++++-- crates/eidos-gui/src/state.rs | 7 ++++- crates/eidos-gui/src/theme.rs | 9 ++++++ crates/eidos-gui/src/update.rs | 22 +++++++++++++- crates/eidos-gui/src/view.rs | 5 +++- crates/eidos-instance/src/settings.rs | 39 ++++++++++++++++++++++++- 7 files changed, 144 insertions(+), 6 deletions(-) diff --git a/crates/eidos-gui/src/main.rs b/crates/eidos-gui/src/main.rs index 8fbfbe7..2d5b193 100644 --- a/crates/eidos-gui/src/main.rs +++ b/crates/eidos-gui/src/main.rs @@ -700,6 +700,8 @@ enum Message { KeyNav(Nav), /// The pointer moved, or the window was resized. Only stored. PointerAt(iced::Point), + /// The divider between the mod list and the right pane was grabbed. + SplitGrab, WindowResized(iced::Size), /// The pointer entered a FOMOD option; drives the preview pane. FomodHover(Option<(usize, usize)>), @@ -1807,6 +1809,12 @@ struct App { // ---- menu-bar UI toggles + About ---- /// The toolbar / status bar are visible (View menu toggles). ui_toolbar_visible: bool, + /// Fraction of the window width given to the mod list, 0.15 to 0.85. + /// Mirrors `prefs.split`; kept here because it changes on every pointer + /// move during a drag and the preferences are only written when it stops. + split: f32, + /// Whether the divider is being dragged right now. + split_drag: bool, ui_statusbar_visible: bool, /// The View dropdown is open (iced has no native menu, so it's a floating card). view_menu_open: bool, @@ -4916,6 +4924,39 @@ mod tests { let _ = fs::remove_dir_all(&root); } + #[test] + fn the_divider_resizes_the_panes_and_refuses_to_collapse_one() { + let mut app = app_for_game("skyrimse"); + app.window = iced::Size::new(1000.0, 800.0); + let before = app.split; + + // Moving the pointer without grabbing must not move anything: the + // divider is a handle, not a hover target. + let _ = update_inner(&mut app, Message::PointerAt(iced::Point::new(300.0, 400.0))); + assert_eq!(app.split, before, "the pointer alone must not resize"); + + let _ = update_inner(&mut app, Message::SplitGrab); + let _ = update_inner(&mut app, Message::PointerAt(iced::Point::new(300.0, 400.0))); + assert!((app.split - 0.3).abs() < 0.001, "split={}", app.split); + + // Dragged past the edge, both panes must survive: a pane at zero width + // takes the divider with it and there is no way to get either back. + let _ = update_inner(&mut app, Message::PointerAt(iced::Point::new(-500.0, 400.0))); + assert_eq!(app.split, 0.15); + let _ = update_inner(&mut app, Message::PointerAt(iced::Point::new(5000.0, 400.0))); + assert_eq!(app.split, 0.85); + + // Releasing stops the drag and hands the value to the preferences, which + // is what makes it survive a restart. + let _ = update_inner(&mut app, Message::PointerReleased); + assert!(!app.split_drag); + assert_eq!(app.prefs.split, 0.85); + + // And a pointer move after release is inert again. + let _ = update_inner(&mut app, Message::PointerAt(iced::Point::new(100.0, 400.0))); + assert_eq!(app.split, 0.85); + } + #[test] fn a_backup_is_inert_and_can_be_restored_over_the_mod_it_came_from() { let root = temp_portable("skyrimse"); diff --git a/crates/eidos-gui/src/modinfo.rs b/crates/eidos-gui/src/modinfo.rs index 1663bb1..ee07ffa 100644 --- a/crates/eidos-gui/src/modinfo.rs +++ b/crates/eidos-gui/src/modinfo.rs @@ -2708,7 +2708,8 @@ pub(crate) fn right_pane<'a>(app: &App) -> Element<'a, Message> { }; let inner = Column::new().spacing(8).push(top).push(tabs).push(content); - container(inner).width(Length::FillPortion(2)).height(Length::Fill).padding(8).style(panel_style).into() + let portion = 1000_u16.saturating_sub((app.split * 1000.0) as u16); + container(inner).width(Length::FillPortion(portion)).height(Length::Fill).padding(8).style(panel_style).into() } pub(crate) fn status_bar<'a>(app: &App) -> Element<'a, Message> { @@ -2773,10 +2774,32 @@ pub(crate) fn main_screen(app: &App) -> Element<'_, Message> { .push(Space::new().width(Length::Fill)) .push(tool_btn("New instance", Message::Restart)); + // A grabbable divider rather than a gap. The panes were a fixed 3:2, which is + // a guess about somebody else's screen: on a narrow window the right pane's + // tab strip runs out of room and clips "Diagnostics", with nothing to do + // about it. Six pixels wide because a hairline is hard to hit and a bar is + // furniture; it lights up while held so the drag is visibly in progress. + let held = app.split_drag; + let divider = mouse_area( + container(Space::new().width(Length::Fixed(6.0)).height(Length::Fill)) + .height(Length::Fill) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(if held { + crate::theme::DIVIDER_HELD + } else { + crate::theme::DIVIDER + })), + border: iced::Border { radius: 3.0.into(), ..Default::default() }, + ..Default::default() + }), + ) + .on_press(Message::SplitGrab); + let body = Row::new() - .spacing(8) + .spacing(4) .height(Length::Fill) .push(modlist_pane(app)) + .push(divider) .push(right_pane(app)); let mut base = Column::new().spacing(4).padding(4).push(header).push(menu_bar()); diff --git a/crates/eidos-gui/src/state.rs b/crates/eidos-gui/src/state.rs index 872eb51..0727e59 100644 --- a/crates/eidos-gui/src/state.rs +++ b/crates/eidos-gui/src/state.rs @@ -204,6 +204,9 @@ pub(crate) fn new(launch_command: Vec) -> (App, Task) { // If Steam launched us with the game's command (`eidos-gui %command%`), // identify the game and open straight to its instance, like MO2 does. let auto = identify_game(&games, &launch_command); + // Read once: the struct needs it twice, and reading the file twice could give + // two different answers. + let prefs = if cfg!(test) { Settings::default() } else { Settings::load() }; let mut app = App { screen: Screen::Welcome, games, @@ -259,7 +262,7 @@ pub(crate) fn new(launch_command: Vec) -> (App, Task) { // reading it makes every assertion about a default depend on what // happens to be in that person's file. A default has no path, so // saving it is a no-op. - prefs: if cfg!(test) { Settings::default() } else { Settings::load() }, + prefs: prefs.clone(), executables: None, backups: None, dropped: Vec::new(), @@ -322,6 +325,8 @@ pub(crate) fn new(launch_command: Vec) -> (App, Task) { update_in_progress: false, sorting: false, ui_toolbar_visible: true, + split: prefs.split, + split_drag: false, ui_statusbar_visible: true, view_menu_open: false, about_open: false, diff --git a/crates/eidos-gui/src/theme.rs b/crates/eidos-gui/src/theme.rs index c71679a..7fab461 100644 --- a/crates/eidos-gui/src/theme.rs +++ b/crates/eidos-gui/src/theme.rs @@ -51,6 +51,15 @@ pub(crate) fn bar_style(_theme: &Theme) -> container::Style { } } +/// The bar between the two panes at rest: the same muted line the toolbars use, +/// so it reads as furniture rather than as content. +pub(crate) const DIVIDER: Color = Color::from_rgb8(0xC9, 0xB8, 0x90); + +/// The same bar while it is being dragged - the panel border's burgundy, which is +/// the strongest colour in this palette and the one already used for "this is the +/// edge of something". +pub(crate) const DIVIDER_HELD: Color = Color::from_rgb8(0x7A, 0x1F, 0x2B); + pub(crate) fn row_bg(even: bool) -> Color { if even { Color::from_rgb8(0xF3, 0xEA, 0xD3) diff --git a/crates/eidos-gui/src/update.rs b/crates/eidos-gui/src/update.rs index 3dda395..f198397 100644 --- a/crates/eidos-gui/src/update.rs +++ b/crates/eidos-gui/src/update.rs @@ -48,6 +48,9 @@ pub(crate) fn is_ambient(app: &App, m: &Message) -> bool { match m { Message::PointerAt(_) | Message::WindowResized(_) + // Grabbing the divider resizes the window's furniture; it is not the + // user deciding anything about the row a confirmation is armed on. + | Message::SplitGrab | Message::FomodHover(_) | Message::FomodUnhover(..) // The downloads tick fires twice a second on its own. Left out of @@ -5782,6 +5785,15 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task { // from a drop exactly as they do from the Install button. return update(app, Message::ModPicked(Some(d.path))); } + Message::PointerReleased if app.split_drag => { + app.split_drag = false; + // Written once, on release, rather than on every pointer move: the + // drag emits a message per frame and settings.ini is a file. + app.prefs.split = app.split; + if let Err(e) = app.prefs.save() { + app.status = Some(format!("Could not save the pane width: {e}")); + } + } Message::PointerReleased => { // Letting go is a DROP wherever a gap is aimed, and a cancel // otherwise - regardless of where the pointer happens to be. A user @@ -6018,7 +6030,15 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task { } commit_plugin_order(app, &spec); } - Message::PointerAt(p) => app.cursor = p, + Message::PointerAt(p) => { + app.cursor = p; + if app.split_drag && app.window.width > 1.0 { + // Clamped well inside the edges: a pane dragged to zero is a + // pane the user cannot grab again, and the divider goes with it. + app.split = (p.x / app.window.width).clamp(0.15, 0.85); + } + } + Message::SplitGrab => app.split_drag = true, Message::WindowResized(s) => { app.window = s; // "Remember the window size" is a real setting now: it was stored, diff --git a/crates/eidos-gui/src/view.rs b/crates/eidos-gui/src/view.rs index b919c06..c8873d8 100644 --- a/crates/eidos-gui/src/view.rs +++ b/crates/eidos-gui/src/view.rs @@ -1281,7 +1281,10 @@ pub(crate) fn modlist_pane<'a>(app: &App) -> Element<'a, Message> { .push(list_area) .push(overwrite); - container(inner).width(Length::FillPortion(3)).height(Length::Fill).padding(8).style(panel_style).into() + // The two panes share the width by a ratio the user drags, not by a constant. + // A thousandth of the window is finer than a pixel on any screen this runs on. + let portion = (app.split * 1000.0) as u16; + container(inner).width(Length::FillPortion(portion)).height(Length::Fill).padding(8).style(panel_style).into() } /// A single left-aligned action in the mod context menu. diff --git a/crates/eidos-instance/src/settings.rs b/crates/eidos-instance/src/settings.rs index debd0fc..29c56d5 100644 --- a/crates/eidos-instance/src/settings.rs +++ b/crates/eidos-instance/src/settings.rs @@ -297,6 +297,11 @@ pub struct Settings { /// its edges. 1.0 is the tuned default; the range a user can pick from is /// the GUI's business, this only stores what they picked. pub drag_scroll_speed: f32, + /// How much of the main window's width the mod list gets, 0.0 to 1.0. The + /// two panes were a fixed 3:2, which is a guess about somebody else's screen: + /// the right pane's tab strip runs out of room and "Diagnostics (2)" gets + /// clipped on a narrow window, with no way to give it more. + pub split: f32, /// Restore the window to its last size on launch (on by default). Off means /// the size is neither read nor written, so the compositor decides. pub remember_window: bool, @@ -336,6 +341,7 @@ impl Default for Settings { // MO2 defaults `lock_gui` to true, and an absent key means "on". lock_gui: true, drag_scroll_speed: 1.0, + split: 0.6, remember_window: true, offline: false, tools_dir: None, @@ -418,6 +424,17 @@ impl Settings { "window_width" => width = v.parse().ok(), "window_height" => height = v.parse().ok(), // Any value other than an explicit off keeps locking on (default). + "split" => { + // Same policy as the scroll speed: out of range or + // unparseable reads as the default, because a bad number + // here is a window with one pane collapsed and no obvious + // way back. + if let Ok(n) = v.parse::() { + if (0.15..=0.85).contains(&n) { + s.split = n; + } + } + } "drag_scroll_speed" => { // Out-of-range or unparseable reads as the default rather // than as a value: a bad number here is a list that either @@ -481,10 +498,11 @@ impl Settings { /// Render these settings as a `settings.ini` body. Split out for unit tests. pub fn to_ini(&self) -> String { let mut out = format!( - "[eidos]\ntheme={}\nlock_gui={}\ndrag_scroll_speed={}\nremember_window={}\n", + "[eidos]\ntheme={}\nlock_gui={}\ndrag_scroll_speed={}\nsplit={}\nremember_window={}\n", self.theme.as_str(), self.lock_gui, self.drag_scroll_speed, + self.split, self.remember_window ); if self.offline { @@ -727,6 +745,7 @@ mod tests { window_size: Some((1280, 720)), lock_gui: false, drag_scroll_speed: 1.0, + split: 0.6, remember_window: false, }; let parsed = Settings::parse(&s.to_ini()); @@ -740,6 +759,23 @@ mod tests { assert_eq!(parsed, s); } + #[test] + fn the_split_round_trips_and_refuses_a_collapsed_pane() { + assert_eq!(Settings::default().split, 0.6); + assert_eq!(Settings::parse("split=0.35\n").split, 0.35); + // A pane pushed off the edge is not a preference anyone chose; it is a + // window with no way back, so it reads as the default. + for bad in ["0", "1", "0.05", "0.99", "-1", "abc", ""] { + assert_eq!( + Settings::parse(&format!("split={bad}\n")).split, + 0.6, + "split={bad} should have been refused" + ); + } + let s = Settings { split: 0.42, ..Settings::default() }; + assert_eq!(Settings::parse(&s.to_ini()).split, 0.42); + } + #[test] fn the_drag_scroll_speed_round_trips_and_refuses_nonsense() { assert_eq!(Settings::default().drag_scroll_speed, 1.0); @@ -811,6 +847,7 @@ mod tests { window_size: Some((1600, 900)), lock_gui: false, drag_scroll_speed: 1.0, + split: 0.6, remember_window: false, }; fs::write(&path, s.to_ini()).unwrap(); From f343db4c301b8d028f3a20d58bc5e25292215539 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 26 Aug 2026 01:04:08 +0200 Subject: [PATCH 2/4] feat(gui): animate the tab strips and the status line Three rules hold this together, and the first is what makes it affordable. **Nothing animated changes layout.** Every animation here interpolates a colour. A frame in flight never re-measures a row, never reflows the mod list and never touches the file tree - which matters, because a fully expanded Skyrim Data tree is six figures of rows and re-laying it out sixty times a second would be a stutter, not a flourish. Turning motion off therefore changes no spacing at all. **The clock only runs while something is moving.** `anim::animating` gates the frame subscription, so an idle window subscribes to no timer. The cost at rest is not small, it is zero - the same condition the drag-scroll, hover-expand and run-poll timers already use. **A phase ends by itself.** `Phase` holds the instant a transition began and derives everything from elapsed time, so nothing has to remember to switch it off, and a missed frame shows up as a jump forward rather than as an animation stuck half way. ## What moves The two tab strips cross-fade. Both ends of the blend come from iced's own `button::primary` and `button::secondary`, evaluated against the live theme, so no colour is named and the transition stays right on the dark palette and on any palette added later. The tab being LEFT runs the same transition backwards - without that the arriving tab fades in while the old one snaps, which reads as a glitch rather than as a transition. A new status message fades in, so a status that replaces another is visibly a new one rather than a word that quietly became a different word. It is armed by comparing `status` after each message rather than at the dozen places across `state.rs` that assign it: "remember to also start the fade" is a rule that would be forgotten by the second one. ## Motion is a setting, because it has to be `motion` in `settings.ini`, on unless explicitly off, with the toggle under Preferences -> Appearance. Off means every animated value is drawn at its destination on the first frame - not a quicker animation, none - and the frame timer is never subscribed. The Colony convention requires that a program which animates can be told not to; this is that. `AnimationTick` is ambient. It fires sixty times a second, so treating it as an action would not merely shorten an armed two-click confirmation's life, it would end it between the two clicks every time - the defect the pointer and the download tick already taught this window, at sixty times the rate. It joins the set assertion that exists to catch the next timer somebody adds. 273 GUI tests, 138 elsewhere, clippy clean on the pinned 1.94.1. --- crates/eidos-gui/src/anim.rs | 360 ++++++++++++++++++++++++++ crates/eidos-gui/src/dialogs.rs | 14 + crates/eidos-gui/src/main.rs | 148 +++++++++++ crates/eidos-gui/src/modinfo.rs | 91 +++++-- crates/eidos-gui/src/state.rs | 7 + crates/eidos-gui/src/update.rs | 41 ++- crates/eidos-instance/src/settings.rs | 43 ++- 7 files changed, 684 insertions(+), 20 deletions(-) create mode 100644 crates/eidos-gui/src/anim.rs diff --git a/crates/eidos-gui/src/anim.rs b/crates/eidos-gui/src/anim.rs new file mode 100644 index 0000000..d250b34 --- /dev/null +++ b/crates/eidos-gui/src/anim.rs @@ -0,0 +1,360 @@ +//! Time-based animation. +//! +//! Three rules hold everything here together, and they are why this costs +//! nothing when the window is idle. +//! +//! **Nothing animated changes layout.** Every animation in this window +//! interpolates a *colour*. A frame in flight therefore never re-measures a +//! row, never reflows the mod list, and never touches the file tree - which +//! matters, because a fully expanded Skyrim Data tree is six figures of rows and +//! re-laying it out sixty times a second would be a stutter, not a flourish. +//! +//! **The clock only runs while something is moving.** [`App::animating`] gates +//! the frame subscription, so an idle window subscribes to no timer at all. The +//! cost at rest is not small, it is zero. +//! +//! **A phase ends by itself.** [`Phase`] holds the instant a transition began +//! and derives everything from elapsed time, so nothing has to remember to +//! switch it off - and a missed tick shows up as a jump forward rather than as +//! an animation stuck half way. +//! +//! [`App::animating`]: crate::App::animating + +use std::time::Instant; + +use iced::Color; + +/// How long every transition in this window lasts. +/// +/// One value, not one per animation: the Colony convention fixes the sidebar +/// slide at 200 ms, and two durations in one window read as one of them being +/// wrong. Long enough to be seen, short enough that it never sits between the +/// user and the thing they clicked. +pub(crate) const MS: f32 = 200.0; + +/// Fast at the start, settling at the end. +/// +/// The same curve Colony uses. Linear interpolation reads as mechanical because +/// nothing physical starts and stops at a constant rate. +pub(crate) fn ease_out_cubic(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + 1.0 - (1.0 - t).powi(3) +} + +/// One transition: when it started, or `None` if it never has. +/// +/// Copy, small, and derived entirely from a single `Instant` - so a struct that +/// holds several of these stays cheap to clone, which `App` relies on. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct Phase { + started: Option, +} + +impl Phase { + /// (Re)start the transition from zero. + /// + /// Restarting mid-flight is deliberate rather than merely allowed: clicking + /// a third tab while the second is still fading should run the new + /// transition, not queue it behind the old one. + pub(crate) fn start(&mut self) { + self.started = Some(Instant::now()); + } + + /// How far along, 0.0 to 1.0, eased. + /// + /// A phase that has never started reads as **finished**, not as pending: a + /// window opening on its default tab must draw that tab selected, not fade + /// it in from nothing on the first frame. + pub(crate) fn eased(&self) -> f32 { + ease_out_cubic(self.linear()) + } + + /// The raw fraction of `MS` elapsed, before easing. Split out so the tests + /// can talk about time without also talking about the curve. + pub(crate) fn linear(&self) -> f32 { + match self.started { + None => 1.0, + Some(t) => (t.elapsed().as_secs_f32() * 1000.0 / MS).clamp(0.0, 1.0), + } + } + + /// Whether this phase still needs frames. + pub(crate) fn running(&self) -> bool { + self.linear() < 1.0 + } +} + +/// Blend two colours, including their alpha. +/// +/// `t` is clamped, so a caller that hands over an unclamped fraction gets the +/// endpoint rather than a colour outside the range - which would render as +/// something that belongs to no theme. +pub(crate) fn mix(from: Color, to: Color, t: f32) -> Color { + let t = t.clamp(0.0, 1.0); + Color { + r: from.r + (to.r - from.r) * t, + g: from.g + (to.g - from.g) * t, + b: from.b + (to.b - from.b) * t, + a: from.a + (to.a - from.a) * t, + } +} + +/// The same blend for an optional background, treating `None` as transparent. +/// +/// iced's button styles use `None` to mean "draw no background at all", which is +/// not the same as a transparent one for the purposes of blending: fading from +/// `None` to a colour has to start from that colour at zero alpha, or the first +/// frame flashes the wrong hue. +pub(crate) fn mix_bg( + from: Option, + to: Option, + t: f32, +) -> Option { + let colour = |b: Option, other: Option| match b { + Some(iced::Background::Color(c)) => c, + // Not `Color::TRANSPARENT`: black at zero alpha darkens the blend on + // every intermediate frame. Start from the OTHER end's hue instead. + _ => match other { + Some(iced::Background::Color(c)) => Color { a: 0.0, ..c }, + _ => Color::TRANSPARENT, + }, + }; + let a = colour(from, to); + let b = colour(to, from); + Some(iced::Background::Color(mix(a, b, t))) +} + +/// Whether anything is moving right now. +/// +/// This is what keeps an idle window free: `subscription` asks, and when the +/// answer is no it does not subscribe to the frame timer at all - there is no +/// timer running and being ignored, there is no timer. +/// +/// `motion` short-circuits it, so a user who turned animation off never gets a +/// tick even for a transition that was just started. The phases still advance +/// in wall-clock time; they are simply drawn at their destination, because +/// every reader goes through [`at`]. +pub(crate) fn animating(app: &crate::App) -> bool { + app.motion && (app.tab_anim.running() || app.info_anim.running() || app.status_anim.running()) +} + +/// How far a phase should be DRAWN, honouring the motion preference. +/// +/// Every view-side reader goes through here rather than calling `eased` +/// directly. With motion off the answer is always 1.0 - the end state, drawn +/// immediately - which is what "reduced motion" has to mean: not a quicker +/// animation, none at all. +pub(crate) fn at(app: &crate::App, phase: &Phase) -> f32 { + if app.motion { phase.eased() } else { 1.0 } +} + +/// How selected a tab should be DRAWN, from 0.0 (unselected) to 1.0 (selected). +/// +/// Three cases, and the middle one is the whole point: the tab being left +/// behind runs the same transition backwards, so the strip crossfades instead +/// of one end snapping while the other fades. +/// +/// Generic because the window has two of these strips - the main one and the +/// mod-information one - and they must not drift apart. +pub(crate) fn tab_mix(t: f32, current: &T, previous: Option<&T>, this: &T) -> f32 { + if this == current { + t + } else if previous == Some(this) { + 1.0 - t + } else { + 0.0 + } +} + +/// Blend two button styles. +/// +/// Both ends come from iced's own `button::primary` / `button::secondary`, +/// evaluated against the live theme, so this never names a colour: it stays +/// correct on the dark palette and on any palette added later. The endpoints +/// short-circuit, which is also what makes an un-animated window pixel-identical +/// to what it drew before this existed. +pub(crate) fn mix_button( + off: iced::widget::button::Style, + on: iced::widget::button::Style, + t: f32, +) -> iced::widget::button::Style { + if t <= 0.0 { + return off; + } + if t >= 1.0 { + return on; + } + iced::widget::button::Style { + background: mix_bg(off.background, on.background, t), + text_color: mix(off.text_color, on.text_color, t), + border: iced::Border { + color: mix(off.border.color, on.border.color, t), + width: off.border.width + (on.border.width - off.border.width) * t, + radius: on.border.radius, + }, + ..on + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_curve_starts_at_zero_ends_at_one_and_never_leaves_the_range() { + assert_eq!(ease_out_cubic(0.0), 0.0); + assert_eq!(ease_out_cubic(1.0), 1.0); + // Out-of-range input is clamped rather than extrapolated: a colour blend + // past either endpoint is a colour from no theme. + assert_eq!(ease_out_cubic(-5.0), 0.0); + assert_eq!(ease_out_cubic(9.0), 1.0); + + let mut prev = 0.0; + for i in 0..=100 { + let v = ease_out_cubic(i as f32 / 100.0); + assert!((0.0..=1.0).contains(&v)); + assert!(v >= prev, "the curve went backwards at {i}"); + prev = v; + } + } + + #[test] + fn the_curve_is_front_loaded() { + // Ease-OUT: more than half the distance is covered in the first half of + // the time. A linear ramp would fail this. + assert!(ease_out_cubic(0.5) > 0.5); + } + + /// A phase nobody started is finished, not pending - the window's first + /// frame draws its default tab selected rather than fading it in. + #[test] + fn an_unstarted_phase_is_already_finished() { + let p = Phase::default(); + assert_eq!(p.linear(), 1.0); + assert_eq!(p.eased(), 1.0); + assert!(!p.running()); + } + + #[test] + fn a_started_phase_runs_from_the_beginning() { + let mut p = Phase::default(); + p.start(); + // Just started: near zero, and asking for frames. + assert!(p.linear() < 0.5, "linear was {}", p.linear()); + assert!(p.running()); + } + + #[test] + fn a_phase_finishes_by_itself_without_being_told() { + let mut p = Phase::default(); + p.start(); + std::thread::sleep(std::time::Duration::from_millis((MS as u64) + 40)); + assert_eq!(p.linear(), 1.0); + assert_eq!(p.eased(), 1.0); + // And stops asking for frames, which is what lets the subscription drop. + assert!(!p.running()); + } + + #[test] + fn restarting_mid_flight_runs_the_new_transition_from_zero() { + let mut p = Phase::default(); + p.start(); + std::thread::sleep(std::time::Duration::from_millis(120)); + let mid = p.linear(); + assert!(mid > 0.0 && mid < 1.0, "expected mid-flight, got {mid}"); + + p.start(); + assert!(p.linear() < mid, "the restart did not go back to the start"); + } + + #[test] + fn a_blend_hits_both_endpoints_and_moves_between_them() { + let a = Color::from_rgb(0.0, 0.0, 0.0); + let b = Color::from_rgb(1.0, 0.5, 0.25); + assert_eq!(mix(a, b, 0.0), a); + assert_eq!(mix(a, b, 1.0), b); + + let half = mix(a, b, 0.5); + assert!((half.r - 0.5).abs() < 1e-6); + assert!((half.g - 0.25).abs() < 1e-6); + + // Clamped, not extrapolated. + assert_eq!(mix(a, b, 2.0), b); + assert_eq!(mix(a, b, -1.0), a); + } + + #[test] + fn a_blend_carries_alpha_too() { + let a = Color { a: 0.0, ..Color::WHITE }; + let b = Color::WHITE; + assert_eq!(mix(a, b, 0.0).a, 0.0); + assert_eq!(mix(a, b, 1.0).a, 1.0); + assert!((mix(a, b, 0.5).a - 0.5).abs() < 1e-6); + } + + /// Fading in from "no background" must start at the DESTINATION hue with no + /// alpha. Starting from transparent black darkens every frame in between, + /// which on the parchment palette reads as a grey flash. + #[test] + fn fading_in_from_no_background_does_not_go_through_black() { + let to = iced::Background::Color(Color::from_rgb(0.9, 0.2, 0.2)); + let half = mix_bg(None, Some(to), 0.5).unwrap(); + let iced::Background::Color(c) = half else { panic!("not a colour") }; + + assert!((c.a - 0.5).abs() < 1e-6, "alpha should be half way"); + // The hue is the destination's, not a blend with black. + assert!((c.r - 0.9).abs() < 1e-6, "r drifted to {}", c.r); + assert!((c.g - 0.2).abs() < 1e-6, "g drifted to {}", c.g); + } + + #[test] + fn the_strip_crossfades_rather_than_one_end_snapping() { + // Mid-transition from Data to Saves. + let t = 0.25; + let arriving = tab_mix(t, &"saves", Some(&"data"), &"saves"); + let leaving = tab_mix(t, &"saves", Some(&"data"), &"data"); + let bystander = tab_mix(t, &"saves", Some(&"data"), &"plugins"); + + assert_eq!(arriving, 0.25); + assert_eq!(leaving, 0.75, "the tab being left must fade out, not snap"); + assert_eq!(bystander, 0.0); + // The two ends always sum to one: what one gains the other gives up. + assert!((arriving + leaving - 1.0).abs() < 1e-6); + } + + #[test] + fn with_no_transition_the_selected_tab_is_simply_selected() { + // t = 1.0 is what a finished (or never-started) phase reports. + assert_eq!(tab_mix(1.0, &"saves", None, &"saves"), 1.0); + assert_eq!(tab_mix(1.0, &"saves", None, &"data"), 0.0); + // Even with a previous tab recorded, a finished phase leaves it at zero. + assert_eq!(tab_mix(1.0, &"saves", Some(&"data"), &"data"), 0.0); + } + + #[test] + fn the_endpoints_of_a_button_blend_are_the_untouched_originals() { + let off = iced::widget::button::Style { + text_color: Color::BLACK, + ..Default::default() + }; + let on = iced::widget::button::Style { + text_color: Color::WHITE, + ..Default::default() + }; + // Pixel-identical to no animation at all at both ends - which is what + // makes an idle window look exactly as it did before. + assert_eq!(mix_button(off, on, 0.0).text_color, Color::BLACK); + assert_eq!(mix_button(off, on, 1.0).text_color, Color::WHITE); + let half = mix_button(off, on, 0.5).text_color; + assert!((half.r - 0.5).abs() < 1e-6); + } + + #[test] + fn fading_out_to_no_background_is_the_mirror_image() { + let from = iced::Background::Color(Color::from_rgb(0.9, 0.2, 0.2)); + let half = mix_bg(Some(from), None, 0.5).unwrap(); + let iced::Background::Color(c) = half else { panic!("not a colour") }; + assert!((c.a - 0.5).abs() < 1e-6); + assert!((c.r - 0.9).abs() < 1e-6); + } +} diff --git a/crates/eidos-gui/src/dialogs.rs b/crates/eidos-gui/src/dialogs.rs index 2bccd78..c9ea1f9 100644 --- a/crates/eidos-gui/src/dialogs.rs +++ b/crates/eidos-gui/src/dialogs.rs @@ -191,6 +191,20 @@ pub(crate) fn settings_dialog<'a>(app: &App) -> Element<'a, Message> { picker.into(), ), )) + .push(settings_section( + "motion", + "Motion", + open("motion"), + settings_toggle( + "Animate the window", + "The tab strips cross-fade and a new status message fades in. \ + Off means they change instantly - nothing is animated more \ + quickly, and no frame timer runs at all. Nothing here moves \ + the layout either way, so turning it off changes no spacing.", + app.prefs.motion, + Message::ToggleMotion(!app.prefs.motion), + ), + )) .into() } SettingsTab::ModList => { diff --git a/crates/eidos-gui/src/main.rs b/crates/eidos-gui/src/main.rs index 2d5b193..4bbfc24 100644 --- a/crates/eidos-gui/src/main.rs +++ b/crates/eidos-gui/src/main.rs @@ -34,6 +34,7 @@ use eidos_conflicts::{ConflictMap, ConflictState, Layer}; // // The three modules main.rs no longer imports from - theme, widgets, fomod - are // the measure of the split: nothing at the root draws anything any more. +mod anim; mod dialogs; mod fomod; mod health; @@ -400,6 +401,8 @@ enum Message { /// Toggle the conflict marks on the mod list's scrollbar. /// Toggle restoring the window to its last size. ToggleRememberWindow(bool), + /// Turn the window's animations on or off (Preferences -> Appearance). + ToggleMotion(bool), /// MO2's offline mode: cut every Nexus request. ToggleOffline(bool), /// Editing the preferred-CDN list. Saved on submit, not per keystroke. @@ -702,6 +705,11 @@ enum Message { PointerAt(iced::Point), /// The divider between the mod list and the right pane was grabbed. SplitGrab, + /// One animation frame. Carries nothing: every animated value is derived + /// from elapsed time, so the tick's only job is to make iced redraw. + /// + /// Only subscribed while something is actually moving - see `subscription`. + AnimationTick, WindowResized(iced::Size), /// The pointer entered a FOMOD option; drives the preview pane. FomodHover(Option<(usize, usize)>), @@ -1815,6 +1823,28 @@ struct App { split: f32, /// Whether the divider is being dragged right now. split_drag: bool, + // ---- motion ---- + /// Whether this window animates at all (Preferences -> Appearance -> Motion, + /// mirroring `prefs.motion`). Off means every animated value is drawn at its + /// destination and the frame timer is never subscribed. + motion: bool, + /// The main tab strip's crossfade, and the tab it is fading AWAY from. + /// + /// The previous tab is kept because a crossfade needs both ends: without it + /// the arriving tab fades in while the leaving one snaps, which reads as a + /// glitch rather than as a transition. + tab_anim: anim::Phase, + tab_prev: Option, + /// The same for the mod-information strip on the right. + info_anim: anim::Phase, + info_prev: Option, + /// The status line's fade-in, and the text it is currently showing. + /// + /// The copy is what makes the fade fire at all: `status` is assigned from a + /// dozen places across `state.rs`, and instrumenting each of them is a rule + /// somebody would forget. Comparing after every message cannot be forgotten. + status_anim: anim::Phase, + status_shown: Option, ui_statusbar_visible: bool, /// The View dropdown is open (iced has no native menu, so it's a floating card). view_menu_open: bool, @@ -2211,6 +2241,14 @@ const DOWNLOAD_IDLE_TICK: std::time::Duration = std::time::Duration::from_secs(2 fn subscription(app: &App) -> iced::Subscription { use iced::keyboard::{self, key::Named, Key}; + // 60 Hz, and ONLY while something is actually moving. An idle window + // subscribes to nothing here, so the cost of having animations at all is + // zero rather than small - which is the whole reason this is a condition + // and not an always-on timer that most frames ignore. + let frames = anim::animating(app).then(|| { + iced::time::every(std::time::Duration::from_millis(16)).map(|_| Message::AnimationTick) + }); + // Track held modifiers from every key press AND release (a release with no // remaining keys still carries the updated modifier set). // One stream now: `listen` yields every keyboard event and all three variants @@ -2465,6 +2503,10 @@ fn subscription(app: &App) -> iced::Subscription { iced::time::every(std::time::Duration::from_millis(600)).map(|_| Message::PollRunning), ); } + // Pushed last so the condition sits beside every other conditional timer + // above, all of which follow the same rule: subscribe only while the thing + // they watch is actually happening. + subs.extend(frames); iced::Subscription::batch(subs) } @@ -6072,6 +6114,10 @@ mod tests { Message::DownloadTick, Message::SavesTick, Message::LogRefresh, + // The sharpest case: this one fires sixty times a second, so + // treating it as an action would not shorten a confirmation's life, + // it would end it between the two clicks every time. + Message::AnimationTick, Message::PointerAt(iced::Point::ORIGIN), Message::ModifiersChanged(iced::keyboard::Modifiers::default()), ] { @@ -7418,6 +7464,108 @@ mod tests { assert_eq!(app.confirm_delete_download.as_deref(), Some("a.zip")); } + #[test] + fn a_frame_of_animation_does_not_cancel_an_armed_confirmation() { + // Sixty a second while a tab is cross-fading. Arm Delete, switch tabs so + // the strip animates, and the arming must survive every frame of it. + let mut app = nav_app(&[]); + update_inner(&mut app, Message::DeleteDownload("a.zip".into())); + assert_eq!(app.confirm_delete_download.as_deref(), Some("a.zip")); + + for _ in 0..12 { + let _ = update(&mut app, Message::AnimationTick); + } + assert_eq!( + app.confirm_delete_download.as_deref(), + Some("a.zip"), + "the frame timer disarmed the user's confirmation" + ); + } + + #[test] + fn changing_tab_crossfades_from_the_one_being_left() { + let mut app = nav_app(&[]); + app.tab = Tab::Data; + assert!(!anim::animating(&app), "an idle window must ask for no frames"); + + let _ = update(&mut app, Message::SelectTab(Tab::Saves)); + assert_eq!(app.tab, Tab::Saves); + assert_eq!(app.tab_prev, Some(Tab::Data), "the strip needs both ends to cross-fade"); + assert!(anim::animating(&app), "the frame timer should be running now"); + + // Mid-flight the two ends share the transition between them. + let t = anim::at(&app, &app.tab_anim); + let arriving = anim::tab_mix(t, &app.tab, app.tab_prev.as_ref(), &Tab::Saves); + let leaving = anim::tab_mix(t, &app.tab, app.tab_prev.as_ref(), &Tab::Data); + assert!((arriving + leaving - 1.0).abs() < 1e-6); + // And a tab that took no part in it is simply unselected. + assert_eq!(anim::tab_mix(t, &app.tab, app.tab_prev.as_ref(), &Tab::Archives), 0.0); + } + + #[test] + fn re_selecting_the_tab_already_open_starts_nothing() { + // Otherwise clicking the current tab restarts a transition from itself + // to itself, which flashes. + let mut app = nav_app(&[]); + app.tab = Tab::Data; + let _ = update(&mut app, Message::SelectTab(Tab::Data)); + assert_eq!(app.tab_prev, None); + assert!(!anim::animating(&app)); + } + + #[test] + fn a_new_status_message_fades_in_however_it_was_set() { + // The fade is armed by comparison after the message, not by each of the + // dozen places that assign `status` - so this must work for a path that + // knows nothing about animation. + let mut app = nav_app(&[]); + assert!(!anim::animating(&app)); + + app.status = Some("Installed 3 mods.".to_string()); + let _ = update(&mut app, Message::Noop); + assert!(anim::animating(&app), "a new status message must fade in"); + assert_eq!(app.status_shown.as_deref(), Some("Installed 3 mods.")); + + // The SAME message arriving again is not a new one, and must not restart + // the fade - otherwise a repeating status strobes. + app.status_anim = anim::Phase::default(); + let _ = update(&mut app, Message::Noop); + assert!(!anim::animating(&app), "an unchanged status restarted the fade"); + } + + #[test] + fn with_motion_off_nothing_animates_and_no_frames_are_asked_for() { + // "Reduced motion" has to mean none, not faster: every animated value is + // drawn at its destination and the frame timer is never subscribed. + let mut app = nav_app(&[]); + let _ = update(&mut app, Message::ToggleMotion(false)); + assert!(!app.prefs.motion, "the preference is what gets saved"); + assert!(!app.motion, "the live copy has to follow, or it keeps animating"); + + app.tab = Tab::Data; + let _ = update(&mut app, Message::SelectTab(Tab::Saves)); + app.status = Some("Something happened.".to_string()); + let _ = update(&mut app, Message::Noop); + + assert!(!anim::animating(&app), "motion is off; nothing may ask for frames"); + // Drawn at the end state: the selected tab is fully selected, the one + // left behind fully unselected, on the very first frame. + let t = anim::at(&app, &app.tab_anim); + assert_eq!(t, 1.0); + assert_eq!(anim::tab_mix(t, &app.tab, app.tab_prev.as_ref(), &Tab::Saves), 1.0); + assert_eq!(anim::tab_mix(t, &app.tab, app.tab_prev.as_ref(), &Tab::Data), 0.0); + assert_eq!(anim::at(&app, &app.status_anim), 1.0); + } + + #[test] + fn the_motion_preference_survives_a_round_trip_through_the_file() { + let mut app = nav_app(&[]); + assert!(app.prefs.motion, "on by default"); + let _ = update(&mut app, Message::ToggleMotion(false)); + let reloaded = eidos_instance::Settings::parse(&app.prefs.to_ini()); + assert!(!reloaded.motion); + } + #[test] fn moving_the_mouse_does_not_cancel_an_armed_confirmation() { // The reported bug: arm Delete on a download, twitch the mouse, and the diff --git a/crates/eidos-gui/src/modinfo.rs b/crates/eidos-gui/src/modinfo.rs index ee07ffa..944c1a2 100644 --- a/crates/eidos-gui/src/modinfo.rs +++ b/crates/eidos-gui/src/modinfo.rs @@ -8,11 +8,18 @@ use crate::theme::*; use crate::widgets::*; use crate::*; -pub(crate) fn info_tab_btn<'a>(label: &'a str, tab: InfoTab, active: bool) -> Element<'a, Message> { +/// One tab of the mod-information strip. Same contract as [`tab_btn`]. +pub(crate) fn info_tab_btn<'a>(label: &'a str, tab: InfoTab, mix: f32) -> Element<'a, Message> { button(text(label).size(12.0)) .padding([4, 10]) .on_press(Message::InfoSelectTab(tab)) - .style(if active { button::primary } else { button::secondary }) + .style(move |theme, status| { + crate::anim::mix_button( + button::secondary(theme, status), + button::primary(theme, status), + mix, + ) + }) .into() } @@ -394,11 +401,11 @@ pub(crate) fn mod_info_dialog<'a>(app: &App, i: usize) -> Element<'a, Message> { let tabs = Row::new() .spacing(4) - .push(info_tab_btn("General", InfoTab::General, app.info_tab == InfoTab::General)) - .push(info_tab_btn("Conflicts", InfoTab::Conflicts, app.info_tab == InfoTab::Conflicts)) - .push(info_tab_btn("Filetree", InfoTab::Filetree, app.info_tab == InfoTab::Filetree)) - .push(info_tab_btn("INI Tweaks", InfoTab::IniTweaks, app.info_tab == InfoTab::IniTweaks)) - .push(info_tab_btn("Notes", InfoTab::Notes, app.info_tab == InfoTab::Notes)); + .push(info_tab_btn("General", InfoTab::General, info_mix(app, InfoTab::General))) + .push(info_tab_btn("Conflicts", InfoTab::Conflicts, info_mix(app, InfoTab::Conflicts))) + .push(info_tab_btn("Filetree", InfoTab::Filetree, info_mix(app, InfoTab::Filetree))) + .push(info_tab_btn("INI Tweaks", InfoTab::IniTweaks, info_mix(app, InfoTab::IniTweaks))) + .push(info_tab_btn("Notes", InfoTab::Notes, info_mix(app, InfoTab::Notes))); let content = match app.info_tab { InfoTab::General => info_general(app, m), @@ -1957,11 +1964,49 @@ pub(crate) fn diagnostics_panel<'a>(app: &App) -> Element<'a, Message> { scrollable(col).height(Length::Fill).into() } -pub(crate) fn tab_btn<'a>(label: String, t: Tab, selected: bool) -> Element<'a, Message> { +/// How selected a main-strip tab should look right now. +/// +/// Split out so the eight call sites below say which tab they are and nothing +/// else - threading the phase, the previous tab and the motion preference +/// through each of them is how one of the eight ends up different. +fn main_mix(app: &App, this: Tab) -> f32 { + crate::anim::tab_mix( + crate::anim::at(app, &app.tab_anim), + &app.tab, + app.tab_prev.as_ref(), + &this, + ) +} + +/// The same for the mod-information strip. +fn info_mix(app: &App, this: InfoTab) -> f32 { + crate::anim::tab_mix( + crate::anim::at(app, &app.info_anim), + &app.info_tab, + app.info_prev.as_ref(), + &this, + ) +} + +/// One tab of the main strip. +/// +/// `mix` is how selected it should LOOK, 0.0 to 1.0, rather than whether it is: +/// mid-transition the arriving tab and the one being left behind are both +/// somewhere in between. A window with motion off only ever passes 0.0 or 1.0, +/// and then this draws exactly what it drew before animation existed. +pub(crate) fn tab_btn<'a>(label: String, t: Tab, mix: f32) -> Element<'a, Message> { button(text(label).size(12.0)) .padding(6) .on_press(Message::SelectTab(t)) - .style(if selected { button::primary } else { button::secondary }) + // Both ends are iced's own styles, resolved against the live theme, so + // no colour is named here and the blend follows the palette. + .style(move |theme, status| { + crate::anim::mix_button( + button::secondary(theme, status), + button::primary(theme, status), + mix, + ) + }) .into() } @@ -2680,21 +2725,21 @@ pub(crate) fn right_pane<'a>(app: &App) -> Element<'a, Message> { let tab = effective_tab(app); let mut tabs = Row::new() .spacing(4) - .push(tab_btn("Data".to_string(), Tab::Data, tab == Tab::Data)); + .push(tab_btn("Data".to_string(), Tab::Data, main_mix(app, Tab::Data))); // Only for a game whose plugins Eidos actually manages. Stellar Blade is the // first game with no plugin system at all, and every other pane keys off the // same `GameSpec::for_id` - so without this the tab is there, opens, and // shows an empty list for a game that will never have one. if game_manages_plugins(app) { - tabs = tabs.push(tab_btn("Plugins".to_string(), Tab::Plugins, tab == Tab::Plugins)); + tabs = tabs.push(tab_btn("Plugins".to_string(), Tab::Plugins, main_mix(app, Tab::Plugins))); } let tabs = tabs - .push(tab_btn("Conflicts".to_string(), Tab::Conflicts, tab == Tab::Conflicts)) - .push(tab_btn("Overwrite".to_string(), Tab::Overwrite, tab == Tab::Overwrite)) - .push(tab_btn("Archives".to_string(), Tab::Archives, tab == Tab::Archives)) - .push(tab_btn("Saves".to_string(), Tab::Saves, tab == Tab::Saves)) - .push(tab_btn("Downloads".to_string(), Tab::Downloads, tab == Tab::Downloads)) - .push(tab_btn(diagnostics_tab_label(app), Tab::Diagnostics, tab == Tab::Diagnostics)); + .push(tab_btn("Conflicts".to_string(), Tab::Conflicts, main_mix(app, Tab::Conflicts))) + .push(tab_btn("Overwrite".to_string(), Tab::Overwrite, main_mix(app, Tab::Overwrite))) + .push(tab_btn("Archives".to_string(), Tab::Archives, main_mix(app, Tab::Archives))) + .push(tab_btn("Saves".to_string(), Tab::Saves, main_mix(app, Tab::Saves))) + .push(tab_btn("Downloads".to_string(), Tab::Downloads, main_mix(app, Tab::Downloads))) + .push(tab_btn(diagnostics_tab_label(app), Tab::Diagnostics, main_mix(app, Tab::Diagnostics))); let content = match tab { Tab::Data => data_panel(app), @@ -2742,9 +2787,19 @@ pub(crate) fn status_bar<'a>(app: &App) -> Element<'a, Message> { Some(a) => format!("Nexus: {} ({})", a.name, if a.is_premium { "Premium" } else { "free" }), None => "not logged in".to_string(), }; + // The left slot fades in when its message changes, so a status that + // replaces another is visibly a NEW one rather than a word that quietly + // became a different word. Colour only - the row keeps its height whatever + // the fade is doing, so the status bar never nudges the panes above it. + let fg = crate::theme::palette().text; + let ink = crate::anim::mix( + Color { a: 0.0, ..fg }, + fg, + crate::anim::at(app, &app.status_anim), + ); let mut row = Row::new() .align_y(iced::Alignment::Center) - .push(text(left).size(11.0).width(Length::Fill)); + .push(text(left).size(11.0).color(ink).width(Length::Fill)); if showing_status { // A tiny dismiss so a stale message stops masking the selection count and // instance summary. diff --git a/crates/eidos-gui/src/state.rs b/crates/eidos-gui/src/state.rs index 0727e59..80792a5 100644 --- a/crates/eidos-gui/src/state.rs +++ b/crates/eidos-gui/src/state.rs @@ -327,6 +327,13 @@ pub(crate) fn new(launch_command: Vec) -> (App, Task) { ui_toolbar_visible: true, split: prefs.split, split_drag: false, + motion: prefs.motion, + tab_anim: crate::anim::Phase::default(), + tab_prev: None, + info_anim: crate::anim::Phase::default(), + info_prev: None, + status_anim: crate::anim::Phase::default(), + status_shown: None, ui_statusbar_visible: true, view_menu_open: false, about_open: false, diff --git a/crates/eidos-gui/src/update.rs b/crates/eidos-gui/src/update.rs index f198397..38225c1 100644 --- a/crates/eidos-gui/src/update.rs +++ b/crates/eidos-gui/src/update.rs @@ -20,6 +20,16 @@ pub(crate) fn update(app: &mut App, message: Message) -> Task { let draining = !app.dropped.is_empty(); let task = update_inner(app, message); refresh_diagnostics(app); + // A new status message fades in. Detected by comparison HERE rather than by + // starting the phase at each assignment: `app.status` is written from about + // a dozen places across `state.rs`, and "remember to also start the fade" is + // a rule that would be forgotten by the second one. The comparison is a + // string compare per message; the clone happens only when it actually + // changed, which is also the only time anything is drawn differently. + if app.status != app.status_shown { + app.status_shown = app.status.clone(); + app.status_anim.start(); + } // A multi-file drop is walked one file at a time, because each install can // open a modal that has to be answered before the next archive is touched. // Re-armed here rather than at the end of every install path, because the @@ -64,6 +74,11 @@ pub(crate) fn is_ambient(app: &App, m: &Message) -> bool { // click can land, which is exactly what the note above describes. | Message::SavesTick | Message::LogRefresh + // And the frame timer, for exactly the same reason and more sharply: + // it fires SIXTY times a second, so leaving it out would not merely + // shorten a confirmation's life, it would end it before the finger + // came back down. + | Message::AnimationTick // And the hover-to-expand timer, which fires only while a drag rests on // a collapsed group. Same reason: the program watching a pointer sit // still is not the user deciding anything. @@ -303,6 +318,10 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task { mods_changed(app); } Message::SelectTab(t) => { + if app.tab != t { + app.tab_prev = Some(app.tab); + app.tab_anim.start(); + } app.tab = t; if t == Tab::Plugins && app.plugins.is_none() { app.plugins = compute_plugins(app); @@ -2082,7 +2101,13 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task { } } Message::CloseInfo => app.info_mod = None, - Message::InfoSelectTab(t) => app.info_tab = t, + Message::InfoSelectTab(t) => { + if app.info_tab != t { + app.info_prev = Some(app.info_tab); + app.info_anim.start(); + } + app.info_tab = t; + } Message::NotesChanged(s) => { app.typing = true; app.notes_edit = s; @@ -2630,6 +2655,16 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task { app.status = Some(format!("Could not save preferences: {e}")); } } + Message::ToggleMotion(on) => { + app.prefs.motion = on; + // The live copy too, not only the saved one: every animated value is + // read through `anim::at`, which asks `app.motion`. Saving alone + // would leave the window animating until the next launch. + app.motion = on; + if let Err(e) = app.prefs.save() { + app.status = Some(format!("Could not save preferences: {e}")); + } + } Message::ToggleOffline(on) => { app.prefs.offline = on; if let Err(e) = app.prefs.save() { @@ -6039,6 +6074,10 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task { } } Message::SplitGrab => app.split_drag = true, + // Nothing to store. Every animated value is a function of the instant + // its phase began, so a frame's whole job is to have arrived: reaching + // `update` is what makes iced call `view` again. + Message::AnimationTick => {} Message::WindowResized(s) => { app.window = s; // "Remember the window size" is a real setting now: it was stored, diff --git a/crates/eidos-instance/src/settings.rs b/crates/eidos-instance/src/settings.rs index 29c56d5..c73a087 100644 --- a/crates/eidos-instance/src/settings.rs +++ b/crates/eidos-instance/src/settings.rs @@ -302,6 +302,14 @@ pub struct Settings { /// the right pane's tab strip runs out of room and "Diagnostics (2)" gets /// clipped on a narrow window, with no way to give it more. pub split: f32, + /// Whether the window animates at all (on by default). + /// + /// Named for what it controls rather than for the preference that usually + /// turns it off: the Colony convention calls this Accessibility -> Motion, + /// and requires that a program which animates can be told not to. Off means + /// every animated value jumps straight to its target - not a faster + /// animation, none - and the frame timer is never even subscribed. + pub motion: bool, /// Restore the window to its last size on launch (on by default). Off means /// the size is neither read nor written, so the compositor decides. pub remember_window: bool, @@ -342,6 +350,7 @@ impl Default for Settings { lock_gui: true, drag_scroll_speed: 1.0, split: 0.6, + motion: true, remember_window: true, offline: false, tools_dir: None, @@ -483,6 +492,12 @@ impl Settings { "lock_gui" => { s.lock_gui = !matches!(v.to_ascii_lowercase().as_str(), "false" | "0" | "no" | "off") } + // On unless explicitly off, like lock_gui. A user who has never + // heard of this key gets the animations; one who wrote + // `motion=off` gets a window that never moves on its own. + "motion" => { + s.motion = !matches!(v.to_ascii_lowercase().as_str(), "false" | "0" | "no" | "off") + } _ => {} } } @@ -498,11 +513,12 @@ impl Settings { /// Render these settings as a `settings.ini` body. Split out for unit tests. pub fn to_ini(&self) -> String { let mut out = format!( - "[eidos]\ntheme={}\nlock_gui={}\ndrag_scroll_speed={}\nsplit={}\nremember_window={}\n", + "[eidos]\ntheme={}\nlock_gui={}\ndrag_scroll_speed={}\nsplit={}\nmotion={}\nremember_window={}\n", self.theme.as_str(), self.lock_gui, self.drag_scroll_speed, self.split, + self.motion, self.remember_window ); if self.offline { @@ -746,6 +762,7 @@ mod tests { lock_gui: false, drag_scroll_speed: 1.0, split: 0.6, + motion: false, remember_window: false, }; let parsed = Settings::parse(&s.to_ini()); @@ -776,6 +793,29 @@ mod tests { assert_eq!(Settings::parse(&s.to_ini()).split, 0.42); } + /// On unless told otherwise, and only an explicit off turns it off. A user + /// who has never heard of the key must get the animations; the key exists + /// for the one who does not want them. + #[test] + fn motion_is_on_unless_explicitly_refused() { + assert!(Settings::default().motion); + assert!(Settings::parse("").motion); + assert!(Settings::parse("motion=true\n").motion); + + for off in ["false", "0", "no", "off", "OFF", "No"] { + assert!( + !Settings::parse(&format!("motion={off}\n")).motion, + "{off} should have turned motion off" + ); + } + // Anything else is not an off switch. Nonsense leaves it on rather than + // silently stopping the window from animating. + assert!(Settings::parse("motion=maybe\n").motion); + + let s = Settings { motion: false, ..Settings::default() }; + assert!(!Settings::parse(&s.to_ini()).motion); + } + #[test] fn the_drag_scroll_speed_round_trips_and_refuses_nonsense() { assert_eq!(Settings::default().drag_scroll_speed, 1.0); @@ -848,6 +888,7 @@ mod tests { lock_gui: false, drag_scroll_speed: 1.0, split: 0.6, + motion: false, remember_window: false, }; fs::write(&path, s.to_ini()).unwrap(); From 5031e96c7ad4760723aa908019250baef41ea7f5 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 26 Aug 2026 01:31:43 +0200 Subject: [PATCH 3/4] refactor(gui): build Preferences to the Colony convention Measured against `design/settings-page.md` in Project-Colony-Resources, this page broke the convention's first structural rule and was missing one of its three mandatory categories. ## It was a modal, which is the one thing it must not be The convention names a modal, a separate window and a popover as the three counter-examples, and this was the first. It is a page now: it replaces the content area, and the header, menu bar, toolbar and status bar all stay exactly where they are - the program's chrome does not move because the user went to configure it. What that buys is not tidiness. The card was capped at 620x240, so every category was read through a 240-pixel slot however large the window was. The screen that most needs room to be scanned had the least of it. ## Accessibility was missing, and it is not optional "The Accessibility category is part of the skeleton, not a nice-to-have." The first three categories are imposed and ordered - General, Appearance, Accessibility - and this had five with the third absent. Motion moves there from Appearance, where the previous commit had put it. The distinction is not filing: Appearance is what the window looks like, Accessibility is what it does for somebody who needs it to do less. The convention also asks for a high-contrast palette, a dyslexia-friendly font and a text scale. Eidos has none of the three - every size in this window is a literal, so a scale is a change to the whole GUI rather than a setting. The category says so in as many words rather than showing switches that do nothing. ## The rest of the checklist - Titled **Preferences**, at 22, not "Settings" at 18. The convention settles the user-facing word; only the code is still named `settings_*`, which costs the user nothing. - `settings_dialog` is now `preferences_page`: a function whose name disagrees with what it returns is how the next reader is misled. - Each category opens with its own heading and one line saying what it changes. General's carries the contract that matters most: preferences are saved automatically. There is no Save button and there must not be one. - Close at 13, padding [6, 14]; rail buttons at [8, 14], widened to 148 so "Accessibility" does not resize the column. - Descriptions in a muted ink, so a category reads as titles with explanations rather than as two columns of equally loud text. ## Two deliberate deviations, both about scale The convention's chevrons are Nerd Font codepoints. Eidos bundles no Nerd Font - it uses PNG icons - so those would render as tofu; the existing triangles stay. Section titles are 14, not the convention's 15. That number assumes Colony's type scale, whose body text is 13 where Eidos's is 12, and 15 would leave a section title as loud as the category heading above it. What the convention specifies is the hierarchy - page, category, section, body - and this keeps it, at 22 / 16 / 14 / 12. Six new tests state the convention where a refactor would otherwise break it silently: the first three categories and their order, About last, every category explaining itself in words it does not repeat, General carrying the no-Save contract, and motion filed under Accessibility. The rank table is an exhaustive match, so adding a category without placing it does not compile. 278 GUI tests, clippy clean on the pinned 1.94.1. --- crates/eidos-gui/src/dialogs.rs | 115 +++++++++++++++++++++++++------- crates/eidos-gui/src/main.rs | 113 +++++++++++++++++++++++++++++-- crates/eidos-gui/src/modinfo.rs | 30 +++++---- crates/eidos-gui/src/theme.rs | 9 +++ crates/eidos-gui/src/widgets.rs | 8 ++- 5 files changed, 231 insertions(+), 44 deletions(-) diff --git a/crates/eidos-gui/src/dialogs.rs b/crates/eidos-gui/src/dialogs.rs index c9ea1f9..3ef864c 100644 --- a/crates/eidos-gui/src/dialogs.rs +++ b/crates/eidos-gui/src/dialogs.rs @@ -52,26 +52,44 @@ fn adult_content_state() -> Option { eidos_instance::settings::load_nexus_creds().adult_pref(now) } -pub(crate) fn settings_dialog<'a>(app: &App) -> Element<'a, Message> { +/// The Preferences page. +/// +/// It **replaces the content area**: not a modal, not a separate window, not a +/// popover, which the Colony convention names as the three counter-examples - +/// and this was the first of them. The name is `preferences_page` rather than +/// `settings_dialog` because it is no longer a dialog, and a function whose name +/// disagrees with what it returns is how the next reader is misled. +pub(crate) fn preferences_page<'a>(app: &App) -> Element<'a, Message> { + // "Preferences", not "Settings". The convention settles the user-facing word + // and Colony, Grape and Xion all say Preferences; only the code here is still + // named `settings_*`, which costs the user nothing and is not worth a rename + // on its own. Size and padding are the convention's too. let header = Row::new() .spacing(6) .align_y(iced::Alignment::Center) - .push(text("Settings").size(18.0).width(Length::Fill)) + .push(text("Preferences").size(22.0).width(Length::Fill)) .push( - button(text("Close").size(12.0)) - .padding([5, 12]) + // The same message the title button sends to OPEN the page: two ways + // out, one message, so they cannot drift apart. + button(text("Close").size(13.0)) + .padding([6, 14]) .on_press(Message::CloseSettings) .style(button::secondary), ); // A vertical rail, as in Colony: five sections do not fit across a dialog, // and a rail takes a sixth without re-laying anything out. - let mut rail = Column::new().spacing(2).width(Length::Fixed(132.0)); + // Wide enough for "Accessibility", the longest of the six, so the rail does + // not resize when the category changes. + let mut rail = Column::new().spacing(2).width(Length::Fixed(148.0)); for tab in SettingsTab::ALL { let active = app.settings_tab == tab; rail = rail.push( button(text(tab.label()).size(12.0).width(Length::Fill)) - .padding([6, 10]) + // The convention's own numbers, and the same selection rules as + // the main list: selected is a filled background, hover is the + // card hover, and the two must not look alike. + .padding([8, 14]) .width(Length::Fill) .on_press(Message::SettingsTabSelected(tab)) .style(if active { button::primary } else { button::text }), @@ -191,22 +209,43 @@ pub(crate) fn settings_dialog<'a>(app: &App) -> Element<'a, Message> { picker.into(), ), )) - .push(settings_section( - "motion", - "Motion", - open("motion"), - settings_toggle( - "Animate the window", - "The tab strips cross-fade and a new status message fades in. \ - Off means they change instantly - nothing is animated more \ - quickly, and no frame timer runs at all. Nothing here moves \ - the layout either way, so turning it off changes no spacing.", - app.prefs.motion, - Message::ToggleMotion(!app.prefs.motion), - ), - )) .into() } + // The convention puts motion under Accessibility, not Appearance, and + // the distinction is not filing: Appearance is what the window looks + // like, Accessibility is what it does to somebody who needs it to do + // less. Reduced motion is the second. + SettingsTab::Accessibility => Column::new() + .spacing(2) + .push(settings_section( + "motion", + "Motion", + open("motion"), + settings_toggle( + "Animate the window", + "The tab strips cross-fade and a new status message fades in. Off means \ + they change instantly - nothing is animated more quickly, none of it \ + runs, and no frame timer is started. No animation moves the layout \ + either way, so turning this off changes no spacing.", + app.prefs.motion, + Message::ToggleMotion(!app.prefs.motion), + ), + )) + .push(settings_section( + "a11y_todo", + "Vision and reading", + open("a11y_todo"), + settings_row( + "Not implemented yet", + "The convention also asks for a high-contrast palette, a dyslexia-friendly \ + font and a text scale here. Eidos has none of the three: every size in \ + this window is a literal, so a scale is a change to the whole GUI rather \ + than a setting. Said out loud rather than shown as switches that do \ + nothing.", + Space::new().into(), + ), + )) + .into(), SettingsTab::ModList => { let speed = app.prefs.drag_scroll_speed; let slider_row = Row::new() @@ -421,12 +460,42 @@ pub(crate) fn settings_dialog<'a>(app: &App) -> Element<'a, Message> { .into(), }; + // Each category opens with its own heading and one line saying what it + // changes. General's line carries the contract: there is no Save button. + let titled = Column::new() + .spacing(2) + .push(text(app.settings_tab.label()).size(16.0)) + .push(text(app.settings_tab.description()).size(11.0).color(TEXT_MUTED)) + .push(Space::new().height(Length::Fixed(8.0))) + .push(body); + let panes = Row::new() .spacing(14) + .height(Length::Fill) .push(rail) - .push(container(scrollable(body)).width(Length::Fill).height(Length::Fixed(240.0))); - let card = Column::new().spacing(12).push(header).push(panes); - container(card).max_width(620.0).padding(16).style(card_style).into() + .push( + container(scrollable(titled)) + .width(Length::Fill) + .height(Length::Fill), + ); + + // A PAGE, not a card floating on a scrim. The convention names a modal, a + // separate window and a popover as the three things this must not be, and + // this was the first of them: it replaces the content area between the + // toolbar and the status bar, and the program's own chrome stays put. + // + // What that buys is not tidiness. A modal capped at 620x240 could not grow, + // so every category was read through a 240-pixel slot however big the window + // was - and the page that most needs room to be scanned was the one with the + // least. + Column::new() + .spacing(12) + .padding(4) + .width(Length::Fill) + .height(Length::Fill) + .push(header) + .push(panes) + .into() } // ---- Executables editor (MO2's Modify Executables) -------------------------- diff --git a/crates/eidos-gui/src/main.rs b/crates/eidos-gui/src/main.rs index 4bbfc24..ed42ec1 100644 --- a/crates/eidos-gui/src/main.rs +++ b/crates/eidos-gui/src/main.rs @@ -118,35 +118,61 @@ enum InfoTab { enum SettingsTab { General, Appearance, + Accessibility, ModList, Nexus, About, } impl SettingsTab { - /// Every section, in the order the sidebar lists them. - pub(crate) const ALL: [SettingsTab; 5] = [ + /// Every category, in the order the rail lists them. + /// + /// The first three are imposed by the Colony convention and must stay in + /// this order: they are what somebody hunting for a setting scans first, and + /// they hold the same things in every program in the ecosystem. After them + /// come Eidos's own, and About last. `the_first_three_categories_are_the_imposed_ones` + /// fails if that is disturbed. + pub(crate) const ALL: [SettingsTab; 6] = [ SettingsTab::General, SettingsTab::Appearance, + SettingsTab::Accessibility, SettingsTab::ModList, SettingsTab::Nexus, SettingsTab::About, ]; - /// The sections open the first time Settings is shown - one per category, so - /// every page says something without a click. - pub(crate) const DEFAULT_OPEN: [&'static str; 5] = - ["startup", "theme", "dragging", "account", "paths"]; + /// The sections open the first time Preferences is shown - one per category, + /// so every page says something without a click. + pub(crate) const DEFAULT_OPEN: [&'static str; 6] = + ["startup", "theme", "motion", "dragging", "account", "paths"]; pub(crate) fn label(self) -> &'static str { match self { SettingsTab::General => "General", SettingsTab::Appearance => "Appearance", + SettingsTab::Accessibility => "Accessibility", SettingsTab::ModList => "Mod list", SettingsTab::Nexus => "Nexus", SettingsTab::About => "About", } } + + /// The line under the category's own heading: what it changes, not its name + /// said again. + pub(crate) fn description(self) -> &'static str { + match self { + // This sentence is imposed by the convention, near enough word for + // word, because it carries the contract that matters most on this + // page: there is no Save button, and its absence has to be explained + // somewhere rather than left to be discovered. + SettingsTab::General => "Preferences are saved automatically.", + SettingsTab::Appearance => "Applies immediately - nothing here needs a restart.", + SettingsTab::Accessibility => "Changes how the window behaves, on any theme.", + SettingsTab::ModList => "How the list of mods behaves under the pointer.", + SettingsTab::Nexus => "The account downloads are fetched with, and what is cached.", + SettingsTab::About => "Version, licence and where this instance lives.", + } + } } #[derive(Debug, Clone)] @@ -3275,6 +3301,81 @@ mod tests { assert!(app.drag_scroll.is_none()); } + /// The rank each category is required to hold, written out separately from + /// `ALL`. The match is exhaustive, so adding a category without giving it a + /// rank does not compile - which is the point, because `ALL` is a + /// hand-written array and nothing else would force the question. + fn expected_rank(tab: SettingsTab) -> usize { + match tab { + SettingsTab::General => 0, + SettingsTab::Appearance => 1, + SettingsTab::Accessibility => 2, + SettingsTab::ModList => 3, + SettingsTab::Nexus => 4, + SettingsTab::About => 5, + } + } + + /// "Do not reorder the first three. They are what a user hunting for a + /// setting scans first" - the ecosystem convention is explicit, and this is + /// exactly the kind of constraint a reshuffle breaks without noticing. + #[test] + fn the_first_three_categories_are_the_imposed_ones_in_order() { + assert_eq!( + &SettingsTab::ALL[..3], + &[SettingsTab::General, SettingsTab::Appearance, SettingsTab::Accessibility], + ); + } + + /// "About last where it exists." + #[test] + fn about_comes_last() { + assert_eq!(SettingsTab::ALL.last(), Some(&SettingsTab::About)); + for (rank, tab) in SettingsTab::ALL.into_iter().enumerate() { + assert_eq!(rank, expected_rank(tab), "{tab:?} is in the wrong place"); + } + } + + /// The contract the page lives by: no Save button, so the sentence that + /// explains its absence has to be somewhere the user will read. + #[test] + fn general_carries_the_no_save_button_contract() { + assert!( + SettingsTab::General.description().contains("saved automatically"), + "General must say preferences save themselves; it says {:?}", + SettingsTab::General.description(), + ); + } + + /// A description that repeats the title teaches nothing. The convention asks + /// it to say the CONSEQUENCE of the category, so no two may be the same and + /// none may echo its own label. + #[test] + fn each_category_explains_itself_and_says_something_different() { + let mut seen: Vec<&str> = SettingsTab::ALL.into_iter().map(|t| t.description()).collect(); + seen.sort_unstable(); + let before = seen.len(); + seen.dedup(); + assert_eq!(before, seen.len(), "two categories share a description"); + + for tab in SettingsTab::ALL { + assert_ne!(tab.label(), tab.description(), "{tab:?} repeats its own name"); + assert!(!tab.description().is_empty()); + } + } + + /// Reduced motion belongs to Accessibility, not to Appearance. Appearance is + /// what the window looks like; Accessibility is what it does to somebody who + /// needs it to do less. + #[test] + fn motion_is_filed_under_accessibility() { + let rank = expected_rank(SettingsTab::Accessibility); + assert_eq!( + SettingsTab::DEFAULT_OPEN[rank], "motion", + "Accessibility must open on its motion section", + ); + } + #[test] fn every_settings_category_opens_with_something_to_read() { // A page whose sections are all shut asks the user to click before it diff --git a/crates/eidos-gui/src/modinfo.rs b/crates/eidos-gui/src/modinfo.rs index 944c1a2..e06dad2 100644 --- a/crates/eidos-gui/src/modinfo.rs +++ b/crates/eidos-gui/src/modinfo.rs @@ -2850,12 +2850,22 @@ pub(crate) fn main_screen(app: &App) -> Element<'_, Message> { ) .on_press(Message::SplitGrab); - let body = Row::new() - .spacing(4) - .height(Length::Fill) - .push(modlist_pane(app)) - .push(divider) - .push(right_pane(app)); + // Preferences REPLACE the content area - they are not a modal, a separate + // window or a popover, which the Colony convention names as the three things + // this must not be. The header, the menu bar, the toolbar and the status bar + // all stay exactly where they are: the program's chrome does not move + // because the user went to configure it. + let body: Element<'_, Message> = if app.settings_open { + preferences_page(app) + } else { + Row::new() + .spacing(4) + .height(Length::Fill) + .push(modlist_pane(app)) + .push(divider) + .push(right_pane(app)) + .into() + }; let mut base = Column::new().spacing(4).padding(4).push(header).push(menu_bar()); if app.ui_toolbar_visible { @@ -2914,14 +2924,6 @@ pub(crate) fn main_screen(app: &App) -> Element<'_, Message> { layers = layers.push(scrim).push(dialog); } - // The Preferences modal (MO2's Settings dialog). - if app.settings_open { - let scrim = - mouse_area(Space::new().width(Length::Fill).height(Length::Fill)).on_press(Message::CloseSettings); - let dialog = container(settings_dialog(app)).center(Length::Fill); - layers = layers.push(scrim).push(dialog); - } - // The Executables editor (MO2's Modify Executables dialog). if let Some(state) = &app.executables { let scrim = mouse_area(Space::new().width(Length::Fill).height(Length::Fill)) diff --git a/crates/eidos-gui/src/theme.rs b/crates/eidos-gui/src/theme.rs index 7fab461..92bb291 100644 --- a/crates/eidos-gui/src/theme.rs +++ b/crates/eidos-gui/src/theme.rs @@ -60,6 +60,15 @@ pub(crate) const DIVIDER: Color = Color::from_rgb8(0xC9, 0xB8, 0x90); /// edge of something". pub(crate) const DIVIDER_HELD: Color = Color::from_rgb8(0x7A, 0x1F, 0x2B); +/// Secondary text: a description under a title, a caption, a hint. +/// +/// The convention asks for descriptions in `text_muted` rather than in the body +/// colour, so that a page of settings reads as titles with explanations under +/// them instead of as two columns of equally loud text. This is the parchment +/// palette's version of it - the same brown as the body ink, lightened until it +/// recedes without becoming hard to read. +pub(crate) const TEXT_MUTED: Color = Color::from_rgb8(0x6A, 0x5A, 0x40); + pub(crate) fn row_bg(even: bool) -> Color { if even { Color::from_rgb8(0xF3, 0xEA, 0xD3) diff --git a/crates/eidos-gui/src/widgets.rs b/crates/eidos-gui/src/widgets.rs index d5fcd75..cbe6eb6 100644 --- a/crates/eidos-gui/src/widgets.rs +++ b/crates/eidos-gui/src/widgets.rs @@ -29,7 +29,13 @@ pub(crate) fn settings_section<'a>( Row::new() .spacing(8) .align_y(iced::Alignment::Center) - .push(text(title).size(13.0).width(Length::Fill)) + // The convention says 15 for a section title. That number assumes + // Colony's type scale, whose body text is 13; Eidos's is 12, and 15 + // here would leave a section title as loud as the category heading + // above it. What the convention is actually specifying is the + // HIERARCHY - page, category, section, body - and that is what this + // keeps: 22 / 16 / 14 / 12. + .push(text(title).size(14.0).width(Length::Fill)) .push(text(chevron).size(10.0)), ) .padding([7, 4]) From ee89817381952d111e9940f6fd4c9b4649311145 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 26 Aug 2026 01:50:01 +0200 Subject: [PATCH 4/4] feat(gui): the 57 shared palettes, and a theme setting that works The theme setting had never done anything. `theme()` ignored its `App` argument and returned the same parchment whatever was picked, so the Light / Dark / System list had changed no pixel since it was written. It is gone, and the key it stored is now an unknown key an old `settings.ini` carries harmlessly. In its place: **25 theme families, 57 palettes and the 8 accent overrides** from the Colony catalogue, generated from the design tokens in Project-Colony-Resources rather than copied here. Adding a family upstream needs no arm, no picker entry and no code in this repository - only a tag bump. ## One palette, filled from one of two places Every colour in this window now comes from one 38-field `ThemePalette`: either `PARCHMENT` - the look Eidos has always worn, written out as those same 38 fields instead of scattered across seventy literals - or one of the catalogue's 57. `theme.rs` is the only file allowed to name a colour; everywhere else asks the palette. A literal hex outside it is a bug, right on one theme and wrong on fifty-seven. The parchment stays the default and keeps its own row at the top of the picker. An upgrade repaints nobody's window, and a picker you cannot come back through is a trap. ## Two colours had to stop being fixed The origin tint - a plugin belonging to the selected mod - was a fixed pale blue. It is now the card tinted towards the accent, and the STRENGTH of the tint is chosen rather than fixed: on several palettes the selection is itself an accent-tinted card, and one ratio landed on top of it - on catppuccin/frappe the two came out 0.02 apart, which is to say identical. Five strengths are measured against the three tints this must never be confused with and the furthest wins. A test walks all 57 palettes and fails if any pair becomes confusable. The FOMOD installer's ink-on-burgundy was a fixed pale colour. On a palette whose accent is light that is unreadable, so it asks `contrast_on` which side can actually be read. ## High contrast, which the convention had been asking for Derived from whichever theme is on rather than shipped as a twin, so it works on the parchment and on all 57. It fills the Vision section the Accessibility category was carrying an apology for. It moves the ink and the lines, never the grounds - a high-contrast mode that repainted the backgrounds would be a different theme rather than the same one read more easily, and a test says so. ## Drawn here, not by the shared widgets `colony-ui` ships a theme picker and an accent picker. Both are built to Colony's type scale, whose body text is 13 where this window's is 12 - a picker a size and a half larger than the page around it. The catalogue is the valuable part and it is shared; the hundred lines that draw it are this program's. The accent list still comes from `ACCENT_OVERRIDES`, never a local copy: its ORDER is load-bearing across the ecosystem. `accent` stores a key or nothing. "Auto" is the absence of an override, never a colour - writing a resolved colour would freeze it against the theme it came from. 289 GUI tests, 39 suites elsewhere, clippy clean on the pinned 1.94.1. Every literal left in the GUI is inside a `#[cfg(test)]` block. --- Cargo.lock | 12 + crates/eidos-gui/Cargo.toml | 7 + crates/eidos-gui/src/dialogs.rs | 298 ++++++++++++++----- crates/eidos-gui/src/fomod.rs | 88 +++--- crates/eidos-gui/src/main.rs | 119 +++++++- crates/eidos-gui/src/modinfo.rs | 38 +-- crates/eidos-gui/src/state.rs | 9 + crates/eidos-gui/src/theme.rs | 411 +++++++++++++++++++++++--- crates/eidos-gui/src/update.rs | 35 ++- crates/eidos-gui/src/view.rs | 31 +- crates/eidos-gui/src/widgets.rs | 24 +- crates/eidos-instance/src/lib.rs | 2 +- crates/eidos-instance/src/settings.rs | 150 ++++++---- 13 files changed, 953 insertions(+), 271 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f596ac..63dda80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -649,6 +649,16 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "colony-ui" +version = "0.1.0" +source = "git+https://github.com/Project-Colony/Project-Colony-Resources?tag=v0.1.0#108ee8010997e69e06929ae84e112a4d8b90ae80" +dependencies = [ + "dirs", + "iced", + "serde_json", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1098,6 +1108,7 @@ dependencies = [ name = "eidos-gui" version = "1.11.2" dependencies = [ + "colony-ui", "eidos-addons", "eidos-conflicts", "eidos-core", @@ -4104,6 +4115,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap", "itoa", "memchr", "serde", diff --git a/crates/eidos-gui/Cargo.toml b/crates/eidos-gui/Cargo.toml index e3d6a45..fcba259 100644 --- a/crates/eidos-gui/Cargo.toml +++ b/crates/eidos-gui/Cargo.toml @@ -33,3 +33,10 @@ eidos-gamefeatures = { path = "../eidos-gamefeatures" } eidos-install = { path = "../eidos-install" } eidos-fomod = { path = "../eidos-fomod" } rfd = "0.17" +# The 25 theme families / 57 palettes of the Colony ecosystem, the eight accent +# overrides, the high-contrast derivation and the shared pickers - generated from +# the design tokens in Project-Colony-Resources rather than copied here. +# +# Pinned to a tag, as Colony and Xion pin it: a change upstream must not be able +# to alter this window without a deliberate bump. +colony-ui = { git = "https://github.com/Project-Colony/Project-Colony-Resources", tag = "v0.1.0" } diff --git a/crates/eidos-gui/src/dialogs.rs b/crates/eidos-gui/src/dialogs.rs index 3ef864c..b9b4694 100644 --- a/crates/eidos-gui/src/dialogs.rs +++ b/crates/eidos-gui/src/dialogs.rs @@ -24,20 +24,6 @@ impl std::fmt::Display for DefaultGameChoice { } } -/// A wrapped theme for the theme `pick_list`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct ThemeChoice(PrefTheme); - -impl std::fmt::Display for ThemeChoice { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self.0 { - PrefTheme::System => "Follow system", - PrefTheme::Light => "Light", - PrefTheme::Dark => "Dark", - }) - } -} - /// The cached "show adult content" answer for the signed-in account: `Some(true)` /// shown, `Some(false)` turned off by the user, `None` not known. /// @@ -59,6 +45,174 @@ fn adult_content_state() -> Option { /// and this was the first of them. The name is `preferences_page` rather than /// `settings_dialog` because it is no longer a dialog, and a function whose name /// disagrees with what it returns is how the next reader is misled. +/// The theme picker: Eidos's own parchment first, then the 25 families of the +/// shared Colony catalogue, 57 palettes in all. +/// +/// Drawn here rather than with `colony_ui::widgets::theme_picker`. The catalogue +/// is the valuable part and it is shared; the widget is a hundred lines built to +/// Colony's type scale, whose body text is 13 where this window's is 12 - a +/// picker a size and a half larger than the page around it. The DATA is shared, +/// the drawing is this program's. +fn theme_picker<'a>(app: &App) -> Element<'a, Message> { + let chosen = (app.prefs.theme_family.as_str(), app.prefs.theme_variant.as_str()); + + // Eidos's own, on its own row, because it is not in the catalogue and must + // still be reachable - a picker you cannot come back through is a trap. + let mut col = Column::new().spacing(10).push( + Column::new() + .spacing(4) + .push(text(theme::OWN_LABEL).size(12.0)) + .push( + Row::new().spacing(6).push(theme_card( + theme::OWN_VARIANT_LABEL, + theme::PARCHMENT.bg_primary, + theme::PARCHMENT.accent_blue, + chosen == (theme::OWN_FAMILY, theme::OWN_VARIANT), + Message::ThemeChanged( + theme::OWN_FAMILY.to_string(), + theme::OWN_VARIANT.to_string(), + ), + )), + ), + ); + + // And the catalogue, straight from `THEME_FAMILIES`. Adding a family + // upstream needs no change here and no new arm: that is the whole point of + // the generated catalogue. + for family in colony_ui::THEME_FAMILIES { + let mut variants = Row::new().spacing(6); + for variant in family.variants { + variants = variants.push(theme_card( + colony_ui::i18n::t(variant.label_key), + variant.swatch_bg_color(), + variant.swatch_accent_color(), + chosen == (family.key, variant.key), + Message::ThemeChanged(family.key.to_string(), variant.key.to_string()), + )); + } + col = col.push( + Column::new() + .spacing(4) + .push(text(colony_ui::i18n::t(family.label_key)).size(12.0)) + .push(variants), + ); + } + + scrollable(col).height(Length::Fixed(300.0)).into() +} + +/// One card: a field of the variant's background crossed by a bar of its accent, +/// its name underneath, a border when it is the chosen one. +/// +/// The swatch comes from the tokens rather than being recomputed, so a card +/// always resembles the theme it selects. A picker whose cards do not is a +/// picker that lies. +fn theme_card<'a>( + label: &'a str, + bg: Color, + accent_of: Color, + active: bool, + msg: Message, +) -> Element<'a, Message> { + let swatch = container( + container(Space::new().width(Length::Fill).height(Length::Fixed(3.0))).style( + move |_t: &Theme| container::Style { + background: Some(Background::Color(accent_of)), + border: Border { radius: 2.0.into(), ..Default::default() }, + ..Default::default() + }, + ), + ) + .width(Length::Fill) + .height(Length::Fixed(24.0)) + .padding(iced::Padding { top: 16.0, right: 5.0, bottom: 3.0, left: 5.0 }) + .style(move |_t: &Theme| container::Style { + background: Some(Background::Color(bg)), + border: Border { radius: 4.0.into(), ..Default::default() }, + ..Default::default() + }); + + button( + Column::new() + .spacing(2) + .width(Length::Fixed(78.0)) + .push(swatch) + .push(text(label).size(10.0)), + ) + .padding(3) + .on_press(msg) + .style(move |_t: &Theme, status: button::Status| button::Style { + background: Some(Background::Color(pal().bg_card)), + text_color: pal().text_primary, + border: Border { + color: if active { + accent() + } else if matches!(status, button::Status::Hovered) { + pal().text_dimmer + } else { + pal().border_subtle + }, + width: if active { 2.0 } else { 1.0 }, + radius: 5.0.into(), + }, + ..Default::default() + }) + .into() +} + +/// The eight accent overrides, plus the way back to the theme's own. +/// +/// The list and its ORDER come from `ACCENT_OVERRIDES`, generated from +/// `tokens/accents.toml`. Copying it here would be the mistake Colony had made: +/// the order is load-bearing across the ecosystem. +fn accent_picker<'a>(app: &App) -> Element<'a, Message> { + let chosen = app.prefs.accent.as_deref(); + let mut row = Row::new().spacing(6).align_y(iced::Alignment::Center); + + for a in colony_ui::ACCENT_OVERRIDES { + let active = chosen == Some(a.key); + let dot = colony_ui::hex(a.color); + row = row.push( + button(Space::new().width(Length::Fixed(20.0)).height(Length::Fixed(20.0))) + .padding(0) + .on_press(Message::AccentChanged(Some(a.key.to_string()))) + .style(move |_t: &Theme, status: button::Status| button::Style { + background: Some(Background::Color(dot)), + border: Border { + color: if active { + pal().text_primary + } else if matches!(status, button::Status::Hovered) { + pal().text_dimmer + } else { + Color::TRANSPARENT + }, + width: if active { 2.0 } else { 0.0 }, + radius: 10.0.into(), + }, + ..Default::default() + }), + ); + } + + // "Auto" is the ABSENCE of an override, not a ninth colour - so it is a + // button that clears, not a swatch that sets. + let clear = button(text("Theme's own").size(11.0)) + .padding([3, 8]) + .on_press(Message::AccentChanged(None)) + .style(if chosen.is_none() { button::secondary } else { button::text }); + + Column::new() + .spacing(6) + .push(row) + .push(clear) + .push( + text("With no accent picked, the theme's own is used.") + .size(10.0) + .color(text_muted()), + ) + .into() +} + pub(crate) fn preferences_page<'a>(app: &App) -> Element<'a, Message> { // "Preferences", not "Settings". The convention settles the user-facing word // and Colony, Grape and Xion all say Preferences; only the code here is still @@ -186,31 +340,21 @@ pub(crate) fn preferences_page<'a>(app: &App) -> Element<'a, Message> { )) .into() } - SettingsTab::Appearance => { - let themes = vec![ - ThemeChoice(PrefTheme::System), - ThemeChoice(PrefTheme::Light), - ThemeChoice(PrefTheme::Dark), - ]; - let picker = pick_list(themes, Some(ThemeChoice(app.prefs.theme)), |c: ThemeChoice| { - Message::ThemeChanged(c.0) - }) - .text_size(12.0) - .padding(6); - Column::new() - .spacing(2) - .push(settings_section( - "theme", - "Theme", - open("theme"), - settings_row( - "Colour theme", - "System follows your desktop's light/dark preference.", - picker.into(), - ), - )) - .into() - } + SettingsTab::Appearance => Column::new() + .spacing(2) + .push(settings_section( + "theme", + "Theme", + open("theme"), + theme_picker(app), + )) + .push(settings_section( + "accent", + "Colours", + open("accent"), + accent_picker(app), + )) + .into(), // The convention puts motion under Accessibility, not Appearance, and // the distinction is not filing: Appearance is what the window looks // like, Accessibility is what it does to somebody who needs it to do @@ -232,18 +376,28 @@ pub(crate) fn preferences_page<'a>(app: &App) -> Element<'a, Message> { ), )) .push(settings_section( - "a11y_todo", - "Vision and reading", - open("a11y_todo"), - settings_row( - "Not implemented yet", - "The convention also asks for a high-contrast palette, a dyslexia-friendly \ - font and a text scale here. Eidos has none of the three: every size in \ - this window is a literal, so a scale is a change to the whole GUI rather \ - than a setting. Said out loud rather than shown as switches that do \ - nothing.", - Space::new().into(), - ), + "vision", + "Vision", + open("vision"), + Column::new() + .spacing(2) + .push(settings_toggle( + "High contrast", + "Strengthens the separation between surfaces and text. Derived from \ + whichever theme is on, so it works on the parchment and on all 57 \ + palettes - no theme ships a separate high-contrast twin.", + app.prefs.high_contrast, + Message::ToggleHighContrast(!app.prefs.high_contrast), + )) + .push(settings_row( + "Text size", + "Not implemented. The convention asks for a text scale and a \ + dyslexia-friendly font here; every size in this window is a literal, \ + so a scale is a change to the whole GUI rather than a setting. Said \ + out loud rather than shown as a switch that does nothing.", + Space::new().into(), + )) + .into(), )) .into(), SettingsTab::ModList => { @@ -325,7 +479,7 @@ pub(crate) fn preferences_page<'a>(app: &App) -> Element<'a, Message> { } if let Some(err) = &app.nexus_error { account = account - .push(text(format!("Error: {err}")).size(11.0).color(Color::from_rgb8(0x8A, 0x2A, 0x2A))); + .push(text(format!("Error: {err}")).size(11.0).color(pal().error)); } Column::new() @@ -465,7 +619,7 @@ pub(crate) fn preferences_page<'a>(app: &App) -> Element<'a, Message> { let titled = Column::new() .spacing(2) .push(text(app.settings_tab.label()).size(16.0)) - .push(text(app.settings_tab.description()).size(11.0).color(TEXT_MUTED)) + .push(text(app.settings_tab.description()).size(11.0).color(text_muted())) .push(Space::new().height(Length::Fixed(8.0))) .push(body); @@ -825,9 +979,9 @@ pub(crate) fn cap_warning_banner<'a>() -> Element<'a, Message> { container(text(cmd).size(11.0)) .padding([2, 8]) .style(|_| container::Style { - background: Some(Background::Color(Color::from_rgb8(0xF3, 0xEA, 0xD3))), + background: Some(Background::Color(pal().bg_card)), border: Border { - color: Color::from_rgb8(0xB0, 0x6A, 0x10), + color: pal().warning, width: 1.0, radius: 3.0.into(), }, @@ -840,13 +994,13 @@ pub(crate) fn cap_warning_banner<'a>() -> Element<'a, Message> { .width(Length::Fill) .padding([4, 8]) .style(|_| container::Style { - background: Some(Background::Color(Color::from_rgb8(0xF6, 0xE3, 0xC0))), + background: Some(Background::Color(pal().warning_bg)), border: Border { - color: Color::from_rgb8(0xB0, 0x6A, 0x10), + color: pal().warning, width: 1.0, radius: 4.0.into(), }, - text_color: Some(Color::from_rgb8(0x6B, 0x42, 0x0A)), + text_color: Some(pal().warning), ..Default::default() }) .into() @@ -875,7 +1029,7 @@ pub(crate) fn running_lock_card<'a>(run: &RunningState) -> Element<'a, Message> .push( text("Unlock re-enables the GUI but leaves the game running.") .size(10.0) - .color(Color::from_rgb8(0x6A, 0x5A, 0x40)), + .color(text_muted()), ); container(card).max_width(470.0).padding(20).style(card_style).into() } @@ -923,9 +1077,9 @@ pub(crate) fn split_markdown_links(text: &str) -> Vec<(String, Option)> pub(crate) fn loot_message_row<'a>(m: &eidos_loot::LootMessage) -> Element<'a, Message> { use eidos_loot::MessageType; let (prefix, color) = match m.kind { - MessageType::Error => ("Error: ", Color::from_rgb8(0x8A, 0x2A, 0x2A)), - MessageType::Warn => ("Warning: ", Color::from_rgb8(0xB0, 0x6A, 0x10)), - MessageType::Say => ("", Color::from_rgb8(0x4A, 0x40, 0x30)), + MessageType::Error => ("Error: ", pal().error), + MessageType::Warn => ("Warning: ", pal().warning), + MessageType::Say => ("", pal().text_secondary), }; let parts = split_markdown_links(&m.text); if parts.iter().all(|(_, url)| url.is_none()) { @@ -938,7 +1092,7 @@ pub(crate) fn loot_message_row<'a>(m: &eidos_loot::LootMessage) -> Element<'a, M for (label, url) in parts { row = match url { Some(u) => row.push( - button(text(label).size(11.0).color(Color::from_rgb8(0x2B, 0x4F, 0x8A))) + button(text(label).size(11.0).color(accent())) .padding(0) .on_press(Message::OpenUrl(u)) .style(button::text), @@ -993,7 +1147,7 @@ pub(crate) fn loot_report_dialog<'a>(report: &eidos_loot::LootReport) -> Element sec = sec.push( text(format!("Missing masters: {}", p.missing_masters.join(", "))) .size(11.0) - .color(Color::from_rgb8(0x8A, 0x2A, 0x2A)), + .color(pal().error), ); } for m in &p.messages { @@ -1007,7 +1161,7 @@ pub(crate) fn loot_report_dialog<'a>(report: &eidos_loot::LootReport) -> Element d.itm_count, d.deleted_reference_count, d.deleted_navmesh_count )) .size(11.0) - .color(Color::from_rgb8(0xB0, 0x6A, 0x10)), + .color(pal().warning), ); } body = body.push(sec); @@ -1555,8 +1709,8 @@ pub(crate) fn log_pane_dialog<'a>(state: &LogPaneState) -> Element<'a, Message> } for (lvl, msg) in &state.lines { let colour = match lvl { - Level::Error => Some(CONFLICT_LOSES_FG), - Level::Warn => Some(Color::from_rgb8(0x8A, 0x5A, 0x00)), + Level::Error => Some(conflict_loses_fg()), + Level::Warn => Some(pal().warning), _ => None, }; let mut line = text(format!("{:<5} {msg}", lvl.as_str())).size(11.0).font(iced::Font::MONOSPACE); @@ -1609,7 +1763,7 @@ pub(crate) fn addons_dialog<'a>(app: &App) -> Element<'a, Message> { .push( text(path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()) .size(13.0) - .color(CONFLICT_LOSES_FG), + .color(conflict_loses_fg()), ) .push(text(format!("refused: {why}")).size(11.0)), ); @@ -1754,7 +1908,7 @@ pub(crate) fn export_dialog<'a>(app: &App, state: &ExportDialogState) -> Element }, ); if state.picked().is_empty() { - cols = cols.push(text("Tick at least one column.").size(11.0).color(CONFLICT_LOSES_FG)); + cols = cols.push(text("Tick at least one column.").size(11.0).color(conflict_loses_fg())); } let run = button(text("Export...").size(12.0)) @@ -1984,7 +2138,7 @@ pub(crate) fn collection_dialog<'a>(state: &CollectionState) -> Element<'a, Mess let mut card = Column::new().spacing(10).push(header).push(field); if let Some(e) = &state.error { - card = card.push(text(e.clone()).size(12.0).color(CONFLICT_LOSES_FG)); + card = card.push(text(e.clone()).size(12.0).color(conflict_loses_fg())); } if let Some(rev) = &state.revision { @@ -2048,9 +2202,9 @@ pub(crate) fn collection_dialog<'a>(state: &CollectionState) -> Element<'a, Mess let mut rows = Column::new().spacing(1); for (i, (m, st)) in rev.mods.iter().zip(&state.states).enumerate() { let (label, colour) = match st { - MemberState::Installed => ("installed", Some(CONFLICT_WINS_FG)), + MemberState::Installed => ("installed", Some(conflict_wins_fg())), MemberState::Downloaded => ("downloaded", None), - MemberState::Missing => ("missing", Some(CONFLICT_LOSES_FG)), + MemberState::Missing => ("missing", Some(conflict_loses_fg())), }; let mut status = text(label.to_string()).size(11.0).width(Length::Fixed(84.0)); if let Some(c) = colour { diff --git a/crates/eidos-gui/src/fomod.rs b/crates/eidos-gui/src/fomod.rs index 5807867..438dc52 100644 --- a/crates/eidos-gui/src/fomod.rs +++ b/crates/eidos-gui/src/fomod.rs @@ -58,17 +58,37 @@ pub(crate) const FOMOD_PREVIEW_H: f32 = 420.0; // window uses; the wizard only ever looked out of place because it was drawn with // iced's stock `button::secondary` and an ASCII `[x]`, not because it was missing // anything iced cannot do. -pub(crate) const FOMOD_RULE: Color = Color::from_rgb(0.81, 0.75, 0.63); // hairlines and dividers -pub(crate) const FOMOD_ROW_BG: Color = Color::from_rgb(0.89, 0.84, 0.72); // an unselected option -pub(crate) const FOMOD_ROW_HOVER: Color = Color::from_rgb(0.93, 0.88, 0.78); +/// The colour of the installer's hairlines and dividers. (`fomod_rule` below +/// is the widget that draws one.) +pub(crate) fn fomod_rule_color() -> Color { + pal().border_subtle +} +/// An option nobody has ticked. +pub(crate) fn fomod_row_bg() -> Color { + pal().bg_card_hover +} +pub(crate) fn fomod_row_hover() -> Color { + pal().bg_card +} // Both inks are measured against the page (0xECDFC2): SOFT reaches 6.2:1 and FAINT // 4.5:1, the WCAG floor for text this small. The first pass had FAINT at 2.9:1, // which is a decorative grey, not a legible one - and it was carrying "required" // and "recommended", the only guidance the mod author gives. Below ~10px there is // no room for a genuinely faint tier, so the hierarchy lives in size and weight. -pub(crate) const FOMOD_INK_SOFT: Color = Color::from_rgb(0.36, 0.30, 0.23); // descriptions, tags -pub(crate) const FOMOD_INK_FAINT: Color = Color::from_rgb(0.44, 0.38, 0.30); // group metadata -pub(crate) const FOMOD_PARCHMENT: Color = Color::from_rgb(0.95, 0.92, 0.83); // ink on burgundy +/// Descriptions and tags. +pub(crate) fn fomod_ink_soft() -> Color { + pal().text_secondary +} +/// Group metadata. +pub(crate) fn fomod_ink_faint() -> Color { + pal().text_muted +} +/// Ink ON the accent. Not a fixed pale colour: on a dark palette the accent can +/// be light, and pale ink on it is unreadable. `contrast_on` picks the side that +/// can actually be read. +pub(crate) fn fomod_on_accent() -> Color { + colony_ui::contrast_on(accent()) +} /// The circle or square in front of an option, drawn rather than written. /// @@ -84,11 +104,11 @@ pub(crate) fn fomod_marker<'a>(on: bool, usable: bool, radio: bool) -> Element<' // `!usable` first painted the marker in dark ink on that burgundy, so an option // that was both ticked and forbidden showed a tick you could not see. let ink = if on { - FOMOD_PARCHMENT + fomod_on_accent() } else if !usable { - FOMOD_INK_FAINT + fomod_ink_faint() } else { - FOMOD_INK_SOFT + fomod_ink_soft() }; // The dot fills what the 4px inset leaves it, rather than being a fixed size // that gets centred. Centring a 7px dot in a 14px ring asks the renderer for a @@ -138,7 +158,7 @@ pub(crate) fn fomod_rule<'a>(vertical: bool) -> Element<'a, Message> { .width(w) .height(h) .style(|_t: &Theme| container::Style { - background: Some(Background::Color(FOMOD_RULE)), + background: Some(Background::Color(fomod_rule_color())), ..Default::default() }) .into() @@ -195,13 +215,13 @@ pub(crate) fn fomod_wizard_view(w: &FomodWizard) -> Element<'_, Message> { .align_y(iced::Alignment::Center) .push(text(config.module_name.clone()).size(17.0).font(bold).width(Length::Fill)); if let Some(s) = step { - title = title.push(text(s.name.clone()).size(12.0).color(FOMOD_INK_FAINT)); + title = title.push(text(s.name.clone()).size(12.0).color(fomod_ink_faint())); } let head = title.push( // The step counter as a chip, so it reads as status rather than as one // more sentence competing with the mod's name. container( - text(format!("Step {shown_no} of {total}")).size(11.0).color(FOMOD_PARCHMENT), + text(format!("Step {shown_no} of {total}")).size(11.0).color(fomod_on_accent()), ) .padding([3, 9]) .style(|t: &Theme| container::Style { @@ -232,14 +252,14 @@ pub(crate) fn fomod_wizard_view(w: &FomodWizard) -> Element<'_, Message> { .push( text(group.name.clone()) .size(11.0) - .color(FOMOD_INK_SOFT) + .color(fomod_ink_soft()) .width(Length::Fill), ) - .push(text("·").size(11.0).color(FOMOD_INK_FAINT)) + .push(text("·").size(11.0).color(fomod_ink_faint())) .push( text(group_type_label(group.group_type)) .size(11.0) - .color(FOMOD_INK_FAINT), + .color(fomod_ink_faint()), ), ) .padding([9, 4]), @@ -285,11 +305,11 @@ pub(crate) fn fomod_wizard_view(w: &FomodWizard) -> Element<'_, Message> { _ => "", }; let label = if on { - FOMOD_PARCHMENT + fomod_on_accent() } else if usable { palette().text } else { - FOMOD_INK_FAINT + fomod_ink_faint() }; let row = Row::new() .spacing(8) @@ -299,9 +319,9 @@ pub(crate) fn fomod_wizard_view(w: &FomodWizard) -> Element<'_, Message> { // 10px, not 9.5, and in the darker ink: this string carries the // author's own guidance and was being rendered at 2.7:1. .push(text(tag).size(10.0).color(if on { - FOMOD_PARCHMENT + fomod_on_accent() } else { - FOMOD_INK_SOFT + fomod_ink_soft() })); let mut b = button(row) .padding([7, 9]) @@ -313,17 +333,17 @@ pub(crate) fn fomod_wizard_view(w: &FomodWizard) -> Element<'_, Message> { let bg = if on { t.palette().primary } else if !usable { - Color { a: 0.35, ..FOMOD_ROW_BG } + Color { a: 0.35, ..fomod_row_bg() } } else if hovered { - FOMOD_ROW_HOVER + fomod_row_hover() } else { - FOMOD_ROW_BG + fomod_row_bg() }; button::Style { background: Some(Background::Color(bg)), text_color: label, border: Border { - color: if on { t.palette().primary } else { FOMOD_RULE }, + color: if on { t.palette().primary } else { fomod_rule_color() }, width: 1.0, radius: 5.0.into(), }, @@ -360,7 +380,7 @@ pub(crate) fn fomod_wizard_view(w: &FomodWizard) -> Element<'_, Message> { .into(), // INK_SOFT, not FAINT: this sits on the preview fill, which is darker than // the page, so the faint ink fell to 2.4:1 and the box just read as blank. - None => container(text("No preview for this option.").size(12.0).color(FOMOD_INK_SOFT)) + None => container(text("No preview for this option.").size(12.0).color(fomod_ink_soft())) .center_x(Length::Fill) .center_y(Length::Fill) .into(), @@ -371,9 +391,9 @@ pub(crate) fn fomod_wizard_view(w: &FomodWizard) -> Element<'_, Message> { .height(Length::Fixed(FOMOD_PREVIEW_H)) .padding(8) .style(|_t: &Theme| container::Style { - background: Some(Background::Color(Color::from_rgb8(0xD9, 0xC9, 0xA8))), + background: Some(Background::Color(pal().bg_progress)), border: Border { - color: FOMOD_RULE, + color: fomod_rule_color(), width: 1.0, radius: 6.0.into(), }, @@ -389,7 +409,7 @@ pub(crate) fn fomod_wizard_view(w: &FomodWizard) -> Element<'_, Message> { .push(text(p.name.clone()).size(13.0).font(bold).width(Length::Fill)); if !p.description.is_empty() { d = d.push( - text(p.description.clone()).size(12.0).color(FOMOD_INK_SOFT).width(Length::Fill), + text(p.description.clone()).size(12.0).color(fomod_ink_soft()).width(Length::Fill), ); } // Scrollable, because the preview box above it is a hard 420px and the pane @@ -409,7 +429,7 @@ pub(crate) fn fomod_wizard_view(w: &FomodWizard) -> Element<'_, Message> { let mut nav = Row::new().spacing(8).align_y(iced::Alignment::Center); if !valid { nav = nav.push( - text("Select the required option(s) to continue.").size(11.0).color(FOMOD_INK_FAINT), + text("Select the required option(s) to continue.").size(11.0).color(fomod_ink_faint()), ); } nav = nav.push(Space::new().width(Length::Fill)); @@ -476,17 +496,17 @@ pub(crate) fn fomod_btn<'a>(label: &'a str, msg: Option, primary: bool) // moment the user most needs to read a button is when it will not let // them past. Opaque fill, dark ink: readable, and unmistakably inert. let (bg, fg) = match (primary, live, hovered) { - (true, true, false) => (p, FOMOD_PARCHMENT), - (true, true, true) => (Color { a: 0.85, ..p }, FOMOD_PARCHMENT), - (true, false, _) => (FOMOD_ROW_BG, FOMOD_INK_SOFT), - (false, _, true) => (FOMOD_ROW_HOVER, t.palette().text), - (false, _, false) => (FOMOD_ROW_BG, t.palette().text), + (true, true, false) => (p, fomod_on_accent()), + (true, true, true) => (Color { a: 0.85, ..p }, fomod_on_accent()), + (true, false, _) => (fomod_row_bg(), fomod_ink_soft()), + (false, _, true) => (fomod_row_hover(), t.palette().text), + (false, _, false) => (fomod_row_bg(), t.palette().text), }; button::Style { background: Some(Background::Color(bg)), text_color: fg, border: Border { - color: if primary && live { Color { a: 0.0, ..p } } else { FOMOD_RULE }, + color: if primary && live { Color { a: 0.0, ..p } } else { fomod_rule_color() }, width: 1.0, radius: 5.0.into(), }, diff --git a/crates/eidos-gui/src/main.rs b/crates/eidos-gui/src/main.rs index ed42ec1..e23f088 100644 --- a/crates/eidos-gui/src/main.rs +++ b/crates/eidos-gui/src/main.rs @@ -21,7 +21,7 @@ use iced::widget; use iced::{Background, Border, Color, Element, Length, Task, Theme}; use eidos_games::{detect, home, DetectedGame}; -use eidos_instance::settings::{Settings, Theme as PrefTheme}; +use eidos_instance::settings::Settings; use eidos_instance::{ExportScope, Instance, InstanceKind, ModEntry, SaveEntry, Tool}; use eidos_plugins::{plugins_txt_dir, GameSpec, MovableRange, PluginList}; use eidos_conflicts::{ConflictMap, ConflictState, Layer}; @@ -47,7 +47,8 @@ mod widgets; mod wizard; use dialogs::*; -use fomod::{fomod_wizard_view, FOMOD_INK_FAINT, FOMOD_INK_SOFT}; +use fomod::{fomod_ink_faint, fomod_ink_soft, fomod_wizard_view}; +use theme::pal; use modinfo::*; use state::*; use update::*; @@ -415,7 +416,13 @@ enum Message { /// Forget the stored Nexus session. NexusSignOut, /// Set the preferred colour theme. - ThemeChanged(PrefTheme), + /// Pick a palette: a `(family, variant)` pair from the shared catalogue, or + /// Eidos's own parchment. + ThemeChanged(String, String), + /// Pick an accent override, or `None` to go back to the theme's own. + AccentChanged(Option), + /// Boost the separation between surfaces and text on whatever theme is on. + ToggleHighContrast(bool), /// Set the default game id to open (`None` = none). DefaultGameChanged(Option), /// Toggle "lock the GUI while a game/tool runs" (MO2's `lock_gui`). @@ -2693,7 +2700,7 @@ fn prereq_status_rows<'a>(app: &App, prereqs: &str) -> Element<'a, Message> { done.extend(eidos_gamefeatures::verbs_in_prefix(&prefix.join("pfx"))); } - let mut col = Column::new().spacing(2).push(text("Status").size(11.0).color(FOMOD_INK_FAINT)); + let mut col = Column::new().spacing(2).push(text("Status").size(11.0).color(fomod_ink_faint())); let mut any_missing = false; for v in verbs { let (label, missing) = prereq_state(&v, &done); @@ -2703,9 +2710,9 @@ fn prereq_status_rows<'a>(app: &App, prereqs: &str) -> Element<'a, Message> { .spacing(8) .push(text(v.clone()).size(11.0).width(Length::Fixed(150.0))) .push(text(label).size(11.0).color(if missing { - Color::from_rgb8(0x8A, 0x2A, 0x2A) + pal().error } else { - FOMOD_INK_SOFT + fomod_ink_soft() })); col = if missing { col.push( @@ -2722,7 +2729,7 @@ fn prereq_status_rows<'a>(app: &App, prereqs: &str) -> Element<'a, Message> { col = col.push( text("Downloads run in the background; the status bar reports when they finish.") .size(10.0) - .color(FOMOD_INK_FAINT), + .color(fomod_ink_faint()), ); } col.into() @@ -3319,6 +3326,90 @@ mod tests { /// "Do not reorder the first three. They are what a user hunting for a /// setting scans first" - the ecosystem convention is explicit, and this is /// exactly the kind of constraint a reshuffle breaks without noticing. + #[test] + fn picking_a_theme_takes_effect_at_once_and_is_written_down() { + let mut app = nav_app(&[]); + // Everyone starts on the parchment - an upgrade repaints nobody. + assert_eq!(app.prefs.theme_family, "eidos"); + assert_eq!(theme::pal().bg_primary, theme::PARCHMENT.bg_primary); + + let _ = update( + &mut app, + Message::ThemeChanged("nord".to_string(), "dark".to_string()), + ); + assert_eq!(app.prefs.theme_family, "nord"); + // The palette is a global that every style closure reads: changing the + // preference alone would leave the window drawing the old theme. + assert_ne!(theme::pal().bg_primary, theme::PARCHMENT.bg_primary); + assert_eq!(theme::pal().bg_primary, colony_ui::resolve("nord", "dark").bg_primary); + + // And it survives the file. + assert_eq!( + eidos_instance::Settings::parse(&app.prefs.to_ini()).theme_family, + "nord" + ); + + let _ = update( + &mut app, + Message::ThemeChanged( + theme::OWN_FAMILY.to_string(), + theme::OWN_VARIANT.to_string(), + ), + ); + assert_eq!(theme::pal().bg_primary, theme::PARCHMENT.bg_primary, "no way back"); + } + + #[test] + fn an_accent_can_be_picked_and_given_back() { + let mut app = nav_app(&[]); + let own = theme::accent(); + + let _ = update(&mut app, Message::AccentChanged(Some("green".to_string()))); + assert_eq!(app.prefs.accent.as_deref(), Some("green")); + assert_ne!(theme::accent(), own, "the override did not reach the window"); + + // "Auto" is the absence of an override, not a ninth colour. + let _ = update(&mut app, Message::AccentChanged(None)); + assert_eq!(app.prefs.accent, None); + assert_eq!(theme::accent(), own); + assert!(!app.prefs.to_ini().contains("accent=")); + } + + #[test] + fn high_contrast_reaches_the_window_and_is_saved() { + let mut app = nav_app(&[]); + let plain = theme::pal().text_primary; + + let _ = update(&mut app, Message::ToggleHighContrast(true)); + assert!(app.prefs.high_contrast); + assert_ne!(theme::pal().text_primary, plain, "the boost never took effect"); + assert!(eidos_instance::Settings::parse(&app.prefs.to_ini()).high_contrast); + + let _ = update(&mut app, Message::ToggleHighContrast(false)); + assert_eq!(theme::pal().text_primary, plain); + } + + /// The catalogue is data, not code: the picker draws whatever it holds, so a + /// family added upstream needs no arm here. This states the size it has, so + /// a bump that silently loses half of it is visible. + #[test] + fn the_shared_catalogue_carries_what_it_should() { + let families = colony_ui::THEME_FAMILIES.len(); + let variants: usize = + colony_ui::THEME_FAMILIES.iter().map(|f| f.variants.len()).sum(); + assert_eq!(families, 25, "theme families"); + assert_eq!(variants, 57, "theme palettes"); + assert_eq!(colony_ui::ACCENT_OVERRIDES.len(), 8, "accent overrides"); + + // Every one of them resolves to a palette that is actually filled in. + for f in colony_ui::THEME_FAMILIES { + for v in f.variants { + let p = colony_ui::resolve(f.key, v.key); + assert_eq!(p.bg_primary.a, 1.0, "{}/{} has no background", f.key, v.key); + } + } + } + #[test] fn the_first_three_categories_are_the_imposed_ones_in_order() { assert_eq!( @@ -4104,8 +4195,8 @@ mod tests { app.conflicts = Some(ConflictMap { files: Default::default(), mods, names: HashMap::new() }); app.selected_mod = Some(1); - assert_eq!(conflict_tint(&app, 0), Some(CONFLICT_WINS_BG), "the row it beats"); - assert_eq!(conflict_tint(&app, 2), Some(CONFLICT_LOSES_BG), "the row that beats it"); + assert_eq!(conflict_tint(&app, 0), Some(conflict_wins_bg()), "the row it beats"); + assert_eq!(conflict_tint(&app, 2), Some(conflict_loses_bg()), "the row that beats it"); assert_eq!(conflict_tint(&app, 1), None, "the focused row keeps its selection colour"); // Nothing focused, nothing tinted. @@ -7962,21 +8053,21 @@ mod tests { fn the_row_colour_has_exactly_one_owner() { // The fill and the fade must agree, always. They agree because they ask // the same function - this pins the precedence they both inherit. - let conflict = Some(CONFLICT_WINS_BG); + let conflict = Some(conflict_wins_bg()); assert_eq!( row_background(true, true, conflict, None), - SEL_BG, + sel_bg(), "selection outranks the conflict tint" ); - assert_eq!(row_background(true, false, conflict, None), CONFLICT_WINS_BG); + assert_eq!(row_background(true, false, conflict, None), conflict_wins_bg()); assert_eq!(row_background(true, false, None, None), row_bg(true)); assert_eq!(row_background(false, false, None, None), row_bg(false)); // A user colour paints when nothing more urgent is asking for the row, // and yields to both selection and a live conflict answer. let tint = mod_tint([0x2e, 0x5e, 0x8b], true); assert_eq!(row_background(true, false, None, Some(tint)), tint); - assert_eq!(row_background(true, false, conflict, Some(tint)), CONFLICT_WINS_BG); - assert_eq!(row_background(true, true, None, Some(tint)), SEL_BG); + assert_eq!(row_background(true, false, conflict, Some(tint)), conflict_wins_bg()); + assert_eq!(row_background(true, true, None, Some(tint)), sel_bg()); // And it is a WASH: closer to the stripe than to the raw colour. let raw = Color::from_rgb8(0x2e, 0x5e, 0x8b); let d = |a: Color, b: Color| (a.r - b.r).abs() + (a.g - b.g).abs() + (a.b - b.b).abs(); diff --git a/crates/eidos-gui/src/modinfo.rs b/crates/eidos-gui/src/modinfo.rs index e06dad2..6787068 100644 --- a/crates/eidos-gui/src/modinfo.rs +++ b/crates/eidos-gui/src/modinfo.rs @@ -3,7 +3,7 @@ //! //! Split out of `main.rs` unchanged, and by far the largest single block in it. -use crate::fomod::{FOMOD_INK_FAINT, FOMOD_INK_SOFT}; +use crate::fomod::{fomod_ink_faint, fomod_ink_soft}; use crate::theme::*; use crate::widgets::*; use crate::*; @@ -426,9 +426,9 @@ pub(crate) fn mod_info_dialog<'a>(app: &App, i: usize) -> Element<'a, Message> { .height(Length::Fixed(460.0)) .padding(16) .style(|_t: &Theme| container::Style { - background: Some(Background::Color(Color::from_rgb8(0xEC, 0xDF, 0xC2))), + background: Some(Background::Color(pal().bg_primary)), border: Border { - color: Color::from_rgb8(0x6E, 0x24, 0x2E), + color: accent(), width: 2.0, radius: 4.0.into(), }, @@ -512,7 +512,7 @@ pub(crate) fn data_panel<'a>(app: &App) -> Element<'a, Message> { // The colour is the one the mod list already uses for "this wins the // file", because that is exactly what the row is saying. let label: Element<'a, Message> = if r.row.conflicted { - text(format!("{} *", r.row.name)).size(12.0).color(CONFLICT_WINS_FG).into() + text(format!("{} *", r.row.name)).size(12.0).color(conflict_wins_fg()).into() } else { text(r.row.name.clone()).size(12.0).into() }; @@ -697,7 +697,7 @@ pub(crate) fn overwrite_panel<'a>(app: &App) -> Element<'a, Message> { .push(text(r.name).size(11.5)); if let Some(n) = r.files { // How much is under a folder, so a closed one still says something. - row = row.push(text(format!(" {n}")).size(10.0).color(FOMOD_INK_FAINT)); + row = row.push(text(format!(" {n}")).size(10.0).color(fomod_ink_faint())); } c = c.push(row); } @@ -1163,7 +1163,7 @@ pub(crate) fn downloads_panel<'a>(app: &App) -> Element<'a, Message> { // the download went forwards. let readout = text(label) .size(9.5) - .color(FOMOD_INK_SOFT) + .color(fomod_ink_soft()) .width(Length::Fixed(DL_READOUT_W)) .align_x(iced::alignment::Horizontal::Right); let cell = Row::new() @@ -1938,9 +1938,9 @@ pub(crate) fn diagnostics_panel<'a>(app: &App) -> Element<'a, Message> { .push(text(summary).size(12.0)); for d in checks { let (tag, color) = match d.level { - DiagLevel::Problem => ("PROBLEM", Color::from_rgb8(0x8A, 0x2A, 0x2A)), - DiagLevel::Advice => ("ADVICE", Color::from_rgb8(0xB0, 0x6A, 0x10)), - DiagLevel::Ok => ("OK", Color::from_rgb8(0x3E, 0x73, 0x50)), + DiagLevel::Problem => ("PROBLEM", pal().error), + DiagLevel::Advice => ("ADVICE", pal().warning), + DiagLevel::Ok => ("OK", pal().success), }; let mut card = Column::new() .spacing(2) @@ -1951,7 +1951,7 @@ pub(crate) fn diagnostics_panel<'a>(app: &App) -> Element<'a, Message> { .push(text(tag).size(9.0).color(color).width(Length::Fixed(58.0))) .push(text(d.title).size(12.0).width(Length::Fill)), ) - .push(text(d.detail).size(10.5).color(Color::from_rgb8(0x6A, 0x5A, 0x40))); + .push(text(d.detail).size(10.5).color(text_muted())); if !d.actions.is_empty() { let mut row = Row::new().spacing(6); for (label, msg) in d.actions { @@ -2451,7 +2451,7 @@ pub(crate) fn plugins_panel<'a>(app: &App) -> Element<'a, Message> { // Same padding as `striped`, or a selected row would be a different // height from its neighbours and the list would twitch as focus moves. let painted: Element<'a, Message> = if selected || from_selected_mod { - let bg = if selected { SEL_BG } else { ORIGIN_BG }; + let bg = if selected { sel_bg() } else { origin_bg() }; container(row) .width(Length::Fill) .padding(2) @@ -2476,7 +2476,7 @@ pub(crate) fn plugins_panel<'a>(app: &App) -> Element<'a, Message> { Message::PluginDragDrop, )); rows = rows.push(grab); - marks.push(from_selected_mod.then_some(ORIGIN_BG)); + marks.push(from_selected_mod.then_some(origin_bg())); } // The trailing strip: hovering a row always means "above it", so this is the // only way to aim at the end of the load order. @@ -2673,7 +2673,7 @@ pub(crate) fn conflicting_files<'a>(app: &App, map: &ConflictMap) -> Element<'a, text(verdict) .size(11.0) .width(Length::Fixed(260.0)) - .color(if wins { CONFLICT_WINS_FG } else { CONFLICT_LOSES_FG }), + .color(if wins { conflict_wins_fg() } else { conflict_loses_fg() }), ); rows = rows.push(striped(row.into(), n.is_multiple_of(2))); } @@ -2840,9 +2840,9 @@ pub(crate) fn main_screen(app: &App) -> Element<'_, Message> { .height(Length::Fill) .style(move |_: &Theme| container::Style { background: Some(Background::Color(if held { - crate::theme::DIVIDER_HELD + crate::theme::divider_held() } else { - crate::theme::DIVIDER + crate::theme::divider() })), border: iced::Border { radius: 3.0.into(), ..Default::default() }, ..Default::default() @@ -4084,9 +4084,9 @@ pub(crate) fn install_picker_dialog<'a>(p: &InstallPicker) -> Element<'a, Messag }) .size(11.0) .color(if valid { - Color::from_rgb8(0x2E, 0x6E, 0x31) + pal().success } else { - Color::from_rgb8(0x8E, 0x2A, 0x2A) + pal().error }), ) // MO2 warns but still lets you through: the checker only knows @@ -4196,7 +4196,7 @@ pub(crate) fn archives_panel<'a>(app: &App) -> Element<'a, Message> { format!("{orphans} will not load") }) .size(11.0) - .color(if orphans == 0 { CONFLICT_WINS_FG } else { CONFLICT_LOSES_FG }), + .color(if orphans == 0 { conflict_wins_fg() } else { conflict_loses_fg() }), ); let col_header = Row::new() @@ -4215,7 +4215,7 @@ pub(crate) fn archives_panel<'a>(app: &App) -> Element<'a, Message> { (Some(p), _) => text(format!("{p} is active")).size(11.0).into(), (None, false) => text("nothing loads it - it is dead weight") .size(11.0) - .color(CONFLICT_LOSES_FG) + .color(conflict_loses_fg()) .into(), }; let row = Row::new() diff --git a/crates/eidos-gui/src/state.rs b/crates/eidos-gui/src/state.rs index 80792a5..3e1e834 100644 --- a/crates/eidos-gui/src/state.rs +++ b/crates/eidos-gui/src/state.rs @@ -207,6 +207,15 @@ pub(crate) fn new(launch_command: Vec) -> (App, Task) { // Read once: the struct needs it twice, and reading the file twice could give // two different answers. let prefs = if cfg!(test) { Settings::default() } else { Settings::load() }; + // Before anything is drawn. The palette is a global that every style closure + // reads, so a window built before this call would paint its first frame in + // the previous theme and only correct itself on the next redraw. + crate::theme::apply( + &prefs.theme_family, + &prefs.theme_variant, + prefs.accent.as_deref(), + prefs.high_contrast, + ); let mut app = App { screen: Screen::Welcome, games, diff --git a/crates/eidos-gui/src/theme.rs b/crates/eidos-gui/src/theme.rs index 92bb291..cede713 100644 --- a/crates/eidos-gui/src/theme.rs +++ b/crates/eidos-gui/src/theme.rs @@ -1,25 +1,160 @@ -//! The Colony parchment palette and the container styles built on it. +//! The palette, and the container styles built on it. //! -//! Split out of `main.rs` unchanged. These are leaves: called from everywhere, -//! calling nothing back, which is what made them the first thing worth moving -//! out of a 13k-line file. +//! Every colour in this window comes from one [`ThemePalette`] - the Colony +//! ecosystem's 38-field palette shape, from `colony-ui`. Two things can fill it: +//! +//! * [`PARCHMENT`], the look Eidos has always worn, written out here as those +//! same 38 fields rather than as scattered literals; and +//! * any of the **57 palettes** in the shared catalogue, 25 families generated +//! from the design tokens in Project-Colony-Resources. +//! +//! Before this, the parchment was hard-coded in about seventy places across the +//! GUI and the theme setting did nothing at all: `theme(_app)` ignored its +//! argument, so the Light / Dark / System picker had never changed a pixel. +//! +//! **A literal hex outside this file is a bug.** It will be right on one palette +//! and wrong on the other fifty-seven. + +use std::sync::RwLock; +use colony_ui::{hex, ThemePalette}; use iced::widget::container; use iced::{Background, Border, Color, Element, Length, Theme}; use crate::{App, Message}; +/// The key that means "Eidos's own", as stored in `settings.ini`. +/// +/// Not a family in the shared catalogue: the catalogue is the ecosystem's, and +/// this parchment is this program's. Kept as the default so an upgrade changes +/// nobody's window - what a user sees today is what they keep. +pub(crate) const OWN_FAMILY: &str = "eidos"; +pub(crate) const OWN_VARIANT: &str = "parchment"; +pub(crate) const OWN_LABEL: &str = "Eidos"; +pub(crate) const OWN_VARIANT_LABEL: &str = "Parchment"; + +/// Eidos's parchment, as the 38 fields every other palette also fills. +/// +/// The values are the ones this window already used - the background, the card, +/// the burgundy, the muted brown, the two conflict tints - with the fields that +/// had no literal filled in from the same family so nothing reads as borrowed +/// from another theme. +pub(crate) const PARCHMENT: ThemePalette = ThemePalette { + bg_primary: hex(0xECDFC2), + bg_sidebar: hex(0xE3D6B6), + bg_card: hex(0xF3EAD3), + bg_card_hover: hex(0xEADDBF), + bg_card_pressed: hex(0xE0D2B2), + bg_selected: hex(0xCFB886), + bg_input: hex(0xF7F0DE), + bg_progress: hex(0xD8C9A6), + + text_primary: hex(0x2B2018), + text_secondary: hex(0x4A3B2C), + text_muted: hex(0x6A5A40), + text_dim: hex(0x7C6C52), + text_dimmer: hex(0x8E7E64), + text_dimmest: hex(0xA09076), + text_placeholder: hex(0xA89A80), + + accent_blue: hex(0x7A1F2B), + accent_icon: hex(0x7A1F2B), + accent_progress: hex(0x7A1F2B), + + btn_default: hex(0xE3D6B6), + btn_hover: hex(0xEADDBF), + btn_pressed: hex(0xD8C9A6), + + success: hex(0x216B29), + success_bg: hex(0xC8DAB4), + btn_success: hex(0x4A6B3A), + btn_success_hover: hex(0x577E45), + btn_success_pressed: hex(0x3D5930), + + warning: hex(0xB06A1E), + warning_bg: hex(0xEDD9B4), + + error: hex(0x8A2A2A), + error_light: hex(0x992929), + error_bg: hex(0xEBC4BD), + btn_danger_bg: hex(0x8A2A2A), + btn_danger_hover: hex(0x9C3232), + btn_trash_hover: hex(0x9C3232), + btn_trash_pressed: hex(0x742222), + + bg_modal_section: hex(0xEFE5CC), + border_subtle: hex(0xC9B890), + divider: hex(0xC9B890), +}; + +/// The palette in force, before high contrast. Written at boot and whenever the +/// user picks, read on every style call. +/// +/// A global for the same reason `colony-ui` uses one: a style closure inside +/// `iced` cannot reach `App`, and threading a palette through some seventy of +/// them is how one of the seventy ends up different. +static ACTIVE: RwLock = RwLock::new(PARCHMENT); +/// The user's accent override, or `None` for the palette's own. +static ACCENT: RwLock> = RwLock::new(None); +static CONTRAST: RwLock = RwLock::new(false); + +/// Point the window at a theme. +/// +/// An unknown family or variant resolves to the parchment rather than failing, +/// so a `settings.ini` written by a later version - or naming a family that has +/// since been removed upstream - degrades instead of stopping the program. +pub(crate) fn apply(family: &str, variant: &str, accent: Option<&str>, high_contrast: bool) { + let resolved = if family == OWN_FAMILY { + PARCHMENT + } else { + // `resolve` never fails: an unknown pair gives the catalogue's own + // fallback. Checking membership first is what keeps an unknown family on + // EIDOS's default rather than on somebody else's. + if colony_ui::THEME_FAMILIES.iter().any(|f| { + f.key == family && f.variants.iter().any(|v| v.key == variant) + }) { + colony_ui::resolve(family, variant) + } else { + PARCHMENT + } + }; + *ACTIVE.write().unwrap() = resolved; + *ACCENT.write().unwrap() = accent.and_then(colony_ui::accent_key_to_color); + *CONTRAST.write().unwrap() = high_contrast; + + // The shared catalogue's own globals, kept in step so anything drawn from + // `colony_ui` agrees with what this file draws. + *ACTIVE.write().unwrap() = resolved; + colony_ui::set_high_contrast(high_contrast); +} + +/// The palette to draw with, high contrast already applied. +pub(crate) fn pal() -> ThemePalette { + let base = *ACTIVE.read().unwrap(); + if *CONTRAST.read().unwrap() { + // Derived rather than shipped: no theme carries a high-contrast twin, so + // the boost works on the parchment and on all 57 alike. + base.with_high_contrast() + } else { + base + } +} + +/// The accent: the user's override if they picked one, else the palette's own. +pub(crate) fn accent() -> Color { + ACCENT.read().unwrap().unwrap_or_else(|| pal().accent_blue) +} + pub(crate) fn palette() -> iced::theme::Palette { + let p = pal(); iced::theme::Palette { - background: Color::from_rgb8(0xEC, 0xDF, 0xC2), - text: Color::from_rgb8(0x2B, 0x20, 0x18), - primary: Color::from_rgb8(0x7A, 0x1F, 0x2B), - success: Color::from_rgb8(0x4A, 0x6B, 0x3A), - // New in iced 0.14, and it has to sit between the green of success and - // the deep red of danger without reading as either: a burnt amber that - // belongs to the same parchment family. - warning: Color::from_rgb8(0xB0, 0x6A, 0x1E), - danger: Color::from_rgb8(0x8A, 0x2A, 0x2A), + background: p.bg_primary, + text: p.text_primary, + primary: accent(), + success: p.success, + // Sits between success and danger without reading as either. + warning: p.warning, + danger: p.error, } } @@ -28,52 +163,55 @@ pub(crate) fn theme(_app: &App) -> Theme { } pub(crate) fn card_style(_theme: &Theme) -> container::Style { + let p = pal(); container::Style { - background: Some(Background::Color(Color::from_rgb8(0xF3, 0xEA, 0xD3))), - border: Border { color: Color::from_rgb8(0x7A, 0x1F, 0x2B), width: 1.5, radius: 8.0.into() }, + background: Some(Background::Color(p.bg_card)), + border: Border { color: accent(), width: 1.5, radius: 8.0.into() }, ..Default::default() } } pub(crate) fn panel_style(_theme: &Theme) -> container::Style { + let p = pal(); container::Style { - background: Some(Background::Color(Color::from_rgb8(0xF3, 0xEA, 0xD3))), - border: Border { color: Color::from_rgb8(0x7A, 0x1F, 0x2B), width: 1.0, radius: 3.0.into() }, + background: Some(Background::Color(p.bg_card)), + border: Border { color: accent(), width: 1.0, radius: 3.0.into() }, ..Default::default() } } pub(crate) fn bar_style(_theme: &Theme) -> container::Style { + let p = pal(); container::Style { - background: Some(Background::Color(Color::from_rgb8(0xE3, 0xD6, 0xB6))), - border: Border { color: Color::from_rgb8(0xC9, 0xB8, 0x90), width: 1.0, radius: 0.0.into() }, + background: Some(Background::Color(p.bg_sidebar)), + border: Border { color: p.border_subtle, width: 1.0, radius: 0.0.into() }, ..Default::default() } } /// The bar between the two panes at rest: the same muted line the toolbars use, /// so it reads as furniture rather than as content. -pub(crate) const DIVIDER: Color = Color::from_rgb8(0xC9, 0xB8, 0x90); +pub(crate) fn divider() -> Color { + pal().divider +} -/// The same bar while it is being dragged - the panel border's burgundy, which is -/// the strongest colour in this palette and the one already used for "this is the -/// edge of something". -pub(crate) const DIVIDER_HELD: Color = Color::from_rgb8(0x7A, 0x1F, 0x2B); +/// The same bar while it is being dragged - the accent, which is the strongest +/// colour in any palette and already means "this is the edge of something". +pub(crate) fn divider_held() -> Color { + accent() +} /// Secondary text: a description under a title, a caption, a hint. -/// -/// The convention asks for descriptions in `text_muted` rather than in the body -/// colour, so that a page of settings reads as titles with explanations under -/// them instead of as two columns of equally loud text. This is the parchment -/// palette's version of it - the same brown as the body ink, lightened until it -/// recedes without becoming hard to read. -pub(crate) const TEXT_MUTED: Color = Color::from_rgb8(0x6A, 0x5A, 0x40); +pub(crate) fn text_muted() -> Color { + pal().text_muted +} pub(crate) fn row_bg(even: bool) -> Color { + let p = pal(); if even { - Color::from_rgb8(0xF3, 0xEA, 0xD3) + p.bg_card } else { - Color::from_rgb8(0xEA, 0xDD, 0xBF) + p.bg_card_hover } } @@ -90,20 +228,199 @@ pub(crate) fn striped<'a>(content: Element<'a, Message>, even: bool) -> Element< } /// The highlight behind the selected mod row. -pub(crate) const SEL_BG: Color = Color::from_rgb(0.812, 0.722, 0.525); // tan, distinct from the stripes +pub(crate) fn sel_bg() -> Color { + pal().bg_selected +} + +/// A plugin that comes FROM the mod selected in the mod list (MO2 highlights the +/// same relationship). It answers a different question from the conflict tints - +/// not "who wins", but "who ships this" - so it must not be mistaken for either +/// of them, nor for the selection. +/// +/// It used to be a fixed pale blue, which cannot survive 57 palettes. It is now +/// the card tinted towards the accent - so it belongs to the theme rather than +/// sitting on top of it. +/// +/// The strength of the tint is CHOSEN, not fixed. On several palettes the +/// selection is itself an accent-tinted card, and a fixed ratio landed on top of +/// it - on `catppuccin/frappe` the two were 0.02 apart, which is to say +/// identical. So five strengths are measured against the three tints this must +/// never be confused with, and the one that stays furthest from all of them +/// wins. Fifteen subtractions per call, and it makes the guarantee hold on every +/// palette instead of on most of them. +pub(crate) fn origin_bg() -> Color { + let p = pal(); + let rivals = [p.bg_selected, p.success_bg, p.error_bg]; + let gap = |a: Color, b: Color| (a.r - b.r).abs() + (a.g - b.g).abs() + (a.b - b.b).abs(); -/// A plugin that comes FROM the mod selected in the mod list (MO2 highlights -/// the same relationship). Blue on purpose: it must not be mistaken for the -/// selection tan, nor for the green/red of the conflict tints, because it -/// answers a different question - not "who wins", but "who ships this". -pub(crate) const ORIGIN_BG: Color = Color::from_rgb(0.796, 0.851, 0.898); + let mut best = mix(p.bg_card, p.accent_icon, 0.28); + let mut best_gap = -1.0; + for r in [0.28, 0.42, 0.56, 0.70, 0.84] { + let c = mix(p.bg_card, p.accent_icon, r); + let worst = rivals.iter().fold(f32::MAX, |m, v| m.min(gap(c, *v))); + if worst > best_gap { + best_gap = worst; + best = c; + } + } + best +} /// A mod the focused one OVERWRITES: it sits lower in the list and wins the -/// files they share. Green - the focused mod is on top of these. -pub(crate) const CONFLICT_WINS_BG: Color = Color::from_rgb(0.784, 0.855, 0.706); -/// A mod that overwrites the focused one: it sits lower and takes those files -/// away. Red - the focused mod is losing to these. -pub(crate) const CONFLICT_LOSES_BG: Color = Color::from_rgb(0.921, 0.769, 0.741); -/// The same two meanings as text, dark enough to read on parchment. -pub(crate) const CONFLICT_WINS_FG: Color = Color::from_rgb(0.13, 0.42, 0.16); -pub(crate) const CONFLICT_LOSES_FG: Color = Color::from_rgb(0.60, 0.16, 0.16); +/// files they share. The palette's success tint - the focused mod is on top. +pub(crate) fn conflict_wins_bg() -> Color { + pal().success_bg +} +/// A mod that overwrites the focused one: it takes those files away. +pub(crate) fn conflict_loses_bg() -> Color { + pal().error_bg +} +/// The same two meanings as text. +pub(crate) fn conflict_wins_fg() -> Color { + pal().success +} +pub(crate) fn conflict_loses_fg() -> Color { + pal().error +} + +/// Blend two colours. Shared with `anim`, which needs the same operation for a +/// different reason. +fn mix(from: Color, to: Color, t: f32) -> Color { + crate::anim::mix(from, to, t) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The parchment must fill every field. A hole would read as transparent + /// black on whatever it was used for, and only on the default theme. + #[test] + fn the_parchment_names_a_colour_for_every_field() { + let p = PARCHMENT; + for (name, c) in [ + ("bg_primary", p.bg_primary), + ("bg_card", p.bg_card), + ("bg_selected", p.bg_selected), + ("text_primary", p.text_primary), + ("text_muted", p.text_muted), + ("accent_blue", p.accent_blue), + ("success", p.success), + ("success_bg", p.success_bg), + ("error", p.error), + ("error_bg", p.error_bg), + ("warning", p.warning), + ("divider", p.divider), + ("border_subtle", p.border_subtle), + ] { + assert_eq!(c.a, 1.0, "{name} is not opaque"); + } + } + + /// An upgrade must not repaint anybody's window: with nothing chosen, the + /// parchment is what is drawn. + #[test] + fn the_default_is_the_parchment_this_program_has_always_worn() { + apply(OWN_FAMILY, OWN_VARIANT, None, false); + assert_eq!(pal().bg_primary, hex(0xECDFC2)); + assert_eq!(pal().bg_card, hex(0xF3EAD3)); + assert_eq!(accent(), hex(0x7A1F2B)); + } + + #[test] + fn a_catalogue_theme_really_replaces_the_palette() { + apply("gruvbox", "dark", None, false); + assert_eq!(pal().bg_primary, hex(0x282828), "gruvbox dark did not take"); + assert_ne!(pal().bg_primary, PARCHMENT.bg_primary); + + // And back. + apply(OWN_FAMILY, OWN_VARIANT, None, false); + assert_eq!(pal().bg_primary, PARCHMENT.bg_primary); + } + + /// A family this build has never heard of - a config from a later version, + /// or one removed upstream - must land on EIDOS's default, not on the + /// catalogue's, which would repaint the window for a typo. + #[test] + fn an_unknown_theme_degrades_to_the_parchment() { + for (family, variant) in [ + ("no-such-family", "dark"), + ("gruvbox", "no-such-variant"), + ("", ""), + ] { + apply(family, variant, None, false); + assert_eq!( + pal().bg_primary, + PARCHMENT.bg_primary, + "{family}/{variant} did not degrade to the parchment" + ); + } + } + + #[test] + fn an_accent_override_wins_over_the_palettes_own() { + apply(OWN_FAMILY, OWN_VARIANT, Some("green"), false); + assert_ne!(accent(), PARCHMENT.accent_blue, "the override was ignored"); + // An unknown accent key is not an accent: fall back to the theme's. + apply(OWN_FAMILY, OWN_VARIANT, Some("chartreuse"), false); + assert_eq!(accent(), PARCHMENT.accent_blue); + // And none means the theme's own. + apply(OWN_FAMILY, OWN_VARIANT, None, false); + assert_eq!(accent(), PARCHMENT.accent_blue); + } + + /// Derived from the active palette, not shipped as a twin - so it works on + /// the parchment and on all 57 alike. + #[test] + fn high_contrast_moves_the_palette_and_is_reversible() { + apply(OWN_FAMILY, OWN_VARIANT, None, false); + let plain = pal(); + apply(OWN_FAMILY, OWN_VARIANT, None, true); + let boosted = pal(); + + // It moves the INK and the lines, not the grounds: a high-contrast mode + // that repainted the backgrounds would be a different theme rather than + // the same one read more easily. + assert_ne!(plain.text_primary, boosted.text_primary, "the ink did not move"); + assert_ne!(plain.divider, boosted.divider, "the lines did not move"); + assert_eq!(plain.bg_primary, boosted.bg_primary, "the ground must not move"); + + // On a light palette the ink gets darker, not lighter. + assert!(boosted.text_primary.r < plain.text_primary.r); + + apply(OWN_FAMILY, OWN_VARIANT, None, false); + assert_eq!(pal().text_primary, plain.text_primary); + } + + /// The three list tints answer three different questions and must never be + /// confusable - on ANY palette, which is what a fixed hex could not promise. + #[test] + fn the_list_tints_stay_distinguishable_on_every_palette() { + let mut checked = 0; + for family in colony_ui::THEME_FAMILIES { + for variant in family.variants { + apply(family.key, variant.key, None, false); + let tints = [ + ("selection", sel_bg()), + ("origin", origin_bg()), + ("wins", conflict_wins_bg()), + ("loses", conflict_loses_bg()), + ]; + for (i, (an, a)) in tints.iter().enumerate() { + for (bn, b) in tints.iter().skip(i + 1) { + let d = (a.r - b.r).abs() + (a.g - b.g).abs() + (a.b - b.b).abs(); + assert!( + d > 0.04, + "{}/{}: {an} and {bn} are indistinguishable ({d})", + family.key, + variant.key + ); + } + } + checked += 1; + } + } + assert_eq!(checked, 57, "the catalogue should carry 57 palettes"); + apply(OWN_FAMILY, OWN_VARIANT, None, false); + } +} diff --git a/crates/eidos-gui/src/update.rs b/crates/eidos-gui/src/update.rs index 38225c1..30a681d 100644 --- a/crates/eidos-gui/src/update.rs +++ b/crates/eidos-gui/src/update.rs @@ -14,6 +14,24 @@ use crate::*; /// function has 68 early returns and a refresh reachable from only some of them /// is worse than none - the tab count would be right or wrong depending on which /// branch ran. +/// Put a theme choice into force and write it down. +/// +/// The palette lives in a global - a style closure inside `iced` cannot reach +/// `App` - so changing the preference is not enough on its own: it has to be +/// pushed into `theme::apply` or the window keeps drawing the old one until the +/// next launch. One function, so the three pickers cannot each get it half right. +fn repaint(app: &mut App) { + crate::theme::apply( + &app.prefs.theme_family, + &app.prefs.theme_variant, + app.prefs.accent.as_deref(), + app.prefs.high_contrast, + ); + if let Err(e) = app.prefs.save() { + app.status = Some(format!("Could not save preferences: {e}")); + } +} + pub(crate) fn update(app: &mut App, message: Message) -> Task { // Whether we were mid-drain BEFORE the message ran: a `DrainDrops` that // popped the last item must not re-arm itself off its own empty queue. @@ -2693,11 +2711,18 @@ pub(crate) fn update_inner(app: &mut App, message: Message) -> Task { // double space does not survive in the field looking meaningful. app.servers_edit = app.prefs.preferred_servers.join(", "); } - Message::ThemeChanged(t) => { - app.prefs.theme = t; - if let Err(e) = app.prefs.save() { - app.status = Some(format!("Could not save preferences: {e}")); - } + Message::ThemeChanged(family, variant) => { + app.prefs.theme_family = family; + app.prefs.theme_variant = variant; + repaint(app); + } + Message::AccentChanged(key) => { + app.prefs.accent = key; + repaint(app); + } + Message::ToggleHighContrast(on) => { + app.prefs.high_contrast = on; + repaint(app); } Message::DefaultGameChanged(g) => { app.prefs.default_game = g; diff --git a/crates/eidos-gui/src/view.rs b/crates/eidos-gui/src/view.rs index c8873d8..24b8b1f 100644 --- a/crates/eidos-gui/src/view.rs +++ b/crates/eidos-gui/src/view.rs @@ -16,7 +16,11 @@ pub(crate) const C_PRIO: Length = Length::Fixed(26.0); /// The ground behind a synthetic group header - a wash rather than the /// separator's full-strength bar, because it is a fact about the list rather /// than a row in it. -pub(crate) const GROUP_HEADER_BG: Color = Color::from_rgb(0.86, 0.84, 0.79); +/// The band behind a collapsed group header. The sidebar tone, so a header +/// reads as furniture between rows rather than as a row of its own. +pub(crate) fn group_header_bg() -> Color { + pal().bg_sidebar +} pub(crate) const C_FLAGS: Length = Length::Fixed(46.0); @@ -678,7 +682,7 @@ pub(crate) fn mod_row<'a>( if meta.is_some_and(|r| r.nexus_gone) { flags = flags.push( tooltip( - text("\u{2298}").size(12.0).color(CONFLICT_LOSES_FG), + text("\u{2298}").size(12.0).color(conflict_loses_fg()), container( text("Nexus no longer serves this mod's page. Keep your archive: you will not be able to download it again.") .size(11.0), @@ -696,7 +700,7 @@ pub(crate) fn mod_row<'a>( if meta.is_some_and(|r| r.invalid_data) { flags = flags.push( tooltip( - text("\u{26A0}").size(12.0).color(CONFLICT_LOSES_FG), + text("\u{26A0}").size(12.0).color(conflict_loses_fg()), container( text( "Nothing at the top of this mod looks like data this game loads. It \ @@ -715,7 +719,7 @@ pub(crate) fn mod_row<'a>( if let Some(other) = meta.and_then(|r| r.other_game.clone()) { flags = flags.push( tooltip( - text("\u{25C6}").size(12.0).color(CONFLICT_LOSES_FG), + text("\u{25C6}").size(12.0).color(conflict_loses_fg()), container( text(format!( "Downloaded for {other}, not for this game. It may still work - many \ @@ -839,7 +843,7 @@ fn group_header_row<'a>(label: String, count: usize, folded: bool) -> Element<'a .push(text(label).size(12.0).width(Length::Fill)) .push(text(format!("{count}")).size(11.0).width(C_PRIO)); mouse_area(container(row).padding([0, 4]).style(|_: &Theme| container::Style { - background: Some(Background::Color(GROUP_HEADER_BG)), + background: Some(Background::Color(group_header_bg())), ..Default::default() })) .on_press(msg) @@ -856,7 +860,10 @@ fn fmt_day(t: std::time::SystemTime) -> String { /// Default separator bar colour when its `meta.ini` carries none (a parchment tan, /// #C8B895). -pub(crate) const SEPARATOR_ACCENT: Color = Color::from_rgb(0.784, 0.722, 0.584); +/// A separator the user has given no colour of their own. +pub(crate) fn separator_accent() -> Color { + pal().border_subtle +} /// A separator (group divider) row, MO2-style: a full-width coloured bar with the /// display name centred, no checkbox / version / conflict flags, but still movable. @@ -867,7 +874,7 @@ pub(crate) fn separator_row<'a>( collapsed: bool, selected: bool, ) -> Element<'a, Message> { - let bg = color.map(|[r, g, b]| Color::from_rgb8(r, g, b)).unwrap_or(SEPARATOR_ACCENT); + let bg = color.map(|[r, g, b]| Color::from_rgb8(r, g, b)).unwrap_or(separator_accent()); // The collapse/expand toggle sits in the checkbox column (a separator has no // checkbox); it hides/shows the mods grouped beneath this separator. @@ -907,7 +914,7 @@ pub(crate) fn separator_row<'a>( .style(move |_t: &Theme| container::Style { background: Some(Background::Color(bg)), border: Border { - color: if selected { Color::from_rgb8(0x6E, 0x24, 0x2E) } else { bg }, + color: if selected { accent() } else { bg }, width: if selected { 2.0 } else { 0.0 }, radius: 0.0.into(), }, @@ -1509,7 +1516,7 @@ pub(crate) fn menu_sep<'a>() -> Element<'a, Message> { container(Space::new().width(Length::Fill).height(Length::Fixed(1.0))) .padding([2, 6]) .style(|_t: &Theme| container::Style { - background: Some(Background::Color(Color::from_rgb8(0xC8, 0xB8, 0x95))), + background: Some(Background::Color(pal().border_subtle)), ..Default::default() }) .into() @@ -1804,7 +1811,7 @@ pub(crate) fn separator_swatches<'a>(i: usize, current: Option<[u8; 3]>) -> Elem .style(move |_t: &Theme, _s: button::Status| button::Style { background: Some(Background::Color(Color::from_rgb8(r, g, b))), border: Border { - color: Color::from_rgb8(0x3a, 0x2a, 0x1a), + color: pal().text_primary, width: if sel { 2.0 } else { 0.5 }, radius: 2.0.into(), }, @@ -1827,9 +1834,9 @@ pub(crate) fn menu_frame<'a>(content: Element<'a, Message>) -> Element<'a, Messa .width(Length::Fixed(210.0)) .padding(6) .style(|_t: &Theme| container::Style { - background: Some(Background::Color(Color::from_rgb8(0xF3, 0xEA, 0xD3))), + background: Some(Background::Color(pal().bg_card)), border: Border { - color: Color::from_rgb8(0x6E, 0x24, 0x2E), + color: accent(), width: 1.0, radius: 3.0.into(), }, diff --git a/crates/eidos-gui/src/widgets.rs b/crates/eidos-gui/src/widgets.rs index cbe6eb6..e20c74c 100644 --- a/crates/eidos-gui/src/widgets.rs +++ b/crates/eidos-gui/src/widgets.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use iced::widget::{button, container, image, mouse_area, text, Column, Row, Space}; use iced::{Background, Border, Color, Element, Length, Theme}; -use crate::theme::{row_bg, CONFLICT_LOSES_BG, CONFLICT_WINS_BG, SEL_BG}; +use crate::theme::{accent, conflict_loses_bg, conflict_wins_bg, pal, row_bg, sel_bg}; use crate::{App, Message}; /// A collapsible settings section: a title row that toggles, and its body when @@ -48,7 +48,7 @@ pub(crate) fn settings_section<'a>( } let rule = container(Space::new().width(Length::Fill).height(Length::Fixed(1.0))).style( |_t: &Theme| container::Style { - background: Some(Background::Color(Color::from_rgb8(0xC9, 0xB8, 0x90))), + background: Some(Background::Color(pal().border_subtle)), ..Default::default() }, ); @@ -72,7 +72,7 @@ pub(crate) fn settings_toggle<'a>( ) -> Element<'a, Message> { let knob = container(Space::new().width(Length::Fixed(13.0)).height(Length::Fixed(13.0))) .style(|_t: &Theme| container::Style { - background: Some(Background::Color(Color::from_rgb8(0xF3, 0xEA, 0xD3))), + background: Some(Background::Color(pal().bg_card)), border: Border { radius: 7.0.into(), ..Default::default() }, ..Default::default() }); @@ -86,9 +86,9 @@ pub(crate) fn settings_toggle<'a>( .align_y(iced::alignment::Vertical::Center) .style(move |_t: &Theme| container::Style { background: Some(Background::Color(if on { - Color::from_rgb8(0x7A, 0x1F, 0x2B) + accent() } else { - Color::from_rgb8(0xC9, 0xB8, 0x90) + pal().border_subtle })), border: Border { radius: 9.0.into(), ..Default::default() }, ..Default::default() @@ -255,7 +255,7 @@ pub(crate) fn conflict_legend<'a>(app: &App) -> Option> { container(Space::new().width(Length::Fixed(12.0)).height(Length::Fixed(12.0))) .style(move |_t: &Theme| container::Style { background: Some(Background::Color(c)), - border: Border { color: Color::from_rgb8(0x6E, 0x24, 0x2E), width: 1.0, radius: 2.0.into() }, + border: Border { color: accent(), width: 1.0, radius: 2.0.into() }, ..Default::default() }), ) @@ -266,10 +266,10 @@ pub(crate) fn conflict_legend<'a>(app: &App) -> Option> { let mut row = Row::new().spacing(10).align_y(iced::Alignment::Center); row = row.push(text(format!("{name} conflicts:")).size(11.0)); if over > 0 { - row = row.push(swatch(CONFLICT_WINS_BG, format!("{over} it overwrites"))); + row = row.push(swatch(conflict_wins_bg(), format!("{over} it overwrites"))); } if under > 0 { - row = row.push(swatch(CONFLICT_LOSES_BG, format!("{under} overwrite it"))); + row = row.push(swatch(conflict_loses_bg(), format!("{under} overwrite it"))); } Some(row.into()) } @@ -293,9 +293,9 @@ pub(crate) fn conflict_tint(app: &App, i: usize) -> Option { let me = map.mods.get(&((focus + 1) as u32))?; let other = (i + 1) as u32; if me.overwrites.contains(&other) { - Some(CONFLICT_WINS_BG) + Some(conflict_wins_bg()) } else if me.overwritten_by.contains(&other) { - Some(CONFLICT_LOSES_BG) + Some(conflict_loses_bg()) } else { None } @@ -337,7 +337,7 @@ pub(crate) fn drop_gap<'a>( let bar = container(Space::new().width(Length::Fill).height(Length::Fixed(if active { 2.0 } else { 0.0 }))) .width(Length::Fill) .style(move |_t: &Theme| container::Style { - background: active.then(|| Background::Color(Color::from_rgb8(0x6E, 0x24, 0x2E))), + background: active.then(|| Background::Color(accent())), ..Default::default() }); // `center_y(len)` is `height(len) + align`, so passing Fill here silently @@ -377,7 +377,7 @@ pub(crate) fn row_background( // The user's own colour is last of the three because it is permanent: it can // afford to be covered for a moment, and it comes back on its own. if selected { - SEL_BG + sel_bg() } else { conflict.or(tint).unwrap_or_else(|| row_bg(even)) } diff --git a/crates/eidos-instance/src/lib.rs b/crates/eidos-instance/src/lib.rs index ab2881c..19b107c 100644 --- a/crates/eidos-instance/src/lib.rs +++ b/crates/eidos-instance/src/lib.rs @@ -43,7 +43,7 @@ pub use profile::{ cosave_siblings, format_stamp, Backup, BackupKind, is_save_data, is_save_listing, read_text_lossy, untweak_ini, write_text, ListTrust, Profile, SaveEntry, TweakedKey, }; -pub use settings::{Settings, Theme}; +pub use settings::Settings; pub use tools::{ default_prereqs, default_tools, default_tools_in, merge_tools, read_tools, write_tools, tool_search_roots, GameExecutables, Tool, diff --git a/crates/eidos-instance/src/settings.rs b/crates/eidos-instance/src/settings.rs index c73a087..3896f7f 100644 --- a/crates/eidos-instance/src/settings.rs +++ b/crates/eidos-instance/src/settings.rs @@ -246,36 +246,6 @@ pub fn settings_path() -> PathBuf { config_home().join("settings.ini") } -/// Which color theme the app renders in. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Theme { - /// Follow the platform, or fall back to dark. - #[default] - System, - Light, - Dark, -} - -impl Theme { - /// The on-disk token (lowercase, stable across releases). - fn as_str(self) -> &'static str { - match self { - Theme::System => "system", - Theme::Light => "light", - Theme::Dark => "dark", - } - } - - /// Parse a stored token; unknown values fall back to the default. - fn parse(s: &str) -> Theme { - match s.trim().to_ascii_lowercase().as_str() { - "light" => Theme::Light, - "dark" => Theme::Dark, - _ => Theme::System, - } - } -} - /// The app-global preferences that are not secret. The Nexus API key is stored /// separately (see `load_nexus_key`/`save_nexus_key`) so it never lands in this /// file. @@ -284,7 +254,26 @@ impl Theme { #[derive(Debug, Clone, PartialEq)] pub struct Settings { /// The color theme to render in. - pub theme: Theme, + /// The theme family, either `eidos` for this program's own parchment or one + /// of the 25 in the shared Colony catalogue. + /// + /// This replaces a `theme = system | light | dark` key that had never done + /// anything: the GUI's `theme()` ignored its argument, so all three choices + /// drew the same parchment. An old file's `theme=` is simply an unknown key + /// now, which the parser ignores - so an upgrade leaves everybody on the + /// parchment, which is what they were looking at whatever they had picked. + pub theme_family: String, + /// The variant within the family. + pub theme_variant: String, + /// The accent override the user picked, or `None` for the theme's own. + /// + /// Never stores a colour, only a key: "auto" is the ABSENCE of an override, + /// not a value, and writing a resolved colour here would freeze it against + /// the theme it was resolved from. + pub accent: Option, + /// Boost the separation between surfaces and text, on any palette. Derived + /// from the active theme rather than shipped as a second palette. + pub high_contrast: bool, /// The game id to open by default when none is given (e.g. `skyrimse`). pub default_game: Option, /// The last window size in logical pixels, `(width, height)`. @@ -343,7 +332,12 @@ pub struct Settings { impl Default for Settings { fn default() -> Self { Settings { - theme: Theme::default(), + // The look this program has always worn. An upgrade must not repaint + // anybody's window. + theme_family: "eidos".to_string(), + theme_variant: "parchment".to_string(), + accent: None, + high_contrast: false, default_game: None, window_size: None, // MO2 defaults `lock_gui` to true, and an absent key means "on". @@ -428,7 +422,16 @@ impl Settings { let Some((k, v)) = line.split_once('=') else { continue }; let v = v.trim(); match k.trim() { - "theme" => s.theme = Theme::parse(v), + "theme_family" if !v.is_empty() => s.theme_family = v.to_string(), + "theme_variant" if !v.is_empty() => s.theme_variant = v.to_string(), + // An empty value is meaningful: it is how "no override" is + // written back, and it must read as None rather than as an + // accent named "". + "accent" => s.accent = (!v.is_empty()).then(|| v.to_string()), + "high_contrast" => { + s.high_contrast = + matches!(v.to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "on") + } "default_game" if !v.is_empty() => s.default_game = Some(v.to_string()), "window_width" => width = v.parse().ok(), "window_height" => height = v.parse().ok(), @@ -513,14 +516,19 @@ impl Settings { /// Render these settings as a `settings.ini` body. Split out for unit tests. pub fn to_ini(&self) -> String { let mut out = format!( - "[eidos]\ntheme={}\nlock_gui={}\ndrag_scroll_speed={}\nsplit={}\nmotion={}\nremember_window={}\n", - self.theme.as_str(), + "[eidos]\ntheme_family={}\ntheme_variant={}\nhigh_contrast={}\nlock_gui={}\ndrag_scroll_speed={}\nsplit={}\nmotion={}\nremember_window={}\n", + self.theme_family, + self.theme_variant, + self.high_contrast, self.lock_gui, self.drag_scroll_speed, self.split, self.motion, self.remember_window ); + if let Some(a) = &self.accent { + out.push_str(&format!("accent={a}\n")); + } if self.offline { out.push_str("offline=true\n"); } @@ -566,7 +574,10 @@ mod tests { let s = Settings::parse( "[eidos]\ntheme=dark\nlock_gui=false\nconflict_marks=false\nremember_window=false\n", ); - assert_eq!(s.theme, Theme::Dark); + // `theme=dark` is itself one of these now: it named a setting that never + // did anything, so it is read by nobody and written back by nobody, and + // the keys beside it still survive. + assert!(!s.to_ini().contains("theme=dark")); assert!(!s.lock_gui); assert!(!s.remember_window); // And the key is not written back: nothing reads it any more. @@ -724,30 +735,51 @@ mod tests { let _ = fs::remove_file(&path); } + /// The default is Eidos's own parchment, so an upgrade repaints nobody's + /// window - whatever they had picked, the parchment is what they saw. #[test] - fn theme_round_trips_each_variant() { - for theme in [Theme::System, Theme::Light, Theme::Dark] { - assert_eq!(Theme::parse(theme.as_str()), theme); - } + fn the_default_theme_is_eidoss_own() { + let s = Settings::default(); + assert_eq!(s.theme_family, "eidos"); + assert_eq!(s.theme_variant, "parchment"); + assert_eq!(s.accent, None); + assert!(!s.high_contrast); + + // And a file that predates all four keys lands there too. + let old = Settings::parse("[eidos]\ntheme=dark\nlock_gui=true\n"); + assert_eq!(old.theme_family, "eidos"); + assert_eq!(old.accent, None); } #[test] - fn theme_parse_is_case_insensitive_and_falls_back() { - assert_eq!(Theme::parse("DARK"), Theme::Dark); - assert_eq!(Theme::parse("Light"), Theme::Light); - assert_eq!(Theme::parse("nonsense"), Theme::System); - assert_eq!(Theme::parse(""), Theme::System); + fn a_theme_choice_survives_the_file() { + let s = Settings { + theme_family: "kanagawa".to_string(), + theme_variant: "journal".to_string(), + accent: Some("violet".to_string()), + high_contrast: true, + ..Settings::default() + }; + let back = Settings::parse(&s.to_ini()); + assert_eq!(back.theme_family, "kanagawa"); + assert_eq!(back.theme_variant, "journal"); + assert_eq!(back.accent.as_deref(), Some("violet")); + assert!(back.high_contrast); } + /// "Auto" is the ABSENCE of an override, never a colour and never a key + /// named for one. An accent that is unset must not come back as `Some("")`. #[test] - fn defaults_are_empty() { - let s = Settings::default(); - assert_eq!(s.theme, Theme::System); - assert_eq!(s.default_game, None); - assert_eq!(s.window_size, None); - assert!(s.lock_gui, "locking the GUI during a run is on by default (MO2 parity)"); + fn no_accent_is_none_and_not_an_empty_name() { + let s = Settings { accent: None, ..Settings::default() }; + let ini = s.to_ini(); + assert!(!ini.contains("accent="), "an absent accent must not be written"); + assert_eq!(Settings::parse(&ini).accent, None); + // And an empty value on disk reads as no override, not as an accent. + assert_eq!(Settings::parse("accent=\n").accent, None); } + #[test] fn settings_round_trip_full() { let s = Settings { @@ -756,7 +788,10 @@ mod tests { preferred_servers: Vec::new(), mod_columns: None, tools_dir: None, - theme: Theme::Dark, + theme_family: "gruvbox".to_string(), + theme_variant: "dark".to_string(), + accent: Some("green".to_string()), + high_contrast: true, default_game: Some("skyrimse".to_string()), window_size: Some((1280, 720)), lock_gui: false, @@ -771,7 +806,7 @@ mod tests { #[test] fn settings_round_trip_minimal() { - let s = Settings { theme: Theme::Light, ..Settings::default() }; + let s = Settings { theme_family: "nord".to_string(), ..Settings::default() }; let parsed = Settings::parse(&s.to_ini()); assert_eq!(parsed, s); } @@ -850,7 +885,9 @@ mod tests { fn parse_ignores_unknown_and_malformed() { let text = "[eidos]\ntheme=dark\nbogus_key=whatever\nno-equals-sign\ndefault_game=fallout4\n"; let s = Settings::parse(text); - assert_eq!(s.theme, Theme::Dark); + // `theme=dark` joins the unknown keys: it named a setting that never + // changed a pixel, and nothing reads it now. + assert_eq!(s.theme_family, "eidos"); assert_eq!(s.default_game.as_deref(), Some("fallout4")); } @@ -882,7 +919,10 @@ mod tests { preferred_servers: Vec::new(), mod_columns: None, tools_dir: None, - theme: Theme::Dark, + theme_family: "gruvbox".to_string(), + theme_variant: "dark".to_string(), + accent: Some("green".to_string()), + high_contrast: true, default_game: Some("starfield".to_string()), window_size: Some((1600, 900)), lock_gui: false,