diff --git a/Cargo.lock b/Cargo.lock index 7ed3d40..6da3567 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "adventure-mods" -version = "0.3.11" +version = "0.3.12" dependencies = [ "anyhow", "async-channel", diff --git a/Cargo.toml b/Cargo.toml index a84f2c1..a9f9ae9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "adventure-mods" -version = "0.3.11" +version = "0.3.12" edition = "2024" license = "MIT" diff --git a/Makefile b/Makefile index 5eca481..db52232 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,13 @@ -.PHONY: appimage +.PHONY: appimage flatpak + +# Local Flatpak install (Devel by default). Override for a production build: +# make flatpak FLATPAK_MANIFEST=build-aux/io.github.astrovm.AdventureMods.json +FLATPAK_BUILD_DIR ?= build +FLATPAK_MANIFEST ?= build-aux/io.github.astrovm.AdventureMods.Devel.json appimage: ./build-aux/appimage/build-container.sh + +flatpak: + flatpak-builder --force-clean --user --install-deps-from=flathub --install \ + $(FLATPAK_BUILD_DIR) $(FLATPAK_MANIFEST) diff --git a/README.md b/README.md index 5a7f7b0..22635a1 100644 --- a/README.md +++ b/README.md @@ -66,56 +66,46 @@ Running without a subcommand launches the GUI. Pass a subcommand for CLI mode. -## Command line (optional) +## CLI (optional)

CLI

-The graphical app is recommended for most users. Use the command line for automation or non-interactive setup. - -Run a command through the installed Flatpak: +The graphical app is recommended for most users. From a terminal, the usual entry point is the interactive setup wizard: ```sh -flatpak run io.github.astrovm.AdventureMods [command] [options] +flatpak run io.github.astrovm.AdventureMods setup ``` -Start the interactive setup from a terminal: +It walks through game selection, mods, and install steps. Omit game or mod flags to stay interactive; pass them only when you want a fully non-interactive run. -```sh -flatpak run io.github.astrovm.AdventureMods setup -``` +
+Other commands | Command | Description | | ---------------------------- | ------------------------------------------------------------ | | `detect` | Show detected game installs and inaccessible Steam libraries | | `list-mods --game sadx\|sa2` | List available presets and mods for a game | -| `setup` | Install runtimes, mod manager, mods, and config files | - -Examples: ```sh flatpak run io.github.astrovm.AdventureMods detect flatpak run io.github.astrovm.AdventureMods list-mods --game sadx -flatpak run io.github.astrovm.AdventureMods setup --game sadx --preset "DX Enhanced" -flatpak run io.github.astrovm.AdventureMods setup --game sa2 --all-mods -``` - -Use `--help` to see every command and option: - -```sh flatpak run io.github.astrovm.AdventureMods --help -flatpak run io.github.astrovm.AdventureMods setup --help ``` +
+
Non-interactive setup options +Use these with `setup` for scripting. When game, path, or mod selection is omitted, `setup` stays interactive. + | Flag | Description | | ---------------------------- | -------------------------------------------- | | `--game sadx\|sa2` | Select the game | | `--game-path /path` | Override Steam detection | -| `--preset "Name"` | Named preset (SADX only) | +| `--preset "Name"` | Named preset (SADX only) | | `--all-mods` | Install all recommended mods | | `--mods slug1,slug2` | Install specific mods by slug | | `--subtitle-language` | Select subtitles | @@ -129,19 +119,23 @@ Subtitle languages: - SADX: `japanese`, `english`, `french`, `spanish`, `german` - SA2: `english`, `german`, `spanish`, `french`, `italian`, `japanese` -When game, path, or mod selection is omitted, `setup` opens the interactive wizard. +```sh +flatpak run io.github.astrovm.AdventureMods setup --game sadx --preset "DX Enhanced" +flatpak run io.github.astrovm.AdventureMods setup --help +```
## Development -**Flatpak** +**Flatpak** (Devel manifest, installs for the current user) ```sh -flatpak-builder --force-clean --user --install-deps-from=flathub --install \ - build build-aux/io.github.astrovm.AdventureMods.Devel.json +make flatpak ``` +Production manifest: `make flatpak FLATPAK_MANIFEST=build-aux/io.github.astrovm.AdventureMods.json` + **AppImage** (Podman + `debian:13`) ```sh diff --git a/data/io.github.astrovm.AdventureMods.metainfo.xml.in b/data/io.github.astrovm.AdventureMods.metainfo.xml.in index af0e082..e4207cd 100644 --- a/data/io.github.astrovm.AdventureMods.metainfo.xml.in +++ b/data/io.github.astrovm.AdventureMods.metainfo.xml.in @@ -44,6 +44,16 @@ #1a5fb4 + + +

Improved animation smoothness and interface responsiveness.

+
    +
  • Coalesced progress display updates to keep animations responsive
  • +
  • Cached and reused mod preview images while avoiding redundant refreshes
  • +
  • Added quick fade-in step transitions with reduced-motion support
  • +
+
+

Updated runtime requirements for the latest SA Mod Manager.

diff --git a/data/resources/style.css b/data/resources/style.css index 8558cff..6d7e951 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -33,13 +33,19 @@ } .game-card-clickable { - transition: box-shadow 160ms ease, transform 160ms ease; + transition: box-shadow 160ms ease-out; } .game-card-clickable:hover { box-shadow: 0 12px 26px rgba(0, 0, 0, 0.22); } +@media (prefers-reduced-motion: reduce) { + .game-card-clickable { + transition: none; + } +} + .game-card-title { font-weight: bold; font-size: 0.95em; diff --git a/data/resources/ui/setup_page.ui b/data/resources/ui/setup_page.ui index c8adddb..6c34098 100644 --- a/data/resources/ui/setup_page.ui +++ b/data/resources/ui/setup_page.ui @@ -43,13 +43,22 @@ - - vertical - 12 - fill + + false + crossfade + 100 true - fill true + + + vertical + 12 + fill + true + fill + true + + diff --git a/src/ui/setup_page.rs b/src/ui/setup_page.rs index 7c1b6ab..f8588fd 100644 --- a/src/ui/setup_page.rs +++ b/src/ui/setup_page.rs @@ -1,11 +1,13 @@ -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::collections::HashMap; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::rc::Rc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use adw::prelude::*; use adw::subclass::prelude::*; -use gtk::{gio, glib}; +use gtk::{gdk, gio, glib}; use crate::blocking; use crate::setup::steps::StepId; @@ -14,9 +16,12 @@ use crate::steam::game::Game; const MOD_PREVIEW_DESCRIPTION_HEIGHT: i32 = 150; const MOD_PREVIEW_TEXT_WIDTH_CHARS: i32 = 42; -mod imp { - use std::cell::RefCell; +const MOD_PREVIEW_HOVER_DELAY: Duration = Duration::from_millis(50); +const PROGRESS_UPDATE_INTERVAL: Duration = Duration::from_millis(16); +/// Fade-in only duration for step body swaps. Kept short so Continue/Back feel snappy. +const CONTENT_FADE_MS: u32 = 100; +mod imp { use super::*; #[derive(Default, gtk::CompositeTemplate)] @@ -29,6 +34,8 @@ mod imp { #[template_child] pub step_description: TemplateChild, #[template_child] + pub content_revealer: TemplateChild, + #[template_child] pub content_box: TemplateChild, #[template_child] pub back_button: TemplateChild, @@ -76,6 +83,14 @@ mod imp { fn constructed(&self) { self.parent_constructed(); + sync_content_fade_duration(&self.content_revealer); + if let Some(settings) = gtk::Settings::default() { + let revealer = self.content_revealer.get(); + settings.connect_gtk_enable_animations_notify(move |_| { + sync_content_fade_duration(&revealer); + }); + } + let obj = self.obj().clone(); self.next_button.connect_clicked(move |_| { if crate::ui::catch_ui_panic("setup next button", || obj.on_next_clicked()).is_err() @@ -101,6 +116,8 @@ mod imp { } enum ProgressMsg { + /// Wakes the UI so it can read the latest byte samples. Safe to drop when full. + Refresh, Bytes { downloaded: u64, total: Option, @@ -110,12 +127,6 @@ enum ProgressMsg { mod_name: String, total: usize, }, - ModBytes { - mod_name: String, - total: usize, - downloaded: u64, - total_bytes: Option, - }, ModFinished { mod_name: String, completed: usize, @@ -126,90 +137,257 @@ enum ProgressMsg { }, } +/// Latest high-frequency byte samples. Download workers overwrite these without blocking; +/// the UI merges them each frame so a full progress channel cannot leave stale per-mod totals. +#[derive(Default)] +struct ProgressSamples { + step: Mutex, String)>>, + mods: Mutex)>>, + mod_total: AtomicUsize, +} + +impl ProgressSamples { + fn set_step_bytes(&self, downloaded: u64, total: Option, status: impl Into) { + *self.step.lock().expect("progress samples lock") = + Some((downloaded, total, status.into())); + } + + fn set_mod_bytes( + &self, + mod_name: impl Into, + downloaded: u64, + total_bytes: Option, + total_mods: usize, + ) { + self.mod_total.store(total_mods, Ordering::Relaxed); + self.mods + .lock() + .expect("progress samples lock") + .insert(mod_name.into(), (downloaded, total_bytes)); + } + + fn clear_mod(&self, mod_name: &str) { + self.mods + .lock() + .expect("progress samples lock") + .remove(mod_name); + } + + fn clear(&self) { + *self.step.lock().expect("progress samples lock") = None; + self.mods.lock().expect("progress samples lock").clear(); + self.mod_total.store(0, Ordering::Relaxed); + } + + fn apply_to(&self, state: &mut ProgressState) { + if let Some((downloaded, total, status)) = + self.step.lock().expect("progress samples lock").clone() + { + state.apply(ProgressMsg::Bytes { + downloaded, + total, + status, + }); + } + + let mods = self.mods.lock().expect("progress samples lock").clone(); + if mods.is_empty() { + return; + } + + let total_mods = self.mod_total.load(Ordering::Relaxed); + state.active_downloads = mods; + let (downloaded, total_bytes) = state.active_downloads.values().fold( + (0u64, Some(0u64)), + |(downloaded, total), (item_downloaded, item_total)| { + let total = match (total, item_total) { + (Some(total), Some(item_total)) => Some(total + item_total), + _ => None, + }; + (downloaded + item_downloaded, total) + }, + ); + let update = + mod_download_progress_update(state.completed_mods, total_mods, downloaded, total_bytes); + state.display = Some(ProgressDisplay { + fraction: Some(update.fraction), + pulse: update.pulse, + text: update.text, + }); + } +} + +fn wake_progress_ui(tx: &async_channel::Sender) { + let _ = tx.try_send(ProgressMsg::Refresh); +} + +fn publish_step_bytes( + tx: &async_channel::Sender, + samples: &ProgressSamples, + downloaded: u64, + total: Option, + status: &str, +) { + samples.set_step_bytes(downloaded, total, status); + wake_progress_ui(tx); +} + +fn publish_mod_bytes( + tx: &async_channel::Sender, + samples: &ProgressSamples, + mod_name: &str, + total_mods: usize, + downloaded: u64, + total_bytes: Option, +) { + samples.set_mod_bytes(mod_name, downloaded, total_bytes, total_mods); + wake_progress_ui(tx); +} + +fn content_animations_enabled() -> bool { + gtk::Settings::default() + .map(|settings| settings.is_gtk_enable_animations()) + .unwrap_or(true) +} + +fn sync_content_fade_duration(revealer: >k::Revealer) { + revealer.set_transition_duration(if content_animations_enabled() { + CONTENT_FADE_MS + } else { + 0 + }); +} + glib::wrapper! { pub struct AdventureModsSetupPage(ObjectSubclass) @extends gtk::Widget, adw::Bin, @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; } +#[derive(Clone, Debug, PartialEq)] +struct ProgressDisplay { + fraction: Option, + pulse: bool, + text: String, +} + +fn fraction_needs_update(previous: Option<&ProgressDisplay>, fraction: f64) -> bool { + previous.is_none_or(|previous| { + previous.pulse + || previous + .fraction + .is_none_or(|previous| (previous - fraction).abs() >= 0.001) + }) +} + +#[derive(Default)] +struct ProgressState { + completed_mods: usize, + active_downloads: HashMap)>, + display: Option, +} + +impl ProgressState { + fn apply(&mut self, msg: ProgressMsg) { + let display = match msg { + ProgressMsg::Refresh => return, + ProgressMsg::Bytes { + downloaded, + total, + status, + } => ProgressDisplay { + fraction: total + .filter(|total| *total > 0) + .map(|total| downloaded as f64 / total as f64), + pulse: total.is_none(), + text: format_step_download_text(&status, downloaded, total), + }, + ProgressMsg::ModInstall { mod_name, total } => ProgressDisplay { + fraction: Some(completed_mod_fraction(self.completed_mods, total)), + pulse: false, + text: mod_download_start_text(&mod_name), + }, + ProgressMsg::ModFinished { + mod_name, + completed, + total, + } => { + self.completed_mods = completed; + self.active_downloads.remove(&mod_name); + ProgressDisplay { + fraction: Some(completed_mod_fraction(completed, total)), + pulse: false, + text: mod_download_finished_text(&mod_name, completed, total), + } + } + ProgressMsg::Configuring { total } => { + self.active_downloads.clear(); + ProgressDisplay { + fraction: Some(1.0), + pulse: false, + text: format!("Generating config... ({total}/{total})"), + } + } + }; + self.display = Some(display); + } + + fn render(&self, progress_bar: >k::ProgressBar, previous: &mut Option) { + let Some(display) = self.display.as_ref() else { + return; + }; + + if display.pulse { + progress_bar.pulse(); + } else if let Some(fraction) = display.fraction { + let changed = fraction_needs_update(previous.as_ref(), fraction); + if changed { + progress_bar.set_fraction(fraction); + } + } + + if previous.as_ref().map(|previous| previous.text.as_str()) != Some(display.text.as_str()) { + progress_bar.set_text(Some(&display.text)); + } + + *previous = Some(display.clone()); + } +} + +fn drain_progress_updates( + receiver: &async_channel::Receiver, + state: &mut ProgressState, +) { + while let Ok(msg) = receiver.try_recv() { + state.apply(msg); + } +} + fn spawn_progress_receiver( progress_bar: gtk::ProgressBar, receiver: async_channel::Receiver, + samples: Arc, ) { glib::spawn_future_local(async move { - let mut completed_mods = 0; - let mut active_downloads: HashMap)> = HashMap::new(); + let mut state = ProgressState::default(); + let mut previous = None; + let mut last_render: Option = None; + while let Ok(msg) = receiver.recv().await { - match msg { - ProgressMsg::Bytes { - downloaded, - total, - status, - } => { - if let Some(total) = total { - if total > 0 { - progress_bar.set_fraction(downloaded as f64 / total as f64); - } - } else { - progress_bar.pulse(); - } - let text = format_step_download_text(&status, downloaded, total); - progress_bar.set_text(Some(&text)); - } - ProgressMsg::ModInstall { mod_name, total } => { - progress_bar.set_fraction(completed_mod_fraction(completed_mods, total)); - progress_bar.set_text(Some(&mod_download_start_text(&mod_name))); - } - ProgressMsg::ModBytes { - mod_name, - total, - downloaded, - total_bytes, - } => { - active_downloads.insert(mod_name.clone(), (downloaded, total_bytes)); - - // Aggregate bytes across all active downloads for a monotonic fraction. - let (agg_downloaded, agg_total) = - active_downloads - .values() - .fold((0u64, Some(0u64)), |(ad, at), (d, t)| { - let new_at = match (at, t) { - (Some(a), Some(b)) => Some(a + b), - _ => None, - }; - (ad + d, new_at) - }); - - let update = mod_download_progress_update( - completed_mods, - total, - agg_downloaded, - agg_total, - ); - if update.pulse { - progress_bar.pulse(); - } else { - progress_bar.set_fraction(update.fraction); - } - progress_bar.set_text(Some(&update.text)); - } - ProgressMsg::ModFinished { - mod_name, - completed, - total, - } => { - completed_mods = completed; - active_downloads.remove(&mod_name); - progress_bar.set_fraction(completed_mod_fraction(completed, total)); - progress_bar.set_text(Some(&mod_download_finished_text( - &mod_name, completed, total, - ))); - } - ProgressMsg::Configuring { total } => { - progress_bar.set_fraction(1.0); - progress_bar.set_text(Some(&format!("Generating config... ({total}/{total})"))); + state.apply(msg); + + if let Some(last_render) = last_render { + let elapsed = last_render.elapsed(); + if elapsed < PROGRESS_UPDATE_INTERVAL { + glib::timeout_future(PROGRESS_UPDATE_INTERVAL - elapsed).await; } } + + drain_progress_updates(&receiver, &mut state); + samples.apply_to(&mut state); + + state.render(&progress_bar, &mut previous); + last_render = Some(Instant::now()); } }); } @@ -337,14 +515,122 @@ fn voice_language_index(language: config::VoiceLanguage) -> u32 { .unwrap_or(0) as u32 } -fn populate_mod_preview( - title_label: >k::Label, - carousel: &adw::Carousel, - carousel_frame: >k::Frame, - description_label: >k::Label, - links_box: >k::FlowBox, - mod_entry: Option<&common::ModEntry>, -) { +#[derive(Default)] +struct ModPreviewState { + current_index: Option, + pages: HashMap>, +} + +#[derive(Clone)] +struct ModPreview { + game_kind: crate::steam::game::GameKind, + title_label: gtk::Label, + carousel: adw::Carousel, + carousel_frame: gtk::Frame, + description_label: gtk::Label, + links_box: gtk::FlowBox, + state: Rc>, + hover_source: Rc>>, +} + +impl ModPreview { + fn new( + game_kind: crate::steam::game::GameKind, + title_label: >k::Label, + carousel: &adw::Carousel, + carousel_frame: >k::Frame, + description_label: >k::Label, + links_box: >k::FlowBox, + ) -> Self { + Self { + game_kind, + title_label: title_label.clone(), + carousel: carousel.clone(), + carousel_frame: carousel_frame.clone(), + description_label: description_label.clone(), + links_box: links_box.clone(), + state: Rc::new(RefCell::new(ModPreviewState::default())), + hover_source: Rc::new(RefCell::new(None)), + } + } + + fn show(&self, index: usize) { + let mods = common::recommended_mods_for_game(self.game_kind); + self.show_entry(Some(index), mods.get(index)); + } + + fn show_entry(&self, index: Option, mod_entry: Option<&common::ModEntry>) { + if self.state.borrow().current_index == index { + return; + } + + clear_carousel(&self.carousel); + let (name, description, links) = if let Some(mod_entry) = mod_entry { + ( + mod_entry.name, + mod_entry.full_description.unwrap_or(mod_entry.description), + mod_entry.links, + ) + } else { + ("", "", &[][..]) + }; + + let pages = match (index, mod_entry) { + (Some(index), Some(mod_entry)) => { + let cached_pages = self.state.borrow().pages.get(&index).cloned(); + if let Some(pages) = cached_pages { + pages + } else { + let pages = build_mod_preview_pages(mod_entry); + self.state.borrow_mut().pages.insert(index, pages.clone()); + pages + } + } + _ => Vec::new(), + }; + + self.title_label.set_label(name); + self.carousel_frame.set_visible(!pages.is_empty()); + for page in pages { + self.carousel.append(&page); + } + self.description_label.set_label(description); + + while let Some(child) = self.links_box.first_child() { + self.links_box.remove(&child); + } + for link in links { + let button = gtk::LinkButton::builder() + .label(link.label) + .uri(link.url) + .halign(gtk::Align::Start) + .build(); + self.links_box.insert(&button, -1); + } + + self.state.borrow_mut().current_index = index; + } + + fn queue_hover(&self, index: usize) { + self.cancel_hover(); + + let preview = self.clone(); + let source_id = glib::timeout_add_local(MOD_PREVIEW_HOVER_DELAY, move || { + preview.hover_source.borrow_mut().take(); + preview.show(index); + glib::ControlFlow::Break + }); + self.hover_source.borrow_mut().replace(source_id); + } + + fn cancel_hover(&self) { + if let Some(source_id) = self.hover_source.borrow_mut().take() { + source_id.remove(); + } + } +} + +fn clear_carousel(carousel: &adw::Carousel) { let mut children = Vec::new(); let mut child = carousel.first_child(); while let Some(widget) = child { @@ -354,32 +640,21 @@ fn populate_mod_preview( for child in children { carousel.remove(&child); } +} - let (name, pictures, description, links) = if let Some(mod_entry) = mod_entry { - ( - mod_entry.name, - mod_entry.pictures, - mod_entry.full_description.unwrap_or(mod_entry.description), - mod_entry.links, - ) - } else { - ("", &[][..], "", &[][..]) - }; - - title_label.set_label(name); - - if pictures.is_empty() { - carousel_frame.set_visible(false); - } else { - carousel_frame.set_visible(true); - for pic in pictures { - let img = gtk::Picture::builder() +fn build_mod_preview_pages(mod_entry: &common::ModEntry) -> Vec { + mod_entry + .pictures + .iter() + .map(|pic| { + let texture = gdk::Texture::from_resource(pic); + let image = gtk::Picture::builder() .can_shrink(true) .content_fit(gtk::ContentFit::Contain) .hexpand(true) .vexpand(true) .build(); - img.set_resource(Some(*pic)); + image.set_paintable(Some(&texture)); let badge_text = if pic.contains("_before") { Some("Before") @@ -398,52 +673,17 @@ fn populate_mod_preview( .css_classes(vec!["caption".to_string(), "osd".to_string()]) .build(); let overlay = gtk::Overlay::builder() - .child(&img) + .child(&image) .hexpand(true) .vexpand(true) .build(); overlay.add_overlay(&badge); - carousel.append(&overlay); + overlay.upcast::() } else { - carousel.append(&img); + image.upcast::() } - } - } - - description_label.set_label(description); - - // Update links - while let Some(child) = links_box.first_child() { - links_box.remove(&child); - } - for link in links { - let button = gtk::LinkButton::builder() - .label(link.label) - .uri(link.url) - .halign(gtk::Align::Start) - .build(); - links_box.insert(&button, -1); - } -} - -fn populate_mod_preview_for_index( - title_label: >k::Label, - carousel: &adw::Carousel, - carousel_frame: >k::Frame, - description_label: >k::Label, - links_box: >k::FlowBox, - game_kind: crate::steam::game::GameKind, - index: usize, -) { - let mods = common::recommended_mods_for_game(game_kind); - populate_mod_preview( - title_label, - carousel, - carousel_frame, - description_label, - links_box, - mods.get(index), - ); + }) + .collect() } impl AdventureModsSetupPage { @@ -482,6 +722,65 @@ impl AdventureModsSetupPage { return; }; + // Gate navigation immediately for steps that start work as soon as they render. + if matches!(step.kind, steps::StepKind::Auto | steps::StepKind::Download) { + imp.next_button.set_sensitive(false); + } + + let is_last_step = step_idx + 1 >= all_steps.len(); + imp.back_button.set_visible(!is_last_step); + imp.back_button + .set_sensitive(!is_last_step && !imp.step_busy.get()); + + drop(all_steps); + self.transition_step_content(); + } + + fn current_step_prefers_instant_transition(&self) -> bool { + let imp = self.imp(); + imp.all_steps + .borrow() + .get(imp.current_step.get()) + .is_some_and(|step| { + matches!(step.kind, steps::StepKind::Auto | steps::StepKind::Download) + }) + } + + /// Swap step body with a short fade-in. Work steps (Auto/Download) skip motion + /// so progress UI appears immediately. + fn transition_step_content(&self) { + let imp = self.imp(); + let revealer = &imp.content_revealer; + + let instant = self.current_step_prefers_instant_transition() + || !self.is_mapped() + || !content_animations_enabled(); + + if instant { + revealer.set_transition_duration(0); + self.render_current_step_content(); + revealer.set_reveal_child(true); + sync_content_fade_duration(revealer); + return; + } + + // Fade-in only: hide without animating, rebuild, then crossfade in (~100 ms). + revealer.set_transition_duration(0); + revealer.set_reveal_child(false); + self.render_current_step_content(); + sync_content_fade_duration(revealer); + revealer.set_reveal_child(true); + } + + /// Title, description, and layout stay in sync with the step body. + fn apply_step_chrome(&self) { + let imp = self.imp(); + let step_idx = imp.current_step.get(); + let all_steps = imp.all_steps.borrow(); + let Some(step) = all_steps.get(step_idx) else { + return; + }; + let centered_layout = !matches!(step.kind, steps::StepKind::ModSelection); imp.body_box.set_valign(if centered_layout { gtk::Align::Center @@ -511,18 +810,23 @@ impl AdventureModsSetupPage { step.description.to_string() }; imp.step_description.set_label(&step_description); + } + + fn render_current_step_content(&self) { + self.apply_step_chrome(); + let imp = self.imp(); + let step_idx = imp.current_step.get(); + let all_steps = imp.all_steps.borrow(); + let Some(step) = all_steps.get(step_idx) else { + return; + }; let is_last_step = step_idx + 1 >= all_steps.len(); - imp.back_button.set_visible(!is_last_step); - imp.back_button - .set_sensitive(!is_last_step && !imp.step_busy.get()); - let content_box = &imp.content_box; - while let Some(child) = content_box.first_child() { - content_box.remove(&child); + while let Some(child) = imp.content_box.first_child() { + imp.content_box.remove(&child); } - - self.render_step(step, is_last_step, content_box); + self.render_step(step, is_last_step, &imp.content_box); } fn render_step(&self, step: &steps::SetupStep, is_last_step: bool, content_box: >k::Box) { @@ -560,7 +864,6 @@ impl AdventureModsSetupPage { .spacing(12) .hexpand(true) .build(); - let subtitle_box = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(12) @@ -660,17 +963,15 @@ impl AdventureModsSetupPage { // the step, so we don't start a new task while the old one is // still writing to disk. let obj2 = obj.clone(); - let source_id = glib::timeout_add_local( - std::time::Duration::from_millis(50), - move || { + let source_id = + glib::timeout_add_local(Duration::from_millis(50), move || { if obj2.imp().task_running.get() { return glib::ControlFlow::Continue; } obj2.imp().poll_source.borrow_mut().take(); obj2.show_current_step(); glib::ControlFlow::Break - }, - ); + }); obj.imp().poll_source.replace(Some(source_id)); }) .is_err() @@ -730,9 +1031,7 @@ impl AdventureModsSetupPage { .css_classes(vec!["boxed-list".to_string()]) .build(); - let checks: std::rc::Rc>> = - std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); - + let checks: Rc>> = Rc::new(RefCell::new(Vec::new())); if !presets.is_empty() { let preset_box = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) @@ -880,26 +1179,23 @@ impl AdventureModsSetupPage { .map(common::recommended_mods_for_game) .unwrap_or(&[]); let preview_game_kind = game_kind.unwrap_or(crate::steam::game::GameKind::SADX); + let preview = ModPreview::new( + preview_game_kind, + &preview_title_label, + &carousel, + &carousel_frame, + &full_desc_label, + &links_box, + ); let mut initial_selected = Vec::new(); { - let preview_title_clone = preview_title_label.clone(); - let carousel_clone = carousel.clone(); - let carousel_frame_clone = carousel_frame.clone(); - let desc_lbl_clone = full_desc_label.clone(); - let links_box_clone = links_box.clone(); + let preview = preview.clone(); list_box.connect_row_selected(move |_, row| { let _ = crate::ui::catch_ui_panic("mod row selection", || { let Some(row) = row else { return }; - populate_mod_preview_for_index( - &preview_title_clone, - &carousel_clone, - &carousel_frame_clone, - &desc_lbl_clone, - &links_box_clone, - preview_game_kind, - row.index() as usize, - ); + preview.cancel_hover(); + preview.show(row.index() as usize); }); }); } @@ -967,47 +1263,25 @@ impl AdventureModsSetupPage { }); }); - let preview_title_clone = preview_title_label.clone(); - let carousel_clone = carousel.clone(); - let carousel_frame_clone = carousel_frame.clone(); - let desc_lbl_clone = full_desc_label.clone(); - let links_box_clone = links_box.clone(); + let preview_focus = preview.clone(); check.connect_has_focus_notify(move |btn| { let _ = crate::ui::catch_ui_panic("mod checkbox focus", || { if btn.has_focus() { - populate_mod_preview_for_index( - &preview_title_clone, - &carousel_clone, - &carousel_frame_clone, - &desc_lbl_clone, - &links_box_clone, - preview_game_kind, - idx, - ); + preview_focus.cancel_hover(); + preview_focus.show(idx); } }); }); - let preview_title_clone = preview_title_label.clone(); - let carousel_clone = carousel.clone(); - let carousel_frame_clone = carousel_frame.clone(); - let desc_lbl_clone = full_desc_label.clone(); - let links_box_clone = links_box.clone(); - let mod_entry_clone = mod_entry; - + let preview_enter = preview.clone(); + let preview_leave = preview.clone(); let gesture = gtk::EventControllerMotion::new(); gesture.connect_enter(move |_, _, _| { let _ = crate::ui::catch_ui_panic("mod row hover", || { - populate_mod_preview( - &preview_title_clone, - &carousel_clone, - &carousel_frame_clone, - &desc_lbl_clone, - &links_box_clone, - Some(mod_entry_clone), - ); + preview_enter.queue_hover(idx); }); }); + gesture.connect_leave(move |_| preview_leave.cancel_hover()); list_row.add_controller(gesture); list_box.append(&list_row); @@ -1017,21 +1291,13 @@ impl AdventureModsSetupPage { } imp.selected_mods.replace(initial_selected); - let preview_entry = initial_preview_index(mods_list.len(), &imp.selected_mods.borrow()) - .and_then(|idx| mods_list.get(idx)); - populate_mod_preview( - &preview_title_label, - &carousel, - &carousel_frame, - &full_desc_label, - &links_box, - preview_entry, - ); if let Some(initial_index) = initial_preview_index(mods_list.len(), &imp.selected_mods.borrow()) - && let Some(row) = list_box.row_at_index(initial_index as i32) { - list_box.select_row(Some(&row)); + preview.show(initial_index); + if let Some(row) = list_box.row_at_index(initial_index as i32) { + list_box.select_row(Some(&row)); + } } scrolled.set_child(Some(&list_box)); @@ -1089,8 +1355,9 @@ impl AdventureModsSetupPage { }; let (tx, rx) = async_channel::bounded::(32); + let samples = Arc::new(ProgressSamples::default()); - spawn_progress_receiver(progress_bar.clone(), rx); + spawn_progress_receiver(progress_bar.clone(), rx, samples.clone()); let game_kind = game.kind; let (width, height) = obj.get_resolution(); @@ -1098,13 +1365,10 @@ impl AdventureModsSetupPage { StepId::ConvertSteam => { let game_path = game.path.clone(); let tx_clone = tx.clone(); + let samples = samples.clone(); let progress_fn: Option = Some(Box::new(move |dl, total| { - let _ = tx_clone.send_blocking(ProgressMsg::Bytes { - downloaded: dl, - total, - status: "Downloading...".to_string(), - }); + publish_step_bytes(&tx_clone, &samples, dl, total, "Downloading..."); })); blocking::flatten_spawn_result( gio::spawn_blocking(move || { @@ -1117,13 +1381,10 @@ impl AdventureModsSetupPage { let game_path = game.path.clone(); let game_kind = game.kind; let tx_clone = tx.clone(); + let samples = samples.clone(); let progress_fn: Option = Some(Box::new(move |dl, total| { - let _ = tx_clone.send_blocking(ProgressMsg::Bytes { - downloaded: dl, - total, - status: "Downloading...".to_string(), - }); + publish_step_bytes(&tx_clone, &samples, dl, total, "Downloading..."); })); blocking::flatten_spawn_result( gio::spawn_blocking(move || { @@ -1172,12 +1433,14 @@ impl AdventureModsSetupPage { if cancel_during_install.load(Ordering::Relaxed) { return Err(anyhow::anyhow!("cancelled")); } - let _ = tx.send_blocking(ProgressMsg::ModBytes { - mod_name: mod_name.to_string(), - total: total_count, + publish_mod_bytes( + &tx, + &samples, + mod_name, + total_count, downloaded, total_bytes, - }); + ); } pipeline::InstallProgress::Finished { mod_name, @@ -1187,6 +1450,7 @@ impl AdventureModsSetupPage { if cancel_during_install.load(Ordering::Relaxed) { return Err(anyhow::anyhow!("cancelled")); } + samples.clear_mod(mod_name); let _ = tx.send_blocking(ProgressMsg::ModFinished { mod_name: mod_name.to_string(), completed, @@ -1197,6 +1461,7 @@ impl AdventureModsSetupPage { // Do not honour cancel here: all mods are already // installed. Aborting now would leave mods installed // but no config written. + samples.clear(); let _ = tx.send_blocking(ProgressMsg::Configuring { total: total_count, }); @@ -1297,35 +1562,42 @@ impl AdventureModsSetupPage { } fn on_next_clicked(&self) { - if self.imp().is_error.get() { + let imp = self.imp(); + + // Ignore clicks while a step is running work (Auto/Download). + if imp.step_busy.get() { + return; + } + + if imp.is_error.get() { // Retry: re-run the current step self.show_current_step(); + return; + } + + let Some(current_step_id) = imp + .all_steps + .borrow() + .get(imp.current_step.get()) + .map(|step| step.id) + else { + tracing::warn!( + "Setup next button clicked with invalid step index {}", + imp.current_step.get() + ); + self.show_error("Setup state is out of date. Please try again."); + return; + }; + let next = imp.current_step.get() + 1; + let total = imp.all_steps.borrow().len(); + if next >= total { + // Last step: navigate back to the welcome page + self.go_back_to_welcome(); } else { - let imp = self.imp(); - let Some(current_step_id) = imp - .all_steps - .borrow() - .get(imp.current_step.get()) - .map(|step| step.id) - else { - tracing::warn!( - "Setup next button clicked with invalid step index {}", - imp.current_step.get() - ); - self.show_error("Setup state is out of date. Please try again."); - return; - }; - let next = imp.current_step.get() + 1; - let total = imp.all_steps.borrow().len(); - if next >= total { - // Last step: navigate back to the welcome page - self.go_back_to_welcome(); - } else { - if current_step_id == StepId::LanguageOptions { - self.persist_language_selection(); - } - self.advance_step(); + if current_step_id == StepId::LanguageOptions { + self.persist_language_selection(); } + self.advance_step(); } } @@ -1415,11 +1687,15 @@ impl AdventureModsSetupPage { let imp = self.imp(); imp.is_error.set(true); + // Show errors immediately; do not wait on the step fade-in. + imp.content_revealer.set_transition_duration(0); + imp.content_revealer.set_reveal_child(true); + sync_content_fade_duration(&imp.content_revealer); + let content_box = &imp.content_box; while let Some(child) = content_box.first_child() { content_box.remove(&child); } - let label = gtk::Label::builder() .label(message) .wrap(true) @@ -1440,9 +1716,11 @@ mod tests { use super::AdventureModsSetupPage; use super::{ - completed_mod_fraction, format_step_download_text, initial_preview_index, - mod_download_finished_text, mod_download_fraction, mod_download_progress_update, - mod_download_start_text, subtitle_language_labels, voice_language_labels, + ProgressDisplay, ProgressMsg, ProgressSamples, ProgressState, completed_mod_fraction, + drain_progress_updates, format_step_download_text, fraction_needs_update, + initial_preview_index, mod_download_finished_text, mod_download_fraction, + mod_download_progress_update, mod_download_start_text, subtitle_language_labels, + voice_language_labels, }; use crate::setup::steps::StepId; use crate::steam::game::Game; @@ -1535,6 +1813,80 @@ mod tests { assert_eq!(update.text, "Downloading mods - 1.0 / 2.0 MB"); } + #[test] + fn draining_progress_updates_keeps_the_latest_value() { + let (sender, receiver) = async_channel::bounded(4); + sender + .try_send(ProgressMsg::Bytes { + downloaded: 1_048_576, + total: Some(4_194_304), + status: "Downloading...".to_string(), + }) + .unwrap(); + sender + .try_send(ProgressMsg::Bytes { + downloaded: 2_097_152, + total: Some(4_194_304), + status: "Downloading...".to_string(), + }) + .unwrap(); + + let mut state = ProgressState::default(); + drain_progress_updates(&receiver, &mut state); + + let display = state.display.unwrap(); + assert_eq!(display.fraction, Some(0.5)); + assert_eq!(display.text, "Downloading... - 2.0 / 4.0 MB"); + } + + #[test] + fn progress_samples_keep_latest_bytes_per_mod() { + let samples = ProgressSamples::default(); + samples.set_mod_bytes("Alpha", 1_048_576, Some(2_097_152), 2); + samples.set_mod_bytes("Beta", 524_288, Some(2_097_152), 2); + samples.set_mod_bytes("Alpha", 1_572_864, Some(2_097_152), 2); + + let mut state = ProgressState::default(); + samples.apply_to(&mut state); + + assert_eq!( + state.active_downloads.get("Alpha"), + Some(&(1_572_864, Some(2_097_152))) + ); + assert_eq!( + state.active_downloads.get("Beta"), + Some(&(524_288, Some(2_097_152))) + ); + let display = state.display.unwrap(); + assert_eq!(display.text, "Downloading mods - 2.0 / 4.0 MB"); + assert!(!display.pulse); + } + + #[test] + fn progress_samples_overwrite_step_bytes() { + let samples = ProgressSamples::default(); + samples.set_step_bytes(1_048_576, Some(4_194_304), "Downloading..."); + samples.set_step_bytes(2_097_152, Some(4_194_304), "Downloading..."); + + let mut state = ProgressState::default(); + samples.apply_to(&mut state); + + let display = state.display.unwrap(); + assert_eq!(display.fraction, Some(0.5)); + assert_eq!(display.text, "Downloading... - 2.0 / 4.0 MB"); + } + + #[test] + fn determinate_progress_refreshes_after_pulse_mode() { + let previous = ProgressDisplay { + fraction: Some(0.25), + pulse: true, + text: "Downloading mods - 1.0 MB".to_string(), + }; + + assert!(fraction_needs_update(Some(&previous), 0.25)); + } + #[test] fn mod_download_start_text_is_uniform() { assert_eq!(