1use 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
22pub struct Product {
27 pub id: &'static str,
31 pub extension: &'static str,
33 pub format: &'static str,
39 pub icon: &'static [u8],
48}
49
50pub trait View {
52 fn open(&mut self, ctx: &egui::Context, bytes: &[u8], path: &Path) -> Result<(), String>;
63
64 fn close(&mut self);
66
67 fn is_open(&self) -> bool;
69
70 fn title(&self) -> Option<String>;
72
73 fn document_mut(&mut self) -> Option<&mut Document>;
75
76 fn reindex(&mut self) {}
79
80 fn caret(&self) -> Option<Caret> {
83 None
84 }
85
86 fn restore_caret(&mut self, _caret: Caret) {}
89
90 fn menus(&mut self, _ui: &mut Ui, _editing: &Editing) {}
94
95 fn presenting(&self) -> bool {
99 false
100 }
101
102 fn find(&mut self, _query: &str) -> usize {
107 0
108 }
109
110 fn can_replace(&self, _editing: &Editing) -> bool {
113 false
114 }
115
116 fn replace(&mut self, _with: &str, _all: bool, _editing: &mut Editing) -> Replaced {
119 Replaced::default()
120 }
121
122 fn show_match(&mut self, _index: usize) {}
124
125 fn central(&mut self, ui: &mut Ui, zoom: f32, editing: &mut Editing);
129
130 fn side(&mut self, _ui: &mut Ui) -> bool {
133 false
134 }
135
136 fn view_menu(&mut self, _ui: &mut Ui) {}
138
139 fn file_menu(&mut self, _ui: &mut Ui, _path: Option<&Path>) {}
143
144 fn take_notice(&mut self) -> Option<Notice> {
147 None
148 }
149}
150
151pub enum Notice {
153 Done(String),
155 Problem(String),
157}
158
159pub struct Shell<V: View> {
161 view: V,
162 product: Product,
163 path: Option<PathBuf>,
164 error: Option<String>,
165 notice: Option<String>,
167 settling: bool,
171 zoom: f32,
172 show_side: bool,
173 editing: Editing,
174 settings: Settings,
175 find: FindBar,
176 screen: Screen,
178 pending: Option<Pending>,
181 closing: bool,
184}
185
186#[derive(Clone, Copy, PartialEq, Eq)]
188enum Screen {
189 Window,
190 Full,
191}
192
193#[derive(Clone, Copy)]
196enum Pending {
197 AskForAFile,
198 Reload,
199 Close,
200 Quit,
201}
202
203const ZOOM_STEP: f32 = 1.1;
205const ZOOM_MIN: f32 = 0.4;
206const ZOOM_MAX: f32 = 4.0;
207
208impl<V: View> Shell<V> {
209 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 } 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 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 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 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 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 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 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 fn nothing_open(&mut self, ui: &mut Ui) {
824 ui.vertical_centered(|ui| {
825 ui.add_space(ui.available_height() * 0.3);
826 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 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 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 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
919fn 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
927pub 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 let _ = std::fs::remove_file(&temporary);
968 }
969 written
970 }
971}
972
973pub 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 .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 #[cfg(target_os = "macos")]
1014 let viewport = viewport.with_icon(egui::IconData::default());
1015
1016 #[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 #[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 #[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#[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 #[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 #[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}