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