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