From f1e35832db176446b4e5cfc1cba4aeff78c165e7 Mon Sep 17 00:00:00 2001 From: Lily Hopkins Date: Thu, 5 Dec 2024 21:28:23 +0000 Subject: [PATCH 01/10] bumped version --- Cargo.lock | 16 ++++++++-------- Cargo.toml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c4149e2..51c46aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4174,7 +4174,7 @@ dependencies = [ [[package]] name = "testangel" -version = "0.21.0-rc.13" +version = "0.21.0-rc.14" dependencies = [ "arboard", "base64 0.22.1", @@ -4212,7 +4212,7 @@ dependencies = [ [[package]] name = "testangel-engine" -version = "0.21.0-rc.13" +version = "0.21.0-rc.14" dependencies = [ "testangel-engine-macros", "testangel-ipc", @@ -4220,11 +4220,11 @@ dependencies = [ [[package]] name = "testangel-engine-macros" -version = "0.21.0-rc.13" +version = "0.21.0-rc.14" [[package]] name = "testangel-evidence" -version = "0.21.0-rc.13" +version = "0.21.0-rc.14" dependencies = [ "lazy_static", "testangel-engine", @@ -4232,7 +4232,7 @@ dependencies = [ [[package]] name = "testangel-ipc" -version = "0.21.0-rc.13" +version = "0.21.0-rc.14" dependencies = [ "schemars", "serde", @@ -4241,7 +4241,7 @@ dependencies = [ [[package]] name = "testangel-rand" -version = "0.21.0-rc.13" +version = "0.21.0-rc.14" dependencies = [ "lazy_static", "rand", @@ -4252,7 +4252,7 @@ dependencies = [ [[package]] name = "testangel-time" -version = "0.21.0-rc.13" +version = "0.21.0-rc.14" dependencies = [ "lazy_static", "testangel-engine", @@ -4261,7 +4261,7 @@ dependencies = [ [[package]] name = "testangel-user-interaction" -version = "0.21.0-rc.13" +version = "0.21.0-rc.14" dependencies = [ "lazy_static", "rfd", diff --git a/Cargo.toml b/Cargo.toml index a815b20..90ab4ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace.package] -version = "0.21.0-rc.13" +version = "0.21.0-rc.14" edition = "2021" [workspace] From afbc477ac2df2e9a8200eb991fbbb62470a18761 Mon Sep 17 00:00:00 2001 From: Lily Hopkins Date: Thu, 5 Dec 2024 21:28:46 +0000 Subject: [PATCH 02/10] changed state switch fixes #214 --- testangel/src/ui/actions/metadata_component.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testangel/src/ui/actions/metadata_component.rs b/testangel/src/ui/actions/metadata_component.rs index f13e09a..1e6c193 100644 --- a/testangel/src/ui/actions/metadata_component.rs +++ b/testangel/src/ui/actions/metadata_component.rs @@ -91,7 +91,7 @@ impl Component for Metadata { new_visible: Some(state), ..Default::default() }); - gtk::glib::signal::Propagation::Stop + gtk::glib::signal::Propagation::Proceed }, }, From 3369c7a7c576b4e6353c4ef60db29970a3f6e608 Mon Sep 17 00:00:00 2001 From: Lily Hopkins Date: Sun, 8 Dec 2024 16:35:49 +0000 Subject: [PATCH 03/10] disables menu items if no file resolves #215 Co-authored-by: Some-Birb7190 --- testangel/src/ui/actions/header.rs | 456 ++++++++++---------- testangel/src/ui/flows/header.rs | 472 +++++++++++---------- testangel/src/ui/header_bar.rs | 657 +++++++++++++++-------------- 3 files changed, 827 insertions(+), 758 deletions(-) diff --git a/testangel/src/ui/actions/header.rs b/testangel/src/ui/actions/header.rs index 887fd6e..47f06c6 100644 --- a/testangel/src/ui/actions/header.rs +++ b/testangel/src/ui/actions/header.rs @@ -1,222 +1,234 @@ -use std::sync::Arc; - -use adw::prelude::*; -use relm4::{ - adw, factory::FactoryVecDeque, gtk, Component, ComponentParts, ComponentSender, RelmWidgetExt, -}; -use testangel::{action_loader::ActionMap, ipc::EngineList}; - -use crate::ui::{ - components::add_step_factory::{AddStepInit, AddStepResult}, - 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, - AddStep(String), -} - -#[derive(Debug)] -pub enum ActionsHeaderInput { - 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), - /// Ask this to output the provided event - PleaseOutput(ActionsHeaderOutput), -} - -#[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_names::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)); - }, - }, - - #[name = "menu_scrolled_area"] - 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, - }, - }, - }, - }, - }, - }, - } - - 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::builder() - .launch(gtk::Box::default()) - .forward(sender.input_sender(), ActionsHeaderInput::AddStep), - }; - // 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!(); - - ComponentParts { model, widgets } - } - - fn update_with_view( - &mut self, - widgets: &mut Self::Widgets, - message: Self::Input, - sender: ComponentSender, - _root: &Self::Root, - ) { - match message { - ActionsHeaderInput::PleaseOutput(output) => { - let _ = sender.output(output); - } - ActionsHeaderInput::ChangeActionOpen(now) => { - self.action_open = now; - } - ActionsHeaderInput::ActionsMapChanged(new_map) => { - self.action_map = new_map; - } - 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(); - - // Reset scroll - let adj = widgets.menu_scrolled_area.vadjustment(); - adj.set_value(adj.lower()); - - // 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); - } -} +use std::sync::Arc; + +use adw::prelude::*; +use relm4::{ + adw, factory::FactoryVecDeque, gtk, Component, ComponentParts, ComponentSender, RelmWidgetExt, + Sender, +}; +use testangel::{action_loader::ActionMap, ipc::EngineList}; + +use crate::ui::{ + components::add_step_factory::{AddStepInit, AddStepResult}, + header_bar::HeaderBarInput, + lang, +}; + +#[derive(Debug)] +pub struct ActionsHeader { + action_map: Arc, + engine_list: Arc, + add_button: gtk::MenuButton, + action_open: bool, + search_results: FactoryVecDeque, + generic_sender: Option>, +} + +#[derive(Debug)] +pub enum ActionsHeaderOutput { + NewAction, + OpenAction, + SaveAction, + SaveAsAction, + CloseAction, + AddStep(String), +} + +#[derive(Debug)] +pub enum ActionsHeaderInput { + 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), + /// Ask this to output the provided event + PleaseOutput(ActionsHeaderOutput), + /// Provide this actions header with a sender to update the generic header bar + SetGenericHeaderBarSender(Sender), +} + +#[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_names::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)); + }, + }, + + #[name = "menu_scrolled_area"] + 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, + }, + }, + }, + }, + }, + }, + } + + 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::builder() + .launch(gtk::Box::default()) + .forward(sender.input_sender(), ActionsHeaderInput::AddStep), + generic_sender: None, + }; + // 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!(); + + ComponentParts { model, widgets } + } + + fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + message: Self::Input, + sender: ComponentSender, + _root: &Self::Root, + ) { + match message { + ActionsHeaderInput::PleaseOutput(output) => { + let _ = sender.output(output); + } + ActionsHeaderInput::ChangeActionOpen(now) => { + self.action_open = now; + if let Some(gs) = &self.generic_sender { + gs.send(HeaderBarInput::ActionOpened(now)).unwrap(); + } + } + ActionsHeaderInput::ActionsMapChanged(new_map) => { + self.action_map = new_map; + } + ActionsHeaderInput::SetGenericHeaderBarSender(generic_sender) => { + self.generic_sender = Some(generic_sender); + } + 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(); + + // Reset scroll + let adj = widgets.menu_scrolled_area.vadjustment(); + adj.set_value(adj.lower()); + + // 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); + } +} diff --git a/testangel/src/ui/flows/header.rs b/testangel/src/ui/flows/header.rs index de55b08..605db9d 100644 --- a/testangel/src/ui/flows/header.rs +++ b/testangel/src/ui/flows/header.rs @@ -1,230 +1,242 @@ -use std::sync::Arc; - -use adw::prelude::*; -use relm4::{ - adw, factory::FactoryVecDeque, gtk, Component, ComponentParts, ComponentSender, RelmWidgetExt, -}; -use testangel::action_loader::ActionMap; - -use crate::ui::{ - components::add_step_factory::{AddStepInit, AddStepResult}, - lang, -}; - -#[derive(Debug)] -pub struct FlowsHeader { - 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 { - 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), - /// Ask this to output the provided event - PleaseOutput(FlowsHeaderOutput), -} - -#[relm4::component(pub)] -impl Component for FlowsHeader { - type Init = 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_names::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)); - }, - }, - - #[name = "menu_scrolled_area"] - 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_names::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(); - }, - }, - }, - } - - fn init( - init: Self::Init, - root: Self::Root, - sender: ComponentSender, - ) -> ComponentParts { - let model = FlowsHeader { - action_map: init, - flow_open: false, - add_button: gtk::MenuButton::default(), - search_results: FactoryVecDeque::builder() - .launch(gtk::Box::default()) - .forward(sender.input_sender(), FlowsHeaderInput::AddStep), - }; - // 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!(); - - ComponentParts { model, widgets } - } - - fn update_with_view( - &mut self, - widgets: &mut Self::Widgets, - message: Self::Input, - sender: ComponentSender, - _root: &Self::Root, - ) { - match message { - FlowsHeaderInput::PleaseOutput(output) => { - let _ = sender.output(output); - } - FlowsHeaderInput::ChangeFlowOpen(now) => { - self.flow_open = now; - } - FlowsHeaderInput::ActionsMapChanged(new_map) => { - self.action_map = new_map; - sender.input(FlowsHeaderInput::SearchForSteps(String::new())); - } - 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(); - - // Reset scroll - let adj = widgets.menu_scrolled_area.vadjustment(); - adj.set_value(adj.lower()); - - 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); - } -} +use std::sync::Arc; + +use adw::prelude::*; +use relm4::{ + adw, factory::FactoryVecDeque, gtk, Component, ComponentParts, ComponentSender, RelmWidgetExt, + Sender, +}; +use testangel::action_loader::ActionMap; + +use crate::ui::{ + components::add_step_factory::{AddStepInit, AddStepResult}, + header_bar::HeaderBarInput, + lang, +}; + +#[derive(Debug)] +pub struct FlowsHeader { + action_map: Arc, + add_button: gtk::MenuButton, + flow_open: bool, + search_results: FactoryVecDeque, + generic_sender: Option>, +} + +#[derive(Debug)] +pub enum FlowsHeaderOutput { + NewFlow, + OpenFlow, + SaveFlow, + SaveAsFlow, + CloseFlow, + RunFlow, + AddStep(String), +} + +#[derive(Debug)] +pub enum FlowsHeaderInput { + 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), + /// Ask this to output the provided event + PleaseOutput(FlowsHeaderOutput), + /// Provide this actions header with a sender to update the generic header bar + SetGenericHeaderBarSender(Sender), +} + +#[relm4::component(pub)] +impl Component for FlowsHeader { + type Init = 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_names::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)); + }, + }, + + #[name = "menu_scrolled_area"] + 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_names::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(); + }, + }, + }, + } + + fn init( + init: Self::Init, + root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let model = FlowsHeader { + action_map: init, + flow_open: false, + add_button: gtk::MenuButton::default(), + search_results: FactoryVecDeque::builder() + .launch(gtk::Box::default()) + .forward(sender.input_sender(), FlowsHeaderInput::AddStep), + generic_sender: None, + }; + // 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!(); + + ComponentParts { model, widgets } + } + + fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + message: Self::Input, + sender: ComponentSender, + _root: &Self::Root, + ) { + match message { + FlowsHeaderInput::PleaseOutput(output) => { + let _ = sender.output(output); + } + FlowsHeaderInput::ChangeFlowOpen(now) => { + self.flow_open = now; + if let Some(gs) = &self.generic_sender { + gs.send(HeaderBarInput::FlowOpened(now)).unwrap(); + } + } + FlowsHeaderInput::ActionsMapChanged(new_map) => { + self.action_map = new_map; + sender.input(FlowsHeaderInput::SearchForSteps(String::new())); + } + FlowsHeaderInput::SetGenericHeaderBarSender(generic_sender) => { + self.generic_sender = Some(generic_sender); + } + 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(); + + // Reset scroll + let adj = widgets.menu_scrolled_area.vadjustment(); + adj.set_value(adj.lower()); + + 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); + } +} diff --git a/testangel/src/ui/header_bar.rs b/testangel/src/ui/header_bar.rs index 8c4e6ad..58c5f3e 100644 --- a/testangel/src/ui/header_bar.rs +++ b/testangel/src/ui/header_bar.rs @@ -1,306 +1,351 @@ -use std::{rc::Rc, sync::Arc}; - -use gtk::prelude::*; -use relm4::{ - actions::{AccelsPlus, RelmAction, RelmActionGroup}, - adw, gtk, Component, ComponentController, ComponentParts, ComponentSender, Controller, - RelmIterChildrenExt, RelmWidgetExt, -}; -use testangel::{action_loader::ActionMap, ipc::EngineList}; - -use crate::ui::lang; - -use super::{ - actions::header::{ActionsHeader, ActionsHeaderInput}, - flows::header::{FlowsHeader, FlowsHeaderInput}, -}; - -#[derive(Debug)] -pub enum HeaderBarInput { - ChangedView(String), - OpenAboutDialog, - ActionsMapChanged(Arc), - NewFile, - OpenFile, - SaveFile, - SaveAsFile, - CloseFile, -} - -#[derive(Debug)] -pub enum HeaderBarOutput { - AttachFileActionGroup(RelmActionGroup), - AttachGeneralActionGroup(RelmActionGroup), -} - -#[derive(Debug)] -enum MenuTarget { - Nothing, - Flows, - Actions, -} - -#[derive(Debug)] -pub struct HeaderBarModel { - currently_menu_target: MenuTarget, - engine_list: Arc, - action_map: Arc, - 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, - Arc, - Arc, - ); - type Input = HeaderBarInput; - type Output = HeaderBarOutput; - type CommandOutput = (); - - view! { - #[root] - 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, - }, - - pack_end = >k::MenuButton { - set_icon_name: relm4_icons::icon_names::MENU, - set_tooltip: &lang::lookup("header-more"), - set_direction: gtk::ArrowType::Down, - - #[wrap(Some)] - set_popover = >k::PopoverMenu::from_model(Some(&menu)) { - set_position: gtk::PositionType::Bottom, - }, - }, - } - } - - menu! { - menu: { - &lang::lookup("header-new") => FileNewAction, - &lang::lookup("header-open") => FileOpenAction, - &lang::lookup("header-save") => FileSaveAction, - &lang::lookup("header-save-as") => FileSaveAsAction, - &lang::lookup("header-close") => FileCloseAction, - section! { - &lang::lookup("header-about") => GeneralAboutAction, - } - } - } - - fn init( - init: Self::Init, - root: Self::Root, - sender: ComponentSender, - ) -> ComponentParts { - let model = HeaderBarModel { - currently_menu_target: MenuTarget::Nothing, - action_header_rc: init.0, - flow_header_rc: init.1, - engine_list: init.3, - action_map: init.4, - }; - - let stack = &*init.2; - 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.input(HeaderBarInput::NewFile); - }); - 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.input(HeaderBarInput::OpenFile); - }); - 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.input(HeaderBarInput::SaveFile); - }); - 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.input(HeaderBarInput::SaveAsFile); - }); - 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.input(HeaderBarInput::CloseFile); - }); - relm4::main_application().set_accelerators_for_action::(&["W"]); - - let sender_c = sender.clone(); - let about_action: RelmAction = RelmAction::new_stateless(move |_| { - sender_c.input(HeaderBarInput::OpenAboutDialog); - }); - relm4::main_application().set_accelerators_for_action::(&["F1"]); - - 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); - let _ = sender.output(HeaderBarOutput::AttachFileActionGroup(group)); - - let mut group = RelmActionGroup::::new(); - group.add_action(about_action); - let _ = sender.output(HeaderBarOutput::AttachGeneralActionGroup(group)); - - ComponentParts { model, widgets } - } - - fn update_with_view( - &mut self, - widgets: &mut Self::Widgets, - message: Self::Input, - sender: ComponentSender, - root: &Self::Root, - ) { - match message { - HeaderBarInput::ActionsMapChanged(new_map) => self.action_map = new_map, - HeaderBarInput::OpenAboutDialog => { - crate::ui::about::AppAbout::builder() - .transient_for(root) - .launch((self.engine_list.clone(), self.action_map.clone())) - .widget() - .set_visible(true); - } - 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.currently_menu_target = MenuTarget::Flows; - } else if new_view == "actions" { - let rc_clone = self.action_header_rc.clone(); - self.swap_content(&widgets.start_box, &rc_clone.widgets().start); - self.currently_menu_target = MenuTarget::Actions; - } else { - self.swap_content(&widgets.start_box, >k::Box::builder().build()); - self.currently_menu_target = MenuTarget::Nothing; - } - } - HeaderBarInput::NewFile => match self.currently_menu_target { - MenuTarget::Nothing => (), - MenuTarget::Flows => { - self.flow_header_rc.emit(FlowsHeaderInput::PleaseOutput( - super::flows::header::FlowsHeaderOutput::NewFlow, - )); - } - MenuTarget::Actions => { - self.action_header_rc.emit(ActionsHeaderInput::PleaseOutput( - super::actions::header::ActionsHeaderOutput::NewAction, - )); - } - }, - HeaderBarInput::OpenFile => match self.currently_menu_target { - MenuTarget::Nothing => (), - MenuTarget::Flows => { - self.flow_header_rc.emit(FlowsHeaderInput::PleaseOutput( - super::flows::header::FlowsHeaderOutput::OpenFlow, - )); - } - MenuTarget::Actions => { - self.action_header_rc.emit(ActionsHeaderInput::PleaseOutput( - super::actions::header::ActionsHeaderOutput::OpenAction, - )); - } - }, - HeaderBarInput::SaveFile => match self.currently_menu_target { - MenuTarget::Nothing => (), - MenuTarget::Flows => { - self.flow_header_rc.emit(FlowsHeaderInput::PleaseOutput( - super::flows::header::FlowsHeaderOutput::SaveFlow, - )); - } - MenuTarget::Actions => { - self.action_header_rc.emit(ActionsHeaderInput::PleaseOutput( - super::actions::header::ActionsHeaderOutput::SaveAction, - )); - } - }, - HeaderBarInput::SaveAsFile => match self.currently_menu_target { - MenuTarget::Nothing => (), - MenuTarget::Flows => { - self.flow_header_rc.emit(FlowsHeaderInput::PleaseOutput( - super::flows::header::FlowsHeaderOutput::SaveAsFlow, - )); - } - MenuTarget::Actions => { - self.action_header_rc.emit(ActionsHeaderInput::PleaseOutput( - super::actions::header::ActionsHeaderOutput::SaveAsAction, - )); - } - }, - HeaderBarInput::CloseFile => match self.currently_menu_target { - MenuTarget::Nothing => (), - MenuTarget::Flows => { - self.flow_header_rc.emit(FlowsHeaderInput::PleaseOutput( - super::flows::header::FlowsHeaderOutput::CloseFlow, - )); - } - MenuTarget::Actions => { - self.action_header_rc.emit(ActionsHeaderInput::PleaseOutput( - super::actions::header::ActionsHeaderOutput::CloseAction, - )); - } - }, - } - self.update_view(widgets, sender); - } -} - -relm4::new_action_group!(pub FileActionGroup, "file"); -relm4::new_stateless_action!(FileNewAction, FileActionGroup, "new"); -relm4::new_stateless_action!(FileOpenAction, FileActionGroup, "open"); -relm4::new_stateless_action!(FileSaveAction, FileActionGroup, "save"); -relm4::new_stateless_action!(FileSaveAsAction, FileActionGroup, "save-as"); -relm4::new_stateless_action!(FileCloseAction, FileActionGroup, "close"); - -impl std::fmt::Debug for FileActionGroup { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "FileActionGroup") - } -} - -relm4::new_action_group!(pub GeneralActionGroup, "general"); -relm4::new_stateless_action!(pub GeneralAboutAction, GeneralActionGroup, "about"); - -impl std::fmt::Debug for GeneralActionGroup { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "GeneralActionGroup") - } -} +use std::{rc::Rc, sync::Arc}; + +use gtk::prelude::*; +use relm4::{ + actions::{AccelsPlus, RelmAction, RelmActionGroup}, + adw, gtk, Component, ComponentController, ComponentParts, ComponentSender, Controller, + RelmIterChildrenExt, RelmWidgetExt, +}; +use testangel::{action_loader::ActionMap, ipc::EngineList}; + +use crate::ui::lang; + +use super::{ + actions::header::{ActionsHeader, ActionsHeaderInput}, + flows::header::{FlowsHeader, FlowsHeaderInput}, +}; + +#[derive(Debug)] +pub enum HeaderBarInput { + ChangedView(String), + OpenAboutDialog, + ActionsMapChanged(Arc), + NewFile, + OpenFile, + SaveFile, + SaveAsFile, + CloseFile, + ActionOpened(bool), + FlowOpened(bool), +} + +#[derive(Debug)] +pub enum HeaderBarOutput { + AttachFileActionGroup(RelmActionGroup), + AttachGeneralActionGroup(RelmActionGroup), +} + +#[derive(Debug)] +enum MenuTarget { + Nothing, + Flows, + Actions, +} + +#[derive(Debug)] +pub struct HeaderBarModel { + currently_menu_target: MenuTarget, + engine_list: Arc, + action_map: Arc, + action_header_rc: Rc>, + flow_header_rc: Rc>, + action_save: RelmAction, + action_save_as: RelmAction, + action_close: RelmAction, + is_action_open: bool, + is_flow_open: bool, +} + +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, + Arc, + Arc, + ); + type Input = HeaderBarInput; + type Output = HeaderBarOutput; + type CommandOutput = (); + + view! { + #[root] + 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, + }, + + pack_end = >k::MenuButton { + set_icon_name: relm4_icons::icon_names::MENU, + set_tooltip: &lang::lookup("header-more"), + set_direction: gtk::ArrowType::Down, + + #[wrap(Some)] + set_popover = >k::PopoverMenu::from_model(Some(&menu)) { + set_position: gtk::PositionType::Bottom, + }, + }, + } + } + + menu! { + menu: { + &lang::lookup("header-new") => FileNewAction, + &lang::lookup("header-open") => FileOpenAction, + &lang::lookup("header-save") => FileSaveAction, + &lang::lookup("header-save-as") => FileSaveAsAction, + &lang::lookup("header-close") => FileCloseAction, + section! { + &lang::lookup("header-about") => GeneralAboutAction, + } + } + } + + fn init( + init: Self::Init, + root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let sender_c = sender.clone(); + let new_action: RelmAction = RelmAction::new_stateless(move |_| { + // unwrap rationale: receiver will never be disconnected + sender_c.input(HeaderBarInput::NewFile); + }); + 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.input(HeaderBarInput::OpenFile); + }); + 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.input(HeaderBarInput::SaveFile); + }); + save_action.set_enabled(false); + 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.input(HeaderBarInput::SaveAsFile); + }); + save_as_action.set_enabled(false); + 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.input(HeaderBarInput::CloseFile); + }); + close_action.set_enabled(false); + relm4::main_application().set_accelerators_for_action::(&["W"]); + + let sender_c = sender.clone(); + let about_action: RelmAction = RelmAction::new_stateless(move |_| { + sender_c.input(HeaderBarInput::OpenAboutDialog); + }); + relm4::main_application().set_accelerators_for_action::(&["F1"]); + + let mut group = RelmActionGroup::::new(); + group.add_action(new_action); + group.add_action(open_action); + group.add_action(save_action.clone()); + group.add_action(save_as_action.clone()); + group.add_action(close_action.clone()); + let _ = sender.output(HeaderBarOutput::AttachFileActionGroup(group)); + + let mut group = RelmActionGroup::::new(); + group.add_action(about_action); + let _ = sender.output(HeaderBarOutput::AttachGeneralActionGroup(group)); + + let model = HeaderBarModel { + currently_menu_target: MenuTarget::Nothing, + action_header_rc: init.0, + flow_header_rc: init.1, + engine_list: init.3, + action_map: init.4, + action_save: save_action, + action_save_as: save_as_action, + action_close: close_action, + is_action_open: false, + is_flow_open: false, + }; + model + .action_header_rc + .emit(ActionsHeaderInput::SetGenericHeaderBarSender( + sender.input_sender().clone(), + )); + model + .flow_header_rc + .emit(FlowsHeaderInput::SetGenericHeaderBarSender( + sender.input_sender().clone(), + )); + 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::ActionsMapChanged(new_map) => self.action_map = new_map, + HeaderBarInput::OpenAboutDialog => { + crate::ui::about::AppAbout::builder() + .transient_for(root) + .launch((self.engine_list.clone(), self.action_map.clone())) + .widget() + .set_visible(true); + } + 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.currently_menu_target = MenuTarget::Flows; + self.action_save.set_enabled(self.is_flow_open); + self.action_save_as.set_enabled(self.is_flow_open); + self.action_close.set_enabled(self.is_flow_open); + } else if new_view == "actions" { + let rc_clone = self.action_header_rc.clone(); + self.swap_content(&widgets.start_box, &rc_clone.widgets().start); + self.currently_menu_target = MenuTarget::Actions; + self.action_save.set_enabled(self.is_action_open); + self.action_save_as.set_enabled(self.is_action_open); + self.action_close.set_enabled(self.is_action_open); + } else { + self.swap_content(&widgets.start_box, >k::Box::builder().build()); + self.currently_menu_target = MenuTarget::Nothing; + self.action_save.set_enabled(false); + self.action_save_as.set_enabled(false); + self.action_close.set_enabled(false); + } + } + HeaderBarInput::NewFile => match self.currently_menu_target { + MenuTarget::Nothing => (), + MenuTarget::Flows => { + self.flow_header_rc.emit(FlowsHeaderInput::PleaseOutput( + super::flows::header::FlowsHeaderOutput::NewFlow, + )); + } + MenuTarget::Actions => { + self.action_header_rc.emit(ActionsHeaderInput::PleaseOutput( + super::actions::header::ActionsHeaderOutput::NewAction, + )); + } + }, + HeaderBarInput::OpenFile => match self.currently_menu_target { + MenuTarget::Nothing => (), + MenuTarget::Flows => { + self.flow_header_rc.emit(FlowsHeaderInput::PleaseOutput( + super::flows::header::FlowsHeaderOutput::OpenFlow, + )); + } + MenuTarget::Actions => { + self.action_header_rc.emit(ActionsHeaderInput::PleaseOutput( + super::actions::header::ActionsHeaderOutput::OpenAction, + )); + } + }, + HeaderBarInput::SaveFile => match self.currently_menu_target { + MenuTarget::Nothing => (), + MenuTarget::Flows => { + self.flow_header_rc.emit(FlowsHeaderInput::PleaseOutput( + super::flows::header::FlowsHeaderOutput::SaveFlow, + )); + } + MenuTarget::Actions => { + self.action_header_rc.emit(ActionsHeaderInput::PleaseOutput( + super::actions::header::ActionsHeaderOutput::SaveAction, + )); + } + }, + HeaderBarInput::SaveAsFile => match self.currently_menu_target { + MenuTarget::Nothing => (), + MenuTarget::Flows => { + self.flow_header_rc.emit(FlowsHeaderInput::PleaseOutput( + super::flows::header::FlowsHeaderOutput::SaveAsFlow, + )); + } + MenuTarget::Actions => { + self.action_header_rc.emit(ActionsHeaderInput::PleaseOutput( + super::actions::header::ActionsHeaderOutput::SaveAsAction, + )); + } + }, + HeaderBarInput::CloseFile => match self.currently_menu_target { + MenuTarget::Nothing => (), + MenuTarget::Flows => { + self.flow_header_rc.emit(FlowsHeaderInput::PleaseOutput( + super::flows::header::FlowsHeaderOutput::CloseFlow, + )); + } + MenuTarget::Actions => { + self.action_header_rc.emit(ActionsHeaderInput::PleaseOutput( + super::actions::header::ActionsHeaderOutput::CloseAction, + )); + } + }, + HeaderBarInput::ActionOpened(is_open) => { + self.action_save.set_enabled(is_open); + self.action_save_as.set_enabled(is_open); + self.action_close.set_enabled(is_open); + self.is_action_open = is_open; + } + HeaderBarInput::FlowOpened(is_open) => { + self.action_save.set_enabled(is_open); + self.action_save_as.set_enabled(is_open); + self.action_close.set_enabled(is_open); + self.is_flow_open = is_open; + } + } + self.update_view(widgets, sender); + } +} + +relm4::new_action_group!(pub FileActionGroup, "file"); +relm4::new_stateless_action!(FileNewAction, FileActionGroup, "new"); +relm4::new_stateless_action!(FileOpenAction, FileActionGroup, "open"); +relm4::new_stateless_action!(FileSaveAction, FileActionGroup, "save"); +relm4::new_stateless_action!(FileSaveAsAction, FileActionGroup, "save-as"); +relm4::new_stateless_action!(FileCloseAction, FileActionGroup, "close"); + +impl std::fmt::Debug for FileActionGroup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "FileActionGroup") + } +} + +relm4::new_action_group!(pub GeneralActionGroup, "general"); +relm4::new_stateless_action!(pub GeneralAboutAction, GeneralActionGroup, "about"); + +impl std::fmt::Debug for GeneralActionGroup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "GeneralActionGroup") + } +} From 2b1f1dd14d6386b9d8438201f2a41593b980b376 Mon Sep 17 00:00:00 2001 From: Lily Hopkins Date: Sun, 8 Dec 2024 16:41:54 +0000 Subject: [PATCH 04/10] added version check flow --- .github/workflows/check-version-bumped.yml | 38 ++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/check-version-bumped.yml diff --git a/.github/workflows/check-version-bumped.yml b/.github/workflows/check-version-bumped.yml new file mode 100644 index 0000000..b9ce9da --- /dev/null +++ b/.github/workflows/check-version-bumped.yml @@ -0,0 +1,38 @@ +name: Check version is suitable for merge to upstream + +on: + pull_request: + branches: + - main + - next + +jobs: + check-version: + name: Check version + runs-on: ubuntu-latest + + steps: + - name: Checkout this PR + uses: actions/checkout@v4 + - name: Determine Cargo version of this PR + id: version-pr + run: | + export CARGO_PKG_VERSION=$(awk -F '["=]' '/^\[(workspace.)?package\]/{p=1} p && /^version[[:space:]]*=/ {gsub(/"/, "", $3); print $3; p=0}' Cargo.toml) + export CARGO_PKG_PRERELEASE=$([[ $CARGO_PKG_VERSION =~ -[0-9A-Za-z]+ ]] && echo "true" || echo "false") + echo "CARGO_PKG_VERSION=${CARGO_PKG_VERSION}" >> $GITHUB_OUTPUT + echo "CARGO_PKG_PRERELEASE=${CARGO_PKG_PRERELEASE}" >> $GITHUB_OUTPUT + + - name: Checkout ${{ github.base_ref }} + uses: actions/checkout@v4 + with: + ref: ${{ github.base_ref }} + - name: Determine Cargo version of ${{ github.base_ref }} + id: version-upstream + run: | + export CARGO_PKG_VERSION=$(awk -F '["=]' '/^\[(workspace.)?package\]/{p=1} p && /^version[[:space:]]*=/ {gsub(/"/, "", $3); print $3; p=0}' Cargo.toml) + export CARGO_PKG_PRERELEASE=$([[ $CARGO_PKG_VERSION =~ -[0-9A-Za-z]+ ]] && echo "true" || echo "false") + echo "CARGO_PKG_VERSION=${CARGO_PKG_VERSION}" >> $GITHUB_OUTPUT + echo "CARGO_PKG_PRERELEASE=${CARGO_PKG_PRERELEASE}" >> $GITHUB_OUTPUT + + - name: Assert versions are different + run: go run github.com/davidrjonas/semver-cli@latest greater ${{ steps.version-pr.outputs.CARGO_PKG_VERSION }} ${{ steps.version-upstream.outputs.CARGO_PKG_VERSION }} From 4c4b26d3dbe21f06222e387511b908fc0c7a1d1a Mon Sep 17 00:00:00 2001 From: Lily Hopkins Date: Sun, 8 Dec 2024 16:52:20 +0000 Subject: [PATCH 05/10] Added authors --- testangel-ipc/Cargo.toml | 3 +++ testangel/Cargo.toml | 4 ++++ testangel/locales/en/main.ftl | 1 + testangel/locales/sv/main.ftl | 1 + testangel/src/ui/about.rs | 1 + 5 files changed, 10 insertions(+) diff --git a/testangel-ipc/Cargo.toml b/testangel-ipc/Cargo.toml index a7441cb..d6c004b 100644 --- a/testangel-ipc/Cargo.toml +++ b/testangel-ipc/Cargo.toml @@ -2,6 +2,9 @@ name = "testangel-ipc" version.workspace = true edition.workspace = true +authors = [ + "Lily Hopkins ", +] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/testangel/Cargo.toml b/testangel/Cargo.toml index 0f85660..dfe7ca4 100644 --- a/testangel/Cargo.toml +++ b/testangel/Cargo.toml @@ -2,6 +2,10 @@ name = "testangel" version.workspace = true edition.workspace = true +authors = [ + "Lily Hopkins ", + "Eden Turner ", +] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/testangel/locales/en/main.ftl b/testangel/locales/en/main.ftl index c7d4f5a..13105fd 100644 --- a/testangel/locales/en/main.ftl +++ b/testangel/locales/en/main.ftl @@ -16,6 +16,7 @@ header-save = Save header-save-as = Save as... header-close = Close header-about = About { app-name } +acknowledgements-code-title = Code acknowledgements-testing-title = Software Testing acknowledgements-translations-title = Translations diff --git a/testangel/locales/sv/main.ftl b/testangel/locales/sv/main.ftl index 5868938..03f5bc2 100644 --- a/testangel/locales/sv/main.ftl +++ b/testangel/locales/sv/main.ftl @@ -16,6 +16,7 @@ header-save = Spara header-save-as = Spara som... header-close = Stäng header-about = Om { app-name } +acknowledgements-code-title = Kod acknowledgements-testing-title = Programtestning acknowledgements-translations-title = Översättningar diff --git a/testangel/src/ui/about.rs b/testangel/src/ui/about.rs index ef37257..b060c24 100644 --- a/testangel/src/ui/about.rs +++ b/testangel/src/ui/about.rs @@ -26,6 +26,7 @@ impl SimpleComponent for AppAbout { set_developer_name: "Lily Hopkins", set_debug_info: &log_data, + add_acknowledgement_section: (Some(&lang::lookup("acknowledgements-code-title")), &["Lily Hopkins", "Eden Turner"]), add_acknowledgement_section: (Some(&lang::lookup("acknowledgements-testing-title")), &["John Chander", "Eden Turner"]), add_acknowledgement_section: (Some(&lang::lookup("acknowledgements-translations-title")), &["Lily Hopkins"]), add_legal_section: ("GTK", None, gtk::License::Gpl20Only, None), From 0b43bbe0fc0359ea7fdc0d12f019c12231d2b6da Mon Sep 17 00:00:00 2001 From: Lily Hopkins Date: Mon, 9 Dec 2024 23:07:16 +0000 Subject: [PATCH 06/10] ci: migrate windows build to prebuilt binaries resolves #221 --- .github/workflows/rust-build.yml | 114 +++++++++++++------------------ 1 file changed, 46 insertions(+), 68 deletions(-) diff --git a/.github/workflows/rust-build.yml b/.github/workflows/rust-build.yml index 14aa853..feb4735 100644 --- a/.github/workflows/rust-build.yml +++ b/.github/workflows/rust-build.yml @@ -160,63 +160,54 @@ jobs: target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - name: Restore GTK4 - id: cache-gtk4 - uses: actions/cache/restore@v3 - with: - key: windows-gtk4 - path: | - C:\gtk-build - - - name: Move git binary + - name: Get latest Win-GTK4 build + if: matrix.platform.os_name == 'Windows' + id: wingtk-install + shell: bash run: | - Move-Item "C:\Program Files\Git\usr\bin" "C:\Program Files\Git\usr\notbin" - Move-Item "C:\Program Files\Git\bin" "C:\Program Files\Git\notbin" + REPO_OWNER="wingtk" # Replace with the owner of the repository + REPO_NAME="gvsbuild" # Replace with the name of the repository + ASSET_PREFIX="GTK4" # The prefix of the asset you want to download - - name: Build GTK4 - continue-on-error: true - id: build-gtk4-fallible - run: | - python -m pip install --user pipx - python -m pipx ensurepath - pipx install gvsbuild - gvsbuild build gtk4 libadwaita librsvg gtksourceview5 --ninja-opts -j2 + mkdir -p "${ASSET_PREFIX}" + cd "${ASSET_PREFIX}" || exit 1 - - name: Build GTK4 with known good gvsbuild (${{ vars.GVSBUILD_KNOWN_GOOD_VERSION }}) - if: steps.build-gtk4-fallible.outcome == 'failure' - run: | - python -m pip install --user pipx - python -m pipx ensurepath - pipx install --force gvsbuild==${{ vars.GVSBUILD_KNOWN_GOOD_VERSION }} - gvsbuild build gtk4 libadwaita librsvg gtksourceview5 --ninja-opts -j2 + # Get the latest release + RELEASE_INFO=$(curl -s \ + "https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/releases/latest") - - name: Restore git binary - run: | - Move-Item "C:\Program Files\Git\usr\notbin" "C:\Program Files\Git\usr\bin" - Move-Item "C:\Program Files\Git\notbin" "C:\Program Files\Git\bin" + # Extract the asset URL that starts with the specified prefix + ASSET_URL=$(echo "$RELEASE_INFO" | jq -r --arg ASSET_PREFIX "$ASSET_PREFIX" \ + '.assets[] | select(.name | startswith($ASSET_PREFIX)) | .url') - - name: Save GTK4 - uses: actions/cache/save@v3 - with: - key: ${{ steps.cache-gtk4.outputs.cache-primary-key }} - path: | - C:\gtk-build + if [ -z "$ASSET_URL" ]; then + echo "No asset found starting with '$ASSET_PREFIX'!" + exit 1 + fi + + # Download the asset + curl -L \ + -H "Accept: application/octet-stream" \ + "$ASSET_URL" -o "${ASSET_PREFIX}_asset.zip" + + echo "Downloaded asset: ${ASSET_PREFIX}_asset.zip" + + unzip "${ASSET_PREFIX}_asset.zip" + rm -f "${ASSET_PREFIX}_asset.zip" + rm -rf "include/" "python/" "wheels/" + BASE_DIR=$(pwd) + echo "BASE_DIR=${BASE_DIR}" >> $GITHUB_OUTPUT - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - - name: Tree GVSBuild - shell: pwsh - run: | - tree C:\gtk-build\ - - name: Build - shell: pwsh + shell: bash run: | - $env:PATH="C:\gtk-build\gtk\x64\release\bin;" + $env:PATH - $env:LIB="C:\gtk-build\gtk\x64\release\lib;" + $env:LIB - $env:INCLUDE="C:\gtk-build\gtk\x64\release\include;C:\gtk-build\gtk\x64\release\include\cairo;C:\gtk-build\gtk\x64\release\include\glib-2.0;C:\gtk-build\gtk\x64\release\include\gobject-introspection-1.0;C:\gtk-build\gtk\x64\release\lib\glib-2.0\include;" + $env:INCLUDE - $env:PKG_CONFIG_PATH="C:\gtk-build\gtk\x64\release\lib\pkgconfig;" + $env:PKG_CONFIG_PATH + PATH="${{ steps.wingtk-install.outputs.BASE_DIR }}/bin:$PATH" + LIB="${{ steps.wingtk-install.outputs.BASE_DIR }}/lib:$LIB" + INCLUDE="${{ steps.wingtk-install.outputs.BASE_DIR }}/include:${{ steps.wingtk-install.outputs.BASE_DIR }}/include/cairo:${{ steps.wingtk-install.outputs.BASE_DIR }}/include/glib-2.0:${{ steps.wingtk-install.outputs.BASE_DIR }}/include/gobject-introspection-1.0:${{ steps.wingtk-install.outputs.BASE_DIR }}/lib/glib-2.0/include:$INCLUDE" + PKG_CONFIG_PATH="${{ steps.wingtk-install.outputs.BASE_DIR }}/lib/pkgconfig:$PKG_CONFIG_PATH" cargo build -p testangel --bin testangel --release cargo build -p testangel --bin testangel-executor --no-default-features --features cli --release @@ -224,35 +215,22 @@ jobs: cargo build -p testangel-rand --release cargo build -p testangel-time --release cargo build -p testangel-user-interaction --release - mkdir build - copy target/release/testangel.exe build/ + + mkdir -p build/bin + + cp target/release/testangel.exe build/bin/ cargo build -p testangel --bin testangel --release --features windows-keep-console-window - copy target/release/testangel.exe build/testangel-dbg.exe - copy target/release/testangel-executor.exe build/ - copy C:\gtk-build\gtk\x64\release\bin\*.dll build/ + cp target/release/testangel.exe build/bin/testangel-dbg.exe + cp target/release/testangel-executor.exe build/ + mkdir build/engines copy target/release/testangel_evidence.dll build/engines/ copy target/release/testangel_rand.dll build/engines/ copy target/release/testangel_time.dll build/engines/ copy target/release/testangel_user_interaction.dll build/engines/ - # GSchemas for FileChooser - mkdir -p build/share/glib-2.0/schemas - copy C:\gtk-build\gtk\x64\release\share\glib-2.0\schemas\gschemas.compiled build/share/glib-2.0/schemas/ - - # SVG loader for icons - mkdir -p build/lib/gdk-pixbuf-2.0/2.10.0/loaders - copy C:\gtk-build\gtk\x64\release\lib\gdk-pixbuf-2.0\2.10.0\loaders.cache build/lib/gdk-pixbuf-2.0/2.10.0/ - copy C:\gtk-build\gtk\x64\release\lib\gdk-pixbuf-2.0\2.10.0\loaders\libpixbufloader-svg.dll build/lib/gdk-pixbuf-2.0/2.10.0/loaders/ - - # Language Spec - mkdir -p build/language-specs - copy C:\gtk-build\build\x64\release\gtksourceview5\data\language-specs\def.lang build/language-specs - copy C:\gtk-build\build\x64\release\gtksourceview5\data\language-specs\language2.rng build/language-specs - copy C:\gtk-build\build\x64\release\gtksourceview5\data\language-specs\lua.lang build/language-specs - - mkdir -p build/styles - copy C:\gtk-build\build\x64\release\gtksourceview5\data\styles\* build/styles + rm -rf ${{ steps.wingtk-install.outputs.BASE_DIR }}/include + cp -r ${{ steps.wingtk-install.outputs.BASE_DIR }}/* bundle/ - name: Save Cargo cache uses: actions/cache/save@v3 From ff4a8f02a0974668e47f359688f7451dc4826151 Mon Sep 17 00:00:00 2001 From: Lily Hopkins Date: Mon, 9 Dec 2024 23:35:09 +0000 Subject: [PATCH 07/10] added check that types match after new actions are loaded fixes #216 --- testangel/src/ui/flows/mod.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/testangel/src/ui/flows/mod.rs b/testangel/src/ui/flows/mod.rs index b68acb0..ae94e24 100644 --- a/testangel/src/ui/flows/mod.rs +++ b/testangel/src/ui/flows/mod.rs @@ -420,6 +420,7 @@ impl Component for FlowsModel { let mut close_flow = false; let mut steps_reset = vec![]; if let Some(flow) = &mut self.open_flow { + let actions_clone = flow.actions.clone(); for (step, ac) in flow.actions.iter_mut().enumerate() { match self.action_map.get_action_by_id(&ac.action_id) { None => { @@ -427,9 +428,32 @@ impl Component for FlowsModel { } Some(action) => { // Check that action parameters haven't changed. If they have, reset values. - if ac.update(action) { + if ac.update(action.clone()) { steps_reset.push(step); } + + // Check that the references from this AC to another don't now violate types + for (p_id, src) in &mut ac.parameter_sources { + match src { + ActionParameterSource::FromOutput(other_step, output) => { + let (_name, kind) = &action.parameters()[*p_id]; + // Check that parameter from step->output is of type kind + if let Some(other_ac) = actions_clone.get(*other_step) { + if let Some(other_action) = &self.action_map.get_action_by_id(&other_ac.action_id) { + if let Some((_name, other_output_kind)) = other_action.outputs().get(*output) { + if kind != other_output_kind { + // Reset to literal + steps_reset.push(step); + *src = ActionParameterSource::Literal; + } + } + } + } + // If any of these if's fail, then the main loop will catch and fail later. + }, + _ => (), + } + } } } } From 49df38e5799a507acbd6f5c4f061ecf485660fc6 Mon Sep 17 00:00:00 2001 From: Lily Hopkins Date: Mon, 9 Dec 2024 23:45:13 +0000 Subject: [PATCH 08/10] added a demo action resolves #218 --- testangel/src/action_loader.rs | 183 ++-- testangel/src/demo_action.taaction | 1 + testangel/src/ui/flows/mod.rs | 1607 ++++++++++++++-------------- 3 files changed, 902 insertions(+), 889 deletions(-) create mode 100644 testangel/src/demo_action.taaction diff --git a/testangel/src/action_loader.rs b/testangel/src/action_loader.rs index 624cede..ea697c7 100644 --- a/testangel/src/action_loader.rs +++ b/testangel/src/action_loader.rs @@ -1,88 +1,95 @@ -use std::{collections::HashMap, env, fs, path::PathBuf, sync::Arc}; - -use crate::ipc::EngineList; -use crate::types::{Action, VersionedFile}; - -#[derive(Debug, Default)] -pub struct ActionMap(HashMap); - -impl ActionMap { - /// Get an action from an action ID by iterating through available actions. - pub fn get_action_by_id(&self, action_id: &String) -> Option { - for action in self.0.values() { - if action.id == *action_id { - return Some(action.clone()); - } - } - None - } - - /// Get actions grouped by action group - pub fn get_by_group(&self) -> HashMap> { - let mut map = HashMap::new(); - for action in self.0.values() { - map.entry(action.group.clone()).or_default(); - map.entry(action.group.clone()) - .and_modify(|vec: &mut Vec| vec.push(action.clone())); - } - map - } -} - -/// Get the list of available engines. -pub fn get_actions(engine_list: Arc) -> ActionMap { - let mut actions = HashMap::new(); - let action_dir = env::var("TA_ACTION_DIR").unwrap_or("./actions".to_owned()); - fs::create_dir_all(action_dir.clone()).unwrap(); - 'action_loop: for path in fs::read_dir(action_dir).unwrap() { - let path = path.unwrap(); - let filename = path.file_name(); - if let Ok(meta) = path.metadata() { - if meta.is_dir() { - continue; - } - } - - if let Ok(str) = filename.into_string() { - if str.ends_with(".taaction") { - log::debug!("Detected possible action {str}"); - if let Ok(res) = fs::read_to_string(path.path()) { - if let Ok(versioned_file) = ron::from_str::(&res) { - if versioned_file.version() != 2 { - log::warn!("Action {str} uses an incompatible file version."); - continue 'action_loop; - } - } - - if let Ok(action) = ron::from_str::(&res) { - // Validate that all instructions are available for this action before loading - if let Err(missing) = - action.check_instructions_available(engine_list.clone()) - { - log::warn!( - "Couldn't load action {} because instructions {:?} aren't available.", - action.friendly_name, - missing, - ); - continue 'action_loop; - } - - log::info!( - "Discovered action {} ({}) at {:?}", - action.friendly_name, - action.id, - path.path(), - ); - - actions.insert(path.path(), action); - } else { - log::warn!("Couldn't parse action"); - } - } else { - log::warn!("Couldn't read action"); - } - } - } - } - ActionMap(actions) -} +use std::{collections::HashMap, env, fs, path::PathBuf, sync::Arc}; + +use crate::ipc::EngineList; +use crate::types::{Action, VersionedFile}; + +#[derive(Debug, Default)] +pub struct ActionMap(HashMap); + +impl ActionMap { + /// Get an action from an action ID by iterating through available actions. + pub fn get_action_by_id(&self, action_id: &String) -> Option { + for action in self.0.values() { + if action.id == *action_id { + return Some(action.clone()); + } + } + None + } + + /// Get actions grouped by action group + pub fn get_by_group(&self) -> HashMap> { + let mut map = HashMap::new(); + for action in self.0.values() { + map.entry(action.group.clone()).or_default(); + map.entry(action.group.clone()) + .and_modify(|vec: &mut Vec| vec.push(action.clone())); + } + map + } +} + +/// Get the list of available engines. +pub fn get_actions(engine_list: Arc) -> ActionMap { + let mut actions = HashMap::new(); + let action_dir = env::var("TA_ACTION_DIR").unwrap_or("./actions".to_owned()); + if let Ok(exists) = fs::exists(&action_dir) { + if !exists { + fs::create_dir_all(action_dir.clone()).unwrap(); + let mut path = PathBuf::from(&action_dir); + path.push("example.taaction"); + let _ = fs::write(path, include_str!("demo_action.taaction")); + } + } + 'action_loop: for path in fs::read_dir(action_dir).unwrap() { + let path = path.unwrap(); + let filename = path.file_name(); + if let Ok(meta) = path.metadata() { + if meta.is_dir() { + continue; + } + } + + if let Ok(str) = filename.into_string() { + if str.ends_with(".taaction") { + log::debug!("Detected possible action {str}"); + if let Ok(res) = fs::read_to_string(path.path()) { + if let Ok(versioned_file) = ron::from_str::(&res) { + if versioned_file.version() != 2 { + log::warn!("Action {str} uses an incompatible file version."); + continue 'action_loop; + } + } + + if let Ok(action) = ron::from_str::(&res) { + // Validate that all instructions are available for this action before loading + if let Err(missing) = + action.check_instructions_available(engine_list.clone()) + { + log::warn!( + "Couldn't load action {} because instructions {:?} aren't available.", + action.friendly_name, + missing, + ); + continue 'action_loop; + } + + log::info!( + "Discovered action {} ({}) at {:?}", + action.friendly_name, + action.id, + path.path(), + ); + + actions.insert(path.path(), action); + } else { + log::warn!("Couldn't parse action"); + } + } else { + log::warn!("Couldn't read action"); + } + } + } + } + ActionMap(actions) +} diff --git a/testangel/src/demo_action.taaction b/testangel/src/demo_action.taaction new file mode 100644 index 0000000..e31b53a --- /dev/null +++ b/testangel/src/demo_action.taaction @@ -0,0 +1 @@ +(version:2,id:"9d4cc73f-e70d-484e-9b24-b14bca40c9a0",friendly_name:"Demo Action",description:"Welcome to TestAngel! This action demonstrates how you can get started with the included engines.",group:"TestAngel",author:"TestAngel Developers",visible:true,script:"--: param Integer Age\n--: param Integer Age Limit\n--: return Boolean Is over limit?\nfunction run_action(age, limit)\n Evidence.AddText(\'Age\', \'The provided age was \' .. age)\n Evidence.AddText(\'Limit\', \'The provided age limit was \' .. limit)\n \n if age < limit then\n Interaction.WaitForOK(\'The age is less than 18, so you cannot proceed\')\n return false\n end\n \n Evidence.AddText(\'Result\', \'The age was old enough!\')\n Interaction.WaitForOK(\'The age is old enough\')\n return true\nend\n",required_instructions:["evidence-add-text","user-interaction-wait"]) \ No newline at end of file diff --git a/testangel/src/ui/flows/mod.rs b/testangel/src/ui/flows/mod.rs index ae94e24..68f6494 100644 --- a/testangel/src/ui/flows/mod.rs +++ b/testangel/src/ui/flows/mod.rs @@ -1,801 +1,806 @@ -use std::{collections::HashMap, fmt, 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::{ActionConfiguration, ActionParameterSource, AutomationFlow, VersionedFile}, -}; - -use crate::ui::flows::action_component::ActionComponentOutput; - -use super::{file_filters, lang}; - -mod action_component; -mod execution_dialog; -pub mod header; - -pub enum SaveOrOpenFlowError { - IoError(std::io::Error), - ParsingError(ron::error::SpannedError), - SerializingError(ron::Error), - FlowNotVersionCompatible, - MissingAction(usize, String), -} - -impl fmt::Display for SaveOrOpenFlowError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{}", - match self { - Self::IoError(e) => lang::lookup_with_args("flow-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("flow-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("flow-save-open-error-serializing-error", { - let mut map = HashMap::new(); - map.insert("error", e.to_string().into()); - map - }) - } - Self::FlowNotVersionCompatible => { - lang::lookup("flow-save-open-error-flow-not-version-compatible") - } - Self::MissingAction(step, e) => { - lang::lookup_with_args("flow-save-open-error-missing-action", { - let mut map = HashMap::new(); - map.insert("step", (step + 1).into()); - map.insert("error", e.to_string().into()); - map - }) - } - } - ) - } -} - -#[derive(Clone, Debug)] -pub enum FlowInputs { - /// Do nothing - NoOp, - /// The map of actions has changed and should be updated - ActionsMapChanged(Arc), - /// Create a new flow - NewFlow, - /// Actually create the new flow - _NewFlow, - /// Prompt the user to open a flow. This will ask to save first if needed. - OpenFlow, - /// Actually show the user the open file dialog - _OpenFlow, - /// Actually open a flow after the user has finished selecting - __OpenFlow(PathBuf), - /// Save the flow, prompting if needed to set file path - SaveFlow, - /// Save the flow as a new file, always prompting for a file path - SaveAsFlow, - /// Ask where to save if needed, then save - _SaveFlowThen(Box), - /// Actually write the flow to disk, then emit then input - __SaveFlowThen(PathBuf, Box), - /// Close the flow, prompting if needing to save first - CloseFlow, - /// Actually close the flow - _CloseFlow, - /// Add the step with the ID provided - AddStep(String), - /// Update the UI steps from the open flow. 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 [`FlowInputs::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, ActionConfiguration), - /// Move a step from the index to a position offset (param 3) from a new index (param 2). - MoveStep(DynamicIndex, DynamicIndex, isize), - /// Start the flow exection - RunFlow, - /// The [`ActionConfiguration`] has changed for the step indicated by the [`DynamicIndex`]. - /// This does not refresh the UI. - ConfigUpdate(DynamicIndex, ActionConfiguration), -} - -#[derive(Debug)] -pub enum FlowOutputs {} - -#[derive(Debug)] -pub struct FlowsModel { - action_map: Arc, - engine_list: Arc, - - open_flow: Option, - open_path: Option, - needs_saving: bool, - header: Rc>, - live_actions_list: FactoryVecDeque, - - execution_dialog: Option>, -} - -impl FlowsModel { - /// 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( - "FlowsModel::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 flow - fn new_flow(&mut self) { - self.open_path = None; - self.needs_saving = true; - self.open_flow = Some(AutomationFlow::default()); - self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( - self.open_flow.is_some(), - )); - } - - /// Open a flow. This does not ask to save first. - fn open_flow(&mut self, file: PathBuf) -> Result, SaveOrOpenFlowError> { - let data = &fs::read_to_string(&file).map_err(SaveOrOpenFlowError::IoError)?; - - let versioned_file: VersionedFile = - ron::from_str(data).map_err(SaveOrOpenFlowError::ParsingError)?; - if versioned_file.version() != 1 { - return Err(SaveOrOpenFlowError::FlowNotVersionCompatible); - } - - let mut flow: AutomationFlow = - ron::from_str(data).map_err(SaveOrOpenFlowError::ParsingError)?; - if flow.version() != 1 { - return Err(SaveOrOpenFlowError::FlowNotVersionCompatible); - } - let mut steps_reset = vec![]; - for (step, ac) in flow.actions.iter_mut().enumerate() { - match self.action_map.get_action_by_id(&ac.action_id) { - None => { - return Err(SaveOrOpenFlowError::MissingAction( - step, - ac.action_id.clone(), - )) - } - Some(action) => { - // Check that action parameters haven't changed. If they have, reset values. - if ac.update(action) { - steps_reset.push(step + 1); - } - } - } - } - self.open_flow = Some(flow); - self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( - self.open_flow.is_some(), - )); - self.open_path = Some(file); - self.needs_saving = false; - log::debug!("New flow open."); - log::debug!("Flow: {:?}", self.open_flow); - Ok(steps_reset) - } - - /// 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: FlowInputs) { - if self.needs_saving { - let question = self.create_message_dialog_skeleton( - lang::lookup("flow-save-before"), - lang::lookup("flow-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(FlowInputs::_SaveFlowThen(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: FlowInputs, - ) { - if always_ask_where || self.open_path.is_none() { - // Ask where - let dialog = gtk::FileDialog::builder() - .modal(true) - .title(lang::lookup("header-save")) - .initial_folder(>k::gio::File::for_path( - std::env::var("TA_FLOW_DIR").unwrap_or(".".to_string()), - )) - .filters(&file_filters::filter_list(vec![ - file_filters::flows(), - 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 mut path = file.path().unwrap(); - path.set_extension("taflow"); - sender_c.emit(FlowInputs::__SaveFlowThen(path, Box::new(then.clone()))); - } - }, - ); - } else { - sender.emit(FlowInputs::__SaveFlowThen( - self.open_path.clone().unwrap(), - Box::new(then), - )); - } - } - - /// Just save the flow to disk with the current `open_path` as the destination - fn save_flow(&mut self) -> Result<(), SaveOrOpenFlowError> { - let save_path = self.open_path.as_ref().unwrap(); - let data = ron::to_string(self.open_flow.as_ref().unwrap()) - .map_err(SaveOrOpenFlowError::SerializingError)?; - fs::write(save_path, data).map_err(SaveOrOpenFlowError::IoError)?; - self.needs_saving = false; - Ok(()) - } - - /// Close this flow without checking first - fn close_flow(&mut self) { - self.open_flow = None; - self.open_path = None; - self.needs_saving = false; - self.live_actions_list.guard().clear(); - self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( - self.open_flow.is_some(), - )); - } -} - -#[relm4::component(pub)] -impl Component for FlowsModel { - type Init = (Arc, Arc); - type Input = FlowInputs; - type Output = FlowOutputs; - type CommandOutput = (); - - view! { - #[root] - toast_target = adw::ToastOverlay { - gtk::ScrolledWindow { - set_vexpand: true, - set_hscrollbar_policy: gtk::PolicyType::Never, - - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_margin_all: 5, - - adw::StatusPage { - set_title: &lang::lookup("nothing-open"), - set_description: Some(&lang::lookup("flow-nothing-open-description")), - set_icon_name: Some(relm4_icons::icon_names::LIGHTBULB), - #[watch] - set_visible: model.open_flow.is_none(), - set_vexpand: true, - }, - - #[local_ref] - live_actions_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::FlowsHeader::builder() - .launch(init.0.clone()) - .forward(sender.input_sender(), |msg| match msg { - header::FlowsHeaderOutput::NewFlow => FlowInputs::NewFlow, - header::FlowsHeaderOutput::OpenFlow => FlowInputs::OpenFlow, - header::FlowsHeaderOutput::SaveFlow => FlowInputs::SaveFlow, - header::FlowsHeaderOutput::SaveAsFlow => FlowInputs::SaveAsFlow, - header::FlowsHeaderOutput::CloseFlow => FlowInputs::CloseFlow, - header::FlowsHeaderOutput::RunFlow => FlowInputs::RunFlow, - header::FlowsHeaderOutput::AddStep(step) => FlowInputs::AddStep(step), - }), - ); - - let model = FlowsModel { - action_map: init.0, - engine_list: init.1, - open_flow: None, - open_path: None, - needs_saving: false, - execution_dialog: None, - header, - live_actions_list: FactoryVecDeque::builder() - .launch(gtk::Box::default()) - .forward(sender.input_sender(), |output| match output { - ActionComponentOutput::Remove(idx) => FlowInputs::RemoveStep(idx), - ActionComponentOutput::Cut(idx) => FlowInputs::CutStep(idx), - ActionComponentOutput::Paste(idx, step) => FlowInputs::PasteStep(idx, step), - ActionComponentOutput::ConfigUpdate(step, config) => { - FlowInputs::ConfigUpdate(step, config) - } - ActionComponentOutput::MoveStep(from, to, offset) => { - FlowInputs::MoveStep(from, to, offset) - } - }), - }; - - // Trigger update actions from model - sender.input(FlowInputs::UpdateStepsFromModel); - - let live_actions_list = model.live_actions_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 { - FlowInputs::NoOp => (), - FlowInputs::ActionsMapChanged(new_map) => { - self.action_map = new_map.clone(); - self.header - .emit(header::FlowsHeaderInput::ActionsMapChanged(new_map)); - - // This may have changed action parameters, so check again. - let mut close_flow = false; - let mut steps_reset = vec![]; - if let Some(flow) = &mut self.open_flow { - let actions_clone = flow.actions.clone(); - for (step, ac) in flow.actions.iter_mut().enumerate() { - match self.action_map.get_action_by_id(&ac.action_id) { - None => { - close_flow = true; - } - Some(action) => { - // Check that action parameters haven't changed. If they have, reset values. - if ac.update(action.clone()) { - steps_reset.push(step); - } - - // Check that the references from this AC to another don't now violate types - for (p_id, src) in &mut ac.parameter_sources { - match src { - ActionParameterSource::FromOutput(other_step, output) => { - let (_name, kind) = &action.parameters()[*p_id]; - // Check that parameter from step->output is of type kind - if let Some(other_ac) = actions_clone.get(*other_step) { - if let Some(other_action) = &self.action_map.get_action_by_id(&other_ac.action_id) { - if let Some((_name, other_output_kind)) = other_action.outputs().get(*output) { - if kind != other_output_kind { - // Reset to literal - steps_reset.push(step); - *src = ActionParameterSource::Literal; - } - } - } - } - // If any of these if's fail, then the main loop will catch and fail later. - }, - _ => (), - } - } - } - } - } - sender.input(FlowInputs::UpdateStepsFromModel); - } - if !steps_reset.is_empty() { - let toast = - adw::Toast::new(&lang::lookup_with_args("flow-action-changed-message", { - let mut map = HashMap::new(); - map.insert("stepCount", steps_reset.len().into()); - map.insert( - "steps", - steps_reset - .iter() - .map(|i| (i + 1).to_string()) - .collect::>() - .join(", ") - .into(), - ); - map - })); - toast.set_timeout(0); // indefinte so it can be seen when switching back - widgets.toast_target.add_toast(toast); - } - if close_flow { - self.close_flow(); - } - } - FlowInputs::ConfigUpdate(step, new_config) => { - // unwrap rationale: config updates can't happen if nothing is open - let flow = self.open_flow.as_mut().unwrap(); - flow.actions[step.current_index()] = new_config; - self.needs_saving = true; - } - FlowInputs::NewFlow => { - self.prompt_to_save(sender.input_sender(), FlowInputs::_NewFlow); - } - FlowInputs::_NewFlow => { - self.new_flow(); - sender.input(FlowInputs::UpdateStepsFromModel); - } - FlowInputs::OpenFlow => { - self.prompt_to_save(sender.input_sender(), FlowInputs::_OpenFlow); - } - FlowInputs::_OpenFlow => { - let dialog = gtk::FileDialog::builder() - .modal(true) - .title(lang::lookup("header-open")) - .filters(&file_filters::filter_list(vec![ - file_filters::flows(), - file_filters::all(), - ])) - .initial_folder(>k::gio::File::for_path( - std::env::var("TA_FLOW_DIR").unwrap_or(".".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(FlowInputs::__OpenFlow(path)); - } - }, - ); - } - FlowInputs::__OpenFlow(path) => { - match self.open_flow(path) { - Ok(changes) => { - // Reload UI - sender.input(FlowInputs::UpdateStepsFromModel); - - if !changes.is_empty() { - let changed_steps = changes - .iter() - .map(|step| step.to_string()) - .collect::>() - .join(","); - self.create_message_dialog( - lang::lookup("flow-action-changed"), - lang::lookup_with_args("flow-action-changed-message", { - let mut map = HashMap::new(); - map.insert("stepCount", changes.len().into()); - map.insert("steps", changed_steps.into()); - map - }), - ) - .set_visible(true); - } - } - Err(e) => { - // Show error dialog - self.create_message_dialog( - lang::lookup("flow-error-opening"), - e.to_string(), - ) - .set_visible(true); - } - } - } - FlowInputs::SaveFlow => { - if self.open_flow.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, - FlowInputs::NoOp, - ); - } - } - FlowInputs::SaveAsFlow => { - if self.open_flow.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, - FlowInputs::NoOp, - ); - } - } - FlowInputs::_SaveFlowThen(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, - ); - } - FlowInputs::__SaveFlowThen(path, then) => { - self.open_path = Some(path); - if let Err(e) = self.save_flow() { - self.create_message_dialog(lang::lookup("flow-error-saving"), e.to_string()) - .set_visible(true); - } else { - widgets - .toast_target - .add_toast(adw::Toast::new(&lang::lookup("flow-saved"))); - sender.input_sender().emit(*then); - } - } - FlowInputs::CloseFlow => { - self.prompt_to_save(sender.input_sender(), FlowInputs::_CloseFlow); - } - FlowInputs::_CloseFlow => { - self.close_flow(); - } - - FlowInputs::RunFlow => { - if let Some(flow) = &self.open_flow { - let e_dialog = execution_dialog::ExecutionDialog::builder() - .transient_for(root) - .launch(execution_dialog::ExecutionDialogInit { - flow: flow.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); - } - } - - FlowInputs::AddStep(step_id) => { - if self.open_flow.is_none() { - self.new_flow(); - } - - // unwrap rationale: we've just guaranteed a flow is open - let flow = self.open_flow.as_mut().unwrap(); - // unwrap rationale: the header can't ask to add an action that doesn't exist - flow.actions.push(ActionConfiguration::from( - self.action_map.get_action_by_id(&step_id).unwrap(), - )); - self.needs_saving = true; - // Trigger UI steps refresh - sender.input(FlowInputs::UpdateStepsFromModel); - } - - FlowInputs::UpdateStepsFromModel => { - let mut live_list = self.live_actions_list.guard(); - live_list.clear(); - if let Some(flow) = &self.open_flow { - let mut possible_outputs = vec![]; - for (step, config) in flow.actions.iter().enumerate() { - live_list.push_back(action_component::ActionComponentInitialiser { - possible_outputs: possible_outputs.clone(), - config: config.clone(), - action: self.action_map.get_action_by_id(&config.action_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_idx, (name, kind)) in self - .action_map - .get_action_by_id(&config.action_id) - .unwrap() - .outputs() - .iter() - .enumerate() - { - 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, - ActionParameterSource::FromOutput(step, output_idx), - )); - } - } - } - } - - FlowInputs::RemoveStep(step_idx) => { - let idx = step_idx.current_index(); - let flow = self.open_flow.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 >= flow.actions.len() { - log::warn!("Skipped running RemoveStep as the index was invalid."); - return; - } - - log::info!("Deleting step {}", idx + 1); - - flow.actions.remove(idx); - - // Remove references to step and renumber references above step to one less than they were - for step in flow.actions.iter_mut() { - for (_step_idx, source) in step.parameter_sources.iter_mut() { - if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { - match (*from_step).cmp(&idx) { - std::cmp::Ordering::Equal => { - *source = ActionParameterSource::Literal - } - std::cmp::Ordering::Greater => *from_step -= 1, - _ => (), - } - } - } - } - - self.needs_saving = true; - - // Trigger UI steps refresh - sender.input(FlowInputs::UpdateStepsFromModel); - } - FlowInputs::CutStep(step_idx) => { - let idx = step_idx.current_index(); - let flow = self.open_flow.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 >= flow.actions.len() { - log::warn!("Skipped running CutStep as the index was invalid."); - return; - } - - flow.actions.remove(idx); - - // Remove references to step and renumber references above step to one less than they were - for step in flow.actions.iter_mut() { - for (_param_idx, source) in step.parameter_sources.iter_mut() { - if let ActionParameterSource::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, - _ => (), - } - } - } - } - - log::debug!("After cut, flow is: {flow:?}"); - - self.needs_saving = true; - } - FlowInputs::PasteStep(idx, mut config) => { - let flow = self.open_flow.as_mut().unwrap(); - let idx = idx.max(0).min(flow.actions.len()); - - // Adjust step just about to paste - for (_param_idx, source) in config.parameter_sources.iter_mut() { - if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { - if *from_step <= idx { - *source = ActionParameterSource::Literal; - } - } - } - - log::info!("Pasting step to {}", idx + 1); - flow.actions.insert(idx, config); - - // Remove references to step and renumber references above step to one less than they were - for (step_idx, step) in flow.actions.iter_mut().enumerate() { - for (_param_idx, source) in step.parameter_sources.iter_mut() { - if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { - if *from_step == usize::MAX { - if step_idx < idx { - // can't refer to it anymore - *source = ActionParameterSource::Literal; - } else { - *from_step = idx; - } - } else if *from_step >= idx { - *from_step += 1; - } - } - } - } - - log::debug!("After paste, flow is: {flow:?}"); - - self.needs_saving = true; - - // Trigger UI steps refresh - sender.input(FlowInputs::UpdateStepsFromModel); - } - FlowInputs::MoveStep(from, to, offset) => { - let current_from = from.current_index(); - let step = self.open_flow.as_ref().unwrap().actions[current_from].clone(); - sender.input(FlowInputs::CutStep(from)); - - // Establish new position - let mut to = (to.current_index() as isize + offset).max(0) as usize; - if to > current_from && to > 0 { - to -= 1; - } - - sender.input(FlowInputs::PasteStep(to, step)); - } - } - self.update_view(widgets, sender); - } -} +use std::{collections::HashMap, fmt, 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::{ActionConfiguration, ActionParameterSource, AutomationFlow, VersionedFile}, +}; + +use crate::ui::flows::action_component::ActionComponentOutput; + +use super::{file_filters, lang}; + +mod action_component; +mod execution_dialog; +pub mod header; + +pub enum SaveOrOpenFlowError { + IoError(std::io::Error), + ParsingError(ron::error::SpannedError), + SerializingError(ron::Error), + FlowNotVersionCompatible, + MissingAction(usize, String), +} + +impl fmt::Display for SaveOrOpenFlowError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::IoError(e) => lang::lookup_with_args("flow-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("flow-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("flow-save-open-error-serializing-error", { + let mut map = HashMap::new(); + map.insert("error", e.to_string().into()); + map + }) + } + Self::FlowNotVersionCompatible => { + lang::lookup("flow-save-open-error-flow-not-version-compatible") + } + Self::MissingAction(step, e) => { + lang::lookup_with_args("flow-save-open-error-missing-action", { + let mut map = HashMap::new(); + map.insert("step", (step + 1).into()); + map.insert("error", e.to_string().into()); + map + }) + } + } + ) + } +} + +#[derive(Clone, Debug)] +pub enum FlowInputs { + /// Do nothing + NoOp, + /// The map of actions has changed and should be updated + ActionsMapChanged(Arc), + /// Create a new flow + NewFlow, + /// Actually create the new flow + _NewFlow, + /// Prompt the user to open a flow. This will ask to save first if needed. + OpenFlow, + /// Actually show the user the open file dialog + _OpenFlow, + /// Actually open a flow after the user has finished selecting + __OpenFlow(PathBuf), + /// Save the flow, prompting if needed to set file path + SaveFlow, + /// Save the flow as a new file, always prompting for a file path + SaveAsFlow, + /// Ask where to save if needed, then save + _SaveFlowThen(Box), + /// Actually write the flow to disk, then emit then input + __SaveFlowThen(PathBuf, Box), + /// Close the flow, prompting if needing to save first + CloseFlow, + /// Actually close the flow + _CloseFlow, + /// Add the step with the ID provided + AddStep(String), + /// Update the UI steps from the open flow. 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 [`FlowInputs::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, ActionConfiguration), + /// Move a step from the index to a position offset (param 3) from a new index (param 2). + MoveStep(DynamicIndex, DynamicIndex, isize), + /// Start the flow exection + RunFlow, + /// The [`ActionConfiguration`] has changed for the step indicated by the [`DynamicIndex`]. + /// This does not refresh the UI. + ConfigUpdate(DynamicIndex, ActionConfiguration), +} + +#[derive(Debug)] +pub enum FlowOutputs {} + +#[derive(Debug)] +pub struct FlowsModel { + action_map: Arc, + engine_list: Arc, + + open_flow: Option, + open_path: Option, + needs_saving: bool, + header: Rc>, + live_actions_list: FactoryVecDeque, + + execution_dialog: Option>, +} + +impl FlowsModel { + /// 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( + "FlowsModel::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 flow + fn new_flow(&mut self) { + self.open_path = None; + self.needs_saving = true; + self.open_flow = Some(AutomationFlow::default()); + self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( + self.open_flow.is_some(), + )); + } + + /// Open a flow. This does not ask to save first. + fn open_flow(&mut self, file: PathBuf) -> Result, SaveOrOpenFlowError> { + let data = &fs::read_to_string(&file).map_err(SaveOrOpenFlowError::IoError)?; + + let versioned_file: VersionedFile = + ron::from_str(data).map_err(SaveOrOpenFlowError::ParsingError)?; + if versioned_file.version() != 1 { + return Err(SaveOrOpenFlowError::FlowNotVersionCompatible); + } + + let mut flow: AutomationFlow = + ron::from_str(data).map_err(SaveOrOpenFlowError::ParsingError)?; + if flow.version() != 1 { + return Err(SaveOrOpenFlowError::FlowNotVersionCompatible); + } + let mut steps_reset = vec![]; + for (step, ac) in flow.actions.iter_mut().enumerate() { + match self.action_map.get_action_by_id(&ac.action_id) { + None => { + return Err(SaveOrOpenFlowError::MissingAction( + step, + ac.action_id.clone(), + )) + } + Some(action) => { + // Check that action parameters haven't changed. If they have, reset values. + if ac.update(action) { + steps_reset.push(step + 1); + } + } + } + } + self.open_flow = Some(flow); + self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( + self.open_flow.is_some(), + )); + self.open_path = Some(file); + self.needs_saving = false; + log::debug!("New flow open."); + log::debug!("Flow: {:?}", self.open_flow); + Ok(steps_reset) + } + + /// 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: FlowInputs) { + if self.needs_saving { + let question = self.create_message_dialog_skeleton( + lang::lookup("flow-save-before"), + lang::lookup("flow-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(FlowInputs::_SaveFlowThen(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: FlowInputs, + ) { + if always_ask_where || self.open_path.is_none() { + // Ask where + let dialog = gtk::FileDialog::builder() + .modal(true) + .title(lang::lookup("header-save")) + .initial_folder(>k::gio::File::for_path( + std::env::var("TA_FLOW_DIR").unwrap_or(".".to_string()), + )) + .filters(&file_filters::filter_list(vec![ + file_filters::flows(), + 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 mut path = file.path().unwrap(); + path.set_extension("taflow"); + sender_c.emit(FlowInputs::__SaveFlowThen(path, Box::new(then.clone()))); + } + }, + ); + } else { + sender.emit(FlowInputs::__SaveFlowThen( + self.open_path.clone().unwrap(), + Box::new(then), + )); + } + } + + /// Just save the flow to disk with the current `open_path` as the destination + fn save_flow(&mut self) -> Result<(), SaveOrOpenFlowError> { + let save_path = self.open_path.as_ref().unwrap(); + let data = ron::to_string(self.open_flow.as_ref().unwrap()) + .map_err(SaveOrOpenFlowError::SerializingError)?; + fs::write(save_path, data).map_err(SaveOrOpenFlowError::IoError)?; + self.needs_saving = false; + Ok(()) + } + + /// Close this flow without checking first + fn close_flow(&mut self) { + self.open_flow = None; + self.open_path = None; + self.needs_saving = false; + self.live_actions_list.guard().clear(); + self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( + self.open_flow.is_some(), + )); + } +} + +#[relm4::component(pub)] +impl Component for FlowsModel { + type Init = (Arc, Arc); + type Input = FlowInputs; + type Output = FlowOutputs; + type CommandOutput = (); + + view! { + #[root] + toast_target = adw::ToastOverlay { + gtk::ScrolledWindow { + set_vexpand: true, + set_hscrollbar_policy: gtk::PolicyType::Never, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_margin_all: 5, + + adw::StatusPage { + set_title: &lang::lookup("nothing-open"), + set_description: Some(&lang::lookup("flow-nothing-open-description")), + set_icon_name: Some(relm4_icons::icon_names::LIGHTBULB), + #[watch] + set_visible: model.open_flow.is_none(), + set_vexpand: true, + }, + + #[local_ref] + live_actions_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::FlowsHeader::builder() + .launch(init.0.clone()) + .forward(sender.input_sender(), |msg| match msg { + header::FlowsHeaderOutput::NewFlow => FlowInputs::NewFlow, + header::FlowsHeaderOutput::OpenFlow => FlowInputs::OpenFlow, + header::FlowsHeaderOutput::SaveFlow => FlowInputs::SaveFlow, + header::FlowsHeaderOutput::SaveAsFlow => FlowInputs::SaveAsFlow, + header::FlowsHeaderOutput::CloseFlow => FlowInputs::CloseFlow, + header::FlowsHeaderOutput::RunFlow => FlowInputs::RunFlow, + header::FlowsHeaderOutput::AddStep(step) => FlowInputs::AddStep(step), + }), + ); + + let model = FlowsModel { + action_map: init.0, + engine_list: init.1, + open_flow: None, + open_path: None, + needs_saving: false, + execution_dialog: None, + header, + live_actions_list: FactoryVecDeque::builder() + .launch(gtk::Box::default()) + .forward(sender.input_sender(), |output| match output { + ActionComponentOutput::Remove(idx) => FlowInputs::RemoveStep(idx), + ActionComponentOutput::Cut(idx) => FlowInputs::CutStep(idx), + ActionComponentOutput::Paste(idx, step) => FlowInputs::PasteStep(idx, step), + ActionComponentOutput::ConfigUpdate(step, config) => { + FlowInputs::ConfigUpdate(step, config) + } + ActionComponentOutput::MoveStep(from, to, offset) => { + FlowInputs::MoveStep(from, to, offset) + } + }), + }; + + // Trigger update actions from model + sender.input(FlowInputs::UpdateStepsFromModel); + + let live_actions_list = model.live_actions_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 { + FlowInputs::NoOp => (), + FlowInputs::ActionsMapChanged(new_map) => { + self.action_map = new_map.clone(); + self.header + .emit(header::FlowsHeaderInput::ActionsMapChanged(new_map)); + + // This may have changed action parameters, so check again. + let mut close_flow = false; + let mut steps_reset = vec![]; + if let Some(flow) = &mut self.open_flow { + let actions_clone = flow.actions.clone(); + for (step, ac) in flow.actions.iter_mut().enumerate() { + match self.action_map.get_action_by_id(&ac.action_id) { + None => { + close_flow = true; + } + Some(action) => { + // Check that action parameters haven't changed. If they have, reset values. + if ac.update(action.clone()) { + steps_reset.push(step); + } + + // Check that the references from this AC to another don't now violate types + for (p_id, src) in &mut ac.parameter_sources { + match src { + ActionParameterSource::FromOutput(other_step, output) => { + let (_name, kind) = &action.parameters()[*p_id]; + // Check that parameter from step->output is of type kind + if let Some(other_ac) = actions_clone.get(*other_step) { + if let Some(other_action) = &self + .action_map + .get_action_by_id(&other_ac.action_id) + { + if let Some((_name, other_output_kind)) = + other_action.outputs().get(*output) + { + if kind != other_output_kind { + // Reset to literal + steps_reset.push(step); + *src = ActionParameterSource::Literal; + } + } + } + } + // If any of these if's fail, then the main loop will catch and fail later. + } + _ => (), + } + } + } + } + } + sender.input(FlowInputs::UpdateStepsFromModel); + } + if !steps_reset.is_empty() { + let toast = + adw::Toast::new(&lang::lookup_with_args("flow-action-changed-message", { + let mut map = HashMap::new(); + map.insert("stepCount", steps_reset.len().into()); + map.insert( + "steps", + steps_reset + .iter() + .map(|i| (i + 1).to_string()) + .collect::>() + .join(", ") + .into(), + ); + map + })); + toast.set_timeout(0); // indefinte so it can be seen when switching back + widgets.toast_target.add_toast(toast); + } + if close_flow { + self.close_flow(); + } + } + FlowInputs::ConfigUpdate(step, new_config) => { + // unwrap rationale: config updates can't happen if nothing is open + let flow = self.open_flow.as_mut().unwrap(); + flow.actions[step.current_index()] = new_config; + self.needs_saving = true; + } + FlowInputs::NewFlow => { + self.prompt_to_save(sender.input_sender(), FlowInputs::_NewFlow); + } + FlowInputs::_NewFlow => { + self.new_flow(); + sender.input(FlowInputs::UpdateStepsFromModel); + } + FlowInputs::OpenFlow => { + self.prompt_to_save(sender.input_sender(), FlowInputs::_OpenFlow); + } + FlowInputs::_OpenFlow => { + let dialog = gtk::FileDialog::builder() + .modal(true) + .title(lang::lookup("header-open")) + .filters(&file_filters::filter_list(vec![ + file_filters::flows(), + file_filters::all(), + ])) + .initial_folder(>k::gio::File::for_path( + std::env::var("TA_FLOW_DIR").unwrap_or(".".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(FlowInputs::__OpenFlow(path)); + } + }, + ); + } + FlowInputs::__OpenFlow(path) => { + match self.open_flow(path) { + Ok(changes) => { + // Reload UI + sender.input(FlowInputs::UpdateStepsFromModel); + + if !changes.is_empty() { + let changed_steps = changes + .iter() + .map(|step| step.to_string()) + .collect::>() + .join(","); + self.create_message_dialog( + lang::lookup("flow-action-changed"), + lang::lookup_with_args("flow-action-changed-message", { + let mut map = HashMap::new(); + map.insert("stepCount", changes.len().into()); + map.insert("steps", changed_steps.into()); + map + }), + ) + .set_visible(true); + } + } + Err(e) => { + // Show error dialog + self.create_message_dialog( + lang::lookup("flow-error-opening"), + e.to_string(), + ) + .set_visible(true); + } + } + } + FlowInputs::SaveFlow => { + if self.open_flow.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, + FlowInputs::NoOp, + ); + } + } + FlowInputs::SaveAsFlow => { + if self.open_flow.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, + FlowInputs::NoOp, + ); + } + } + FlowInputs::_SaveFlowThen(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, + ); + } + FlowInputs::__SaveFlowThen(path, then) => { + self.open_path = Some(path); + if let Err(e) = self.save_flow() { + self.create_message_dialog(lang::lookup("flow-error-saving"), e.to_string()) + .set_visible(true); + } else { + widgets + .toast_target + .add_toast(adw::Toast::new(&lang::lookup("flow-saved"))); + sender.input_sender().emit(*then); + } + } + FlowInputs::CloseFlow => { + self.prompt_to_save(sender.input_sender(), FlowInputs::_CloseFlow); + } + FlowInputs::_CloseFlow => { + self.close_flow(); + } + + FlowInputs::RunFlow => { + if let Some(flow) = &self.open_flow { + let e_dialog = execution_dialog::ExecutionDialog::builder() + .transient_for(root) + .launch(execution_dialog::ExecutionDialogInit { + flow: flow.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); + } + } + + FlowInputs::AddStep(step_id) => { + if self.open_flow.is_none() { + self.new_flow(); + } + + // unwrap rationale: we've just guaranteed a flow is open + let flow = self.open_flow.as_mut().unwrap(); + // unwrap rationale: the header can't ask to add an action that doesn't exist + flow.actions.push(ActionConfiguration::from( + self.action_map.get_action_by_id(&step_id).unwrap(), + )); + self.needs_saving = true; + // Trigger UI steps refresh + sender.input(FlowInputs::UpdateStepsFromModel); + } + + FlowInputs::UpdateStepsFromModel => { + let mut live_list = self.live_actions_list.guard(); + live_list.clear(); + if let Some(flow) = &self.open_flow { + let mut possible_outputs = vec![]; + for (step, config) in flow.actions.iter().enumerate() { + live_list.push_back(action_component::ActionComponentInitialiser { + possible_outputs: possible_outputs.clone(), + config: config.clone(), + action: self.action_map.get_action_by_id(&config.action_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_idx, (name, kind)) in self + .action_map + .get_action_by_id(&config.action_id) + .unwrap() + .outputs() + .iter() + .enumerate() + { + 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, + ActionParameterSource::FromOutput(step, output_idx), + )); + } + } + } + } + + FlowInputs::RemoveStep(step_idx) => { + let idx = step_idx.current_index(); + let flow = self.open_flow.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 >= flow.actions.len() { + log::warn!("Skipped running RemoveStep as the index was invalid."); + return; + } + + log::info!("Deleting step {}", idx + 1); + + flow.actions.remove(idx); + + // Remove references to step and renumber references above step to one less than they were + for step in flow.actions.iter_mut() { + for (_step_idx, source) in step.parameter_sources.iter_mut() { + if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { + match (*from_step).cmp(&idx) { + std::cmp::Ordering::Equal => { + *source = ActionParameterSource::Literal + } + std::cmp::Ordering::Greater => *from_step -= 1, + _ => (), + } + } + } + } + + self.needs_saving = true; + + // Trigger UI steps refresh + sender.input(FlowInputs::UpdateStepsFromModel); + } + FlowInputs::CutStep(step_idx) => { + let idx = step_idx.current_index(); + let flow = self.open_flow.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 >= flow.actions.len() { + log::warn!("Skipped running CutStep as the index was invalid."); + return; + } + + flow.actions.remove(idx); + + // Remove references to step and renumber references above step to one less than they were + for step in flow.actions.iter_mut() { + for (_param_idx, source) in step.parameter_sources.iter_mut() { + if let ActionParameterSource::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, + _ => (), + } + } + } + } + + log::debug!("After cut, flow is: {flow:?}"); + + self.needs_saving = true; + } + FlowInputs::PasteStep(idx, mut config) => { + let flow = self.open_flow.as_mut().unwrap(); + let idx = idx.max(0).min(flow.actions.len()); + + // Adjust step just about to paste + for (_param_idx, source) in config.parameter_sources.iter_mut() { + if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { + if *from_step <= idx { + *source = ActionParameterSource::Literal; + } + } + } + + log::info!("Pasting step to {}", idx + 1); + flow.actions.insert(idx, config); + + // Remove references to step and renumber references above step to one less than they were + for (step_idx, step) in flow.actions.iter_mut().enumerate() { + for (_param_idx, source) in step.parameter_sources.iter_mut() { + if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { + if *from_step == usize::MAX { + if step_idx < idx { + // can't refer to it anymore + *source = ActionParameterSource::Literal; + } else { + *from_step = idx; + } + } else if *from_step >= idx { + *from_step += 1; + } + } + } + } + + log::debug!("After paste, flow is: {flow:?}"); + + self.needs_saving = true; + + // Trigger UI steps refresh + sender.input(FlowInputs::UpdateStepsFromModel); + } + FlowInputs::MoveStep(from, to, offset) => { + let current_from = from.current_index(); + let step = self.open_flow.as_ref().unwrap().actions[current_from].clone(); + sender.input(FlowInputs::CutStep(from)); + + // Establish new position + let mut to = (to.current_index() as isize + offset).max(0) as usize; + if to > current_from && to > 0 { + to -= 1; + } + + sender.input(FlowInputs::PasteStep(to, step)); + } + } + self.update_view(widgets, sender); + } +} From 3bdff1bc95367efcd0643e8f97cea57949f429ed Mon Sep 17 00:00:00 2001 From: Lily Hopkins Date: Mon, 9 Dec 2024 23:45:46 +0000 Subject: [PATCH 09/10] applied clippy suggestions --- testangel/src/ui/flows/mod.rs | 1609 ++++++++++++++++----------------- 1 file changed, 803 insertions(+), 806 deletions(-) diff --git a/testangel/src/ui/flows/mod.rs b/testangel/src/ui/flows/mod.rs index 68f6494..eb43088 100644 --- a/testangel/src/ui/flows/mod.rs +++ b/testangel/src/ui/flows/mod.rs @@ -1,806 +1,803 @@ -use std::{collections::HashMap, fmt, 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::{ActionConfiguration, ActionParameterSource, AutomationFlow, VersionedFile}, -}; - -use crate::ui::flows::action_component::ActionComponentOutput; - -use super::{file_filters, lang}; - -mod action_component; -mod execution_dialog; -pub mod header; - -pub enum SaveOrOpenFlowError { - IoError(std::io::Error), - ParsingError(ron::error::SpannedError), - SerializingError(ron::Error), - FlowNotVersionCompatible, - MissingAction(usize, String), -} - -impl fmt::Display for SaveOrOpenFlowError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{}", - match self { - Self::IoError(e) => lang::lookup_with_args("flow-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("flow-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("flow-save-open-error-serializing-error", { - let mut map = HashMap::new(); - map.insert("error", e.to_string().into()); - map - }) - } - Self::FlowNotVersionCompatible => { - lang::lookup("flow-save-open-error-flow-not-version-compatible") - } - Self::MissingAction(step, e) => { - lang::lookup_with_args("flow-save-open-error-missing-action", { - let mut map = HashMap::new(); - map.insert("step", (step + 1).into()); - map.insert("error", e.to_string().into()); - map - }) - } - } - ) - } -} - -#[derive(Clone, Debug)] -pub enum FlowInputs { - /// Do nothing - NoOp, - /// The map of actions has changed and should be updated - ActionsMapChanged(Arc), - /// Create a new flow - NewFlow, - /// Actually create the new flow - _NewFlow, - /// Prompt the user to open a flow. This will ask to save first if needed. - OpenFlow, - /// Actually show the user the open file dialog - _OpenFlow, - /// Actually open a flow after the user has finished selecting - __OpenFlow(PathBuf), - /// Save the flow, prompting if needed to set file path - SaveFlow, - /// Save the flow as a new file, always prompting for a file path - SaveAsFlow, - /// Ask where to save if needed, then save - _SaveFlowThen(Box), - /// Actually write the flow to disk, then emit then input - __SaveFlowThen(PathBuf, Box), - /// Close the flow, prompting if needing to save first - CloseFlow, - /// Actually close the flow - _CloseFlow, - /// Add the step with the ID provided - AddStep(String), - /// Update the UI steps from the open flow. 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 [`FlowInputs::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, ActionConfiguration), - /// Move a step from the index to a position offset (param 3) from a new index (param 2). - MoveStep(DynamicIndex, DynamicIndex, isize), - /// Start the flow exection - RunFlow, - /// The [`ActionConfiguration`] has changed for the step indicated by the [`DynamicIndex`]. - /// This does not refresh the UI. - ConfigUpdate(DynamicIndex, ActionConfiguration), -} - -#[derive(Debug)] -pub enum FlowOutputs {} - -#[derive(Debug)] -pub struct FlowsModel { - action_map: Arc, - engine_list: Arc, - - open_flow: Option, - open_path: Option, - needs_saving: bool, - header: Rc>, - live_actions_list: FactoryVecDeque, - - execution_dialog: Option>, -} - -impl FlowsModel { - /// 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( - "FlowsModel::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 flow - fn new_flow(&mut self) { - self.open_path = None; - self.needs_saving = true; - self.open_flow = Some(AutomationFlow::default()); - self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( - self.open_flow.is_some(), - )); - } - - /// Open a flow. This does not ask to save first. - fn open_flow(&mut self, file: PathBuf) -> Result, SaveOrOpenFlowError> { - let data = &fs::read_to_string(&file).map_err(SaveOrOpenFlowError::IoError)?; - - let versioned_file: VersionedFile = - ron::from_str(data).map_err(SaveOrOpenFlowError::ParsingError)?; - if versioned_file.version() != 1 { - return Err(SaveOrOpenFlowError::FlowNotVersionCompatible); - } - - let mut flow: AutomationFlow = - ron::from_str(data).map_err(SaveOrOpenFlowError::ParsingError)?; - if flow.version() != 1 { - return Err(SaveOrOpenFlowError::FlowNotVersionCompatible); - } - let mut steps_reset = vec![]; - for (step, ac) in flow.actions.iter_mut().enumerate() { - match self.action_map.get_action_by_id(&ac.action_id) { - None => { - return Err(SaveOrOpenFlowError::MissingAction( - step, - ac.action_id.clone(), - )) - } - Some(action) => { - // Check that action parameters haven't changed. If they have, reset values. - if ac.update(action) { - steps_reset.push(step + 1); - } - } - } - } - self.open_flow = Some(flow); - self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( - self.open_flow.is_some(), - )); - self.open_path = Some(file); - self.needs_saving = false; - log::debug!("New flow open."); - log::debug!("Flow: {:?}", self.open_flow); - Ok(steps_reset) - } - - /// 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: FlowInputs) { - if self.needs_saving { - let question = self.create_message_dialog_skeleton( - lang::lookup("flow-save-before"), - lang::lookup("flow-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(FlowInputs::_SaveFlowThen(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: FlowInputs, - ) { - if always_ask_where || self.open_path.is_none() { - // Ask where - let dialog = gtk::FileDialog::builder() - .modal(true) - .title(lang::lookup("header-save")) - .initial_folder(>k::gio::File::for_path( - std::env::var("TA_FLOW_DIR").unwrap_or(".".to_string()), - )) - .filters(&file_filters::filter_list(vec![ - file_filters::flows(), - 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 mut path = file.path().unwrap(); - path.set_extension("taflow"); - sender_c.emit(FlowInputs::__SaveFlowThen(path, Box::new(then.clone()))); - } - }, - ); - } else { - sender.emit(FlowInputs::__SaveFlowThen( - self.open_path.clone().unwrap(), - Box::new(then), - )); - } - } - - /// Just save the flow to disk with the current `open_path` as the destination - fn save_flow(&mut self) -> Result<(), SaveOrOpenFlowError> { - let save_path = self.open_path.as_ref().unwrap(); - let data = ron::to_string(self.open_flow.as_ref().unwrap()) - .map_err(SaveOrOpenFlowError::SerializingError)?; - fs::write(save_path, data).map_err(SaveOrOpenFlowError::IoError)?; - self.needs_saving = false; - Ok(()) - } - - /// Close this flow without checking first - fn close_flow(&mut self) { - self.open_flow = None; - self.open_path = None; - self.needs_saving = false; - self.live_actions_list.guard().clear(); - self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( - self.open_flow.is_some(), - )); - } -} - -#[relm4::component(pub)] -impl Component for FlowsModel { - type Init = (Arc, Arc); - type Input = FlowInputs; - type Output = FlowOutputs; - type CommandOutput = (); - - view! { - #[root] - toast_target = adw::ToastOverlay { - gtk::ScrolledWindow { - set_vexpand: true, - set_hscrollbar_policy: gtk::PolicyType::Never, - - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_margin_all: 5, - - adw::StatusPage { - set_title: &lang::lookup("nothing-open"), - set_description: Some(&lang::lookup("flow-nothing-open-description")), - set_icon_name: Some(relm4_icons::icon_names::LIGHTBULB), - #[watch] - set_visible: model.open_flow.is_none(), - set_vexpand: true, - }, - - #[local_ref] - live_actions_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::FlowsHeader::builder() - .launch(init.0.clone()) - .forward(sender.input_sender(), |msg| match msg { - header::FlowsHeaderOutput::NewFlow => FlowInputs::NewFlow, - header::FlowsHeaderOutput::OpenFlow => FlowInputs::OpenFlow, - header::FlowsHeaderOutput::SaveFlow => FlowInputs::SaveFlow, - header::FlowsHeaderOutput::SaveAsFlow => FlowInputs::SaveAsFlow, - header::FlowsHeaderOutput::CloseFlow => FlowInputs::CloseFlow, - header::FlowsHeaderOutput::RunFlow => FlowInputs::RunFlow, - header::FlowsHeaderOutput::AddStep(step) => FlowInputs::AddStep(step), - }), - ); - - let model = FlowsModel { - action_map: init.0, - engine_list: init.1, - open_flow: None, - open_path: None, - needs_saving: false, - execution_dialog: None, - header, - live_actions_list: FactoryVecDeque::builder() - .launch(gtk::Box::default()) - .forward(sender.input_sender(), |output| match output { - ActionComponentOutput::Remove(idx) => FlowInputs::RemoveStep(idx), - ActionComponentOutput::Cut(idx) => FlowInputs::CutStep(idx), - ActionComponentOutput::Paste(idx, step) => FlowInputs::PasteStep(idx, step), - ActionComponentOutput::ConfigUpdate(step, config) => { - FlowInputs::ConfigUpdate(step, config) - } - ActionComponentOutput::MoveStep(from, to, offset) => { - FlowInputs::MoveStep(from, to, offset) - } - }), - }; - - // Trigger update actions from model - sender.input(FlowInputs::UpdateStepsFromModel); - - let live_actions_list = model.live_actions_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 { - FlowInputs::NoOp => (), - FlowInputs::ActionsMapChanged(new_map) => { - self.action_map = new_map.clone(); - self.header - .emit(header::FlowsHeaderInput::ActionsMapChanged(new_map)); - - // This may have changed action parameters, so check again. - let mut close_flow = false; - let mut steps_reset = vec![]; - if let Some(flow) = &mut self.open_flow { - let actions_clone = flow.actions.clone(); - for (step, ac) in flow.actions.iter_mut().enumerate() { - match self.action_map.get_action_by_id(&ac.action_id) { - None => { - close_flow = true; - } - Some(action) => { - // Check that action parameters haven't changed. If they have, reset values. - if ac.update(action.clone()) { - steps_reset.push(step); - } - - // Check that the references from this AC to another don't now violate types - for (p_id, src) in &mut ac.parameter_sources { - match src { - ActionParameterSource::FromOutput(other_step, output) => { - let (_name, kind) = &action.parameters()[*p_id]; - // Check that parameter from step->output is of type kind - if let Some(other_ac) = actions_clone.get(*other_step) { - if let Some(other_action) = &self - .action_map - .get_action_by_id(&other_ac.action_id) - { - if let Some((_name, other_output_kind)) = - other_action.outputs().get(*output) - { - if kind != other_output_kind { - // Reset to literal - steps_reset.push(step); - *src = ActionParameterSource::Literal; - } - } - } - } - // If any of these if's fail, then the main loop will catch and fail later. - } - _ => (), - } - } - } - } - } - sender.input(FlowInputs::UpdateStepsFromModel); - } - if !steps_reset.is_empty() { - let toast = - adw::Toast::new(&lang::lookup_with_args("flow-action-changed-message", { - let mut map = HashMap::new(); - map.insert("stepCount", steps_reset.len().into()); - map.insert( - "steps", - steps_reset - .iter() - .map(|i| (i + 1).to_string()) - .collect::>() - .join(", ") - .into(), - ); - map - })); - toast.set_timeout(0); // indefinte so it can be seen when switching back - widgets.toast_target.add_toast(toast); - } - if close_flow { - self.close_flow(); - } - } - FlowInputs::ConfigUpdate(step, new_config) => { - // unwrap rationale: config updates can't happen if nothing is open - let flow = self.open_flow.as_mut().unwrap(); - flow.actions[step.current_index()] = new_config; - self.needs_saving = true; - } - FlowInputs::NewFlow => { - self.prompt_to_save(sender.input_sender(), FlowInputs::_NewFlow); - } - FlowInputs::_NewFlow => { - self.new_flow(); - sender.input(FlowInputs::UpdateStepsFromModel); - } - FlowInputs::OpenFlow => { - self.prompt_to_save(sender.input_sender(), FlowInputs::_OpenFlow); - } - FlowInputs::_OpenFlow => { - let dialog = gtk::FileDialog::builder() - .modal(true) - .title(lang::lookup("header-open")) - .filters(&file_filters::filter_list(vec![ - file_filters::flows(), - file_filters::all(), - ])) - .initial_folder(>k::gio::File::for_path( - std::env::var("TA_FLOW_DIR").unwrap_or(".".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(FlowInputs::__OpenFlow(path)); - } - }, - ); - } - FlowInputs::__OpenFlow(path) => { - match self.open_flow(path) { - Ok(changes) => { - // Reload UI - sender.input(FlowInputs::UpdateStepsFromModel); - - if !changes.is_empty() { - let changed_steps = changes - .iter() - .map(|step| step.to_string()) - .collect::>() - .join(","); - self.create_message_dialog( - lang::lookup("flow-action-changed"), - lang::lookup_with_args("flow-action-changed-message", { - let mut map = HashMap::new(); - map.insert("stepCount", changes.len().into()); - map.insert("steps", changed_steps.into()); - map - }), - ) - .set_visible(true); - } - } - Err(e) => { - // Show error dialog - self.create_message_dialog( - lang::lookup("flow-error-opening"), - e.to_string(), - ) - .set_visible(true); - } - } - } - FlowInputs::SaveFlow => { - if self.open_flow.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, - FlowInputs::NoOp, - ); - } - } - FlowInputs::SaveAsFlow => { - if self.open_flow.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, - FlowInputs::NoOp, - ); - } - } - FlowInputs::_SaveFlowThen(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, - ); - } - FlowInputs::__SaveFlowThen(path, then) => { - self.open_path = Some(path); - if let Err(e) = self.save_flow() { - self.create_message_dialog(lang::lookup("flow-error-saving"), e.to_string()) - .set_visible(true); - } else { - widgets - .toast_target - .add_toast(adw::Toast::new(&lang::lookup("flow-saved"))); - sender.input_sender().emit(*then); - } - } - FlowInputs::CloseFlow => { - self.prompt_to_save(sender.input_sender(), FlowInputs::_CloseFlow); - } - FlowInputs::_CloseFlow => { - self.close_flow(); - } - - FlowInputs::RunFlow => { - if let Some(flow) = &self.open_flow { - let e_dialog = execution_dialog::ExecutionDialog::builder() - .transient_for(root) - .launch(execution_dialog::ExecutionDialogInit { - flow: flow.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); - } - } - - FlowInputs::AddStep(step_id) => { - if self.open_flow.is_none() { - self.new_flow(); - } - - // unwrap rationale: we've just guaranteed a flow is open - let flow = self.open_flow.as_mut().unwrap(); - // unwrap rationale: the header can't ask to add an action that doesn't exist - flow.actions.push(ActionConfiguration::from( - self.action_map.get_action_by_id(&step_id).unwrap(), - )); - self.needs_saving = true; - // Trigger UI steps refresh - sender.input(FlowInputs::UpdateStepsFromModel); - } - - FlowInputs::UpdateStepsFromModel => { - let mut live_list = self.live_actions_list.guard(); - live_list.clear(); - if let Some(flow) = &self.open_flow { - let mut possible_outputs = vec![]; - for (step, config) in flow.actions.iter().enumerate() { - live_list.push_back(action_component::ActionComponentInitialiser { - possible_outputs: possible_outputs.clone(), - config: config.clone(), - action: self.action_map.get_action_by_id(&config.action_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_idx, (name, kind)) in self - .action_map - .get_action_by_id(&config.action_id) - .unwrap() - .outputs() - .iter() - .enumerate() - { - 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, - ActionParameterSource::FromOutput(step, output_idx), - )); - } - } - } - } - - FlowInputs::RemoveStep(step_idx) => { - let idx = step_idx.current_index(); - let flow = self.open_flow.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 >= flow.actions.len() { - log::warn!("Skipped running RemoveStep as the index was invalid."); - return; - } - - log::info!("Deleting step {}", idx + 1); - - flow.actions.remove(idx); - - // Remove references to step and renumber references above step to one less than they were - for step in flow.actions.iter_mut() { - for (_step_idx, source) in step.parameter_sources.iter_mut() { - if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { - match (*from_step).cmp(&idx) { - std::cmp::Ordering::Equal => { - *source = ActionParameterSource::Literal - } - std::cmp::Ordering::Greater => *from_step -= 1, - _ => (), - } - } - } - } - - self.needs_saving = true; - - // Trigger UI steps refresh - sender.input(FlowInputs::UpdateStepsFromModel); - } - FlowInputs::CutStep(step_idx) => { - let idx = step_idx.current_index(); - let flow = self.open_flow.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 >= flow.actions.len() { - log::warn!("Skipped running CutStep as the index was invalid."); - return; - } - - flow.actions.remove(idx); - - // Remove references to step and renumber references above step to one less than they were - for step in flow.actions.iter_mut() { - for (_param_idx, source) in step.parameter_sources.iter_mut() { - if let ActionParameterSource::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, - _ => (), - } - } - } - } - - log::debug!("After cut, flow is: {flow:?}"); - - self.needs_saving = true; - } - FlowInputs::PasteStep(idx, mut config) => { - let flow = self.open_flow.as_mut().unwrap(); - let idx = idx.max(0).min(flow.actions.len()); - - // Adjust step just about to paste - for (_param_idx, source) in config.parameter_sources.iter_mut() { - if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { - if *from_step <= idx { - *source = ActionParameterSource::Literal; - } - } - } - - log::info!("Pasting step to {}", idx + 1); - flow.actions.insert(idx, config); - - // Remove references to step and renumber references above step to one less than they were - for (step_idx, step) in flow.actions.iter_mut().enumerate() { - for (_param_idx, source) in step.parameter_sources.iter_mut() { - if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { - if *from_step == usize::MAX { - if step_idx < idx { - // can't refer to it anymore - *source = ActionParameterSource::Literal; - } else { - *from_step = idx; - } - } else if *from_step >= idx { - *from_step += 1; - } - } - } - } - - log::debug!("After paste, flow is: {flow:?}"); - - self.needs_saving = true; - - // Trigger UI steps refresh - sender.input(FlowInputs::UpdateStepsFromModel); - } - FlowInputs::MoveStep(from, to, offset) => { - let current_from = from.current_index(); - let step = self.open_flow.as_ref().unwrap().actions[current_from].clone(); - sender.input(FlowInputs::CutStep(from)); - - // Establish new position - let mut to = (to.current_index() as isize + offset).max(0) as usize; - if to > current_from && to > 0 { - to -= 1; - } - - sender.input(FlowInputs::PasteStep(to, step)); - } - } - self.update_view(widgets, sender); - } -} +use std::{collections::HashMap, fmt, 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::{ActionConfiguration, ActionParameterSource, AutomationFlow, VersionedFile}, +}; + +use crate::ui::flows::action_component::ActionComponentOutput; + +use super::{file_filters, lang}; + +mod action_component; +mod execution_dialog; +pub mod header; + +pub enum SaveOrOpenFlowError { + IoError(std::io::Error), + ParsingError(ron::error::SpannedError), + SerializingError(ron::Error), + FlowNotVersionCompatible, + MissingAction(usize, String), +} + +impl fmt::Display for SaveOrOpenFlowError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::IoError(e) => lang::lookup_with_args("flow-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("flow-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("flow-save-open-error-serializing-error", { + let mut map = HashMap::new(); + map.insert("error", e.to_string().into()); + map + }) + } + Self::FlowNotVersionCompatible => { + lang::lookup("flow-save-open-error-flow-not-version-compatible") + } + Self::MissingAction(step, e) => { + lang::lookup_with_args("flow-save-open-error-missing-action", { + let mut map = HashMap::new(); + map.insert("step", (step + 1).into()); + map.insert("error", e.to_string().into()); + map + }) + } + } + ) + } +} + +#[derive(Clone, Debug)] +pub enum FlowInputs { + /// Do nothing + NoOp, + /// The map of actions has changed and should be updated + ActionsMapChanged(Arc), + /// Create a new flow + NewFlow, + /// Actually create the new flow + _NewFlow, + /// Prompt the user to open a flow. This will ask to save first if needed. + OpenFlow, + /// Actually show the user the open file dialog + _OpenFlow, + /// Actually open a flow after the user has finished selecting + __OpenFlow(PathBuf), + /// Save the flow, prompting if needed to set file path + SaveFlow, + /// Save the flow as a new file, always prompting for a file path + SaveAsFlow, + /// Ask where to save if needed, then save + _SaveFlowThen(Box), + /// Actually write the flow to disk, then emit then input + __SaveFlowThen(PathBuf, Box), + /// Close the flow, prompting if needing to save first + CloseFlow, + /// Actually close the flow + _CloseFlow, + /// Add the step with the ID provided + AddStep(String), + /// Update the UI steps from the open flow. 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 [`FlowInputs::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, ActionConfiguration), + /// Move a step from the index to a position offset (param 3) from a new index (param 2). + MoveStep(DynamicIndex, DynamicIndex, isize), + /// Start the flow exection + RunFlow, + /// The [`ActionConfiguration`] has changed for the step indicated by the [`DynamicIndex`]. + /// This does not refresh the UI. + ConfigUpdate(DynamicIndex, ActionConfiguration), +} + +#[derive(Debug)] +pub enum FlowOutputs {} + +#[derive(Debug)] +pub struct FlowsModel { + action_map: Arc, + engine_list: Arc, + + open_flow: Option, + open_path: Option, + needs_saving: bool, + header: Rc>, + live_actions_list: FactoryVecDeque, + + execution_dialog: Option>, +} + +impl FlowsModel { + /// 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( + "FlowsModel::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 flow + fn new_flow(&mut self) { + self.open_path = None; + self.needs_saving = true; + self.open_flow = Some(AutomationFlow::default()); + self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( + self.open_flow.is_some(), + )); + } + + /// Open a flow. This does not ask to save first. + fn open_flow(&mut self, file: PathBuf) -> Result, SaveOrOpenFlowError> { + let data = &fs::read_to_string(&file).map_err(SaveOrOpenFlowError::IoError)?; + + let versioned_file: VersionedFile = + ron::from_str(data).map_err(SaveOrOpenFlowError::ParsingError)?; + if versioned_file.version() != 1 { + return Err(SaveOrOpenFlowError::FlowNotVersionCompatible); + } + + let mut flow: AutomationFlow = + ron::from_str(data).map_err(SaveOrOpenFlowError::ParsingError)?; + if flow.version() != 1 { + return Err(SaveOrOpenFlowError::FlowNotVersionCompatible); + } + let mut steps_reset = vec![]; + for (step, ac) in flow.actions.iter_mut().enumerate() { + match self.action_map.get_action_by_id(&ac.action_id) { + None => { + return Err(SaveOrOpenFlowError::MissingAction( + step, + ac.action_id.clone(), + )) + } + Some(action) => { + // Check that action parameters haven't changed. If they have, reset values. + if ac.update(action) { + steps_reset.push(step + 1); + } + } + } + } + self.open_flow = Some(flow); + self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( + self.open_flow.is_some(), + )); + self.open_path = Some(file); + self.needs_saving = false; + log::debug!("New flow open."); + log::debug!("Flow: {:?}", self.open_flow); + Ok(steps_reset) + } + + /// 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: FlowInputs) { + if self.needs_saving { + let question = self.create_message_dialog_skeleton( + lang::lookup("flow-save-before"), + lang::lookup("flow-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(FlowInputs::_SaveFlowThen(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: FlowInputs, + ) { + if always_ask_where || self.open_path.is_none() { + // Ask where + let dialog = gtk::FileDialog::builder() + .modal(true) + .title(lang::lookup("header-save")) + .initial_folder(>k::gio::File::for_path( + std::env::var("TA_FLOW_DIR").unwrap_or(".".to_string()), + )) + .filters(&file_filters::filter_list(vec![ + file_filters::flows(), + 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 mut path = file.path().unwrap(); + path.set_extension("taflow"); + sender_c.emit(FlowInputs::__SaveFlowThen(path, Box::new(then.clone()))); + } + }, + ); + } else { + sender.emit(FlowInputs::__SaveFlowThen( + self.open_path.clone().unwrap(), + Box::new(then), + )); + } + } + + /// Just save the flow to disk with the current `open_path` as the destination + fn save_flow(&mut self) -> Result<(), SaveOrOpenFlowError> { + let save_path = self.open_path.as_ref().unwrap(); + let data = ron::to_string(self.open_flow.as_ref().unwrap()) + .map_err(SaveOrOpenFlowError::SerializingError)?; + fs::write(save_path, data).map_err(SaveOrOpenFlowError::IoError)?; + self.needs_saving = false; + Ok(()) + } + + /// Close this flow without checking first + fn close_flow(&mut self) { + self.open_flow = None; + self.open_path = None; + self.needs_saving = false; + self.live_actions_list.guard().clear(); + self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( + self.open_flow.is_some(), + )); + } +} + +#[relm4::component(pub)] +impl Component for FlowsModel { + type Init = (Arc, Arc); + type Input = FlowInputs; + type Output = FlowOutputs; + type CommandOutput = (); + + view! { + #[root] + toast_target = adw::ToastOverlay { + gtk::ScrolledWindow { + set_vexpand: true, + set_hscrollbar_policy: gtk::PolicyType::Never, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_margin_all: 5, + + adw::StatusPage { + set_title: &lang::lookup("nothing-open"), + set_description: Some(&lang::lookup("flow-nothing-open-description")), + set_icon_name: Some(relm4_icons::icon_names::LIGHTBULB), + #[watch] + set_visible: model.open_flow.is_none(), + set_vexpand: true, + }, + + #[local_ref] + live_actions_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::FlowsHeader::builder() + .launch(init.0.clone()) + .forward(sender.input_sender(), |msg| match msg { + header::FlowsHeaderOutput::NewFlow => FlowInputs::NewFlow, + header::FlowsHeaderOutput::OpenFlow => FlowInputs::OpenFlow, + header::FlowsHeaderOutput::SaveFlow => FlowInputs::SaveFlow, + header::FlowsHeaderOutput::SaveAsFlow => FlowInputs::SaveAsFlow, + header::FlowsHeaderOutput::CloseFlow => FlowInputs::CloseFlow, + header::FlowsHeaderOutput::RunFlow => FlowInputs::RunFlow, + header::FlowsHeaderOutput::AddStep(step) => FlowInputs::AddStep(step), + }), + ); + + let model = FlowsModel { + action_map: init.0, + engine_list: init.1, + open_flow: None, + open_path: None, + needs_saving: false, + execution_dialog: None, + header, + live_actions_list: FactoryVecDeque::builder() + .launch(gtk::Box::default()) + .forward(sender.input_sender(), |output| match output { + ActionComponentOutput::Remove(idx) => FlowInputs::RemoveStep(idx), + ActionComponentOutput::Cut(idx) => FlowInputs::CutStep(idx), + ActionComponentOutput::Paste(idx, step) => FlowInputs::PasteStep(idx, step), + ActionComponentOutput::ConfigUpdate(step, config) => { + FlowInputs::ConfigUpdate(step, config) + } + ActionComponentOutput::MoveStep(from, to, offset) => { + FlowInputs::MoveStep(from, to, offset) + } + }), + }; + + // Trigger update actions from model + sender.input(FlowInputs::UpdateStepsFromModel); + + let live_actions_list = model.live_actions_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 { + FlowInputs::NoOp => (), + FlowInputs::ActionsMapChanged(new_map) => { + self.action_map = new_map.clone(); + self.header + .emit(header::FlowsHeaderInput::ActionsMapChanged(new_map)); + + // This may have changed action parameters, so check again. + let mut close_flow = false; + let mut steps_reset = vec![]; + if let Some(flow) = &mut self.open_flow { + let actions_clone = flow.actions.clone(); + for (step, ac) in flow.actions.iter_mut().enumerate() { + match self.action_map.get_action_by_id(&ac.action_id) { + None => { + close_flow = true; + } + Some(action) => { + // Check that action parameters haven't changed. If they have, reset values. + if ac.update(action.clone()) { + steps_reset.push(step); + } + + // Check that the references from this AC to another don't now violate types + for (p_id, src) in &mut ac.parameter_sources { + if let ActionParameterSource::FromOutput(other_step, output) = src { + let (_name, kind) = &action.parameters()[*p_id]; + // Check that parameter from step->output is of type kind + if let Some(other_ac) = actions_clone.get(*other_step) { + if let Some(other_action) = &self + .action_map + .get_action_by_id(&other_ac.action_id) + { + if let Some((_name, other_output_kind)) = + other_action.outputs().get(*output) + { + if kind != other_output_kind { + // Reset to literal + steps_reset.push(step); + *src = ActionParameterSource::Literal; + } + } + } + } + // If any of these if's fail, then the main loop will catch and fail later. + } + } + } + } + } + sender.input(FlowInputs::UpdateStepsFromModel); + } + if !steps_reset.is_empty() { + let toast = + adw::Toast::new(&lang::lookup_with_args("flow-action-changed-message", { + let mut map = HashMap::new(); + map.insert("stepCount", steps_reset.len().into()); + map.insert( + "steps", + steps_reset + .iter() + .map(|i| (i + 1).to_string()) + .collect::>() + .join(", ") + .into(), + ); + map + })); + toast.set_timeout(0); // indefinte so it can be seen when switching back + widgets.toast_target.add_toast(toast); + } + if close_flow { + self.close_flow(); + } + } + FlowInputs::ConfigUpdate(step, new_config) => { + // unwrap rationale: config updates can't happen if nothing is open + let flow = self.open_flow.as_mut().unwrap(); + flow.actions[step.current_index()] = new_config; + self.needs_saving = true; + } + FlowInputs::NewFlow => { + self.prompt_to_save(sender.input_sender(), FlowInputs::_NewFlow); + } + FlowInputs::_NewFlow => { + self.new_flow(); + sender.input(FlowInputs::UpdateStepsFromModel); + } + FlowInputs::OpenFlow => { + self.prompt_to_save(sender.input_sender(), FlowInputs::_OpenFlow); + } + FlowInputs::_OpenFlow => { + let dialog = gtk::FileDialog::builder() + .modal(true) + .title(lang::lookup("header-open")) + .filters(&file_filters::filter_list(vec![ + file_filters::flows(), + file_filters::all(), + ])) + .initial_folder(>k::gio::File::for_path( + std::env::var("TA_FLOW_DIR").unwrap_or(".".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(FlowInputs::__OpenFlow(path)); + } + }, + ); + } + FlowInputs::__OpenFlow(path) => { + match self.open_flow(path) { + Ok(changes) => { + // Reload UI + sender.input(FlowInputs::UpdateStepsFromModel); + + if !changes.is_empty() { + let changed_steps = changes + .iter() + .map(|step| step.to_string()) + .collect::>() + .join(","); + self.create_message_dialog( + lang::lookup("flow-action-changed"), + lang::lookup_with_args("flow-action-changed-message", { + let mut map = HashMap::new(); + map.insert("stepCount", changes.len().into()); + map.insert("steps", changed_steps.into()); + map + }), + ) + .set_visible(true); + } + } + Err(e) => { + // Show error dialog + self.create_message_dialog( + lang::lookup("flow-error-opening"), + e.to_string(), + ) + .set_visible(true); + } + } + } + FlowInputs::SaveFlow => { + if self.open_flow.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, + FlowInputs::NoOp, + ); + } + } + FlowInputs::SaveAsFlow => { + if self.open_flow.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, + FlowInputs::NoOp, + ); + } + } + FlowInputs::_SaveFlowThen(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, + ); + } + FlowInputs::__SaveFlowThen(path, then) => { + self.open_path = Some(path); + if let Err(e) = self.save_flow() { + self.create_message_dialog(lang::lookup("flow-error-saving"), e.to_string()) + .set_visible(true); + } else { + widgets + .toast_target + .add_toast(adw::Toast::new(&lang::lookup("flow-saved"))); + sender.input_sender().emit(*then); + } + } + FlowInputs::CloseFlow => { + self.prompt_to_save(sender.input_sender(), FlowInputs::_CloseFlow); + } + FlowInputs::_CloseFlow => { + self.close_flow(); + } + + FlowInputs::RunFlow => { + if let Some(flow) = &self.open_flow { + let e_dialog = execution_dialog::ExecutionDialog::builder() + .transient_for(root) + .launch(execution_dialog::ExecutionDialogInit { + flow: flow.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); + } + } + + FlowInputs::AddStep(step_id) => { + if self.open_flow.is_none() { + self.new_flow(); + } + + // unwrap rationale: we've just guaranteed a flow is open + let flow = self.open_flow.as_mut().unwrap(); + // unwrap rationale: the header can't ask to add an action that doesn't exist + flow.actions.push(ActionConfiguration::from( + self.action_map.get_action_by_id(&step_id).unwrap(), + )); + self.needs_saving = true; + // Trigger UI steps refresh + sender.input(FlowInputs::UpdateStepsFromModel); + } + + FlowInputs::UpdateStepsFromModel => { + let mut live_list = self.live_actions_list.guard(); + live_list.clear(); + if let Some(flow) = &self.open_flow { + let mut possible_outputs = vec![]; + for (step, config) in flow.actions.iter().enumerate() { + live_list.push_back(action_component::ActionComponentInitialiser { + possible_outputs: possible_outputs.clone(), + config: config.clone(), + action: self.action_map.get_action_by_id(&config.action_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_idx, (name, kind)) in self + .action_map + .get_action_by_id(&config.action_id) + .unwrap() + .outputs() + .iter() + .enumerate() + { + 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, + ActionParameterSource::FromOutput(step, output_idx), + )); + } + } + } + } + + FlowInputs::RemoveStep(step_idx) => { + let idx = step_idx.current_index(); + let flow = self.open_flow.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 >= flow.actions.len() { + log::warn!("Skipped running RemoveStep as the index was invalid."); + return; + } + + log::info!("Deleting step {}", idx + 1); + + flow.actions.remove(idx); + + // Remove references to step and renumber references above step to one less than they were + for step in flow.actions.iter_mut() { + for (_step_idx, source) in step.parameter_sources.iter_mut() { + if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { + match (*from_step).cmp(&idx) { + std::cmp::Ordering::Equal => { + *source = ActionParameterSource::Literal + } + std::cmp::Ordering::Greater => *from_step -= 1, + _ => (), + } + } + } + } + + self.needs_saving = true; + + // Trigger UI steps refresh + sender.input(FlowInputs::UpdateStepsFromModel); + } + FlowInputs::CutStep(step_idx) => { + let idx = step_idx.current_index(); + let flow = self.open_flow.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 >= flow.actions.len() { + log::warn!("Skipped running CutStep as the index was invalid."); + return; + } + + flow.actions.remove(idx); + + // Remove references to step and renumber references above step to one less than they were + for step in flow.actions.iter_mut() { + for (_param_idx, source) in step.parameter_sources.iter_mut() { + if let ActionParameterSource::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, + _ => (), + } + } + } + } + + log::debug!("After cut, flow is: {flow:?}"); + + self.needs_saving = true; + } + FlowInputs::PasteStep(idx, mut config) => { + let flow = self.open_flow.as_mut().unwrap(); + let idx = idx.max(0).min(flow.actions.len()); + + // Adjust step just about to paste + for (_param_idx, source) in config.parameter_sources.iter_mut() { + if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { + if *from_step <= idx { + *source = ActionParameterSource::Literal; + } + } + } + + log::info!("Pasting step to {}", idx + 1); + flow.actions.insert(idx, config); + + // Remove references to step and renumber references above step to one less than they were + for (step_idx, step) in flow.actions.iter_mut().enumerate() { + for (_param_idx, source) in step.parameter_sources.iter_mut() { + if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { + if *from_step == usize::MAX { + if step_idx < idx { + // can't refer to it anymore + *source = ActionParameterSource::Literal; + } else { + *from_step = idx; + } + } else if *from_step >= idx { + *from_step += 1; + } + } + } + } + + log::debug!("After paste, flow is: {flow:?}"); + + self.needs_saving = true; + + // Trigger UI steps refresh + sender.input(FlowInputs::UpdateStepsFromModel); + } + FlowInputs::MoveStep(from, to, offset) => { + let current_from = from.current_index(); + let step = self.open_flow.as_ref().unwrap().actions[current_from].clone(); + sender.input(FlowInputs::CutStep(from)); + + // Establish new position + let mut to = (to.current_index() as isize + offset).max(0) as usize; + if to > current_from && to > 0 { + to -= 1; + } + + sender.input(FlowInputs::PasteStep(to, step)); + } + } + self.update_view(widgets, sender); + } +} From d865d9aac18ce111975b25c39b748ad320157b7f Mon Sep 17 00:00:00 2001 From: Lily Hopkins Date: Mon, 9 Dec 2024 23:46:26 +0000 Subject: [PATCH 10/10] reformatted --- testangel/src/ui/flows/mod.rs | 1608 +++++++++++++++++---------------- 1 file changed, 805 insertions(+), 803 deletions(-) diff --git a/testangel/src/ui/flows/mod.rs b/testangel/src/ui/flows/mod.rs index eb43088..fa075cc 100644 --- a/testangel/src/ui/flows/mod.rs +++ b/testangel/src/ui/flows/mod.rs @@ -1,803 +1,805 @@ -use std::{collections::HashMap, fmt, 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::{ActionConfiguration, ActionParameterSource, AutomationFlow, VersionedFile}, -}; - -use crate::ui::flows::action_component::ActionComponentOutput; - -use super::{file_filters, lang}; - -mod action_component; -mod execution_dialog; -pub mod header; - -pub enum SaveOrOpenFlowError { - IoError(std::io::Error), - ParsingError(ron::error::SpannedError), - SerializingError(ron::Error), - FlowNotVersionCompatible, - MissingAction(usize, String), -} - -impl fmt::Display for SaveOrOpenFlowError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{}", - match self { - Self::IoError(e) => lang::lookup_with_args("flow-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("flow-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("flow-save-open-error-serializing-error", { - let mut map = HashMap::new(); - map.insert("error", e.to_string().into()); - map - }) - } - Self::FlowNotVersionCompatible => { - lang::lookup("flow-save-open-error-flow-not-version-compatible") - } - Self::MissingAction(step, e) => { - lang::lookup_with_args("flow-save-open-error-missing-action", { - let mut map = HashMap::new(); - map.insert("step", (step + 1).into()); - map.insert("error", e.to_string().into()); - map - }) - } - } - ) - } -} - -#[derive(Clone, Debug)] -pub enum FlowInputs { - /// Do nothing - NoOp, - /// The map of actions has changed and should be updated - ActionsMapChanged(Arc), - /// Create a new flow - NewFlow, - /// Actually create the new flow - _NewFlow, - /// Prompt the user to open a flow. This will ask to save first if needed. - OpenFlow, - /// Actually show the user the open file dialog - _OpenFlow, - /// Actually open a flow after the user has finished selecting - __OpenFlow(PathBuf), - /// Save the flow, prompting if needed to set file path - SaveFlow, - /// Save the flow as a new file, always prompting for a file path - SaveAsFlow, - /// Ask where to save if needed, then save - _SaveFlowThen(Box), - /// Actually write the flow to disk, then emit then input - __SaveFlowThen(PathBuf, Box), - /// Close the flow, prompting if needing to save first - CloseFlow, - /// Actually close the flow - _CloseFlow, - /// Add the step with the ID provided - AddStep(String), - /// Update the UI steps from the open flow. 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 [`FlowInputs::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, ActionConfiguration), - /// Move a step from the index to a position offset (param 3) from a new index (param 2). - MoveStep(DynamicIndex, DynamicIndex, isize), - /// Start the flow exection - RunFlow, - /// The [`ActionConfiguration`] has changed for the step indicated by the [`DynamicIndex`]. - /// This does not refresh the UI. - ConfigUpdate(DynamicIndex, ActionConfiguration), -} - -#[derive(Debug)] -pub enum FlowOutputs {} - -#[derive(Debug)] -pub struct FlowsModel { - action_map: Arc, - engine_list: Arc, - - open_flow: Option, - open_path: Option, - needs_saving: bool, - header: Rc>, - live_actions_list: FactoryVecDeque, - - execution_dialog: Option>, -} - -impl FlowsModel { - /// 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( - "FlowsModel::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 flow - fn new_flow(&mut self) { - self.open_path = None; - self.needs_saving = true; - self.open_flow = Some(AutomationFlow::default()); - self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( - self.open_flow.is_some(), - )); - } - - /// Open a flow. This does not ask to save first. - fn open_flow(&mut self, file: PathBuf) -> Result, SaveOrOpenFlowError> { - let data = &fs::read_to_string(&file).map_err(SaveOrOpenFlowError::IoError)?; - - let versioned_file: VersionedFile = - ron::from_str(data).map_err(SaveOrOpenFlowError::ParsingError)?; - if versioned_file.version() != 1 { - return Err(SaveOrOpenFlowError::FlowNotVersionCompatible); - } - - let mut flow: AutomationFlow = - ron::from_str(data).map_err(SaveOrOpenFlowError::ParsingError)?; - if flow.version() != 1 { - return Err(SaveOrOpenFlowError::FlowNotVersionCompatible); - } - let mut steps_reset = vec![]; - for (step, ac) in flow.actions.iter_mut().enumerate() { - match self.action_map.get_action_by_id(&ac.action_id) { - None => { - return Err(SaveOrOpenFlowError::MissingAction( - step, - ac.action_id.clone(), - )) - } - Some(action) => { - // Check that action parameters haven't changed. If they have, reset values. - if ac.update(action) { - steps_reset.push(step + 1); - } - } - } - } - self.open_flow = Some(flow); - self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( - self.open_flow.is_some(), - )); - self.open_path = Some(file); - self.needs_saving = false; - log::debug!("New flow open."); - log::debug!("Flow: {:?}", self.open_flow); - Ok(steps_reset) - } - - /// 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: FlowInputs) { - if self.needs_saving { - let question = self.create_message_dialog_skeleton( - lang::lookup("flow-save-before"), - lang::lookup("flow-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(FlowInputs::_SaveFlowThen(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: FlowInputs, - ) { - if always_ask_where || self.open_path.is_none() { - // Ask where - let dialog = gtk::FileDialog::builder() - .modal(true) - .title(lang::lookup("header-save")) - .initial_folder(>k::gio::File::for_path( - std::env::var("TA_FLOW_DIR").unwrap_or(".".to_string()), - )) - .filters(&file_filters::filter_list(vec![ - file_filters::flows(), - 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 mut path = file.path().unwrap(); - path.set_extension("taflow"); - sender_c.emit(FlowInputs::__SaveFlowThen(path, Box::new(then.clone()))); - } - }, - ); - } else { - sender.emit(FlowInputs::__SaveFlowThen( - self.open_path.clone().unwrap(), - Box::new(then), - )); - } - } - - /// Just save the flow to disk with the current `open_path` as the destination - fn save_flow(&mut self) -> Result<(), SaveOrOpenFlowError> { - let save_path = self.open_path.as_ref().unwrap(); - let data = ron::to_string(self.open_flow.as_ref().unwrap()) - .map_err(SaveOrOpenFlowError::SerializingError)?; - fs::write(save_path, data).map_err(SaveOrOpenFlowError::IoError)?; - self.needs_saving = false; - Ok(()) - } - - /// Close this flow without checking first - fn close_flow(&mut self) { - self.open_flow = None; - self.open_path = None; - self.needs_saving = false; - self.live_actions_list.guard().clear(); - self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( - self.open_flow.is_some(), - )); - } -} - -#[relm4::component(pub)] -impl Component for FlowsModel { - type Init = (Arc, Arc); - type Input = FlowInputs; - type Output = FlowOutputs; - type CommandOutput = (); - - view! { - #[root] - toast_target = adw::ToastOverlay { - gtk::ScrolledWindow { - set_vexpand: true, - set_hscrollbar_policy: gtk::PolicyType::Never, - - gtk::Box { - set_orientation: gtk::Orientation::Vertical, - set_margin_all: 5, - - adw::StatusPage { - set_title: &lang::lookup("nothing-open"), - set_description: Some(&lang::lookup("flow-nothing-open-description")), - set_icon_name: Some(relm4_icons::icon_names::LIGHTBULB), - #[watch] - set_visible: model.open_flow.is_none(), - set_vexpand: true, - }, - - #[local_ref] - live_actions_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::FlowsHeader::builder() - .launch(init.0.clone()) - .forward(sender.input_sender(), |msg| match msg { - header::FlowsHeaderOutput::NewFlow => FlowInputs::NewFlow, - header::FlowsHeaderOutput::OpenFlow => FlowInputs::OpenFlow, - header::FlowsHeaderOutput::SaveFlow => FlowInputs::SaveFlow, - header::FlowsHeaderOutput::SaveAsFlow => FlowInputs::SaveAsFlow, - header::FlowsHeaderOutput::CloseFlow => FlowInputs::CloseFlow, - header::FlowsHeaderOutput::RunFlow => FlowInputs::RunFlow, - header::FlowsHeaderOutput::AddStep(step) => FlowInputs::AddStep(step), - }), - ); - - let model = FlowsModel { - action_map: init.0, - engine_list: init.1, - open_flow: None, - open_path: None, - needs_saving: false, - execution_dialog: None, - header, - live_actions_list: FactoryVecDeque::builder() - .launch(gtk::Box::default()) - .forward(sender.input_sender(), |output| match output { - ActionComponentOutput::Remove(idx) => FlowInputs::RemoveStep(idx), - ActionComponentOutput::Cut(idx) => FlowInputs::CutStep(idx), - ActionComponentOutput::Paste(idx, step) => FlowInputs::PasteStep(idx, step), - ActionComponentOutput::ConfigUpdate(step, config) => { - FlowInputs::ConfigUpdate(step, config) - } - ActionComponentOutput::MoveStep(from, to, offset) => { - FlowInputs::MoveStep(from, to, offset) - } - }), - }; - - // Trigger update actions from model - sender.input(FlowInputs::UpdateStepsFromModel); - - let live_actions_list = model.live_actions_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 { - FlowInputs::NoOp => (), - FlowInputs::ActionsMapChanged(new_map) => { - self.action_map = new_map.clone(); - self.header - .emit(header::FlowsHeaderInput::ActionsMapChanged(new_map)); - - // This may have changed action parameters, so check again. - let mut close_flow = false; - let mut steps_reset = vec![]; - if let Some(flow) = &mut self.open_flow { - let actions_clone = flow.actions.clone(); - for (step, ac) in flow.actions.iter_mut().enumerate() { - match self.action_map.get_action_by_id(&ac.action_id) { - None => { - close_flow = true; - } - Some(action) => { - // Check that action parameters haven't changed. If they have, reset values. - if ac.update(action.clone()) { - steps_reset.push(step); - } - - // Check that the references from this AC to another don't now violate types - for (p_id, src) in &mut ac.parameter_sources { - if let ActionParameterSource::FromOutput(other_step, output) = src { - let (_name, kind) = &action.parameters()[*p_id]; - // Check that parameter from step->output is of type kind - if let Some(other_ac) = actions_clone.get(*other_step) { - if let Some(other_action) = &self - .action_map - .get_action_by_id(&other_ac.action_id) - { - if let Some((_name, other_output_kind)) = - other_action.outputs().get(*output) - { - if kind != other_output_kind { - // Reset to literal - steps_reset.push(step); - *src = ActionParameterSource::Literal; - } - } - } - } - // If any of these if's fail, then the main loop will catch and fail later. - } - } - } - } - } - sender.input(FlowInputs::UpdateStepsFromModel); - } - if !steps_reset.is_empty() { - let toast = - adw::Toast::new(&lang::lookup_with_args("flow-action-changed-message", { - let mut map = HashMap::new(); - map.insert("stepCount", steps_reset.len().into()); - map.insert( - "steps", - steps_reset - .iter() - .map(|i| (i + 1).to_string()) - .collect::>() - .join(", ") - .into(), - ); - map - })); - toast.set_timeout(0); // indefinte so it can be seen when switching back - widgets.toast_target.add_toast(toast); - } - if close_flow { - self.close_flow(); - } - } - FlowInputs::ConfigUpdate(step, new_config) => { - // unwrap rationale: config updates can't happen if nothing is open - let flow = self.open_flow.as_mut().unwrap(); - flow.actions[step.current_index()] = new_config; - self.needs_saving = true; - } - FlowInputs::NewFlow => { - self.prompt_to_save(sender.input_sender(), FlowInputs::_NewFlow); - } - FlowInputs::_NewFlow => { - self.new_flow(); - sender.input(FlowInputs::UpdateStepsFromModel); - } - FlowInputs::OpenFlow => { - self.prompt_to_save(sender.input_sender(), FlowInputs::_OpenFlow); - } - FlowInputs::_OpenFlow => { - let dialog = gtk::FileDialog::builder() - .modal(true) - .title(lang::lookup("header-open")) - .filters(&file_filters::filter_list(vec![ - file_filters::flows(), - file_filters::all(), - ])) - .initial_folder(>k::gio::File::for_path( - std::env::var("TA_FLOW_DIR").unwrap_or(".".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(FlowInputs::__OpenFlow(path)); - } - }, - ); - } - FlowInputs::__OpenFlow(path) => { - match self.open_flow(path) { - Ok(changes) => { - // Reload UI - sender.input(FlowInputs::UpdateStepsFromModel); - - if !changes.is_empty() { - let changed_steps = changes - .iter() - .map(|step| step.to_string()) - .collect::>() - .join(","); - self.create_message_dialog( - lang::lookup("flow-action-changed"), - lang::lookup_with_args("flow-action-changed-message", { - let mut map = HashMap::new(); - map.insert("stepCount", changes.len().into()); - map.insert("steps", changed_steps.into()); - map - }), - ) - .set_visible(true); - } - } - Err(e) => { - // Show error dialog - self.create_message_dialog( - lang::lookup("flow-error-opening"), - e.to_string(), - ) - .set_visible(true); - } - } - } - FlowInputs::SaveFlow => { - if self.open_flow.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, - FlowInputs::NoOp, - ); - } - } - FlowInputs::SaveAsFlow => { - if self.open_flow.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, - FlowInputs::NoOp, - ); - } - } - FlowInputs::_SaveFlowThen(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, - ); - } - FlowInputs::__SaveFlowThen(path, then) => { - self.open_path = Some(path); - if let Err(e) = self.save_flow() { - self.create_message_dialog(lang::lookup("flow-error-saving"), e.to_string()) - .set_visible(true); - } else { - widgets - .toast_target - .add_toast(adw::Toast::new(&lang::lookup("flow-saved"))); - sender.input_sender().emit(*then); - } - } - FlowInputs::CloseFlow => { - self.prompt_to_save(sender.input_sender(), FlowInputs::_CloseFlow); - } - FlowInputs::_CloseFlow => { - self.close_flow(); - } - - FlowInputs::RunFlow => { - if let Some(flow) = &self.open_flow { - let e_dialog = execution_dialog::ExecutionDialog::builder() - .transient_for(root) - .launch(execution_dialog::ExecutionDialogInit { - flow: flow.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); - } - } - - FlowInputs::AddStep(step_id) => { - if self.open_flow.is_none() { - self.new_flow(); - } - - // unwrap rationale: we've just guaranteed a flow is open - let flow = self.open_flow.as_mut().unwrap(); - // unwrap rationale: the header can't ask to add an action that doesn't exist - flow.actions.push(ActionConfiguration::from( - self.action_map.get_action_by_id(&step_id).unwrap(), - )); - self.needs_saving = true; - // Trigger UI steps refresh - sender.input(FlowInputs::UpdateStepsFromModel); - } - - FlowInputs::UpdateStepsFromModel => { - let mut live_list = self.live_actions_list.guard(); - live_list.clear(); - if let Some(flow) = &self.open_flow { - let mut possible_outputs = vec![]; - for (step, config) in flow.actions.iter().enumerate() { - live_list.push_back(action_component::ActionComponentInitialiser { - possible_outputs: possible_outputs.clone(), - config: config.clone(), - action: self.action_map.get_action_by_id(&config.action_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_idx, (name, kind)) in self - .action_map - .get_action_by_id(&config.action_id) - .unwrap() - .outputs() - .iter() - .enumerate() - { - 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, - ActionParameterSource::FromOutput(step, output_idx), - )); - } - } - } - } - - FlowInputs::RemoveStep(step_idx) => { - let idx = step_idx.current_index(); - let flow = self.open_flow.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 >= flow.actions.len() { - log::warn!("Skipped running RemoveStep as the index was invalid."); - return; - } - - log::info!("Deleting step {}", idx + 1); - - flow.actions.remove(idx); - - // Remove references to step and renumber references above step to one less than they were - for step in flow.actions.iter_mut() { - for (_step_idx, source) in step.parameter_sources.iter_mut() { - if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { - match (*from_step).cmp(&idx) { - std::cmp::Ordering::Equal => { - *source = ActionParameterSource::Literal - } - std::cmp::Ordering::Greater => *from_step -= 1, - _ => (), - } - } - } - } - - self.needs_saving = true; - - // Trigger UI steps refresh - sender.input(FlowInputs::UpdateStepsFromModel); - } - FlowInputs::CutStep(step_idx) => { - let idx = step_idx.current_index(); - let flow = self.open_flow.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 >= flow.actions.len() { - log::warn!("Skipped running CutStep as the index was invalid."); - return; - } - - flow.actions.remove(idx); - - // Remove references to step and renumber references above step to one less than they were - for step in flow.actions.iter_mut() { - for (_param_idx, source) in step.parameter_sources.iter_mut() { - if let ActionParameterSource::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, - _ => (), - } - } - } - } - - log::debug!("After cut, flow is: {flow:?}"); - - self.needs_saving = true; - } - FlowInputs::PasteStep(idx, mut config) => { - let flow = self.open_flow.as_mut().unwrap(); - let idx = idx.max(0).min(flow.actions.len()); - - // Adjust step just about to paste - for (_param_idx, source) in config.parameter_sources.iter_mut() { - if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { - if *from_step <= idx { - *source = ActionParameterSource::Literal; - } - } - } - - log::info!("Pasting step to {}", idx + 1); - flow.actions.insert(idx, config); - - // Remove references to step and renumber references above step to one less than they were - for (step_idx, step) in flow.actions.iter_mut().enumerate() { - for (_param_idx, source) in step.parameter_sources.iter_mut() { - if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { - if *from_step == usize::MAX { - if step_idx < idx { - // can't refer to it anymore - *source = ActionParameterSource::Literal; - } else { - *from_step = idx; - } - } else if *from_step >= idx { - *from_step += 1; - } - } - } - } - - log::debug!("After paste, flow is: {flow:?}"); - - self.needs_saving = true; - - // Trigger UI steps refresh - sender.input(FlowInputs::UpdateStepsFromModel); - } - FlowInputs::MoveStep(from, to, offset) => { - let current_from = from.current_index(); - let step = self.open_flow.as_ref().unwrap().actions[current_from].clone(); - sender.input(FlowInputs::CutStep(from)); - - // Establish new position - let mut to = (to.current_index() as isize + offset).max(0) as usize; - if to > current_from && to > 0 { - to -= 1; - } - - sender.input(FlowInputs::PasteStep(to, step)); - } - } - self.update_view(widgets, sender); - } -} +use std::{collections::HashMap, fmt, 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::{ActionConfiguration, ActionParameterSource, AutomationFlow, VersionedFile}, +}; + +use crate::ui::flows::action_component::ActionComponentOutput; + +use super::{file_filters, lang}; + +mod action_component; +mod execution_dialog; +pub mod header; + +pub enum SaveOrOpenFlowError { + IoError(std::io::Error), + ParsingError(ron::error::SpannedError), + SerializingError(ron::Error), + FlowNotVersionCompatible, + MissingAction(usize, String), +} + +impl fmt::Display for SaveOrOpenFlowError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::IoError(e) => lang::lookup_with_args("flow-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("flow-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("flow-save-open-error-serializing-error", { + let mut map = HashMap::new(); + map.insert("error", e.to_string().into()); + map + }) + } + Self::FlowNotVersionCompatible => { + lang::lookup("flow-save-open-error-flow-not-version-compatible") + } + Self::MissingAction(step, e) => { + lang::lookup_with_args("flow-save-open-error-missing-action", { + let mut map = HashMap::new(); + map.insert("step", (step + 1).into()); + map.insert("error", e.to_string().into()); + map + }) + } + } + ) + } +} + +#[derive(Clone, Debug)] +pub enum FlowInputs { + /// Do nothing + NoOp, + /// The map of actions has changed and should be updated + ActionsMapChanged(Arc), + /// Create a new flow + NewFlow, + /// Actually create the new flow + _NewFlow, + /// Prompt the user to open a flow. This will ask to save first if needed. + OpenFlow, + /// Actually show the user the open file dialog + _OpenFlow, + /// Actually open a flow after the user has finished selecting + __OpenFlow(PathBuf), + /// Save the flow, prompting if needed to set file path + SaveFlow, + /// Save the flow as a new file, always prompting for a file path + SaveAsFlow, + /// Ask where to save if needed, then save + _SaveFlowThen(Box), + /// Actually write the flow to disk, then emit then input + __SaveFlowThen(PathBuf, Box), + /// Close the flow, prompting if needing to save first + CloseFlow, + /// Actually close the flow + _CloseFlow, + /// Add the step with the ID provided + AddStep(String), + /// Update the UI steps from the open flow. 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 [`FlowInputs::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, ActionConfiguration), + /// Move a step from the index to a position offset (param 3) from a new index (param 2). + MoveStep(DynamicIndex, DynamicIndex, isize), + /// Start the flow exection + RunFlow, + /// The [`ActionConfiguration`] has changed for the step indicated by the [`DynamicIndex`]. + /// This does not refresh the UI. + ConfigUpdate(DynamicIndex, ActionConfiguration), +} + +#[derive(Debug)] +pub enum FlowOutputs {} + +#[derive(Debug)] +pub struct FlowsModel { + action_map: Arc, + engine_list: Arc, + + open_flow: Option, + open_path: Option, + needs_saving: bool, + header: Rc>, + live_actions_list: FactoryVecDeque, + + execution_dialog: Option>, +} + +impl FlowsModel { + /// 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( + "FlowsModel::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 flow + fn new_flow(&mut self) { + self.open_path = None; + self.needs_saving = true; + self.open_flow = Some(AutomationFlow::default()); + self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( + self.open_flow.is_some(), + )); + } + + /// Open a flow. This does not ask to save first. + fn open_flow(&mut self, file: PathBuf) -> Result, SaveOrOpenFlowError> { + let data = &fs::read_to_string(&file).map_err(SaveOrOpenFlowError::IoError)?; + + let versioned_file: VersionedFile = + ron::from_str(data).map_err(SaveOrOpenFlowError::ParsingError)?; + if versioned_file.version() != 1 { + return Err(SaveOrOpenFlowError::FlowNotVersionCompatible); + } + + let mut flow: AutomationFlow = + ron::from_str(data).map_err(SaveOrOpenFlowError::ParsingError)?; + if flow.version() != 1 { + return Err(SaveOrOpenFlowError::FlowNotVersionCompatible); + } + let mut steps_reset = vec![]; + for (step, ac) in flow.actions.iter_mut().enumerate() { + match self.action_map.get_action_by_id(&ac.action_id) { + None => { + return Err(SaveOrOpenFlowError::MissingAction( + step, + ac.action_id.clone(), + )) + } + Some(action) => { + // Check that action parameters haven't changed. If they have, reset values. + if ac.update(action) { + steps_reset.push(step + 1); + } + } + } + } + self.open_flow = Some(flow); + self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( + self.open_flow.is_some(), + )); + self.open_path = Some(file); + self.needs_saving = false; + log::debug!("New flow open."); + log::debug!("Flow: {:?}", self.open_flow); + Ok(steps_reset) + } + + /// 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: FlowInputs) { + if self.needs_saving { + let question = self.create_message_dialog_skeleton( + lang::lookup("flow-save-before"), + lang::lookup("flow-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(FlowInputs::_SaveFlowThen(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: FlowInputs, + ) { + if always_ask_where || self.open_path.is_none() { + // Ask where + let dialog = gtk::FileDialog::builder() + .modal(true) + .title(lang::lookup("header-save")) + .initial_folder(>k::gio::File::for_path( + std::env::var("TA_FLOW_DIR").unwrap_or(".".to_string()), + )) + .filters(&file_filters::filter_list(vec![ + file_filters::flows(), + 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 mut path = file.path().unwrap(); + path.set_extension("taflow"); + sender_c.emit(FlowInputs::__SaveFlowThen(path, Box::new(then.clone()))); + } + }, + ); + } else { + sender.emit(FlowInputs::__SaveFlowThen( + self.open_path.clone().unwrap(), + Box::new(then), + )); + } + } + + /// Just save the flow to disk with the current `open_path` as the destination + fn save_flow(&mut self) -> Result<(), SaveOrOpenFlowError> { + let save_path = self.open_path.as_ref().unwrap(); + let data = ron::to_string(self.open_flow.as_ref().unwrap()) + .map_err(SaveOrOpenFlowError::SerializingError)?; + fs::write(save_path, data).map_err(SaveOrOpenFlowError::IoError)?; + self.needs_saving = false; + Ok(()) + } + + /// Close this flow without checking first + fn close_flow(&mut self) { + self.open_flow = None; + self.open_path = None; + self.needs_saving = false; + self.live_actions_list.guard().clear(); + self.header.emit(header::FlowsHeaderInput::ChangeFlowOpen( + self.open_flow.is_some(), + )); + } +} + +#[relm4::component(pub)] +impl Component for FlowsModel { + type Init = (Arc, Arc); + type Input = FlowInputs; + type Output = FlowOutputs; + type CommandOutput = (); + + view! { + #[root] + toast_target = adw::ToastOverlay { + gtk::ScrolledWindow { + set_vexpand: true, + set_hscrollbar_policy: gtk::PolicyType::Never, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_margin_all: 5, + + adw::StatusPage { + set_title: &lang::lookup("nothing-open"), + set_description: Some(&lang::lookup("flow-nothing-open-description")), + set_icon_name: Some(relm4_icons::icon_names::LIGHTBULB), + #[watch] + set_visible: model.open_flow.is_none(), + set_vexpand: true, + }, + + #[local_ref] + live_actions_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::FlowsHeader::builder() + .launch(init.0.clone()) + .forward(sender.input_sender(), |msg| match msg { + header::FlowsHeaderOutput::NewFlow => FlowInputs::NewFlow, + header::FlowsHeaderOutput::OpenFlow => FlowInputs::OpenFlow, + header::FlowsHeaderOutput::SaveFlow => FlowInputs::SaveFlow, + header::FlowsHeaderOutput::SaveAsFlow => FlowInputs::SaveAsFlow, + header::FlowsHeaderOutput::CloseFlow => FlowInputs::CloseFlow, + header::FlowsHeaderOutput::RunFlow => FlowInputs::RunFlow, + header::FlowsHeaderOutput::AddStep(step) => FlowInputs::AddStep(step), + }), + ); + + let model = FlowsModel { + action_map: init.0, + engine_list: init.1, + open_flow: None, + open_path: None, + needs_saving: false, + execution_dialog: None, + header, + live_actions_list: FactoryVecDeque::builder() + .launch(gtk::Box::default()) + .forward(sender.input_sender(), |output| match output { + ActionComponentOutput::Remove(idx) => FlowInputs::RemoveStep(idx), + ActionComponentOutput::Cut(idx) => FlowInputs::CutStep(idx), + ActionComponentOutput::Paste(idx, step) => FlowInputs::PasteStep(idx, step), + ActionComponentOutput::ConfigUpdate(step, config) => { + FlowInputs::ConfigUpdate(step, config) + } + ActionComponentOutput::MoveStep(from, to, offset) => { + FlowInputs::MoveStep(from, to, offset) + } + }), + }; + + // Trigger update actions from model + sender.input(FlowInputs::UpdateStepsFromModel); + + let live_actions_list = model.live_actions_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 { + FlowInputs::NoOp => (), + FlowInputs::ActionsMapChanged(new_map) => { + self.action_map = new_map.clone(); + self.header + .emit(header::FlowsHeaderInput::ActionsMapChanged(new_map)); + + // This may have changed action parameters, so check again. + let mut close_flow = false; + let mut steps_reset = vec![]; + if let Some(flow) = &mut self.open_flow { + let actions_clone = flow.actions.clone(); + for (step, ac) in flow.actions.iter_mut().enumerate() { + match self.action_map.get_action_by_id(&ac.action_id) { + None => { + close_flow = true; + } + Some(action) => { + // Check that action parameters haven't changed. If they have, reset values. + if ac.update(action.clone()) { + steps_reset.push(step); + } + + // Check that the references from this AC to another don't now violate types + for (p_id, src) in &mut ac.parameter_sources { + if let ActionParameterSource::FromOutput(other_step, output) = + src + { + let (_name, kind) = &action.parameters()[*p_id]; + // Check that parameter from step->output is of type kind + if let Some(other_ac) = actions_clone.get(*other_step) { + if let Some(other_action) = &self + .action_map + .get_action_by_id(&other_ac.action_id) + { + if let Some((_name, other_output_kind)) = + other_action.outputs().get(*output) + { + if kind != other_output_kind { + // Reset to literal + steps_reset.push(step); + *src = ActionParameterSource::Literal; + } + } + } + } + // If any of these if's fail, then the main loop will catch and fail later. + } + } + } + } + } + sender.input(FlowInputs::UpdateStepsFromModel); + } + if !steps_reset.is_empty() { + let toast = + adw::Toast::new(&lang::lookup_with_args("flow-action-changed-message", { + let mut map = HashMap::new(); + map.insert("stepCount", steps_reset.len().into()); + map.insert( + "steps", + steps_reset + .iter() + .map(|i| (i + 1).to_string()) + .collect::>() + .join(", ") + .into(), + ); + map + })); + toast.set_timeout(0); // indefinte so it can be seen when switching back + widgets.toast_target.add_toast(toast); + } + if close_flow { + self.close_flow(); + } + } + FlowInputs::ConfigUpdate(step, new_config) => { + // unwrap rationale: config updates can't happen if nothing is open + let flow = self.open_flow.as_mut().unwrap(); + flow.actions[step.current_index()] = new_config; + self.needs_saving = true; + } + FlowInputs::NewFlow => { + self.prompt_to_save(sender.input_sender(), FlowInputs::_NewFlow); + } + FlowInputs::_NewFlow => { + self.new_flow(); + sender.input(FlowInputs::UpdateStepsFromModel); + } + FlowInputs::OpenFlow => { + self.prompt_to_save(sender.input_sender(), FlowInputs::_OpenFlow); + } + FlowInputs::_OpenFlow => { + let dialog = gtk::FileDialog::builder() + .modal(true) + .title(lang::lookup("header-open")) + .filters(&file_filters::filter_list(vec![ + file_filters::flows(), + file_filters::all(), + ])) + .initial_folder(>k::gio::File::for_path( + std::env::var("TA_FLOW_DIR").unwrap_or(".".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(FlowInputs::__OpenFlow(path)); + } + }, + ); + } + FlowInputs::__OpenFlow(path) => { + match self.open_flow(path) { + Ok(changes) => { + // Reload UI + sender.input(FlowInputs::UpdateStepsFromModel); + + if !changes.is_empty() { + let changed_steps = changes + .iter() + .map(|step| step.to_string()) + .collect::>() + .join(","); + self.create_message_dialog( + lang::lookup("flow-action-changed"), + lang::lookup_with_args("flow-action-changed-message", { + let mut map = HashMap::new(); + map.insert("stepCount", changes.len().into()); + map.insert("steps", changed_steps.into()); + map + }), + ) + .set_visible(true); + } + } + Err(e) => { + // Show error dialog + self.create_message_dialog( + lang::lookup("flow-error-opening"), + e.to_string(), + ) + .set_visible(true); + } + } + } + FlowInputs::SaveFlow => { + if self.open_flow.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, + FlowInputs::NoOp, + ); + } + } + FlowInputs::SaveAsFlow => { + if self.open_flow.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, + FlowInputs::NoOp, + ); + } + } + FlowInputs::_SaveFlowThen(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, + ); + } + FlowInputs::__SaveFlowThen(path, then) => { + self.open_path = Some(path); + if let Err(e) = self.save_flow() { + self.create_message_dialog(lang::lookup("flow-error-saving"), e.to_string()) + .set_visible(true); + } else { + widgets + .toast_target + .add_toast(adw::Toast::new(&lang::lookup("flow-saved"))); + sender.input_sender().emit(*then); + } + } + FlowInputs::CloseFlow => { + self.prompt_to_save(sender.input_sender(), FlowInputs::_CloseFlow); + } + FlowInputs::_CloseFlow => { + self.close_flow(); + } + + FlowInputs::RunFlow => { + if let Some(flow) = &self.open_flow { + let e_dialog = execution_dialog::ExecutionDialog::builder() + .transient_for(root) + .launch(execution_dialog::ExecutionDialogInit { + flow: flow.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); + } + } + + FlowInputs::AddStep(step_id) => { + if self.open_flow.is_none() { + self.new_flow(); + } + + // unwrap rationale: we've just guaranteed a flow is open + let flow = self.open_flow.as_mut().unwrap(); + // unwrap rationale: the header can't ask to add an action that doesn't exist + flow.actions.push(ActionConfiguration::from( + self.action_map.get_action_by_id(&step_id).unwrap(), + )); + self.needs_saving = true; + // Trigger UI steps refresh + sender.input(FlowInputs::UpdateStepsFromModel); + } + + FlowInputs::UpdateStepsFromModel => { + let mut live_list = self.live_actions_list.guard(); + live_list.clear(); + if let Some(flow) = &self.open_flow { + let mut possible_outputs = vec![]; + for (step, config) in flow.actions.iter().enumerate() { + live_list.push_back(action_component::ActionComponentInitialiser { + possible_outputs: possible_outputs.clone(), + config: config.clone(), + action: self.action_map.get_action_by_id(&config.action_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_idx, (name, kind)) in self + .action_map + .get_action_by_id(&config.action_id) + .unwrap() + .outputs() + .iter() + .enumerate() + { + 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, + ActionParameterSource::FromOutput(step, output_idx), + )); + } + } + } + } + + FlowInputs::RemoveStep(step_idx) => { + let idx = step_idx.current_index(); + let flow = self.open_flow.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 >= flow.actions.len() { + log::warn!("Skipped running RemoveStep as the index was invalid."); + return; + } + + log::info!("Deleting step {}", idx + 1); + + flow.actions.remove(idx); + + // Remove references to step and renumber references above step to one less than they were + for step in flow.actions.iter_mut() { + for (_step_idx, source) in step.parameter_sources.iter_mut() { + if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { + match (*from_step).cmp(&idx) { + std::cmp::Ordering::Equal => { + *source = ActionParameterSource::Literal + } + std::cmp::Ordering::Greater => *from_step -= 1, + _ => (), + } + } + } + } + + self.needs_saving = true; + + // Trigger UI steps refresh + sender.input(FlowInputs::UpdateStepsFromModel); + } + FlowInputs::CutStep(step_idx) => { + let idx = step_idx.current_index(); + let flow = self.open_flow.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 >= flow.actions.len() { + log::warn!("Skipped running CutStep as the index was invalid."); + return; + } + + flow.actions.remove(idx); + + // Remove references to step and renumber references above step to one less than they were + for step in flow.actions.iter_mut() { + for (_param_idx, source) in step.parameter_sources.iter_mut() { + if let ActionParameterSource::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, + _ => (), + } + } + } + } + + log::debug!("After cut, flow is: {flow:?}"); + + self.needs_saving = true; + } + FlowInputs::PasteStep(idx, mut config) => { + let flow = self.open_flow.as_mut().unwrap(); + let idx = idx.max(0).min(flow.actions.len()); + + // Adjust step just about to paste + for (_param_idx, source) in config.parameter_sources.iter_mut() { + if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { + if *from_step <= idx { + *source = ActionParameterSource::Literal; + } + } + } + + log::info!("Pasting step to {}", idx + 1); + flow.actions.insert(idx, config); + + // Remove references to step and renumber references above step to one less than they were + for (step_idx, step) in flow.actions.iter_mut().enumerate() { + for (_param_idx, source) in step.parameter_sources.iter_mut() { + if let ActionParameterSource::FromOutput(from_step, _output_idx) = source { + if *from_step == usize::MAX { + if step_idx < idx { + // can't refer to it anymore + *source = ActionParameterSource::Literal; + } else { + *from_step = idx; + } + } else if *from_step >= idx { + *from_step += 1; + } + } + } + } + + log::debug!("After paste, flow is: {flow:?}"); + + self.needs_saving = true; + + // Trigger UI steps refresh + sender.input(FlowInputs::UpdateStepsFromModel); + } + FlowInputs::MoveStep(from, to, offset) => { + let current_from = from.current_index(); + let step = self.open_flow.as_ref().unwrap().actions[current_from].clone(); + sender.input(FlowInputs::CutStep(from)); + + // Establish new position + let mut to = (to.current_index() as isize + offset).max(0) as usize; + if to > current_from && to > 0 { + to -= 1; + } + + sender.input(FlowInputs::PasteStep(to, step)); + } + } + self.update_view(widgets, sender); + } +}