diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index ac1dd39efe88de91643906895d7a027edb726a98..4efb2bd455b51cc9456bebba44fb890d56828f5d 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -64,7 +64,7 @@ web-sys = { version = "0.3", features = ["console", "Document", "Element", "Html [target.'cfg(target_os = "macos")'.dependencies] libc = "0.2" objc2 = "0.5.2" -objc2-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"] } +objc2-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"] } objc2-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"] } block2 = "0.5.1" diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index e0279cbf1d6340c32102896315f74971833c7ce0..c3c071a3049f853eaf8ba7837edbb5c641bcf4c6 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -1079,22 +1079,13 @@ impl State { self.window.request_redraw(); } + /// Runs Find, New Page and New Subpage at once, after the work queued before them and what + /// that queues, so the next keystroke reaches the field or page they focus; other choices + /// queue as `choose`. pub(crate) fn choose_input(&mut self, choice: Choice) { - #[cfg(target_os = "macos")] - if !platform::commands_allowed(&choice) { - return; - } - let id = match choice { - Choice::Command(id @ (Id::Find | Id::NewPage | Id::NewSubpage)) => id, - choice => { - self.choose(choice); - return; - } + let Choice::Command(id @ (Id::Find | Id::NewPage | Id::NewSubpage)) = choice else { + return self.choose(choice); }; - #[cfg(any(windows, target_os = "linux"))] - if self.prompt.strong_count() != 0 { - return; - } let result = (|| -> Result<(), Box> { let size = self.window.inner_size(); let size = [size.width, size.height] @@ -1102,16 +1093,13 @@ impl State { let scale = self.scale(); self.layout(size, scale)?; while !self.commands.is_empty() { - let mut commands = std::mem::take(&mut self.commands).into_iter(); - while let Some(command) = commands.next() { + for command in std::mem::take(&mut self.commands) { if let Err(error) = self.apply(command) { - self.commands = - commands.chain(std::mem::take(&mut self.commands)).collect(); - return Err(error); + eprintln!("{error}"); } } } - self.run(Choice::Command(id))?; + self.run(choice)?; self.layout(size, scale) })(); if let Err(error) = result { @@ -1460,7 +1448,7 @@ impl State { /// Runs `choice` where it applies now. pub(crate) fn run(&mut self, choice: Choice) -> Result<(), Box> { #[cfg(target_os = "macos")] - if !platform::commands_allowed(&choice) { + if !platform::commands_allowed() { return Ok(()); } if !self.status(&choice, &self.format_state()).enabled { @@ -1551,16 +1539,14 @@ impl State { }, ) } - Id::NewPage | Id::NewSubpage => { - let under = self - .session - .as_ref() - .filter(|_| id == Id::NewSubpage) - .map(|session| session.space); - self.new_page(under, "")?; - self.edited(Vec::new()); - return Ok(()); - } + Id::NewPage => Work::NewPage { + under: None, + title: String::new(), + }, + Id::NewSubpage => Work::NewPage { + under: self.session.as_ref().map(|session| session.space), + title: String::new(), + }, Id::PageVersions => { let session = self.session.as_ref().ok_or("No notebook is open")?; Work::History { @@ -1861,15 +1847,7 @@ impl State { return Ok(()); } }; - #[cfg(target_os = "macos")] - { - self.apply(response) - } - #[cfg(not(target_os = "macos"))] - { - self.commands.push(response); - Ok(()) - } + self.apply(response) } } diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 83b0ae8b2a372f083f8ea067113dcc495b382399..ca9a567dd3418f0b68e3ab83b871b541f388515d 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -9,10 +9,9 @@ use objc2::{ sel, }; use objc2_app_kit::{ - NSAlert, NSAlertFirstButtonReturn, NSApplication, NSApplicationActivationPolicy, NSColor, - NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, - NSEventSubtype, NSEventType, NSMenu, NSMenuItem, NSModalResponse, NSModalResponseCancel, - NSModalResponseOK, NSSavePanel, NSWindow, + NSAlert, NSAlertFirstButtonReturn, NSApplication, NSColor, NSColorSpace, NSDatePicker, + NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventSubtype, NSEventType, NSMenu, + NSMenuItem, NSModalResponse, NSModalResponseCancel, NSModalResponseOK, NSSavePanel, NSWindow, }; use objc2_foundation::{ MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter, @@ -58,6 +57,8 @@ thread_local! { static IN_KEY_DOWN: Cell = const { Cell::new(false) }; static STATUSES: RefCell> = const { RefCell::new(Vec::new()) }; static TAGS_MENU: RefCell>> = const { RefCell::new(None) }; + /// Keys pressed after a menu command was sent and before the window ran it. + static HELD: RefCell>>> = const { RefCell::new(None) }; } unsafe extern "C" fn key_down(view: &AnyObject, _: Sel, event: &NSEvent) { @@ -1455,42 +1456,60 @@ pub fn text_colors(window: &Window) -> [[f32; 4]; 3] { } } -pub(crate) fn commands_allowed(choice: &commands::Choice) -> bool { - if matches!( - choice, - commands::Choice::Command(commands::Id::Settings | commands::Id::CheckForUpdates) - ) { - return true; - } +/// Whether the window takes commands: not while a native dialog is up over it. +pub(crate) fn commands_allowed() -> bool { let app = NSApplication::sharedApplication( MainThreadMarker::new().expect("Commands run on the main thread"), ); - WINDOW.with_borrow(|window| { - let Some(window) = window else { return false }; - unsafe { - if app.modalWindow().is_some() || window.attachedSheet().is_some() { - return false; - } - match app.keyWindow() { - Some(key) => std::ptr::eq(&*key, &**window), - // Hidden screenshot replays have no key window and cannot activate. - None => { - !window.isVisible() - && app.activationPolicy() == NSApplicationActivationPolicy::Prohibited - } - } - } + WINDOW.with_borrow(|window| unsafe { + app.modalWindow().is_none() + && window + .as_ref() + .is_none_or(|window| window.attachedSheet().is_none()) }) } -fn send_choice(choice: commands::Choice) { - if commands_allowed(&choice) - && let Some(proxy) = PROXY.get() +/// Sends a menu's `choice` to the window, the keys pressed after it held until it runs. +fn send(choice: commands::Choice) { + if let Some(proxy) = PROXY.get() + && proxy.send_event(crate::UserEvent::Choose(choice)).is_ok() { - let _ = proxy.send_event(crate::UserEvent::Choose(choice)); + HELD.with_borrow_mut(|held| { + held.get_or_insert_default(); + }); } } +/// Queues the keys held behind a menu command ahead of newer ones, as its choice arrives. +pub(crate) fn release_keys() { + let held = HELD.take().unwrap_or_default(); + let app = NSApplication::sharedApplication( + MainThreadMarker::new().expect("Choices arrive on the main thread"), + ); + for event in held.iter().rev() { + app.postEvent_atStart(event, true); + } +} + +/// The page's editing command `action` names while its window holds the keys. +fn page_editing(action: Sel) -> Option { + let app = NSApplication::sharedApplication(MainThreadMarker::new()?); + let key = app.keyWindow()?; + WINDOW + .with_borrow(|window| { + window + .as_ref() + .is_some_and(|window| std::ptr::eq(&*key, &**window)) + }) + .then(|| { + crate::menubar::editing() + .into_iter() + .find(|(_, editing)| *editing == action) + }) + .flatten() + .map(|(id, _)| commands::Choice::Command(id)) +} + declare_class!( struct CanvasApplication; @@ -1520,35 +1539,32 @@ declare_class!( fn choose(&self, sender: &NSMenuItem) { let tag = usize::try_from(unsafe { sender.tag() }).ok(); if let Some(choice) = tag.and_then(|tag| commands::choices().nth(tag)) { - send_choice(choice); + send(choice); } } - #[method(cut:)] - fn cut(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Cut)); } - - #[method(copy:)] - fn copy(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Copy)); } - - #[method(paste:)] - fn paste(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Paste)); } - - #[method(selectAll:)] - fn select_all(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::SelectAll)); } - - #[method(undo:)] - fn undo(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Undo)); } + // Ahead of the window, whose own undo manager would otherwise claim Undo and Redo. + #[method(targetForAction:to:from:)] + fn target_for_action(&self, action: Sel, target: *mut AnyObject, sender: *mut AnyObject) -> *mut AnyObject { + if target.is_null() && page_editing(action).is_some() { + return self as *const Self as *mut AnyObject; + } + unsafe { msg_send![super(self), targetForAction: action, to: target, from: sender] } + } - #[method(redo:)] - fn redo(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Redo)); } + #[method(sendAction:to:from:)] + fn send_action(&self, action: Sel, target: *mut AnyObject, sender: *mut AnyObject) -> objc2::runtime::Bool { + if target.is_null() && let Some(choice) = page_editing(action) { + send(choice); + return objc2::runtime::Bool::YES; + } + unsafe { msg_send![super(self), sendAction: action, to: target, from: sender] } + } #[method(validateMenuItem:)] fn validate_menu_item(&self, item: &NSMenuItem) -> objc2::runtime::Bool { - let choice = usize::try_from(unsafe { item.tag() }).ok().and_then(|tag| commands::choices().nth(tag)); let action = unsafe { item.action() }; - if action != Some(sel!(choose:)) - && !(action.is_some() && choice.as_ref().is_some_and(|choice| crate::menubar::editing_action(choice) == action)) - { + if action != Some(sel!(choose:)) && action.and_then(page_editing).is_none() { // AppKit's own items validate as NSApplication does. return objc2::runtime::Bool::new( !NSApplication::class().responds_to(sel!(validateMenuItem:)) @@ -1559,7 +1575,7 @@ declare_class!( STATUSES.with_borrow(|statuses| statuses.get(tag).copied()) }).unwrap_or_default(); unsafe { item.setState(isize::from(status.checked == Some(true))) }; - objc2::runtime::Bool::new(status.enabled && choice.as_ref().is_some_and(commands_allowed)) + objc2::runtime::Bool::new(status.enabled && commands_allowed()) } // NSAlert consumes Escape before it reaches cancelOperation:, and 10.6's date @@ -1567,6 +1583,13 @@ declare_class!( #[method(sendEvent:)] fn send_event(&self, event: &NSEvent) { unsafe { + if matches!(event.r#type(), NSEventType::KeyDown | NSEventType::KeyUp | NSEventType::FlagsChanged) + && self.modalWindow().is_none() + && HELD.with_borrow_mut(|held| held.as_mut().map(|held| held.push(event.retain()))) + .is_some() + { + return; + } let key = (event.r#type() == NSEventType::KeyDown) .then(|| event.charactersIgnoringModifiers()) .flatten() diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 24842777522a09c5cf94da3e02bbac1cbf797b88..f79dd3074b10b5a70eaf5435f5c51ab69ecdd4a4 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -237,7 +237,8 @@ type Continuation = Box Result<(), Box> + S /// one is open; one cancelled drops its reply unanswered. struct Reply(Box); -/// Open Notebook blocks target shortcuts until its reply is consumed or cancelled. +/// Open Notebook asking. Until it answers or is cancelled the window takes no commands, since +/// its dialog shows a moment later, and the portal's beside the window rather than over it. #[cfg(any(windows, target_os = "linux"))] struct Prompt(EventLoopProxy); @@ -707,7 +708,8 @@ enum Laid { Library(Arc, Option), } -/// A page ready for adoption, with the scene and editor laid out together. +/// A page read and laid out, which `open_loaded` holds until the pictures it shows first +/// are drawn, so it never appears without them. struct Opening { loaded: Shown, scene: (PageScene, [f32; 2]), @@ -4301,8 +4303,11 @@ impl State { .collect(), }, )?; - // The page list shows the title as it is typed. - if self.view.editor.active_outline().title { + // The page list shows the title as it is typed, even when the caret left it since. + let title = (self.view.editor.outlines().iter()) + .find(|outline| outline.title) + .map(canvas::editor::TextOutline::shown_text); + if title.is_some_and(|title| title != session.title()) { session.pages = session.section.pages()?; } self.edited(vec![space]); @@ -6582,6 +6587,10 @@ const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3); #[cfg(not(target_arch = "wasm32"))] impl ApplicationHandler for App { fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { + #[cfg(target_os = "macos")] + if matches!(event, UserEvent::Choose(_)) { + platform::release_keys(); + } let Some(state) = &mut self.state else { match event { UserEvent::Quit => self.close(event_loop), diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index a97f74044a0693d54ae6bf302fe14b2205c06776..c05697f6790fb64f2292d8dd80300ca273c87b30 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -218,11 +218,12 @@ impl State { space, before, level, )?]))); } - let session = self.session.as_mut().ok_or("No section is open")?; + let session = self.session.as_ref().ok_or("No section is open")?; let edit = Edit { at: crate::filetime(), ops, }; + // A section being rebuilt answers reads from before the page was made. #[cfg(not(target_arch = "wasm32"))] session.section.replica().apply(&self.author, edit)?; #[cfg(target_arch = "wasm32")] @@ -233,26 +234,18 @@ impl State { show: Some(shown), created: true, }); - let session = self.session.as_mut().ok_or("No section is open")?; - session.pages = session.section.pages()?; - let page = session.section.replica().page(space)?; + let session = self.session.as_ref().ok_or("No section is open")?; + let page = session.section.page(space)?; let (scene, editor) = canvas::gpu::page::PageScene::from_page(page, &mut self.view.engine)?; + self.stop_loading(); let now = crate::Instant::now(); - let shown = self.show_page(crate::Opening { + self.show_page(crate::Opening { loaded: crate::Shown::Page(space), scene: (scene, [0.0; 2]), editor, since: now, requested: now, - }); - if self - .session - .as_ref() - .is_some_and(|session| session.space == space) - { - self.stop_loading(); - } - shown?; + })?; self.ui.set_focus(Some(crate::page())); if let Some(title) = self .view diff --git a/crates/snowbound/src/menubar.rs b/crates/snowbound/src/menubar.rs index 0902aa5c0a966afb58d313cab52841f8afc5cbe6..b432d4204a02591d5c487917b692febd48b92bb9 100644 --- a/crates/snowbound/src/menubar.rs +++ b/crates/snowbound/src/menubar.rs @@ -239,7 +239,8 @@ pub struct Menus { pub tags: Retained, } -/// Builds the bar; native editing follows the responder chain before the application. +/// Builds the bar; the table's items send `choose:` to `target`, which validates them, and +/// the editing items go up the responder chain, which `target` heads while the page has the keys. pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus { let services = NSMenu::new(mtm); let bar = NSMenu::new(mtm); @@ -270,16 +271,25 @@ pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus { } } -pub(crate) fn editing_action(choice: &Choice) -> Option { - Some(match choice { - Choice::Command(Id::Cut) => objc2::sel!(cut:), - Choice::Command(Id::Copy) => objc2::sel!(copy:), - Choice::Command(Id::Paste) => objc2::sel!(paste:), - Choice::Command(Id::SelectAll) => objc2::sel!(selectAll:), - Choice::Command(Id::Undo) => objc2::sel!(undo:), - Choice::Command(Id::Redo) => objc2::sel!(redo:), - _ => return None, - }) +/// The editing commands whose items send AppKit's own actions, which a native dialog's fields +/// answer while it holds the keys. +pub(crate) fn editing() -> [(Id, Sel); 6] { + [ + (Id::Cut, objc2::sel!(cut:)), + (Id::Copy, objc2::sel!(copy:)), + (Id::Paste, objc2::sel!(paste:)), + (Id::SelectAll, objc2::sel!(selectAll:)), + (Id::Undo, objc2::sel!(undo:)), + (Id::Redo, objc2::sel!(redo:)), + ] +} + +/// The action `choice`'s item sends where it is an editing command. +fn editing_action(choice: &Choice) -> Option { + editing() + .into_iter() + .find(|(id, _)| *choice == Choice::Command(*id)) + .map(|(_, action)| action) } fn menu( @@ -468,9 +478,7 @@ mod tests { .filter(|action| *action != objc2::sel!(submenuAction:)) .map_or(String::new(), |action| { if action == objc2::sel!(choose:) - || commands::choices() - .nth(unsafe { item.tag() } as usize) - .is_some_and(|choice| editing_action(&choice) == Some(action)) + || editing().iter().any(|(_, editing)| *editing == action) { let tag = unsafe { item.tag() } as usize; let choice = commands::choices().nth(tag).expect("Tags index choices"); diff --git a/crates/snowbound/src/search.rs b/crates/snowbound/src/search.rs index 6940022e5d609d64f2cd2f3295653fc0a07716e5..19135283d94691d833e05b7f774179a42a3fa290 100644 --- a/crates/snowbound/src/search.rs +++ b/crates/snowbound/src/search.rs @@ -131,7 +131,7 @@ pub struct Search { pub(crate) open: bool, /// Where the scope menu opens. anchor: Option, - /// The find field, or the Search Results pane's, takes the keys once built. + /// The Search Results pane's field takes the keys once built. pub(crate) claim: bool, pub(crate) pane: Option, } @@ -1203,9 +1203,6 @@ impl State { }, ); name(&mut self.ui, find_field(), "Find on Page"); - if std::mem::take(&mut self.search.claim) { - self.ui.set_focus(Some(find_field())); - } let close = ui::shell::tool_button(&mut self.ui, "close", art::CLOSE, theme.text_dim, None).clicked; let button = self.ui.id("close");