Skip to main content

odox_ui/
shell.rs

1//! The window around a document: opening one, saving it, saying what went
2//! wrong, and the menu and keys that are the same in all three applications.
3//!
4//! The shell owns every read from and write to the disk. A view is handed
5//! bytes and hands bytes back, so the one place that knows a path is here, and
6//! so is the one place that asks before throwing changes away.
7//
8// Author: David M. Anderson
9// Built with AI assistance (Claude, Anthropic)
10
11use std::path::{Path, PathBuf};
12
13use eframe::egui::{self, Key, KeyboardShortcut, Modifiers, Ui};
14use odox_core::Document;
15
16use crate::edit::Editing;
17use crate::i18n::{fill, t};
18use crate::settings::Settings;
19
20/// What an application tells the shell about itself.
21///
22/// The identifiers are not translated: a desktop entry, a window class and a file
23/// extension are the same in every language.
24pub struct Product {
25    /// The binary's name, which is also the `.desktop` entry's basename and the
26    /// Wayland application id. The compositor matches the three to find the
27    /// window's icon, so they have to agree.
28    pub id: &'static str,
29    /// The file extension the application opens.
30    pub extension: &'static str,
31    /// What the file dialog and the empty window call that kind of file.
32    ///
33    /// The English, which is the message id: it is looked up through [`t`] where
34    /// it is shown, because a `Product` is built before `run` puts a catalogue in
35    /// force and a translation looked up here would be the English every time.
36    pub format: &'static str,
37    /// The application's icon directory, for the one platform with no other way
38    /// to give a window its icon.
39    ///
40    /// Windows takes a window's icon from a resource compiled into the
41    /// executable, and there is no resource compiler in this build, so the file
42    /// travels in the binary instead. Empty everywhere else, and empty in a
43    /// build made from a published crate, where the file is not there to stage:
44    /// see each application's `build.rs`.
45    pub icon: &'static [u8],
46}
47
48/// What the shell needs from the view that draws a particular format.
49pub trait View {
50    /// Take a document's bytes. The path is for the window's title and for
51    /// reloading, and is never read from here: this is given the bytes.
52    ///
53    /// The context is for the work a new document makes necessary before it can
54    /// be drawn — loading the font faces its styles name, which rebuilds egui's
55    /// glyph atlas and so belongs to opening rather than to drawing.
56    ///
57    /// # Errors
58    ///
59    /// A message to show the person, already translated.
60    fn open(&mut self, ctx: &egui::Context, bytes: &[u8], path: &Path) -> Result<(), String>;
61
62    /// Forget the document.
63    fn close(&mut self);
64
65    /// Whether a document is open.
66    fn is_open(&self) -> bool;
67
68    /// What the document calls itself, for the window's title.
69    fn title(&self) -> Option<String>;
70
71    /// The document, for saving and for undo. `None` while nothing is open.
72    fn document_mut(&mut self) -> Option<&mut Document>;
73
74    /// The content tree was replaced under the document, by undo or redo.
75    /// A view that derives anything from the tree rebuilds it here.
76    fn reindex(&mut self) {}
77
78    /// Draw the document. The shell has already put a scroll area or a panel
79    /// around whatever this needs. The editing state says whether edit mode is
80    /// on and takes the snapshot an edit records before it changes the tree.
81    fn central(&mut self, ui: &mut Ui, zoom: f32, editing: &mut Editing);
82
83    /// Draw the panel beside the document — an outline, a sheet list, a slide
84    /// list — and answer whether there is one to draw.
85    fn side(&mut self, _ui: &mut Ui) -> bool {
86        false
87    }
88
89    /// Add the application's own items to the View menu.
90    fn view_menu(&mut self, _ui: &mut Ui) {}
91}
92
93/// The window: chrome, keys, errors, and one view inside it.
94pub struct Shell<V: View> {
95    view: V,
96    product: Product,
97    path: Option<PathBuf>,
98    error: Option<String>,
99    /// Set when a document was opened during a frame, cleared at the end of it.
100    ///
101    /// See the comment where it is read in [`eframe::App::ui`].
102    settling: bool,
103    zoom: f32,
104    show_side: bool,
105    editing: Editing,
106    settings: Settings,
107    /// What was asked for while the document had unsaved changes, waiting on
108    /// the person's answer.
109    pending: Option<Pending>,
110    /// The person has answered that the window may close, so the next request
111    /// to close it is not asked about again.
112    closing: bool,
113}
114
115/// Something that would throw away unsaved changes, held until the person
116/// says whether to save them, discard them, or not do it after all.
117#[derive(Clone, Copy)]
118enum Pending {
119    AskForAFile,
120    Reload,
121    Close,
122    Quit,
123}
124
125/// How far a zoom step moves, and the limits.
126const ZOOM_STEP: f32 = 1.1;
127const ZOOM_MIN: f32 = 0.4;
128const ZOOM_MAX: f32 = 4.0;
129
130impl<V: View> Shell<V> {
131    /// A window with nothing open.
132    pub fn new(product: Product, view: V) -> Self {
133        Self {
134            view,
135            product,
136            path: None,
137            error: None,
138            settling: false,
139            zoom: 1.0,
140            show_side: true,
141            editing: Editing::default(),
142            settings: Settings::load(),
143            pending: None,
144            closing: false,
145        }
146    }
147
148    /// Open a path, reading it here so that the view never touches the file
149    /// system.
150    ///
151    /// Unconditional: the document that was open is gone. [`Self::request`]
152    /// is the way in for anything a person asked for, and it asks first.
153    pub fn open(&mut self, ctx: &egui::Context, path: &Path) {
154        match std::fs::read(path) {
155            Ok(bytes) => match self.view.open(ctx, &bytes, path) {
156                Ok(()) => {
157                    self.path = Some(path.to_path_buf());
158                    self.error = None;
159                    self.settling = true;
160                    self.editing.reset();
161                    self.editing.on = self.settings.open_in_edit_mode;
162                }
163                Err(message) => {
164                    self.view.close();
165                    self.path = None;
166                    self.error = Some(message);
167                    self.editing.reset();
168                }
169            },
170            Err(e) => {
171                self.error = Some(fill(
172                    t("{file} could not be read: {reason}"),
173                    &[("file", &name_of(path)), ("reason", &e.to_string())],
174                ));
175            }
176        }
177    }
178
179    /// Do something that would lose unsaved changes, or ask first.
180    ///
181    /// With nothing to lose it is done now. Otherwise it waits on the answer
182    /// to the question [`Self::ask_about_changes`] draws, and is done, or not,
183    /// from there.
184    fn request(&mut self, ctx: &egui::Context, action: Pending) {
185        if self.editing.modified() {
186            self.pending = Some(action);
187        } else {
188            self.perform(ctx, action);
189        }
190    }
191
192    fn perform(&mut self, ctx: &egui::Context, action: Pending) {
193        match action {
194            Pending::AskForAFile => self.ask_for_a_file(ctx),
195            Pending::Reload => {
196                if let Some(path) = self.path.clone() {
197                    self.open(ctx, &path);
198                }
199            }
200            Pending::Close => self.close(),
201            Pending::Quit => {
202                self.closing = true;
203                ctx.send_viewport_cmd(egui::ViewportCommand::Close);
204            }
205        }
206    }
207
208    fn ask_for_a_file(&mut self, ctx: &egui::Context) {
209        let file = rfd::FileDialog::new()
210            .add_filter(t(self.product.format), &[self.product.extension])
211            .pick_file();
212        if let Some(path) = file {
213            self.open(ctx, &path);
214        }
215    }
216
217    fn close(&mut self) {
218        self.view.close();
219        self.path = None;
220        self.error = None;
221        self.editing.reset();
222    }
223
224    /// Write the document over the file it was read from.
225    ///
226    /// Answers whether it was written, which is what a pending action waits
227    /// on: a save that failed is not a reason to go on and close the window.
228    fn save(&mut self) -> bool {
229        match self.path.clone() {
230            Some(path) => self.write_to(&path),
231            None => self.save_as(),
232        }
233    }
234
235    /// Ask where to write the document, and write it there.
236    fn save_as(&mut self) -> bool {
237        let mut dialog =
238            rfd::FileDialog::new().add_filter(t(self.product.format), &[self.product.extension]);
239        if let Some(path) = &self.path {
240            dialog = dialog.set_file_name(name_of(path));
241            if let Some(directory) = path.parent() {
242                dialog = dialog.set_directory(directory);
243            }
244        }
245        let Some(mut path) = dialog.save_file() else {
246            return false;
247        };
248        // A name typed without an extension gets the format's, because the
249        // desktop finds the application by the extension and a file without
250        // one opens nowhere.
251        if path.extension().is_none() {
252            path.set_extension(self.product.extension);
253        }
254        if self.write_to(&path) {
255            self.path = Some(path);
256            true
257        } else {
258            false
259        }
260    }
261
262    /// Serialize the document, prove it reads back, and put it on disk.
263    /// DESIGN.md §11.
264    fn write_to(&mut self, path: &Path) -> bool {
265        let Some(document) = self.view.document_mut() else {
266            return false;
267        };
268        let written = document
269            .write_verified()
270            .map_err(|e| e.to_string())
271            .and_then(|bytes| replace_file(path, &bytes).map_err(|e| e.to_string()));
272        match written {
273            Ok(()) => {
274                self.editing.mark_saved();
275                self.error = None;
276                true
277            }
278            Err(reason) => {
279                self.error = Some(fill(
280                    t("{file} could not be saved: {reason}"),
281                    &[("file", &name_of(path)), ("reason", &reason)],
282                ));
283                false
284            }
285        }
286    }
287
288    fn undo(&mut self) {
289        if let Some(document) = self.view.document_mut()
290            && let Some(previous) = self.editing.undo(&document.content)
291        {
292            document.content = previous;
293            self.view.reindex();
294        }
295    }
296
297    fn redo(&mut self) {
298        if let Some(document) = self.view.document_mut()
299            && let Some(next) = self.editing.redo(&document.content)
300        {
301            document.content = next;
302            self.view.reindex();
303        }
304    }
305
306    /// The window's title: the document's own title, or its file name, marked
307    /// when it has changes the file does not.
308    fn window_title(&self) -> String {
309        let document = self
310            .view
311            .title()
312            .filter(|title| !title.trim().is_empty())
313            .or_else(|| self.path.as_deref().map(name_of));
314        let mark = if self.editing.modified() { "● " } else { "" };
315        match document {
316            Some(name) => format!("{mark}{name} — {}", self.product.id),
317            None => self.product.id.to_owned(),
318        }
319    }
320
321    /// The question a pending action waits on, drawn over the window.
322    fn ask_about_changes(&mut self, ctx: &egui::Context) {
323        if self.pending.is_none() {
324            return;
325        }
326        let file = self.path.as_deref().map(name_of).unwrap_or_default();
327        // Save, discard, or neither. `None` until the person answers.
328        let mut answer = None;
329        egui::Modal::new(egui::Id::new("unsaved")).show(ctx, |ui| {
330            ui.heading(fill(t("Save changes to {file}?"), &[("file", &file)]));
331            ui.label(t("The document has changes that are not saved."));
332            ui.add_space(8.0);
333            ui.horizontal(|ui| {
334                if ui.button(t("Save")).clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
335                    answer = Some(Some(true));
336                }
337                if ui.button(t("Discard")).clicked() {
338                    answer = Some(Some(false));
339                }
340                if ui.button(t("Cancel")).clicked() || ui.input(|i| i.key_pressed(Key::Escape)) {
341                    answer = Some(None);
342                }
343            });
344        });
345        let Some(answer) = answer else {
346            return;
347        };
348        let Some(action) = self.pending.take() else {
349            return;
350        };
351        match answer {
352            Some(true) => {
353                if self.save() {
354                    self.perform(ctx, action);
355                }
356            }
357            Some(false) => self.perform(ctx, action),
358            None => {}
359        }
360    }
361}
362
363impl<V: View> eframe::App for Shell<V> {
364    fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
365        let ctx = ui.ctx().clone();
366
367        // The window's own close button, which asks like Quit does. The close
368        // is cancelled and asked for again when the person has answered.
369        if ctx.input(|i| i.viewport().close_requested()) && !self.closing && self.editing.modified()
370        {
371            ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
372            self.pending = Some(Pending::Quit);
373        }
374
375        // Nothing is taken while the question is up, so an answer typed at it
376        // reaches it and nothing else; and nothing is taken while a text field
377        // has the focus, so Ctrl+Z inside a cell undoes the typing and not the
378        // document.
379        self.editing.asking = self.pending.is_some();
380        if self.pending.is_none() && !ctx.egui_wants_keyboard_input() {
381            self.keys(&ctx);
382        }
383
384        // A document macOS asked for, which reaches here rather than through
385        // the command line. Taken rather than read, so one event opens one
386        // document instead of reopening it on every frame after.
387        #[cfg(target_os = "macos")]
388        if let Some(path) = crate::opened_document::taken() {
389            self.open(&ctx, &path);
390        }
391
392        ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title()));
393
394        egui::Panel::top("menu").show(ui, |ui| self.menu_bar(ui, &ctx));
395
396        if let Some(message) = self.error.clone() {
397            egui::Panel::bottom("error").show(ui, |ui| {
398                ui.horizontal_wrapped(|ui| {
399                    ui.colored_label(ui.visuals().error_fg_color, "\u{26a0}");
400                    ui.label(message);
401                    if ui.button(t("Dismiss")).clicked() {
402                        self.error = None;
403                    }
404                });
405            });
406        }
407
408        if self.show_side && self.view.is_open() {
409            // The view answers whether it has anything to put beside the
410            // document, so that a format with nothing there gets no empty panel
411            // rather than a blank one a person has to close.
412            let mut drew = false;
413            egui::Panel::left("side")
414                .resizable(true)
415                .default_size(220.0)
416                .min_size(120.0)
417                .show(ui, |ui| {
418                    egui::ScrollArea::both().show(ui, |ui| {
419                        drew = self.view.side(ui);
420                    });
421                });
422            if !drew {
423                self.show_side = false;
424            }
425        }
426
427        // **A document opened during this frame is not drawn until the next
428        // one.** Opening one registers the font families it names, and
429        // `Context::set_fonts` takes effect at the start of the following pass,
430        // so laying the document out now would ask for a family the definitions
431        // still in force do not carry. egui does not fall back for that: it
432        // panics, and a panic inside the macOS event callback cannot unwind, so
433        // the process aborts.
434        //
435        // Every way of opening a document but one goes through a frame:
436        // Ctrl+O, Reload, and the Apple Event. The exception is the path on the
437        // command line, which is opened in eframe's creation closure before any
438        // pass has begun, and is why this went unnoticed until a runner opened a
439        // document through Launch Services. `tests/fonts_midframe.rs` pins the
440        // hazard.
441        //
442        // One frame, and the repaint is asked for rather than waited for, so
443        // the document appears immediately rather than when the pointer next
444        // moves.
445        let settling = self.settling;
446        if settling {
447            self.settling = false;
448            ctx.request_repaint();
449        }
450
451        egui::CentralPanel::default_margins().show(ui, |ui| {
452            if settling {
453                // Deliberately blank, and for one frame. Drawing the
454                // nothing-open message here instead would flash it between a
455                // double-click and the document.
456            } else if self.view.is_open() {
457                self.view.central(ui, self.zoom, &mut self.editing);
458            } else {
459                self.nothing_open(ui);
460            }
461        });
462
463        self.ask_about_changes(&ctx);
464    }
465}
466
467impl<V: View> Shell<V> {
468    /// The menu bar: the File and Edit menus that are the same in every
469    /// application, the View menu with the application's own items after the
470    /// shell's, and on the right what mode the window is in and how far it is
471    /// zoomed.
472    fn menu_bar(&mut self, ui: &mut Ui, ctx: &egui::Context) {
473        egui::MenuBar::new().ui(ui, |ui| {
474            ui.menu_button(t("File"), |ui| {
475                if ui.button(t("Open…")).clicked() {
476                    ui.close();
477                    self.request(ctx, Pending::AskForAFile);
478                }
479                let open = self.path.is_some();
480                if ui
481                    .add_enabled(open, egui::Button::new(t("Reload")))
482                    .clicked()
483                {
484                    ui.close();
485                    self.request(ctx, Pending::Reload);
486                }
487                if ui
488                    .add_enabled(open, egui::Button::new(t("Close")))
489                    .clicked()
490                {
491                    ui.close();
492                    self.request(ctx, Pending::Close);
493                }
494                ui.separator();
495                let modified = open && self.editing.modified();
496                if ui
497                    .add_enabled(modified, egui::Button::new(t("Save")))
498                    .clicked()
499                {
500                    ui.close();
501                    self.save();
502                }
503                if ui
504                    .add_enabled(open, egui::Button::new(t("Save as…")))
505                    .clicked()
506                {
507                    ui.close();
508                    self.save_as();
509                }
510                ui.separator();
511                if ui.button(t("Quit")).clicked() {
512                    ui.close();
513                    self.request(ctx, Pending::Quit);
514                }
515            });
516            ui.menu_button(t("Edit"), |ui| {
517                if ui
518                    .add_enabled(self.editing.can_undo(), egui::Button::new(t("Undo")))
519                    .clicked()
520                {
521                    ui.close();
522                    self.undo();
523                }
524                if ui
525                    .add_enabled(self.editing.can_redo(), egui::Button::new(t("Redo")))
526                    .clicked()
527                {
528                    ui.close();
529                    self.redo();
530                }
531                ui.separator();
532                ui.checkbox(&mut self.editing.on, t("Edit mode"));
533                if ui
534                    .checkbox(
535                        &mut self.settings.open_in_edit_mode,
536                        t("Open documents in edit mode"),
537                    )
538                    .changed()
539                    && let Err(e) = self.settings.save()
540                {
541                    self.error = Some(fill(
542                        t("The setting could not be saved: {reason}"),
543                        &[("reason", &e.to_string())],
544                    ));
545                }
546            });
547            ui.menu_button(t("View"), |ui| {
548                if ui.button(t("Zoom in")).clicked() {
549                    self.zoom = (self.zoom * ZOOM_STEP).min(ZOOM_MAX);
550                }
551                if ui.button(t("Zoom out")).clicked() {
552                    self.zoom = (self.zoom / ZOOM_STEP).max(ZOOM_MIN);
553                }
554                if ui.button(t("Actual size")).clicked() {
555                    self.zoom = 1.0;
556                }
557                ui.separator();
558                ui.checkbox(&mut self.show_side, t("Show the side panel"));
559                self.view.view_menu(ui);
560            });
561            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
562                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
563                let percent = (self.zoom * 100.0).round() as u32;
564                ui.label(fill(t("{percent}%"), &[("percent", &percent.to_string())]));
565                if self.editing.on && self.view.is_open() {
566                    ui.separator();
567                    ui.strong(t("Editing"));
568                }
569            });
570        });
571    }
572
573    /// What the window says before a document is opened.
574    fn nothing_open(&mut self, ui: &mut Ui) {
575        ui.vertical_centered(|ui| {
576            ui.add_space(ui.available_height() * 0.3);
577            // The application's own name, which is not translated, and the name
578            // of the format, which is: Comma's German has said
579            // `OpenDocument-Tabellendokument` since it shipped.
580            ui.heading(self.product.id);
581            ui.label(t(self.product.format));
582            ui.add_space(12.0);
583            if ui.button(t("Open a document…")).clicked() {
584                let ctx = ui.ctx().clone();
585                self.ask_for_a_file(&ctx);
586            }
587        });
588    }
589
590    fn keys(&mut self, ctx: &egui::Context) {
591        let pressed = |key| {
592            ctx.input_mut(|input| {
593                input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, key))
594            })
595        };
596        let shifted = |key| {
597            ctx.input_mut(|input| {
598                input.consume_shortcut(&KeyboardShortcut::new(
599                    Modifiers::COMMAND | Modifiers::SHIFT,
600                    key,
601                ))
602            })
603        };
604        // The shifted pair first, because Ctrl+Shift+S is not Ctrl+S.
605        if shifted(Key::S) {
606            self.save_as();
607        }
608        if shifted(Key::Z) || pressed(Key::Y) {
609            self.redo();
610        }
611        if pressed(Key::S) && self.editing.modified() {
612            self.save();
613        }
614        if pressed(Key::Z) {
615            self.undo();
616        }
617        if pressed(Key::E) && self.view.is_open() {
618            self.editing.on = !self.editing.on;
619        }
620        if pressed(Key::O) {
621            self.request(ctx, Pending::AskForAFile);
622        }
623        if pressed(Key::R) {
624            self.request(ctx, Pending::Reload);
625        }
626        if pressed(Key::W) {
627            self.request(ctx, Pending::Close);
628        }
629        // `Plus` is what the key sends with shift held and `Equals` without, and
630        // a person pressing the same physical key means the same thing either way.
631        if pressed(Key::Plus) || pressed(Key::Equals) {
632            self.zoom = (self.zoom * ZOOM_STEP).min(ZOOM_MAX);
633        }
634        if pressed(Key::Minus) {
635            self.zoom = (self.zoom / ZOOM_STEP).max(ZOOM_MIN);
636        }
637        if pressed(Key::Num0) {
638            self.zoom = 1.0;
639        }
640    }
641}
642
643/// A file's name without its directory, for a title or a message.
644fn name_of(path: &Path) -> String {
645    path.file_name().map_or_else(
646        || path.display().to_string(),
647        |name| name.to_string_lossy().into_owned(),
648    )
649}
650
651/// Put bytes where a file is, so that the file is either what it was or what
652/// was written and never half of each.
653///
654/// The bytes go into a temporary file beside the target, which is then renamed
655/// over it: a rename within one filesystem is atomic, and a crash before it
656/// leaves the original untouched and a stray `.part` file to delete. The
657/// target's permissions are carried over, because the rename replaces the
658/// inode and would otherwise leave a document writable by whoever the
659/// temporary file's default said.
660///
661/// **macOS writes in place.** The sandbox grant a person gives by choosing a
662/// file covers the file and not its directory, so a temporary file beside it
663/// is refused with *Operation not permitted*. The safe rewrite there goes
664/// through `NSItemReplacementDirectory` and `replaceItemAtURL:`, which is a
665/// platform arm this build cannot verify and does not carry yet.
666fn replace_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
667    #[cfg(target_os = "macos")]
668    {
669        std::fs::write(path, bytes)
670    }
671    #[cfg(not(target_os = "macos"))]
672    {
673        use std::io::Write as _;
674        let temporary = path.with_file_name(format!("{}.part", name_of(path)));
675        let mut file = std::fs::File::create(&temporary)?;
676        let written = file
677            .write_all(bytes)
678            .and_then(|()| file.sync_all())
679            .and_then(|()| match std::fs::metadata(path) {
680                Ok(existing) => std::fs::set_permissions(&temporary, existing.permissions()),
681                Err(_) => Ok(()),
682            })
683            .and_then(|()| std::fs::rename(&temporary, path));
684        if written.is_err() {
685            // Best effort: the error the person sees is the one that stopped
686            // the write, not the one about tidying up after it.
687            let _ = std::fs::remove_file(&temporary);
688        }
689        written
690    }
691}
692
693/// Open a window for a product, with the paths given on the command line.
694///
695/// # Errors
696///
697/// The window could not be created, which is eframe's answer and not this
698/// application's.
699pub fn run<V: View + 'static>(
700    product: Product,
701    build: impl FnOnce(&egui::Context) -> V + 'static,
702) -> eframe::Result {
703    crate::i18n::start();
704
705    let id = product.id;
706    let viewport = egui::ViewportBuilder::default()
707        // How a Wayland compositor finds the window's icon and its name: it
708        // matches this against the basename of the `.desktop` entry.
709        .with_app_id(id)
710        .with_title(id)
711        .with_inner_size([1000.0, 760.0])
712        .with_min_inner_size([420.0, 320.0]);
713
714    // **Naming no icon is not neutral on macOS, and it costs the Dock.** The
715    // bundle carries the `.icns` and `CFBundleIconFile` points at it, which is
716    // where a macOS application's icon comes from, so this looks like an arm
717    // with nothing to do. It has something to do: eframe substitutes its own
718    // logo for a viewport that names no icon and hands that to
719    // `setApplicationIconImage:`, which outranks the bundle. Finder, Launch
720    // Services and every API still resolve the right drawing, so nothing short
721    // of a person looking at the Dock finds it, and it caught two of the
722    // sibling applications before it was written down.
723    //
724    // An empty `IconData` declines the icon rather than replacing it:
725    // eframe turns one into `None` and the macOS arm only calls the selector
726    // where there is an image. Handing the drawing over again would also work
727    // and would carry a second copy of it in the binary to overwrite the
728    // bundle's with a worse-scaled equal.
729    //
730    // One line for three windows, because all three come through here.
731    // **Unverified from Linux**: it compiles for the target and nothing else
732    // about it can be checked without looking at a Dock.
733    #[cfg(target_os = "macos")]
734    let viewport = viewport.with_icon(egui::IconData::default());
735
736    // Windows, which is the other half of the same question. Here an icon has
737    // to be given, because the shell reads one out of a resource and this build
738    // compiles no resource.
739    #[cfg(target_os = "windows")]
740    let viewport = match window_icon(product.icon) {
741        Some(icon) => viewport.with_icon(icon),
742        None => viewport,
743    };
744
745    let options = eframe::NativeOptions {
746        viewport,
747        ..eframe::NativeOptions::default()
748    };
749
750    // Before `eframe`, because macOS dispatches the document that launched this
751    // process before the creation closure is reached and AppKit's own handler
752    // refuses it there. `opened_document` has the measurement.
753    #[cfg(target_os = "macos")]
754    crate::opened_document::watch();
755
756    let first = std::env::args_os().nth(1).map(PathBuf::from);
757    eframe::run_native(
758        id,
759        options,
760        Box::new(move |cc| {
761            crate::system_theme::follow(&cc.egui_ctx);
762            // Now that there is a context, a document that arrives has
763            // somewhere to wake.
764            #[cfg(target_os = "macos")]
765            crate::opened_document::wake_with(&cc.egui_ctx);
766            let mut shell = Shell::new(product, build(&cc.egui_ctx));
767            if let Some(path) = first {
768                shell.open(&cc.egui_ctx, &path);
769            }
770            Ok(Box::new(shell))
771        }),
772    )
773}
774
775/// The 64-pixel entry of an icon directory, as a window icon.
776///
777/// 64 because it is the largest size no scaling has to enlarge, and every
778/// smaller one the shell wants is a reduction of it. `None` where the directory
779/// is empty, which is what a build from a published crate has, or where it does
780/// not decode, which is a broken artefact rather than a reason to refuse to
781/// open a window.
782#[cfg(target_os = "windows")]
783fn window_icon(bytes: &'static [u8]) -> Option<egui::IconData> {
784    if bytes.is_empty() {
785        return None;
786    }
787    let directory = ico::IconDir::read(std::io::Cursor::new(bytes)).ok()?;
788    let entry = directory
789        .entries()
790        .iter()
791        .find(|entry| entry.width() == 64)
792        .or_else(|| directory.entries().last())?;
793    let image = entry.decode().ok()?;
794    Some(egui::IconData {
795        width: image.width(),
796        height: image.height(),
797        rgba: image.rgba_data().to_vec(),
798    })
799}
800
801#[cfg(test)]
802mod tests {
803    use super::replace_file;
804
805    /// The document on disk is what was written, with the permissions it had,
806    /// and nothing is left beside it.
807    #[test]
808    fn a_file_is_replaced_whole() {
809        let directory = std::env::temp_dir().join(format!("odox-replace-{}", std::process::id()));
810        std::fs::create_dir_all(&directory).expect("a scratch directory");
811        let path = directory.join("document.odt");
812        std::fs::write(&path, b"before").expect("the original");
813        let mode = std::fs::metadata(&path)
814            .expect("its metadata")
815            .permissions();
816
817        replace_file(&path, b"after").expect("the replacement");
818
819        assert_eq!(std::fs::read(&path).expect("the file"), b"after");
820        assert_eq!(
821            std::fs::metadata(&path)
822                .expect("its metadata")
823                .permissions(),
824            mode
825        );
826        let left: Vec<_> = std::fs::read_dir(&directory)
827            .expect("the directory")
828            .filter_map(Result::ok)
829            .map(|entry| entry.file_name())
830            .collect();
831        assert_eq!(left, vec![std::ffi::OsString::from("document.odt")]);
832        std::fs::remove_dir_all(directory).expect("tidy");
833    }
834
835    /// A target that cannot be written leaves nothing behind and says so.
836    #[test]
837    fn a_failed_write_leaves_no_part_file() {
838        let directory = std::env::temp_dir().join(format!("odox-refuse-{}", std::process::id()));
839        std::fs::create_dir_all(&directory).expect("a scratch directory");
840        let missing = directory.join("nowhere").join("document.odt");
841        assert!(replace_file(&missing, b"x").is_err());
842        assert_eq!(
843            std::fs::read_dir(&directory)
844                .expect("the directory")
845                .count(),
846            0
847        );
848        std::fs::remove_dir_all(directory).expect("tidy");
849    }
850}