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