feat(ui): better looking bundle updates
This commit is contained in:
parent
34742efc78
commit
430e8ea28f
8 changed files with 360 additions and 98 deletions
|
|
@ -89,7 +89,7 @@ pub enum BridgeCommand {
|
|||
MarkNotificationRead(u64),
|
||||
DismissNotification(u64),
|
||||
AnswerPrompt(UserChoice),
|
||||
OpenClusterUpdate(ClusterUpdateSummary),
|
||||
OpenClusterUpdate(Vec<ClusterUpdateSummary>),
|
||||
CloseClusterUpdate,
|
||||
SendNotification {
|
||||
spec: NotificationSpec,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue