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