authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 23:42:05-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 01:19:52-07:00
log42499fefe59cff82e17b0f6ec64b92bf090a6a78
treea6df23a2c16090f99be8a85aa3421707efe018a9
parenta7388496fddd44f7037f7224f6300a31b9a784ea
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: dialogs never block the window

Every question, alert, file chooser and date picker now answers through a callback run on the event loop instead of waiting on its thread, so the window keeps drawing, syncing and serving the clipboard while one is open. macOS shows them as sheets; Windows and Linux wait on them from a thread of their own; the web build asks with dialogs on the page instead of the browser's blocking prompts. On Linux, where no portal, zenity or kdialog can ask, Snowbound's own dialogs now open in the window, drawn with the interface kit, including a file chooser, rather than as a second process, so New Notebook, Open and Save As no longer do nothing silently on a bare desktop. Signing in to a server whose password nothing keeps happens in Open Notebook from Server, as on macOS, rather than in a system prompt. Assisted-by: claude-opus-5.5

24 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's77 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 portal80- 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's83 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 its85 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).
4949
50The platform still owns what it's best at and what people expect to be50The platform still owns what it's best at and what people expect to be
51native. That means file pickers, alerts and date pickers (AppKit on macOS,51native. That means file pickers, alerts and date pickers (AppKit's sheets on
52zenity or kdialog on Linux), the caret and selection colours, each platform's52macOS; the portal, zenity or kdialog on Linux, else the kit's own), none of
53which blocks the window, the caret and selection colours, each platform's
53editing chords, the keychain, the traffic lights and window frames. On iOS the54editing chords, the keychain, the traffic lights and window frames. On iOS the
54split goes further (see [platforms](platforms.md)).55split goes further (see [platforms](platforms.md)).
5556
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 opening43- 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 kdialog44 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.
4647
47## Known limits48## Known limits
4849
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}
400400
401/// After confirming, removes what Install wrote; notebooks stay where they are.401/// After confirming, removes what Install wrote; notebooks stay where they are.
402pub fn uninstall() {402pub 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
423fn 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 nor1//! 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, which2//! 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.
44
5use draw::Renderer;5use crate::{Reply, State, UserEvent, art};
6use std::{error::Error, ffi::OsString, process::Command, sync::Arc, time::Instant};6use accesskit::Role;
7use ui::{Axis, Id, Spec, Ui, children, fill, fit, px};7use std::path::{Path, PathBuf};
8use winit::{8use ui::{Anchor, Axis, Flags, Id, Spec, children, fill, px};
9 application::ApplicationHandler,9use winit::keyboard::NamedKey;
10 dpi::LogicalSize,
11 event::{ElementState, WindowEvent},
12 event_loop::{ActiveEventLoop, EventLoop},
13 keyboard::{Key, NamedKey},
14 window::{Window, WindowId},
15};
1610
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.
18pub const DIALOG: &str = "--dialog";12pub enum Ask {
19
20pub 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}
3033
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.35pub struct Dialog {
33pub 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}
5544
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
57pub fn run(mut args: impl Iterator<Item = OsString>) -> Result<(), Box<dyn Error>> {46/// answered. Any thread may ask.
58 let mut next = || {47pub 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}
8866
89struct Dialog {67/// The folder a file chooser starts in where it is given none.
90 kind: String,68pub 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}
10074
101struct 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,77fn 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}
107100
108fn field(index: usize) -> Id {101fn id() -> Id {
109 Id::ROOT.child(("field", index))102 Id::ROOT.child("own dialog")
110}103}
111104
112impl Dialog {105fn 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();110enum 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}
119118
120 /// Lays out and paints the dialog; Some once a button answers it.119impl 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
246impl 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 }
291303
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)]
360mod 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 {
188188
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 = open194 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's3//! the colour scheme from the settings portal, and text conventions from the C library's
4//! locale.4//! locale.
55
6use crate::dialog::Ask;6use crate::{Reply, dialog::Ask};
7use canvas::date::DateField;7use canvas::date::DateField;
8use std::{8use 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")]
42const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")];42const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")];
43const RESTORE: &[&str] = &[include_str!("../assets/icons/window-restore.svg")];43const RESTORE: &[&str] = &[include_str!("../assets/icons/window-restore.svg")];
4444
45static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();45/// The event loop's, for signals and Snowbound's own dialogs.
46pub(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 where47/// 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.
48static BREEZE_RADIUS: AtomicU32 = AtomicU32::new(0);49static 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}
12001202
1201/// The password the Secret Service keeps for `mount`'s account, as GNOME's file manager1203/// 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.
1203pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> {1205pub 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}
12411229
1242/// What the sign-in offers for keeping a password, where the Secret Service's tool is1230/// 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}
14041392
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.
1406pub fn edit_date(1395pub 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`.
1449fn 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> = answer1455 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}
14611463
1462fn merge_date(1464fn 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}
14991501
1500/// Runs zenity, or kdialog where zenity is missing; the answer is None when cancelled, and an1502/// 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.
1502fn dialog<const Z: usize, const K: usize>(1504fn 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(output1512 Ok(output
1512 .status1513 .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}
15161517
1517/// The path a file dialog answered; none when cancelled or when no tool could ask.
1518fn 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`.
1529pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> {1519pub 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}
15511550
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}
15571556
1558/// The file the file chooser portal answers `method` with, titled `title`, or where no portal1557/// 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
1560fn choose<const Z: usize, const K: usize>(1559/// is installed, Snowbound's `own`.
1560fn 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}
15861600
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}
16801694
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
1682pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool {1696/// else Snowbound's own dialog.
1683 let status = Command::new("zenity")1697pub 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}
17031729
1704/// Asks for a notebook's table of contents or a section file, titled `title`; None when1730/// Asks for a notebook's table of contents or a section file, titled `title`.
1705/// cancelled or when no tool can ask.1731pub fn pick_notebook(title: &str, reply: Reply<PathBuf>) {
1706pub 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}
17211755
1722/// Asks where to put something named `name` by default, in `folder` where given, with1756/// 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}
17531800
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 @@
1use crate::commands;1use crate::{Reply, commands};
2use canvas::date::DateField;2use canvas::date::DateField;
3use objc2::{3use objc2::{
4 ClassType, DeclaredClass, class,4 ClassType, DeclaredClass, class,
...@@ -11,7 +11,8 @@ use objc2::{...@@ -11,7 +11,8 @@ use objc2::{
11use objc2_app_kit::{11use 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};
16use objc2_foundation::{17use 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}
9495
96thread_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.
105fn 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.
127fn 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.
96pub const LEADING: f32 = 78.0;150pub 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 {
131pub fn install_title_bar(window: &Window) {185pub 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> {
765820
766/// Asks for a notebook folder or a notebook file with the system's open panel, titled821/// Asks for a notebook folder or a notebook file with the system's open panel, titled
767/// `title`.822/// `title`.
768pub fn pick_notebook(title: &str) -> Option<std::path::PathBuf> {823pub 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}
784835
...@@ -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}
811860
812/// Asks for a file to insert, one of `types` (extensions) unless empty, with the system's861/// 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`.
814pub fn pick_file(title: &str, types: &[&str]) -> Option<std::path::PathBuf> {863pub 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}
832877
...@@ -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}
10161061
...@@ -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}
11261175
...@@ -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}
15121572
1513/// Asks whether to go ahead with `action`, offering `cancel` first.1573/// Asks whether to go ahead with `action`, offering `cancel` first.
1514pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool {1574pub 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() == NSAlertSecondButtonReturn1582 begin_alert(&alert, |response| {
1583 if response == NSAlertSecondButtonReturn {
1584 reply.send(());
1585 }
1586 });
1524 }1587 }
1525}1588}
15261589
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
214214
215type Continuation = Box<dyn FnOnce(&mut State) -> Result<(), Box<dyn Error>> + Send>;215type Continuation = Box<dyn FnOnce(&mut State) -> Result<(), Box<dyn Error>> + Send>;
216216
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.
220struct Reply<T>(Box<dyn FnOnce(T) + Send>);
221
222impl<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
217enum UserEvent {254enum 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 }
35633612
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 }
35823649
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"))]
5608impl App {5677impl 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 && state5684 .initial_layouts
5616 .initial_layouts5685 .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 && state5689 .initial
5621 .initial5690 .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(editor5696 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}
56415719
...@@ -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}
60096080
crates/snowbound/src/manage.rs+9-7
...@@ -261,14 +261,16 @@ impl State {...@@ -261,14 +261,16 @@ impl State {
261261
262 /// Asks where to create a notebook and creates it, as File, New does in OneNote; the262 /// 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 }
273275
274 /// New iCloud Notebook: asks for a name, the first free of "iCloud Notebook", "iCloud276 /// 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 None650 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 None738 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 = spaces338 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 {
6565
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 }
78107
79 /// Does `request` to the bin's open section on a thread of its own, then shows the page108 /// 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 = (library179 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 then589 /// 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 browse175 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.
55
6use crate::{State, UserEvent, commands, page, settings};6use crate::{Reply, State, UserEvent, commands, page, settings};
7use canvas::date::DateField;7use canvas::date::DateField;
8use std::{8use 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}
646648
649/// Runs `then` with what the glue's dialog resolves `asked` to, once the user answers.
650fn 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`.
648pub fn edit_date(659pub 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`.
683fn 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> = answer689 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}
691713
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.
693pub fn pick_file(_: &str, types: &[&str]) -> Option<PathBuf> {715pub 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}
698719
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.
700pub fn pick_notebook(_: &str) -> Option<PathBuf> {721pub fn pick_notebook(_: &str, _: Reply<PathBuf>) {
701 pick_notebook_files();722 pick_notebook_files();
702 None
703}723}
704724
705/// A new notebook goes in the browser's notebooks, under a name not yet taken; anything else725/// 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.
707pub fn pick_new(title: &str, name: &str, _: &str, _: Option<&Path>) -> Option<PathBuf> {727pub 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}
720744
721pub fn confirm(message: &str, detail: &str, _: &str, _: &str) -> bool {745pub 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}
724755
725pub fn alert(message: &str, detail: &str) {756pub fn alert(message: &str, detail: &str) {
726 tell(&format!("{message}\n\n{detail}"));757 tell(message, detail);
727}758}
728759
729pub fn inform(message: &str, detail: &str) {760pub 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 all5//! Anything newer than Windows 7 is looked up at run time, so one executable starts on all
6//! of them.6//! of them.
77
8use crate::Reply;
8use canvas::date::DateField;9use canvas::date::DateField;
9use std::{10use 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}
13741375
1375/// The password the Credential Manager keeps for `mount`'s server, or what the user types1376/// 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.
1377pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> {1378pub 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}
14551416
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}
16351600
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`.
1637pub fn pick_notebook(title: &str) -> Option<PathBuf> {1602pub 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}
16481609
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`.
1650pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> {1611pub fn pick_file(title: &str, types: &[&str], reply: Reply<PathBuf>) {
1651 let patterns = types1612 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}
16631624
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}
16731641
1674/// The common file dialog: an open dialog over `filters` (name and `;`-separated1642/// 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.
1676fn pick(1644fn 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: folder1672 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_EXPLORER1675 .as_ref()
1708 | dialogs::OFN_NOCHANGEDIR1676 .map_or(std::ptr::null(), |folder| folder.as_ptr()),
1709 | if save {1677 Flags: dialogs::OFN_EXPLORER
1710 dialogs::OFN_OVERWRITEPROMPT1678 | 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_FILEMUSTEXIST1692 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}
17331707
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.
1735pub fn alert(message: &str, detail: &str) {1709pub 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.
1719pub 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}
17411723
1742/// Asks whether to go ahead with `action`, offering `cancel` too: a task dialog with those1724/// 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.
1744pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool {1726fn 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}
364364
365export 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. */
368function 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
389export function askConfirm(message, detail, cancel, action) {
390 return ask(message, detail, [action, cancel]).then(([pressed]) => pressed === 0);
367}391}
368392
369export function askText(message, value) {393export 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}
372398
373export function tell(message) {399export function tell(message, detail) {
374 alert(message);400 ask(message, detail, ["OK"]);
375}401}
376402
377export function openLink(url) {403export 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;