feat(ui): better looking bundle updates

This commit is contained in:
LynithDev 2026-07-25 20:36:52 +02:00
parent 34742efc78
commit 430e8ea28f
8 changed files with 360 additions and 98 deletions

View file

@ -89,7 +89,7 @@ pub enum BridgeCommand {
MarkNotificationRead(u64),
DismissNotification(u64),
AnswerPrompt(UserChoice),
OpenClusterUpdate(ClusterUpdateSummary),
OpenClusterUpdate(Vec<ClusterUpdateSummary>),
CloseClusterUpdate,
SendNotification {
spec: NotificationSpec,

View file

@ -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<NotificationSpec> {
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),
}],
})
}

View file

@ -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<String>,
}
#[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<UpdateTab>,
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<UpdateTab>,
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<String> = 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<ClusterGroup> = summaries
.iter()
.map(|item| resolve_name(item, meta))
.filter_map(|summary| {
let names: Vec<String> = 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()),
)
}

View file

@ -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());
}
}
}

View file

@ -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());
}
}
}

View file

@ -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<ClusterUpdateSummary>) {
self.send(BridgeCommand::OpenClusterUpdate(summaries));
}
pub fn close_cluster_update(&self) {

View file

@ -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<ClusterUpdateSummary>),
}
/// One changed package in a cluster update, carrying enough identity to
@ -149,7 +152,7 @@ pub struct NotificationSnapshot {
pub inbox: Vec<InboxEntry>,
pub center_open: bool,
pub pending_prompt: Option<PendingPromptView>,
pub cluster_update: Option<ClusterUpdateSummary>,
pub cluster_update: Option<Vec<ClusterUpdateSummary>>,
pub active_toast_entry_ids: Vec<u64>,
}
@ -167,7 +170,7 @@ pub struct NotificationState {
grouped_entries: HashMap<Uuid, u64>,
grouped_tasks: HashMap<Uuid, GroupedTasks>,
pending_timers: Vec<ToastDismissTimer>,
cluster_update: Option<ClusterUpdateSummary>,
cluster_update: Option<Vec<ClusterUpdateSummary>>,
}
/// 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<ClusterUpdateSummary>) {
if summaries.is_empty() {
return;
}
self.cluster_update = Some(summaries);
}
pub fn close_cluster_update(&mut self) {

View file

@ -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<ClusterUpdateSummary>;
/// 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<Element> {
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<str>]) -> Vec<ClusterUpdateItem> {
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<ClusterUpdateSummary>) {
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;