1use std::path::{Path, PathBuf};
8
9use eframe::egui::{self, Key, KeyboardShortcut, Modifiers, Ui};
10
11use crate::i18n::{fill, t};
12
13pub struct Product {
18 pub id: &'static str,
22 pub extension: &'static str,
24 pub format: &'static str,
30}
31
32pub trait Viewer {
34 fn open(&mut self, ctx: &egui::Context, bytes: &[u8], path: &Path) -> Result<(), String>;
45
46 fn close(&mut self);
48
49 fn is_open(&self) -> bool;
51
52 fn title(&self) -> Option<String>;
54
55 fn central(&mut self, ui: &mut Ui, zoom: f32);
58
59 fn side(&mut self, _ui: &mut Ui) -> bool {
62 false
63 }
64
65 fn view_menu(&mut self, _ui: &mut Ui) {}
67}
68
69pub 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
79const ZOOM_STEP: f32 = 1.1;
81const ZOOM_MIN: f32 = 0.4;
82const ZOOM_MAX: f32 = 4.0;
83
84impl<V: Viewer> Shell<V> {
85 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 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 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 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 fn nothing_open(&mut self, ui: &mut Ui) {
257 ui.vertical_centered(|ui| {
258 ui.add_space(ui.available_height() * 0.3);
259 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 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
316fn 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
324pub 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 .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}