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...@@ -64,7 +64,7 @@ web-sys = { version = "0.3", features = ["console", "Document", "Element", "Html
64[target.'cfg(target_os = "macos")'.dependencies]64[target.'cfg(target_os = "macos")'.dependencies]
65libc = "0.2"65libc = "0.2"
66objc2 = "0.5.2"66objc2 = "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"] }
68objc2-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"] }68objc2-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"] }
69block2 = "0.5.1"69block2 = "0.5.1"
7070
crates/snowbound/src/commands.rs+18-40
...@@ -1079,22 +1079,13 @@ impl State {...@@ -1079,22 +1079,13 @@ impl State {
1079 self.window.request_redraw();1079 self.window.request_redraw();
1080 }1080 }
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`.
1082 pub(crate) fn choose_input(&mut self, choice: Choice) {1085 pub(crate) fn choose_input(&mut self, choice: Choice) {
1083 #[cfg(target_os = "macos")]1086 let Choice::Command(id @ (Id::Find | Id::NewPage | Id::NewSubpage)) = choice else {
1084 if !platform::commands_allowed(&choice) {1087 return self.choose(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 }
1093 };1088 };
1094 #[cfg(any(windows, target_os = "linux"))]
1095 if self.prompt.strong_count() != 0 {
1096 return;
1097 }
1098 let result = (|| -> Result<(), Box<dyn Error>> {1089 let result = (|| -> Result<(), Box<dyn Error>> {
1099 let size = self.window.inner_size();1090 let size = self.window.inner_size();
1100 let size = [size.width, size.height]1091 let size = [size.width, size.height]
...@@ -1102,16 +1093,13 @@ impl State {...@@ -1102,16 +1093,13 @@ impl State {
1102 let scale = self.scale();1093 let scale = self.scale();
1103 self.layout(size, scale)?;1094 self.layout(size, scale)?;
1104 while !self.commands.is_empty() {1095 while !self.commands.is_empty() {
1105 let mut commands = std::mem::take(&mut self.commands).into_iter();1096 for command in std::mem::take(&mut self.commands) {
1106 while let Some(command) = commands.next() {
1107 if let Err(error) = self.apply(command) {1097 if let Err(error) = self.apply(command) {
1108 self.commands =1098 eprintln!("{error}");
1109 commands.chain(std::mem::take(&mut self.commands)).collect();
1110 return Err(error);
1111 }1099 }
1112 }1100 }
1113 }1101 }
1114 self.run(Choice::Command(id))?;1102 self.run(choice)?;
1115 self.layout(size, scale)1103 self.layout(size, scale)
1116 })();1104 })();
1117 if let Err(error) = result {1105 if let Err(error) = result {
...@@ -1460,7 +1448,7 @@ impl State {...@@ -1460,7 +1448,7 @@ impl State {
1460 /// Runs `choice` where it applies now.1448 /// Runs `choice` where it applies now.
1461 pub(crate) fn run(&mut self, choice: Choice) -> Result<(), Box<dyn Error>> {1449 pub(crate) fn run(&mut self, choice: Choice) -> Result<(), Box<dyn Error>> {
1462 #[cfg(target_os = "macos")]1450 #[cfg(target_os = "macos")]
1463 if !platform::commands_allowed(&choice) {1451 if !platform::commands_allowed() {
1464 return Ok(());1452 return Ok(());
1465 }1453 }
1466 if !self.status(&choice, &self.format_state()).enabled {1454 if !self.status(&choice, &self.format_state()).enabled {
...@@ -1551,16 +1539,14 @@ impl State {...@@ -1551,16 +1539,14 @@ impl State {
1551 },1539 },
1552 )1540 )
1553 }1541 }
1554 Id::NewPage | Id::NewSubpage => {1542 Id::NewPage => Work::NewPage {
1555 let under = self1543 under: None,
1556 .session1544 title: String::new(),
1557 .as_ref()1545 },
1558 .filter(|_| id == Id::NewSubpage)1546 Id::NewSubpage => Work::NewPage {
1559 .map(|session| session.space);1547 under: self.session.as_ref().map(|session| session.space),
1560 self.new_page(under, "")?;1548 title: String::new(),
1561 self.edited(Vec::new());1549 },
1562 return Ok(());
1563 }
1564 Id::PageVersions => {1550 Id::PageVersions => {
1565 let session = self.session.as_ref().ok_or("No notebook is open")?;1551 let session = self.session.as_ref().ok_or("No notebook is open")?;
1566 Work::History {1552 Work::History {
...@@ -1861,15 +1847,7 @@ impl State {...@@ -1861,15 +1847,7 @@ impl State {
1861 return Ok(());1847 return Ok(());
1862 }1848 }
1863 };1849 };
1864 #[cfg(target_os = "macos")]1850 self.apply(response)
1865 {
1866 self.apply(response)
1867 }
1868 #[cfg(not(target_os = "macos"))]
1869 {
1870 self.commands.push(response);
1871 Ok(())
1872 }
1873 }1851 }
1874}1852}
18751853
crates/snowbound/src/macos.rs+75-52
...@@ -9,10 +9,9 @@ use objc2::{...@@ -9,10 +9,9 @@ use objc2::{
9 sel,9 sel,
10};10};
11use objc2_app_kit::{11use objc2_app_kit::{
12 NSAlert, NSAlertFirstButtonReturn, NSApplication, NSApplicationActivationPolicy, NSColor,12 NSAlert, NSAlertFirstButtonReturn, NSApplication, NSColor, NSColorSpace, NSDatePicker,
13 NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent,13 NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventSubtype, NSEventType, NSMenu,
14 NSEventSubtype, NSEventType, NSMenu, NSMenuItem, NSModalResponse, NSModalResponseCancel,14 NSMenuItem, NSModalResponse, NSModalResponseCancel, NSModalResponseOK, NSSavePanel, NSWindow,
15 NSModalResponseOK, NSSavePanel, NSWindow,
16};15};
17use objc2_foundation::{16use objc2_foundation::{
18 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,17 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,
...@@ -58,6 +57,8 @@ thread_local! {...@@ -58,6 +57,8 @@ thread_local! {
58 static IN_KEY_DOWN: Cell<bool> = const { Cell::new(false) };57 static IN_KEY_DOWN: Cell<bool> = const { Cell::new(false) };
59 static STATUSES: RefCell<Vec<commands::Status>> = const { RefCell::new(Vec::new()) };58 static STATUSES: RefCell<Vec<commands::Status>> = const { RefCell::new(Vec::new()) };
60 static TAGS_MENU: RefCell<Option<Retained<NSMenu>>> = const { RefCell::new(None) };59 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) };
61}62}
6263
63unsafe extern "C" fn key_down(view: &AnyObject, _: Sel, event: &NSEvent) {64unsafe extern "C" fn key_down(view: &AnyObject, _: Sel, event: &NSEvent) {
...@@ -1455,42 +1456,60 @@ pub fn text_colors(window: &Window) -> [[f32; 4]; 3] {...@@ -1455,42 +1456,60 @@ pub fn text_colors(window: &Window) -> [[f32; 4]; 3] {
1455 }1456 }
1456}1457}
14571458
1458pub(crate) fn commands_allowed(choice: &commands::Choice) -> bool {1459/// Whether the window takes commands: not while a native dialog is up over it.
1459 if matches!(1460pub(crate) fn commands_allowed() -> bool {
1460 choice,
1461 commands::Choice::Command(commands::Id::Settings | commands::Id::CheckForUpdates)
1462 ) {
1463 return true;
1464 }
1465 let app = NSApplication::sharedApplication(1461 let app = NSApplication::sharedApplication(
1466 MainThreadMarker::new().expect("Commands run on the main thread"),1462 MainThreadMarker::new().expect("Commands run on the main thread"),
1467 );1463 );
1468 WINDOW.with_borrow(|window| {1464 WINDOW.with_borrow(|window| unsafe {
1469 let Some(window) = window else { return false };1465 app.modalWindow().is_none()
1470 unsafe {1466 && window
1471 if app.modalWindow().is_some() || window.attachedSheet().is_some() {1467 .as_ref()
1472 return false;1468 .is_none_or(|window| window.attachedSheet().is_none())
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 }
1483 })1469 })
1484}1470}
14851471
1486fn send_choice(choice: commands::Choice) {1472/// Sends a menu's `choice` to the window, the keys pressed after it held until it runs.
1487 if commands_allowed(&choice)1473fn send(choice: commands::Choice) {
1488 && let Some(proxy) = PROXY.get()1474 if let Some(proxy) = PROXY.get()
1475 && proxy.send_event(crate::UserEvent::Choose(choice)).is_ok()
1489 {1476 {
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);
1491 }1491 }
1492}1492}
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
1494declare_class!(1513declare_class!(
1495 struct CanvasApplication;1514 struct CanvasApplication;
14961515
...@@ -1520,35 +1539,32 @@ declare_class!(...@@ -1520,35 +1539,32 @@ declare_class!(
1520 fn choose(&self, sender: &NSMenuItem) {1539 fn choose(&self, sender: &NSMenuItem) {
1521 let tag = usize::try_from(unsafe { sender.tag() }).ok();1540 let tag = usize::try_from(unsafe { sender.tag() }).ok();
1522 if let Some(choice) = tag.and_then(|tag| commands::choices().nth(tag)) {1541 if let Some(choice) = tag.and_then(|tag| commands::choices().nth(tag)) {
1523 send_choice(choice);1542 send(choice);
1524 }1543 }
1525 }1544 }
15261545
1527 #[method(cut:)]1546 // Ahead of the window, whose own undo manager would otherwise claim Undo and Redo.
1528 fn cut(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Cut)); }1547 #[method(targetForAction:to:from:)]
15291548 fn target_for_action(&self, action: Sel, target: *mut AnyObject, sender: *mut AnyObject) -> *mut AnyObject {
1530 #[method(copy:)]1549 if target.is_null() && page_editing(action).is_some() {
1531 fn copy(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Copy)); }1550 return self as *const Self as *mut AnyObject;
15321551 }
1533 #[method(paste:)]1552 unsafe { msg_send![super(self), targetForAction: action, to: target, from: sender] }
1534 fn paste(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Paste)); }1553 }
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)); }
15411554
1542 #[method(redo:)]1555 #[method(sendAction:to:from:)]
1543 fn redo(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Redo)); }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
1545 #[method(validateMenuItem:)]1564 #[method(validateMenuItem:)]
1546 fn validate_menu_item(&self, item: &NSMenuItem) -> objc2::runtime::Bool {1565 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));
1548 let action = unsafe { item.action() };1566 let action = unsafe { item.action() };
1549 if action != Some(sel!(choose:))1567 if action != Some(sel!(choose:)) && action.and_then(page_editing).is_none() {
1550 && !(action.is_some() && choice.as_ref().is_some_and(|choice| crate::menubar::editing_action(choice) == action))
1551 {
1552 // AppKit's own items validate as NSApplication does.1568 // AppKit's own items validate as NSApplication does.
1553 return objc2::runtime::Bool::new(1569 return objc2::runtime::Bool::new(
1554 !NSApplication::class().responds_to(sel!(validateMenuItem:))1570 !NSApplication::class().responds_to(sel!(validateMenuItem:))
...@@ -1559,7 +1575,7 @@ declare_class!(...@@ -1559,7 +1575,7 @@ declare_class!(
1559 STATUSES.with_borrow(|statuses| statuses.get(tag).copied())1575 STATUSES.with_borrow(|statuses| statuses.get(tag).copied())
1560 }).unwrap_or_default();1576 }).unwrap_or_default();
1561 unsafe { item.setState(isize::from(status.checked == Some(true))) };1577 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())
1563 }1579 }
15641580
1565 // NSAlert consumes Escape before it reaches cancelOperation:, and 10.6's date1581 // NSAlert consumes Escape before it reaches cancelOperation:, and 10.6's date
...@@ -1567,6 +1583,13 @@ declare_class!(...@@ -1567,6 +1583,13 @@ declare_class!(
1567 #[method(sendEvent:)]1583 #[method(sendEvent:)]
1568 fn send_event(&self, event: &NSEvent) {1584 fn send_event(&self, event: &NSEvent) {
1569 unsafe {1585 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 }
1570 let key = (event.r#type() == NSEventType::KeyDown)1593 let key = (event.r#type() == NSEventType::KeyDown)
1571 .then(|| event.charactersIgnoringModifiers())1594 .then(|| event.charactersIgnoringModifiers())
1572 .flatten()1595 .flatten()
crates/snowbound/src/main.rs+13-4
...@@ -237,7 +237,8 @@ type Continuation = Box<dyn FnOnce(&mut State) -> Result<(), Box<dyn Error>> + S...@@ -237,7 +237,8 @@ type Continuation = Box<dyn FnOnce(&mut State) -> Result<(), Box<dyn Error>> + S
237/// one is open; one cancelled drops its reply unanswered.237/// one is open; one cancelled drops its reply unanswered.
238struct Reply<T>(Box<dyn FnOnce(T) + Send>);238struct 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.
241#[cfg(any(windows, target_os = "linux"))]242#[cfg(any(windows, target_os = "linux"))]
242struct Prompt(EventLoopProxy<UserEvent>);243struct Prompt(EventLoopProxy<UserEvent>);
243244
...@@ -707,7 +708,8 @@ enum Laid {...@@ -707,7 +708,8 @@ enum Laid {
707 Library(Arc<Library>, Option<String>),708 Library(Arc<Library>, Option<String>),
708}709}
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.
711struct Opening {713struct Opening {
712 loaded: Shown,714 loaded: Shown,
713 scene: (PageScene, [f32; 2]),715 scene: (PageScene, [f32; 2]),
...@@ -4301,8 +4303,11 @@ impl State {...@@ -4301,8 +4303,11 @@ impl State {
4301 .collect(),4303 .collect(),
4302 },4304 },
4303 )?;4305 )?;
4304 // The page list shows the title as it is typed.4306 // The page list shows the title as it is typed, even when the caret left it since.
4305 if self.view.editor.active_outline().title {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()) {
4306 session.pages = session.section.pages()?;4311 session.pages = session.section.pages()?;
4307 }4312 }
4308 self.edited(vec![space]);4313 self.edited(vec![space]);
...@@ -6582,6 +6587,10 @@ const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3);...@@ -6582,6 +6587,10 @@ const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3);
6582#[cfg(not(target_arch = "wasm32"))]6587#[cfg(not(target_arch = "wasm32"))]
6583impl ApplicationHandler<UserEvent> for App {6588impl ApplicationHandler<UserEvent> for App {
6584 fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {6589 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 }
6585 let Some(state) = &mut self.state else {6594 let Some(state) = &mut self.state else {
6586 match event {6595 match event {
6587 UserEvent::Quit => self.close(event_loop),6596 UserEvent::Quit => self.close(event_loop),
crates/snowbound/src/manage.rs+7-14
...@@ -218,11 +218,12 @@ impl State {...@@ -218,11 +218,12 @@ impl State {
218 space, before, level,218 space, before, level,
219 )?])));219 )?])));
220 }220 }
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")?;
222 let edit = Edit {222 let edit = Edit {
223 at: crate::filetime(),223 at: crate::filetime(),
224 ops,224 ops,
225 };225 };
226 // A section being rebuilt answers reads from before the page was made.
226 #[cfg(not(target_arch = "wasm32"))]227 #[cfg(not(target_arch = "wasm32"))]
227 session.section.replica().apply(&self.author, edit)?;228 session.section.replica().apply(&self.author, edit)?;
228 #[cfg(target_arch = "wasm32")]229 #[cfg(target_arch = "wasm32")]
...@@ -233,26 +234,18 @@ impl State {...@@ -233,26 +234,18 @@ impl State {
233 show: Some(shown),234 show: Some(shown),
234 created: true,235 created: true,
235 });236 });
236 let session = self.session.as_mut().ok_or("No section is open")?;237 let session = self.session.as_ref().ok_or("No section is open")?;
237 session.pages = session.section.pages()?;238 let page = session.section.page(space)?;
238 let page = session.section.replica().page(space)?;
239 let (scene, editor) = canvas::gpu::page::PageScene::from_page(page, &mut self.view.engine)?;239 let (scene, editor) = canvas::gpu::page::PageScene::from_page(page, &mut self.view.engine)?;
240 self.stop_loading();
240 let now = crate::Instant::now();241 let now = crate::Instant::now();
241 let shown = self.show_page(crate::Opening {242 self.show_page(crate::Opening {
242 loaded: crate::Shown::Page(space),243 loaded: crate::Shown::Page(space),
243 scene: (scene, [0.0; 2]),244 scene: (scene, [0.0; 2]),
244 editor,245 editor,
245 since: now,246 since: now,
246 requested: now,247 requested: now,
247 });248 })?;
248 if self
249 .session
250 .as_ref()
251 .is_some_and(|session| session.space == space)
252 {
253 self.stop_loading();
254 }
255 shown?;
256 self.ui.set_focus(Some(crate::page()));249 self.ui.set_focus(Some(crate::page()));
257 if let Some(title) = self250 if let Some(title) = self
258 .view251 .view
crates/snowbound/src/menubar.rs+22-14
...@@ -239,7 +239,8 @@ pub struct Menus {...@@ -239,7 +239,8 @@ pub struct Menus {
239 pub tags: Retained<NSMenu>,239 pub tags: Retained<NSMenu>,
240}240}
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.
243pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus {244pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus {
244 let services = NSMenu::new(mtm);245 let services = NSMenu::new(mtm);
245 let bar = NSMenu::new(mtm);246 let bar = NSMenu::new(mtm);
...@@ -270,16 +271,25 @@ pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus {...@@ -270,16 +271,25 @@ pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus {
270 }271 }
271}272}
272273
273pub(crate) fn editing_action(choice: &Choice) -> Option<Sel> {274/// The editing commands whose items send AppKit's own actions, which a native dialog's fields
274 Some(match choice {275/// answer while it holds the keys.
275 Choice::Command(Id::Cut) => objc2::sel!(cut:),276pub(crate) fn editing() -> [(Id, Sel); 6] {
276 Choice::Command(Id::Copy) => objc2::sel!(copy:),277 [
277 Choice::Command(Id::Paste) => objc2::sel!(paste:),278 (Id::Cut, objc2::sel!(cut:)),
278 Choice::Command(Id::SelectAll) => objc2::sel!(selectAll:),279 (Id::Copy, objc2::sel!(copy:)),
279 Choice::Command(Id::Undo) => objc2::sel!(undo:),280 (Id::Paste, objc2::sel!(paste:)),
280 Choice::Command(Id::Redo) => objc2::sel!(redo:),281 (Id::SelectAll, objc2::sel!(selectAll:)),
281 _ => return None,282 (Id::Undo, objc2::sel!(undo:)),
282 })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)
283}293}
284294
285fn menu(295fn menu(
...@@ -468,9 +478,7 @@ mod tests {...@@ -468,9 +478,7 @@ mod tests {
468 .filter(|action| *action != objc2::sel!(submenuAction:))478 .filter(|action| *action != objc2::sel!(submenuAction:))
469 .map_or(String::new(), |action| {479 .map_or(String::new(), |action| {
470 if action == objc2::sel!(choose:)480 if action == objc2::sel!(choose:)
471 || commands::choices()481 || editing().iter().any(|(_, editing)| *editing == action)
472 .nth(unsafe { item.tag() } as usize)
473 .is_some_and(|choice| editing_action(&choice) == Some(action))
474 {482 {
475 let tag = unsafe { item.tag() } as usize;483 let tag = unsafe { item.tag() } as usize;
476 let choice = commands::choices().nth(tag).expect("Tags index choices");484 let choice = commands::choices().nth(tag).expect("Tags index choices");
crates/snowbound/src/search.rs+1-4
...@@ -131,7 +131,7 @@ pub struct Search {...@@ -131,7 +131,7 @@ pub struct Search {
131 pub(crate) open: bool,131 pub(crate) open: bool,
132 /// Where the scope menu opens.132 /// Where the scope menu opens.
133 anchor: Option<Anchor>,133 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.
135 pub(crate) claim: bool,135 pub(crate) claim: bool,
136 pub(crate) pane: Option<Pane>,136 pub(crate) pane: Option<Pane>,
137}137}
...@@ -1203,9 +1203,6 @@ impl State {...@@ -1203,9 +1203,6 @@ impl State {
1203 },1203 },
1204 );1204 );
1205 name(&mut self.ui, find_field(), "Find on Page");1205 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 }
1209 let close =1206 let close =
1210 ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, None).clicked;1207 ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, None).clicked;
1211 let button = self.ui.id("close");1208 let button = self.ui.id("close");