From 42499fefe59cff82e17b0f6ec64b92bf090a6a78 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 1 Oct 2026 23:42:05 -0700 Subject: [PATCH] fix: dialogs never block the window Every question, alert, file chooser and date picker now answers through a callback run on the event loop instead of waiting on its thread, so the window keeps drawing, syncing and serving the clipboard while one is open. macOS shows them as sheets; Windows and Linux wait on them from a thread of their own; the web build asks with dialogs on the page instead of the browser's blocking prompts. On Linux, where no portal, zenity or kdialog can ask, Snowbound's own dialogs now open in the window, drawn with the interface kit, including a file chooser, rather than as a second process, so New Notebook, Open and Save As no longer do nothing silently on a bare desktop. Signing in to a server whose password nothing keeps happens in Open Notebook from Server, as on macOS, rather than in a system prompt. Assisted-by: claude-opus-5.5 --- arc/platforms.md | 4 +- arc/ui.md | 5 +- crates/snowbound/linux/README.md | 3 +- crates/snowbound/src/attachment.rs | 6 +- crates/snowbound/src/commands.rs | 17 +- crates/snowbound/src/desktop_linux.rs | 22 +- crates/snowbound/src/dialog_linux.rs | 600 ++++++++++++++------------ crates/snowbound/src/history.rs | 25 +- crates/snowbound/src/linux.rs | 347 ++++++++------- crates/snowbound/src/macos.rs | 123 ++++-- crates/snowbound/src/main.rs | 163 +++++-- crates/snowbound/src/manage.rs | 16 +- crates/snowbound/src/menus.rs | 56 ++- crates/snowbound/src/options.rs | 2 +- crates/snowbound/src/print.rs | 27 +- crates/snowbound/src/recycle.rs | 51 ++- crates/snowbound/src/save_as.rs | 83 ++-- crates/snowbound/src/tags.rs | 12 +- crates/snowbound/src/unpack.rs | 29 +- crates/snowbound/src/update.rs | 50 +-- crates/snowbound/src/web.rs | 91 ++-- crates/snowbound/src/windows.rs | 224 +++++----- crates/snowbound/web/glue.js | 36 +- crates/snowbound/web/index.html | 10 + 24 files changed, 1182 insertions(+), 820 deletions(-) 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; -- 2.54.0