use std::path::{Path, PathBuf};
use eframe::egui::{self, Key, KeyboardShortcut, Modifiers, Ui};
use crate::i18n::{fill, t};
pub struct Product {
pub id: &'static str,
pub extension: &'static str,
pub format: &'static str,
}
pub trait Viewer {
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 central(&mut self, ui: &mut Ui, zoom: f32);
fn side(&mut self, _ui: &mut Ui) -> bool {
false
}
fn view_menu(&mut self, _ui: &mut Ui) {}
}
pub struct Shell<V: Viewer> {
view: V,
product: Product,
path: Option<PathBuf>,
error: Option<String>,
zoom: f32,
show_side: bool,
}
const ZOOM_STEP: f32 = 1.1;
const ZOOM_MIN: f32 = 0.4;
const ZOOM_MAX: f32 = 4.0;
impl<V: Viewer> Shell<V> {
pub fn new(product: Product, view: V) -> Self {
Self {
view,
product,
path: None,
error: None,
zoom: 1.0,
show_side: true,
}
}
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;
}
Err(message) => {
self.view.close();
self.path = None;
self.error = Some(message);
}
},
Err(e) => {
self.error = Some(fill(
t("{file} could not be read: {reason}"),
&[("file", &name_of(path)), ("reason", &e.to_string())],
));
}
}
}
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 reload(&mut self, ctx: &egui::Context) {
if let Some(path) = self.path.clone() {
self.open(ctx, &path);
}
}
fn close(&mut self) {
self.view.close();
self.path = None;
self.error = None;
}
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));
match document {
Some(name) => format!("{name} — {}", self.product.id),
None => self.product.id.to_owned(),
}
}
}
impl<V: Viewer> eframe::App for Shell<V> {
fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
self.keys(&ctx);
self.dropped_files(&ctx);
ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title()));
egui::Panel::top("menu").show(ui, |ui| {
egui::MenuBar::new().ui(ui, |ui| {
ui.menu_button(t("File"), |ui| {
if ui.button(t("Open…")).clicked() {
ui.close();
self.ask_for_a_file(&ctx);
}
let open = self.path.is_some();
if ui
.add_enabled(open, egui::Button::new(t("Reload")))
.clicked()
{
ui.close();
self.reload(&ctx);
}
if ui
.add_enabled(open, egui::Button::new(t("Close")))
.clicked()
{
ui.close();
self.close();
}
ui.separator();
if ui.button(t("Quit")).clicked() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
});
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 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;
}
}
egui::CentralPanel::default_margins().show(ui, |ui| {
if self.view.is_open() {
self.view.central(ui, self.zoom);
} else {
self.nothing_open(ui);
}
});
}
}
impl<V: Viewer> Shell<V> {
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);
}
ui.add_space(8.0);
ui.weak(t("or drop one on this window"));
});
}
fn keys(&mut self, ctx: &egui::Context) {
let pressed = |key| {
ctx.input_mut(|input| {
input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, key))
})
};
if pressed(Key::O) {
self.ask_for_a_file(ctx);
}
if pressed(Key::R) {
self.reload(ctx);
}
if pressed(Key::W) {
self.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 dropped_files(&mut self, ctx: &egui::Context) {
let dropped = ctx.input(|input| {
input
.raw
.dropped_files
.first()
.map(|file| file.path().to_path_buf())
});
if let Some(path) = dropped {
self.open(ctx, &path);
}
}
}
fn name_of(path: &Path) -> String {
path.file_name().map_or_else(
|| path.display().to_string(),
|name| name.to_string_lossy().into_owned(),
)
}
pub fn run<V: Viewer + 'static>(
product: Product,
build: impl FnOnce(&egui::Context) -> V + 'static,
) -> eframe::Result {
crate::i18n::start();
let id = product.id;
let options = eframe::NativeOptions {
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]),
..eframe::NativeOptions::default()
};
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);
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))
}),
)
}