From 35dcd584d091bd0fe1b2e1ce852af0a0c4617d7e Mon Sep 17 00:00:00 2001 From: Maciek <108873945+rozwader@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:24:10 +0200 Subject: [PATCH] chore: changed the UX for optional mods modal (#878) * chore: changed the UX for optional mods modal * refactor: fold cancel_optional_mods into close_optional_mods * fix: forget optional mod picks once the modal closes * chore: rustfmt the optional mods import * chore: say what the optional mods buttons actually do --------- Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com> --- .../src/components/optional_mods_popup.rs | 47 +++++++++++-------- packages/oneclient_app/src/hooks/actions.rs | 36 +++++++------- packages/oneclient_app/src/notifications.rs | 18 ++++--- 3 files changed, 57 insertions(+), 44 deletions(-) diff --git a/packages/oneclient_app/src/components/optional_mods_popup.rs b/packages/oneclient_app/src/components/optional_mods_popup.rs index acb26ebf..f0dc3cc5 100644 --- a/packages/oneclient_app/src/components/optional_mods_popup.rs +++ b/packages/oneclient_app/src/components/optional_mods_popup.rs @@ -12,7 +12,9 @@ use crate::hooks::{ package_meta_batch, use_cached_image, use_dispatch, use_notifications_snapshot, use_package_meta_batch, }; -use crate::notifications::{ClusterUpdateItem, OptionalModRef, OptionalModsGroup}; +use crate::notifications::{ + ClusterUpdateItem, OptionalModRef, OptionalModsGroup, OptionalModsOutcome, +}; use crate::theme::colors; use crate::ui::border_all_color; @@ -21,6 +23,8 @@ const DIALOG_W: f32 = 720.; const DIALOG_PAD: f32 = 22.; const LIST_MAX_H: f32 = 306.; const ICON_SIZE: f32 = 40.; +const SKIP_NOTE: &str = + "Anything you don't pick won't be offered again, but you can add it later from the Mods tab."; type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; @@ -33,7 +37,7 @@ impl Component for OptionalModsPopup { fn render(&self) -> impl IntoElement { let snapshot = use_notifications_snapshot(); let dispatch = use_dispatch(); - let picked = use_state(Picked::new); + let mut picked = use_state(Picked::new); let groups = snapshot.optional_mods.clone(); @@ -57,11 +61,12 @@ impl Component for OptionalModsPopup { }; let close = dispatch.clone(); - let dismissed: Vec<(i64, OptionalModRef)> = - groups.iter().flat_map(|group| group.offers()).collect(); OverlayPopup::new() - .on_close(move |_| close.decline_optional_mods(dismissed.clone())) + .on_close(move |_| { + picked.set(Picked::new()); + close.close_optional_mods(OptionalModsOutcome::Cancel); + }) .child( rect() .width(Size::window_percent(100.)) @@ -150,7 +155,7 @@ fn dialog( fn content( groups: &[OptionalModsGroup], meta: &MetaMap, - picked: State, + mut picked: State, dispatch: crate::Actions, ) -> impl IntoElement { let total: usize = groups.iter().map(|group| group.mods.len()).sum(); @@ -168,24 +173,23 @@ fn content( let plural = if total == 1 { "" } else { "s" }; let subtitle = match groups { [only] => format!( - "{} includes {total} optional mod{plural} that {} off by default. Pick the {} to turn on.", + "{} includes {total} optional mod{plural} that {} off by default. Pick the {} to turn on. {SKIP_NOTE}", only.cluster_name, if total == 1 { "is" } else { "are" }, if total == 1 { "one" } else { "ones" } ), _ => format!( - "Your bundles include {total} optional mod{plural} across {} clusters that are off by default. Pick the ones to turn on.", + "Your bundles include {total} optional mod{plural} across {} clusters that are off by default. Pick the ones to turn on. {SKIP_NOTE}", groups.len() ), }; let cancel = dispatch.clone(); - let declined = offers.clone(); - let enable_text = if enable.is_empty() { - "Enable".to_string() + let primary_text = if enable.is_empty() { + "Launch".to_string() } else { - format!("Enable {}", enable.len()) + format!("Install {} & launch", enable.len()) }; rect() @@ -210,7 +214,7 @@ fn content( label() .text(subtitle) .font_size(12.5) - .max_lines(3) + .max_lines(4) .color(colors::fg_secondary()), ), ) @@ -225,22 +229,25 @@ fn content( .child( Button::new() .ghost() - .on_press(move |_| cancel.decline_optional_mods(declined.clone())) + .on_press(move |_| { + picked.set(Picked::new()); + cancel.close_optional_mods(OptionalModsOutcome::Cancel); + }) .text("Cancel"), ) - .maybe_child((!offers.is_empty()).then(|| { + .child({ let apply = dispatch.clone(); + let install = !enable.is_empty(); Button::new() .primary() - .disabled(enable.is_empty()) .on_press(move |_| { + picked.set(Picked::new()); apply.record_skipped_optional_mods(skip.clone()); apply.enable_optional_mods(enable.clone()); }) - .child(Icon::new(IconType::Check).size(15.)) - .text(enable_text.clone()) - .into_element() - })), + .maybe_child(install.then(|| Icon::new(IconType::Check).size(15.))) + .text(primary_text) + }), ) } diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index 269d103f..aa016bab 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -26,7 +26,7 @@ use tokio::sync::mpsc; use crate::components::IconType; use crate::notifications::{ ClusterUpdateSummary, NotificationAction, NotificationSpec, OptionalModRef, OptionalModsGroup, - PackageUpdateGroup, PendingPrompt, + OptionalModsOutcome, PackageUpdateGroup, PendingPrompt, }; use crate::state::{AppChannel, AppState, AsyncStatus, RelocationState}; use crate::{invalidate_java_queries, launcher}; @@ -727,18 +727,13 @@ impl Actions { pub fn open_optional_mods( &self, groups: Vec, - done: Option>, + done: Option>, ) { self.with_engine(move |state| state.notifications.open_optional_mods(groups, done)); } - pub fn close_optional_mods(&self) { - self.with_engine(|state| state.notifications.finish_optional_mods()); - } - - pub fn decline_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) { - self.close_optional_mods(); - self.record_skipped_optional_mods(mods); + pub fn close_optional_mods(&self, outcome: OptionalModsOutcome) { + self.with_engine(move |state| state.notifications.finish_optional_mods(outcome)); } pub fn record_skipped_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) { @@ -768,7 +763,7 @@ impl Actions { /// `mods/` once at startup, so finishing after the process starts is useless pub fn enable_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) { if mods.is_empty() { - self.close_optional_mods(); + self.close_optional_mods(OptionalModsOutcome::Launch); return; } @@ -780,7 +775,7 @@ impl Actions { spawn_forever(async move { let Ok(state) = launcher::state() else { - actions.close_optional_mods(); + actions.close_optional_mods(OptionalModsOutcome::Launch); return; }; let content = state.services.content(); @@ -886,7 +881,7 @@ impl Actions { .send(); } - actions.close_optional_mods(); + actions.close_optional_mods(OptionalModsOutcome::Launch); }); } @@ -1641,7 +1636,7 @@ impl Actions { &self, state: &Arc, cluster_id: ClusterId, - ) { + ) -> OptionalModsOutcome { let pending = match oneclient_core::pending_optional_mods( cluster_id, state.bundles.as_ref(), @@ -1656,7 +1651,7 @@ impl Actions { error = %err, "could not read queued optional mods, launching anyway" ); - return; + return OptionalModsOutcome::Launch; } }; @@ -1665,7 +1660,7 @@ impl Actions { let Some(group) = crate::install::pending_optional_group(cluster_id, &pending, &state.services).await else { - return; + return OptionalModsOutcome::Launch; }; let (done, wait) = tokio::sync::oneshot::channel(); @@ -1676,7 +1671,7 @@ impl Actions { state.center_open = false; }); - let _ = wait.await; + wait.await.unwrap_or(OptionalModsOutcome::Launch) } async fn prompt_package_updates( @@ -1833,9 +1828,14 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) { .await; // After the updates so the player never faces two modals at once - actions + if actions .resolve_optional_mods_before_launch(&state, cluster_id) - .await; + .await + == OptionalModsOutcome::Cancel + { + events.game_stage(cluster_id, oneclient_events::LaunchStage::Exited); + return; + } if let Err(err) = oneclient_core::launch_cluster(&state, cluster_id, &account, true).await { // A missing file is the one failure the launcher can fix itself and a diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index 3da938d6..af634ebb 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -38,6 +38,12 @@ pub struct PackageUpdateGroup { pub type OptionalModRef = (String, String); +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OptionalModsOutcome { + Launch, + Cancel, +} + #[derive(Clone, Debug, PartialEq)] pub struct ClusterUpdateItem { pub provider: ProviderId, @@ -233,7 +239,7 @@ pub struct NotificationState { /// Held here not by the launch task /// because every way the modal can end goes through this state package_updates_done: Option>>, - optional_mods_done: Option>, + optional_mods_done: Option>, } const CATEGORY_ORDER: [TaskCategory; 7] = [ @@ -365,7 +371,7 @@ impl NotificationState { pub fn open_optional_mods( &mut self, groups: Vec, - done: Option>, + done: Option>, ) { let groups: Vec = groups .into_iter() @@ -374,12 +380,12 @@ impl NotificationState { if groups.is_empty() { if let Some(done) = done { - let _ = done.send(()); + let _ = done.send(OptionalModsOutcome::Launch); } return; } - self.finish_optional_mods(); + self.finish_optional_mods(OptionalModsOutcome::Launch); self.optional_mods = Some(groups); self.optional_mods_done = done; } @@ -388,10 +394,10 @@ impl NotificationState { self.optional_mods = None; } - pub fn finish_optional_mods(&mut self) { + pub fn finish_optional_mods(&mut self, outcome: OptionalModsOutcome) { self.optional_mods = None; if let Some(done) = self.optional_mods_done.take() { - let _ = done.send(()); + let _ = done.send(outcome); } }