diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6bbe6dc01b..8f7098c523cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -179,6 +179,8 @@ - [A loading animation is now shown when opening and creating projects][6827], as the previous behaviour of showing a blank screen while the project was being loaded was potentially confusing to users. +- [Performance and readability of documentation panel was improved][6893]. The + documentation is now split into separate pages, which are much smaller. [6279]: https://github.com/enso-org/enso/pull/6279 [6421]: https://github.com/enso-org/enso/pull/6421 @@ -191,6 +193,7 @@ [6474]: https://github.com/enso-org/enso/pull/6474 [6844]: https://github.com/enso-org/enso/pull/6844 [6827]: https://github.com/enso-org/enso/pull/6827 +[6893]: https://github.com/enso-org/enso/pull/6893 #### EnsoGL (rendering engine) @@ -470,6 +473,15 @@ - [Added `Date_Range`.][6621] - [Implemented the `cast` operation for `Table` and `Column`.][6711] - [Added `.round` and `.int` to `Integer` and `Decimal`.][6743] +- [Added `.round`, `.truncate`, `.ceil`, and `.floor` to `Column`.][6817] +- [Added execution control to `Table.write` and various bug fixes.][6835] +- [Implemented `Table.add_row_number`.][6890] +- [Handling edge cases in rounding.][6922] +- [Split `Table.create_database_table` into `Connection.create_table` and + `Table.select_into_database_table`.][6925] +- [Speed improvements to `Column` `.truncate`, `.ceil`, and `.floor`.][6941] +- [Implemented addition and subtraction for `Date_Period` and + `Time_Period`.][6956] [debug-shortcuts]: https://github.com/enso-org/enso/blob/develop/app/gui/docs/product/shortcuts.md#debug @@ -682,6 +694,13 @@ [6621]: https://github.com/enso-org/enso/pull/6621 [6711]: https://github.com/enso-org/enso/pull/6711 [6743]: https://github.com/enso-org/enso/pull/6743 +[6817]: https://github.com/enso-org/enso/pull/6817 +[6835]: https://github.com/enso-org/enso/pull/6835 +[6890]: https://github.com/enso-org/enso/pull/6890 +[6922]: https://github.com/enso-org/enso/pull/6922 +[6925]: https://github.com/enso-org/enso/pull/6925 +[6941]: https://github.com/enso-org/enso/pull/6941 +[6956]: https://github.com/enso-org/enso/pull/6956 #### Enso Compiler @@ -796,6 +815,7 @@ - [Add project creation time to project metadata][6780] - [Upgrade GraalVM to 22.3.1 JDK17][6750] - [Ascribed types are checked during runtime][6790] +- [Improve and colorize compiler's diagnostic messages][6931] [3227]: https://github.com/enso-org/enso/pull/3227 [3248]: https://github.com/enso-org/enso/pull/3248 @@ -910,6 +930,7 @@ [6755]: https://github.com/enso-org/enso/pull/6755 [6780]: https://github.com/enso-org/enso/pull/6780 [6790]: https://github.com/enso-org/enso/pull/6790 +[6931]: https://github.com/enso-org/enso/pull/6931 # Enso 2.0.0-alpha.18 (2021-10-12) diff --git a/Cargo.lock b/Cargo.lock index 7fe5ea817628..6562eec0cf04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1573,6 +1573,18 @@ dependencies = [ "ide-view-execution-environment-selector", ] +[[package]] +name = "debug-scene-graph-editor-edges" +version = "0.1.0" +dependencies = [ + "enso-frp", + "ensogl", + "ensogl-hardcoded-theme", + "ide-view-graph-editor", + "nalgebra", + "wasm-bindgen", +] + [[package]] name = "debug-scene-icons" version = "0.1.0" @@ -2060,6 +2072,7 @@ dependencies = [ "debug-scene-component-list-panel-view", "debug-scene-documentation", "debug-scene-execution-environment-dropdown", + "debug-scene-graph-editor-edges", "debug-scene-icons", "debug-scene-interface", "debug-scene-text-grid-visualization", @@ -4347,6 +4360,7 @@ dependencies = [ "base64 0.13.1", "bimap", "bitflags 2.2.1", + "derivative", "engine-protocol", "enso-config", "enso-frp", diff --git a/app/gui/controller/engine-protocol/src/language_server.rs b/app/gui/controller/engine-protocol/src/language_server.rs index 9ae91a906ab3..eb9c1f3b6821 100644 --- a/app/gui/controller/engine-protocol/src/language_server.rs +++ b/app/gui/controller/engine-protocol/src/language_server.rs @@ -118,7 +118,7 @@ trait API { /// Create a new execution context. Return capabilities executionContext/canModify and /// executionContext/receivesUpdates containing freshly created ContextId #[MethodInput=CreateExecutionContextInput, rpc_name="executionContext/create"] - fn create_execution_context(&self) -> response::CreateExecutionContext; + fn create_execution_context(&self, context_id: ContextId) -> response::CreateExecutionContext; /// Destroy an execution context and free its resources. #[MethodInput=DestroyExecutionContextInput, rpc_name="executionContext/destroy"] diff --git a/app/gui/controller/engine-protocol/src/language_server/tests.rs b/app/gui/controller/engine-protocol/src/language_server/tests.rs index 336dfca8348a..65584df62e2b 100644 --- a/app/gui/controller/engine-protocol/src/language_server/tests.rs +++ b/app/gui/controller/engine-protocol/src/language_server/tests.rs @@ -348,7 +348,7 @@ fn test_computed_value_update() { let update = &expression_updates.updates.first().unwrap(); assert_eq!(update.expression_id, id); assert_eq!(update.typename.as_deref(), Some(typename)); - assert!(update.method_pointer.is_none()); + assert!(update.method_call.is_none()); assert!(update.from_cache); assert!(matches!(update.payload, ExpressionUpdatePayload::Value { warnings: None })) } @@ -373,9 +373,9 @@ fn test_execution_context() { let create_execution_context_response = response::CreateExecutionContext { context_id, can_modify, receives_updates }; test_request( - |client| client.create_execution_context(), + |client| client.create_execution_context(&context_id), "executionContext/create", - json!({}), + json!({"contextId":"00000000-0000-0000-0000-000000000000"}), json!({ "contextId" : "00000000-0000-0000-0000-000000000000", "canModify" : { diff --git a/app/gui/controller/engine-protocol/src/language_server/types.rs b/app/gui/controller/engine-protocol/src/language_server/types.rs index eaec9b5772a5..ed6c3ccb6d59 100644 --- a/app/gui/controller/engine-protocol/src/language_server/types.rs +++ b/app/gui/controller/engine-protocol/src/language_server/types.rs @@ -216,7 +216,7 @@ pub struct ExpressionUpdate { pub expression_id: ExpressionId, #[serde(rename = "type")] // To avoid collision with the `type` keyword. pub typename: Option, - pub method_pointer: Option, + pub method_call: Option, pub profiling_info: Vec, pub from_cache: bool, pub payload: ExpressionUpdatePayload, @@ -740,6 +740,16 @@ pub struct MethodPointer { pub name: String, } +/// A representation of a method call. +#[derive(Hash, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MethodCall { + /// The method pointer of a call. + pub method_pointer: MethodPointer, + /// Indexes of arguments that have not been applied to this method. + pub not_applied_arguments: Vec, +} + /// Used for entering a method. The first item on the execution context stack should always be /// an `ExplicitCall`. #[derive(Hash, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1226,7 +1236,7 @@ pub mod test { ExpressionUpdate { expression_id: id, typename: Some(typename.into()), - method_pointer: None, + method_call: None, profiling_info: default(), from_cache: false, payload: ExpressionUpdatePayload::Value { warnings: None }, @@ -1242,7 +1252,7 @@ pub mod test { ExpressionUpdate { expression_id: id, typename: None, - method_pointer: Some(method_pointer), + method_call: Some(MethodCall { method_pointer, not_applied_arguments: vec![] }), profiling_info: default(), from_cache: false, payload: ExpressionUpdatePayload::Value { warnings: None }, @@ -1256,7 +1266,7 @@ pub mod test { ExpressionUpdate { expression_id: id, typename: None, - method_pointer: None, + method_call: None, profiling_info: default(), from_cache: false, payload: ExpressionUpdatePayload::DataflowError { trace }, @@ -1274,7 +1284,7 @@ pub mod test { ExpressionUpdate { expression_id: id, typename: None, - method_pointer: None, + method_call: None, profiling_info: default(), from_cache: false, payload: ExpressionUpdatePayload::Panic { trace, message }, diff --git a/app/gui/src/controller/graph/executed.rs b/app/gui/src/controller/graph/executed.rs index a7947f67fa66..2c8f4f676d12 100644 --- a/app/gui/src/controller/graph/executed.rs +++ b/app/gui/src/controller/graph/executed.rs @@ -14,6 +14,7 @@ use crate::model::execution_context::QualifiedMethodPointer; use crate::model::execution_context::Visualization; use crate::model::execution_context::VisualizationId; use crate::model::execution_context::VisualizationUpdateData; +use crate::retry::retry_operation_errors_cap; use double_representation::name::QualifiedName; use engine_protocol::language_server::ExecutionEnvironment; @@ -22,6 +23,8 @@ use futures::stream; use futures::TryStreamExt; use span_tree::generate::context::CalledMethodInfo; use span_tree::generate::context::Context; +use std::assert_matches::debug_assert_matches; +use std::time::Duration; // ============== @@ -49,9 +52,14 @@ pub struct NoResolvedMethod(double_representation::node::Id); #[allow(missing_docs)] #[derive(Debug, Fail, Clone, Copy)] -#[fail(display = "Operation is not permitted in read only mode")] +#[fail(display = "Operation is not permitted in read only mode.")] pub struct ReadOnly; +#[allow(missing_docs)] +#[derive(Debug, Fail, Clone, Copy)] +#[fail(display = "Previous operation modifying call stack is still in progress.")] +pub struct SyncingStack; + // ==================== // === Notification === @@ -92,6 +100,11 @@ pub struct Handle { /// The publisher allowing sending notification to subscribed entities. Note that its outputs /// is merged with publishers from the stored graph and execution controllers. notifier: notification::Publisher, + /// A mutex guarding a process syncing Execution Context stack with the current graph. As + /// the syncing requires multiple async calls to the engine, and the stack updates depend on + /// each other, we should not mix various operations (e.g. entering node while still in + /// process of entering another node). + syncing_stack: Rc>, } impl Handle { @@ -99,7 +112,8 @@ impl Handle { #[profile(Task)] pub async fn new(project: model::Project, method: MethodPointer) -> FallibleResult { let graph = controller::Graph::new_method(&project, &method).await?; - let execution = project.create_execution_context(method.clone()).await?; + let context_id = Uuid::new_v4(); + let execution = project.create_execution_context(method.clone(), context_id).await?; Ok(Self::new_internal(graph, project, execution)) } @@ -122,9 +136,13 @@ impl Handle { project: model::Project, execution_ctx: model::ExecutionContext, ) -> Self { - let graph = Rc::new(RefCell::new(graph)); - let notifier = default(); - Handle { graph, execution_ctx, project, notifier } + Handle { + graph: Rc::new(RefCell::new(graph)), + execution_ctx, + project, + notifier: default(), + syncing_stack: default(), + } } /// See [`model::ExecutionContext::when_ready`]. @@ -229,27 +247,49 @@ impl Handle { pub async fn enter_stack(&self, stack: Vec) -> FallibleResult { if self.project.read_only() { Err(ReadOnly.into()) - } else { - let mut successful_calls = Vec::new(); - let result = stream::iter(stack) + } else if let Some(last_call) = stack.last() { + let _syncing = self.syncing_stack.try_lock().map_err(|_| SyncingStack)?; + // Before adding new items to stack, first make sure we're actually able to construct + // the graph controller. + let graph = controller::Graph::new_method(&self.project, &last_call.definition).await?; + let mut successful_calls = 0; + let result = stream::iter(stack.iter()) .then(|local_call| async { - debug!("Entering node {}.", local_call.call); + info!("Entering node {}.", local_call.call); self.execution_ctx.push(local_call.clone()).await?; - Ok(local_call) + Ok(()) }) - .map_ok(|local_call| successful_calls.push(local_call)) + .map_ok(|()| successful_calls += 1) .try_collect::<()>() .await; - if let Some(last_successful_call) = successful_calls.last() { - let graph = - controller::Graph::new_method(&self.project, &last_successful_call.definition) - .await?; - debug!("Replacing graph with {graph:?}."); - self.graph.replace(graph); - debug!("Sending graph invalidation signal."); - self.notifier.publish(Notification::EnteredStack(successful_calls)).await; + match &result { + Ok(()) => { + info!("Replacing graph with {graph:?}."); + self.graph.replace(graph); + info!("Sending graph invalidation signal."); + self.notifier.publish(Notification::EnteredStack(stack)).await; + } + Err(_) => { + let successful_calls_to_revert = iter::repeat(()).take(successful_calls); + for () in successful_calls_to_revert { + let error_msg = "Error while restoring execution context stack after \ + unsuccessful entering node"; + let retry_result = retry_operation_errors_cap( + || self.execution_ctx.pop(), + self.retry_times_for_restoring_stack_operations(), + error_msg, + 0, + ) + .await; + debug_assert_matches!(FallibleResult::from(retry_result), Ok(_)); + } + } } + result + } else { + // The stack passed as argument is empty; nothing to do. + Ok(()) } } @@ -276,22 +316,46 @@ impl Handle { if self.project.read_only() { Err(ReadOnly.into()) } else { - let mut successful_pop_count = 0; - let result = stream::iter(0..frame_count) - .then(|_| self.execution_ctx.pop()) - .map_ok(|_| successful_pop_count += 1) + let method = self.execution_ctx.method_at_frame_back(frame_count)?; + let _syncing = self.syncing_stack.try_lock().map_err(|_| SyncingStack)?; + let graph = controller::Graph::new_method(&self.project, &method).await?; + + let mut successful_pops = Vec::new(); + let result = stream::iter(iter::repeat(()).take(frame_count)) + .then(|()| self.execution_ctx.pop()) + .map_ok(|local_call| successful_pops.push(local_call)) .try_collect::<()>() .await; - if successful_pop_count > 0 { - let method = self.execution_ctx.current_method(); - let graph = controller::Graph::new_method(&self.project, &method).await?; - self.graph.replace(graph); - self.notifier.publish(Notification::ExitedStack(successful_pop_count)).await; + match &result { + Ok(()) => { + self.graph.replace(graph); + self.notifier.publish(Notification::ExitedStack(frame_count)).await; + } + Err(_) => + for frame in successful_pops.into_iter().rev() { + let error_msg = "Error while restoring execution context stack after \ + unsuccessful leaving node"; + let retry_result = retry_operation_errors_cap( + || self.execution_ctx.push(frame.clone()), + self.retry_times_for_restoring_stack_operations(), + error_msg, + 0, + ) + .await; + debug_assert_matches!(FallibleResult::from(retry_result), Ok(_)); + }, } result } } + fn retry_times_for_restoring_stack_operations(&self) -> impl Iterator { + iter::repeat(()).scan(Duration::from_secs(1), |delay, ()| { + *delay = min(*delay * 2, Duration::from_secs(30)); + Some(*delay) + }) + } + /// Interrupt the program execution. pub async fn interrupt(&self) -> FallibleResult { self.execution_ctx.interrupt().await?; @@ -448,7 +512,6 @@ pub mod tests { use crate::test::mock::Fixture; use controller::graph::SpanTree; use engine_protocol::language_server::types::test::value_update_with_type; - use wasm_bindgen_test::wasm_bindgen_test; use wasm_bindgen_test::wasm_bindgen_test_configure; wasm_bindgen_test_configure!(run_in_browser); @@ -487,7 +550,7 @@ pub mod tests { } // Test that checks that value computed notification is properly relayed by the executed graph. - #[wasm_bindgen_test] + #[test] fn dispatching_value_computed_notification() { use crate::test::mock::Fixture; // Setup the controller. @@ -534,8 +597,9 @@ pub mod tests { notifications.expect_pending(); } + // Test that moving nodes is possible in read-only mode. - #[wasm_bindgen_test] + #[test] fn read_only_mode_does_not_restrict_moving_nodes() { use model::module::Position; @@ -555,7 +619,7 @@ pub mod tests { } // Test that certain actions are forbidden in read-only mode. - #[wasm_bindgen_test] + #[test] fn read_only_mode() { fn run(code: &str, f: impl FnOnce(&Handle)) { let mut data = crate::test::mock::Unified::new(); diff --git a/app/gui/src/controller/searcher/action.rs b/app/gui/src/controller/searcher/action.rs index bd24a47471d2..15badb559d32 100644 --- a/app/gui/src/controller/searcher/action.rs +++ b/app/gui/src/controller/searcher/action.rs @@ -135,8 +135,8 @@ pub enum MatchKind { /// The entry's label to be displayed in the component browser was matched. #[default] Label, - /// The code to be generated by the entry was matched. - Code, + /// The entry's name from the code was matched. + Name, /// An alias of the entry was matched, contains the specific alias that was matched. Alias(ImString), } @@ -204,7 +204,7 @@ impl ListEntry { fuzzly::find_best_subsequence(self.action.to_string(), pattern, metric) }); self.match_info = match subsequence { - Some(subsequence) => MatchInfo::Matches { subsequence, kind: MatchKind::Code }, + Some(subsequence) => MatchInfo::Matches { subsequence, kind: MatchKind::Label }, None => MatchInfo::DoesNotMatch, }; } @@ -442,7 +442,7 @@ impl<'a> CategoryBuilder<'a> { let category = self.category_id; built_list.entries.borrow_mut().extend(iter.into_iter().map(|action| { let match_info = - MatchInfo::Matches { subsequence: default(), kind: MatchKind::Code }; + MatchInfo::Matches { subsequence: default(), kind: MatchKind::Label }; ListEntry { category, match_info, action } })); } @@ -497,7 +497,7 @@ impl ListWithSearchResultBuilder { .iter() .map(|entry| { let match_info = - MatchInfo::Matches { subsequence: default(), kind: MatchKind::Code }; + MatchInfo::Matches { subsequence: default(), kind: MatchKind::Label }; let action = entry.action.clone_ref(); let category = self.search_result_category; ListEntry { category, match_info, action } diff --git a/app/gui/src/controller/searcher/component.rs b/app/gui/src/controller/searcher/component.rs index 142f8282cf50..41cfd7f1d5e5 100644 --- a/app/gui/src/controller/searcher/component.rs +++ b/app/gui/src/controller/searcher/component.rs @@ -176,36 +176,32 @@ impl Component { pub fn update_matching_info(&self, filter: Filter) { // Match the input pattern to the component label. let label = self.to_string(); - let label_matches = fuzzly::matches(&label, filter.pattern.clone_ref()); + let label_matches = fuzzly::matches(&label, filter.pattern.as_str()); let label_subsequence = label_matches.and_option_from(|| { let metric = fuzzly::metric::default(); - fuzzly::find_best_subsequence(label, filter.pattern.clone_ref(), metric) + fuzzly::find_best_subsequence(label, filter.pattern.as_str(), metric) }); let label_match_info = label_subsequence .map(|subsequence| MatchInfo::Matches { subsequence, kind: MatchKind::Label }); - // Match the input pattern to the code to be inserted. - let in_module = QualifiedName::as_ref(&filter.module_name); - let code = match &self.data { - Data::FromDatabase { entry, .. } => entry.code_to_insert(true, in_module).to_string(), - Data::Virtual { snippet } => snippet.code.to_string(), - }; - let code_matches = fuzzly::matches(&code, filter.pattern.clone_ref()); - let code_subsequence = code_matches.and_option_from(|| { + // Match the input pattern to the component name. + let name = self.name(); + let name_matches = fuzzly::matches(name, filter.pattern.as_str()); + let name_subsequence = name_matches.and_option_from(|| { let metric = fuzzly::metric::default(); - fuzzly::find_best_subsequence(code, filter.pattern.clone_ref(), metric) + fuzzly::find_best_subsequence(name, filter.pattern.as_str(), metric) }); - let code_match_info = code_subsequence.map(|subsequence| { + let name_match_info = name_subsequence.map(|subsequence| { let subsequence = fuzzly::Subsequence { indices: Vec::new(), ..subsequence }; - MatchInfo::Matches { subsequence, kind: MatchKind::Code } + MatchInfo::Matches { subsequence, kind: MatchKind::Name } }); // Match the input pattern to an entry's aliases and select the best alias match. let alias_matches = self.aliases().filter_map(|alias| { - if fuzzly::matches(alias, filter.pattern.clone_ref()) { + if fuzzly::matches(alias, filter.pattern.as_str()) { let metric = fuzzly::metric::default(); let subsequence = - fuzzly::find_best_subsequence(alias, filter.pattern.clone_ref(), metric); + fuzzly::find_best_subsequence(alias, filter.pattern.as_str(), metric); subsequence.map(|subsequence| (subsequence, alias)) } else { None @@ -221,7 +217,7 @@ impl Component { }); // Select the best match of the available label-, code- and alias matches. - let match_info_iter = [alias_match_info, code_match_info, label_match_info].into_iter(); + let match_info_iter = [alias_match_info, name_match_info, label_match_info].into_iter(); let best_match_info = match_info_iter.flatten().max_by(|lhs, rhs| lhs.cmp(rhs)); *self.match_info.borrow_mut() = best_match_info.unwrap_or(MatchInfo::DoesNotMatch); diff --git a/app/gui/src/ide/initializer.rs b/app/gui/src/ide/initializer.rs index 1f320877670f..ece7f37f50cf 100644 --- a/app/gui/src/ide/initializer.rs +++ b/app/gui/src/ide/initializer.rs @@ -4,11 +4,11 @@ use crate::prelude::*; use crate::config; use crate::ide::Ide; +use crate::retry::retry_operation; use crate::transport::web::WebSocket; use crate::FailedIde; use engine_protocol::project_manager; -use enso_web::sleep; use ensogl::application::Application; use std::time::Duration; @@ -99,22 +99,11 @@ impl Initializer { } async fn initialize_ide_controller_with_retries(&self) -> FallibleResult { - let mut retry_after = INITIALIZATION_RETRY_TIMES.iter(); - loop { - match self.initialize_ide_controller().await { - Ok(controller) => break Ok(controller), - Err(error) => { - error!("Failed to initialize controller: {error}"); - match retry_after.next() { - Some(time) => { - error!("Retrying after {} seconds", time.as_secs_f32()); - sleep(*time).await; - } - None => break Err(error), - } - } - } - } + let retry_times = INITIALIZATION_RETRY_TIMES.iter().copied(); + let error_message = "Failed to initialize controller."; + retry_operation(|| self.initialize_ide_controller(), retry_times, error_message) + .await + .into() } /// Initialize and return a new Ide Controller. diff --git a/app/gui/src/lib.rs b/app/gui/src/lib.rs index 491ba641382e..9b15f1d354f0 100644 --- a/app/gui/src/lib.rs +++ b/app/gui/src/lib.rs @@ -82,6 +82,7 @@ pub mod ide; pub mod integration_test; pub mod model; pub mod presenter; +pub mod retry; pub mod sync; pub mod test; pub mod transport; diff --git a/app/gui/src/model/execution_context.rs b/app/gui/src/model/execution_context.rs index 8316c13f7cac..aa7cea01b44f 100644 --- a/app/gui/src/model/execution_context.rs +++ b/app/gui/src/model/execution_context.rs @@ -64,7 +64,7 @@ impl From for ComputedValueInfo { fn from(update: ExpressionUpdate) -> Self { ComputedValueInfo { typename: update.typename.map(ImString::new), - method_call: update.method_pointer, + method_call: update.method_call.map(|mc| mc.method_pointer), payload: update.payload, } } @@ -414,6 +414,10 @@ pub trait API: Debug { /// Obtain the method pointer to the method of the call stack's top frame. fn current_method(&self) -> MethodPointer; + /// Obtain the method pointer to the method of the call `count` frames back from the stack's top + /// (calling with 0 is the same as [`current_method`](Self::current_method). + fn method_at_frame_back(&self, count: usize) -> FallibleResult; + /// Get the information about the given visualization. Fails, if there's no such visualization /// active. fn visualization_info(&self, id: VisualizationId) -> FallibleResult; diff --git a/app/gui/src/model/execution_context/plain.rs b/app/gui/src/model/execution_context/plain.rs index b0ee3823e82e..b83fc3e9c7a5 100644 --- a/app/gui/src/model/execution_context/plain.rs +++ b/app/gui/src/model/execution_context/plain.rs @@ -15,6 +15,7 @@ use engine_protocol::language_server::ExecutionEnvironment; use engine_protocol::language_server::MethodPointer; use engine_protocol::language_server::VisualisationConfiguration; use futures::future::LocalBoxFuture; +use std::cmp::Ordering; @@ -22,11 +23,22 @@ use futures::future::LocalBoxFuture; // === Errors === // ============== -/// Error then trying to pop stack item on ExecutionContext when there only root call remains. +/// Error when trying to pop stack item on ExecutionContext when there only root call remains. #[derive(Clone, Copy, Debug, Fail)] #[fail(display = "Tried to pop an entry point.")] pub struct PopOnEmptyStack(); +/// Error when trying to refer too much frames back. +#[derive(Clone, Copy, Debug, Fail)] +#[fail( + display = "Tried to get information from {} frames back, but stack has only {} frames.", + requested, actual +)] +pub struct TooManyFrames { + requested: usize, + actual: usize, +} + /// Error when using an Id that does not correspond to any known visualization. #[derive(Clone, Copy, Debug, Fail)] #[fail(display = "Tried to use incorrect visualization Id: {}.", _0)] @@ -176,6 +188,19 @@ impl model::execution_context::API for ExecutionContext { } } + fn method_at_frame_back(&self, count: usize) -> FallibleResult { + let stack = self.stack.borrow(); + match count.cmp(&stack.len()) { + Ordering::Less => { + let index = stack.len() - count - 1; + Ok(stack[index].definition.clone()) + } + Ordering::Equal => Ok(self.entry_point.borrow().clone()), + Ordering::Greater => + Err(TooManyFrames { requested: count, actual: stack.len() }.into()), + } + } + fn visualization_info(&self, id: VisualizationId) -> FallibleResult { let err = || InvalidVisualizationId(id).into(); self.visualizations.borrow_mut().get(&id).map(|v| v.visualization.clone()).ok_or_else(err) diff --git a/app/gui/src/model/execution_context/synchronized.rs b/app/gui/src/model/execution_context/synchronized.rs index 41d5dd58a2f6..6b2d2c4a3e23 100644 --- a/app/gui/src/model/execution_context/synchronized.rs +++ b/app/gui/src/model/execution_context/synchronized.rs @@ -66,10 +66,11 @@ impl ExecutionContext { pub fn create( language_server: Rc, root_definition: language_server::MethodPointer, + id: model::execution_context::Id, ) -> impl Future> { async move { info!("Creating."); - let id = language_server.client.create_execution_context().await?.context_id; + let id = language_server.client.create_execution_context(&id).await?.context_id; let model = model::execution_context::Plain::new(root_definition); info!("Created. Id: {id}."); let this = Self { id, model, language_server }; @@ -171,6 +172,10 @@ impl model::execution_context::API for ExecutionContext { self.model.current_method() } + fn method_at_frame_back(&self, count: usize) -> FallibleResult { + self.model.method_at_frame_back(count) + } + fn visualization_info(&self, id: VisualizationId) -> FallibleResult { self.model.visualization_info(id) } @@ -415,7 +420,7 @@ pub mod test { let connection = language_server::Connection::new_mock_rc(ls_client); let mut test = TestWithLocalPoolExecutor::set_up(); let method = data.main_method_pointer(); - let context = ExecutionContext::create(connection, method); + let context = ExecutionContext::create(connection, method, data.context_id); let context = test.expect_completion(context).unwrap(); Fixture { data, context, test } } @@ -434,7 +439,7 @@ pub mod test { fn mock_create_destroy_calls(data: &MockData, ls: &mut language_server::MockClient) { let id = data.context_id; let result = Self::expected_creation_response(data); - expect_call!(ls.create_execution_context() => Ok(result)); + expect_call!(ls.create_execution_context(id) => Ok(result)); expect_call!(ls.destroy_execution_context(id) => Ok(())); } diff --git a/app/gui/src/model/project.rs b/app/gui/src/model/project.rs index c67c22f6e4b6..ac06801a0a4d 100644 --- a/app/gui/src/model/project.rs +++ b/app/gui/src/model/project.rs @@ -85,6 +85,7 @@ pub trait API: Debug { fn create_execution_context<'a>( &'a self, root_definition: language_server::MethodPointer, + context_id: model::execution_context::Id, ) -> BoxFuture<'a, FallibleResult>; /// Set a new project name. @@ -234,8 +235,10 @@ pub mod test { let ctx2 = ctx.clone_ref(); project .expect_create_execution_context() - .withf_st(move |root_definition| root_definition == &ctx.current_method()) - .returning_st(move |_root_definition| ready(Ok(ctx2.clone_ref())).boxed_local()); + .withf_st(move |root_definition, _context_id| root_definition == &ctx.current_method()) + .returning_st(move |_root_definition, _context_id| { + ready(Ok(ctx2.clone_ref())).boxed_local() + }); } /// Sets up project root id expectation on the mock project, returning a given id. diff --git a/app/gui/src/model/project/synchronized.rs b/app/gui/src/model/project/synchronized.rs index d6e950dde8a5..392d3c8c1156 100644 --- a/app/gui/src/model/project/synchronized.rs +++ b/app/gui/src/model/project/synchronized.rs @@ -704,10 +704,12 @@ impl model::project::API for Project { fn create_execution_context( &self, root_definition: MethodPointer, + context_id: execution_context::Id, ) -> BoxFuture> { async move { let ls_rpc = self.language_server_rpc.clone_ref(); - let context = execution_context::Synchronized::create(ls_rpc, root_definition); + let context = + execution_context::Synchronized::create(ls_rpc, root_definition, context_id); let context = Rc::new(context.await?); self.execution_contexts.insert(context.clone_ref()); let context: model::ExecutionContext = context; @@ -928,7 +930,8 @@ mod test { assert!(result1.is_err()); // Create execution context. - let execution = project.create_execution_context(context_data.main_method_pointer()); + let execution = project + .create_execution_context(context_data.main_method_pointer(), context_data.context_id); let execution = test.expect_completion(execution).unwrap(); // Now context is in registry. @@ -950,7 +953,10 @@ mod test { // Context now has the information about type. let value_info = value_registry.get(&expression_id).unwrap(); assert_eq!(value_info.typename, value_update.typename.clone().map(ImString::new)); - assert_eq!(value_info.method_call, value_update.method_pointer); + assert_eq!( + value_info.method_call, + value_update.method_call.clone().map(|mc| mc.method_pointer) + ); } diff --git a/app/gui/src/presenter/graph/visualization.rs b/app/gui/src/presenter/graph/visualization.rs index 2d8ee8d20de5..7eeef199b5f1 100644 --- a/app/gui/src/presenter/graph/visualization.rs +++ b/app/gui/src/presenter/graph/visualization.rs @@ -12,6 +12,7 @@ use crate::presenter::graph::AstNodeId; use crate::presenter::graph::ViewNodeId; use enso_frp as frp; +use ensogl::application::View; use ide_view as view; use ide_view::graph_editor::component::node as node_view; use ide_view::graph_editor::component::visualization as visualization_view; @@ -110,11 +111,12 @@ impl Model { /// endpoint. fn handle_controller_failure( &self, - failure_endpoint: &frp::Source, + failure_endpoint: &frp::Source<(ViewNodeId, String)>, node: AstNodeId, + message: String, ) { if let Some(node_view) = self.state.view_id_of_ast_node(node) { - failure_endpoint.emit(node_view); + failure_endpoint.emit((node_view, message)); } } @@ -179,6 +181,7 @@ impl Visualization { state, }); + let app = &view.app().frp; frp::extend! { network eval view.visualization_shown (((node, metadata)) model.visualization_shown(*node, metadata.clone())); eval view.visualization_hidden ((node) model.visualization_hidden(*node)); @@ -186,20 +189,21 @@ impl Visualization { eval view.visualization_preprocessor_changed (((node, preprocessor)) model.visualization_preprocessor_changed(*node, preprocessor.clone_ref())); eval view.set_node_error_status (((node, error)) model.error_on_node_changed(*node, error)); - update <- source::<(ViewNodeId, visualization_view::Data)>(); + set_data <- source::<(ViewNodeId, visualization_view::Data)>(); error_update <- source::<(ViewNodeId, visualization_view::Data)>(); - visualization_failure <- source::(); - error_vis_failure <- source::(); + visualization_failure <- source::<(ViewNodeId,String)>(); + error_vis_failure <- source::<(ViewNodeId,String)>(); - view.set_visualization_data <+ update; + view.set_visualization_data <+ set_data; view.set_error_visualization_data <+ error_update; - view.disable_visualization <+ visualization_failure; + view.disable_visualization <+ visualization_failure._0(); + app.show_notification <+ visualization_failure._1(); eval_ view.visualization_registry_reload_requested (model.load_visualizations()); } Self { model, _network: network } - .spawn_visualization_handler(notifications, manager, update, visualization_failure) + .spawn_visualization_handler(notifications, manager, set_data, visualization_failure) .spawn_visualization_handler( error_notifications, error_manager, @@ -213,19 +217,25 @@ impl Visualization { self, notifier: impl Stream + Unpin + 'static, manager: Rc, - update_endpoint: frp::Source<(ViewNodeId, visualization_view::Data)>, - failure_endpoint: frp::Source, + set_data_endpoint: frp::Source<(ViewNodeId, visualization_view::Data)>, + failure_endpoint: frp::Source<(ViewNodeId, String)>, ) -> Self { let weak = Rc::downgrade(&self.model); spawn_stream_handler(weak, notifier, move |notification, model| { info!("Received update for visualization: {notification:?}"); match notification { manager::Notification::ValueUpdate { target, data, .. } => { - model.handle_value_update(&update_endpoint, target, data); + model.handle_value_update(&set_data_endpoint, target, data); } manager::Notification::FailedToAttach { visualization, error } => { error!("Visualization {} failed to attach: {error}.", visualization.id); - model.handle_controller_failure(&failure_endpoint, visualization.expression_id); + let message = + format!("Failed to open visualization because of an error: {error}"); + model.handle_controller_failure( + &failure_endpoint, + visualization.expression_id, + message, + ); } manager::Notification::FailedToDetach { visualization, error } => { error!("Visualization {} failed to detach: {error}.", visualization.id); @@ -245,11 +255,17 @@ impl Visualization { "Visualization {} failed to be modified: {error}. Will hide it in GUI.", desired.id ); + let message = + format!("Failed to modify visualization because of an error: {error}"); // Actually it would likely have more sense if we had just restored the previous // visualization, as its LS state should be preserved. However, we already // scrapped it on the GUI side and we don't even know its // path anymore. - model.handle_controller_failure(&failure_endpoint, desired.expression_id); + model.handle_controller_failure( + &failure_endpoint, + desired.expression_id, + message, + ); } } std::future::ready(()) diff --git a/app/gui/src/retry.rs b/app/gui/src/retry.rs new file mode 100644 index 000000000000..8e86b4cffde3 --- /dev/null +++ b/app/gui/src/retry.rs @@ -0,0 +1,185 @@ +//! A utilities used for retrying various asynchronous operations. + +use crate::prelude::*; + +use enso_web::sleep; +use std::time::Duration; + + + +// ======================= +// === retry_operation === +// ======================= + +// === RetryResult === + +/// A result of `retry_operation` method. +/// +/// Similar to [`Result`] it returns a vector of all failures and it has additional variant +/// `OkAfterRetries` returning both result of successful call and all errors. It can be cast to +/// `Result` or `Result` with the first error from the list. +#[derive(Clone, Debug, Eq, PartialEq)] +#[must_use] +pub enum RetryResult { + /// The operation was successful without any retries. + Ok(T), + /// The operation succeeded at some retry. + OkAfterRetries(T, NonEmptyVec), + /// The operation and all retries failed. + Err(NonEmptyVec), +} + +impl From> for Result> { + fn from(value: RetryResult) -> Self { + match value { + RetryResult::Ok(value) => Ok(value), + RetryResult::OkAfterRetries(value, _) => Ok(value), + RetryResult::Err(errors) => Err(errors), + } + } +} + +impl From> for Result { + fn from(value: RetryResult) -> Self { + let result_with_vec: Result> = value.into(); + result_with_vec.map_err(|errors| errors.take_first()) + } +} + + +// === retry_operation === + +/// Run asynchronous operation retrying if not successful. +/// +/// This function runs the `operation` and, if it returned [`Err`] wait some time and try again. +/// +/// The waiting times are specified by `retry_times` argument. If the iterator yield no more +/// element, no retry is performed anymore and [`RetryResult::Err`] is returned. +/// +/// If operation was successful only after some retry, [`RetryResult::OkAfterRetries`] is returned. +pub async fn retry_operation( + operation: impl FnMut() -> Operation, + retry_times: impl IntoIterator, + message_on_failure: &str, +) -> RetryResult +where + Operation: Future>, + E: Display, +{ + retry_operation_errors_cap(operation, retry_times, message_on_failure, usize::MAX).await +} + + +// === retry_operation_errors_cap === + +/// Similar to [`retry_operation`] but the number of errors is capped, preventing making huge +/// vectors in cases when, for example, we retry something indefinitely. +/// +/// If cap is reached, the earlier errors will be kept and later will be discarded. +pub async fn retry_operation_errors_cap( + mut operation: impl FnMut() -> Operation, + retry_times: impl IntoIterator, + message_on_failure: &str, + errors_cap: usize, +) -> RetryResult +where + Operation: Future>, + E: Display, +{ + let result = operation().await; + result.log_err(message_on_failure); + match result { + Ok(result) => RetryResult::Ok(result), + Err(first_error) => { + let mut errors = NonEmptyVec::singleton(first_error); + let mut retry_times = retry_times.into_iter(); + loop { + match retry_times.next() { + Some(time) => { + error!("Retrying after {} seconds", time.as_secs_f32()); + sleep(time).await; + } + None => break RetryResult::Err(errors), + }; + let retry = operation().await; + retry.log_err(message_on_failure); + match retry { + Ok(result) => break RetryResult::OkAfterRetries(result, errors), + Err(error) if errors.len() < errors_cap => errors.push(error), + Err(_) => {} + } + } + } + } +} + + + +// ============ +// === Test === +// ============ + +#[cfg(test)] +pub mod tests { + use super::*; + + #[test] + fn successful_operation() { + let operation = || async { Ok(4) }; + let retry_times = iter::repeat_with(|| panic!("Should not ask for retry time.")); + let mut future = + retry_operation(operation, retry_times, "Test operation failed. This cannot happen.") + .boxed_local(); + let result: RetryResult<_, usize> = future.expect_ready(); + assert_eq!(result, RetryResult::Ok(4)); + } + + #[test] + fn operation_successful_after_retry() { + let mut call_index = 0; + let operation = move || { + call_index += 1; + async move { + if call_index >= 3 { + Ok(call_index) + } else { + Err(call_index) + } + } + }; + let retry_times = [10, 20, 30, 40, 50].into_iter().map(Duration::from_millis); + let mut future = + retry_operation(operation, retry_times, "Test operation failed.").boxed_local(); + future.expect_pending(); + std::thread::sleep(Duration::from_millis(10)); + future.expect_pending(); + std::thread::sleep(Duration::from_millis(30)); + let result = future.expect_ready(); + assert_eq!(result, RetryResult::OkAfterRetries(3, NonEmptyVec::new(1, vec![2]))); + } + + #[test] + fn operation_always_failing() { + let mut call_index = 0; + let operation = move || { + call_index += 1; + async move { Err(call_index) } + }; + let retry_times = [10, 20, 30, 40, 50].into_iter().map(Duration::from_millis); + let mut future = + retry_operation(operation, retry_times, "One does not simply walk into Mordor.") + .boxed_local(); + future.expect_pending(); + std::thread::sleep(Duration::from_millis(10)); + future.expect_pending(); + std::thread::sleep(Duration::from_millis(20)); + future.expect_pending(); + std::thread::sleep(Duration::from_millis(30)); + future.expect_pending(); + std::thread::sleep(Duration::from_millis(40)); + future.expect_pending(); + std::thread::sleep(Duration::from_millis(60)); + let result: RetryResult = future.expect_ready(); + assert_eq!(result, RetryResult::Err(NonEmptyVec::new(1, vec![2, 3, 4, 5, 6]))); + } +} diff --git a/app/gui/suggestion-database/src/documentation_ir.rs b/app/gui/suggestion-database/src/documentation_ir.rs index 51ecbc89eb94..9a92d3d8559a 100644 --- a/app/gui/suggestion-database/src/documentation_ir.rs +++ b/app/gui/suggestion-database/src/documentation_ir.rs @@ -1,9 +1,21 @@ //! The intermediate representation of the entry's documentation. //! //! [`EntryDocumentation`] contains all the necessary information to generate HTML -//! documentation of the specific entry. [`EntryDocumentation`] is created by aggregating -//! documentation of the entry, and also its children entries, such as methods of the type or types -//! defined in the module. +//! documentation of the specific entry. +//! +//! When displaying the documentation for the user, we render the information contained in +//! [`EntryDocumentation`], and include hyperlinks to other related documentation pages. For +//! example, the type's documentation has a link to its parent module's documentation, and +//! to every method or constructor it has. These links are created by the +//! [`EntryDocumentation::linked_doc_pages`] method. +//! +//! We don't link modules to each other, but a type's documentation does link to its module. Since +//! we don't have a documentation registry, we must create a whole module's documentation for each +//! method entry, following the `method -> type -> module` link path. We can't create module +//! documentation on demand as the link handler doesn't have access to the suggestion database, and +//! we can't share module documentation between entries because it needs mutable state. We store the +//! suggestion database in memory, so this process is quick, but we might need to improve it in the +//! future. use crate::prelude::*; @@ -21,6 +33,17 @@ use std::cmp::Ordering; +// ============== +// === Errors === +// ============== + +#[allow(missing_docs)] +#[derive(Debug, Clone, Eq, Fail, PartialEq)] +#[fail(display = "Can't find parent module for entry {}.", _0)] +pub struct NoParentModule(String); + + + // ========================== // === EntryDocumentation === // ========================== @@ -42,28 +65,35 @@ impl Default for EntryDocumentation { } } +/// A link to the other documentation entry. It is used to connect documentation pages (for +/// example, the module documentation with every type's documentation). +#[derive(Debug)] +pub struct LinkedDocPage { + /// The name of the liked entry. It is used to produce a unique ID for the link. + pub name: Rc, + /// The intermediate reprentation of the linked entry's documentation. + pub page: EntryDocumentation, +} + impl EntryDocumentation { /// Constructor. - pub fn new(db: &SuggestionDatabase, id: &entry::Id) -> Result { + pub fn new(db: &SuggestionDatabase, id: &entry::Id) -> FallibleResult { let entry = db.lookup(*id); let result = match entry { Ok(entry) => match entry.kind { - Kind::Type => { - let type_docs = TypeDocumentation::new(*id, &entry, db)?.into(); - Documentation::Type(type_docs).into() - } + Kind::Type => Self::type_docs(db, &entry, *id)?, Kind::Module => { - let module_docs = ModuleDocumentation::new(*id, &entry, db)?.into(); + let module_docs = ModuleDocumentation::new(*id, &entry, db)?; Documentation::Module(module_docs).into() } Kind::Constructor => Self::constructor_docs(db, &entry)?, Kind::Method => Self::method_docs(db, &entry)?, Kind::Function => { - let function_docs = FunctionDocumentation::from_entry(&entry).into(); + let function_docs = Function::from_entry(&entry); Documentation::Function(function_docs).into() } Kind::Local => { - let local_docs = LocalDocumentation::from_entry(&entry).into(); + let local_docs = LocalDocumentation::from_entry(&entry); Documentation::Local(local_docs).into() } }, @@ -75,24 +105,129 @@ impl EntryDocumentation { Ok(result) } - /// Qualified name of the function-like entry. See [`Documentation::function_name`]. - pub fn function_name(&self) -> Option<&QualifiedName> { + /// Create documentation for a hard-coded builtin entry. + pub fn builtin(sections: impl IntoIterator) -> Self { + let sections = BuiltinDocumentation::from_doc_sections(sections.into_iter()); + Self::Docs(Documentation::Builtin(sections)) + } + + /// The list of links displayed on the documentation page. + pub fn linked_doc_pages(&self) -> Vec { match self { - EntryDocumentation::Docs(docs) => docs.function_name(), - _ => None, + EntryDocumentation::Docs(docs) => match docs { + // Module documentation contains links to all methods and types defined in this + // module. + Documentation::Module(docs) => { + let methods = docs.methods.iter().map(|method| LinkedDocPage { + name: method.name.clone_ref(), + page: Documentation::ModuleMethod { + docs: method.clone_ref(), + module_docs: docs.clone_ref(), + } + .into(), + }); + let types = docs.types.iter().map(|type_| LinkedDocPage { + name: type_.name.clone_ref(), + page: Documentation::Type { + docs: type_.clone_ref(), + module_docs: docs.clone_ref(), + } + .into(), + }); + methods.chain(types).collect() + } + // Type documentation contains links to all constructors and methods of this type, + // and also a link to the parent module. + Documentation::Type { docs, module_docs } => { + let methods = docs.methods.iter().map(|method| LinkedDocPage { + name: method.name.clone_ref(), + page: Documentation::Method { + docs: method.clone_ref(), + type_docs: docs.clone_ref(), + module_docs: module_docs.clone_ref(), + } + .into(), + }); + let constructors = docs.constructors.iter().map(|constructor| LinkedDocPage { + name: constructor.name.clone_ref(), + page: Documentation::Constructor { + docs: constructor.clone_ref(), + type_docs: docs.clone_ref(), + module_docs: module_docs.clone_ref(), + } + .into(), + }); + let parent_module = LinkedDocPage { + name: module_docs.name.clone_ref(), + page: Documentation::Module(module_docs.clone_ref()).into(), + }; + methods.chain(constructors).chain(iter::once(parent_module)).collect() + } + // Constructor documentation contains a link to the type. We also need to provide a + // module documentation here, because the type documentation has a link to the + // module documentation. + Documentation::Constructor { type_docs, module_docs, .. } => vec![LinkedDocPage { + name: type_docs.name.clone_ref(), + page: Documentation::Type { + docs: type_docs.clone_ref(), + module_docs: module_docs.clone_ref(), + } + .into(), + }], + // Method documentation contains a link to the type. We also need to provide a + // module documentation here, because the type documentation has a link to the + // module documentation. + Documentation::Method { type_docs, module_docs, .. } => vec![LinkedDocPage { + name: type_docs.name.clone_ref(), + page: Documentation::Type { + docs: type_docs.clone_ref(), + module_docs: module_docs.clone_ref(), + } + .into(), + }], + // Module method documentation contains a link to the module. + Documentation::ModuleMethod { module_docs, .. } => vec![LinkedDocPage { + name: module_docs.name.clone_ref(), + page: Documentation::Module(module_docs.clone_ref()).into(), + }], + Documentation::Function(_) => default(), + Documentation::Local(_) => default(), + Documentation::Builtin(_) => default(), + }, + EntryDocumentation::Placeholder(_) => default(), } } - /// Create documentation for a hard-coded builtin entry. - pub fn builtin(sections: impl IntoIterator) -> Self { - let sections = Rc::new(BuiltinDocumentation::from_doc_sections(sections.into_iter())); - Self::Docs(Documentation::Builtin(sections)) + fn parent_module( + db: &SuggestionDatabase, + entry: &Entry, + ) -> Result { + let defined_in = &entry.defined_in; + let parent_module = db.lookup_by_qualified_name(defined_in); + match parent_module { + Some((id, parent)) => match parent.kind { + Kind::Module => Ok(ModuleDocumentation::new(id, &parent, db) + .map_err(|_| NoParentModule(entry.qualified_name().to_string()))?), + _ => Err(NoParentModule(entry.qualified_name().to_string())), + }, + None => { + error!("Parent module for entry {} not found.", entry.qualified_name()); + Err(NoParentModule(entry.qualified_name().to_string())) + } + } } - fn method_docs( + fn type_docs( db: &SuggestionDatabase, entry: &Entry, - ) -> Result { + entry_id: entry::Id, + ) -> FallibleResult { + let module_docs = Self::parent_module(db, entry)?; + let type_docs = TypeDocumentation::new(entry_id, entry, db)?; + Ok(Documentation::Type { docs: type_docs, module_docs }.into()) + } + + fn method_docs(db: &SuggestionDatabase, entry: &Entry) -> FallibleResult { let self_type = match &entry.self_type { Some(self_type) => self_type, None => { @@ -100,26 +235,25 @@ impl EntryDocumentation { return Ok(Placeholder::NoDocumentation.into()); } }; - let return_type = db.lookup_by_qualified_name(self_type); - match return_type { - Some((id, parent)) => { - let name = entry.qualified_name().into(); - match parent.kind { - Kind::Type => { - let type_docs = TypeDocumentation::new(id, &parent, db)?.into(); - Ok(Documentation::Method { name, type_docs }.into()) - } - Kind::Module => { - let module_docs = ModuleDocumentation::new(id, &parent, db)?; - let module_docs = module_docs.into(); - Ok(Documentation::ModuleMethod { name, module_docs }.into()) - } - _ => { - error!("Unexpected parent kind for method {}.", entry.qualified_name()); - Ok(Placeholder::NoDocumentation.into()) - } + let self_type = db.lookup_by_qualified_name(self_type); + match self_type { + Some((id, parent)) => match parent.kind { + Kind::Type => { + let docs = Function::from_entry(entry); + let type_docs = TypeDocumentation::new(id, &parent, db)?; + let module_docs = Self::parent_module(db, &parent)?; + Ok(Documentation::Method { docs, type_docs, module_docs }.into()) } - } + Kind::Module => { + let docs = Function::from_entry(entry); + let module_docs = ModuleDocumentation::new(id, &parent, db)?; + Ok(Documentation::ModuleMethod { docs, module_docs }.into()) + } + _ => { + error!("Unexpected parent kind for method {}.", entry.qualified_name()); + Ok(Placeholder::NoDocumentation.into()) + } + }, None => { error!("Parent entry for method {} not found.", entry.qualified_name()); Ok(Self::Placeholder(Placeholder::NoDocumentation)) @@ -130,15 +264,16 @@ impl EntryDocumentation { fn constructor_docs( db: &SuggestionDatabase, entry: &Entry, - ) -> Result { + ) -> FallibleResult { let return_type = &entry.return_type; let return_type = db.lookup_by_qualified_name(return_type); match return_type { Some((id, parent)) => { - let name = entry.qualified_name().into(); - let type_docs = TypeDocumentation::new(id, &parent, db)?.into(); - Ok(Documentation::Constructor { name, type_docs }.into()) + let docs = Function::from_entry(entry); + let type_docs = TypeDocumentation::new(id, &parent, db)?; + let module_docs = Self::parent_module(db, &parent)?; + Ok(Documentation::Constructor { docs, type_docs, module_docs }.into()) } None => { error!("No return type found for constructor {}.", entry.qualified_name()); @@ -166,31 +301,32 @@ pub enum Placeholder { #[derive(Debug, Clone, CloneRef, PartialEq)] #[allow(missing_docs)] pub enum Documentation { - Module(Rc), - Type(Rc), - Constructor { name: Rc, type_docs: Rc }, - Method { name: Rc, type_docs: Rc }, - ModuleMethod { name: Rc, module_docs: Rc }, - Function(Rc), - Local(Rc), - Builtin(Rc), -} - -impl Documentation { - /// Qualified name of the documented function. Functions are part of the documentation for - /// the larger entity, e.g., constructor documentation is embedded into the type - /// documentation. The returned qualified name is used to scroll to the corresponding section in - /// a larger documentation page. - pub fn function_name(&self) -> Option<&QualifiedName> { - match self { - Documentation::Constructor { name, .. } => Some(name), - Documentation::Method { name, .. } => Some(name), - Documentation::ModuleMethod { name, .. } => Some(name), - _ => None, - } - } + Module(ModuleDocumentation), + Type { + docs: TypeDocumentation, + module_docs: ModuleDocumentation, + }, + Constructor { + docs: Function, + type_docs: TypeDocumentation, + module_docs: ModuleDocumentation, + }, + Method { + docs: Function, + type_docs: TypeDocumentation, + module_docs: ModuleDocumentation, + }, + ModuleMethod { + docs: Function, + module_docs: ModuleDocumentation, + }, + Function(Function), + Local(LocalDocumentation), + Builtin(BuiltinDocumentation), } + + // ========================= // === TypeDocumentation === // ========================= @@ -242,7 +378,7 @@ impl TypeDocumentation { // =========================== /// Documentation of the [`EntryKind::Module`] entries. -#[derive(Debug, Clone, CloneRef, PartialEq)] +#[derive(Debug, Clone, CloneRef, PartialEq, Eq)] #[allow(missing_docs)] pub struct ModuleDocumentation { pub name: Rc, @@ -271,38 +407,6 @@ impl ModuleDocumentation { -// ============================= -// === FunctionDocumentation === -// ============================= - -/// Documentation of the [`EntryKind::Function`] entries. -#[derive(Debug, Clone, CloneRef, PartialEq)] -#[allow(missing_docs)] -pub struct FunctionDocumentation { - pub name: Rc, - pub arguments: Rc>, - pub tags: Tags, - pub synopsis: Synopsis, - pub examples: Examples, -} - -impl FunctionDocumentation { - /// Constructor. - pub fn from_entry(entry: &Entry) -> Self { - let FilteredDocSections { tags, synopsis, examples } = - FilteredDocSections::new(entry.documentation.iter()); - Self { - name: entry.qualified_name().into(), - arguments: entry.arguments.clone().into(), - tags, - synopsis, - examples, - } - } -} - - - // ========================== // === LocalDocumentation === // ========================== @@ -422,7 +526,7 @@ impl Synopsis { // ============= /// A list of types defined in the module. -#[derive(Debug, Clone, CloneRef, PartialEq, Default, Deref)] +#[derive(Debug, Clone, CloneRef, PartialEq, Eq, Default, Deref)] pub struct Types { list: SortedVec, } @@ -663,8 +767,9 @@ mod tests { fn test_documentation_of_constructor() { let db = mock_db(); let name = Rc::new(QualifiedName::from_text("Standard.Base.A.Foo").unwrap()); - let type_docs = a_type().into(); - let expected = Documentation::Constructor { name: name.clone(), type_docs }; + let type_docs = a_type(); + let docs = a_foo_constructor(); + let expected = Documentation::Constructor { docs, type_docs, module_docs: module_docs() }; assert_docs(&db, name, expected); } @@ -676,23 +781,26 @@ mod tests { // === Type method === let name = Rc::new(QualifiedName::from_text("Standard.Base.A.baz").unwrap()); - let type_docs = a_type().into(); - let expected = Documentation::Method { name: name.clone(), type_docs }; + let type_docs = a_type(); + let docs = a_baz_method(); + let expected = + Documentation::Method { docs, type_docs, module_docs: module_docs().clone_ref() }; assert_docs(&db, name, expected); // === Module method === let name = Rc::new(QualifiedName::from_text("Standard.Base.module_method").unwrap()); - let module_docs = module_docs().into(); - let expected = Documentation::ModuleMethod { name: name.clone(), module_docs }; + let module_docs = module_docs(); + let docs = module_method_function(); + let expected = Documentation::ModuleMethod { docs, module_docs }; assert_docs(&db, name, expected); } #[test] fn test_documentation_of_module() { let db = mock_db(); - let expected = Documentation::Module(Rc::new(module_docs())); + let expected = Documentation::Module(module_docs()); let name = Rc::new(QualifiedName::from_text("Standard.Base").unwrap()); assert_docs(&db, name, expected); } @@ -703,7 +811,7 @@ mod tests { // === Type Standard.Base.A === - let expected = Documentation::Type(Rc::new(a_type())); + let expected = Documentation::Type { docs: a_type(), module_docs: module_docs() }; let name = QualifiedName::from_text("Standard.Base.A").unwrap(); let (entry_id, _) = db.lookup_by_qualified_name(&name).unwrap(); let docs = EntryDocumentation::new(&db, &entry_id).unwrap(); @@ -711,7 +819,7 @@ mod tests { // === Type Standard.Base.B === - let expected = Documentation::Type(Rc::new(b_type())); + let expected = Documentation::Type { docs: b_type(), module_docs: module_docs() }; let name = Rc::new(QualifiedName::from_text("Standard.Base.B").unwrap()); assert_docs(&db, name, expected); } diff --git a/app/gui/tests/language_server.rs b/app/gui/tests/language_server.rs index a19e31e7ae40..74e04c960231 100644 --- a/app/gui/tests/language_server.rs +++ b/app/gui/tests/language_server.rs @@ -95,7 +95,7 @@ async fn ls_text_protocol_test() { response.expect("Couldn't write yaml file."); // Setting execution context. - let execution_context = client.create_execution_context().await; + let execution_context = client.create_execution_context(&Uuid::new_v4()).await; let execution_context = execution_context.expect("Couldn't create execution context."); let execution_context_id = execution_context.context_id; @@ -356,7 +356,10 @@ async fn binary_visualization_updates_test_hlp() { let module_qualified_name = project.qualified_module_name(&module_path); let module = project.module(module_path).await.unwrap(); info!("Got module: {module:?}"); - let graph_executed = controller::ExecutedGraph::new(project, method).await.unwrap(); + let context_id = Uuid::new_v4(); + let execution_ctx = project.create_execution_context(method.clone(), context_id).await.unwrap(); + let graph = controller::Graph::new_method(&project, &method).await.unwrap(); + let graph_executed = controller::ExecutedGraph::new_internal(graph, project, execution_ctx); let the_node = graph_executed.graph().nodes().unwrap()[0].info.clone(); graph_executed.graph().set_expression(the_node.id(), "10+20").unwrap(); diff --git a/app/gui/view/documentation/assets/input.css b/app/gui/view/documentation/assets/input.css index 12f860034456..0162fe7b8e3e 100644 --- a/app/gui/view/documentation/assets/input.css +++ b/app/gui/view/documentation/assets/input.css @@ -17,3 +17,7 @@ ul { list-style-type: disc; list-style-position: inside; } + +svg { + pointer-events: none; +} diff --git a/app/gui/view/documentation/src/html.rs b/app/gui/view/documentation/src/html.rs index 802a7c3a1416..0efb7b1c2733 100644 --- a/app/gui/view/documentation/src/html.rs +++ b/app/gui/view/documentation/src/html.rs @@ -14,7 +14,6 @@ use enso_suggestion_database::documentation_ir::Documentation; use enso_suggestion_database::documentation_ir::EntryDocumentation; use enso_suggestion_database::documentation_ir::Examples; use enso_suggestion_database::documentation_ir::Function; -use enso_suggestion_database::documentation_ir::FunctionDocumentation; use enso_suggestion_database::documentation_ir::LocalDocumentation; use enso_suggestion_database::documentation_ir::ModuleDocumentation; use enso_suggestion_database::documentation_ir::Placeholder; @@ -62,14 +61,14 @@ fn svg_icon(content: &'static str) -> impl Render { /// Render entry documentation to HTML code with Tailwind CSS styles. #[profile(Detail)] -pub fn render(docs: EntryDocumentation) -> String { +pub fn render(docs: &EntryDocumentation) -> String { let html = match docs { EntryDocumentation::Placeholder(placeholder) => match placeholder { Placeholder::NoDocumentation => String::from("No documentation available."), Placeholder::VirtualComponentGroup { name } => - render_virtual_component_group_docs(name), + render_virtual_component_group_docs(name.clone_ref()), }, - EntryDocumentation::Docs(docs) => render_documentation(docs), + EntryDocumentation::Docs(docs) => render_documentation(docs.clone_ref()), }; match validate_utf8(&html) { Ok(_) => html, @@ -87,17 +86,33 @@ fn validate_utf8(s: &str) -> Result<&str, std::str::Utf8Error> { } fn render_documentation(docs: Documentation) -> String { + let back_link = match &docs { + Documentation::Constructor { type_docs, .. } => Some(BackLink { + displayed: type_docs.name.name().to_owned(), + id: anchor_name(&type_docs.name), + }), + Documentation::Method { type_docs, .. } => Some(BackLink { + displayed: type_docs.name.name().to_owned(), + id: anchor_name(&type_docs.name), + }), + Documentation::ModuleMethod { module_docs, .. } => Some(BackLink { + displayed: module_docs.name.name().to_owned(), + id: anchor_name(&module_docs.name), + }), + Documentation::Type { module_docs, .. } => Some(BackLink { + displayed: module_docs.name.name().to_owned(), + id: anchor_name(&module_docs.name), + }), + _ => None, + }; match docs { - Documentation::Module(module_docs) => render_module_documentation(&module_docs, None), - Documentation::Type(type_docs) => render_type_documentation(&type_docs, None), - Documentation::Function(docs) => render_function_documentation(&docs), + Documentation::Module(module_docs) => render_module_documentation(&module_docs), + Documentation::Type { docs, .. } => render_type_documentation(&docs, back_link), + Documentation::Function(docs) => render_function_documentation(&docs, back_link), Documentation::Local(docs) => render_local_documentation(&docs), - Documentation::Constructor { type_docs, name } => - render_type_documentation(&type_docs, Some(&name)), - Documentation::Method { type_docs, name } => - render_type_documentation(&type_docs, Some(&name)), - Documentation::ModuleMethod { module_docs, name } => - render_module_documentation(&module_docs, Some(&name)), + Documentation::Constructor { docs, .. } => render_function_documentation(&docs, back_link), + Documentation::Method { docs, .. } => render_function_documentation(&docs, back_link), + Documentation::ModuleMethod { docs, .. } => render_function_documentation(&docs, back_link), Documentation::Builtin(builtin_docs) => render_builtin_documentation(&builtin_docs), } } @@ -112,6 +127,17 @@ fn render_virtual_component_group_docs(name: ImString) -> String { docs_content(content).into_string().unwrap() } +/// An optional link to the parent entry (module or type), that is displayed in the documentation +/// header. Pressing this link will switch the documentation to the parent entry, allowing +/// bidirectional navigation. +#[derive(Debug, Clone)] +struct BackLink { + /// Displayed text. + displayed: String, + /// The unique ID of the link. + id: String, +} + // === Types === @@ -122,23 +148,20 @@ fn render_virtual_component_group_docs(name: ImString) -> String { /// - Synopsis and a list of constructors. /// - Methods. /// - Examples. -fn render_type_documentation( - docs: &TypeDocumentation, - function_name: Option<&QualifiedName>, -) -> String { +fn render_type_documentation(docs: &TypeDocumentation, back_link: Option) -> String { let methods_exist = !docs.methods.is_empty(); let examples_exist = !docs.examples.is_empty(); let name = &docs.name; let arguments = &docs.arguments; let synopsis = &docs.synopsis; let constructors = &docs.constructors; - let synopsis = section_content(type_synopsis(synopsis, constructors, function_name)); - let methods = section_content(list_of_functions(&docs.methods, function_name)); + let synopsis = section_content(type_synopsis(synopsis, constructors)); + let methods = section_content(list_of_functions(&docs.methods)); let examples = section_content(list_of_examples(&docs.examples)); let tags = section_content(list_of_tags(&docs.tags)); let content = owned_html! { - : header(ICON_TYPE, type_header(name.name(), arguments_list(arguments))); + : header(ICON_TYPE, type_header(name.name(), arguments_list(arguments), back_link.as_ref())); : &tags; : &synopsis; @ if methods_exist { @@ -154,8 +177,18 @@ fn render_type_documentation( } /// A header for the type documentation. -fn type_header<'a>(name: &'a str, arguments: impl Render + 'a) -> Box { +fn type_header<'a>( + name: &'a str, + arguments: impl Render + 'a, + back_link: Option<&'a BackLink>, +) -> Box { box_html! { + @ if let Some(BackLink { id, displayed }) = &back_link { + a(id=id, class="text-2xl font-bold text-typeName hover:underline cursor-pointer") { + : displayed; + } + : " :: "; + } span(class="text-2xl font-bold text-typeName") { span { : name } span(class="opacity-34") { : &arguments } @@ -176,7 +209,6 @@ fn methods_header() -> impl Render { fn type_synopsis<'a>( synopsis: &'a Synopsis, constructors: &'a Constructors, - function_name: Option<&'a QualifiedName>, ) -> Box { box_html! { @ for p in synopsis.iter() { @@ -189,86 +221,65 @@ fn type_synopsis<'a>( } ul(class="list-disc list-outside marker:text-typeName") { @ for method in constructors.iter() { - : single_constructor(method, function_name); + : single_constructor(method); } } } } /// A documentation for a single constructor in the list. -fn single_constructor<'a>( - method: &'a Function, - function_name: Option<&'a QualifiedName>, -) -> Box { - let highlight = function_name.map(|n| n == &*method.name).unwrap_or(false); +/// If the first [`DocSection`] is of type [`DocSection::Paragraph`], it is rendered on the first +/// line, after the list of arguments. +fn single_constructor<'a>(constructor: &'a Function) -> Box { + let first = match &constructor.synopsis.as_ref()[..] { + [DocSection::Paragraph { body }, ..] => Some(body), + _ => None, + }; box_html! { - li(id=anchor_name(&method.name)) { - span(class=labels!("text-typeName", "font-bold", "bg-yellow-100" => highlight)) { + li(id=anchor_name(&constructor.name), class="hover:underline cursor-pointer") { + span(class=labels!("text-typeName", "font-bold")) { span(class="opacity-85") { - : method.name.name(); + : constructor.name.name(); } - span(class="opacity-34") { : arguments_list(&method.arguments); } + span(class="opacity-34") { : arguments_list(&constructor.arguments); } + } + @ if let Some(first) = first { + span { : ", "; : Raw(first); } } - : function_docs(method); } } } /// A list of methods defined for the type. -fn list_of_functions<'a>( - functions: &'a [Function], - function_name: Option<&'a QualifiedName>, -) -> Box { +fn list_of_functions<'a>(functions: &'a [Function]) -> Box { box_html! { ul(class="list-disc list-inside") { @ for f in functions.iter() { - : single_function(f, function_name); + : single_function(f); } } } } /// A documentation for a single method in the list. -fn single_function<'a>( - function: &'a Function, - function_name: Option<&'a QualifiedName>, -) -> Box { - let highlight = function_name.map(|n| n == &*function.name).unwrap_or(false); +/// If the first [`DocSection`] is of type [`DocSection::Paragraph`], it is rendered on the first +/// line, after the list of arguments. +fn single_function<'a>(function: &'a Function) -> Box { + let first = match &function.synopsis.as_ref()[..] { + [DocSection::Paragraph { body }, ..] => Some(body), + _ => None, + }; box_html! { - li(id=anchor_name(&function.name)) { - span(class=labels!("text-methodName", "font-semibold", "bg-yellow-100" => highlight)) { + li(id=anchor_name(&function.name), class="hover:underline cursor-pointer") { + span(class=labels!("text-methodName", "font-semibold")) { span(class="opacity-85") { : function.name.name(); } span(class="opacity-34") { : arguments_list(&function.arguments); } } - : function_docs(function); - } - } -} - -/// Synopsis of a function. If the first [`DocSection`] is of type -/// [`DocSection::Paragraph`], it is rendered on the first line, after the list of arguments. All -/// other sections are rendered as separate paragraphs below. Examples for the function are rendered -/// below the main part of the documentation in a separate subsection. -fn function_docs<'a>(constructor: &'a Function) -> Box { - let (first, rest) = match &constructor.synopsis.as_ref()[..] { - [DocSection::Paragraph { body }, rest @ ..] => (Some(body), rest), - [_, rest @ ..] => (None, rest), - [] => (None, default()), - }; - let tags = list_of_tags(&constructor.tags); - box_html! { - @ if let Some(first) = first { - span { : ", "; : Raw(first); } - } - : &tags; - @ for p in rest { - : paragraph(p); - } - @ if !constructor.examples.is_empty() { - h2(class="font-semibold") { : "Examples" } - : list_of_examples(&constructor.examples); + @ if let Some(first) = first { + span { : ", "; : Raw(first); } + } } } } @@ -284,17 +295,14 @@ fn function_docs<'a>(constructor: &'a Function) -> Box { /// - Types. /// - Functions. /// - Examples. -fn render_module_documentation( - docs: &ModuleDocumentation, - function_name: Option<&QualifiedName>, -) -> String { +fn render_module_documentation(docs: &ModuleDocumentation) -> String { let types_exist = !docs.types.is_empty(); let methods_exist = !docs.methods.is_empty(); let examples_exist = !docs.examples.is_empty(); let name = &docs.name; let synopsis = section_content(module_synopsis(&docs.synopsis)); let types = section_content(list_of_types(&docs.types)); - let methods = section_content(list_of_functions(&docs.methods, function_name)); + let methods = section_content(list_of_functions(&docs.methods)); let examples = section_content(list_of_examples(&docs.examples)); let tags = section_content(list_of_tags(&docs.tags)); let content = owned_html! { @@ -331,7 +339,7 @@ fn list_of_types<'a>(types: &'a Types) -> Box { /// A single type in the list. fn single_type<'a>(type_: &'a TypeDocumentation) -> Box { box_html! { - li(id=anchor_name(&type_.name), class="text-typeName font-semibold") { + li(id=anchor_name(&type_.name), class="text-typeName font-semibold hover:underline cursor-pointer") { span(class="opacity-85") { : type_.name.name(); } @@ -402,15 +410,15 @@ fn module_synopsis<'a>(synopsis: &'a Synopsis) -> Box { // === Functions === /// Render documentation of a function. -fn render_function_documentation(docs: &FunctionDocumentation) -> String { - let FunctionDocumentation { name, arguments, synopsis, tags, .. } = docs; +fn render_function_documentation(docs: &Function, back_link: Option) -> String { + let Function { name, arguments, synopsis, tags, .. } = docs; let examples_exist = !docs.examples.is_empty(); let synopsis = section_content(function_synopsis(synopsis)); let tags = section_content(list_of_tags(tags)); let examples = section_content(list_of_examples(&docs.examples)); let content = owned_html! { - : header(ICON_TYPE, function_header(name.name(), arguments_list(arguments))); + : header(ICON_TYPE, function_header(name.name(), arguments_list(arguments), back_link.as_ref())); : &tags; : &synopsis; @ if examples_exist { @@ -422,8 +430,18 @@ fn render_function_documentation(docs: &FunctionDocumentation) -> String { } /// A header for the function documentation. -fn function_header<'a>(name: &'a str, arguments: impl Render + 'a) -> Box { +fn function_header<'a>( + name: &'a str, + arguments: impl Render + 'a, + back_link: Option<&'a BackLink>, +) -> Box { box_html! { + @ if let Some(BackLink { id, displayed }) = &back_link { + a(id=id, class="text-2xl font-bold text-typeName hover:underline cursor-pointer") { + : displayed; + } + : " :: "; + } span(class="text-2xl font-bold text-typeName") { span { : name } span(class="opacity-34") { : &arguments } diff --git a/app/gui/view/documentation/src/lib.rs b/app/gui/view/documentation/src/lib.rs index 33799f27acc2..75a44d8c84ac 100644 --- a/app/gui/view/documentation/src/lib.rs +++ b/app/gui/view/documentation/src/lib.rs @@ -40,6 +40,7 @@ use ensogl::system::web::traits::*; use enso_frp as frp; use enso_suggestion_database::documentation_ir::EntryDocumentation; +use enso_suggestion_database::documentation_ir::LinkedDocPage; use ensogl::animation::physics::inertia::Spring; use ensogl::application::Application; use ensogl::data::color; @@ -55,8 +56,6 @@ use ensogl_derive_theme::FromTheme; use ensogl_hardcoded_theme::application::component_browser::documentation as theme; use graph_editor::component::visualization; use ide_view_graph_editor as graph_editor; -use web::HtmlElement; -use web::JsCast; // ============== @@ -110,6 +109,7 @@ pub struct Model { /// to EnsoGL shapes, and pass them to the DOM instead. overlay: overlay::View, display_object: display::object::Instance, + event_handlers: Rc>>, } impl Model { @@ -150,7 +150,15 @@ impl Model { scene.dom.layers.node_searcher.manage(&inner_dom); scene.dom.layers.node_searcher.manage(&caption_dom); - Model { outer_dom, inner_dom, caption_dom, overlay, display_object }.init() + Model { + outer_dom, + inner_dom, + caption_dom, + overlay, + display_object, + event_handlers: default(), + } + .init() } fn init(self) -> Self { @@ -173,18 +181,34 @@ impl Model { } /// Display the documentation and scroll to the qualified name if needed. - fn display_doc(&self, docs: EntryDocumentation) { - let anchor = docs.function_name().map(html::anchor_name); - let html = html::render(docs); + fn display_doc(&self, docs: EntryDocumentation, display_doc: &frp::Source) { + let linked_pages = docs.linked_doc_pages(); + let html = html::render(&docs); self.inner_dom.dom().set_inner_html(&html); - if let Some(anchor) = anchor { + self.set_link_handlers(linked_pages, display_doc); + // Scroll to the top of the page. + self.inner_dom.dom().set_scroll_top(0); + } + + /// Setup event handlers for links on the documentation page. + fn set_link_handlers( + &self, + linked_pages: Vec, + display_doc: &frp::Source, + ) { + let new_handlers = linked_pages.into_iter().filter_map(|page| { + let content = page.page.clone_ref(); + let anchor = html::anchor_name(&page.name); if let Some(element) = web::document.get_element_by_id(&anchor) { - let offset = element.dyn_ref::().map(|e| e.offset_top()).unwrap_or(0); - self.inner_dom.dom().set_scroll_top(offset); + let closure: web::JsEventHandler = web::Closure::new(f_!([display_doc, content] { + display_doc.emit(content.clone_ref()); + })); + Some(web::add_event_listener(&element, "click", closure)) + } else { + None } - } else { - self.inner_dom.dom().set_scroll_top(0); - } + }); + let _ = self.event_handlers.replace(new_handlers.collect()); } /// Load an HTML file into the documentation view when user is waiting for data to be received. @@ -300,7 +324,11 @@ impl View { docs <+ frp.display_documentation; display_delay.restart <+ frp.display_documentation.constant(DISPLAY_DELAY_MS); display_docs <- display_delay.on_expired.map2(&docs,|_,docs| docs.clone_ref()); - eval display_docs((docs) model.display_doc(docs.clone_ref())); + display_docs_callback <- source(); + display_docs <- any(&display_docs, &display_docs_callback); + eval display_docs([model, display_docs_callback] + (docs) model.display_doc(docs.clone_ref(), &display_docs_callback) + ); // === Hovered item preview caption === diff --git a/app/gui/view/examples/Cargo.toml b/app/gui/view/examples/Cargo.toml index 0eae83f4486c..40fc8e029243 100644 --- a/app/gui/view/examples/Cargo.toml +++ b/app/gui/view/examples/Cargo.toml @@ -10,6 +10,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] debug-scene-component-list-panel-view = { path = "component-list-panel-view" } debug-scene-documentation = { path = "documentation" } +debug-scene-graph-editor-edges = { path = "graph-editor-edges" } debug-scene-icons = { path = "icons" } debug-scene-interface = { path = "interface" } debug-scene-text-grid-visualization = { path = "text-grid-visualization" } diff --git a/app/gui/view/examples/graph-editor-edges/Cargo.toml b/app/gui/view/examples/graph-editor-edges/Cargo.toml new file mode 100644 index 000000000000..7f440d6fba4d --- /dev/null +++ b/app/gui/view/examples/graph-editor-edges/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "debug-scene-graph-editor-edges" +version = "0.1.0" +authors = ["Enso Team "] +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +enso-frp = { path = "../../../../../lib/rust/frp" } +ensogl = { path = "../../../../../lib/rust/ensogl" } +ensogl-hardcoded-theme = { path = "../../../../../lib/rust/ensogl/app/theme/hardcoded" } +ide-view-graph-editor = { path = "../../graph-editor" } +nalgebra = { workspace = true } +wasm-bindgen = { workspace = true } + +# Stop wasm-pack from running wasm-opt, because we run it from our build scripts in order to customize options. +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/app/gui/view/examples/graph-editor-edges/src/lib.rs b/app/gui/view/examples/graph-editor-edges/src/lib.rs new file mode 100644 index 000000000000..4011d326fcd0 --- /dev/null +++ b/app/gui/view/examples/graph-editor-edges/src/lib.rs @@ -0,0 +1,139 @@ +//! Example scene showing a graph editor edge. +//! - Click-and-drag the source and target node-like objects to see the computed edge shape. +//! - Hover over the edge to see the focused portion. + +#![recursion_limit = "256"] +// === Standard Linter Configuration === +#![deny(non_ascii_idents)] +#![warn(missing_copy_implementations)] +#![warn(missing_debug_implementations)] +#![warn(missing_docs)] +#![warn(trivial_casts)] +#![warn(trivial_numeric_casts)] +#![warn(unsafe_code)] +#![warn(unused_import_braces)] +#![warn(unused_qualifications)] + +use ensogl::display::shape::*; +use ensogl::display::traits::*; +use ensogl::prelude::*; + +use ensogl::application::Application; +use ensogl::control::io::mouse; +use ensogl::data::color; +use ensogl::display; +use ensogl::display::navigation::navigator::Navigator; +use ensogl::display::Scene; +use ensogl::frp; +use ensogl::system::web; +use ide_view_graph_editor::component::edge::Edge; +use ide_view_graph_editor::component::node; + + + +// ================= +// === Constants === +// ================= + +/// Location of the center of the source node-like object, in screen coordinates. +const SOURCE_CENTER: Vector2 = Vector2(0.0, 0.0); +/// Width of the example source node-like object, in pixels. +const ARBITRARY_NODE_WIDTH: f32 = 100.0; +/// The initial value, in pixels, of the offset between the center of the source and center of the +/// target. +const INITIAL_TARGET_OFFSET: Vector2 = Vector2(150.0, -200.0); + + + +// =================== +// === Entry Point === +// =================== + +/// The example entry point. +#[entry_point(graph_editor_edges)] +pub fn main() { + let app = Application::new("root"); + + let world = &app.display; + let scene = &world.default_scene; + let navigator = Navigator::new(scene, &scene.layers.node_searcher.camera()); + let greenish = color::Lcha(0.5, 0.5, 0.5, 1.0); + let lowest_layer = &scene.layers.viz; + + let source = Rectangle::new(); + let source_center = SOURCE_CENTER; + let source_size = Vector2(ARBITRARY_NODE_WIDTH, node::HEIGHT); + source.set_size(source_size); + source.set_color(color::Rgba(0.75, 0.75, 0.75, 1.0)); + source.set_border_color(color::Rgba::transparent()); + source.set_corner_radius_max(); + source.set_xy(source_center - source_size / 2.0); + lowest_layer.add(&source); + world.add_child(&source); + let source_moved = make_draggable(scene, &source); + + let target = Rectangle::new(); + let target_center = source_center + INITIAL_TARGET_OFFSET; + let target_size = Vector2(node::HEIGHT, node::HEIGHT); + target.set_size(target_size); + target.set_color(color::Rgba(0.75, 0.75, 0.75, 1.0)); + target.set_border_color(color::Rgba::transparent()); + target.set_corner_radius_max(); + target.set_xy(target_center - target_size / 2.0); + lowest_layer.add(&target); + world.add_child(&target); + let target_moved = make_draggable(scene, &target); + + let edge = Edge::new(&app); + edge.set_disabled(false); + edge.set_color(greenish); + edge.source_size(source_size); + edge.target_attached(true); + edge.source_attached(true); + world.add_child(&edge); + + let network = edge.network(); + let target_ = target.display_object().clone_ref(); + let source_ = source.display_object().clone_ref(); + frp::extend! { network + init <- source_(); + _eval <- all_with(&target_moved, &init, f!([edge, target_] (_, _) { + edge.target_position(target_.xy() + target_size / 2.0) + })); + _eval <- all_with(&source_moved, &init, f!([edge, source_] (_, _) { + edge.set_xy(source_.xy() + source_size / 2.0) + })); + } + init.emit(()); + + web::document + .get_element_by_id("loader") + .map(|t| t.parent_node().map(|p| p.remove_child(&t).unwrap())); + + mem::forget(app); + mem::forget(source); + mem::forget(target); + mem::forget(edge); + mem::forget(navigator); +} + +fn make_draggable(scene: &Scene, object: impl display::Object) -> frp::Stream<()> { + let object = object.display_object(); + let mouse_down = object.on_event::(); + let mouse_move = object.on_event::(); + let mouse_up = scene.on_event::(); + let network = &object.network; + frp::extend! { network + on_drag <- source::<()>(); + drag_offset <- any(...); + drag_offset <+ mouse_down.map(f!([object] (e) Some(object.xy() - e.client_centered()))); + drag_offset <+ mouse_up.constant(None); + _eval <- mouse_move.map2(&drag_offset, f!([object, on_drag] (e, offset) { + if let Some(offset) = offset { + object.set_xy(e.client_centered() + offset); + on_drag.emit(()); + } + })); + } + on_drag.into() +} diff --git a/app/gui/view/examples/src/lib.rs b/app/gui/view/examples/src/lib.rs index 3b18d962ff33..60ef627caca4 100644 --- a/app/gui/view/examples/src/lib.rs +++ b/app/gui/view/examples/src/lib.rs @@ -25,6 +25,7 @@ pub use debug_scene_component_list_panel_view as new_component_list_panel_view; pub use debug_scene_documentation as documentation; pub use debug_scene_execution_environment_dropdown as execution_environment_dropdown; +pub use debug_scene_graph_editor_edges as graph_editor_edges; pub use debug_scene_icons as icons; pub use debug_scene_interface as interface; pub use debug_scene_text_grid_visualization as text_grid_visualization; diff --git a/app/gui/view/graph-editor/Cargo.toml b/app/gui/view/graph-editor/Cargo.toml index ac1a90cb0e6f..5adbcd12f28c 100644 --- a/app/gui/view/graph-editor/Cargo.toml +++ b/app/gui/view/graph-editor/Cargo.toml @@ -12,6 +12,7 @@ analytics = { path = "../../analytics" } ast = { path = "../../language/ast/impl" } base64 = "0.13" bimap = { version = "0.4.0" } +derivative = "2.2.0" engine-protocol = { path = "../../controller/engine-protocol" } enso-config = { path = "../../config" } enso-frp = { path = "../../../../lib/rust/frp" } diff --git a/app/gui/view/graph-editor/src/builtin/visualization/java_script/table.js b/app/gui/view/graph-editor/src/builtin/visualization/java_script/table.js index da52b9a3d814..75115da7dcf3 100644 --- a/app/gui/view/graph-editor/src/builtin/visualization/java_script/table.js +++ b/app/gui/view/graph-editor/src/builtin/visualization/java_script/table.js @@ -91,6 +91,11 @@ class TableVisualization extends Visualization { return content } + function escapeHTML(str) { + const mapping = { '&': '&', '<': '<', '"': '"', "'": ''', '>': '>' } + return str.replace(/[&<>"']/g, m => mapping[m]) + } + function cellRenderer(params) { if (params.value === null) { return 'Nothing' @@ -99,7 +104,7 @@ class TableVisualization extends Visualization { } else if (params.value === '') { return 'Empty' } - return params.value.toString() + return escapeHTML(params.value.toString()) } if (!this.tabElem) { @@ -146,6 +151,7 @@ class TableVisualization extends Visualization { cellRenderer: cellRenderer, }, onColumnResized: e => this.lockColumnSize(e), + suppressFieldDotNotation: true, } this.agGrid = new agGrid.Grid(tabElem, this.agGridOptions) } diff --git a/app/gui/view/graph-editor/src/component/edge.rs b/app/gui/view/graph-editor/src/component/edge.rs index 8790598087ea..a46675378f13 100644 --- a/app/gui/view/graph-editor/src/component/edge.rs +++ b/app/gui/view/graph-editor/src/component/edge.rs @@ -4,1050 +4,27 @@ use crate::prelude::*; use ensogl::display::shape::*; use ensogl::display::traits::*; -use crate::component::node; - use enso_frp as frp; use enso_frp; use ensogl::application::Application; +use ensogl::control::io::mouse; +use ensogl::data::bounding_box::BoundingBox; use ensogl::data::color; +use ensogl::define_endpoints_2; use ensogl::display; use ensogl::display::scene::Scene; -use ensogl::gui::component::PointerTarget_DEPRECATED; use ensogl_hardcoded_theme as theme; -use nalgebra::Rotation2; - - - -// ================= -// === Constants === -// ================= - -const LINE_SHAPE_WIDTH: f32 = LINE_WIDTH + 2.0 * PADDING; -const LINE_SIDE_OVERLAP: f32 = 1.0; -const LINE_SIDES_OVERLAP: f32 = 2.0 * LINE_SIDE_OVERLAP; -const LINE_WIDTH: f32 = 4.0; -const ARROW_SIZE_X: f32 = 20.0; -const ARROW_SIZE_Y: f32 = 20.0; - -const HOVER_EXTENSION: f32 = 10.0; - -const MOUSE_OFFSET: f32 = 2.0; - -// It was node::SHADOW_SIZE; Should be moved to theme manager and linked to node::shadow. -const NODE_PADDING: f32 = 10.0; - -// The padding needs to be large enough to accommodate the extended hover area without clipping it. -const PADDING: f32 = 4.0 + HOVER_EXTENSION; -const RIGHT_ANGLE: f32 = std::f32::consts::PI / 2.0; -const INFINITE: f32 = 99999.0; - -/// The threshold for the y-distance between nodes at which we switch from using the y-distance -/// only to determine the closest port to using the full cartesian distance. -const MIN_SOURCE_TARGET_DIFFERENCE_FOR_Y_VALUE_DISCRIMINATION: f32 = 45.0; - -const HOVER_COLOR: color::Rgba = color::Rgba::new(1.0, 0.0, 0.0, 0.000_001); - - - -// =================== -// === Vector Math === -// =================== - -fn up() -> Vector2 { - Vector2(1.0, 0.0) -} - -fn point_rotation(point: Vector2) -> Rotation2 { - Rotation2::rotation_between(&point, &up()) -} - - - -// ================= -// === EdgeShape === -// ================= - -/// Abstraction for all sub-shapes the edge shape is build from. -trait EdgeShape: display::Object { - // === Info === - - fn id(&self) -> display::object::Id { - self.display_object().id() - } - fn events(&self) -> &PointerTarget_DEPRECATED; - fn set_color(&self, color: color::Rgba); - fn set_color_focus(&self, color: color::Rgba); - - - // === Hover === - - /// Set the center of the shape split on this shape. The coordinates must be in the shape local - /// coordinate system. - fn set_focus_split_center_local(&self, center: Vector2); - - /// Set the angle of the half plane that will be focused. Rotation starts with the plane - /// focusing the left half plane. - fn set_focus_split_angle(&self, angle: f32); - - /// Set the focus split for this shape. The `split` indicates where the shape should be - /// split and how the split should be rotated. - fn set_focus_split(&self, split: FocusSplit) { - let angle = self.global_to_local_rotation(split.angle); - let center = self.global_to_local_position(split.position); - self.set_focus_split_angle(angle); - self.set_focus_split_center_local(center); - } - - /// Focus the whole edge. - fn focus_none(&self) { - // Set the focus split in the top right corner and focus everything to the right of it. - self.set_focus_split_center_local(Vector2(INFINITE, INFINITE)); - self.set_focus_split_angle(RIGHT_ANGLE); - } - - /// Do not focus any part of the edge. - fn focus_all(&self) { - // Set the focus split in the top right corner and focus everything below it. - self.set_focus_split_center_local(Vector2(INFINITE, INFINITE)); - self.set_focus_split_angle(2.0 * RIGHT_ANGLE); - } - - - // === Snapping === - - /// Snaps the provided point to the closest location on the shape. - fn snap_local(&self, point: Vector2) -> Option>; - - /// Snaps the provided point to the closest location on the shape. - fn snap(&self, point: Vector2) -> Option> { - let local = self.global_to_local_position(point); - let local_snapped = self.snap_local(local)?; - Some(self.local_to_global_position(local_snapped)) - } - - - // === Shape Analysis === - - /// Return the angle perpendicular to the shape at the point given in the shapes local - /// coordinate system . Defaults to zero, if not implemented. - fn normal_local(&self, _point: Vector2) -> Rotation2; - - /// Return the angle perpendicular to the shape at the given point. - fn normal(&self, point: Vector2) -> Rotation2 { - let local = self.global_to_local_position(point); - self.normal_local(local) - } - - - // === Metrics === - - /// Convert the angle to the local coordinate system. - fn global_to_local_rotation(&self, angle: f32) -> f32 { - angle + self.display_object().rotation().z - } - - /// Convert the global position to the local coordinate system. - fn global_to_local_position(&self, point: Vector2) -> Vector2 { - let base_rotation = self.display_object().rotation().z; - let local_unrotated = point - self.display_object().global_position().xy(); - Rotation2::new(-base_rotation) * local_unrotated - } - - /// Convert the local position to the global coordinate system. - fn local_to_global_position(&self, point: Vector2) -> Vector2 { - let base_rotation = self.display_object().rotation().z; - let local_unrotated = Rotation2::new(base_rotation) * point; - local_unrotated + self.display_object().global_position().xy() - } -} - - - -// ==================== -// === AnyEdgeShape === -// ==================== - -/// The AnyEdgeShape trait allows operations on a collection of `EdgeShape`. -trait AnyEdgeShape { - /// Return references to all `EdgeShape`s in this `AnyEdgeShape`. - fn shapes(&self) -> Vec<&dyn EdgeShape>; - - /// Connect the given `PointerTargetProxy` to the mouse events of all sub-shapes. - fn register_proxy_frp(&self, network: &frp::Network, frp: &PointerTargetProxy) { - for shape in &self.shapes() { - let event = shape.events(); - let id = shape.id(); - frp::extend! { network - eval_ event.mouse_down_primary (frp.on_mouse_down.emit(id)); - eval_ event.mouse_over (frp.on_mouse_over.emit(id)); - eval_ event.mouse_out (frp.on_mouse_out.emit(id)); - } - } - } -} - - - -// ======================= -// === Hover Extension === -// ======================= - -/// Add an invisible hover area to the provided shape. The base shape should already be colored -/// otherwise coloring it later will also color the hover area. -fn hover_area(base_shape: AnyShape, size: Var) -> AnyShape { - let hover_area = base_shape.grow(size).fill(HOVER_COLOR); - (hover_area + base_shape).into() -} - - - -// ================== -// === FocusSplit === -// ================== - -/// Holds the data required to split a shape into two focus visual groups. -#[derive(Clone, Copy, Debug)] -struct FocusSplit { - position: Vector2, - angle: f32, -} - -impl FocusSplit { - fn new(position: Vector2, angle: f32) -> Self { - FocusSplit { position, angle } - } -} - - - -// =================== -// === FocusedEdge === -// =================== - -/// An edge split into two parts - focused and unfocused one. -struct FocusedEdge { - focused: AnyShape, - unfocused: AnyShape, -} - -impl FocusedEdge { - /// Splits the shape in two at the line given by the `split_center` and `split_angle`. - fn new( - base_shape: impl Into, - split_center: &Var>, - split_angle: &Var, - ) -> Self { - let base_shape = base_shape.into(); - let split_mask = HalfPlane().rotate(split_angle).translate(split_center); - let focused = (&base_shape * &split_mask).into(); - let unfocused = (&base_shape - &split_mask).into(); - FocusedEdge { focused, unfocused } - } - - /// Color the focused and unfocused parts with the provided colors. - fn fill>>(&self, focused_color: C, unfocused_color: C) -> AnyShape { - let focused_color = focused_color.into(); - let unfocused_color = unfocused_color.into(); - let focused = self.focused.fill(focused_color); - let unfocused = self.unfocused.fill(unfocused_color); - (focused + unfocused).into() - } -} - - - -// ================== -// === SnapTarget === -// ================== - -/// `SnapTarget` is the result value of snapping operations on `AnyEdgeShape`. It holds the -/// shape that a hover position was snapped to and the snapped position on the shape. The snapped -/// position lies (a) on the visible part of the shape and (b) is the closes position on the shape -/// to the source position that was used to compute the snapped position. -#[derive(Clone, Debug)] -struct SnapTarget { - position: Vector2, - target_shape_id: display::object::Id, -} - -impl SnapTarget { - fn new(position: Vector2, target_shape_id: display::object::Id) -> Self { - SnapTarget { position, target_shape_id } - } -} - - - -// ========================= -// === Shape Definitions === -// ========================= - -/// Joint definition. -pub mod joint { - use super::*; - - ensogl::shape! { - above = [compound::rectangle::shape]; - pointer_events = false; - alignment = center; - (style: Style, color_rgba: Vector4) { - let radius = Var::::from("input_size.y"); - let joint = Circle((radius-PADDING.px())/2.0); - let joint_color = Var::::from(color_rgba); - let joint_colored = joint.fill(joint_color); - joint_colored.into() - } - } -} - -fn corner_base_shape( - radius: &Var, - width: &Var, - angle: &Var, - start_angle: &Var, -) -> AnyShape { - let radius = 1.px() * radius; - let width2 = width / 2.0; - let radius_outer = &radius + &width2; - let radius_inner = &radius - &width2; - let ring = Circle(radius_outer) - Circle(radius_inner); - let right: Var = RIGHT_ANGLE.into(); - let rot = right - angle / 2.0 + start_angle; - let mask = Plane().cut_angle_fast(angle.clone()).rotate(rot); - let shape = ring * mask; - shape.into() -} - -// FIXME [WD]: The 2 following impls are almost the same. Should be merged. This task should be -// handled by Wojciech. -macro_rules! define_corner_start { - ($($args:tt)*) => { - /// Shape definition. - pub mod corner { - use super::*; - - ensogl::shape! { - $($args)* - alignment = center; - ( style: Style - , radius : f32 - , angle : f32 - , start_angle : f32 - , pos : Vector2 - , dim : Vector2 - , focus_split_center : Vector2 - , focus_split_angle : f32 - , color_rgba:Vector4 - , focus_color_rgba:Vector4 - ) { - let width = &LINE_WIDTH.px(); - let shape = corner_base_shape(&radius,width,&angle,&start_angle); - let color = Var::::from(color_rgba); - let focus_color = Var::::from(focus_color_rgba); - - - let shadow_size = 10.px(); - let node_radius = &shadow_size + 1.px() * dim.y(); - let node_width = &shadow_size*2.0 + 2.px() * dim.x(); - let node_heigt = &node_radius*2.0; - - let node_shape = Rect((node_width,node_heigt)).corners_radius(node_radius); - let node_shape = node_shape.fill(color::Rgba::new(1.0,0.0,0.0,1.0)); - let tx = - 1.px() * pos.x(); - let ty = - 1.px() * pos.y(); - let node_shape = node_shape.translate((tx,ty)); - - let shape = shape.difference(node_shape); - - let split_shape = FocusedEdge::new( - shape,&focus_split_center.px(),&focus_split_angle.into()); - let shape = split_shape.fill(&color, &focus_color); - - let hover_width = width + HOVER_EXTENSION.px() * 2.0; - let hover_area = corner_base_shape(&radius,&hover_width,&angle,&start_angle); - let hover_area = hover_area.fill(HOVER_COLOR); - (hover_area + shape).into() - } - } - - impl EdgeShape for View { - fn set_focus_split_center_local(&self, center: Vector2) { - self.focus_split_center.set(center); - } - - fn set_focus_split_angle(&self, angle: f32) { - self.focus_split_angle.set(angle); - } - - fn events(&self) -> &PointerTarget_DEPRECATED { - &self.events_deprecated - } - - fn set_color(&self, color: color::Rgba) { - self.color_rgba.set(Vector4(color.red, color.green, color.blue, color.alpha)); - } - - fn set_color_focus(&self, color: color::Rgba) { - let color_vec = Vector4(color.red, color.green, color.blue, color.alpha); - self.focus_color_rgba.set(color_vec); - } - - fn normal_local(&self, point: Vector2) -> Rotation2 { - point_rotation(point) - } - - fn snap_local(&self, point: Vector2) -> Option> { - // FIXME: These bounds check should not be required and should be removed once - // issue #689 is resolved. - let radius = self.radius.get(); - let center = Vector2::zero(); - let point_to_center = point.xy() - center; - - let closest_point = - center + point_to_center / point_to_center.magnitude() * radius; - let vector_angle = - -Rotation2::rotation_between(&Vector2(0.0, 1.0), &closest_point).angle(); - let start_angle = self.start_angle.get(); - let end_angle = start_angle + self.angle.get(); - let upper_bound = start_angle.max(end_angle); - let lower_bound = start_angle.min(end_angle); - - let correct_quadrant = lower_bound < vector_angle && upper_bound > vector_angle; - correct_quadrant.as_some(Vector2(closest_point.x, closest_point.y)) - } - } - } - }; -} - - -macro_rules! define_corner_end { - ($($args:tt)*) => { - /// Shape definition. - pub mod corner { - use super::*; - ensogl::shape! { - $($args)* - alignment = center; - ( - style: Style, - radius: f32, - angle: f32, - start_angle: f32, - pos: Vector2, - dim: Vector2, - focus_split_center: Vector2, - focus_split_angle: f32, - color_rgba: Vector4, - focus_color_rgba: Vector4, - ) { - let width = &LINE_WIDTH.px(); - let shape = corner_base_shape(&radius,width,&angle,&start_angle); - let color = Var::::from(color_rgba); - let focus_color = Var::::from(focus_color_rgba); - - let shadow_size = 10.px() + 1.px(); - let node_radius = &shadow_size + 1.px() * dim.y(); - let node_shape = Rect((&shadow_size*2.0 + 2.px() * dim.x(),&node_radius*2.0)); - let node_shape = node_shape.corners_radius(node_radius); - let node_shape = node_shape.fill(color::Rgba::new(1.0,0.0,0.0,1.0)); - let tx = - 1.px() * pos.x(); - let ty = - 1.px() * pos.y(); - let node_shape = node_shape.translate((tx,ty)); - - let shape = shape.intersection(node_shape); - - let split_shape = FocusedEdge::new( - shape,&focus_split_center.px(),&focus_split_angle.into()); - let shape = split_shape.fill(&color,&focus_color); - - let hover_width = width + HOVER_EXTENSION.px() * 2.0; - let hover_area = corner_base_shape(&radius,&hover_width,&angle,&start_angle); - let hover_area = hover_area.fill(HOVER_COLOR); - (hover_area + shape).into() - } - } - - impl EdgeShape for View { - fn set_focus_split_center_local(&self, center: Vector2) { - self.focus_split_center.set(center); - } - - fn set_focus_split_angle(&self, angle: f32) { - self.focus_split_angle.set(angle); - } - - fn events(&self) -> &PointerTarget_DEPRECATED { - &self.events_deprecated - } - - fn set_color(&self, color: color::Rgba) { - self.color_rgba.set(Vector4(color.red, color.green, color.blue, color.alpha)); - } - - fn set_color_focus(&self, color: color::Rgba) { - self.focus_color_rgba.set(Vector4( - color.red, - color.green, - color.blue, - color.alpha, - )); - } - - fn normal_local(&self, point: Vector2) -> Rotation2 { - point_rotation(point) - } - - fn snap_local(&self, point: Vector2) -> Option> { - // FIXME: These bounds check should not be required and should be removed once - // issue #689 is resolved. - let radius = self.radius.get(); - let center = Vector2::zero(); - let point_to_center = point.xy() - center; - - let closest_point = - center + point_to_center / point_to_center.magnitude() * radius; - let vector_angle = - -Rotation2::rotation_between(&Vector2(0.0, 1.0), &closest_point).angle(); - let start_angle = self.start_angle.get(); - let end_angle = start_angle + self.angle.get(); - let upper_bound = start_angle.max(end_angle); - let lower_bound = start_angle.min(end_angle); - - let correct_quadrant = lower_bound < vector_angle && upper_bound > vector_angle; - if correct_quadrant { - Some(Vector2(closest_point.x, closest_point.y)) - } else { - None - } - } - } - } - }; -} - -macro_rules! define_line { - ($($args:tt)*) => { - /// Shape definition. - pub mod line { - use super::*; - ensogl::shape! { - $($args)* - alignment = center; - ( - style: Style, - focus_split_center: Vector2, - focus_split_angle: f32, - color_rgba: Vector4, - focus_color_rgba: Vector4 - ) { - let width = LINE_WIDTH.px(); - let height = Var::::from("input_size.y"); - let shape = Rect((width.clone(),height)); - let color = Var::::from(color_rgba); - let focus_color = Var::::from(focus_color_rgba); - - let split_shape = FocusedEdge::new( - shape,&focus_split_center.px(),&focus_split_angle.into()); - let shape = split_shape.fill(&color,&focus_color); - hover_area(shape,HOVER_EXTENSION.px()).into() - } - } - - impl EdgeShape for View { - fn set_focus_split_center_local(&self, center: Vector2) { - self.focus_split_center.set(center); - } - - fn set_focus_split_angle(&self, angle: f32) { - self.focus_split_angle.set(angle); - } - - fn events(&self) -> &PointerTarget_DEPRECATED { - &self.events_deprecated - } - - fn set_color(&self, color: color::Rgba) { - self.color_rgba.set(Vector4(color.red, color.green, color.blue, color.alpha)); - } - - fn set_color_focus(&self, color: color::Rgba) { - self.focus_color_rgba.set(Vector4( - color.red, - color.green, - color.blue, - color.alpha, - )); - } - - fn normal_local(&self, _: Vector2) -> Rotation2 { - Rotation2::new(0.0) - } - - fn snap_local(&self, point: Vector2) -> Option> { - // FIXME: These bounds check should not be required and should be removed once - // issue #689 is resolved. - let height = self.computed_size().y; - let y = point.y.clamp(-height / 2.0, height / 2.0); - Some(Vector2(0.0, y)) - } - } - } - }; -} - -macro_rules! define_arrow { ($($args:tt)*) => { - /// Shape definition. - pub mod arrow { - use super::*; - ensogl::shape! { - $($args)* - alignment = center; - ( - style: Style, - focus_split_center: Vector2, - focus_split_angle: f32, - color_rgba: Vector4, - focus_color_rgba: Vector4 - ) { - let width : Var = "input_size.x".into(); - let height : Var = "input_size.y".into(); - let color = Var::::from(color_rgba); - let focus_color = Var::::from(focus_color_rgba); - let focus_split_angle = focus_split_angle.into(); - let focus_split_center = focus_split_center.px(); - - let shape_padding = -1.px(); - let shape = Triangle(width+&shape_padding,height+&shape_padding); - let shape = FocusedEdge::new(shape,&focus_split_center,&focus_split_angle); - let shape = shape.fill(&color, &focus_color); - shape.into() - } - } - - impl EdgeShape for View { - fn set_focus_split_center_local(&self, center:Vector2) { - // We don't want the arrow to be half-focused. The focus split point is set to the - // closest edge (all or nothing). - let min = -Vector2(ARROW_SIZE_X,ARROW_SIZE_Y); - let max = Vector2(ARROW_SIZE_X,ARROW_SIZE_Y); - let mid = Vector2::::zero(); - let x = if center.x < mid.x { min.x } else { max.x }; - let y = if center.y < mid.y { min.y } else { max.y }; - self.focus_split_center.set(Vector2(x,y)); - } - - fn set_focus_split_angle(&self, angle:f32) { - self.focus_split_angle.set(angle); - } - - fn events(&self) -> &PointerTarget_DEPRECATED { - &self.events_deprecated - } - - fn set_color(&self, color:color::Rgba) { - self.color_rgba.set(Vector4(color.red,color.green,color.blue,color.alpha)); - } - - fn set_color_focus(&self, color:color::Rgba) { - self.focus_color_rgba.set(Vector4(color.red,color.green,color.blue,color.alpha)); - } - - fn normal_local(&self, _:Vector2) -> Rotation2 { - Rotation2::new(0.0) - } - - fn normal(&self, _point:Vector2) -> Rotation2 { - Rotation2::new(-RIGHT_ANGLE) - } - - fn snap_local(&self, point:Vector2) -> Option> { - Some(Vector2(0.0, point.y)) - } - } - } -}} - - - -// ======================== -// === Shape Operations === -// ======================== - -trait LayoutLine { - fn layout_v(&self, start: Vector2, len: f32); - fn layout_h(&self, start: Vector2, len: f32); - fn layout_v_no_overlap(&self, start: Vector2, len: f32); - fn layout_h_no_overlap(&self, start: Vector2, len: f32); -} - -impl LayoutLine for front::line::View { - fn layout_v(&self, start: Vector2, len: f32) { - let pos = Vector2(start.x, start.y + len / 2.0); - let size = Vector2(LINE_SHAPE_WIDTH, len.abs() + LINE_SIDES_OVERLAP); - self.set_size(size); - self.set_xy(pos); - } - fn layout_h(&self, start: Vector2, len: f32) { - let pos = Vector2(start.x + len / 2.0, start.y); - let size = Vector2(LINE_SHAPE_WIDTH, len.abs() + LINE_SIDES_OVERLAP); - self.set_size(size); - self.set_xy(pos); - } - fn layout_v_no_overlap(&self, start: Vector2, len: f32) { - let pos = Vector2(start.x, start.y + len / 2.0); - let size = Vector2(LINE_SHAPE_WIDTH, len.abs()); - self.set_size(size); - self.set_xy(pos); - } - fn layout_h_no_overlap(&self, start: Vector2, len: f32) { - let pos = Vector2(start.x + len / 2.0, start.y); - let size = Vector2(LINE_SHAPE_WIDTH, len.abs()); - self.set_size(size); - self.set_xy(pos); - } -} - -impl LayoutLine for back::line::View { - fn layout_v(&self, start: Vector2, len: f32) { - let pos = Vector2(start.x, start.y + len / 2.0); - let size = Vector2(LINE_SHAPE_WIDTH, len.abs() + LINE_SIDES_OVERLAP); - self.set_size(size); - self.set_xy(pos); - } - fn layout_h(&self, start: Vector2, len: f32) { - let pos = Vector2(start.x + len / 2.0, start.y); - let size = Vector2(LINE_SHAPE_WIDTH, len.abs() + LINE_SIDES_OVERLAP); - self.set_size(size); - self.set_xy(pos); - } - fn layout_v_no_overlap(&self, start: Vector2, len: f32) { - let pos = Vector2(start.x, start.y + len / 2.0); - let size = Vector2(LINE_SHAPE_WIDTH, len.abs()); - self.set_size(size); - self.set_xy(pos); - } - fn layout_h_no_overlap(&self, start: Vector2, len: f32) { - let pos = Vector2(start.x + len / 2.0, start.y); - let size = Vector2(LINE_SHAPE_WIDTH, len.abs()); - self.set_size(size); - self.set_xy(pos); - } -} - - - -// =========================== -// === Front / Back Shapes === -// =========================== - -/// Shape definitions which will be rendered in the front layer (on top of nodes). -pub mod front { - use super::*; - define_corner_start!( - above = [node::backdrop, compound::rectangle::shape]; - below = [joint]; - ); - define_line!( - above = [node::backdrop, compound::rectangle::shape]; - below = [joint]; - ); - define_arrow!( - above = [joint, node::backdrop, compound::rectangle::shape]; - ); -} - -/// Shape definitions which will be rendered in the bottom layer (below nodes). -pub mod back { - use super::*; - define_corner_end!( - below = [node::backdrop]; - ); - define_line!( - below = [node::backdrop]; - ); - define_arrow!( - below = [node::backdrop]; - ); -} - - - -// =========================== -// === Front / Back Layers === -// =========================== - -macro_rules! define_components { - ($name:ident { - $($field:ident : ($field_type:ty, $field_shape_type:expr)),* $(,)? - }) => { - #[derive(Debug,Clone,CloneRef)] - #[allow(missing_docs)] - pub struct $name { - pub display_object : display::object::Instance, - pub shape_view_events : Rc>, - shape_type_map : Rc>, - $(pub $field : $field_type),* - } - - impl $name { - /// Constructor. - #[allow(clippy::vec_init_then_push)] - pub fn new() -> Self { - let display_object = display::object::Instance::new_named(stringify!($name)); - $(let $field = <$field_type>::new();)* - $(display_object.add_child(&$field);)* - let mut shape_view_events:Vec = Vec::default(); - $(shape_view_events.push($field.events_deprecated.clone_ref());)* - let shape_view_events = Rc::new(shape_view_events); - - let mut shape_type_map:HashMap = default(); - $(shape_type_map.insert(EdgeShape::id(&$field), $field_shape_type);)* - let shape_type_map = Rc::new(shape_type_map); - - Self {display_object,shape_view_events,shape_type_map,$($field),*} - } - - fn get_shape(&self, id:display::object::Id) -> Option<&dyn EdgeShape> { - match id { - $(id if id == EdgeShape::id(&self.$field) => Some(&self.$field),)* - _ => None, - } - } - - fn get_shape_type(&self, id:display::object::Id) -> Option { - self.shape_type_map.get(&id).cloned() - } - } - - impl Default for $name { - fn default() -> Self { - Self::new() - } - } - - impl display::Object for $name { - fn display_object(&self) -> &display::object::Instance { - &self.display_object - } - } - - impl AnyEdgeShape for $name { - #[allow(clippy::vec_init_then_push)] - fn shapes(&self) -> Vec<&dyn EdgeShape> { - let mut output = Vec::<&dyn EdgeShape>::default(); - $(output.push(&self.$field);)* - output - } - } - } -} - -define_components! { - Front { - corner : (front::corner::View, ShapeRole::Corner), - corner2 : (front::corner::View, ShapeRole::Corner2), - corner3 : (front::corner::View, ShapeRole::Corner3), - side_line : (front::line::View, ShapeRole::SideLine), - side_line2 : (front::line::View, ShapeRole::SideLine2), - main_line : (front::line::View, ShapeRole::MainLine), - port_line : (front::line::View, ShapeRole::PortLine), - arrow : (front::arrow::View, ShapeRole::Arrow), - } -} - -define_components! { - Back { - corner : (back::corner::View, ShapeRole::Corner), - corner2 : (back::corner::View, ShapeRole::Corner2), - corner3 : (back::corner::View, ShapeRole::Corner3), - side_line : (back::line::View, ShapeRole::SideLine), - side_line2 : (back::line::View, ShapeRole::SideLine2), - main_line : (back::line::View, ShapeRole::MainLine), - arrow : (back::arrow::View, ShapeRole::Arrow), - } -} - -impl AnyEdgeShape for EdgeModelData { - fn shapes(&self) -> Vec<&dyn EdgeShape> { - let mut shapes_back = self.back.shapes(); - let mut shapes_front = self.front.shapes(); - shapes_front.append(&mut shapes_back); - shapes_front - } -} - - - -// =========================== -// === Shape & State Enums === -// =========================== -/// Indicates which role a shape plays within the overall edge. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ShapeRole { - SideLine, - Corner, - MainLine, - Corner2, - SideLine2, - Corner3, - PortLine, - Arrow, -} - -/// Indicates the state the shape layout is in. Can be used to adjust behaviour based on state -/// to address edge cases for specific layouts. The terms are used to follow the direction of the -/// edge from `Output` to `Input`. -/// -/// Each state represents a unique layout in terms of: adjacency of shapes (some shapes may -/// disappear in some layout), or the relative geometric position of shapes. For example, the -/// `TopCenterRightLoop` has the main line leaving the node right to left, while corner2 and -/// corner3 are left to right relative to each other. Compare the `UpRight`, which is almost the -/// same, but has the main line leave the source node left to right. -/// -/// This list is not exhaustive and new constellations should be added as needed. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum LayoutState { - UpLeft, - UpRight, - DownLeft, - DownRight, - /// The edge goes right / up / left / down. - TopCenterRightLoop, - /// The edge goes left / up / right / down. - TopCenterLeftLoop, -} - -impl LayoutState { - /// Indicates whether the `OutputPort` is below the `InputPort` in the current layout - /// configuration. - fn is_output_above_input(self) -> bool { - match self { - LayoutState::UpLeft => false, - LayoutState::UpRight => false, - LayoutState::TopCenterRightLoop => false, - LayoutState::TopCenterLeftLoop => false, - LayoutState::DownLeft => true, - LayoutState::DownRight => true, - } - } - - fn is_input_above_output(self) -> bool { - !self.is_output_above_input() - } -} - - - -// ===================== -// === SemanticSplit === -// ===================== - -/// The semantic split, splits the sub-shapes according to their relative position from `OutputPort` -/// to `InputPort` and allows access to the three different groups of shapes: -/// - shapes that are input side of the split; -/// - shapes that are at the split; -/// - shapes that are output side of the split. -/// -/// This allows us to apply special handling to these groups. This is required as a simple geometric -/// split based on a line, can lead to double intersections with the edge. Thus we avoid the -/// geometric intersections, for shapes away from the intersection point, and instead color them -/// based on their position within the edge. -/// -/// We have seven "slots" of shapes within the edge that can be ordered from output port to input -/// port: `SideLine` `Corner`, `MainLine`/`Arrow`, `Corner2`, `SideLine2`, `Corner3`, `PortLine`. -/// Note that it does not matter, if we have multiple adjacent shapes in the same bucket (as can -/// be the case with back/front shapes), as long as no self-intersection is possible for these -/// shapes. -/// -/// Example: We need to split on the `SideLine2` and focus the shapes closer to the -/// output port. That means we need to do the geometric split on `Corner2`, `SideLine2`, `Corner3`, -/// which we can access via `split_shapes` and apply the focusing to `SideLine` `Corner` and -/// `MainLine`/`Arrow`, which we can access via `output_side_shapes`. The remaining shapes that must -/// not be focused can be accessed via `input_side_shapes`. -#[derive(Clone, Debug)] -struct SemanticSplit { - /// Ids of the shapes in the order they appear in the edge. Shapes that fill the same "slot" - /// are binned into a sub-vector and can be handled together. - ordered_part_ids: Vec>, - /// The index the shape where the edge split occurs in the `ordered_part_ids`. - split_index: usize, -} +mod inputs; +mod layout; +mod render; +mod state; -impl SemanticSplit { - fn new(edge_data: &EdgeModelData, split_shape: display::object::Id) -> Option { - let ordered_part_ids = Self::semantically_binned_edges(edge_data); - - // Find the object id in our `ordered_part_ids` - let mut split_index = None; - for (index, shape_ids) in ordered_part_ids.iter().enumerate() { - if shape_ids.contains(&split_shape) { - split_index = Some(index); - break; - } - } - let split_index = split_index?; - - Some(SemanticSplit { ordered_part_ids, split_index }) - } - - /// Return an ordered vector that contains the ids of the shapes in the order they appear in the - /// edge. Shapes that are to be handled as in the same place, are binned into a sub-vector. - /// This enables us to infer which parts are next to each other, and which ones are - /// "source-side"/"target-side". - /// - /// In general, we treat the equivalent shape from front and back as the same, as they tend to - /// occupy the same space within the shape and thus need to be handled together. But, - /// for example, also the arrow needs to be handled together with the main line. - /// - /// The important information we create here is the rough adjacency of shapes. This is used to - /// determine which shapes are adjacent to avoid rendering a split on a shape that can be all - /// focus on or all focus off. - fn semantically_binned_edges(edge_data: &EdgeModelData) -> Vec> { - let front = &edge_data.front; - let back = &edge_data.back; - vec![ - vec![EdgeShape::id(&front.side_line), EdgeShape::id(&back.side_line)], - vec![EdgeShape::id(&front.corner), EdgeShape::id(&back.corner)], - vec![ - EdgeShape::id(&front.main_line), - EdgeShape::id(&back.main_line), - EdgeShape::id(&front.arrow), - EdgeShape::id(&back.arrow), - ], - vec![EdgeShape::id(&front.corner2), EdgeShape::id(&back.corner2)], - vec![EdgeShape::id(&front.side_line2), EdgeShape::id(&back.side_line2)], - vec![EdgeShape::id(&front.corner3), EdgeShape::id(&back.corner3)], - vec![EdgeShape::id(&front.port_line)], - ] - } - - /// Return `Id`s that match the given index condition `cond`. - fn index_filtered_shapes bool>(&self, cond: F) -> Vec { - self.ordered_part_ids - .iter() - .enumerate() - .filter(|(index, _)| cond(*index as i32)) - .flat_map(|(_index, ids)| ids.clone()) - .collect() - } - - /// Shapes that are output side of the split. - fn output_side_shapes(&self) -> Vec { - self.index_filtered_shapes(move |index| (index) < self.split_index as i32) - } - - /// Shapes that are input side of the split. - fn input_side_shapes(&self) -> Vec { - self.index_filtered_shapes(move |index| index > (self.split_index as i32)) - } - - /// Shapes that are at the split location. - fn split_shapes(&self) -> Vec { - self.index_filtered_shapes(move |index| (index == self.split_index as i32)) - } -} +use inputs::Inputs; +use layout::EndPoint; +use render::ShapeParent; +use render::Shapes; +use state::*; @@ -1055,131 +32,32 @@ impl SemanticSplit { // === FRP === // =========== -/// FRP system that is used to collect and aggregate shape view events from the sub-shapes of an -/// `Edge`. The Edge exposes the `mouse_down`/`mouse_over`/`mouse_out` streams, while the sub-shapes -/// emit events via th internal `on_mouse_down`/`on_mouse_over`/`on_mouse_out` sources. -#[derive(Clone, CloneRef, Debug)] -#[allow(missing_docs)] -pub struct PointerTargetProxy { - pub mouse_down_primary: frp::Stream, - pub mouse_over: frp::Stream, - pub mouse_out: frp::Stream, - - on_mouse_down: frp::Source, - on_mouse_over: frp::Source, - on_mouse_out: frp::Source, -} - -#[allow(missing_docs)] -impl PointerTargetProxy { - pub fn new(network: &frp::Network) -> Self { - frp::extend! { network - on_mouse_over <- source(); - on_mouse_out <- source(); - on_mouse_down <- source(); - - mouse_down_primary <- on_mouse_down.constant(()); - mouse_over <- on_mouse_over.constant(()); - mouse_out <- on_mouse_out.constant(()); - } - - Self { - mouse_down_primary, - mouse_over, - mouse_out, - on_mouse_down, - on_mouse_over, - on_mouse_out, - } +define_endpoints_2! { + Input { + /// The width and height of the source node in pixels. + source_size(Vector2), + /// The location of the center of the target node's input port. + target_position(Vector2), + /// Whether the target end of the edge is attached to a node (If `false`, it is being + /// dragged by the mouse.) + target_attached(bool), + /// Whether the source end of the edge is attached to a node (If `false`, it is being + /// dragged by the mouse.) + source_attached(bool), + set_disabled(bool), + /// The typical color of the node; also used to derive the focus color. + set_color(color::Lcha), } -} - - -/// FRP endpoints of the edge. -#[derive(Clone, CloneRef, Debug)] -#[allow(missing_docs)] -pub struct Frp { - pub source_width: frp::Source, - pub source_height: frp::Source, - pub target_position: frp::Source>, - pub target_attached: frp::Source, - pub source_attached: frp::Source, - pub redraw: frp::Source, - pub set_disabled: frp::Source, - pub set_color: frp::Source, - - pub hover_position: frp::Source>>, - pub shape_events: PointerTargetProxy, -} - -impl Frp { - /// Constructor. - #[profile(Debug)] - pub fn new(network: &frp::Network) -> Self { - frp::extend! { network - def source_width = source(); - def source_height = source(); - def target_position = source(); - def target_attached = source(); - def source_attached = source(); - def redraw = source(); - def hover_position = source(); - def set_disabled = source(); - def set_color = source(); - } - let shape_events = PointerTargetProxy::new(network); - Self { - source_width, - source_height, - target_position, - target_attached, - source_attached, - redraw, - set_disabled, - set_color, - hover_position, - shape_events, - } + Output { + /// The mouse has clicked to detach the source end of the edge. + source_click(), + /// The mouse has clicked to detach the target end of the edge. + target_click(), } } -// ================== -// === Math Utils === -// ================== - -/// For the given radius of the first circle (`r1`), radius of the second circle (`r2`), and the -/// x-axis position of the second circle (`x`), computes the y-axis position of the second circle in -/// such a way, that the borders of the circle cross at the right angle. It also computes the angle -/// of the intersection. Please note, that the center of the first circle is in the origin. -/// -/// ```text -/// r1 -/// ◄───► (1) x^2 + y^2 = r1^2 + r2^2 -/// _____ (1) => y = sqrt((r1^2 + r2^2)/x^2) -/// .' `. -/// / _.-"""B-._ â–² -/// | .'0┼ | `. │ angle1 = A-XY-0 -/// \/ │ / \ │ r2 angle2 = 0-XY-B -/// |`._ │__.' | │ alpha = B-XY-X_AXIS -/// | A└───┼─ | â–¼ -/// | (x,y) | tg(angle1) = y / x -/// \ / tg(angle2) = r1 / r2 -/// `._ _.' alpha = PI - angle1 - angle2 -/// `-....-' -/// ``` -fn circle_intersection(x: f32, r1: f32, r2: f32) -> (f32, f32) { - let x_norm = x.clamp(-r2, r1); - let y = (r1 * r1 + r2 * r2 - x_norm * x_norm).sqrt(); - let angle1 = f32::atan2(y, x_norm); - let angle2 = f32::atan2(r1, r2); - let angle = std::f32::consts::PI - angle1 - angle2; - (y, angle) -} - - - // ============ // === Edge === // ============ @@ -1188,8 +66,8 @@ fn circle_intersection(x: f32, r1: f32, r2: f32) -> (f32, f32) { #[derive(AsRef, Clone, CloneRef, Debug, Deref)] pub struct Edge { #[deref] - model: Rc, - network: frp::Network, + frp: Frp, + model: Rc, } impl AsRef for Edge { @@ -1198,79 +76,76 @@ impl AsRef for Edge { } } -impl display::Object for EdgeModelData { - fn display_object(&self) -> &display::object::Instance { - &self.display_object - } -} - impl Edge { /// Constructor. #[profile(Detail)] pub fn new(app: &Application) -> Self { - let network = frp::Network::new("node_edge"); - let data = Rc::new(EdgeModelData::new(&app.display.default_scene, &network)); - let model = Rc::new(EdgeModel { data }); - Self { model, network }.init(app) - } - - fn init(self, app: &Application) -> Self { - let network = &self.network; - let input = &self.frp; - let target_position = &self.target_position; - let target_attached = &self.target_attached; - // FIXME This should be used for #672 (Edges created from Input Ports do not overlay nodes) - let _source_attached = &self.source_attached; - let source_width = &self.source_width; - let source_height = &self.source_height; - let hover_position = &self.hover_position; - let hover_target = &self.hover_target; + let frp = Frp::new(); + let model = Rc::new(EdgeModel::new(&app.display.default_scene)); + let network = &frp.network; + let display_object = &model.display_object; + let output = &frp.private.output; - let model = &self.model; - let shape_events = &self.frp.shape_events; let edge_color = color::Animation::new(network); - let edge_focus_color = color::Animation::new(network); - let _style = StyleWatch::new(&app.display.default_scene.style_sheet); - - model.data.front.register_proxy_frp(network, &input.shape_events); - model.data.back.register_proxy_frp(network, &input.shape_events); + let mouse_move = display_object.on_event::(); + let mouse_down = display_object.on_event::(); + let mouse_out = display_object.on_event::(); frp::extend! { network - eval input.target_position ((t) target_position.set(*t)); - // FIXME This should be enabled for #672 (Edges created from Input Ports do not overlay - // nodes) - // eval input.source_attached ((t) source_attached.set(*t)); - eval input.target_attached ((t) target_attached.set(*t)); - eval input.source_width ((t) source_width.set(*t)); - eval input.source_height ((t) source_height.set(*t)); - eval input.hover_position ((t) hover_position.set(*t)); - - eval shape_events.on_mouse_over ((id) hover_target.set(Some(*id))); - eval_ shape_events.on_mouse_out (hover_target.set(None)); - eval_ input.redraw (model.redraw()); - - - // === Colors === - - is_hovered <- input.hover_position.map(|t| t.is_some()); - new_color <- all_with(&input.set_color,&input.set_disabled, - f!((c,t)model.base_color(*c,*t))); - new_focus_color <- new_color.map(f!((color) model.focus_color(*color))); - focus_color <- switch(&is_hovered,&new_color,&new_focus_color); + // Setters. + eval frp.target_position ((t) model.inputs.set_target_position(ParentCoords(*t))); + eval frp.source_attached ((t) model.inputs.set_source_attached(*t)); + eval frp.target_attached ((t) model.inputs.set_target_attached(*t)); + eval frp.source_size ((t) model.inputs.set_source_size(*t)); + eval frp.set_disabled ((t) model.inputs.set_disabled(*t)); + + // Mouse events. + eval mouse_move ([model] (e) { + let pos = model.screen_pos_to_scene_pos(e.client_centered()); + model.inputs.set_mouse_position(pos); + }); + eval_ mouse_out (model.inputs.clear_focus.set(true)); + eval mouse_down ([model, output] (e) { + let pos = model.screen_pos_to_scene_pos(e.client_centered()); + let pos = model.scene_pos_to_parent_pos(pos); + match model.closer_end(pos) { + Some(EndPoint::Source) => output.target_click.emit(()), + Some(EndPoint::Target) => output.source_click.emit(()), + // Ignore click events that were delivered to our display object inaccurately. + None => (), + } + }); - edge_color.target <+ new_color; - edge_focus_color.target <+ focus_color; + // Colors. + edge_color.target <+ frp.set_color; + eval edge_color.value ((color) model.inputs.set_color(color.into())); - eval edge_color.value ((color) model.set_color(color.into())); - eval edge_focus_color.value ((color) model.set_focus_color(color.into())); + // Invalidation. + redraw_needed <- any(...); + redraw_needed <+ frp.target_position.constant(()); + redraw_needed <+ frp.source_attached.constant(()); + redraw_needed <+ frp.target_attached.constant(()); + redraw_needed <+ frp.source_size.constant(()); + redraw_needed <+ frp.set_disabled.constant(()); + redraw_needed <+ mouse_move.constant(()); + redraw_needed <+ mouse_out.constant(()); + redraw_needed <+ edge_color.value.constant(()); + redraw_needed <+ display_object.on_transformed.constant(()); + redraw <- redraw_needed.debounce(); + eval_ redraw (model.redraw()); } - self + Self { model, frp } + } + + /// Return the FRP network. + pub fn network(&self) -> &frp::Network { + &self.frp.network } } impl display::Object for Edge { fn display_object(&self) -> &display::object::Instance { - &self.display_object + &self.model.display_object } } @@ -1280,772 +155,215 @@ impl display::Object for Edge { // === EdgeModel === // ================= -/// Indicates the type of end connection of the Edge. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[allow(missing_docs)] -pub enum PortType { - InputPort, - OutputPort, -} - -/// Edge definition. -#[derive(AsRef, Clone, CloneRef, Debug, Deref)] -pub struct EdgeModel { - data: Rc, -} - /// Internal data of `Edge` #[derive(Debug)] -#[allow(missing_docs)] -pub struct EdgeModelData { - pub display_object: display::object::Instance, - pub frp: Frp, - pub front: Front, - pub back: Back, - pub joint: joint::View, - pub source_width: Rc>, - pub source_height: Rc>, - pub target_position: Rc>, - pub target_attached: Rc>, - pub source_attached: Rc>, - - layout_state: Rc>, - hover_position: Rc>>>, - hover_target: Rc>>, +struct EdgeModel { + /// The parent display object of all the edge's parts. + display_object: display::object::Instance, + /// The [`Scene`], needed for coordinate conversions and special layer assignments. scene: Scene, + /// The raw inputs the state is computed from. + inputs: Inputs, + /// The state, as of the last redraw. + state: RefCell>, + /// The currently-rendered shapes implementing the state. + shapes: Shapes, } -impl EdgeModelData { +impl EdgeModel { /// Constructor. #[profile(Debug)] - pub fn new(scene: &Scene, network: &frp::Network) -> Self { + pub fn new(scene: &Scene) -> Self { let display_object = display::object::Instance::new_named("Edge"); - let front = Front::new(); - let back = Back::new(); - let joint = joint::View::new(); - - display_object.add_child(&front); - display_object.add_child(&back); - display_object.add_child(&joint); - - front.side_line.set_rotation_z(RIGHT_ANGLE); - back.side_line.set_rotation_z(RIGHT_ANGLE); - front.side_line2.set_rotation_z(RIGHT_ANGLE); - back.side_line2.set_rotation_z(RIGHT_ANGLE); - - let frp = Frp::new(network); - let source_height = default(); - let source_width = default(); - let target_position = default(); - let target_attached = Rc::new(Cell::new(false)); - let source_attached = Rc::new(Cell::new(true)); - let hover_position = default(); - let layout_state = Rc::new(Cell::new(LayoutState::UpLeft)); - let hover_target = default(); - - let scene = scene.into(); - Self { - display_object, - frp, - front, - back, - joint, - source_width, - source_height, - target_position, - target_attached, - source_attached, - layout_state, - hover_position, - hover_target, - scene, - } - } - - /// Set the color of the edge. - fn set_color(&self, color: color::Lcha) { - // We must never use alpha in edges, as it will show artifacts with overlapping sub-parts. - let color: color::Lcha = color.opaque.into(); - let color_rgba = color::Rgba::from(color); - self.shapes().iter().for_each(|shape| shape.set_color(color_rgba)); - self.joint.color_rgba.set(color_rgba.into()); - } - - fn set_focus_color(&self, color: color::Lcha) { - let color: color::Lcha = color.opaque.into(); - self.shapes().iter().for_each(|shape| shape.set_color_focus(color.into())); - } - - fn base_color(&self, color: color::Lcha, is_disabled: bool) -> color::Lcha { - let color: color::Lcha = color.opaque.into(); - if !is_disabled { - color - } else { - let styles = StyleWatch::new(&self.scene.style_sheet); - styles.get_color(theme::code::syntax::disabled).into() - } - } - - fn focus_color(&self, color: color::Lcha) -> color::Lcha { - // We must never use alpha in edges, as it will show artifacts with overlapping sub-parts. - let color: color::Lcha = color.opaque.into(); - let styles = StyleWatch::new(&self.scene.style_sheet); - let bg_color = styles.get_color(theme::application::background).into(); - color::mix(bg_color, color, 0.25) + let scene = scene.clone_ref(); + scene.layers.main_edges_level.add(&display_object); + Self { display_object, scene, inputs: default(), state: default(), shapes: default() } } /// Redraws the connection. - #[allow(clippy::cognitive_complexity)] + #[profile(Detail)] pub fn redraw(&self) { - // === Variables === - - let fg = &self.front; - let bg = &self.back; - // FIXME This should be enabled for #672 - // let fully_attached = self.target_attached.get() && self.source_attached.get(); - let fully_attached = self.target_attached.get(); - let node_half_width = self.source_width.get() / 2.0; - let target_node_half_height = node::HEIGHT / 2.0; - let source_node_half_height = self.source_height.get() / 2.0; - let source_node_circle = Vector2(node_half_width - source_node_half_height, 0.0); - let source_node_radius = source_node_half_height; - - - // === Update Highlights === - - match (fully_attached, self.hover_position.get(), self.hover_target.get()) { - (true, Some(hover_position), Some(hover_target)) => { - let focus_part = self.port_to_detach_for_position(hover_position); - let focus_split_result = - self.try_enable_focus_split(hover_position, hover_target, focus_part); - if let Ok(snap_data) = focus_split_result { - let joint_position = snap_data.position - self.display_object.position().xy(); - self.joint.set_xy(joint_position); - let joint_size = LINE_WIDTH + PADDING; - self.joint.set_size(Vector2(joint_size, joint_size)); - } - } - _ => { - self.focus_none(); - self.joint.set_size(Vector2::::zero()); - } - } - - - // === Target === - // - // Target is the end position of the connection in local node space (the origin is placed in - // the center of the node). We handle lines drawing in special way when target is below the - // node (for example, not to draw the port line above source node). - // - // ╭──────────────╮ - // │ ┼ (0,0) │ - // ╰──────────────╯────╮ - // │ - // â–¢ target - - let world_space_target = self.target_position.get(); - let target_x = world_space_target.x - self.position().x; - let target_y = world_space_target.y - self.position().y; - let side = target_x.signum(); - let target_x = target_x.abs(); - let target = Vector2(target_x, target_y); - let target_is_below_node_x = target.x < node_half_width; - let target_is_below_node_y = target.y < (-source_node_half_height); - let target_is_below_node = target_is_below_node_x && target_is_below_node_y; - let port_line_len_max = target_node_half_height + NODE_PADDING; - let side_right_angle = side * RIGHT_ANGLE; - - - // === Upward Discovery === - // - // Discovers when the connection should go upwards. The `upward_corner_radius` defines the - // preferred radius for every corner in this scenario. - // - // ╭─────╮ ╭─╮ - // ╰─────╯────╯ │ - // â–¢ - - let upward_corner_radius = 20.0; - let min_len_for_non_curved_line = upward_corner_radius + port_line_len_max; - let upward_distance = target.y + min_len_for_non_curved_line; - - - // === Flat side === - // - // Maximum side distance before connection is curved up. - // - // ╭─────╮◄──► ╭─────╮◄──►╭─╮ - // ╰─────╯───╮ ╰─────╯────╯ │ - // â–¢ â–¢ - - let flat_side_size = 40.0; - let is_flat_side = target.x < node_half_width + flat_side_size; - let downward_flat = - if target_is_below_node_x { target_is_below_node_y } else { target.y < 0.0 }; - let downward_far = -target.y > min_len_for_non_curved_line || target_is_below_node; - let is_down = if is_flat_side { downward_flat } else { downward_far }; - - // === Layout State === - // Initial guess at our layout. Can still be changed further down in the layout code in - // case we encounter a layout where the corners need to loop back. - let state = match (is_down, (side < 0.0)) { - (true, true) => LayoutState::DownLeft, - (true, false) => LayoutState::DownRight, - (false, true) => LayoutState::UpLeft, - (false, false) => LayoutState::UpRight, - }; - self.layout_state.set(state); - - // === Port Line Length === - // - // ╭──╮ - // ╰──╯───╮ - // ╵ - // ╭──┼──╮ â–² Port line covers the area above target node and the area of target node - // │ â–¢ │ â–¼ shadow. It can be shorter if the target position is below the node or the - // ╰─────╯ connection is being dragged, in order not to overlap with the source node. - - let port_line_start = Vector2(side * target.x, target.y + MOUSE_OFFSET); - let space_attached = -port_line_start.y - target_node_half_height - LINE_SIDE_OVERLAP; - let space = space_attached - NODE_PADDING; - let len_below_free = max(0.0, min(port_line_len_max, space)); - let len_below_attached = max(0.0, min(port_line_len_max, space_attached)); - let len_below = if fully_attached { len_below_attached } else { len_below_free }; - let far_side_len = if target_is_below_node { len_below } else { port_line_len_max }; - let flat_side_len = min(far_side_len, -port_line_start.y); - let mut port_line_len = if is_flat_side && is_down { flat_side_len } else { far_side_len }; - let port_line_end = Vector2(target.x, port_line_start.y + port_line_len); - - - // === Corner1 === - // - // The first corner on the line. It is always placed at the right angle to the tangent of - // the node border. In case the edge is in the drag mode, the curve is divided into two - // parts. The first part is placed under the source node shadow, while the second part is - // placed on the top layer. - // - // ╭─────╮ ╭─────╮ 2╭───╮3 - // ╰─────╯──╮1 ╰─────╯──╯1 â–¢ - // â–¢ - - let mut corner1_target = port_line_end; - if !is_down { - corner1_target.x = if is_flat_side { - let radius_grow = max(0.0, target.x - node_half_width + upward_corner_radius); - node_half_width + upward_corner_radius + radius_grow - } else { - let radius1 = node_half_width + (target.x - node_half_width) / 2.0; - let radius2 = node_half_width + 2.0 * upward_corner_radius; - min(radius1, radius2) - }; - corner1_target.y = min(upward_corner_radius, upward_distance / 2.0); - } - - let corner1_grow = ((corner1_target.x - node_half_width) * 0.6).max(0.0); - let corner1_radius = 20.0 + corner1_grow; - let corner1_radius = corner1_radius.min(corner1_target.y.abs()); - let corner1_x = corner1_target.x - corner1_radius; - - let corner1_x_loc = corner1_x - source_node_circle.x; - let (y, angle) = circle_intersection(corner1_x_loc, source_node_radius, corner1_radius); - let corner1_y = if is_down { -y } else { y }; - let corner1 = Vector2(corner1_x * side, corner1_y); - let angle_overlap = if corner1_x > node_half_width { 0.0 } else { 0.1 }; - let corner1_side = (corner1_radius + PADDING) * 2.0; - let corner1_size = Vector2(corner1_side, corner1_side); - let corner1_start_angle = if is_down { 0.0 } else { side_right_angle }; - let corner1_angle = (angle + angle_overlap) * side; - let corner1_angle = if is_down { corner1_angle } else { side_right_angle }; - - bg.corner.set_size(corner1_size); - bg.corner.start_angle.set(corner1_start_angle); - bg.corner.angle.set(corner1_angle); - bg.corner.radius.set(corner1_radius); - bg.corner.pos.set(corner1); - bg.corner.set_xy(corner1); - if !fully_attached { - bg.corner.dim.set(Vector2(node_half_width, source_node_half_height)); - fg.corner.set_size(corner1_size); - fg.corner.start_angle.set(corner1_start_angle); - fg.corner.angle.set(corner1_angle); - fg.corner.radius.set(corner1_radius); - fg.corner.pos.set(corner1); - fg.corner.dim.set(Vector2(node_half_width, source_node_half_height)); - fg.corner.set_xy(corner1); - } else { - fg.corner.set_size(Vector2(0.0, 0.0)); - bg.corner.dim.set(Vector2(INFINITE, INFINITE)); - } - - - // === Side Line === - // - // Side line is the first horizontal line. In case the edge is in drag mode, the line is - // divided into two segments. The first is placed below the shadow of the source node, while - // the second is placed on the top layer. The side line placement is the same in case of - // upwards connections - it is then placed between node and corenr 1. - // - // ╭─────╮ ╭─────╮ 2╭───╮3 - // ╰─────╯╴──╮ ╰─────╯╴─╯1 â–¢ - // â–¢ - - let side_line_shift = LINE_SIDES_OVERLAP; - let side_line_len = max(0.0, corner1_x - node_half_width + side_line_shift); - let bg_line_x = node_half_width - side_line_shift; - let bg_line_start = Vector2(side * bg_line_x, 0.0); - if fully_attached { - let bg_line_len = side * side_line_len; - fg.side_line.set_size(Vector2(0.0, 0.0)); - bg.side_line.layout_h(bg_line_start, bg_line_len); + let state = self.calculate_state(); + self.apply_state(&state); + self.state.set(state); + } + + fn calculate_state(&self) -> State { + if self.inputs.clear_focus.take() { + self.inputs.hover_position.take(); + } + let target_offset = self.target_offset(); + let target_attached = self.inputs.target_attached.get(); + let source_attached = self.inputs.source_attached.get(); + let layout = layout::layout(self.source_half_width(), target_offset, target_attached); + let is_attached = target_attached && source_attached; + let focus_split = is_attached + .then(|| { + // Pointer targets are updated by an asynchronous process, independent of pointer + // movement detection. As a result, we can receive mouse events when the pointer is + // not within the bounding box of any of our shapes, in which case `find_position` + // here will return `None`. We treat it the same way as a + // `mouse::Out` event. + self.inputs.hover_position.get().and_then(|position| { + let position = self.scene_pos_to_parent_pos(position); + let source_height = self.inputs.source_size.get().y(); + layout::find_position(position, &layout, source_height, render::HOVER_WIDTH) + }) + }) + .flatten(); + let styles = StyleWatch::new(&self.scene.style_sheet); + let normal_color = if self.inputs.disabled.get() { + styles.get_color(theme::code::syntax::disabled) } else { - let bg_max_len = NODE_PADDING + side_line_shift; - let bg_line_len = min(side_line_len, bg_max_len); - let bg_end_x = bg_line_x + bg_line_len; - let fg_line_start = Vector2(side * (bg_end_x + LINE_SIDE_OVERLAP), 0.0); - let fg_line_len = side * (side_line_len - bg_line_len); - let bg_line_len_overlap = side * min(side_line_len, bg_max_len + LINE_SIDES_OVERLAP); - bg.side_line.layout_h(bg_line_start, bg_line_len_overlap); - fg.side_line.layout_h_no_overlap(fg_line_start, fg_line_len); - } - - - // === Main Line (downwards) === - // - // Main line is the long vertical line. In case it is placed below the node and the edge is - // in drag mode, it is divided into two segments. The upper segment is drawn behind node - // shadow, while the second is drawn on the top layer. In case of edge in drag mode drawn - // next to node, only the top layer segment is used. - // - // Please note that only applies to edges going down. Refer to docs of main line of edges - // going up to learn more. - // - // Double edge: Single edge: - // ╭─────╮ ╭─────╮ - // ╰──┬──╯ ╰─────╯────╮ - // â•· │ - // â–¢ â–¢ - - if is_down { - let main_line_end_y = corner1.y; - let main_line_len = main_line_end_y - port_line_start.y; - if !fully_attached && target_is_below_node { - let back_line_start_y = - max(-source_node_half_height - NODE_PADDING, port_line_start.y); - let back_line_start = Vector2(port_line_start.x, back_line_start_y); - let back_line_len = main_line_end_y - back_line_start_y; - let front_line_len = main_line_len - back_line_len; - bg.main_line.layout_v(back_line_start, back_line_len); - fg.main_line.layout_v(port_line_start, front_line_len); - } else if fully_attached { - let main_line_start_y = port_line_start.y + port_line_len; - let main_line_start = Vector2(port_line_start.x, main_line_start_y); - fg.main_line.set_size(Vector2(0.0, 0.0)); - bg.main_line.layout_v(main_line_start, main_line_len - port_line_len); - } else { - bg.main_line.set_size(Vector2(0.0, 0.0)); - fg.main_line.layout_v(port_line_start, main_line_len); - } - } - - - if !is_down { - // === Corner2 & Corner3 Radius === - // - // ╭─────╮ 2╭───╮3 - // ╰─────╯──╯1 â–¢ - - let corner2_radius = corner1_radius; - let corner3_radius = upward_corner_radius; - - let corner2_x = corner1_target.x + corner1_radius; - let corner3_x = port_line_end.x - corner3_radius; - let corner2_bbox_x = corner2_x - corner2_radius; - let corner3_bbox_x = corner3_x + corner3_radius; - - let corner_2_3_dist = corner3_bbox_x - corner2_bbox_x; - let corner_2_3_side = corner_2_3_dist.signum(); - let corner_2_3_dist = corner_2_3_dist.abs(); - let corner_2_3_width = corner2_radius + corner3_radius; - let corner_2_3_do_scale = corner_2_3_dist < corner_2_3_width; - let corner_2_3_scale = corner_2_3_dist / corner_2_3_width; - let corner_2_3_scale = if corner_2_3_do_scale { corner_2_3_scale } else { 1.0 }; - - let side_combined = side * corner_2_3_side; - let corner2_radius = corner2_radius * corner_2_3_scale; - let corner3_radius = corner3_radius * corner_2_3_scale; - let is_right_side = (side_combined - 1.0).abs() < std::f32::EPSILON; - - - // === Layout State Update === - // Corner case: we are above the node and the corners loop back - match (side < 0.0, corner_2_3_side < 0.0) { - (false, true) => self.layout_state.set(LayoutState::TopCenterRightLoop), - (true, true) => self.layout_state.set(LayoutState::TopCenterLeftLoop), - _ => (), - }; - - - // === Corner2 & Corner3 Placement === - // - // ╭─────╮ 2╭───╮3 - // ╰─────╯──╯1 â–¢ - - let corner3_side = (corner3_radius + PADDING) * 2.0; - let corner3_size = Vector2(corner3_side, corner3_side); - let corner3_x = port_line_end.x - corner_2_3_side * corner3_radius; - let corner3_y = port_line_end.y; - let corner2_y = corner3_y + corner3_radius - corner2_radius; - let corner2_y = max(corner2_y, corner1.y); - let corner3_y = max(corner3_y, corner2_y - corner3_radius + corner2_radius); - let corner3 = Vector2(corner3_x * side, corner3_y); - let corner3_angle = if is_right_side { 0.0 } else { -RIGHT_ANGLE }; - - if fully_attached { - fg.corner3.set_size(Vector2(0.0, 0.0)); - bg.corner3.set_size(corner3_size); - bg.corner3.start_angle.set(corner3_angle); - bg.corner3.angle.set(RIGHT_ANGLE); - bg.corner3.radius.set(corner3_radius); - bg.corner3.pos.set(corner3); - bg.corner3.dim.set(Vector2(INFINITE, INFINITE)); - bg.corner3.set_xy(corner3); - } else { - bg.corner3.set_size(Vector2(0.0, 0.0)); - fg.corner3.set_size(corner3_size); - fg.corner3.start_angle.set(corner3_angle); - fg.corner3.angle.set(RIGHT_ANGLE); - fg.corner3.radius.set(corner3_radius); - fg.corner3.pos.set(corner3); - fg.corner3.dim.set(zero()); - fg.corner3.set_xy(corner3); - } - - let corner2_x = corner1_target.x + corner_2_3_side * corner2_radius; - let corner2 = Vector2(corner2_x * side, corner2_y); - let corner2_angle = if is_right_side { -RIGHT_ANGLE } else { 0.0 }; - - if fully_attached { - fg.corner2.set_size(Vector2(0.0, 0.0)); - bg.corner2.set_size(corner1_size); - bg.corner2.start_angle.set(corner2_angle); - bg.corner2.angle.set(RIGHT_ANGLE); - bg.corner2.radius.set(corner2_radius); - bg.corner2.pos.set(corner2); - bg.corner2.dim.set(Vector2(INFINITE, INFINITE)); - bg.corner2.set_xy(corner2); - } else { - bg.corner2.set_size(Vector2(0.0, 0.0)); - fg.corner2.set_size(corner1_size); - fg.corner2.start_angle.set(corner2_angle); - fg.corner2.angle.set(RIGHT_ANGLE); - fg.corner2.radius.set(corner2_radius); - fg.corner2.pos.set(corner2); - fg.corner2.dim.set(zero()); - fg.corner2.set_xy(corner2); - } - - - // === Main Line (upwards) === - // - // Main line is the first vertical line of the edge placed between the corner 1 and the - // corner 2. In case the line is long enough, it has an arrow pointing up to show its - // direction. - // - // ╭─────╮ 2╭───╮3 - // ╰─────╯──╯1 â–¢ - - let main_line_len = corner2_y - corner1.y; - let main_line_start = Vector2(side * corner1_target.x, corner1.y); - - if fully_attached { - fg.main_line.set_size(Vector2(0.0, 0.0)); - bg.main_line.layout_v(main_line_start, main_line_len); - } else { - bg.main_line.set_size(Vector2(0.0, 0.0)); - fg.main_line.layout_v(main_line_start, main_line_len); - } - - if main_line_len > ARROW_SIZE_Y { - let arrow_y = (corner1.y - corner1_radius + corner2_y + corner2_radius) / 2.0; - let arrow_pos = Vector2(main_line_start.x, arrow_y); - let arrow_size = Vector2(ARROW_SIZE_X, ARROW_SIZE_Y); - if fully_attached { - fg.arrow.set_size(Vector2(0.0, 0.0)); - bg.arrow.set_size(arrow_size); - bg.arrow.set_xy(arrow_pos); - } else { - bg.arrow.set_size(Vector2(0.0, 0.0)); - fg.arrow.set_size(arrow_size); - fg.arrow.set_xy(arrow_pos); - } - } else { - bg.arrow.set_size(Vector2(0.0, 0.0)); - fg.arrow.set_size(Vector2(0.0, 0.0)); - } - - - // === Side Line 2 === - // - // Side line 2 is the horizontal line connecting corner 2 and corner 3. + self.inputs.color.get() + }; + let bg_color = styles.get_color(theme::application::background); + let focused_color = color::mix(bg_color, normal_color, 0.25); + let (source_color, target_color) = match focus_split.map(|split| split.closer_end) { + Some(EndPoint::Target) => (focused_color, normal_color), + Some(EndPoint::Source) => (normal_color, focused_color), + None => (normal_color, normal_color), + }; + State { + layout, + colors: Colors { source_color, target_color }, + is_attached: IsAttached { is_attached }, + focus_split: FocusSplit { focus_split }, + } + } + + fn apply_state(&self, state: &State) { + let StateUpdate { layout, colors, is_attached, focus_split } = + state.compare(&self.state.borrow()); + let display_object_dirty = None + .or(any(layout, is_attached).changed( + |(Layout { corners, .. }, IsAttached { is_attached, .. })| { + let hover_corners = is_attached.then_some(&corners[..]).unwrap_or_default(); + self.shapes.redraw_hover_sections(self, hover_corners) + }, + )) + .or(any4(layout, colors, focus_split, is_attached).changed( + |( + Layout { corners, arrow, .. }, + Colors { source_color, target_color, .. }, + FocusSplit { focus_split, .. }, + IsAttached { is_attached, .. }, + )| { + self.shapes.redraw_sections(self, render::RedrawSections { + corners, + source_color: *source_color, + target_color: *target_color, + focus_split: *focus_split, + is_attached: *is_attached, + }); + self.shapes.redraw_dataflow_arrow(self, render::RedrawDataflowArrow { + arrow: *arrow, + source_color: *source_color, + target_color: *target_color, + focus_split: *focus_split, + is_attached: *is_attached, + }); + }, + )) + .or(any(layout, colors).changed( + |(Layout { target_attachment, .. }, Colors { target_color, .. })| { + self.shapes.redraw_target_attachment(self, *target_attachment, *target_color); + }, + )) + .is_some(); + if display_object_dirty { + // Force layout update of this object's children. Because edge positions are computed + // based on node positions, `redraw` must be run after the layout has been updated. + // Updating the layouts of modified edges a second time later in the frame avoids + // latency when edge children are modified. // - // ╭─────╮ 2╭───╮3 - // ╰─────╯──╯1 â–¢ - - let side_line2_len = side * (corner3_x - corner2_x); - let side_line2_start = Vector2(side * corner2_x, corner2_y + corner2_radius); - if fully_attached { - fg.side_line2.set_size(Vector2(0.0, 0.0)); - bg.side_line2.layout_h(side_line2_start, side_line2_len); - } else { - bg.side_line2.set_size(Vector2(0.0, 0.0)); - fg.side_line2.layout_h(side_line2_start, side_line2_len); - } - - port_line_len = corner3_y - port_line_start.y; - } else { - fg.arrow.set_size(Vector2(0.0, 0.0)); - bg.arrow.set_size(Vector2(0.0, 0.0)); - fg.corner3.set_size(Vector2(0.0, 0.0)); - bg.corner3.set_size(Vector2(0.0, 0.0)); - fg.corner2.set_size(Vector2(0.0, 0.0)); - bg.corner2.set_size(Vector2(0.0, 0.0)); - fg.side_line2.set_size(Vector2(0.0, 0.0)); - bg.side_line2.set_size(Vector2(0.0, 0.0)); + // FIXME: Find a better solution to fix this issue. We either need a layout that can + // depend on other arbitrary position, or we need the layout update to be multi-stage. + self.display_object.update(&self.scene); } - - - // === Port Line === - fg.port_line.layout_v(port_line_start, port_line_len); } } -// === Edge Splitting === +// === Low-level operations === -impl EdgeModelData { - /// Return whether the point is in the upper half of the overall edge shape. - fn is_in_upper_half(&self, point: Vector2) -> bool { - let world_space_source = self.position().y; - let world_space_target = self.target_position.get().y; - let mid_y = (world_space_source + world_space_target) / 2.0; - point.y > mid_y +impl EdgeModel { + fn source_half_width(&self) -> f32 { + self.inputs.source_size.get().x() / 2.0 } - /// Returns whether the given position should detach the the `Input` or `Output` part of the - /// edge. - /// - /// We determine the target port primarily based y-position. We only use the y distance to the - /// start/end of the edge and whichever is closer, is the target. However, this becomes - /// problematic if the start and end of the edge have the same y-position or even if they are - /// almost level. That is why, we then switch to using the euclidean distance instead. - pub fn port_to_detach_for_position(&self, point: Vector2) -> PortType { - if self.input_and_output_y_too_close() { - return self.closest_end_for_point(point); - } - let input_port_is_in_upper_half = self.layout_state.get().is_input_above_output(); - let point_is_in_upper_half = self.is_in_upper_half(point); + fn screen_pos_to_scene_pos(&self, screen_pos: Vector2) -> SceneCoords { + let screen_pos_3d = Vector3(screen_pos.x(), screen_pos.y(), 0.0); + SceneCoords(self.scene.screen_to_scene_coordinates(screen_pos_3d).xy()) + } - // We always detach the port that is on the opposite side of the cursor. - if point_is_in_upper_half != input_port_is_in_upper_half { - PortType::InputPort - } else { - PortType::OutputPort - } + fn scene_pos_to_parent_pos(&self, scene_pos: SceneCoords) -> ParentCoords { + ParentCoords(*scene_pos - self.display_object.xy()) } - /// Return the `EndDesignation` for the closest end of the edge for the given point. Uses - /// euclidean distance between point and `Input`/`Output`. - fn closest_end_for_point(&self, point: Vector2) -> PortType { - let target_position = self.target_position.get().xy(); - let source_position = self.position().xy() - Vector2(0.0, self.source_height.get() / 2.0); - let target_distance = (point - target_position).norm(); - let source_distance = (point - source_position).norm(); - if source_distance > target_distance { - PortType::OutputPort - } else { - PortType::InputPort - } + fn closer_end(&self, pos: ParentCoords) -> Option { + let state = self.state.borrow(); + let state = state.as_ref()?; + let source_height = self.inputs.source_size.get().y(); + layout::find_position(pos, &state.layout, source_height, render::HOVER_WIDTH) + .map(|split| split.closer_end) } - /// Indicates whether the height difference between input and output is too small to use the - /// y value to assign the `EndDesignation` for a given point. - fn input_and_output_y_too_close(&self) -> bool { - let target_y = self.position().y; - let source_y = self.target_position.get().y; - let delta_y = target_y - source_y; - delta_y > 0.0 && delta_y < MIN_SOURCE_TARGET_DIFFERENCE_FOR_Y_VALUE_DISCRIMINATION + fn target_offset(&self) -> Vector2 { + *self.inputs.target_position.get() - self.display_object.xy() } +} - /// Return the correct cut angle for the given `shape_id` at the `position` to focus the - /// `target_end`. Will return `None` if the `shape_id` is not a valid sub-shape of this edge. - fn cut_angle_for_shape( - &self, - shape_id: display::object::Id, - position: Vector2, - target_end: PortType, - ) -> Option { - let shape = self.get_shape(shape_id)?; - let shape_role = self.get_shape_role(shape_id)?; - let cut_angle_correction = self.get_cut_angle_correction(shape_role); - let target_angle = self.get_target_angle(target_end); +// === Trait implementations === - let base_rotation = shape.display_object().rotation().z + 2.0 * RIGHT_ANGLE; - let shape_normal = shape.normal(position).angle(); - Some(shape_normal - base_rotation + cut_angle_correction + target_angle) +impl display::Object for EdgeModel { + fn display_object(&self) -> &display::object::Instance { + &self.display_object } +} - /// Return the cut angle value needed to focus the given end of the shape. This takes into - /// account the current layout. - fn get_target_angle(&self, target_end: PortType) -> f32 { - let output_on_top = self.layout_state.get().is_output_above_input(); - match (output_on_top, target_end) { - (false, PortType::InputPort) => 2.0 * RIGHT_ANGLE, - (false, PortType::OutputPort) => 0.0, - (true, PortType::InputPort) => 0.0, - (true, PortType::OutputPort) => 2.0 * RIGHT_ANGLE, - } +impl ShapeParent for EdgeModel { + fn scene(&self) -> &Scene { + &self.scene } +} - /// These corrections are needed as sometimes shapes are in places that lead to inconsistent - /// results, e.g., the side line leaving the node from left/right or right/left. The shape - /// itself does not have enough information about its own placement to determine which end - /// is pointed towards the `Target` or `Source` part of the whole edge. So we need to account - /// for these here based on the specific layout state we are in. - /// - /// Example: - /// ```text - /// - /// Case 1 - /// - /// (===)----... - /// Node Side Line - /// - /// Case 2 - /// - /// ...----(===) - /// Side Line Node - /// ``` - /// - /// In both case 1 and 2 the side line is oriented left to right just placed in a different - /// location. However, in Case 1 the left side of the line is "output side" and in Case 2 the - /// right side is "output side". So if we want to set an equivalent angle, we need to apply a - /// correction based on this layout property. - fn get_cut_angle_correction(&self, shape_role: ShapeRole) -> f32 { - let layout_state = self.layout_state.get(); - - let flip = 2.0 * RIGHT_ANGLE; - - // These rules are derived from the algorithm in `redraw`. In some layout configurations - // shapes are inverted top/down or left/right and we need to apply the appropriate - // corrections here. Sometimes these are just the side-effect of some layouting mechanics - // without visual justification (e.g., the `PortLine` sometimes ends up with a negative - // height and is thus flipped upside down. - match (layout_state, shape_role) { - (LayoutState::DownLeft, ShapeRole::SideLine) => flip, - (LayoutState::DownLeft, ShapeRole::Corner) => flip, - - (LayoutState::UpLeft, ShapeRole::PortLine) => flip, - (LayoutState::UpLeft, ShapeRole::Corner) => flip, - - (LayoutState::UpRight, ShapeRole::PortLine) => flip, - (LayoutState::UpRight, ShapeRole::Corner3) => flip, - (LayoutState::UpRight, ShapeRole::SideLine2) => flip, - (LayoutState::UpRight, ShapeRole::Corner2) => flip, - (LayoutState::UpRight, ShapeRole::SideLine) => flip, - (LayoutState::TopCenterRightLoop, ShapeRole::SideLine) => flip, - (LayoutState::TopCenterRightLoop, ShapeRole::PortLine) => flip, - (LayoutState::TopCenterLeftLoop, ShapeRole::SideLine2) => flip, - (LayoutState::TopCenterLeftLoop, ShapeRole::Corner2) => flip, - (LayoutState::TopCenterLeftLoop, ShapeRole::Corner) => flip, - (LayoutState::TopCenterLeftLoop, ShapeRole::Corner3) => flip, - (LayoutState::TopCenterLeftLoop, ShapeRole::PortLine) => flip, +// ========================== +// === Coordinate systems === +// ========================== - (_, ShapeRole::Arrow) => RIGHT_ANGLE, +mod coords { + use super::*; - _ => 0.0, - } - } + /// Marker for coordinates relative to the origin of the parent display object. + #[derive(Debug, Copy, Clone, PartialEq, Default)] + pub struct ParentOrigin; - /// Return a reference to sub-shape indicated by the given shape id. - fn get_shape(&self, id: display::object::Id) -> Option<&dyn EdgeShape> { - let shape_ref = self.back.get_shape(id); - if shape_ref.is_some() { - return shape_ref; - } - self.front.get_shape(id) - } + /// Marker for coordinates relative to the origin of the scene. + #[derive(Debug, Copy, Clone, PartialEq, Default)] + pub struct SceneOrigin; - /// Return the `ShapeRole` for the given sub-shape. - fn get_shape_role(&self, id: display::object::Id) -> Option { - let shape_type = self.back.get_shape_type(id); - if shape_type.is_some() { - return shape_type; - } - self.front.get_shape_type(id) + /// Coordinates marked to identify different coordinate spaces. + #[derive(Debug, Copy, Clone, PartialEq, Default, Deref)] + pub struct Coords { + #[deref] + coords: Vector2, + space: PhantomData<*const Space>, } - /// Check whether the provided point is close enough to be snapped to the edge. - fn try_point_snap( - &self, - point: Vector2, - focus_shape_id: display::object::Id, - ) -> Option { - let focus_shape = self.get_shape(focus_shape_id)?; - let snap_position = focus_shape.snap(point)?; - Some(SnapTarget::new(snap_position, focus_shape_id)) - } + pub type ParentCoords = Coords; + pub type SceneCoords = Coords; - /// Disable the splitting of the shape. - fn focus_none(&self) { - for shape in self.shapes() { - shape.focus_none(); - } + #[allow(non_snake_case)] + pub fn ParentCoords(coords: Vector2) -> ParentCoords { + Coords { coords, space: default() } } - - /// FocusSplit the shape at the given `position` and focus the given `EndDesignation`. This - /// might fail if the given position is too far from the shape. - fn try_enable_focus_split( - &self, - position: Vector2, - focus_shape_id: display::object::Id, - part: PortType, - ) -> Result { - let snap_data = self.try_point_snap(position, focus_shape_id).ok_or(())?; - let semantic_split = SemanticSplit::new(self, snap_data.target_shape_id).ok_or(())?; - let angle = self.cut_angle_for_shape(snap_data.target_shape_id, position, part).ok_or(())?; - - // Completely disable/enable focus for shapes that are not close to the split based on their - // relative position within the shape. This avoids issues with splitting not working - // correctly when a split would intersect the edge at multiple points. - semantic_split.output_side_shapes().iter().for_each(|shape_id| { - if let Some(shape) = self.get_shape(*shape_id) { - match part { - PortType::OutputPort => shape.focus_all(), - PortType::InputPort => shape.focus_none(), - } - } - }); - semantic_split.input_side_shapes().iter().for_each(|shape_id| { - if let Some(shape) = self.get_shape(*shape_id) { - match part { - PortType::OutputPort => shape.focus_none(), - PortType::InputPort => shape.focus_all(), - } - } - }); - // Apply a split to the shapes at the split location, and next to the split shapes. The - // extension to neighbours is required to show the correct transition from one shape to the - // next. - semantic_split.split_shapes().iter().for_each(|shape_id| { - if let Some(shape) = self.get_shape(*shape_id) { - let split_data = FocusSplit::new(snap_data.position, angle); - shape.set_focus_split(split_data) - } - }); - Ok(snap_data) + #[allow(non_snake_case)] + pub fn SceneCoords(coords: Vector2) -> SceneCoords { + Coords { coords, space: default() } } } +use coords::*; diff --git a/app/gui/view/graph-editor/src/component/edge/inputs.rs b/app/gui/view/graph-editor/src/component/edge/inputs.rs new file mode 100644 index 000000000000..ed48332181a0 --- /dev/null +++ b/app/gui/view/graph-editor/src/component/edge/inputs.rs @@ -0,0 +1,68 @@ +use crate::prelude::*; +use ensogl::data::color; + +use super::coords::*; + + + +// ============== +// === Inputs === +// ============== + +/// The inputs to the edge state computation. These values are all set orthogonally, so that the +/// order of events that set different properties doesn't affect the outcome. +#[derive(Debug, Default)] +pub(super) struct Inputs { + /// The width and height of the node that originates the edge. The edge may begin anywhere + /// around the bottom half of the node. + pub source_size: Cell, + /// The coordinates of the node input the edge connects to. The edge enters the node from + /// above. + pub target_position: Cell, + /// Whether the edge is connected to a node input. + pub target_attached: Cell, + /// Whether the edge is connected to a node output. + pub source_attached: Cell, + pub color: Cell, + /// The location of the mouse over the edge. + pub hover_position: Cell>, + pub disabled: Cell, + /// Reset the hover position at next redraw. + pub clear_focus: Cell, +} + +impl Inputs { + /// Set the color of the edge. + pub(super) fn set_color(&self, color: color::Lcha) { + // We must never use alpha in edges, as it will show artifacts with overlapping sub-parts. + let color: color::Lcha = color.opaque.into(); + let color_rgba = color::Rgba::from(color); + self.color.set(color_rgba); + } + + pub(super) fn set_source_size(&self, size: Vector2) { + self.source_size.set(size); + } + + pub(super) fn set_disabled(&self, disabled: bool) { + self.disabled.set(disabled); + } + + pub(super) fn set_target_position(&self, position: ParentCoords) { + self.target_position.set(position); + } + + pub(super) fn set_target_attached(&self, attached: bool) { + self.target_attached.set(attached); + self.clear_focus.set(true); + } + + pub(super) fn set_source_attached(&self, attached: bool) { + self.source_attached.set(attached); + self.clear_focus.set(true); + } + + pub(super) fn set_mouse_position(&self, pos: SceneCoords) { + self.hover_position.set(Some(pos)); + } +} diff --git a/app/gui/view/graph-editor/src/component/edge/layout.rs b/app/gui/view/graph-editor/src/component/edge/layout.rs new file mode 100644 index 000000000000..62c5dd6e9951 --- /dev/null +++ b/app/gui/view/graph-editor/src/component/edge/layout.rs @@ -0,0 +1,679 @@ +//! Edge layout calculation. +//! +//! # Corners +//! +//! ```text +//! ────╮ +//! ``` +//! +//! The fundamental unit of edge layout is the [`Corner`]. A corner is a line segment attached to a +//! 90° arc. The length of the straight segment, the radius of the arc, and the orientation of the +//! shape may vary. Any shape of edge is built from corners. +//! +//! The shape of a corner can be fully-specified by two points: The horizontal end, and the vertical +//! end. +//! +//! In special cases, a corner may be *trivial*: It may have a radius of zero, in which case either +//! the horizontal or vertical end will not be in the usual orientation. The layout algorithm only +//! produces trivial corners when the source is directly in line with the target, or in some cases +//! when subdividing a corner (see [Partial edges] below). +//! +//! # Junction points +//! +//! ```text +//! 3 +//! 1 / +//! \ ╭─────╮ +//! ────╯\ \ +//! 2 4 +//! ``` +//! +//! The layout algorithm doesn't directly place corners. The layout algorithm places a sequence of +//! junction points--coordinates where two horizontal corner ends or two vertical corner ends meet +//! (or just one corner end, at an end of an edge). A series of junction points, always alternating +//! horizontal/vertical, has a one-to-one relationship with a sequence of corners. +//! +//! # Partial edges +//! +//! Corners are sufficient to draw any complete edge; however, in order to split an edge into a +//! focused portion and an unfocused portion at an arbitrary location based on the mouse position, +//! we need to subdivide one of the corners of the edge. +//! +//! ```text +//! |\ +//! | 3 +//! / +//! .' +//! ..........-' +//! \ \ +//! 1 2 (split) +//! ``` +//! +//! When the split position is on the straight segment of a corner, the corner can simply be split +//! into a corner with a shorter segment (2-3), and a trivial corner consisting only of a straight +//! segment (1-2). +//! +//! ```text +//! |\ +//! | 4 +//! / +//! .' +//! ..........-' \ +//! \ \ 3 (split) +//! 1 2 +//! ``` +//! +//! The difficult case is when the split position is on the arc. In this case, it is not possible to +//! draw the split using the same [`Rectangle`] shader that is used for everything else; a +//! specialized shape is used which supports drawing arbitrary-angle arcs. A trivial corner will +//! draw the straight line up to the beginning of the arc (1-2); arc shapes will draw the split arc +//! (2-3) and (3-4). + +use super::*; + +use std::f32::consts::FRAC_PI_2; +use std::f32::consts::TAU; + + + +// ================= +// === Constants === +// ================= + +/// Constants affecting all layouts. +mod shared { + /// Minimum height above the target the edge must approach it from. + pub(super) const MIN_APPROACH_HEIGHT: f32 = 32.25; + pub(super) const NODE_HEIGHT: f32 = crate::component::node::HEIGHT; + pub(super) const NODE_CORNER_RADIUS: f32 = crate::component::node::CORNER_RADIUS; + /// The preferred arc radius. + pub(super) const RADIUS_BASE: f32 = 20.0; +} +use shared::*; + +/// Constants configuring the 1-corner layout. +mod single_corner { + /// The y-allocation for the radius will be the full available height minus this value. + pub(super) const RADIUS_Y_ADJUSTMENT: f32 = 29.0; + /// The base x-allocation for the radius. + pub(super) const RADIUS_X_BASE: f32 = super::RADIUS_BASE; + /// Proportion (0-1) of extra x-distance allocated to the radius. + pub(super) const RADIUS_X_FACTOR: f32 = 0.6; + /// Distance for the line to continue under the node, to ensure that there isn't a gap. + pub(super) const SOURCE_NODE_OVERLAP: f32 = 4.0; + /// Minimum arc radius at which we offset the source end to exit normal to the node's curve. + pub(super) const MINIMUM_TANGENT_EXIT_RADIUS: f32 = 2.0; +} + +/// Constants configuring the 3-corner layouts. +mod three_corner { + /// The maximum arc radius. + pub(super) const RADIUS_MAX: f32 = super::RADIUS_BASE; + pub(super) const BACKWARD_EDGE_ARROW_THRESHOLD: f32 = 15.0; + /// The maximum radius reduction (from [`RADIUS_BASE`]) to allow when choosing whether to use + /// the three-corner layout that doesn't use a backward corner. + pub(super) const MAX_SQUEEZE: f32 = 2.0; +} + + + +// ============== +// === Layout === +// ============== + +/// Determine the positions and shapes of all the components of the edge. +pub(super) fn layout(source_half_width: f32, target: Vector2, target_attached: bool) -> Layout { + let (junction_points, max_radius, attachment_length) = + junction_points(source_half_width, target, target_attached); + let corners = corners(&junction_points, max_radius).collect_vec(); + let arrow = arrow(target, &junction_points); + let target_attachment = attachment_length.map(|length| TargetAttachment { target, length }); + Layout { corners, arrow, target_attachment } +} + + + +// ======================= +// === Junction points === +// ======================= + +/// Calculate the start and end positions of each 1-corner section composing an edge to the +/// given offset. Return the points, the maximum radius that should be used to draw the corners +/// connecting them, and the length of the target attachment bit. +fn junction_points( + source_half_width: f32, + target: Vector2, + target_attached: bool, +) -> (Vec, f32, Option) { + // The maximum x-distance from the source (our local coordinate origin) for the point where the + // edge will begin. + let source_max_x_offset = (source_half_width - NODE_CORNER_RADIUS).max(0.0); + // The maximum y-length of the target-attachment segment. If the layout allows, the + // target-attachment segment will fully exit the node before the first corner begins. + let target_max_attachment_height = target_attached.then_some(NODE_HEIGHT / 2.0); + let target_well_below_source = + target.y() + target_max_attachment_height.unwrap_or_default() <= -MIN_APPROACH_HEIGHT; + let target_below_source = target.y() < -NODE_HEIGHT / 2.0; + let target_beyond_source = target.x().abs() > source_max_x_offset; + let horizontal_room_for_3_corners = target_beyond_source + && target.x().abs() - source_max_x_offset + >= 3.0 * (RADIUS_BASE - three_corner::MAX_SQUEEZE); + if target_well_below_source || (target_below_source && !horizontal_room_for_3_corners) { + use single_corner::*; + // The edge can originate anywhere along the length of the node. + let source_x = target.x().clamp(-source_max_x_offset, source_max_x_offset); + let distance_x = max(target.x().abs() - source_half_width, 0.0); + let radius_x = RADIUS_X_BASE + distance_x * RADIUS_X_FACTOR; + // The minimum length of straight line there should be at the target end of the edge. This + // is a fixed value, except it is reduced when the target is horizontally very close to the + // edge of the source, so that very short edges are less sharp. + let y_adjustment = min( + target.x().abs() - source_half_width + RADIUS_Y_ADJUSTMENT / 2.0, + RADIUS_Y_ADJUSTMENT, + ); + let radius_y = max(target.y().abs() - y_adjustment, 0.0); + let max_radius = min(radius_x, radius_y); + // The radius the edge would have, if the arc portion were as large as possible. + let natural_radius = min((target.x() - source_x).abs(), target.y().abs()); + let source_y = if natural_radius > MINIMUM_TANGENT_EXIT_RADIUS { + // Offset the beginning of the edge so that it is normal to the curve of the source node + // at the point that it exits the node. + let radius = min(natural_radius, max_radius); + let arc_origin_x = target.x().abs() - radius; + let source_arc_origin = source_half_width - NODE_CORNER_RADIUS; + let circle_offset = arc_origin_x - source_arc_origin; + let intersection = circle_intersection(circle_offset, NODE_CORNER_RADIUS, radius); + -(radius - intersection).abs() + } else { + SOURCE_NODE_OVERLAP - NODE_HEIGHT / 2.0 + }; + let source = Vector2(source_x, source_y); + // The target attachment will extend as far toward the edge of the node as it can without + // rising above the source. + let attachment_height = target_max_attachment_height.map(|dy| min(dy, target.y().abs())); + let attachment_y = target.y() + attachment_height.unwrap_or_default(); + let target_attachment = Vector2(target.x(), attachment_y); + (vec![source, target_attachment], max_radius, attachment_height) + } else { + use three_corner::*; + // The edge originates from either side of the node. + let source_x = source_half_width.copysign(target.x()); + let distance_x = (target.x() - source_x).abs(); + let (j0_x, j1_x, height_adjustment); + if horizontal_room_for_3_corners { + // J1 + // / + // ╭──────╮ + // ╭─────╮ │ â–¢ + // ╰─────╯────╯\ + // J0 + // Junctions (J0, J1) are in between source and target. + let j0_dx = min(2.0 * RADIUS_MAX, distance_x / 2.0); + let j1_dx = min(RADIUS_MAX, (distance_x - j0_dx) / 2.0); + j0_x = source_x + j0_dx.copysign(target.x()); + j1_x = j0_x + j1_dx.copysign(target.x()); + height_adjustment = RADIUS_MAX - j1_dx; + } else { + // J1 + // / + // ╭──────╮ J0 + // â–¢ │/ + // ╭─────╮ │ + // ╰─────╯────╯ + // J0 > source; J0 > J1; J1 > target. + j1_x = target.x() + RADIUS_MAX.copysign(target.x()); + let j0_beyond_target = target.x().abs() + RADIUS_MAX * 2.0; + let j0_beyond_source = source_x.abs() + RADIUS_MAX; + j0_x = j0_beyond_source.max(j0_beyond_target).copysign(target.x()); + height_adjustment = 0.0; + } + let attachment_height = target_max_attachment_height.unwrap_or_default(); + let top = + max(target.y() + MIN_APPROACH_HEIGHT + attachment_height - height_adjustment, 0.0); + let source = Vector2(source_x, 0.0); + let j0 = Vector2(j0_x, top / 2.0); + let j1 = Vector2(j1_x, top); + // The corners meet the target attachment at the top of the node. + let attachment_height = target_max_attachment_height.unwrap_or_default(); + let target_attachment = target + Vector2(0.0, attachment_height); + (vec![source, j0, j1, target_attachment], RADIUS_MAX, Some(attachment_height)) + } +} + + + +// ================== +// === End points === +// ================== + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(super) enum EndPoint { + Source, + Target, +} + + + +// ======================= +// === Splitting edges === +// ======================= + +#[derive(Debug, Copy, Clone, PartialEq)] +pub(super) struct EdgeSplit { + pub corner_index: usize, + pub closer_end: EndPoint, + pub split_corner: SplitCorner, +} + +/// Find a point along the edge. Return the index of the corner the point occurs in, and which end +/// is closer to the point, and information about how the corner under the point has been split. +/// +/// Returns [`None`] if the point is not on the edge. +pub(super) fn find_position( + position: ParentCoords, + layout: &Layout, + source_height: f32, + input_width: f32, +) -> Option { + let position = *position; + let corners = &layout.corners; + let corner_index = corners + .iter() + .position(|&corner| corner.bounding_box(input_width).contains_inclusive(position))?; + let split_corner = corners[corner_index].split(position, input_width)?; + let (full_corners, following_corners) = corners.split_at(corner_index); + let full_corners_distance: f32 = + full_corners.iter().map(|&corner| corner.rectilinear_length()).sum(); + let following_distance: f32 = + following_corners.iter().map(|&corner| corner.rectilinear_length()).sum(); + let target_attachment_distance = + layout.target_attachment.map(|bit| bit.length).unwrap_or_default(); + // The source end of the edge is on a horizontal line through the center of the source node + // (this gives nice behavior when the edge exits the end at an angle). To accurately determine + // which end a point appears closer to, we must exclude the portion of the edge that is hidden + // under the source node. + let hidden_source_distance = source_height / 2.0; + let total_distance = full_corners_distance + following_distance - hidden_source_distance + + target_attachment_distance; + let offset_from_partial_corner = position - corners[corner_index].source_end(); + let partial_corner_distance = + offset_from_partial_corner.x().abs() + offset_from_partial_corner.y().abs(); + let distance_from_source = + full_corners_distance + partial_corner_distance - hidden_source_distance; + let closer_end = match distance_from_source * 2.0 < total_distance { + true => EndPoint::Source, + false => EndPoint::Target, + }; + Some(EdgeSplit { corner_index, closer_end, split_corner }) +} + + + +// ====================================== +// === Connecting points with corners === +// ====================================== + +fn corners(points: &[Vector2], max_radius: f32) -> impl Iterator> + '_ { + let mut next_direction = CornerDirection::HorizontalToVertical; + points.array_windows().map(move |&[p0, p1]| { + let direction = next_direction; + next_direction = next_direction.reverse(); + let corner = match direction { + CornerDirection::HorizontalToVertical => + Corner { horizontal: p0, vertical: p1, max_radius }, + CornerDirection::VerticalToHorizontal => + Corner { horizontal: p1, vertical: p0, max_radius }, + }; + Oriented::new(corner, direction) + }) +} + + + +// ============== +// === Corner === +// ============== + +#[derive(Debug, Copy, Clone, PartialEq)] +pub(super) struct Corner { + horizontal: Vector2, + vertical: Vector2, + max_radius: f32, +} + +impl Corner { + #[inline] + pub fn clip(self) -> Vector2 { + let Corner { horizontal, vertical, .. } = self; + let (dx, dy) = (vertical.x() - horizontal.x(), horizontal.y() - vertical.y()); + let (x_clip, y_clip) = (0.5f32.copysign(dx), 0.5f32.copysign(dy)); + Vector2(x_clip, y_clip) + } + + #[inline] + pub fn origin(self, line_width: f32) -> Vector2 { + let Corner { horizontal, vertical, .. } = self; + let x = horizontal.x().min(vertical.x() - line_width / 2.0); + let y = vertical.y().min(horizontal.y() - line_width / 2.0); + Vector2(x, y) + } + + #[inline] + pub fn size(self, line_width: f32) -> Vector2 { + let Corner { horizontal, vertical, .. } = self; + let offset = horizontal - vertical; + let width = (offset.x().abs() + line_width / 2.0).max(line_width); + let height = (offset.y().abs() + line_width / 2.0).max(line_width); + Vector2(width, height) + } + + #[inline] + pub fn max_radius(self) -> f32 { + self.max_radius + } + + fn bounding_box(self, line_width: f32) -> BoundingBox { + let origin = self.origin(line_width); + let size = self.size(line_width); + BoundingBox::from_position_and_size_unchecked(origin, size) + } + + #[allow(unused)] + fn euclidean_length(self) -> f32 { + let Corner { horizontal, vertical, max_radius } = self; + let offset = horizontal - vertical; + let (dx, dy) = (offset.x().abs(), offset.y().abs()); + let radius = min(dx, dy).min(max_radius); + let linear_x = dx - radius; + let linear_y = dy - radius; + let arc = FRAC_PI_2 * radius; + arc + linear_x + linear_y + } + + fn rectilinear_length(self) -> f32 { + let Corner { horizontal, vertical, .. } = self; + let offset = horizontal - vertical; + offset.x().abs() + offset.y().abs() + } + + #[allow(unused)] + fn transpose(self) -> Self { + let Corner { horizontal, vertical, max_radius } = self; + Corner { horizontal: vertical.yx(), vertical: horizontal.yx(), max_radius } + } + + fn vertical_end_angle(self) -> f32 { + match self.vertical.x() > self.horizontal.x() { + true => 0.0, + false => std::f32::consts::PI.copysign(self.horizontal.y() - self.vertical.y()), + } + } + + fn horizontal_end_angle(self) -> f32 { + FRAC_PI_2.copysign(self.horizontal.y() - self.vertical.y()) + } +} + + +// === Parameters for drawing the arc portion of a corner in two parts === + +#[derive(Debug, Copy, Clone, Default, PartialEq)] +pub(super) struct SplitArc { + pub origin: Vector2, + pub radius: f32, + pub source_end_angle: f32, + pub split_angle: f32, + pub target_end_angle: f32, +} + + + +// ======================== +// === Oriented corners === +// ======================== + +#[derive(Debug, Copy, Clone, Deref, PartialEq)] +pub(super) struct Oriented { + #[deref] + value: T, + direction: CornerDirection, +} + +impl Oriented { + fn new(value: T, direction: CornerDirection) -> Self { + Self { value, direction } + } +} + +impl Oriented { + /// Split the shape at the given point, if the point is within the tolerance specified by + /// `snap_line_width` of the shape. + fn split(self, split_point: Vector2, snap_line_width: f32) -> Option { + let Corner { horizontal, vertical, max_radius } = self.value; + let hv_offset = horizontal - vertical; + let (dx, dy) = (hv_offset.x().abs(), hv_offset.y().abs()); + let radius = min(dx, dy).min(max_radius); + + // Calculate closeness to the straight segments. + let (linear_x, linear_y) = (dx - radius, dy - radius); + let snap_distance = snap_line_width / 2.0; + let y_along_vertical = (self.vertical.y() - split_point.y()).abs() < linear_y; + let x_along_horizontal = (self.horizontal.x() - split_point.x()).abs() < linear_x; + let y_near_horizontal = (self.horizontal.y() - split_point.y()).abs() <= snap_distance; + let x_near_vertical = (self.vertical.x() - split_point.x()).abs() <= snap_distance; + + // Calculate closeness to the arc. + // 1. Find the origin of the circle the arc is part of. + // The corner of our bounding box that is immediately outside the arc. + let point_outside_arc = Vector2(self.vertical.x(), self.horizontal.y()); + // The opposite corner of our bounding box, far inside the arc. + // Used to find the direction from outside the arc to the origin of the arc's circle. + let point_inside_arc = Vector2(self.horizontal.x(), self.vertical.y()); + let outside_to_inside = point_inside_arc - point_outside_arc; + let outside_to_origin = + Vector2(radius.copysign(outside_to_inside.x()), radius.copysign(outside_to_inside.y())); + let origin = point_outside_arc + outside_to_origin; + // 2. Check if the point is on the arc. + let input_to_origin = split_point - origin; + let distance_squared_from_origin = + input_to_origin.x().powi(2) + input_to_origin.y().powi(2); + let min_radius = radius - snap_distance; + let max_radius = radius + snap_distance; + let too_close = distance_squared_from_origin < min_radius.powi(2); + let too_far = distance_squared_from_origin > max_radius.powi(2); + let on_arc = !(too_close || too_far); + + if y_near_horizontal && x_along_horizontal { + // The point is along the horizontal line. Snap its y-value, and draw a corner to it. + let snapped = Vector2(split_point.x(), self.horizontal.y()); + let source_end = self.with_target_end(snapped); + let target_end = self.with_source_end(snapped); + Some(SplitCorner { source_end, target_end, split_arc: None }) + } else if x_near_vertical && y_along_vertical { + // The point is along the vertical line. Snap its x-value, and draw a corner to it. + let snapped = Vector2(self.vertical.x(), split_point.y()); + let source_end = self.with_target_end(snapped); + let target_end = self.with_source_end(snapped); + Some(SplitCorner { source_end, target_end, split_arc: None }) + } else if on_arc { + // Find the input point's angle along the arc. + let offset_from_origin = split_point - origin; + let split_angle = offset_from_origin.y().atan2(offset_from_origin.x()); + // Split the arc on the angle. + let arc_horizontal_end = origin - Vector2(0.0, radius.copysign(outside_to_inside.y())); + let arc_vertical_end = origin - Vector2(radius.copysign(outside_to_inside.x()), 0.0); + let (arc_begin, arc_end) = match self.direction { + CornerDirection::HorizontalToVertical => (arc_horizontal_end, arc_vertical_end), + CornerDirection::VerticalToHorizontal => (arc_vertical_end, arc_horizontal_end), + }; + let source_end = self.with_target_end(arc_begin); + let target_end = self.with_source_end(arc_end); + let source_end_angle = self.source_end_angle(); + let target_end_angle = self.target_end_angle(); + let split_angle = self.clamp_to_arc(split_angle); + let split = + SplitArc { origin, radius, source_end_angle, split_angle, target_end_angle }; + Some(SplitCorner { source_end, target_end, split_arc: Some(split) }) + } else { + None + } + } + + fn clamp_to_arc(self, c: f32) -> f32 { + let a = self.horizontal_end_angle(); + let b = self.vertical_end_angle(); + let a_to_c = (c.rem_euclid(TAU) - a.rem_euclid(TAU)).abs(); + let b_to_c = (c.rem_euclid(TAU) - b.rem_euclid(TAU)).abs(); + let ac = min(a_to_c, TAU - a_to_c); + let bc = min(b_to_c, TAU - b_to_c); + let close_to_a = ac < FRAC_PI_2; + let close_to_b = bc < FRAC_PI_2; + // The angle is on the minor arc if it is close to both limits; otherwise, clamp it to + // whichever is closer. + if close_to_a && close_to_b { + c + } else if ac < bc { + a + } else { + b + } + } + + fn source_end(self) -> Vector2 { + match self.direction { + CornerDirection::VerticalToHorizontal => self.value.vertical, + CornerDirection::HorizontalToVertical => self.value.horizontal, + } + } + + #[allow(unused)] + fn target_end(self) -> Vector2 { + match self.direction { + CornerDirection::VerticalToHorizontal => self.value.horizontal, + CornerDirection::HorizontalToVertical => self.value.vertical, + } + } + + fn with_target_end(mut self, value: Vector2) -> Self { + *(match self.direction { + CornerDirection::VerticalToHorizontal => &mut self.value.horizontal, + CornerDirection::HorizontalToVertical => &mut self.value.vertical, + }) = value; + self + } + + fn with_source_end(mut self, value: Vector2) -> Self { + *(match self.direction { + CornerDirection::VerticalToHorizontal => &mut self.value.vertical, + CornerDirection::HorizontalToVertical => &mut self.value.horizontal, + }) = value; + self + } + + fn source_end_angle(self) -> f32 { + match self.direction { + CornerDirection::HorizontalToVertical => self.horizontal_end_angle(), + CornerDirection::VerticalToHorizontal => self.vertical_end_angle(), + } + } + + fn target_end_angle(self) -> f32 { + self.reverse().source_end_angle() + } + + fn reverse(self) -> Self { + let Self { value, direction } = self; + let direction = direction.reverse(); + Self { value, direction } + } +} + + +// === Corner direction === + +#[derive(Debug, Copy, Clone, PartialEq)] +pub(super) enum CornerDirection { + HorizontalToVertical, + VerticalToHorizontal, +} + +impl CornerDirection { + pub(super) fn reverse(self) -> Self { + match self { + CornerDirection::HorizontalToVertical => CornerDirection::VerticalToHorizontal, + CornerDirection::VerticalToHorizontal => CornerDirection::HorizontalToVertical, + } + } +} + + +// === Split (oriented) corners ==== + +#[derive(Debug, Copy, Clone, PartialEq)] +pub(super) struct SplitCorner { + pub source_end: Oriented, + pub target_end: Oriented, + pub split_arc: Option, +} + + + +// =========================== +// === Backward-edge arrow === +// =========================== + +fn arrow(target_offset: Vector2, junction_points: &[Vector2]) -> Option { + let three_corner_layout = junction_points.len() > 2; + let long_backward_edge = target_offset.y() >= three_corner::BACKWARD_EDGE_ARROW_THRESHOLD; + // The points are ordered from source end to destination, and are alternately horizontal + // and vertical junctions. The arrow must be in a vertical part of the edge. Place it at + // the first vertical junction. + let arrow_origin = junction_points[1]; + (three_corner_layout && long_backward_edge).then_some(arrow_origin) +} + + + +// ============================= +// === Target-attachment bit === +// ============================= + +/// The target-end of the edge, drawn on top of a node. +#[derive(Debug, Copy, Clone, PartialEq)] +pub(super) struct TargetAttachment { + /// The target end. + pub target: Vector2, + /// How far to extend from the target. + pub length: f32, +} + + + +// ================== +// === Math Utils === +// ================== + +/// For the given radius of the first circle (`r1`), radius of the second circle (`r2`), and the +/// x-axis position of the second circle (`x`), computes the y-axis position of the second circle in +/// such a way, that the borders of the circle cross at the right angle. It also computes the angle +/// of the intersection. Please note, that the center of the first circle is in the origin. +/// +/// ```text +/// r1 +/// ◄───► (1) x^2 + y^2 = r1^2 + r2^2 +/// _____ (1) => y = sqrt((r1^2 + r2^2)/x^2) +/// .' `. +/// / _.-"""B-._ â–² +/// | .'0┼ | `. │ angle1 = A-XY-0 +/// \/ │ / \ │ r2 angle2 = 0-XY-B +/// |`._ │__.' | │ alpha = B-XY-X_AXIS +/// | A└───┼─ | â–¼ +/// | (x,y) | tg(angle1) = y / x +/// \ / tg(angle2) = r1 / r2 +/// `._ _.' alpha = PI - angle1 - angle2 +/// `-....-' +/// ``` +fn circle_intersection(x: f32, r1: f32, r2: f32) -> f32 { + let x_norm = x.clamp(-r2, r1); + (r1 * r1 + r2 * r2 - x_norm * x_norm).sqrt() +} diff --git a/app/gui/view/graph-editor/src/component/edge/render.rs b/app/gui/view/graph-editor/src/component/edge/render.rs new file mode 100644 index 000000000000..d30cdd376631 --- /dev/null +++ b/app/gui/view/graph-editor/src/component/edge/render.rs @@ -0,0 +1,457 @@ +//! Definitions, constructors, and management for the EnsoGL shapes that are used to draw an edge. +//! +//! The core function of this module is to translate edge layouts into the shape parameters that +//! will implement them. + +use crate::prelude::*; + +use super::layout::Corner; +use super::layout::EdgeSplit; +use super::layout::Oriented; +use super::layout::SplitArc; +use super::layout::TargetAttachment; +use ensogl::data::color; +use ensogl::display; +use ensogl::display::scene::Scene; +use ensogl::display::shape::*; + +use std::f32::consts::FRAC_PI_2; +use std::f32::consts::PI; +use std::f32::consts::TAU; + + + +// ================= +// === Constants === +// ================= + +const LINE_WIDTH: f32 = 4.0; +const HOVER_EXTENSION: f32 = 10.0; +pub(super) const HOVER_WIDTH: f32 = LINE_WIDTH + HOVER_EXTENSION; + +mod arrow { + use super::*; + pub(super) const SIZE: Vector2 = Vector2(18.75, 18.75); +} + +mod attachment { + /// Extra length to add to the top of the target-attachment bit, to ensure that it + /// appears to pass through the top of the node. Without this adjustment, inexact + /// floating-point math and anti-aliasing would cause a 1-pixel gap artifact right where + /// the attachment should meet the corner at the edge of the node. + pub(super) const TOP_ADJUSTMENT: f32 = 0.5; +} + + + +// =================== +// === Edge Shapes === +// =================== + +/// The shapes used to render an edge. +#[derive(Debug, Default)] +pub(super) struct Shapes { + /// The individual [`Corner`]s making up the edge. Each is drawn in the focused or unfocused + /// color. + sections: RefCell>, + /// A pair of [`arc`] shapes used when the mouse is over the rounded corner, and the edge must + /// must be split into focused and unfocused sides at a certain angle along the arc. + split_arc: RefCell>, + /// Wider versions of the [`sections`], for receiving mouse events. + hover_sections: RefCell>, + /// The end of the edge that is drawn on top of the node and connects to the target node's + /// input port. + target_attachment: RefCell>, + /// Arrow drawn on long backward edges to indicate data flow direction. + dataflow_arrow: RefCell>, +} + +impl Shapes { + /// Redraw the arrow used to mark long backward edges. + pub(super) fn redraw_dataflow_arrow( + &self, + parent: &impl ShapeParent, + parameters: RedrawDataflowArrow, + ) { + let RedrawDataflowArrow { arrow, source_color, target_color, focus_split, is_attached } = + parameters; + let shape = self.dataflow_arrow.take(); + if let Some(arrow_center) = arrow { + // The arrow will have the same color as the target-end of the first corner from the + // source (this is the `arrow_center` point). + let color = match focus_split.map(|split| split.corner_index) { + Some(0) => target_color, + _ => source_color, + }; + let shape = shape.unwrap_or_else(|| parent.new_dataflow_arrow()); + shape.set_xy(arrow_center - arrow::SIZE / 2.0); + shape.set_color(color); + Self::set_layer(parent, is_attached, &shape); + self.dataflow_arrow.set(shape); + } + } + + /// Redraw the invisible mouse-event-catching edges. + pub(super) fn redraw_hover_sections( + &self, + parent: &impl ShapeParent, + corners: &[Oriented], + ) { + let hover_factory = self + .hover_sections + .take() + .into_iter() + .chain(iter::repeat_with(|| parent.new_hover_section())); + *self.hover_sections.borrow_mut() = corners + .iter() + .zip(hover_factory) + .map(|(corner, shape)| draw_corner(shape, **corner, HOVER_WIDTH)) + .collect(); + } + + /// Redraw the sections, each of which is a [`Rectangle`] implementing a [`Corner`], or multiple + /// [`Rectangle`]s and multiple [`arc::View`]s, if it is a split [`Corner`]. + pub(super) fn redraw_sections(&self, parent: &impl ShapeParent, parameters: RedrawSections) { + let RedrawSections { corners, source_color, target_color, focus_split, is_attached } = + parameters; + let corner_index = + focus_split.map(|split| split.corner_index).unwrap_or_else(|| corners.len()); + let split_corner = focus_split.map(|split| split.split_corner); + let mut section_factory = + self.sections.take().into_iter().chain(iter::repeat_with(|| parent.new_section())); + let mut new_sections = self.redraw_complete_sections( + &mut section_factory, + corners, + corner_index, + source_color, + target_color, + ); + for shape in &new_sections { + Self::set_layer(parent, is_attached, shape); + } + let arc_shapes = self.split_arc.take(); + if let Some(split_corner) = split_corner { + let source_side = rectangle_geometry(*split_corner.source_end, LINE_WIDTH); + let target_side = rectangle_geometry(*split_corner.target_end, LINE_WIDTH); + let split_arc = split_corner.split_arc; + if let Some(split_arc) = split_arc { + let arc_shapes = arc_shapes.unwrap_or_else(|| [parent.new_arc(), parent.new_arc()]); + let arc_shapes = draw_split_arc(arc_shapes, split_arc); + arc_shapes[0].color.set(source_color.into()); + arc_shapes[1].color.set(target_color.into()); + self.split_arc.set(arc_shapes); + } + let (source_shape, target_shape) = + (section_factory.next().unwrap(), section_factory.next().unwrap()); + source_shape.set_border_color(source_color); + target_shape.set_border_color(target_color); + new_sections.push(draw_geometry(source_shape, source_side)); + new_sections.push(draw_geometry(target_shape, target_side)); + } + *self.sections.borrow_mut() = new_sections; + } + + /// Redraw the sections that aren't split by the focus position. + pub(super) fn redraw_complete_sections( + &self, + section_factory: impl Iterator, + corners: &[Oriented], + corner_index: usize, + source_color: color::Rgba, + target_color: color::Rgba, + ) -> Vec { + corners + .iter() + .enumerate() + .filter_map(|(i, corner)| { + if i == corner_index { + None + } else { + let color = match i < corner_index { + true => source_color, + false => target_color, + }; + Some((color, corner)) + } + }) + .zip(section_factory) + .map(|((color, corner), shape)| { + let shape = draw_corner(shape, **corner, LINE_WIDTH); + shape.set_border_color(color); + shape + }) + .collect() + } + + /// Redraw the little bit that goes on top of the target node. + pub(super) fn redraw_target_attachment( + &self, + parent: &impl ShapeParent, + target_attachment: Option, + color: color::Rgba, + ) { + let shape = self.target_attachment.take(); + if let Some(TargetAttachment { target, length }) = target_attachment + && length > f32::EPSILON { + let shape = shape.unwrap_or_else(|| parent.new_target_attachment()); + shape.set_size_y(length + attachment::TOP_ADJUSTMENT); + shape.set_xy(target + Vector2(-LINE_WIDTH / 2.0, attachment::TOP_ADJUSTMENT)); + shape.set_color(color); + self.target_attachment.set(shape); + } + } + + /// Add the given shape to the appropriate layer depending on whether it is attached. + fn set_layer(parent: &impl ShapeParent, is_attached: bool, shape: &Rectangle) { + (match is_attached { + true => &parent.scene().layers.main_edges_level, + false => &parent.scene().layers.main_above_inactive_nodes_level, + }) + .add(shape) + } +} + + +// === Redraw parameters ==== + +/// Arguments passed to [`Shapes::redraw_sections`]. +pub(super) struct RedrawSections<'a> { + /// The corners to be redrawn. + pub(super) corners: &'a [Oriented], + /// The color to use for the part of the edge closer to the source. + pub(super) source_color: color::Rgba, + /// The color to use for the part of the edge closer to the target. + pub(super) target_color: color::Rgba, + /// Where the edge should be split into differently-colored source and target parts. + pub(super) focus_split: Option, + /// Whether the edge is fully-attached. + pub(super) is_attached: bool, +} + +/// Arguments passed to [`Shapes::redraw_dataflow_arrow`]. +pub(super) struct RedrawDataflowArrow { + /// The center of the arrow, if the arrow should be drawn. + pub(super) arrow: Option, + /// The color to use for the part of the edge closer to the source. + pub(super) source_color: color::Rgba, + /// The color to use for the part of the edge closer to the target. + pub(super) target_color: color::Rgba, + /// Where the edge should be split into differently-colored source and target parts. + pub(super) focus_split: Option, + /// Whether the edge is fully-attached. + pub(super) is_attached: bool, +} + + + +// ========================= +// === Shape Definitions === +// ========================= + +/// An arc around the origin. `outer_radius` determines the distance from the origin to the outer +/// edge of the arc, `stroke_width` the width of the arc. The arc starts at `start_angle`, relative +/// to the origin. Its radial size is `sector_angle`. The ends are flat, not rounded as in +/// [`RoundedArc`]. +mod arc { + use super::*; + ensogl::shape! { + pointer_events = false; + ( + style: Style, + color: Vector4, + outer_radius: f32, + stroke_width: f32, + start_angle: f32, + sector_angle: f32, + ) { + let circle = Circle(outer_radius.px()) - Circle((outer_radius - stroke_width).px()); + let angle_adjust = Var::::from(FRAC_PI_2); + let rotate_angle = -start_angle + angle_adjust - §or_angle / 2.0; + let angle = PlaneAngleFast(sector_angle).rotate(rotate_angle); + let angle = angle.grow(0.5.px()); + let shape = circle * angle; + let shape = shape.fill(color); + shape.into() + } + } +} + + + +// ====================== +// === Shape Creation === +// ====================== + +pub(super) trait ShapeParent: display::Object { + fn scene(&self) -> &Scene; + + /// Create a shape object to render one of the [`Corner`]s making up the edge. + fn new_section(&self) -> Rectangle { + let new = Rectangle::new(); + new.set_corner_radius_max(); + new.set_inset_border(LINE_WIDTH); + new.set_color(color::Rgba::transparent()); + new.set_pointer_events(false); + self.display_object().add_child(&new); + new + } + + /// Create a shape object to render the invisible hover area corresponding to one of the + /// [`Corner`]s making up the edge. + fn new_hover_section(&self) -> Rectangle { + let new = Rectangle::new(); + new.set_corner_radius_max(); + new.set_inset_border(HOVER_WIDTH); + new.set_color(color::Rgba::transparent()); + new.set_border_color(INVISIBLE_HOVER_COLOR); + self.display_object().add_child(&new); + new + } + + /// Create a shape object to render an arbitrary-angle arc. This is used when the focus is split + /// in the rounded part of a [`Corner`]. + fn new_arc(&self) -> arc::View { + let arc = arc::View::new(); + arc.stroke_width.set(LINE_WIDTH); + self.display_object().add_child(&arc); + self.scene().layers.below_main.add(&arc); + arc + } + + /// Create a shape object to render the little bit at the target end of the edge, that draws on + /// top of the node. + fn new_target_attachment(&self) -> Rectangle { + let new = Rectangle::new(); + new.set_size_x(LINE_WIDTH); + new.set_border_color(color::Rgba::transparent()); + new.set_pointer_events(false); + self.display_object().add_child(&new); + self.scene().layers.main_above_all_nodes_level.add(&new); + new + } + + /// Create a shape object to render the arrow that is drawn on long backward edges to show the + /// direction of data flow. + fn new_dataflow_arrow(&self) -> Rectangle { + let new = SimpleTriangle::from_size(arrow::SIZE); + new.set_pointer_events(false); + self.display_object().add_child(&new); + new.into() + } +} + + + +// ========================= +// === Rendering Corners === +// ========================= + +/// Set the given [`Rectangle`]'s geometry to draw this corner shape. +/// +/// Note that the shape's `inset` and `border` should be the same value as the provided +/// [`line_width`]. They are not set here as an optimization: When shapes are reused, the value does +/// not need to be set again, reducing needed GPU uploads. +pub(super) fn draw_corner(shape: Rectangle, corner: Corner, line_width: f32) -> Rectangle { + draw_geometry(shape, rectangle_geometry(corner, line_width)) +} + +fn draw_geometry(shape: Rectangle, geometry: RectangleGeometry) -> Rectangle { + shape.set_clip(geometry.clip); + shape.set_size(geometry.size); + shape.set_xy(geometry.xy); + shape.set_corner_radius(geometry.radius); + shape +} + + +// === Rectangle Geometry === + +#[derive(Debug, Copy, Clone, Default)] +struct RectangleGeometry { + pub clip: Vector2, + pub size: Vector2, + pub xy: Vector2, + pub radius: f32, +} + +/// Return [`Rectangle`] geometry parameters to draw this corner shape. +fn rectangle_geometry(corner: Corner, line_width: f32) -> RectangleGeometry { + // Convert from a layout radius (in the center of the line) to a [`Rectangle`] radius (on the + // inside edge of the border). + let radius = max(corner.max_radius() - line_width / 2.0, 0.0); + RectangleGeometry { + clip: corner.clip(), + size: corner.size(line_width), + xy: corner.origin(line_width), + radius, + } +} + + + +// ============================== +// === Rendering Partial Arcs === +// ============================== + +/// Apply the specified arc-splitting parameters to the given arc shapes. +pub(super) fn draw_split_arc(arc_shapes: [arc::View; 2], split_arc: SplitArc) -> [arc::View; 2] { + let outer_radius = split_arc.radius + LINE_WIDTH / 2.0; + let arc_box = Vector2(outer_radius * 2.0, outer_radius * 2.0); + let arc_offset = Vector2(-outer_radius, -outer_radius); + let geometry = ArcGeometry::bisection( + split_arc.source_end_angle, + split_arc.split_angle, + split_arc.target_end_angle, + ); + for (shape, geometry) in arc_shapes.iter().zip(&geometry) { + shape.set_xy(split_arc.origin + arc_offset); + shape.set_size(arc_box); + shape.outer_radius.set(outer_radius); + shape.start_angle.set(geometry.start); + shape.sector_angle.set(geometry.sector); + } + arc_shapes +} + + +// === Arc geometry === + +#[derive(Debug, Copy, Clone, PartialEq)] +struct ArcGeometry { + start: f32, + sector: f32, +} + +impl ArcGeometry { + fn bisection(a: f32, b: f32, c: f32) -> [Self; 2] { + [Self::new_minor(a, b), Self::new_minor(b, c)] + } + + fn new_minor(a: f32, b: f32) -> Self { + let start = minor_arc_start(a, b); + let sector = minor_arc_sector(a, b); + Self { start, sector } + } +} + +fn minor_arc_start(a: f32, b: f32) -> f32 { + let a = a.rem_euclid(TAU); + let b = b.rem_euclid(TAU); + let wrapped = (a - b).abs() >= PI; + if wrapped { + if a < f32::EPSILON { + b + } else { + a + } + } else { + min(a, b) + } +} + +fn minor_arc_sector(a: f32, b: f32) -> f32 { + let a = a.abs(); + let b = b.abs(); + let ab = (a - b).abs(); + min(ab, TAU - ab) +} diff --git a/app/gui/view/graph-editor/src/component/edge/state.rs b/app/gui/view/graph-editor/src/component/edge/state.rs new file mode 100644 index 000000000000..2625453bc618 --- /dev/null +++ b/app/gui/view/graph-editor/src/component/edge/state.rs @@ -0,0 +1,145 @@ +use crate::prelude::*; +use ensogl::data::color; + +use super::layout::Corner; +use super::layout::EdgeSplit; +use super::layout::Oriented; +use super::layout::TargetAttachment; + + + +// ========================= +// === State information === +// ========================= + +/// The complete computed state of an edge, containing all information needed to render it. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct State { + /// The layout. + pub layout: Layout, + /// The color scheme. + pub colors: Colors, + /// Whether the edge is attached to nodes at both ends. + pub is_attached: IsAttached, + /// What part, if any, is focused. + pub focus_split: FocusSplit, +} + +/// An edge's layout. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct Layout { + /// The corners composing the main part of the edge. + pub corners: Vec>, + /// The center of the backward-edge arrow. + pub arrow: Option, + /// The target-attachment end. + pub target_attachment: Option, +} + +/// An edge's color scheme. +#[derive(Debug, Copy, Clone, PartialEq)] +pub(super) struct Colors { + /// The color of the part of the edge nearer the source. + pub source_color: color::Rgba, + /// The color of the part of the edge nearer the target. + pub target_color: color::Rgba, +} + +/// Whether an edge is attached at both ends. +#[derive(Debug, Copy, Clone, PartialEq)] +pub(super) struct IsAttached { + /// Whether the edge is attached at both ends. + pub is_attached: bool, +} + +/// Information about how an edge may be divided into two parts that would be colored differently. +#[derive(Debug, Copy, Clone, PartialEq)] +pub(super) struct FocusSplit { + /// What part of the edge is focused. + pub focus_split: Option, +} + + + +// ===================== +// === State changes === +// ===================== + +/// References to all the parts of a [`State`], along with information about whether the values have +/// changed. +#[derive(Debug, Copy, Clone)] +pub(super) struct StateUpdate<'a, 'b, 'c, 'd> { + pub layout: Update<&'a Layout>, + pub colors: Update<&'b Colors>, + pub is_attached: Update<&'c IsAttached>, + pub focus_split: Update<&'d FocusSplit>, +} + +/// A value, along with information about whether it has changed. +#[derive(Debug, Copy, Clone)] +pub(super) struct Update { + value: T, + changed: bool, +} + +impl State { + pub(super) fn compare(&self, other: &Option) -> StateUpdate { + macro_rules! compare { + ($field:ident) => { + Update { + value: &self.$field, + changed: other.as_ref().map_or(true, |value| value.$field != self.$field), + } + }; + } + StateUpdate { + layout: compare!(layout), + colors: compare!(colors), + is_attached: compare!(is_attached), + focus_split: compare!(focus_split), + } + } +} + +impl Update { + /// Apply the given function to the value if the value has changed; otherwise, return `None`. + pub(super) fn changed(self, f: impl FnOnce(T) -> U) -> Option { + match self.changed { + true => Some(f(self.value)), + false => None, + } + } +} + +/// Return the product of the inputs. +#[allow(unused)] +pub(super) fn any<'a, 'b, A, B>(a: Update<&'a A>, b: Update<&'b B>) -> Update<(&'a A, &'b B)> { + let value = (a.value, b.value); + let changed = a.changed | b.changed; + Update { value, changed } +} + +/// Return the product of the inputs. +#[allow(unused)] +pub(super) fn any3<'a, 'b, 'c, A, B, C>( + a: Update<&'a A>, + b: Update<&'b B>, + c: Update<&'c C>, +) -> Update<(&'a A, &'b B, &'c C)> { + let value = (a.value, b.value, c.value); + let changed = a.changed | b.changed | c.changed; + Update { value, changed } +} + +/// Return the product of the inputs. +#[allow(unused)] +pub(super) fn any4<'a, 'b, 'c, 'd, A, B, C, D>( + a: Update<&'a A>, + b: Update<&'b B>, + c: Update<&'c C>, + d: Update<&'d D>, +) -> Update<(&'a A, &'b B, &'c C, &'d D)> { + let value = (a.value, b.value, c.value, d.value); + let changed = a.changed | b.changed | c.changed | d.changed; + Update { value, changed } +} diff --git a/app/gui/view/graph-editor/src/component/node.rs b/app/gui/view/graph-editor/src/component/node.rs index 7a5479570ab2..7cce80dd8701 100644 --- a/app/gui/view/graph-editor/src/component/node.rs +++ b/app/gui/view/graph-editor/src/component/node.rs @@ -1,7 +1,5 @@ //! Definition of the Node component. -// WARNING! UNDER HEAVY DEVELOPMENT. EXPECT DRASTIC CHANGES. - use crate::prelude::*; use ensogl::display::shape::*; use ensogl::display::traits::*; @@ -23,13 +21,13 @@ use ensogl::control::io::mouse; use ensogl::data::color; use ensogl::display; use ensogl::display::scene::Layer; +use ensogl::display::shape::compound::rectangle; use ensogl::gui; use ensogl::Animation; -use ensogl_component::shadow; use ensogl_component::text; use ensogl_hardcoded_theme as theme; use ensogl_hardcoded_theme; -use std::f32::EPSILON; + // ============== @@ -100,74 +98,57 @@ pub type Comment = ImString; -// ============= -// === Shape === -// ============= - -/// Node backdrop. Contains shadow and selection. -pub mod backdrop { - use super::*; - - ensogl::shape! { - // Disabled to allow interaction with the output port. - below = [compound::rectangle::shape]; - pointer_events = false; - alignment = center; - (style:Style, selection:f32) { - - let width = Var::::from("input_size.x"); - let height = Var::::from("input_size.y"); - let width = width - PADDING.px() * 2.0; - let height = height - PADDING.px() * 2.0; - - // === Shadow === - - let shadow_radius = &height / 2.0; - let shadow_base = Rect((&width,&height)).corners_radius(shadow_radius); - let shadow = shadow::from_shape(shadow_base.into(),style); - - - // === Selection === - - let sel_color = style.get_color(ensogl_hardcoded_theme::graph_editor::node::selection); - let sel_size = style.get_number(ensogl_hardcoded_theme::graph_editor::node::selection::size); - let sel_offset = style.get_number(ensogl_hardcoded_theme::graph_editor::node::selection::offset); - - let sel_width = &width - 2.px() + &sel_offset.px() * 2.0 * &selection; - let sel_height = &height - 2.px() + &sel_offset.px() * 2.0 * &selection; - let sel_radius = &sel_height / 2.0; - let select = Rect((&sel_width,&sel_height)).corners_radius(sel_radius); - - let sel2_width = &width - 2.px() + &(sel_size + sel_offset).px() * 2.0 * &selection; - let sel2_height = &height - 2.px() + &(sel_size + sel_offset).px() * 2.0 * &selection; - let sel2_radius = &sel2_height / 2.0; - let select2 = Rect((&sel2_width,&sel2_height)).corners_radius(sel2_radius); - - let select = select2 - select; - let select = select.fill(sel_color); +// ============== +// === Shapes === +// ============== +/// A node's background area and selection. +#[derive(Debug, Clone, CloneRef)] +pub struct Background { + shape: Rectangle, + inset: Immutable, + selection_color: Immutable, +} - // === Error Pattern Alternative === - // TODO: Remove once the error indicator design is finalised. - // let repeat = Var::>::from((10.px(), 10.px())); - // let error_width = Var::::from(5.px()); - // - // let stripe_red = Rect((error_width, 99999.px())); - // let pattern = stripe_red.repeat(repeat).rotate(45.0.radians()); - // let mask = Rect((&width,&height)).corners_radius(&radius); - // let pattern1 = mask.intersection(pattern).fill(color::Rgba::red()); +impl Background { + fn new(style: &StyleWatchFrp) -> Self { + let selection_color = + style.get_color(ensogl_hardcoded_theme::graph_editor::node::selection).value(); + let selection_size = + style.get_number(ensogl_hardcoded_theme::graph_editor::node::selection::size).value(); + let selection_offset = + style.get_number(ensogl_hardcoded_theme::graph_editor::node::selection::offset).value(); + let inset = selection_size + selection_offset; + let shape = Rectangle(); + shape.set_corner_radius(RADIUS); + shape.set_border(selection_size); + shape.set_border_color(color::Rgba::transparent()); + shape.set_inset(inset); + Self { shape, inset: Immutable(inset), selection_color: Immutable(selection_color) } + } - // let out = select + shadow + shape + pattern1; + fn set_selected(&self, degree: f32) { + let selected = self.selection_color; + let blended = color::Rgba(selected.red, selected.green, selected.blue, degree); + self.shape.set_border_color(blended); + } - // === Final Shape === + fn set_size_and_center_xy(&self, size: Vector2, center: Vector2) { + let size_with_inset = size + 2.0 * Vector2(*self.inset, *self.inset); + let origin = center - size_with_inset / 2.0; + self.shape.set_size(size_with_inset); + self.shape.set_xy(origin); + } +} - let out = select + shadow; - out.into() - } +impl display::Object for Background { + fn display_object(&self) -> &display::object::Instance { + self.shape.display_object() } } + // ======================= // === Error Indicator === // ======================= @@ -177,7 +158,7 @@ pub mod error_shape { use super::*; ensogl::shape! { - below = [backdrop]; + below = [rectangle]; alignment = center; (style:Style,color_rgba:Vector4) { use ensogl_hardcoded_theme::graph_editor::node as node_theme; @@ -259,6 +240,7 @@ ensogl::define_endpoints_2! { select (), deselect (), enable_visualization (), + enable_fullscreen_visualization (), disable_visualization (), set_visualization (Option), set_disabled (bool), @@ -318,12 +300,6 @@ ensogl::define_endpoints_2! { freeze (bool), hover (bool), error (Option), - /// Whether visualization was permanently enabled (e.g. by pressing the button). - visualization_enabled (bool), - /// Visualization can be visible even when it is not enabled, e.g. when showing preview. - /// Visualization can be invisible even when enabled, e.g. when the node has an error. - visualization_visible (bool), - visualization_path (Option), expression_label_visible (bool), /// The [`display::object::Model::position`] of the Node. Emitted when the Display Object /// hierarchy is updated (see: [`ensogl_core::display::object::Instance::update`]). @@ -429,8 +405,7 @@ impl Deref for Node { pub struct NodeModel { pub app: Application, pub display_object: display::object::Instance, - pub backdrop: backdrop::View, - pub background: Rectangle, + pub background: Background, pub error_indicator: error_shape::View, pub profiling_label: ProfilingLabel, pub input: input::Area, @@ -447,19 +422,25 @@ impl NodeModel { /// Constructor. #[profile(Debug)] pub fn new(app: &Application, registry: visualization::Registry) -> Self { - let scene = &app.display.default_scene; + use display::shape::compound::rectangle; + ensogl::shapes_order_dependencies! { + app.display.default_scene => { + error_shape -> output::port::single_port; + error_shape -> output::port::multi_port; + output::port::single_port -> rectangle; + output::port::multi_port -> rectangle; + } + } + + let style = StyleWatchFrp::new(&app.display.default_scene.style_sheet); let error_indicator = error_shape::View::new(); let profiling_label = ProfilingLabel::new(app); - let backdrop = backdrop::View::new(); - let background = Rectangle::new().build(|v| { - v.set_corner_radius(RADIUS); - }); + let background = Background::new(&style); let vcs_indicator = vcs::StatusIndicator::new(app); let display_object = display::object::Instance::new_named("Node"); display_object.add_child(&profiling_label); - display_object.add_child(&backdrop); display_object.add_child(&background); display_object.add_child(&vcs_indicator); @@ -475,13 +456,10 @@ impl NodeModel { let action_bar = action_bar::ActionBar::new(app); display_object.add_child(&action_bar); - scene.layers.above_nodes.add(&action_bar); let output = output::Area::new(app); display_object.add_child(&output); - let style = StyleWatchFrp::new(&app.display.default_scene.style_sheet); - let comment = text::Text::new(app); display_object.add_child(&comment); @@ -489,7 +467,6 @@ impl NodeModel { Self { app, display_object, - backdrop, background, error_indicator, profiling_label, @@ -508,12 +485,12 @@ impl NodeModel { #[profile(Debug)] fn init(self) -> Self { self.set_expression(Expression::new_plain("empty")); + self.move_to_main_layer(); self } #[profile(Debug)] - fn set_layers(&self, layer: &Layer, text_layer: &Layer, action_bar_layer: &Layer) { - layer.add(&self.display_object); + fn set_special_layers(&self, text_layer: &Layer, action_bar_layer: &Layer) { action_bar_layer.add(&self.action_bar); self.output.set_label_layer(text_layer); self.input.set_label_layer(text_layer); @@ -528,13 +505,13 @@ impl NodeModel { /// /// `action_bar` is moved to the `edited_node` layer as well, though normally it lives on a /// separate `above_nodes` layer, unlike every other node component. - #[profile(Debug)] pub fn move_to_edited_node_layer(&self) { let scene = &self.app.display.default_scene; let layer = &scene.layers.edited_node; let text_layer = &scene.layers.edited_node_text; let action_bar_layer = &scene.layers.edited_node; - self.set_layers(layer, text_layer, action_bar_layer); + layer.add(&self.display_object); + self.set_special_layers(text_layer, action_bar_layer); } /// Move all sub-components to `main` layer. @@ -544,13 +521,25 @@ impl NodeModel { /// /// `action_bar` is handled separately, as it uses `above_nodes` scene layer unlike any other /// node component. - #[profile(Debug)] pub fn move_to_main_layer(&self) { let scene = &self.app.display.default_scene; - let layer = &scene.layers.main; + let layer = &scene.layers.main_nodes_level; let text_layer = &scene.layers.label; let action_bar_layer = &scene.layers.above_nodes; - self.set_layers(layer, text_layer, action_bar_layer); + layer.add(&self.display_object); + self.set_special_layers(text_layer, action_bar_layer); + } + + /// Move the node to the normal layer used when the node is not connected to a detached edge. + pub fn move_to_resting_node_layer(&self) { + let layer = &self.app.display.default_scene.layers.main_nodes_level; + layer.add(&self.background); + } + + /// Move the node to the layer used for nodes that have a detached edge. + pub fn move_to_active_node_layer(&self) { + let layer = &self.app.display.default_scene.layers.main_active_nodes_level; + layer.add(&self.background); } #[allow(missing_docs)] // FIXME[everyone] All pub functions should have docs. @@ -581,19 +570,13 @@ impl NodeModel { let height = self.height(); let size = Vector2(width, height); let padded_size = size + Vector2(PADDING, PADDING) * 2.0; - self.backdrop.set_size(padded_size); - self.background.set_size(size); self.error_indicator.set_size(padded_size); self.vcs_indicator.frp.set_size(padded_size); let x_offset_to_node_center = x_offset_to_node_center(width); - - // Position shapes such that the center of their left edge is at the node origin: - // - Most shapes are still center-aligned, we have to move them horizontally. - self.backdrop.set_x(x_offset_to_node_center); + let background_origin = Vector2(x_offset_to_node_center, 0.0); + self.background.set_size_and_center_xy(size, background_origin); self.error_indicator.set_x(x_offset_to_node_center); self.vcs_indicator.set_x(x_offset_to_node_center); - // - Background is a bottom-left aligned Rectangle, thus we have to move it vertically. - self.background.set_y(-height / 2.0); let action_bar_width = ACTION_BAR_WIDTH; self.action_bar @@ -607,12 +590,6 @@ impl NodeModel { size } - #[profile(Debug)] - #[allow(missing_docs)] // FIXME[everyone] All pub functions should have docs. - pub fn visualization(&self) -> &visualization::Container { - &self.visualization - } - #[profile(Debug)] fn set_error(&self, error: Option<&Error>) { if let Some(error) = error { @@ -631,12 +608,16 @@ impl NodeModel { #[profile(Debug)] fn set_error_color(&self, color: &color::Lcha) { self.error_indicator.color_rgba.set(color::Rgba::from(color).into()); - if color.alpha < EPSILON { + if color.alpha < f32::EPSILON { self.error_indicator.unset_parent(); } else { self.display_object.add_child(&self.error_indicator); } } + + fn set_selected(&self, degree: f32) { + self.background.set_selected(degree); + } } impl Node { @@ -668,7 +649,7 @@ impl Node { // === Hover === // The hover discovery of a node is an interesting process. First, we discover whether - // ths user hovers the drag area. The input port manager merges this information with + // ths user hovers the background. The input port manager merges this information with // port hover events and outputs the final hover event for any part inside of the node. let background_enter = model.background.on_event::(); @@ -699,7 +680,7 @@ impl Node { deselect_target <- input.deselect.constant(0.0); select_target <- input.select.constant(1.0); selection.target <+ any(&deselect_target, &select_target); - eval selection.value ((t) model.backdrop.selection.set(*t)); + eval selection.value ((t) model.set_selected(*t)); // === Expression === @@ -753,7 +734,6 @@ impl Node { // === Action Bar === - let visualization_button_state = action_bar.action_visibility.clone_ref(); out.context_switch <+ action_bar.action_context_switch; out.skip <+ action_bar.action_skip; out.freeze <+ action_bar.action_freeze; @@ -790,7 +770,10 @@ impl Node { hover_onset_delay.set_delay(VIS_PREVIEW_ONSET_MS); hover_onset_delay.set_duration(0.0); + let visualization = &model.visualization.frp; + frp::extend! { network + enabled <- bool(&input.disable_visualization, &input.enable_visualization); out.error <+ input.set_error; is_error_set <- input.set_error.map( @@ -806,11 +789,23 @@ impl Node { } )); - eval input.set_visualization ((t) model.visualization.frp.set_visualization.emit(t)); - visualization_enabled_frp <- bool(&input.disable_visualization,&input.enable_visualization); - eval visualization_enabled_frp ((enabled) - model.action_bar.set_action_visibility_state(enabled) - ); + viz_enabled <- enabled && no_error_set; + visualization.set_view_state <+ viz_enabled.on_true().constant(visualization::ViewState::Enabled); + visualization.set_view_state <+ viz_enabled.on_false().constant(visualization::ViewState::Disabled); + + // Integration between visualization and action bar. + visualization.set_visualization <+ input.set_visualization; + is_enabled <- visualization.view_state.map(|state|{ + matches!(state,visualization::ViewState::Enabled) + }); + action_bar.set_action_visibility_state <+ is_enabled; + button_set_to_true <- action_bar.user_action_visibility.on_true(); + button_set_to_true_without_error <- button_set_to_true.gate_not(&is_error_set); + button_set_to_true_with_error <- button_set_to_true.gate(&is_error_set); + visualization.set_view_state <+ button_set_to_true_without_error.constant(visualization::ViewState::Enabled); + action_bar.set_action_visibility_state <+ button_set_to_true_with_error.constant(false); + + visualization.set_view_state <+ action_bar.user_action_visibility.on_false().constant(visualization::ViewState::Disabled); // Show preview visualisation after some delay, depending on whether we show an error // or are in quick preview mode. Also, omit the preview if we don't have an @@ -827,7 +822,7 @@ impl Node { } }); hover_onset_delay.set_delay <+ preview_show_delay; - hide_tooltip <- preview_show_delay.map(|&delay| delay <= EPSILON); + hide_tooltip <- preview_show_delay.map(|&delay| delay <= f32::EPSILON); output_hover <- model.output.on_port_hover.map(|s| s.is_on()); hover_onset_delay.start <+ output_hover.on_true(); @@ -840,38 +835,10 @@ impl Node { hide_preview <+ editing_finished; preview_enabled <- bool(&hide_preview, &input.show_preview); preview_visible <- hover_preview_visible || preview_enabled; - preview_visible <- preview_visible.on_change(); - - // If the preview is visible while the visualization button is disabled, clicking the - // visualization button hides the preview and keeps the visualization button disabled. - vis_button_on <- visualization_button_state.filter(|e| *e).constant(()); - vis_button_off <- visualization_button_state.filter(|e| !*e).constant(()); - visualization_on <- vis_button_on.gate_not(&preview_visible); - vis_button_on_while_preview_visible <- vis_button_on.gate(&preview_visible); - hide_preview <+ vis_button_on_while_preview_visible; - hide_preview <+ vis_button_off; - action_bar.set_action_visibility_state <+ - vis_button_on_while_preview_visible.constant(false); - visualization_enabled <- bool(&vis_button_off, &visualization_on); - - visualization_visible <- visualization_enabled || preview_visible; - visualization_visible <- visualization_visible && no_error_set; - visualization_visible_on_change <- visualization_visible.on_change(); - out.visualization_visible <+ visualization_visible_on_change; - out.visualization_enabled <+ visualization_enabled; - eval visualization_visible_on_change ((is_visible) - model.visualization.frp.set_visibility(is_visible) - ); - out.visualization_path <+ model.visualization.frp.visualisation.all_with(&init,|def_opt,_| { - def_opt.as_ref().map(|def| def.signature.path.clone_ref()) - }); - - // Ensure the preview is visible above all other elements, but the normal visualisation - // is below nodes. - layer_on_hover <- hover_preview_visible.on_false().map(|_| visualization::Layer::Default); - layer_on_not_hover <- hover_preview_visible.on_true().map(|_| visualization::Layer::Front); - layer <- any(layer_on_hover,layer_on_not_hover); - model.visualization.frp.set_layer <+ layer; + vis_preview_visible <- preview_visible && no_error_set; + vis_preview_visible <- vis_preview_visible.on_change(); + visualization.set_view_state <+ vis_preview_visible.on_true().constant(visualization::ViewState::Preview); + visualization.set_view_state <+ vis_preview_visible.on_false().constant(visualization::ViewState::Disabled); update_error <- all(input.set_error,preview_visible); eval update_error([model]((error,visible)){ @@ -883,6 +850,10 @@ impl Node { }); eval error_color_anim.value ((value) model.set_error_color(value)); + visualization.set_view_state <+ input.set_error.is_some().constant(visualization::ViewState::Disabled); + + enable_fullscreen <- frp.enable_fullscreen_visualization.gate(&no_error_set); + visualization.set_view_state <+ enable_fullscreen.constant(visualization::ViewState::Fullscreen); } @@ -934,7 +905,7 @@ impl Node { // })); // bg_color_anim.target <+ bg_color; - eval bg_color_anim.value ([model] (c) { model.background.set_color(c.into()); }); + eval bg_color_anim.value ((c) model.background.shape.set_color(c.into());); // === Tooltip === @@ -949,16 +920,14 @@ impl Node { // === Type Labels === model.output.set_type_label_visibility - <+ visualization_visible.not().and(&no_error_set); + <+ visualization.visible.not().and(&no_error_set); // === Bounding Box === let visualization_size = &model.visualization.frp.size; - // Visualization can be enabled and not visible when the node has an error. - visualization_enabled_and_visible <- visualization_enabled && visualization_visible; bbox_input <- all4( - &out.position,&new_size,&visualization_enabled_and_visible,visualization_size); + &out.position,&new_size,&visualization.visible,visualization_size); out.bounding_box <+ bbox_input.map(|(a,b,c,d)| bounding_box(*a,*b,c.then(|| *d))); inner_bbox_input <- all2(&out.position,&new_size); @@ -997,6 +966,11 @@ impl Node { color::Lcha::transparent() } } + + /// FRP API of the visualization container attached to this node. + pub fn visualization(&self) -> &visualization::container::Frp { + &self.model().visualization.frp + } } impl display::Object for Node { diff --git a/app/gui/view/graph-editor/src/component/node/action_bar.rs b/app/gui/view/graph-editor/src/component/node/action_bar.rs index 0d49c0293f67..6bb514cf4c3e 100644 --- a/app/gui/view/graph-editor/src/component/node/action_bar.rs +++ b/app/gui/view/graph-editor/src/component/node/action_bar.rs @@ -44,20 +44,11 @@ const SKIP_TOOLTIP_LABEL: &str = "Skip"; // =============== /// Invisible rectangular area that can be hovered. -mod hover_area { - use super::*; - - ensogl::shape! { - alignment = center; - (style: Style, corner_radius: f32) { - let width : Var = "input_size.x".into(); - let height : Var = "input_size.y".into(); - let rect = Rect((&width,&height)); - let rect_rounded = rect.corners_radius(corner_radius); - let rect_filled = rect_rounded.fill(INVISIBLE_HOVER_COLOR); - rect_filled.into() - } - } +fn hover_area() -> Rectangle { + let area = Rectangle(); + area.set_color(INVISIBLE_HOVER_COLOR); + area.set_border_color(INVISIBLE_HOVER_COLOR); + area } @@ -70,6 +61,7 @@ ensogl::define_endpoints! { Input { set_size (Vector2), set_visibility (bool), + /// Set whether the `visibility` icon should be toggled on or off. set_action_visibility_state (bool), set_action_skip_state (bool), set_action_freeze_state (bool), @@ -86,6 +78,9 @@ ensogl::define_endpoints! { mouse_over (), mouse_out (), action_visibility (bool), + /// The last visibility selection by the user. Ignores changes to the + /// visibility chooser icon made through the input API. + user_action_visibility (bool), action_context_switch (bool), action_freeze (bool), action_skip (bool), @@ -233,7 +228,7 @@ impl display::Object for ContextSwitchButton { #[derive(Clone, CloneRef, Debug)] struct Model { display_object: display::object::Instance, - hover_area: hover_area::View, + hover_area: Rectangle, icons: Icons, size: Rc>, shapes: compound::events::MouseEvents, @@ -244,7 +239,7 @@ impl Model { fn new(app: &Application) -> Self { let scene = &app.display.default_scene; let display_object = display::object::Instance::new(); - let hover_area = hover_area::View::new(); + let hover_area = hover_area(); let icons = Icons::new(app); let shapes = compound::events::MouseEvents::default(); let size = default(); @@ -257,13 +252,14 @@ impl Model { shapes.add_sub_shape(&icons.freeze.view()); shapes.add_sub_shape(&icons.skip.view()); + use display::shape::compound::rectangle; ensogl::shapes_order_dependencies! { scene => { - hover_area -> icon::visibility; - hover_area -> icon::disable_output_context; - hover_area -> icon::enable_output_context; - hover_area -> icon::freeze; - hover_area -> icon::skip; + rectangle -> icon::visibility; + rectangle -> icon::disable_output_context; + rectangle -> icon::enable_output_context; + rectangle -> icon::freeze; + rectangle -> icon::skip; } } @@ -299,11 +295,12 @@ impl Model { let hover_width = button_width * (button_count + hover_padding + offset + padding) + HOVER_EXTENSION_X; let hover_height = button_width * 2.0; - let hover_ara_size = Vector2::new(hover_width, hover_height); - self.hover_area.set_size(hover_ara_size); - let center_offset = -size.x / 2.0 + hover_ara_size.x / 2.0; + let hover_area_size = Vector2::new(hover_width, hover_height); + self.hover_area.set_size(hover_area_size); + let center_offset = -size.x / 2.0; let padding_offset = -0.5 * hover_padding * button_width - HOVER_EXTENSION_X / 2.0; - self.hover_area.set_x(center_offset + padding_offset); + let hover_origin = Vector2(center_offset + padding_offset, -hover_height / 2.0); + self.hover_area.set_xy(hover_origin); } fn set_size(&self, size: Vector2) { @@ -412,6 +409,7 @@ impl ActionBar { // === Icon Actions === frp.source.action_visibility <+ model.icons.visibility.state; + frp.source.user_action_visibility <+ model.icons.visibility.last_user_state; frp.source.action_skip <+ model.icons.skip.state; frp.source.action_freeze <+ model.icons.freeze.state; disable_context_button_clicked <- model.icons.context_switch.disable_button.is_pressed.on_true(); diff --git a/app/gui/view/graph-editor/src/component/node/input/area.rs b/app/gui/view/graph-editor/src/component/node/input/area.rs index 9e3c5c0daab2..7a0c73de8897 100644 --- a/app/gui/view/graph-editor/src/component/node/input/area.rs +++ b/app/gui/view/graph-editor/src/component/node/input/area.rs @@ -357,6 +357,7 @@ ensogl::define_endpoints! { Output { pointer_style (cursor::Style), width (f32), + /// Changes done when nodes is in edit mode. expression_edit (ImString, Vec>), editing (bool), @@ -477,9 +478,10 @@ impl Area { legit_edit <- frp.input.edit_expression.gate(&set_editing); model.edit_mode_label.select <+ legit_edit.map(|(range, _)| (range.start.into(), range.end.into())); model.edit_mode_label.insert <+ legit_edit._1(); - expression_changed_by_user <- model.edit_mode_label.content.gate(&set_editing); - frp.output.source.expression_edit <+ model.edit_mode_label.selections.map2( - &expression_changed_by_user, + expression_edited <- model.edit_mode_label.content.gate(&set_editing); + selections_edited <- model.edit_mode_label.selections.gate(&set_editing); + frp.output.source.expression_edit <+ selections_edited.gate(&set_editing).map2( + &model.edit_mode_label.content, f!([model](selection, full_content) { let full_content = full_content.into(); let to_byte = |loc| text::Byte::from_in_context_snapped(&model.edit_mode_label, loc); @@ -487,7 +489,7 @@ impl Area { (full_content, selections) }) ); - frp.output.source.on_port_code_update <+ expression_changed_by_user.map(|e| { + frp.output.source.on_port_code_update <+ expression_edited.map(|e| { // Treat edit mode update as a code modification at the span tree root. (default(), e.into()) }); diff --git a/app/gui/view/graph-editor/src/component/node/output/port.rs b/app/gui/view/graph-editor/src/component/node/output/port.rs index 128e29791deb..d29ab906e4f8 100644 --- a/app/gui/view/graph-editor/src/component/node/output/port.rs +++ b/app/gui/view/graph-editor/src/component/node/output/port.rs @@ -173,7 +173,6 @@ pub mod single_port { use ensogl::display::shape::*; ensogl::shape! { - above = [node::backdrop]; below = [compound::rectangle::shape]; alignment = center; (style:Style, size_multiplier:f32, opacity:f32, color_rgb:Vector3) { @@ -302,7 +301,6 @@ pub mod multi_port { } ensogl::shape! { - above = [node::backdrop]; below = [compound::rectangle::shape]; alignment = center; ( style : Style diff --git a/app/gui/view/graph-editor/src/component/visualization/container.rs b/app/gui/view/graph-editor/src/component/visualization/container.rs index cc135403a8f8..d1a5f0ad781c 100644 --- a/app/gui/view/graph-editor/src/component/visualization/container.rs +++ b/app/gui/view/graph-editor/src/component/visualization/container.rs @@ -1,4 +1,12 @@ -//! This module defines the `Container` struct and related functionality. +//! This module defines the `Container` struct and related functionality. This represent the view +//! a visualisation in the graph editor and includes a visual box that contains the visualisation, +//! and action bar that allows setting the visualisation type. +//! +//! The `[Container]` struct is responsible for managing the visualisation and action bar and +//! providing a unified interface to the graph editor. This includes ensuring that the visualisation +//! is correctly positioned, sized and layouted in its different [ViewState]s (which include the +//! `Enabled`, `Fullscreen` and `Preview` states). Importantly, this also includes EnsoGL layer +//! management to ensure correct occlusion of the visualisation with respect to other scene objects. // FIXME There is a serious performance problem in this implementation. It assumes that the // FIXME visualization is a child of the container. However, this is very inefficient. Consider a @@ -24,6 +32,7 @@ use ensogl::data::color::Rgba; use ensogl::display; use ensogl::display::scene; use ensogl::display::scene::Scene; +use ensogl::display::DomScene; use ensogl::display::DomSymbol; use ensogl::system::web; use ensogl::Animation; @@ -125,29 +134,59 @@ pub mod background { // === Frp === // =========== -ensogl::define_endpoints! { +/// Indicates the visibility state of the visualisation. +#[derive(Clone, Copy, Debug, PartialEq, Derivative)] +#[derivative(Default)] +pub enum ViewState { + /// Visualisation is permanently enabled and visible in the graph editor. It is attached to a + /// single node and can be moved and interacted with when selected. + Enabled, + /// Visualisation is disabled and hidden in the graph editor. + #[derivative(Default)] + Disabled, + /// Visualisation is temporarily enabled and visible in the graph editor. It should be placed + /// above other scene elements to allow quick inspection. + Preview, + /// Visualisation is enabled and visible in the graph editor in fullscreen mode. It occludes + /// the whole graph and can be interacted with. + Fullscreen, +} + +impl ViewState { + /// Indicates whether the visualisation is visible in the graph editor. It is always visible + /// when not disabled. + pub fn is_visible(&self) -> bool { + !matches!(self, ViewState::Disabled) + } + + /// Indicates whether the visualisation is fullscreen mode. + pub fn is_fullscreen(&self) -> bool { + matches!(self, ViewState::Fullscreen) + } +} + + +ensogl::define_endpoints_2! { Input { - set_visibility (bool), - toggle_visibility (), + set_view_state (ViewState), set_visualization (Option), cycle_visualization (), - set_data (visualization::Data), + set_data (Option), select (), deselect (), set_size (Vector2), - enable_fullscreen (), - disable_fullscreen (), set_vis_input_type (Option), - set_layer (visualization::Layer), } - Output { preprocessor (PreprocessorConfiguration), visualisation (Option), + visualization_path (Option), size (Vector2), is_selected (bool), + vis_input_type (Option), + fullscreen (bool), visible (bool), - vis_input_type (Option) + view_state (ViewState), } } @@ -161,8 +200,7 @@ ensogl::define_endpoints! { #[derive(Debug)] #[allow(missing_docs)] pub struct View { - display_object: display::object::Instance, - + display_object: display::object::Instance, background: background::View, overlay: overlay::View, background_dom: DomSymbol, @@ -277,7 +315,6 @@ pub struct ContainerModel { scene: Scene, view: View, fullscreen_view: fullscreen::Panel, - is_fullscreen: Rc>, registry: visualization::Registry, size: Rc>, action_bar: ActionBar, @@ -294,7 +331,6 @@ impl ContainerModel { let view = View::new(scene.clone_ref()); let fullscreen_view = fullscreen::Panel::new(scene); let scene = scene.clone_ref(); - let is_fullscreen = default(); let size = default(); let action_bar = ActionBar::new(app, registry.clone_ref()); view.add_child(&action_bar); @@ -307,7 +343,6 @@ impl ContainerModel { scene, view, fullscreen_view, - is_fullscreen, registry, size, action_bar, @@ -318,21 +353,16 @@ impl ContainerModel { fn init(self) -> Self { self.display_object.add_child(&self.drag_root); self.scene.layers.above_nodes.add(&self.action_bar); - - self.update_shape_sizes(); + self.update_shape_sizes(ViewState::default()); self.init_corner_roundness(); - // FIXME: These 2 lines fix a bug with display objects visible on stage. - self.set_visibility(true); - self.set_visibility(false); - self.view.show_waiting_screen(); self } - /// Indicates whether the visualization container is visible and active. - /// Note: can't be called `is_visible` due to a naming conflict with `display::object::class`. - pub fn is_active(&self) -> bool { - self.view.has_parent() + fn set_visualization_layer(&self, layer: visualization::Layer) { + if let Some(vis) = self.visualization.borrow().as_ref() { + vis.set_layer.emit(layer) + } } } @@ -340,54 +370,60 @@ impl ContainerModel { // === Private API === impl ContainerModel { - fn set_visibility(&self, visibility: bool) { + fn apply_view_state(&self, view_state: ViewState) { // This is a workaround for #6600. It ensures the action bar is removed // and receive no further mouse events. - if visibility { + if view_state.is_visible() { self.view.add_child(&self.action_bar); } else { self.action_bar.unset_parent(); } // Show or hide the visualization. - if visibility { + if view_state.is_visible() { self.drag_root.add_child(&self.view); - self.show_visualisation(); } else { self.drag_root.remove_child(&self.view); } + + match view_state { + ViewState::Enabled => self.enable_default_view(), + ViewState::Disabled => {} + ViewState::Preview => self.enable_preview(), + ViewState::Fullscreen => self.enable_fullscreen(), + } } - fn enable_fullscreen(&self) { - self.is_fullscreen.set(true); + fn set_vis_parents(&self, parent: &dyn display::Object, dom_parent: &DomScene) { if let Some(viz) = &*self.visualization.borrow() { - self.fullscreen_view.add_child(viz); + parent.add_child(viz); if let Some(dom) = viz.root_dom() { - self.scene.dom.layers.fullscreen_vis.manage(dom); + dom_parent.manage(dom); } viz.inputs.activate.emit(()); } } - fn disable_fullscreen(&self) { - self.is_fullscreen.set(false); - if let Some(viz) = &*self.visualization.borrow() { - self.view.add_child(viz); - if let Some(dom) = viz.root_dom() { - self.scene.dom.layers.back.manage(dom); - } - viz.inputs.deactivate.emit(()); - } + fn enable_fullscreen(&self) { + self.set_visualization_layer(visualization::Layer::Fullscreen); + self.set_vis_parents(&self.fullscreen_view, &self.scene.dom.layers.fullscreen_vis) } - fn toggle_visibility(&self) { - self.set_visibility(!self.is_active()) + fn enable_default_view(&self) { + self.set_visualization_layer(visualization::Layer::Default); + self.set_vis_parents(&self.view, &self.scene.dom.layers.back) + } + + fn enable_preview(&self) { + self.set_visualization_layer(visualization::Layer::Front); + self.set_vis_parents(&self.view, &self.scene.dom.layers.front); } fn set_visualization( &self, visualization: visualization::Instance, preprocessor: &frp::Any, + view_state: ViewState, ) { let size = self.size.get(); visualization.frp.set_size.emit(size); @@ -399,31 +435,27 @@ impl ContainerModel { vis_preprocessor_change <- visualization.on_preprocessor_change.map(|x| x.clone()); preprocessor <+ vis_preprocessor_change; } - preprocessor.emit(visualization.on_preprocessor_change.value()); - if self.is_fullscreen.get() { - self.fullscreen_view.add_child(&visualization) - } else { - self.view.add_child(&visualization); - } - self.visualization.replace(Some(visualization)); + self.visualization.replace(Some(visualization.clone_ref())); self.vis_frp_connection.replace(Some(vis_frp_connection)); + self.apply_view_state(view_state); + preprocessor.emit(visualization.on_preprocessor_change.value()); } fn set_visualization_data(&self, data: &visualization::Data) { self.visualization.borrow().for_each_ref(|vis| vis.send_data.emit(data)) } - fn update_shape_sizes(&self) { + fn update_shape_sizes(&self, view_state: ViewState) { let size = self.size.get(); - self.set_size(size); + self.update_layout(size, view_state); } - fn set_size(&self, size: impl Into) { + fn update_layout(&self, size: impl Into, view_state: ViewState) { let dom = self.view.background_dom.dom(); let bg_dom = self.fullscreen_view.background_dom.dom(); let size = size.into(); self.size.set(size); - if self.is_fullscreen.get() { + if view_state.is_fullscreen() { self.view.overlay.set_size(Vector2(0.0, 0.0)); dom.set_style_or_warn("width", "0"); dom.set_style_or_warn("height", "0"); @@ -461,16 +493,6 @@ impl ContainerModel { self.view.background.roundness.set(value); } - fn show_visualisation(&self) { - if let Some(vis) = self.visualization.borrow().as_ref() { - if self.is_fullscreen.get() { - self.fullscreen_view.add_child(vis); - } else { - self.view.add_child(vis); - } - } - } - /// Check if given mouse-event-target means this visualization. fn is_this_target(&self, target: scene::PointerTargetId) -> bool { self.view.overlay.is_this_target(target) @@ -526,7 +548,9 @@ impl Container { } fn init(self, app: &Application) -> Self { - let frp = &self.frp; + let frp = &self.frp.private; + let input = &frp.input; + let output = &frp.output; let network = &self.frp.network; let model = &self.model; let scene = &self.model.scene; @@ -536,30 +560,27 @@ impl Container { let selection = Animation::new(network); frp::extend! { network - eval frp.set_visibility ((v) model.set_visibility(*v)); - eval_ frp.toggle_visibility (model.toggle_visibility()); - - visualisation_uninitialised <- frp.set_visualization.map(|t| t.is_none()); - default_visualisation <- visualisation_uninitialised.on_true().map(|_| { + eval input.set_view_state((state) model.apply_view_state(*state)); + output.view_state <+ input.set_view_state.on_change(); + output.fullscreen <+ output.view_state.map(|state| state.is_fullscreen()).on_change(); + output.visible <+ output.view_state.map(|state| state.is_visible()).on_change(); + output.size <+ input.set_size.on_change(); + + visualisation_not_selected <- input.set_visualization.map(|t| t.is_none()); + input_type_not_set <- input.set_vis_input_type.is_some().not(); + uninitialised <- visualisation_not_selected && input_type_not_set; + set_default_visualisation <- uninitialised.on_change().on_true().map(|_| { Some(visualization::Registry::default_visualisation()) }); - vis_input_type <- frp.set_vis_input_type.on_change(); - vis_input_type <- vis_input_type.gate(&visualisation_uninitialised).unwrap(); - default_visualisation_for_type <- vis_input_type.map(f!((tp) { + vis_input_type_changed <- input.set_vis_input_type.on_change(); + vis_input_type_changed_without_selection <- + vis_input_type_changed.gate(&visualisation_not_selected).unwrap(); + set_default_visualisation_for_type <- vis_input_type_changed_without_selection.map(f!((tp) { registry.default_visualization_for_type(tp) })); - default_visualisation <- any(&default_visualisation, &default_visualisation_for_type); - - eval frp.set_data ((t) model.set_visualization_data(t)); - frp.source.size <+ frp.set_size; - frp.source.visible <+ frp.set_visibility; - frp.source.visible <+ frp.toggle_visibility.map(f!((()) model.is_active())); - eval frp.set_layer ([model](l) { - if let Some(vis) = model.visualization.borrow().as_ref() { - vis.set_layer.emit(l) - } - model.view.set_layer(*l); - }); + set_default_visualisation <- any( + &set_default_visualisation, &set_default_visualisation_for_type); + } @@ -569,15 +590,17 @@ impl Container { selected_definition <- action_bar.visualisation_selection.map(f!([registry](path) path.as_ref().and_then(|path| registry.definition_from_path(path)) )); - action_bar.set_vis_input_type <+ frp.set_vis_input_type; - frp.source.vis_input_type <+ frp.set_vis_input_type; + action_bar.hide_icons <+ selected_definition.constant(()); + output.vis_input_type <+ input.set_vis_input_type; + let chooser = &model.action_bar.visualization_chooser(); + chooser.frp.set_vis_input_type <+ input.set_vis_input_type; } // === Cycling Visualizations === frp::extend! { network - vis_after_cycling <- frp.cycle_visualization.map3(&frp.visualisation,&frp.vis_input_type, + vis_after_cycling <- input.cycle_visualization.map3(&output.visualisation, &output.vis_input_type, f!(((),vis,input_type) model.next_visualization(vis,input_type)) ); } @@ -587,19 +610,19 @@ impl Container { frp::extend! { network vis_definition_set <- any( - frp.set_visualization, + input.set_visualization, selected_definition, vis_after_cycling, - default_visualisation); + set_default_visualisation); new_vis_definition <- vis_definition_set.on_change(); - let preprocessor = &frp.source.preprocessor; - frp.source.visualisation <+ new_vis_definition.map(f!( - [model,action_bar,app,preprocessor](vis_definition) { + let preprocessor = &output.preprocessor; + output.visualisation <+ new_vis_definition.map2(&output.view_state, f!( + [model,action_bar,app,preprocessor](vis_definition, view_state) { if let Some(definition) = vis_definition { match definition.new_instance(&app) { Ok(vis) => { - model.set_visualization(vis,&preprocessor); + model.set_visualization(vis,&preprocessor, *view_state); let path = Some(definition.signature.path.clone()); action_bar.set_selected_visualization.emit(path); }, @@ -611,21 +634,24 @@ impl Container { vis_definition.clone() })); + output.visualization_path <+ output.visualisation.map(|definition| { + definition.as_ref().map(|def| def.signature.path.clone_ref()) + }); + } // === Visualisation Loading Spinner === - eval_ frp.source.visualisation ( model.view.show_waiting_screen() ); - eval_ frp.set_data ( model.view.disable_waiting_screen() ); - + frp::extend! { network + eval_ output.visualisation ( model.view.show_waiting_screen() ); + eval_ input.set_data ( model.view.disable_waiting_screen() ); } - // === Selecting Visualization === frp::extend! { network mouse_down_target <- scene.mouse.frp_deprecated.down.map(f_!(scene.mouse.target.get())); - selected_by_click <= mouse_down_target.map(f!([model] (target){ + selected_by_click <= mouse_down_target.map2(&output.view_state, f!([model] (target,view_state){ let vis = &model.visualization; let activate = || vis.borrow().as_ref().map(|v| v.activate.clone_ref()); let deactivate = || vis.borrow().as_ref().map(|v| v.deactivate.clone_ref()); @@ -634,7 +660,7 @@ impl Container { activate.emit(()); return Some(true); } - } else if !model.is_fullscreen.get() { + } else if !view_state.is_fullscreen() { if let Some(deactivate) = deactivate() { deactivate.emit(()); return Some(false); @@ -645,34 +671,28 @@ impl Container { selection_after_click <- selected_by_click.map(|sel| if *sel {1.0} else {0.0}); selection.target <+ selection_after_click; eval selection.value ((selection) model.view.background.selection.set(*selection)); - - selected_by_going_fullscreen <- bool(&frp.disable_fullscreen,&frp.enable_fullscreen); - selected <- any(selected_by_click,selected_by_going_fullscreen); - - is_selected_changed <= selected.map2(&frp.output.is_selected, |&new,&old| { - (new != old).as_some(new) - }); - frp.source.is_selected <+ is_selected_changed; + is_selected <- selected_by_click || output.fullscreen; + output.is_selected <+ is_selected.on_change(); } // === Fullscreen View === frp::extend! { network - eval_ frp.enable_fullscreen (model.enable_fullscreen()); - eval_ frp.disable_fullscreen (model.disable_fullscreen()); - fullscreen_enabled_weight <- frp.enable_fullscreen.constant(1.0); - fullscreen_disabled_weight <- frp.disable_fullscreen.constant(0.0); + enable_fullscreen <- output.fullscreen.on_true(); + disable_fullscreen <- output.fullscreen.on_false(); + + fullscreen_enabled_weight <- enable_fullscreen.constant(1.0); + fullscreen_disabled_weight <- disable_fullscreen.constant(0.0); fullscreen_weight <- any(fullscreen_enabled_weight,fullscreen_disabled_weight); - frp.source.size <+ frp.set_size; - _eval <- fullscreen_weight.all_with3(&frp.size,scene_shape, - f!([model] (weight,viz_size,scene_size) { + _eval <- fullscreen_weight.all_with4(&output.size,scene_shape,&output.view_state, + f!([model] (weight,viz_size,scene_size,view_state) { let weight_inv = 1.0 - weight; let scene_size : Vector2 = scene_size.into(); let current_size = viz_size * weight_inv + scene_size * *weight; model.set_corner_roundness(weight_inv); - model.set_size(current_size); + model.update_layout(current_size,*view_state); let m1 = model.scene.layers.panel.camera().inversed_view_matrix(); let m2 = model.scene.layers.viz.camera().view_matrix(); @@ -683,6 +703,16 @@ impl Container { let current_pos = pp * weight_inv; model.fullscreen_view.set_position(current_pos); })); + + + // === Data Update === + + data <- input.set_data.unwrap(); + has_data <- input.set_data.is_some(); + reset_data <- data.sample(&new_vis_definition).gate(&has_data); + data_update <- any(&data,&reset_data); + eval data_update ((t) model.set_visualization_data(t)); + } @@ -709,8 +739,8 @@ impl Container { // // This is not optimal the optimal solution to this problem, as it also means that we have // an animation on an invisible component running. - frp.set_size.emit(Vector2(DEFAULT_SIZE.0, DEFAULT_SIZE.1)); - frp.set_visualization.emit(None); + self.frp.public.set_size(Vector2(DEFAULT_SIZE.0, DEFAULT_SIZE.1)); + self.frp.public.set_visualization(None); self } diff --git a/app/gui/view/graph-editor/src/component/visualization/container/action_bar.rs b/app/gui/view/graph-editor/src/component/visualization/container/action_bar.rs index 7e7db0845f6c..6163c35057c4 100644 --- a/app/gui/view/graph-editor/src/component/visualization/container/action_bar.rs +++ b/app/gui/view/graph-editor/src/component/visualization/container/action_bar.rs @@ -432,6 +432,11 @@ impl ActionBar { } self } + + /// Visualization Chooser component getter. + pub fn visualization_chooser(&self) -> &VisualizationChooser { + &self.model.visualization_chooser + } } impl display::Object for ActionBar { diff --git a/app/gui/view/graph-editor/src/component/visualization/container/fullscreen.rs b/app/gui/view/graph-editor/src/component/visualization/container/fullscreen.rs index 110b7ac24283..35df480ffa0d 100644 --- a/app/gui/view/graph-editor/src/component/visualization/container/fullscreen.rs +++ b/app/gui/view/graph-editor/src/component/visualization/container/fullscreen.rs @@ -9,39 +9,6 @@ use ensogl::display; use ensogl::display::scene::Scene; use ensogl::display::DomSymbol; use ensogl::system::web; -use ensogl_hardcoded_theme as theme; - - - -// ============== -// === Shapes === -// ============== - -/// Container background shape definition. -/// -/// Provides a backdrop and outline for visualisations. Can indicate the selection status of the -/// container. -/// TODO : We do not use backgrounds because otherwise they would overlap JS -/// visualizations. Instead we added a HTML background to the `View`. -/// This should be further investigated while fixing rust visualization displaying. (#526) -pub mod background { - use super::*; - - ensogl::shape! { - alignment = center; - (style:Style,selected:f32,radius:f32,roundness:f32) { - let width : Var = "input_size.x".into(); - let height : Var = "input_size.y".into(); - let radius = 1.px() * &radius; - let color_path = theme::graph_editor::visualization::background; - let color_bg = style.get_color(color_path); - let corner_radius = &radius * &roundness; - let background = Rect((&width,&height)).corners_radius(corner_radius); - let background = background.fill(color_bg); - background.into() - } - } -} @@ -54,9 +21,9 @@ pub mod background { #[allow(missing_docs)] pub struct Panel { display_object: display::object::Instance, + // Note: We use a HTML background, because a EnsoGL background would be + // overlapping the JS visualization. pub background_dom: DomSymbol, - // TODO: See TODO above. - // background : background::View, } impl Panel { @@ -76,9 +43,6 @@ impl Panel { let div = web::document.create_div_or_panic(); let background_dom = DomSymbol::new(&div); - // TODO : We added a HTML background to the `View`, because "shape" background was - // overlapping the JS visualization. This should be further investigated - // while fixing rust visualization displaying. (#796) background_dom.dom().set_style_or_warn("width", "0"); background_dom.dom().set_style_or_warn("height", "0"); background_dom.dom().set_style_or_warn("z-index", "1"); diff --git a/app/gui/view/graph-editor/src/component/visualization/layer.rs b/app/gui/view/graph-editor/src/component/visualization/layer.rs index f34635bb9f96..45e355928fbb 100644 --- a/app/gui/view/graph-editor/src/component/visualization/layer.rs +++ b/app/gui/view/graph-editor/src/component/visualization/layer.rs @@ -16,6 +16,8 @@ pub enum Layer { Default, /// Display the visualisation over the scene. Front, + /// Display the visualisation in fullscreen mode. + Fullscreen, } impl Layer { @@ -24,6 +26,7 @@ impl Layer { match self { Layer::Default => scene.dom.layers.back.manage(dom), Layer::Front => scene.dom.layers.front.manage(dom), + Layer::Fullscreen => scene.dom.layers.fullscreen_vis.manage(dom), } } } diff --git a/app/gui/view/graph-editor/src/lib.rs b/app/gui/view/graph-editor/src/lib.rs index a1755d483a07..8841fd79cb67 100644 --- a/app/gui/view/graph-editor/src/lib.rs +++ b/app/gui/view/graph-editor/src/lib.rs @@ -15,6 +15,7 @@ #![feature(trait_alias)] #![feature(type_alias_impl_trait)] #![feature(unboxed_closures)] +#![feature(array_windows)] // === Standard Linter Configuration === #![deny(non_ascii_idents)] #![warn(unsafe_code)] @@ -26,7 +27,6 @@ #![warn(missing_docs)] #![warn(trivial_casts)] #![warn(trivial_numeric_casts)] -#![warn(unsafe_code)] #![warn(unused_import_braces)] #![warn(unused_qualifications)] #![recursion_limit = "1024"] @@ -721,6 +721,8 @@ ensogl::define_endpoints_2! { hover_node_input (Option), hover_node_output (Option), + invalidate_sources_of_detached_edges (), + // === Other === // FIXME: To be refactored @@ -1297,21 +1299,15 @@ impl Nodes { // === Edges === // ============= -#[derive(Debug, Clone, CloneRef, Default)] +#[derive(Debug, Clone, CloneRef, Default, Deref)] #[allow(missing_docs)] // FIXME[everyone] Public-facing API should be documented. pub struct Edges { + #[deref] pub all: SharedHashMap, pub detached_source: SharedHashSet, pub detached_target: SharedHashSet, } -impl Deref for Edges { - type Target = SharedHashMap; - fn deref(&self) -> &Self::Target { - &self.all - } -} - impl Edges { /// Constructor. pub fn new() -> Self { @@ -1471,9 +1467,8 @@ impl GraphEditorModelWithNetwork { fn create_edge( &self, - edge_click: &frp::Source, - edge_over: &frp::Source, - edge_out: &frp::Source, + edge_source_click: &frp::Source, + edge_target_click: &frp::Source, ) -> EdgeId { let edge = Edge::new(component::Edge::new(&self.app)); let edge_id = edge.id(); @@ -1481,9 +1476,8 @@ impl GraphEditorModelWithNetwork { self.edges.insert(edge.clone_ref()); if let Some(network) = &self.network.upgrade_or_warn() { frp::extend! { network - eval_ edge.view.frp.shape_events.mouse_down_primary (edge_click.emit(edge_id)); - eval_ edge.view.frp.shape_events.mouse_over (edge_over.emit(edge_id)); - eval_ edge.view.frp.shape_events.mouse_out (edge_out.emit(edge_id)); + eval_ edge.view.source_click (edge_source_click.emit(edge_id)); + eval_ edge.view.target_click (edge_target_click.emit(edge_id)); } } edge_id @@ -1491,11 +1485,10 @@ impl GraphEditorModelWithNetwork { fn new_edge_from_output( &self, - edge_click: &frp::Source, - edge_over: &frp::Source, - edge_out: &frp::Source, + edge_source_click: &frp::Source, + edge_target_click: &frp::Source, ) -> EdgeId { - let edge_id = self.create_edge(edge_click, edge_over, edge_out); + let edge_id = self.create_edge(edge_source_click, edge_target_click); let first_detached = self.edges.detached_target.is_empty(); self.edges.detached_target.insert(edge_id); if first_detached { @@ -1506,11 +1499,10 @@ impl GraphEditorModelWithNetwork { fn new_edge_from_input( &self, - edge_click: &frp::Source, - edge_over: &frp::Source, - edge_out: &frp::Source, + edge_source_click: &frp::Source, + edge_target_click: &frp::Source, ) -> EdgeId { - let edge_id = self.create_edge(edge_click, edge_over, edge_out); + let edge_id = self.create_edge(edge_source_click, edge_target_click); let first_detached = self.edges.detached_source.is_empty(); self.edges.detached_source.insert(edge_id); if first_detached { @@ -1596,6 +1588,7 @@ impl GraphEditorModelWithNetwork { let touch = &self.touch_state; let model = &self.model; let NodeCreationContext { pointer_style, output_press, input_press, output } = ctx; + let visualisation = node.visualization(); if let Some(network) = self.network.upgrade_or_warn() { frp::new_bridge_network! { [network, node_network] graph_node_bridge @@ -1697,8 +1690,8 @@ impl GraphEditorModelWithNetwork { // === Visualizations === - visualization_shown <- node.visualization_visible.gate(&node.visualization_visible); - visualization_hidden <- node.visualization_visible.gate_not(&node.visualization_visible); + visualization_shown <- visualisation.visible.on_true(); + visualization_hidden <- visualisation.visible.on_false(); let vis_is_selected = node_model.visualization.frp.is_selected.clone_ref(); @@ -1711,7 +1704,7 @@ impl GraphEditorModelWithNetwork { node_model.visualization.frp.preprocessor.map(move |preprocessor| { (node_id,preprocessor.clone()) }); - output.visualization_preprocessor_changed <+ preprocessor_changed.gate(&node.visualization_visible); + output.visualization_preprocessor_changed <+ preprocessor_changed; metadata <- any(...); @@ -1729,7 +1722,7 @@ impl GraphEditorModelWithNetwork { init <- source::<()>(); enabled_visualization_path <- init.all_with3( - &node.visualization_enabled, &node.visualization_path, + &visualisation.visible, &visualisation.visualization_path, move |_init, is_enabled, path| (node_id, is_enabled.and_option(path.clone())) ); output.enabled_visualization_path <+ enabled_visualization_path; @@ -1828,6 +1821,7 @@ pub struct GraphEditorModel { styles_frp: StyleWatchFrp, selection_controller: selection::Controller, execution_environment_selector: ExecutionEnvironmentSelector, + sources_of_detached_target_edges: SharedHashSet, } @@ -1864,6 +1858,7 @@ impl GraphEditorModel { &touch_state, &nodes, ); + let sources_of_detached_target_edges = default(); Self { display_object, @@ -1886,6 +1881,7 @@ impl GraphEditorModel { styles_frp, selection_controller, execution_environment_selector, + sources_of_detached_target_edges, } .init() } @@ -2004,17 +2000,20 @@ impl GraphEditorModel { } } - fn enable_visualization_fullscreen(&self, node_id: impl Into) { + fn enable_visualization_fullscreen(&self, node_id: impl Into) -> bool { let node_id = node_id.into(); if let Some(node) = self.nodes.get_cloned_ref(&node_id) { - node.model().visualization.frp.enable_fullscreen.emit(()); + node.frp().enable_fullscreen_visualization(); + node.visualization().fullscreen.value() + } else { + false } } fn disable_visualization_fullscreen(&self, node_id: impl Into) { let node_id = node_id.into(); if let Some(node) = self.nodes.get_cloned_ref(&node_id) { - node.model().visualization.frp.disable_fullscreen.emit(()); + node.model().visualization.frp.set_view_state(visualization::ViewState::Enabled); } } @@ -2132,10 +2131,11 @@ impl GraphEditorModel { if let Some(node) = self.nodes.get_cloned_ref(&target.node_id) { node.out_edges.insert(edge_id); edge.set_source(target); - edge.view.frp.source_attached.emit(true); + edge.view.source_attached.emit(true); self.refresh_edge_position(edge_id); } } + self.frp.output.invalidate_sources_of_detached_edges.emit(()); } fn remove_edge_source(&self, edge_id: EdgeId) { @@ -2143,7 +2143,8 @@ impl GraphEditorModel { if let Some(source) = edge.take_source() { if let Some(node) = self.nodes.get_cloned_ref(&source.node_id) { node.out_edges.remove(&edge_id); - edge.view.frp.source_attached.emit(false); + edge.view.source_attached.emit(false); + edge.view.source_size.emit(Vector2(0.0, 0.0)); let first_detached = self.edges.detached_source.is_empty(); self.edges.detached_source.insert(edge_id); self.refresh_edge_position(edge_id); @@ -2168,10 +2169,10 @@ impl GraphEditorModel { self.frp.output.on_all_edges_targets_set.emit(()); } - edge.view.frp.target_attached.emit(true); - edge.view.frp.redraw.emit(()); + edge.view.target_attached.emit(true); self.refresh_edge_position(edge_id); }; + self.frp.output.invalidate_sources_of_detached_edges.emit(()); } } @@ -2182,28 +2183,27 @@ impl GraphEditorModel { node.in_edges.remove(&edge_id); let first_detached = self.edges.detached_target.is_empty(); self.edges.detached_target.insert(edge_id); - edge.view.frp.target_attached.emit(false); + edge.view.target_attached.emit(false); self.refresh_edge_position(edge_id); if first_detached { self.frp.output.on_some_edges_targets_unset.emit(()); } }; } + self.frp.output.invalidate_sources_of_detached_edges.emit(()); } } fn replace_detached_edge_target(&self, edge_id: EdgeId, crumbs: &span_tree::Crumbs) { - if !self.edges.detached_source.contains(&edge_id) { - return; - } - - if let Some(edge) = self.edges.get_cloned_ref(&edge_id) { - if let Some(target) = edge.take_target() { - self.set_input_connected(&target, None); - let port = crumbs.clone(); - let new_target = EdgeEndpoint { port, ..target }; - edge.set_target(new_target); - self.refresh_edge_position(edge_id); + if self.edges.detached_source.contains(&edge_id) { + if let Some(edge) = self.edges.get_cloned_ref(&edge_id) { + if let Some(target) = edge.take_target() { + self.set_input_connected(&target, None); + let port = crumbs.clone(); + let new_target = EdgeEndpoint { port, ..target }; + edge.set_target(new_target); + self.refresh_edge_position(edge_id); + } } } } @@ -2211,6 +2211,7 @@ impl GraphEditorModel { fn take_edges_with_detached_targets(&self) -> HashSet { let edges = self.edges.detached_target.mem_take(); self.check_edge_attachment_status_and_emit_events(); + self.frp.output.invalidate_sources_of_detached_edges.emit(()); edges } @@ -2224,17 +2225,18 @@ impl GraphEditorModel { self.edges.detached_target.raw.borrow().clone() } - #[allow(missing_docs)] // FIXME[everyone] All pub functions should have docs. - pub fn clear_all_detached_edges(&self) -> Vec { + /// Remove all edges that are detached at either end. Returns IDs of the removed edges. + fn clear_all_detached_edges(&self) -> Vec { let source_edges = self.edges.detached_source.mem_take(); - source_edges.iter().for_each(|edge| { + for edge in &source_edges { self.edges.all.remove(edge); - }); + } let target_edges = self.edges.detached_target.mem_take(); - target_edges.iter().for_each(|edge| { + for edge in &target_edges { self.edges.all.remove(edge); - }); + } self.check_edge_attachment_status_and_emit_events(); + self.frp.output.invalidate_sources_of_detached_edges.emit(()); source_edges.into_iter().chain(target_edges).collect() } @@ -2249,6 +2251,26 @@ impl GraphEditorModel { } } + /// Recalculate the set of source nodes that are attached to edges with detached targets, and + /// set layers appropriately for any nodes that have entered or left the set. + fn refresh_sources_of_detached_targets(&self) { + let old_sources = self.sources_of_detached_target_edges.raw.take(); + let detached_target_edges = self.edges.detached_target.raw.borrow(); + let detached_target_edges = detached_target_edges.iter(); + let get_edge = |id: &EdgeId| self.edges.get_cloned(id); + let get_source_id = |edge: Edge| edge.source().map(|endpoint| endpoint.node_id); + let get_node = |id: &NodeId| self.nodes.get_cloned(id); + let new_sources: HashSet<_> = + detached_target_edges.filter_map(get_edge).filter_map(get_source_id).collect(); + for added in new_sources.difference(&old_sources).filter_map(get_node) { + added.model().move_to_active_node_layer(); + } + for removed in old_sources.difference(&new_sources).filter_map(get_node) { + removed.model().move_to_resting_node_layer(); + } + self.sources_of_detached_target_edges.raw.replace(new_sources); + } + fn overlapping_edges(&self, target: &EdgeEndpoint) -> Vec { let mut overlapping = vec![]; if let Some(node) = self.nodes.get_cloned_ref(&target.node_id) { @@ -2268,7 +2290,7 @@ impl GraphEditorModel { fn set_edge_freeze>(&self, edge_id: T, is_frozen: bool) { let edge_id = edge_id.into(); if let Some(edge) = self.edges.get_cloned_ref(&edge_id) { - edge.view.frp.set_disabled.emit(is_frozen); + edge.view.set_disabled.emit(is_frozen); } } } @@ -2348,7 +2370,7 @@ impl GraphEditorModel { pub fn refresh_edge_color(&self, edge_id: EdgeId, neutral_color: color::Lcha) { if let Some(edge) = self.edges.get_cloned_ref(&edge_id) { let color = self.edge_color(edge_id, neutral_color); - edge.view.frp.set_color.emit(color); + edge.view.set_color.emit(color); if let Some(target) = edge.target() { self.set_input_connected(&target, Some(color)); } @@ -2361,91 +2383,50 @@ impl GraphEditorModel { } } - /// Refresh the source and target position of the edge identified by `edge_id`. Only redraws the - /// edge if a modification was made. Return `true` if either of the edge endpoint's position was - /// modified. - pub fn refresh_edge_position(&self, edge_id: EdgeId) -> bool { - let mut redraw = false; + /// Refresh the source and target position of the edge identified by `edge_id`. + pub fn refresh_edge_position(&self, edge_id: EdgeId) { if let Some(edge) = self.edges.get_cloned_ref(&edge_id) { if let Some(edge_source) = edge.source() { if let Some(node) = self.nodes.get_cloned_ref(&edge_source.node_id) { let node_width = node.model().width(); let node_height = node.model().height(); let new_position = node.position().xy() + Vector2::new(node_width / 2.0, 0.0); - let prev_width = edge.source_width.get(); - let prev_height = edge.source_height.get(); let prev_position = edge.position().xy(); if prev_position != new_position { - redraw = true; edge.set_xy(new_position); } - if prev_width != node_width { - redraw = true; - edge.view.frp.source_width.emit(node_width); - } - if prev_height != node_height { - redraw = true; - edge.view.frp.source_height.emit(node_height); - } + edge.view.source_size.emit(Vector2(node_width, node_height)); } } if let Some(edge_target) = edge.target() { if let Some(node) = self.nodes.get_cloned_ref(&edge_target.node_id) { - let offset = node.model().input.port_offset(&edge_target.port); - let new_position = node.position().xy() + offset; - let prev_position = edge.view.target_position.get(); - if prev_position != new_position { - redraw = true; - edge.view.frp.target_position.emit(new_position); - } + let port_offset = node.model().input.port_offset(&edge_target.port); + let node_position = node.position().xy(); + edge.view.target_position.emit(node_position + port_offset); } } - - if redraw { - edge.view.frp.redraw.emit(()); - } } - redraw } - /// Refresh the positions of all outgoing edges connected to the given node. Returns `true` if - /// at least one edge has been changed. - pub fn refresh_outgoing_edge_positions(&self, node_ids: &[NodeId]) -> bool { - let mut updated = false; + /// Refresh the positions of all outgoing edges connected to the given node. + pub fn refresh_outgoing_edge_positions(&self, node_ids: &[NodeId]) { for node_id in node_ids { for edge_id in self.node_out_edges(node_id) { - updated |= self.refresh_edge_position(edge_id); + self.refresh_edge_position(edge_id); } } - updated } /// Refresh the positions of all incoming edges connected to the given node. This is useful when /// we know that the node ports has been updated, but we don't track which exact edges are - /// affected. Returns `true` if at least one edge has been changed. - pub fn refresh_incoming_edge_positions(&self, node_ids: &[NodeId]) -> bool { - let mut updated = false; + /// affected. + pub fn refresh_incoming_edge_positions(&self, node_ids: &[NodeId]) { for node_id in node_ids { for edge_id in self.node_in_edges(node_id) { - updated |= self.refresh_edge_position(edge_id); + self.refresh_edge_position(edge_id); } } - updated - } - - /// Force layout update of the graph UI elements. Because display objects track changes made to - /// them, only objects modified since last update will have layout recomputed. Using this - /// function is still discouraged, because changes - /// - /// Because edge positions are computed based on the node positions, it is usually done after - /// the layout has been updated. In order to avoid edge flickering, we have to update their - /// layout second time. - /// - /// FIXME: Find a better solution to fix this issue. We either need a layout that can depend on - /// other arbitrary position, or we need the layout update to be multi-stage. - pub fn force_update_layout(&self) { - self.display_object().update(self.scene()); } fn map_node(&self, id: NodeId, f: impl FnOnce(Node) -> T) -> Option { @@ -2930,51 +2911,19 @@ fn new_graph_editor(app: &Application) -> GraphEditor { // === Edge interactions === frp::extend! { network - edge_mouse_down <- source::(); edge_over <- source::(); edge_out <- source::(); edge_hover <- source::>(); + raw_edge_source_click <- source::(); + raw_edge_target_click <- source::(); eval edge_over((edge_id) edge_hover.emit(Some(*edge_id))); eval_ edge_out(edge_hover.emit(None)); - edge_over_pos <- map2(&cursor_pos_in_scene,&edge_hover,|pos, edge_id| - edge_id.map(|id| (id, *pos)) - ).unwrap(); - - // We do not want edge hover to occur for detached edges. - set_edge_hover <- edge_over_pos.gate_not(&has_detached_edge); - - eval set_edge_hover ([model]((edge_id,pos)) { - if let Some(edge) = model.edges.get_cloned_ref(edge_id){ - edge.frp.hover_position.emit(Some(*pos)); - edge.frp.redraw.emit(()); - } - }); - - remove_split <- any(&edge_out,&edge_mouse_down); - eval remove_split ([model](edge_id) { - if let Some(edge) = model.edges.get_cloned_ref(edge_id){ - edge.frp.hover_position.emit(None); - edge.frp.redraw.emit(()); - } - }); - edge_click <- map2(&edge_mouse_down,&cursor_pos_in_scene,|edge_id,pos|(*edge_id,*pos)); - valid_edge_disconnect_click <- edge_click.gate_not(&has_detached_edge).gate_not(&inputs.set_read_only); - - edge_is_source_click <- valid_edge_disconnect_click.map(f!([model]((edge_id,pos)) { - if let Some(edge) = model.edges.get_cloned_ref(edge_id){ - edge.port_to_detach_for_position(*pos) == component::edge::PortType::OutputPort - } else { - false - } - })); - - edge_source_click <- valid_edge_disconnect_click.gate(&edge_is_source_click); - edge_target_click <- valid_edge_disconnect_click.gate_not(&edge_is_source_click); - - on_edge_source_unset <= edge_source_click.map(f!(((id,_)) model.with_edge_source(*id,|t|(*id,t)))); - on_edge_target_unset <= edge_target_click.map(f!(((id,_)) model.with_edge_target(*id,|t|(*id,t)))); + edge_source_click <- raw_edge_source_click.gate_not(&inputs.set_read_only); + edge_target_click <- raw_edge_target_click.gate_not(&inputs.set_read_only); + on_edge_source_unset <= edge_source_click.map(f!((id) model.with_edge_source(*id,|t|(*id,t)))); + on_edge_target_unset <= edge_target_click.map(f!((id) model.with_edge_target(*id,|t|(*id,t)))); out.on_edge_source_unset <+ on_edge_source_unset; out.on_edge_target_unset <+ on_edge_target_unset; } @@ -3004,14 +2953,14 @@ fn new_graph_editor(app: &Application) -> GraphEditor { deselect_edges <- on_new_edge.gate_not(&keep_selection); eval_ deselect_edges ( model.clear_all_detached_edges() ); - new_output_edge <- create_edge_from_output.map(f_!([model,edge_mouse_down,edge_over,edge_out] { - Some(model.new_edge_from_output(&edge_mouse_down,&edge_over,&edge_out)) + new_output_edge <- create_edge_from_output.map(f_!([model,raw_edge_source_click,raw_edge_target_click] { + Some(model.new_edge_from_output(&raw_edge_source_click, &raw_edge_target_click)) })).unwrap(); - new_input_edge <- create_edge_from_input.map(f!([model,edge_mouse_down,edge_over,edge_out]((target)){ + new_input_edge <- create_edge_from_input.map(f!([model,raw_edge_source_click,raw_edge_target_click]((target)){ if model.is_node_connected_at_input(target.node_id,&target.port) { return None }; - Some(model.new_edge_from_input(&edge_mouse_down,&edge_over,&edge_out)) + Some(model.new_edge_from_input(&raw_edge_source_click, &raw_edge_target_click)) })).unwrap(); out.on_edge_add <+ new_output_edge; @@ -3038,7 +2987,7 @@ fn new_graph_editor(app: &Application) -> GraphEditor { out.on_edge_target_set <+ inputs.set_edge_target; let endpoints = inputs.connect_nodes.clone_ref(); - edge <- endpoints . map(f_!(model.new_edge_from_output(&edge_mouse_down,&edge_over,&edge_out))); + edge <- endpoints . map(f_!(model.new_edge_from_output(&raw_edge_source_click,&raw_edge_target_click))); new_edge_source <- endpoints . _0() . map2(&edge, |t,id| (*id,t.clone())); new_edge_target <- endpoints . _1() . map2(&edge, |t,id| (*id,t.clone())); out.on_edge_add <+ edge; @@ -3073,11 +3022,8 @@ fn new_graph_editor(app: &Application) -> GraphEditor { incoming_batch <- out.node_incoming_edge_updates.batch(); outgoing_batch <- out.node_outgoing_edge_updates.batch(); - incoming_dirty <- incoming_batch.map(f!((n) model.refresh_incoming_edge_positions(n))); - outgoing_dirty <- outgoing_batch.map(f!((n) model.refresh_outgoing_edge_positions(n))); - any_edges_dirty <- incoming_dirty || outgoing_dirty; - force_update_layout <- any_edges_dirty.on_true().debounce(); - eval force_update_layout((_) model.force_update_layout()); + eval incoming_batch ((nodes) model.refresh_incoming_edge_positions(nodes)); + eval outgoing_batch ((nodes) model.refresh_outgoing_edge_positions(nodes)); } // === Adding Node === @@ -3434,45 +3380,29 @@ fn new_graph_editor(app: &Application) -> GraphEditor { eval refresh_target ([edges](position) { edges.detached_target.for_each(|id| { if let Some(edge) = edges.get_cloned_ref(id) { - edge.view.frp.target_position.emit(position.xy()); - edge.view.frp.redraw.emit(()); + edge.view.target_position.emit(position.xy()); } }); }); - eval refresh_source ([edges,model](position) { + eval refresh_source ([edges](position) { edges.detached_source.for_each(|edge_id| { if let Some(edge) = edges.get_cloned_ref(edge_id) { - edge.view.frp.source_width.emit(cursor::DEFAULT_RADIUS); - edge.view.frp.source_height.emit(cursor::DEFAULT_RADIUS); - edge.view.frp.target_position.emit(-position.xy()); - edge.view.frp.redraw.emit(()); - edge.modify_position(|p| { - p.x = position.x; - p.y = position.y; - }); - model.refresh_edge_position(*edge_id); + edge.set_xy(position.xy()); + edge.view.source_size.emit(Vector2(0.0, 0.0)); } }); }); - eval snap_source_to_node ([nodes,edges,model](target) { + eval snap_source_to_node ([nodes,edges](target) { edges.detached_source.for_each(|edge_id| { if let Some(node) = nodes.get_cloned_ref(&target.node_id) { if let Some(edge) = edges.get_cloned_ref(edge_id) { let node_width = node.view.model().width(); let node_height = node.view.model().height(); let node_pos = node.position(); - - edge.view.frp.source_width.emit(node_width); - edge.view.frp.source_height.emit(node_height); - edge.view.frp.target_position.emit(-node_pos.xy()); - edge.view.frp.redraw.emit(()); - edge.modify_position(|p| { - p.x = node_pos.x + node_width/2.0; - p.y = node_pos.y; - }); - model.refresh_edge_position(*edge_id); + edge.set_xy(Vector2(node_pos.x + node_width/2.0, node_pos.y)); + edge.view.source_size.emit(Vector2(node_width, node_height)); } } }); @@ -3581,7 +3511,7 @@ fn new_graph_editor(app: &Application) -> GraphEditor { viz_tgt_nodes_off <- viz_tgt_nodes.map(f!([model](node_ids) { node_ids.iter().cloned().filter(|node_id| { model.nodes.get_cloned_ref(node_id) - .map(|node| !node.visualization_enabled.value()) + .map(|node| !node.visualization().visible.value()) .unwrap_or_default() }).collect_vec() })); @@ -3597,7 +3527,9 @@ fn new_graph_editor(app: &Application) -> GraphEditor { eval viz_enable ((id) model.enable_visualization(id)); eval viz_disable ((id) model.disable_visualization(id)); eval viz_preview_disable ((id) model.disable_visualization(id)); - eval viz_fullscreen_on ((id) model.enable_visualization_fullscreen(id)); + fullscreen_vis_was_enabled <- viz_fullscreen_on.map(f!((id) + model.enable_visualization_fullscreen(id).then(|| *id)) + ).unwrap(); viz_fs_to_close <- out.visualization_fullscreen.sample(&inputs.close_fullscreen_visualization); eval viz_fs_to_close ([model](vis) { @@ -3607,7 +3539,7 @@ fn new_graph_editor(app: &Application) -> GraphEditor { } }); - out.visualization_fullscreen <+ viz_fullscreen_on.map(|id| Some(*id)); + out.visualization_fullscreen <+ fullscreen_vis_was_enabled.map(|id| Some(*id)); out.visualization_fullscreen <+ inputs.close_fullscreen_visualization.constant(None); out.is_fs_visualization_displayed <+ out.visualization_fullscreen.map(Option::is_some); @@ -3703,6 +3635,9 @@ fn new_graph_editor(app: &Application) -> GraphEditor { out.some_edge_endpoints_unset <+ some_edge_endpoints_unset; out.on_all_edges_endpoints_set <+ out.some_edge_endpoints_unset.on_false(); + refresh_sources_of_detached_targets <- out.invalidate_sources_of_detached_edges.debounce(); + eval_ refresh_sources_of_detached_targets (model.refresh_sources_of_detached_targets()); + // === Endpoints === diff --git a/app/gui/view/src/project.rs b/app/gui/view/src/project.rs index 185fa7f967d7..00cb7cafd592 100644 --- a/app/gui/view/src/project.rs +++ b/app/gui/view/src/project.rs @@ -15,7 +15,6 @@ use crate::graph_editor::GraphEditor; use crate::graph_editor::NodeId; use crate::popup; use crate::project_list::ProjectList; -use crate::searcher; use enso_config::ARGS; use enso_frp as frp; @@ -23,8 +22,9 @@ use ensogl::application; use ensogl::application::shortcut; use ensogl::application::Application; use ensogl::display; +use ensogl::display::Scene; use ensogl::system::web; -use ensogl::DEPRECATED_Animation; +use ensogl::Animation; use ensogl_component::text; use ensogl_component::text::selection::Selection; use ensogl_hardcoded_theme::Theme; @@ -85,10 +85,6 @@ ensogl::define_endpoints! { hide_project_list(), /// Close the searcher without taking any actions close_searcher(), - /// Show the graph editor. - show_graph_editor(), - /// Hide the graph editor. - hide_graph_editor(), /// Simulates a style toggle press event. toggle_style(), /// Toggles the visibility of private components in the component browser. @@ -126,7 +122,6 @@ ensogl::define_endpoints! { adding_new_node (bool), old_expression_of_edited_node (Expression), editing_aborted (NodeId), - editing_committed_old_searcher (NodeId, Option), editing_committed (NodeId, Option), project_list_shown (bool), code_editor_shown (bool), @@ -226,31 +221,13 @@ impl Model { } } - /// Update Searcher View - its visibility and position - when edited node changed. - fn update_searcher_view( - &self, - searcher_parameters: Option, - is_searcher_empty: bool, - searcher_left_top_position: &DEPRECATED_Animation>, - ) { - match searcher_parameters { - Some(SearcherParams { input, .. }) if !is_searcher_empty => { - self.searcher.show(); - let new_position = self.searcher_anchor_next_to_node(input); - searcher_left_top_position.set_target_value(new_position); - } - _ => { - self.searcher.hide(); - } - } - } - fn show_fullscreen_visualization(&self, node_id: NodeId) { let node = self.graph_editor.nodes().get_cloned_ref(&node_id); if let Some(node) = node { let visualization = node.view.model().visualization.fullscreen_visualization().clone_ref(); self.display_object.remove_child(&*self.graph_editor); + self.display_object.remove_child(&self.project_view_top_bar); self.display_object.add_child(&visualization); *self.fullscreen_vis.borrow_mut() = Some(visualization); } @@ -260,6 +237,7 @@ impl Model { if let Some(visualization) = std::mem::take(&mut *self.fullscreen_vis.borrow_mut()) { self.display_object.remove_child(&visualization); self.display_object.add_child(&*self.graph_editor); + self.display_object.add_child(&self.project_view_top_bar); } } @@ -292,14 +270,6 @@ impl Model { fn hide_project_list(&self) { self.display_object.remove_child(&*self.project_list); } - - fn show_graph_editor(&self) { - self.display_object.add_child(&*self.graph_editor); - } - - fn hide_graph_editor(&self) { - self.display_object.remove_child(&*self.graph_editor); - } } @@ -368,32 +338,36 @@ impl View { _ => Theme::Light, }; - let scene = app.display.default_scene.clone_ref(); + let scene = &app.display.default_scene; scene.begin_shader_initialization(); let model = Model::new(app); let frp = Frp::new(); - let network = &frp.network; - let searcher = &model.searcher.frp(); - let project_view_top_bar = &model.project_view_top_bar; - let graph = &model.graph_editor.frp; - let code_editor = &model.code_editor; - let project_list = &model.project_list; - let searcher_anchor = DEPRECATED_Animation::>::new(network); // FIXME[WD]: Think how to refactor it, as it needs to be done before model, as we do not // want shader recompilation. Model uses styles already. model.set_style(theme); - let input_change_delay = frp::io::timer::Timeout::new(network); + Self { model, frp } + .init_top_bar_frp(scene) + .init_graph_editor_frp() + .init_code_editor_frp() + .init_searcher_position_frp(scene) + .init_searcher_input_changes_frp() + .init_opening_searcher_frp() + .init_closing_searcher_frp() + .init_open_projects_dialog_frp(scene) + .init_style_toggle_frp() + .init_fullscreen_visualization_frp() + .init_debug_mode_frp() + } + + fn init_top_bar_frp(self, scene: &Scene) -> Self { + let frp = &self.frp; + let network = &frp.network; + let model = &self.model; + let project_view_top_bar = &model.project_view_top_bar; frp::extend! { network init <- source_(); - - eval_ frp.show_graph_editor(model.show_graph_editor()); - eval_ frp.hide_graph_editor(model.hide_graph_editor()); - - - // === Project View Top Bar === - let window_control_buttons = &project_view_top_bar.window_control_buttons; eval_ window_control_buttons.close (model.on_close_clicked()); eval_ window_control_buttons.fullscreen (model.on_fullscreen_clicked()); @@ -412,20 +386,56 @@ impl View { ); project_view_top_bar_width <- project_view_top_bar_display_object.on_resized.map(|new_size| new_size.x); - graph.graph_editor_top_bar_offset_x <+ project_view_top_bar_width; + self.model.graph_editor.graph_editor_top_bar_offset_x <+ project_view_top_bar_width; + } + init.emit(()); + self + } + fn init_graph_editor_frp(self) -> Self { + let frp = &self.frp; + let network = &frp.network; + let model = &self.model; + let graph = &model.graph_editor; + let searcher = &model.searcher; + let documentation = &searcher.model().documentation; - // === Read-only mode === + frp::extend! { network + // We block graph navigator if it interferes with other panels (searcher, documentation, + // etc.) + searcher_active <- searcher.is_hovered || documentation.frp.is_selected; + disable_navigation <- searcher_active || frp.project_list_shown; + graph.set_navigator_disabled <+ disable_navigation; + model.popup.set_label <+ graph.model.breadcrumbs.project_name_error; graph.set_read_only <+ frp.set_read_only; - code_editor.set_read_only <+ frp.set_read_only; + graph.set_debug_mode <+ frp.source.debug_mode; + + frp.source.fullscreen_visualization_shown <+ + graph.output.visualization_fullscreen.is_some(); + } + self + } + fn init_code_editor_frp(self) -> Self { + let _network = &self.frp.network; + frp::extend! { _network + self.model.code_editor.set_read_only <+ self.frp.set_read_only; + } + self + } - // === Searcher Position and Size === + fn init_searcher_position_frp(self, scene: &Scene) -> Self { + let frp = &self.frp; + let network = &frp.network; + let model = &self.model; + let main_cam = scene.layers.main.camera(); + let searcher_cam = scene.layers.node_searcher.camera(); + let main_cam_frp = &main_cam.frp(); + let searcher = &self.model.searcher; + let anchor = Animation::>::new(network); - let main_cam = app.display.default_scene.layers.main.camera(); - let searcher_cam = app.display.default_scene.layers.node_searcher.camera(); - let main_cam_frp = &main_cam.frp(); + frp::extend! { network // We want to: // 1. Preserve the zoom factor of the searcher. // 2. Keep it directly below edited node at all times. @@ -442,8 +452,10 @@ impl View { // x = 100 * 0.1 = 10 in searcher_cam-space. To compensate for that, we need to move // searcher (or rather searcher_cam) by 90 units, so that the node is at x = 100 both // in searcher_cam- and in main_cam-space. - searcher_cam_pos <- all_with3 - (&main_cam_frp.position, &main_cam_frp.zoom, &searcher_anchor.value, + searcher_cam_pos <- all_with3( + &main_cam_frp.position, + &main_cam_frp.zoom, + &anchor.value, |&main_cam_pos, &zoom, &searcher_pos| { let preserve_zoom = (main_cam_pos * zoom).xy(); let move_to_edited_node = searcher_pos * (1.0 - zoom); @@ -451,148 +463,151 @@ impl View { }); eval searcher_cam_pos ((pos) searcher_cam.set_xy(*pos)); + // Compute positions. It should be done _before_ showing searcher (or we display it at + // wrong position). + input <- frp.searcher.filter_map(|s| Some(s.as_ref()?.input)); + let node_position_set = model.graph_editor.output.node_position_set.clone_ref(); + is_input_position_update <- + node_position_set.map2(&input, |&(node_id, _), &input_id| node_id == input_id); + input_position_changed <- is_input_position_update.on_true(); + set_anchor_to_node <- all(input, input_position_changed)._0(); + anchor.target <+ set_anchor_to_node.map(f!((&input) model.searcher_anchor_next_to_node(input))); + anchor.skip <+ set_anchor_to_node.gate_not(&searcher.is_visible).constant(()); + let searcher_offset = &model.searcher.expression_input_position; + position <- all_with(&anchor.value, searcher_offset, |anchor, pos| anchor - pos); + eval position ((pos) model.searcher.set_xy(*pos)); + + // Showing searcher. + searcher.show <+ frp.searcher.is_some().on_true().constant(()); + searcher.hide <+ frp.searcher.is_none().on_true().constant(()); eval searcher.is_visible ([model](is_visible) { let is_attached = model.searcher.has_parent(); - if !is_attached && *is_visible { - model.display_object.add_child(&model.searcher); - } else if is_attached && !is_visible { - model.display_object.remove_child(&model.searcher); + match (is_attached, is_visible) { + (false, true) => model.display_object.add_child(&model.searcher), + (true, false) => model.display_object.remove_child(&model.searcher), + _ => () } }); + } + self + } + fn init_opening_searcher_frp(self) -> Self { + let frp = &self.frp; + let network = &frp.network; + let graph = &self.model.graph_editor; - // === Closing Searcher + frp::extend! { network + node_added_by_user <- graph.node_added.filter(|(_, _, should_edit)| *should_edit); + searcher_for_adding <- node_added_by_user.map( + |&(node, src, _)| SearcherParams::new_for_new_node(node, src) + ); + frp.source.adding_new_node <+ searcher_for_adding.to_true(); + new_node_edited <- graph.node_editing_started.gate(&frp.adding_new_node); + frp.source.searcher <+ searcher_for_adding.sample(&new_node_edited).some(); + edit_which_opens_searcher <- + graph.node_expression_edited.gate_not(&frp.is_searcher_opened).debounce(); + frp.source.searcher <+ edit_which_opens_searcher.map(|(node_id, _, selections)| { + let cursor_position = selections.last().map(|sel| sel.end).unwrap_or_default(); + Some(SearcherParams::new_for_edited_node(*node_id, cursor_position)) + }); frp.source.is_searcher_opened <+ frp.searcher.map(|s| s.is_some()); - last_searcher <- frp.searcher.filter_map(|&s| s); + } + self + } - finished_with_searcher <- graph.node_editing_finished.gate(&frp.is_searcher_opened); - frp.source.searcher <+ frp.close_searcher.constant(None); - frp.source.searcher <+ searcher.editing_committed.constant(None); - frp.source.searcher <+ finished_with_searcher.constant(None); + fn init_closing_searcher_frp(self) -> Self { + let frp = &self.frp; + let network = &frp.network; + let grid = &self.model.searcher.model().list.model().grid; + let graph = &self.model.graph_editor; + frp::extend! { network + last_searcher <- frp.searcher.filter_map(|&s| s); + + node_editing_finished <- graph.node_editing_finished.gate(&frp.is_searcher_opened); + committed_in_searcher <- + grid.expression_accepted.map2(&last_searcher, |&entry, &s| (s.input, entry)); aborted_in_searcher <- frp.close_searcher.map2(&last_searcher, |(), &s| s.input); + frp.source.editing_committed <+ committed_in_searcher; + frp.source.editing_committed <+ node_editing_finished.map(|id| (*id,None)); frp.source.editing_aborted <+ aborted_in_searcher; - } - let grid = &model.searcher.model().list.model().grid; - frp::extend! { network - committed_in_browser <- grid.expression_accepted.map2(&last_searcher, |&entry, &s| (s.input, entry)); - frp.source.editing_committed <+ committed_in_browser; - frp.source.editing_committed <+ finished_with_searcher.map(|id| (*id,None)); - } + // Should be done before we update `searcher` and `adding_new_node` outputs. + adding_committed <- committed_in_searcher.gate(&frp.adding_new_node); + graph.deselect_all_nodes <+ adding_committed.constant(()); + graph.select_node <+ adding_committed._0(); - let anchor = &searcher_anchor.value; - frp::extend! { network - committed_in_searcher_event <- searcher.editing_committed.constant(()); + node_editing_finished_event <- node_editing_finished.constant(()); + committed_in_searcher_event <- committed_in_searcher.constant(()); aborted_in_searcher_event <- aborted_in_searcher.constant(()); + searcher_should_close <- any( + node_editing_finished_event, + committed_in_searcher_event, + aborted_in_searcher_event + ); graph.stop_editing <+ any(&committed_in_searcher_event, &aborted_in_searcher_event); + frp.source.searcher <+ searcher_should_close.constant(None); + frp.source.adding_new_node <+ searcher_should_close.constant(false); + } + self + } + fn init_searcher_input_changes_frp(self) -> Self { + let frp = &self.frp; + let network = &frp.network; + let graph = &self.model.graph_editor; + let input_change_delay = frp::io::timer::Timeout::new(network); - // === Editing === - - node_edited_by_user <- graph.node_being_edited.gate_not(&frp.adding_new_node); - existing_node_edited <- graph.node_expression_edited.gate_not(&frp.is_searcher_opened); - open_searcher <- existing_node_edited.map2(&node_edited_by_user, - |(id, _, _), edited| edited.map_or(false, |edited| *id == edited) - ).on_true().debounce(); - cursor_position <- existing_node_edited.map2( - &node_edited_by_user, - |(node_id, _, selections), edited| { - edited.map_or(None, |edited| { - let position = || selections.last().map(|sel| sel.end).unwrap_or_default(); - (*node_id == edited).then(position) - }) - } - ).filter_map(|pos| *pos); - edited_node <- node_edited_by_user.filter_map(|node| *node); - position_and_edited_node <- cursor_position.map2(&edited_node, |pos, id| (*pos, *id)); - prepare_params <- position_and_edited_node.sample(&open_searcher); - frp.source.searcher <+ prepare_params.map(|(pos, node_id)| { - Some(SearcherParams::new_for_edited_node(*node_id, *pos)) - }); + frp::extend! { network searcher_input_change_opt <- graph.node_expression_edited.map2(&frp.searcher, |(node_id, expr, selections), searcher| { let input_change = || (*node_id, expr.clone_ref(), selections.clone()); (searcher.as_ref()?.input == *node_id).then(input_change) } ); - searcher_input_change <- searcher_input_change_opt.unwrap(); - input_change_delay.restart <+ searcher_input_change.constant(INPUT_CHANGE_DELAY_MS); - update_searcher_input_on_commit <- frp.output.editing_committed.constant(()); - input_change_delay.cancel <+ update_searcher_input_on_commit; - update_searcher_input <- any(&input_change_delay.on_expired, &update_searcher_input_on_commit); - input_change_and_searcher <- all_with(&searcher_input_change, &frp.searcher, - |c, s| (c.clone(), *s) - ); - updated_input <- input_change_and_searcher.sample(&update_searcher_input); + input_change <- searcher_input_change_opt.unwrap(); + input_change_delay.restart <+ input_change.constant(INPUT_CHANGE_DELAY_MS); + update_input_on_commit <- frp.output.editing_committed.constant(()); + input_change_delay.cancel <+ update_input_on_commit; + update_input <- any(&input_change_delay.on_expired, &update_input_on_commit); + input_change_and_searcher <- + all_with(&input_change, &frp.searcher, |c, s| (c.clone(), *s)); + updated_input <- input_change_and_searcher.sample(&update_input); input_changed <- updated_input.filter_map(|((node_id, expr, selections), searcher)| { let input_change = || (expr.clone_ref(), selections.clone()); (searcher.as_ref()?.input == *node_id).then(input_change) }); frp.source.searcher_input_changed <+ input_changed; + } + self + } - // === Adding Node === - - node_added_by_user <- graph.node_added.filter(|(_, _, should_edit)| *should_edit); - searcher_for_adding <- node_added_by_user.map( - |&(node, src, _)| SearcherParams::new_for_new_node(node, src) - ); - frp.source.adding_new_node <+ searcher_for_adding.to_true(); - new_node_edited <- graph.node_editing_started.gate(&frp.adding_new_node); - frp.source.searcher <+ searcher_for_adding.sample(&new_node_edited).map(|&s| Some(s)); - - adding_committed_new_searcher <- frp.editing_committed.map(|(id,_)| *id); - adding_committed_old_searcher <- frp.editing_committed_old_searcher.map(|(id,_)| *id); - adding_committed <- any(&adding_committed_new_searcher,&adding_committed_old_searcher).gate(&frp.adding_new_node); - adding_aborted <- frp.editing_aborted.gate(&frp.adding_new_node); - adding_finished <- any(adding_committed,adding_aborted); - frp.source.adding_new_node <+ adding_finished.constant(false); - frp.source.searcher <+ adding_finished.constant(None); - - eval adding_committed ([graph](node) { - graph.deselect_all_nodes(); - graph.select_node(node); - }); - - - // === Searcher Position and Visibility === - - visibility_conditions <- all(&frp.searcher,&searcher.is_empty); - _eval <- visibility_conditions.map2(&searcher.is_visible, - f!([model,searcher_anchor]((searcher,is_searcher_empty),is_visible) { - model.update_searcher_view(*searcher,*is_searcher_empty,&searcher_anchor); - if !is_visible { - // Do not animate - searcher_anchor.skip(); - } - }) - ); - - _eval <- graph.output.node_position_set.map2(&frp.searcher, - f!([searcher_anchor](&(node_id, position), &searcher) { - if searcher.map_or(false, |s| s.input == node_id) { - searcher_anchor.set_target_value(position); - } - }) - ); - - cb_position <- all_with(anchor, &model.searcher.expression_input_position, |anchor, pos| anchor - pos); - eval cb_position ((pos) model.searcher.set_xy(*pos)); - - // === Project Dialog === - + fn init_open_projects_dialog_frp(self, scene: &Scene) -> Self { + let frp = &self.frp; + let network = &frp.network; + let model = &self.model; + let project_list = &model.project_list; + frp::extend! { network eval_ frp.show_project_list (model.show_project_list()); project_chosen <- project_list.frp.selected_project.constant(()); - mouse_down <- scene.mouse.frp_deprecated.down.constant(()); - clicked_on_bg <- mouse_down.filter(f_!(scene.mouse.target.get().is_background())); + mouse_down <- scene.mouse.frp_deprecated.down.constant(()); + clicked_on_bg <- mouse_down.filter(f_!(scene.mouse.target.get().is_background())); should_be_closed <- any(frp.hide_project_list,project_chosen,clicked_on_bg); eval_ should_be_closed (model.hide_project_list()); frp.source.project_list_shown <+ bool(&should_be_closed,&frp.show_project_list); + frp.source.drop_files_enabled <+ frp.project_list_shown.map(|v| !v); + } + frp.source.drop_files_enabled.emit(true); + self + } - - // === Style toggle === - + fn init_style_toggle_frp(self) -> Self { + let frp = &self.frp; + let network = &frp.network; + let model = &self.model; + frp::extend! {network let style_toggle_ev = frp.toggle_style.clone_ref(); style_pressed <- style_toggle_ev.toggle() ; style_was_pressed <- style_pressed.previous(); @@ -603,10 +618,15 @@ impl View { }); frp.source.style <+ style_press_on_off; eval frp.style ((style) model.set_style(style.clone())); + } + self + } - - // === Fullscreen Visualization === - + fn init_fullscreen_visualization_frp(self) -> Self { + let network = &self.frp.network; + let model = &self.model; + let graph = &self.model.graph_editor; + frp::extend! { network // TODO[ao]: All DOM elements in visualizations are displayed below canvas, because // The mouse cursor must be displayed over them. But fullscreen visualization should // be displayed "above" nodes. The workaround is to hide whole graph editor except @@ -621,35 +641,21 @@ impl View { model.hide_fullscreen_visualization() } }); + } + self + } - // === Disabling Navigation === - - let documentation = &model.searcher.model().documentation; - searcher_active <- searcher.is_hovered || documentation.frp.is_selected; - disable_navigation <- searcher_active || frp.project_list_shown; - graph.set_navigator_disabled <+ disable_navigation; - - // === Disabling Dropping === - - frp.source.drop_files_enabled <+ init.constant(true); - frp.source.drop_files_enabled <+ frp.project_list_shown.map(|v| !v); - - // === Debug Mode === - + fn init_debug_mode_frp(self) -> Self { + let frp = &self.frp; + let network = &frp.network; + let popup = &self.model.debug_mode_popup; + frp::extend! { network frp.source.debug_mode <+ bool(&frp.disable_debug_mode, &frp.enable_debug_mode); - graph.set_debug_mode <+ frp.source.debug_mode; - - model.debug_mode_popup.enabled <+ frp.enable_debug_mode; - model.debug_mode_popup.disabled <+ frp.disable_debug_mode; - // === Error Pop-up === - - model.popup.set_label <+ model.graph_editor.model.breadcrumbs.project_name_error; + popup.enabled <+ frp.enable_debug_mode; + popup.disabled <+ frp.disable_debug_mode; } - - init.emit(()); - - Self { model, frp } + self } /// Graph Editor View. diff --git a/app/gui/view/src/project_list.rs b/app/gui/view/src/project_list.rs index 71177c7f2ff9..afb5a8429f21 100644 --- a/app/gui/view/src/project_list.rs +++ b/app/gui/view/src/project_list.rs @@ -12,7 +12,6 @@ use ensogl::data::color; use ensogl::display; use ensogl::display::scene::Layer; use ensogl_component::grid_view; -use ensogl_component::list_view; use ensogl_component::shadow; use ensogl_derive_theme::FromTheme; use ensogl_hardcoded_theme::application::project_list as theme; @@ -247,13 +246,6 @@ impl ProjectList { caption.set_content("Open Project"); caption.add_to_scene_layer(&app.display.default_scene.layers.panel_text); - ensogl::shapes_order_dependencies! { - app.display.default_scene => { - background -> list_view::selection; - list_view::background -> background; - } - } - let style_frp = StyleWatchFrp::new(&app.display.default_scene.style_sheet); let style = Style::from_theme(network, &style_frp); diff --git a/app/gui/view/src/root.rs b/app/gui/view/src/root.rs index 11984b2f081e..5919ab3a1e8b 100644 --- a/app/gui/view/src/root.rs +++ b/app/gui/view/src/root.rs @@ -84,9 +84,17 @@ impl Model { fn init_project_view(&self) { if self.project_view.get().is_none() { + let network = &self.frp.network; let view = self.app.new_view::(); let project_list_frp = &view.project_list().frp; - frp::extend! { network + let status_bar = &self.status_bar; + let display_object = &self.display_object; + frp::new_bridge_network! { [network, view.network] project_bridge + fs_vis_shown <- view.fullscreen_visualization_shown.on_true(); + fs_vis_hidden <- view.fullscreen_visualization_shown.on_false(); + eval fs_vis_shown ((_) status_bar.unset_parent()); + eval fs_vis_hidden ([display_object, status_bar](_) display_object.add_child(&status_bar)); + self.frp.source.selected_project <+ project_list_frp.selected_project; } self.project_view.set(Some(view)); @@ -150,6 +158,10 @@ impl View { eval_ frp.switch_view_to_welcome_screen(model.switch_view_to_welcome_screen()); offset_y <- all(&init,&offset_y)._1(); eval offset_y ((offset_y) model.status_bar.set_y(*offset_y)); + + model.status_bar.add_event <+ app.frp.show_notification.map(|message| { + message.into() + }); } init.emit(()); Self { model, frp } diff --git a/app/gui/view/src/searcher.rs b/app/gui/view/src/searcher.rs index 3ad96ab40241..f927a9ca6042 100644 --- a/app/gui/view/src/searcher.rs +++ b/app/gui/view/src/searcher.rs @@ -231,7 +231,7 @@ impl View { eval height.value ((h) model.set_height(*h)); eval frp.show ((()) height.set_target_value(SEARCHER_HEIGHT)); - eval frp.hide ((()) height.set_target_value(-list_view::SHADOW_PX)); + eval frp.hide ((()) height.set_target_value(0.0)); is_selected <- selected_entry.map(|e| e.is_some()); is_enabled <- bool(&frp.hide,&frp.show); diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index 9b7bcf76da35..146654203945 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -274,6 +274,14 @@ export default [ }, ], 'sort-imports': ['error', { allowSeparatedGroups: true }], + 'no-restricted-properties': [ + 'error', + { + object: 'router', + property: 'useNavigate', + message: 'Use `hooks.useNavigate` instead.', + }, + ], 'no-restricted-syntax': ['error', ...RESTRICTED_SYNTAXES], 'prefer-arrow-callback': 'error', // Prefer `interface` over `type`. @@ -425,6 +433,30 @@ export default [ 'no-undef': 'off', }, }, + { + files: [ + 'lib/dashboard/src/**/*.ts', + 'lib/dashboard/src/**/*.mts', + 'lib/dashboard/src/**/*.cts', + 'lib/dashboard/src/**/*.tsx', + 'lib/dashboard/src/**/*.mtsx', + 'lib/dashboard/src/**/*.ctsx', + ], + rules: { + 'no-restricted-properties': [ + 'error', + { + object: 'console', + message: 'Avoid leaving debugging statements when committing code', + }, + { + object: 'hooks', + property: 'useDebugState', + message: 'Avoid leaving debugging statements when committing code', + }, + ], + }, + }, { files: ['**/*.d.ts'], rules: { diff --git a/app/ide-desktop/lib/content/esbuild-config.ts b/app/ide-desktop/lib/content/esbuild-config.ts index 222194bee60b..dfbccd1b7da7 100644 --- a/app/ide-desktop/lib/content/esbuild-config.ts +++ b/app/ide-desktop/lib/content/esbuild-config.ts @@ -116,6 +116,7 @@ export function bundlerOptions(args: Arguments) { pathModule.resolve(THIS_PATH, 'src', 'run.js'), pathModule.resolve(THIS_PATH, 'src', 'style.css'), pathModule.resolve(THIS_PATH, 'src', 'docsStyle.css'), + pathModule.resolve(THIS_PATH, 'src', 'serviceWorker.ts'), ...wasmArtifacts.split(pathModule.delimiter), ...fsSync .readdirSync(assetsPath) diff --git a/app/ide-desktop/lib/content/src/devServiceWorker.ts b/app/ide-desktop/lib/content/src/devServiceWorker.ts new file mode 100644 index 000000000000..8d2089a79c7f --- /dev/null +++ b/app/ide-desktop/lib/content/src/devServiceWorker.ts @@ -0,0 +1,59 @@ +/** @file A service worker that redirects paths without extensions to `/index.html`. + * This is required for paths like `/login`, which are handled by client-side routing, + * to work when developing locally on `localhost:8080`. */ +// Bring globals and interfaces specific to Web Workers into scope. +/// +import * as common from 'enso-common' + +import * as constants from './serviceWorkerConstants' + +// ===================== +// === Fetch handler === +// ===================== + +// We `declare` a variable here because Service Workers have a different global scope. +// eslint-disable-next-line no-restricted-syntax +declare const self: ServiceWorkerGlobalScope + +self.addEventListener('install', event => { + event.waitUntil( + caches.open(constants.CACHE_NAME).then(cache => { + void cache.addAll(constants.DEPENDENCIES) + return + }) + ) +}) + +self.addEventListener('fetch', event => { + const url = new URL(event.request.url) + if (url.hostname === 'localhost' && url.pathname === '/esbuild') { + return false + } else if (url.hostname === 'localhost') { + const responsePromise = caches + .open(constants.CACHE_NAME) + .then(cache => cache.match(event.request)) + .then(response => + response ?? /\/[^.]+$/.test(url.pathname) + ? fetch('/index.html') + : fetch(event.request.url) + ) + event.respondWith( + responsePromise.then(response => { + const clonedResponse = new Response(response.body, response) + for (const [header, value] of common.COOP_COEP_CORP_HEADERS) { + clonedResponse.headers.set(header, value) + } + return clonedResponse + }) + ) + return + } else { + event.respondWith( + caches + .open(constants.CACHE_NAME) + .then(cache => cache.match(event.request)) + .then(response => response ?? fetch(event.request)) + ) + return + } +}) diff --git a/app/ide-desktop/lib/content/src/index.ts b/app/ide-desktop/lib/content/src/index.ts index 746e3ce4dae8..b5aec160b6f2 100644 --- a/app/ide-desktop/lib/content/src/index.ts +++ b/app/ide-desktop/lib/content/src/index.ts @@ -23,8 +23,10 @@ const INITIAL_URL_KEY = `${common.PRODUCT_NAME.toLowerCase()}-initial-url` const ESBUILD_PATH = '/esbuild' /** SSE event indicating a build has finished. */ const ESBUILD_EVENT_NAME = 'change' -/** Path to the service worker that resolves all extensionless paths to `/index.html`. - * This service worker is required for client-side routing to work when doing `./run gui watch`. */ +/** Path to the serice worker that caches assets for offline usage. + * In development, it also resolves all extensionless paths to `/index.html`. + * This is required for client-side routing to work when doing `./run gui watch`. + */ const SERVICE_WORKER_PATH = '/serviceWorker.js' /** One second in milliseconds. */ const SECOND = 1000 @@ -41,12 +43,8 @@ if (IS_DEV_MODE) { // The `toString()` is to bypass a lint without using a comment. location.href = location.href.toString() }) - void navigator.serviceWorker.register(SERVICE_WORKER_PATH) -} else { - void navigator.serviceWorker - .getRegistration() - .then(serviceWorker => serviceWorker?.unregister()) } +void navigator.serviceWorker.register(SERVICE_WORKER_PATH) // ============= // === Fetch === @@ -221,13 +219,13 @@ class Main implements AppRunner { const isOpeningMainEntryPoint = contentConfig.OPTIONS.groups.startup.options.entry.value === contentConfig.OPTIONS.groups.startup.options.entry.default - const isNotOpeningProject = - contentConfig.OPTIONS.groups.startup.options.project.value === '' - if ( - (isUsingAuthentication || isUsingNewDashboard) && - isOpeningMainEntryPoint && - isNotOpeningProject - ) { + // This MUST be removed as it would otherwise override the `startup.project` passed + // explicitly in `ide.tsx`. + if (isOpeningMainEntryPoint && url.searchParams.has('startup.project')) { + url.searchParams.delete('startup.project') + history.replaceState(null, '', url.toString()) + } + if ((isUsingAuthentication || isUsingNewDashboard) && isOpeningMainEntryPoint) { this.runAuthentication(isInAuthenticationFlow, inputConfig) } else { void this.runApp(inputConfig) @@ -237,6 +235,8 @@ class Main implements AppRunner { /** Begins the authentication UI flow. */ runAuthentication(isInAuthenticationFlow: boolean, inputConfig?: StringConfig) { + const initialProjectName = + contentConfig.OPTIONS.groups.startup.options.project.value || null /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/345 * `content` and `dashboard` packages **MUST BE MERGED INTO ONE**. The IDE * should only have one entry point. Right now, we have two. One for the cloud @@ -252,6 +252,7 @@ class Main implements AppRunner { supportsLocalBackend: SUPPORTS_LOCAL_BACKEND, supportsDeepLinks: SUPPORTS_DEEP_LINKS, showDashboard: contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value, + initialProjectName, onAuthenticated: () => { if (isInAuthenticationFlow) { const initialUrl = localStorage.getItem(INITIAL_URL_KEY) diff --git a/app/ide-desktop/lib/content/src/project_manager.ts b/app/ide-desktop/lib/content/src/project_manager.ts deleted file mode 100644 index 324f4ea79fe1..000000000000 --- a/app/ide-desktop/lib/content/src/project_manager.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** @file This module defines the Project Manager endpoint. */ -import * as newtype from './newtype' - -const PROJECT_MANAGER_ENDPOINT = 'ws://127.0.0.1:30535' - -// ============= -// === Types === -// ============= - -/** Possible actions to take when a component is missing. */ -export enum MissingComponentAction { - fail = 'Fail', - install = 'Install', - forceInstallBroken = 'ForceInstallBroken', -} - -/** The return value of a JSON-RPC call. */ -interface Result { - result: T -} - -// This intentionally has the same brand as in the cloud backend API. -/** An ID of a project. */ -export type ProjectId = newtype.Newtype -/** A name of a project. */ -export type ProjectName = newtype.Newtype -/** A UTC value containing a date and a time. */ -export type UTCDateTime = newtype.Newtype - -/** Details for a project. */ -interface ProjectMetadata { - name: ProjectName - namespace: string - id: ProjectId - engineVersion: string | null - lastOpened: UTCDateTime | null -} - -/** A value specifying a socket's hostname and port. */ -interface IpWithSocket { - host: string - port: number -} - -/** The return value of the "list projects" endpoint. */ -interface ProjectList { - projects: ProjectMetadata[] -} - -/** The return value of the "create project" endpoint. */ -interface CreateProject { - projectId: ProjectId -} - -/** The return value of the "open project" endpoint. */ -interface OpenProject { - engineVersion: string - languageServerJsonAddress: IpWithSocket - languageServerBinaryAddress: IpWithSocket - projectName: ProjectName - projectNamespace: string -} - -// ================================ -// === Parameters for endpoints === -// ================================ - -/** Parameters for the "open project" endpoint. */ -export interface OpenProjectParams { - projectId: ProjectId - missingComponentAction: MissingComponentAction -} - -/** Parameters for the "close project" endpoint. */ -export interface CloseProjectParams { - projectId: ProjectId -} - -/** Parameters for the "list projects" endpoint. */ -export interface ListProjectsParams { - numberOfProjects?: number -} - -/** Parameters for the "create project" endpoint. */ -export interface CreateProjectParams { - name: ProjectName - projectTemplate?: string - version?: string - missingComponentAction?: MissingComponentAction -} - -/** Parameters for the "list samples" endpoint. */ -export interface RenameProjectParams { - projectId: ProjectId - name: ProjectName -} - -/** Parameters for the "delete project" endpoint. */ -export interface DeleteProjectParams { - projectId: ProjectId -} - -/** Parameters for the "list samples" endpoint. */ -export interface ListSamplesParams { - projectId: ProjectId -} - -// ======================= -// === Project Manager === -// ======================= - -/** A WebSocket endpoint to the Project Manager. */ -export class ProjectManager { - /** Creates a {@link ProjectManager}. */ - constructor(protected readonly connectionUrl: string) {} - - /** The returns the singleton instance of the {@link ProjectManager}. */ - static default() { - return new ProjectManager(PROJECT_MANAGER_ENDPOINT) - } - - /** Sends a JSON-RPC request to the WebSocket endpoint. */ - public async sendRequest(method: string, params: unknown): Promise> { - const req = { - jsonrpc: '2.0', - id: 0, - method, - params, - } - - const ws = new WebSocket(this.connectionUrl) - return new Promise>((resolve, reject) => { - ws.onopen = () => { - ws.send(JSON.stringify(req)) - } - ws.onmessage = event => { - // There is no way to avoid this; `JSON.parse` returns `any`. - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - resolve(JSON.parse(event.data)) - } - ws.onerror = error => { - reject(error) - } - }).finally(() => { - ws.close() - }) - } - - /** * Open an existing project. */ - public async openProject(params: OpenProjectParams): Promise> { - return this.sendRequest('project/open', params) - } - - /** * Close an open project. */ - public async closeProject(params: CloseProjectParams): Promise> { - return this.sendRequest('project/close', params) - } - - /** * Get the projects list, sorted by open time. */ - public async listProjects(params: ListProjectsParams): Promise> { - return this.sendRequest('project/list', params) - } - - /** * Create a new project. */ - public async createProject(params: CreateProjectParams): Promise> { - return this.sendRequest('project/create', { - missingComponentAction: MissingComponentAction.install, - ...params, - }) - } - - /** * Rename a project. */ - public async renameProject(params: RenameProjectParams): Promise> { - return this.sendRequest('project/rename', params) - } - - /** * Delete a project. */ - public async deleteProject(params: DeleteProjectParams): Promise> { - return this.sendRequest('project/delete', params) - } - - /** * Get the list of sample projects that are available to the user. */ - public async listSamples(params: ListSamplesParams): Promise> { - return this.sendRequest('project/listSample', params) - } -} diff --git a/app/ide-desktop/lib/content/src/serviceWorker.ts b/app/ide-desktop/lib/content/src/serviceWorker.ts index e51a59ef3e95..9066999b86d5 100644 --- a/app/ide-desktop/lib/content/src/serviceWorker.ts +++ b/app/ide-desktop/lib/content/src/serviceWorker.ts @@ -3,7 +3,7 @@ * to work when developing locally on `localhost:8080`. */ // Bring globals and interfaces specific to Web Workers into scope. /// -import * as common from 'enso-common' +import * as constants from './serviceWorkerConstants' // ===================== // === Fetch handler === @@ -13,23 +13,26 @@ import * as common from 'enso-common' // eslint-disable-next-line no-restricted-syntax declare const self: ServiceWorkerGlobalScope +self.addEventListener('install', event => { + event.waitUntil( + caches.open(constants.CACHE_NAME).then(cache => { + void cache.addAll(constants.DEPENDENCIES) + return + }) + ) +}) + self.addEventListener('fetch', event => { const url = new URL(event.request.url) - if (url.hostname === 'localhost' && url.pathname !== '/esbuild') { - const responsePromise = /\/[^.]+$/.test(new URL(event.request.url).pathname) - ? fetch('/index.html') - : fetch(event.request.url) + if (url.hostname === 'localhost') { + return false + } else { event.respondWith( - responsePromise.then(response => { - const clonedResponse = new Response(response.body, response) - for (const [header, value] of common.COOP_COEP_CORP_HEADERS) { - clonedResponse.headers.set(header, value) - } - return clonedResponse - }) + caches + .open(constants.CACHE_NAME) + .then(cache => cache.match(event.request)) + .then(response => response ?? fetch(event.request)) ) return - } else { - return false } }) diff --git a/app/ide-desktop/lib/content/src/serviceWorkerConstants.js b/app/ide-desktop/lib/content/src/serviceWorkerConstants.js new file mode 100644 index 000000000000..b3904f5c976e --- /dev/null +++ b/app/ide-desktop/lib/content/src/serviceWorkerConstants.js @@ -0,0 +1,61 @@ +/** @file Constants shared between all service workers (development and production). */ +import * as common from 'enso-common' + +/** The name of the cache under which offline assets are stored. */ +export const CACHE_NAME = common.PRODUCT_NAME.toLowerCase() + +/** The numbers after each font loaded by the "M PLUS 1" font. */ +const M_PLUS_1_SECTIONS = [ + /* eslint-disable @typescript-eslint/no-magic-numbers */ + 0, 1, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, + 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 53, + 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, + 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, + 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, + /* eslint-enable @typescript-eslint/no-magic-numbers */ +] + +/** The complete list of assets to cache for offline use. */ +export const DEPENDENCIES = [ + // app/gui/view/graph-editor/src/builtin/visualization/java_script/heatmap.js + // app/gui/view/graph-editor/src/builtin/visualization/java_script/histogram.js + // app/gui/view/graph-editor/src/builtin/visualization/java_script/scatterPlot.js + 'https://d3js.org/d3.v4.min.js', + 'https://fonts.cdnfonts.com/css/dejavu-sans-mono', + // Loaded by https://fonts.cdnfonts.com/css/dejavu-sans-mono + 'https://fonts.cdnfonts.com/s/108/DejaVuSansMono.woff', + 'https://fonts.cdnfonts.com/s/108/DejaVuSansMono-Oblique.woff', + 'https://fonts.cdnfonts.com/s/108/DejaVuSansMono-Bold.woff', + 'https://fonts.cdnfonts.com/s/108/DejaVuSansMono-BoldOblique.woff', + // app/gui/view/graph-editor/src/builtin/visualization/java_script/geoMap.js + 'https://unpkg.com/deck.gl@8.4/dist.min.js', + 'https://api.mapbox.com/mapbox-gl-js/v2.1.1/mapbox-gl.js', + 'https://api.mapbox.com/mapbox-gl-js/v2.1.1/mapbox-gl.css', + // Loaded by https://api.mapbox.com/mapbox-gl-js/v2.1.1/mapbox-gl.js + 'https://api.mapbox.com/styles/v1/mapbox/light-v9?access_token=pk.' + + 'eyJ1IjoiZW5zby1vcmciLCJhIjoiY2tmNnh5MXh2MGlyOTJ5cWdubnFxbXo4ZSJ9.3KdAcCiiXJcSM18nwk09-Q', + 'https://api.mapbox.com/styles/v1/mapbox/light-v9/sprite.json?access_token=pk.' + + 'eyJ1IjoiZW5zby1vcmciLCJhIjoiY2tmNnh5MXh2MGlyOTJ5cWdubnFxbXo4ZSJ9.3KdAcCiiXJcSM18nwk09-Q', + 'https://api.mapbox.com/styles/v1/mapbox/light-v9/sprite.png?access_token=pk.' + + 'eyJ1IjoiZW5zby1vcmciLCJhIjoiY2tmNnh5MXh2MGlyOTJ5cWdubnFxbXo4ZSJ9.3KdAcCiiXJcSM18nwk09-Q', + // app/gui/view/graph-editor/src/builtin/visualization/java_script/sql.js + 'https://cdnjs.cloudflare.com/ajax/libs/sql-formatter/4.0.2/sql-formatter.min.js', + // app/gui/view/graph-editor/src/builtin/visualization/java_script/table.js + 'https://cdn.jsdelivr.net/npm/ag-grid-community/dist/ag-grid-community.min.js', + 'https://cdn.jsdelivr.net/npm/ag-grid-community/styles/ag-grid.css', + 'https://cdn.jsdelivr.net/npm/ag-grid-community/styles/ag-theme-alpine.css', + // app/ide-desktop/lib/content/src/docsStyle.css + 'https://fonts.gstatic.com/s/sourcecodepro/v14/HI_XiYsKILxRpg3hIP6sJ7fM7PqtlsnztA.ttf', + 'https://fonts.gstatic.com/s/sourcecodepro/v14/HI_SiYsKILxRpg3hIP6sJ7fM7PqVOg.ttf', + 'https://fonts.gstatic.com/s/sourcecodepro/v14/HI_XiYsKILxRpg3hIP6sJ7fM7PqtzsjztA.ttf', + 'https://fonts.gstatic.com/s/sourcecodepro/v14/HI_XiYsKILxRpg3hIP6sJ7fM7Pqt4s_ztA.ttf', + 'https://fonts.gstatic.com/s/sourcecodepro/v14/HI_XiYsKILxRpg3hIP6sJ7fM7Pqths7ztA.ttf', + // app/ide-desktop/lib/dashboard/src/tailwind.css + 'https://fonts.googleapis.com/css2?family=M+PLUS+1:wght@500;700&display=swap', + // Loaded by https://fonts.googleapis.com/css2?family=M+PLUS+1:wght@500;700&display=swap + ...M_PLUS_1_SECTIONS.map( + number => + `https://fonts.gstatic.com/s/mplus1/v6/` + + `R70ZjygA28ymD4HgBVu92j6eR1mYP_TX-Bb-rTg93gHfHe9F4Q.${number}.woff2` + ), +] diff --git a/app/ide-desktop/lib/content/watch.ts b/app/ide-desktop/lib/content/watch.ts index 091b48a87d70..8d951c4ea4b9 100644 --- a/app/ide-desktop/lib/content/watch.ts +++ b/app/ide-desktop/lib/content/watch.ts @@ -39,10 +39,12 @@ async function watch() { }) ) opts.define.REDIRECT_OVERRIDE = JSON.stringify('http://localhost:8080') - opts.entryPoints.push({ - in: path.resolve(THIS_PATH, 'src', 'serviceWorker.ts'), - out: 'serviceWorker', - }) + // This is safe as this entry point is statically known. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const serviceWorkerEntryPoint = opts.entryPoints.find( + entryPoint => entryPoint.out === 'serviceWorker' + )! + serviceWorkerEntryPoint.in = path.resolve(THIS_PATH, 'src', 'devServiceWorker.ts') const builder = await esbuild.context(opts) await builder.watch() await builder.serve({ diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/confirmRegistration.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/confirmRegistration.tsx index 5fdff64ffe70..157d368c0efe 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/confirmRegistration.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/confirmRegistration.tsx @@ -6,6 +6,7 @@ import toast from 'react-hot-toast' import * as app from '../../components/app' import * as auth from '../providers/auth' +import * as hooks from '../../hooks' import * as loggerProvider from '../../providers/logger' // ================= @@ -26,7 +27,7 @@ function ConfirmRegistration() { const logger = loggerProvider.useLogger() const { confirmSignUp } = auth.useAuth() const { search } = router.useLocation() - const navigate = router.useNavigate() + const navigate = hooks.useNavigate() const { verificationCode, email } = parseUrlSearchParams(search) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx index 527074fd132b..b343ba68b31c 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx @@ -81,6 +81,7 @@ function Registration() { { email: string } -/** Object containing the currently signed-in user's session data. */ -export interface FullUserSession extends BaseUserSession { - /** User's organization information. */ - organization: backendModule.UserOrOrganization +// Extends `BaseUserSession` in order to inherit the documentation. +/** Empty object of an offline user session. + * Contains some fields from {@link FullUserSession} to allow destructuring. */ +export interface OfflineUserSession extends Pick, 'type'> { + accessToken: null + organization: null +} + +/** The singleton instance of {@link OfflineUserSession}. + * Minimizes React re-renders. */ +const OFFLINE_USER_SESSION: OfflineUserSession = { + type: UserSessionType.offline, + accessToken: null, + organization: null, } /** Object containing the currently signed-in user's session data, if the user has not yet set their @@ -72,9 +86,15 @@ export interface FullUserSession extends BaseUserSession { * used by the `SetUsername` component. */ export interface PartialUserSession extends BaseUserSession {} +/** Object containing the currently signed-in user's session data. */ +export interface FullUserSession extends BaseUserSession { + /** User's organization information. */ + organization: backendModule.UserOrOrganization +} + /** A user session for a user that may be either fully registered, * or in the process of registering. */ -export type UserSession = FullUserSession | PartialUserSession +export type UserSession = FullUserSession | OfflineUserSession | PartialUserSession // =================== // === AuthContext === @@ -88,6 +108,7 @@ export type UserSession = FullUserSession | PartialUserSession * * See {@link Cognito} for details on each of the authentication functions. */ interface AuthContextType { + goOffline: () => Promise signUp: (email: string, password: string) => Promise confirmSignUp: (email: string, code: string) => Promise setUsername: ( @@ -152,12 +173,23 @@ export function AuthProvider(props: AuthProviderProps) { const { authService, onAuthenticated, children } = props const { cognito } = authService const { session, deinitializeSession } = sessionProvider.useSession() - const { setBackend } = backendProvider.useSetBackend() + const { setBackendWithoutSavingType } = backendProvider.useSetBackend() const logger = loggerProvider.useLogger() + // This must not be `hooks.useNavigate` as `goOffline` would be inaccessible, + // and the function call would error. + // eslint-disable-next-line no-restricted-properties const navigate = router.useNavigate() const [initialized, setInitialized] = react.useState(false) const [userSession, setUserSession] = react.useState(null) + // This is identical to `hooks.useOnlineCheck`, however it is inline here to avoid any possible + // circular dependency. + react.useEffect(() => { + if (!navigator.onLine) { + void goOffline() + } + }, [navigator.onLine]) + /** Fetch the JWT access token from the session via the AWS Amplify library. * * When invoked, retrieves the access token (if available) from the storage method chosen when @@ -165,7 +197,9 @@ export function AuthProvider(props: AuthProviderProps) { * If the token has expired, automatically refreshes the token and returns the new token. */ react.useEffect(() => { const fetchSession = async () => { - if (session.none) { + if (!navigator.onLine) { + goOfflineInternal() + } else if (session.none) { setInitialized(true) setUserSession(null) } else { @@ -177,9 +211,27 @@ export function AuthProvider(props: AuthProviderProps) { // The backend MUST be the remote backend before login is finished. // This is because the "set username" flow requires the remote backend. if (!initialized || userSession == null) { - setBackend(backend) + setBackendWithoutSavingType(backend) + } + let organization + // eslint-disable-next-line no-restricted-syntax + while (organization === undefined) { + try { + organization = await backend.usersMe() + } catch { + // The value may have changed after the `await`. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!navigator.onLine) { + goOfflineInternal() + // eslint-disable-next-line no-restricted-syntax + return + } + // This prevents a busy loop when request blocking is enabled in DevTools. + // The UI will be blank indefinitely. This is intentional, since for real + // network outages, `navigator.onLine` will be false. + await new Promise(resolve => setTimeout(resolve, REQUEST_DELAY_MS)) + } } - const organization = await backend.usersMe().catch(() => null) let newUserSession: UserSession const sharedSessionData = { email, accessToken } if (!organization) { @@ -231,6 +283,19 @@ export function AuthProvider(props: AuthProviderProps) { return result } + const goOfflineInternal = () => { + setInitialized(true) + setUserSession(OFFLINE_USER_SESSION) + setBackendWithoutSavingType(new localBackend.LocalBackend()) + } + + const goOffline = () => { + toast.error('You are offline, switching to offline mode.') + goOfflineInternal() + navigate(app.DASHBOARD_PATH) + return Promise.resolve(true) + } + const signUp = async (username: string, password: string) => { const result = await cognito.signUp(username, password) if (result.ok) { @@ -345,19 +410,20 @@ export function AuthProvider(props: AuthProviderProps) { } const value = { + goOffline: goOffline, signUp: withLoadingToast(signUp), confirmSignUp: withLoadingToast(confirmSignUp), setUsername, signInWithGoogle: () => - cognito - .signInWithGoogle() - .then(() => true) - .catch(() => false), + cognito.signInWithGoogle().then( + () => true, + () => false + ), signInWithGitHub: () => - cognito - .signInWithGitHub() - .then(() => true) - .catch(() => false), + cognito.signInWithGitHub().then( + () => true, + () => false + ), signInWithPassword: withLoadingToast(signInWithPassword), forgotPassword: withLoadingToast(forgotPassword), resetPassword: withLoadingToast(resetPassword), @@ -474,11 +540,11 @@ export function usePartialUserSession() { return router.useOutletContext() } -// ========================== -// === useFullUserSession === -// ========================== +// ================================ +// === useNonPartialUserSession === +// ================================ -/** A React context hook returning the user session for a user that has completed registration. */ -export function useFullUserSession() { - return router.useOutletContext() +/** A React context hook returning the user session for a user that can perform actions. */ +export function useNonPartialUserSession() { + return router.useOutletContext>() } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx index 6dc553eca089..7159aa59ee31 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx @@ -40,6 +40,7 @@ import * as toast from 'react-hot-toast' import * as authService from '../authentication/service' import * as detect from '../detect' +import * as hooks from '../hooks' import * as authProvider from '../authentication/providers/auth' import * as backendProvider from '../providers/backend' @@ -88,6 +89,8 @@ export interface AppProps { supportsDeepLinks: boolean /** Whether the dashboard should be rendered. */ showDashboard: boolean + /** The name of the project to open on startup, if any. */ + initialProjectName: string | null onAuthenticated: () => void appRunner: AppRunner } @@ -124,7 +127,7 @@ function App(props: AppProps) { * component as the component that defines the provider. */ function AppRouter(props: AppProps) { const { logger, showDashboard, onAuthenticated } = props - const navigate = router.useNavigate() + const navigate = hooks.useNavigate() // FIXME[sb]: After platform detection for Electron is merged in, `IS_DEV_MODE` should be // set to true on `ide watch`. if (IS_DEV_MODE) { diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/components/svg.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/components/svg.tsx index b03888589a0a..da5c910ab1b1 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/components/svg.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/components/svg.tsx @@ -264,6 +264,13 @@ export const DEFAULT_USER_ICON = ( ) +/** An icon representing a menu that can be expanded downwards. */ +export const DOWN_CARET_ICON = ( + + + +) + /** Props for a {@link Spinner}. */ export interface SpinnerProps { size: number diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/config.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/config.ts index 106a70990645..eb677a3ed15d 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/config.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/config.ts @@ -7,6 +7,9 @@ import * as newtype from './newtype' // === Constants === // ================= +/** The web domain of the cloud website. */ +export const CLOUD_DOMAIN = 'https://cloud.enso.org' + /** The current environment that we're running in. */ export const ENVIRONMENT: Environment = 'production' @@ -17,9 +20,7 @@ const CLOUD_REDIRECTS = { * when it is created. In the native app, the port is unpredictable, but this is not a problem * because the native app does not use port-based redirects, but deep links. */ development: newtype.asNewtype('http://localhost:8080'), - production: newtype.asNewtype( - REDIRECT_OVERRIDE ?? 'https://cloud.enso.org' - ), + production: newtype.asNewtype(REDIRECT_OVERRIDE ?? CLOUD_DOMAIN), } /** All possible API URLs, sorted by environment. */ diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts index 296366f63593..3dfebe2c9684 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts @@ -1,4 +1,5 @@ /** @file Type definitions common between all backends. */ + import * as dateTime from './dateTime' import * as newtype from '../newtype' diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx index 78fbf6552bb3..34f76bb59293 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx @@ -72,6 +72,8 @@ function ChangePasswordModal() { type="password" name="old_password" placeholder="Old Password" + pattern={validation.PREVIOUS_PASSWORD_PATTERN} + title={validation.PREVIOUS_PASSWORD_TITLE} value={oldPassword} setValue={setOldPassword} className="text-sm sm:text-base placeholder-gray-500 pl-10 pr-4 rounded-lg border border-gray-400 w-full py-2 focus:outline-none focus:border-blue-400" diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx index 14362dc14230..8952304f4cbb 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx @@ -1,4 +1,5 @@ /** @file Modal for confirming delete of any type of asset. */ +import * as react from 'react' import toast from 'react-hot-toast' import * as modalProvider from '../../providers/modal' @@ -23,14 +24,23 @@ function ConfirmDeleteModal(props: ConfirmDeleteModalProps) { const { assetType, name, doDelete, onSuccess } = props const { unsetModal } = modalProvider.useSetModal() + const [isSubmitting, setIsSubmitting] = react.useState(false) + const onSubmit = async () => { - unsetModal() - await toast.promise(doDelete(), { - loading: `Deleting ${assetType}...`, - success: `Deleted ${assetType}.`, - error: `Could not delete ${assetType}.`, - }) - onSuccess() + if (!isSubmitting) { + try { + setIsSubmitting(true) + await toast.promise(doDelete(), { + loading: `Deleting ${assetType}...`, + success: `Deleted ${assetType}.`, + error: `Could not delete ${assetType}.`, + }) + unsetModal() + onSuccess() + } finally { + setIsSubmitting(false) + } + } } return ( @@ -52,18 +62,25 @@ function ConfirmDeleteModal(props: ConfirmDeleteModalProps) { Are you sure you want to delete the {assetType} '{name}'?
-
Delete -
-
+
+
diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenu.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenu.tsx index fb7a254cb60c..d65a1db64b31 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenu.tsx @@ -25,9 +25,13 @@ export interface ContextMenuProps { function ContextMenu(props: react.PropsWithChildren) { const { children, event } = props const contextMenuRef = react.useRef(null) + const [top, setTop] = react.useState(event.pageY) + // This must be the original height before the returned element affects the `scrollHeight`. + const [bodyHeight] = react.useState(document.body.scrollHeight) react.useEffect(() => { if (contextMenuRef.current != null) { + setTop(Math.min(top, bodyHeight - contextMenuRef.current.clientHeight)) const boundingBox = contextMenuRef.current.getBoundingClientRect() const scrollBy = boundingBox.bottom - innerHeight + SCROLL_MARGIN if (scrollBy > 0) { @@ -39,7 +43,8 @@ function ContextMenu(props: react.PropsWithChildren) { return (
{children} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenuEntry.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenuEntry.tsx index c45834a92779..0dab37ba088f 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenuEntry.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenuEntry.tsx @@ -8,16 +8,18 @@ import * as react from 'react' /** Props for a {@link ContextMenuEntry}. */ export interface ContextMenuEntryProps { disabled?: boolean + title?: string onClick: (event: react.MouseEvent) => void } // This component MUST NOT use `useState` because it is not rendered directly. /** An item in a `ContextMenu`. */ function ContextMenuEntry(props: react.PropsWithChildren) { - const { children, disabled, onClick } = props + const { children, disabled, title, onClick } = props return ( -
- ) + } + }} + > + {svg.ADD_ICON} + + ) : ( <>{COLUMN_NAME[column]} ) - // The purpose of this effect is to enable search action. react.useEffect(() => { - setVisibleProjectAssets(projectAssets.filter(asset => asset.title.includes(query))) - setVisibleDirectoryAssets(directoryAssets.filter(asset => asset.title.includes(query))) - setVisibleSecretAssets(secretAssets.filter(asset => asset.title.includes(query))) - setVisibleFileAssets(fileAssets.filter(asset => asset.title.includes(query))) + const queryRegex = new RegExp(regexEscape(query), 'i') + const doesItMatchQuery = (asset: backendModule.Asset) => queryRegex.test(asset.title) + setVisibleProjectAssets(projectAssets.filter(doesItMatchQuery)) + setVisibleDirectoryAssets(directoryAssets.filter(doesItMatchQuery)) + setVisibleSecretAssets(secretAssets.filter(doesItMatchQuery)) + setVisibleFileAssets(fileAssets.filter(doesItMatchQuery)) }, [query]) const setAssets = (assets: backendModule.Asset[]) => { @@ -631,24 +787,55 @@ function Dashboard(props: DashboardProps) { setDirectoryAssets(newDirectoryAssets) setSecretAssets(newSecretAssets) setFileAssets(newFileAssets) + if (nameOfProjectToImmediatelyOpen != null) { + const projectToLoad = newProjectAssets.find( + projectAsset => projectAsset.title === nameOfProjectToImmediatelyOpen + ) + if (projectToLoad != null) { + setProjectEvent({ + type: projectActionButton.ProjectEventType.open, + projectId: projectToLoad.id, + }) + } + setNameOfProjectToImmediatelyOpen(null) + } + onDirectoryNextLoaded?.(assets) + setOnDirectoryNextLoaded(null) } hooks.useAsyncEffect( null, async signal => { - if (listingLocalDirectoryAndWillFail) { - // Do not `setIsLoadingAssets(false)` - } else if (!listingRemoteDirectoryAndWillFail) { - const assets = await backend.listDirectory({ parentId: directoryId }) - if (!signal.aborted) { - setIsLoadingAssets(false) - setAssets(assets) + switch (backend.type) { + case backendModule.BackendType.local: { + if (!isListingLocalDirectoryAndWillFail) { + const assets = await backend.listDirectory() + if (!signal.aborted) { + setIsLoadingAssets(false) + setAssets(assets) + } + } + return + } + case backendModule.BackendType.remote: { + if ( + !isListingRemoteDirectoryAndWillFail && + !isListingRemoteDirectoryWhileOffline && + directoryId != null + ) { + const assets = await backend.listDirectory({ parentId: directoryId }) + if (!signal.aborted) { + setIsLoadingAssets(false) + setAssets(assets) + } + } else { + setIsLoadingAssets(false) + } + return } - } else { - setIsLoadingAssets(false) } }, - [accessToken, directoryId, refresh, backend] + [session.accessToken, directoryId, refresh, backend] ) react.useEffect(() => { @@ -703,9 +890,45 @@ function Dashboard(props: DashboardProps) { parentDirectoryId: directoryId, } await backend.createProject(body) + // `newProject.projectId` cannot be used directly in a `ProjectEvet` as the project + // does not yet exist in the project list. Opening the project would work, but the project + // would display as closed as it would be created after the event is sent. + setNameOfProjectToImmediatelyOpen(projectName) doRefresh() } + const handleCreateDirectory = async () => { + if (backend.type !== backendModule.BackendType.remote) { + // This should never happen, but even if it does, it is the caller's responsibility + // to log, or display this error. + throw new Error('Folders cannot be created on the local backend.') + } else { + const directoryIndices = directoryAssets + .map(directoryAsset => DIRECTORY_NAME_REGEX.exec(directoryAsset.title)) + .map(match => match?.groups?.directoryIndex) + .map(maybeIndex => (maybeIndex != null ? parseInt(maybeIndex, 10) : 0)) + const title = `${DIRECTORY_NAME_DEFAULT_PREFIX}${Math.max(...directoryIndices) + 1}` + setDirectoryAssets([ + { + title, + type: backendModule.AssetType.directory, + id: newtype.asNewtype(Number(new Date()).toString()), + modifiedAt: dateTime.toRfc3339(new Date()), + parentId: directoryId ?? newtype.asNewtype(''), + permissions: [], + projectState: null, + }, + ...directoryAssets, + ]) + await backend.createDirectory({ + parentId: directoryId, + title, + }) + doRefresh() + return + } + } + return (
{ - if (newBackendType !== backend.type) { - setIsLoadingAssets(true) - setProjectAssets([]) - setDirectoryAssets([]) - setSecretAssets([]) - setFileAssets([]) - switch (newBackendType) { - case backendModule.BackendType.local: - setBackend(new localBackend.LocalBackend()) - break - case backendModule.BackendType.remote: { - const headers = new Headers() - headers.append('Authorization', `Bearer ${accessToken}`) - const client = new http.Client(headers) - setBackend(new remoteBackendModule.RemoteBackend(client, logger)) - break - } - } - } - }} + setBackendType={setBackendType} query={query} setQuery={setQuery} /> - {listingLocalDirectoryAndWillFail ? ( + {isListingRemoteDirectoryWhileOffline ? ( +
+
+ You are offline. Please connect to the internet and refresh to access the + cloud backend. +
+
+ ) : isListingLocalDirectoryAndWillFail ? (
Could not connect to the Project Manager. Please try restarting{' '} {common.PRODUCT_NAME}, or manually launching the Project Manager.
- ) : listingRemoteDirectoryAndWillFail ? ( + ) : isListingRemoteDirectoryAndWillFail ? (
We will review your user details and enable the cloud experience for you @@ -808,7 +1018,10 @@ function Dashboard(props: DashboardProps) { event.stopPropagation() setModal(() => ( )) @@ -884,7 +1097,7 @@ function Dashboard(props: DashboardProps) { )}
- +
{columnsFor(columnDisplayMode, backend.type).map(column => ( @@ -893,7 +1106,7 @@ function Dashboard(props: DashboardProps) { > items={visibleProjectAssets} - getKey={proj => proj.id} + getKey={projectAsset => projectAsset.id} isLoading={isLoadingAssets} placeholder={ @@ -924,8 +1137,11 @@ function Dashboard(props: DashboardProps) { event.preventDefault() event.stopPropagation() const doOpenForEditing = () => { - // FIXME[sb]: Switch to IDE tab - // once merged with `show-and-open-workspace` branch. + unsetModal() + setProjectEvent({ + type: projectActionButton.ProjectEventType.open, + projectId: projectAsset.id, + }) } const doOpenAsFolder = () => { // FIXME[sb]: Uncomment once backend support @@ -976,9 +1192,12 @@ function Dashboard(props: DashboardProps) { /> )) } + const isDisabled = + backend.type === backendModule.BackendType.local && + (projectDatas[projectAsset.id]?.isRunning ?? false) setModal(() => ( - - + + Open for editing {backend.type !== backendModule.BackendType.local && ( @@ -989,7 +1208,15 @@ function Dashboard(props: DashboardProps) { Rename - + Delete @@ -1004,7 +1231,7 @@ function Dashboard(props: DashboardProps) { backendModule.Asset > items={visibleDirectoryAssets} - getKey={dir => dir.id} + getKey={directoryAsset => directoryAsset.id} isLoading={isLoadingAssets} placeholder={ @@ -1036,11 +1263,14 @@ function Dashboard(props: DashboardProps) { : [directoryAsset] ) }} - onContextMenu={(_directory, event) => { + onContextMenu={(directoryAsset, event) => { event.preventDefault() event.stopPropagation() setModal(() => ( - + )) }} /> @@ -1098,7 +1328,7 @@ function Dashboard(props: DashboardProps) { )) } setModal(() => ( - + Delete @@ -1169,7 +1399,7 @@ function Dashboard(props: DashboardProps) { /** TODO: Wait for backend endpoint. */ } setModal(() => ( - + Copy @@ -1195,7 +1425,9 @@ function Dashboard(props: DashboardProps) { ))(backend)}
- {isFileBeingDragged && backend.type === backendModule.BackendType.remote ? ( + {isFileBeingDragged && + directoryId != null && + backend.type === backendModule.BackendType.remote ? (
{ diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryCreateForm.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryCreateForm.tsx deleted file mode 100644 index 2fd6a2b4922e..000000000000 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryCreateForm.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/** @file Form to create a project. */ -import * as react from 'react' -import toast from 'react-hot-toast' - -import * as backendModule from '../backend' -import * as backendProvider from '../../providers/backend' -import * as error from '../../error' -import * as modalProvider from '../../providers/modal' -import CreateForm, * as createForm from './createForm' - -// =========================== -// === DirectoryCreateForm === -// =========================== - -/** Props for a {@link DirectoryCreateForm}. */ -export interface DirectoryCreateFormProps extends createForm.CreateFormPassthroughProps { - directoryId: backendModule.DirectoryId - onSuccess: () => void -} - -/** A form to create a directory. */ -function DirectoryCreateForm(props: DirectoryCreateFormProps) { - const { directoryId, onSuccess, ...passThrough } = props - const { backend } = backendProvider.useBackend() - const { unsetModal } = modalProvider.useSetModal() - const [name, setName] = react.useState(null) - - if (backend.type === backendModule.BackendType.local) { - return <> - } else { - const onSubmit = async (event: react.FormEvent) => { - event.preventDefault() - if (name == null) { - toast.error('Please provide a directory name.') - } else { - unsetModal() - await toast - .promise( - backend.createDirectory({ - parentId: directoryId, - title: name, - }), - { - loading: 'Creating directory...', - success: 'Sucessfully created directory.', - error: error.unsafeIntoErrorMessage, - } - ) - .then(onSuccess) - } - } - - return ( - -
- - { - setName(event.target.value) - }} - /> -
-
- ) - } -} - -export default DirectoryCreateForm diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx index 5bd037805746..5551bb5d7cde 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx @@ -1,15 +1,51 @@ /** @file An interactive button displaying the status of a project. */ import * as react from 'react' +import toast from 'react-hot-toast' import * as backendModule from '../backend' import * as backendProvider from '../../providers/backend' import * as localBackend from '../localBackend' +import * as modalProvider from '../../providers/modal' import * as svg from '../../components/svg' // ============= // === Types === // ============= +/** Data associated with a project, used for rendering. + * FIXME[sb]: This is a hack that is required because each row does not carry its own extra state. + * It will be obsoleted by the implementation in https://github.com/enso-org/enso/pull/6546. */ +export interface ProjectData { + isRunning: boolean +} + +/** Possible types of project state change. */ +export enum ProjectEventType { + open = 'open', + cancelOpeningAll = 'cancelOpeningAll', +} + +/** Properties common to all project state change events. */ +interface ProjectBaseEvent { + type: Type +} + +/** Requests the specified project to be opened. */ +export interface ProjectOpenEvent extends ProjectBaseEvent { + // FIXME: provide projectId instead + /** This must be a name because it may be specified by name on the command line. + * Note that this will not work properly with the cloud backend if there are multiple projects + * with the same name. */ + projectId: backendModule.ProjectId +} + +/** Requests the specified project to be opened. */ +export interface ProjectCancelOpeningAllEvent + extends ProjectBaseEvent {} + +/** Every possible type of project event. */ +export type ProjectEvent = ProjectCancelOpeningAllEvent | ProjectOpenEvent + /** The state of the spinner. It should go from initial, to loading, to done. */ enum SpinnerState { initial = 'initial', @@ -21,10 +57,26 @@ enum SpinnerState { // === Constants === // ================= +/** The default {@link ProjectData} associated with a {@link backendModule.Project}. */ +export const DEFAULT_PROJECT_DATA: ProjectData = Object.freeze({ + isRunning: false, +}) +const LOADING_MESSAGE = + 'Your environment is being created. It will take some time, please be patient.' /** The interval between requests checking whether the IDE is ready. */ const CHECK_STATUS_INTERVAL_MS = 5000 /** The interval between requests checking whether the VM is ready. */ const CHECK_RESOURCES_INTERVAL_MS = 1000 +/** The fallback project state, when it is set to `null` before it is first set. */ +const DEFAULT_PROJECT_STATE = backendModule.ProjectState.created +/** The corresponding {@link SpinnerState} for each {@link backendModule.ProjectState}. */ +const SPINNER_STATE: Record = { + [backendModule.ProjectState.closed]: SpinnerState.initial, + [backendModule.ProjectState.created]: SpinnerState.initial, + [backendModule.ProjectState.new]: SpinnerState.initial, + [backendModule.ProjectState.openInProgress]: SpinnerState.loading, + [backendModule.ProjectState.opened]: SpinnerState.done, +} const SPINNER_CSS_CLASSES: Record = { [SpinnerState.initial]: 'dasharray-5 ease-linear', @@ -39,7 +91,12 @@ const SPINNER_CSS_CLASSES: Record = { /** Props for a {@link ProjectActionButton}. */ export interface ProjectActionButtonProps { project: backendModule.Asset + projectData: ProjectData + setProjectData: react.Dispatch> appRunner: AppRunner | null + event: ProjectEvent | null + /** Called when the project is opened via the {@link ProjectActionButton}. */ + doOpenManually: () => void onClose: () => void openIde: () => void doRefresh: () => void @@ -47,63 +104,139 @@ export interface ProjectActionButtonProps { /** An interactive button displaying the status of a project. */ function ProjectActionButton(props: ProjectActionButtonProps) { - const { project, onClose, appRunner, openIde, doRefresh } = props + const { + project, + setProjectData, + event, + appRunner, + doOpenManually, + onClose, + openIde, + doRefresh, + } = props const { backend } = backendProvider.useBackend() + const { unsetModal } = modalProvider.useSetModal() - const [state, setState] = react.useState(backendModule.ProjectState.created) + const [state, setState] = react.useState(null) const [isCheckingStatus, setIsCheckingStatus] = react.useState(false) const [isCheckingResources, setIsCheckingResources] = react.useState(false) - const [spinnerState, setSpinnerState] = react.useState(SpinnerState.done) + const [spinnerState, setSpinnerState] = react.useState(SpinnerState.initial) + const [shouldOpenWhenReady, setShouldOpenWhenReady] = react.useState(false) + const [toastId, setToastId] = react.useState(null) + + react.useEffect(() => { + if (toastId != null) { + return () => { + toast.dismiss(toastId) + } + } else { + return + } + }, [toastId]) + + react.useEffect(() => { + // Ensure that the previous spinner state is visible for at least one frame. + requestAnimationFrame(() => { + setSpinnerState(SPINNER_STATE[state ?? DEFAULT_PROJECT_STATE]) + }) + }, [state]) + + react.useEffect(() => { + if (toastId != null && state !== backendModule.ProjectState.openInProgress) { + toast.dismiss(toastId) + } + }, [state]) react.useEffect(() => { switch (project.projectState.type) { case backendModule.ProjectState.opened: setState(backendModule.ProjectState.openInProgress) - setSpinnerState(SpinnerState.initial) setIsCheckingResources(true) break case backendModule.ProjectState.openInProgress: setState(backendModule.ProjectState.openInProgress) - setSpinnerState(SpinnerState.initial) setIsCheckingStatus(true) break default: - setState(project.projectState.type) + // Some functions below set the state to something different to + // the backend state. In that case, the state should not be overridden. + setState(oldState => oldState ?? project.projectState.type) break } }, []) react.useEffect(() => { - if (backend.type === backendModule.BackendType.local) { - if (project.id !== localBackend.LocalBackend.currentlyOpeningProjectId) { - setIsCheckingResources(false) - setIsCheckingStatus(false) - setState(backendModule.ProjectState.closed) - setSpinnerState(SpinnerState.done) + if (event != null) { + switch (event.type) { + case ProjectEventType.open: { + if (event.projectId !== project.id) { + setShouldOpenWhenReady(false) + } else { + setShouldOpenWhenReady(true) + void openProject() + } + break + } + case ProjectEventType.cancelOpeningAll: { + setShouldOpenWhenReady(false) + } } } + }, [event]) + + react.useEffect(() => { + if (shouldOpenWhenReady && state === backendModule.ProjectState.opened) { + openIde() + setShouldOpenWhenReady(false) + } + }, [shouldOpenWhenReady, state]) + + react.useEffect(() => { + if ( + backend.type === backendModule.BackendType.local && + project.id !== localBackend.LocalBackend.currentlyOpeningProjectId + ) { + setState(backendModule.ProjectState.closed) + } }, [project, state, localBackend.LocalBackend.currentlyOpeningProjectId]) react.useEffect(() => { if (!isCheckingStatus) { return } else { + let handle: number | null = null + let continuePolling = true + let previousTimestamp = 0 const checkProjectStatus = async () => { - const response = await backend.getProjectDetails(project.id) - if (response.state.type === backendModule.ProjectState.opened) { - setIsCheckingStatus(false) - setIsCheckingResources(true) - } else { - setState(response.state.type) + try { + const response = await backend.getProjectDetails(project.id) + handle = null + if ( + continuePolling && + response.state.type === backendModule.ProjectState.opened + ) { + continuePolling = false + setIsCheckingStatus(false) + setIsCheckingResources(true) + } + } finally { + if (continuePolling) { + const nowTimestamp = Number(new Date()) + const delay = CHECK_STATUS_INTERVAL_MS - (nowTimestamp - previousTimestamp) + previousTimestamp = nowTimestamp + handle = window.setTimeout( + () => void checkProjectStatus(), + Math.max(0, delay) + ) + } } } - const handle = window.setInterval( - () => void checkProjectStatus(), - CHECK_STATUS_INTERVAL_MS - ) void checkProjectStatus() return () => { - clearInterval(handle) + continuePolling = false + if (handle != null) { + clearTimeout(handle) + } } } }, [isCheckingStatus]) @@ -112,85 +245,144 @@ function ProjectActionButton(props: ProjectActionButtonProps) { if (!isCheckingResources) { return } else { + let handle: number | null = null + let continuePolling = true + let previousTimestamp = 0 const checkProjectResources = async () => { - if (!('checkResources' in backend)) { + if (backend.type === backendModule.BackendType.local) { setState(backendModule.ProjectState.opened) setIsCheckingResources(false) - setSpinnerState(SpinnerState.done) } else { try { // This call will error if the VM is not ready yet. await backend.checkResources(project.id) - setState(backendModule.ProjectState.opened) - setIsCheckingResources(false) - setSpinnerState(SpinnerState.done) + handle = null + if (continuePolling) { + continuePolling = false + setState(backendModule.ProjectState.opened) + setIsCheckingResources(false) + } } catch { - // Ignored. + if (continuePolling) { + const nowTimestamp = Number(new Date()) + const delay = + CHECK_RESOURCES_INTERVAL_MS - (nowTimestamp - previousTimestamp) + previousTimestamp = nowTimestamp + handle = window.setTimeout( + () => void checkProjectResources(), + Math.max(0, delay) + ) + } } } } - const handle = window.setInterval( - () => void checkProjectResources(), - CHECK_RESOURCES_INTERVAL_MS - ) void checkProjectResources() return () => { - clearInterval(handle) + continuePolling = false + if (handle != null) { + clearTimeout(handle) + } } } }, [isCheckingResources]) - const closeProject = () => { + const closeProject = async () => { + onClose() + setShouldOpenWhenReady(false) setState(backendModule.ProjectState.closed) appRunner?.stopApp() - void backend.closeProject(project.id) setIsCheckingStatus(false) setIsCheckingResources(false) - onClose() + try { + await backend.closeProject(project.id) + } finally { + // This is not 100% correct, but it is better than never setting `isRunning` to `false`, + // which would prevent the project from ever being deleted. + setProjectData(oldProjectData => ({ ...oldProjectData, isRunning: false })) + } } const openProject = async () => { setState(backendModule.ProjectState.openInProgress) - setSpinnerState(SpinnerState.initial) - // The `setTimeout` is required so that the completion percentage goes from - // the `initial` fraction to the `loading` fraction, - // rather than starting at the `loading` fraction. - setTimeout(() => { - setSpinnerState(SpinnerState.loading) - }, 0) - switch (backend.type) { - case backendModule.BackendType.remote: - await backend.openProject(project.id) - doRefresh() - setIsCheckingStatus(true) - break - case backendModule.BackendType.local: - await backend.openProject(project.id) - doRefresh() - setState(backendModule.ProjectState.opened) - setSpinnerState(SpinnerState.done) - break + try { + switch (backend.type) { + case backendModule.BackendType.remote: + setToastId(toast.loading(LOADING_MESSAGE)) + await backend.openProject(project.id) + setProjectData(oldProjectData => ({ ...oldProjectData, isRunning: true })) + doRefresh() + setIsCheckingStatus(true) + break + case backendModule.BackendType.local: + await backend.openProject(project.id) + setProjectData(oldProjectData => ({ ...oldProjectData, isRunning: true })) + setState(oldState => { + if (oldState === backendModule.ProjectState.openInProgress) { + doRefresh() + return backendModule.ProjectState.opened + } else { + return oldState + } + }) + break + } + } catch { + setIsCheckingStatus(false) + setIsCheckingResources(false) + toast.error(`Error opening project '${project.title}'.`) + setState(backendModule.ProjectState.closed) } } switch (state) { + case null: case backendModule.ProjectState.created: case backendModule.ProjectState.new: case backendModule.ProjectState.closed: - return + return ( + + ) case backendModule.ProjectState.openInProgress: return ( - ) case backendModule.ProjectState.opened: return ( <> - - + ) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectCreateForm.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectCreateForm.tsx deleted file mode 100644 index e0b57a3a4b96..000000000000 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectCreateForm.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** @file Form to create a project. */ -import * as react from 'react' -import toast from 'react-hot-toast' - -import * as backendModule from '../backend' -import * as backendProvider from '../../providers/backend' -import * as error from '../../error' -import * as modalProvider from '../../providers/modal' -import CreateForm, * as createForm from './createForm' - -// ========================= -// === ProjectCreateForm === -// ========================= - -/** Props for a {@link ProjectCreateForm}. */ -export interface ProjectCreateFormProps extends createForm.CreateFormPassthroughProps { - directoryId: backendModule.DirectoryId - onSuccess: () => void -} - -/** A form to create a project. */ -function ProjectCreateForm(props: ProjectCreateFormProps) { - const { directoryId, onSuccess, ...passThrough } = props - const { backend } = backendProvider.useBackend() - const { unsetModal } = modalProvider.useSetModal() - - const [name, setName] = react.useState(null) - const [template, setTemplate] = react.useState(null) - - if (backend.type === backendModule.BackendType.local) { - return <> - } else { - const onSubmit = async (event: react.FormEvent) => { - event.preventDefault() - if (name == null) { - toast.error('Please provide a project name.') - } else { - unsetModal() - await toast - .promise( - backend.createProject({ - parentDirectoryId: directoryId, - projectName: name, - projectTemplateName: template, - }), - { - loading: 'Creating project...', - success: 'Sucessfully created project.', - error: error.unsafeIntoErrorMessage, - } - ) - .then(onSuccess) - } - } - - return ( - -
- - { - setName(event.target.value) - }} - /> -
-
- {/* FIXME[sb]: Use the array of templates in a dropdown when it becomes available. */} - - { - setTemplate(event.target.value) - }} - /> -
-
- ) - } -} - -export default ProjectCreateForm diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx index e27bd6362e42..b3d2600a1d72 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx @@ -27,22 +27,28 @@ function RenameModal(props: RenameModalProps) { const { assetType, name, namePattern, title, doRename, onSuccess } = props const { unsetModal } = modalProvider.useSetModal() + const [isSubmitting, setIsSubmitting] = react.useState(false) const [newName, setNewName] = react.useState(null) const onSubmit = async (event: React.FormEvent) => { event.preventDefault() if (newName == null) { toast.error('Please provide a new name.') - } else { - unsetModal() - await toast.promise(doRename(newName), { - loading: `Renaming ${assetType}...`, - success: `Renamed ${assetType}.`, - // This is UNSAFE, as the original function's parameter is of type `any`. - error: (promiseError: Error) => - `Error renaming ${assetType}: ${promiseError.message}`, - }) - onSuccess() + } else if (!isSubmitting) { + try { + setIsSubmitting(true) + await toast.promise(doRename(newName), { + loading: `Renaming ${assetType}...`, + success: `Renamed ${assetType}.`, + // This is UNSAFE, as the original function's parameter is of type `any`. + error: (promiseError: Error) => + `Error renaming ${assetType}: ${promiseError.message}`, + }) + unsetModal() + onSuccess() + } finally { + setIsSubmitting(false) + } } } @@ -65,9 +71,10 @@ function RenameModal(props: RenameModalProps) { -
Cancel -
+
diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/templates.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/templates.tsx index 2ed71940b2c8..57a340db61cd 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/templates.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/templates.tsx @@ -6,7 +6,7 @@ import * as svg from '../../components/svg' // ================= /** Template metadata. */ -interface Template { +export interface Template { title: string description: string id: string @@ -14,7 +14,7 @@ interface Template { } /** The full list of templates. */ -const TEMPLATES: Template[] = [ +export const TEMPLATES: [Template, ...Template[]] = [ { title: 'Colorado COVID', id: 'Colorado_COVID', @@ -74,7 +74,7 @@ function TemplatesRender(props: TemplatesRenderProps) { onClick={() => { onTemplateClick(null) }} - className="h-40 cursor-pointer" + className="h-40 w-60 cursor-pointer" >
@@ -91,7 +91,7 @@ function TemplatesRender(props: TemplatesRenderProps) { {templates.map(template => (