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}
31
32/// What the shell needs from the view that draws a particular format.
33pub trait Viewer {
34    /// Take a document's bytes. The path is for the window's title and for
35    /// reloading, and is never read from here: this is given the bytes.
36    ///
37    /// The context is for the work a new document makes necessary before it can
38    /// be drawn — loading the font faces its styles name, which rebuilds egui's
39    /// glyph atlas and so belongs to opening rather than to drawing.
40    ///
41    /// # Errors
42    ///
43    /// A message to show the person, already translated.
44    fn open(&mut self, ctx: &egui::Context, bytes: &[u8], path: &Path) -> Result<(), String>;
45
46    /// Forget the document.
47    fn close(&mut self);
48
49    /// Whether a document is open.
50    fn is_open(&self) -> bool;
51
52    /// What the document calls itself, for the window's title.
53    fn title(&self) -> Option<String>;
54
55    /// Draw the document. The shell has already put a scroll area or a panel
56    /// around whatever this needs.
57    fn central(&mut self, ui: &mut Ui, zoom: f32);
58
59    /// Draw the panel beside the document — an outline, a sheet list, a slide
60    /// list — and answer whether there is one to draw.
61    fn side(&mut self, _ui: &mut Ui) -> bool {
62        false
63    }
64
65    /// Add the application's own items to the View menu.
66    fn view_menu(&mut self, _ui: &mut Ui) {}
67}
68
69/// The window: chrome, keys, errors, and one view inside it.
70pub struct Shell<V: Viewer> {
71    view: V,
72    product: Product,
73    path: Option<PathBuf>,
74    error: Option<String>,
75    zoom: f32,
76    show_side: bool,
77}
78
79/// How far a zoom step moves, and the limits.
80const ZOOM_STEP: f32 = 1.1;
81const ZOOM_MIN: f32 = 0.4;
82const ZOOM_MAX: f32 = 4.0;
83
84impl<V: Viewer> Shell<V> {
85    /// A window with nothing open.
86    pub fn new(product: Product, view: V) -> Self {
87        Self {
88            view,
89            product,
90            path: None,
91            error: None,
92            zoom: 1.0,
93            show_side: true,
94        }
95    }
96
97    /// Open a path, reading it here so that the view never touches the file
98    /// system.
99    pub fn open(&mut self, ctx: &egui::Context, path: &Path) {
100        match std::fs::read(path) {
101            Ok(bytes) => match self.view.open(ctx, &bytes, path) {
102                Ok(()) => {
103                    self.path = Some(path.to_path_buf());
104                    self.error = None;
105                }
106                Err(message) => {
107                    self.view.close();
108                    self.path = None;
109                    self.error = Some(message);
110                }
111            },
112            Err(e) => {
113                self.error = Some(fill(
114                    t("{file} could not be read: {reason}"),
115                    &[("file", &name_of(path)), ("reason", &e.to_string())],
116                ));
117            }
118        }
119    }
120
121    fn ask_for_a_file(&mut self, ctx: &egui::Context) {
122        let file = rfd::FileDialog::new()
123            .add_filter(t(self.product.format), &[self.product.extension])
124            .pick_file();
125        if let Some(path) = file {
126            self.open(ctx, &path);
127        }
128    }
129
130    fn reload(&mut self, ctx: &egui::Context) {
131        if let Some(path) = self.path.clone() {
132            self.open(ctx, &path);
133        }
134    }
135
136    fn close(&mut self) {
137        self.view.close();
138        self.path = None;
139        self.error = None;
140    }
141
142    /// The window's title: the document's own title, or its file name.
143    fn window_title(&self) -> String {
144        let document = self
145            .view
146            .title()
147            .filter(|title| !title.trim().is_empty())
148            .or_else(|| self.path.as_deref().map(name_of));
149        match document {
150            Some(name) => format!("{name} — {}", self.product.id),
151            None => self.product.id.to_owned(),
152        }
153    }
154}
155
156impl<V: Viewer> eframe::App for Shell<V> {
157    fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
158        let ctx = ui.ctx().clone();
159        self.keys(&ctx);
160        self.dropped_files(&ctx);
161
162        ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title()));
163
164        egui::Panel::top("menu").show(ui, |ui| {
165            egui::MenuBar::new().ui(ui, |ui| {
166                ui.menu_button(t("File"), |ui| {
167                    if ui.button(t("Open…")).clicked() {
168                        ui.close();
169                        self.ask_for_a_file(&ctx);
170                    }
171                    let open = self.path.is_some();
172                    if ui
173                        .add_enabled(open, egui::Button::new(t("Reload")))
174                        .clicked()
175                    {
176                        ui.close();
177                        self.reload(&ctx);
178                    }
179                    if ui
180                        .add_enabled(open, egui::Button::new(t("Close")))
181                        .clicked()
182                    {
183                        ui.close();
184                        self.close();
185                    }
186                    ui.separator();
187                    if ui.button(t("Quit")).clicked() {
188                        ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
189                    }
190                });
191                ui.menu_button(t("View"), |ui| {
192                    if ui.button(t("Zoom in")).clicked() {
193                        self.zoom = (self.zoom * ZOOM_STEP).min(ZOOM_MAX);
194                    }
195                    if ui.button(t("Zoom out")).clicked() {
196                        self.zoom = (self.zoom / ZOOM_STEP).max(ZOOM_MIN);
197                    }
198                    if ui.button(t("Actual size")).clicked() {
199                        self.zoom = 1.0;
200                    }
201                    ui.separator();
202                    ui.checkbox(&mut self.show_side, t("Show the side panel"));
203                    self.view.view_menu(ui);
204                });
205                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
206                    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
207                    let percent = (self.zoom * 100.0).round() as u32;
208                    ui.label(fill(t("{percent}%"), &[("percent", &percent.to_string())]));
209                });
210            });
211        });
212
213        if let Some(message) = self.error.clone() {
214            egui::Panel::bottom("error").show(ui, |ui| {
215                ui.horizontal_wrapped(|ui| {
216                    ui.colored_label(ui.visuals().error_fg_color, "\u{26a0}");
217                    ui.label(message);
218                    if ui.button(t("Dismiss")).clicked() {
219                        self.error = None;
220                    }
221                });
222            });
223        }
224
225        if self.show_side && self.view.is_open() {
226            // The view answers whether it has anything to put beside the
227            // document, so that a format with nothing there gets no empty panel
228            // rather than a blank one a person has to close.
229            let mut drew = false;
230            egui::Panel::left("side")
231                .resizable(true)
232                .default_size(220.0)
233                .min_size(120.0)
234                .show(ui, |ui| {
235                    egui::ScrollArea::both().show(ui, |ui| {
236                        drew = self.view.side(ui);
237                    });
238                });
239            if !drew {
240                self.show_side = false;
241            }
242        }
243
244        egui::CentralPanel::default_margins().show(ui, |ui| {
245            if self.view.is_open() {
246                self.view.central(ui, self.zoom);
247            } else {
248                self.nothing_open(ui);
249            }
250        });
251    }
252}
253
254impl<V: Viewer> Shell<V> {
255    /// What the window says before a document is opened.
256    fn nothing_open(&mut self, ui: &mut Ui) {
257        ui.vertical_centered(|ui| {
258            ui.add_space(ui.available_height() * 0.3);
259            // The application's own name, which is not translated, and the name
260            // of the format, which is: Comma's German has said
261            // `OpenDocument-Tabellendokument` since it shipped.
262            ui.heading(self.product.id);
263            ui.label(t(self.product.format));
264            ui.add_space(12.0);
265            if ui.button(t("Open a document…")).clicked() {
266                let ctx = ui.ctx().clone();
267                self.ask_for_a_file(&ctx);
268            }
269            ui.add_space(8.0);
270            ui.weak(t("or drop one on this window"));
271        });
272    }
273
274    fn keys(&mut self, ctx: &egui::Context) {
275        let pressed = |key| {
276            ctx.input_mut(|input| {
277                input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, key))
278            })
279        };
280        if pressed(Key::O) {
281            self.ask_for_a_file(ctx);
282        }
283        if pressed(Key::R) {
284            self.reload(ctx);
285        }
286        if pressed(Key::W) {
287            self.close();
288        }
289        // `Plus` is what the key sends with shift held and `Equals` without, and
290        // a person pressing the same physical key means the same thing either way.
291        if pressed(Key::Plus) || pressed(Key::Equals) {
292            self.zoom = (self.zoom * ZOOM_STEP).min(ZOOM_MAX);
293        }
294        if pressed(Key::Minus) {
295            self.zoom = (self.zoom / ZOOM_STEP).max(ZOOM_MIN);
296        }
297        if pressed(Key::Num0) {
298            self.zoom = 1.0;
299        }
300    }
301
302    fn dropped_files(&mut self, ctx: &egui::Context) {
303        let dropped = ctx.input(|input| {
304            input
305                .raw
306                .dropped_files
307                .first()
308                .map(|file| file.path().to_path_buf())
309        });
310        if let Some(path) = dropped {
311            self.open(ctx, &path);
312        }
313    }
314}
315
316/// A file's name without its directory, for a title or a message.
317fn name_of(path: &Path) -> String {
318    path.file_name().map_or_else(
319        || path.display().to_string(),
320        |name| name.to_string_lossy().into_owned(),
321    )
322}
323
324/// Open a window for a product, with the paths given on the command line.
325///
326/// # Errors
327///
328/// The window could not be created, which is eframe's answer and not this
329/// application's.
330pub fn run<V: Viewer + 'static>(
331    product: Product,
332    build: impl FnOnce(&egui::Context) -> V + 'static,
333) -> eframe::Result {
334    crate::i18n::start();
335
336    let id = product.id;
337    let options = eframe::NativeOptions {
338        viewport: egui::ViewportBuilder::default()
339            // How a Wayland compositor finds the window's icon and its name: it
340            // matches this against the basename of the `.desktop` entry.
341            .with_app_id(id)
342            .with_title(id)
343            .with_inner_size([1000.0, 760.0])
344            .with_min_inner_size([420.0, 320.0]),
345        ..eframe::NativeOptions::default()
346    };
347
348    let first = std::env::args_os().nth(1).map(PathBuf::from);
349    eframe::run_native(
350        id,
351        options,
352        Box::new(move |cc| {
353            crate::system_theme::follow(&cc.egui_ctx);
354            let mut shell = Shell::new(product, build(&cc.egui_ctx));
355            if let Some(path) = first {
356                shell.open(&cc.egui_ctx, &path);
357            }
358            Ok(Box::new(shell))
359        }),
360    )
361}