diff --git a/testangel/src/next_ui/actions/execution_dialog.rs b/testangel/src/next_ui/actions/execution_dialog.rs index c4f21fb..f571b0e 100644 --- a/testangel/src/next_ui/actions/execution_dialog.rs +++ b/testangel/src/next_ui/actions/execution_dialog.rs @@ -1,227 +1,227 @@ -use std::{collections::HashMap, sync::Arc}; - -use adw::prelude::*; -use relm4::{adw, gtk, Component, ComponentParts, RelmWidgetExt}; -use testangel::{ - action_loader::ActionMap, - ipc::EngineList, - report_generation::{self, ReportGenerationError}, - types::{FlowError, Action}, -}; -use testangel_ipc::prelude::{Evidence, EvidenceContent, ParameterValue}; - -use crate::next_ui::{file_filters, lang}; - -#[derive(Debug)] -pub enum ExecutionDialogCommandOutput { - /// Execution completed with the resulting evidence - Complete(Vec), - - /// Execution failed at the given step and for the given reason - Failed(usize, FlowError), -} - -#[derive(Debug)] -pub struct ExecutionDialogInit { - pub action: Action, - pub engine_list: Arc, - pub action_map: Arc, -} - -#[derive(Debug)] -pub enum ExecutionDialogInput { - Close, - FailedToGenerateReport(ReportGenerationError), -} - -#[derive(Debug)] -pub struct ExecutionDialog; - -impl ExecutionDialog { - /// Create the absolute barebones of a message dialog, allowing for custom button and response mapping. - fn create_message_dialog(&self, title: S, message: S) -> adw::MessageDialog - where - S: AsRef, - { - adw::MessageDialog::builder() - .title(title.as_ref()) - .heading(title.as_ref()) - .body(message.as_ref()) - .modal(true) - .build() - } -} - -#[relm4::component(pub)] -impl Component for ExecutionDialog { - type Init = ExecutionDialogInit; - type Input = ExecutionDialogInput; - type Output = (); - type CommandOutput = ExecutionDialogCommandOutput; - - view! { - #[root] - adw::Window { - set_modal: true, - set_resizable: false, - - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 5, - set_margin_all: 50, - - gtk::Spinner { - set_spinning: true, - }, - gtk::Label { - set_label: &lang::lookup("flow-execution-running"), - }, - }, - }, - } - - fn init( - init: Self::Init, - root: &Self::Root, - sender: relm4::ComponentSender, - ) -> relm4::ComponentParts { - let model = ExecutionDialog; - let widgets = view_output!(); - let action = init.action; - let engine_list = init.engine_list.clone(); - let action_map = init.action_map.clone(); - - // TODO: ask for input, then build `ActionConfiguration` and trigger - // sender.spawn_oneshot_command(move || { - // let mut outputs: Vec> = Vec::new(); - // let mut evidence = Vec::new(); - - // for engine in engine_list.inner() { - // if engine.reset_state().is_err() { - // evidence.push(Evidence { - // label: String::from("WARNING: State Warning"), - // content: EvidenceContent::Textual(String::from("For this test execution, the state couldn't be correctly reset. Some results may not be accurate.")) - // }); - // } - // } - - // for (step, action_config) in action.actions.iter().enumerate() { - // match action_config.execute( - // action_map.clone(), - // engine_list.clone(), - // outputs.clone(), - // ) { - // Ok((output, ev)) => { - // outputs.push(output); - // evidence = [evidence, ev].concat(); - // } - // Err(e) => { - // return ExecutionDialogCommandOutput::Failed(step + 1, e); - // } - // } - // } - - // ExecutionDialogCommandOutput::Complete(evidence) - // }); - - ComponentParts { model, widgets } - } - - fn update( - &mut self, - message: Self::Input, - sender: relm4::ComponentSender, - root: &Self::Root, - ) { - match message { - ExecutionDialogInput::Close => root.destroy(), - ExecutionDialogInput::FailedToGenerateReport(reason) => { - let dialog = self.create_message_dialog( - lang::lookup("report-failed"), - lang::lookup_with_args("report-failed-message", { - let mut map = HashMap::new(); - map.insert("reason", reason.to_string().into()); - map - }), - ); - dialog.set_transient_for(Some(root)); - dialog.add_response("ok", &lang::lookup("ok")); - dialog.set_default_response(Some("ok")); - let sender_c = sender.clone(); - dialog.connect_response(None, move |dlg, _response| { - sender_c.input(ExecutionDialogInput::Close); - dlg.close(); - }); - dialog.set_visible(true); - } - } - } - - fn update_cmd( - &mut self, - message: Self::CommandOutput, - sender: relm4::ComponentSender, - root: &Self::Root, - ) { - match message { - ExecutionDialogCommandOutput::Complete(evidence) => { - log::info!("Execution complete."); - - // Present save dialog - let dialog = gtk::FileDialog::builder() - .modal(true) - .title(lang::lookup("report-save-title")) - .initial_name(lang::lookup("report-default-name")) - .filters(&file_filters::filter_list(vec![ - file_filters::pdfs(), - file_filters::all(), - ])) - .build(); - - let sender_c = sender.clone(); - dialog.save( - Some(root), - Some(&relm4::gtk::gio::Cancellable::new()), - move |res| { - if let Ok(file) = res { - let path = file.path().unwrap(); - if let Err(e) = report_generation::save_report( - path.with_extension("pdf"), - evidence.clone(), - ) { - // Failed to generate report - sender_c.input(ExecutionDialogInput::FailedToGenerateReport(e)); - return; - } else if let Err(e) = opener::open(path.with_extension("pdf")) { - log::warn!("Failed to open evidence: {e}"); - } - } - sender_c.input(ExecutionDialogInput::Close); - }, - ); - } - - ExecutionDialogCommandOutput::Failed(step, reason) => { - log::warn!("Execution failed"); - let dialog = self.create_message_dialog( - lang::lookup("flow-execution-failed"), - lang::lookup_with_args("flow-execution-failed-message", { - let mut map = HashMap::new(); - map.insert("step", step.into()); - map.insert("reason", reason.to_string().into()); - map - }), - ); - dialog.set_transient_for(Some(root)); - dialog.add_response("ok", &lang::lookup("ok")); - dialog.set_default_response(Some("ok")); - let sender_c = sender.clone(); - dialog.connect_response(None, move |dlg, _response| { - sender_c.input(ExecutionDialogInput::Close); - dlg.close(); - }); - dialog.set_visible(true); - } - } - } -} +use std::{collections::HashMap, sync::Arc}; + +use adw::prelude::*; +use relm4::{adw, gtk, Component, ComponentParts, RelmWidgetExt}; +use testangel::{ + action_loader::ActionMap, + ipc::EngineList, + report_generation::{self, ReportGenerationError}, + types::{Action, FlowError}, +}; +use testangel_ipc::prelude::{Evidence, EvidenceContent, ParameterValue}; + +use crate::next_ui::{file_filters, lang}; + +#[derive(Debug)] +pub enum ExecutionDialogCommandOutput { + /// Execution completed with the resulting evidence + Complete(Vec), + + /// Execution failed at the given step and for the given reason + Failed(usize, FlowError), +} + +#[derive(Debug)] +pub struct ExecutionDialogInit { + pub action: Action, + pub engine_list: Arc, + pub action_map: Arc, +} + +#[derive(Debug)] +pub enum ExecutionDialogInput { + Close, + FailedToGenerateReport(ReportGenerationError), +} + +#[derive(Debug)] +pub struct ExecutionDialog; + +impl ExecutionDialog { + /// Create the absolute barebones of a message dialog, allowing for custom button and response mapping. + fn create_message_dialog(&self, title: S, message: S) -> adw::MessageDialog + where + S: AsRef, + { + adw::MessageDialog::builder() + .title(title.as_ref()) + .heading(title.as_ref()) + .body(message.as_ref()) + .modal(true) + .build() + } +} + +#[relm4::component(pub)] +impl Component for ExecutionDialog { + type Init = ExecutionDialogInit; + type Input = ExecutionDialogInput; + type Output = (); + type CommandOutput = ExecutionDialogCommandOutput; + + view! { + #[root] + adw::Window { + set_modal: true, + set_resizable: false, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 5, + set_margin_all: 50, + + gtk::Spinner { + set_spinning: true, + }, + gtk::Label { + set_label: &lang::lookup("flow-execution-running"), + }, + }, + }, + } + + fn init( + init: Self::Init, + root: &Self::Root, + sender: relm4::ComponentSender, + ) -> relm4::ComponentParts { + let model = ExecutionDialog; + let widgets = view_output!(); + let action = init.action; + let engine_list = init.engine_list.clone(); + let action_map = init.action_map.clone(); + + // TODO: ask for input, then build `ActionConfiguration` and trigger + // sender.spawn_oneshot_command(move || { + // let mut outputs: Vec> = Vec::new(); + // let mut evidence = Vec::new(); + + // for engine in engine_list.inner() { + // if engine.reset_state().is_err() { + // evidence.push(Evidence { + // label: String::from("WARNING: State Warning"), + // content: EvidenceContent::Textual(String::from("For this test execution, the state couldn't be correctly reset. Some results may not be accurate.")) + // }); + // } + // } + + // for (step, action_config) in action.actions.iter().enumerate() { + // match action_config.execute( + // action_map.clone(), + // engine_list.clone(), + // outputs.clone(), + // ) { + // Ok((output, ev)) => { + // outputs.push(output); + // evidence = [evidence, ev].concat(); + // } + // Err(e) => { + // return ExecutionDialogCommandOutput::Failed(step + 1, e); + // } + // } + // } + + // ExecutionDialogCommandOutput::Complete(evidence) + // }); + + ComponentParts { model, widgets } + } + + fn update( + &mut self, + message: Self::Input, + sender: relm4::ComponentSender, + root: &Self::Root, + ) { + match message { + ExecutionDialogInput::Close => root.destroy(), + ExecutionDialogInput::FailedToGenerateReport(reason) => { + let dialog = self.create_message_dialog( + lang::lookup("report-failed"), + lang::lookup_with_args("report-failed-message", { + let mut map = HashMap::new(); + map.insert("reason", reason.to_string().into()); + map + }), + ); + dialog.set_transient_for(Some(root)); + dialog.add_response("ok", &lang::lookup("ok")); + dialog.set_default_response(Some("ok")); + let sender_c = sender.clone(); + dialog.connect_response(None, move |dlg, _response| { + sender_c.input(ExecutionDialogInput::Close); + dlg.close(); + }); + dialog.set_visible(true); + } + } + } + + fn update_cmd( + &mut self, + message: Self::CommandOutput, + sender: relm4::ComponentSender, + root: &Self::Root, + ) { + match message { + ExecutionDialogCommandOutput::Complete(evidence) => { + log::info!("Execution complete."); + + // Present save dialog + let dialog = gtk::FileDialog::builder() + .modal(true) + .title(lang::lookup("report-save-title")) + .initial_name(lang::lookup("report-default-name")) + .filters(&file_filters::filter_list(vec![ + file_filters::pdfs(), + file_filters::all(), + ])) + .build(); + + let sender_c = sender.clone(); + dialog.save( + Some(root), + Some(&relm4::gtk::gio::Cancellable::new()), + move |res| { + if let Ok(file) = res { + let path = file.path().unwrap(); + if let Err(e) = report_generation::save_report( + path.with_extension("pdf"), + evidence.clone(), + ) { + // Failed to generate report + sender_c.input(ExecutionDialogInput::FailedToGenerateReport(e)); + return; + } else if let Err(e) = opener::open(path.with_extension("pdf")) { + log::warn!("Failed to open evidence: {e}"); + } + } + sender_c.input(ExecutionDialogInput::Close); + }, + ); + } + + ExecutionDialogCommandOutput::Failed(step, reason) => { + log::warn!("Execution failed"); + let dialog = self.create_message_dialog( + lang::lookup("flow-execution-failed"), + lang::lookup_with_args("flow-execution-failed-message", { + let mut map = HashMap::new(); + map.insert("step", step.into()); + map.insert("reason", reason.to_string().into()); + map + }), + ); + dialog.set_transient_for(Some(root)); + dialog.add_response("ok", &lang::lookup("ok")); + dialog.set_default_response(Some("ok")); + let sender_c = sender.clone(); + dialog.connect_response(None, move |dlg, _response| { + sender_c.input(ExecutionDialogInput::Close); + dlg.close(); + }); + dialog.set_visible(true); + } + } + } +} diff --git a/testangel/src/next_ui/actions/header.rs b/testangel/src/next_ui/actions/header.rs index f93d628..ddff6a1 100644 --- a/testangel/src/next_ui/actions/header.rs +++ b/testangel/src/next_ui/actions/header.rs @@ -1,317 +1,331 @@ -use std::sync::Arc; - -use adw::prelude::*; -use relm4::{ - actions::{AccelsPlus, RelmAction, RelmActionGroup}, - adw, - factory::FactoryVecDeque, - gtk, Component, ComponentController, ComponentParts, ComponentSender, RelmWidgetExt, -}; -use testangel::{action_loader::ActionMap, ipc::EngineList}; - -use crate::next_ui::{lang, components::add_step_factory::{AddStepResult, AddStepTrait, AddStepInit}}; - -#[derive(Debug)] -pub struct ActionsHeader { - action_map: Arc, - engine_list: Arc, - add_button: gtk::MenuButton, - action_open: bool, - search_results: FactoryVecDeque>, -} - -#[derive(Debug)] -pub enum ActionsHeaderOutput { - NewAction, - OpenAction, - SaveAction, - SaveAsAction, - CloseAction, - RunAction, - AddStep(String), -} - -#[derive(Debug)] -pub enum ActionsHeaderInput { - OpenAboutDialog, - ActionsMapChanged(Arc), - /// Add the step with the instruction ID given - AddStep(String), - /// Trigger a search for the steps provided - SearchForSteps(String), - /// Add the top search result to the action. - AddTopSearchResult, - /// Inform the header bar if a action is open or not. - ChangeActionOpen(bool), -} - -impl AddStepTrait for ActionsHeaderInput { - fn add_step(value: String) -> Self { - Self::AddStep(value) - } -} - -#[relm4::component(pub)] -impl Component for ActionsHeader { - type Init = (Arc, Arc); - type Input = ActionsHeaderInput; - type Output = ActionsHeaderOutput; - type CommandOutput = (); - - view! { - #[root] - #[name = "start"] - gtk::Box { - set_spacing: 5, - - #[local_ref] - add_button -> gtk::MenuButton { - set_icon_name: relm4_icons::icon_name::PLUS, - set_tooltip: &lang::lookup("action-header-add"), - - #[wrap(Some)] - #[name = "menu_popover"] - set_popover = >k::Popover { - gtk::Box { - set_spacing: 2, - set_orientation: gtk::Orientation::Vertical, - - gtk::SearchEntry { - set_max_width_chars: 20, - - connect_activate[sender] => move |_| { - sender.input(ActionsHeaderInput::AddTopSearchResult); - }, - - connect_search_changed[sender] => move |slf| { - let query = slf.text().to_string(); - sender.input(ActionsHeaderInput::SearchForSteps(query)); - }, - }, - - gtk::ScrolledWindow { - set_hscrollbar_policy: gtk::PolicyType::Never, - set_min_content_height: 150, - - #[local_ref] - results_box -> gtk::Box { - set_spacing: 2, - set_orientation: gtk::Orientation::Vertical, - }, - }, - }, - }, - }, - gtk::Button { - set_icon_name: relm4_icons::icon_name::PLAY, - set_tooltip: &lang::lookup("action-header-run"), - // TODO uncomment when execution dialog ready - //#[watch] - //set_sensitive: model.action_open, - set_sensitive: false, - connect_clicked[sender] => move |_| { - // unwrap rationale: receivers will never be dropped - sender.output(ActionsHeaderOutput::RunAction).unwrap(); - }, - }, - }, - - #[name = "end"] - gtk::Box { - set_spacing: 5, - - gtk::MenuButton { - set_icon_name: relm4_icons::icon_name::MENU, - set_tooltip: &lang::lookup("action-header-more"), - set_direction: gtk::ArrowType::Down, - - #[wrap(Some)] - set_popover = >k::PopoverMenu::from_model(Some(&actions_menu)) { - set_position: gtk::PositionType::Bottom, - }, - }, - }, - } - - menu! { - actions_menu: { - &lang::lookup("action-header-new") => ActionsNewAction, - &lang::lookup("action-header-open") => ActionsOpenAction, - &lang::lookup("action-header-save") => ActionsSaveAction, - &lang::lookup("action-header-save-as") => ActionsSaveAsAction, - &lang::lookup("action-header-close") => ActionsCloseAction, - section! { - &lang::lookup("action-header-about") => ActionsAboutAction, - } - } - } - - fn init( - init: Self::Init, - root: &Self::Root, - sender: ComponentSender, - ) -> ComponentParts { - let model = ActionsHeader { - engine_list: init.0, - action_map: init.1, - action_open: false, - add_button: gtk::MenuButton::default(), - search_results: FactoryVecDeque::new(gtk::Box::default(), sender.input_sender()), - }; - // Reset search results - sender.input(ActionsHeaderInput::SearchForSteps(String::new())); - - let results_box = model.search_results.widget(); - let add_button = &model.add_button; - let widgets = view_output!(); - - let sender_c = sender.clone(); - let new_action: RelmAction = RelmAction::new_stateless(move |_| { - // unwrap rationale: receiver will never be disconnected - sender_c.output(ActionsHeaderOutput::NewAction).unwrap(); - }); - relm4::main_application().set_accelerators_for_action::(&["N"]); - - let sender_c = sender.clone(); - let open_action: RelmAction = RelmAction::new_stateless(move |_| { - // unwrap rationale: receiver will never be disconnected - sender_c.output(ActionsHeaderOutput::OpenAction).unwrap(); - }); - relm4::main_application().set_accelerators_for_action::(&["O"]); - - let sender_c = sender.clone(); - let save_action: RelmAction = RelmAction::new_stateless(move |_| { - // unwrap rationale: receiver will never be disconnected - sender_c.output(ActionsHeaderOutput::SaveAction).unwrap(); - }); - relm4::main_application().set_accelerators_for_action::(&["S"]); - - let sender_c = sender.clone(); - let save_as_action: RelmAction = RelmAction::new_stateless(move |_| { - // unwrap rationale: receiver will never be disconnected - sender_c.output(ActionsHeaderOutput::SaveAsAction).unwrap(); - }); - relm4::main_application() - .set_accelerators_for_action::(&["S"]); - - let sender_c = sender.clone(); - let close_action: RelmAction = RelmAction::new_stateless(move |_| { - // unwrap rationale: receiver will never be disconnected - sender_c.output(ActionsHeaderOutput::CloseAction).unwrap(); - }); - relm4::main_application().set_accelerators_for_action::(&["W"]); - - let sender_c = sender.clone(); - let about_action: RelmAction = RelmAction::new_stateless(move |_| { - sender_c.input(ActionsHeaderInput::OpenAboutDialog); - }); - relm4::main_application().set_accelerators_for_action::(&["A"]); - - let mut group = RelmActionGroup::::new(); - group.add_action(new_action); - group.add_action(open_action); - group.add_action(save_action); - group.add_action(save_as_action); - group.add_action(close_action); - group.add_action(about_action); - group.register_for_widget(&widgets.end); - - ComponentParts { model, widgets } - } - - fn update_with_view( - &mut self, - widgets: &mut Self::Widgets, - message: Self::Input, - sender: ComponentSender, - root: &Self::Root, - ) { - match message { - ActionsHeaderInput::ChangeActionOpen(now) => { - self.action_open = now; - } - ActionsHeaderInput::ActionsMapChanged(new_map) => { - self.action_map = new_map; - } - ActionsHeaderInput::OpenAboutDialog => { - crate::next_ui::about::AppAbout::builder() - .transient_for(root) - .launch((self.engine_list.clone(), self.action_map.clone())) - .widget() - .set_visible(true); - } - ActionsHeaderInput::AddStep(step_id) => { - // close popover - self.add_button.popdown(); - // unwrap rationale: the receiver will never be disconnected - sender.output(ActionsHeaderOutput::AddStep(step_id)).unwrap(); - } - ActionsHeaderInput::AddTopSearchResult => { - if let Some(result) = self.search_results.get(0) { - widgets.menu_popover.popdown(); - let id = result.value(); - // unwrap rationale: the receiver will never be disconnected - sender.output(ActionsHeaderOutput::AddStep(id)).unwrap(); - } - } - ActionsHeaderInput::SearchForSteps(query) => { - let mut results = self.search_results.guard(); - results.clear(); - - // Collect results - if query.is_empty() { - // List all alphabetically - let mut unsorted_results = vec![]; - for engine in self.engine_list.inner() { - for instruction in &engine.instructions { - unsorted_results - .push((format!("{}: {}", engine.name, instruction.friendly_name()), engine.name.clone(), instruction.clone())); - } - } - - // Sort - unsorted_results.sort_by(|(a, _, _), (b, _, _)| a.cmp(b)); - for (_, engine_name, ins) in unsorted_results { - results.push_back(AddStepInit { - label: format!("{engine_name}: {}", ins.friendly_name()), - value: ins.id().clone(), - }); - } - } else { - let mut unsorted_results = vec![]; - use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; - let matcher = SkimMatcherV2::default(); - for engine in self.engine_list.inner() { - for instruction in &engine.instructions { - if let Some(score) = matcher.fuzzy_match( - &format!("{}: {}", engine.name, instruction.friendly_name()), - &query, - ) { - unsorted_results - .push((score, engine.name.clone(), instruction.clone())); - } - } - } - - // Sort - unsorted_results.sort_by(|(a, _, _), (b, _, _)| a.cmp(b)); - for (_, engine_name, ins) in unsorted_results { - results.push_back(AddStepInit { - label: format!("{engine_name}: {}", ins.friendly_name()), - value: ins.id().clone(), - }); - } - } - } - } - self.update_view(widgets, sender); - } -} - -relm4::new_action_group!(ActionsActionGroup, "actions"); -relm4::new_stateless_action!(ActionsNewAction, ActionsActionGroup, "new"); -relm4::new_stateless_action!(ActionsOpenAction, ActionsActionGroup, "open"); -relm4::new_stateless_action!(ActionsSaveAction, ActionsActionGroup, "save"); -relm4::new_stateless_action!(ActionsSaveAsAction, ActionsActionGroup, "save-as"); -relm4::new_stateless_action!(ActionsCloseAction, ActionsActionGroup, "close"); -relm4::new_stateless_action!(ActionsAboutAction, ActionsActionGroup, "about"); +use std::sync::Arc; + +use adw::prelude::*; +use relm4::{ + actions::{AccelsPlus, RelmAction, RelmActionGroup}, + adw, + factory::FactoryVecDeque, + gtk, Component, ComponentController, ComponentParts, ComponentSender, RelmWidgetExt, +}; +use testangel::{action_loader::ActionMap, ipc::EngineList}; + +use crate::next_ui::{ + components::add_step_factory::{AddStepInit, AddStepResult, AddStepTrait}, + lang, +}; + +#[derive(Debug)] +pub struct ActionsHeader { + action_map: Arc, + engine_list: Arc, + add_button: gtk::MenuButton, + action_open: bool, + search_results: FactoryVecDeque>, +} + +#[derive(Debug)] +pub enum ActionsHeaderOutput { + NewAction, + OpenAction, + SaveAction, + SaveAsAction, + CloseAction, + RunAction, + AddStep(String), +} + +#[derive(Debug)] +pub enum ActionsHeaderInput { + OpenAboutDialog, + ActionsMapChanged(Arc), + /// Add the step with the instruction ID given + AddStep(String), + /// Trigger a search for the steps provided + SearchForSteps(String), + /// Add the top search result to the action. + AddTopSearchResult, + /// Inform the header bar if a action is open or not. + ChangeActionOpen(bool), +} + +impl AddStepTrait for ActionsHeaderInput { + fn add_step(value: String) -> Self { + Self::AddStep(value) + } +} + +#[relm4::component(pub)] +impl Component for ActionsHeader { + type Init = (Arc, Arc); + type Input = ActionsHeaderInput; + type Output = ActionsHeaderOutput; + type CommandOutput = (); + + view! { + #[root] + #[name = "start"] + gtk::Box { + set_spacing: 5, + + #[local_ref] + add_button -> gtk::MenuButton { + set_icon_name: relm4_icons::icon_name::PLUS, + set_tooltip: &lang::lookup("action-header-add"), + + #[wrap(Some)] + #[name = "menu_popover"] + set_popover = >k::Popover { + gtk::Box { + set_spacing: 2, + set_orientation: gtk::Orientation::Vertical, + + gtk::SearchEntry { + set_max_width_chars: 20, + + connect_activate[sender] => move |_| { + sender.input(ActionsHeaderInput::AddTopSearchResult); + }, + + connect_search_changed[sender] => move |slf| { + let query = slf.text().to_string(); + sender.input(ActionsHeaderInput::SearchForSteps(query)); + }, + }, + + gtk::ScrolledWindow { + set_hscrollbar_policy: gtk::PolicyType::Never, + set_min_content_height: 150, + + #[local_ref] + results_box -> gtk::Box { + set_spacing: 2, + set_orientation: gtk::Orientation::Vertical, + }, + }, + }, + }, + }, + gtk::Button { + set_icon_name: relm4_icons::icon_name::PLAY, + set_tooltip: &lang::lookup("action-header-run"), + // TODO uncomment when execution dialog ready + //#[watch] + //set_sensitive: model.action_open, + set_sensitive: false, + connect_clicked[sender] => move |_| { + // unwrap rationale: receivers will never be dropped + sender.output(ActionsHeaderOutput::RunAction).unwrap(); + }, + }, + }, + + #[name = "end"] + gtk::Box { + set_spacing: 5, + + gtk::MenuButton { + set_icon_name: relm4_icons::icon_name::MENU, + set_tooltip: &lang::lookup("action-header-more"), + set_direction: gtk::ArrowType::Down, + + #[wrap(Some)] + set_popover = >k::PopoverMenu::from_model(Some(&actions_menu)) { + set_position: gtk::PositionType::Bottom, + }, + }, + }, + } + + menu! { + actions_menu: { + &lang::lookup("action-header-new") => ActionsNewAction, + &lang::lookup("action-header-open") => ActionsOpenAction, + &lang::lookup("action-header-save") => ActionsSaveAction, + &lang::lookup("action-header-save-as") => ActionsSaveAsAction, + &lang::lookup("action-header-close") => ActionsCloseAction, + section! { + &lang::lookup("action-header-about") => ActionsAboutAction, + } + } + } + + fn init( + init: Self::Init, + root: &Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let model = ActionsHeader { + engine_list: init.0, + action_map: init.1, + action_open: false, + add_button: gtk::MenuButton::default(), + search_results: FactoryVecDeque::new(gtk::Box::default(), sender.input_sender()), + }; + // Reset search results + sender.input(ActionsHeaderInput::SearchForSteps(String::new())); + + let results_box = model.search_results.widget(); + let add_button = &model.add_button; + let widgets = view_output!(); + + let sender_c = sender.clone(); + let new_action: RelmAction = RelmAction::new_stateless(move |_| { + // unwrap rationale: receiver will never be disconnected + sender_c.output(ActionsHeaderOutput::NewAction).unwrap(); + }); + relm4::main_application().set_accelerators_for_action::(&["N"]); + + let sender_c = sender.clone(); + let open_action: RelmAction = RelmAction::new_stateless(move |_| { + // unwrap rationale: receiver will never be disconnected + sender_c.output(ActionsHeaderOutput::OpenAction).unwrap(); + }); + relm4::main_application().set_accelerators_for_action::(&["O"]); + + let sender_c = sender.clone(); + let save_action: RelmAction = RelmAction::new_stateless(move |_| { + // unwrap rationale: receiver will never be disconnected + sender_c.output(ActionsHeaderOutput::SaveAction).unwrap(); + }); + relm4::main_application().set_accelerators_for_action::(&["S"]); + + let sender_c = sender.clone(); + let save_as_action: RelmAction = + RelmAction::new_stateless(move |_| { + // unwrap rationale: receiver will never be disconnected + sender_c.output(ActionsHeaderOutput::SaveAsAction).unwrap(); + }); + relm4::main_application() + .set_accelerators_for_action::(&["S"]); + + let sender_c = sender.clone(); + let close_action: RelmAction = RelmAction::new_stateless(move |_| { + // unwrap rationale: receiver will never be disconnected + sender_c.output(ActionsHeaderOutput::CloseAction).unwrap(); + }); + relm4::main_application() + .set_accelerators_for_action::(&["W"]); + + let sender_c = sender.clone(); + let about_action: RelmAction = RelmAction::new_stateless(move |_| { + sender_c.input(ActionsHeaderInput::OpenAboutDialog); + }); + relm4::main_application() + .set_accelerators_for_action::(&["A"]); + + let mut group = RelmActionGroup::::new(); + group.add_action(new_action); + group.add_action(open_action); + group.add_action(save_action); + group.add_action(save_as_action); + group.add_action(close_action); + group.add_action(about_action); + group.register_for_widget(&widgets.end); + + ComponentParts { model, widgets } + } + + fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + message: Self::Input, + sender: ComponentSender, + root: &Self::Root, + ) { + match message { + ActionsHeaderInput::ChangeActionOpen(now) => { + self.action_open = now; + } + ActionsHeaderInput::ActionsMapChanged(new_map) => { + self.action_map = new_map; + } + ActionsHeaderInput::OpenAboutDialog => { + crate::next_ui::about::AppAbout::builder() + .transient_for(root) + .launch((self.engine_list.clone(), self.action_map.clone())) + .widget() + .set_visible(true); + } + ActionsHeaderInput::AddStep(step_id) => { + // close popover + self.add_button.popdown(); + // unwrap rationale: the receiver will never be disconnected + sender + .output(ActionsHeaderOutput::AddStep(step_id)) + .unwrap(); + } + ActionsHeaderInput::AddTopSearchResult => { + if let Some(result) = self.search_results.get(0) { + widgets.menu_popover.popdown(); + let id = result.value(); + // unwrap rationale: the receiver will never be disconnected + sender.output(ActionsHeaderOutput::AddStep(id)).unwrap(); + } + } + ActionsHeaderInput::SearchForSteps(query) => { + let mut results = self.search_results.guard(); + results.clear(); + + // Collect results + if query.is_empty() { + // List all alphabetically + let mut unsorted_results = vec![]; + for engine in self.engine_list.inner() { + for instruction in &engine.instructions { + unsorted_results.push(( + format!("{}: {}", engine.name, instruction.friendly_name()), + engine.name.clone(), + instruction.clone(), + )); + } + } + + // Sort + unsorted_results.sort_by(|(a, _, _), (b, _, _)| a.cmp(b)); + for (_, engine_name, ins) in unsorted_results { + results.push_back(AddStepInit { + label: format!("{engine_name}: {}", ins.friendly_name()), + value: ins.id().clone(), + }); + } + } else { + let mut unsorted_results = vec![]; + use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; + let matcher = SkimMatcherV2::default(); + for engine in self.engine_list.inner() { + for instruction in &engine.instructions { + if let Some(score) = matcher.fuzzy_match( + &format!("{}: {}", engine.name, instruction.friendly_name()), + &query, + ) { + unsorted_results.push(( + score, + engine.name.clone(), + instruction.clone(), + )); + } + } + } + + // Sort + unsorted_results.sort_by(|(a, _, _), (b, _, _)| a.cmp(b)); + for (_, engine_name, ins) in unsorted_results { + results.push_back(AddStepInit { + label: format!("{engine_name}: {}", ins.friendly_name()), + value: ins.id().clone(), + }); + } + } + } + } + self.update_view(widgets, sender); + } +} + +relm4::new_action_group!(ActionsActionGroup, "actions"); +relm4::new_stateless_action!(ActionsNewAction, ActionsActionGroup, "new"); +relm4::new_stateless_action!(ActionsOpenAction, ActionsActionGroup, "open"); +relm4::new_stateless_action!(ActionsSaveAction, ActionsActionGroup, "save"); +relm4::new_stateless_action!(ActionsSaveAsAction, ActionsActionGroup, "save-as"); +relm4::new_stateless_action!(ActionsCloseAction, ActionsActionGroup, "close"); +relm4::new_stateless_action!(ActionsAboutAction, ActionsActionGroup, "about"); diff --git a/testangel/src/next_ui/actions/instruction_component.rs b/testangel/src/next_ui/actions/instruction_component.rs index 79dddb8..02e955d 100644 --- a/testangel/src/next_ui/actions/instruction_component.rs +++ b/testangel/src/next_ui/actions/instruction_component.rs @@ -8,8 +8,8 @@ use relm4::{ prelude::{DynamicIndex, FactoryComponent}, RelmWidgetExt, }; -use testangel::types::{InstructionParameterSource, InstructionConfiguration}; -use testangel_ipc::prelude::{ParameterKind, ParameterValue, Instruction}; +use testangel::types::{InstructionConfiguration, InstructionParameterSource}; +use testangel_ipc::prelude::{Instruction, ParameterKind, ParameterValue}; use crate::next_ui::{ components::variable_row::{ @@ -33,7 +33,8 @@ pub struct InstructionComponent { visible: bool, possible_outputs: Vec<(String, ParameterKind, InstructionParameterSource)>, - variable_rows: FactoryVecDeque>, + variable_rows: + FactoryVecDeque>, /// True when a drag-and-drop operation is proposed to add a component above this one drop_proposed_above: bool, @@ -108,7 +109,7 @@ impl FactoryComponent for InstructionComponent { map } ), - set_description: Some(&self.instruction.description()), + set_description: Some(self.instruction.description()), #[watch] set_visible: self.visible, diff --git a/testangel/src/next_ui/actions/metadata_component.rs b/testangel/src/next_ui/actions/metadata_component.rs index dfd3c36..b56d498 100644 --- a/testangel/src/next_ui/actions/metadata_component.rs +++ b/testangel/src/next_ui/actions/metadata_component.rs @@ -1,129 +1,129 @@ -use relm4::{adw, gtk, Component}; -use adw::prelude::*; -use testangel::types::Action; - -use crate::next_ui::lang; - -#[derive(Debug)] -pub enum MetadataInput { - /// Inform the metadata component that the action has changed and as such - /// it should reload the metadata values - ChangeAction(Action), -} - -#[derive(Clone, Debug, Default)] -pub struct MetadataOutput { - pub new_name: Option, - pub new_group: Option, - pub new_author: Option, - pub new_description: Option, - pub new_visible: Option, -} - -#[derive(Debug)] -pub struct Metadata; - -#[relm4::component(pub)] -impl Component for Metadata { - type Init = (); - type Input = MetadataInput; - type Output = MetadataOutput; - type CommandOutput = (); - - view! { - adw::PreferencesGroup { - set_title: &lang::lookup("action-metadata-label"), - - #[name = "name"] - adw::EntryRow { - set_title: &lang::lookup("action-metadata-name"), - - connect_changed[sender] => move |entry| { - let _ = sender.output(MetadataOutput { - new_name: Some(entry.text().to_string()), - ..Default::default() - }); - }, - }, - #[name = "group"] - adw::EntryRow { - set_title: &lang::lookup("action-metadata-group"), - - connect_changed[sender] => move |entry| { - let _ = sender.output(MetadataOutput { - new_group: Some(entry.text().to_string()), - ..Default::default() - }); - }, - }, - #[name = "author"] - adw::EntryRow { - set_title: &lang::lookup("action-metadata-author"), - - connect_changed[sender] => move |entry| { - let _ = sender.output(MetadataOutput { - new_author: Some(entry.text().to_string()), - ..Default::default() - }); - }, - }, - #[name = "description"] - adw::EntryRow { - set_title: &lang::lookup("action-metadata-description"), - - connect_changed[sender] => move |entry| { - let _ = sender.output(MetadataOutput { - new_description: Some(entry.text().to_string()), - ..Default::default() - }); - }, - }, - adw::ActionRow { - set_title: &lang::lookup("action-metadata-visible"), - - #[name = "visible"] - add_suffix = >k::Switch { - connect_state_set[sender] => move |_switch, state| { - let _ = sender.output(MetadataOutput { - new_visible: Some(state), - ..Default::default() - }); - gtk::Inhibit(false) - }, - }, - - } - }, - } - - fn init( - _init: Self::Init, - root: &Self::Root, - sender: relm4::ComponentSender, - ) -> relm4::ComponentParts { - let model = Metadata; - let widgets = view_output!(); - - relm4::ComponentParts { model, widgets } - } - - fn update_with_view( - &mut self, - widgets: &mut Self::Widgets, - message: Self::Input, - sender: relm4::ComponentSender, - _root: &Self::Root, - ) { - match message { - MetadataInput::ChangeAction(action) => { - widgets.name.set_text(&action.friendly_name); - widgets.group.set_text(&action.group); - widgets.author.set_text(&action.author); - widgets.description.set_text(&action.description); - widgets.visible.set_active(action.visible); - } - } - - self.update_view(widgets, sender) - } -} +use adw::prelude::*; +use relm4::{adw, gtk, Component}; +use testangel::types::Action; + +use crate::next_ui::lang; + +#[derive(Debug)] +pub enum MetadataInput { + /// Inform the metadata component that the action has changed and as such + /// it should reload the metadata values + ChangeAction(Action), +} + +#[derive(Clone, Debug, Default)] +pub struct MetadataOutput { + pub new_name: Option, + pub new_group: Option, + pub new_author: Option, + pub new_description: Option, + pub new_visible: Option, +} + +#[derive(Debug)] +pub struct Metadata; + +#[relm4::component(pub)] +impl Component for Metadata { + type Init = (); + type Input = MetadataInput; + type Output = MetadataOutput; + type CommandOutput = (); + + view! { + adw::PreferencesGroup { + set_title: &lang::lookup("action-metadata-label"), + + #[name = "name"] + adw::EntryRow { + set_title: &lang::lookup("action-metadata-name"), + + connect_changed[sender] => move |entry| { + let _ = sender.output(MetadataOutput { + new_name: Some(entry.text().to_string()), + ..Default::default() + }); + }, + }, + #[name = "group"] + adw::EntryRow { + set_title: &lang::lookup("action-metadata-group"), + + connect_changed[sender] => move |entry| { + let _ = sender.output(MetadataOutput { + new_group: Some(entry.text().to_string()), + ..Default::default() + }); + }, + }, + #[name = "author"] + adw::EntryRow { + set_title: &lang::lookup("action-metadata-author"), + + connect_changed[sender] => move |entry| { + let _ = sender.output(MetadataOutput { + new_author: Some(entry.text().to_string()), + ..Default::default() + }); + }, + }, + #[name = "description"] + adw::EntryRow { + set_title: &lang::lookup("action-metadata-description"), + + connect_changed[sender] => move |entry| { + let _ = sender.output(MetadataOutput { + new_description: Some(entry.text().to_string()), + ..Default::default() + }); + }, + }, + adw::ActionRow { + set_title: &lang::lookup("action-metadata-visible"), + + #[name = "visible"] + add_suffix = >k::Switch { + connect_state_set[sender] => move |_switch, state| { + let _ = sender.output(MetadataOutput { + new_visible: Some(state), + ..Default::default() + }); + gtk::Inhibit(false) + }, + }, + + } + }, + } + + fn init( + _init: Self::Init, + root: &Self::Root, + sender: relm4::ComponentSender, + ) -> relm4::ComponentParts { + let model = Metadata; + let widgets = view_output!(); + + relm4::ComponentParts { model, widgets } + } + + fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + message: Self::Input, + sender: relm4::ComponentSender, + _root: &Self::Root, + ) { + match message { + MetadataInput::ChangeAction(action) => { + widgets.name.set_text(&action.friendly_name); + widgets.group.set_text(&action.group); + widgets.author.set_text(&action.author); + widgets.description.set_text(&action.description); + widgets.visible.set_active(action.visible); + } + } + + self.update_view(widgets, sender) + } +} diff --git a/testangel/src/next_ui/actions/mod.rs b/testangel/src/next_ui/actions/mod.rs index 90ddf3a..2bc1ccb 100644 --- a/testangel/src/next_ui/actions/mod.rs +++ b/testangel/src/next_ui/actions/mod.rs @@ -1,699 +1,730 @@ -use std::{collections::HashMap, fs, path::PathBuf, rc::Rc, sync::Arc}; - -use adw::prelude::*; -use relm4::{ - adw, component::Connector, factory::FactoryVecDeque, gtk, prelude::DynamicIndex, Component, - ComponentController, ComponentParts, ComponentSender, Controller, RelmWidgetExt, -}; -use testangel::{ - action_loader::ActionMap, - ipc::EngineList, - types::{VersionedFile, InstructionConfiguration, Action, InstructionParameterSource}, -}; - -use super::{file_filters, lang}; - -mod instruction_component; -mod execution_dialog; -pub mod header; -mod metadata_component; - -pub enum SaveOrOpenActionError { - IoError(std::io::Error), - ParsingError(ron::error::SpannedError), - SerializingError(ron::Error), - ActionNotVersionCompatible, - MissingInstruction(usize, String), -} - -impl ToString for SaveOrOpenActionError { - fn to_string(&self) -> String { - match self { - Self::IoError(e) => lang::lookup_with_args("action-save-open-error-io-error", { - let mut map = HashMap::new(); - map.insert("error", e.to_string().into()); - map - }), - Self::ParsingError(e) => { - lang::lookup_with_args("action-save-open-error-parsing-error", { - let mut map = HashMap::new(); - map.insert("error", e.to_string().into()); - map - }) - } - Self::SerializingError(e) => { - lang::lookup_with_args("action-save-open-error-serializing-error", { - let mut map = HashMap::new(); - map.insert("error", e.to_string().into()); - map - }) - } - Self::ActionNotVersionCompatible => { - lang::lookup("action-save-open-error-action-not-version-compatible") - } - Self::MissingInstruction(step, e) => { - lang::lookup_with_args("action-save-open-error-missing-instruction", { - let mut map = HashMap::new(); - map.insert("step", (step + 1).into()); - map.insert("error", e.to_string().into()); - map - }) - } - } - } -} - -#[derive(Clone, Debug)] -pub enum ActionInputs { - /// Do nothing - NoOp, - /// The map of actions has changed and should be updated - ActionsMapChanged(Arc), - /// Create a new action - NewAction, - /// Actually create the new action - _NewAction, - /// Prompt the user to open an action. This will ask to save first if needed. - OpenAction, - /// Actually show the user the open file dialog - _OpenAction, - /// Actually open an action after the user has finished selecting - __OpenAction(PathBuf), - /// Save the action, prompting if needed to set file path - SaveAction, - /// Save the action as a new file, always prompting for a file path - SaveAsAction, - /// Ask where to save if needed, then save - _SaveActionThen(Box), - /// Actually write the action to disk, then emit then input - __SaveActionThen(PathBuf, Box), - /// Close the action, prompting if needing to save first - CloseAction, - /// Actually close the action - _CloseAction, - /// Add the step with the ID provided - AddStep(String), - /// Update the UI steps from the open action. This will clear first and overwrite any changes! - UpdateStepsFromModel, - /// Remove the step with the provided index, resetting all references to it. - RemoveStep(DynamicIndex), - /// Remove the step with the provided index, but change references to it to a temporary value (`usize::MAX`) - /// that can be set again with [`ActionInputs::PasteStep`]. - /// This doesn't refresh the UI until Paste is called. - CutStep(DynamicIndex), - /// Insert a step at the specified index and set references back to the correct step. - /// This refreshes the UI. - PasteStep(usize, InstructionConfiguration), - /// Move a step from the index to a position offset (param 3) from a new index (param 2). - MoveStep(DynamicIndex, DynamicIndex, isize), - /// Show the action test dialog - RunAction, - /// The [`InstructionConfiguration`] has changed for the step indicated by the [`DynamicIndex`]. - /// This does not refresh the UI. - ConfigUpdate(DynamicIndex, InstructionConfiguration), - /// The metadata has been updated and the action should be updated to reflect that - MetadataUpdated(metadata_component::MetadataOutput), -} -#[derive(Clone, Debug)] -pub enum ActionOutputs { - /// Inform other parts that actions may have changed, reload them! - ReloadActions, -} - -#[derive(Debug)] -pub struct ActionsModel { - action_map: Arc, - engine_list: Arc, - - open_action: Option, - open_path: Option, - needs_saving: bool, - header: Rc>, - metadata: Controller, - live_instructions_list: FactoryVecDeque, - - execution_dialog: Option>, -} - -impl ActionsModel { - /// Get an [`Rc`] clone of the header controller - pub fn header_controller_rc(&self) -> Rc> { - self.header.clone() - } - - /// Create the absolute barebones of a message dialog, allowing for custom button and response mapping. - fn create_message_dialog_skeleton(&self, title: S, message: S) -> adw::MessageDialog - where - S: AsRef, - { - adw::MessageDialog::builder() - .transient_for(&self.header.widget().toplevel_window().expect( - "ActionsModel::create_message_dialog cannot be called until the header is attached", - )) - .title(title.as_ref()) - .heading(title.as_ref()) - .body(message.as_ref()) - .modal(true) - .build() - } - - /// Create a message dialog attached to the toplevel window. This includes default implementations of an 'OK' button. - fn create_message_dialog(&self, title: S, message: S) -> adw::MessageDialog - where - S: AsRef, - { - let dialog = self.create_message_dialog_skeleton(title, message); - dialog.add_response("ok", &lang::lookup("ok")); - dialog.set_default_response(Some("ok")); - dialog.set_close_response("ok"); - dialog - } - - /// Just open a brand new action - fn new_action(&mut self) { - self.open_path = None; - self.needs_saving = true; - self.open_action = Some(Action::default()); - self.header.emit(header::ActionsHeaderInput::ChangeActionOpen( - self.open_action.is_some(), - )); - } - - /// Open an action. This does not ask to save first. - fn open_action(&mut self, file: PathBuf) -> Result<(), SaveOrOpenActionError> { - let data = &fs::read_to_string(&file).map_err(SaveOrOpenActionError::IoError)?; - - let versioned_file: VersionedFile = - ron::from_str(data).map_err(SaveOrOpenActionError::ParsingError)?; - if versioned_file.version() != 1 { - return Err(SaveOrOpenActionError::ActionNotVersionCompatible); - } - - let mut action: Action = - ron::from_str(data).map_err(SaveOrOpenActionError::ParsingError)?; - if action.version() != 1 { - return Err(SaveOrOpenActionError::ActionNotVersionCompatible); - } - for (step, ic) in action.instructions.iter_mut().enumerate() { - if self.engine_list.get_engine_by_instruction_id(&ic.instruction_id).is_none() { - return Err(SaveOrOpenActionError::MissingInstruction( - step, - ic.instruction_id.clone(), - )) - } - } - - self.open_action = Some(action.clone()); - self.header.emit(header::ActionsHeaderInput::ChangeActionOpen( - self.open_action.is_some(), - )); - self.metadata.emit(metadata_component::MetadataInput::ChangeAction(action)); - self.open_path = Some(file); - self.needs_saving = false; - log::debug!("New action open."); - log::debug!("Action: {:?}", self.open_action); - Ok(()) - } - - /// Ask the user if they want to save this file. If they response yes, this will also trigger the save function. - /// This function will only ask the user if needed, otherwise it will emit immediately. - fn prompt_to_save(&self, sender: &relm4::Sender, then: ActionInputs) { - if self.needs_saving { - let question = self.create_message_dialog_skeleton( - lang::lookup("action-save-before"), - lang::lookup("action-save-before-message"), - ); - question.add_response("discard", &lang::lookup("discard")); - question.add_response("save", &lang::lookup("save")); - question.set_response_appearance("discard", adw::ResponseAppearance::Destructive); - question.set_default_response(Some("save")); - question.set_close_response("discard"); - let sender_c = sender.clone(); - let then_c = then.clone(); - question.connect_response(Some("save"), move |_, _| { - sender_c.emit(ActionInputs::_SaveActionThen(Box::new(then_c.clone()))); - }); - let sender_c = sender.clone(); - question.connect_response(Some("discard"), move |_, _| { - sender_c.emit(then.clone()); - }); - question.set_visible(true); - } else { - sender.emit(then); - } - } - - /// Ask the user where to save the flow, or just save if that's good enough - fn ask_where_to_save( - &mut self, - sender: &relm4::Sender, - transient_for: &impl IsA, - always_ask_where: bool, - then: ActionInputs, - ) { - if always_ask_where || self.open_path.is_none() { - // Ask where - let dialog = gtk::FileDialog::builder() - .modal(true) - .title(lang::lookup("action-header-save")) - .initial_folder(>k::gio::File::for_path( - std::env::var("TA_ACTION_DIR").unwrap_or("./actions".to_string()), - )) - .filters(&file_filters::filter_list(vec![ - file_filters::actions(), - file_filters::all(), - ])) - .build(); - - let sender_c = sender.clone(); - dialog.save( - Some(transient_for), - Some(&relm4::gtk::gio::Cancellable::new()), - move |res| { - if let Ok(file) = res { - let path = file.path().unwrap(); - sender_c.emit(ActionInputs::__SaveActionThen(path, Box::new(then.clone()))); - } - }, - ); - } else { - sender.emit(ActionInputs::__SaveActionThen( - self.open_path.clone().unwrap(), - Box::new(then), - )); - } - } - - /// Just save the action to disk with the current `open_path` as the destination - fn save_action(&mut self) -> Result<(), SaveOrOpenActionError> { - let save_path = self.open_path.as_ref().unwrap(); - let data = ron::to_string(self.open_action.as_ref().unwrap()) - .map_err(SaveOrOpenActionError::SerializingError)?; - fs::write(save_path, data).map_err(SaveOrOpenActionError::IoError)?; - self.needs_saving = false; - Ok(()) - } - - /// Close this action without checking first - fn close_action(&mut self) { - self.open_action = None; - self.open_path = None; - self.needs_saving = false; - self.live_instructions_list.guard().clear(); - self.header.emit(header::ActionsHeaderInput::ChangeActionOpen( - self.open_action.is_some(), - )); - } -} - -#[relm4::component(pub)] -impl Component for ActionsModel { - type Init = (Arc, Arc); - type Input = ActionInputs; - type Output = ActionOutputs; - type CommandOutput = (); - - view! { - #[root] - toast_target = adw::ToastOverlay { - gtk::ScrolledWindow { - set_vexpand: true, - set_hscrollbar_policy: gtk::PolicyType::Never, - - if model.open_action.is_none() { - adw::StatusPage { - set_title: &lang::lookup("nothing-open"), - set_description: Some(&lang::lookup("action-nothing-open-description")), - set_icon_name: Some(relm4_icons::icon_name::LIGHTBULB), - set_vexpand: true, - } - } else { - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_margin_all: 10, - set_spacing: 10, - - model.metadata.widget(), - - gtk::Separator { - set_orientation: gtk::Orientation::Horizontal, - }, - - #[local_ref] - live_instructions_list -> gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 5, - }, - } - } - }, - }, - } - - fn init( - init: Self::Init, - root: &Self::Root, - sender: ComponentSender, - ) -> ComponentParts { - let header = Rc::new( - header::ActionsHeader::builder() - .launch((init.1.clone(), init.0.clone())) - .forward(sender.input_sender(), |msg| match msg { - header::ActionsHeaderOutput::NewAction => ActionInputs::NewAction, - header::ActionsHeaderOutput::OpenAction => ActionInputs::OpenAction, - header::ActionsHeaderOutput::SaveAction => ActionInputs::SaveAction, - header::ActionsHeaderOutput::SaveAsAction => ActionInputs::SaveAsAction, - header::ActionsHeaderOutput::CloseAction => ActionInputs::CloseAction, - header::ActionsHeaderOutput::RunAction => ActionInputs::RunAction, - header::ActionsHeaderOutput::AddStep(step) => ActionInputs::AddStep(step), - }), - ); - - let model = ActionsModel { - action_map: init.0, - engine_list: init.1, - open_action: None, - open_path: None, - needs_saving: false, - execution_dialog: None, - header, - live_instructions_list: FactoryVecDeque::new(gtk::Box::default(), sender.input_sender()), - metadata: metadata_component::Metadata::builder().launch(()).forward(sender.input_sender(), |msg| ActionInputs::MetadataUpdated(msg)), - }; - - // Trigger update actions from model - sender.input(ActionInputs::UpdateStepsFromModel); - - let live_instructions_list = model.live_instructions_list.widget(); - let widgets = view_output!(); - - ComponentParts { model, widgets } - } - - fn update_with_view( - &mut self, - widgets: &mut Self::Widgets, - message: Self::Input, - sender: ComponentSender, - root: &Self::Root, - ) { - match message { - ActionInputs::NoOp => (), - - ActionInputs::MetadataUpdated(meta) => { - if let Some(action) = self.open_action.as_mut() { - if let Some(new_name) = meta.new_name { - action.friendly_name = new_name; - } - if let Some(new_group) = meta.new_group { - action.group = new_group; - } - if let Some(new_author) = meta.new_author { - action.author = new_author; - } - if let Some(new_description) = meta.new_description { - action.description = new_description; - } - if let Some(new_visible) = meta.new_visible { - action.visible = new_visible; - } - } - } - - ActionInputs::ActionsMapChanged(new_map) => { - self.action_map = new_map.clone(); - self.header.emit(header::ActionsHeaderInput::ActionsMapChanged(new_map)); - } - ActionInputs::ConfigUpdate(step, new_config) => { - // unwrap rationale: config updates can't happen if nothing is open - let action = self.open_action.as_mut().unwrap(); - action.instructions[step.current_index()] = new_config; - } - ActionInputs::NewAction => { - self.prompt_to_save(sender.input_sender(), ActionInputs::_NewAction); - } - ActionInputs::_NewAction => { - self.new_action(); - } - ActionInputs::OpenAction => { - self.prompt_to_save(sender.input_sender(), ActionInputs::_OpenAction); - } - ActionInputs::_OpenAction => { - let dialog = gtk::FileDialog::builder() - .modal(true) - .title(lang::lookup("action-header-open")) - .filters(&file_filters::filter_list(vec![ - file_filters::actions(), - file_filters::all(), - ])) - .initial_folder(>k::gio::File::for_path( - std::env::var("TA_ACTION_DIR").unwrap_or("./actions".to_string()), - )) - .build(); - - let sender_c = sender.clone(); - dialog.open( - Some(&root.toplevel_window().unwrap()), - Some(&relm4::gtk::gio::Cancellable::new()), - move |res| { - if let Ok(file) = res { - let path = file.path().unwrap(); - sender_c.input(ActionInputs::__OpenAction(path)); - } - }, - ); - } - ActionInputs::__OpenAction(path) => { - match self.open_action(path) { - Ok(_) => { - // Reload UI - sender.input(ActionInputs::UpdateStepsFromModel); - } - Err(e) => { - // Show error dialog - self.create_message_dialog( - lang::lookup("action-error-opening"), - e.to_string(), - ) - .set_visible(true); - } - } - } - ActionInputs::SaveAction => { - if self.open_action.is_some() { - // unwrap rationale: this cannot be triggered if not attached to a window - self.ask_where_to_save( - sender.input_sender(), - &root.toplevel_window().unwrap(), - false, - ActionInputs::NoOp, - ); - } - } - ActionInputs::SaveAsAction => { - if self.open_action.is_some() { - // unwrap rationale: this cannot be triggered if not attached to a window - self.ask_where_to_save( - sender.input_sender(), - &root.toplevel_window().unwrap(), - true, - ActionInputs::NoOp, - ); - } - } - ActionInputs::_SaveActionThen(then) => { - // unwrap rationale: this cannot be triggered if not attached to a window - self.ask_where_to_save( - sender.input_sender(), - &root.toplevel_window().unwrap(), - false, - *then, - ); - } - ActionInputs::__SaveActionThen(path, then) => { - self.open_path = Some(path); - if let Err(e) = self.save_action() { - self.create_message_dialog(lang::lookup("action-error-saving"), e.to_string()) - .set_visible(true); - } else { - widgets - .toast_target - .add_toast(adw::Toast::new(&lang::lookup("action-saved"))); - sender.input_sender().emit(*then); - } - let _ = sender.output(ActionOutputs::ReloadActions); - } - ActionInputs::CloseAction => { - self.prompt_to_save(sender.input_sender(), ActionInputs::_CloseAction); - } - ActionInputs::_CloseAction => { - self.close_action(); - } - - ActionInputs::RunAction => { - if let Some(action) = &self.open_action { - let e_dialog = execution_dialog::ExecutionDialog::builder() - .transient_for(root) - .launch(execution_dialog::ExecutionDialogInit { - action: action.clone(), - engine_list: self.engine_list.clone(), - action_map: self.action_map.clone(), - }); - let dialog = e_dialog.widget(); - dialog.set_modal(true); - dialog.set_visible(true); - self.execution_dialog = Some(e_dialog); - } - } - - ActionInputs::AddStep(step_id) => { - if self.open_action.is_none() { - self.new_action(); - } - - // unwrap rationale: we've just guaranteed a flow is open - let action = self.open_action.as_mut().unwrap(); - // unwrap rationale: the header can't ask to add an action that doesn't exist - action.instructions.push(InstructionConfiguration::from( - self.engine_list.get_instruction_by_id(&step_id).unwrap(), - )); - // Trigger UI steps refresh - sender.input(ActionInputs::UpdateStepsFromModel); - } - - ActionInputs::UpdateStepsFromModel => { - let mut live_list = self.live_instructions_list.guard(); - live_list.clear(); - if let Some(action) = &self.open_action { - let mut possible_outputs = vec![]; - for (step, config) in action.instructions.iter().enumerate() { - live_list.push_back(instruction_component::InstructionComponentInitialiser { - possible_outputs: possible_outputs.clone(), - config: config.clone(), - instruction: self.engine_list.get_instruction_by_id(&config.instruction_id).unwrap(), // rationale: we have already checked the actions are here when the file is opened - }); - // add possible outputs to list AFTER processing this step - // unwrap rationale: actions are check to exist prior to opening. - for (output_id, (name, kind)) in self - .engine_list - .get_instruction_by_id(&config.instruction_id) - .unwrap() - .outputs() - .iter() - { - possible_outputs.push(( - lang::lookup_with_args("source-from-step", { - let mut map = HashMap::new(); - map.insert("step", (step + 1).into()); - map.insert("name", name.clone().into()); - map - }), - *kind, - InstructionParameterSource::FromOutput(step, output_id.clone()), - )); - } - } - } - } - - ActionInputs::RemoveStep(step_idx) => { - let idx = step_idx.current_index(); - let action = self.open_action.as_mut().unwrap(); - - // This is needed as sometimes, if a menu item lines up above the delete step button, - // they can both be simultaneously triggered. - if idx >= action.instructions.len() { - log::warn!("Skipped running RemoveStep as the index was invalid."); - return; - } - - log::info!("Deleting step {}", idx + 1); - - action.instructions.remove(idx); - - // Remove references to step and renumber references above step to one less than they were - for step in action.instructions.iter_mut() { - for (_step_idx, source) in step.parameter_sources.iter_mut() { - if let InstructionParameterSource::FromOutput(from_step, _output_idx) = source { - match (*from_step).cmp(&idx) { - std::cmp::Ordering::Equal => { - *source = InstructionParameterSource::Literal - } - std::cmp::Ordering::Greater => *from_step -= 1, - _ => (), - } - } - } - } - - // Trigger UI steps refresh - sender.input(ActionInputs::UpdateStepsFromModel); - } - ActionInputs::CutStep(step_idx) => { - let idx = step_idx.current_index(); - let action = self.open_action.as_mut().unwrap(); - log::info!("Cut step {}", idx + 1); - - // This is needed as sometimes, if a menu item lines up above a button that triggers this, - // they can both be simultaneously triggered. - if idx >= action.instructions.len() { - log::warn!("Skipped running CutStep as the index was invalid."); - return; - } - - action.instructions.remove(idx); - - // Remove references to step and renumber references above step to one less than they were - for step in action.instructions.iter_mut() { - for (_step_idx, source) in step.parameter_sources.iter_mut() { - if let InstructionParameterSource::FromOutput(from_step, _output_idx) = source { - match (*from_step).cmp(&idx) { - std::cmp::Ordering::Equal => *from_step = usize::MAX, - std::cmp::Ordering::Greater => *from_step -= 1, - _ => (), - } - } - } - } - } - ActionInputs::PasteStep(idx, config) => { - let action = self.open_action.as_mut().unwrap(); - let idx = idx.max(0).min(action.instructions.len()); - log::info!("Pasting step to {}", idx + 1); - action.instructions.insert(idx, config); - - // Remove references to step and renumber references above step to one less than they were - for (step_idx, step) in action.instructions.iter_mut().enumerate() { - for (_param_idx, source) in step.parameter_sources.iter_mut() { - if let InstructionParameterSource::FromOutput(from_step, _output_idx) = source { - if *from_step == usize::MAX { - if step_idx < idx { - // can't refer to it anymore - *source = InstructionParameterSource::Literal; - } else { - *from_step = idx; - } - } else if *from_step >= idx { - *from_step += 1; - } - } - } - } - - // Trigger UI steps refresh - sender.input(ActionInputs::UpdateStepsFromModel); - } - ActionInputs::MoveStep(from, to, offset) => { - let current_from = from.current_index(); - let step = self.open_action.as_ref().unwrap().instructions[current_from].clone(); - sender.input(ActionInputs::CutStep(from)); - let mut to = (to.current_index() as isize + offset).max(0) as usize; - if to > current_from && to > 0 { - to -= 1; - } - sender.input(ActionInputs::PasteStep(to, step)); - } - } - self.update_view(widgets, sender); - } -} +use std::{collections::HashMap, fs, path::PathBuf, rc::Rc, sync::Arc}; + +use adw::prelude::*; +use relm4::{ + adw, component::Connector, factory::FactoryVecDeque, gtk, prelude::DynamicIndex, Component, + ComponentController, ComponentParts, ComponentSender, Controller, RelmWidgetExt, +}; +use testangel::{ + action_loader::ActionMap, + ipc::EngineList, + types::{Action, InstructionConfiguration, InstructionParameterSource, VersionedFile}, +}; + +use super::{file_filters, lang}; + +mod execution_dialog; +pub mod header; +mod instruction_component; +mod metadata_component; + +pub enum SaveOrOpenActionError { + IoError(std::io::Error), + ParsingError(ron::error::SpannedError), + SerializingError(ron::Error), + ActionNotVersionCompatible, + MissingInstruction(usize, String), +} + +impl ToString for SaveOrOpenActionError { + fn to_string(&self) -> String { + match self { + Self::IoError(e) => lang::lookup_with_args("action-save-open-error-io-error", { + let mut map = HashMap::new(); + map.insert("error", e.to_string().into()); + map + }), + Self::ParsingError(e) => { + lang::lookup_with_args("action-save-open-error-parsing-error", { + let mut map = HashMap::new(); + map.insert("error", e.to_string().into()); + map + }) + } + Self::SerializingError(e) => { + lang::lookup_with_args("action-save-open-error-serializing-error", { + let mut map = HashMap::new(); + map.insert("error", e.to_string().into()); + map + }) + } + Self::ActionNotVersionCompatible => { + lang::lookup("action-save-open-error-action-not-version-compatible") + } + Self::MissingInstruction(step, e) => { + lang::lookup_with_args("action-save-open-error-missing-instruction", { + let mut map = HashMap::new(); + map.insert("step", (step + 1).into()); + map.insert("error", e.to_string().into()); + map + }) + } + } + } +} + +#[derive(Clone, Debug)] +pub enum ActionInputs { + /// Do nothing + NoOp, + /// The map of actions has changed and should be updated + ActionsMapChanged(Arc), + /// Create a new action + NewAction, + /// Actually create the new action + _NewAction, + /// Prompt the user to open an action. This will ask to save first if needed. + OpenAction, + /// Actually show the user the open file dialog + _OpenAction, + /// Actually open an action after the user has finished selecting + __OpenAction(PathBuf), + /// Save the action, prompting if needed to set file path + SaveAction, + /// Save the action as a new file, always prompting for a file path + SaveAsAction, + /// Ask where to save if needed, then save + _SaveActionThen(Box), + /// Actually write the action to disk, then emit then input + __SaveActionThen(PathBuf, Box), + /// Close the action, prompting if needing to save first + CloseAction, + /// Actually close the action + _CloseAction, + /// Add the step with the ID provided + AddStep(String), + /// Update the UI steps from the open action. This will clear first and overwrite any changes! + UpdateStepsFromModel, + /// Remove the step with the provided index, resetting all references to it. + RemoveStep(DynamicIndex), + /// Remove the step with the provided index, but change references to it to a temporary value (`usize::MAX`) + /// that can be set again with [`ActionInputs::PasteStep`]. + /// This doesn't refresh the UI until Paste is called. + CutStep(DynamicIndex), + /// Insert a step at the specified index and set references back to the correct step. + /// This refreshes the UI. + PasteStep(usize, InstructionConfiguration), + /// Move a step from the index to a position offset (param 3) from a new index (param 2). + MoveStep(DynamicIndex, DynamicIndex, isize), + /// Show the action test dialog + RunAction, + /// The [`InstructionConfiguration`] has changed for the step indicated by the [`DynamicIndex`]. + /// This does not refresh the UI. + ConfigUpdate(DynamicIndex, InstructionConfiguration), + /// The metadata has been updated and the action should be updated to reflect that + MetadataUpdated(metadata_component::MetadataOutput), +} +#[derive(Clone, Debug)] +pub enum ActionOutputs { + /// Inform other parts that actions may have changed, reload them! + ReloadActions, +} + +#[derive(Debug)] +pub struct ActionsModel { + action_map: Arc, + engine_list: Arc, + + open_action: Option, + open_path: Option, + needs_saving: bool, + header: Rc>, + metadata: Controller, + live_instructions_list: FactoryVecDeque, + + execution_dialog: Option>, +} + +impl ActionsModel { + /// Get an [`Rc`] clone of the header controller + pub fn header_controller_rc(&self) -> Rc> { + self.header.clone() + } + + /// Create the absolute barebones of a message dialog, allowing for custom button and response mapping. + fn create_message_dialog_skeleton(&self, title: S, message: S) -> adw::MessageDialog + where + S: AsRef, + { + adw::MessageDialog::builder() + .transient_for(&self.header.widget().toplevel_window().expect( + "ActionsModel::create_message_dialog cannot be called until the header is attached", + )) + .title(title.as_ref()) + .heading(title.as_ref()) + .body(message.as_ref()) + .modal(true) + .build() + } + + /// Create a message dialog attached to the toplevel window. This includes default implementations of an 'OK' button. + fn create_message_dialog(&self, title: S, message: S) -> adw::MessageDialog + where + S: AsRef, + { + let dialog = self.create_message_dialog_skeleton(title, message); + dialog.add_response("ok", &lang::lookup("ok")); + dialog.set_default_response(Some("ok")); + dialog.set_close_response("ok"); + dialog + } + + /// Just open a brand new action + fn new_action(&mut self) { + self.open_path = None; + self.needs_saving = true; + self.open_action = Some(Action::default()); + self.header + .emit(header::ActionsHeaderInput::ChangeActionOpen( + self.open_action.is_some(), + )); + self.metadata + .emit(metadata_component::MetadataInput::ChangeAction( + Action::default(), + )); + } + + /// Open an action. This does not ask to save first. + fn open_action(&mut self, file: PathBuf) -> Result<(), SaveOrOpenActionError> { + let data = &fs::read_to_string(&file).map_err(SaveOrOpenActionError::IoError)?; + + let versioned_file: VersionedFile = + ron::from_str(data).map_err(SaveOrOpenActionError::ParsingError)?; + if versioned_file.version() != 1 { + return Err(SaveOrOpenActionError::ActionNotVersionCompatible); + } + + let mut action: Action = + ron::from_str(data).map_err(SaveOrOpenActionError::ParsingError)?; + if action.version() != 1 { + return Err(SaveOrOpenActionError::ActionNotVersionCompatible); + } + for (step, ic) in action.instructions.iter_mut().enumerate() { + if self + .engine_list + .get_engine_by_instruction_id(&ic.instruction_id) + .is_none() + { + return Err(SaveOrOpenActionError::MissingInstruction( + step, + ic.instruction_id.clone(), + )); + } + } + + self.open_action = Some(action.clone()); + self.header + .emit(header::ActionsHeaderInput::ChangeActionOpen( + self.open_action.is_some(), + )); + self.metadata + .emit(metadata_component::MetadataInput::ChangeAction(action)); + self.open_path = Some(file); + self.needs_saving = false; + log::debug!("New action open."); + log::debug!("Action: {:?}", self.open_action); + Ok(()) + } + + /// Ask the user if they want to save this file. If they response yes, this will also trigger the save function. + /// This function will only ask the user if needed, otherwise it will emit immediately. + fn prompt_to_save(&self, sender: &relm4::Sender, then: ActionInputs) { + if self.needs_saving { + let question = self.create_message_dialog_skeleton( + lang::lookup("action-save-before"), + lang::lookup("action-save-before-message"), + ); + question.add_response("discard", &lang::lookup("discard")); + question.add_response("save", &lang::lookup("save")); + question.set_response_appearance("discard", adw::ResponseAppearance::Destructive); + question.set_default_response(Some("save")); + question.set_close_response("discard"); + let sender_c = sender.clone(); + let then_c = then.clone(); + question.connect_response(Some("save"), move |_, _| { + sender_c.emit(ActionInputs::_SaveActionThen(Box::new(then_c.clone()))); + }); + let sender_c = sender.clone(); + question.connect_response(Some("discard"), move |_, _| { + sender_c.emit(then.clone()); + }); + question.set_visible(true); + } else { + sender.emit(then); + } + } + + /// Ask the user where to save the flow, or just save if that's good enough + fn ask_where_to_save( + &mut self, + sender: &relm4::Sender, + transient_for: &impl IsA, + always_ask_where: bool, + then: ActionInputs, + ) { + if always_ask_where || self.open_path.is_none() { + // Ask where + let dialog = gtk::FileDialog::builder() + .modal(true) + .title(lang::lookup("action-header-save")) + .initial_folder(>k::gio::File::for_path( + std::env::var("TA_ACTION_DIR").unwrap_or("./actions".to_string()), + )) + .filters(&file_filters::filter_list(vec![ + file_filters::actions(), + file_filters::all(), + ])) + .build(); + + let sender_c = sender.clone(); + dialog.save( + Some(transient_for), + Some(&relm4::gtk::gio::Cancellable::new()), + move |res| { + if let Ok(file) = res { + let path = file.path().unwrap(); + sender_c.emit(ActionInputs::__SaveActionThen(path, Box::new(then.clone()))); + } + }, + ); + } else { + sender.emit(ActionInputs::__SaveActionThen( + self.open_path.clone().unwrap(), + Box::new(then), + )); + } + } + + /// Just save the action to disk with the current `open_path` as the destination + fn save_action(&mut self) -> Result<(), SaveOrOpenActionError> { + let save_path = self.open_path.as_ref().unwrap(); + let data = ron::to_string(self.open_action.as_ref().unwrap()) + .map_err(SaveOrOpenActionError::SerializingError)?; + fs::write(save_path, data).map_err(SaveOrOpenActionError::IoError)?; + self.needs_saving = false; + Ok(()) + } + + /// Close this action without checking first + fn close_action(&mut self) { + self.open_action = None; + self.open_path = None; + self.needs_saving = false; + self.live_instructions_list.guard().clear(); + self.header + .emit(header::ActionsHeaderInput::ChangeActionOpen( + self.open_action.is_some(), + )); + } +} + +#[relm4::component(pub)] +impl Component for ActionsModel { + type Init = (Arc, Arc); + type Input = ActionInputs; + type Output = ActionOutputs; + type CommandOutput = (); + + view! { + #[root] + toast_target = adw::ToastOverlay { + gtk::ScrolledWindow { + set_vexpand: true, + set_hscrollbar_policy: gtk::PolicyType::Never, + + if model.open_action.is_none() { + adw::StatusPage { + set_title: &lang::lookup("nothing-open"), + set_description: Some(&lang::lookup("action-nothing-open-description")), + set_icon_name: Some(relm4_icons::icon_name::LIGHTBULB), + set_vexpand: true, + } + } else { + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_margin_all: 10, + set_spacing: 10, + + model.metadata.widget(), + + gtk::Separator { + set_orientation: gtk::Orientation::Horizontal, + }, + + #[local_ref] + live_instructions_list -> gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 5, + }, + } + } + }, + }, + } + + fn init( + init: Self::Init, + root: &Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let header = Rc::new( + header::ActionsHeader::builder() + .launch((init.1.clone(), init.0.clone())) + .forward(sender.input_sender(), |msg| match msg { + header::ActionsHeaderOutput::NewAction => ActionInputs::NewAction, + header::ActionsHeaderOutput::OpenAction => ActionInputs::OpenAction, + header::ActionsHeaderOutput::SaveAction => ActionInputs::SaveAction, + header::ActionsHeaderOutput::SaveAsAction => ActionInputs::SaveAsAction, + header::ActionsHeaderOutput::CloseAction => ActionInputs::CloseAction, + header::ActionsHeaderOutput::RunAction => ActionInputs::RunAction, + header::ActionsHeaderOutput::AddStep(step) => ActionInputs::AddStep(step), + }), + ); + + let model = ActionsModel { + action_map: init.0, + engine_list: init.1, + open_action: None, + open_path: None, + needs_saving: false, + execution_dialog: None, + header, + live_instructions_list: FactoryVecDeque::new( + gtk::Box::default(), + sender.input_sender(), + ), + metadata: metadata_component::Metadata::builder() + .launch(()) + .forward(sender.input_sender(), |msg| { + ActionInputs::MetadataUpdated(msg) + }), + }; + + // Trigger update actions from model + sender.input(ActionInputs::UpdateStepsFromModel); + + let live_instructions_list = model.live_instructions_list.widget(); + let widgets = view_output!(); + + ComponentParts { model, widgets } + } + + fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + message: Self::Input, + sender: ComponentSender, + root: &Self::Root, + ) { + match message { + ActionInputs::NoOp => (), + + ActionInputs::MetadataUpdated(meta) => { + if let Some(action) = self.open_action.as_mut() { + if let Some(new_name) = meta.new_name { + action.friendly_name = new_name; + } + if let Some(new_group) = meta.new_group { + action.group = new_group; + } + if let Some(new_author) = meta.new_author { + action.author = new_author; + } + if let Some(new_description) = meta.new_description { + action.description = new_description; + } + if let Some(new_visible) = meta.new_visible { + action.visible = new_visible; + } + } + } + + ActionInputs::ActionsMapChanged(new_map) => { + self.action_map = new_map.clone(); + self.header + .emit(header::ActionsHeaderInput::ActionsMapChanged(new_map)); + } + ActionInputs::ConfigUpdate(step, new_config) => { + // unwrap rationale: config updates can't happen if nothing is open + let action = self.open_action.as_mut().unwrap(); + action.instructions[step.current_index()] = new_config; + } + ActionInputs::NewAction => { + self.prompt_to_save(sender.input_sender(), ActionInputs::_NewAction); + } + ActionInputs::_NewAction => { + self.new_action(); + } + ActionInputs::OpenAction => { + self.prompt_to_save(sender.input_sender(), ActionInputs::_OpenAction); + } + ActionInputs::_OpenAction => { + let dialog = gtk::FileDialog::builder() + .modal(true) + .title(lang::lookup("action-header-open")) + .filters(&file_filters::filter_list(vec![ + file_filters::actions(), + file_filters::all(), + ])) + .initial_folder(>k::gio::File::for_path( + std::env::var("TA_ACTION_DIR").unwrap_or("./actions".to_string()), + )) + .build(); + + let sender_c = sender.clone(); + dialog.open( + Some(&root.toplevel_window().unwrap()), + Some(&relm4::gtk::gio::Cancellable::new()), + move |res| { + if let Ok(file) = res { + let path = file.path().unwrap(); + sender_c.input(ActionInputs::__OpenAction(path)); + } + }, + ); + } + ActionInputs::__OpenAction(path) => { + match self.open_action(path) { + Ok(_) => { + // Reload UI + sender.input(ActionInputs::UpdateStepsFromModel); + } + Err(e) => { + // Show error dialog + self.create_message_dialog( + lang::lookup("action-error-opening"), + e.to_string(), + ) + .set_visible(true); + } + } + } + ActionInputs::SaveAction => { + if self.open_action.is_some() { + // unwrap rationale: this cannot be triggered if not attached to a window + self.ask_where_to_save( + sender.input_sender(), + &root.toplevel_window().unwrap(), + false, + ActionInputs::NoOp, + ); + } + } + ActionInputs::SaveAsAction => { + if self.open_action.is_some() { + // unwrap rationale: this cannot be triggered if not attached to a window + self.ask_where_to_save( + sender.input_sender(), + &root.toplevel_window().unwrap(), + true, + ActionInputs::NoOp, + ); + } + } + ActionInputs::_SaveActionThen(then) => { + // unwrap rationale: this cannot be triggered if not attached to a window + self.ask_where_to_save( + sender.input_sender(), + &root.toplevel_window().unwrap(), + false, + *then, + ); + } + ActionInputs::__SaveActionThen(path, then) => { + self.open_path = Some(path); + if let Err(e) = self.save_action() { + self.create_message_dialog(lang::lookup("action-error-saving"), e.to_string()) + .set_visible(true); + } else { + widgets + .toast_target + .add_toast(adw::Toast::new(&lang::lookup("action-saved"))); + sender.input_sender().emit(*then); + } + let _ = sender.output(ActionOutputs::ReloadActions); + } + ActionInputs::CloseAction => { + self.prompt_to_save(sender.input_sender(), ActionInputs::_CloseAction); + } + ActionInputs::_CloseAction => { + self.close_action(); + } + + ActionInputs::RunAction => { + if let Some(action) = &self.open_action { + let e_dialog = execution_dialog::ExecutionDialog::builder() + .transient_for(root) + .launch(execution_dialog::ExecutionDialogInit { + action: action.clone(), + engine_list: self.engine_list.clone(), + action_map: self.action_map.clone(), + }); + let dialog = e_dialog.widget(); + dialog.set_modal(true); + dialog.set_visible(true); + self.execution_dialog = Some(e_dialog); + } + } + + ActionInputs::AddStep(step_id) => { + if self.open_action.is_none() { + self.new_action(); + } + + // unwrap rationale: we've just guaranteed a flow is open + let action = self.open_action.as_mut().unwrap(); + // unwrap rationale: the header can't ask to add an action that doesn't exist + action.instructions.push(InstructionConfiguration::from( + self.engine_list.get_instruction_by_id(&step_id).unwrap(), + )); + // Trigger UI steps refresh + sender.input(ActionInputs::UpdateStepsFromModel); + } + + ActionInputs::UpdateStepsFromModel => { + let mut live_list = self.live_instructions_list.guard(); + live_list.clear(); + if let Some(action) = &self.open_action { + let mut possible_outputs = vec![]; + for (step, config) in action.instructions.iter().enumerate() { + live_list.push_back( + instruction_component::InstructionComponentInitialiser { + possible_outputs: possible_outputs.clone(), + config: config.clone(), + instruction: self + .engine_list + .get_instruction_by_id(&config.instruction_id) + .unwrap(), // rationale: we have already checked the actions are here when the file is opened + }, + ); + // add possible outputs to list AFTER processing this step + // unwrap rationale: actions are check to exist prior to opening. + for (output_id, (name, kind)) in self + .engine_list + .get_instruction_by_id(&config.instruction_id) + .unwrap() + .outputs() + .iter() + { + possible_outputs.push(( + lang::lookup_with_args("source-from-step", { + let mut map = HashMap::new(); + map.insert("step", (step + 1).into()); + map.insert("name", name.clone().into()); + map + }), + *kind, + InstructionParameterSource::FromOutput(step, output_id.clone()), + )); + } + } + } + } + + ActionInputs::RemoveStep(step_idx) => { + let idx = step_idx.current_index(); + let action = self.open_action.as_mut().unwrap(); + + // This is needed as sometimes, if a menu item lines up above the delete step button, + // they can both be simultaneously triggered. + if idx >= action.instructions.len() { + log::warn!("Skipped running RemoveStep as the index was invalid."); + return; + } + + log::info!("Deleting step {}", idx + 1); + + action.instructions.remove(idx); + + // Remove references to step and renumber references above step to one less than they were + for step in action.instructions.iter_mut() { + for (_step_idx, source) in step.parameter_sources.iter_mut() { + if let InstructionParameterSource::FromOutput(from_step, _output_idx) = + source + { + match (*from_step).cmp(&idx) { + std::cmp::Ordering::Equal => { + *source = InstructionParameterSource::Literal + } + std::cmp::Ordering::Greater => *from_step -= 1, + _ => (), + } + } + } + } + + // Trigger UI steps refresh + sender.input(ActionInputs::UpdateStepsFromModel); + } + ActionInputs::CutStep(step_idx) => { + let idx = step_idx.current_index(); + let action = self.open_action.as_mut().unwrap(); + log::info!("Cut step {}", idx + 1); + + // This is needed as sometimes, if a menu item lines up above a button that triggers this, + // they can both be simultaneously triggered. + if idx >= action.instructions.len() { + log::warn!("Skipped running CutStep as the index was invalid."); + return; + } + + action.instructions.remove(idx); + + // Remove references to step and renumber references above step to one less than they were + for step in action.instructions.iter_mut() { + for (_step_idx, source) in step.parameter_sources.iter_mut() { + if let InstructionParameterSource::FromOutput(from_step, _output_idx) = + source + { + match (*from_step).cmp(&idx) { + std::cmp::Ordering::Equal => *from_step = usize::MAX, + std::cmp::Ordering::Greater => *from_step -= 1, + _ => (), + } + } + } + } + } + ActionInputs::PasteStep(idx, config) => { + let action = self.open_action.as_mut().unwrap(); + let idx = idx.max(0).min(action.instructions.len()); + log::info!("Pasting step to {}", idx + 1); + action.instructions.insert(idx, config); + + // Remove references to step and renumber references above step to one less than they were + for (step_idx, step) in action.instructions.iter_mut().enumerate() { + for (_param_idx, source) in step.parameter_sources.iter_mut() { + if let InstructionParameterSource::FromOutput(from_step, _output_idx) = + source + { + if *from_step == usize::MAX { + if step_idx < idx { + // can't refer to it anymore + *source = InstructionParameterSource::Literal; + } else { + *from_step = idx; + } + } else if *from_step >= idx { + *from_step += 1; + } + } + } + } + + // Trigger UI steps refresh + sender.input(ActionInputs::UpdateStepsFromModel); + } + ActionInputs::MoveStep(from, to, offset) => { + let current_from = from.current_index(); + let step = self.open_action.as_ref().unwrap().instructions[current_from].clone(); + sender.input(ActionInputs::CutStep(from)); + let mut to = (to.current_index() as isize + offset).max(0) as usize; + if to > current_from && to > 0 { + to -= 1; + } + sender.input(ActionInputs::PasteStep(to, step)); + } + } + self.update_view(widgets, sender); + } +} diff --git a/testangel/src/next_ui/components/mod.rs b/testangel/src/next_ui/components/mod.rs index ffb2ef9..9b61536 100644 --- a/testangel/src/next_ui/components/mod.rs +++ b/testangel/src/next_ui/components/mod.rs @@ -1,6 +1,6 @@ -/// A reusable input component. -pub mod literal_input; -/// A reusable row for input variables -pub mod variable_row; -/// A reusable list of items that returns a String value when selected. -pub mod add_step_factory; +/// A reusable list of items that returns a String value when selected. +pub mod add_step_factory; +/// A reusable input component. +pub mod literal_input; +/// A reusable row for input variables +pub mod variable_row; diff --git a/testangel/src/next_ui/components/variable_row.rs b/testangel/src/next_ui/components/variable_row.rs index 44c2227..01a6a49 100644 --- a/testangel/src/next_ui/components/variable_row.rs +++ b/testangel/src/next_ui/components/variable_row.rs @@ -1,271 +1,274 @@ -use std::marker::PhantomData; -use std::{collections::HashMap, fmt::Debug}; - -use adw::prelude::*; -use relm4::{ - adw, factory::FactoryVecDeque, gtk, prelude::FactoryComponent, Component, ComponentController, - Controller, FactorySender, -}; -use testangel_ipc::prelude::{ParameterKind, ParameterValue}; - -use crate::next_ui::{ - components::literal_input::{LiteralInput, LiteralInputOutput}, - lang, -}; - -#[derive(Debug)] -pub struct VariableRow -where - PS: Debug + Clone + 'static, - I: VariableRowParentInput, -{ - idx: T, - name: String, - kind: ParameterKind, - source: PS, - value: ParameterValue, - - literal_input: Controller, - potential_sources_raw: Vec<(String, PS)>, - potential_sources: FactoryVecDeque>, - _input_marker: PhantomData, -} - -pub struct VariableRowInit -where - PS: ParameterSourceTrait + Debug + std::fmt::Display + PartialEq + Clone + 'static, -{ - pub index: T, - pub name: String, - pub kind: ParameterKind, - pub current_source: PS, - pub current_value: ParameterValue, - pub potential_sources: Vec<(String, PS)>, -} - -pub trait VariableRowParentInput { - /// Replace the value of the source with the index `idx` - fn new_source_for(idx: T, new_source: PS) -> Self; - /// Replace the value of the variable with the index `idx` - fn new_value_for(idx: T, new_value: ParameterValue) -> Self; -} - -pub trait ParameterSourceTrait { - fn literal() -> Self; -} - -impl + ToString + Clone + Debug, T, I: VariableRowParentInput> - VariableRow -{ - fn get_nice_name_for(&self, source: &PS) -> String { - for (name, src) in &self.potential_sources_raw { - if *src == *source { - return name.clone(); - } - } - - source.to_string() - } -} - -#[derive(Debug)] -pub enum VariableRowInput { - SourceSelected(PS), - ChangeValue(ParameterValue), -} - -#[derive(Debug)] -pub enum VariableRowOutput { - NewSourceFor(T, PS), - NewValueFor(T, ParameterValue), -} - -#[relm4::factory(pub)] -impl FactoryComponent for VariableRow -where - PS: ParameterSourceTrait + Debug + std::fmt::Display + PartialEq + Clone + 'static, - I: Debug + VariableRowParentInput + 'static, - T: Clone + Debug + 'static, -{ - type Init = VariableRowInit; - type Input = VariableRowInput; - type Output = VariableRowOutput; - type CommandOutput = (); - type ParentWidget = adw::PreferencesGroup; - type ParentInput = I; - - view! { - adw::ActionRow { - set_title: &self.name, - #[watch] - set_subtitle: &if self.source == PS::literal() { - lang::lookup_with_args( - "variable-row-subtitle-with-value", - { - let mut map = HashMap::new(); - map.insert("kind", self.kind.to_string().into()); - map.insert("source", self.source.to_string().into()); - map.insert("value", self.value.to_string().into()); - map - } - ) - } else { - lang::lookup_with_args( - "variable-row-subtitle", - { - let mut map = HashMap::new(); - map.insert("kind", self.kind.to_string().into()); - map.insert("source", self.get_nice_name_for(&self.source).into()); - map - } - ) - }, - - add_suffix = >k::Box { - set_spacing: 15, - set_orientation: gtk::Orientation::Horizontal, - - adw::Bin { - #[watch] - set_visible: self.source == PS::literal(), - self.literal_input.widget(), - }, - - gtk::MenuButton { - set_icon_name: relm4_icons::icon_name::EDIT, - set_tooltip_text: Some(&lang::lookup("variable-row-edit-param")), - set_css_classes: &["flat"], - set_direction: gtk::ArrowType::Left, - - #[wrap(Some)] - set_popover = >k::Popover { - gtk::ScrolledWindow { - set_hscrollbar_policy: gtk::PolicyType::Never, - set_min_content_height: 150, - - #[local_ref] - potential_sources -> gtk::Box { - set_spacing: 5, - set_orientation: gtk::Orientation::Vertical, - }, - } - }, - }, - }, - } - } - - fn init_model( - init: Self::Init, - _index: &Self::Index, - sender: relm4::FactorySender, - ) -> Self { - let mut potential_sources = - FactoryVecDeque::new(gtk::Box::default(), sender.input_sender()); - { - // populate sources - let mut potential_sources = potential_sources.guard(); - for (label, source) in init.potential_sources.clone() { - potential_sources.push_back((label, source)); - } - } - - let literal_input = LiteralInput::builder() - .launch(init.current_value.clone()) - .forward(sender.input_sender(), |msg| match msg { - LiteralInputOutput::ValueChanged(new_value) => { - VariableRowInput::ChangeValue(new_value) - } - }); - - Self { - idx: init.index, - name: init.name, - kind: init.kind, - source: init.current_source, - value: init.current_value, - literal_input, - potential_sources_raw: init.potential_sources, - potential_sources, - _input_marker: PhantomData, - } - } - - fn init_widgets( - &mut self, - _index: &Self::Index, - root: &Self::Root, - _returned_widget: &::ReturnedWidget, - _sender: FactorySender, - ) -> Self::Widgets { - let potential_sources = self.potential_sources.widget(); - let widgets = view_output!(); - widgets - } - - fn update(&mut self, message: Self::Input, sender: FactorySender) { - match message { - VariableRowInput::SourceSelected(new_source) => { - self.source = new_source.clone(); - sender.output(VariableRowOutput::NewSourceFor(self.idx.clone(), new_source)); - } - VariableRowInput::ChangeValue(new_value) => { - self.value = new_value.clone(); - sender.output(VariableRowOutput::NewValueFor(self.idx.clone(), new_value)); - } - } - } - - fn forward_to_parent(output: Self::Output) -> Option { - match output { - VariableRowOutput::NewSourceFor(idx, source) => Some(I::new_source_for(idx, source)), - VariableRowOutput::NewValueFor(idx, value) => Some(I::new_value_for(idx, value)), - } - } -} - -#[derive(Debug)] -struct SourceSearchResult { - label: String, - source: PS, -} - -#[derive(Debug)] -enum SourceSearchResultInput { - Select, -} - -#[relm4::factory] -impl FactoryComponent for SourceSearchResult { - type Init = (String, PS); - type Input = SourceSearchResultInput; - type Output = PS; - type CommandOutput = (); - type ParentWidget = gtk::Box; - type ParentInput = VariableRowInput; - - view! { - root = gtk::Button::builder().css_classes(["flat"]).build() { - set_label: &self.label, - - connect_clicked => SourceSearchResultInput::Select, - } - } - - fn init_model(init: Self::Init, _index: &Self::Index, _sender: FactorySender) -> Self { - Self { - label: init.0, - source: init.1, - } - } - - fn update(&mut self, message: Self::Input, sender: FactorySender) { - match message { - SourceSearchResultInput::Select => sender.output(self.source.clone()), - } - } - - fn forward_to_parent(output: Self::Output) -> Option { - Some(VariableRowInput::SourceSelected(output)) - } -} +use std::marker::PhantomData; +use std::{collections::HashMap, fmt::Debug}; + +use adw::prelude::*; +use relm4::{ + adw, factory::FactoryVecDeque, gtk, prelude::FactoryComponent, Component, ComponentController, + Controller, FactorySender, +}; +use testangel_ipc::prelude::{ParameterKind, ParameterValue}; + +use crate::next_ui::{ + components::literal_input::{LiteralInput, LiteralInputOutput}, + lang, +}; + +#[derive(Debug)] +pub struct VariableRow +where + PS: Debug + Clone + 'static, + I: VariableRowParentInput, +{ + idx: T, + name: String, + kind: ParameterKind, + source: PS, + value: ParameterValue, + + literal_input: Controller, + potential_sources_raw: Vec<(String, PS)>, + potential_sources: FactoryVecDeque>, + _input_marker: PhantomData, +} + +pub struct VariableRowInit +where + PS: ParameterSourceTrait + Debug + std::fmt::Display + PartialEq + Clone + 'static, +{ + pub index: T, + pub name: String, + pub kind: ParameterKind, + pub current_source: PS, + pub current_value: ParameterValue, + pub potential_sources: Vec<(String, PS)>, +} + +pub trait VariableRowParentInput { + /// Replace the value of the source with the index `idx` + fn new_source_for(idx: T, new_source: PS) -> Self; + /// Replace the value of the variable with the index `idx` + fn new_value_for(idx: T, new_value: ParameterValue) -> Self; +} + +pub trait ParameterSourceTrait { + fn literal() -> Self; +} + +impl + ToString + Clone + Debug, T, I: VariableRowParentInput> + VariableRow +{ + fn get_nice_name_for(&self, source: &PS) -> String { + for (name, src) in &self.potential_sources_raw { + if *src == *source { + return name.clone(); + } + } + + source.to_string() + } +} + +#[derive(Debug)] +pub enum VariableRowInput { + SourceSelected(PS), + ChangeValue(ParameterValue), +} + +#[derive(Debug)] +pub enum VariableRowOutput { + NewSourceFor(T, PS), + NewValueFor(T, ParameterValue), +} + +#[relm4::factory(pub)] +impl FactoryComponent for VariableRow +where + PS: ParameterSourceTrait + Debug + std::fmt::Display + PartialEq + Clone + 'static, + I: Debug + VariableRowParentInput + 'static, + T: Clone + Debug + 'static, +{ + type Init = VariableRowInit; + type Input = VariableRowInput; + type Output = VariableRowOutput; + type CommandOutput = (); + type ParentWidget = adw::PreferencesGroup; + type ParentInput = I; + + view! { + adw::ActionRow { + set_title: &self.name, + #[watch] + set_subtitle: &if self.source == PS::literal() { + lang::lookup_with_args( + "variable-row-subtitle-with-value", + { + let mut map = HashMap::new(); + map.insert("kind", self.kind.to_string().into()); + map.insert("source", self.source.to_string().into()); + map.insert("value", self.value.to_string().into()); + map + } + ) + } else { + lang::lookup_with_args( + "variable-row-subtitle", + { + let mut map = HashMap::new(); + map.insert("kind", self.kind.to_string().into()); + map.insert("source", self.get_nice_name_for(&self.source).into()); + map + } + ) + }, + + add_suffix = >k::Box { + set_spacing: 15, + set_orientation: gtk::Orientation::Horizontal, + + adw::Bin { + #[watch] + set_visible: self.source == PS::literal(), + self.literal_input.widget(), + }, + + gtk::MenuButton { + set_icon_name: relm4_icons::icon_name::EDIT, + set_tooltip_text: Some(&lang::lookup("variable-row-edit-param")), + set_css_classes: &["flat"], + set_direction: gtk::ArrowType::Left, + + #[wrap(Some)] + set_popover = >k::Popover { + gtk::ScrolledWindow { + set_hscrollbar_policy: gtk::PolicyType::Never, + set_min_content_height: 150, + + #[local_ref] + potential_sources -> gtk::Box { + set_spacing: 5, + set_orientation: gtk::Orientation::Vertical, + }, + } + }, + }, + }, + } + } + + fn init_model( + init: Self::Init, + _index: &Self::Index, + sender: relm4::FactorySender, + ) -> Self { + let mut potential_sources = + FactoryVecDeque::new(gtk::Box::default(), sender.input_sender()); + { + // populate sources + let mut potential_sources = potential_sources.guard(); + for (label, source) in init.potential_sources.clone() { + potential_sources.push_back((label, source)); + } + } + + let literal_input = LiteralInput::builder() + .launch(init.current_value.clone()) + .forward(sender.input_sender(), |msg| match msg { + LiteralInputOutput::ValueChanged(new_value) => { + VariableRowInput::ChangeValue(new_value) + } + }); + + Self { + idx: init.index, + name: init.name, + kind: init.kind, + source: init.current_source, + value: init.current_value, + literal_input, + potential_sources_raw: init.potential_sources, + potential_sources, + _input_marker: PhantomData, + } + } + + fn init_widgets( + &mut self, + _index: &Self::Index, + root: &Self::Root, + _returned_widget: &::ReturnedWidget, + _sender: FactorySender, + ) -> Self::Widgets { + let potential_sources = self.potential_sources.widget(); + let widgets = view_output!(); + widgets + } + + fn update(&mut self, message: Self::Input, sender: FactorySender) { + match message { + VariableRowInput::SourceSelected(new_source) => { + self.source = new_source.clone(); + sender.output(VariableRowOutput::NewSourceFor( + self.idx.clone(), + new_source, + )); + } + VariableRowInput::ChangeValue(new_value) => { + self.value = new_value.clone(); + sender.output(VariableRowOutput::NewValueFor(self.idx.clone(), new_value)); + } + } + } + + fn forward_to_parent(output: Self::Output) -> Option { + match output { + VariableRowOutput::NewSourceFor(idx, source) => Some(I::new_source_for(idx, source)), + VariableRowOutput::NewValueFor(idx, value) => Some(I::new_value_for(idx, value)), + } + } +} + +#[derive(Debug)] +struct SourceSearchResult { + label: String, + source: PS, +} + +#[derive(Debug)] +enum SourceSearchResultInput { + Select, +} + +#[relm4::factory] +impl FactoryComponent for SourceSearchResult { + type Init = (String, PS); + type Input = SourceSearchResultInput; + type Output = PS; + type CommandOutput = (); + type ParentWidget = gtk::Box; + type ParentInput = VariableRowInput; + + view! { + root = gtk::Button::builder().css_classes(["flat"]).build() { + set_label: &self.label, + + connect_clicked => SourceSearchResultInput::Select, + } + } + + fn init_model(init: Self::Init, _index: &Self::Index, _sender: FactorySender) -> Self { + Self { + label: init.0, + source: init.1, + } + } + + fn update(&mut self, message: Self::Input, sender: FactorySender) { + match message { + SourceSearchResultInput::Select => sender.output(self.source.clone()), + } + } + + fn forward_to_parent(output: Self::Output) -> Option { + Some(VariableRowInput::SourceSelected(output)) + } +} diff --git a/testangel/src/next_ui/flows/header.rs b/testangel/src/next_ui/flows/header.rs index cfac4f5..2796b2b 100644 --- a/testangel/src/next_ui/flows/header.rs +++ b/testangel/src/next_ui/flows/header.rs @@ -1,321 +1,324 @@ -use std::sync::Arc; - -use adw::prelude::*; -use relm4::{ - actions::{AccelsPlus, RelmAction, RelmActionGroup}, - adw, - factory::FactoryVecDeque, - gtk, Component, ComponentController, ComponentParts, ComponentSender, RelmWidgetExt, -}; -use testangel::{action_loader::ActionMap, ipc::EngineList}; - -use crate::next_ui::{lang, components::add_step_factory::{AddStepResult, AddStepTrait, AddStepInit}}; - -#[derive(Debug)] -pub struct FlowsHeader { - engine_list: Arc, - action_map: Arc, - add_button: gtk::MenuButton, - flow_open: bool, - search_results: FactoryVecDeque>, -} - -#[derive(Debug)] -pub enum FlowsHeaderOutput { - NewFlow, - OpenFlow, - SaveFlow, - SaveAsFlow, - CloseFlow, - RunFlow, - AddStep(String), -} - -#[derive(Debug)] -pub enum FlowsHeaderInput { - OpenAboutDialog, - ActionsMapChanged(Arc), - /// Add the step with the action ID given - AddStep(String), - /// Trigger a search for the steps provided - SearchForSteps(String), - /// Add the top search result to the flow. - AddTopSearchResult, - /// Inform the header bar if a flow is open or not. - ChangeFlowOpen(bool), -} - -impl AddStepTrait for FlowsHeaderInput { - fn add_step(value: String) -> Self { - Self::AddStep(value) - } -} - -#[relm4::component(pub)] -impl Component for FlowsHeader { - type Init = (Arc, Arc); - type Input = FlowsHeaderInput; - type Output = FlowsHeaderOutput; - type CommandOutput = (); - - view! { - #[root] - #[name = "start"] - gtk::Box { - set_spacing: 5, - - #[local_ref] - add_button -> gtk::MenuButton { - set_icon_name: relm4_icons::icon_name::PLUS, - set_tooltip: &lang::lookup("flow-header-add"), - - #[wrap(Some)] - #[name = "menu_popover"] - set_popover = >k::Popover { - gtk::Box { - set_spacing: 2, - set_orientation: gtk::Orientation::Vertical, - - gtk::SearchEntry { - set_max_width_chars: 20, - - connect_activate[sender] => move |_| { - sender.input(FlowsHeaderInput::AddTopSearchResult); - }, - - connect_search_changed[sender] => move |slf| { - let query = slf.text().to_string(); - sender.input(FlowsHeaderInput::SearchForSteps(query)); - }, - }, - - gtk::ScrolledWindow { - set_hscrollbar_policy: gtk::PolicyType::Never, - set_min_content_height: 150, - - #[local_ref] - results_box -> gtk::Box { - set_spacing: 2, - set_orientation: gtk::Orientation::Vertical, - }, - }, - }, - }, - }, - gtk::Button { - set_icon_name: relm4_icons::icon_name::PLAY, - set_tooltip: &lang::lookup("flow-header-run"), - #[watch] - set_sensitive: model.flow_open, - connect_clicked[sender] => move |_| { - // unwrap rationale: receivers will never be dropped - sender.output(FlowsHeaderOutput::RunFlow).unwrap(); - }, - }, - }, - - #[name = "end"] - gtk::Box { - set_spacing: 5, - - gtk::MenuButton { - set_icon_name: relm4_icons::icon_name::MENU, - set_tooltip: &lang::lookup("flow-header-more"), - set_direction: gtk::ArrowType::Down, - - #[wrap(Some)] - set_popover = >k::PopoverMenu::from_model(Some(&flows_menu)) { - set_position: gtk::PositionType::Bottom, - }, - }, - }, - } - - menu! { - flows_menu: { - &lang::lookup("flow-header-new") => FlowsNewAction, - &lang::lookup("flow-header-open") => FlowsOpenAction, - &lang::lookup("flow-header-save") => FlowsSaveAction, - &lang::lookup("flow-header-save-as") => FlowsSaveAsAction, - &lang::lookup("flow-header-close") => FlowsCloseAction, - section! { - &lang::lookup("flow-header-about") => FlowsAboutAction, - } - } - } - - fn init( - init: Self::Init, - root: &Self::Root, - sender: ComponentSender, - ) -> ComponentParts { - let model = FlowsHeader { - engine_list: init.0, - action_map: init.1, - flow_open: false, - add_button: gtk::MenuButton::default(), - search_results: FactoryVecDeque::new(gtk::Box::default(), sender.input_sender()), - }; - // Reset search results - sender.input(FlowsHeaderInput::SearchForSteps(String::new())); - - let results_box = model.search_results.widget(); - let add_button = &model.add_button; - let widgets = view_output!(); - - let sender_c = sender.clone(); - let new_action: RelmAction = RelmAction::new_stateless(move |_| { - // unwrap rationale: receiver will never be disconnected - sender_c.output(FlowsHeaderOutput::NewFlow).unwrap(); - }); - relm4::main_application().set_accelerators_for_action::(&["N"]); - - let sender_c = sender.clone(); - let open_action: RelmAction = RelmAction::new_stateless(move |_| { - // unwrap rationale: receiver will never be disconnected - sender_c.output(FlowsHeaderOutput::OpenFlow).unwrap(); - }); - relm4::main_application().set_accelerators_for_action::(&["O"]); - - let sender_c = sender.clone(); - let save_action: RelmAction = RelmAction::new_stateless(move |_| { - // unwrap rationale: receiver will never be disconnected - sender_c.output(FlowsHeaderOutput::SaveFlow).unwrap(); - }); - relm4::main_application().set_accelerators_for_action::(&["S"]); - - let sender_c = sender.clone(); - let save_as_action: RelmAction = RelmAction::new_stateless(move |_| { - // unwrap rationale: receiver will never be disconnected - sender_c.output(FlowsHeaderOutput::SaveAsFlow).unwrap(); - }); - relm4::main_application() - .set_accelerators_for_action::(&["S"]); - - let sender_c = sender.clone(); - let close_action: RelmAction = RelmAction::new_stateless(move |_| { - // unwrap rationale: receiver will never be disconnected - sender_c.output(FlowsHeaderOutput::CloseFlow).unwrap(); - }); - relm4::main_application().set_accelerators_for_action::(&["W"]); - - let sender_c = sender.clone(); - let about_action: RelmAction = RelmAction::new_stateless(move |_| { - sender_c.input(FlowsHeaderInput::OpenAboutDialog); - }); - relm4::main_application().set_accelerators_for_action::(&["A"]); - - let mut group = RelmActionGroup::::new(); - group.add_action(new_action); - group.add_action(open_action); - group.add_action(save_action); - group.add_action(save_as_action); - group.add_action(close_action); - group.add_action(about_action); - group.register_for_widget(&widgets.end); - - ComponentParts { model, widgets } - } - - fn update_with_view( - &mut self, - widgets: &mut Self::Widgets, - message: Self::Input, - sender: ComponentSender, - root: &Self::Root, - ) { - match message { - FlowsHeaderInput::ChangeFlowOpen(now) => { - self.flow_open = now; - } - FlowsHeaderInput::OpenAboutDialog => { - crate::next_ui::about::AppAbout::builder() - .transient_for(root) - .launch((self.engine_list.clone(), self.action_map.clone())) - .widget() - .set_visible(true); - } - FlowsHeaderInput::ActionsMapChanged(new_map) => { - self.action_map = new_map; - } - FlowsHeaderInput::AddStep(step_id) => { - // close popover - self.add_button.popdown(); - // unwrap rationale: the receiver will never be disconnected - sender.output(FlowsHeaderOutput::AddStep(step_id)).unwrap(); - } - FlowsHeaderInput::AddTopSearchResult => { - if let Some(result) = self.search_results.get(0) { - widgets.menu_popover.popdown(); - let id = result.value(); - // unwrap rationale: the receiver will never be disconnected - sender.output(FlowsHeaderOutput::AddStep(id)).unwrap(); - } - } - FlowsHeaderInput::SearchForSteps(query) => { - let mut results = self.search_results.guard(); - results.clear(); - - let show_hidden = std::env::var("TA_SHOW_HIDDEN_ACTIONS") - .unwrap_or("no".to_string()) - .eq_ignore_ascii_case("yes"); - // Collect results - if query.is_empty() { - // List all alphabetically - let mut unsorted_results = vec![]; - for (group, actions) in self.action_map.get_by_group() { - for action in actions { - if action.visible || show_hidden { - unsorted_results - .push((format!("{group}: {}", action.friendly_name), action)); - } - } - } - - // Sort - unsorted_results.sort_by(|(a, _a), (b, _b)| a.cmp(b)); - for (_, a) in unsorted_results { - results.push_back(AddStepInit { - label: format!("{}: {}", a.group, a.friendly_name), - value: a.id, - }); - } - } else { - let mut unsorted_results = vec![]; - use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; - let matcher = SkimMatcherV2::default(); - for (group, actions) in self.action_map.get_by_group() { - for action in actions { - if action.visible || show_hidden { - if let Some(score) = matcher.fuzzy_match( - &format!("{group}: {}", action.friendly_name), - &query, - ) { - unsorted_results.push((score, action)); - } - } - } - } - - // Sort - unsorted_results.sort_by(|(a, _a), (b, _b)| a.cmp(b)); - for (_, a) in unsorted_results { - results.push_back(AddStepInit { - label: format!("{}: {}", a.group, a.friendly_name), - value: a.id, - }); - } - } - } - } - self.update_view(widgets, sender); - } -} - -relm4::new_action_group!(FlowsActionGroup, "flows"); -relm4::new_stateless_action!(FlowsNewAction, FlowsActionGroup, "new"); -relm4::new_stateless_action!(FlowsOpenAction, FlowsActionGroup, "open"); -relm4::new_stateless_action!(FlowsSaveAction, FlowsActionGroup, "save"); -relm4::new_stateless_action!(FlowsSaveAsAction, FlowsActionGroup, "save-as"); -relm4::new_stateless_action!(FlowsCloseAction, FlowsActionGroup, "close"); -relm4::new_stateless_action!(FlowsAboutAction, FlowsActionGroup, "about"); +use std::sync::Arc; + +use adw::prelude::*; +use relm4::{ + actions::{AccelsPlus, RelmAction, RelmActionGroup}, + adw, + factory::FactoryVecDeque, + gtk, Component, ComponentController, ComponentParts, ComponentSender, RelmWidgetExt, +}; +use testangel::{action_loader::ActionMap, ipc::EngineList}; + +use crate::next_ui::{ + components::add_step_factory::{AddStepInit, AddStepResult, AddStepTrait}, + lang, +}; + +#[derive(Debug)] +pub struct FlowsHeader { + engine_list: Arc, + action_map: Arc, + add_button: gtk::MenuButton, + flow_open: bool, + search_results: FactoryVecDeque>, +} + +#[derive(Debug)] +pub enum FlowsHeaderOutput { + NewFlow, + OpenFlow, + SaveFlow, + SaveAsFlow, + CloseFlow, + RunFlow, + AddStep(String), +} + +#[derive(Debug)] +pub enum FlowsHeaderInput { + OpenAboutDialog, + ActionsMapChanged(Arc), + /// Add the step with the action ID given + AddStep(String), + /// Trigger a search for the steps provided + SearchForSteps(String), + /// Add the top search result to the flow. + AddTopSearchResult, + /// Inform the header bar if a flow is open or not. + ChangeFlowOpen(bool), +} + +impl AddStepTrait for FlowsHeaderInput { + fn add_step(value: String) -> Self { + Self::AddStep(value) + } +} + +#[relm4::component(pub)] +impl Component for FlowsHeader { + type Init = (Arc, Arc); + type Input = FlowsHeaderInput; + type Output = FlowsHeaderOutput; + type CommandOutput = (); + + view! { + #[root] + #[name = "start"] + gtk::Box { + set_spacing: 5, + + #[local_ref] + add_button -> gtk::MenuButton { + set_icon_name: relm4_icons::icon_name::PLUS, + set_tooltip: &lang::lookup("flow-header-add"), + + #[wrap(Some)] + #[name = "menu_popover"] + set_popover = >k::Popover { + gtk::Box { + set_spacing: 2, + set_orientation: gtk::Orientation::Vertical, + + gtk::SearchEntry { + set_max_width_chars: 20, + + connect_activate[sender] => move |_| { + sender.input(FlowsHeaderInput::AddTopSearchResult); + }, + + connect_search_changed[sender] => move |slf| { + let query = slf.text().to_string(); + sender.input(FlowsHeaderInput::SearchForSteps(query)); + }, + }, + + gtk::ScrolledWindow { + set_hscrollbar_policy: gtk::PolicyType::Never, + set_min_content_height: 150, + + #[local_ref] + results_box -> gtk::Box { + set_spacing: 2, + set_orientation: gtk::Orientation::Vertical, + }, + }, + }, + }, + }, + gtk::Button { + set_icon_name: relm4_icons::icon_name::PLAY, + set_tooltip: &lang::lookup("flow-header-run"), + #[watch] + set_sensitive: model.flow_open, + connect_clicked[sender] => move |_| { + // unwrap rationale: receivers will never be dropped + sender.output(FlowsHeaderOutput::RunFlow).unwrap(); + }, + }, + }, + + #[name = "end"] + gtk::Box { + set_spacing: 5, + + gtk::MenuButton { + set_icon_name: relm4_icons::icon_name::MENU, + set_tooltip: &lang::lookup("flow-header-more"), + set_direction: gtk::ArrowType::Down, + + #[wrap(Some)] + set_popover = >k::PopoverMenu::from_model(Some(&flows_menu)) { + set_position: gtk::PositionType::Bottom, + }, + }, + }, + } + + menu! { + flows_menu: { + &lang::lookup("flow-header-new") => FlowsNewAction, + &lang::lookup("flow-header-open") => FlowsOpenAction, + &lang::lookup("flow-header-save") => FlowsSaveAction, + &lang::lookup("flow-header-save-as") => FlowsSaveAsAction, + &lang::lookup("flow-header-close") => FlowsCloseAction, + section! { + &lang::lookup("flow-header-about") => FlowsAboutAction, + } + } + } + + fn init( + init: Self::Init, + root: &Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let model = FlowsHeader { + engine_list: init.0, + action_map: init.1, + flow_open: false, + add_button: gtk::MenuButton::default(), + search_results: FactoryVecDeque::new(gtk::Box::default(), sender.input_sender()), + }; + // Reset search results + sender.input(FlowsHeaderInput::SearchForSteps(String::new())); + + let results_box = model.search_results.widget(); + let add_button = &model.add_button; + let widgets = view_output!(); + + let sender_c = sender.clone(); + let new_action: RelmAction = RelmAction::new_stateless(move |_| { + // unwrap rationale: receiver will never be disconnected + sender_c.output(FlowsHeaderOutput::NewFlow).unwrap(); + }); + relm4::main_application().set_accelerators_for_action::(&["N"]); + + let sender_c = sender.clone(); + let open_action: RelmAction = RelmAction::new_stateless(move |_| { + // unwrap rationale: receiver will never be disconnected + sender_c.output(FlowsHeaderOutput::OpenFlow).unwrap(); + }); + relm4::main_application().set_accelerators_for_action::(&["O"]); + + let sender_c = sender.clone(); + let save_action: RelmAction = RelmAction::new_stateless(move |_| { + // unwrap rationale: receiver will never be disconnected + sender_c.output(FlowsHeaderOutput::SaveFlow).unwrap(); + }); + relm4::main_application().set_accelerators_for_action::(&["S"]); + + let sender_c = sender.clone(); + let save_as_action: RelmAction = RelmAction::new_stateless(move |_| { + // unwrap rationale: receiver will never be disconnected + sender_c.output(FlowsHeaderOutput::SaveAsFlow).unwrap(); + }); + relm4::main_application() + .set_accelerators_for_action::(&["S"]); + + let sender_c = sender.clone(); + let close_action: RelmAction = RelmAction::new_stateless(move |_| { + // unwrap rationale: receiver will never be disconnected + sender_c.output(FlowsHeaderOutput::CloseFlow).unwrap(); + }); + relm4::main_application().set_accelerators_for_action::(&["W"]); + + let sender_c = sender.clone(); + let about_action: RelmAction = RelmAction::new_stateless(move |_| { + sender_c.input(FlowsHeaderInput::OpenAboutDialog); + }); + relm4::main_application().set_accelerators_for_action::(&["A"]); + + let mut group = RelmActionGroup::::new(); + group.add_action(new_action); + group.add_action(open_action); + group.add_action(save_action); + group.add_action(save_as_action); + group.add_action(close_action); + group.add_action(about_action); + group.register_for_widget(&widgets.end); + + ComponentParts { model, widgets } + } + + fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + message: Self::Input, + sender: ComponentSender, + root: &Self::Root, + ) { + match message { + FlowsHeaderInput::ChangeFlowOpen(now) => { + self.flow_open = now; + } + FlowsHeaderInput::OpenAboutDialog => { + crate::next_ui::about::AppAbout::builder() + .transient_for(root) + .launch((self.engine_list.clone(), self.action_map.clone())) + .widget() + .set_visible(true); + } + FlowsHeaderInput::ActionsMapChanged(new_map) => { + self.action_map = new_map; + } + FlowsHeaderInput::AddStep(step_id) => { + // close popover + self.add_button.popdown(); + // unwrap rationale: the receiver will never be disconnected + sender.output(FlowsHeaderOutput::AddStep(step_id)).unwrap(); + } + FlowsHeaderInput::AddTopSearchResult => { + if let Some(result) = self.search_results.get(0) { + widgets.menu_popover.popdown(); + let id = result.value(); + // unwrap rationale: the receiver will never be disconnected + sender.output(FlowsHeaderOutput::AddStep(id)).unwrap(); + } + } + FlowsHeaderInput::SearchForSteps(query) => { + let mut results = self.search_results.guard(); + results.clear(); + + let show_hidden = std::env::var("TA_SHOW_HIDDEN_ACTIONS") + .unwrap_or("no".to_string()) + .eq_ignore_ascii_case("yes"); + // Collect results + if query.is_empty() { + // List all alphabetically + let mut unsorted_results = vec![]; + for (group, actions) in self.action_map.get_by_group() { + for action in actions { + if action.visible || show_hidden { + unsorted_results + .push((format!("{group}: {}", action.friendly_name), action)); + } + } + } + + // Sort + unsorted_results.sort_by(|(a, _a), (b, _b)| a.cmp(b)); + for (_, a) in unsorted_results { + results.push_back(AddStepInit { + label: format!("{}: {}", a.group, a.friendly_name), + value: a.id, + }); + } + } else { + let mut unsorted_results = vec![]; + use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; + let matcher = SkimMatcherV2::default(); + for (group, actions) in self.action_map.get_by_group() { + for action in actions { + if action.visible || show_hidden { + if let Some(score) = matcher.fuzzy_match( + &format!("{group}: {}", action.friendly_name), + &query, + ) { + unsorted_results.push((score, action)); + } + } + } + } + + // Sort + unsorted_results.sort_by(|(a, _a), (b, _b)| a.cmp(b)); + for (_, a) in unsorted_results { + results.push_back(AddStepInit { + label: format!("{}: {}", a.group, a.friendly_name), + value: a.id, + }); + } + } + } + } + self.update_view(widgets, sender); + } +} + +relm4::new_action_group!(FlowsActionGroup, "flows"); +relm4::new_stateless_action!(FlowsNewAction, FlowsActionGroup, "new"); +relm4::new_stateless_action!(FlowsOpenAction, FlowsActionGroup, "open"); +relm4::new_stateless_action!(FlowsSaveAction, FlowsActionGroup, "save"); +relm4::new_stateless_action!(FlowsSaveAsAction, FlowsActionGroup, "save-as"); +relm4::new_stateless_action!(FlowsCloseAction, FlowsActionGroup, "close"); +relm4::new_stateless_action!(FlowsAboutAction, FlowsActionGroup, "about"); diff --git a/testangel/src/next_ui/header_bar.rs b/testangel/src/next_ui/header_bar.rs index f9c1db6..b05831d 100644 --- a/testangel/src/next_ui/header_bar.rs +++ b/testangel/src/next_ui/header_bar.rs @@ -1,97 +1,101 @@ -use std::rc::Rc; - -use gtk::prelude::*; -use relm4::{ - adw, gtk, Component, ComponentController, ComponentParts, ComponentSender, Controller, - RelmIterChildrenExt, -}; - -use super::{flows::header::FlowsHeader, actions::header::ActionsHeader}; - -#[derive(Debug)] -pub enum HeaderBarInput { - ChangedView(String), -} - -#[derive(Debug)] -pub struct HeaderBarModel { - action_header_rc: Rc>, - flow_header_rc: Rc>, -} - -impl HeaderBarModel { - fn swap_content(&mut self, swap_target: >k::Box, new_content: >k::Box) { - for child in swap_target.iter_children() { - swap_target.remove(&child); - } - swap_target.append(new_content); - } -} - -#[relm4::component(pub)] -impl Component for HeaderBarModel { - type Init = (Rc>, Rc>, Rc); - type Input = HeaderBarInput; - type Output = (); - type CommandOutput = (); - - view! { - #[root] - adw::HeaderBar { - #[name = "start_box"] - pack_start = >k::Box, - - #[wrap(Some)] - set_title_widget = &adw::ViewSwitcher { - #[local_ref] - #[wrap(Some)] - set_stack = stack -> adw::ViewStack, - }, - - #[name = "end_box"] - pack_end = >k::Box, - } - } - - fn init( - init: Self::Init, - root: &Self::Root, - _sender: ComponentSender, - ) -> ComponentParts { - let model = HeaderBarModel { - action_header_rc: init.0, - flow_header_rc: init.1, - }; - - let stack = &*init.2; - let widgets = view_output!(); - - ComponentParts { model, widgets } - } - - fn update_with_view( - &mut self, - widgets: &mut Self::Widgets, - message: Self::Input, - sender: ComponentSender, - _root: &Self::Root, - ) { - match message { - HeaderBarInput::ChangedView(new_view) => { - if new_view == "flows" { - let rc_clone = self.flow_header_rc.clone(); - self.swap_content(&widgets.start_box, &rc_clone.widgets().start); - self.swap_content(&widgets.end_box, &rc_clone.widgets().end); - } else if new_view == "actions" { - let rc_clone = self.action_header_rc.clone(); - self.swap_content(&widgets.start_box, &rc_clone.widgets().start); - self.swap_content(&widgets.end_box, &rc_clone.widgets().end); - } else { - self.swap_content(&widgets.start_box, >k::Box::builder().build()); - self.swap_content(&widgets.end_box, >k::Box::builder().build()); - } - } - } - self.update_view(widgets, sender); - } -} +use std::rc::Rc; + +use gtk::prelude::*; +use relm4::{ + adw, gtk, Component, ComponentController, ComponentParts, ComponentSender, Controller, + RelmIterChildrenExt, +}; + +use super::{actions::header::ActionsHeader, flows::header::FlowsHeader}; + +#[derive(Debug)] +pub enum HeaderBarInput { + ChangedView(String), +} + +#[derive(Debug)] +pub struct HeaderBarModel { + action_header_rc: Rc>, + flow_header_rc: Rc>, +} + +impl HeaderBarModel { + fn swap_content(&mut self, swap_target: >k::Box, new_content: >k::Box) { + for child in swap_target.iter_children() { + swap_target.remove(&child); + } + swap_target.append(new_content); + } +} + +#[relm4::component(pub)] +impl Component for HeaderBarModel { + type Init = ( + Rc>, + Rc>, + Rc, + ); + type Input = HeaderBarInput; + type Output = (); + type CommandOutput = (); + + view! { + #[root] + adw::HeaderBar { + #[name = "start_box"] + pack_start = >k::Box, + + #[wrap(Some)] + set_title_widget = &adw::ViewSwitcher { + #[local_ref] + #[wrap(Some)] + set_stack = stack -> adw::ViewStack, + }, + + #[name = "end_box"] + pack_end = >k::Box, + } + } + + fn init( + init: Self::Init, + root: &Self::Root, + _sender: ComponentSender, + ) -> ComponentParts { + let model = HeaderBarModel { + action_header_rc: init.0, + flow_header_rc: init.1, + }; + + let stack = &*init.2; + let widgets = view_output!(); + + ComponentParts { model, widgets } + } + + fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + message: Self::Input, + sender: ComponentSender, + _root: &Self::Root, + ) { + match message { + HeaderBarInput::ChangedView(new_view) => { + if new_view == "flows" { + let rc_clone = self.flow_header_rc.clone(); + self.swap_content(&widgets.start_box, &rc_clone.widgets().start); + self.swap_content(&widgets.end_box, &rc_clone.widgets().end); + } else if new_view == "actions" { + let rc_clone = self.action_header_rc.clone(); + self.swap_content(&widgets.start_box, &rc_clone.widgets().start); + self.swap_content(&widgets.end_box, &rc_clone.widgets().end); + } else { + self.swap_content(&widgets.start_box, >k::Box::builder().build()); + self.swap_content(&widgets.end_box, >k::Box::builder().build()); + } + } + } + self.update_view(widgets, sender); + } +} diff --git a/testangel/src/next_ui/mod.rs b/testangel/src/next_ui/mod.rs index 795d394..4f90448 100644 --- a/testangel/src/next_ui/mod.rs +++ b/testangel/src/next_ui/mod.rs @@ -1,181 +1,185 @@ -use std::{rc::Rc, sync::Arc}; - -use gtk::prelude::*; -use relm4::{ - adw, gtk, Component, ComponentController, ComponentParts, Controller, RelmApp, SimpleComponent, -}; -use testangel::{ - action_loader::{self, ActionMap}, - ipc::{self, EngineList}, -}; - -use self::header_bar::HeaderBarInput; - -mod about; -mod actions; -mod components; -mod file_filters; -mod flows; -mod header_bar; -pub(crate) mod lang; - -/// Initialise and open the UI. -pub fn initialise_ui() { - log::info!("Starting Next UI..."); - let app = RelmApp::new("lilopkins.testangel"); - relm4_icons::initialize_icons(); - initialise_icons(); - - let engines = Arc::new(ipc::get_engines()); - let actions = Arc::new(action_loader::get_actions(engines.clone())); - app.run::(AppInit { engines, actions }); -} - -fn initialise_icons() { - relm4::gtk::gio::resources_register_include!("icons.gresource").unwrap(); - log::info!("Loaded icon bundle."); - - let display = relm4::gtk::gdk::Display::default().unwrap(); - let theme = gtk::IconTheme::for_display(&display); - theme.add_resource_path("/uk/hpkns/testangel/icons"); -} - -pub struct AppInit { - engines: Arc, - actions: Arc, -} - -#[derive(Debug)] -enum AppInput { - NoOp, - /// The view has changed and should be read from visible_child_name, then components updated as needed. - ChangedView(Option), - /// The actions might have changed and should be reloaded - ReloadActionsMap, -} - -#[derive(Debug)] -struct AppModel { - stack: Rc, - header: Controller, - - flows: Controller, - actions: Controller, - - engines_list: Arc, - actions_map: Arc, -} - -#[relm4::component] -impl SimpleComponent for AppModel { - type Init = AppInit; - type Input = AppInput; - type Output = (); - - view! { - main_window = adw::Window { - set_title: Some(&lang::lookup("app-name")), - set_default_width: 800, - set_default_height: 600, - set_icon_name: Some("testangel"), - - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_spacing: 0, - - model.header.widget(), - - #[local_ref] - stack -> adw::ViewStack { - connect_visible_child_name_notify[sender] => move |st| { - sender.input(AppInput::ChangedView(st.visible_child_name().map(|s| s.into()))); - }, - }, - } - } - } - - fn init( - init: Self::Init, - root: &Self::Root, - sender: relm4::ComponentSender, - ) -> relm4::ComponentParts { - // Initialise the sub-components (pages) - let flows = flows::FlowsModel::builder() - .launch((init.actions.clone(), init.engines.clone())) - .forward(sender.input_sender(), |_msg| AppInput::NoOp); - let actions = actions::ActionsModel::builder() - .launch((init.actions.clone(), init.engines.clone())) - .forward(sender.input_sender(), |msg| match msg { - actions::ActionOutputs::ReloadActions => AppInput::ReloadActionsMap, - }); - - let stack = Rc::new(adw::ViewStack::new()); - - // Initialise the headerbar - let header = header_bar::HeaderBarModel::builder() - .launch((actions.model().header_controller_rc(), flows.model().header_controller_rc(), stack.clone())) - .forward(sender.input_sender(), |_msg| AppInput::NoOp); - - // Build model - let model = AppModel { - actions_map: init.actions, - engines_list: init.engines, - stack, - header, - flows, - actions, - }; - - // Render window parts - let stack = &*model.stack; - - // Add pages - stack.add_titled_with_icon( - model.flows.widget(), - Some("flows"), - &lang::lookup("tab-flows"), - relm4_icons::icon_name::PAPYRUS_VERTICAL, - ); - if !std::env::var("TA_HIDE_ACTION_EDITOR") - .unwrap_or("no".to_string()) - .eq_ignore_ascii_case("yes") - { - stack.add_titled_with_icon( - model.actions.widget(), - Some("actions"), - &lang::lookup("tab-actions"), - relm4_icons::icon_name::PUZZLE_PIECE, - ); - } - - let widgets = view_output!(); - log::debug!("Initialised model: {model:?}"); - - // Trigger initial header bar update - sender.input(AppInput::ChangedView( - stack.visible_child_name().map(|s| s.into()), - )); - - ComponentParts { model, widgets } - } - - fn update(&mut self, message: Self::Input, _sender: relm4::ComponentSender) { - match message { - AppInput::NoOp => (), - AppInput::ChangedView(new_view) => { - self.header - .emit(HeaderBarInput::ChangedView(new_view.unwrap_or_default())); - } - AppInput::ReloadActionsMap => { - self.actions_map = Arc::new(action_loader::get_actions(self.engines_list.clone())); - self.flows.emit(flows::FlowInputs::ActionsMapChanged( - self.actions_map.clone(), - )); - self.actions.emit(actions::ActionInputs::ActionsMapChanged( - self.actions_map.clone(), - )); - } - } - } -} +use std::{rc::Rc, sync::Arc}; + +use gtk::prelude::*; +use relm4::{ + adw, gtk, Component, ComponentController, ComponentParts, Controller, RelmApp, SimpleComponent, +}; +use testangel::{ + action_loader::{self, ActionMap}, + ipc::{self, EngineList}, +}; + +use self::header_bar::HeaderBarInput; + +mod about; +mod actions; +mod components; +mod file_filters; +mod flows; +mod header_bar; +pub(crate) mod lang; + +/// Initialise and open the UI. +pub fn initialise_ui() { + log::info!("Starting Next UI..."); + let app = RelmApp::new("lilopkins.testangel"); + relm4_icons::initialize_icons(); + initialise_icons(); + + let engines = Arc::new(ipc::get_engines()); + let actions = Arc::new(action_loader::get_actions(engines.clone())); + app.run::(AppInit { engines, actions }); +} + +fn initialise_icons() { + relm4::gtk::gio::resources_register_include!("icons.gresource").unwrap(); + log::info!("Loaded icon bundle."); + + let display = relm4::gtk::gdk::Display::default().unwrap(); + let theme = gtk::IconTheme::for_display(&display); + theme.add_resource_path("/uk/hpkns/testangel/icons"); +} + +pub struct AppInit { + engines: Arc, + actions: Arc, +} + +#[derive(Debug)] +enum AppInput { + NoOp, + /// The view has changed and should be read from visible_child_name, then components updated as needed. + ChangedView(Option), + /// The actions might have changed and should be reloaded + ReloadActionsMap, +} + +#[derive(Debug)] +struct AppModel { + stack: Rc, + header: Controller, + + flows: Controller, + actions: Controller, + + engines_list: Arc, + actions_map: Arc, +} + +#[relm4::component] +impl SimpleComponent for AppModel { + type Init = AppInit; + type Input = AppInput; + type Output = (); + + view! { + main_window = adw::Window { + set_title: Some(&lang::lookup("app-name")), + set_default_width: 800, + set_default_height: 600, + set_icon_name: Some("testangel"), + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 0, + + model.header.widget(), + + #[local_ref] + stack -> adw::ViewStack { + connect_visible_child_name_notify[sender] => move |st| { + sender.input(AppInput::ChangedView(st.visible_child_name().map(|s| s.into()))); + }, + }, + } + } + } + + fn init( + init: Self::Init, + root: &Self::Root, + sender: relm4::ComponentSender, + ) -> relm4::ComponentParts { + // Initialise the sub-components (pages) + let flows = flows::FlowsModel::builder() + .launch((init.actions.clone(), init.engines.clone())) + .forward(sender.input_sender(), |_msg| AppInput::NoOp); + let actions = actions::ActionsModel::builder() + .launch((init.actions.clone(), init.engines.clone())) + .forward(sender.input_sender(), |msg| match msg { + actions::ActionOutputs::ReloadActions => AppInput::ReloadActionsMap, + }); + + let stack = Rc::new(adw::ViewStack::new()); + + // Initialise the headerbar + let header = header_bar::HeaderBarModel::builder() + .launch(( + actions.model().header_controller_rc(), + flows.model().header_controller_rc(), + stack.clone(), + )) + .forward(sender.input_sender(), |_msg| AppInput::NoOp); + + // Build model + let model = AppModel { + actions_map: init.actions, + engines_list: init.engines, + stack, + header, + flows, + actions, + }; + + // Render window parts + let stack = &*model.stack; + + // Add pages + stack.add_titled_with_icon( + model.flows.widget(), + Some("flows"), + &lang::lookup("tab-flows"), + relm4_icons::icon_name::PAPYRUS_VERTICAL, + ); + if !std::env::var("TA_HIDE_ACTION_EDITOR") + .unwrap_or("no".to_string()) + .eq_ignore_ascii_case("yes") + { + stack.add_titled_with_icon( + model.actions.widget(), + Some("actions"), + &lang::lookup("tab-actions"), + relm4_icons::icon_name::PUZZLE_PIECE, + ); + } + + let widgets = view_output!(); + log::debug!("Initialised model: {model:?}"); + + // Trigger initial header bar update + sender.input(AppInput::ChangedView( + stack.visible_child_name().map(|s| s.into()), + )); + + ComponentParts { model, widgets } + } + + fn update(&mut self, message: Self::Input, _sender: relm4::ComponentSender) { + match message { + AppInput::NoOp => (), + AppInput::ChangedView(new_view) => { + self.header + .emit(HeaderBarInput::ChangedView(new_view.unwrap_or_default())); + } + AppInput::ReloadActionsMap => { + self.actions_map = Arc::new(action_loader::get_actions(self.engines_list.clone())); + self.flows.emit(flows::FlowInputs::ActionsMapChanged( + self.actions_map.clone(), + )); + self.actions.emit(actions::ActionInputs::ActionsMapChanged( + self.actions_map.clone(), + )); + } + } + } +}