| author | |
| committer | |
| log | c355a943bcb3d015015c97abff850d15d654252f |
| tree | 6d8635db36c12978a9318cc84fa145712742a462 |
| parent | 1b25afc5251a9d40a681b22186ce7b77ff9956ed |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
- 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.57 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] |
| 65 | libc = "0.2" | 65 | libc = "0.2" |
| 66 | objc2 = "0.5.2" | 66 | objc2 = "0.5.2" |
| 67 | 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"] } | 67 | 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"] } |
| 68 | 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"] } | 68 | 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"] } |
| 69 | block2 = "0.5.1" | 69 | block2 = "0.5.1" |
| 70 | 70 |
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 | } |
| 1081 | 1081 | ||
| 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 = self | 1543 | under: None, |
| 1556 | .session | 1544 | 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 | } |
| 1875 | 1853 |
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 | }; |
| 11 | use objc2_app_kit::{ | 11 | use 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 | }; |
| 17 | use objc2_foundation::{ | 16 | use 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 | } |
| 62 | 63 | ||
| 63 | unsafe extern "C" fn key_down(view: &AnyObject, _: Sel, event: &NSEvent) { | 64 | unsafe 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 | } |
| 1457 | 1458 | ||
| 1458 | pub(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!( | 1460 | pub(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 | } |
| 1485 | 1471 | ||
| 1486 | fn 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) | 1473 | fn 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. | ||
| 1484 | pub(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 | } |
| 1493 | 1493 | ||
| 1494 | /// The page's editing command `action` names while its window holds the keys. | ||
| 1495 | fn 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 | |||
| 1494 | declare_class!( | 1513 | declare_class!( |
| 1495 | struct CanvasApplication; | 1514 | struct CanvasApplication; |
| 1496 | 1515 | ||
| ... | @@ -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 | } |
| 1526 | 1545 | ||
| 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:)] |
| 1529 | 1548 | 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; |
| 1532 | 1551 | } | |
| 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)); } | ||
| 1541 | 1554 | ||
| 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 | } | ||
| 1544 | 1563 | ||
| 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 | } |
| 1564 | 1580 | ||
| 1565 | // NSAlert consumes Escape before it reaches cancelOperation:, and 10.6's date | 1581 | // 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. |
| 238 | struct Reply<T>(Box<dyn FnOnce(T) + Send>); | 238 | struct Reply<T>(Box<dyn FnOnce(T) + Send>); |
| 239 | 239 | ||
| 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"))] |
| 242 | struct Prompt(EventLoopProxy<UserEvent>); | 243 | struct Prompt(EventLoopProxy<UserEvent>); |
| 243 | 244 | ||
| ... | @@ -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 | } |
| 709 | 710 | ||
| 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. | ||
| 711 | struct Opening { | 713 | struct 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"))] |
| 6583 | impl ApplicationHandler<UserEvent> for App { | 6588 | impl 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) = self | 250 | if let Some(title) = self |
| 258 | .view | 251 | .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 | } |
| 241 | 241 | ||
| 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. | ||
| 243 | pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus { | 244 | pub 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 | } |
| 272 | 273 | ||
| 273 | pub(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:), | 276 | pub(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. | ||
| 288 | fn editing_action(choice: &Choice) -> Option<Sel> { | ||
| 289 | editing() | ||
| 290 | .into_iter() | ||
| 291 | .find(|(id, _)| *choice == Choice::Command(*id)) | ||
| 292 | .map(|(_, action)| action) | ||
| 283 | } | 293 | } |
| 284 | 294 | ||
| 285 | fn menu( | 295 | fn 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"); |