authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 20:58:04-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 07:05:30-07:00
log7a0b2662303af3d08687c1a26ff0cf6555c56723
treebcac2d494c22f0d5b6180d42edc7e2408e7c658a
parent6860ff7532383c1406ccbc04fe14e04b26e9aad8
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: the sync popup always ends in Check for Updates, which becomes the update once one is here

Its foot is one row in every state: a Check for Updates link, or the update downloading, ready (Restart to Update) or available (Build Folder), whose changes unfold below it. The version and summary move to the tooltip. Assisted-by: claude-opus-5.5

1 files changed, 169 insertions(+), 83 deletions(-)

crates/snowbound/src/sync.rs+169-83
......@@ -280,50 +280,13 @@ struct Picked {
280280 build_folder: bool,
281281 restart: bool,
282282 list_changes: bool,
283 check: bool,
283284}
284285
285/// What a newer build changes: `summary`, which unfolds the titles under their kinds when
286/// `listed`. Returns whether the summary was clicked.
287fn changes_list(ui: &mut Ui, summary: &str, changes: &update::Changes, listed: bool) -> bool {
286/// What a newer build changes, unfolded under the popup's foot while `listed`.
287fn changes_list(ui: &mut Ui, changes: &update::Changes, listed: bool) {
288288 let theme = ui.theme.clone();
289289 let line = theme.font_size * 1.6;
290 let toggle = ui.open(
291 "summary",
292 Spec {
293 flags: Flags::CLICKABLE,
294 size: [fill(), px(line)],
295 gap: 4.0,
296 role: Some(accesskit::Role::Button),
297 ..Spec::default()
298 },
299 );
300 if let Some(node) = ui.access(toggle) {
301 node.set_label(summary);
302 node.set_expanded(listed);
303 }
304 ui.leaf(
305 "label",
306 Spec {
307 size: [fit(), px(line)],
308 text: Some(summary),
309 overflow: Overflow::Ellipsis,
310 ..Spec::default()
311 },
312 );
313 ui.leaf(
314 "chevron",
315 Spec {
316 size: [fit(), px(line)],
317 icon: Some(if listed {
318 art::CHEVRON_UP
319 } else {
320 ui::shell::CHEVRON
321 }),
322 color: Some(theme.text_dim),
323 ..Spec::default()
324 },
325 );
326 ui.close();
327290 // Unfolds to the titles' height, scrolling past a few dozen lines.
328291 let list = ui.id("changes");
329292 let rows = ui
......@@ -410,7 +373,126 @@ fn changes_list(ui: &mut Ui, summary: &str, changes: &update::Changes, listed: b
410373 ui.close();
411374 ui.close();
412375 }
413 ui.signal(toggle).clicked
376}
377
378/// The popup's foot, one row tall whatever the update's state: Check for Updates, or the
379/// newer build on its way or here, which unfolds what it changes, and what installs it.
380fn update_row(ui: &mut Ui, update: &update::Status, listed: bool, picked: &mut Picked) {
381 let theme = ui.theme.clone();
382 let height = theme.font_size * 2.0;
383 let changes = match update {
384 update::Status::Ready(_, _, changes) | update::Status::Available(_, changes) => {
385 Some(changes).filter(|changes| !changes.list.is_empty())
386 }
387 _ => None,
388 };
389 ui.open(
390 "update",
391 Spec {
392 size: [fill(), px(height)],
393 gap: 8.0,
394 ..Spec::default()
395 },
396 );
397 let note = match update {
398 update::Status::Downloading(_) => Some("Downloading update…"),
399 update::Status::Ready(..) => Some("Update ready"),
400 update::Status::Available(..) => Some("Update available"),
401 _ => None,
402 };
403 match note {
404 None => {
405 picked.check = ui
406 .leaf(
407 "check",
408 Spec {
409 flags: Flags::CLICKABLE,
410 size: [fit(), px(height)],
411 icon: Some(art::UPDATE),
412 text: Some("Check for Updates"),
413 color: Some(theme.accent),
414 role: Some(accesskit::Role::Link),
415 ..Spec::default()
416 },
417 )
418 .clicked;
419 }
420 Some(note) => {
421 let toggle = ui.open(
422 "note",
423 Spec {
424 flags: if changes.is_some() {
425 Flags::CLICKABLE
426 } else {
427 Flags::default()
428 },
429 size: [fit(), px(height)],
430 gap: 4.0,
431 role: changes.is_some().then_some(accesskit::Role::Button),
432 ..Spec::default()
433 },
434 );
435 // The version and what it brings, which the row leaves to its tooltip.
436 let version =
437 update_note(update).map_or_else(String::new, |note| format!("Snowbound {note}"));
438 let summary = changes.and_then(update::summary);
439 if let Some(node) = ui.access(toggle) {
440 node.set_label(note);
441 node.set_description(version.as_str());
442 if changes.is_some() {
443 node.set_expanded(listed);
444 }
445 }
446 ui.leaf(
447 "label",
448 Spec {
449 size: [fit(), px(height)],
450 text: Some(note),
451 icon: Some(art::UPDATE),
452 overflow: Overflow::Ellipsis,
453 ..Spec::default()
454 },
455 );
456 if changes.is_some() {
457 ui.leaf(
458 "chevron",
459 Spec {
460 size: [fit(), px(height)],
461 icon: Some(if listed {
462 art::CHEVRON_UP
463 } else {
464 ui::shell::CHEVRON
465 }),
466 color: Some(theme.text_dim),
467 ..Spec::default()
468 },
469 );
470 }
471 ui.close();
472 ui::popup::tooltip(ui, &version, "", summary.as_deref());
473 picked.list_changes = changes.is_some() && ui.signal(toggle).clicked;
474 }
475 }
476 ui.leaf(
477 "space",
478 Spec {
479 size: [fill(), px(1.0)],
480 ..Spec::default()
481 },
482 );
483 match update {
484 update::Status::Ready(..) => {
485 picked.restart = ui::button(ui, "restart", "Restart to Update").clicked;
486 }
487 update::Status::Available(..) => {
488 picked.build_folder = ui::button(ui, "build-folder", "Build Folder").clicked;
489 }
490 _ => {}
491 }
492 ui.close();
493 if let Some(changes) = changes {
494 changes_list(ui, changes, listed);
495 }
414496}
415497
416498/// Lays out the open popup's contents.
......@@ -872,48 +954,6 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked {
872954 }
873955 ui.close();
874956
875 if let Some(note) = update_note(facts.update) {
876 rule(ui, "update-rule");
877 ui.open(
878 "update",
879 Spec {
880 axis: Axis::Y,
881 size: [fill(), children()],
882 gap: 4.0,
883 ..Spec::default()
884 },
885 );
886 text(ui, "note", &format!("Snowbound {note}"), theme.text);
887 if let update::Status::Ready(_, _, changes) | update::Status::Available(_, changes) =
888 facts.update
889 && let Some(summary) = update::summary(changes)
890 {
891 picked.list_changes = changes_list(ui, &summary, changes, facts.changes_listed);
892 }
893 ui.open(
894 "actions",
895 Spec {
896 size: [fill(), children()],
897 pad: [0.0, 4.0],
898 gap: 8.0,
899 ..Spec::default()
900 },
901 );
902 ui.leaf(
903 "space",
904 Spec {
905 size: [fill(), px(1.0)],
906 ..Spec::default()
907 },
908 );
909 picked.build_folder = ui::button(ui, "build-folder", "Build Folder").clicked;
910 if matches!(facts.update, update::Status::Ready(..)) {
911 picked.restart = ui::button(ui, "restart", "Restart to Update").clicked;
912 }
913 ui.close();
914 ui.close();
915 }
916
917957 ui.open(
918958 "controls",
919959 Spec {
......@@ -957,6 +997,8 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked {
957997 picked.show_file = button(ui, "show-file", platform::SHOW_FILE, facts.local);
958998 picked.sync_now = button(ui, "sync-now", "Sync Now", !facts.offline);
959999 ui.close();
1000 rule(ui, "update-rule");
1001 update_row(ui, facts.update, facts.changes_listed, &mut picked);
9601002 picked
9611003}
9621004
......@@ -1113,6 +1155,7 @@ impl State {
11131155 }
11141156 _ if picked.restart => self.restart_to_update(),
11151157 _ if picked.list_changes => self.updates.changes_listed ^= true,
1158 _ if picked.check => self.updates.check_now(),
11161159 _ => {}
11171160 }
11181161 Ok(())
......@@ -1446,6 +1489,16 @@ mod tests {
14461489 update: ready(false),
14471490 ..facts(sections(|_| status(true, 0, None)))
14481491 }),
1492 ("downloading", || Facts {
1493 update: Box::leak(Box::new(update::Status::Downloading(
1494 update::Version::parse("2026-10-01-r5").unwrap(),
1495 ))),
1496 ..facts(sections(|_| status(true, 0, None)))
1497 }),
1498 ("available", || Facts {
1499 update: available(),
1500 ..facts(sections(|_| status(true, 0, None)))
1501 }),
14491502 ("update-listed", || Facts {
14501503 update: ready(true),
14511504 changes_listed: true,
......@@ -1472,6 +1525,39 @@ mod tests {
14721525 )))
14731526 }
14741527
1528 /// The build `ready` brings, newer than one that can't install it itself.
1529 fn available() -> &'static update::Status {
1530 let update::Status::Ready(version, _, changes) = ready(false) else {
1531 unreachable!()
1532 };
1533 Box::leak(Box::new(update::Status::Available(
1534 version.clone(),
1535 changes.clone(),
1536 )))
1537 }
1538
1539 /// The popup stands as tall with an update on its way, here or none: its foot reads
1540 /// Check for Updates or holds the update in the same row.
1541 #[test]
1542 fn an_update_keeps_the_popup_its_height() {
1543 let heights: Vec<(&str, f32)> = states()
1544 .into_iter()
1545 .filter(|(name, _)| ["up-to-date", "downloading", "available", "update"].contains(name))
1546 .map(|(name, made)| {
1547 let mut ui = ui(Appearance::Light);
1548 let mut now = Instant::now();
1549 settle(&mut ui, &mut now, &made());
1550 let [_, top, _, bottom] = ui.rect(id()).unwrap();
1551 (name, bottom - top)
1552 })
1553 .collect();
1554 assert_eq!(heights.len(), 4);
1555 assert!(
1556 heights.windows(2).all(|pair| pair[0].1 == pair[1].1),
1557 "{heights:?}"
1558 );
1559 }
1560
14751561 /// Work offline's switch, and everything below it, stays put as it turns on and off.
14761562 #[test]
14771563 fn toggling_offline_moves_nothing() {