diff --git a/arc/platforms.md b/arc/platforms.md index f45d4b0f571d352b02117fd883b0b9b5cfd06d9d..5048ff83801a40e8abbc3d19f3ab0d84bd4dcf67 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -77,7 +77,9 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. popover menus on GNOME, shown and hidden at once as GTK 4 does, and Breeze's on KDE, faded as KWin fades popups and scaled by Plasma's animation speed. The desktop is read once from `XDG_CURRENT_DESKTOP`; elsewhere the kit's own. -- zenity or kdialog provide the pickers and alerts. The XDG settings portal +- The desktop portal provides the file pickers, zenity or kdialog the alerts, + and where neither can, Snowbound asks with the kit's own dialogs in the + window. No dialog waits on the event loop's thread. The XDG settings portal provides the colour scheme. Text conventions come from the C library's locale. Fontconfig is loaded at run time, so builds need no headers for it, and so is Enchant, which checks spelling with whatever dictionaries its diff --git a/arc/ui.md b/arc/ui.md index 2823a0eb21e759c3ceb7637b07d01cb76d917422..760c75ee2544003b1fc5c24c622dc4fa63ce4dbb 100644 --- a/arc/ui.md +++ b/arc/ui.md @@ -48,8 +48,9 @@ look and move as the desktop's own (see [platforms](platforms.md)). project aims further (Windows, and old versions of OS X). The platform still owns what it's best at and what people expect to be -native. That means file pickers, alerts and date pickers (AppKit on macOS, -zenity or kdialog on Linux), the caret and selection colours, each platform's +native. That means file pickers, alerts and date pickers (AppKit's sheets on +macOS; the portal, zenity or kdialog on Linux, else the kit's own), none of +which blocks the window, the caret and selection colours, each platform's editing chords, the keychain, the traffic lights and window frames. On iOS the split goes further (see [platforms](platforms.md)). diff --git a/crates/snowbound/linux/README.md b/crates/snowbound/linux/README.md index eec74ec345ced4b708b99cde92bab7a08bc0ee09..3664a45d8d0f5f575e798ed971c441c6f10e8800 100644 --- a/crates/snowbound/linux/README.md +++ b/crates/snowbound/linux/README.md @@ -42,7 +42,8 @@ Snowbound. - fontconfig, and X11 or Wayland with libxkbcommon. - The desktop's portal (xdg-desktop-portal) for choosing, printing and opening files. Questions, alerts and the page date and time use zenity or kdialog - where installed, and Snowbound's own dialogs otherwise. + where installed. Where none of these is, Snowbound asks, and chooses files, + with its own dialogs. ## Known limits diff --git a/crates/snowbound/src/attachment.rs b/crates/snowbound/src/attachment.rs index c2e32d4f92505b83372c1e3fa1a94030211c4596..91fdbe66229601fbc0b12b8d5bb177cb70646bcb 100644 --- a/crates/snowbound/src/attachment.rs +++ b/crates/snowbound/src/attachment.rs @@ -82,9 +82,9 @@ impl State { unstored(); return Ok(()); }; - if let Some(path) = platform::pick_new("Save As", &file.filename, "Save", None) { - notebook::fs::write(path, bytes)?; - } + let bytes = bytes.clone(); + let reply = self.reply(move |_, path| Ok(notebook::fs::write(path, bytes)?)); + platform::pick_new("Save As", &file.filename, "Save", None, reply); Ok(()) } } diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index a9a5f8ba18e7591ff318c4b08a670547c707b041..11594c38f40b653e98581acf5da8ee0902dd21f1 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -1558,17 +1558,16 @@ impl State { return Ok(()); } Id::Picture => { - let Some(path) = platform::pick_file("Insert Picture", &crate::PICTURE_TYPES) - else { - return Ok(()); - }; - return self.insert_picture(notebook::fs::read(path)?, None); + let reply = self.reply(|state, path: std::path::PathBuf| { + state.insert_picture(notebook::fs::read(path)?, None) + }); + platform::pick_file("Insert Picture", &crate::PICTURE_TYPES, reply); + return Ok(()); } Id::Attachment => { - let Some(path) = platform::pick_file("Attach File", &[]) else { - return Ok(()); - }; - return self.attach(&path, None); + let reply = self.reply(|state, path: std::path::PathBuf| state.attach(&path, None)); + platform::pick_file("Attach File", &[], reply); + return Ok(()); } Id::ScreenClipping => { #[cfg(target_os = "macos")] diff --git a/crates/snowbound/src/desktop_linux.rs b/crates/snowbound/src/desktop_linux.rs index 463e86fbdf2dd3f6084d7df8a9264083ae101159..02c8cf727a0c560bde387de8fafe0b6e0fa65d85 100644 --- a/crates/snowbound/src/desktop_linux.rs +++ b/crates/snowbound/src/desktop_linux.rs @@ -399,17 +399,31 @@ fn try_install() -> io::Result<()> { } /// After confirming, removes what Install wrote; notebooks stay where they are. -pub fn uninstall() { - let (Some(data), Some(binary)) = (data_home(), binary()) else { +pub fn uninstall(proxy: &winit::event_loop::EventLoopProxy) { + let Some(binary) = binary() else { return; }; let detail = format!( "Snowbound leaves the app menu and {} is deleted. Your notebooks stay where they are.", binary.display() ); - if !crate::platform::confirm("Uninstall Snowbound?", &detail, "Cancel", "Uninstall") { + let reply = crate::Reply::new(proxy, |_, ()| { + remove(); + Ok(()) + }); + crate::platform::confirm( + "Uninstall Snowbound?", + &detail, + "Cancel", + "Uninstall", + reply, + ); +} + +fn remove() { + let (Some(data), Some(binary)) = (data_home(), binary()) else { return; - } + }; let mut failed = None; let icons = SIZES.map(|side| theme_icon(&data, side)); if let Some(staged) = crate::update::staging(&binary) diff --git a/crates/snowbound/src/dialog_linux.rs b/crates/snowbound/src/dialog_linux.rs index 18c80f84efd83d935f9939fea5fc25e49becdc99..42871f13d5d8d7d2566e2bf241a231545c824444 100644 --- a/crates/snowbound/src/dialog_linux.rs +++ b/crates/snowbound/src/dialog_linux.rs @@ -1,351 +1,387 @@ -//! Snowbound's own message, question and entry dialogs, for desktops with neither zenity nor -//! kdialog, as GNOME's image-based systems ship: the app runs itself as the dialog, which -//! answers on stdout and exits successfully once accepted. +//! Snowbound's own dialogs, drawn with the interface kit in the window, where no desktop +//! portal, zenity or kdialog can ask, as on GNOME's image-based systems or a bare X11 +//! desktop: messages, questions, a line of text, and a file chooser. -use draw::Renderer; -use std::{error::Error, ffi::OsString, process::Command, sync::Arc, time::Instant}; -use ui::{Axis, Id, Spec, Ui, children, fill, fit, px}; -use winit::{ - application::ApplicationHandler, - dpi::LogicalSize, - event::{ElementState, WindowEvent}, - event_loop::{ActiveEventLoop, EventLoop}, - keyboard::{Key, NamedKey}, - window::{Window, WindowId}, -}; +use crate::{Reply, State, UserEvent, art}; +use accesskit::Role; +use std::path::{Path, PathBuf}; +use ui::{Anchor, Axis, Flags, Id, Spec, children, fill, px}; +use winit::keyboard::NamedKey; -/// The argument that runs the app as a dialog. -pub const DIALOG: &str = "--dialog"; - -pub enum Ask<'a> { +/// What a dialog asks, and where its answer goes; one dismissed drops its reply. +pub enum Ask { /// A message with an OK button. Message, /// Whether to go ahead: buttons for `cancel` and for `action`. - Question { cancel: &'a str, action: &'a str }, - /// A line of text, starting as `value`. - Entry { value: &'a str }, - /// A user name, starting as `user`, and a password, answered as zenity does: `user|password`. - Login { user: &'a str }, + Question { + cancel: String, + action: String, + reply: Reply<()>, + }, + /// A line of text, starting as `text`. + Entry { text: String, reply: Reply }, + /// A file in `folder`: one of `types` (extensions) unless empty, to open; or with + /// `save`, a new one named `name`. + File { + folder: PathBuf, + types: Vec, + save: bool, + name: String, + reply: Reply, + }, } -/// Shows `title` and `detail` as `ask` asks, then waits: the answer, empty but for an entry, -/// or None when cancelled. -pub fn ask(title: &str, detail: &str, ask: Ask) -> Option { - let (kind, extra): (&str, &[&str]) = match &ask { - Ask::Message => ("message", &[]), - Ask::Question { cancel, action } => ("question", &[cancel, action]), - Ask::Entry { value } => ("entry", &[value]), - Ask::Login { user } => ("login", &[user]), - }; - let output = crate::loader::executable() - .and_then(|exe| { - Command::new(exe) - .args([DIALOG, kind, title, detail]) - .args(extra) - .stderr(std::process::Stdio::inherit()) - .output() - }) - .inspect_err(|error| eprintln!("Cannot show {title:?}: {error}")) - .ok()?; - output - .status - .success() - .then(|| String::from_utf8_lossy(&output.stdout).into_owned()) -} - -/// The dialog `ask` would show, given the arguments after `DIALOG`. -pub fn run(mut args: impl Iterator) -> Result<(), Box> { - let mut next = || { - args.next() - .and_then(|arg| arg.into_string().ok()) - .unwrap_or_default() - }; - let [kind, title, detail, first, second] = [(); 5].map(|()| next()); - let mut dialog = Dialog { - kind, - title, - detail, - buttons: [first, second], - fields: Default::default(), - shown: None, - accepted: false, - }; - if dialog.kind == "entry" || dialog.kind == "login" { - dialog.fields[0] = std::mem::take(&mut dialog.buttons[0]); - } - EventLoop::new()?.run_app(&mut dialog)?; - if !dialog.accepted { - std::process::exit(1); - } - let [first, second] = &dialog.fields; - match dialog.kind.as_str() { - "entry" => print!("{first}"), - "login" => print!("{first}|{second}"), - _ => {} - } - Ok(()) -} - -struct Dialog { - kind: String, +/// A dialog waiting its turn, or shown. +pub struct Dialog { title: String, detail: String, - /// A question's cancel and action labels. - buttons: [String; 2], - /// An entry's text, or a login's user name and password. - fields: [String; 2], - shown: Option, - accepted: bool, + ask: Ask, + /// A file chooser's folder: its folders, then the files it offers, each with whether it + /// is a folder; or why the folder can't be read. + entries: Result, String>, + shown: bool, } -struct Shown { - window: Arc, - surface: crate::surface::Surface, - renderer: Renderer, - ui: Ui, -} - -fn field(index: usize) -> Id { - Id::ROOT.child(("field", index)) -} - -impl Dialog { - fn finish(&mut self, event_loop: &ActiveEventLoop, accepted: bool) { - self.accepted = accepted; - // The window's resources end while the event loop still holds its display. - self.shown = None; - event_loop.exit(); +/// Shows `ask`, titled `title` and explained by `detail`, once the dialogs before it are +/// answered. Any thread may ask. +pub fn show(title: String, detail: String, ask: Ask) { + let entries = match &ask { + Ask::File { folder, types, .. } => listing(folder, types), + _ => Ok(Vec::new()), + }; + let dialog = Dialog { + title, + detail, + ask, + entries, + shown: false, + }; + if let Some(proxy) = crate::platform::PROXY.get() { + let _ = proxy.send_event(UserEvent::Then(Box::new(move |state| { + state.asking.push_back(dialog); + Ok(()) + }))); } +} - /// Lays out and paints the dialog; Some once a button answers it. - fn frame(&mut self) -> Result, Box> { - let Some(shown) = &mut self.shown else { - return Ok(None); +/// The folder a file chooser starts in where it is given none. +pub fn start_folder() -> PathBuf { + crate::platform::documents_dir() + .filter(|folder| folder.is_dir()) + .or_else(|| std::env::var_os("HOME").map(PathBuf::from)) + .unwrap_or_else(|| "/".into()) +} + +/// `folder`'s folders, then its files of `types` (extensions; all where empty), each by +/// name without regard to case; hidden ones left out. +fn listing(folder: &Path, types: &[String]) -> Result, String> { + let mut entries: Vec<(String, bool)> = std::fs::read_dir(folder) + .map_err(|error| format!("Couldn't read {}: {error}", folder.display()))? + .filter_map(|entry| { + let entry = entry.ok()?; + let name = entry.file_name().into_string().ok()?; + let is_folder = entry.path().is_dir(); + let offered = is_folder + || types.is_empty() + || Path::new(&name) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + types + .iter() + .any(|kind| kind.eq_ignore_ascii_case(extension)) + }); + (offered && !name.starts_with('.')).then_some((name, is_folder)) + }) + .collect(); + entries.sort_by_cached_key(|(name, is_folder)| (!is_folder, name.to_lowercase())); + Ok(entries) +} + +fn id() -> Id { + Id::ROOT.child("own dialog") +} + +fn field() -> Id { + id().child("field") +} + +/// How the user answered a dialog this frame. +enum Answer { + Cancel, + Accept, + /// A file chooser moves to this folder. + Open(PathBuf), + /// A file chooser's file to open, or the name to save as. + Choose(String), +} + +impl State { + /// Builds the first dialog waiting, and answers it once the user does. + pub(crate) fn own_dialog(&mut self) { + let Some(dialog) = self.asking.front_mut() else { + return; }; - let ui = &mut shown.ui; - let scale = shown.window.scale_factor() as f32; - let size = shown.window.inner_size(); - ui.begin( - [size.width as f32 / scale, size.height as f32 / scale], - scale, - Instant::now(), - ); + let ui = &mut self.ui; + if !dialog.shown { + dialog.shown = true; + ui.open_popup(id()); + if let Ask::Entry { .. } | Ask::File { save: true, .. } = dialog.ask { + ui.focus_all(field()); + } + } else if !ui.popup_open(id()) { + self.asking.pop_front(); + return; + } let theme = ui.theme.clone(); - ui.open( - "dialog", + let row = theme.font_size * 2.0; + let entered = ui::popup::navigation(ui, &[id(), field()], &[NamedKey::Enter]) + .contains(&NamedKey::Enter); + let file = matches!(dialog.ask, Ask::File { .. }); + ui.open_as( + id(), Spec { axis: Axis::Y, - size: [fill(), fill()], + size: [px(if file { 480.0 } else { 420.0 }), children()], fill: Some(theme.popup), - pad: [20.0, 16.0], - gap: 10.0, - role: Some(accesskit::Role::Dialog), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 8.0, + pad: [16.0, 12.0], + gap: 6.0, + anchor: Some(Anchor::Dialog), + role: Some(Role::Dialog), ..Spec::default() }, ); - let text = |ui: &mut Ui, part, text: &str, bold| { + if let Some(node) = ui.access(id()) { + node.set_label(dialog.title.as_str()); + } + let text = |ui: &mut ui::Ui, part, text: &str, bold: bool, color| { ui.leaf( part, Spec { - size: [fill(), fit()], + size: [fill(), ui::fit()], text: Some(text), bold, + color, overflow: ui::Overflow::Wrap, + role: bold.then_some(Role::Heading), ..Spec::default() }, ); }; - text(ui, "title", &self.title, true); - if !self.detail.is_empty() { - text(ui, "detail", &self.detail, false); + text(ui, "title", &dialog.title, true, None); + if !dialog.detail.is_empty() { + text(ui, "detail", &dialog.detail, false, None); } - let entry = Spec { - size: [fill(), px(theme.font_size * 2.0)], + let field_spec = Spec { + size: [fill(), px(row)], fill: Some(theme.base), border: Some(theme.accent), radius: 4.0, pad: [6.0, 0.0], ..Spec::default() }; - let fields = match self.kind.as_str() { - "entry" => 1, - "login" => 2, - _ => 0, - }; - if fields > 0 && ui.focused().is_none() { - ui.set_focus(Some(field(0))); - } - for (index, value) in self.fields.iter_mut().take(fields).enumerate() { - if index == 1 { - text(ui, "password label", "Password:", false); - ui::password_field(ui, field(index), value, "", entry.clone()); - } else { - ui::text_field(ui, field(index), value, "", entry.clone()); + // Return goes ahead, but for a question, whose safe answer is the default as + // AppKit's and Windows' are. + let mut answer = entered.then_some(match dialog.ask { + Ask::Question { .. } => Answer::Cancel, + _ => Answer::Accept, + }); + match &mut dialog.ask { + Ask::Entry { text, .. } => { + ui::text_field(ui, field(), text, "", field_spec); + crate::name(ui, field(), &dialog.title); + } + Ask::File { + folder, save, name, .. + } => { + text( + ui, + "place", + &folder.to_string_lossy(), + false, + Some(theme.text_dim), + ); + let list = ui.open( + "entries", + Spec { + flags: Flags::SCROLL | Flags::CLIP, + axis: Axis::Y, + size: [fill(), px(row * 10.0 + 8.0)], + fill: Some(theme.base), + border: Some(theme.chip), + radius: 4.0, + pad: [4.0, 4.0], + role: Some(Role::List), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(list) { + node.set_label("Files"); + } + match &dialog.entries { + Ok(entries) => { + for (entry, is_folder) in entries { + let signal = ui.leaf( + ("entry", entry), + Spec { + flags: Flags::CLICKABLE, + size: [fill(), px(row)], + icon: Some(if *is_folder { art::FOLDER } else { art::PAGE }), + text: Some(entry), + fill: (!is_folder && entry == name).then_some(theme.chip), + hover_fill: Some(theme.hover()), + radius: 4.0, + pad: [8.0, 0.0], + gap: 6.0, + role: Some(Role::ListItem), + ..Spec::default() + }, + ); + if *is_folder && signal.clicked { + answer = Some(Answer::Open(folder.join(entry))); + } else if !is_folder && signal.pressed { + // A double click opens or saves as the file. + answer = + Some(if signal.unit == draw::edit::SelectionUnit::Grapheme { + Answer::Choose(entry.clone()) + } else { + Answer::Accept + }); + } + } + } + Err(reason) => text(ui, "unread", reason, false, Some(theme.text_dim)), + } + ui.close(); + if *save { + ui::text_field(ui, field(), name, "", field_spec); + crate::name(ui, field(), "Name"); + } } + Ask::Message | Ask::Question { .. } => {} } - ui.leaf( - "space", - Spec { - size: [fill(), fill()], - ..Spec::default() - }, - ); ui.open( "buttons", Spec { size: [fill(), children()], + pad: [0.0, 8.0], gap: 8.0, ..Spec::default() }, ); + if let Ask::File { folder, .. } = &dialog.ask + && let Some(parent) = folder.parent() + && ui::button(ui, "up", "Up").clicked + { + answer = Some(Answer::Open(parent.to_owned())); + } ui.leaf( "space", Spec { - size: [fill(), px(0.0)], + size: [fill(), px(1.0)], ..Spec::default() }, ); - let [cancel, action] = match self.kind.as_str() { - "question" => [self.buttons[0].as_str(), self.buttons[1].as_str()], - "message" => ["", "OK"], - _ => ["Cancel", "OK"], + let (cancel, action) = match &dialog.ask { + Ask::Message => (None, "OK"), + Ask::Question { cancel, action, .. } => (Some(cancel.as_str()), action.as_str()), + Ask::Entry { .. } => (Some("Cancel"), "OK"), + Ask::File { save: false, .. } => (Some("Cancel"), "Open"), + Ask::File { save: true, .. } => (Some("Cancel"), "Save"), }; - let mut answer = None; - if !cancel.is_empty() && ui::button(ui, "cancel", cancel).clicked { - answer = Some(false); + if cancel.is_some_and(|cancel| ui::button(ui, "cancel", cancel).clicked) { + answer = Some(Answer::Cancel); } if ui::button(ui, "action", action).clicked { - answer = Some(true); + answer = Some(Answer::Accept); } ui.close(); ui.close(); - ui.end(); - shown.window.set_cursor(ui.cursor().unwrap_or_default()); - if let Some(frame) = shown.surface.frame(&shown.renderer)? { - let interface = ui.layers(); - let layers: Vec<_> = interface - .iter() - .filter_map(|layer| match layer { - ui::Layer::Primitives(primitives) => Some(primitives.layer(scale)), - ui::Layer::Custom { .. } => None, - }) - .collect(); - shown - .renderer - .draw(&frame.target, shown.surface.size, theme.popup, &layers) - .map_err(|error| format!("Dialog drawing failed: {error:?}"))?; - shown.surface.present(&shown.renderer, frame); - } - if ui.wants_frame() { - shown.window.request_redraw(); - } - Ok(answer) - } -} - -impl ApplicationHandler for Dialog { - fn resumed(&mut self, event_loop: &ActiveEventLoop) { - if self.shown.is_some() { - return; - } - let height = match self.kind.as_str() { - "login" => 260.0, - "entry" => 200.0, - _ => 170.0, - }; - let shown = (|| -> Result> { - let window = Arc::new( - event_loop.create_window( - crate::platform::window_attributes() - .with_title(&self.title) - .with_inner_size(LogicalSize::new(440.0, height)) - .with_resizable(false), - )?, - ); - let (surface, renderer) = - pollster::block_on(crate::surface::Surface::new(window.clone(), false))?; - let appearance = crate::platform::appearance(&window); - window.set_theme(Some(appearance)); - let ui = Ui::new( - crate::theme(appearance, false, false), - crate::platform::double_click_interval(), - ); - Ok(Shown { - window, - surface, - renderer, - ui, - }) - })(); - match shown { - Ok(shown) => { - shown.window.request_redraw(); - self.shown = Some(shown); - } - Err(error) => { - eprintln!("Cannot show {:?}: {error}", self.title); - self.finish(event_loop, false); - } - } - } - fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) { - let Some(shown) = &mut self.shown else { - return; - }; - let scale = shown.window.scale_factor() as f32; - let input = match event { - WindowEvent::CloseRequested => return self.finish(event_loop, false), - WindowEvent::RedrawRequested => { - match self.frame() { - Ok(Some(answer)) => self.finish(event_loop, answer), - Ok(None) => {} - Err(error) => { - eprintln!("{error}"); - self.finish(event_loop, false); + if let Ask::File { + folder, + types, + save, + name, + .. + } = &mut dialog.ask + { + match answer { + Some(Answer::Open(to)) => { + dialog.entries = listing(&to, types); + *folder = to; + if !*save { + name.clear(); } + return; } - return; - } - WindowEvent::Resized(size) => { - shown.surface.size = [size.width, size.height]; - shown.surface.configure(&shown.renderer); - return shown.window.request_redraw(); - } - WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => { - match event.logical_key { - Key::Named(NamedKey::Enter) => return self.finish(event_loop, true), - Key::Named(NamedKey::Escape) => return self.finish(event_loop, false), - key => ui::Event::Key { - key, - text: event.text.map(|text| text.to_string()), - }, - } - } - WindowEvent::ModifiersChanged(modifiers) => ui::Event::Modifiers(modifiers.state()), - WindowEvent::Ime(ime) => ui::Event::Ime(ime), - WindowEvent::CursorMoved { position, .. } => { - ui::Event::PointerMoved([position.x as f32 / scale, position.y as f32 / scale]) - } - WindowEvent::CursorLeft { .. } => ui::Event::PointerLeft, - WindowEvent::MouseInput { state, button, .. } => { - // A press away from the controls moves the window, which has no title bar - // under winit's Adwaita frame. - if state == ElementState::Pressed - && shown.ui.cursor().is_none() - && let Err(error) = shown.window.drag_window() - { - eprintln!("{error}"); + Some(Answer::Choose(entry)) => { + *name = entry; + return; } - ui::Event::Button { - button, - pressed: state == ElementState::Pressed, - at: Instant::now(), + Some(Answer::Accept) if name.trim().is_empty() => return, + // A folder typed in opens. + Some(Answer::Accept) if folder.join(name.trim()).is_dir() => { + let to = folder.join(name.trim()); + dialog.entries = listing(&to, types); + *folder = to; + name.clear(); + return; } + _ => {} } - _ => return, + } + let Some(answer) = answer else { + return; }; - shown.ui.event(input); - shown.window.request_redraw(); + let dialog = self.asking.pop_front().expect("A dialog is shown"); + self.ui.close_popup(id()); + if let Answer::Cancel = answer { + return; + } + match dialog.ask { + Ask::Message => {} + Ask::Question { reply, .. } => reply.send(()), + Ask::Entry { text, reply } => reply.send(text), + Ask::File { + folder, + name, + reply, + .. + } => reply.send(folder.join(name.trim())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_file_chooser_lists_folders_then_the_files_it_offers() { + let folder = std::env::temp_dir().join(format!("snowbound-own-{}", std::process::id())); + for name in ["b", "A", ".hidden"] { + std::fs::create_dir_all(folder.join(name)).unwrap(); + } + for name in ["z.one", "Notes.ONETOC2", "picture.png", ".x.one"] { + std::fs::write(folder.join(name), b"").unwrap(); + } + let types = ["one", "onetoc2"].map(String::from); + let expected = [ + ("A", true), + ("b", true), + ("Notes.ONETOC2", false), + ("z.one", false), + ]; + assert_eq!( + listing(&folder, &types).unwrap(), + expected.map(|(name, is_folder)| (name.to_owned(), is_folder)) + ); + assert_eq!(listing(&folder, &[]).unwrap().len(), 5); + assert!(listing(&folder.join("missing"), &[]).is_err()); + std::fs::remove_dir_all(&folder).unwrap(); } } diff --git a/crates/snowbound/src/history.rs b/crates/snowbound/src/history.rs index 3c28e016a2403d97f90ace7cf1b59e2deb0b5db9..d45d5b5ded4c2e3f6b09fc799bd1d0632b234d06 100644 --- a/crates/snowbound/src/history.rs +++ b/crates/snowbound/src/history.rs @@ -188,7 +188,7 @@ impl State { /// Delete All Versions in the section, its group or the notebook, once confirmed. pub(crate) fn delete_all_versions(&mut self, scope: Scope) -> Result<(), Box> { - 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 tab = &session.tabs[session.tab]; let (open, section) = (tab.path.clone(), tab.name.clone()); let folder = open @@ -203,14 +203,31 @@ impl State { ), Scope::Notebook => ("notebook", session.library.name.clone()), }; - if !platform::confirm( + let asked = Arc::clone(&session.library); + platform::confirm( &format!("Do you want to delete all page versions in the {container} \"{name}\"?"), "You can't restore these versions afterward.", "Cancel", "Delete Versions", - ) { + self.reply(move |state, ()| state.clear_versions(scope, &asked, open, folder)), + ); + Ok(()) + } + + /// Deletes every page version in `scope` around `open`, the section in `library` that + /// was shown when asked; nothing once another is shown. + fn clear_versions( + &mut self, + scope: Scope, + library: &Arc, + open: String, + folder: String, + ) -> Result<(), Box> { + let Some(session) = self.session.as_mut().filter(|session| { + Arc::ptr_eq(&session.library, library) && session.tabs[session.tab].path == open + }) else { return Ok(()); - } + }; clear(&session.section)?; session.refresh_history()?; if session.version.is_some() { diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index cc9d510e5844fac9a7182f6bafd289e9afaba2e1..78aee94627ac8b197ba77c3305f0a8612be3e9db 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -3,13 +3,13 @@ //! the colour scheme from the settings portal, and text conventions from the C library's //! locale. -use crate::dialog::Ask; +use crate::{Reply, dialog::Ask}; use canvas::date::DateField; use std::{ cell::UnsafeCell, collections::HashMap, error::Error, - ffi::{CStr, CString, OsString, c_char, c_void}, + ffi::{CStr, CString, OsStr, OsString, c_char, c_void}, os::unix::ffi::OsStringExt, path::PathBuf, process::Command, @@ -42,7 +42,8 @@ const MINIMIZE: &[&str] = &[include_str!("../assets/icons/window-minimize.svg")] const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")]; const RESTORE: &[&str] = &[include_str!("../assets/icons/window-restore.svg")]; -static QUIT: OnceLock> = OnceLock::new(); +/// The event loop's, for signals and Snowbound's own dialogs. +pub(crate) static PROXY: OnceLock> = OnceLock::new(); /// Breeze's corner radius in units of the decoration's pixel grid, as `f32` bits; zero where /// KWin leaves windows square. static BREEZE_RADIUS: AtomicU32 = AtomicU32::new(0); @@ -77,7 +78,8 @@ pub fn event_loop(headless: bool) -> Result, Box window.set_minimized(true), "maximize" => zoom(window), _ => { - if let Some(proxy) = QUIT.get() { + if let Some(proxy) = PROXY.get() { let _ = proxy.send_event(crate::UserEvent::Quit); } } @@ -1199,7 +1201,7 @@ pub fn smb_mount(path: &std::path::Path) -> Option { } /// The password the Secret Service keeps for `mount`'s account, as GNOME's file manager -/// saves one, or what the user types when it keeps none. +/// saves one; without one, Snowbound's sign-in asks. pub fn smb_login(mount: &crate::library::Mount) -> Result { let user = mount.user.clone().unwrap_or_default(); let mut lookup = Command::new("secret-tool"); @@ -1219,24 +1221,10 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result [String; 2] { date_labels(&local_time(filetime)) } -/// Asks for the page's date or time with the desktop's dialog tool. +/// Asks for the page's date or time with the desktop's dialog tool, or else as text in +/// Snowbound's own dialog. pub fn edit_date( timestamp: u64, field: DateField, title: &str, -) -> Result, &'static str> { - let tm = local_time(timestamp); - let answer = match field { - DateField::Date => dialog( - [ - "--calendar", - &format!("--title={title}"), - "--text=", - &format!("--day={}", tm.tm_mday), - &format!("--month={}", tm.tm_mon + 1), - &format!("--year={}", tm.tm_year + 1900), - "--date-format=%Y-%m-%d", - ], - ["--calendar", title, "--dateformat", "yyyy-MM-dd"], - ) - .unwrap_or_else(|_| { - let value = format(&tm, c"%Y-%m-%d"); - crate::dialog::ask( - title, + reply: Reply>, +) { + let title = title.to_owned(); + let reply = reply + .map(move |answer: String| changed_date(timestamp, local_time(timestamp), field, &answer)); + std::thread::spawn(move || { + let tm = local_time(timestamp); + let (asked, detail, value) = match field { + DateField::Date => ( + dialog( + &[ + "--calendar", + &format!("--title={title}"), + "--text=", + &format!("--day={}", tm.tm_mday), + &format!("--month={}", tm.tm_mon + 1), + &format!("--year={}", tm.tm_year + 1900), + "--date-format=%Y-%m-%d", + ], + &["--calendar", &title, "--dateformat", "yyyy-MM-dd"], + ), "Date, as year-month-day:", - Ask::Entry { value: &value }, - ) - }), - DateField::Time => { - let current = format(&tm, c"%H:%M"); - dialog( - [ - "--entry", - &format!("--title={title}"), - "--text=Time, as hours and minutes:", - &format!("--entry-text={current}"), - ], - ["--inputbox", "Time, as hours and minutes:", ¤t], - ) - .unwrap_or_else(|_| { - let value = Ask::Entry { value: ¤t }; - crate::dialog::ask(title, "Time, as hours and minutes:", value) - }) + format(&tm, c"%Y-%m-%d"), + ), + DateField::Time => { + let current = format(&tm, c"%H:%M"); + let asked = dialog( + &[ + "--entry", + &format!("--title={title}"), + "--text=Time, as hours and minutes:", + &format!("--entry-text={current}"), + ], + &["--inputbox", "Time, as hours and minutes:", ¤t], + ); + (asked, "Time, as hours and minutes:", current) + } + }; + match asked { + Ok(Some(answer)) => reply.send(answer), + Ok(None) => {} + Err(_) => { + let ask = Ask::Entry { text: value, reply }; + crate::dialog::show(title, detail.to_owned(), ask); + } } - }; - let Some(answer) = answer else { - return Ok(None); - }; + }); +} + +/// `timestamp` with `field` changed to what the user typed or chose, `answer`. +fn changed_date( + timestamp: u64, + tm: libc::tm, + field: DateField, + answer: &str, +) -> Result<(u64, [String; 2]), &'static str> { let numbers: Vec = answer .split(|c: char| !c.is_ascii_digit()) .filter(|part| !part.is_empty()) .map(|part| part.parse().unwrap_or(i32::MAX)) .collect(); let afternoon = answer.to_lowercase().contains("pm"); - merge_date(timestamp, tm, field, &numbers, afternoon).map(Some) + merge_date(timestamp, tm, field, &numbers, afternoon) } fn merge_date( @@ -1497,36 +1499,24 @@ fn merge_date( Ok((updated, date_labels(&tm))) } -/// Runs zenity, or kdialog where zenity is missing; the answer is None when cancelled, and an -/// error when neither is installed. -fn dialog( - zenity: [&str; Z], - kdialog: [&str; K], -) -> Result, &'static str> { +/// Runs zenity, or kdialog where zenity is missing, waiting for the answer: None when +/// cancelled, and an error when neither is installed. +fn dialog( + zenity: &[impl AsRef], + kdialog: &[impl AsRef], +) -> std::io::Result> { let output = Command::new("zenity") .args(zenity) .output() - .or_else(|_| Command::new("kdialog").args(kdialog).output()) - .map_err(|_| "Install zenity or kdialog for Snowbound's dialogs.")?; + .or_else(|_| Command::new("kdialog").args(kdialog).output())?; Ok(output .status .success() .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())) } -/// The path a file dialog answered; none when cancelled or when no tool could ask. -fn picked(asked: Result, &str>) -> Option { - asked - .unwrap_or_else(|error| { - eprintln!("{error}"); - None - }) - .filter(|path| !path.is_empty()) - .map(PathBuf::from) -} - /// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. -pub fn pick_file(title: &str, types: &[&str]) -> Option { +pub fn pick_file(title: &str, types: &[&str], reply: Reply) { let globs: Vec<_> = types.iter().map(|kind| format!("*.{kind}")).collect(); let patterns = match types { [] => "*".to_owned(), @@ -1536,17 +1526,26 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option { if !globs.is_empty() { options.insert("filters", filters(&patterns, &globs)); } + let types = types.iter().map(|kind| kind.to_string()).collect(); choose( title, "OpenFile", options, - [ + &[ "--file-selection", &format!("--title={title}"), &format!("--file-filter={patterns}"), ], - ["--getopenfilename", ".", &patterns, "--title", title], - ) + &["--getopenfilename", ".", &patterns, "--title", title], + move |reply| Ask::File { + folder: crate::dialog::start_folder(), + types, + save: false, + name: String::new(), + reply, + }, + reply, + ); } /// A file chooser's `filters` option: one filter, `name`, of `globs`. @@ -1555,33 +1554,48 @@ fn filters(name: &str, globs: &[String]) -> Value<'static> { Value::from(vec![(name.to_owned(), globs)]) } -/// The file the file chooser portal answers `method` with, titled `title`, or where no portal -/// answers, zenity's or kdialog's; None when cancelled. -fn choose( +/// Replies with the file the file chooser portal answers `method` with, titled `title`; +/// where no portal answers, zenity's or kdialog's, given their arguments; and where neither +/// is installed, Snowbound's `own`. +fn choose( title: &str, - method: &str, - mut options: HashMap<&str, Value>, - zenity: [&str; Z], - kdialog: [&str; K], -) -> Option { - let chosen = portal("org.freedesktop.portal.FileChooser") - .map_err(Into::into) - .and_then(|chooser| { - portal_request(&chooser, |token| { - options.insert("handle_token", Value::from(token.to_owned())); - chooser.call(method, &("", title, &options)) - }) - }); - let results = match chosen { - Ok(results) => results?, - Err(error) => { - eprintln!("The file chooser portal failed: {error}"); - return picked(dialog(zenity, kdialog)); + method: &'static str, + mut options: HashMap<&'static str, Value<'static>>, + zenity: &[&str], + kdialog: &[&str], + own: impl FnOnce(Reply) -> Ask + Send + 'static, + reply: Reply, +) { + let title = title.to_owned(); + let [zenity, kdialog]: [Vec; 2] = + [zenity, kdialog].map(|args| args.iter().map(|arg| arg.to_string()).collect()); + std::thread::spawn(move || { + let chosen = portal("org.freedesktop.portal.FileChooser") + .map_err(Into::into) + .and_then(|chooser| { + portal_request(&chooser, |token| { + options.insert("handle_token", Value::from(token.to_owned())); + chooser.call(method, &("", &title, &options)) + }) + }); + let path = match chosen { + Ok(results) => results.and_then(|results| { + let uris: Vec = results.get("uris")?.try_clone().ok()?.try_into().ok()?; + let path = uris.first()?.strip_prefix("file://")?; + Some(OsString::from_vec(crate::paste::percent_decode(path)).into()) + }), + Err(error) => { + eprintln!("The file chooser portal failed: {error}"); + match dialog(&zenity, &kdialog) { + Ok(path) => path.filter(|path| !path.is_empty()).map(PathBuf::from), + Err(_) => return crate::dialog::show(title, String::new(), own(reply)), + } + } + }; + if let Some(path) = path { + reply.send(path); } - }; - let uris: Vec = results.get("uris")?.try_clone().ok()?.try_into().ok()?; - let path = uris.first()?.strip_prefix("file://")?; - Some(std::ffi::OsString::from_vec(crate::paste::percent_decode(path)).into()) + }); } /// The desktop portal's `interface`, whose calls give up after five seconds. @@ -1678,45 +1692,65 @@ pub fn reveal(target: impl AsRef) { } } -/// Asks whether to go ahead with `action`, through zenity or kdialog where installed. -pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool { - let status = Command::new("zenity") - .args([ - "--question", - &format!("--title={message}"), - &format!("--text={detail}"), - &format!("--ok-label={action}"), - &format!("--cancel-label={cancel}"), - ]) - .status() - .or_else(|_| { - Command::new("kdialog") - .args(["--warningcontinuecancel", detail, "--title", message]) - .args(["--continue-label", action]) - .status() - }); - match status { - Ok(status) => status.success(), - Err(_) => crate::dialog::ask(message, detail, Ask::Question { cancel, action }).is_some(), - } +/// Asks whether to go ahead with `action`, through zenity or kdialog where installed, or +/// else Snowbound's own dialog. +pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) { + let [message, detail, cancel, action] = [message, detail, cancel, action].map(str::to_owned); + std::thread::spawn(move || { + let status = Command::new("zenity") + .args([ + "--question", + &format!("--title={message}"), + &format!("--text={detail}"), + &format!("--ok-label={action}"), + &format!("--cancel-label={cancel}"), + ]) + .status() + .or_else(|_| { + Command::new("kdialog") + .args(["--warningcontinuecancel", &detail, "--title", &message]) + .args(["--continue-label", &action]) + .status() + }); + match status { + Ok(status) if status.success() => reply.send(()), + Ok(_) => {} + Err(_) => { + let ask = Ask::Question { + cancel, + action, + reply, + }; + crate::dialog::show(message, detail, ask); + } + } + }); } -/// Asks for a notebook's table of contents or a section file, titled `title`; None when -/// cancelled or when no tool can ask. -pub fn pick_notebook(title: &str) -> Option { +/// Asks for a notebook's table of contents or a section file, titled `title`. +pub fn pick_notebook(title: &str, reply: Reply) { let name = "OneNote notebooks, sections and packages"; - let globs = ["*.onetoc2", "*.one", "*.onepkg"].map(String::from); + let types = ["onetoc2", "one", "onepkg"]; + let globs = types.map(|kind| format!("*.{kind}")); choose( title, "OpenFile", HashMap::from([("filters", filters(name, &globs))]), - [ + &[ "--file-selection", &format!("--title={title}"), &format!("--file-filter={name} | {}", globs.join(" ")), ], - ["--getopenfilename", ".", &globs.join(" "), "--title", title], - ) + &["--getopenfilename", ".", &globs.join(" "), "--title", title], + move |reply| Ask::File { + folder: crate::dialog::start_folder(), + types: types.map(String::from).to_vec(), + save: false, + name: String::new(), + reply, + }, + reply, + ); } /// Asks where to put something named `name` by default, in `folder` where given, with @@ -1726,29 +1760,42 @@ pub fn pick_new( name: &str, action: &str, folder: Option<&std::path::Path>, -) -> Option { + reply: Reply, +) { use std::os::unix::ffi::OsStrExt; let mut options = HashMap::from([ - ("current_name", Value::from(name)), - ("accept_label", Value::from(action)), + ("current_name", Value::from(name.to_owned())), + ("accept_label", Value::from(action.to_owned())), ]); if let Some(folder) = folder { let mut bytes = folder.as_os_str().as_bytes().to_vec(); bytes.push(0); options.insert("current_folder", Value::from(bytes)); } + let (named, folder) = ( + name.to_owned(), + folder.map_or_else(crate::dialog::start_folder, Into::into), + ); choose( title, "SaveFile", options, - [ + &[ "--file-selection", "--save", &format!("--title={title}"), &format!("--filename={name}"), ], - ["--getsavefilename", name, "--title", title], - ) + &["--getsavefilename", name, "--title", title], + move |reply| Ask::File { + folder, + types: Vec::new(), + save: true, + name: named, + reply, + }, + reply, + ); } /// Tells the user something they asked for could not be done: `message`, then what to do. @@ -1779,7 +1826,7 @@ fn show([zenity, kdialog]: [&'static str; 2], message: &str, detail: &str) { .status() }); if shown.is_err() { - crate::dialog::ask(&title, &text, Ask::Message); + crate::dialog::show(title, text, Ask::Message); } }); } diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index c9d4724ee67fb2628709c9aa54a3dd53b8b63ccc..07d0ceaf31eea0c40207ddb7d82e5588e9d8eec5 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -1,4 +1,4 @@ -use crate::commands; +use crate::{Reply, commands}; use canvas::date::DateField; use objc2::{ ClassType, DeclaredClass, class, @@ -11,7 +11,8 @@ use objc2::{ use objc2_app_kit::{ NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor, NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, - NSEventSubtype, NSEventType, NSMenu, NSMenuItem, + NSEventSubtype, NSEventType, NSMenu, NSMenuItem, NSModalResponse, NSModalResponseCancel, + NSModalResponseOK, NSSavePanel, NSWindow, }; use objc2_foundation::{ MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter, @@ -92,6 +93,59 @@ pub(crate) fn ns_window(window: &Window) -> Retained { unsafe { msg_send_id![&ns_view(window), window] } } +thread_local! { + /// The window dialogs open on as sheets. + static WINDOW: RefCell>> = const { RefCell::new(None) }; + /// The alert shown as a sheet, which Escape cancels. + static ALERT: RefCell>> = const { RefCell::new(None) }; +} + +/// Shows `alert` as a sheet on the window, then answers with the button pressed. Before +/// 10.9, whose alerts have no such sheet, the alert waits as a modal one. +fn begin_alert(alert: &NSAlert, answer: impl FnOnce(NSModalResponse) + 'static) { + unsafe { + let sheets: bool = msg_send![ + alert, + respondsToSelector: sel!(beginSheetModalForWindow:completionHandler:) + ]; + let Some(window) = WINDOW.with_borrow(Clone::clone).filter(|_| sheets) else { + return answer(alert.runModal()); + }; + ALERT.set(Some(alert.window())); + let answer = Cell::new(Some(answer)); + let done = block2::RcBlock::new(move |response: NSModalResponse| { + ALERT.set(None); + if let Some(answer) = answer.take() { + answer(response); + } + }); + let _: () = msg_send![alert, beginSheetModalForWindow: &*window, completionHandler: &*done]; + } +} + +/// Shows `panel` as a sheet on the window, then replies with the path chosen. +fn begin_panel(panel: Retained, reply: Reply) { + let chosen = panel.clone(); + let reply = Cell::new(Some(reply)); + let done = block2::RcBlock::new(move |response: NSModalResponse| { + let path = unsafe { chosen.URL().and_then(|url| url.path()) }; + if let (true, Some(path), Some(reply)) = (response == NSModalResponseOK, path, reply.take()) + { + reply.send(path.to_string().into()); + } + }); + unsafe { + match WINDOW.with_borrow(Clone::clone) { + Some(window) => { + let _: () = msg_send![&panel, beginSheetModalForWindow: &*window, completionHandler: &*done]; + } + None => { + let _: () = msg_send![&panel, beginWithCompletionHandler: &*done]; + } + } + } +} + /// Room the traffic lights take at the title bar's leading edge. pub const LEADING: f32 = 78.0; /// The title bar's trailing margin, the gap the traffic lights leave before the toolbar. @@ -131,6 +185,7 @@ pub fn window_attributes() -> WindowAttributes { pub fn install_title_bar(window: &Window) { MainThreadMarker::new().expect("Windows belong to the main thread"); let window = ns_window(window); + WINDOW.set(Some(unsafe { Retained::cast(window.clone()) })); let responds = |selector: Sel| -> bool { unsafe { msg_send![&window, respondsToSelector: selector] } }; // Before 10.10 the title bar keeps its own line above the row. @@ -765,7 +820,7 @@ pub fn documents_dir() -> Option { /// Asks for a notebook folder or a notebook file with the system's open panel, titled /// `title`. -pub fn pick_notebook(title: &str) -> Option { +pub fn pick_notebook(title: &str, reply: Reply) { let mtm = MainThreadMarker::new().expect("Panels belong to the main thread"); unsafe { let panel = objc2_app_kit::NSOpenPanel::openPanel(mtm); @@ -774,11 +829,7 @@ pub fn pick_notebook(title: &str) -> Option { panel.setCanCreateDirectories(true); panel.setTitle(Some(&NSString::from_str(title))); panel.setPrompt(Some(&NSString::from_str("Open"))); - if panel.runModal() != objc2_app_kit::NSModalResponseOK { - return None; - } - let path = panel.URLs().firstObject()?.path()?; - Some(path.to_string().into()) + begin_panel(Retained::into_super(panel), reply); } } @@ -789,10 +840,11 @@ pub fn pick_new( name: &str, action: &str, folder: Option<&std::path::Path>, -) -> Option { + reply: Reply, +) { let mtm = MainThreadMarker::new().expect("Panels belong to the main thread"); unsafe { - let panel = objc2_app_kit::NSSavePanel::savePanel(mtm); + let panel = NSSavePanel::savePanel(mtm); if let Some(folder) = folder.and_then(std::path::Path::to_str) { panel.setDirectoryURL(Some(&objc2_foundation::NSURL::fileURLWithPath( &NSString::from_str(folder), @@ -802,16 +854,13 @@ pub fn pick_new( panel.setTitle(Some(&NSString::from_str(title))); panel.setPrompt(Some(&NSString::from_str(action))); panel.setNameFieldStringValue(&NSString::from_str(name)); - if panel.runModal() != objc2_app_kit::NSModalResponseOK { - return None; - } - Some(panel.URL()?.path()?.to_string().into()) + begin_panel(panel, reply); } } /// Asks for a file to insert, one of `types` (extensions) unless empty, with the system's /// open panel titled `title`. -pub fn pick_file(title: &str, types: &[&str]) -> Option { +pub fn pick_file(title: &str, types: &[&str], reply: Reply) { let mtm = MainThreadMarker::new().expect("Panels belong to the main thread"); unsafe { let panel = objc2_app_kit::NSOpenPanel::openPanel(mtm); @@ -822,11 +871,7 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option { } panel.setTitle(Some(&NSString::from_str(title))); panel.setPrompt(Some(&NSString::from_str("Insert"))); - if panel.runModal() != objc2_app_kit::NSModalResponseOK { - return None; - } - let path = panel.URLs().firstObject()?.path()?; - Some(path.to_string().into()) + begin_panel(Retained::into_super(panel), reply); } } @@ -1010,7 +1055,7 @@ pub fn alert(message: &str, detail: &str) { let alert = NSAlert::new(mtm); alert.setMessageText(&NSString::from_str(message)); alert.setInformativeText(&NSString::from_str(detail)); - alert.runModal(); + begin_alert(&alert, |_| {}); } } @@ -1086,7 +1131,8 @@ pub fn edit_date( timestamp: u64, field: DateField, title: &str, -) -> Result, &'static str> { + reply: Reply>, +) { let mtm = MainThreadMarker::new().expect("Date controls belong to the main thread"); unsafe { let calendar = NSCalendar::currentCalendar(); @@ -1116,11 +1162,14 @@ pub fn edit_date( alert.addButtonWithTitle(&NSString::from_str("Cancel")); alert.layout(); alert.window().makeFirstResponder(Some(&picker)); - if alert.runModal() != NSAlertFirstButtonReturn { - return Ok(None); - } - alert.window().makeFirstResponder(None); - merge_date(timestamp, &picker.dateValue(), field, &calendar).map(Some) + let window = alert.window(); + begin_alert(&alert, move |response| { + if response != NSAlertFirstButtonReturn { + return; + } + window.makeFirstResponder(None); + reply.send(merge_date(timestamp, &picker.dateValue(), field, &calendar)); + }); } } @@ -1402,8 +1451,19 @@ declare_class!( .flatten() .map(|text| text.to_string()); let modal = self.modalWindow(); + let sheet = ALERT.with_borrow(Clone::clone).filter(|sheet| { + self.keyWindow().is_some_and(|key| std::ptr::eq(&*key, &**sheet)) + }); + let sheet = sheet.zip(WINDOW.with_borrow(Clone::clone)); match (key.as_deref(), &modal) { (Some("\u{1b}"), Some(_)) => self.stopModal(), + (Some("\u{1b}"), None) if let Some((sheet, parent)) = &sheet => { + let _: () = msg_send![ + parent, + endSheet: &**sheet, + returnCode: NSModalResponseCancel + ]; + } (Some("\r" | "\u{3}"), Some(modal)) if { let responder: *mut AnyObject = msg_send![modal, firstResponder]; !responder.is_null() @@ -1511,16 +1571,19 @@ pub fn reveal(target: impl AsRef) { } /// Asks whether to go ahead with `action`, offering `cancel` first. -pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool { +pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) { let mtm = MainThreadMarker::new().expect("Window events run on the main thread"); - // The alert and its strings stay on AppKit's main thread for the modal call. unsafe { let alert = NSAlert::new(mtm); alert.setMessageText(&NSString::from_str(message)); alert.setInformativeText(&NSString::from_str(detail)); alert.addButtonWithTitle(&NSString::from_str(cancel)); alert.addButtonWithTitle(&NSString::from_str(action)); - alert.runModal() == NSAlertSecondButtonReturn + begin_alert(&alert, |response| { + if response == NSAlertSecondButtonReturn { + reply.send(()); + } + }); } } diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 8c1ddd796fe72b06e340b42d646eea6dae9fc8a5..32c00b80d7f77944759e72647ae066dc7a09eca9 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -214,8 +214,48 @@ const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported r type Continuation = Box Result<(), Box> + Send>; +/// What follows a dialog's answer, run on the event loop. Dialogs never wait on the event +/// loop's thread, so the window keeps drawing and other apps can paste what it copied while +/// one is open; one cancelled drops its reply unanswered. +struct Reply(Box); + +impl Reply { + fn new( + proxy: &EventLoopProxy, + then: impl FnOnce(&mut State, T) -> Result<(), Box> + Send + 'static, + ) -> Self { + let proxy = proxy.clone(); + Self(Box::new(move |answer| { + let _ = proxy.send_event(UserEvent::Then(Box::new(move |state| then(state, answer)))); + })) + } + + fn send(self, answer: T) { + (self.0)(answer); + } + + /// The reply that sends `convert`'s answer here. + #[cfg(target_os = "linux")] + fn map(self, convert: impl FnOnce(U) -> T + Send + 'static) -> Reply { + Reply(Box::new(move |answer| self.send(convert(answer)))) + } + + /// Sends what `ask` answers, waiting on its dialog on a thread of its own. + #[cfg(windows)] + fn after(self, ask: impl FnOnce() -> Option + Send + 'static) { + std::thread::spawn(move || { + if let Some(answer) = ask() { + self.send(answer); + } + }); + } +} + enum UserEvent { Quit, + /// Quits without asking, as after the user agreed to discard a temporary page. + #[cfg(not(target_arch = "wasm32"))] + Exit, /// Text AppKit inserts outside key events, such as the character palette's. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] InsertText(String), @@ -750,6 +790,9 @@ struct State { save_as: Option, /// Unpack Notebook while it is open. unpacking: Option, + /// Snowbound's own dialogs, first the one shown, where the desktop has none. + #[cfg(target_os = "linux")] + asking: std::collections::VecDeque, /// What has been read in each notebook, on this computer. reads: unread::Reads, /// Open Notebook from Server while it is open. @@ -1125,6 +1168,8 @@ impl State { symbols: None, save_as: None, unpacking: None, + #[cfg(target_os = "linux")] + asking: Default::default(), reads, server: None, new_icloud: None, @@ -1301,6 +1346,8 @@ impl State { self.save_as_dialog(); self.unpack_dialog(); self.password_dialog()?; + #[cfg(target_os = "linux")] + self.own_dialog(); self.customize_tags(); self.palette(); self.sync_popup()?; @@ -3138,13 +3185,15 @@ impl State { Command::RestoreVersion { page, version } => self.restore_version(page, version)?, Command::DeletePageVersion { page, version } => self.delete_version(page, version)?, Command::DeleteAllVersions(scope) => self.delete_all_versions(scope)?, - Command::OpenNotebook => { - if let Some(path) = platform::pick_notebook("Open Notebook") { - self.open_path(&path); - } - } + Command::OpenNotebook => platform::pick_notebook( + "Open Notebook", + self.reply(|state, path: PathBuf| { + state.open_path(&path); + Ok(()) + }), + ), Command::OpenFromServer(location) => self.open_server(location.as_deref()), - Command::NewNotebook => self.new_notebook()?, + Command::NewNotebook => self.new_notebook(), Command::NewICloudNotebook => self.new_icloud_notebook(), Command::OpenGuide => self.open_guide()?, #[cfg(target_os = "linux")] @@ -3172,7 +3221,7 @@ impl State { self.edited(Vec::new()); } Command::Template(choice) => self.apply_template(choice)?, - Command::Page(Request::EditDate(field)) => self.edit_date(field)?, + Command::Page(Request::EditDate(field)) => self.edit_date(field), Command::Page(Request::Copy(clip)) => self.clipboard.set(clip)?, Command::Page(Request::Paste) => self.paste()?, Command::Page(Request::OpenLink(address)) => self.open_link(&address)?, @@ -3561,9 +3610,9 @@ impl State { } } - fn edit_date(&mut self, field: DateField) -> Result<(), Box> { + fn edit_date(&mut self, field: DateField) { let Some(date) = self.view.editor.date() else { - return Ok(()); + return; }; let timestamp = date.timestamp(); self.view.editor.finish_composition(); @@ -3572,12 +3621,30 @@ impl State { DateField::Date => "Change Page Date", DateField::Time => "Change Page Time", }; - if let Some((timestamp, text)) = platform::edit_date(timestamp, field, title)? { - let response = self.view.change_date(timestamp, text)?; - self.respond(response); - self.window.request_redraw(); - } - Ok(()) + let page = self.session.as_ref().map(|session| session.space); + let reply = self.reply( + move |state, chosen: Result<(u64, [String; 2]), &'static str>| { + let (changed, text) = chosen?; + // The answer belongs to the page that asked, unless another has opened since. + if state.session.as_ref().map(|session| session.space) != page + || state.view.editor.date().map(|date| date.timestamp()) != Some(timestamp) + { + return Ok(()); + } + let response = state.view.change_date(changed, text)?; + state.respond(response); + Ok(()) + }, + ); + platform::edit_date(timestamp, field, title, reply); + } + + /// Where a dialog's answer goes: `then`, run with it on the event loop. + fn reply( + &self, + then: impl FnOnce(&mut State, T) -> Result<(), Box> + Send + 'static, + ) -> Reply { + Reply::new(&self.proxy, then) } /// The open conflict page's conflicting changes in page order: each text's outline, @@ -3970,7 +4037,7 @@ impl State { } if let Some(field) = self.accessibility.date_for_node(request.target_node) { if request.action == Action::Click { - self.edit_date(field)?; + self.edit_date(field); } return Ok(()); } @@ -5491,6 +5558,8 @@ impl State { match event { // The event loop quits; a page stays open. UserEvent::Quit | UserEvent::Replay(Replay::Quit) => {} + #[cfg(not(target_arch = "wasm32"))] + UserEvent::Exit => {} UserEvent::Picture(bytes) => { if let Err(error) = self.insert_picture(bytes, None) { eprintln!("{error}"); @@ -5607,35 +5676,44 @@ impl State { #[cfg(not(target_arch = "wasm32"))] impl App { fn close(&self, event_loop: &ActiveEventLoop) { - if self.state.as_ref().is_none_or(|state| { + let Some(state) = self.state.as_ref().filter(|state| { let editor = &state.view.editor; - state.session.is_some() - || editor.caret_outline().is_none_or(TextOutline::is_empty) - && state.initial_date == editor.date().map(|date| date.timestamp()) - && state - .initial_layouts + let unchanged = editor.caret_outline().is_none_or(TextOutline::is_empty) + && state.initial_date == editor.date().map(|date| date.timestamp()) + && state + .initial_layouts + .iter() + .map(|(id, layout)| (*id, layout)) + .eq(editor.object_layouts()) + && state + .initial + .iter() + .map(|(id, document)| (id, document)) + .eq(editor + .outlines() .iter() - .map(|(id, layout)| (*id, layout)) - .eq(editor.object_layouts()) - && state - .initial - .iter() - .map(|(id, document)| (id, document)) - .eq(editor - .outlines() - .iter() - .map(|outline| (&outline.id, outline.document()))) - }) || platform::confirm( + .map(|outline| (&outline.id, outline.document()))); + state.session.is_none() && !unchanged + }) else { + return self.exit(event_loop); + }; + platform::confirm( "Discard this page?", "This temporary page has no saved copy. Closing it will discard your edits.", "Keep Editing", "Discard Changes", - ) { - if let Some(state) = &self.state { - state.publish_now(QUIT_PUBLISH); - } - event_loop.exit(); + state.reply(|state, ()| { + let _ = state.proxy.send_event(UserEvent::Exit); + Ok(()) + }), + ); + } + + fn exit(&self, event_loop: &ActiveEventLoop) { + if let Some(state) = &self.state { + state.publish_now(QUIT_PUBLISH); } + event_loop.exit(); } } @@ -5658,6 +5736,7 @@ impl ApplicationHandler for App { }; match event { UserEvent::Quit | UserEvent::Replay(Replay::Quit) => self.close(event_loop), + UserEvent::Exit => self.exit(event_loop), event => state.user_event(event), } } @@ -5996,14 +6075,6 @@ fn main() -> Result<(), Box> { } #[cfg(target_os = "linux")] loader::preload(); - // Before the crash log, which the app showing the dialog keeps writing. - #[cfg(target_os = "linux")] - if std::env::args_os() - .nth(1) - .is_some_and(|arg| arg == dialog::DIALOG) - { - return dialog::run(std::env::args_os().skip(2)); - } platform::with_pool(launch) } diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index ef3dfceb84a4c38cce1fea230638da59aa032450..d596d1a6ac0512cc2f1e20b299ddcc9205b013f6 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -261,14 +261,16 @@ impl State { /// Asks where to create a notebook and creates it, as File, New does in OneNote; the /// panel starts in iCloud Drive, as Notes keeps notes there. - pub(crate) fn new_notebook(&mut self) -> Result<(), Box> { + pub(crate) fn new_notebook(&mut self) { let icloud = crate::icloud::folder().or_else(crate::icloud::drive); - let Some(root) = - platform::pick_new("New Notebook", "My Notebook", "Create", icloud.as_deref()) - else { - return Ok(()); - }; - self.create_notebook(root) + let reply = self.reply(|state, root| state.create_notebook(root)); + platform::pick_new( + "New Notebook", + "My Notebook", + "Create", + icloud.as_deref(), + reply, + ); } /// New iCloud Notebook: asks for a name, the first free of "iCloud Notebook", "iCloud diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index 826a14a5e14f0824a827b437e6c179ed4f167db5..0de513afef25bbf19e2e5177371e8145521e4c1d 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -649,24 +649,45 @@ impl State { }); None } - (Target::Section { library, path }, Action::Delete) => platform::confirm( - "Are you sure you want to move this section to this notebook's Recycle Bin?", - &path, - "Cancel", - "Delete", - ) - .then(|| Command::Structure(library, Structure::Delete { path })), - (Target::Group { library, path }, Action::Delete) => platform::confirm( - "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?", - path.rsplit('/').next().unwrap_or_default(), - "Cancel", - "Delete", - ) - .then(|| Command::Structure(library, Structure::Delete { path })), + (Target::Section { library, path }, Action::Delete) => { + platform::confirm( + "Are you sure you want to move this section to this notebook's Recycle Bin?", + &path.clone(), + "Cancel", + "Delete", + self.reply(|state, ()| { + let change = Structure::Delete { path }; + state.commands.push(Command::Structure(library, change)); + Ok(()) + }), + ); + None + } + (Target::Group { library, path }, Action::Delete) => { + platform::confirm( + "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?", + path.rsplit('/') + .next() + .unwrap_or_default() + .to_owned() + .as_str(), + "Cancel", + "Delete", + self.reply(|state, ()| { + let change = Structure::Delete { path }; + state.commands.push(Command::Structure(library, change)); + Ok(()) + }), + ); + None + } ( Target::Section { library, path } | Target::Group { library, path }, Action::MoveTo(folder), - ) => Some(Command::Structure(library, Structure::Move { path, folder })), + ) => Some(Command::Structure( + library, + Structure::Move { path, folder }, + )), (Target::Section { library, path }, Action::Theme) => { if let Some(identity) = library.section_identity(&path) { let scope = notebook::sidecar::themes::Scope::section(identity); @@ -710,7 +731,10 @@ impl State { { session.section.wake(); } - library.background.iter().for_each(|background| background.wake()); + library + .background + .iter() + .for_each(|background| background.wake()); None } (Target::Notebook(library), Action::CopyLink) => { diff --git a/crates/snowbound/src/options.rs b/crates/snowbound/src/options.rs index 56ce5c9e505c3b26a5aa96d08151c947063ccf90..d229936d54e0e99714b2f861eeb1c87e82ee135f 100644 --- a/crates/snowbound/src/options.rs +++ b/crates/snowbound/src/options.rs @@ -871,7 +871,7 @@ fn installed(state: &mut State, _: &mut Options) { if crate::desktop::uninstallable() { path(ui, &crate::desktop::binary().unwrap_or_default()); if ui::button(ui, "uninstall", "Uninstall…").clicked { - crate::desktop::uninstall(); + crate::desktop::uninstall(&state.proxy); } } else if crate::desktop::installable() { path(ui, std::path::Path::new("Not in the app menu")); diff --git a/crates/snowbound/src/print.rs b/crates/snowbound/src/print.rs index 68ba85bd48b28fa32887d4ee20197574d0c7de2f..1011863a6bd8d45f424830e6f58dc11f129c63ff 100644 --- a/crates/snowbound/src/print.rs +++ b/crates/snowbound/src/print.rs @@ -308,18 +308,6 @@ impl State { Scope::Section => tab.name.clone(), Scope::Notebook => session.library.name.clone(), }; - let destination = if export { - let name = format!("{}.pdf", file_name(&title)); - let Some(mut path) = platform::pick_new("Export as PDF", &name, "Export", None) else { - return Ok(()); - }; - if path.extension().is_none() { - path.set_extension("pdf"); - } - Some(path) - } else { - None - }; // Other sections are opened on the thread, in the notebook's order. let others = match setup.scope { Scope::Notebook => notebook_tabs(&session.library), @@ -343,7 +331,8 @@ impl State { footer: setup.footer, }; let whole = matches!(setup.scope, Scope::Section | Scope::Notebook); - crate::spawn(move || { + let name = format!("{}.pdf", file_name(&title)); + let print = move |destination: Option| { let made = (|| -> Result, Box> { let mut open = Some(open); let this = spaces @@ -399,7 +388,19 @@ impl State { } .inspect_err(|error| platform::alert(failed, &error.to_string())) }))); + }; + if !export { + crate::spawn(move || print(None)); + return Ok(()); + } + let reply = self.reply(|_, mut path: std::path::PathBuf| { + if path.extension().is_none() { + path.set_extension("pdf"); + } + crate::spawn(move || print(Some(path))); + Ok(()) }); + platform::pick_new("Export as PDF", &name, "Export", None, reply); Ok(()) } } diff --git a/crates/snowbound/src/recycle.rs b/crates/snowbound/src/recycle.rs index b413aed4dba433d88726cc6c3a280fcd65cb12d7..907430a228f616905289cfe017b6b981bee84753 100644 --- a/crates/snowbound/src/recycle.rs +++ b/crates/snowbound/src/recycle.rs @@ -65,35 +65,54 @@ impl State { /// Empty Recycle Bin on `library`, once confirmed as OneNote asks. pub(crate) fn empty_recycle_bin(&mut self, library: Arc) { - if crate::platform::confirm( + crate::platform::confirm( "Are you sure you want to empty the Recycle Bin for this notebook?", "Its pages and sections are deleted for good.", "Cancel", "Empty Recycle Bin", - ) { - let change = crate::manage::Structure::EmptyRecycleBin; - self.commands.push(Command::Structure(library, change)); - } + self.reply(|state, ()| { + let change = crate::manage::Structure::EmptyRecycleBin; + state.commands.push(Command::Structure(library, change)); + Ok(()) + }), + ); + } + + /// Does `request` to the bin's open section, a purge once confirmed. + pub(crate) fn recycle(&mut self, request: Request) -> Result<(), Box> { + let Request::Purge(spaces) = &request else { + return self.recycle_now(request); + }; + let spaces = spaces.clone(); + crate::platform::confirm( + "Are you sure you want to delete this page for good?", + "It can't be restored.", + "Cancel", + "Delete", + self.reply(move |state, ()| { + // The pages asked about, unless their section closed meanwhile. + let open = state.session.as_ref().is_some_and(|session| { + (spaces.iter()) + .all(|space| session.pages.iter().any(|(page, ..)| page == space)) + }); + if open { + state.recycle_now(request) + } else { + Ok(()) + } + }), + ); + Ok(()) } /// Does `request` to the bin's open section on a thread of its own, then shows the page /// after the ones gone, or the notebook once the section holds none. - pub(crate) fn recycle(&mut self, request: Request) -> Result<(), Box> { + fn recycle_now(&mut self, request: Request) -> Result<(), Box> { let gone = match &request { Request::Restore { copy: true, .. } => Vec::new(), Request::Restore { space, .. } => vec![*space], Request::Purge(spaces) => spaces.clone(), }; - if matches!(request, Request::Purge(_)) - && !crate::platform::confirm( - "Are you sure you want to delete this page for good?", - "It can't be restored.", - "Cancel", - "Delete", - ) - { - return Ok(()); - } let session = self.session.as_ref().ok_or("No section is open")?; let replica = Arc::clone(session.section.replica()); let library = Arc::clone(&session.library); diff --git a/crates/snowbound/src/save_as.rs b/crates/snowbound/src/save_as.rs index ace385c461fd631edf6002d4679c41ea464ebdc9..61b9c348d0be8f342078e972a37e735c80614ff9 100644 --- a/crates/snowbound/src/save_as.rs +++ b/crates/snowbound/src/save_as.rs @@ -175,54 +175,55 @@ impl State { Scope::Notebook => library.name.clone(), }; let name = format!("{}.{extension}", print::file_name(&title)); - let Some(mut path) = platform::pick_new("Save As", &name, "Save", None) else { - return Ok(()); - }; - if path.extension().is_none() { - path.set_extension(extension); - } let (scope, author, proxy) = (dialog.scope, self.author.clone(), self.proxy.clone()); let color = (library .tabs(crate::menus::folder(§ion).as_str()) .into_iter()) .find(|tab| tab.path == section) .and_then(|tab| tab.color); - crate::spawn(move || { - let written = (|| -> Result<(), Box> { - let image = |path: &str| { - let (_, replica, _) = open.as_ref().filter(|(open, ..)| open == path)?; - replica.snapshot().ok() - }; - let bytes = match scope { - Scope::Page => { - let (_, replica, space) = open.as_ref().ok_or("No page is open")?; - let page = replica.page(*space)?; - let file = file_name(&path); - package::page_section(&page, &file, color, &author)? - } - Scope::Section => { - let stored = match image(§ion) { - Some(image) => image, - None => library.reopen()?.read_section(§ion)?, - }; - package::section_copy(stored)? - } - Scope::Notebook => { - let notebook = library.reopen()?; - package::pack(package::notebook_files(¬ebook, image)?)? - } - }; - notebook::fs::write(&path, bytes)?; - Ok(()) - })(); - let written = written.map_err(|error| error.to_string()); - let _ = proxy.send_event(UserEvent::Then(Box::new(move |_| { - written.map_err(|error| { - platform::alert("Couldn't save", &error); - error.into() - }) - }))); + let reply = self.reply(move |_, mut path: std::path::PathBuf| { + if path.extension().is_none() { + path.set_extension(extension); + } + crate::spawn(move || { + let written = (|| -> Result<(), Box> { + let image = |path: &str| { + let (_, replica, _) = open.as_ref().filter(|(open, ..)| open == path)?; + replica.snapshot().ok() + }; + let bytes = match scope { + Scope::Page => { + let (_, replica, space) = open.as_ref().ok_or("No page is open")?; + let page = replica.page(*space)?; + let file = file_name(&path); + package::page_section(&page, &file, color, &author)? + } + Scope::Section => { + let stored = match image(§ion) { + Some(image) => image, + None => library.reopen()?.read_section(§ion)?, + }; + package::section_copy(stored)? + } + Scope::Notebook => { + let notebook = library.reopen()?; + package::pack(package::notebook_files(¬ebook, image)?)? + } + }; + notebook::fs::write(&path, bytes)?; + Ok(()) + })(); + let written = written.map_err(|error| error.to_string()); + let _ = proxy.send_event(UserEvent::Then(Box::new(move |_| { + written.map_err(|error| { + platform::alert("Couldn't save", &error); + error.into() + }) + }))); + }); + Ok(()) }); + platform::pick_new("Save As", &name, "Save", None, reply); Ok(()) } } diff --git a/crates/snowbound/src/tags.rs b/crates/snowbound/src/tags.rs index 13807c71965bb4039f01b8f1dffadda83b0928ac..2e76efe3e1aaca72cef87c3b02446020eaade5cf 100644 --- a/crates/snowbound/src/tags.rs +++ b/crates/snowbound/src/tags.rs @@ -589,12 +589,18 @@ impl State { /// Asks for a picture, a PNG or SVG, for the tag New Tag or Modify Tag edits, which then /// draws with it; OneNote shows the tag's symbol, or a blue circle where it has none. pub(crate) fn pick_tag_picture(&mut self) { + let reply = self.reply(|state, path| { + state.use_tag_picture(path); + Ok(()) + }); + platform::pick_file("Custom Image", &["png", "svg"], reply); + } + + /// Draws the tag New Tag or Modify Tag edits with the picture at `path`, while it does. + fn use_tag_picture(&mut self, path: std::path::PathBuf) { let Some((tag, _)) = self.tag_list.as_mut().and_then(|list| list.editor.as_mut()) else { return; }; - let Some(path) = platform::pick_file("Custom Image", &["png", "svg"]) else { - return; - }; let kept = notebook::fs::read(&path) .ok() .and_then(|bytes| canvas::gpu::import_tag_art(&bytes)); diff --git a/crates/snowbound/src/unpack.rs b/crates/snowbound/src/unpack.rs index 35cc0e059efc48494e2cb06423550f4a1799c383..8fedad8d7051e6fa1208afb1922d539b609b2cb9 100644 --- a/crates/snowbound/src/unpack.rs +++ b/crates/snowbound/src/unpack.rs @@ -172,16 +172,25 @@ impl State { let create = ui::button(ui, "create", "Create").clicked || entered; ui.close(); ui.close(); - if browse - && let Some(chosen) = - platform::pick_new(TITLE, dialog.name.trim(), "Choose", Some(&dialog.folder)) - { - if let Some(name) = chosen.file_name() { - dialog.name = name.to_string_lossy().into_owned(); - } - if let Some(parent) = chosen.parent() { - dialog.folder = parent.to_owned(); - } + if browse { + let reply = crate::Reply::new(&self.proxy, |state, chosen: PathBuf| { + if let Some(dialog) = &mut state.unpacking { + if let Some(name) = chosen.file_name() { + dialog.name = name.to_string_lossy().into_owned(); + } + if let Some(parent) = chosen.parent() { + dialog.folder = parent.to_owned(); + } + } + Ok(()) + }); + platform::pick_new( + TITLE, + dialog.name.trim(), + "Choose", + Some(&dialog.folder), + reply, + ); } let name = dialog.name.trim(); let valid = diff --git a/crates/snowbound/src/update.rs b/crates/snowbound/src/update.rs index fd1f7c2c8e07cd2bebf38569ef7c39342ef273bb..d8dd004a7755e78a4818987db9e4666d7f344326 100644 --- a/crates/snowbound/src/update.rs +++ b/crates/snowbound/src/update.rs @@ -642,32 +642,32 @@ impl State { return; }; match status { - Status::Ready(version, _, changes) => { - if platform::confirm( - "Update ready", - &described( - format!("Snowbound {version} is ready to install."), - &changes, - ), - "Later", - "Restart to Update", - ) { - self.restart_to_update(); - } - } - Status::Available(version, changes) => { - if platform::confirm( - "Update available", - &described( - format!("Download Snowbound {version} from its build folder."), - &changes, - ), - "Later", - "Open Build Folder", - ) { + Status::Ready(version, _, changes) => platform::confirm( + "Update ready", + &described( + format!("Snowbound {version} is ready to install."), + &changes, + ), + "Later", + "Restart to Update", + self.reply(|state, ()| { + state.restart_to_update(); + Ok(()) + }), + ), + Status::Available(version, changes) => platform::confirm( + "Update available", + &described( + format!("Download Snowbound {version} from its build folder."), + &changes, + ), + "Later", + "Open Build Folder", + self.reply(move |_, ()| { show_build(&version); - } - } + Ok(()) + }), + ), Status::UpToDate => platform::alert( "Snowbound is up to date", &format!("You have {}.", describe_running()), diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs index 28c113df9a1b242d545343856353780db3e71abb..6eb54c24473dffcce4ca44e4c81761a7d3d0692b 100644 --- a/crates/snowbound/src/web.rs +++ b/crates/snowbound/src/web.rs @@ -3,7 +3,7 @@ //! event loop are stand-ins with the calls `State` makes of winit's; menus are the kit's own, //! as on Linux; the browser's chords stay the browser's. See arc/platforms.md. -use crate::{State, UserEvent, commands, page, settings}; +use crate::{Reply, State, UserEvent, commands, page, settings}; use canvas::date::DateField; use std::{ cell::{Cell, RefCell}, @@ -60,12 +60,14 @@ extern "C" { fn date_strings(ms: f64) -> Vec; #[wasm_bindgen(js_name = shortDate)] fn short_date_string(ms: f64) -> String; + /// Resolves to whether the user chose `action` over `cancel`. #[wasm_bindgen(js_name = askConfirm)] - fn ask_confirm(message: &str) -> bool; + fn ask_confirm(message: &str, detail: &str, cancel: &str, action: &str) -> js_sys::Promise; + /// Resolves to the text the user enters, starting as `value`; undefined when cancelled. #[wasm_bindgen(js_name = askText)] - fn ask_text(message: &str, value: &str) -> Option; + fn ask_text(message: &str, value: &str) -> js_sys::Promise; #[wasm_bindgen(js_name = tell)] - fn tell(message: &str); + fn tell(message: &str, detail: &str); #[wasm_bindgen(js_name = openLink)] fn open_link(url: &str); /// Writes changes out: `[path]` removed, `[path, null]` a folder, and `[path, length, @@ -644,14 +646,23 @@ pub fn date_text(filetime: u64) -> [String; 2] { ] } +/// Runs `then` with what the glue's dialog resolves `asked` to, once the user answers. +fn answered(asked: js_sys::Promise, then: impl FnOnce(JsValue) + 'static) { + wasm_bindgen_futures::spawn_local(async move { + if let Ok(answer) = wasm_bindgen_futures::JsFuture::from(asked).await { + then(answer); + } + }); +} + /// Asks for the page's date as `YYYY-MM-DD`, or its time as `HH:MM`. pub fn edit_date( timestamp: u64, field: DateField, title: &str, -) -> Result, &'static str> { - let ms = unix_ms(timestamp); - let date = js_sys::Date::new(&ms.into()); + reply: Reply>, +) { + let date = js_sys::Date::new(&unix_ms(timestamp).into()); let current = match field { DateField::Date => format!( "{:04}-{:02}-{:02}", @@ -661,9 +672,20 @@ pub fn edit_date( ), DateField::Time => format!("{:02}:{:02}", date.get_hours(), date.get_minutes()), }; - let Some(answer) = ask_text(title, ¤t) else { - return Ok(None); - }; + answered(ask_text(title, ¤t), move |answer| { + if let Some(answer) = answer.as_string() { + reply.send(changed_date(timestamp, field, &answer)); + } + }); +} + +/// `timestamp` with `field` changed to what the user typed, `answer`. +fn changed_date( + timestamp: u64, + field: DateField, + answer: &str, +) -> Result<(u64, [String; 2]), &'static str> { + let date = js_sys::Date::new(&unix_ms(timestamp).into()); let numbers: Vec = answer .split(|c: char| !c.is_ascii_digit()) .filter(|part| !part.is_empty()) @@ -686,44 +708,53 @@ pub fn edit_date( .checked_mul(10_000_000) .and_then(|ticks| ticks.checked_add(timestamp % 10_000_000)) .ok_or(crate::DATE_OUT_OF_RANGE)?; - Ok(Some((updated, date_text(updated)))) + Ok((updated, date_text(updated))) } /// Asks for a file to insert; once chosen it goes to the caret, as a dropped file does. -pub fn pick_file(_: &str, types: &[&str]) -> Option { +pub fn pick_file(_: &str, types: &[&str], _: Reply) { let accept: Vec = types.iter().map(|kind| format!(".{kind}")).collect(); pick_files("place", &accept.join(",")); - None } /// Asks for notebooks, sections or packages to open; they open once read. -pub fn pick_notebook(_: &str) -> Option { +pub fn pick_notebook(_: &str, _: Reply) { pick_notebook_files(); - None } /// A new notebook goes in the browser's notebooks, under a name not yet taken; anything else /// asked a place goes there too, then downloads. -pub fn pick_new(title: &str, name: &str, _: &str, _: Option<&Path>) -> Option { - let name = ask_text(title, name)?; - let name = name.trim().replace(['/', '\\'], " "); - let folder = Path::new(NOTEBOOKS); - let _ = notebook::fs::create_dir_all(folder); - let mut path = folder.join(&name); - let mut number = 2; - while notebook::fs::metadata(&path).is_ok() { - path = folder.join(format!("{name} {number}")); - number += 1; - } - Some(path) +pub fn pick_new(title: &str, name: &str, _: &str, _: Option<&Path>, reply: Reply) { + answered(ask_text(title, name), move |answer| { + let Some(name) = answer.as_string() else { + return; + }; + let name = name.trim().replace(['/', '\\'], " "); + let folder = Path::new(NOTEBOOKS); + let _ = notebook::fs::create_dir_all(folder); + let mut path = folder.join(&name); + let mut number = 2; + while notebook::fs::metadata(&path).is_ok() { + path = folder.join(format!("{name} {number}")); + number += 1; + } + reply.send(path); + }); } -pub fn confirm(message: &str, detail: &str, _: &str, _: &str) -> bool { - ask_confirm(&format!("{message}\n\n{detail}")) +pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) { + answered( + ask_confirm(message, detail, cancel, action), + move |chosen| { + if chosen.is_truthy() { + reply.send(()); + } + }, + ); } pub fn alert(message: &str, detail: &str) { - tell(&format!("{message}\n\n{detail}")); + tell(message, detail); } pub fn inform(message: &str, detail: &str) { diff --git a/crates/snowbound/src/windows.rs b/crates/snowbound/src/windows.rs index 727aa2c1817f8a34c27a8b5f6b07be4eb103ce85..6de093463ef64fa84b3ac567f37e5c261ab55133 100644 --- a/crates/snowbound/src/windows.rs +++ b/crates/snowbound/src/windows.rs @@ -5,6 +5,7 @@ //! Anything newer than Windows 7 is looked up at run time, so one executable starts on all //! of them. +use crate::Reply; use canvas::date::DateField; use std::{ ffi::c_void, @@ -1372,8 +1373,8 @@ fn credential_target(mount: &crate::library::Mount) -> Vec { wide(format!("Snowbound/smb/{}", mount.host())) } -/// The password the Credential Manager keeps for `mount`'s server, or what the user types -/// into the system's sign-in dialog when it keeps none. +/// The password the Credential Manager keeps for `mount`'s server; without one, Snowbound's +/// sign-in asks. pub fn smb_login(mount: &crate::library::Mount) -> Result { use windows_sys::Win32::Security::Credentials as cred; let target = credential_target(mount); @@ -1406,51 +1407,11 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result() as u32, - hwndParent: owner(), - pszMessageText: message.as_ptr(), - pszCaptionText: caption.as_ptr(), - hbmBanner: std::ptr::null_mut(), - }; - let mut user = [0u16; cred::CREDUI_MAX_USERNAME_LENGTH as usize + 1]; - for (slot, unit) in user - .iter_mut() - .zip(mount.user.clone().unwrap_or_default().encode_utf16()) - { - *slot = unit; - } - let mut password = [0u16; 257]; - let mut save: BOOL = 0; - let server = wide(&mount.server); - let result = unsafe { - cred::CredUIPromptForCredentialsW( - &info, - server.as_ptr(), - std::ptr::null_mut(), - 0, - user.as_mut_ptr(), - user.len() as u32, - password.as_mut_ptr(), - password.len() as u32, - &mut save, - cred::CREDUI_FLAGS_GENERIC_CREDENTIALS - | cred::CREDUI_FLAGS_DO_NOT_PERSIST - | cred::CREDUI_FLAGS_ALWAYS_SHOW_UI, - ) - }; - let login = crate::library::Login { - user: narrow(&user), - password: narrow(&password), - domain: mount.domain.clone(), - }; - password.fill(0); - match result { - 0 => Ok(login), - _ => Err("Signing in was canceled".to_owned()), - } + Err(format!( + "Enter the password for \u{201c}{}\u{201d} on \u{201c}{}\u{201d}.", + mount.user.as_deref().unwrap_or_default(), + mount.host() + )) } /// What the sign-in offers for keeping a password, which the Credential Manager keeps. @@ -1613,41 +1574,41 @@ pub fn edit_date( timestamp: u64, field: DateField, title: &str, -) -> Result, &'static str> { - let before = local_time(timestamp); - let Some(chosen) = picker::pick(&before, field, title) else { - return Ok(None); - }; - let mut time = before; - match field { - DateField::Date => { - (time.wYear, time.wMonth, time.wDay) = (chosen.wYear, chosen.wMonth, chosen.wDay); + reply: Reply>, +) { + let title = title.to_owned(); + reply.after(move || { + let before = local_time(timestamp); + let chosen = picker::pick(&before, field, &title)?; + let mut time = before; + match field { + DateField::Date => { + (time.wYear, time.wMonth, time.wDay) = (chosen.wYear, chosen.wMonth, chosen.wDay); + } + DateField::Time => { + (time.wHour, time.wMinute) = (chosen.wHour, chosen.wMinute); + } } - DateField::Time => { - (time.wHour, time.wMinute) = (chosen.wHour, chosen.wMinute); - } - } - let updated = filetime(&time).ok_or(crate::DATE_OUT_OF_RANGE)?; - // FILETIME's sub-second ticks stay as they were. - let updated = updated / 10_000_000 * 10_000_000 + timestamp % 10_000_000; - Ok(Some((updated, date_labels(&local_time(updated))))) + let updated = filetime(&time).ok_or(crate::DATE_OUT_OF_RANGE); + // FILETIME's sub-second ticks stay as they were. + Some(updated.map(|updated| { + let updated = updated / 10_000_000 * 10_000_000 + timestamp % 10_000_000; + (updated, date_labels(&local_time(updated))) + })) + }); } /// Asks for a notebook's table of contents or a section file, titled `title`. -pub fn pick_notebook(title: &str) -> Option { - pick( - title, - &[( - "OneNote notebooks, sections and packages", - "*.onetoc2;*.one;*.onepkg", - )], - None, - false, - ) +pub fn pick_notebook(title: &str, reply: Reply) { + let filter = ( + "OneNote notebooks, sections and packages", + "*.onetoc2;*.one;*.onepkg", + ); + pick(title, &[filter], None, false, reply); } /// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. -pub fn pick_file(title: &str, types: &[&str]) -> Option { +pub fn pick_file(title: &str, types: &[&str], reply: Reply) { let patterns = types .iter() .map(|kind| format!("*.{kind}")) @@ -1658,7 +1619,7 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option { } else { vec![("Supported files", patterns.as_str()), ("All files", "*.*")] }; - pick(title, &filters, None, false) + pick(title, &filters, None, false, reply); } /// Asks where to put something named `name` by default; the dialog names its own button. @@ -1667,19 +1628,26 @@ pub fn pick_new( name: &str, _action: &str, folder: Option<&std::path::Path>, -) -> Option { - pick(title, &[("All files", "*.*")], Some((name, folder)), true) + reply: Reply, +) { + pick( + title, + &[("All files", "*.*")], + Some((name, folder)), + true, + reply, + ); } -/// The common file dialog: an open dialog over `filters` (name and `;`-separated -/// patterns), or with `new`, a save dialog suggesting a name in a folder. +/// The common file dialog, on a thread of its own: an open dialog over `filters` (name and +/// `;`-separated patterns), or with `new`, a save dialog suggesting a name in a folder. fn pick( title: &str, filters: &[(&str, &str)], new: Option<(&str, Option<&std::path::Path>)>, save: bool, -) -> Option { - use windows_sys::Win32::UI::Controls::Dialogs as dialogs; + reply: Reply, +) { let mut filter: Vec = Vec::new(); for (name, patterns) in filters { filter.extend(name.encode_utf16().chain([0])); @@ -1694,54 +1662,68 @@ fn pick( } let folder = new.and_then(|(_, folder)| folder).map(wide); let title = wide(title); - let mut dialog = dialogs::OPENFILENAMEW { - lStructSize: size_of::() as u32, - hwndOwner: owner(), - lpstrFilter: filter.as_ptr(), - lpstrFile: file.as_mut_ptr(), - nMaxFile: file.len() as u32, - lpstrTitle: title.as_ptr(), - lpstrInitialDir: folder - .as_ref() - .map_or(std::ptr::null(), |folder| folder.as_ptr()), - Flags: dialogs::OFN_EXPLORER - | dialogs::OFN_NOCHANGEDIR - | if save { - dialogs::OFN_OVERWRITEPROMPT + reply.after(move || { + use windows_sys::Win32::{System::Com, UI::Controls::Dialogs as dialogs}; + let mut dialog = dialogs::OPENFILENAMEW { + lStructSize: size_of::() as u32, + hwndOwner: owner(), + lpstrFilter: filter.as_ptr(), + lpstrFile: file.as_mut_ptr(), + nMaxFile: file.len() as u32, + lpstrTitle: title.as_ptr(), + lpstrInitialDir: folder + .as_ref() + .map_or(std::ptr::null(), |folder| folder.as_ptr()), + Flags: dialogs::OFN_EXPLORER + | dialogs::OFN_NOCHANGEDIR + | if save { + dialogs::OFN_OVERWRITEPROMPT + } else { + dialogs::OFN_FILEMUSTEXIST + }, + ..unsafe { std::mem::zeroed() } + }; + // The Explorer-style dialog hosts the shell's COM objects, which want an apartment. + unsafe { Com::CoInitializeEx(std::ptr::null(), Com::COINIT_APARTMENTTHREADED as u32) }; + let chosen = unsafe { + if save { + dialogs::GetSaveFileNameW(&mut dialog) } else { - dialogs::OFN_FILEMUSTEXIST - }, - ..unsafe { std::mem::zeroed() } - }; - let chosen = unsafe { - if save { - dialogs::GetSaveFileNameW(&mut dialog) - } else { - dialogs::GetOpenFileNameW(&mut dialog) + dialogs::GetOpenFileNameW(&mut dialog) + } + }; + unsafe { Com::CoUninitialize() }; + if chosen == 0 { + // Zero where the user cancelled. + let error = unsafe { dialogs::CommDlgExtendedError() }; + if error != 0 { + eprintln!("The file dialog failed: {error:#x}"); + } + return None; } - }; - if chosen == 0 { - // Zero where the user cancelled. - let error = unsafe { dialogs::CommDlgExtendedError() }; - if error != 0 { - eprintln!("The file dialog failed: {error:#x}"); - } - return None; - } - Some(PathBuf::from(narrow(&file))) + Some(PathBuf::from(narrow(&file))) + }); } /// Tells the user something they asked for could not be done: `message`, then what to do. pub fn alert(message: &str, detail: &str) { let text = wide(format!("{message}\n\n{detail}")); - let caption = wide("Snowbound"); - let style = wm::MB_OK | wm::MB_ICONWARNING; - unsafe { wm::MessageBoxW(owner(), text.as_ptr(), caption.as_ptr(), style) }; + std::thread::spawn(move || { + let caption = wide("Snowbound"); + let style = wm::MB_OK | wm::MB_ICONWARNING; + unsafe { wm::MessageBoxW(owner(), text.as_ptr(), caption.as_ptr(), style) }; + }); } -/// Asks whether to go ahead with `action`, offering `cancel` too: a task dialog with those -/// buttons where the common controls have one, as from Windows Vista, else OK and Cancel. -pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool { +/// Asks whether to go ahead with `action`, offering `cancel` too. +pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) { + let [message, detail, cancel, action] = [message, detail, cancel, action].map(str::to_owned); + reply.after(move || ask(&message, &detail, &cancel, &action).then_some(())); +} + +/// Whether the user goes ahead with `action`: a task dialog with buttons for it and `cancel` +/// where the common controls have one, as from Windows Vista, else OK and Cancel. +fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool { use windows_sys::Win32::UI::Controls as controls; type Indirect = unsafe extern "system" fn( *const controls::TASKDIALOGCONFIG, diff --git a/crates/snowbound/web/glue.js b/crates/snowbound/web/glue.js index 5f5b1f822775cb705faae0bfb77ede8f4f6f8965..c8aac1ee4f5a2885820568e0f70fd081a3d0e26d 100644 --- a/crates/snowbound/web/glue.js +++ b/crates/snowbound/web/glue.js @@ -362,16 +362,42 @@ export function shortDate(ms) { return new Date(ms).toLocaleDateString(); } -export function askConfirm(message) { - return confirm(message); +/** Shows `message` and `detail` in a dialog over the page, with a field holding `value` + * unless that is undefined, and `buttons`, the first the default. Resolves to the index of + * the button pressed, -1 when dismissed, and the field's text. */ +function ask(message, detail, buttons, value) { + const dialog = Object.assign(document.createElement("dialog"), { className: "ask" }); + const form = Object.assign(document.createElement("form"), { method: "dialog" }); + const field = value === undefined ? null : Object.assign(document.createElement("input"), { value }); + const row = document.createElement("div"); + row.append(...buttons.map((label, index) => + Object.assign(document.createElement("button"), { value: index, textContent: label }))); + form.append(...[field, row].filter(Boolean)); + dialog.append(Object.assign(document.createElement("h1"), { textContent: message })); + if (detail) dialog.append(Object.assign(document.createElement("p"), { textContent: detail })); + dialog.append(form); + document.body.append(dialog); + dialog.showModal(); + return new Promise((resolve) => + dialog.addEventListener("close", () => { + dialog.remove(); + resolve([dialog.returnValue === "" ? -1 : Number(dialog.returnValue), field?.value]); + }), + ); +} + +export function askConfirm(message, detail, cancel, action) { + return ask(message, detail, [action, cancel]).then(([pressed]) => pressed === 0); } export function askText(message, value) { - return prompt(message, value) ?? undefined; + return ask(message, "", ["OK", "Cancel"], value).then(([pressed, text]) => + pressed === 0 ? text : undefined, + ); } -export function tell(message) { - alert(message); +export function tell(message, detail) { + ask(message, detail, ["OK"]); } export function openLink(url) { diff --git a/crates/snowbound/web/index.html b/crates/snowbound/web/index.html index 0efd2211509a955aa0396d1c0adcb7239597bb27..eba995fa13a92a9e761a4cd71171925f03608f41 100644 --- a/crates/snowbound/web/index.html +++ b/crates/snowbound/web/index.html @@ -23,6 +23,16 @@ position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); font: 14px system-ui, sans-serif; padding: 6px 14px; } + /* Dialogs (`ask` in glue.js); the default button is first in order and last in the row. */ + dialog.ask { + color-scheme: light dark; width: min(420px, 90vw); box-sizing: border-box; padding: 20px; + border: 0; border-radius: 10px; box-shadow: 0 8px 32px #0004; font: 14px system-ui, sans-serif; + } + dialog.ask::backdrop { background: #0002; } + dialog.ask h1 { margin: 0 0 8px; font-size: 15px; } + dialog.ask p { margin: 0; white-space: pre-line; } + dialog.ask input { box-sizing: border-box; width: 100%; font: inherit; } + dialog.ask div { display: flex; flex-direction: row-reverse; gap: 8px; margin-top: 16px; } #status { position: fixed; inset: 0; display: grid; place-items: center; font: 15px system-ui, sans-serif; color: #666; pointer-events: none;