Skip to main content

odox_ui/
shell.rs

1//! The window around a document: opening one, saying what went wrong, and the
2//! menu and keys that are the same in all three applications.
3//
4// Author: David M. Anderson
5// Built with AI assistance (Claude, Anthropic)
6
7use std::path::{Path, PathBuf};
8
9use eframe::egui::{self, Key, KeyboardShortcut, Modifiers, Ui};
10
11use crate::i18n::{fill, t};
12
13/// What an application tells the shell about itself.
14///
15/// The identifiers are not translated: a desktop entry, a window class and a file
16/// extension are the same in every language.
17pub struct Product {
18    /// The binary's name, which is also the `.desktop` entry's basename and the
19    /// Wayland application id. The compositor matches the three to find the
20    /// window's icon, so they have to agree.
21    pub id: &'static str,
22    /// The file extension the application opens.
23    pub extension: &'static str,
24    /// What the file dialog and the empty window call that kind of file.
25    ///
26    /// The English, which is the message id: it is looked up through [`t`] where
27    /// it is shown, because a `Product` is built before `run` puts a catalogue in
28    /// force and a translation looked up here would be the English every time.
29    pub format: &'static str,
30    /// The application's icon directory, for the one platform with no other way
31    /// to give a window its icon.
32    ///
33    /// Windows takes a window's icon from a resource compiled into the
34    /// executable, and there is no resource compiler in this build, so the file
35    /// travels in the binary instead. Empty everywhere else, and empty in a
36    /// build made from a published crate, where the file is not there to stage:
37    /// see each application's `build.rs`.
38    pub icon: &'static [u8],
39}
40
41/// What the shell needs from the view that draws a particular format.
42pub trait Viewer {
43    /// Take a document's bytes. The path is for the window's title and for
44    /// reloading, and is never read from here: this is given the bytes.
45    ///
46    /// The context is for the work a new document makes necessary before it can
47    /// be drawn — loading the font faces its styles name, which rebuilds egui's
48    /// glyph atlas and so belongs to opening rather than to drawing.
49    ///
50    /// # Errors
51    ///
52    /// A message to show the person, already translated.
53    fn open(&mut self, ctx: &egui::Context, bytes: &[u8], path: &Path) -> Result<(), String>;
54
55    /// Forget the document.
56    fn close(&mut self);
57
58    /// Whether a document is open.
59    fn is_open(&self) -> bool;
60
61    /// What the document calls itself, for the window's title.
62    fn title(&self) -> Option<String>;
63
64    /// Draw the document. The shell has already put a scroll area or a panel
65    /// around whatever this needs.
66    fn central(&mut self, ui: &mut Ui, zoom: f32);
67
68    /// Draw the panel beside the document — an outline, a sheet list, a slide
69    /// list — and answer whether there is one to draw.
70    fn side(&mut self, _ui: &mut Ui) -> bool {
71        false
72    }
73
74    /// Add the application's own items to the View menu.
75    fn view_menu(&mut self, _ui: &mut Ui) {}
76}
77
78/// The window: chrome, keys, errors, and one view inside it.
79pub struct Shell<V: Viewer> {
80    view: V,
81    product: Product,
82    path: Option<PathBuf>,
83    error: Option<String>,
84    /// Set when a document was opened during a frame, cleared at the end of it.
85    ///
86    /// See the comment where it is read in [`eframe::App::ui`].
87    settling: bool,
88    zoom: f32,
89    show_side: bool,
90}
91
92/// How far a zoom step moves, and the limits.
93const ZOOM_STEP: f32 = 1.1;
94const ZOOM_MIN: f32 = 0.4;
95const ZOOM_MAX: f32 = 4.0;
96
97impl<V: Viewer> Shell<V> {
98    /// A window with nothing open.
99    pub fn new(product: Product, view: V) -> Self {
100        Self {
101            view,
102            product,
103            path: None,
104            error: None,
105            settling: false,
106            zoom: 1.0,
107            show_side: true,
108        }
109    }
110
111    /// Open a path, reading it here so that the view never touches the file
112    /// system.
113    pub fn open(&mut self, ctx: &egui::Context, path: &Path) {
114        match std::fs::read(path) {
115            Ok(bytes) => match self.view.open(ctx, &bytes, path) {
116                Ok(()) => {
117                    self.path = Some(path.to_path_buf());
118                    self.error = None;
119                    self.settling = true;
120                }
121                Err(message) => {
122                    self.view.close();
123                    self.path = None;
124                    self.error = Some(message);
125                }
126            },
127            Err(e) => {
128                self.error = Some(fill(
129                    t("{file} could not be read: {reason}"),
130                    &[("file", &name_of(path)), ("reason", &e.to_string())],
131                ));
132            }
133        }
134    }
135
136    fn ask_for_a_file(&mut self, ctx: &egui::Context) {
137        let file = rfd::FileDialog::new()
138            .add_filter(t(self.product.format), &[self.product.extension])
139            .pick_file();
140        if let Some(path) = file {
141            self.open(ctx, &path);
142        }
143    }
144
145    fn reload(&mut self, ctx: &egui::Context) {
146        if let Some(path) = self.path.clone() {
147            self.open(ctx, &path);
148        }
149    }
150
151    fn close(&mut self) {
152        self.view.close();
153        self.path = None;
154        self.error = None;
155    }
156
157    /// The window's title: the document's own title, or its file name.
158    fn window_title(&self) -> String {
159        let document = self
160            .view
161            .title()
162            .filter(|title| !title.trim().is_empty())
163            .or_else(|| self.path.as_deref().map(name_of));
164        match document {
165            Some(name) => format!("{name} — {}", self.product.id),
166            None => self.product.id.to_owned(),
167        }
168    }
169}
170
171impl<V: Viewer> eframe::App for Shell<V> {
172    fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
173        let ctx = ui.ctx().clone();
174        self.keys(&ctx);
175        self.dropped_files(&ctx);
176
177        // A document macOS asked for, which reaches here rather than through
178        // the command line. Taken rather than read, so one event opens one
179        // document instead of reopening it on every frame after.
180        #[cfg(target_os = "macos")]
181        if let Some(path) = crate::opened_document::taken() {
182            self.open(&ctx, &path);
183        }
184
185        ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title()));
186
187        egui::Panel::top("menu").show(ui, |ui| {
188            egui::MenuBar::new().ui(ui, |ui| {
189                ui.menu_button(t("File"), |ui| {
190                    if ui.button(t("Open…")).clicked() {
191                        ui.close();
192                        self.ask_for_a_file(&ctx);
193                    }
194                    let open = self.path.is_some();
195                    if ui
196                        .add_enabled(open, egui::Button::new(t("Reload")))
197                        .clicked()
198                    {
199                        ui.close();
200                        self.reload(&ctx);
201                    }
202                    if ui
203                        .add_enabled(open, egui::Button::new(t("Close")))
204                        .clicked()
205                    {
206                        ui.close();
207                        self.close();
208                    }
209                    ui.separator();
210                    if ui.button(t("Quit")).clicked() {
211                        ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
212                    }
213                });
214                ui.menu_button(t("View"), |ui| {
215                    if ui.button(t("Zoom in")).clicked() {
216                        self.zoom = (self.zoom * ZOOM_STEP).min(ZOOM_MAX);
217                    }
218                    if ui.button(t("Zoom out")).clicked() {
219                        self.zoom = (self.zoom / ZOOM_STEP).max(ZOOM_MIN);
220                    }
221                    if ui.button(t("Actual size")).clicked() {
222                        self.zoom = 1.0;
223                    }
224                    ui.separator();
225                    ui.checkbox(&mut self.show_side, t("Show the side panel"));
226                    self.view.view_menu(ui);
227                });
228                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
229                    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
230                    let percent = (self.zoom * 100.0).round() as u32;
231                    ui.label(fill(t("{percent}%"), &[("percent", &percent.to_string())]));
232                });
233            });
234        });
235
236        if let Some(message) = self.error.clone() {
237            egui::Panel::bottom("error").show(ui, |ui| {
238                ui.horizontal_wrapped(|ui| {
239                    ui.colored_label(ui.visuals().error_fg_color, "\u{26a0}");
240                    ui.label(message);
241                    if ui.button(t("Dismiss")).clicked() {
242                        self.error = None;
243                    }
244                });
245            });
246        }
247
248        if self.show_side && self.view.is_open() {
249            // The view answers whether it has anything to put beside the
250            // document, so that a format with nothing there gets no empty panel
251            // rather than a blank one a person has to close.
252            let mut drew = false;
253            egui::Panel::left("side")
254                .resizable(true)
255                .default_size(220.0)
256                .min_size(120.0)
257                .show(ui, |ui| {
258                    egui::ScrollArea::both().show(ui, |ui| {
259                        drew = self.view.side(ui);
260                    });
261                });
262            if !drew {
263                self.show_side = false;
264            }
265        }
266
267        // **A document opened during this frame is not drawn until the next
268        // one.** Opening one registers the font families it names, and
269        // `Context::set_fonts` takes effect at the start of the following pass,
270        // so laying the document out now would ask for a family the definitions
271        // still in force do not carry. egui does not fall back for that: it
272        // panics, and a panic inside the macOS event callback cannot unwind, so
273        // the process aborts.
274        //
275        // Every way of opening a document but one goes through a frame: a drop,
276        // Ctrl+O, Reload, and the Apple Event. The exception is the path on the
277        // command line, which is opened in eframe's creation closure before any
278        // pass has begun, and is why this went unnoticed until a runner opened a
279        // document through Launch Services. `tests/fonts_midframe.rs` pins the
280        // hazard.
281        //
282        // One frame, and the repaint is asked for rather than waited for, so
283        // the document appears immediately rather than when the pointer next
284        // moves.
285        let settling = self.settling;
286        if settling {
287            self.settling = false;
288            ctx.request_repaint();
289        }
290
291        egui::CentralPanel::default_margins().show(ui, |ui| {
292            if settling {
293                // Deliberately blank, and for one frame. Drawing the
294                // nothing-open message here instead would flash it between a
295                // double-click and the document.
296            } else if self.view.is_open() {
297                self.view.central(ui, self.zoom);
298            } else {
299                self.nothing_open(ui);
300            }
301        });
302    }
303}
304
305impl<V: Viewer> Shell<V> {
306    /// What the window says before a document is opened.
307    fn nothing_open(&mut self, ui: &mut Ui) {
308        ui.vertical_centered(|ui| {
309            ui.add_space(ui.available_height() * 0.3);
310            // The application's own name, which is not translated, and the name
311            // of the format, which is: Comma's German has said
312            // `OpenDocument-Tabellendokument` since it shipped.
313            ui.heading(self.product.id);
314            ui.label(t(self.product.format));
315            ui.add_space(12.0);
316            if ui.button(t("Open a document…")).clicked() {
317                let ctx = ui.ctx().clone();
318                self.ask_for_a_file(&ctx);
319            }
320            ui.add_space(8.0);
321            ui.weak(t("or drop one on this window"));
322        });
323    }
324
325    fn keys(&mut self, ctx: &egui::Context) {
326        let pressed = |key| {
327            ctx.input_mut(|input| {
328                input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, key))
329            })
330        };
331        if pressed(Key::O) {
332            self.ask_for_a_file(ctx);
333        }
334        if pressed(Key::R) {
335            self.reload(ctx);
336        }
337        if pressed(Key::W) {
338            self.close();
339        }
340        // `Plus` is what the key sends with shift held and `Equals` without, and
341        // a person pressing the same physical key means the same thing either way.
342        if pressed(Key::Plus) || pressed(Key::Equals) {
343            self.zoom = (self.zoom * ZOOM_STEP).min(ZOOM_MAX);
344        }
345        if pressed(Key::Minus) {
346            self.zoom = (self.zoom / ZOOM_STEP).max(ZOOM_MIN);
347        }
348        if pressed(Key::Num0) {
349            self.zoom = 1.0;
350        }
351    }
352
353    fn dropped_files(&mut self, ctx: &egui::Context) {
354        let dropped = ctx.input(|input| {
355            input
356                .raw
357                .dropped_files
358                .first()
359                .map(|file| file.path().to_path_buf())
360        });
361        if let Some(path) = dropped {
362            self.open(ctx, &path);
363        }
364    }
365}
366
367/// A file's name without its directory, for a title or a message.
368fn name_of(path: &Path) -> String {
369    path.file_name().map_or_else(
370        || path.display().to_string(),
371        |name| name.to_string_lossy().into_owned(),
372    )
373}
374
375/// Open a window for a product, with the paths given on the command line.
376///
377/// # Errors
378///
379/// The window could not be created, which is eframe's answer and not this
380/// application's.
381pub fn run<V: Viewer + 'static>(
382    product: Product,
383    build: impl FnOnce(&egui::Context) -> V + 'static,
384) -> eframe::Result {
385    crate::i18n::start();
386
387    let id = product.id;
388    let viewport = egui::ViewportBuilder::default()
389        // How a Wayland compositor finds the window's icon and its name: it
390        // matches this against the basename of the `.desktop` entry.
391        .with_app_id(id)
392        .with_title(id)
393        .with_inner_size([1000.0, 760.0])
394        .with_min_inner_size([420.0, 320.0]);
395
396    // **Naming no icon is not neutral on macOS, and it costs the Dock.** The
397    // bundle carries the `.icns` and `CFBundleIconFile` points at it, which is
398    // where a macOS application's icon comes from, so this looks like an arm
399    // with nothing to do. It has something to do: eframe substitutes its own
400    // logo for a viewport that names no icon and hands that to
401    // `setApplicationIconImage:`, which outranks the bundle. Finder, Launch
402    // Services and every API still resolve the right drawing, so nothing short
403    // of a person looking at the Dock finds it, and it caught two of the
404    // sibling applications before it was written down.
405    //
406    // An empty `IconData` declines the icon rather than replacing it:
407    // eframe turns one into `None` and the macOS arm only calls the selector
408    // where there is an image. Handing the drawing over again would also work
409    // and would carry a second copy of it in the binary to overwrite the
410    // bundle's with a worse-scaled equal.
411    //
412    // One line for three windows, because all three come through here.
413    // **Unverified from Linux**: it compiles for the target and nothing else
414    // about it can be checked without looking at a Dock. `CHECKLIST.md` asks.
415    #[cfg(target_os = "macos")]
416    let viewport = viewport.with_icon(egui::IconData::default());
417
418    // Windows, which is the other half of the same question. Here an icon has
419    // to be given, because the shell reads one out of a resource and this build
420    // compiles no resource.
421    #[cfg(target_os = "windows")]
422    let viewport = match window_icon(product.icon) {
423        Some(icon) => viewport.with_icon(icon),
424        None => viewport,
425    };
426
427    let options = eframe::NativeOptions {
428        viewport,
429        ..eframe::NativeOptions::default()
430    };
431
432    // Before `eframe`, because macOS dispatches the document that launched this
433    // process before the creation closure is reached and AppKit's own handler
434    // refuses it there. `opened_document` has the measurement.
435    #[cfg(target_os = "macos")]
436    crate::opened_document::watch();
437
438    let first = std::env::args_os().nth(1).map(PathBuf::from);
439    eframe::run_native(
440        id,
441        options,
442        Box::new(move |cc| {
443            crate::system_theme::follow(&cc.egui_ctx);
444            // Now that there is a context, a document that arrives has
445            // somewhere to wake.
446            #[cfg(target_os = "macos")]
447            crate::opened_document::wake_with(&cc.egui_ctx);
448            let mut shell = Shell::new(product, build(&cc.egui_ctx));
449            if let Some(path) = first {
450                shell.open(&cc.egui_ctx, &path);
451            }
452            Ok(Box::new(shell))
453        }),
454    )
455}
456
457/// The 64-pixel entry of an icon directory, as a window icon.
458///
459/// 64 because it is the largest size no scaling has to enlarge, and every
460/// smaller one the shell wants is a reduction of it. `None` where the directory
461/// is empty, which is what a build from a published crate has, or where it does
462/// not decode, which is a broken artefact rather than a reason to refuse to
463/// open a window.
464#[cfg(target_os = "windows")]
465fn window_icon(bytes: &'static [u8]) -> Option<egui::IconData> {
466    if bytes.is_empty() {
467        return None;
468    }
469    let directory = ico::IconDir::read(std::io::Cursor::new(bytes)).ok()?;
470    let entry = directory
471        .entries()
472        .iter()
473        .find(|entry| entry.width() == 64)
474        .or_else(|| directory.entries().last())?;
475    let image = entry.decode().ok()?;
476    Some(egui::IconData {
477        width: image.width(),
478        height: image.height(),
479        rgba: image.rgba_data().to_vec(),
480    })
481}