authorclover caruso <git@paperclover.net> 2026-10-11 18:57:52-07:00
committerclover caruso <git@paperclover.net> 2026-10-11 18:57:52-07:00
log3b673de430e5bec20ebf20c4ba79dd1572469f7e
treedfda5f98ed343f101e2cc6e35f57b609b8e38d8c
parent1407178c794c7688ff663f46095cb7c4eb24276f
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix: offer notebook sources in one Open Notebook menu

Share the sidebar and welcome menu, routing filesystem, SMB and Live Share choices through the existing opening flows. Preserve keyboard focus and disabled platform choices, with real accessibility-action replay coverage. fixes #130 Assisted-by: gpt-6.1-sol

3 files changed, 299 insertions(+), 106 deletions(-)

crates/snowbound/src/main.rs+36-1
......@@ -390,6 +390,7 @@ impl std::task::Wake for Redraw {
390390#[derive(Debug)]
391391enum Replay {
392392 Input(ui::Event),
393 Access(accesskit::Action, String),
393394 /// A trackpad pinch scaling the page by the factor.
394395 Pinch(f32),
395396 /// Paints the next frame into a PNG as well as the window.
......@@ -6408,6 +6409,31 @@ impl State {
64086409 };
64096410 match replay {
64106411 Replay::Input(event) => self.input(event),
6412 Replay::Access(action, label) => {
6413 let tree = self.ui.accessibility_tree("", self.window.scale_factor());
6414 let mut nodes = tree
6415 .nodes
6416 .iter()
6417 .filter(|(_, node)| node.label() == Some(label.as_str()));
6418 let (id, node) = nodes
6419 .next()
6420 .unwrap_or_else(|| panic!("Replay label {label:?} is missing"));
6421 assert!(
6422 nodes.next().is_none(),
6423 "Replay label {label:?} is ambiguous"
6424 );
6425 assert!(
6426 node.supports_action(action),
6427 "Replay label {label:?} does not support {action:?}"
6428 );
6429 self.access_action(accesskit::ActionRequest {
6430 action,
6431 target_tree: accesskit::TreeId::ROOT,
6432 target_node: *id,
6433 data: None,
6434 })
6435 .expect("Replay accessibility action");
6436 }
64116437 Replay::Pinch(factor) => {
64126438 if let Err(error) = self.pinch(factor) {
64136439 eprintln!("{error}");
......@@ -6794,7 +6820,7 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E
67946820/// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift]
67956821/// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `settle` and
67966822/// `accessibility TEXT_PATH` (which wait for what is on its way), `appearance light|dark`,
6797/// `resize WIDTH HEIGHT` and `quit`.
6823/// `access click|focus LABEL`, `resize WIDTH HEIGHT` and `quit`.
67986824#[cfg(not(target_arch = "wasm32"))]
67996825fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {
68006826 let (settle, settled) = std::sync::mpsc::channel();
......@@ -6868,6 +6894,15 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
68686894 "snapshot" => Ok(Replay::Snapshot(rest.into())),
68696895 "settle" => Ok(Replay::Settle(None, settle.clone())),
68706896 "accessibility" => Ok(Replay::Settle(Some(rest.into()), settle.clone())),
6897 "access" => {
6898 let (action, label) = rest.split_once(' ').ok_or("access takes ACTION LABEL")?;
6899 let action = match action {
6900 "click" => accesskit::Action::Click,
6901 "focus" => accesskit::Action::Focus,
6902 _ => return Err(format!("Unknown accessibility action: {action}").into()),
6903 };
6904 Ok(Replay::Access(action, label.to_owned()))
6905 }
68716906 "renderer" => Ok(Replay::Renderer(settings::Backend::named(rest)?)),
68726907 "appearance" => Ok(Replay::Appearance(match rest {
68736908 "light" => winit::window::Theme::Light,
crates/snowbound/src/sidebar.rs+129-76
......@@ -29,7 +29,7 @@ pub const NAV: f32 = 2.0 * ui::shell::TOOL + 1.0 + (RAIL - ui::shell::TOOL);
2929const INDENT: f32 = 16.0;
3030
3131/// What the sidebar was asked to do.
32pub enum Action {
32enum Action {
3333 /// Opens a notebook's section by catalog path.
3434 Open {
3535 notebook: usize,
......@@ -37,10 +37,7 @@ pub enum Action {
3737 },
3838 /// Folds or unfolds a notebook's or section group's rows, by `Library::key`.
3939 Fold(String),
40 NewNotebook,
41 OpenNotebook,
42 #[cfg(feature = "live")]
43 OpenShared,
40 Command(crate::Command),
4441 /// Signs in again to the notebook opened from its server at this location.
4542 SignIn(String),
4643 Options,
......@@ -56,7 +53,7 @@ pub enum Action {
5653}
5754
5855/// What the tree's rows read and what they were asked.
59pub struct Tree<'a> {
56struct Tree<'a> {
6057 theme: &'a Theme,
6158 /// The open section's notebook index and catalog path.
6259 open: Option<(usize, &'a str)>,
......@@ -331,34 +328,8 @@ fn sidebar(
331328 ..Spec::default()
332329 },
333330 );
334 for (part, icon, label, chosen) in [
335 (
336 "new",
337 Leading::Icon(art::PLUS),
338 "New Notebook",
339 Action::NewNotebook,
340 ),
341 (
342 "open",
343 Leading::Notebook(art::NOTEBOOK, None),
344 "Open Notebook",
345 Action::OpenNotebook,
346 ),
347 #[cfg(feature = "live")]
348 (
349 "shared",
350 Leading::Icon(art::OPEN_SHARED),
351 "Open Shared",
352 Action::OpenShared,
353 ),
354 (
355 "options",
356 Leading::Icon(art::OPTIONS),
357 "Options",
358 Action::Options,
359 ),
360 ] {
361 let (row, _) = tree_row(
331 let row = |ui: &mut Ui, tree: &mut Tree, part, icon, label| {
332 tree_row(
362333 ui,
363334 tree,
364335 ui.id(part),
......@@ -372,10 +343,27 @@ fn sidebar(
372343 renamed: false,
373344 unread: false,
374345 },
375 );
376 if row.clicked {
377 tree.action = Some(chosen);
378 }
346 )
347 .0
348 };
349 if row(ui, tree, "new", Leading::Icon(art::PLUS), "New Notebook").clicked {
350 tree.action = Some(Action::Command(crate::Command::NewNotebook));
351 }
352 if let Some(command) = open_notebook(
353 ui,
354 Spec {
355 size: [fill(), px(ROW)],
356 hover_fill: Some(ui::mix(theme.sidebar, theme.hover(), 0.6)),
357 radius: 4.0,
358 pad: [ROW_PAD, 0.0],
359 gap: ROW_PAD,
360 ..Spec::default()
361 },
362 ) {
363 tree.action = Some(Action::Command(command));
364 }
365 if row(ui, tree, "options", Leading::Icon(art::OPTIONS), "Options").clicked {
366 tree.action = Some(Action::Options);
379367 }
380368 ui.close();
381369}
......@@ -839,6 +827,77 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {
839827 (ui.signal(id), folded)
840828}
841829
830fn open_notebook(ui: &mut Ui, spec: Spec) -> Option<crate::Command> {
831 let button = ui.id("open");
832 let menu = button.child("menu");
833 let center = spec.center;
834 let theme = ui.theme.clone();
835 ui.open_as(
836 button,
837 Spec {
838 flags: Flags::CLICKABLE,
839 role: Some(accesskit::Role::Button),
840 ..spec
841 },
842 );
843 ui.leaf(
844 "label",
845 Spec {
846 size: [fill(), fill()],
847 icon: Some(art::NOTEBOOK),
848 tint: Some(crate::notebook_color(&theme, None)),
849 text: Some(crate::commands::command(crate::commands::Id::OpenNotebook).title),
850 center,
851 ..Spec::default()
852 },
853 );
854 ui.leaf(
855 "arrow",
856 Spec {
857 size: [px(ICON), fill()],
858 icon: Some(ui::shell::CHEVRON),
859 color: Some(theme.text_dim),
860 ..Spec::default()
861 },
862 );
863 ui.close();
864 if ui.signal(button).pressed {
865 ui.set_focus(Some(button));
866 ui.open_popup(menu);
867 }
868 let open = ui.popup_open(menu);
869 if let Some(node) = ui.access(button) {
870 node.set_has_popup(accesskit::HasPopup::Menu);
871 node.set_expanded(open);
872 }
873 let items = [
874 ui::popup::Item {
875 text: "From File System",
876 icon: Some(art::NOTEBOOK),
877 ..Default::default()
878 },
879 ui::popup::Item {
880 text: "From SMB Share",
881 icon: Some(art::SERVER),
882 disabled: cfg!(target_arch = "wasm32"),
883 ..Default::default()
884 },
885 ui::popup::Item {
886 text: "Connect to Live Share",
887 icon: Some(art::OPEN_SHARED),
888 disabled: !cfg!(feature = "live"),
889 ..Default::default()
890 },
891 ];
892 match ui::popup::menu(ui, menu, ui::Anchor::Below(button), &items, None) {
893 Some(0) => Some(crate::Command::OpenNotebook),
894 Some(1) => Some(crate::Command::OpenFromServer(None)),
895 #[cfg(feature = "live")]
896 Some(2) => Some(crate::Command::OpenShared),
897 _ => None,
898 }
899}
900
842901impl crate::State {
843902 /// How wide the sidebar stands this frame as it eases open and shut.
844903 pub(crate) fn sidebar_width(&mut self) -> f32 {
......@@ -969,10 +1028,7 @@ impl crate::State {
9691028 self.rename(target);
9701029 }
9711030 Some(Action::Renamed(keep)) => self.finish_renaming(keep),
972 Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook),
973 Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook),
974 #[cfg(feature = "live")]
975 Some(Action::OpenShared) => self.open_shared(),
1031 Some(Action::Command(command)) => self.commands.push(command),
9761032 Some(Action::SignIn(location)) => self
9771033 .commands
9781034 .push(crate::Command::OpenFromServer(Some(location))),
......@@ -1454,22 +1510,9 @@ impl crate::State {
14541510 (
14551511 "open",
14561512 Leading::Notebook(art::NOTEBOOK, None),
1457 "Open Notebook",
1513 crate::commands::command(crate::commands::Id::OpenNotebook).title,
14581514 crate::Command::OpenNotebook,
14591515 ),
1460 #[cfg(feature = "live")]
1461 (
1462 "shared",
1463 Leading::Icon(art::OPEN_SHARED),
1464 "Open Shared Notebook",
1465 crate::Command::OpenShared,
1466 ),
1467 (
1468 "server",
1469 Leading::Icon(art::SERVER),
1470 "Open Notebook from Server",
1471 crate::Command::OpenFromServer(None),
1472 ),
14731516 ]
14741517 .into_iter()
14751518 .chain(crate::guide::OFFERED.then_some((
......@@ -1572,26 +1615,36 @@ impl crate::State {
15721615 );
15731616 let mut chosen = None;
15741617 for (part, icon, label, command) in buttons {
1575 let (icon, tint) = icon.art(&theme);
1576 let button = self.ui.leaf(
1577 part,
1578 Spec {
1579 flags: Flags::CLICKABLE,
1580 size: [fill(), px(BUTTON[1])],
1581 icon: Some(icon),
1582 tint: Some(tint),
1583 text: Some(label),
1584 fill: Some(theme.chip),
1585 hover_fill: Some(theme.hover()),
1586 hover_border: Some(theme.accent),
1587 radius: 6.0,
1588 center: true,
1589 role: Some(accesskit::Role::Button),
1590 ..Spec::default()
1591 },
1592 );
1593 if button.clicked {
1594 chosen = Some(command);
1618 let spec = Spec {
1619 flags: Flags::CLICKABLE,
1620 size: [fill(), px(BUTTON[1])],
1621 fill: Some(theme.chip),
1622 hover_fill: Some(theme.hover()),
1623 hover_border: Some(theme.accent),
1624 radius: 6.0,
1625 center: true,
1626 role: Some(accesskit::Role::Button),
1627 ..Spec::default()
1628 };
1629 if matches!(command, crate::Command::OpenNotebook) {
1630 chosen = open_notebook(&mut self.ui, spec).or(chosen);
1631 } else {
1632 let (icon, tint) = icon.art(&theme);
1633 if self
1634 .ui
1635 .leaf(
1636 part,
1637 Spec {
1638 icon: Some(icon),
1639 tint: Some(tint),
1640 text: Some(label),
1641 ..spec
1642 },
1643 )
1644 .clicked
1645 {
1646 chosen = Some(command);
1647 }
15951648 }
15961649 }
15971650 self.commands.extend(chosen);
crates/snowbound/tests/replay.rs+134-29
......@@ -796,37 +796,142 @@ fn stop_sharing_ends_the_share() {
796796 assert!(!kept.contains("code"), "still kept: {kept}");
797797}
798798
799/// Open Shared Notebook is where people look with notebooks open: in the sidebar's footer,
800/// and in the palette, which opens it.
801#[cfg(feature = "live")]
802799#[test]
803fn open_shared_notebook_is_offered_with_notebooks_open() {
804 let scratch = Scratch::new("open-shared");
805 std::fs::write(
806 scratch.0.join("settings.json"),
807 r#"{"user_name": "Snowbound Test", "sidebar": true}"#,
808 )
809 .unwrap();
810 let notebook =
811 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
812 let mut steps = vec!["accessibility sidebar", "modifiers command shift", "key p"];
813 steps.extend([
814 "modifiers",
815 "settle",
816 "type Open Shared",
817 "settle",
818 "key Enter",
819 ]);
820 steps.extend(["settle", "accessibility dialog"]);
821 let variables = [("SNOWBOUND_LIVE", "off"), ("SNOWBOUND_LIVE_RELAY", "off")];
822 let [sidebar, dialog] = replay_with(&scratch, Some(&notebook), &steps, &variables)
823 .try_into()
800fn open_notebook_routes_from_the_sidebar_and_welcome() {
801 for welcome in [false, true] {
802 let scratch = Scratch::new(if welcome {
803 "welcome-open"
804 } else {
805 "sidebar-open"
806 });
807 std::fs::write(
808 scratch.0.join("settings.json"),
809 r#"{"user_name": "Snowbound Test", "sidebar": true}"#,
810 )
824811 .unwrap();
825 assert!(sidebar.contains(r#""Open Shared""#), "{sidebar}");
826 assert!(
827 dialog.contains(r#"Dialog "Open Shared Notebook""#),
828 "{dialog}"
829 );
812 let notebook =
813 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
814 let mut steps = Vec::new();
815 if welcome {
816 steps.extend([
817 "modifiers command shift",
818 "key p",
819 "modifiers",
820 "settle",
821 "type Close This Notebook",
822 "key Enter",
823 "settle",
824 ]);
825 }
826 steps.extend([
827 "accessibility initial",
828 "access focus Open Notebook",
829 "key Enter",
830 "accessibility menu",
831 "key Escape",
832 "accessibility escaped",
833 "key Space",
834 "accessibility space-menu",
835 "move 1170 10",
836 "press",
837 "release",
838 "accessibility outside",
839 "key Enter",
840 "key Down",
841 "key Down",
842 "key Enter",
843 "accessibility keyboard-smb",
844 "key Escape",
845 "access click Open Notebook",
846 "access click From SMB Share",
847 "accessibility access-smb",
848 "key Escape",
849 "accessibility cancelled",
850 ]);
851 #[cfg(feature = "live")]
852 steps.extend([
853 "access click Open Notebook",
854 "access click Connect to Live Share",
855 "accessibility live",
856 "key Escape",
857 "accessibility live-cancelled",
858 "modifiers command shift",
859 "key p",
860 "modifiers",
861 "settle",
862 "type Open Shared",
863 "key Enter",
864 "accessibility palette-live",
865 ]);
866 let variables = [("SNOWBOUND_LIVE", "off"), ("SNOWBOUND_LIVE_RELAY", "off")];
867 let trees = replay_with(&scratch, Some(&notebook), &steps, &variables);
868 assert_eq!(
869 trees[0].matches(r#"Button "Open Notebook""#).count(),
870 1,
871 "{}",
872 trees[0]
873 );
874 assert_eq!(
875 trees[0].contains("No notebooks open"),
876 welcome,
877 "{}",
878 trees[0]
879 );
880 for tree in [&trees[1], &trees[3]] {
881 assert_eq!(
882 menu_items(tree),
883 [
884 "From File System",
885 "From SMB Share",
886 "Connect to Live Share"
887 ]
888 );
889 assert!(
890 tree.contains(r#"Button "Open Notebook" [expanded]"#),
891 "{tree}"
892 );
893 }
894 let live = trees[1]
895 .lines()
896 .find(|line| line.contains(r#"MenuItem "Connect to Live Share""#))
897 .unwrap();
898 assert_eq!(
899 live.contains("[disabled]"),
900 !cfg!(feature = "live"),
901 "{live}"
902 );
903 for tree in [&trees[2], &trees[4], &trees[7]] {
904 assert!(
905 tree.contains(r#"Button "Open Notebook" [focused]"#),
906 "{tree}"
907 );
908 assert!(!tree.contains("MenuItem"), "{tree}");
909 }
910 for tree in &trees[5..7] {
911 assert_eq!(
912 tree.matches(r#"Dialog "Open Notebook from Server""#)
913 .count(),
914 1,
915 "{tree}"
916 );
917 assert!(!tree.contains("MenuItem"), "{tree}");
918 }
919 #[cfg(feature = "live")]
920 {
921 for tree in [&trees[8], &trees[10]] {
922 assert_eq!(
923 tree.matches(r#"Dialog "Open Shared Notebook""#).count(),
924 1,
925 "{tree}"
926 );
927 }
928 assert!(
929 trees[9].contains(r#"Button "Open Notebook" [focused]"#),
930 "{}",
931 trees[9]
932 );
933 }
934 }
830935}
831936
832937/// Switching between Metal and OpenGL, again and again, leaves the page, its edits and the