use std::path::{Path, PathBuf};
use eframe::egui::{self, Key, KeyboardShortcut, Modifiers, Ui};
use odox_core::Document;
use crate::edit::{Caret, Editing};
use crate::find_bar::{FindBar, Step};
use crate::i18n::{fill, t};
use crate::settings::Settings;
pub struct Product {
pub id: &'static str,
pub extension: &'static str,
pub format: &'static str,
pub icon: &'static [u8],
}
pub trait View {
fn open(&mut self, ctx: &egui::Context, bytes: &[u8], path: &Path) -> Result<(), String>;
fn close(&mut self);
fn is_open(&self) -> bool;
fn title(&self) -> Option<String>;
fn document_mut(&mut self) -> Option<&mut Document>;
fn reindex(&mut self) {}
fn caret(&self) -> Option<Caret> {
None
}
fn restore_caret(&mut self, _caret: Caret) {}
fn presenting(&self) -> bool {
false
}
fn find(&mut self, _query: &str) -> usize {
0
}
fn show_match(&mut self, _index: usize) {}
fn central(&mut self, ui: &mut Ui, zoom: f32, editing: &mut Editing);
fn side(&mut self, _ui: &mut Ui) -> bool {
false
}
fn view_menu(&mut self, _ui: &mut Ui) {}
}
pub struct Shell<V: View> {
view: V,
product: Product,
path: Option<PathBuf>,
error: Option<String>,
settling: bool,
zoom: f32,
show_side: bool,
editing: Editing,
settings: Settings,
find: FindBar,
screen: Screen,
pending: Option<Pending>,
closing: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Screen {
Window,
Full,
}
#[derive(Clone, Copy)]
enum Pending {
AskForAFile,
Reload,
Close,
Quit,
}
const ZOOM_STEP: f32 = 1.1;
const ZOOM_MIN: f32 = 0.4;
const ZOOM_MAX: f32 = 4.0;
impl<V: View> Shell<V> {
pub fn new(product: Product, view: V) -> Self {
Self {
view,
product,
path: None,
error: None,
settling: false,
zoom: 1.0,
show_side: true,
editing: Editing::default(),
settings: Settings::load(),
find: FindBar::default(),
screen: Screen::Window,
pending: None,
closing: false,
}
}
pub fn open(&mut self, ctx: &egui::Context, path: &Path) {
match std::fs::read(path) {
Ok(bytes) => match self.view.open(ctx, &bytes, path) {
Ok(()) => {
self.path = Some(path.to_path_buf());
self.error = None;
self.settling = true;
self.editing.reset();
self.editing.on = self.settings.open_in_edit_mode;
}
Err(message) => {
self.view.close();
self.path = None;
self.error = Some(message);
self.editing.reset();
}
},
Err(e) => {
self.error = Some(fill(
t("{file} could not be read: {reason}"),
&[("file", &name_of(path)), ("reason", &e.to_string())],
));
}
}
}
fn request(&mut self, ctx: &egui::Context, action: Pending) {
if self.editing.modified() {
self.pending = Some(action);
} else {
self.perform(ctx, action);
}
}
fn perform(&mut self, ctx: &egui::Context, action: Pending) {
match action {
Pending::AskForAFile => self.ask_for_a_file(ctx),
Pending::Reload => {
if let Some(path) = self.path.clone() {
self.open(ctx, &path);
}
}
Pending::Close => self.close(),
Pending::Quit => {
self.closing = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
fn ask_for_a_file(&mut self, ctx: &egui::Context) {
let file = rfd::FileDialog::new()
.add_filter(t(self.product.format), &[self.product.extension])
.pick_file();
if let Some(path) = file {
self.open(ctx, &path);
}
}
fn close(&mut self) {
self.view.close();
self.path = None;
self.error = None;
self.editing.reset();
}
fn save(&mut self) -> bool {
match self.path.clone() {
Some(path) => self.write_to(&path),
None => self.save_as(),
}
}
fn save_as(&mut self) -> bool {
let mut dialog =
rfd::FileDialog::new().add_filter(t(self.product.format), &[self.product.extension]);
if let Some(path) = &self.path {
dialog = dialog.set_file_name(name_of(path));
if let Some(directory) = path.parent() {
dialog = dialog.set_directory(directory);
}
}
let Some(mut path) = dialog.save_file() else {
return false;
};
if path.extension().is_none() {
path.set_extension(self.product.extension);
}
if self.write_to(&path) {
self.path = Some(path);
true
} else {
false
}
}
fn write_to(&mut self, path: &Path) -> bool {
let Some(document) = self.view.document_mut() else {
return false;
};
let written = document
.write_verified()
.map_err(|e| e.to_string())
.and_then(|bytes| replace_file(path, &bytes).map_err(|e| e.to_string()));
match written {
Ok(()) => {
self.editing.mark_saved();
self.error = None;
true
}
Err(reason) => {
self.error = Some(fill(
t("{file} could not be saved: {reason}"),
&[("file", &name_of(path)), ("reason", &reason)],
));
false
}
}
}
fn undo(&mut self) {
let caret = self.view.caret();
if let Some(document) = self.view.document_mut()
&& let Some((previous, caret)) = self.editing.undo(&document.content, caret)
{
document.content = previous;
self.after_history(caret);
}
}
fn redo(&mut self) {
let caret = self.view.caret();
if let Some(document) = self.view.document_mut()
&& let Some((next, caret)) = self.editing.redo(&document.content, caret)
{
document.content = next;
self.after_history(caret);
}
}
fn after_history(&mut self, caret: Option<Caret>) {
self.view.reindex();
if let Some(caret) = caret {
self.view.restore_caret(caret);
}
}
fn window_title(&self) -> String {
let document = self
.view
.title()
.filter(|title| !title.trim().is_empty())
.or_else(|| self.path.as_deref().map(name_of));
let mark = if self.editing.modified() { "● " } else { "" };
match document {
Some(name) => format!("{mark}{name} — {}", self.product.id),
None => self.product.id.to_owned(),
}
}
fn ask_about_changes(&mut self, ctx: &egui::Context) {
if self.pending.is_none() {
return;
}
let file = self.path.as_deref().map(name_of).unwrap_or_default();
let mut answer = None;
egui::Modal::new(egui::Id::new("unsaved")).show(ctx, |ui| {
ui.heading(fill(t("Save changes to {file}?"), &[("file", &file)]));
ui.label(t("The document has changes that are not saved."));
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button(t("Save")).clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
answer = Some(Some(true));
}
if ui.button(t("Discard")).clicked() {
answer = Some(Some(false));
}
if ui.button(t("Cancel")).clicked() || ui.input(|i| i.key_pressed(Key::Escape)) {
answer = Some(None);
}
});
});
let Some(answer) = answer else {
return;
};
let Some(action) = self.pending.take() else {
return;
};
match answer {
Some(true) => {
if self.save() {
self.perform(ctx, action);
}
}
Some(false) => self.perform(ctx, action),
None => {}
}
}
}
impl<V: View> eframe::App for Shell<V> {
fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
if ctx.input(|i| i.viewport().close_requested()) && !self.closing && self.editing.modified()
{
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.pending = Some(Pending::Quit);
}
let presenting = self.view.presenting();
let screen = if presenting {
Screen::Full
} else {
Screen::Window
};
if screen != self.screen {
self.screen = screen;
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(presenting));
}
self.editing.asking = self.pending.is_some();
let field_focused = ctx
.memory(egui::Memory::focused)
.is_some_and(|id| id != crate::flow_model::page_editor_id());
if self.pending.is_none() && !presenting {
self.find_keys(&ctx);
}
if self.pending.is_none() && !field_focused && !presenting {
self.keys(&ctx);
}
#[cfg(target_os = "macos")]
if let Some(path) = crate::opened_document::taken() {
self.open(&ctx, &path);
}
ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title()));
if !presenting {
self.chrome(ui, &ctx);
}
let settling = self.settling;
if settling {
self.settling = false;
ctx.request_repaint();
}
let central = if presenting {
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK))
} else {
egui::CentralPanel::default_margins()
};
central.show(ui, |ui| {
if settling {
} else if self.view.is_open() {
self.view.central(ui, self.zoom, &mut self.editing);
} else {
self.nothing_open(ui);
}
});
self.ask_about_changes(&ctx);
}
}
impl<V: View> Shell<V> {
fn chrome(&mut self, ui: &mut Ui, ctx: &egui::Context) {
egui::Panel::top("menu").show(ui, |ui| self.menu_bar(ui, ctx));
self.search(ui);
if let Some(message) = self.error.clone() {
egui::Panel::bottom("error").show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.colored_label(ui.visuals().error_fg_color, "\u{26a0}");
ui.label(message);
if ui.button(t("Dismiss")).clicked() {
self.error = None;
}
});
});
}
if self.show_side && self.view.is_open() {
let mut drew = false;
egui::Panel::left("side")
.resizable(true)
.default_size(220.0)
.min_size(120.0)
.show(ui, |ui| {
egui::ScrollArea::both().show(ui, |ui| {
drew = self.view.side(ui);
});
});
if !drew {
self.show_side = false;
}
}
}
fn menu_bar(&mut self, ui: &mut Ui, ctx: &egui::Context) {
egui::MenuBar::new().ui(ui, |ui| {
ui.menu_button(t("File"), |ui| {
if ui.button(t("Open…")).clicked() {
ui.close();
self.request(ctx, Pending::AskForAFile);
}
let open = self.path.is_some();
if ui
.add_enabled(open, egui::Button::new(t("Reload")))
.clicked()
{
ui.close();
self.request(ctx, Pending::Reload);
}
if ui
.add_enabled(open, egui::Button::new(t("Close")))
.clicked()
{
ui.close();
self.request(ctx, Pending::Close);
}
ui.separator();
let modified = open && self.editing.modified();
if ui
.add_enabled(modified, egui::Button::new(t("Save")))
.clicked()
{
ui.close();
self.save();
}
if ui
.add_enabled(open, egui::Button::new(t("Save as…")))
.clicked()
{
ui.close();
self.save_as();
}
ui.separator();
if ui.button(t("Quit")).clicked() {
ui.close();
self.request(ctx, Pending::Quit);
}
});
ui.menu_button(t("Edit"), |ui| self.edit_menu(ui));
ui.menu_button(t("View"), |ui| {
if ui.button(t("Zoom in")).clicked() {
self.zoom = (self.zoom * ZOOM_STEP).min(ZOOM_MAX);
}
if ui.button(t("Zoom out")).clicked() {
self.zoom = (self.zoom / ZOOM_STEP).max(ZOOM_MIN);
}
if ui.button(t("Actual size")).clicked() {
self.zoom = 1.0;
}
ui.separator();
ui.checkbox(&mut self.show_side, t("Show the side panel"));
self.view.view_menu(ui);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let percent = (self.zoom * 100.0).round() as u32;
ui.label(fill(t("{percent}%"), &[("percent", &percent.to_string())]));
if self.editing.on && self.view.is_open() {
ui.separator();
ui.strong(t("Editing"));
}
});
});
}
fn edit_menu(&mut self, ui: &mut Ui) {
if ui
.add_enabled(self.editing.can_undo(), egui::Button::new(t("Undo")))
.clicked()
{
ui.close();
self.undo();
}
if ui
.add_enabled(self.editing.can_redo(), egui::Button::new(t("Redo")))
.clicked()
{
ui.close();
self.redo();
}
ui.separator();
if ui
.add_enabled(self.view.is_open(), egui::Button::new(t("Find…")))
.clicked()
{
ui.close();
self.find.open();
}
ui.separator();
ui.checkbox(&mut self.editing.on, t("Edit mode"));
if ui
.checkbox(
&mut self.settings.open_in_edit_mode,
t("Open documents in edit mode"),
)
.changed()
&& let Err(e) = self.settings.save()
{
self.error = Some(fill(
t("The setting could not be saved: {reason}"),
&[("reason", &e.to_string())],
));
}
}
fn search(&mut self, ui: &mut Ui) {
if !self.find.is_open() || !self.view.is_open() {
if self.find.searching {
self.find.searching = false;
self.find.count = 0;
self.view.find("");
}
return;
}
let mut step = None;
egui::Panel::top("find").show(ui, |ui| step = self.find.ui(ui));
if !self.find.is_open() {
return;
}
if let Some(query_changed) = self.find.due(self.editing.revision()) {
self.find.searching = true;
self.find.count = self.view.find(self.find.query());
if query_changed {
self.find.current = 0;
if self.find.count > 0 {
self.view.show_match(0);
}
} else {
self.find.current = self.find.current.min(self.find.count.saturating_sub(1));
}
}
if let Some(step) = step {
self.step_match(step);
}
}
fn step_match(&mut self, step: Step) {
let count = self.find.count;
if count == 0 {
return;
}
self.find.current = match step {
Step::Next => (self.find.current + 1) % count,
Step::Previous => (self.find.current + count - 1) % count,
};
self.view.show_match(self.find.current);
}
fn nothing_open(&mut self, ui: &mut Ui) {
ui.vertical_centered(|ui| {
ui.add_space(ui.available_height() * 0.3);
ui.heading(self.product.id);
ui.label(t(self.product.format));
ui.add_space(12.0);
if ui.button(t("Open a document…")).clicked() {
let ctx = ui.ctx().clone();
self.ask_for_a_file(&ctx);
}
});
}
fn find_keys(&mut self, ctx: &egui::Context) {
if !self.view.is_open() {
return;
}
let pressed = |modifiers, key| {
ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(modifiers, key)))
};
if pressed(Modifiers::COMMAND, Key::F) {
self.find.open();
}
if pressed(Modifiers::SHIFT, Key::F3) {
self.step_match(Step::Previous);
}
if pressed(Modifiers::NONE, Key::F3) {
if self.find.is_open() {
self.step_match(Step::Next);
} else {
self.find.open();
}
}
}
fn keys(&mut self, ctx: &egui::Context) {
let pressed = |key| {
ctx.input_mut(|input| {
input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, key))
})
};
let shifted = |key| {
ctx.input_mut(|input| {
input.consume_shortcut(&KeyboardShortcut::new(
Modifiers::COMMAND | Modifiers::SHIFT,
key,
))
})
};
if shifted(Key::S) {
self.save_as();
}
if shifted(Key::Z) || pressed(Key::Y) {
self.redo();
}
if pressed(Key::S) && self.editing.modified() {
self.save();
}
if pressed(Key::Z) {
self.undo();
}
if pressed(Key::E) && self.view.is_open() {
self.editing.on = !self.editing.on;
}
if pressed(Key::O) {
self.request(ctx, Pending::AskForAFile);
}
if pressed(Key::R) {
self.request(ctx, Pending::Reload);
}
if pressed(Key::W) {
self.request(ctx, Pending::Close);
}
if pressed(Key::Plus) || pressed(Key::Equals) {
self.zoom = (self.zoom * ZOOM_STEP).min(ZOOM_MAX);
}
if pressed(Key::Minus) {
self.zoom = (self.zoom / ZOOM_STEP).max(ZOOM_MIN);
}
if pressed(Key::Num0) {
self.zoom = 1.0;
}
}
}
fn name_of(path: &Path) -> String {
path.file_name().map_or_else(
|| path.display().to_string(),
|name| name.to_string_lossy().into_owned(),
)
}
fn replace_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
{
std::fs::write(path, bytes)
}
#[cfg(not(target_os = "macos"))]
{
use std::io::Write as _;
let temporary = path.with_file_name(format!("{}.part", name_of(path)));
let mut file = std::fs::File::create(&temporary)?;
let written = file
.write_all(bytes)
.and_then(|()| file.sync_all())
.and_then(|()| match std::fs::metadata(path) {
Ok(existing) => std::fs::set_permissions(&temporary, existing.permissions()),
Err(_) => Ok(()),
})
.and_then(|()| std::fs::rename(&temporary, path));
if written.is_err() {
let _ = std::fs::remove_file(&temporary);
}
written
}
}
pub fn run<V: View + 'static>(
product: Product,
build: impl FnOnce(&egui::Context) -> V + 'static,
) -> eframe::Result {
crate::i18n::start();
let id = product.id;
let viewport = egui::ViewportBuilder::default()
.with_app_id(id)
.with_title(id)
.with_inner_size([1000.0, 760.0])
.with_min_inner_size([420.0, 320.0]);
#[cfg(target_os = "macos")]
let viewport = viewport.with_icon(egui::IconData::default());
#[cfg(target_os = "windows")]
let viewport = match window_icon(product.icon) {
Some(icon) => viewport.with_icon(icon),
None => viewport,
};
let options = eframe::NativeOptions {
viewport,
..eframe::NativeOptions::default()
};
#[cfg(target_os = "macos")]
crate::opened_document::watch();
let first = std::env::args_os().nth(1).map(PathBuf::from);
eframe::run_native(
id,
options,
Box::new(move |cc| {
crate::system_theme::follow(&cc.egui_ctx);
#[cfg(target_os = "macos")]
crate::opened_document::wake_with(&cc.egui_ctx);
let mut shell = Shell::new(product, build(&cc.egui_ctx));
if let Some(path) = first {
shell.open(&cc.egui_ctx, &path);
}
Ok(Box::new(shell))
}),
)
}
#[cfg(target_os = "windows")]
fn window_icon(bytes: &'static [u8]) -> Option<egui::IconData> {
if bytes.is_empty() {
return None;
}
let directory = ico::IconDir::read(std::io::Cursor::new(bytes)).ok()?;
let entry = directory
.entries()
.iter()
.find(|entry| entry.width() == 64)
.or_else(|| directory.entries().last())?;
let image = entry.decode().ok()?;
Some(egui::IconData {
width: image.width(),
height: image.height(),
rgba: image.rgba_data().to_vec(),
})
}
#[cfg(test)]
mod tests {
use super::replace_file;
#[test]
fn a_file_is_replaced_whole() {
let directory = std::env::temp_dir().join(format!("odox-replace-{}", std::process::id()));
std::fs::create_dir_all(&directory).expect("a scratch directory");
let path = directory.join("document.odt");
std::fs::write(&path, b"before").expect("the original");
let mode = std::fs::metadata(&path)
.expect("its metadata")
.permissions();
replace_file(&path, b"after").expect("the replacement");
assert_eq!(std::fs::read(&path).expect("the file"), b"after");
assert_eq!(
std::fs::metadata(&path)
.expect("its metadata")
.permissions(),
mode
);
let left: Vec<_> = std::fs::read_dir(&directory)
.expect("the directory")
.filter_map(Result::ok)
.map(|entry| entry.file_name())
.collect();
assert_eq!(left, vec![std::ffi::OsString::from("document.odt")]);
std::fs::remove_dir_all(directory).expect("tidy");
}
#[test]
fn a_failed_write_leaves_no_part_file() {
let directory = std::env::temp_dir().join(format!("odox-refuse-{}", std::process::id()));
std::fs::create_dir_all(&directory).expect("a scratch directory");
let missing = directory.join("nowhere").join("document.odt");
assert!(replace_file(&missing, b"x").is_err());
assert_eq!(
std::fs::read_dir(&directory)
.expect("the directory")
.count(),
0
);
std::fs::remove_dir_all(directory).expect("tidy");
}
}