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 {...@@ -390,6 +390,7 @@ impl std::task::Wake for Redraw {
390#[derive(Debug)]390#[derive(Debug)]
391enum Replay {391enum Replay {
392 Input(ui::Event),392 Input(ui::Event),
393 Access(accesskit::Action, String),
393 /// A trackpad pinch scaling the page by the factor.394 /// A trackpad pinch scaling the page by the factor.
394 Pinch(f32),395 Pinch(f32),
395 /// Paints the next frame into a PNG as well as the window.396 /// Paints the next frame into a PNG as well as the window.
...@@ -6408,6 +6409,31 @@ impl State {...@@ -6408,6 +6409,31 @@ impl State {
6408 };6409 };
6409 match replay {6410 match replay {
6410 Replay::Input(event) => self.input(event),6411 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 }
6411 Replay::Pinch(factor) => {6437 Replay::Pinch(factor) => {
6412 if let Err(error) = self.pinch(factor) {6438 if let Err(error) = self.pinch(factor) {
6413 eprintln!("{error}");6439 eprintln!("{error}");
...@@ -6794,7 +6820,7 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E...@@ -6794,7 +6820,7 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E
6794/// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift]6820/// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift]
6795/// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `settle` and6821/// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `settle` and
6796/// `accessibility TEXT_PATH` (which wait for what is on its way), `appearance light|dark`,6822/// `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`.
6798#[cfg(not(target_arch = "wasm32"))]6824#[cfg(not(target_arch = "wasm32"))]
6799fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {6825fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {
6800 let (settle, settled) = std::sync::mpsc::channel();6826 let (settle, settled) = std::sync::mpsc::channel();
...@@ -6868,6 +6894,15 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy...@@ -6868,6 +6894,15 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
6868 "snapshot" => Ok(Replay::Snapshot(rest.into())),6894 "snapshot" => Ok(Replay::Snapshot(rest.into())),
6869 "settle" => Ok(Replay::Settle(None, settle.clone())),6895 "settle" => Ok(Replay::Settle(None, settle.clone())),
6870 "accessibility" => Ok(Replay::Settle(Some(rest.into()), settle.clone())),6896 "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 }
6871 "renderer" => Ok(Replay::Renderer(settings::Backend::named(rest)?)),6906 "renderer" => Ok(Replay::Renderer(settings::Backend::named(rest)?)),
6872 "appearance" => Ok(Replay::Appearance(match rest {6907 "appearance" => Ok(Replay::Appearance(match rest {
6873 "light" => winit::window::Theme::Light,6908 "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);...@@ -29,7 +29,7 @@ pub const NAV: f32 = 2.0 * ui::shell::TOOL + 1.0 + (RAIL - ui::shell::TOOL);
29const INDENT: f32 = 16.0;29const INDENT: f32 = 16.0;
3030
31/// What the sidebar was asked to do.31/// What the sidebar was asked to do.
32pub enum Action {32enum Action {
33 /// Opens a notebook's section by catalog path.33 /// Opens a notebook's section by catalog path.
34 Open {34 Open {
35 notebook: usize,35 notebook: usize,
...@@ -37,10 +37,7 @@ pub enum Action {...@@ -37,10 +37,7 @@ pub enum Action {
37 },37 },
38 /// Folds or unfolds a notebook's or section group's rows, by `Library::key`.38 /// Folds or unfolds a notebook's or section group's rows, by `Library::key`.
39 Fold(String),39 Fold(String),
40 NewNotebook,40 Command(crate::Command),
41 OpenNotebook,
42 #[cfg(feature = "live")]
43 OpenShared,
44 /// Signs in again to the notebook opened from its server at this location.41 /// Signs in again to the notebook opened from its server at this location.
45 SignIn(String),42 SignIn(String),
46 Options,43 Options,
...@@ -56,7 +53,7 @@ pub enum Action {...@@ -56,7 +53,7 @@ pub enum Action {
56}53}
5754
58/// What the tree's rows read and what they were asked.55/// What the tree's rows read and what they were asked.
59pub struct Tree<'a> {56struct Tree<'a> {
60 theme: &'a Theme,57 theme: &'a Theme,
61 /// The open section's notebook index and catalog path.58 /// The open section's notebook index and catalog path.
62 open: Option<(usize, &'a str)>,59 open: Option<(usize, &'a str)>,
...@@ -331,34 +328,8 @@ fn sidebar(...@@ -331,34 +328,8 @@ fn sidebar(
331 ..Spec::default()328 ..Spec::default()
332 },329 },
333 );330 );
334 for (part, icon, label, chosen) in [331 let row = |ui: &mut Ui, tree: &mut Tree, part, icon, label| {
335 (332 tree_row(
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(
362 ui,333 ui,
363 tree,334 tree,
364 ui.id(part),335 ui.id(part),
...@@ -372,10 +343,27 @@ fn sidebar(...@@ -372,10 +343,27 @@ fn sidebar(
372 renamed: false,343 renamed: false,
373 unread: false,344 unread: false,
374 },345 },
375 );346 )
376 if row.clicked {347 .0
377 tree.action = Some(chosen);348 };
378 }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);
379 }367 }
380 ui.close();368 ui.close();
381}369}
...@@ -839,6 +827,77 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {...@@ -839,6 +827,77 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {
839 (ui.signal(id), folded)827 (ui.signal(id), folded)
840}828}
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
842impl crate::State {901impl crate::State {
843 /// How wide the sidebar stands this frame as it eases open and shut.902 /// How wide the sidebar stands this frame as it eases open and shut.
844 pub(crate) fn sidebar_width(&mut self) -> f32 {903 pub(crate) fn sidebar_width(&mut self) -> f32 {
...@@ -969,10 +1028,7 @@ impl crate::State {...@@ -969,10 +1028,7 @@ impl crate::State {
969 self.rename(target);1028 self.rename(target);
970 }1029 }
971 Some(Action::Renamed(keep)) => self.finish_renaming(keep),1030 Some(Action::Renamed(keep)) => self.finish_renaming(keep),
972 Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook),1031 Some(Action::Command(command)) => self.commands.push(command),
973 Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook),
974 #[cfg(feature = "live")]
975 Some(Action::OpenShared) => self.open_shared(),
976 Some(Action::SignIn(location)) => self1032 Some(Action::SignIn(location)) => self
977 .commands1033 .commands
978 .push(crate::Command::OpenFromServer(Some(location))),1034 .push(crate::Command::OpenFromServer(Some(location))),
...@@ -1454,22 +1510,9 @@ impl crate::State {...@@ -1454,22 +1510,9 @@ impl crate::State {
1454 (1510 (
1455 "open",1511 "open",
1456 Leading::Notebook(art::NOTEBOOK, None),1512 Leading::Notebook(art::NOTEBOOK, None),
1457 "Open Notebook",1513 crate::commands::command(crate::commands::Id::OpenNotebook).title,
1458 crate::Command::OpenNotebook,1514 crate::Command::OpenNotebook,
1459 ),1515 ),
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 ),
1473 ]1516 ]
1474 .into_iter()1517 .into_iter()
1475 .chain(crate::guide::OFFERED.then_some((1518 .chain(crate::guide::OFFERED.then_some((
...@@ -1572,26 +1615,36 @@ impl crate::State {...@@ -1572,26 +1615,36 @@ impl crate::State {
1572 );1615 );
1573 let mut chosen = None;1616 let mut chosen = None;
1574 for (part, icon, label, command) in buttons {1617 for (part, icon, label, command) in buttons {
1575 let (icon, tint) = icon.art(&theme);1618 let spec = Spec {
1576 let button = self.ui.leaf(1619 flags: Flags::CLICKABLE,
1577 part,1620 size: [fill(), px(BUTTON[1])],
1578 Spec {1621 fill: Some(theme.chip),
1579 flags: Flags::CLICKABLE,1622 hover_fill: Some(theme.hover()),
1580 size: [fill(), px(BUTTON[1])],1623 hover_border: Some(theme.accent),
1581 icon: Some(icon),1624 radius: 6.0,
1582 tint: Some(tint),1625 center: true,
1583 text: Some(label),1626 role: Some(accesskit::Role::Button),
1584 fill: Some(theme.chip),1627 ..Spec::default()
1585 hover_fill: Some(theme.hover()),1628 };
1586 hover_border: Some(theme.accent),1629 if matches!(command, crate::Command::OpenNotebook) {
1587 radius: 6.0,1630 chosen = open_notebook(&mut self.ui, spec).or(chosen);
1588 center: true,1631 } else {
1589 role: Some(accesskit::Role::Button),1632 let (icon, tint) = icon.art(&theme);
1590 ..Spec::default()1633 if self
1591 },1634 .ui
1592 );1635 .leaf(
1593 if button.clicked {1636 part,
1594 chosen = Some(command);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 }
1595 }1648 }
1596 }1649 }
1597 self.commands.extend(chosen);1650 self.commands.extend(chosen);
crates/snowbound/tests/replay.rs+134-29
...@@ -796,37 +796,142 @@ fn stop_sharing_ends_the_share() {...@@ -796,37 +796,142 @@ fn stop_sharing_ends_the_share() {
796 assert!(!kept.contains("code"), "still kept: {kept}");796 assert!(!kept.contains("code"), "still kept: {kept}");
797}797}
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")]
802#[test]799#[test]
803fn open_shared_notebook_is_offered_with_notebooks_open() {800fn open_notebook_routes_from_the_sidebar_and_welcome() {
804 let scratch = Scratch::new("open-shared");801 for welcome in [false, true] {
805 std::fs::write(802 let scratch = Scratch::new(if welcome {
806 scratch.0.join("settings.json"),803 "welcome-open"
807 r#"{"user_name": "Snowbound Test", "sidebar": true}"#,804 } else {
808 )805 "sidebar-open"
809 .unwrap();806 });
810 let notebook =807 std::fs::write(
811 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");808 scratch.0.join("settings.json"),
812 let mut steps = vec!["accessibility sidebar", "modifiers command shift", "key p"];809 r#"{"user_name": "Snowbound Test", "sidebar": true}"#,
813 steps.extend([810 )
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()
824 .unwrap();811 .unwrap();
825 assert!(sidebar.contains(r#""Open Shared""#), "{sidebar}");812 let notebook =
826 assert!(813 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
827 dialog.contains(r#"Dialog "Open Shared Notebook""#),814 let mut steps = Vec::new();
828 "{dialog}"815 if welcome {
829 );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 }
830}935}
831936
832/// Switching between Metal and OpenGL, again and again, leaves the page, its edits and the937/// Switching between Metal and OpenGL, again and again, leaves the page, its edits and the