use std::sync::{Arc, OnceLock};
use anyhow::Result;
use clap::Parser;
use editors::corpus_tree::CorpusTree;
use editors::spans::SpanEditor;
use eframe::IntegrationInfo;
use egui::{Button, Color32, FontData, Key, KeyboardShortcut, MenuBar, Modifiers, RichText, Theme};
use egui_file_dialog::{FileDialog, OpeningMode};
use graphannis::update::GraphUpdate;
use job_executor::JobExecutor;
use lazy_static::lazy_static;
use log::debug;
use messages::Notifier;
use project::Project;
use serde::{Deserialize, Serialize};
use views::Editor;
use crate::app::{job_executor::Job, project::ChangeSet};
pub(crate) mod actions;
pub(crate) mod data;
mod editors;
pub(crate) mod job_executor;
mod messages;
mod project;
#[cfg(test)]
mod tests;
pub(crate) mod util;
mod views;
pub(crate) mod widgets;
pub(crate) const APP_ID: &str = "annatomic";
pub const QUIT_SHORTCUT: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::Q);
pub const SAVE_SHORTCUT: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::S);
pub const UNDO_SHORTCUT: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::Z);
pub const REDO_SHORTCUT: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::Y);
pub const GO_BACK_SHORTCUT: KeyboardShortcut =
KeyboardShortcut::new(Modifiers::ALT, Key::ArrowLeft);
pub const CHANGE_PENDING_COLOR_DARK: Color32 = Color32::from_rgb(160, 50, 50);
pub const CHANGE_PENDING_COLOR_LIGHT: Color32 = Color32::from_rgb(255, 128, 128);
lazy_static! {
static ref OPEN_LABEL: String = format!("Open {}", egui_phosphor::regular::ARROW_RIGHT);
}
#[derive(Default, serde::Deserialize, serde::Serialize, Clone, PartialEq, Debug)]
pub(crate) enum MainView {
#[default]
Start,
Import,
Export,
CorpusStructure,
EditDocument {
node_name: String,
},
}
#[derive(Parser, Debug, Default, Serialize, Deserialize)]
pub struct AnnatomicArgs {
#[arg(long)]
dev: bool,
}
#[derive(Default)]
enum ShutdownRequest {
#[default]
None,
Requested,
ShutdownIsSafe,
}
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub struct AnnatomicApp {
main_view: MainView,
new_corpus_name: String,
project: Project,
#[serde(skip)]
current_editor: OnceLock<Box<dyn Editor>>,
#[serde(skip)]
last_selected_node: Option<String>,
#[serde(skip)]
shutdown_request: ShutdownRequest,
#[serde(skip)]
jobs: JobExecutor,
#[serde(skip)]
notifier: Notifier,
#[serde(skip)]
args: AnnatomicArgs,
#[serde(skip)]
file_dialog: FileDialog,
}
impl Default for AnnatomicApp {
fn default() -> Self {
let notifier = Notifier::default();
let jobs = JobExecutor::default();
let project = Project::new(notifier.clone(), jobs.clone());
Self {
main_view: MainView::Start,
project,
jobs,
notifier,
new_corpus_name: String::default(),
last_selected_node: None,
args: AnnatomicArgs::default(),
current_editor: OnceLock::new(),
shutdown_request: ShutdownRequest::None,
file_dialog: FileDialog::new().opening_mode(OpeningMode::LastVisitedDir),
}
}
}
pub(crate) fn set_fonts(ctx: &egui::Context) {
let mut defs = egui::FontDefinitions::default();
defs.font_data.insert(
"NotoEmoji-Regular".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../assets/Noto_Emoji/static/NotoEmoji-Regular.ttf"
))),
);
defs.font_data.insert(
"NotoSansMath-Regular".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../assets/Noto_Sans_Math/NotoSansMath-Regular.ttf"
))),
);
defs.font_data.insert(
"NotoSansSymbols2-Regular".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../assets/Noto_Sans_Symbols_2/NotoSansSymbols2-Regular.ttf"
))),
);
defs.font_data.insert(
"NotoSans-Regular".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../assets/Noto_Sans/static/NotoSans-Regular.ttf"
))),
);
defs.font_data.insert(
"NotoSansMono-Regular".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../assets/Noto_Sans_Mono/static/NotoSansMono-Regular.ttf"
))),
);
defs.families.insert(
egui::FontFamily::Proportional,
vec![
"NotoSans-Regular".to_owned(),
"NotoEmoji-Regular".to_owned(),
"NotoSansMath-Regular".to_owned(),
"NotoSansSymbols2-Regular".to_owned(),
],
);
defs.families.insert(
egui::FontFamily::Monospace,
vec!["NotoSansMono-Regular".to_owned()],
);
egui_phosphor::add_to_fonts(&mut defs, egui_phosphor::Variant::Regular);
ctx.set_fonts(defs);
}
impl AnnatomicApp {
pub fn new(cc: &eframe::CreationContext<'_>, args: AnnatomicArgs) -> Result<Self> {
set_fonts(&cc.egui_ctx);
let mut app = if let Some(storage) = cc.storage {
let mut app_from_storage: AnnatomicApp =
eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default();
app_from_storage.args = args;
app_from_storage
} else {
Self {
args,
..Default::default()
}
};
app.project
.load_after_init(app.notifier.clone(), app.jobs.clone())?;
app.project.cleanup_unused_corpus_files_in_background()?;
Ok(app)
}
pub(crate) fn change_view(&mut self, new_view: MainView) {
if self.main_view != new_view {
self.main_view = new_view;
self.reset_editor();
}
}
fn reset_editor(&mut self) {
if let Some(old_editor) = self.current_editor.take() {
self.last_selected_node = old_editor.get_selected_nodes().first().cloned();
}
}
pub(crate) fn load_editor_if_necessary(&mut self) {
match &self.main_view {
MainView::Start | MainView::Import | MainView::Export => {
self.current_editor = OnceLock::new();
}
MainView::CorpusStructure => {
if let Some(corpus) = &self.project.selected_corpus {
let job_title = "Creating corpus tree editor";
if self.current_editor.get().is_none()
&& !self.jobs.has_job_with_title(job_title)
{
self.current_editor.take();
let corpus_cache = self.project.corpus_cache.clone();
let jobs = self.jobs.clone();
let notifier = self.notifier.clone();
let location = corpus.location.clone();
let root_corpus_name = self
.project
.selected_corpus
.as_ref()
.map(|c| c.name.clone())
.unwrap_or_default();
let last_selected_node = self.last_selected_node.clone();
self.jobs.add_foreground_job(
job_title,
move |job| {
job.update_message("Loading corpus");
let graph = corpus_cache.get(&location)?;
let last_selected_node = if let Some(node_name) = last_selected_node
{
let graph = graph.read();
graph.get_node_annos().get_node_id_from_name(&node_name)?
} else {
None
};
job.update_message("Loading corpus graph");
let corpus_tree = CorpusTree::create_from_graph(
root_corpus_name,
graph,
last_selected_node,
jobs,
notifier,
)?;
Ok(corpus_tree)
},
|corpus_tree, app| {
app.current_editor.get_or_init(|| Box::new(corpus_tree));
},
);
}
} else {
self.current_editor = OnceLock::new();
}
}
MainView::EditDocument { node_name } => {
if let Some(corpus) = &self.project.selected_corpus {
let job_title = "Creating document editor";
if self.current_editor.get().is_none()
&& !self.jobs.has_job_with_title(job_title)
{
self.current_editor.take();
let corpus_cache = self.project.corpus_cache.clone();
let location = corpus.location.clone();
let notifier = self.notifier.clone();
let node_name = node_name.clone();
self.jobs.add_foreground_job(
job_title,
move |job| {
job.update_message("Loading corpus");
let graph = corpus_cache.get(&location)?;
job.update_message("Loading document");
let start_time = std::time::Instant::now();
let document_editor = SpanEditor::create_from_graph(
node_name, true, graph, notifier,
)?;
debug!(
"Creating document editor took {} ms",
start_time.elapsed().as_millis()
);
Ok(document_editor)
},
|document_editor, app| {
app.current_editor.get_or_init(|| Box::new(document_editor));
},
);
}
}
}
}
}
fn handle_corpus_confirmation_dialog(&mut self, ctx: &egui::Context) {
if self.project.scheduled_for_deletion.is_some() {
egui::Modal::new("corpus_deletion_confirmation".into()).show(ctx, |ui| {
let corpus_name = self
.project
.scheduled_for_deletion
.clone()
.unwrap_or_default();
ui.horizontal(|ui| {
ui.label(RichText::new(egui_phosphor::regular::WARNING).color(Color32::ORANGE).size(32.0));
ui.label(format!("Are you sure to delete the corpus \"{corpus_name}\" permanently? This can not be undone."));
});
ui.separator();
ui.horizontal(|ui| {
if ui
.button(RichText::new("Do not delete the corpus.").color(Color32::BLUE))
.clicked()
{
self.project.scheduled_for_deletion = None;
}
ui.add_space(5.0);
if ui
.button(
RichText::new(format!("Delete \"{corpus_name}\" permanently"))
.color(Color32::RED),
)
.clicked()
{
self.project.delete_corpus(corpus_name);
}
});
});
}
}
pub(crate) fn select_corpus(&mut self, selection: Option<String>) {
self.project.select_corpus(selection);
self.reset_editor();
}
pub(crate) fn current_typed_editor<E: Editor + 'static>(&mut self) -> Option<&mut E> {
if let Some(editor) = self.current_editor.get_mut() {
editor.any_mut().downcast_mut()
} else {
None
}
}
fn apply_pending_updates(&mut self) -> Result<()> {
if let Some(editor) = self.current_editor.get_mut()
&& let Some(graph) = &self.project.get_selected_graph()?
&& editor.has_pending_updates()
{
let graph = graph.read();
let pending_actions = editor.take_pending_updates();
let mut changesets = Vec::new();
for action in pending_actions {
let mut change = ChangeSet::default();
if let Some(editor) = self.current_editor.get() {
change.editor_state_updates = editor.editor_state_updates(&action)?;
}
let changeset_from_action = action.create_changeset(&graph)?;
change.updates = changeset_from_action.updates;
change.reverse_updates = changeset_from_action.reverse_updates;
changesets.push(change);
}
if !changesets.is_empty() {
self.add_changesets(changesets);
}
}
Ok(())
}
pub(crate) fn add_changesets(&mut self, changesets: Vec<ChangeSet>) {
if let Some(selected_corpus) = self.project.selected_corpus.clone() {
for changes in changesets {
if let Some(state_update_before) = changes.editor_state_updates.before {
state_update_before(self)
}
let corpus_cache = self.project.corpus_cache.clone();
let selected_corpus = selected_corpus.clone();
let worker = move |job: Job| {
job.update_message("Storing update events");
let mut update = GraphUpdate::new();
for c in &changes.updates {
update.add_event(c.clone())?;
}
job.update_message("Loading corpus if necessary");
let graph = corpus_cache.get(&selected_corpus.location)?;
job.update_message("Applying updates");
let mut graph = graph.write();
graph.apply_update_keep_statistics(&mut update, |msg| {
job.update_message(format!("Applying updates: {msg}"))
})?;
let changeset = ChangeSet::from((changes.updates, changes.reverse_updates));
Ok(changeset)
};
if let Some(state_update_after) = changes.editor_state_updates.after {
self.jobs
.add_foreground_job("Updating corpus", worker, |changeset, app| {
app.project.add_checkpoint(changeset);
state_update_after(app);
});
} else {
self.jobs
.add_background_job("Updating corpus", worker, |changeset, app| {
app.project.add_checkpoint(changeset);
});
}
}
}
}
fn consume_shortcuts(&mut self, ctx: &egui::Context) {
if let Some(editor) = self.current_editor.get_mut() {
editor.consume_shortcuts(ctx);
}
if ctx.input_mut(|i| i.consume_shortcut(&QUIT_SHORTCUT)) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
if ctx.input_mut(|i| i.consume_shortcut(&UNDO_SHORTCUT)) {
self.project.undo();
}
if ctx.input_mut(|i| i.consume_shortcut(&REDO_SHORTCUT)) {
self.project.redo();
}
if ctx.input_mut(|i| i.consume_shortcut(&SAVE_SHORTCUT)) {
let result = self.apply_pending_updates();
self.notifier.ok_or_report(result);
}
if ctx.input_mut(|i| i.consume_shortcut(&GO_BACK_SHORTCUT)) {
if self.main_view == MainView::CorpusStructure {
self.change_view(MainView::Start);
} else if let MainView::EditDocument { .. } = self.main_view {
self.change_view(MainView::CorpusStructure);
}
}
}
pub(crate) fn show(&mut self, ctx: &egui::Context, frame_info: &IntegrationInfo) {
egui_extras::install_image_loaders(ctx);
if let ShutdownRequest::None = self.shutdown_request
&& ctx.input(|input_state| input_state.viewport().close_requested())
{
if let Some(editor) = self.current_editor.get()
&& editor.has_pending_updates()
{
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
let result = self.apply_pending_updates();
self.notifier.ok_or_report(result);
self.shutdown_request = ShutdownRequest::Requested;
} else {
self.shutdown_request = ShutdownRequest::ShutdownIsSafe;
}
}
match self.shutdown_request {
ShutdownRequest::None => {
self.show_view(ctx, frame_info);
}
ShutdownRequest::Requested => {
if !self.jobs.clone().show(ctx, self) {
self.shutdown_request = ShutdownRequest::ShutdownIsSafe;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
ShutdownRequest::ShutdownIsSafe => {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Closing application...");
ui.label("Please wait until the corpus is persisted to disk.");
});
}
}
}
fn show_view(&mut self, ctx: &egui::Context, frame_info: &IntegrationInfo) {
self.consume_shortcuts(ctx);
self.handle_corpus_confirmation_dialog(ctx);
self.load_editor_if_necessary();
let has_foreground_jobs = self.jobs.clone().show(ctx, self);
if !has_foreground_jobs {
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
MenuBar::new().ui(ui, |ui| {
ui.image(egui::include_image!("../assets/icon-32.png"));
ui.menu_button("File", |ui| {
if ui
.add(
Button::new("Quit")
.shortcut_text(ctx.format_shortcut(&QUIT_SHORTCUT)),
)
.clicked()
{
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
});
ui.menu_button("Edit", |ui| {
if let Some(editor) = self.current_editor.get_mut() {
editor.add_edit_menu_entries(ui);
}
if ui
.add_enabled(
self.project.can_undo(),
Button::new("Undo")
.shortcut_text(ctx.format_shortcut(&UNDO_SHORTCUT)),
)
.clicked()
{
self.project.undo();
}
if ui
.add_enabled(
self.project.can_redo(),
Button::new("Redo")
.shortcut_text(ctx.format_shortcut(&REDO_SHORTCUT)),
)
.clicked()
{
self.project.redo();
}
});
ui.menu_button("View", |ui| {
egui::gui_zoom::zoom_menu_buttons(ui);
});
ui.add_space(16.0);
ui.separator();
let marker_color = if ui.ctx().theme() == Theme::Light {
CHANGE_PENDING_COLOR_LIGHT
} else {
CHANGE_PENDING_COLOR_DARK
};
if let Some(editor) = self.current_editor.get()
&& editor.has_pending_updates()
{
ui.label(RichText::new("Has pending changes").color(marker_color));
let result = self.apply_pending_updates();
self.notifier.ok_or_report(result);
} else {
ui.label("No pending changes");
}
ui.separator();
ui.add_space(16.0);
if self.args.dev
&& let Some(seconds) = frame_info.cpu_usage
{
ui.label(format!("CPU usage: {:.1} ms / frame", seconds * 1000.0));
ui.add_space(16.0);
}
egui::widgets::global_theme_preference_switch(ui);
});
});
egui::CentralPanel::default().show(ctx, |ui| {
if !has_foreground_jobs {
self.notifier.show(ctx);
let response = match self.main_view {
MainView::Start => views::start::show(ui, self),
MainView::Import => views::import::show(ui, self),
MainView::Export => views::export::show(ui, self),
MainView::CorpusStructure => views::corpus_structure::show(ui, self),
MainView::EditDocument { .. } => views::edit::show(ui, self),
};
if let Err(e) = response {
self.notifier.report_error(e);
}
}
});
}
}
}
impl eframe::App for AnnatomicApp {
fn save(&mut self, storage: &mut dyn eframe::Storage) {
eframe::set_value(storage, eframe::APP_KEY, self);
}
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
self.show(ctx, frame.info());
self.file_dialog.update(ctx);
}
fn on_exit(&mut self) {
self.notifier
.ok_or_report(self.project.persist_changes_on_exit());
}
}