From 430e8ea28f603762d4537b7326166b17553a82b1 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:36:52 +0200 Subject: [PATCH] feat(ui): better looking bundle updates --- packages/oneclient_app/src/bridge/commands.rs | 2 +- packages/oneclient_app/src/bridge/runtime.rs | 24 ++- .../src/components/cluster_update_popup.rs | 202 ++++++++++++++---- .../src/components/notifications.rs | 4 +- .../oneclient_app/src/components/toasts.rs | 4 +- packages/oneclient_app/src/hooks/dispatch.rs | 4 +- packages/oneclient_app/src/notifications.rs | 16 +- packages/oneclient_app/src/view/app/debug.rs | 202 +++++++++++++++--- 8 files changed, 360 insertions(+), 98 deletions(-) diff --git a/packages/oneclient_app/src/bridge/commands.rs b/packages/oneclient_app/src/bridge/commands.rs index d76b7664..49cdcf87 100644 --- a/packages/oneclient_app/src/bridge/commands.rs +++ b/packages/oneclient_app/src/bridge/commands.rs @@ -89,7 +89,7 @@ pub enum BridgeCommand { MarkNotificationRead(u64), DismissNotification(u64), AnswerPrompt(UserChoice), - OpenClusterUpdate(ClusterUpdateSummary), + OpenClusterUpdate(Vec), CloseClusterUpdate, SendNotification { spec: NotificationSpec, diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs index 9343d508..ab8233a4 100644 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ b/packages/oneclient_app/src/bridge/runtime.rs @@ -856,35 +856,34 @@ async fn cluster_update_notification( progress: None, actions: vec![NotificationAction { label: "View changes".to_string(), - kind: NotificationActionKind::OpenClusterUpdate(summary), + kind: NotificationActionKind::OpenClusterUpdate(vec![summary]), }], }) } -/// Builds a single notification summarising a batch bundle sync, with one -/// "view changes" action per changed cluster. `None` when nothing changed. +/// Builds a single notification summarising a batch bundle sync. Every changed +/// cluster rides along on one "View changes" action so the notification keeps +/// exactly two buttons no matter how many clusters moved; the modal does the +/// per-cluster breakdown. `None` when nothing changed. async fn combined_cluster_update_spec( changed: &[(i64, oneclient_core::ApplyBundleUpdatesResult)], services: &oneclient_core::LauncherServices, ) -> Option { - let mut actions = Vec::new(); + let mut summaries = Vec::new(); let mut total_changes = 0usize; for (cluster_id, result) in changed { if let Some(summary) = cluster_update_summary(*cluster_id, result, services).await { total_changes += summary.total(); - actions.push(NotificationAction { - label: summary.cluster_name.clone(), - kind: NotificationActionKind::OpenClusterUpdate(summary), - }); + summaries.push(summary); } } - if actions.is_empty() { + if summaries.is_empty() { return None; } - let cluster_count = actions.len(); + let cluster_count = summaries.len(); let body = format!( "{total_changes} package{} updated across {cluster_count} cluster{}", if total_changes == 1 { "" } else { "s" }, @@ -897,7 +896,10 @@ async fn combined_cluster_update_spec( level: NotificationLevel::Info, icon: Some(IconType::DownloadCloud02), progress: None, - actions, + actions: vec![NotificationAction { + label: "View changes".to_string(), + kind: NotificationActionKind::OpenClusterUpdate(summaries), + }], }) } diff --git a/packages/oneclient_app/src/components/cluster_update_popup.rs b/packages/oneclient_app/src/components/cluster_update_popup.rs index abd75745..35081267 100644 --- a/packages/oneclient_app/src/components/cluster_update_popup.rs +++ b/packages/oneclient_app/src/components/cluster_update_popup.rs @@ -34,6 +34,15 @@ impl UpdateTab { } } + /// Empty-state copy; the tab labels don't read as sentences on their own. + fn empty_text(self) -> &'static str { + match self { + UpdateTab::Updates => "Nothing was updated.", + UpdateTab::Additions => "Nothing was added.", + UpdateTab::Removals => "Nothing was removed.", + } + } + fn icon(self) -> IconType { match self { UpdateTab::Updates => IconType::RefreshCw01, @@ -59,6 +68,13 @@ impl UpdateTab { } } +/// One cluster's resolved package names for the active tab. +struct ClusterGroup { + cluster_id: i64, + cluster_name: String, + names: Vec, +} + #[derive(PartialEq)] pub struct ClusterUpdatePopup; @@ -68,13 +84,14 @@ impl Component for ClusterUpdatePopup { let dispatch = use_dispatch(); let active = use_state(|| UpdateTab::Updates); - let summary = snapshot.cluster_update.clone(); + let summaries = snapshot.cluster_update.clone(); // Resolve pretty display names through the package-meta cache. Hooks // must run unconditionally, so gather ids (empty when no summary) and // query every remote provider before the early return below. - let all_items: Vec<&ClusterUpdateItem> = summary + let all_items: Vec<&ClusterUpdateItem> = summaries .iter() + .flatten() .flat_map(|s| s.updated.iter().chain(&s.added).chain(&s.removed)) .collect(); let mut meta = MetaMap::new(); @@ -90,7 +107,7 @@ impl Component for ClusterUpdatePopup { } } - let Some(summary) = summary else { + let Some(summaries) = summaries.filter(|s| !s.is_empty()) else { return rect().into_element(); }; @@ -103,7 +120,7 @@ impl Component for ClusterUpdatePopup { .width(Size::window_percent(100.)) .height(Size::window_percent(100.)) .center() - .child(dialog(&summary, &meta, active, dispatch)), + .child(dialog(&summaries, &meta, active, dispatch)), ) .into_element() } @@ -119,7 +136,7 @@ fn resolve_name(item: &ClusterUpdateItem, meta: &MetaMap) -> String { } fn dialog( - summary: &ClusterUpdateSummary, + summaries: &[ClusterUpdateSummary], meta: &MetaMap, active: State, dispatch: crate::BridgeDispatch, @@ -140,24 +157,37 @@ fn dialog( 0., Color::from_argb(150, 0, 0, 0), ))) - .child(content(summary, meta, active, dispatch)) + .child(content(summaries, meta, active, dispatch)) } fn content( - summary: &ClusterUpdateSummary, + summaries: &[ClusterUpdateSummary], meta: &MetaMap, active: State, dispatch: crate::BridgeDispatch, ) -> impl IntoElement { let dismiss = dispatch.clone(); - let cluster_id = summary.cluster_id; - let open = move |_| { - dispatch.close_cluster_update(); - let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id }); + let total: usize = summaries.iter().map(|s| s.total()).sum(); + let active_tab = *active.read(); + + // A batch sync can touch several clusters; a single-cluster update keeps the + // plain wording and the "Open cluster" shortcut in the footer. + let single = match summaries { + [only] => Some((only.cluster_id, only.cluster_name.clone())), + _ => None, }; - let total = summary.total(); - let active_tab = *active.read(); + let subtitle = match &single { + Some((_, name)) => format!( + "{total} change{} synced to {name}.", + if total == 1 { "" } else { "s" } + ), + None => format!( + "{total} change{} across {} clusters.", + if total == 1 { "" } else { "s" }, + summaries.len() + ), + }; // Tab row: every category always shown with a count pill, even at zero, so // the modal shape stays stable across clusters. @@ -166,17 +196,29 @@ fn content( .height(Size::px(30.)) .spacing(20.) .tabs(UpdateTab::ALL.into_iter().map(|tab| { - let count = tab.items(summary).len(); + let count: usize = summaries.iter().map(|s| tab.items(s).len()).sum(); let mut set = active; TabItem::new(tab.label(), tab == active_tab) .count_text(count.to_string()) .on_press(move |_| *set.write() = tab) })); - let names: Vec = active_tab - .items(summary) + // Clusters with nothing in the active category drop out, so the list never + // shows an empty section header. + let groups: Vec = summaries .iter() - .map(|item| resolve_name(item, meta)) + .filter_map(|summary| { + let names: Vec = active_tab + .items(summary) + .iter() + .map(|item| resolve_name(item, meta)) + .collect(); + (!names.is_empty()).then(|| ClusterGroup { + cluster_id: summary.cluster_id, + cluster_name: summary.cluster_name.clone(), + names, + }) + }) .collect(); rect() @@ -200,17 +242,19 @@ fn content( ) .child( label() - .text(format!( - "{total} change{} synced to this cluster.", - if total == 1 { "" } else { "s" } - )) + .text(subtitle) .font_size(12.5) .max_lines(2) .color(colors::fg_secondary()), ), ) .child(tabs) - .child(change_list(active_tab, &names)) + .child(change_list( + active_tab, + &groups, + single.is_none(), + &dispatch, + )) .child( rect() .horizontal() @@ -224,17 +268,29 @@ fn content( .on_press(move |_| dismiss.close_cluster_update()) .text("Dismiss"), ) - .child( + .maybe_child(single.map(|(cluster_id, _)| { + let open = dispatch.clone(); Button::new() .primary() - .on_press(open) + .on_press(move |_| open_cluster(&open, cluster_id)) .text("Open cluster") - .child(Icon::new(IconType::ArrowRight).size(15.)), - ), + .child(Icon::new(IconType::ArrowRight).size(15.)) + .into_element() + })), ) } -fn change_list(tab: UpdateTab, names: &[String]) -> impl IntoElement { +fn open_cluster(dispatch: &crate::BridgeDispatch, cluster_id: i64) { + dispatch.close_cluster_update(); + let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id }); +} + +fn change_list( + tab: UpdateTab, + groups: &[ClusterGroup], + show_headers: bool, + dispatch: &crate::BridgeDispatch, +) -> impl IntoElement { let accent = tab.accent(); let mut scroll = ScrollArea::new() @@ -242,7 +298,7 @@ fn change_list(tab: UpdateTab, names: &[String]) -> impl IntoElement { .height(Size::flex(1.0)) .spacing(1.); - if names.is_empty() { + if groups.is_empty() { return scroll .child( rect() @@ -251,7 +307,7 @@ fn change_list(tab: UpdateTab, names: &[String]) -> impl IntoElement { .center() .child( label() - .text(format!("No {} in this update.", tab.label().to_lowercase())) + .text(tab.empty_text()) .font_size(12.5) .color(colors::fg_secondary()), ), @@ -259,25 +315,77 @@ fn change_list(tab: UpdateTab, names: &[String]) -> impl IntoElement { .into_element(); } - for name in names { - scroll = scroll.child( - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(9.) - .padding(Gaps::new_symmetric(5., 6.)) - .child(Icon::new(tab.icon()).size(13.).color(accent)) - .child( - label() - .text(name.clone()) - .font_size(12.5) - .max_lines(1) - .width(Size::flex(1.0)) - .color(colors::fg_primary()), - ), - ); + for (index, group) in groups.iter().enumerate() { + if show_headers { + scroll = scroll.child(cluster_header(group, index == 0, dispatch.clone())); + } + + for name in &group.names { + scroll = scroll.child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(9.) + .padding(Gaps::new_symmetric(5., 6.)) + .child(Icon::new(tab.icon()).size(13.).color(accent)) + .child( + label() + .text(name.clone()) + .font_size(12.5) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ), + ); + } } scroll.into_element() } + +/// Section header naming the cluster a run of changes belongs to. Pressing it +/// jumps straight to that cluster, which is the multi-cluster stand-in for the +/// footer's "Open cluster" shortcut. +fn cluster_header( + group: &ClusterGroup, + first: bool, + dispatch: crate::BridgeDispatch, +) -> impl IntoElement { + let cluster_id = group.cluster_id; + let count = group.names.len(); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(6., 8.)) + .margin(Gaps::new(if first { 0. } else { 8. }, 0., 3., 0.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_press(move |_| open_cluster(&dispatch, cluster_id)) + .child( + label() + .text(group.cluster_name.clone()) + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ) + .child( + label() + .text(count.to_string()) + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + Icon::new(IconType::ArrowRight) + .size(12.) + .color(colors::fg_secondary()), + ) +} diff --git a/packages/oneclient_app/src/components/notifications.rs b/packages/oneclient_app/src/components/notifications.rs index 5f9d5411..a7190388 100644 --- a/packages/oneclient_app/src/components/notifications.rs +++ b/packages/oneclient_app/src/components/notifications.rs @@ -479,8 +479,8 @@ fn transfer_footer(stats: TransferStats) -> Element { fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) { match kind { - NotificationActionKind::OpenClusterUpdate(summary) => { - dispatch.open_cluster_update(summary.clone()); + NotificationActionKind::OpenClusterUpdate(summaries) => { + dispatch.open_cluster_update(summaries.clone()); } } } diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs index 0976248b..e50366b4 100644 --- a/packages/oneclient_app/src/components/toasts.rs +++ b/packages/oneclient_app/src/components/toasts.rs @@ -330,8 +330,8 @@ fn action_row( fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) { match kind { - NotificationActionKind::OpenClusterUpdate(summary) => { - dispatch.open_cluster_update(summary.clone()); + NotificationActionKind::OpenClusterUpdate(summaries) => { + dispatch.open_cluster_update(summaries.clone()); } } } diff --git a/packages/oneclient_app/src/hooks/dispatch.rs b/packages/oneclient_app/src/hooks/dispatch.rs index 07224aed..00a895aa 100644 --- a/packages/oneclient_app/src/hooks/dispatch.rs +++ b/packages/oneclient_app/src/hooks/dispatch.rs @@ -182,8 +182,8 @@ impl BridgeDispatch { self.send(BridgeCommand::AnswerPrompt(choice)); } - pub fn open_cluster_update(&self, summary: ClusterUpdateSummary) { - self.send(BridgeCommand::OpenClusterUpdate(summary)); + pub fn open_cluster_update(&self, summaries: Vec) { + self.send(BridgeCommand::OpenClusterUpdate(summaries)); } pub fn close_cluster_update(&self) { diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index d4a6620b..630682a3 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -21,7 +21,10 @@ pub struct NotificationAction { #[derive(Clone, Debug, PartialEq)] pub enum NotificationActionKind { - OpenClusterUpdate(ClusterUpdateSummary), + /// Opens the changes modal for every cluster touched by one sync. Kept as a + /// list so a batch sync stays a single "View changes" action instead of one + /// button per cluster. + OpenClusterUpdate(Vec), } /// One changed package in a cluster update, carrying enough identity to @@ -149,7 +152,7 @@ pub struct NotificationSnapshot { pub inbox: Vec, pub center_open: bool, pub pending_prompt: Option, - pub cluster_update: Option, + pub cluster_update: Option>, pub active_toast_entry_ids: Vec, } @@ -167,7 +170,7 @@ pub struct NotificationState { grouped_entries: HashMap, grouped_tasks: HashMap, pending_timers: Vec, - cluster_update: Option, + cluster_update: Option>, } /// Fixed display order for the category rows. @@ -282,8 +285,11 @@ impl NotificationState { } } - pub fn open_cluster_update(&mut self, summary: ClusterUpdateSummary) { - self.cluster_update = Some(summary); + pub fn open_cluster_update(&mut self, summaries: Vec) { + if summaries.is_empty() { + return; + } + self.cluster_update = Some(summaries); } pub fn close_cluster_update(&mut self) { diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index 5020f908..86d9c2a0 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -199,12 +199,18 @@ impl Component for ClusterUpdateSimulator { let added = use_state(|| "Lithium".to_string()); let removed = use_state(|| "OptiFine".to_string()); - let simulate = dispatch; + let simulate = dispatch.clone(); rect() .vertical() .width(Size::fill()) .spacing(10.) + .child( + label() + .text("Builds the same notification the bundle sync sends: one \"View changes\" action carrying every changed cluster, opened into the changes modal.") + .font_size(13.) + .color(colors::fg_secondary()), + ) .child( rect() .horizontal() @@ -248,42 +254,182 @@ impl Component for ClusterUpdateSimulator { .child(Icon::new(IconType::DownloadCloud02).size(16.)) .text("Simulate Cluster Update") .on_press(move |_| { - let name = cluster_name.read().clone(); let summary = ClusterUpdateSummary { cluster_id: cluster_id.read().trim().parse().unwrap_or(1), - cluster_name: name.clone(), - updated: split_csv(&updated.read()) - .into_iter() - .map(ClusterUpdateItem::from_name) - .collect(), - added: split_csv(&added.read()) - .into_iter() - .map(ClusterUpdateItem::from_name) - .collect(), - removed: split_csv(&removed.read()) - .into_iter() - .map(ClusterUpdateItem::from_name) - .collect(), + cluster_name: cluster_name.read().clone(), + updated: cluster_update_items(&split_csv(&updated.read())), + added: cluster_update_items(&split_csv(&added.read())), + removed: cluster_update_items(&split_csv(&removed.read())), }; - let total = summary.total(); - simulate - .notify("Cluster updated") - .body(format!( - "{total} package{} changed in {name}", - if total == 1 { "" } else { "s" } - )) - .icon(IconType::DownloadCloud02) - .action(NotificationAction { - label: "View changes".to_string(), - kind: NotificationActionKind::OpenClusterUpdate(summary), - }) - .send(); + send_cluster_update(&simulate, vec![summary]); }), ) + .child( + label() + .text("Presets — every shape the notification and modal can take.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .children(cluster_update_preset_rows(&dispatch)) .into_element() } } +type ClusterUpdatePreset = fn() -> Vec; + +/// Each preset pins down one variation the changes modal has to handle: the +/// singular/plural copy, an empty category tab, the single- vs multi-cluster +/// footer, per-tab cluster grouping, and header overflow. +const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [ + ( + "1 cluster · all categories", + IconType::DownloadCloud02, + || { + vec![preset_summary( + 1, + "PolyBlock", + &["Sodium 0.5 → 0.6", "Iris 1.7 → 1.8", "Patcher 1.8 → 1.9"], + &["Lithium", "FerriteCore"], + &["OptiFine"], + )] + }, + ), + ("1 cluster · updates only", IconType::RefreshCw01, || { + vec![preset_summary( + 1, + "PolyBlock", + &[ + "Sodium 0.5 → 0.6", + "Iris 1.7 → 1.8", + "Indium 1.0 → 1.1", + "Fabric API 0.92 → 0.100", + ], + &[], + &[], + )] + }), + ("1 cluster · single change", IconType::Plus, || { + vec![preset_summary(1, "PolyBlock", &[], &["Lithium"], &[])] + }), + ("3 clusters · mixed", IconType::DotsGrid, || { + vec![ + preset_summary( + 1, + "PolyBlock", + &["Sodium 0.5 → 0.6", "Iris 1.7 → 1.8"], + &["Lithium"], + &[], + ), + preset_summary(2, "Skyblock", &[], &[], &["OptiFine", "Skytils"]), + preset_summary(3, "Vanilla+", &[], &["Sodium", "Iris", "FerriteCore"], &[]), + ] + }), + ("2 clusters · removals only", IconType::Trash01, || { + vec![ + preset_summary(1, "PolyBlock", &[], &[], &["OptiFine"]), + preset_summary(2, "Skyblock", &[], &[], &["Skytils", "NotEnoughUpdates"]), + ] + }), + ("6 clusters · long names", IconType::Database01, || { + (1..=6) + .map(|i| { + ClusterUpdateSummary { + cluster_id: i, + cluster_name: format!( + "Cluster {i} with a deliberately overlong name that has to truncate" + ), + updated: cluster_update_items(&[ + format!("Sodium 0.{i} → 0.{}", i + 1), + format!("Iris 1.{i} → 1.{}", i + 1), + ]), + added: cluster_update_items(&[format!("Lithium {i}")]), + removed: Vec::new(), + } + }) + .collect() + }), +]; + +fn cluster_update_preset_rows(dispatch: &crate::BridgeDispatch) -> Vec { + CLUSTER_UPDATE_PRESETS + .chunks(3) + .map(|chunk| { + let mut row = rect().horizontal().width(Size::fill()).spacing(12.); + + for (text, icon, build) in chunk { + let dispatch = dispatch.clone(); + let build = *build; + row = row.child( + Button::new() + .secondary() + .child(Icon::new(*icon).size(16.)) + .text(*text) + .on_press(move |_| send_cluster_update(&dispatch, build())), + ); + } + + row.into_element() + }) + .collect() +} + +fn preset_summary( + cluster_id: i64, + cluster_name: &str, + updated: &[&str], + added: &[&str], + removed: &[&str], +) -> ClusterUpdateSummary { + ClusterUpdateSummary { + cluster_id, + cluster_name: cluster_name.to_string(), + updated: cluster_update_items(updated), + added: cluster_update_items(added), + removed: cluster_update_items(removed), + } +} + +fn cluster_update_items(names: &[impl AsRef]) -> Vec { + names + .iter() + .map(|name| ClusterUpdateItem::from_name(name.as_ref())) + .collect() +} + +/// Mirrors the copy the bridge builds for real syncs so the simulated +/// notification is indistinguishable from the real one. +fn send_cluster_update(dispatch: &crate::BridgeDispatch, summaries: Vec) { + if summaries.is_empty() { + return; + } + + let total: usize = summaries.iter().map(|s| s.total()).sum(); + let plural = if total == 1 { "" } else { "s" }; + let (title, body) = match summaries.as_slice() { + [only] => ( + "Cluster updated", + format!("{total} package{plural} changed in {}", only.cluster_name), + ), + many => ( + "Mods updated", + format!( + "{total} package{plural} updated across {} clusters", + many.len() + ), + ), + }; + + dispatch + .notify(title) + .body(body) + .icon(IconType::DownloadCloud02) + .action(NotificationAction { + label: "View changes".to_string(), + kind: NotificationActionKind::OpenClusterUpdate(summaries), + }) + .send(); +} + #[derive(PartialEq)] struct LauncherUpdateSimulator;