annatomic 0.4.0

The Annatomic annotation editor is intended to be used for the [RIDGES corpus](https://www.linguistik.hu-berlin.de/en/institut-en/professuren-en/korpuslinguistik/research/ridges-projekt). It is based on [graphANNIS](https://github.com/korpling/graphANNIS) and thus is internal data model is in principle suitable for a wide range of annotation concepts. "
Documentation
use std::collections::HashMap;

use annatto::{ExporterStep, WriteAs, exporter::graphml::GraphMLExporter};
use anyhow::Context;
use egui::{Button, Color32, Id, InnerResponse, RichText, TextEdit, Widget};

use egui_file_dialog::FileDialog;
use facet::Facet;
use facet_reflect::peek_enum_variants;
use serde::{Deserialize, Serialize};

use crate::app::{messages::Notifier, util};

pub struct ExportConfigWidget<'a> {
    widget_id: Id,
    file_dialog: &'a mut FileDialog,
    notifier: &'a Notifier,
}

#[derive(Serialize, Deserialize, Clone)]
pub struct ExportConfigWidgetState {
    output_path: String,
    current_exporter: WriteAs,
    config_by_name: HashMap<String, String>,
    additional_config: String,
    config_error_message: Option<String>,
}

impl Default for ExportConfigWidgetState {
    fn default() -> Self {
        let graphml_config = GraphMLExporter::default();

        Self {
            output_path: String::default(),
            current_exporter: WriteAs::GraphML(graphml_config.clone()),
            config_by_name: HashMap::new(),
            additional_config: String::default(),
            config_error_message: None,
        }
    }
}

#[derive(Default)]
pub struct ExportConfigWidgetOutput {
    pub export_step: Option<annatto::ExporterStep>,
    pub output_path: String,
}

impl<'a> ExportConfigWidget<'a> {
    pub fn new<I: Into<Id>>(
        widget_id: I,
        notifier: &'a Notifier,
        file_dialog: &'a mut FileDialog,
    ) -> Self {
        Self {
            widget_id: widget_id.into(),
            notifier,
            file_dialog,
        }
    }

    pub fn load_state(&self, ctx: &egui::Context) -> ExportConfigWidgetState {
        ctx.data_mut(|d| d.get_persisted(self.widget_id))
            .unwrap_or_default()
    }

    pub fn store_state(&self, ctx: &egui::Context, state: ExportConfigWidgetState) {
        ctx.data_mut(|d| d.insert_persisted(self.widget_id, state));
    }

    pub fn show(self, ui: &mut egui::Ui) -> InnerResponse<ExportConfigWidgetOutput> {
        let mut state = self.load_state(ui.ctx());

        let response = ui.vertical(|ui| {
            let mut output = ExportConfigWidgetOutput::default();

            let current_exporter_name = state.current_exporter.name().unwrap_or_default();
            let cb_response = egui::ComboBox::from_label("Format")
                .selected_text(&current_exporter_name)
                .show_ui(ui, |ui| {
                    let mut new_config_string = None;
                    // Get all exporter modules
                    for variant in peek_enum_variants(WriteAs::SHAPE).unwrap_or_default() {
                        // Only include exporter that have a default serialization value
                        if variant.data.fields.len() == 1
                            && variant.data.fields[0].shape().is_default()
                        {
                            let module_name = variant.name.to_lowercase();
                            let config = state
                                .config_by_name
                                .entry(module_name.clone())
                                .or_insert_with(|| {
                                    format!(
                                        "format=\"{module_name}\"\npath=\"{}\"\n\n[config]\n",
                                        &state.output_path.escape_default().to_string()
                                    )
                                });

                            if let Some(deserialized_module) = self.notifier.ok_or_report(
                                toml::from_str::<WriteAs>(config).with_context(|| {
                                    format!("Creating module {module_name} failed")
                                }),
                            ) && ui
                                .selectable_value(
                                    &mut state.current_exporter,
                                    deserialized_module,
                                    module_name,
                                )
                                .clicked()
                            {
                                new_config_string = Some(config.clone());
                            }
                        }
                    }

                    new_config_string
                });

            if let Some(Some(new_string)) = cb_response.inner
                && let Some(new_string) = self
                    .notifier
                    .ok_or_report(util::strip_module_header(&new_string))
            {
                state.additional_config = new_string;
            }

            ui.horizontal(|ui| {
                TextEdit::singleline(&mut state.output_path)
                    .hint_text("Output directory")
                    .show(ui);
                if ui
                    .button("...")
                    .on_hover_text("Select the output path")
                    .clicked()
                {
                    self.file_dialog.pick_directory();
                }
                if let Some(new_path) = self.file_dialog.take_picked() {
                    state.output_path = new_path.to_string_lossy().to_string();
                }
            });

            ui.hyperlink_to(
                format!("Documentation for \"{current_exporter_name}\""),
                format!("https://github.com/korpling/annatto/blob/main/docs/exporters/{current_exporter_name}.md")
            );

            ui.label("Configuration");
            let txt_config = TextEdit::multiline(&mut state.additional_config)
                .code_editor()
                .desired_width(ui.available_width())
                .show(ui);

            // Test if the new value is valid TOML, gently report the error otherwise
            if txt_config.response.changed() {
                state.config_error_message = match update_config_from_string(&mut state) {
                    Ok(_) => None,
                    Err(err) => Some(err.to_string()),
                };
            }

            if let Some(err) = &state.config_error_message {
                ui.label(RichText::new(err.to_string()).color(Color32::DARK_RED));
            }

            if ui
                .add_enabled(
                    state.config_error_message.is_none() && !state.output_path.is_empty(),
                    Button::new("Start export"),
                )
                .clicked()
            {
                self.notifier
                    .ok_or_report(update_config_from_string(&mut state));
                // Create a workflow and return it for execution
                output.export_step = Some(ExporterStep::new(
                    state.current_exporter.clone(),
                    state.output_path.clone(),
                ));
                output.output_path = state.output_path.clone();
            }

            output
        });

        self.store_state(ui.ctx(), state);

        response
    }
}

fn update_config_from_string(state: &mut ExportConfigWidgetState) -> anyhow::Result<()> {
    let current_exporter_name = state.current_exporter.name()?;

    // Add the format and path to the string
    let full_string = format!(
        "format=\"{}\"\npath=\"{}\"\n\n[config]\n{}",
        current_exporter_name,
        &state.output_path.escape_default().to_string(),
        &state.additional_config
    );

    state.current_exporter = toml::from_str(&full_string)?;
    state
        .config_by_name
        .insert(current_exporter_name, full_string);

    Ok(())
}

impl<'a> Widget for ExportConfigWidget<'a> {
    fn ui(self, ui: &mut egui::Ui) -> egui::Response {
        self.show(ui).response
    }
}