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.
7777 popover menus on GNOME, shown and hidden at once as GTK 4 does, and Breeze's
7878 on KDE, faded as KWin fades popups and scaled by Plasma's animation speed.
7979 The desktop is read once from `XDG_CURRENT_DESKTOP`; elsewhere the kit's own.
80- zenity or kdialog provide the pickers and alerts. The XDG settings portal
80- The desktop portal provides the file pickers, zenity or kdialog the alerts,
81 and where neither can, Snowbound asks with the kit's own dialogs in the
82 window. No dialog waits on the event loop's thread. The XDG settings portal
8183 provides the colour scheme. Text conventions come from the C library's
8284 locale. Fontconfig is loaded at run time, so builds need no headers for it,
8385 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)).
4848 project aims further (Windows, and old versions of OS X).
4949
5050The 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,
52zenity or kdialog on Linux), the caret and selection colours, each platform's
51native. That means file pickers, alerts and date pickers (AppKit's sheets on
52macOS; 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
5354editing chords, the keychain, the traffic lights and window frames. On iOS the
5455split goes further (see [platforms](platforms.md)).
5556
crates/snowbound/linux/README.md+2-1
......@@ -42,7 +42,8 @@ Snowbound.
4242- fontconfig, and X11 or Wayland with libxkbcommon.
4343- The desktop's portal (xdg-desktop-portal) for choosing, printing and opening
4444 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
4748## Known limits
4849
crates/snowbound/src/attachment.rs+3-3
......@@ -82,9 +82,9 @@ impl State {
8282 unstored();
8383 return Ok(());
8484 };
85 if let Some(path) = platform::pick_new("Save As", &file.filename, "Save", None) {
86 notebook::fs::write(path, bytes)?;
87 }
85 let bytes = bytes.clone();
86 let reply = self.reply(move |_, path| Ok(notebook::fs::write(path, bytes)?));
87 platform::pick_new("Save As", &file.filename, "Save", None, reply);
8888 Ok(())
8989 }
9090}
crates/snowbound/src/commands.rs+8-9
......@@ -1558,17 +1558,16 @@ impl State {
15581558 return Ok(());
15591559 }
15601560 Id::Picture => {
1561 let Some(path) = platform::pick_file("Insert Picture", &crate::PICTURE_TYPES)
1562 else {
1563 return Ok(());
1564 };
1565 return self.insert_picture(notebook::fs::read(path)?, None);
1561 let reply = self.reply(|state, path: std::path::PathBuf| {
1562 state.insert_picture(notebook::fs::read(path)?, None)
1563 });
1564 platform::pick_file("Insert Picture", &crate::PICTURE_TYPES, reply);
1565 return Ok(());
15661566 }
15671567 Id::Attachment => {
1568 let Some(path) = platform::pick_file("Attach File", &[]) else {
1569 return Ok(());
1570 };
1571 return self.attach(&path, None);
1568 let reply = self.reply(|state, path: std::path::PathBuf| state.attach(&path, None));
1569 platform::pick_file("Attach File", &[], reply);
1570 return Ok(());
15721571 }
15731572 Id::ScreenClipping => {
15741573 #[cfg(target_os = "macos")]
crates/snowbound/src/desktop_linux.rs+18-4
......@@ -399,17 +399,31 @@ fn try_install() -> io::Result<()> {
399399}
400400
401401/// After confirming, removes what Install wrote; notebooks stay where they are.
402pub fn uninstall() {
403 let (Some(data), Some(binary)) = (data_home(), binary()) else {
402pub fn uninstall(proxy: &winit::event_loop::EventLoopProxy<crate::UserEvent>) {
403 let Some(binary) = binary() else {
404404 return;
405405 };
406406 let detail = format!(
407407 "Snowbound leaves the app menu and {} is deleted. Your notebooks stay where they are.",
408408 binary.display()
409409 );
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 {
411425 return;
412 }
426 };
413427 let mut failed = None;
414428 let icons = SIZES.map(|side| theme_icon(&data, side));
415429 if let Some(staged) = crate::update::staging(&binary)
crates/snowbound/src/dialog_linux.rs+308-272
......@@ -1,351 +1,387 @@
1//! Snowbound's own message, question and entry dialogs, for desktops with neither zenity nor
2//! kdialog, as GNOME's image-based systems ship: the app runs itself as the dialog, which
3//! answers on stdout and exits successfully once accepted.
1//! Snowbound's own dialogs, drawn with the interface kit in the window, where no desktop
2//! portal, zenity or kdialog can ask, as on GNOME's image-based systems or a bare X11
3//! desktop: messages, questions, a line of text, and a file chooser.
44
5use draw::Renderer;
6use std::{error::Error, ffi::OsString, process::Command, sync::Arc, time::Instant};
7use ui::{Axis, Id, Spec, Ui, children, fill, fit, px};
8use winit::{
9 application::ApplicationHandler,
10 dpi::LogicalSize,
11 event::{ElementState, WindowEvent},
12 event_loop::{ActiveEventLoop, EventLoop},
13 keyboard::{Key, NamedKey},
14 window::{Window, WindowId},
15};
5use crate::{Reply, State, UserEvent, art};
6use accesskit::Role;
7use std::path::{Path, PathBuf};
8use ui::{Anchor, Axis, Flags, Id, Spec, children, fill, px};
9use winit::keyboard::NamedKey;
1610
17/// The argument that runs the app as a dialog.
18pub const DIALOG: &str = "--dialog";
19
20pub enum Ask<'a> {
11/// What a dialog asks, and where its answer goes; one dismissed drops its reply.
12pub enum Ask {
2113 /// A message with an OK button.
2214 Message,
2315 /// Whether to go ahead: buttons for `cancel` and for `action`.
24 Question { cancel: &'a str, action: &'a str },
25 /// A line of text, starting as `value`.
26 Entry { value: &'a str },
27 /// A user name, starting as `user`, and a password, answered as zenity does: `user|password`.
28 Login { user: &'a str },
16 Question {
17 cancel: String,
18 action: String,
19 reply: Reply<()>,
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 },
2932}
3033
31/// Shows `title` and `detail` as `ask` asks, then waits: the answer, empty but for an entry,
32/// or None when cancelled.
33pub fn ask(title: &str, detail: &str, ask: Ask) -> Option<String> {
34 let (kind, extra): (&str, &[&str]) = match &ask {
35 Ask::Message => ("message", &[]),
36 Ask::Question { cancel, action } => ("question", &[cancel, action]),
37 Ask::Entry { value } => ("entry", &[value]),
38 Ask::Login { user } => ("login", &[user]),
39 };
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())
34/// A dialog waiting its turn, or shown.
35pub struct Dialog {
36 title: String,
37 detail: String,
38 ask: Ask,
39 /// A file chooser's folder: its folders, then the files it offers, each with whether it
40 /// is a folder; or why the folder can't be read.
41 entries: Result<Vec<(String, bool)>, String>,
42 shown: bool,
5443}
5544
56/// The dialog `ask` would show, given the arguments after `DIALOG`.
57pub fn run(mut args: impl Iterator<Item = OsString>) -> Result<(), Box<dyn Error>> {
58 let mut next = || {
59 args.next()
60 .and_then(|arg| arg.into_string().ok())
61 .unwrap_or_default()
45/// Shows `ask`, titled `title` and explained by `detail`, once the dialogs before it are
46/// answered. Any thread may ask.
47pub fn show(title: String, detail: String, ask: Ask) {
48 let entries = match &ask {
49 Ask::File { folder, types, .. } => listing(folder, types),
50 _ => Ok(Vec::new()),
6251 };
63 let [kind, title, detail, first, second] = [(); 5].map(|()| next());
64 let mut dialog = Dialog {
65 kind,
52 let dialog = Dialog {
6653 title,
6754 detail,
68 buttons: [first, second],
69 fields: Default::default(),
70 shown: None,
71 accepted: false,
55 ask,
56 entries,
57 shown: false,
7258 };
73 if dialog.kind == "entry" || dialog.kind == "login" {
74 dialog.fields[0] = std::mem::take(&mut dialog.buttons[0]);
59 if let Some(proxy) = crate::platform::PROXY.get() {
60 let _ = proxy.send_event(UserEvent::Then(Box::new(move |state| {
61 state.asking.push_back(dialog);
62 Ok(())
63 })));
7564 }
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(())
8765}
8866
89struct Dialog {
90 kind: String,
91 title: String,
92 detail: String,
93 /// A question's cancel and action labels.
94 buttons: [String; 2],
95 /// An entry's text, or a login's user name and password.
96 fields: [String; 2],
97 shown: Option<Shown>,
98 accepted: bool,
67/// The folder a file chooser starts in where it is given none.
68pub fn start_folder() -> PathBuf {
69 crate::platform::documents_dir()
70 .filter(|folder| folder.is_dir())
71 .or_else(|| std::env::var_os("HOME").map(PathBuf::from))
72 .unwrap_or_else(|| "/".into())
9973}
10074
101struct Shown {
102 window: Arc<Window>,
103 surface: crate::surface::Surface,
104 renderer: Renderer,
105 ui: Ui,
75/// `folder`'s folders, then its files of `types` (extensions; all where empty), each by
76/// name without regard to case; hidden ones left out.
77fn listing(folder: &Path, types: &[String]) -> Result<Vec<(String, bool)>, String> {
78 let mut entries: Vec<(String, bool)> = std::fs::read_dir(folder)
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)
10699}
107100
108fn field(index: usize) -> Id {
109 Id::ROOT.child(("field", index))
101fn id() -> Id {
102 Id::ROOT.child("own dialog")
110103}
111104
112impl Dialog {
113 fn finish(&mut self, event_loop: &ActiveEventLoop, accepted: bool) {
114 self.accepted = accepted;
115 // The window's resources end while the event loop still holds its display.
116 self.shown = None;
117 event_loop.exit();
118 }
105fn field() -> Id {
106 id().child("field")
107}
108
109/// How the user answered a dialog this frame.
110enum Answer {
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.
121 fn frame(&mut self) -> Result<Option<bool>, Box<dyn Error>> {
122 let Some(shown) = &mut self.shown else {
123 return Ok(None);
119impl State {
120 /// Builds the first dialog waiting, and answers it once the user does.
121 pub(crate) fn own_dialog(&mut self) {
122 let Some(dialog) = self.asking.front_mut() else {
123 return;
124124 };
125 let ui = &mut shown.ui;
126 let scale = shown.window.scale_factor() as f32;
127 let size = shown.window.inner_size();
128 ui.begin(
129 [size.width as f32 / scale, size.height as f32 / scale],
130 scale,
131 Instant::now(),
132 );
125 let ui = &mut self.ui;
126 if !dialog.shown {
127 dialog.shown = true;
128 ui.open_popup(id());
129 if let Ask::Entry { .. } | Ask::File { save: true, .. } = dialog.ask {
130 ui.focus_all(field());
131 }
132 } else if !ui.popup_open(id()) {
133 self.asking.pop_front();
134 return;
135 }
133136 let theme = ui.theme.clone();
134 ui.open(
135 "dialog",
137 let row = theme.font_size * 2.0;
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(),
136143 Spec {
137144 axis: Axis::Y,
138 size: [fill(), fill()],
145 size: [px(if file { 480.0 } else { 420.0 }), children()],
139146 fill: Some(theme.popup),
140 pad: [20.0, 16.0],
141 gap: 10.0,
142 role: Some(accesskit::Role::Dialog),
147 border: Some(theme.chip),
148 shadow: Some(theme.shadow),
149 radius: 8.0,
150 pad: [16.0, 12.0],
151 gap: 6.0,
152 anchor: Some(Anchor::Dialog),
153 role: Some(Role::Dialog),
143154 ..Spec::default()
144155 },
145156 );
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| {
147161 ui.leaf(
148162 part,
149163 Spec {
150 size: [fill(), fit()],
164 size: [fill(), ui::fit()],
151165 text: Some(text),
152166 bold,
167 color,
153168 overflow: ui::Overflow::Wrap,
169 role: bold.then_some(Role::Heading),
154170 ..Spec::default()
155171 },
156172 );
157173 };
158 text(ui, "title", &self.title, true);
159 if !self.detail.is_empty() {
160 text(ui, "detail", &self.detail, false);
174 text(ui, "title", &dialog.title, true, None);
175 if !dialog.detail.is_empty() {
176 text(ui, "detail", &dialog.detail, false, None);
161177 }
162 let entry = Spec {
163 size: [fill(), px(theme.font_size * 2.0)],
178 let field_spec = Spec {
179 size: [fill(), px(row)],
164180 fill: Some(theme.base),
165181 border: Some(theme.accent),
166182 radius: 4.0,
167183 pad: [6.0, 0.0],
168184 ..Spec::default()
169185 };
170 let fields = match self.kind.as_str() {
171 "entry" => 1,
172 "login" => 2,
173 _ => 0,
174 };
175 if fields > 0 && ui.focused().is_none() {
176 ui.set_focus(Some(field(0)));
177 }
178 for (index, value) in self.fields.iter_mut().take(fields).enumerate() {
179 if index == 1 {
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());
186 // Return goes ahead, but for a question, whose safe answer is the default as
187 // AppKit's and Windows' are.
188 let mut answer = entered.then_some(match dialog.ask {
189 Ask::Question { .. } => Answer::Cancel,
190 _ => Answer::Accept,
191 });
192 match &mut dialog.ask {
193 Ask::Entry { text, .. } => {
194 ui::text_field(ui, field(), text, "", field_spec);
195 crate::name(ui, field(), &dialog.title);
184196 }
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 { .. } => {}
185265 }
186 ui.leaf(
187 "space",
188 Spec {
189 size: [fill(), fill()],
190 ..Spec::default()
191 },
192 );
193266 ui.open(
194267 "buttons",
195268 Spec {
196269 size: [fill(), children()],
270 pad: [0.0, 8.0],
197271 gap: 8.0,
198272 ..Spec::default()
199273 },
200274 );
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 }
201281 ui.leaf(
202282 "space",
203283 Spec {
204 size: [fill(), px(0.0)],
284 size: [fill(), px(1.0)],
205285 ..Spec::default()
206286 },
207287 );
208 let [cancel, action] = match self.kind.as_str() {
209 "question" => [self.buttons[0].as_str(), self.buttons[1].as_str()],
210 "message" => ["", "OK"],
211 _ => ["Cancel", "OK"],
288 let (cancel, action) = match &dialog.ask {
289 Ask::Message => (None, "OK"),
290 Ask::Question { cancel, action, .. } => (Some(cancel.as_str()), action.as_str()),
291 Ask::Entry { .. } => (Some("Cancel"), "OK"),
292 Ask::File { save: false, .. } => (Some("Cancel"), "Open"),
293 Ask::File { save: true, .. } => (Some("Cancel"), "Save"),
212294 };
213 let mut answer = None;
214 if !cancel.is_empty() && ui::button(ui, "cancel", cancel).clicked {
215 answer = Some(false);
295 if cancel.is_some_and(|cancel| ui::button(ui, "cancel", cancel).clicked) {
296 answer = Some(Answer::Cancel);
216297 }
217298 if ui::button(ui, "action", action).clicked {
218 answer = Some(true);
299 answer = Some(Answer::Accept);
219300 }
220301 ui.close();
221302 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) {
293 let Some(shown) = &mut self.shown else {
294 return;
295 };
296 let scale = shown.window.scale_factor() as f32;
297 let input = match event {
298 WindowEvent::CloseRequested => return self.finish(event_loop, false),
299 WindowEvent::RedrawRequested => {
300 match self.frame() {
301 Ok(Some(answer)) => self.finish(event_loop, answer),
302 Ok(None) => {}
303 Err(error) => {
304 eprintln!("{error}");
305 self.finish(event_loop, false);
304 if let Ask::File {
305 folder,
306 types,
307 save,
308 name,
309 ..
310 } = &mut dialog.ask
311 {
312 match answer {
313 Some(Answer::Open(to)) => {
314 dialog.entries = listing(&to, types);
315 *folder = to;
316 if !*save {
317 name.clear();
306318 }
319 return;
307320 }
308 return;
309 }
310 WindowEvent::Resized(size) => {
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}");
321 Some(Answer::Choose(entry)) => {
322 *name = entry;
323 return;
339324 }
340 ui::Event::Button {
341 button,
342 pressed: state == ElementState::Pressed,
343 at: Instant::now(),
325 Some(Answer::Accept) if name.trim().is_empty() => return,
326 // A folder typed in opens.
327 Some(Answer::Accept) if folder.join(name.trim()).is_dir() => {
328 let to = folder.join(name.trim());
329 dialog.entries = listing(&to, types);
330 *folder = to;
331 name.clear();
332 return;
344333 }
334 _ => {}
345335 }
346 _ => return,
336 }
337 let Some(answer) = answer else {
338 return;
347339 };
348 shown.ui.event(input);
349 shown.window.request_redraw();
340 let dialog = self.asking.pop_front().expect("A dialog is shown");
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();
350386 }
351387}
crates/snowbound/src/history.rs+21-4
......@@ -188,7 +188,7 @@ impl State {
188188
189189 /// Delete All Versions in the section, its group or the notebook, once confirmed.
190190 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")?;
192192 let tab = &session.tabs[session.tab];
193193 let (open, section) = (tab.path.clone(), tab.name.clone());
194194 let folder = open
......@@ -203,14 +203,31 @@ impl State {
203203 ),
204204 Scope::Notebook => ("notebook", session.library.name.clone()),
205205 };
206 if !platform::confirm(
206 let asked = Arc::clone(&session.library);
207 platform::confirm(
207208 &format!("Do you want to delete all page versions in the {container} \"{name}\"?"),
208209 "You can't restore these versions afterward.",
209210 "Cancel",
210211 "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 {
212229 return Ok(());
213 }
230 };
214231 clear(&session.section)?;
215232 session.refresh_history()?;
216233 if session.version.is_some() {
crates/snowbound/src/linux.rs+197-150
......@@ -3,13 +3,13 @@
33//! the colour scheme from the settings portal, and text conventions from the C library's
44//! locale.
55
6use crate::dialog::Ask;
6use crate::{Reply, dialog::Ask};
77use canvas::date::DateField;
88use std::{
99 cell::UnsafeCell,
1010 collections::HashMap,
1111 error::Error,
12 ffi::{CStr, CString, OsString, c_char, c_void},
12 ffi::{CStr, CString, OsStr, OsString, c_char, c_void},
1313 os::unix::ffi::OsStringExt,
1414 path::PathBuf,
1515 process::Command,
......@@ -42,7 +42,8 @@ const MINIMIZE: &[&str] = &[include_str!("../assets/icons/window-minimize.svg")]
4242const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")];
4343const 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();
4647/// Breeze's corner radius in units of the decoration's pixel grid, as `f32` bits; zero where
4748/// KWin leaves windows square.
4849static BREEZE_RADIUS: AtomicU32 = AtomicU32::new(0);
......@@ -77,7 +78,8 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, Box<dyn
7778 if !headless {
7879 crate::desktop::prepare(&event_loop);
7980 }
80 QUIT.set(event_loop.create_proxy())
81 PROXY
82 .set(event_loop.create_proxy())
8183 .expect("Only one application event loop is created");
8284 watch_settings(event_loop.create_proxy());
8385 if desktop() == Desktop::Kde {
......@@ -649,7 +651,7 @@ pub fn window_controls(ui: &mut Ui, window: &Window) {
649651 "minimize" => window.set_minimized(true),
650652 "maximize" => zoom(window),
651653 _ => {
652 if let Some(proxy) = QUIT.get() {
654 if let Some(proxy) = PROXY.get() {
653655 let _ = proxy.send_event(crate::UserEvent::Quit);
654656 }
655657 }
......@@ -1199,7 +1201,7 @@ pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> {
11991201}
12001202
12011203/// 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.
12031205pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> {
12041206 let user = mount.user.clone().unwrap_or_default();
12051207 let mut lookup = Command::new("secret-tool");
......@@ -1219,24 +1221,10 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login,
12191221 domain: mount.domain.clone(),
12201222 });
12211223 }
1222 let title = format!("Sign in to {}", mount.server);
1223 let asked = dialog(
1224 ["--password", "--username", &format!("--title={title}")],
1225 ["--password", &title],
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 })
1224 Err(format!(
1225 "Enter the password for \u{201c}{user}\u{201d} on \u{201c}{}\u{201d}.",
1226 mount.host()
1227 ))
12401228}
12411229
12421230/// 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] {
14021390 date_labels(&local_time(filetime))
14031391}
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.
14061395pub fn edit_date(
14071396 timestamp: u64,
14081397 field: DateField,
14091398 title: &str,
1410) -> Result<Option<(u64, [String; 2])>, &'static str> {
1411 let tm = local_time(timestamp);
1412 let answer = match field {
1413 DateField::Date => dialog(
1414 [
1415 "--calendar",
1416 &format!("--title={title}"),
1417 "--text=",
1418 &format!("--day={}", tm.tm_mday),
1419 &format!("--month={}", tm.tm_mon + 1),
1420 &format!("--year={}", tm.tm_year + 1900),
1421 "--date-format=%Y-%m-%d",
1422 ],
1423 ["--calendar", title, "--dateformat", "yyyy-MM-dd"],
1424 )
1425 .unwrap_or_else(|_| {
1426 let value = format(&tm, c"%Y-%m-%d");
1427 crate::dialog::ask(
1428 title,
1399 reply: Reply<Result<(u64, [String; 2]), &'static str>>,
1400) {
1401 let title = title.to_owned();
1402 let reply = reply
1403 .map(move |answer: String| changed_date(timestamp, local_time(timestamp), field, &answer));
1404 std::thread::spawn(move || {
1405 let tm = local_time(timestamp);
1406 let (asked, detail, value) = match field {
1407 DateField::Date => (
1408 dialog(
1409 &[
1410 "--calendar",
1411 &format!("--title={title}"),
1412 "--text=",
1413 &format!("--day={}", tm.tm_mday),
1414 &format!("--month={}", tm.tm_mon + 1),
1415 &format!("--year={}", tm.tm_year + 1900),
1416 "--date-format=%Y-%m-%d",
1417 ],
1418 &["--calendar", &title, "--dateformat", "yyyy-MM-dd"],
1419 ),
14291420 "Date, as year-month-day:",
1430 Ask::Entry { value: &value },
1431 )
1432 }),
1433 DateField::Time => {
1434 let current = format(&tm, c"%H:%M");
1435 dialog(
1436 [
1437 "--entry",
1438 &format!("--title={title}"),
1439 "--text=Time, as hours and minutes:",
1440 &format!("--entry-text={current}"),
1441 ],
1442 ["--inputbox", "Time, as hours and minutes:", &current],
1443 )
1444 .unwrap_or_else(|_| {
1445 let value = Ask::Entry { value: &current };
1446 crate::dialog::ask(title, "Time, as hours and minutes:", value)
1447 })
1421 format(&tm, c"%Y-%m-%d"),
1422 ),
1423 DateField::Time => {
1424 let current = format(&tm, c"%H:%M");
1425 let asked = dialog(
1426 &[
1427 "--entry",
1428 &format!("--title={title}"),
1429 "--text=Time, as hours and minutes:",
1430 &format!("--entry-text={current}"),
1431 ],
1432 &["--inputbox", "Time, as hours and minutes:", &current],
1433 );
1434 (asked, "Time, as hours and minutes:", current)
1435 }
1436 };
1437 match asked {
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 }
14481444 }
1449 };
1450 let Some(answer) = answer else {
1451 return Ok(None);
1452 };
1445 });
1446}
1447
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> {
14531455 let numbers: Vec<i32> = answer
14541456 .split(|c: char| !c.is_ascii_digit())
14551457 .filter(|part| !part.is_empty())
14561458 .map(|part| part.parse().unwrap_or(i32::MAX))
14571459 .collect();
14581460 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)
14601462}
14611463
14621464fn merge_date(
......@@ -1497,36 +1499,24 @@ fn merge_date(
14971499 Ok((updated, date_labels(&tm)))
14981500}
14991501
1500/// Runs zenity, or kdialog where zenity is missing; the answer is None when cancelled, and an
1501/// error when neither is installed.
1502fn dialog<const Z: usize, const K: usize>(
1503 zenity: [&str; Z],
1504 kdialog: [&str; K],
1505) -> Result<Option<String>, &'static str> {
1502/// Runs zenity, or kdialog where zenity is missing, waiting for the answer: None when
1503/// cancelled, and an error when neither is installed.
1504fn dialog(
1505 zenity: &[impl AsRef<OsStr>],
1506 kdialog: &[impl AsRef<OsStr>],
1507) -> std::io::Result<Option<String>> {
15061508 let output = Command::new("zenity")
15071509 .args(zenity)
15081510 .output()
1509 .or_else(|_| Command::new("kdialog").args(kdialog).output())
1510 .map_err(|_| "Install zenity or kdialog for Snowbound's dialogs.")?;
1511 .or_else(|_| Command::new("kdialog").args(kdialog).output())?;
15111512 Ok(output
15121513 .status
15131514 .success()
15141515 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()))
15151516}
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
15281518/// 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>) {
15301520 let globs: Vec<_> = types.iter().map(|kind| format!("*.{kind}")).collect();
15311521 let patterns = match types {
15321522 [] => "*".to_owned(),
......@@ -1536,17 +1526,26 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> {
15361526 if !globs.is_empty() {
15371527 options.insert("filters", filters(&patterns, &globs));
15381528 }
1529 let types = types.iter().map(|kind| kind.to_string()).collect();
15391530 choose(
15401531 title,
15411532 "OpenFile",
15421533 options,
1543 [
1534 &[
15441535 "--file-selection",
15451536 &format!("--title={title}"),
15461537 &format!("--file-filter={patterns}"),
15471538 ],
1548 ["--getopenfilename", ".", &patterns, "--title", title],
1549 )
1539 &["--getopenfilename", ".", &patterns, "--title", title],
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 );
15501549}
15511550
15521551/// A file chooser's `filters` option: one filter, `name`, of `globs`.
......@@ -1555,33 +1554,48 @@ fn filters(name: &str, globs: &[String]) -> Value<'static> {
15551554 Value::from(vec![(name.to_owned(), globs)])
15561555}
15571556
1558/// The file the file chooser portal answers `method` with, titled `title`, or where no portal
1559/// answers, zenity's or kdialog's; None when cancelled.
1560fn choose<const Z: usize, const K: usize>(
1557/// Replies with the file the file chooser portal answers `method` with, titled `title`;
1558/// where no portal answers, zenity's or kdialog's, given their arguments; and where neither
1559/// is installed, Snowbound's `own`.
1560fn choose(
15611561 title: &str,
1562 method: &str,
1563 mut options: HashMap<&str, Value>,
1564 zenity: [&str; Z],
1565 kdialog: [&str; K],
1566) -> Option<PathBuf> {
1567 let chosen = portal("org.freedesktop.portal.FileChooser")
1568 .map_err(Into::into)
1569 .and_then(|chooser| {
1570 portal_request(&chooser, |token| {
1571 options.insert("handle_token", Value::from(token.to_owned()));
1572 chooser.call(method, &("", title, &options))
1573 })
1574 });
1575 let results = match chosen {
1576 Ok(results) => results?,
1577 Err(error) => {
1578 eprintln!("The file chooser portal failed: {error}");
1579 return picked(dialog(zenity, kdialog));
1562 method: &'static str,
1563 mut options: HashMap<&'static str, Value<'static>>,
1564 zenity: &[&str],
1565 kdialog: &[&str],
1566 own: impl FnOnce(Reply<PathBuf>) -> Ask + Send + 'static,
1567 reply: Reply<PathBuf>,
1568) {
1569 let title = title.to_owned();
1570 let [zenity, kdialog]: [Vec<String>; 2] =
1571 [zenity, kdialog].map(|args| args.iter().map(|arg| arg.to_string()).collect());
1572 std::thread::spawn(move || {
1573 let chosen = portal("org.freedesktop.portal.FileChooser")
1574 .map_err(Into::into)
1575 .and_then(|chooser| {
1576 portal_request(&chooser, |token| {
1577 options.insert("handle_token", Value::from(token.to_owned()));
1578 chooser.call(method, &("", &title, &options))
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);
15801597 }
1581 };
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())
1598 });
15851599}
15861600
15871601/// 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>) {
16781692 }
16791693}
16801694
1681/// Asks whether to go ahead with `action`, through zenity or kdialog where installed.
1682pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
1683 let status = Command::new("zenity")
1684 .args([
1685 "--question",
1686 &format!("--title={message}"),
1687 &format!("--text={detail}"),
1688 &format!("--ok-label={action}"),
1689 &format!("--cancel-label={cancel}"),
1690 ])
1691 .status()
1692 .or_else(|_| {
1693 Command::new("kdialog")
1694 .args(["--warningcontinuecancel", detail, "--title", message])
1695 .args(["--continue-label", action])
1696 .status()
1697 });
1698 match status {
1699 Ok(status) => status.success(),
1700 Err(_) => crate::dialog::ask(message, detail, Ask::Question { cancel, action }).is_some(),
1701 }
1695/// Asks whether to go ahead with `action`, through zenity or kdialog where installed, or
1696/// else Snowbound's own dialog.
1697pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) {
1698 let [message, detail, cancel, action] = [message, detail, cancel, action].map(str::to_owned);
1699 std::thread::spawn(move || {
1700 let status = Command::new("zenity")
1701 .args([
1702 "--question",
1703 &format!("--title={message}"),
1704 &format!("--text={detail}"),
1705 &format!("--ok-label={action}"),
1706 &format!("--cancel-label={cancel}"),
1707 ])
1708 .status()
1709 .or_else(|_| {
1710 Command::new("kdialog")
1711 .args(["--warningcontinuecancel", &detail, "--title", &message])
1712 .args(["--continue-label", &action])
1713 .status()
1714 });
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 });
17021728}
17031729
1704/// Asks for a notebook's table of contents or a section file, titled `title`; None when
1705/// cancelled or when no tool can ask.
1706pub fn pick_notebook(title: &str) -> Option<PathBuf> {
1730/// Asks for a notebook's table of contents or a section file, titled `title`.
1731pub fn pick_notebook(title: &str, reply: Reply<PathBuf>) {
17071732 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}"));
17091735 choose(
17101736 title,
17111737 "OpenFile",
17121738 HashMap::from([("filters", filters(name, &globs))]),
1713 [
1739 &[
17141740 "--file-selection",
17151741 &format!("--title={title}"),
17161742 &format!("--file-filter={name} | {}", globs.join(" ")),
17171743 ],
1718 ["--getopenfilename", ".", &globs.join(" "), "--title", title],
1719 )
1744 &["--getopenfilename", ".", &globs.join(" "), "--title", title],
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 );
17201754}
17211755
17221756/// Asks where to put something named `name` by default, in `folder` where given, with
......@@ -1726,29 +1760,42 @@ pub fn pick_new(
17261760 name: &str,
17271761 action: &str,
17281762 folder: Option<&std::path::Path>,
1729) -> Option<PathBuf> {
1763 reply: Reply<PathBuf>,
1764) {
17301765 use std::os::unix::ffi::OsStrExt;
17311766 let mut options = HashMap::from([
1732 ("current_name", Value::from(name)),
1733 ("accept_label", Value::from(action)),
1767 ("current_name", Value::from(name.to_owned())),
1768 ("accept_label", Value::from(action.to_owned())),
17341769 ]);
17351770 if let Some(folder) = folder {
17361771 let mut bytes = folder.as_os_str().as_bytes().to_vec();
17371772 bytes.push(0);
17381773 options.insert("current_folder", Value::from(bytes));
17391774 }
1775 let (named, folder) = (
1776 name.to_owned(),
1777 folder.map_or_else(crate::dialog::start_folder, Into::into),
1778 );
17401779 choose(
17411780 title,
17421781 "SaveFile",
17431782 options,
1744 [
1783 &[
17451784 "--file-selection",
17461785 "--save",
17471786 &format!("--title={title}"),
17481787 &format!("--filename={name}"),
17491788 ],
1750 ["--getsavefilename", name, "--title", title],
1751 )
1789 &["--getsavefilename", name, "--title", title],
1790 move |reply| Ask::File {
1791 folder,
1792 types: Vec::new(),
1793 save: true,
1794 name: named,
1795 reply,
1796 },
1797 reply,
1798 );
17521799}
17531800
17541801/// 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) {
17791826 .status()
17801827 });
17811828 if shown.is_err() {
1782 crate::dialog::ask(&title, &text, Ask::Message);
1829 crate::dialog::show(title, text, Ask::Message);
17831830 }
17841831 });
17851832}
crates/snowbound/src/macos.rs+93-30
......@@ -1,4 +1,4 @@
1use crate::commands;
1use crate::{Reply, commands};
22use canvas::date::DateField;
33use objc2::{
44 ClassType, DeclaredClass, class,
......@@ -11,7 +11,8 @@ use objc2::{
1111use objc2_app_kit::{
1212 NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSApplication, NSColor,
1313 NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent,
14 NSEventSubtype, NSEventType, NSMenu, NSMenuItem,
14 NSEventSubtype, NSEventType, NSMenu, NSMenuItem, NSModalResponse, NSModalResponseCancel,
15 NSModalResponseOK, NSSavePanel, NSWindow,
1516};
1617use objc2_foundation::{
1718 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,
......@@ -92,6 +93,59 @@ pub(crate) fn ns_window(window: &Window) -> Retained<AnyObject> {
9293 unsafe { msg_send_id![&ns_view(window), window] }
9394}
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
95149/// Room the traffic lights take at the title bar's leading edge.
96150pub const LEADING: f32 = 78.0;
97151/// The title bar's trailing margin, the gap the traffic lights leave before the toolbar.
......@@ -131,6 +185,7 @@ pub fn window_attributes() -> WindowAttributes {
131185pub fn install_title_bar(window: &Window) {
132186 MainThreadMarker::new().expect("Windows belong to the main thread");
133187 let window = ns_window(window);
188 WINDOW.set(Some(unsafe { Retained::cast(window.clone()) }));
134189 let responds =
135190 |selector: Sel| -> bool { unsafe { msg_send![&window, respondsToSelector: selector] } };
136191 // 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> {
765820
766821/// Asks for a notebook folder or a notebook file with the system's open panel, titled
767822/// `title`.
768pub fn pick_notebook(title: &str) -> Option<std::path::PathBuf> {
823pub fn pick_notebook(title: &str, reply: Reply<std::path::PathBuf>) {
769824 let mtm = MainThreadMarker::new().expect("Panels belong to the main thread");
770825 unsafe {
771826 let panel = objc2_app_kit::NSOpenPanel::openPanel(mtm);
......@@ -774,11 +829,7 @@ pub fn pick_notebook(title: &str) -> Option<std::path::PathBuf> {
774829 panel.setCanCreateDirectories(true);
775830 panel.setTitle(Some(&NSString::from_str(title)));
776831 panel.setPrompt(Some(&NSString::from_str("Open")));
777 if panel.runModal() != objc2_app_kit::NSModalResponseOK {
778 return None;
779 }
780 let path = panel.URLs().firstObject()?.path()?;
781 Some(path.to_string().into())
832 begin_panel(Retained::into_super(panel), reply);
782833 }
783834}
784835
......@@ -789,10 +840,11 @@ pub fn pick_new(
789840 name: &str,
790841 action: &str,
791842 folder: Option<&std::path::Path>,
792) -> Option<std::path::PathBuf> {
843 reply: Reply<std::path::PathBuf>,
844) {
793845 let mtm = MainThreadMarker::new().expect("Panels belong to the main thread");
794846 unsafe {
795 let panel = objc2_app_kit::NSSavePanel::savePanel(mtm);
847 let panel = NSSavePanel::savePanel(mtm);
796848 if let Some(folder) = folder.and_then(std::path::Path::to_str) {
797849 panel.setDirectoryURL(Some(&objc2_foundation::NSURL::fileURLWithPath(
798850 &NSString::from_str(folder),
......@@ -802,16 +854,13 @@ pub fn pick_new(
802854 panel.setTitle(Some(&NSString::from_str(title)));
803855 panel.setPrompt(Some(&NSString::from_str(action)));
804856 panel.setNameFieldStringValue(&NSString::from_str(name));
805 if panel.runModal() != objc2_app_kit::NSModalResponseOK {
806 return None;
807 }
808 Some(panel.URL()?.path()?.to_string().into())
857 begin_panel(panel, reply);
809858 }
810859}
811860
812861/// Asks for a file to insert, one of `types` (extensions) unless empty, with the system's
813862/// 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>) {
815864 let mtm = MainThreadMarker::new().expect("Panels belong to the main thread");
816865 unsafe {
817866 let panel = objc2_app_kit::NSOpenPanel::openPanel(mtm);
......@@ -822,11 +871,7 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option<std::path::PathBuf> {
822871 }
823872 panel.setTitle(Some(&NSString::from_str(title)));
824873 panel.setPrompt(Some(&NSString::from_str("Insert")));
825 if panel.runModal() != objc2_app_kit::NSModalResponseOK {
826 return None;
827 }
828 let path = panel.URLs().firstObject()?.path()?;
829 Some(path.to_string().into())
874 begin_panel(Retained::into_super(panel), reply);
830875 }
831876}
832877
......@@ -1010,7 +1055,7 @@ pub fn alert(message: &str, detail: &str) {
10101055 let alert = NSAlert::new(mtm);
10111056 alert.setMessageText(&NSString::from_str(message));
10121057 alert.setInformativeText(&NSString::from_str(detail));
1013 alert.runModal();
1058 begin_alert(&alert, |_| {});
10141059 }
10151060}
10161061
......@@ -1086,7 +1131,8 @@ pub fn edit_date(
10861131 timestamp: u64,
10871132 field: DateField,
10881133 title: &str,
1089) -> Result<Option<(u64, [String; 2])>, &'static str> {
1134 reply: Reply<Result<(u64, [String; 2]), &'static str>>,
1135) {
10901136 let mtm = MainThreadMarker::new().expect("Date controls belong to the main thread");
10911137 unsafe {
10921138 let calendar = NSCalendar::currentCalendar();
......@@ -1116,11 +1162,14 @@ pub fn edit_date(
11161162 alert.addButtonWithTitle(&NSString::from_str("Cancel"));
11171163 alert.layout();
11181164 alert.window().makeFirstResponder(Some(&picker));
1119 if alert.runModal() != NSAlertFirstButtonReturn {
1120 return Ok(None);
1121 }
1122 alert.window().makeFirstResponder(None);
1123 merge_date(timestamp, &picker.dateValue(), field, &calendar).map(Some)
1165 let window = alert.window();
1166 begin_alert(&alert, move |response| {
1167 if response != NSAlertFirstButtonReturn {
1168 return;
1169 }
1170 window.makeFirstResponder(None);
1171 reply.send(merge_date(timestamp, &picker.dateValue(), field, &calendar));
1172 });
11241173 }
11251174}
11261175
......@@ -1402,8 +1451,19 @@ declare_class!(
14021451 .flatten()
14031452 .map(|text| text.to_string());
14041453 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));
14051458 match (key.as_deref(), &modal) {
14061459 (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 }
14071467 (Some("\r" | "\u{3}"), Some(modal)) if {
14081468 let responder: *mut AnyObject = msg_send![modal, firstResponder];
14091469 !responder.is_null()
......@@ -1511,16 +1571,19 @@ pub fn reveal(target: impl AsRef<std::ffi::OsStr>) {
15111571}
15121572
15131573/// 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<()>) {
15151575 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.
15171576 unsafe {
15181577 let alert = NSAlert::new(mtm);
15191578 alert.setMessageText(&NSString::from_str(message));
15201579 alert.setInformativeText(&NSString::from_str(detail));
15211580 alert.addButtonWithTitle(&NSString::from_str(cancel));
15221581 alert.addButtonWithTitle(&NSString::from_str(action));
1523 alert.runModal() == NSAlertSecondButtonReturn
1582 begin_alert(&alert, |response| {
1583 if response == NSAlertSecondButtonReturn {
1584 reply.send(());
1585 }
1586 });
15241587 }
15251588}
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
214214
215215type 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
217254enum UserEvent {
218255 Quit,
256 /// Quits without asking, as after the user agreed to discard a temporary page.
257 #[cfg(not(target_arch = "wasm32"))]
258 Exit,
219259 /// Text AppKit inserts outside key events, such as the character palette's.
220260 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
221261 InsertText(String),
......@@ -750,6 +790,9 @@ struct State {
750790 save_as: Option<save_as::Dialog>,
751791 /// Unpack Notebook while it is open.
752792 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>,
753796 /// What has been read in each notebook, on this computer.
754797 reads: unread::Reads,
755798 /// Open Notebook from Server while it is open.
......@@ -1125,6 +1168,8 @@ impl State {
11251168 symbols: None,
11261169 save_as: None,
11271170 unpacking: None,
1171 #[cfg(target_os = "linux")]
1172 asking: Default::default(),
11281173 reads,
11291174 server: None,
11301175 new_icloud: None,
......@@ -1301,6 +1346,8 @@ impl State {
13011346 self.save_as_dialog();
13021347 self.unpack_dialog();
13031348 self.password_dialog()?;
1349 #[cfg(target_os = "linux")]
1350 self.own_dialog();
13041351 self.customize_tags();
13051352 self.palette();
13061353 self.sync_popup()?;
......@@ -3138,13 +3185,15 @@ impl State {
31383185 Command::RestoreVersion { page, version } => self.restore_version(page, version)?,
31393186 Command::DeletePageVersion { page, version } => self.delete_version(page, version)?,
31403187 Command::DeleteAllVersions(scope) => self.delete_all_versions(scope)?,
3141 Command::OpenNotebook => {
3142 if let Some(path) = platform::pick_notebook("Open Notebook") {
3143 self.open_path(&path);
3144 }
3145 }
3188 Command::OpenNotebook => platform::pick_notebook(
3189 "Open Notebook",
3190 self.reply(|state, path: PathBuf| {
3191 state.open_path(&path);
3192 Ok(())
3193 }),
3194 ),
31463195 Command::OpenFromServer(location) => self.open_server(location.as_deref()),
3147 Command::NewNotebook => self.new_notebook()?,
3196 Command::NewNotebook => self.new_notebook(),
31483197 Command::NewICloudNotebook => self.new_icloud_notebook(),
31493198 Command::OpenGuide => self.open_guide()?,
31503199 #[cfg(target_os = "linux")]
......@@ -3172,7 +3221,7 @@ impl State {
31723221 self.edited(Vec::new());
31733222 }
31743223 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),
31763225 Command::Page(Request::Copy(clip)) => self.clipboard.set(clip)?,
31773226 Command::Page(Request::Paste) => self.paste()?,
31783227 Command::Page(Request::OpenLink(address)) => self.open_link(&address)?,
......@@ -3561,9 +3610,9 @@ impl State {
35613610 }
35623611 }
35633612
3564 fn edit_date(&mut self, field: DateField) -> Result<(), Box<dyn Error>> {
3613 fn edit_date(&mut self, field: DateField) {
35653614 let Some(date) = self.view.editor.date() else {
3566 return Ok(());
3615 return;
35673616 };
35683617 let timestamp = date.timestamp();
35693618 self.view.editor.finish_composition();
......@@ -3572,12 +3621,30 @@ impl State {
35723621 DateField::Date => "Change Page Date",
35733622 DateField::Time => "Change Page Time",
35743623 };
3575 if let Some((timestamp, text)) = platform::edit_date(timestamp, field, title)? {
3576 let response = self.view.change_date(timestamp, text)?;
3577 self.respond(response);
3578 self.window.request_redraw();
3579 }
3580 Ok(())
3624 let page = self.session.as_ref().map(|session| session.space);
3625 let reply = self.reply(
3626 move |state, chosen: Result<(u64, [String; 2]), &'static str>| {
3627 let (changed, text) = chosen?;
3628 // The answer belongs to the page that asked, unless another has opened since.
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)
35813648 }
35823649
35833650 /// The open conflict page's conflicting changes in page order: each text's outline,
......@@ -3970,7 +4037,7 @@ impl State {
39704037 }
39714038 if let Some(field) = self.accessibility.date_for_node(request.target_node) {
39724039 if request.action == Action::Click {
3973 self.edit_date(field)?;
4040 self.edit_date(field);
39744041 }
39754042 return Ok(());
39764043 }
......@@ -5491,6 +5558,8 @@ impl State {
54915558 match event {
54925559 // The event loop quits; a page stays open.
54935560 UserEvent::Quit | UserEvent::Replay(Replay::Quit) => {}
5561 #[cfg(not(target_arch = "wasm32"))]
5562 UserEvent::Exit => {}
54945563 UserEvent::Picture(bytes) => {
54955564 if let Err(error) = self.insert_picture(bytes, None) {
54965565 eprintln!("{error}");
......@@ -5607,35 +5676,44 @@ impl State {
56075676#[cfg(not(target_arch = "wasm32"))]
56085677impl App {
56095678 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| {
56115680 let editor = &state.view.editor;
5612 state.session.is_some()
5613 || editor.caret_outline().is_none_or(TextOutline::is_empty)
5614 && state.initial_date == editor.date().map(|date| date.timestamp())
5615 && state
5616 .initial_layouts
5617 .iter()
5618 .map(|(id, layout)| (*id, layout))
5619 .eq(editor.object_layouts())
5620 && state
5621 .initial
5681 let unchanged = editor.caret_outline().is_none_or(TextOutline::is_empty)
5682 && state.initial_date == editor.date().map(|date| date.timestamp())
5683 && state
5684 .initial_layouts
5685 .iter()
5686 .map(|(id, layout)| (*id, layout))
5687 .eq(editor.object_layouts())
5688 && state
5689 .initial
5690 .iter()
5691 .map(|(id, document)| (id, document))
5692 .eq(editor
5693 .outlines()
56225694 .iter()
5623 .map(|(id, document)| (id, document))
5624 .eq(editor
5625 .outlines()
5626 .iter()
5627 .map(|outline| (&outline.id, outline.document())))
5628 }) || platform::confirm(
5695 .map(|outline| (&outline.id, outline.document())));
5696 state.session.is_none() && !unchanged
5697 }) else {
5698 return self.exit(event_loop);
5699 };
5700 platform::confirm(
56295701 "Discard this page?",
56305702 "This temporary page has no saved copy. Closing it will discard your edits.",
56315703 "Keep Editing",
56325704 "Discard Changes",
5633 ) {
5634 if let Some(state) = &self.state {
5635 state.publish_now(QUIT_PUBLISH);
5636 }
5637 event_loop.exit();
5705 state.reply(|state, ()| {
5706 let _ = state.proxy.send_event(UserEvent::Exit);
5707 Ok(())
5708 }),
5709 );
5710 }
5711
5712 fn exit(&self, event_loop: &ActiveEventLoop) {
5713 if let Some(state) = &self.state {
5714 state.publish_now(QUIT_PUBLISH);
56385715 }
5716 event_loop.exit();
56395717 }
56405718}
56415719
......@@ -5658,6 +5736,7 @@ impl ApplicationHandler<UserEvent> for App {
56585736 };
56595737 match event {
56605738 UserEvent::Quit | UserEvent::Replay(Replay::Quit) => self.close(event_loop),
5739 UserEvent::Exit => self.exit(event_loop),
56615740 event => state.user_event(event),
56625741 }
56635742 }
......@@ -5996,14 +6075,6 @@ fn main() -> Result<(), Box<dyn Error>> {
59966075 }
59976076 #[cfg(target_os = "linux")]
59986077 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 }
60076078 platform::with_pool(launch)
60086079}
60096080
crates/snowbound/src/manage.rs+9-7
......@@ -261,14 +261,16 @@ impl State {
261261
262262 /// Asks where to create a notebook and creates it, as File, New does in OneNote; the
263263 /// 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) {
265265 let icloud = crate::icloud::folder().or_else(crate::icloud::drive);
266 let Some(root) =
267 platform::pick_new("New Notebook", "My Notebook", "Create", icloud.as_deref())
268 else {
269 return Ok(());
270 };
271 self.create_notebook(root)
266 let reply = self.reply(|state, root| state.create_notebook(root));
267 platform::pick_new(
268 "New Notebook",
269 "My Notebook",
270 "Create",
271 icloud.as_deref(),
272 reply,
273 );
272274 }
273275
274276 /// 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 {
649649 });
650650 None
651651 }
652 (Target::Section { library, path }, Action::Delete) => platform::confirm(
653 "Are you sure you want to move this section to this notebook's Recycle Bin?",
654 &path,
655 "Cancel",
656 "Delete",
657 )
658 .then(|| Command::Structure(library, Structure::Delete { path })),
659 (Target::Group { library, path }, Action::Delete) => platform::confirm(
660 "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?",
661 path.rsplit('/').next().unwrap_or_default(),
662 "Cancel",
663 "Delete",
664 )
665 .then(|| Command::Structure(library, Structure::Delete { path })),
652 (Target::Section { library, path }, Action::Delete) => {
653 platform::confirm(
654 "Are you sure you want to move this section to this notebook's Recycle Bin?",
655 &path.clone(),
656 "Cancel",
657 "Delete",
658 self.reply(|state, ()| {
659 let change = Structure::Delete { path };
660 state.commands.push(Command::Structure(library, change));
661 Ok(())
662 }),
663 );
664 None
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 }
666684 (
667685 Target::Section { library, path } | Target::Group { library, path },
668686 Action::MoveTo(folder),
669 ) => Some(Command::Structure(library, Structure::Move { path, folder })),
687 ) => Some(Command::Structure(
688 library,
689 Structure::Move { path, folder },
690 )),
670691 (Target::Section { library, path }, Action::Theme) => {
671692 if let Some(identity) = library.section_identity(&path) {
672693 let scope = notebook::sidecar::themes::Scope::section(identity);
......@@ -710,7 +731,10 @@ impl State {
710731 {
711732 session.section.wake();
712733 }
713 library.background.iter().for_each(|background| background.wake());
734 library
735 .background
736 .iter()
737 .for_each(|background| background.wake());
714738 None
715739 }
716740 (Target::Notebook(library), Action::CopyLink) => {
crates/snowbound/src/options.rs+1-1
......@@ -871,7 +871,7 @@ fn installed(state: &mut State, _: &mut Options) {
871871 if crate::desktop::uninstallable() {
872872 path(ui, &crate::desktop::binary().unwrap_or_default());
873873 if ui::button(ui, "uninstall", "Uninstall…").clicked {
874 crate::desktop::uninstall();
874 crate::desktop::uninstall(&state.proxy);
875875 }
876876 } else if crate::desktop::installable() {
877877 path(ui, std::path::Path::new("Not in the app menu"));
crates/snowbound/src/print.rs+14-13
......@@ -308,18 +308,6 @@ impl State {
308308 Scope::Section => tab.name.clone(),
309309 Scope::Notebook => session.library.name.clone(),
310310 };
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 };
323311 // Other sections are opened on the thread, in the notebook's order.
324312 let others = match setup.scope {
325313 Scope::Notebook => notebook_tabs(&session.library),
......@@ -343,7 +331,8 @@ impl State {
343331 footer: setup.footer,
344332 };
345333 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>| {
347336 let made = (|| -> Result<Vec<u8>, Box<dyn Error>> {
348337 let mut open = Some(open);
349338 let this = spaces
......@@ -399,7 +388,19 @@ impl State {
399388 }
400389 .inspect_err(|error| platform::alert(failed, &error.to_string()))
401390 })));
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(())
402402 });
403 platform::pick_new("Export as PDF", &name, "Export", None, reply);
403404 Ok(())
404405 }
405406}
crates/snowbound/src/recycle.rs+35-16
......@@ -65,35 +65,54 @@ impl State {
6565
6666 /// Empty Recycle Bin on `library`, once confirmed as OneNote asks.
6767 pub(crate) fn empty_recycle_bin(&mut self, library: Arc<Library>) {
68 if crate::platform::confirm(
68 crate::platform::confirm(
6969 "Are you sure you want to empty the Recycle Bin for this notebook?",
7070 "Its pages and sections are deleted for good.",
7171 "Cancel",
7272 "Empty Recycle Bin",
73 ) {
74 let change = crate::manage::Structure::EmptyRecycleBin;
75 self.commands.push(Command::Structure(library, change));
76 }
73 self.reply(|state, ()| {
74 let change = crate::manage::Structure::EmptyRecycleBin;
75 state.commands.push(Command::Structure(library, change));
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(())
77106 }
78107
79108 /// Does `request` to the bin's open section on a thread of its own, then shows the page
80109 /// 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>> {
82111 let gone = match &request {
83112 Request::Restore { copy: true, .. } => Vec::new(),
84113 Request::Restore { space, .. } => vec![*space],
85114 Request::Purge(spaces) => spaces.clone(),
86115 };
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 }
97116 let session = self.session.as_ref().ok_or("No section is open")?;
98117 let replica = Arc::clone(session.section.replica());
99118 let library = Arc::clone(&session.library);
crates/snowbound/src/save_as.rs+42-41
......@@ -175,54 +175,55 @@ impl State {
175175 Scope::Notebook => library.name.clone(),
176176 };
177177 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 }
184178 let (scope, author, proxy) = (dialog.scope, self.author.clone(), self.proxy.clone());
185179 let color = (library
186180 .tabs(crate::menus::folder(&section).as_str())
187181 .into_iter())
188182 .find(|tab| tab.path == section)
189183 .and_then(|tab| tab.color);
190 crate::spawn(move || {
191 let written = (|| -> Result<(), Box<dyn Error>> {
192 let image = |path: &str| {
193 let (_, replica, _) = open.as_ref().filter(|(open, ..)| open == path)?;
194 replica.snapshot().ok()
195 };
196 let bytes = match scope {
197 Scope::Page => {
198 let (_, replica, space) = open.as_ref().ok_or("No page is open")?;
199 let page = replica.page(*space)?;
200 let file = file_name(&path);
201 package::page_section(&page, &file, color, &author)?
202 }
203 Scope::Section => {
204 let stored = match image(&section) {
205 Some(image) => image,
206 None => library.reopen()?.read_section(&section)?,
207 };
208 package::section_copy(stored)?
209 }
210 Scope::Notebook => {
211 let notebook = library.reopen()?;
212 package::pack(package::notebook_files(&notebook, image)?)?
213 }
214 };
215 notebook::fs::write(&path, bytes)?;
216 Ok(())
217 })();
218 let written = written.map_err(|error| error.to_string());
219 let _ = proxy.send_event(UserEvent::Then(Box::new(move |_| {
220 written.map_err(|error| {
221 platform::alert("Couldn't save", &error);
222 error.into()
223 })
224 })));
184 let reply = self.reply(move |_, mut path: std::path::PathBuf| {
185 if path.extension().is_none() {
186 path.set_extension(extension);
187 }
188 crate::spawn(move || {
189 let written = (|| -> Result<(), Box<dyn Error>> {
190 let image = |path: &str| {
191 let (_, replica, _) = open.as_ref().filter(|(open, ..)| open == path)?;
192 replica.snapshot().ok()
193 };
194 let bytes = match scope {
195 Scope::Page => {
196 let (_, replica, space) = open.as_ref().ok_or("No page is open")?;
197 let page = replica.page(*space)?;
198 let file = file_name(&path);
199 package::page_section(&page, &file, color, &author)?
200 }
201 Scope::Section => {
202 let stored = match image(&section) {
203 Some(image) => image,
204 None => library.reopen()?.read_section(&section)?,
205 };
206 package::section_copy(stored)?
207 }
208 Scope::Notebook => {
209 let notebook = library.reopen()?;
210 package::pack(package::notebook_files(&notebook, image)?)?
211 }
212 };
213 notebook::fs::write(&path, bytes)?;
214 Ok(())
215 })();
216 let written = written.map_err(|error| error.to_string());
217 let _ = proxy.send_event(UserEvent::Then(Box::new(move |_| {
218 written.map_err(|error| {
219 platform::alert("Couldn't save", &error);
220 error.into()
221 })
222 })));
223 });
224 Ok(())
225225 });
226 platform::pick_new("Save As", &name, "Save", None, reply);
226227 Ok(())
227228 }
228229}
crates/snowbound/src/tags.rs+9-3
......@@ -589,12 +589,18 @@ impl State {
589589 /// Asks for a picture, a PNG or SVG, for the tag New Tag or Modify Tag edits, which then
590590 /// draws with it; OneNote shows the tag's symbol, or a blue circle where it has none.
591591 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) {
592601 let Some((tag, _)) = self.tag_list.as_mut().and_then(|list| list.editor.as_mut()) else {
593602 return;
594603 };
595 let Some(path) = platform::pick_file("Custom Image", &["png", "svg"]) else {
596 return;
597 };
598604 let kept = notebook::fs::read(&path)
599605 .ok()
600606 .and_then(|bytes| canvas::gpu::import_tag_art(&bytes));
crates/snowbound/src/unpack.rs+19-10
......@@ -172,16 +172,25 @@ impl State {
172172 let create = ui::button(ui, "create", "Create").clicked || entered;
173173 ui.close();
174174 ui.close();
175 if browse
176 && let Some(chosen) =
177 platform::pick_new(TITLE, dialog.name.trim(), "Choose", Some(&dialog.folder))
178 {
179 if let Some(name) = chosen.file_name() {
180 dialog.name = name.to_string_lossy().into_owned();
181 }
182 if let Some(parent) = chosen.parent() {
183 dialog.folder = parent.to_owned();
184 }
175 if browse {
176 let reply = crate::Reply::new(&self.proxy, |state, chosen: PathBuf| {
177 if let Some(dialog) = &mut state.unpacking {
178 if let Some(name) = chosen.file_name() {
179 dialog.name = name.to_string_lossy().into_owned();
180 }
181 if let Some(parent) = chosen.parent() {
182 dialog.folder = parent.to_owned();
183 }
184 }
185 Ok(())
186 });
187 platform::pick_new(
188 TITLE,
189 dialog.name.trim(),
190 "Choose",
191 Some(&dialog.folder),
192 reply,
193 );
185194 }
186195 let name = dialog.name.trim();
187196 let valid =
crates/snowbound/src/update.rs+25-25
......@@ -642,32 +642,32 @@ impl State {
642642 return;
643643 };
644644 match status {
645 Status::Ready(version, _, changes) => {
646 if platform::confirm(
647 "Update ready",
648 &described(
649 format!("Snowbound {version} is ready to install."),
650 &changes,
651 ),
652 "Later",
653 "Restart to Update",
654 ) {
655 self.restart_to_update();
656 }
657 }
658 Status::Available(version, changes) => {
659 if platform::confirm(
660 "Update available",
661 &described(
662 format!("Download Snowbound {version} from its build folder."),
663 &changes,
664 ),
665 "Later",
666 "Open Build Folder",
667 ) {
645 Status::Ready(version, _, changes) => platform::confirm(
646 "Update ready",
647 &described(
648 format!("Snowbound {version} is ready to install."),
649 &changes,
650 ),
651 "Later",
652 "Restart to Update",
653 self.reply(|state, ()| {
654 state.restart_to_update();
655 Ok(())
656 }),
657 ),
658 Status::Available(version, changes) => platform::confirm(
659 "Update available",
660 &described(
661 format!("Download Snowbound {version} from its build folder."),
662 &changes,
663 ),
664 "Later",
665 "Open Build Folder",
666 self.reply(move |_, ()| {
668667 show_build(&version);
669 }
670 }
668 Ok(())
669 }),
670 ),
671671 Status::UpToDate => platform::alert(
672672 "Snowbound is up to date",
673673 &format!("You have {}.", describe_running()),
crates/snowbound/src/web.rs+61-30
......@@ -3,7 +3,7 @@
33//! event loop are stand-ins with the calls `State` makes of winit's; menus are the kit's own,
44//! 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};
77use canvas::date::DateField;
88use std::{
99 cell::{Cell, RefCell},
......@@ -60,12 +60,14 @@ extern "C" {
6060 fn date_strings(ms: f64) -> Vec<String>;
6161 #[wasm_bindgen(js_name = shortDate)]
6262 fn short_date_string(ms: f64) -> String;
63 /// Resolves to whether the user chose `action` over `cancel`.
6364 #[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.
6567 #[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;
6769 #[wasm_bindgen(js_name = tell)]
68 fn tell(message: &str);
70 fn tell(message: &str, detail: &str);
6971 #[wasm_bindgen(js_name = openLink)]
7072 fn open_link(url: &str);
7173 /// Writes changes out: `[path]` removed, `[path, null]` a folder, and `[path, length,
......@@ -644,14 +646,23 @@ pub fn date_text(filetime: u64) -> [String; 2] {
644646 ]
645647}
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
647658/// Asks for the page's date as `YYYY-MM-DD`, or its time as `HH:MM`.
648659pub fn edit_date(
649660 timestamp: u64,
650661 field: DateField,
651662 title: &str,
652) -> Result<Option<(u64, [String; 2])>, &'static str> {
653 let ms = unix_ms(timestamp);
654 let date = js_sys::Date::new(&ms.into());
663 reply: Reply<Result<(u64, [String; 2]), &'static str>>,
664) {
665 let date = js_sys::Date::new(&unix_ms(timestamp).into());
655666 let current = match field {
656667 DateField::Date => format!(
657668 "{:04}-{:02}-{:02}",
......@@ -661,9 +672,20 @@ pub fn edit_date(
661672 ),
662673 DateField::Time => format!("{:02}:{:02}", date.get_hours(), date.get_minutes()),
663674 };
664 let Some(answer) = ask_text(title, &current) else {
665 return Ok(None);
666 };
675 answered(ask_text(title, &current), move |answer| {
676 if let Some(answer) = answer.as_string() {
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());
667689 let numbers: Vec<u32> = answer
668690 .split(|c: char| !c.is_ascii_digit())
669691 .filter(|part| !part.is_empty())
......@@ -686,44 +708,53 @@ pub fn edit_date(
686708 .checked_mul(10_000_000)
687709 .and_then(|ticks| ticks.checked_add(timestamp % 10_000_000))
688710 .ok_or(crate::DATE_OUT_OF_RANGE)?;
689 Ok(Some((updated, date_text(updated))))
711 Ok((updated, date_text(updated)))
690712}
691713
692714/// 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>) {
694716 let accept: Vec<String> = types.iter().map(|kind| format!(".{kind}")).collect();
695717 pick_files("place", &accept.join(","));
696 None
697718}
698719
699720/// 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>) {
701722 pick_notebook_files();
702 None
703723}
704724
705725/// A new notebook goes in the browser's notebooks, under a name not yet taken; anything else
706726/// asked a place goes there too, then downloads.
707pub fn pick_new(title: &str, name: &str, _: &str, _: Option<&Path>) -> Option<PathBuf> {
708 let name = ask_text(title, name)?;
709 let name = name.trim().replace(['/', '\\'], " ");
710 let folder = Path::new(NOTEBOOKS);
711 let _ = notebook::fs::create_dir_all(folder);
712 let mut path = folder.join(&name);
713 let mut number = 2;
714 while notebook::fs::metadata(&path).is_ok() {
715 path = folder.join(format!("{name} {number}"));
716 number += 1;
717 }
718 Some(path)
727pub fn pick_new(title: &str, name: &str, _: &str, _: Option<&Path>, reply: Reply<PathBuf>) {
728 answered(ask_text(title, name), move |answer| {
729 let Some(name) = answer.as_string() else {
730 return;
731 };
732 let name = name.trim().replace(['/', '\\'], " ");
733 let folder = Path::new(NOTEBOOKS);
734 let _ = notebook::fs::create_dir_all(folder);
735 let mut path = folder.join(&name);
736 let mut number = 2;
737 while notebook::fs::metadata(&path).is_ok() {
738 path = folder.join(format!("{name} {number}"));
739 number += 1;
740 }
741 reply.send(path);
742 });
719743}
720744
721pub fn confirm(message: &str, detail: &str, _: &str, _: &str) -> bool {
722 ask_confirm(&format!("{message}\n\n{detail}"))
745pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply<()>) {
746 answered(
747 ask_confirm(message, detail, cancel, action),
748 move |chosen| {
749 if chosen.is_truthy() {
750 reply.send(());
751 }
752 },
753 );
723754}
724755
725756pub fn alert(message: &str, detail: &str) {
726 tell(&format!("{message}\n\n{detail}"));
757 tell(message, detail);
727758}
728759
729760pub fn inform(message: &str, detail: &str) {
crates/snowbound/src/windows.rs+103-121
......@@ -5,6 +5,7 @@
55//! Anything newer than Windows 7 is looked up at run time, so one executable starts on all
66//! of them.
77
8use crate::Reply;
89use canvas::date::DateField;
910use std::{
1011 ffi::c_void,
......@@ -1372,8 +1373,8 @@ fn credential_target(mount: &crate::library::Mount) -> Vec<u16> {
13721373 wide(format!("Snowbound/smb/{}", mount.host()))
13731374}
13741375
1375/// The password the Credential Manager keeps for `mount`'s server, or what the user types
1376/// into the system's sign-in dialog when it keeps none.
1376/// The password the Credential Manager keeps for `mount`'s server; without one, Snowbound's
1377/// sign-in asks.
13771378pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login, String> {
13781379 use windows_sys::Win32::Security::Credentials as cred;
13791380 let target = credential_target(mount);
......@@ -1406,51 +1407,11 @@ pub fn smb_login(mount: &crate::library::Mount) -> Result<crate::library::Login,
14061407 domain: mount.domain.clone(),
14071408 });
14081409 }
1409 let caption = wide("Snowbound");
1410 let message = wide(format!("Sign in to {}", mount.server));
1411 let info = cred::CREDUI_INFOW {
1412 cbSize: size_of::<cred::CREDUI_INFOW>() as u32,
1413 hwndParent: owner(),
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 }
1410 Err(format!(
1411 "Enter the password for \u{201c}{}\u{201d} on \u{201c}{}\u{201d}.",
1412 mount.user.as_deref().unwrap_or_default(),
1413 mount.host()
1414 ))
14541415}
14551416
14561417/// What the sign-in offers for keeping a password, which the Credential Manager keeps.
......@@ -1613,41 +1574,41 @@ pub fn edit_date(
16131574 timestamp: u64,
16141575 field: DateField,
16151576 title: &str,
1616) -> Result<Option<(u64, [String; 2])>, &'static str> {
1617 let before = local_time(timestamp);
1618 let Some(chosen) = picker::pick(&before, field, title) else {
1619 return Ok(None);
1620 };
1621 let mut time = before;
1622 match field {
1623 DateField::Date => {
1624 (time.wYear, time.wMonth, time.wDay) = (chosen.wYear, chosen.wMonth, chosen.wDay);
1625 }
1626 DateField::Time => {
1627 (time.wHour, time.wMinute) = (chosen.wHour, chosen.wMinute);
1577 reply: Reply<Result<(u64, [String; 2]), &'static str>>,
1578) {
1579 let title = title.to_owned();
1580 reply.after(move || {
1581 let before = local_time(timestamp);
1582 let chosen = picker::pick(&before, field, &title)?;
1583 let mut time = before;
1584 match field {
1585 DateField::Date => {
1586 (time.wYear, time.wMonth, time.wDay) = (chosen.wYear, chosen.wMonth, chosen.wDay);
1587 }
1588 DateField::Time => {
1589 (time.wHour, time.wMinute) = (chosen.wHour, chosen.wMinute);
1590 }
16281591 }
1629 }
1630 let updated = filetime(&time).ok_or(crate::DATE_OUT_OF_RANGE)?;
1631 // FILETIME's sub-second ticks stay as they were.
1632 let updated = updated / 10_000_000 * 10_000_000 + timestamp % 10_000_000;
1633 Ok(Some((updated, date_labels(&local_time(updated)))))
1592 let updated = filetime(&time).ok_or(crate::DATE_OUT_OF_RANGE);
1593 // FILETIME's sub-second ticks stay as they were.
1594 Some(updated.map(|updated| {
1595 let updated = updated / 10_000_000 * 10_000_000 + timestamp % 10_000_000;
1596 (updated, date_labels(&local_time(updated)))
1597 }))
1598 });
16341599}
16351600
16361601/// Asks for a notebook's table of contents or a section file, titled `title`.
1637pub fn pick_notebook(title: &str) -> Option<PathBuf> {
1638 pick(
1639 title,
1640 &[(
1641 "OneNote notebooks, sections and packages",
1642 "*.onetoc2;*.one;*.onepkg",
1643 )],
1644 None,
1645 false,
1646 )
1602pub fn pick_notebook(title: &str, reply: Reply<PathBuf>) {
1603 let filter = (
1604 "OneNote notebooks, sections and packages",
1605 "*.onetoc2;*.one;*.onepkg",
1606 );
1607 pick(title, &[filter], None, false, reply);
16471608}
16481609
16491610/// 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>) {
16511612 let patterns = types
16521613 .iter()
16531614 .map(|kind| format!("*.{kind}"))
......@@ -1658,7 +1619,7 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option<PathBuf> {
16581619 } else {
16591620 vec![("Supported files", patterns.as_str()), ("All files", "*.*")]
16601621 };
1661 pick(title, &filters, None, false)
1622 pick(title, &filters, None, false, reply);
16621623}
16631624
16641625/// Asks where to put something named `name` by default; the dialog names its own button.
......@@ -1667,19 +1628,26 @@ pub fn pick_new(
16671628 name: &str,
16681629 _action: &str,
16691630 folder: Option<&std::path::Path>,
1670) -> Option<PathBuf> {
1671 pick(title, &[("All files", "*.*")], Some((name, folder)), true)
1631 reply: Reply<PathBuf>,
1632) {
1633 pick(
1634 title,
1635 &[("All files", "*.*")],
1636 Some((name, folder)),
1637 true,
1638 reply,
1639 );
16721640}
16731641
1674/// The common file dialog: an open dialog over `filters` (name and `;`-separated
1675/// patterns), or with `new`, a save dialog suggesting a name in a folder.
1642/// The common file dialog, on a thread of its own: an open dialog over `filters` (name and
1643/// `;`-separated patterns), or with `new`, a save dialog suggesting a name in a folder.
16761644fn pick(
16771645 title: &str,
16781646 filters: &[(&str, &str)],
16791647 new: Option<(&str, Option<&std::path::Path>)>,
16801648 save: bool,
1681) -> Option<PathBuf> {
1682 use windows_sys::Win32::UI::Controls::Dialogs as dialogs;
1649 reply: Reply<PathBuf>,
1650) {
16831651 let mut filter: Vec<u16> = Vec::new();
16841652 for (name, patterns) in filters {
16851653 filter.extend(name.encode_utf16().chain([0]));
......@@ -1694,54 +1662,68 @@ fn pick(
16941662 }
16951663 let folder = new.and_then(|(_, folder)| folder).map(wide);
16961664 let title = wide(title);
1697 let mut dialog = dialogs::OPENFILENAMEW {
1698 lStructSize: size_of::<dialogs::OPENFILENAMEW>() as u32,
1699 hwndOwner: owner(),
1700 lpstrFilter: filter.as_ptr(),
1701 lpstrFile: file.as_mut_ptr(),
1702 nMaxFile: file.len() as u32,
1703 lpstrTitle: title.as_ptr(),
1704 lpstrInitialDir: folder
1705 .as_ref()
1706 .map_or(std::ptr::null(), |folder| folder.as_ptr()),
1707 Flags: dialogs::OFN_EXPLORER
1708 | dialogs::OFN_NOCHANGEDIR
1709 | if save {
1710 dialogs::OFN_OVERWRITEPROMPT
1665 reply.after(move || {
1666 use windows_sys::Win32::{System::Com, UI::Controls::Dialogs as dialogs};
1667 let mut dialog = dialogs::OPENFILENAMEW {
1668 lStructSize: size_of::<dialogs::OPENFILENAMEW>() as u32,
1669 hwndOwner: owner(),
1670 lpstrFilter: filter.as_ptr(),
1671 lpstrFile: file.as_mut_ptr(),
1672 nMaxFile: file.len() as u32,
1673 lpstrTitle: title.as_ptr(),
1674 lpstrInitialDir: folder
1675 .as_ref()
1676 .map_or(std::ptr::null(), |folder| folder.as_ptr()),
1677 Flags: dialogs::OFN_EXPLORER
1678 | dialogs::OFN_NOCHANGEDIR
1679 | if save {
1680 dialogs::OFN_OVERWRITEPROMPT
1681 } else {
1682 dialogs::OFN_FILEMUSTEXIST
1683 },
1684 ..unsafe { std::mem::zeroed() }
1685 };
1686 // The Explorer-style dialog hosts the shell's COM objects, which want an apartment.
1687 unsafe { Com::CoInitializeEx(std::ptr::null(), Com::COINIT_APARTMENTTHREADED as u32) };
1688 let chosen = unsafe {
1689 if save {
1690 dialogs::GetSaveFileNameW(&mut dialog)
17111691 } else {
1712 dialogs::OFN_FILEMUSTEXIST
1713 },
1714 ..unsafe { std::mem::zeroed() }
1715 };
1716 let chosen = unsafe {
1717 if save {
1718 dialogs::GetSaveFileNameW(&mut dialog)
1719 } else {
1720 dialogs::GetOpenFileNameW(&mut dialog)
1721 }
1722 };
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}");
1692 dialogs::GetOpenFileNameW(&mut dialog)
1693 }
1694 };
1695 unsafe { Com::CoUninitialize() };
1696 if chosen == 0 {
1697 // Zero where the user cancelled.
1698 let error = unsafe { dialogs::CommDlgExtendedError() };
1699 if error != 0 {
1700 eprintln!("The file dialog failed: {error:#x}");
1701 }
1702 return None;
17281703 }
1729 return None;
1730 }
1731 Some(PathBuf::from(narrow(&file)))
1704 Some(PathBuf::from(narrow(&file)))
1705 });
17321706}
17331707
17341708/// Tells the user something they asked for could not be done: `message`, then what to do.
17351709pub fn alert(message: &str, detail: &str) {
17361710 let text = wide(format!("{message}\n\n{detail}"));
1737 let caption = wide("Snowbound");
1738 let style = wm::MB_OK | wm::MB_ICONWARNING;
1739 unsafe { wm::MessageBoxW(owner(), text.as_ptr(), caption.as_ptr(), style) };
1711 std::thread::spawn(move || {
1712 let caption = wide("Snowbound");
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(()));
17401722}
17411723
1742/// Asks whether to go ahead with `action`, offering `cancel` too: a task dialog with those
1743/// buttons 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 {
1724/// Whether the user goes ahead with `action`: a task dialog with buttons for it and `cancel`
1725/// where the common controls have one, as from Windows Vista, else OK and Cancel.
1726fn ask(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
17451727 use windows_sys::Win32::UI::Controls as controls;
17461728 type Indirect = unsafe extern "system" fn(
17471729 *const controls::TASKDIALOGCONFIG,
crates/snowbound/web/glue.js+31-5
......@@ -362,16 +362,42 @@ export function shortDate(ms) {
362362 return new Date(ms).toLocaleDateString();
363363}
364364
365export function askConfirm(message) {
366 return confirm(message);
365/** Shows `message` and `detail` in a dialog over the page, with a field holding `value`
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);
367391}
368392
369393export 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 );
371397}
372398
373export function tell(message) {
374 alert(message);
399export function tell(message, detail) {
400 ask(message, detail, ["OK"]);
375401}
376402
377403export function openLink(url) {
crates/snowbound/web/index.html+10
......@@ -23,6 +23,16 @@
2323 position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%);
2424 font: 14px system-ui, sans-serif; padding: 6px 14px;
2525 }
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; }
2636 #status {
2737 position: fixed; inset: 0; display: grid; place-items: center;
2838 font: 15px system-ui, sans-serif; color: #666; pointer-events: none;