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}