authorclover caruso <git@paperclover.net> 2026-10-15 02:13:26-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 02:58:22-07:00
logc355a943bcb3d015015c97abff850d15d654252f
tree6d8635db36c12978a9318cc84fa145712742a462
parent1b25afc5251a9d40a681b22186ce7b77ff9956ed
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(desktop): keep Find and New Page working after a failed command

- A failed earlier command no longer cancels Find or New Page, or shows its error under their name - The page list shows a title typed and tabbed out of within one frame On macOS a sheet or modal session holds every command, editing items reach the page while its window has the keys, and keys pressed after a menu shortcut wait until it has run. Commands apply their work at once on every platform. Fixes #123 Refs #129 Assisted-by: claude-opus-5.5

7 files changed, 137 insertions(+), 129 deletions(-)

crates/snowbound/Cargo.toml+1-1
......@@ -64,7 +64,7 @@ web-sys = { version = "0.3", features = ["console", "Document", "Element", "Html
6464[target.'cfg(target_os = "macos")'.dependencies]
6565libc = "0.2"
6666objc2 = "0.5.2"
67objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSCell", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSRunningApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel", "NSWorkspace"] }
67objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSCell", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel", "NSWorkspace"] }
6868objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray", "NSRange", "NSError", "NSFileCoordinator", "NSFilePresenter", "NSFileVersion", "NSFileManager", "NSNotification", "NSOperation", "block2"] }
6969block2 = "0.5.1"
7070
crates/snowbound/src/commands.rs+18-40
......@@ -1079,22 +1079,13 @@ impl State {
10791079 self.window.request_redraw();
10801080 }
10811081
1082 /// Runs Find, New Page and New Subpage at once, after the work queued before them and what
1083 /// that queues, so the next keystroke reaches the field or page they focus; other choices
1084 /// queue as `choose`.
10821085 pub(crate) fn choose_input(&mut self, choice: Choice) {
1083 #[cfg(target_os = "macos")]
1084 if !platform::commands_allowed(&choice) {
1085 return;
1086 }
1087 let id = match choice {
1088 Choice::Command(id @ (Id::Find | Id::NewPage | Id::NewSubpage)) => id,
1089 choice => {
1090 self.choose(choice);
1091 return;
1092 }
1086 let Choice::Command(id @ (Id::Find | Id::NewPage | Id::NewSubpage)) = choice else {
1087 return self.choose(choice);
10931088 };
1094 #[cfg(any(windows, target_os = "linux"))]
1095 if self.prompt.strong_count() != 0 {
1096 return;
1097 }
10981089 let result = (|| -> Result<(), Box<dyn Error>> {
10991090 let size = self.window.inner_size();
11001091 let size = [size.width, size.height]
......@@ -1102,16 +1093,13 @@ impl State {
11021093 let scale = self.scale();
11031094 self.layout(size, scale)?;
11041095 while !self.commands.is_empty() {
1105 let mut commands = std::mem::take(&mut self.commands).into_iter();
1106 while let Some(command) = commands.next() {
1096 for command in std::mem::take(&mut self.commands) {
11071097 if let Err(error) = self.apply(command) {
1108 self.commands =
1109 commands.chain(std::mem::take(&mut self.commands)).collect();
1110 return Err(error);
1098 eprintln!("{error}");
11111099 }
11121100 }
11131101 }
1114 self.run(Choice::Command(id))?;
1102 self.run(choice)?;
11151103 self.layout(size, scale)
11161104 })();
11171105 if let Err(error) = result {
......@@ -1460,7 +1448,7 @@ impl State {
14601448 /// Runs `choice` where it applies now.
14611449 pub(crate) fn run(&mut self, choice: Choice) -> Result<(), Box<dyn Error>> {
14621450 #[cfg(target_os = "macos")]
1463 if !platform::commands_allowed(&choice) {
1451 if !platform::commands_allowed() {
14641452 return Ok(());
14651453 }
14661454 if !self.status(&choice, &self.format_state()).enabled {
......@@ -1551,16 +1539,14 @@ impl State {
15511539 },
15521540 )
15531541 }
1554 Id::NewPage | Id::NewSubpage => {
1555 let under = self
1556 .session
1557 .as_ref()
1558 .filter(|_| id == Id::NewSubpage)
1559 .map(|session| session.space);
1560 self.new_page(under, "")?;
1561 self.edited(Vec::new());
1562 return Ok(());
1563 }
1542 Id::NewPage => Work::NewPage {
1543 under: None,
1544 title: String::new(),
1545 },
1546 Id::NewSubpage => Work::NewPage {
1547 under: self.session.as_ref().map(|session| session.space),
1548 title: String::new(),
1549 },
15641550 Id::PageVersions => {
15651551 let session = self.session.as_ref().ok_or("No notebook is open")?;
15661552 Work::History {
......@@ -1861,15 +1847,7 @@ impl State {
18611847 return Ok(());
18621848 }
18631849 };
1864 #[cfg(target_os = "macos")]
1865 {
1866 self.apply(response)
1867 }
1868 #[cfg(not(target_os = "macos"))]
1869 {
1870 self.commands.push(response);
1871 Ok(())
1872 }
1850 self.apply(response)
18731851 }
18741852}
18751853
crates/snowbound/src/macos.rs+75-52
......@@ -9,10 +9,9 @@ use objc2::{
99 sel,
1010};
1111use objc2_app_kit::{
12 NSAlert, NSAlertFirstButtonReturn, NSApplication, NSApplicationActivationPolicy, NSColor,
13 NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent,
14 NSEventSubtype, NSEventType, NSMenu, NSMenuItem, NSModalResponse, NSModalResponseCancel,
15 NSModalResponseOK, NSSavePanel, NSWindow,
12 NSAlert, NSAlertFirstButtonReturn, NSApplication, NSColor, NSColorSpace, NSDatePicker,
13 NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventSubtype, NSEventType, NSMenu,
14 NSMenuItem, NSModalResponse, NSModalResponseCancel, NSModalResponseOK, NSSavePanel, NSWindow,
1615};
1716use objc2_foundation::{
1817 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,
......@@ -58,6 +57,8 @@ thread_local! {
5857 static IN_KEY_DOWN: Cell<bool> = const { Cell::new(false) };
5958 static STATUSES: RefCell<Vec<commands::Status>> = const { RefCell::new(Vec::new()) };
6059 static TAGS_MENU: RefCell<Option<Retained<NSMenu>>> = const { RefCell::new(None) };
60 /// Keys pressed after a menu command was sent and before the window ran it.
61 static HELD: RefCell<Option<Vec<Retained<NSEvent>>>> = const { RefCell::new(None) };
6162}
6263
6364unsafe extern "C" fn key_down(view: &AnyObject, _: Sel, event: &NSEvent) {
......@@ -1455,42 +1456,60 @@ pub fn text_colors(window: &Window) -> [[f32; 4]; 3] {
14551456 }
14561457}
14571458
1458pub(crate) fn commands_allowed(choice: &commands::Choice) -> bool {
1459 if matches!(
1460 choice,
1461 commands::Choice::Command(commands::Id::Settings | commands::Id::CheckForUpdates)
1462 ) {
1463 return true;
1464 }
1459/// Whether the window takes commands: not while a native dialog is up over it.
1460pub(crate) fn commands_allowed() -> bool {
14651461 let app = NSApplication::sharedApplication(
14661462 MainThreadMarker::new().expect("Commands run on the main thread"),
14671463 );
1468 WINDOW.with_borrow(|window| {
1469 let Some(window) = window else { return false };
1470 unsafe {
1471 if app.modalWindow().is_some() || window.attachedSheet().is_some() {
1472 return false;
1473 }
1474 match app.keyWindow() {
1475 Some(key) => std::ptr::eq(&*key, &**window),
1476 // Hidden screenshot replays have no key window and cannot activate.
1477 None => {
1478 !window.isVisible()
1479 && app.activationPolicy() == NSApplicationActivationPolicy::Prohibited
1480 }
1481 }
1482 }
1464 WINDOW.with_borrow(|window| unsafe {
1465 app.modalWindow().is_none()
1466 && window
1467 .as_ref()
1468 .is_none_or(|window| window.attachedSheet().is_none())
14831469 })
14841470}
14851471
1486fn send_choice(choice: commands::Choice) {
1487 if commands_allowed(&choice)
1488 && let Some(proxy) = PROXY.get()
1472/// Sends a menu's `choice` to the window, the keys pressed after it held until it runs.
1473fn send(choice: commands::Choice) {
1474 if let Some(proxy) = PROXY.get()
1475 && proxy.send_event(crate::UserEvent::Choose(choice)).is_ok()
14891476 {
1490 let _ = proxy.send_event(crate::UserEvent::Choose(choice));
1477 HELD.with_borrow_mut(|held| {
1478 held.get_or_insert_default();
1479 });
1480 }
1481}
1482
1483/// Queues the keys held behind a menu command ahead of newer ones, as its choice arrives.
1484pub(crate) fn release_keys() {
1485 let held = HELD.take().unwrap_or_default();
1486 let app = NSApplication::sharedApplication(
1487 MainThreadMarker::new().expect("Choices arrive on the main thread"),
1488 );
1489 for event in held.iter().rev() {
1490 app.postEvent_atStart(event, true);
14911491 }
14921492}
14931493
1494/// The page's editing command `action` names while its window holds the keys.
1495fn page_editing(action: Sel) -> Option<commands::Choice> {
1496 let app = NSApplication::sharedApplication(MainThreadMarker::new()?);
1497 let key = app.keyWindow()?;
1498 WINDOW
1499 .with_borrow(|window| {
1500 window
1501 .as_ref()
1502 .is_some_and(|window| std::ptr::eq(&*key, &**window))
1503 })
1504 .then(|| {
1505 crate::menubar::editing()
1506 .into_iter()
1507 .find(|(_, editing)| *editing == action)
1508 })
1509 .flatten()
1510 .map(|(id, _)| commands::Choice::Command(id))
1511}
1512
14941513declare_class!(
14951514 struct CanvasApplication;
14961515
......@@ -1520,35 +1539,32 @@ declare_class!(
15201539 fn choose(&self, sender: &NSMenuItem) {
15211540 let tag = usize::try_from(unsafe { sender.tag() }).ok();
15221541 if let Some(choice) = tag.and_then(|tag| commands::choices().nth(tag)) {
1523 send_choice(choice);
1542 send(choice);
15241543 }
15251544 }
15261545
1527 #[method(cut:)]
1528 fn cut(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Cut)); }
1529
1530 #[method(copy:)]
1531 fn copy(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Copy)); }
1532
1533 #[method(paste:)]
1534 fn paste(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Paste)); }
1535
1536 #[method(selectAll:)]
1537 fn select_all(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::SelectAll)); }
1538
1539 #[method(undo:)]
1540 fn undo(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Undo)); }
1546 // Ahead of the window, whose own undo manager would otherwise claim Undo and Redo.
1547 #[method(targetForAction:to:from:)]
1548 fn target_for_action(&self, action: Sel, target: *mut AnyObject, sender: *mut AnyObject) -> *mut AnyObject {
1549 if target.is_null() && page_editing(action).is_some() {
1550 return self as *const Self as *mut AnyObject;
1551 }
1552 unsafe { msg_send![super(self), targetForAction: action, to: target, from: sender] }
1553 }
15411554
1542 #[method(redo:)]
1543 fn redo(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Redo)); }
1555 #[method(sendAction:to:from:)]
1556 fn send_action(&self, action: Sel, target: *mut AnyObject, sender: *mut AnyObject) -> objc2::runtime::Bool {
1557 if target.is_null() && let Some(choice) = page_editing(action) {
1558 send(choice);
1559 return objc2::runtime::Bool::YES;
1560 }
1561 unsafe { msg_send![super(self), sendAction: action, to: target, from: sender] }
1562 }
15441563
15451564 #[method(validateMenuItem:)]
15461565 fn validate_menu_item(&self, item: &NSMenuItem) -> objc2::runtime::Bool {
1547 let choice = usize::try_from(unsafe { item.tag() }).ok().and_then(|tag| commands::choices().nth(tag));
15481566 let action = unsafe { item.action() };
1549 if action != Some(sel!(choose:))
1550 && !(action.is_some() && choice.as_ref().is_some_and(|choice| crate::menubar::editing_action(choice) == action))
1551 {
1567 if action != Some(sel!(choose:)) && action.and_then(page_editing).is_none() {
15521568 // AppKit's own items validate as NSApplication does.
15531569 return objc2::runtime::Bool::new(
15541570 !NSApplication::class().responds_to(sel!(validateMenuItem:))
......@@ -1559,7 +1575,7 @@ declare_class!(
15591575 STATUSES.with_borrow(|statuses| statuses.get(tag).copied())
15601576 }).unwrap_or_default();
15611577 unsafe { item.setState(isize::from(status.checked == Some(true))) };
1562 objc2::runtime::Bool::new(status.enabled && choice.as_ref().is_some_and(commands_allowed))
1578 objc2::runtime::Bool::new(status.enabled && commands_allowed())
15631579 }
15641580
15651581 // NSAlert consumes Escape before it reaches cancelOperation:, and 10.6's date
......@@ -1567,6 +1583,13 @@ declare_class!(
15671583 #[method(sendEvent:)]
15681584 fn send_event(&self, event: &NSEvent) {
15691585 unsafe {
1586 if matches!(event.r#type(), NSEventType::KeyDown | NSEventType::KeyUp | NSEventType::FlagsChanged)
1587 && self.modalWindow().is_none()
1588 && HELD.with_borrow_mut(|held| held.as_mut().map(|held| held.push(event.retain())))
1589 .is_some()
1590 {
1591 return;
1592 }
15701593 let key = (event.r#type() == NSEventType::KeyDown)
15711594 .then(|| event.charactersIgnoringModifiers())
15721595 .flatten()
crates/snowbound/src/main.rs+13-4
......@@ -237,7 +237,8 @@ type Continuation = Box<dyn FnOnce(&mut State) -> Result<(), Box<dyn Error>> + S
237237/// one is open; one cancelled drops its reply unanswered.
238238struct Reply<T>(Box<dyn FnOnce(T) + Send>);
239239
240/// Open Notebook blocks target shortcuts until its reply is consumed or cancelled.
240/// Open Notebook asking. Until it answers or is cancelled the window takes no commands, since
241/// its dialog shows a moment later, and the portal's beside the window rather than over it.
241242#[cfg(any(windows, target_os = "linux"))]
242243struct Prompt(EventLoopProxy<UserEvent>);
243244
......@@ -707,7 +708,8 @@ enum Laid {
707708 Library(Arc<Library>, Option<String>),
708709}
709710
710/// A page ready for adoption, with the scene and editor laid out together.
711/// A page read and laid out, which `open_loaded` holds until the pictures it shows first
712/// are drawn, so it never appears without them.
711713struct Opening {
712714 loaded: Shown,
713715 scene: (PageScene, [f32; 2]),
......@@ -4301,8 +4303,11 @@ impl State {
43014303 .collect(),
43024304 },
43034305 )?;
4304 // The page list shows the title as it is typed.
4305 if self.view.editor.active_outline().title {
4306 // The page list shows the title as it is typed, even when the caret left it since.
4307 let title = (self.view.editor.outlines().iter())
4308 .find(|outline| outline.title)
4309 .map(canvas::editor::TextOutline::shown_text);
4310 if title.is_some_and(|title| title != session.title()) {
43064311 session.pages = session.section.pages()?;
43074312 }
43084313 self.edited(vec![space]);
......@@ -6582,6 +6587,10 @@ const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3);
65826587#[cfg(not(target_arch = "wasm32"))]
65836588impl ApplicationHandler<UserEvent> for App {
65846589 fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
6590 #[cfg(target_os = "macos")]
6591 if matches!(event, UserEvent::Choose(_)) {
6592 platform::release_keys();
6593 }
65856594 let Some(state) = &mut self.state else {
65866595 match event {
65876596 UserEvent::Quit => self.close(event_loop),
crates/snowbound/src/manage.rs+7-14
......@@ -218,11 +218,12 @@ impl State {
218218 space, before, level,
219219 )?])));
220220 }
221 let session = self.session.as_mut().ok_or("No section is open")?;
221 let session = self.session.as_ref().ok_or("No section is open")?;
222222 let edit = Edit {
223223 at: crate::filetime(),
224224 ops,
225225 };
226 // A section being rebuilt answers reads from before the page was made.
226227 #[cfg(not(target_arch = "wasm32"))]
227228 session.section.replica().apply(&self.author, edit)?;
228229 #[cfg(target_arch = "wasm32")]
......@@ -233,26 +234,18 @@ impl State {
233234 show: Some(shown),
234235 created: true,
235236 });
236 let session = self.session.as_mut().ok_or("No section is open")?;
237 session.pages = session.section.pages()?;
238 let page = session.section.replica().page(space)?;
237 let session = self.session.as_ref().ok_or("No section is open")?;
238 let page = session.section.page(space)?;
239239 let (scene, editor) = canvas::gpu::page::PageScene::from_page(page, &mut self.view.engine)?;
240 self.stop_loading();
240241 let now = crate::Instant::now();
241 let shown = self.show_page(crate::Opening {
242 self.show_page(crate::Opening {
242243 loaded: crate::Shown::Page(space),
243244 scene: (scene, [0.0; 2]),
244245 editor,
245246 since: now,
246247 requested: now,
247 });
248 if self
249 .session
250 .as_ref()
251 .is_some_and(|session| session.space == space)
252 {
253 self.stop_loading();
254 }
255 shown?;
248 })?;
256249 self.ui.set_focus(Some(crate::page()));
257250 if let Some(title) = self
258251 .view
crates/snowbound/src/menubar.rs+22-14
......@@ -239,7 +239,8 @@ pub struct Menus {
239239 pub tags: Retained<NSMenu>,
240240}
241241
242/// Builds the bar; native editing follows the responder chain before the application.
242/// Builds the bar; the table's items send `choose:` to `target`, which validates them, and
243/// the editing items go up the responder chain, which `target` heads while the page has the keys.
243244pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus {
244245 let services = NSMenu::new(mtm);
245246 let bar = NSMenu::new(mtm);
......@@ -270,16 +271,25 @@ pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus {
270271 }
271272}
272273
273pub(crate) fn editing_action(choice: &Choice) -> Option<Sel> {
274 Some(match choice {
275 Choice::Command(Id::Cut) => objc2::sel!(cut:),
276 Choice::Command(Id::Copy) => objc2::sel!(copy:),
277 Choice::Command(Id::Paste) => objc2::sel!(paste:),
278 Choice::Command(Id::SelectAll) => objc2::sel!(selectAll:),
279 Choice::Command(Id::Undo) => objc2::sel!(undo:),
280 Choice::Command(Id::Redo) => objc2::sel!(redo:),
281 _ => return None,
282 })
274/// The editing commands whose items send AppKit's own actions, which a native dialog's fields
275/// answer while it holds the keys.
276pub(crate) fn editing() -> [(Id, Sel); 6] {
277 [
278 (Id::Cut, objc2::sel!(cut:)),
279 (Id::Copy, objc2::sel!(copy:)),
280 (Id::Paste, objc2::sel!(paste:)),
281 (Id::SelectAll, objc2::sel!(selectAll:)),
282 (Id::Undo, objc2::sel!(undo:)),
283 (Id::Redo, objc2::sel!(redo:)),
284 ]
285}
286
287/// The action `choice`'s item sends where it is an editing command.
288fn editing_action(choice: &Choice) -> Option<Sel> {
289 editing()
290 .into_iter()
291 .find(|(id, _)| *choice == Choice::Command(*id))
292 .map(|(_, action)| action)
283293}
284294
285295fn menu(
......@@ -468,9 +478,7 @@ mod tests {
468478 .filter(|action| *action != objc2::sel!(submenuAction:))
469479 .map_or(String::new(), |action| {
470480 if action == objc2::sel!(choose:)
471 || commands::choices()
472 .nth(unsafe { item.tag() } as usize)
473 .is_some_and(|choice| editing_action(&choice) == Some(action))
481 || editing().iter().any(|(_, editing)| *editing == action)
474482 {
475483 let tag = unsafe { item.tag() } as usize;
476484 let choice = commands::choices().nth(tag).expect("Tags index choices");
crates/snowbound/src/search.rs+1-4
......@@ -131,7 +131,7 @@ pub struct Search {
131131 pub(crate) open: bool,
132132 /// Where the scope menu opens.
133133 anchor: Option<Anchor>,
134 /// The find field, or the Search Results pane's, takes the keys once built.
134 /// The Search Results pane's field takes the keys once built.
135135 pub(crate) claim: bool,
136136 pub(crate) pane: Option<Pane>,
137137}
......@@ -1203,9 +1203,6 @@ impl State {
12031203 },
12041204 );
12051205 name(&mut self.ui, find_field(), "Find on Page");
1206 if std::mem::take(&mut self.search.claim) {
1207 self.ui.set_focus(Some(find_field()));
1208 }
12091206 let close =
12101207 ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, None).clicked;
12111208 let button = self.ui.id("close");