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