| author | |
| committer | |
| log | 42499fefe59cff82e17b0f6ec64b92bf090a6a78 |
| tree | a6df23a2c16090f99be8a85aa3421707efe018a9 |
| parent | a7388496fddd44f7037f7224f6300a31b9a784ea |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.524 files changed, 1172 insertions(+), 810 deletions(-)
arc/platforms.md+3-1| ... | @@ -77,7 +77,9 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. | ... | @@ -77,7 +77,9 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. |
| 77 | popover menus on GNOME, shown and hidden at once as GTK 4 does, and Breeze's | 77 | popover menus on GNOME, shown and hidden at once as GTK 4 does, and Breeze's |
| 78 | on KDE, faded as KWin fades popups and scaled by Plasma's animation speed. | 78 | on KDE, faded as KWin fades popups and scaled by Plasma's animation speed. |
| 79 | The desktop is read once from `XDG_CURRENT_DESKTOP`; elsewhere the kit's own. | 79 | The desktop is read once from `XDG_CURRENT_DESKTOP`; elsewhere the kit's own. |
| 80 | - zenity or kdialog provide the pickers and alerts. The XDG settings portal | 80 | - The desktop portal provides the file pickers, zenity or kdialog the alerts, |
| 81 | and where neither can, Snowbound asks with the kit's own dialogs in the | ||
| 82 | window. No dialog waits on the event loop's thread. The XDG settings portal | ||
| 81 | provides the colour scheme. Text conventions come from the C library's | 83 | provides the colour scheme. Text conventions come from the C library's |
| 82 | locale. Fontconfig is loaded at run time, so builds need no headers for it, | 84 | locale. Fontconfig is loaded at run time, so builds need no headers for it, |
| 83 | and so is Enchant, which checks spelling with whatever dictionaries its | 85 | and so is Enchant, which checks spelling with whatever dictionaries its |
arc/ui.md+3-2| ... | @@ -48,8 +48,9 @@ look and move as the desktop's own (see [platforms](platforms.md)). | ... | @@ -48,8 +48,9 @@ look and move as the desktop's own (see [platforms](platforms.md)). |
| 48 | project aims further (Windows, and old versions of OS X). | 48 | project aims further (Windows, and old versions of OS X). |
| 49 | 49 | ||
| 50 | The platform still owns what it's best at and what people expect to be | 50 | The platform still owns what it's best at and what people expect to be |
| 51 | native. That means file pickers, alerts and date pickers (AppKit on macOS, | 51 | native. That means file pickers, alerts and date pickers (AppKit's sheets on |
| 52 | zenity or kdialog on Linux), the caret and selection colours, each platform's | 52 | macOS; the portal, zenity or kdialog on Linux, else the kit's own), none of |
| 53 | which blocks the window, the caret and selection colours, each platform's | ||
| 53 | editing chords, the keychain, the traffic lights and window frames. On iOS the | 54 | editing chords, the keychain, the traffic lights and window frames. On iOS the |
| 54 | split goes further (see [platforms](platforms.md)). | 55 | split goes further (see [platforms](platforms.md)). |
| 55 | 56 |
crates/snowbound/linux/README.md+2-1| ... | @@ -42,7 +42,8 @@ Snowbound. | ... | @@ -42,7 +42,8 @@ Snowbound. |
| 42 | - fontconfig, and X11 or Wayland with libxkbcommon. | 42 | - fontconfig, and X11 or Wayland with libxkbcommon. |
| 43 | - The desktop's portal (xdg-desktop-portal) for choosing, printing and opening | 43 | - The desktop's portal (xdg-desktop-portal) for choosing, printing and opening |
| 44 | files. Questions, alerts and the page date and time use zenity or kdialog | 44 | files. Questions, alerts and the page date and time use zenity or kdialog |
| 45 | where installed, and Snowbound's own dialogs otherwise. | 45 | where installed. Where none of these is, Snowbound asks, and chooses files, |
| 46 | with its own dialogs. | ||
| 46 | 47 | ||
| 47 | ## Known limits | 48 | ## Known limits |
| 48 | 49 |
crates/snowbound/src/attachment.rs+3-3| ... | @@ -82,9 +82,9 @@ impl State { | ... | @@ -82,9 +82,9 @@ impl State { |
| 82 | unstored(); | 82 | unstored(); |
| 83 | return Ok(()); | 83 | return Ok(()); |
| 84 | }; | 84 | }; |
| 85 | if let Some(path) = platform::pick_new("Save As", &file.filename, "Save", None) { | 85 | let bytes = bytes.clone(); |
| 86 | notebook::fs::write(path, bytes)?; | 86 | let reply = self.reply(move |_, path| Ok(notebook::fs::write(path, bytes)?)); |
| 87 | } | 87 | platform::pick_new("Save As", &file.filename, "Save", None, reply); |
| 88 | Ok(()) | 88 | Ok(()) |
| 89 | } | 89 | } |
| 90 | } | 90 | } |
crates/snowbound/src/commands.rs+8-9| ... | @@ -1558,17 +1558,16 @@ impl State { | ... | @@ -1558,17 +1558,16 @@ impl State { |
| 1558 | return Ok(()); | 1558 | return Ok(()); |
| 1559 | } | 1559 | } |
| 1560 | Id::Picture => { | 1560 | Id::Picture => { |
| 1561 | let Some(path) = platform::pick_file("Insert Picture", &crate::PICTURE_TYPES) | 1561 | let reply = self.reply(|state, path: std::path::PathBuf| { |
| 1562 | else { | 1562 | state.insert_picture(notebook::fs::read(path)?, None) |
| 1563 | return Ok(()); | 1563 | }); |
| 1564 | }; | 1564 | platform::pick_file("Insert Picture", &crate::PICTURE_TYPES, reply); |
| 1565 | return self.insert_picture(notebook::fs::read(path)?, None); | 1565 | return Ok(()); |
| 1566 | } | 1566 | } |
| 1567 | Id::Attachment => { | 1567 | Id::Attachment => { |
| 1568 | let Some(path) = platform::pick_file("Attach File", &[]) else { | 1568 | let reply = self.reply(|state, path: std::path::PathBuf| state.attach(&path, None)); |
| 1569 | return Ok(()); | 1569 | platform::pick_file("Attach File", &[], reply); |
| 1570 | }; | 1570 | return Ok(()); |
| 1571 | return self.attach(&path, None); | ||
| 1572 | } | 1571 | } |
| 1573 | Id::ScreenClipping => { | 1572 | Id::ScreenClipping => { |
| 1574 | #[cfg(target_os = "macos")] | 1573 | #[cfg(target_os = "macos")] |
crates/snowbound/src/desktop_linux.rs+18-4| ... | @@ -399,17 +399,31 @@ fn try_install() -> io::Result<()> { | ... | @@ -399,17 +399,31 @@ fn try_install() -> io::Result<()> { |
| 399 | } | 399 | } |
| 400 | 400 | ||
| 401 | /// After confirming, removes what Install wrote; notebooks stay where they are. | 401 | /// After confirming, removes what Install wrote; notebooks stay where they are. |
| 402 | pub fn uninstall() { | 402 | pub fn uninstall(proxy: &winit::event_loop::EventLoopProxy<crate::UserEvent>) { |
| 403 | let (Some(data), Some(binary)) = (data_home(), binary()) else { | 403 | let Some(binary) = binary() else { |
| 404 | return; | 404 | return; |
| 405 | }; | 405 | }; |
| 406 | let detail = format!( | 406 | let detail = format!( |
| 407 | "Snowbound leaves the app menu and {} is deleted. Your notebooks stay where they are.", | 407 | "Snowbound leaves the app menu and {} is deleted. Your notebooks stay where they are.", |
| 408 | binary.display() | 408 | binary.display() |
| 409 | ); | 409 | ); |
| 410 | if !crate::platform::confirm("Uninstall Snowbound?", &detail, "Cancel", "Uninstall") { | 410 | let reply = crate::Reply::new(proxy, |_, ()| { |
| 411 | remove(); | ||
| 412 | Ok(()) | ||
| 413 | }); | ||
| 414 | crate::platform::confirm( | ||
| 415 | "Uninstall Snowbound?", | ||
| 416 | &detail, | ||
| 417 | "Cancel", | ||
| 418 | "Uninstall", | ||
| 419 | reply, | ||
| 420 | ); | ||
| 421 | } | ||
| 422 | |||
| 423 | fn remove() { | ||
| 424 | let (Some(data), Some(binary)) = (data_home(), binary()) else { | ||
| 411 | return; | 425 | return; |
| 412 | } | 426 | }; |
| 413 | let mut failed = None; | 427 | let mut failed = None; |
| 414 | let icons = SIZES.map(|side| theme_icon(&data, side)); | 428 | let icons = SIZES.map(|side| theme_icon(&data, side)); |
| 415 | if let Some(staged) = crate::update::staging(&binary) | 429 | if let Some(staged) = crate::update::staging(&binary) |
crates/snowbound/src/dialog_linux.rs+308-272| ... | @@ -1,351 +1,387 @@ | ... | @@ -1,351 +1,387 @@ |
| 1 | //! Snowbound's own message, question and entry dialogs, for desktops with neither zenity nor | 1 | //! Snowbound's own dialogs, drawn with the interface kit in the window, where no desktop |
| 2 | //! kdialog, as GNOME's image-based systems ship: the app runs itself as the dialog, which | 2 | //! portal, zenity or kdialog can ask, as on GNOME's image-based systems or a bare X11 |
| 3 | //! answers on stdout and exits successfully once accepted. | 3 | //! desktop: messages, questions, a line of text, and a file chooser. |
| 4 | 4 | ||
| 5 | use draw::Renderer; | 5 | use crate::{Reply, State, UserEvent, art}; |
| 6 | use std::{error::Error, ffi::OsString, process::Command, sync::Arc, time::Instant}; | 6 | use accesskit::Role; |
| 7 | use ui::{Axis, Id, Spec, Ui, children, fill, fit, px}; | 7 | use std::path::{Path, PathBuf}; |
| 8 | use winit::{ | 8 | use ui::{Anchor, Axis, Flags, Id, Spec, children, fill, px}; |
| 9 | application::ApplicationHandler, | 9 | use winit::keyboard::NamedKey; |
| 10 | dpi::LogicalSize, | ||
| 11 | event::{ElementState, WindowEvent}, | ||
| 12 | event_loop::{ActiveEventLoop, EventLoop}, | ||
| 13 | keyboard::{Key, NamedKey}, | ||
| 14 | window::{Window, WindowId}, | ||
| 15 | }; | ||
| 16 | 10 | ||
| 17 | /// The argument that runs the app as a dialog. | 11 | /// What a dialog asks, and where its answer goes; one dismissed drops its reply. |
| 18 | pub const DIALOG: &str = "--dialog"; | 12 | pub enum Ask { |
| 19 | |||
| 20 | pub enum Ask<'a> { | ||
| 21 | /// A message with an OK button. | 13 | /// A message with an OK button. |
| 22 | Message, | 14 | Message, |
| 23 | /// Whether to go ahead: buttons for `cancel` and for `action`. | 15 | /// Whether to go ahead: buttons for `cancel` and for `action`. |
| 24 | Question { cancel: &'a str, action: &'a str }, | 16 | Question { |
| 25 | /// A line of text, starting as `value`. | 17 | cancel: String, |
| 26 | Entry { value: &'a str }, | 18 | action: String, |
| 27 | /// A user name, starting as `user`, and a password, answered as zenity does: `user|password`. | 19 | reply: Reply<()>, |
| 28 | Login { user: &'a str }, | 20 | }, |
| 21 | /// A line of text, starting as `text`. | ||
| 22 | Entry { text: String, reply: Reply<String> }, | ||
| 23 | /// A file in `folder`: one of `types` (extensions) unless empty, to open; or with | ||
| 24 | /// `save`, a new one named `name`. | ||
| 25 | File { | ||
| 26 | folder: PathBuf, | ||
| 27 | types: Vec<String>, | ||
| 28 | save: bool, | ||
| 29 | name: String, | ||
| 30 | reply: Reply<PathBuf>, | ||
| 31 | }, | ||
| 29 | } | 32 | } |
| 30 | 33 | ||
| 31 | /// Shows `title` and `detail` as `ask` asks, then waits: the answer, empty but for an entry, | 34 | /// A dialog waiting its turn, or shown. |
| 32 | /// or None when cancelled. | 35 | pub struct Dialog { |
| 33 | pub fn ask(title: &str, detail: &str, ask: Ask) -> Option<String> { | 36 | title: String, |
| 34 | let (kind, extra): (&str, &[&str]) = match &ask { | 37 | detail: String, |
| 35 | Ask::Message => ("message", &[]), | 38 | ask: Ask, |
| 36 | Ask::Question { cancel, action } => ("question", &[cancel, action]), | 39 | /// A file chooser's folder: its folders, then the files it offers, each with whether it |
| 37 | Ask::Entry { value } => ("entry", &[value]), | 40 | /// is a folder; or why the folder can't be read. |
| 38 | Ask::Login { user } => ("login", &[user]), | 41 | entries: Result<Vec<(String, bool)>, String>, |
| 39 | }; | 42 | shown: bool, |
| 40 | let output = crate::loader::executable() | ||
| 41 | .and_then(|exe| { | ||
| 42 | Command::new(exe) | ||
| 43 | .args([DIALOG, kind, title, detail]) | ||
| 44 | .args(extra) | ||
| 45 | .stderr(std::process::Stdio::inherit()) | ||
| 46 | .output() | ||
| 47 | }) | ||
| 48 | .inspect_err(|error| eprintln!("Cannot show {title:?}: {error}")) | ||
| 49 | .ok()?; | ||
| 50 | output | ||
| 51 | .status | ||
| 52 | .success() | ||
| 53 | .then(|| String::from_utf8_lossy(&output.stdout).into_owned()) | ||
| 54 | } | 43 | } |
| 55 | 44 | ||
| 56 | /// The dialog `ask` would show, given the arguments after `DIALOG`. | 45 | /// Shows `ask`, titled `title` and explained by `detail`, once the dialogs before it are |
| 57 | pub fn run(mut args: impl Iterator<Item = OsString>) -> Result<(), Box<dyn Error>> { | 46 | /// answered. Any thread may ask. |
| 58 | let mut next = || { | 47 | pub fn show(title: String, detail: String, ask: Ask) { |
| 59 | args.next() | 48 | let entries = match &ask { |
| 60 | .and_then(|arg| arg.into_string().ok()) | 49 | Ask::File { folder, types, .. } => listing(folder, types), |
| 61 | .unwrap_or_default() | 50 | _ => Ok(Vec::new()), |
| 62 | }; | 51 | }; |
| 63 | let [kind, title, detail, first, second] = [(); 5].map(|()| next()); | 52 | let dialog = Dialog { |
| 64 | let mut dialog = Dialog { | ||
| 65 | kind, | ||
| 66 | title, | 53 | title, |
| 67 | detail, | 54 | detail, |
| 68 | buttons: [first, second], | 55 | ask, |
| 69 | fields: Default::default(), | 56 | entries, |
| 70 | shown: None, | 57 | shown: false, |
| 71 | accepted: false, | ||
| 72 | }; | 58 | }; |
| 73 | if dialog.kind == "entry" || dialog.kind == "login" { | 59 | if let Some(proxy) = crate::platform::PROXY.get() { |
| 74 | dialog.fields[0] = std::mem::take(&mut dialog.buttons[0]); | 60 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |state| { |
| 61 | state.asking.push_back(dialog); | ||
| 62 | Ok(()) | ||
| 63 | }))); | ||
| 75 | } | 64 | } |
| 76 | EventLoop::new()?.run_app(&mut dialog)?; | ||
| 77 | if !dialog.accepted { | ||
| 78 | std::process::exit(1); | ||
| 79 | } | ||
| 80 | let [first, second] = &dialog.fields; | ||
| 81 | match dialog.kind.as_str() { | ||
| 82 | "entry" => print!("{first}"), | ||
| 83 | "login" => print!("{first}|{second}"), | ||
| 84 | _ => {} | ||
| 85 | } | ||
| 86 | Ok(()) | ||
| 87 | } | 65 | } |
| 88 | 66 | ||
| 89 | struct Dialog { | 67 | /// The folder a file chooser starts in where it is given none. |
| 90 | kind: String, | 68 | pub fn start_folder() -> PathBuf { |
| 91 | title: String, | 69 | crate::platform::documents_dir() |
| 92 | detail: String, | 70 | .filter(|folder| folder.is_dir()) |
| 93 | /// A question's cancel and action labels. | 71 | .or_else(|| std::env::var_os("HOME").map(PathBuf::from)) |
| 94 | buttons: [String; 2], | 72 | .unwrap_or_else(|| "/".into()) |
| 95 | /// An entry's text, or a login's user name and password. | ||
| 96 | fields: [String; 2], | ||
| 97 | shown: Option<Shown>, | ||
| 98 | accepted: bool, | ||
| 99 | } | 73 | } |
| 100 | 74 | ||
| 101 | struct Shown { | 75 | /// `folder`'s folders, then its files of `types` (extensions; all where empty), each by |
| 102 | window: Arc<Window>, | 76 | /// name without regard to case; hidden ones left out. |
| 103 | surface: crate::surface::Surface, | 77 | fn listing(folder: &Path, types: &[String]) -> Result<Vec<(String, bool)>, String> { |
| 104 | renderer: Renderer, | 78 | let mut entries: Vec<(String, bool)> = std::fs::read_dir(folder) |
| 105 | ui: Ui, | 79 | .map_err(|error| format!("Couldn't read {}: {error}", folder.display()))? |
| 80 | .filter_map(|entry| { | ||
| 81 | let entry = entry.ok()?; | ||
| 82 | let name = entry.file_name().into_string().ok()?; | ||
| 83 | let is_folder = entry.path().is_dir(); | ||
| 84 | let offered = is_folder | ||
| 85 | || types.is_empty() | ||
| 86 | || Path::new(&name) | ||
| 87 | .extension() | ||
| 88 | .and_then(|extension| extension.to_str()) | ||
| 89 | .is_some_and(|extension| { | ||
| 90 | types | ||
| 91 | .iter() | ||
| 92 | .any(|kind| kind.eq_ignore_ascii_case(extension)) | ||
| 93 | }); | ||
| 94 | (offered && !name.starts_with('.')).then_some((name, is_folder)) | ||
| 95 | }) | ||
| 96 | .collect(); | ||
| 97 | entries.sort_by_cached_key(|(name, is_folder)| (!is_folder, name.to_lowercase())); | ||
| 98 | Ok(entries) | ||
| 106 | } | 99 | } |
| 107 | 100 | ||
| 108 | fn field(index: usize) -> Id { | 101 | fn id() -> Id { |
| 109 | Id::ROOT.child(("field", index)) | 102 | Id::ROOT.child("own dialog") |
| 110 | } | 103 | } |
| 111 | 104 | ||
| 112 | impl Dialog { | 105 | fn field() -> Id { |
| 113 | fn finish(&mut self, event_loop: &ActiveEventLoop, accepted: bool) { | 106 | id().child("field") |
| 114 | self.accepted = accepted; | 107 | } |
| 115 | // The window's resources end while the event loop still holds its display. | 108 | |
| 116 | self.shown = None; | 109 | /// How the user answered a dialog this frame. |
| 117 | event_loop.exit(); | 110 | enum Answer { |
| 118 | } | 111 | Cancel, |
| 112 | Accept, | ||
| 113 | /// A file chooser moves to this folder. | ||
| 114 | Open(PathBuf), | ||
| 115 | /// A file chooser's file to open, or the name to save as. | ||
| 116 | Choose(String), | ||
| 117 | } | ||
| 119 | 118 | ||
| 120 | /// Lays out and paints the dialog; Some once a button answers it. | 119 | impl State { |
| 121 | fn frame(&mut self) -> Result<Option<bool>, Box<dyn Error>> { | 120 | /// Builds the first dialog waiting, and answers it once the user does. |
| 122 | let Some(shown) = &mut self.shown else { | 121 | pub(crate) fn own_dialog(&mut self) { |
| 123 | return Ok(None); | 122 | let Some(dialog) = self.asking.front_mut() else { |
| 123 | return; | ||
| 124 | }; | 124 | }; |
| 125 | let ui = &mut shown.ui; | 125 | let ui = &mut self.ui; |
| 126 | let scale = shown.window.scale_factor() as f32; | 126 | if !dialog.shown { |
| 127 | let size = shown.window.inner_size(); | 127 | dialog.shown = true; |
| 128 | ui.begin( | 128 | ui.open_popup(id()); |
| 129 | [size.width as f32 / scale, size.height as f32 / scale], | 129 | if let Ask::Entry { .. } | Ask::File { save: true, .. } = dialog.ask { |
| 130 | scale, | 130 | ui.focus_all(field()); |
| 131 | Instant::now(), | 131 | } |
| 132 | ); | 132 | } else if !ui.popup_open(id()) { |
| 133 | self.asking.pop_front(); | ||
| 134 | return; | ||
| 135 | } | ||
| 133 | let theme = ui.theme.clone(); | 136 | let theme = ui.theme.clone(); |
| 134 | ui.open( | 137 | let row = theme.font_size * 2.0; |
| 135 | "dialog", | 138 | let entered = ui::popup::navigation(ui, &[id(), field()], &[NamedKey::Enter]) |
| 139 | .contains(&NamedKey::Enter); | ||
| 140 | let file = matches!(dialog.ask, Ask::File { .. }); | ||
| 141 | ui.open_as( | ||
| 142 | id(), | ||
| 136 | Spec { | 143 | Spec { |
| 137 | axis: Axis::Y, | 144 | axis: Axis::Y, |
| 138 | size: [fill(), fill()], | 145 | size: [px(if file { 480.0 } else { 420.0 }), children()], |
| 139 | fill: Some(theme.popup), | 146 | fill: Some(theme.popup), |
| 140 | pad: [20.0, 16.0], | 147 | border: Some(theme.chip), |
| 141 | gap: 10.0, | 148 | shadow: Some(theme.shadow), |
| 142 | role: Some(accesskit::Role::Dialog), | 149 | radius: 8.0, |
| 150 | pad: [16.0, 12.0], | ||
| 151 | gap: 6.0, | ||
| 152 | anchor: Some(Anchor::Dialog), | ||
| 153 | role: Some(Role::Dialog), | ||
| 143 | ..Spec::default() | 154 | ..Spec::default() |
| 144 | }, | 155 | }, |
| 145 | ); | 156 | ); |
| 146 | let text = |ui: &mut Ui, part, text: &str, bold| { | 157 | if let Some(node) = ui.access(id()) { |
| 158 | node.set_label(dialog.title.as_str()); | ||
| 159 | } | ||
| 160 | let text = |ui: &mut ui::Ui, part, text: &str, bold: bool, color| { | ||
| 147 | ui.leaf( | 161 | ui.leaf( |
| 148 | part, | 162 | part, |
| 149 | Spec { | 163 | Spec { |
| 150 | size: [fill(), fit()], | 164 | size: [fill(), ui::fit()], |
| 151 | text: Some(text), | 165 | text: Some(text), |
| 152 | bold, | 166 | bold, |
| 167 | color, | ||
| 153 | overflow: ui::Overflow::Wrap, | 168 | overflow: ui::Overflow::Wrap, |
| 169 | role: bold.then_some(Role::Heading), | ||
| 154 | ..Spec::default() | 170 | ..Spec::default() |
| 155 | }, | 171 | }, |
| 156 | ); | 172 | ); |
| 157 | }; | 173 | }; |
| 158 | text(ui, "title", &self.title, true); | 174 | text(ui, "title", &dialog.title, true, None); |
| 159 | if !self.detail.is_empty() { | 175 | if !dialog.detail.is_empty() { |
| 160 | text(ui, "detail", &self.detail, false); | 176 | text(ui, "detail", &dialog.detail, false, None); |
| 161 | } | 177 | } |
| 162 | let entry = Spec { | 178 | let field_spec = Spec { |
| 163 | size: [fill(), px(theme.font_size * 2.0)], | 179 | size: [fill(), px(row)], |
| 164 | fill: Some(theme.base), | 180 | fill: Some(theme.base), |
| 165 | border: Some(theme.accent), | 181 | border: Some(theme.accent), |
| 166 | radius: 4.0, | 182 | radius: 4.0, |
| 167 | pad: [6.0, 0.0], | 183 | pad: [6.0, 0.0], |
| 168 | ..Spec::default() | 184 | ..Spec::default() |
| 169 | }; | 185 | }; |
| 170 | let fields = match self.kind.as_str() { | 186 | // Return goes ahead, but for a question, whose safe answer is the default as |
| 171 | "entry" => 1, | 187 | // AppKit's and Windows' are. |
| 172 | "login" => 2, | 188 | let mut answer = entered.then_some(match dialog.ask { |
| 173 | _ => 0, | 189 | Ask::Question { .. } => Answer::Cancel, |
| 174 | }; | 190 | _ => Answer::Accept, |
| 175 | if fields > 0 && ui.focused().is_none() { | 191 | }); |
| 176 | ui.set_focus(Some(field(0))); | 192 | match &mut dialog.ask { |
| 177 | } | 193 | Ask::Entry { text, .. } => { |
| 178 | for (index, value) in self.fields.iter_mut().take(fields).enumerate() { | 194 | ui::text_field(ui, field(), text, "", field_spec); |
| 179 | if index == 1 { | 195 | crate::name(ui, field(), &dialog.title); |
| 180 | text(ui, "password label", "Password:", false); | ||
| 181 | ui::password_field(ui, field(index), value, "", entry.clone()); | ||
| 182 | } else { | ||
| 183 | ui::text_field(ui, field(index), value, "", entry.clone()); | ||
| 184 | } | 196 | } |
| 197 | Ask::File { | ||
| 198 | folder, save, name, .. | ||
| 199 | } => { | ||
| 200 | text( | ||
| 201 | ui, | ||
| 202 | "place", | ||
| 203 | &folder.to_string_lossy(), | ||
| 204 | false, | ||
| 205 | Some(theme.text_dim), | ||
| 206 | ); | ||
| 207 | let list = ui.open( | ||
| 208 | "entries", | ||
| 209 | Spec { | ||
| 210 | flags: Flags::SCROLL | Flags::CLIP, | ||
| 211 | axis: Axis::Y, | ||
| 212 | size: [fill(), px(row * 10.0 + 8.0)], | ||
| 213 | fill: Some(theme.base), | ||
| 214 | border: Some(theme.chip), | ||
| 215 | radius: 4.0, | ||
| 216 | pad: [4.0, 4.0], | ||
| 217 | role: Some(Role::List), | ||
| 218 | ..Spec::default() | ||
| 219 | }, | ||
| 220 | ); | ||
| 221 | if let Some(node) = ui.access(list) { | ||
| 222 | node.set_label("Files"); | ||
| 223 | } | ||
| 224 | match &dialog.entries { | ||
| 225 | Ok(entries) => { | ||
| 226 | for (entry, is_folder) in entries { | ||
| 227 | let signal = ui.leaf( | ||
| 228 | ("entry", entry), | ||
| 229 | Spec { | ||
| 230 | flags: Flags::CLICKABLE, | ||
| 231 | size: [fill(), px(row)], | ||
| 232 | icon: Some(if *is_folder { art::FOLDER } else { art::PAGE }), | ||
| 233 | text: Some(entry), | ||
| 234 | fill: (!is_folder && entry == name).then_some(theme.chip), | ||
| 235 | hover_fill: Some(theme.hover()), | ||
| 236 | radius: 4.0, | ||
| 237 | pad: [8.0, 0.0], | ||
| 238 | gap: 6.0, | ||
| 239 | role: Some(Role::ListItem), | ||
| 240 | ..Spec::default() | ||
| 241 | }, | ||
| 242 | ); | ||
| 243 | if *is_folder && signal.clicked { | ||
| 244 | answer = Some(Answer::Open(folder.join(entry))); | ||
| 245 | } else if !is_folder && signal.pressed { | ||
| 246 | // A double click opens or saves as the file. | ||
| 247 | answer = | ||
| 248 | Some(if signal.unit == draw::edit::SelectionUnit::Grapheme { | ||
| 249 | Answer::Choose(entry.clone()) | ||
| 250 | } else { | ||
| 251 | Answer::Accept | ||
| 252 | }); | ||
| 253 | } | ||
| 254 | } | ||
| 255 | } | ||
| 256 | Err(reason) => text(ui, "unread", reason, false, Some(theme.text_dim)), | ||
| 257 | } | ||
| 258 | ui.close(); | ||
| 259 | if *save { | ||
| 260 | ui::text_field(ui, field(), name, "", field_spec); | ||
| 261 | crate::name(ui, field(), "Name"); | ||
| 262 | } | ||
| 263 | } | ||
| 264 | Ask::Message | Ask::Question { .. } => {} | ||
| 185 | } | 265 | } |
| 186 | ui.leaf( | ||
| 187 | "space", | ||
| 188 | Spec { | ||
| 189 | size: [fill(), fill()], | ||
| 190 | ..Spec::default() | ||
| 191 | }, | ||
| 192 | ); | ||
| 193 | ui.open( | 266 | ui.open( |
| 194 | "buttons", | 267 | "buttons", |
| 195 | Spec { | 268 | Spec { |
| 196 | size: [fill(), children()], | 269 | size: [fill(), children()], |
| 270 | pad: [0.0, 8.0], | ||
| 197 | gap: 8.0, | 271 | gap: 8.0, |
| 198 | ..Spec::default() | 272 | ..Spec::default() |
| 199 | }, | 273 | }, |
| 200 | ); | 274 | ); |
| 275 | if let Ask::File { folder, .. } = &dialog.ask | ||
| 276 | && let Some(parent) = folder.parent() | ||
| 277 | && ui::button(ui, "up", "Up").clicked | ||
| 278 | { | ||
| 279 | answer = Some(Answer::Open(parent.to_owned())); | ||
| 280 | } | ||
| 201 | ui.leaf( | 281 | ui.leaf( |
| 202 | "space", | 282 | "space", |
| 203 | Spec { | 283 | Spec { |
| 204 | size: [fill(), px(0.0)], | 284 | size: [fill(), px(1.0)], |
| 205 | ..Spec::default() | 285 | ..Spec::default() |
| 206 | }, | 286 | }, |
| 207 | ); | 287 | ); |
| 208 | let [cancel, action] = match self.kind.as_str() { | 288 | let (cancel, action) = match &dialog.ask { |
| 209 | "question" => [self.buttons[0].as_str(), self.buttons[1].as_str()], | 289 | Ask::Message => (None, "OK"), |
| 210 | "message" => ["", "OK"], | 290 | Ask::Question { cancel, action, .. } => (Some(cancel.as_str()), action.as_str()), |
| 211 | _ => ["Cancel", "OK"], | 291 | Ask::Entry { .. } => (Some("Cancel"), "OK"), |
| 292 | Ask::File { save: false, .. } => (Some("Cancel"), "Open"), | ||
| 293 | Ask::File { save: true, .. } => (Some("Cancel"), "Save"), | ||
| 212 | }; | 294 | }; |
| 213 | let mut answer = None; | 295 | if cancel.is_some_and(|cancel| ui::button(ui, "cancel", cancel).clicked) { |
| 214 | if !cancel.is_empty() && ui::button(ui, "cancel", cancel).clicked { | 296 | answer = Some(Answer::Cancel); |
| 215 | answer = Some(false); | ||
| 216 | } | 297 | } |
| 217 | if ui::button(ui, "action", action).clicked { | 298 | if ui::button(ui, "action", action).clicked { |
| 218 | answer = Some(true); | 299 | answer = Some(Answer::Accept); |
| 219 | } | 300 | } |
| 220 | ui.close(); | 301 | ui.close(); |
| 221 | ui.close(); | 302 | ui.close(); |
| 222 | ui.end(); | ||
| 223 | shown.window.set_cursor(ui.cursor().unwrap_or_default()); | ||
| 224 | if let Some(frame) = shown.surface.frame(&shown.renderer)? { | ||
| 225 | let interface = ui.layers(); | ||
| 226 | let layers: Vec<_> = interface | ||
| 227 | .iter() | ||
| 228 | .filter_map(|layer| match layer { | ||
| 229 | ui::Layer::Primitives(primitives) => Some(primitives.layer(scale)), | ||
| 230 | ui::Layer::Custom { .. } => None, | ||
| 231 | }) | ||
| 232 | .collect(); | ||
| 233 | shown | ||
| 234 | .renderer | ||
| 235 | .draw(&frame.target, shown.surface.size, theme.popup, &layers) | ||
| 236 | .map_err(|error| format!("Dialog drawing failed: {error:?}"))?; | ||
| 237 | shown.surface.present(&shown.renderer, frame); | ||
| 238 | } | ||
| 239 | if ui.wants_frame() { | ||
| 240 | shown.window.request_redraw(); | ||
| 241 | } | ||
| 242 | Ok(answer) | ||
| 243 | } | ||
| 244 | } | ||
| 245 | |||
| 246 | impl ApplicationHandler for Dialog { | ||
| 247 | fn resumed(&mut self, event_loop: &ActiveEventLoop) { | ||
| 248 | if self.shown.is_some() { | ||
| 249 | return; | ||
| 250 | } | ||
| 251 | let height = match self.kind.as_str() { | ||
| 252 | "login" => 260.0, | ||
| 253 | "entry" => 200.0, | ||
| 254 | _ => 170.0, | ||
| 255 | }; | ||
| 256 | let shown = (|| -> Result<Shown, Box<dyn Error>> { | ||
| 257 | let window = Arc::new( | ||
| 258 | event_loop.create_window( | ||
| 259 | crate::platform::window_attributes() | ||
| 260 | .with_title(&self.title) | ||
| 261 | .with_inner_size(LogicalSize::new(440.0, height)) | ||
| 262 | .with_resizable(false), | ||
| 263 | )?, | ||
| 264 | ); | ||
| 265 | let (surface, renderer) = | ||
| 266 | pollster::block_on(crate::surface::Surface::new(window.clone(), false))?; | ||
| 267 | let appearance = crate::platform::appearance(&window); | ||
| 268 | window.set_theme(Some(appearance)); | ||
| 269 | let ui = Ui::new( | ||
| 270 | crate::theme(appearance, false, false), | ||
| 271 | crate::platform::double_click_interval(), | ||
| 272 | ); | ||
| 273 | Ok(Shown { | ||
| 274 | window, | ||
| 275 | surface, | ||
| 276 | renderer, | ||
| 277 | ui, | ||
| 278 | }) | ||
| 279 | })(); | ||
| 280 | match shown { | ||
| 281 | Ok(shown) => { | ||
| 282 | shown.window.request_redraw(); | ||
| 283 | self.shown = Some(shown); | ||
| 284 | } | ||
| 285 | Err(error) => { | ||
| 286 | eprintln!("Cannot show {:?}: {error}", self.title); | ||
| 287 | self.finish(event_loop, false); | ||
| 288 | } | ||
| 289 | } | ||
| 290 | } | ||
| 291 | 303 | ||
| 292 | fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) { | 304 | if let Ask::File { |
| 293 | let Some(shown) = &mut self.shown else { | 305 | folder, |
| 294 | return; | 306 | types, |
| 295 | }; | 307 | save, |
| 296 | let scale = shown.window.scale_factor() as f32; | 308 | name, |
| 297 | let input = match event { | 309 | .. |
| 298 | WindowEvent::CloseRequested => return self.finish(event_loop, false), | 310 | } = &mut dialog.ask |
| 299 | WindowEvent::RedrawRequested => { | 311 | { |
| 300 | match self.frame() { | 312 | match answer { |
| 301 | Ok(Some(answer)) => self.finish(event_loop, answer), | 313 | Some(Answer::Open(to)) => { |
| 302 | Ok(None) => {} | 314 | dialog.entries = listing(&to, types); |
| 303 | Err(error) => { | 315 | *folder = to; |
| 304 | eprintln!("{error}"); | 316 | if !*save { |
| 305 | self.finish(event_loop, false); | 317 | name.clear(); |
| 306 | } | 318 | } |
| 319 | return; | ||
| 307 | } | 320 | } |
| 308 | return; | 321 | Some(Answer::Choose(entry)) => { |
| 309 | } | 322 | *name = entry; |
| 310 | WindowEvent::Resized(size) => { | 323 | return; |
| 311 | shown.surface.size = [size.width, size.height]; | ||
| 312 | shown.surface.configure(&shown.renderer); | ||
| 313 | return shown.window.request_redraw(); | ||
| 314 | } | ||
| 315 | WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => { | ||
| 316 | match event.logical_key { | ||
| 317 | Key::Named(NamedKey::Enter) => return self.finish(event_loop, true), | ||
| 318 | Key::Named(NamedKey::Escape) => return self.finish(event_loop, false), | ||
| 319 | key => ui::Event::Key { | ||
| 320 | key, | ||
| 321 | text: event.text.map(|text| text.to_string()), | ||
| 322 | }, | ||
| 323 | } | ||
| 324 | } | ||
| 325 | WindowEvent::ModifiersChanged(modifiers) => ui::Event::Modifiers(modifiers.state()), | ||
| 326 | WindowEvent::Ime(ime) => ui::Event::Ime(ime), | ||
| 327 | WindowEvent::CursorMoved { position, .. } => { | ||
| 328 | ui::Event::PointerMoved([position.x as f32 / scale, position.y as f32 / scale]) | ||
| 329 | } | ||
| 330 | WindowEvent::CursorLeft { .. } => ui::Event::PointerLeft, | ||
| 331 | WindowEvent::MouseInput { state, button, .. } => { | ||
| 332 | // A press away from the controls moves the window, which has no title bar | ||
| 333 | // under winit's Adwaita frame. | ||
| 334 | if state == ElementState::Pressed | ||
| 335 | && shown.ui.cursor().is_none() | ||
| 336 | && let Err(error) = shown.window.drag_window() | ||
| 337 | { | ||
| 338 | eprintln!("{error}"); | ||
| 339 | } | 324 | } |
| 340 | ui::Event::Button { | 325 | Some(Answer::Accept) if name.trim().is_empty() => return, |
| 341 | button, | 326 | // A folder typed in opens. |
| 342 | pressed: state == ElementState::Pressed, | 327 | Some(Answer::Accept) if folder.join(name.trim()).is_dir() => { |
| 343 | at: Instant::now(), | 328 | let to = folder.join(name.trim()); |
| 329 | dialog.entries = listing(&to, types); | ||
| 330 | *folder = to; | ||
| 331 | name.clear(); | ||
| 332 | return; | ||
| 344 | } | 333 | } |
| 334 | _ => {} | ||
| 345 | } | 335 | } |
| 346 | _ => return, | 336 | } |
| 337 | let Some(answer) = answer else { | ||
| 338 | return; | ||
| 347 | }; | 339 | }; |
| 348 | shown.ui.event(input); | 340 | let dialog = self.asking.pop_front().expect("A dialog is shown"); |
| 349 | shown.window.request_redraw(); | 341 | self.ui.close_popup(id()); |
| 342 | if let Answer::Cancel = answer { | ||
| 343 | return; | ||
| 344 | } | ||
| 345 | match dialog.ask { | ||
| 346 | Ask::Message => {} | ||
| 347 | Ask::Question { reply, .. } => reply.send(()), | ||
| 348 | Ask::Entry { text, reply } => reply.send(text), | ||
| 349 | Ask::File { | ||
| 350 | folder, | ||
| 351 | name, | ||
| 352 | reply, | ||
| 353 | .. | ||
| 354 | } => reply.send(folder.join(name.trim())), | ||
| 355 | } | ||
| 356 | } | ||
| 357 | } | ||
| 358 | |||
| 359 | #[cfg(test)] | ||
| 360 | mod tests { | ||
| 361 | use super::*; | ||
| 362 | |||
| 363 | #[test] | ||
| 364 | fn a_file_chooser_lists_folders_then_the_files_it_offers() { | ||
| 365 | let folder = std::env::temp_dir().join(format!("snowbound-own-{}", std::process::id())); | ||
| 366 | for name in ["b", "A", ".hidden"] { | ||
| 367 | std::fs::create_dir_all(folder.join(name)).unwrap(); | ||
| 368 | } | ||
| 369 | for name in ["z.one", "Notes.ONETOC2", "picture.png", ".x.one"] { | ||
| 370 | std::fs::write(folder.join(name), b"").unwrap(); | ||
| 371 | } | ||
| 372 | let types = ["one", "onetoc2"].map(String::from); | ||
| 373 | let expected = [ | ||
| 374 | ("A", true), | ||
| 375 | ("b", true), | ||
| 376 | ("Notes.ONETOC2", false), | ||
| 377 | ("z.one", false), | ||
| 378 | ]; | ||
| 379 | assert_eq!( | ||
| 380 | listing(&folder, &types).unwrap(), | ||
| 381 | expected.map(|(name, is_folder)| (name.to_owned(), is_folder)) | ||
| 382 | ); | ||
| 383 | assert_eq!(listing(&folder, &[]).unwrap().len(), 5); | ||
| 384 | assert!(listing(&folder.join("missing"), &[]).is_err()); | ||
| 385 | std::fs::remove_dir_all(&folder).unwrap(); | ||
| 350 | } | 386 | } |
| 351 | } | 387 | } |
crates/snowbound/src/history.rs+21-4| ... | @@ -188,7 +188,7 @@ impl State { | ... | @@ -188,7 +188,7 @@ impl State { |
| 188 | 188 | ||
| 189 | /// Delete All Versions in the section, its group or the notebook, once confirmed. | 189 | /// Delete All Versions in the section, its group or the notebook, once confirmed. |
| 190 | pub(crate) fn delete_all_versions(&mut self, scope: Scope) -> Result<(), Box<dyn Error>> { | 190 | pub(crate) fn delete_all_versions(&mut self, scope: Scope) -> Result<(), Box<dyn Error>> { |
| 191 | let session = self.session.as_mut().ok_or("No section is open")?; | 191 | let session = self.session.as_ref().ok_or("No section is open")?; |
| 192 | let tab = &session.tabs[session.tab]; | 192 | let tab = &session.tabs[session.tab]; |
| 193 | let (open, section) = (tab.path.clone(), tab.name.clone()); | 193 | let (open, section) = (tab.path.clone(), tab.name.clone()); |
| 194 | let folder = open | 194 | let folder = open |
| ... | @@ -203,14 +203,31 @@ impl State { | ... | @@ -203,14 +203,31 @@ impl State { |
| 203 | ), | 203 | ), |
| 204 | Scope::Notebook => ("notebook", session.library.name.clone()), | 204 | Scope::Notebook => ("notebook", session.library.name.clone()), |
| 205 | }; | 205 | }; |
| 206 | if !platform::confirm( | 206 | let asked = Arc::clone(&session.library); |
| 207 | platform::confirm( | ||
| 207 | &format!("Do you want to delete all page versions in the {container} \"{name}\"?"), | 208 | &format!("Do you want to delete all page versions in the {container} \"{name}\"?"), |
| 208 | "You can't restore these versions afterward.", | 209 | "You can't restore these versions afterward.", |
| 209 | "Cancel", | 210 | "Cancel", |
| 210 | "Delete Versions", | 211 | "Delete Versions", |
| 211 | ) { | 212 | self.reply(move |state, ()| state.clear_versions(scope, &asked, open, folder)), |
| 213 | ); | ||
| 214 | Ok(()) | ||
| 215 | } | ||
| 216 | |||
| 217 | /// Deletes every page version in `scope` around `open`, the section in `library` that | ||
| 218 | /// was shown when asked; nothing once another is shown. | ||
| 219 | fn clear_versions( | ||
| 220 | &mut self, | ||
| 221 | scope: Scope, | ||
| 222 | library: &Arc<Library>, | ||
| 223 | open: String, | ||
| 224 | folder: String, | ||
| 225 | ) -> Result<(), Box<dyn Error>> { | ||
| 226 | let Some(session) = self.session.as_mut().filter(|session| { | ||
| 227 | Arc::ptr_eq(&session.library, library) && session.tabs[session.tab].path == open | ||
| 228 | }) else { | ||
| 212 | return Ok(()); | 229 | return Ok(()); |
| 213 | } | 230 | }; |
| 214 | clear(&session.section)?; | 231 | clear(&session.section)?; |
| 215 | session.refresh_history()?; | 232 | session.refresh_history()?; |
| 216 | if session.version.is_some() { | 233 | if session.version.is_some() { |
crates/snowbound/src/linux.rs+197-150| ... | @@ -3,13 +3,13 @@ | ... | @@ -3,13 +3,13 @@ |
| 3 | //! the colour scheme from the settings portal, and text conventions from the C library's | 3 | //! the colour scheme from the settings portal, and text conventions from the C library's |
| 4 | //! locale. | 4 | //! locale. |
| 5 | 5 | ||
| 6 | use crate::dialog::Ask; | 6 | use crate::{Reply, dialog::Ask}; |
| 7 | use canvas::date::DateField; | 7 | use canvas::date::DateField; |
| 8 | use std::{ | 8 | use std::{ |
| 9 | cell::UnsafeCell, | 9 | cell::UnsafeCell, |
| 10 | collections::HashMap, | 10 | collections::HashMap, |
| 11 | error::Error, | 11 | error::Error, |
| 12 | ffi::{CStr, CString, OsString, c_char, c_void}, | 12 | ffi::{CStr, CString, OsStr, OsString, c_char, c_void}, |
| 13 | os::unix::ffi::OsStringExt, | 13 | os::unix::ffi::OsStringExt, |
| 14 | path::PathBuf, | 14 | path::PathBuf, |
| 15 | process::Command, | 15 | process::Command, |
| ... | @@ -42,7 +42,8 @@ const MINIMIZE: &[&str] = &[include_str!("../assets/icons/window-minimize.svg")] | ... | @@ -42,7 +42,8 @@ const MINIMIZE: &[&str] = &[include_str!("../assets/icons/window-minimize.svg")] |
| 42 | const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")]; | 42 | const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")]; |
| 43 | const RESTORE: &[&str] = &[include_str!("../assets/icons/window-restore.svg")]; | 43 | const RESTORE: &[&str] = &[include_str!("../assets/icons/window-restore.svg")]; |
| 44 | 44 | ||
| 45 | static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new(); | 45 | /// The event loop's, for signals and Snowbound's own dialogs. |
| 46 | pub(crate) static PROXY: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new(); | ||
| 46 | /// Breeze's corner radius in units of the decoration's pixel grid, as `f32` bits; zero where | 47 | /// Breeze's corner radius in units of the decoration's pixel grid, as `f32` bits; zero where |
| 47 | /// KWin leaves windows square. | 48 | /// KWin leaves windows square. |
| 48 | static BREEZE_RADIUS: AtomicU32 = AtomicU32::new(0); | 49 | static BREEZE_RADIUS: AtomicU32 = AtomicU32::new(0); |
| ... | @@ -77,7 +78,8 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, Box<dyn | ... | @@ -77,7 +78,8 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, Box<dyn |
| 77 | if !headless { | 78 | if !headless { |
| 78 | crate::desktop::prepare(&event_loop); | 79 | crate::desktop::prepare(&event_loop); |
| 79 | } | 80 | } |
| 80 | QUIT.set(event_loop.create_proxy()) | 81 | PROXY |
| 82 | .set(event_loop.create_proxy()) | ||
| 81 | .expect("Only one application event loop is created"); | 83 | .expect("Only one application event loop is created"); |
| 82 | watch_settings(event_loop.create_proxy()); | 84 | watch_settings(event_loop.create_proxy()); |
| 83 | if desktop() == Desktop::Kde { | 85 | if desktop() == Desktop::Kde { |
| ... | @@ -649,7 +651,7 @@ pub fn window_controls(ui: &mut Ui, window: &Window) { | ... | @@ -649,7 +651,7 @@ pub fn window_controls(ui: &mut Ui, window: &Window) { |
| 649 | "minimize" => window.set_minimized(true), | 651 | "minimize" => window.set_minimized(true), |
| 650 | "maximize" => zoom(window), | 652 | "maximize" => zoom(window), |
| 651 | _ => { | 653 | _ => { |
| 652 | if let Some(proxy) = QUIT.get() { | 654 | if let Some(proxy) = PROXY.get() { |
| 653 | let _ = proxy.send_event(crate::UserEvent::Quit); | 655 | let _ = proxy.send_event(crate::UserEvent::Quit); |
| 654 | } | 656 | } |
| 655 | } | 657 | } |
| ... | @@ -1199,7 +1201,7 @@ pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> { | ... | @@ -1199,7 +1201,7 @@ pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> { |
| 1199 | } | 1201 | } |
| 1200 | 1202 | ||
| 1201 | /// The password the Secret Service keeps for `mount`'s account, as GNOME's file manager | 1203 | /// The password the Secret Service keeps for `mount`'s account, as GNOME's file manager |
| 1202 | /// saves one, or what the user types when it keeps none. | 1204 | /// saves one; without one, Snowbound's sign-in asks. |
| 1203 | pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> { | 1205 | pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> { |
| 1204 | let user = mount.user.clone().unwrap_or_default(); | 1206 | let user = mount.user.clone().unwrap_or_default(); |
| 1205 | let mut lookup = Command::new("secret-tool"); | 1207 | let mut lookup = Command::new("secret-tool"); |
| ... | @@ -1219,24 +1221,10 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, | ... | @@ -1219,24 +1221,10 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, |
| 1219 | domain: mount.domain.clone(), | 1221 | domain: mount.domain.clone(), |
| 1220 | }); | 1222 | }); |
| 1221 | } | 1223 | } |
| 1222 | let title = format!("Sign in to {}", mount.server); | 1224 | Err(format!( |
| 1223 | let asked = dialog( | 1225 | "Enter the password for \u{201c}{user}\u{201d} on \u{201c}{}\u{201d}.", |
| 1224 | ["--password", "--username", &format!("--title={title}")], | 1226 | mount.host() |
| 1225 | ["--password", &title], | 1227 | )) |
| 1226 | ) | ||
| 1227 | .unwrap_or_else(|_| crate::dialog::ask(&title, "User name:", Ask::Login { user: &user })) | ||
| 1228 | .ok_or_else(|| "Signing in was canceled".to_owned())?; | ||
| 1229 | // zenity answers "user|password"; kdialog only the password. | ||
| 1230 | let (typed, password) = asked | ||
| 1231 | .split_once('|') | ||
| 1232 | .map_or((user.clone(), asked.clone()), |(typed, password)| { | ||
| 1233 | (typed.to_owned(), password.to_owned()) | ||
| 1234 | }); | ||
| 1235 | Ok(crate::library::Login { | ||
| 1236 | user: if typed.is_empty() { user } else { typed }, | ||
| 1237 | password, | ||
| 1238 | domain: mount.domain.clone(), | ||
| 1239 | }) | ||
| 1240 | } | 1228 | } |
| 1241 | 1229 | ||
| 1242 | /// What the sign-in offers for keeping a password, where the Secret Service's tool is | 1230 | /// What the sign-in offers for keeping a password, where the Secret Service's tool is |
| ... | @@ -1402,61 +1390,75 @@ pub fn date_text(filetime: u64) -> [String; 2] { | ... | @@ -1402,61 +1390,75 @@ pub fn date_text(filetime: u64) -> [String; 2] { |
| 1402 | date_labels(&local_time(filetime)) | 1390 | date_labels(&local_time(filetime)) |
| 1403 | } | 1391 | } |
| 1404 | 1392 | ||
| 1405 | /// Asks for the page's date or time with the desktop's dialog tool. | 1393 | /// Asks for the page's date or time with the desktop's dialog tool, or else as text in |
| 1394 | /// Snowbound's own dialog. | ||
| 1406 | pub fn edit_date( | 1395 | pub fn edit_date( |
| 1407 | timestamp: u64, | 1396 | timestamp: u64, |
| 1408 | field: DateField, | 1397 | field: DateField, |
| 1409 | title: &str, | 1398 | title: &str, |
| 1410 | ) -> Result<Option<(u64, [String; 2])>, &'static str> { | 1399 | reply: Reply<Result<(u64, [String; 2]), &'static str>>, |
| 1411 | let tm = local_time(timestamp); | 1400 | ) { |
| 1412 | let answer = match field { | 1401 | let title = title.to_owned(); |
| 1413 | DateField::Date => dialog( | 1402 | let reply = reply |
| 1414 | [ | 1403 | .map(move |answer: String| changed_date(timestamp, local_time(timestamp), field, &answer)); |
| 1415 | "--calendar", | 1404 | std::thread::spawn(move || { |
| 1416 | &format!("--title={title}"), | 1405 | let tm = local_time(timestamp); |
| 1417 | "--text=", | 1406 | let (asked, detail, value) = match field { |
| 1418 | &format!("--day={}", tm.tm_mday), | 1407 | DateField::Date => ( |
| 1419 | &format!("--month={}", tm.tm_mon + 1), | 1408 | dialog( |
| 1420 | &format!("--year={}", tm.tm_year + 1900), | 1409 | &[ |
| 1421 | "--date-format=%Y-%m-%d", | 1410 | "--calendar", |
| 1422 | ], | 1411 | &format!("--title={title}"), |
| 1423 | ["--calendar", title, "--dateformat", "yyyy-MM-dd"], | 1412 | "--text=", |
| 1424 | ) | 1413 | &format!("--day={}", tm.tm_mday), |
| 1425 | .unwrap_or_else(|_| { | 1414 | &format!("--month={}", tm.tm_mon + 1), |
| 1426 | let value = format(&tm, c"%Y-%m-%d"); | 1415 | &format!("--year={}", tm.tm_year + 1900), |
| 1427 | crate::dialog::ask( | 1416 | "--date-format=%Y-%m-%d", |
| 1428 | title, | 1417 | ], |
| 1418 | &["--calendar", &title, "--dateformat", "yyyy-MM-dd"], | ||
| 1419 | ), | ||
| 1429 | "Date, as year-month-day:", | 1420 | "Date, as year-month-day:", |
| 1430 | Ask::Entry { value: &value }, | 1421 | format(&tm, c"%Y-%m-%d"), |
| 1431 | ) | 1422 | ), |
| 1432 | }), | 1423 | DateField::Time => { |
| 1433 | DateField::Time => { | 1424 | let current = format(&tm, c"%H:%M"); |
| 1434 | let current = format(&tm, c"%H:%M"); | 1425 | let asked = dialog( |
| 1435 | dialog( | 1426 | &[ |
| 1436 | [ | 1427 | "--entry", |
| 1437 | "--entry", | 1428 | &format!("--title={title}"), |
| 1438 | &format!("--title={title}"), | 1429 | "--text=Time, as hours and minutes:", |
| 1439 | "--text=Time, as hours and minutes:", | 1430 | &format!("--entry-text={current}"), |
| 1440 | &format!("--entry-text={current}"), | 1431 | ], |
| 1441 | ], | 1432 | &["--inputbox", "Time, as hours and minutes:", &current], |
| 1442 | ["--inputbox", "Time, as hours and minutes:", &current], | 1433 | ); |
| 1443 | ) | 1434 | (asked, "Time, as hours and minutes:", current) |
| 1444 | .unwrap_or_else(|_| { | 1435 | } |
| 1445 | let value = Ask::Entry { value: &current }; | 1436 | }; |
| 1446 | crate::dialog::ask(title, "Time, as hours and minutes:", value) | 1437 | match asked { |
| 1447 | }) | 1438 | Ok(Some(answer)) => reply.send(answer), |
| 1439 | Ok(None) => {} | ||
| 1440 | Err(_) => { | ||
| 1441 | let ask = Ask::Entry { text: value, reply }; | ||
| 1442 | crate::dialog::show(title, detail.to_owned(), ask); | ||
| 1443 | } | ||
| 1448 | } | 1444 | } |
| 1449 | }; | 1445 | }); |
| 1450 | let Some(answer) = answer else { | 1446 | } |
| 1451 | return Ok(None); | 1447 | |
| 1452 | }; | 1448 | /// `timestamp` with `field` changed to what the user typed or chose, `answer`. |
| 1449 | fn changed_date( | ||
| 1450 | timestamp: u64, | ||
| 1451 | tm: libc::tm, | ||
| 1452 | field: DateField, | ||
| 1453 | answer: &str, | ||
| 1454 | ) -> Result<(u64, [String; 2]), &'static str> { | ||
| 1453 | let numbers: Vec<i32> = answer | 1455 | let numbers: Vec<i32> = answer |
| 1454 | .split(|c: char| !c.is_ascii_digit()) | 1456 | .split(|c: char| !c.is_ascii_digit()) |
| 1455 | .filter(|part| !part.is_empty()) | 1457 | .filter(|part| !part.is_empty()) |
| 1456 | .map(|part| part.parse().unwrap_or(i32::MAX)) | 1458 | .map(|part| part.parse().unwrap_or(i32::MAX)) |
| 1457 | .collect(); | 1459 | .collect(); |
| 1458 | let afternoon = answer.to_lowercase().contains("pm"); | 1460 | let afternoon = answer.to_lowercase().contains("pm"); |
| 1459 | merge_date(timestamp, tm, field, &numbers, afternoon).map(Some) | 1461 | merge_date(timestamp, tm, field, &numbers, afternoon) |
| 1460 | } | 1462 | } |
| 1461 | 1463 | ||
| 1462 | fn merge_date( | 1464 | fn merge_date( |
| ... | @@ -1497,36 +1499,24 @@ fn merge_date( | ... | @@ -1497,36 +1499,24 @@ fn merge_date( |
| 1497 | Ok((updated, date_labels(&tm))) | 1499 | Ok((updated, date_labels(&tm))) |
| 1498 | } | 1500 | } |
| 1499 | 1501 | ||
| 1500 | /// Runs zenity, or kdialog where zenity is missing; the answer is None when cancelled, and an | 1502 | /// Runs zenity, or kdialog where zenity is missing, waiting for the answer: None when |
| 1501 | /// error when neither is installed. | 1503 | /// cancelled, and an error when neither is installed. |
| 1502 | fn dialog<const Z: usize, const K: usize>( | 1504 | fn dialog( |
| 1503 | zenity: [&str; Z], | 1505 | zenity: &[impl AsRef<OsStr>], |
| 1504 | kdialog: [&str; K], | 1506 | kdialog: &[impl AsRef<OsStr>], |
| 1505 | ) -> Result<Option<String>, &'static str> { | 1507 | ) -> std::io::Result<Option<String>> { |
| 1506 | let output = Command::new("zenity") | 1508 | let output = Command::new("zenity") |
| 1507 | .args(zenity) | 1509 | .args(zenity) |
| 1508 | .output() | 1510 | .output() |
| 1509 | .or_else(|_| Command::new("kdialog").args(kdialog).output()) | 1511 | .or_else(|_| Command::new("kdialog").args(kdialog).output())?; |
| 1510 | .map_err(|_| "Install zenity or kdialog for Snowbound's dialogs.")?; | ||
| 1511 | Ok(output | 1512 | Ok(output |
| 1512 | .status | 1513 | .status |
| 1513 | .success() | 1514 | .success() |
| 1514 | .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())) | 1515 | .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())) |
| 1515 | } | 1516 | } |
| 1516 | 1517 | ||
| 1517 | /// The path a file dialog answered; none when cancelled or when no tool could ask. | ||
| 1518 | fn picked(asked: Result<Option<String>, &str>) -> Option<PathBuf> { | ||
| 1519 | asked | ||
| 1520 | .unwrap_or_else(|error| { | ||
| 1521 | eprintln!("{error}"); | ||
| 1522 | None | ||
| 1523 | }) | ||
| 1524 | .filter(|path| !path.is_empty()) | ||
| 1525 | .map(PathBuf::from) | ||
| 1526 | } | ||
| 1527 | |||
| 1528 | /// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. | 1518 | /// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. |
| 1529 | pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> { | 1519 | pub fn pick_file(title: &str, types: &[&str], reply: Reply<PathBuf>) { |
| 1530 | let globs: Vec<_> = types.iter().map(|kind| format!("*.{kind}")).collect(); | 1520 | let globs: Vec<_> = types.iter().map(|kind| format!("*.{kind}")).collect(); |
| 1531 | let patterns = match types { | 1521 | let patterns = match types { |
| 1532 | [] => "*".to_owned(), | 1522 | [] => "*".to_owned(), |
| ... | @@ -1536,17 +1526,26 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> { | ... | @@ -1536,17 +1526,26 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> { |
| 1536 | if !globs.is_empty() { | 1526 | if !globs.is_empty() { |
| 1537 | options.insert("filters", filters(&patterns, &globs)); | 1527 | options.insert("filters", filters(&patterns, &globs)); |
| 1538 | } | 1528 | } |
| 1529 | let types = types.iter().map(|kind| kind.to_string()).collect(); | ||
| 1539 | choose( | 1530 | choose( |
| 1540 | title, | 1531 | title, |
| 1541 | "OpenFile", | 1532 | "OpenFile", |
| 1542 | options, | 1533 | options, |
| 1543 | [ | 1534 | &[ |
| 1544 | "--file-selection", | 1535 | "--file-selection", |
| 1545 | &format!("--title={title}"), | 1536 | &format!("--title={title}"), |
| 1546 | &format!("--file-filter={patterns}"), | 1537 | &format!("--file-filter={patterns}"), |
| 1547 | ], | 1538 | ], |
| 1548 | ["--getopenfilename", ".", &patterns, "--title", title], | 1539 | &["--getopenfilename", ".", &patterns, "--title", title], |
| 1549 | ) | 1540 | move |reply| Ask::File { |
| 1541 | folder: crate::dialog::start_folder(), | ||
| 1542 | types, | ||
| 1543 | save: false, | ||
| 1544 | name: String::new(), | ||
| 1545 | reply, | ||
| 1546 | }, | ||
| 1547 | reply, | ||
| 1548 | ); | ||
| 1550 | } | 1549 | } |
| 1551 | 1550 | ||
| 1552 | /// A file chooser's `filters` option: one filter, `name`, of `globs`. | 1551 | /// A file chooser's `filters` option: one filter, `name`, of `globs`. |
| ... | @@ -1555,33 +1554,48 @@ fn filters(name: &str, globs: &[String]) -> Value<'static> { | ... | @@ -1555,33 +1554,48 @@ fn filters(name: &str, globs: &[String]) -> Value<'static> { |
| 1555 | Value::from(vec![(name.to_owned(), globs)]) | 1554 | Value::from(vec![(name.to_owned(), globs)]) |
| 1556 | } | 1555 | } |
| 1557 | 1556 | ||
| 1558 | /// The file the file chooser portal answers `method` with, titled `title`, or where no portal | 1557 | /// Replies with the file the file chooser portal answers `method` with, titled `title`; |
| 1559 | /// answers, zenity's or kdialog's; None when cancelled. | 1558 | /// where no portal answers, zenity's or kdialog's, given their arguments; and where neither |
| 1560 | fn choose<const Z: usize, const K: usize>( | 1559 | /// is installed, Snowbound's `own`. |
| 1560 | fn choose( | ||
| 1561 | title: &str, | 1561 | title: &str, |
| 1562 | method: &str, | 1562 | method: &'static str, |
| 1563 | mut options: HashMap<&str, Value>, | 1563 | mut options: HashMap<&'static str, Value<'static>>, |
| 1564 | zenity: [&str; Z], | 1564 | zenity: &[&str], |
| 1565 | kdialog: [&str; K], | 1565 | kdialog: &[&str], |
| 1566 | ) -> Option<PathBuf> { | 1566 | own: impl FnOnce(Reply<PathBuf>) -> Ask + Send + 'static, |
| 1567 | let chosen = portal("org.freedesktop.portal.FileChooser") | 1567 | reply: Reply<PathBuf>, |
| 1568 | .map_err(Into::into) | 1568 | ) { |
| 1569 | .and_then(|chooser| { | 1569 | let title = title.to_owned(); |
| 1570 | portal_request(&chooser, |token| { | 1570 | let [zenity, kdialog]: [Vec<String>; 2] = |
| 1571 | options.insert("handle_token", Value::from(token.to_owned())); | 1571 | [zenity, kdialog].map(|args| args.iter().map(|arg| arg.to_string()).collect()); |
| 1572 | chooser.call(method, &("", title, &options)) | 1572 | std::thread::spawn(move || { |
| 1573 | }) | 1573 | let chosen = portal("org.freedesktop.portal.FileChooser") |
| 1574 | }); | 1574 | .map_err(Into::into) |
| 1575 | let results = match chosen { | 1575 | .and_then(|chooser| { |
| 1576 | Ok(results) => results?, | 1576 | portal_request(&chooser, |token| { |
| 1577 | Err(error) => { | 1577 | options.insert("handle_token", Value::from(token.to_owned())); |
| 1578 | eprintln!("The file chooser portal failed: {error}"); | 1578 | chooser.call(method, &("", &title, &options)) |
| 1579 | return picked(dialog(zenity, kdialog)); | 1579 | }) |
| 1580 | }); | ||
| 1581 | let path = match chosen { | ||
| 1582 | Ok(results) => results.and_then(|results| { | ||
| 1583 | let uris: Vec<String> = results.get("uris")?.try_clone().ok()?.try_into().ok()?; | ||
| 1584 | let path = uris.first()?.strip_prefix("file://")?; | ||
| 1585 | Some(OsString::from_vec(crate::paste::percent_decode(path)).into()) | ||
| 1586 | }), | ||
| 1587 | Err(error) => { | ||
| 1588 | eprintln!("The file chooser portal failed: {error}"); | ||
| 1589 | match dialog(&zenity, &kdialog) { | ||
| 1590 | Ok(path) => path.filter(|path| !path.is_empty()).map(PathBuf::from), | ||
| 1591 | Err(_) => return crate::dialog::show(title, String::new(), own(reply)), | ||
| 1592 | } | ||
| 1593 | } | ||
| 1594 | }; | ||
| 1595 | if let Some(path) = path { | ||
| 1596 | reply.send(path); | ||
| 1580 | } | 1597 | } |
| 1581 | }; | 1598 | }); |
| 1582 | let uris: Vec<String> = results.get("uris")?.try_clone().ok()?.try_into().ok()?; | ||
| 1583 | let path = uris.first()?.strip_prefix("file://")?; | ||
| 1584 | Some(std::ffi::OsString::from_vec(crate::paste::percent_decode(path)).into()) | ||
| 1585 | } | 1599 | } |
| 1586 | 1600 | ||
| 1587 | /// The desktop portal's `interface`, whose calls give up after five seconds. | 1601 | /// The desktop portal's `interface`, whose calls give up after five seconds. |
| ... | @@ -1678,45 +1692,65 @@ pub fn reveal(target: impl AsRef<std::ffi::OsStr>) { | ... | @@ -1678,45 +1692,65 @@ pub fn reveal(target: impl AsRef<std::ffi::OsStr>) { |
| 1678 | } | 1692 | } |
| 1679 | } | 1693 | } |
| 1680 | 1694 | ||
| 1681 | /// Asks whether to go ahead with `action`, through zenity or kdialog where installed. | 1695 | /// Asks whether to go ahead with `action`, through zenity or kdialog where installed, or |
| 1682 | pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool { | 1696 | /// else Snowbound's own dialog. |
| 1683 | let status = Command::new("zenity") | 1697 | pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) { |
| 1684 | .args([ | 1698 | let [message, detail, cancel, action] = [message, detail, cancel, action].map(str::to_owned); |
| 1685 | "--question", | 1699 | std::thread::spawn(move || { |
| 1686 | &format!("--title={message}"), | 1700 | let status = Command::new("zenity") |
| 1687 | &format!("--text={detail}"), | 1701 | .args([ |
| 1688 | &format!("--ok-label={action}"), | 1702 | "--question", |
| 1689 | &format!("--cancel-label={cancel}"), | 1703 | &format!("--title={message}"), |
| 1690 | ]) | 1704 | &format!("--text={detail}"), |
| 1691 | .status() | 1705 | &format!("--ok-label={action}"), |
| 1692 | .or_else(|_| { | 1706 | &format!("--cancel-label={cancel}"), |
| 1693 | Command::new("kdialog") | 1707 | ]) |
| 1694 | .args(["--warningcontinuecancel", detail, "--title", message]) | 1708 | .status() |
| 1695 | .args(["--continue-label", action]) | 1709 | .or_else(|_| { |
| 1696 | .status() | 1710 | Command::new("kdialog") |
| 1697 | }); | 1711 | .args(["--warningcontinuecancel", &detail, "--title", &message]) |
| 1698 | match status { | 1712 | .args(["--continue-label", &action]) |
| 1699 | Ok(status) => status.success(), | 1713 | .status() |
| 1700 | Err(_) => crate::dialog::ask(message, detail, Ask::Question { cancel, action }).is_some(), | 1714 | }); |
| 1701 | } | 1715 | match status { |
| 1716 | Ok(status) if status.success() => reply.send(()), | ||
| 1717 | Ok(_) => {} | ||
| 1718 | Err(_) => { | ||
| 1719 | let ask = Ask::Question { | ||
| 1720 | cancel, | ||
| 1721 | action, | ||
| 1722 | reply, | ||
| 1723 | }; | ||
| 1724 | crate::dialog::show(message, detail, ask); | ||
| 1725 | } | ||
| 1726 | } | ||
| 1727 | }); | ||
| 1702 | } | 1728 | } |
| 1703 | 1729 | ||
| 1704 | /// Asks for a notebook's table of contents or a section file, titled `title`; None when | 1730 | /// Asks for a notebook's table of contents or a section file, titled `title`. |
| 1705 | /// cancelled or when no tool can ask. | 1731 | pub fn pick_notebook(title: &str, reply: Reply<PathBuf>) { |
| 1706 | pub fn pick_notebook(title: &str) -> Option<PathBuf> { | ||
| 1707 | let name = "OneNote notebooks, sections and packages"; | 1732 | let name = "OneNote notebooks, sections and packages"; |
| 1708 | let globs = ["*.onetoc2", "*.one", "*.onepkg"].map(String::from); | 1733 | let types = ["onetoc2", "one", "onepkg"]; |
| 1734 | let globs = types.map(|kind| format!("*.{kind}")); | ||
| 1709 | choose( | 1735 | choose( |
| 1710 | title, | 1736 | title, |
| 1711 | "OpenFile", | 1737 | "OpenFile", |
| 1712 | HashMap::from([("filters", filters(name, &globs))]), | 1738 | HashMap::from([("filters", filters(name, &globs))]), |
| 1713 | [ | 1739 | &[ |
| 1714 | "--file-selection", | 1740 | "--file-selection", |
| 1715 | &format!("--title={title}"), | 1741 | &format!("--title={title}"), |
| 1716 | &format!("--file-filter={name} | {}", globs.join(" ")), | 1742 | &format!("--file-filter={name} | {}", globs.join(" ")), |
| 1717 | ], | 1743 | ], |
| 1718 | ["--getopenfilename", ".", &globs.join(" "), "--title", title], | 1744 | &["--getopenfilename", ".", &globs.join(" "), "--title", title], |
| 1719 | ) | 1745 | move |reply| Ask::File { |
| 1746 | folder: crate::dialog::start_folder(), | ||
| 1747 | types: types.map(String::from).to_vec(), | ||
| 1748 | save: false, | ||
| 1749 | name: String::new(), | ||
| 1750 | reply, | ||
| 1751 | }, | ||
| 1752 | reply, | ||
| 1753 | ); | ||
| 1720 | } | 1754 | } |
| 1721 | 1755 | ||
| 1722 | /// Asks where to put something named `name` by default, in `folder` where given, with | 1756 | /// Asks where to put something named `name` by default, in `folder` where given, with |
| ... | @@ -1726,29 +1760,42 @@ pub fn pick_new( | ... | @@ -1726,29 +1760,42 @@ pub fn pick_new( |
| 1726 | name: &str, | 1760 | name: &str, |
| 1727 | action: &str, | 1761 | action: &str, |
| 1728 | folder: Option<&std::path::Path>, | 1762 | folder: Option<&std::path::Path>, |
| 1729 | ) -> Option<PathBuf> { | 1763 | reply: Reply<PathBuf>, |
| 1764 | ) { | ||
| 1730 | use std::os::unix::ffi::OsStrExt; | 1765 | use std::os::unix::ffi::OsStrExt; |
| 1731 | let mut options = HashMap::from([ | 1766 | let mut options = HashMap::from([ |
| 1732 | ("current_name", Value::from(name)), | 1767 | ("current_name", Value::from(name.to_owned())), |
| 1733 | ("accept_label", Value::from(action)), | 1768 | ("accept_label", Value::from(action.to_owned())), |
| 1734 | ]); | 1769 | ]); |
| 1735 | if let Some(folder) = folder { | 1770 | if let Some(folder) = folder { |
| 1736 | let mut bytes = folder.as_os_str().as_bytes().to_vec(); | 1771 | let mut bytes = folder.as_os_str().as_bytes().to_vec(); |
| 1737 | bytes.push(0); | 1772 | bytes.push(0); |
| 1738 | options.insert("current_folder", Value::from(bytes)); | 1773 | options.insert("current_folder", Value::from(bytes)); |
| 1739 | } | 1774 | } |
| 1775 | let (named, folder) = ( | ||
| 1776 | name.to_owned(), | ||
| 1777 | folder.map_or_else(crate::dialog::start_folder, Into::into), | ||
| 1778 | ); | ||
| 1740 | choose( | 1779 | choose( |
| 1741 | title, | 1780 | title, |
| 1742 | "SaveFile", | 1781 | "SaveFile", |
| 1743 | options, | 1782 | options, |
| 1744 | [ | 1783 | &[ |
| 1745 | "--file-selection", | 1784 | "--file-selection", |
| 1746 | "--save", | 1785 | "--save", |
| 1747 | &format!("--title={title}"), | 1786 | &format!("--title={title}"), |
| 1748 | &format!("--filename={name}"), | 1787 | &format!("--filename={name}"), |
| 1749 | ], | 1788 | ], |
| 1750 | ["--getsavefilename", name, "--title", title], | 1789 | &["--getsavefilename", name, "--title", title], |
| 1751 | ) | 1790 | move |reply| Ask::File { |
| 1791 | folder, | ||
| 1792 | types: Vec::new(), | ||
| 1793 | save: true, | ||
| 1794 | name: named, | ||
| 1795 | reply, | ||
| 1796 | }, | ||
| 1797 | reply, | ||
| 1798 | ); | ||
| 1752 | } | 1799 | } |
| 1753 | 1800 | ||
| 1754 | /// Tells the user something they asked for could not be done: `message`, then what to do. | 1801 | /// 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) { | ... | @@ -1779,7 +1826,7 @@ fn show([zenity, kdialog]: [&'static str; 2], message: &str, detail: &str) { |
| 1779 | .status() | 1826 | .status() |
| 1780 | }); | 1827 | }); |
| 1781 | if shown.is_err() { | 1828 | if shown.is_err() { |
| 1782 | crate::dialog::ask(&title, &text, Ask::Message); | 1829 | crate::dialog::show(title, text, Ask::Message); |
| 1783 | } | 1830 | } |
| 1784 | }); | 1831 | }); |
| 1785 | } | 1832 | } |
crates/snowbound/src/macos.rs+93-30| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | use crate::commands; | 1 | use crate::{Reply, commands}; |
| 2 | use canvas::date::DateField; | 2 | use canvas::date::DateField; |
| 3 | use objc2::{ | 3 | use objc2::{ |
| 4 | ClassType, DeclaredClass, class, | 4 | ClassType, DeclaredClass, class, |
| ... | @@ -11,7 +11,8 @@ use objc2::{ | ... | @@ -11,7 +11,8 @@ use objc2::{ |
| 11 | use objc2_app_kit::{ | 11 | use objc2_app_kit::{ |
| 12 | NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor, | 12 | NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor, |
| 13 | NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, | 13 | NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, |
| 14 | NSEventSubtype, NSEventType, NSMenu, NSMenuItem, | 14 | NSEventSubtype, NSEventType, NSMenu, NSMenuItem, NSModalResponse, NSModalResponseCancel, |
| 15 | NSModalResponseOK, NSSavePanel, NSWindow, | ||
| 15 | }; | 16 | }; |
| 16 | use objc2_foundation::{ | 17 | use objc2_foundation::{ |
| 17 | MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter, | 18 | MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter, |
| ... | @@ -92,6 +93,59 @@ pub(crate) fn ns_window(window: &Window) -> Retained<AnyObject> { | ... | @@ -92,6 +93,59 @@ pub(crate) fn ns_window(window: &Window) -> Retained<AnyObject> { |
| 92 | unsafe { msg_send_id![&ns_view(window), window] } | 93 | unsafe { msg_send_id![&ns_view(window), window] } |
| 93 | } | 94 | } |
| 94 | 95 | ||
| 96 | thread_local! { | ||
| 97 | /// The window dialogs open on as sheets. | ||
| 98 | static WINDOW: RefCell<Option<Retained<NSWindow>>> = const { RefCell::new(None) }; | ||
| 99 | /// The alert shown as a sheet, which Escape cancels. | ||
| 100 | static ALERT: RefCell<Option<Retained<NSWindow>>> = const { RefCell::new(None) }; | ||
| 101 | } | ||
| 102 | |||
| 103 | /// Shows `alert` as a sheet on the window, then answers with the button pressed. Before | ||
| 104 | /// 10.9, whose alerts have no such sheet, the alert waits as a modal one. | ||
| 105 | fn begin_alert(alert: &NSAlert, answer: impl FnOnce(NSModalResponse) + 'static) { | ||
| 106 | unsafe { | ||
| 107 | let sheets: bool = msg_send![ | ||
| 108 | alert, | ||
| 109 | respondsToSelector: sel!(beginSheetModalForWindow:completionHandler:) | ||
| 110 | ]; | ||
| 111 | let Some(window) = WINDOW.with_borrow(Clone::clone).filter(|_| sheets) else { | ||
| 112 | return answer(alert.runModal()); | ||
| 113 | }; | ||
| 114 | ALERT.set(Some(alert.window())); | ||
| 115 | let answer = Cell::new(Some(answer)); | ||
| 116 | let done = block2::RcBlock::new(move |response: NSModalResponse| { | ||
| 117 | ALERT.set(None); | ||
| 118 | if let Some(answer) = answer.take() { | ||
| 119 | answer(response); | ||
| 120 | } | ||
| 121 | }); | ||
| 122 | let _: () = msg_send![alert, beginSheetModalForWindow: &*window, completionHandler: &*done]; | ||
| 123 | } | ||
| 124 | } | ||
| 125 | |||
| 126 | /// Shows `panel` as a sheet on the window, then replies with the path chosen. | ||
| 127 | fn begin_panel(panel: Retained<NSSavePanel>, reply: Reply<std::path::PathBuf>) { | ||
| 128 | let chosen = panel.clone(); | ||
| 129 | let reply = Cell::new(Some(reply)); | ||
| 130 | let done = block2::RcBlock::new(move |response: NSModalResponse| { | ||
| 131 | let path = unsafe { chosen.URL().and_then(|url| url.path()) }; | ||
| 132 | if let (true, Some(path), Some(reply)) = (response == NSModalResponseOK, path, reply.take()) | ||
| 133 | { | ||
| 134 | reply.send(path.to_string().into()); | ||
| 135 | } | ||
| 136 | }); | ||
| 137 | unsafe { | ||
| 138 | match WINDOW.with_borrow(Clone::clone) { | ||
| 139 | Some(window) => { | ||
| 140 | let _: () = msg_send![&panel, beginSheetModalForWindow: &*window, completionHandler: &*done]; | ||
| 141 | } | ||
| 142 | None => { | ||
| 143 | let _: () = msg_send![&panel, beginWithCompletionHandler: &*done]; | ||
| 144 | } | ||
| 145 | } | ||
| 146 | } | ||
| 147 | } | ||
| 148 | |||
| 95 | /// Room the traffic lights take at the title bar's leading edge. | 149 | /// Room the traffic lights take at the title bar's leading edge. |
| 96 | pub const LEADING: f32 = 78.0; | 150 | pub const LEADING: f32 = 78.0; |
| 97 | /// The title bar's trailing margin, the gap the traffic lights leave before the toolbar. | 151 | /// The title bar's trailing margin, the gap the traffic lights leave before the toolbar. |
| ... | @@ -131,6 +185,7 @@ pub fn window_attributes() -> WindowAttributes { | ... | @@ -131,6 +185,7 @@ pub fn window_attributes() -> WindowAttributes { |
| 131 | pub fn install_title_bar(window: &Window) { | 185 | pub fn install_title_bar(window: &Window) { |
| 132 | MainThreadMarker::new().expect("Windows belong to the main thread"); | 186 | MainThreadMarker::new().expect("Windows belong to the main thread"); |
| 133 | let window = ns_window(window); | 187 | let window = ns_window(window); |
| 188 | WINDOW.set(Some(unsafe { Retained::cast(window.clone()) })); | ||
| 134 | let responds = | 189 | let responds = |
| 135 | |selector: Sel| -> bool { unsafe { msg_send![&window, respondsToSelector: selector] } }; | 190 | |selector: Sel| -> bool { unsafe { msg_send![&window, respondsToSelector: selector] } }; |
| 136 | // Before 10.10 the title bar keeps its own line above the row. | 191 | // Before 10.10 the title bar keeps its own line above the row. |
| ... | @@ -765,7 +820,7 @@ pub fn documents_dir() -> Option<std::path::PathBuf> { | ... | @@ -765,7 +820,7 @@ pub fn documents_dir() -> Option<std::path::PathBuf> { |
| 765 | 820 | ||
| 766 | /// Asks for a notebook folder or a notebook file with the system's open panel, titled | 821 | /// Asks for a notebook folder or a notebook file with the system's open panel, titled |
| 767 | /// `title`. | 822 | /// `title`. |
| 768 | pub fn pick_notebook(title: &str) -> Option<std::path::PathBuf> { | 823 | pub fn pick_notebook(title: &str, reply: Reply<std::path::PathBuf>) { |
| 769 | let mtm = MainThreadMarker::new().expect("Panels belong to the main thread"); | 824 | let mtm = MainThreadMarker::new().expect("Panels belong to the main thread"); |
| 770 | unsafe { | 825 | unsafe { |
| 771 | let panel = objc2_app_kit::NSOpenPanel::openPanel(mtm); | 826 | let panel = objc2_app_kit::NSOpenPanel::openPanel(mtm); |
| ... | @@ -774,11 +829,7 @@ pub fn pick_notebook(title: &str) -> Option<std::path::PathBuf> { | ... | @@ -774,11 +829,7 @@ pub fn pick_notebook(title: &str) -> Option<std::path::PathBuf> { |
| 774 | panel.setCanCreateDirectories(true); | 829 | panel.setCanCreateDirectories(true); |
| 775 | panel.setTitle(Some(&NSString::from_str(title))); | 830 | panel.setTitle(Some(&NSString::from_str(title))); |
| 776 | panel.setPrompt(Some(&NSString::from_str("Open"))); | 831 | panel.setPrompt(Some(&NSString::from_str("Open"))); |
| 777 | if panel.runModal() != objc2_app_kit::NSModalResponseOK { | 832 | begin_panel(Retained::into_super(panel), reply); |
| 778 | return None; | ||
| 779 | } | ||
| 780 | let path = panel.URLs().firstObject()?.path()?; | ||
| 781 | Some(path.to_string().into()) | ||
| 782 | } | 833 | } |
| 783 | } | 834 | } |
| 784 | 835 | ||
| ... | @@ -789,10 +840,11 @@ pub fn pick_new( | ... | @@ -789,10 +840,11 @@ pub fn pick_new( |
| 789 | name: &str, | 840 | name: &str, |
| 790 | action: &str, | 841 | action: &str, |
| 791 | folder: Option<&std::path::Path>, | 842 | folder: Option<&std::path::Path>, |
| 792 | ) -> Option<std::path::PathBuf> { | 843 | reply: Reply<std::path::PathBuf>, |
| 844 | ) { | ||
| 793 | let mtm = MainThreadMarker::new().expect("Panels belong to the main thread"); | 845 | let mtm = MainThreadMarker::new().expect("Panels belong to the main thread"); |
| 794 | unsafe { | 846 | unsafe { |
| 795 | let panel = objc2_app_kit::NSSavePanel::savePanel(mtm); | 847 | let panel = NSSavePanel::savePanel(mtm); |
| 796 | if let Some(folder) = folder.and_then(std::path::Path::to_str) { | 848 | if let Some(folder) = folder.and_then(std::path::Path::to_str) { |
| 797 | panel.setDirectoryURL(Some(&objc2_foundation::NSURL::fileURLWithPath( | 849 | panel.setDirectoryURL(Some(&objc2_foundation::NSURL::fileURLWithPath( |
| 798 | &NSString::from_str(folder), | 850 | &NSString::from_str(folder), |
| ... | @@ -802,16 +854,13 @@ pub fn pick_new( | ... | @@ -802,16 +854,13 @@ pub fn pick_new( |
| 802 | panel.setTitle(Some(&NSString::from_str(title))); | 854 | panel.setTitle(Some(&NSString::from_str(title))); |
| 803 | panel.setPrompt(Some(&NSString::from_str(action))); | 855 | panel.setPrompt(Some(&NSString::from_str(action))); |
| 804 | panel.setNameFieldStringValue(&NSString::from_str(name)); | 856 | panel.setNameFieldStringValue(&NSString::from_str(name)); |
| 805 | if panel.runModal() != objc2_app_kit::NSModalResponseOK { | 857 | begin_panel(panel, reply); |
| 806 | return None; | ||
| 807 | } | ||
| 808 | Some(panel.URL()?.path()?.to_string().into()) | ||
| 809 | } | 858 | } |
| 810 | } | 859 | } |
| 811 | 860 | ||
| 812 | /// Asks for a file to insert, one of `types` (extensions) unless empty, with the system's | 861 | /// Asks for a file to insert, one of `types` (extensions) unless empty, with the system's |
| 813 | /// open panel titled `title`. | 862 | /// open panel titled `title`. |
| 814 | pub fn pick_file(title: &str, types: &[&str]) -> Option<std::path::PathBuf> { | 863 | pub fn pick_file(title: &str, types: &[&str], reply: Reply<std::path::PathBuf>) { |
| 815 | let mtm = MainThreadMarker::new().expect("Panels belong to the main thread"); | 864 | let mtm = MainThreadMarker::new().expect("Panels belong to the main thread"); |
| 816 | unsafe { | 865 | unsafe { |
| 817 | let panel = objc2_app_kit::NSOpenPanel::openPanel(mtm); | 866 | let panel = objc2_app_kit::NSOpenPanel::openPanel(mtm); |
| ... | @@ -822,11 +871,7 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option<std::path::PathBuf> { | ... | @@ -822,11 +871,7 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option<std::path::PathBuf> { |
| 822 | } | 871 | } |
| 823 | panel.setTitle(Some(&NSString::from_str(title))); | 872 | panel.setTitle(Some(&NSString::from_str(title))); |
| 824 | panel.setPrompt(Some(&NSString::from_str("Insert"))); | 873 | panel.setPrompt(Some(&NSString::from_str("Insert"))); |
| 825 | if panel.runModal() != objc2_app_kit::NSModalResponseOK { | 874 | begin_panel(Retained::into_super(panel), reply); |
| 826 | return None; | ||
| 827 | } | ||
| 828 | let path = panel.URLs().firstObject()?.path()?; | ||
| 829 | Some(path.to_string().into()) | ||
| 830 | } | 875 | } |
| 831 | } | 876 | } |
| 832 | 877 | ||
| ... | @@ -1010,7 +1055,7 @@ pub fn alert(message: &str, detail: &str) { | ... | @@ -1010,7 +1055,7 @@ pub fn alert(message: &str, detail: &str) { |
| 1010 | let alert = NSAlert::new(mtm); | 1055 | let alert = NSAlert::new(mtm); |
| 1011 | alert.setMessageText(&NSString::from_str(message)); | 1056 | alert.setMessageText(&NSString::from_str(message)); |
| 1012 | alert.setInformativeText(&NSString::from_str(detail)); | 1057 | alert.setInformativeText(&NSString::from_str(detail)); |
| 1013 | alert.runModal(); | 1058 | begin_alert(&alert, |_| {}); |
| 1014 | } | 1059 | } |
| 1015 | } | 1060 | } |
| 1016 | 1061 | ||
| ... | @@ -1086,7 +1131,8 @@ pub fn edit_date( | ... | @@ -1086,7 +1131,8 @@ pub fn edit_date( |
| 1086 | timestamp: u64, | 1131 | timestamp: u64, |
| 1087 | field: DateField, | 1132 | field: DateField, |
| 1088 | title: &str, | 1133 | title: &str, |
| 1089 | ) -> Result<Option<(u64, [String; 2])>, &'static str> { | 1134 | reply: Reply<Result<(u64, [String; 2]), &'static str>>, |
| 1135 | ) { | ||
| 1090 | let mtm = MainThreadMarker::new().expect("Date controls belong to the main thread"); | 1136 | let mtm = MainThreadMarker::new().expect("Date controls belong to the main thread"); |
| 1091 | unsafe { | 1137 | unsafe { |
| 1092 | let calendar = NSCalendar::currentCalendar(); | 1138 | let calendar = NSCalendar::currentCalendar(); |
| ... | @@ -1116,11 +1162,14 @@ pub fn edit_date( | ... | @@ -1116,11 +1162,14 @@ pub fn edit_date( |
| 1116 | alert.addButtonWithTitle(&NSString::from_str("Cancel")); | 1162 | alert.addButtonWithTitle(&NSString::from_str("Cancel")); |
| 1117 | alert.layout(); | 1163 | alert.layout(); |
| 1118 | alert.window().makeFirstResponder(Some(&picker)); | 1164 | alert.window().makeFirstResponder(Some(&picker)); |
| 1119 | if alert.runModal() != NSAlertFirstButtonReturn { | 1165 | let window = alert.window(); |
| 1120 | return Ok(None); | 1166 | begin_alert(&alert, move |response| { |
| 1121 | } | 1167 | if response != NSAlertFirstButtonReturn { |
| 1122 | alert.window().makeFirstResponder(None); | 1168 | return; |
| 1123 | merge_date(timestamp, &picker.dateValue(), field, &calendar).map(Some) | 1169 | } |
| 1170 | window.makeFirstResponder(None); | ||
| 1171 | reply.send(merge_date(timestamp, &picker.dateValue(), field, &calendar)); | ||
| 1172 | }); | ||
| 1124 | } | 1173 | } |
| 1125 | } | 1174 | } |
| 1126 | 1175 | ||
| ... | @@ -1402,8 +1451,19 @@ declare_class!( | ... | @@ -1402,8 +1451,19 @@ declare_class!( |
| 1402 | .flatten() | 1451 | .flatten() |
| 1403 | .map(|text| text.to_string()); | 1452 | .map(|text| text.to_string()); |
| 1404 | let modal = self.modalWindow(); | 1453 | let modal = self.modalWindow(); |
| 1454 | let sheet = ALERT.with_borrow(Clone::clone).filter(|sheet| { | ||
| 1455 | self.keyWindow().is_some_and(|key| std::ptr::eq(&*key, &**sheet)) | ||
| 1456 | }); | ||
| 1457 | let sheet = sheet.zip(WINDOW.with_borrow(Clone::clone)); | ||
| 1405 | match (key.as_deref(), &modal) { | 1458 | match (key.as_deref(), &modal) { |
| 1406 | (Some("\u{1b}"), Some(_)) => self.stopModal(), | 1459 | (Some("\u{1b}"), Some(_)) => self.stopModal(), |
| 1460 | (Some("\u{1b}"), None) if let Some((sheet, parent)) = &sheet => { | ||
| 1461 | let _: () = msg_send![ | ||
| 1462 | parent, | ||
| 1463 | endSheet: &**sheet, | ||
| 1464 | returnCode: NSModalResponseCancel | ||
| 1465 | ]; | ||
| 1466 | } | ||
| 1407 | (Some("\r" | "\u{3}"), Some(modal)) if { | 1467 | (Some("\r" | "\u{3}"), Some(modal)) if { |
| 1408 | let responder: *mut AnyObject = msg_send![modal, firstResponder]; | 1468 | let responder: *mut AnyObject = msg_send![modal, firstResponder]; |
| 1409 | !responder.is_null() | 1469 | !responder.is_null() |
| ... | @@ -1511,16 +1571,19 @@ pub fn reveal(target: impl AsRef<std::ffi::OsStr>) { | ... | @@ -1511,16 +1571,19 @@ pub fn reveal(target: impl AsRef<std::ffi::OsStr>) { |
| 1511 | } | 1571 | } |
| 1512 | 1572 | ||
| 1513 | /// Asks whether to go ahead with `action`, offering `cancel` first. | 1573 | /// Asks whether to go ahead with `action`, offering `cancel` first. |
| 1514 | pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool { | 1574 | pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) { |
| 1515 | let mtm = MainThreadMarker::new().expect("Window events run on the main thread"); | 1575 | let mtm = MainThreadMarker::new().expect("Window events run on the main thread"); |
| 1516 | // The alert and its strings stay on AppKit's main thread for the modal call. | ||
| 1517 | unsafe { | 1576 | unsafe { |
| 1518 | let alert = NSAlert::new(mtm); | 1577 | let alert = NSAlert::new(mtm); |
| 1519 | alert.setMessageText(&NSString::from_str(message)); | 1578 | alert.setMessageText(&NSString::from_str(message)); |
| 1520 | alert.setInformativeText(&NSString::from_str(detail)); | 1579 | alert.setInformativeText(&NSString::from_str(detail)); |
| 1521 | alert.addButtonWithTitle(&NSString::from_str(cancel)); | 1580 | alert.addButtonWithTitle(&NSString::from_str(cancel)); |
| 1522 | alert.addButtonWithTitle(&NSString::from_str(action)); | 1581 | alert.addButtonWithTitle(&NSString::from_str(action)); |
| 1523 | alert.runModal() == NSAlertSecondButtonReturn | 1582 | begin_alert(&alert, |response| { |
| 1583 | if response == NSAlertSecondButtonReturn { | ||
| 1584 | reply.send(()); | ||
| 1585 | } | ||
| 1586 | }); | ||
| 1524 | } | 1587 | } |
| 1525 | } | 1588 | } |
| 1526 | 1589 |
crates/snowbound/src/main.rs+117-46| ... | @@ -214,8 +214,48 @@ const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported r | ... | @@ -214,8 +214,48 @@ const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported r |
| 214 | 214 | ||
| 215 | type Continuation = Box<dyn FnOnce(&mut State) -> Result<(), Box<dyn Error>> + Send>; | 215 | type Continuation = Box<dyn FnOnce(&mut State) -> Result<(), Box<dyn Error>> + Send>; |
| 216 | 216 | ||
| 217 | /// What follows a dialog's answer, run on the event loop. Dialogs never wait on the event | ||
| 218 | /// loop's thread, so the window keeps drawing and other apps can paste what it copied while | ||
| 219 | /// one is open; one cancelled drops its reply unanswered. | ||
| 220 | struct Reply<T>(Box<dyn FnOnce(T) + Send>); | ||
| 221 | |||
| 222 | impl<T: Send + 'static> Reply<T> { | ||
| 223 | fn new( | ||
| 224 | proxy: &EventLoopProxy<UserEvent>, | ||
| 225 | then: impl FnOnce(&mut State, T) -> Result<(), Box<dyn Error>> + Send + 'static, | ||
| 226 | ) -> Self { | ||
| 227 | let proxy = proxy.clone(); | ||
| 228 | Self(Box::new(move |answer| { | ||
| 229 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |state| then(state, answer)))); | ||
| 230 | })) | ||
| 231 | } | ||
| 232 | |||
| 233 | fn send(self, answer: T) { | ||
| 234 | (self.0)(answer); | ||
| 235 | } | ||
| 236 | |||
| 237 | /// The reply that sends `convert`'s answer here. | ||
| 238 | #[cfg(target_os = "linux")] | ||
| 239 | fn map<U>(self, convert: impl FnOnce(U) -> T + Send + 'static) -> Reply<U> { | ||
| 240 | Reply(Box::new(move |answer| self.send(convert(answer)))) | ||
| 241 | } | ||
| 242 | |||
| 243 | /// Sends what `ask` answers, waiting on its dialog on a thread of its own. | ||
| 244 | #[cfg(windows)] | ||
| 245 | fn after(self, ask: impl FnOnce() -> Option<T> + Send + 'static) { | ||
| 246 | std::thread::spawn(move || { | ||
| 247 | if let Some(answer) = ask() { | ||
| 248 | self.send(answer); | ||
| 249 | } | ||
| 250 | }); | ||
| 251 | } | ||
| 252 | } | ||
| 253 | |||
| 217 | enum UserEvent { | 254 | enum UserEvent { |
| 218 | Quit, | 255 | Quit, |
| 256 | /// Quits without asking, as after the user agreed to discard a temporary page. | ||
| 257 | #[cfg(not(target_arch = "wasm32"))] | ||
| 258 | Exit, | ||
| 219 | /// Text AppKit inserts outside key events, such as the character palette's. | 259 | /// Text AppKit inserts outside key events, such as the character palette's. |
| 220 | #[cfg_attr(not(target_os = "macos"), allow(dead_code))] | 260 | #[cfg_attr(not(target_os = "macos"), allow(dead_code))] |
| 221 | InsertText(String), | 261 | InsertText(String), |
| ... | @@ -750,6 +790,9 @@ struct State { | ... | @@ -750,6 +790,9 @@ struct State { |
| 750 | save_as: Option<save_as::Dialog>, | 790 | save_as: Option<save_as::Dialog>, |
| 751 | /// Unpack Notebook while it is open. | 791 | /// Unpack Notebook while it is open. |
| 752 | unpacking: Option<unpack::Dialog>, | 792 | unpacking: Option<unpack::Dialog>, |
| 793 | /// Snowbound's own dialogs, first the one shown, where the desktop has none. | ||
| 794 | #[cfg(target_os = "linux")] | ||
| 795 | asking: std::collections::VecDeque<dialog::Dialog>, | ||
| 753 | /// What has been read in each notebook, on this computer. | 796 | /// What has been read in each notebook, on this computer. |
| 754 | reads: unread::Reads, | 797 | reads: unread::Reads, |
| 755 | /// Open Notebook from Server while it is open. | 798 | /// Open Notebook from Server while it is open. |
| ... | @@ -1125,6 +1168,8 @@ impl State { | ... | @@ -1125,6 +1168,8 @@ impl State { |
| 1125 | symbols: None, | 1168 | symbols: None, |
| 1126 | save_as: None, | 1169 | save_as: None, |
| 1127 | unpacking: None, | 1170 | unpacking: None, |
| 1171 | #[cfg(target_os = "linux")] | ||
| 1172 | asking: Default::default(), | ||
| 1128 | reads, | 1173 | reads, |
| 1129 | server: None, | 1174 | server: None, |
| 1130 | new_icloud: None, | 1175 | new_icloud: None, |
| ... | @@ -1301,6 +1346,8 @@ impl State { | ... | @@ -1301,6 +1346,8 @@ impl State { |
| 1301 | self.save_as_dialog(); | 1346 | self.save_as_dialog(); |
| 1302 | self.unpack_dialog(); | 1347 | self.unpack_dialog(); |
| 1303 | self.password_dialog()?; | 1348 | self.password_dialog()?; |
| 1349 | #[cfg(target_os = "linux")] | ||
| 1350 | self.own_dialog(); | ||
| 1304 | self.customize_tags(); | 1351 | self.customize_tags(); |
| 1305 | self.palette(); | 1352 | self.palette(); |
| 1306 | self.sync_popup()?; | 1353 | self.sync_popup()?; |
| ... | @@ -3138,13 +3185,15 @@ impl State { | ... | @@ -3138,13 +3185,15 @@ impl State { |
| 3138 | Command::RestoreVersion { page, version } => self.restore_version(page, version)?, | 3185 | Command::RestoreVersion { page, version } => self.restore_version(page, version)?, |
| 3139 | Command::DeletePageVersion { page, version } => self.delete_version(page, version)?, | 3186 | Command::DeletePageVersion { page, version } => self.delete_version(page, version)?, |
| 3140 | Command::DeleteAllVersions(scope) => self.delete_all_versions(scope)?, | 3187 | Command::DeleteAllVersions(scope) => self.delete_all_versions(scope)?, |
| 3141 | Command::OpenNotebook => { | 3188 | Command::OpenNotebook => platform::pick_notebook( |
| 3142 | if let Some(path) = platform::pick_notebook("Open Notebook") { | 3189 | "Open Notebook", |
| 3143 | self.open_path(&path); | 3190 | self.reply(|state, path: PathBuf| { |
| 3144 | } | 3191 | state.open_path(&path); |
| 3145 | } | 3192 | Ok(()) |
| 3193 | }), | ||
| 3194 | ), | ||
| 3146 | Command::OpenFromServer(location) => self.open_server(location.as_deref()), | 3195 | Command::OpenFromServer(location) => self.open_server(location.as_deref()), |
| 3147 | Command::NewNotebook => self.new_notebook()?, | 3196 | Command::NewNotebook => self.new_notebook(), |
| 3148 | Command::NewICloudNotebook => self.new_icloud_notebook(), | 3197 | Command::NewICloudNotebook => self.new_icloud_notebook(), |
| 3149 | Command::OpenGuide => self.open_guide()?, | 3198 | Command::OpenGuide => self.open_guide()?, |
| 3150 | #[cfg(target_os = "linux")] | 3199 | #[cfg(target_os = "linux")] |
| ... | @@ -3172,7 +3221,7 @@ impl State { | ... | @@ -3172,7 +3221,7 @@ impl State { |
| 3172 | self.edited(Vec::new()); | 3221 | self.edited(Vec::new()); |
| 3173 | } | 3222 | } |
| 3174 | Command::Template(choice) => self.apply_template(choice)?, | 3223 | Command::Template(choice) => self.apply_template(choice)?, |
| 3175 | Command::Page(Request::EditDate(field)) => self.edit_date(field)?, | 3224 | Command::Page(Request::EditDate(field)) => self.edit_date(field), |
| 3176 | Command::Page(Request::Copy(clip)) => self.clipboard.set(clip)?, | 3225 | Command::Page(Request::Copy(clip)) => self.clipboard.set(clip)?, |
| 3177 | Command::Page(Request::Paste) => self.paste()?, | 3226 | Command::Page(Request::Paste) => self.paste()?, |
| 3178 | Command::Page(Request::OpenLink(address)) => self.open_link(&address)?, | 3227 | Command::Page(Request::OpenLink(address)) => self.open_link(&address)?, |
| ... | @@ -3561,9 +3610,9 @@ impl State { | ... | @@ -3561,9 +3610,9 @@ impl State { |
| 3561 | } | 3610 | } |
| 3562 | } | 3611 | } |
| 3563 | 3612 | ||
| 3564 | fn edit_date(&mut self, field: DateField) -> Result<(), Box<dyn Error>> { | 3613 | fn edit_date(&mut self, field: DateField) { |
| 3565 | let Some(date) = self.view.editor.date() else { | 3614 | let Some(date) = self.view.editor.date() else { |
| 3566 | return Ok(()); | 3615 | return; |
| 3567 | }; | 3616 | }; |
| 3568 | let timestamp = date.timestamp(); | 3617 | let timestamp = date.timestamp(); |
| 3569 | self.view.editor.finish_composition(); | 3618 | self.view.editor.finish_composition(); |
| ... | @@ -3572,12 +3621,30 @@ impl State { | ... | @@ -3572,12 +3621,30 @@ impl State { |
| 3572 | DateField::Date => "Change Page Date", | 3621 | DateField::Date => "Change Page Date", |
| 3573 | DateField::Time => "Change Page Time", | 3622 | DateField::Time => "Change Page Time", |
| 3574 | }; | 3623 | }; |
| 3575 | if let Some((timestamp, text)) = platform::edit_date(timestamp, field, title)? { | 3624 | let page = self.session.as_ref().map(|session| session.space); |
| 3576 | let response = self.view.change_date(timestamp, text)?; | 3625 | let reply = self.reply( |
| 3577 | self.respond(response); | 3626 | move |state, chosen: Result<(u64, [String; 2]), &'static str>| { |
| 3578 | self.window.request_redraw(); | 3627 | let (changed, text) = chosen?; |
| 3579 | } | 3628 | // The answer belongs to the page that asked, unless another has opened since. |
| 3580 | Ok(()) | 3629 | if state.session.as_ref().map(|session| session.space) != page |
| 3630 | || state.view.editor.date().map(|date| date.timestamp()) != Some(timestamp) | ||
| 3631 | { | ||
| 3632 | return Ok(()); | ||
| 3633 | } | ||
| 3634 | let response = state.view.change_date(changed, text)?; | ||
| 3635 | state.respond(response); | ||
| 3636 | Ok(()) | ||
| 3637 | }, | ||
| 3638 | ); | ||
| 3639 | platform::edit_date(timestamp, field, title, reply); | ||
| 3640 | } | ||
| 3641 | |||
| 3642 | /// Where a dialog's answer goes: `then`, run with it on the event loop. | ||
| 3643 | fn reply<T: Send + 'static>( | ||
| 3644 | &self, | ||
| 3645 | then: impl FnOnce(&mut State, T) -> Result<(), Box<dyn Error>> + Send + 'static, | ||
| 3646 | ) -> Reply<T> { | ||
| 3647 | Reply::new(&self.proxy, then) | ||
| 3581 | } | 3648 | } |
| 3582 | 3649 | ||
| 3583 | /// The open conflict page's conflicting changes in page order: each text's outline, | 3650 | /// The open conflict page's conflicting changes in page order: each text's outline, |
| ... | @@ -3970,7 +4037,7 @@ impl State { | ... | @@ -3970,7 +4037,7 @@ impl State { |
| 3970 | } | 4037 | } |
| 3971 | if let Some(field) = self.accessibility.date_for_node(request.target_node) { | 4038 | if let Some(field) = self.accessibility.date_for_node(request.target_node) { |
| 3972 | if request.action == Action::Click { | 4039 | if request.action == Action::Click { |
| 3973 | self.edit_date(field)?; | 4040 | self.edit_date(field); |
| 3974 | } | 4041 | } |
| 3975 | return Ok(()); | 4042 | return Ok(()); |
| 3976 | } | 4043 | } |
| ... | @@ -5491,6 +5558,8 @@ impl State { | ... | @@ -5491,6 +5558,8 @@ impl State { |
| 5491 | match event { | 5558 | match event { |
| 5492 | // The event loop quits; a page stays open. | 5559 | // The event loop quits; a page stays open. |
| 5493 | UserEvent::Quit | UserEvent::Replay(Replay::Quit) => {} | 5560 | UserEvent::Quit | UserEvent::Replay(Replay::Quit) => {} |
| 5561 | #[cfg(not(target_arch = "wasm32"))] | ||
| 5562 | UserEvent::Exit => {} | ||
| 5494 | UserEvent::Picture(bytes) => { | 5563 | UserEvent::Picture(bytes) => { |
| 5495 | if let Err(error) = self.insert_picture(bytes, None) { | 5564 | if let Err(error) = self.insert_picture(bytes, None) { |
| 5496 | eprintln!("{error}"); | 5565 | eprintln!("{error}"); |
| ... | @@ -5607,35 +5676,44 @@ impl State { | ... | @@ -5607,35 +5676,44 @@ impl State { |
| 5607 | #[cfg(not(target_arch = "wasm32"))] | 5676 | #[cfg(not(target_arch = "wasm32"))] |
| 5608 | impl App { | 5677 | impl App { |
| 5609 | fn close(&self, event_loop: &ActiveEventLoop) { | 5678 | fn close(&self, event_loop: &ActiveEventLoop) { |
| 5610 | if self.state.as_ref().is_none_or(|state| { | 5679 | let Some(state) = self.state.as_ref().filter(|state| { |
| 5611 | let editor = &state.view.editor; | 5680 | let editor = &state.view.editor; |
| 5612 | state.session.is_some() | 5681 | let unchanged = editor.caret_outline().is_none_or(TextOutline::is_empty) |
| 5613 | || editor.caret_outline().is_none_or(TextOutline::is_empty) | 5682 | && state.initial_date == editor.date().map(|date| date.timestamp()) |
| 5614 | && state.initial_date == editor.date().map(|date| date.timestamp()) | 5683 | && state |
| 5615 | && state | 5684 | .initial_layouts |
| 5616 | .initial_layouts | 5685 | .iter() |
| 5617 | .iter() | 5686 | .map(|(id, layout)| (*id, layout)) |
| 5618 | .map(|(id, layout)| (*id, layout)) | 5687 | .eq(editor.object_layouts()) |
| 5619 | .eq(editor.object_layouts()) | 5688 | && state |
| 5620 | && state | 5689 | .initial |
| 5621 | .initial | 5690 | .iter() |
| 5691 | .map(|(id, document)| (id, document)) | ||
| 5692 | .eq(editor | ||
| 5693 | .outlines() | ||
| 5622 | .iter() | 5694 | .iter() |
| 5623 | .map(|(id, document)| (id, document)) | 5695 | .map(|outline| (&outline.id, outline.document()))); |
| 5624 | .eq(editor | 5696 | state.session.is_none() && !unchanged |
| 5625 | .outlines() | 5697 | }) else { |
| 5626 | .iter() | 5698 | return self.exit(event_loop); |
| 5627 | .map(|outline| (&outline.id, outline.document()))) | 5699 | }; |
| 5628 | }) || platform::confirm( | 5700 | platform::confirm( |
| 5629 | "Discard this page?", | 5701 | "Discard this page?", |
| 5630 | "This temporary page has no saved copy. Closing it will discard your edits.", | 5702 | "This temporary page has no saved copy. Closing it will discard your edits.", |
| 5631 | "Keep Editing", | 5703 | "Keep Editing", |
| 5632 | "Discard Changes", | 5704 | "Discard Changes", |
| 5633 | ) { | 5705 | state.reply(|state, ()| { |
| 5634 | if let Some(state) = &self.state { | 5706 | let _ = state.proxy.send_event(UserEvent::Exit); |
| 5635 | state.publish_now(QUIT_PUBLISH); | 5707 | Ok(()) |
| 5636 | } | 5708 | }), |
| 5637 | event_loop.exit(); | 5709 | ); |
| 5710 | } | ||
| 5711 | |||
| 5712 | fn exit(&self, event_loop: &ActiveEventLoop) { | ||
| 5713 | if let Some(state) = &self.state { | ||
| 5714 | state.publish_now(QUIT_PUBLISH); | ||
| 5638 | } | 5715 | } |
| 5716 | event_loop.exit(); | ||
| 5639 | } | 5717 | } |
| 5640 | } | 5718 | } |
| 5641 | 5719 | ||
| ... | @@ -5658,6 +5736,7 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -5658,6 +5736,7 @@ impl ApplicationHandler<UserEvent> for App { |
| 5658 | }; | 5736 | }; |
| 5659 | match event { | 5737 | match event { |
| 5660 | UserEvent::Quit | UserEvent::Replay(Replay::Quit) => self.close(event_loop), | 5738 | UserEvent::Quit | UserEvent::Replay(Replay::Quit) => self.close(event_loop), |
| 5739 | UserEvent::Exit => self.exit(event_loop), | ||
| 5661 | event => state.user_event(event), | 5740 | event => state.user_event(event), |
| 5662 | } | 5741 | } |
| 5663 | } | 5742 | } |
| ... | @@ -5996,14 +6075,6 @@ fn main() -> Result<(), Box<dyn Error>> { | ... | @@ -5996,14 +6075,6 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 5996 | } | 6075 | } |
| 5997 | #[cfg(target_os = "linux")] | 6076 | #[cfg(target_os = "linux")] |
| 5998 | loader::preload(); | 6077 | loader::preload(); |
| 5999 | // Before the crash log, which the app showing the dialog keeps writing. | ||
| 6000 | #[cfg(target_os = "linux")] | ||
| 6001 | if std::env::args_os() | ||
| 6002 | .nth(1) | ||
| 6003 | .is_some_and(|arg| arg == dialog::DIALOG) | ||
| 6004 | { | ||
| 6005 | return dialog::run(std::env::args_os().skip(2)); | ||
| 6006 | } | ||
| 6007 | platform::with_pool(launch) | 6078 | platform::with_pool(launch) |
| 6008 | } | 6079 | } |
| 6009 | 6080 |
crates/snowbound/src/manage.rs+9-7| ... | @@ -261,14 +261,16 @@ impl State { | ... | @@ -261,14 +261,16 @@ impl State { |
| 261 | 261 | ||
| 262 | /// Asks where to create a notebook and creates it, as File, New does in OneNote; the | 262 | /// Asks where to create a notebook and creates it, as File, New does in OneNote; the |
| 263 | /// panel starts in iCloud Drive, as Notes keeps notes there. | 263 | /// panel starts in iCloud Drive, as Notes keeps notes there. |
| 264 | pub(crate) fn new_notebook(&mut self) -> Result<(), Box<dyn Error>> { | 264 | pub(crate) fn new_notebook(&mut self) { |
| 265 | let icloud = crate::icloud::folder().or_else(crate::icloud::drive); | 265 | let icloud = crate::icloud::folder().or_else(crate::icloud::drive); |
| 266 | let Some(root) = | 266 | let reply = self.reply(|state, root| state.create_notebook(root)); |
| 267 | platform::pick_new("New Notebook", "My Notebook", "Create", icloud.as_deref()) | 267 | platform::pick_new( |
| 268 | else { | 268 | "New Notebook", |
| 269 | return Ok(()); | 269 | "My Notebook", |
| 270 | }; | 270 | "Create", |
| 271 | self.create_notebook(root) | 271 | icloud.as_deref(), |
| 272 | reply, | ||
| 273 | ); | ||
| 272 | } | 274 | } |
| 273 | 275 | ||
| 274 | /// New iCloud Notebook: asks for a name, the first free of "iCloud Notebook", "iCloud | 276 | /// New iCloud Notebook: asks for a name, the first free of "iCloud Notebook", "iCloud |
crates/snowbound/src/menus.rs+40-16| ... | @@ -649,24 +649,45 @@ impl State { | ... | @@ -649,24 +649,45 @@ impl State { |
| 649 | }); | 649 | }); |
| 650 | None | 650 | None |
| 651 | } | 651 | } |
| 652 | (Target::Section { library, path }, Action::Delete) => platform::confirm( | 652 | (Target::Section { library, path }, Action::Delete) => { |
| 653 | "Are you sure you want to move this section to this notebook's Recycle Bin?", | 653 | platform::confirm( |
| 654 | &path, | 654 | "Are you sure you want to move this section to this notebook's Recycle Bin?", |
| 655 | "Cancel", | 655 | &path.clone(), |
| 656 | "Delete", | 656 | "Cancel", |
| 657 | ) | 657 | "Delete", |
| 658 | .then(|| Command::Structure(library, Structure::Delete { path })), | 658 | self.reply(|state, ()| { |
| 659 | (Target::Group { library, path }, Action::Delete) => platform::confirm( | 659 | let change = Structure::Delete { path }; |
| 660 | "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?", | 660 | state.commands.push(Command::Structure(library, change)); |
| 661 | path.rsplit('/').next().unwrap_or_default(), | 661 | Ok(()) |
| 662 | "Cancel", | 662 | }), |
| 663 | "Delete", | 663 | ); |
| 664 | ) | 664 | None |
| 665 | .then(|| Command::Structure(library, Structure::Delete { path })), | 665 | } |
| 666 | (Target::Group { library, path }, Action::Delete) => { | ||
| 667 | platform::confirm( | ||
| 668 | "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?", | ||
| 669 | path.rsplit('/') | ||
| 670 | .next() | ||
| 671 | .unwrap_or_default() | ||
| 672 | .to_owned() | ||
| 673 | .as_str(), | ||
| 674 | "Cancel", | ||
| 675 | "Delete", | ||
| 676 | self.reply(|state, ()| { | ||
| 677 | let change = Structure::Delete { path }; | ||
| 678 | state.commands.push(Command::Structure(library, change)); | ||
| 679 | Ok(()) | ||
| 680 | }), | ||
| 681 | ); | ||
| 682 | None | ||
| 683 | } | ||
| 666 | ( | 684 | ( |
| 667 | Target::Section { library, path } | Target::Group { library, path }, | 685 | Target::Section { library, path } | Target::Group { library, path }, |
| 668 | Action::MoveTo(folder), | 686 | Action::MoveTo(folder), |
| 669 | ) => Some(Command::Structure(library, Structure::Move { path, folder })), | 687 | ) => Some(Command::Structure( |
| 688 | library, | ||
| 689 | Structure::Move { path, folder }, | ||
| 690 | )), | ||
| 670 | (Target::Section { library, path }, Action::Theme) => { | 691 | (Target::Section { library, path }, Action::Theme) => { |
| 671 | if let Some(identity) = library.section_identity(&path) { | 692 | if let Some(identity) = library.section_identity(&path) { |
| 672 | let scope = notebook::sidecar::themes::Scope::section(identity); | 693 | let scope = notebook::sidecar::themes::Scope::section(identity); |
| ... | @@ -710,7 +731,10 @@ impl State { | ... | @@ -710,7 +731,10 @@ impl State { |
| 710 | { | 731 | { |
| 711 | session.section.wake(); | 732 | session.section.wake(); |
| 712 | } | 733 | } |
| 713 | library.background.iter().for_each(|background| background.wake()); | 734 | library |
| 735 | .background | ||
| 736 | .iter() | ||
| 737 | .for_each(|background| background.wake()); | ||
| 714 | None | 738 | None |
| 715 | } | 739 | } |
| 716 | (Target::Notebook(library), Action::CopyLink) => { | 740 | (Target::Notebook(library), Action::CopyLink) => { |
crates/snowbound/src/options.rs+1-1| ... | @@ -871,7 +871,7 @@ fn installed(state: &mut State, _: &mut Options) { | ... | @@ -871,7 +871,7 @@ fn installed(state: &mut State, _: &mut Options) { |
| 871 | if crate::desktop::uninstallable() { | 871 | if crate::desktop::uninstallable() { |
| 872 | path(ui, &crate::desktop::binary().unwrap_or_default()); | 872 | path(ui, &crate::desktop::binary().unwrap_or_default()); |
| 873 | if ui::button(ui, "uninstall", "Uninstall…").clicked { | 873 | if ui::button(ui, "uninstall", "Uninstall…").clicked { |
| 874 | crate::desktop::uninstall(); | 874 | crate::desktop::uninstall(&state.proxy); |
| 875 | } | 875 | } |
| 876 | } else if crate::desktop::installable() { | 876 | } else if crate::desktop::installable() { |
| 877 | path(ui, std::path::Path::new("Not in the app menu")); | 877 | path(ui, std::path::Path::new("Not in the app menu")); |
crates/snowbound/src/print.rs+14-13| ... | @@ -308,18 +308,6 @@ impl State { | ... | @@ -308,18 +308,6 @@ impl State { |
| 308 | Scope::Section => tab.name.clone(), | 308 | Scope::Section => tab.name.clone(), |
| 309 | Scope::Notebook => session.library.name.clone(), | 309 | Scope::Notebook => session.library.name.clone(), |
| 310 | }; | 310 | }; |
| 311 | let destination = if export { | ||
| 312 | let name = format!("{}.pdf", file_name(&title)); | ||
| 313 | let Some(mut path) = platform::pick_new("Export as PDF", &name, "Export", None) else { | ||
| 314 | return Ok(()); | ||
| 315 | }; | ||
| 316 | if path.extension().is_none() { | ||
| 317 | path.set_extension("pdf"); | ||
| 318 | } | ||
| 319 | Some(path) | ||
| 320 | } else { | ||
| 321 | None | ||
| 322 | }; | ||
| 323 | // Other sections are opened on the thread, in the notebook's order. | 311 | // Other sections are opened on the thread, in the notebook's order. |
| 324 | let others = match setup.scope { | 312 | let others = match setup.scope { |
| 325 | Scope::Notebook => notebook_tabs(&session.library), | 313 | Scope::Notebook => notebook_tabs(&session.library), |
| ... | @@ -343,7 +331,8 @@ impl State { | ... | @@ -343,7 +331,8 @@ impl State { |
| 343 | footer: setup.footer, | 331 | footer: setup.footer, |
| 344 | }; | 332 | }; |
| 345 | let whole = matches!(setup.scope, Scope::Section | Scope::Notebook); | 333 | let whole = matches!(setup.scope, Scope::Section | Scope::Notebook); |
| 346 | crate::spawn(move || { | 334 | let name = format!("{}.pdf", file_name(&title)); |
| 335 | let print = move |destination: Option<std::path::PathBuf>| { | ||
| 347 | let made = (|| -> Result<Vec<u8>, Box<dyn Error>> { | 336 | let made = (|| -> Result<Vec<u8>, Box<dyn Error>> { |
| 348 | let mut open = Some(open); | 337 | let mut open = Some(open); |
| 349 | let this = spaces | 338 | let this = spaces |
| ... | @@ -399,7 +388,19 @@ impl State { | ... | @@ -399,7 +388,19 @@ impl State { |
| 399 | } | 388 | } |
| 400 | .inspect_err(|error| platform::alert(failed, &error.to_string())) | 389 | .inspect_err(|error| platform::alert(failed, &error.to_string())) |
| 401 | }))); | 390 | }))); |
| 391 | }; | ||
| 392 | if !export { | ||
| 393 | crate::spawn(move || print(None)); | ||
| 394 | return Ok(()); | ||
| 395 | } | ||
| 396 | let reply = self.reply(|_, mut path: std::path::PathBuf| { | ||
| 397 | if path.extension().is_none() { | ||
| 398 | path.set_extension("pdf"); | ||
| 399 | } | ||
| 400 | crate::spawn(move || print(Some(path))); | ||
| 401 | Ok(()) | ||
| 402 | }); | 402 | }); |
| 403 | platform::pick_new("Export as PDF", &name, "Export", None, reply); | ||
| 403 | Ok(()) | 404 | Ok(()) |
| 404 | } | 405 | } |
| 405 | } | 406 | } |
crates/snowbound/src/recycle.rs+35-16| ... | @@ -65,35 +65,54 @@ impl State { | ... | @@ -65,35 +65,54 @@ impl State { |
| 65 | 65 | ||
| 66 | /// Empty Recycle Bin on `library`, once confirmed as OneNote asks. | 66 | /// Empty Recycle Bin on `library`, once confirmed as OneNote asks. |
| 67 | pub(crate) fn empty_recycle_bin(&mut self, library: Arc<Library>) { | 67 | pub(crate) fn empty_recycle_bin(&mut self, library: Arc<Library>) { |
| 68 | if crate::platform::confirm( | 68 | crate::platform::confirm( |
| 69 | "Are you sure you want to empty the Recycle Bin for this notebook?", | 69 | "Are you sure you want to empty the Recycle Bin for this notebook?", |
| 70 | "Its pages and sections are deleted for good.", | 70 | "Its pages and sections are deleted for good.", |
| 71 | "Cancel", | 71 | "Cancel", |
| 72 | "Empty Recycle Bin", | 72 | "Empty Recycle Bin", |
| 73 | ) { | 73 | self.reply(|state, ()| { |
| 74 | let change = crate::manage::Structure::EmptyRecycleBin; | 74 | let change = crate::manage::Structure::EmptyRecycleBin; |
| 75 | self.commands.push(Command::Structure(library, change)); | 75 | state.commands.push(Command::Structure(library, change)); |
| 76 | } | 76 | Ok(()) |
| 77 | }), | ||
| 78 | ); | ||
| 79 | } | ||
| 80 | |||
| 81 | /// Does `request` to the bin's open section, a purge once confirmed. | ||
| 82 | pub(crate) fn recycle(&mut self, request: Request) -> Result<(), Box<dyn Error>> { | ||
| 83 | let Request::Purge(spaces) = &request else { | ||
| 84 | return self.recycle_now(request); | ||
| 85 | }; | ||
| 86 | let spaces = spaces.clone(); | ||
| 87 | crate::platform::confirm( | ||
| 88 | "Are you sure you want to delete this page for good?", | ||
| 89 | "It can't be restored.", | ||
| 90 | "Cancel", | ||
| 91 | "Delete", | ||
| 92 | self.reply(move |state, ()| { | ||
| 93 | // The pages asked about, unless their section closed meanwhile. | ||
| 94 | let open = state.session.as_ref().is_some_and(|session| { | ||
| 95 | (spaces.iter()) | ||
| 96 | .all(|space| session.pages.iter().any(|(page, ..)| page == space)) | ||
| 97 | }); | ||
| 98 | if open { | ||
| 99 | state.recycle_now(request) | ||
| 100 | } else { | ||
| 101 | Ok(()) | ||
| 102 | } | ||
| 103 | }), | ||
| 104 | ); | ||
| 105 | Ok(()) | ||
| 77 | } | 106 | } |
| 78 | 107 | ||
| 79 | /// Does `request` to the bin's open section on a thread of its own, then shows the page | 108 | /// Does `request` to the bin's open section on a thread of its own, then shows the page |
| 80 | /// after the ones gone, or the notebook once the section holds none. | 109 | /// after the ones gone, or the notebook once the section holds none. |
| 81 | pub(crate) fn recycle(&mut self, request: Request) -> Result<(), Box<dyn Error>> { | 110 | fn recycle_now(&mut self, request: Request) -> Result<(), Box<dyn Error>> { |
| 82 | let gone = match &request { | 111 | let gone = match &request { |
| 83 | Request::Restore { copy: true, .. } => Vec::new(), | 112 | Request::Restore { copy: true, .. } => Vec::new(), |
| 84 | Request::Restore { space, .. } => vec![*space], | 113 | Request::Restore { space, .. } => vec![*space], |
| 85 | Request::Purge(spaces) => spaces.clone(), | 114 | Request::Purge(spaces) => spaces.clone(), |
| 86 | }; | 115 | }; |
| 87 | if matches!(request, Request::Purge(_)) | ||
| 88 | && !crate::platform::confirm( | ||
| 89 | "Are you sure you want to delete this page for good?", | ||
| 90 | "It can't be restored.", | ||
| 91 | "Cancel", | ||
| 92 | "Delete", | ||
| 93 | ) | ||
| 94 | { | ||
| 95 | return Ok(()); | ||
| 96 | } | ||
| 97 | let session = self.session.as_ref().ok_or("No section is open")?; | 116 | let session = self.session.as_ref().ok_or("No section is open")?; |
| 98 | let replica = Arc::clone(session.section.replica()); | 117 | let replica = Arc::clone(session.section.replica()); |
| 99 | let library = Arc::clone(&session.library); | 118 | let library = Arc::clone(&session.library); |
crates/snowbound/src/save_as.rs+42-41| ... | @@ -175,54 +175,55 @@ impl State { | ... | @@ -175,54 +175,55 @@ impl State { |
| 175 | Scope::Notebook => library.name.clone(), | 175 | Scope::Notebook => library.name.clone(), |
| 176 | }; | 176 | }; |
| 177 | let name = format!("{}.{extension}", print::file_name(&title)); | 177 | let name = format!("{}.{extension}", print::file_name(&title)); |
| 178 | let Some(mut path) = platform::pick_new("Save As", &name, "Save", None) else { | ||
| 179 | return Ok(()); | ||
| 180 | }; | ||
| 181 | if path.extension().is_none() { | ||
| 182 | path.set_extension(extension); | ||
| 183 | } | ||
| 184 | let (scope, author, proxy) = (dialog.scope, self.author.clone(), self.proxy.clone()); | 178 | let (scope, author, proxy) = (dialog.scope, self.author.clone(), self.proxy.clone()); |
| 185 | let color = (library | 179 | let color = (library |
| 186 | .tabs(crate::menus::folder(&section).as_str()) | 180 | .tabs(crate::menus::folder(&section).as_str()) |
| 187 | .into_iter()) | 181 | .into_iter()) |
| 188 | .find(|tab| tab.path == section) | 182 | .find(|tab| tab.path == section) |
| 189 | .and_then(|tab| tab.color); | 183 | .and_then(|tab| tab.color); |
| 190 | crate::spawn(move || { | 184 | let reply = self.reply(move |_, mut path: std::path::PathBuf| { |
| 191 | let written = (|| -> Result<(), Box<dyn Error>> { | 185 | if path.extension().is_none() { |
| 192 | let image = |path: &str| { | 186 | path.set_extension(extension); |
| 193 | let (_, replica, _) = open.as_ref().filter(|(open, ..)| open == path)?; | 187 | } |
| 194 | replica.snapshot().ok() | 188 | crate::spawn(move || { |
| 195 | }; | 189 | let written = (|| -> Result<(), Box<dyn Error>> { |
| 196 | let bytes = match scope { | 190 | let image = |path: &str| { |
| 197 | Scope::Page => { | 191 | let (_, replica, _) = open.as_ref().filter(|(open, ..)| open == path)?; |
| 198 | let (_, replica, space) = open.as_ref().ok_or("No page is open")?; | 192 | replica.snapshot().ok() |
| 199 | let page = replica.page(*space)?; | 193 | }; |
| 200 | let file = file_name(&path); | 194 | let bytes = match scope { |
| 201 | package::page_section(&page, &file, color, &author)? | 195 | Scope::Page => { |
| 202 | } | 196 | let (_, replica, space) = open.as_ref().ok_or("No page is open")?; |
| 203 | Scope::Section => { | 197 | let page = replica.page(*space)?; |
| 204 | let stored = match image(&section) { | 198 | let file = file_name(&path); |
| 205 | Some(image) => image, | 199 | package::page_section(&page, &file, color, &author)? |
| 206 | None => library.reopen()?.read_section(&section)?, | 200 | } |
| 207 | }; | 201 | Scope::Section => { |
| 208 | package::section_copy(stored)? | 202 | let stored = match image(&section) { |
| 209 | } | 203 | Some(image) => image, |
| 210 | Scope::Notebook => { | 204 | None => library.reopen()?.read_section(&section)?, |
| 211 | let notebook = library.reopen()?; | 205 | }; |
| 212 | package::pack(package::notebook_files(&notebook, image)?)? | 206 | package::section_copy(stored)? |
| 213 | } | 207 | } |
| 214 | }; | 208 | Scope::Notebook => { |
| 215 | notebook::fs::write(&path, bytes)?; | 209 | let notebook = library.reopen()?; |
| 216 | Ok(()) | 210 | package::pack(package::notebook_files(&notebook, image)?)? |
| 217 | })(); | 211 | } |
| 218 | let written = written.map_err(|error| error.to_string()); | 212 | }; |
| 219 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |_| { | 213 | notebook::fs::write(&path, bytes)?; |
| 220 | written.map_err(|error| { | 214 | Ok(()) |
| 221 | platform::alert("Couldn't save", &error); | 215 | })(); |
| 222 | error.into() | 216 | let written = written.map_err(|error| error.to_string()); |
| 223 | }) | 217 | let _ = proxy.send_event(UserEvent::Then(Box::new(move |_| { |
| 224 | }))); | 218 | written.map_err(|error| { |
| 219 | platform::alert("Couldn't save", &error); | ||
| 220 | error.into() | ||
| 221 | }) | ||
| 222 | }))); | ||
| 223 | }); | ||
| 224 | Ok(()) | ||
| 225 | }); | 225 | }); |
| 226 | platform::pick_new("Save As", &name, "Save", None, reply); | ||
| 226 | Ok(()) | 227 | Ok(()) |
| 227 | } | 228 | } |
| 228 | } | 229 | } |
crates/snowbound/src/tags.rs+9-3| ... | @@ -589,12 +589,18 @@ impl State { | ... | @@ -589,12 +589,18 @@ impl State { |
| 589 | /// Asks for a picture, a PNG or SVG, for the tag New Tag or Modify Tag edits, which then | 589 | /// Asks for a picture, a PNG or SVG, for the tag New Tag or Modify Tag edits, which then |
| 590 | /// draws with it; OneNote shows the tag's symbol, or a blue circle where it has none. | 590 | /// draws with it; OneNote shows the tag's symbol, or a blue circle where it has none. |
| 591 | pub(crate) fn pick_tag_picture(&mut self) { | 591 | pub(crate) fn pick_tag_picture(&mut self) { |
| 592 | let reply = self.reply(|state, path| { | ||
| 593 | state.use_tag_picture(path); | ||
| 594 | Ok(()) | ||
| 595 | }); | ||
| 596 | platform::pick_file("Custom Image", &["png", "svg"], reply); | ||
| 597 | } | ||
| 598 | |||
| 599 | /// Draws the tag New Tag or Modify Tag edits with the picture at `path`, while it does. | ||
| 600 | fn use_tag_picture(&mut self, path: std::path::PathBuf) { | ||
| 592 | let Some((tag, _)) = self.tag_list.as_mut().and_then(|list| list.editor.as_mut()) else { | 601 | let Some((tag, _)) = self.tag_list.as_mut().and_then(|list| list.editor.as_mut()) else { |
| 593 | return; | 602 | return; |
| 594 | }; | 603 | }; |
| 595 | let Some(path) = platform::pick_file("Custom Image", &["png", "svg"]) else { | ||
| 596 | return; | ||
| 597 | }; | ||
| 598 | let kept = notebook::fs::read(&path) | 604 | let kept = notebook::fs::read(&path) |
| 599 | .ok() | 605 | .ok() |
| 600 | .and_then(|bytes| canvas::gpu::import_tag_art(&bytes)); | 606 | .and_then(|bytes| canvas::gpu::import_tag_art(&bytes)); |
crates/snowbound/src/unpack.rs+19-10| ... | @@ -172,16 +172,25 @@ impl State { | ... | @@ -172,16 +172,25 @@ impl State { |
| 172 | let create = ui::button(ui, "create", "Create").clicked || entered; | 172 | let create = ui::button(ui, "create", "Create").clicked || entered; |
| 173 | ui.close(); | 173 | ui.close(); |
| 174 | ui.close(); | 174 | ui.close(); |
| 175 | if browse | 175 | if browse { |
| 176 | && let Some(chosen) = | 176 | let reply = crate::Reply::new(&self.proxy, |state, chosen: PathBuf| { |
| 177 | platform::pick_new(TITLE, dialog.name.trim(), "Choose", Some(&dialog.folder)) | 177 | if let Some(dialog) = &mut state.unpacking { |
| 178 | { | 178 | if let Some(name) = chosen.file_name() { |
| 179 | if let Some(name) = chosen.file_name() { | 179 | dialog.name = name.to_string_lossy().into_owned(); |
| 180 | dialog.name = name.to_string_lossy().into_owned(); | 180 | } |
| 181 | } | 181 | if let Some(parent) = chosen.parent() { |
| 182 | if let Some(parent) = chosen.parent() { | 182 | dialog.folder = parent.to_owned(); |
| 183 | dialog.folder = parent.to_owned(); | 183 | } |
| 184 | } | 184 | } |
| 185 | Ok(()) | ||
| 186 | }); | ||
| 187 | platform::pick_new( | ||
| 188 | TITLE, | ||
| 189 | dialog.name.trim(), | ||
| 190 | "Choose", | ||
| 191 | Some(&dialog.folder), | ||
| 192 | reply, | ||
| 193 | ); | ||
| 185 | } | 194 | } |
| 186 | let name = dialog.name.trim(); | 195 | let name = dialog.name.trim(); |
| 187 | let valid = | 196 | let valid = |
crates/snowbound/src/update.rs+25-25| ... | @@ -642,32 +642,32 @@ impl State { | ... | @@ -642,32 +642,32 @@ impl State { |
| 642 | return; | 642 | return; |
| 643 | }; | 643 | }; |
| 644 | match status { | 644 | match status { |
| 645 | Status::Ready(version, _, changes) => { | 645 | Status::Ready(version, _, changes) => platform::confirm( |
| 646 | if platform::confirm( | 646 | "Update ready", |
| 647 | "Update ready", | 647 | &described( |
| 648 | &described( | 648 | format!("Snowbound {version} is ready to install."), |
| 649 | format!("Snowbound {version} is ready to install."), | 649 | &changes, |
| 650 | &changes, | 650 | ), |
| 651 | ), | 651 | "Later", |
| 652 | "Later", | 652 | "Restart to Update", |
| 653 | "Restart to Update", | 653 | self.reply(|state, ()| { |
| 654 | ) { | 654 | state.restart_to_update(); |
| 655 | self.restart_to_update(); | 655 | Ok(()) |
| 656 | } | 656 | }), |
| 657 | } | 657 | ), |
| 658 | Status::Available(version, changes) => { | 658 | Status::Available(version, changes) => platform::confirm( |
| 659 | if platform::confirm( | 659 | "Update available", |
| 660 | "Update available", | 660 | &described( |
| 661 | &described( | 661 | format!("Download Snowbound {version} from its build folder."), |
| 662 | format!("Download Snowbound {version} from its build folder."), | 662 | &changes, |
| 663 | &changes, | 663 | ), |
| 664 | ), | 664 | "Later", |
| 665 | "Later", | 665 | "Open Build Folder", |
| 666 | "Open Build Folder", | 666 | self.reply(move |_, ()| { |
| 667 | ) { | ||
| 668 | show_build(&version); | 667 | show_build(&version); |
| 669 | } | 668 | Ok(()) |
| 670 | } | 669 | }), |
| 670 | ), | ||
| 671 | Status::UpToDate => platform::alert( | 671 | Status::UpToDate => platform::alert( |
| 672 | "Snowbound is up to date", | 672 | "Snowbound is up to date", |
| 673 | &format!("You have {}.", describe_running()), | 673 | &format!("You have {}.", describe_running()), |
crates/snowbound/src/web.rs+61-30| ... | @@ -3,7 +3,7 @@ | ... | @@ -3,7 +3,7 @@ |
| 3 | //! event loop are stand-ins with the calls `State` makes of winit's; menus are the kit's own, | 3 | //! event loop are stand-ins with the calls `State` makes of winit's; menus are the kit's own, |
| 4 | //! as on Linux; the browser's chords stay the browser's. See arc/platforms.md. | 4 | //! as on Linux; the browser's chords stay the browser's. See arc/platforms.md. |
| 5 | 5 | ||
| 6 | use crate::{State, UserEvent, commands, page, settings}; | 6 | use crate::{Reply, State, UserEvent, commands, page, settings}; |
| 7 | use canvas::date::DateField; | 7 | use canvas::date::DateField; |
| 8 | use std::{ | 8 | use std::{ |
| 9 | cell::{Cell, RefCell}, | 9 | cell::{Cell, RefCell}, |
| ... | @@ -60,12 +60,14 @@ extern "C" { | ... | @@ -60,12 +60,14 @@ extern "C" { |
| 60 | fn date_strings(ms: f64) -> Vec<String>; | 60 | fn date_strings(ms: f64) -> Vec<String>; |
| 61 | #[wasm_bindgen(js_name = shortDate)] | 61 | #[wasm_bindgen(js_name = shortDate)] |
| 62 | fn short_date_string(ms: f64) -> String; | 62 | fn short_date_string(ms: f64) -> String; |
| 63 | /// Resolves to whether the user chose `action` over `cancel`. | ||
| 63 | #[wasm_bindgen(js_name = askConfirm)] | 64 | #[wasm_bindgen(js_name = askConfirm)] |
| 64 | fn ask_confirm(message: &str) -> bool; | 65 | fn ask_confirm(message: &str, detail: &str, cancel: &str, action: &str) -> js_sys::Promise; |
| 66 | /// Resolves to the text the user enters, starting as `value`; undefined when cancelled. | ||
| 65 | #[wasm_bindgen(js_name = askText)] | 67 | #[wasm_bindgen(js_name = askText)] |
| 66 | fn ask_text(message: &str, value: &str) -> Option<String>; | 68 | fn ask_text(message: &str, value: &str) -> js_sys::Promise; |
| 67 | #[wasm_bindgen(js_name = tell)] | 69 | #[wasm_bindgen(js_name = tell)] |
| 68 | fn tell(message: &str); | 70 | fn tell(message: &str, detail: &str); |
| 69 | #[wasm_bindgen(js_name = openLink)] | 71 | #[wasm_bindgen(js_name = openLink)] |
| 70 | fn open_link(url: &str); | 72 | fn open_link(url: &str); |
| 71 | /// Writes changes out: `[path]` removed, `[path, null]` a folder, and `[path, length, | 73 | /// Writes changes out: `[path]` removed, `[path, null]` a folder, and `[path, length, |
| ... | @@ -644,14 +646,23 @@ pub fn date_text(filetime: u64) -> [String; 2] { | ... | @@ -644,14 +646,23 @@ pub fn date_text(filetime: u64) -> [String; 2] { |
| 644 | ] | 646 | ] |
| 645 | } | 647 | } |
| 646 | 648 | ||
| 649 | /// Runs `then` with what the glue's dialog resolves `asked` to, once the user answers. | ||
| 650 | fn answered(asked: js_sys::Promise, then: impl FnOnce(JsValue) + 'static) { | ||
| 651 | wasm_bindgen_futures::spawn_local(async move { | ||
| 652 | if let Ok(answer) = wasm_bindgen_futures::JsFuture::from(asked).await { | ||
| 653 | then(answer); | ||
| 654 | } | ||
| 655 | }); | ||
| 656 | } | ||
| 657 | |||
| 647 | /// Asks for the page's date as `YYYY-MM-DD`, or its time as `HH:MM`. | 658 | /// Asks for the page's date as `YYYY-MM-DD`, or its time as `HH:MM`. |
| 648 | pub fn edit_date( | 659 | pub fn edit_date( |
| 649 | timestamp: u64, | 660 | timestamp: u64, |
| 650 | field: DateField, | 661 | field: DateField, |
| 651 | title: &str, | 662 | title: &str, |
| 652 | ) -> Result<Option<(u64, [String; 2])>, &'static str> { | 663 | reply: Reply<Result<(u64, [String; 2]), &'static str>>, |
| 653 | let ms = unix_ms(timestamp); | 664 | ) { |
| 654 | let date = js_sys::Date::new(&ms.into()); | 665 | let date = js_sys::Date::new(&unix_ms(timestamp).into()); |
| 655 | let current = match field { | 666 | let current = match field { |
| 656 | DateField::Date => format!( | 667 | DateField::Date => format!( |
| 657 | "{:04}-{:02}-{:02}", | 668 | "{:04}-{:02}-{:02}", |
| ... | @@ -661,9 +672,20 @@ pub fn edit_date( | ... | @@ -661,9 +672,20 @@ pub fn edit_date( |
| 661 | ), | 672 | ), |
| 662 | DateField::Time => format!("{:02}:{:02}", date.get_hours(), date.get_minutes()), | 673 | DateField::Time => format!("{:02}:{:02}", date.get_hours(), date.get_minutes()), |
| 663 | }; | 674 | }; |
| 664 | let Some(answer) = ask_text(title, &current) else { | 675 | answered(ask_text(title, &current), move |answer| { |
| 665 | return Ok(None); | 676 | if let Some(answer) = answer.as_string() { |
| 666 | }; | 677 | reply.send(changed_date(timestamp, field, &answer)); |
| 678 | } | ||
| 679 | }); | ||
| 680 | } | ||
| 681 | |||
| 682 | /// `timestamp` with `field` changed to what the user typed, `answer`. | ||
| 683 | fn changed_date( | ||
| 684 | timestamp: u64, | ||
| 685 | field: DateField, | ||
| 686 | answer: &str, | ||
| 687 | ) -> Result<(u64, [String; 2]), &'static str> { | ||
| 688 | let date = js_sys::Date::new(&unix_ms(timestamp).into()); | ||
| 667 | let numbers: Vec<u32> = answer | 689 | let numbers: Vec<u32> = answer |
| 668 | .split(|c: char| !c.is_ascii_digit()) | 690 | .split(|c: char| !c.is_ascii_digit()) |
| 669 | .filter(|part| !part.is_empty()) | 691 | .filter(|part| !part.is_empty()) |
| ... | @@ -686,44 +708,53 @@ pub fn edit_date( | ... | @@ -686,44 +708,53 @@ pub fn edit_date( |
| 686 | .checked_mul(10_000_000) | 708 | .checked_mul(10_000_000) |
| 687 | .and_then(|ticks| ticks.checked_add(timestamp % 10_000_000)) | 709 | .and_then(|ticks| ticks.checked_add(timestamp % 10_000_000)) |
| 688 | .ok_or(crate::DATE_OUT_OF_RANGE)?; | 710 | .ok_or(crate::DATE_OUT_OF_RANGE)?; |
| 689 | Ok(Some((updated, date_text(updated)))) | 711 | Ok((updated, date_text(updated))) |
| 690 | } | 712 | } |
| 691 | 713 | ||
| 692 | /// Asks for a file to insert; once chosen it goes to the caret, as a dropped file does. | 714 | /// Asks for a file to insert; once chosen it goes to the caret, as a dropped file does. |
| 693 | pub fn pick_file(_: &str, types: &[&str]) -> Option<PathBuf> { | 715 | pub fn pick_file(_: &str, types: &[&str], _: Reply<PathBuf>) { |
| 694 | let accept: Vec<String> = types.iter().map(|kind| format!(".{kind}")).collect(); | 716 | let accept: Vec<String> = types.iter().map(|kind| format!(".{kind}")).collect(); |
| 695 | pick_files("place", &accept.join(",")); | 717 | pick_files("place", &accept.join(",")); |
| 696 | None | ||
| 697 | } | 718 | } |
| 698 | 719 | ||
| 699 | /// Asks for notebooks, sections or packages to open; they open once read. | 720 | /// Asks for notebooks, sections or packages to open; they open once read. |
| 700 | pub fn pick_notebook(_: &str) -> Option<PathBuf> { | 721 | pub fn pick_notebook(_: &str, _: Reply<PathBuf>) { |
| 701 | pick_notebook_files(); | 722 | pick_notebook_files(); |
| 702 | None | ||
| 703 | } | 723 | } |
| 704 | 724 | ||
| 705 | /// A new notebook goes in the browser's notebooks, under a name not yet taken; anything else | 725 | /// A new notebook goes in the browser's notebooks, under a name not yet taken; anything else |
| 706 | /// asked a place goes there too, then downloads. | 726 | /// asked a place goes there too, then downloads. |
| 707 | pub fn pick_new(title: &str, name: &str, _: &str, _: Option<&Path>) -> Option<PathBuf> { | 727 | pub fn pick_new(title: &str, name: &str, _: &str, _: Option<&Path>, reply: Reply<PathBuf>) { |
| 708 | let name = ask_text(title, name)?; | 728 | answered(ask_text(title, name), move |answer| { |
| 709 | let name = name.trim().replace(['/', '\\'], " "); | 729 | let Some(name) = answer.as_string() else { |
| 710 | let folder = Path::new(NOTEBOOKS); | 730 | return; |
| 711 | let _ = notebook::fs::create_dir_all(folder); | 731 | }; |
| 712 | let mut path = folder.join(&name); | 732 | let name = name.trim().replace(['/', '\\'], " "); |
| 713 | let mut number = 2; | 733 | let folder = Path::new(NOTEBOOKS); |
| 714 | while notebook::fs::metadata(&path).is_ok() { | 734 | let _ = notebook::fs::create_dir_all(folder); |
| 715 | path = folder.join(format!("{name} {number}")); | 735 | let mut path = folder.join(&name); |
| 716 | number += 1; | 736 | let mut number = 2; |
| 717 | } | 737 | while notebook::fs::metadata(&path).is_ok() { |
| 718 | Some(path) | 738 | path = folder.join(format!("{name} {number}")); |
| 739 | number += 1; | ||
| 740 | } | ||
| 741 | reply.send(path); | ||
| 742 | }); | ||
| 719 | } | 743 | } |
| 720 | 744 | ||
| 721 | pub fn confirm(message: &str, detail: &str, _: &str, _: &str) -> bool { | 745 | pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) { |
| 722 | ask_confirm(&format!("{message}\n\n{detail}")) | 746 | answered( |
| 747 | ask_confirm(message, detail, cancel, action), | ||
| 748 | move |chosen| { | ||
| 749 | if chosen.is_truthy() { | ||
| 750 | reply.send(()); | ||
| 751 | } | ||
| 752 | }, | ||
| 753 | ); | ||
| 723 | } | 754 | } |
| 724 | 755 | ||
| 725 | pub fn alert(message: &str, detail: &str) { | 756 | pub fn alert(message: &str, detail: &str) { |
| 726 | tell(&format!("{message}\n\n{detail}")); | 757 | tell(message, detail); |
| 727 | } | 758 | } |
| 728 | 759 | ||
| 729 | pub fn inform(message: &str, detail: &str) { | 760 | pub fn inform(message: &str, detail: &str) { |
crates/snowbound/src/windows.rs+103-121| ... | @@ -5,6 +5,7 @@ | ... | @@ -5,6 +5,7 @@ |
| 5 | //! Anything newer than Windows 7 is looked up at run time, so one executable starts on all | 5 | //! Anything newer than Windows 7 is looked up at run time, so one executable starts on all |
| 6 | //! of them. | 6 | //! of them. |
| 7 | 7 | ||
| 8 | use crate::Reply; | ||
| 8 | use canvas::date::DateField; | 9 | use canvas::date::DateField; |
| 9 | use std::{ | 10 | use std::{ |
| 10 | ffi::c_void, | 11 | ffi::c_void, |
| ... | @@ -1372,8 +1373,8 @@ fn credential_target(mount: &crate::library::Mount) -> Vec<u16> { | ... | @@ -1372,8 +1373,8 @@ fn credential_target(mount: &crate::library::Mount) -> Vec<u16> { |
| 1372 | wide(format!("Snowbound/smb/{}", mount.host())) | 1373 | wide(format!("Snowbound/smb/{}", mount.host())) |
| 1373 | } | 1374 | } |
| 1374 | 1375 | ||
| 1375 | /// The password the Credential Manager keeps for `mount`'s server, or what the user types | 1376 | /// The password the Credential Manager keeps for `mount`'s server; without one, Snowbound's |
| 1376 | /// into the system's sign-in dialog when it keeps none. | 1377 | /// sign-in asks. |
| 1377 | pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> { | 1378 | pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> { |
| 1378 | use windows_sys::Win32::Security::Credentials as cred; | 1379 | use windows_sys::Win32::Security::Credentials as cred; |
| 1379 | let target = credential_target(mount); | 1380 | let target = credential_target(mount); |
| ... | @@ -1406,51 +1407,11 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, | ... | @@ -1406,51 +1407,11 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, |
| 1406 | domain: mount.domain.clone(), | 1407 | domain: mount.domain.clone(), |
| 1407 | }); | 1408 | }); |
| 1408 | } | 1409 | } |
| 1409 | let caption = wide("Snowbound"); | 1410 | Err(format!( |
| 1410 | let message = wide(format!("Sign in to {}", mount.server)); | 1411 | "Enter the password for \u{201c}{}\u{201d} on \u{201c}{}\u{201d}.", |
| 1411 | let info = cred::CREDUI_INFOW { | 1412 | mount.user.as_deref().unwrap_or_default(), |
| 1412 | cbSize: size_of::<cred::CREDUI_INFOW>() as u32, | 1413 | mount.host() |
| 1413 | hwndParent: owner(), | 1414 | )) |
| 1414 | pszMessageText: message.as_ptr(), | ||
| 1415 | pszCaptionText: caption.as_ptr(), | ||
| 1416 | hbmBanner: std::ptr::null_mut(), | ||
| 1417 | }; | ||
| 1418 | let mut user = [0u16; cred::CREDUI_MAX_USERNAME_LENGTH as usize + 1]; | ||
| 1419 | for (slot, unit) in user | ||
| 1420 | .iter_mut() | ||
| 1421 | .zip(mount.user.clone().unwrap_or_default().encode_utf16()) | ||
| 1422 | { | ||
| 1423 | *slot = unit; | ||
| 1424 | } | ||
| 1425 | let mut password = [0u16; 257]; | ||
| 1426 | let mut save: BOOL = 0; | ||
| 1427 | let server = wide(&mount.server); | ||
| 1428 | let result = unsafe { | ||
| 1429 | cred::CredUIPromptForCredentialsW( | ||
| 1430 | &info, | ||
| 1431 | server.as_ptr(), | ||
| 1432 | std::ptr::null_mut(), | ||
| 1433 | 0, | ||
| 1434 | user.as_mut_ptr(), | ||
| 1435 | user.len() as u32, | ||
| 1436 | password.as_mut_ptr(), | ||
| 1437 | password.len() as u32, | ||
| 1438 | &mut save, | ||
| 1439 | cred::CREDUI_FLAGS_GENERIC_CREDENTIALS | ||
| 1440 | | cred::CREDUI_FLAGS_DO_NOT_PERSIST | ||
| 1441 | | cred::CREDUI_FLAGS_ALWAYS_SHOW_UI, | ||
| 1442 | ) | ||
| 1443 | }; | ||
| 1444 | let login = crate::library::Login { | ||
| 1445 | user: narrow(&user), | ||
| 1446 | password: narrow(&password), | ||
| 1447 | domain: mount.domain.clone(), | ||
| 1448 | }; | ||
| 1449 | password.fill(0); | ||
| 1450 | match result { | ||
| 1451 | 0 => Ok(login), | ||
| 1452 | _ => Err("Signing in was canceled".to_owned()), | ||
| 1453 | } | ||
| 1454 | } | 1415 | } |
| 1455 | 1416 | ||
| 1456 | /// What the sign-in offers for keeping a password, which the Credential Manager keeps. | 1417 | /// What the sign-in offers for keeping a password, which the Credential Manager keeps. |
| ... | @@ -1613,41 +1574,41 @@ pub fn edit_date( | ... | @@ -1613,41 +1574,41 @@ pub fn edit_date( |
| 1613 | timestamp: u64, | 1574 | timestamp: u64, |
| 1614 | field: DateField, | 1575 | field: DateField, |
| 1615 | title: &str, | 1576 | title: &str, |
| 1616 | ) -> Result<Option<(u64, [String; 2])>, &'static str> { | 1577 | reply: Reply<Result<(u64, [String; 2]), &'static str>>, |
| 1617 | let before = local_time(timestamp); | 1578 | ) { |
| 1618 | let Some(chosen) = picker::pick(&before, field, title) else { | 1579 | let title = title.to_owned(); |
| 1619 | return Ok(None); | 1580 | reply.after(move || { |
| 1620 | }; | 1581 | let before = local_time(timestamp); |
| 1621 | let mut time = before; | 1582 | let chosen = picker::pick(&before, field, &title)?; |
| 1622 | match field { | 1583 | let mut time = before; |
| 1623 | DateField::Date => { | 1584 | match field { |
| 1624 | (time.wYear, time.wMonth, time.wDay) = (chosen.wYear, chosen.wMonth, chosen.wDay); | 1585 | DateField::Date => { |
| 1625 | } | 1586 | (time.wYear, time.wMonth, time.wDay) = (chosen.wYear, chosen.wMonth, chosen.wDay); |
| 1626 | DateField::Time => { | 1587 | } |
| 1627 | (time.wHour, time.wMinute) = (chosen.wHour, chosen.wMinute); | 1588 | DateField::Time => { |
| 1589 | (time.wHour, time.wMinute) = (chosen.wHour, chosen.wMinute); | ||
| 1590 | } | ||
| 1628 | } | 1591 | } |
| 1629 | } | 1592 | let updated = filetime(&time).ok_or(crate::DATE_OUT_OF_RANGE); |
| 1630 | let updated = filetime(&time).ok_or(crate::DATE_OUT_OF_RANGE)?; | 1593 | // FILETIME's sub-second ticks stay as they were. |
| 1631 | // FILETIME's sub-second ticks stay as they were. | 1594 | Some(updated.map(|updated| { |
| 1632 | let updated = updated / 10_000_000 * 10_000_000 + timestamp % 10_000_000; | 1595 | let updated = updated / 10_000_000 * 10_000_000 + timestamp % 10_000_000; |
| 1633 | Ok(Some((updated, date_labels(&local_time(updated))))) | 1596 | (updated, date_labels(&local_time(updated))) |
| 1597 | })) | ||
| 1598 | }); | ||
| 1634 | } | 1599 | } |
| 1635 | 1600 | ||
| 1636 | /// Asks for a notebook's table of contents or a section file, titled `title`. | 1601 | /// Asks for a notebook's table of contents or a section file, titled `title`. |
| 1637 | pub fn pick_notebook(title: &str) -> Option<PathBuf> { | 1602 | pub fn pick_notebook(title: &str, reply: Reply<PathBuf>) { |
| 1638 | pick( | 1603 | let filter = ( |
| 1639 | title, | 1604 | "OneNote notebooks, sections and packages", |
| 1640 | &[( | 1605 | "*.onetoc2;*.one;*.onepkg", |
| 1641 | "OneNote notebooks, sections and packages", | 1606 | ); |
| 1642 | "*.onetoc2;*.one;*.onepkg", | 1607 | pick(title, &[filter], None, false, reply); |
| 1643 | )], | ||
| 1644 | None, | ||
| 1645 | false, | ||
| 1646 | ) | ||
| 1647 | } | 1608 | } |
| 1648 | 1609 | ||
| 1649 | /// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. | 1610 | /// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. |
| 1650 | pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> { | 1611 | pub fn pick_file(title: &str, types: &[&str], reply: Reply<PathBuf>) { |
| 1651 | let patterns = types | 1612 | let patterns = types |
| 1652 | .iter() | 1613 | .iter() |
| 1653 | .map(|kind| format!("*.{kind}")) | 1614 | .map(|kind| format!("*.{kind}")) |
| ... | @@ -1658,7 +1619,7 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> { | ... | @@ -1658,7 +1619,7 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> { |
| 1658 | } else { | 1619 | } else { |
| 1659 | vec![("Supported files", patterns.as_str()), ("All files", "*.*")] | 1620 | vec![("Supported files", patterns.as_str()), ("All files", "*.*")] |
| 1660 | }; | 1621 | }; |
| 1661 | pick(title, &filters, None, false) | 1622 | pick(title, &filters, None, false, reply); |
| 1662 | } | 1623 | } |
| 1663 | 1624 | ||
| 1664 | /// Asks where to put something named `name` by default; the dialog names its own button. | 1625 | /// Asks where to put something named `name` by default; the dialog names its own button. |
| ... | @@ -1667,19 +1628,26 @@ pub fn pick_new( | ... | @@ -1667,19 +1628,26 @@ pub fn pick_new( |
| 1667 | name: &str, | 1628 | name: &str, |
| 1668 | _action: &str, | 1629 | _action: &str, |
| 1669 | folder: Option<&std::path::Path>, | 1630 | folder: Option<&std::path::Path>, |
| 1670 | ) -> Option<PathBuf> { | 1631 | reply: Reply<PathBuf>, |
| 1671 | pick(title, &[("All files", "*.*")], Some((name, folder)), true) | 1632 | ) { |
| 1633 | pick( | ||
| 1634 | title, | ||
| 1635 | &[("All files", "*.*")], | ||
| 1636 | Some((name, folder)), | ||
| 1637 | true, | ||
| 1638 | reply, | ||
| 1639 | ); | ||
| 1672 | } | 1640 | } |
| 1673 | 1641 | ||
| 1674 | /// The common file dialog: an open dialog over `filters` (name and `;`-separated | 1642 | /// The common file dialog, on a thread of its own: an open dialog over `filters` (name and |
| 1675 | /// patterns), or with `new`, a save dialog suggesting a name in a folder. | 1643 | /// `;`-separated patterns), or with `new`, a save dialog suggesting a name in a folder. |
| 1676 | fn pick( | 1644 | fn pick( |
| 1677 | title: &str, | 1645 | title: &str, |
| 1678 | filters: &[(&str, &str)], | 1646 | filters: &[(&str, &str)], |
| 1679 | new: Option<(&str, Option<&std::path::Path>)>, | 1647 | new: Option<(&str, Option<&std::path::Path>)>, |
| 1680 | save: bool, | 1648 | save: bool, |
| 1681 | ) -> Option<PathBuf> { | 1649 | reply: Reply<PathBuf>, |
| 1682 | use windows_sys::Win32::UI::Controls::Dialogs as dialogs; | 1650 | ) { |
| 1683 | let mut filter: Vec<u16> = Vec::new(); | 1651 | let mut filter: Vec<u16> = Vec::new(); |
| 1684 | for (name, patterns) in filters { | 1652 | for (name, patterns) in filters { |
| 1685 | filter.extend(name.encode_utf16().chain([0])); | 1653 | filter.extend(name.encode_utf16().chain([0])); |
| ... | @@ -1694,54 +1662,68 @@ fn pick( | ... | @@ -1694,54 +1662,68 @@ fn pick( |
| 1694 | } | 1662 | } |
| 1695 | let folder = new.and_then(|(_, folder)| folder).map(wide); | 1663 | let folder = new.and_then(|(_, folder)| folder).map(wide); |
| 1696 | let title = wide(title); | 1664 | let title = wide(title); |
| 1697 | let mut dialog = dialogs::OPENFILENAMEW { | 1665 | reply.after(move || { |
| 1698 | lStructSize: size_of::<dialogs::OPENFILENAMEW>() as u32, | 1666 | use windows_sys::Win32::{System::Com, UI::Controls::Dialogs as dialogs}; |
| 1699 | hwndOwner: owner(), | 1667 | let mut dialog = dialogs::OPENFILENAMEW { |
| 1700 | lpstrFilter: filter.as_ptr(), | 1668 | lStructSize: size_of::<dialogs::OPENFILENAMEW>() as u32, |
| 1701 | lpstrFile: file.as_mut_ptr(), | 1669 | hwndOwner: owner(), |
| 1702 | nMaxFile: file.len() as u32, | 1670 | lpstrFilter: filter.as_ptr(), |
| 1703 | lpstrTitle: title.as_ptr(), | 1671 | lpstrFile: file.as_mut_ptr(), |
| 1704 | lpstrInitialDir: folder | 1672 | nMaxFile: file.len() as u32, |
| 1705 | .as_ref() | 1673 | lpstrTitle: title.as_ptr(), |
| 1706 | .map_or(std::ptr::null(), |folder| folder.as_ptr()), | 1674 | lpstrInitialDir: folder |
| 1707 | Flags: dialogs::OFN_EXPLORER | 1675 | .as_ref() |
| 1708 | | dialogs::OFN_NOCHANGEDIR | 1676 | .map_or(std::ptr::null(), |folder| folder.as_ptr()), |
| 1709 | | if save { | 1677 | Flags: dialogs::OFN_EXPLORER |
| 1710 | dialogs::OFN_OVERWRITEPROMPT | 1678 | | dialogs::OFN_NOCHANGEDIR |
| 1679 | | if save { | ||
| 1680 | dialogs::OFN_OVERWRITEPROMPT | ||
| 1681 | } else { | ||
| 1682 | dialogs::OFN_FILEMUSTEXIST | ||
| 1683 | }, | ||
| 1684 | ..unsafe { std::mem::zeroed() } | ||
| 1685 | }; | ||
| 1686 | // The Explorer-style dialog hosts the shell's COM objects, which want an apartment. | ||
| 1687 | unsafe { Com::CoInitializeEx(std::ptr::null(), Com::COINIT_APARTMENTTHREADED as u32) }; | ||
| 1688 | let chosen = unsafe { | ||
| 1689 | if save { | ||
| 1690 | dialogs::GetSaveFileNameW(&mut dialog) | ||
| 1711 | } else { | 1691 | } else { |
| 1712 | dialogs::OFN_FILEMUSTEXIST | 1692 | dialogs::GetOpenFileNameW(&mut dialog) |
| 1713 | }, | 1693 | } |
| 1714 | ..unsafe { std::mem::zeroed() } | 1694 | }; |
| 1715 | }; | 1695 | unsafe { Com::CoUninitialize() }; |
| 1716 | let chosen = unsafe { | 1696 | if chosen == 0 { |
| 1717 | if save { | 1697 | // Zero where the user cancelled. |
| 1718 | dialogs::GetSaveFileNameW(&mut dialog) | 1698 | let error = unsafe { dialogs::CommDlgExtendedError() }; |
| 1719 | } else { | 1699 | if error != 0 { |
| 1720 | dialogs::GetOpenFileNameW(&mut dialog) | 1700 | eprintln!("The file dialog failed: {error:#x}"); |
| 1721 | } | 1701 | } |
| 1722 | }; | 1702 | return None; |
| 1723 | if chosen == 0 { | ||
| 1724 | // Zero where the user cancelled. | ||
| 1725 | let error = unsafe { dialogs::CommDlgExtendedError() }; | ||
| 1726 | if error != 0 { | ||
| 1727 | eprintln!("The file dialog failed: {error:#x}"); | ||
| 1728 | } | 1703 | } |
| 1729 | return None; | 1704 | Some(PathBuf::from(narrow(&file))) |
| 1730 | } | 1705 | }); |
| 1731 | Some(PathBuf::from(narrow(&file))) | ||
| 1732 | } | 1706 | } |
| 1733 | 1707 | ||
| 1734 | /// Tells the user something they asked for could not be done: `message`, then what to do. | 1708 | /// Tells the user something they asked for could not be done: `message`, then what to do. |
| 1735 | pub fn alert(message: &str, detail: &str) { | 1709 | pub fn alert(message: &str, detail: &str) { |
| 1736 | let text = wide(format!("{message}\n\n{detail}")); | 1710 | let text = wide(format!("{message}\n\n{detail}")); |
| 1737 | let caption = wide("Snowbound"); | 1711 | std::thread::spawn(move || { |
| 1738 | let style = wm::MB_OK | wm::MB_ICONWARNING; | 1712 | let caption = wide("Snowbound"); |
| 1739 | unsafe { wm::MessageBoxW(owner(), text.as_ptr(), caption.as_ptr(), style) }; | 1713 | let style = wm::MB_OK | wm::MB_ICONWARNING; |
| 1714 | unsafe { wm::MessageBoxW(owner(), text.as_ptr(), caption.as_ptr(), style) }; | ||
| 1715 | }); | ||
| 1716 | } | ||
| 1717 | |||
| 1718 | /// Asks whether to go ahead with `action`, offering `cancel` too. | ||
| 1719 | pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) { | ||
| 1720 | let [message, detail, cancel, action] = [message, detail, cancel, action].map(str::to_owned); | ||
| 1721 | reply.after(move || ask(&message, &detail, &cancel, &action).then_some(())); | ||
| 1740 | } | 1722 | } |
| 1741 | 1723 | ||
| 1742 | /// Asks whether to go ahead with `action`, offering `cancel` too: a task dialog with those | 1724 | /// Whether the user goes ahead with `action`: a task dialog with buttons for it and `cancel` |
| 1743 | /// buttons where the common controls have one, as from Windows Vista, else OK and Cancel. | 1725 | /// where the common controls have one, as from Windows Vista, else OK and Cancel. |
| 1744 | pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool { | 1726 | fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool { |
| 1745 | use windows_sys::Win32::UI::Controls as controls; | 1727 | use windows_sys::Win32::UI::Controls as controls; |
| 1746 | type Indirect = unsafe extern "system" fn( | 1728 | type Indirect = unsafe extern "system" fn( |
| 1747 | *const controls::TASKDIALOGCONFIG, | 1729 | *const controls::TASKDIALOGCONFIG, |
crates/snowbound/web/glue.js+31-5| ... | @@ -362,16 +362,42 @@ export function shortDate(ms) { | ... | @@ -362,16 +362,42 @@ export function shortDate(ms) { |
| 362 | return new Date(ms).toLocaleDateString(); | 362 | return new Date(ms).toLocaleDateString(); |
| 363 | } | 363 | } |
| 364 | 364 | ||
| 365 | export function askConfirm(message) { | 365 | /** Shows `message` and `detail` in a dialog over the page, with a field holding `value` |
| 366 | return confirm(message); | 366 | * unless that is undefined, and `buttons`, the first the default. Resolves to the index of |
| 367 | * the button pressed, -1 when dismissed, and the field's text. */ | ||
| 368 | function ask(message, detail, buttons, value) { | ||
| 369 | const dialog = Object.assign(document.createElement("dialog"), { className: "ask" }); | ||
| 370 | const form = Object.assign(document.createElement("form"), { method: "dialog" }); | ||
| 371 | const field = value === undefined ? null : Object.assign(document.createElement("input"), { value }); | ||
| 372 | const row = document.createElement("div"); | ||
| 373 | row.append(...buttons.map((label, index) => | ||
| 374 | Object.assign(document.createElement("button"), { value: index, textContent: label }))); | ||
| 375 | form.append(...[field, row].filter(Boolean)); | ||
| 376 | dialog.append(Object.assign(document.createElement("h1"), { textContent: message })); | ||
| 377 | if (detail) dialog.append(Object.assign(document.createElement("p"), { textContent: detail })); | ||
| 378 | dialog.append(form); | ||
| 379 | document.body.append(dialog); | ||
| 380 | dialog.showModal(); | ||
| 381 | return new Promise((resolve) => | ||
| 382 | dialog.addEventListener("close", () => { | ||
| 383 | dialog.remove(); | ||
| 384 | resolve([dialog.returnValue === "" ? -1 : Number(dialog.returnValue), field?.value]); | ||
| 385 | }), | ||
| 386 | ); | ||
| 387 | } | ||
| 388 | |||
| 389 | export function askConfirm(message, detail, cancel, action) { | ||
| 390 | return ask(message, detail, [action, cancel]).then(([pressed]) => pressed === 0); | ||
| 367 | } | 391 | } |
| 368 | 392 | ||
| 369 | export function askText(message, value) { | 393 | export function askText(message, value) { |
| 370 | return prompt(message, value) ?? undefined; | 394 | return ask(message, "", ["OK", "Cancel"], value).then(([pressed, text]) => |
| 395 | pressed === 0 ? text : undefined, | ||
| 396 | ); | ||
| 371 | } | 397 | } |
| 372 | 398 | ||
| 373 | export function tell(message) { | 399 | export function tell(message, detail) { |
| 374 | alert(message); | 400 | ask(message, detail, ["OK"]); |
| 375 | } | 401 | } |
| 376 | 402 | ||
| 377 | export function openLink(url) { | 403 | export function openLink(url) { |
crates/snowbound/web/index.html+10| ... | @@ -23,6 +23,16 @@ | ... | @@ -23,6 +23,16 @@ |
| 23 | position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); | 23 | position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); |
| 24 | font: 14px system-ui, sans-serif; padding: 6px 14px; | 24 | font: 14px system-ui, sans-serif; padding: 6px 14px; |
| 25 | } | 25 | } |
| 26 | /* Dialogs (`ask` in glue.js); the default button is first in order and last in the row. */ | ||
| 27 | dialog.ask { | ||
| 28 | color-scheme: light dark; width: min(420px, 90vw); box-sizing: border-box; padding: 20px; | ||
| 29 | border: 0; border-radius: 10px; box-shadow: 0 8px 32px #0004; font: 14px system-ui, sans-serif; | ||
| 30 | } | ||
| 31 | dialog.ask::backdrop { background: #0002; } | ||
| 32 | dialog.ask h1 { margin: 0 0 8px; font-size: 15px; } | ||
| 33 | dialog.ask p { margin: 0; white-space: pre-line; } | ||
| 34 | dialog.ask input { box-sizing: border-box; width: 100%; font: inherit; } | ||
| 35 | dialog.ask div { display: flex; flex-direction: row-reverse; gap: 8px; margin-top: 16px; } | ||
| 26 | #status { | 36 | #status { |
| 27 | position: fixed; inset: 0; display: grid; place-items: center; | 37 | position: fixed; inset: 0; display: grid; place-items: center; |
| 28 | font: 15px system-ui, sans-serif; color: #666; pointer-events: none; | 38 | font: 15px system-ui, sans-serif; color: #666; pointer-events: none; |