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/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..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. /// @@ -52,26 +38,212 @@ 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. +/// 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 + // 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 }), @@ -168,31 +340,66 @@ pub(crate) fn settings_dialog<'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 + // 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( + "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 => { let speed = app.prefs.drag_scroll_speed; let slider_row = Row::new() @@ -272,7 +479,7 @@ pub(crate) fn settings_dialog<'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() @@ -407,12 +614,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) -------------------------- @@ -742,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(), }, @@ -757,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() @@ -792,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() } @@ -840,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()) { @@ -855,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), @@ -910,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 { @@ -924,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); @@ -1472,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); @@ -1526,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)), ); @@ -1671,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)) @@ -1901,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 { @@ -1965,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 2d5b193..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}; @@ -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; @@ -46,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::*; @@ -117,35 +119,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)] @@ -388,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`). @@ -400,6 +434,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 +738,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 +1856,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 +2274,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 +2536,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) } @@ -2625,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); @@ -2635,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( @@ -2654,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() @@ -3233,6 +3308,165 @@ 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 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!( + &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 @@ -3961,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. @@ -6072,6 +6306,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 +7656,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 @@ -7713,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 ee07ffa..6787068 100644 --- a/crates/eidos-gui/src/modinfo.rs +++ b/crates/eidos-gui/src/modinfo.rs @@ -3,16 +3,23 @@ //! //! 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::*; -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), @@ -419,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(), }, @@ -505,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() }; @@ -690,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); } @@ -1156,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() @@ -1931,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) @@ -1944,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 { @@ -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() } @@ -2406,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) @@ -2431,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. @@ -2628,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))); } @@ -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. @@ -2785,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() @@ -2795,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 { @@ -2859,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)) @@ -4027,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 @@ -4139,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() @@ -4158,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 0727e59..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, @@ -327,6 +336,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/theme.rs b/crates/eidos-gui/src/theme.rs index 7fab461..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,43 +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. +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 } } @@ -81,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). 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); +/// 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(); + + 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 f198397..30a681d 100644 --- a/crates/eidos-gui/src/update.rs +++ b/crates/eidos-gui/src/update.rs @@ -14,12 +14,40 @@ 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. 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 +92,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 +336,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 +2119,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 +2673,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() { @@ -2658,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; @@ -6039,6 +6099,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-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 d5fcd75..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 @@ -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]) @@ -42,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() }, ); @@ -66,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() }); @@ -80,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() @@ -249,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() }), ) @@ -260,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()) } @@ -287,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 } @@ -331,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 @@ -371,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 29c56d5..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)`. @@ -302,6 +291,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, @@ -335,13 +332,19 @@ 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". lock_gui: true, drag_scroll_speed: 1.0, split: 0.6, + motion: true, remember_window: true, offline: false, tools_dir: None, @@ -419,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(), @@ -483,6 +495,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,13 +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={}\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"); } @@ -550,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. @@ -708,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 { @@ -740,12 +788,16 @@ 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, drag_scroll_speed: 1.0, split: 0.6, + motion: false, remember_window: false, }; let parsed = Settings::parse(&s.to_ini()); @@ -754,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); } @@ -776,6 +828,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); @@ -810,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")); } @@ -842,12 +919,16 @@ 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, drag_scroll_speed: 1.0, split: 0.6, + motion: false, remember_window: false, }; fs::write(&path, s.to_ini()).unwrap();