diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f3643ca15c93..09e1cda57ad0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -29,16 +29,16 @@ Cargo.toml # Engine (old) # This section should be removed once the engine moves to /app/engine /build.sbt @4e6 @jaroslavtulach @hubertp -/distribution/ @4e6 @jdunkerley @radeusgd +/distribution/ @4e6 @jdunkerley @radeusgd @GregoryTravis /engine/ @4e6 @jaroslavtulach @hubertp /project/ @4e6 @jaroslavtulach @hubertp -/test/ @jdunkerley @radeusgd +/test/ @jdunkerley @radeusgd @GregoryTravis /tools/ @4e6 @jaroslavtulach @radeusgd # Enso Libraries # This section should be amended once the engine moves to /app/engine -/distribution/lib/ @jdunkerley @radeusgd -/std-bits/ @jdunkerley @radeusgd +/distribution/lib/ @jdunkerley @radeusgd @GregoryTravis +/std-bits/ @jdunkerley @radeusgd @GregoryTravis # Cloud Dashboard & Authentication /app/ide-desktop/lib/dashboard @PabloBuchu @indiv0 @somebody1234 diff --git a/CHANGELOG.md b/CHANGELOG.md index 89940719247e..2c55975758bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,10 @@ quickly understand each button's function. - [File associations are created on Windows and macOS][6077]. This allows opening Enso files by double-clicking them in the file explorer. +- [Added capability to create node widgets with complex UI][6347]. Node widgets + such as dropdown can now be placed in the node and affect the code text flow. +- [The IDE UI element for selecting the execution mode of the project is now + sending messages to the backend.][6341]. - [Fixed text visualisations which were being cut off at the last line.][6421] [6421]: https://github.com/enso-org/enso/pull/6421 @@ -197,6 +201,7 @@ [5895]: https://github.com/enso-org/enso/pull/6130 [6035]: https://github.com/enso-org/enso/pull/6035 [6097]: https://github.com/enso-org/enso/pull/6097 +[6097]: https://github.com/enso-org/enso/pull/6341 #### Enso Standard Library @@ -392,6 +397,8 @@ for thousands and decimal point automatic detection.][6253] - [Implemented `Table.parse_text_to_table`.][6294] - [Added `Table.parse_to_columns`.][6383] +- [Added parsing methods for `Integer`, `Decimal`, `Json`, `Date`, `Date_Time`, + `Time_Of_Day`, `Time_Zone`, and `URI` to `Text`.][6404] [debug-shortcuts]: https://github.com/enso-org/enso/blob/develop/app/gui/docs/product/shortcuts.md#debug @@ -593,6 +600,8 @@ [6253]: https://github.com/enso-org/enso/pull/6253 [6294]: https://github.com/enso-org/enso/pull/6294 [6383]: https://github.com/enso-org/enso/pull/6383 +[6404]: https://github.com/enso-org/enso/pull/6404 +[6347]: https://github.com/enso-org/enso/pull/6347 #### Enso Compiler diff --git a/Cargo.lock b/Cargo.lock index 8a2cc5970d00..bde01b80ce76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1614,7 +1614,7 @@ dependencies = [ ] [[package]] -name = "debug-scene-execution-mode-dropdown" +name = "debug-scene-execution-environment-dropdown" version = "0.1.0" dependencies = [ "ensogl", @@ -1622,7 +1622,7 @@ dependencies = [ "ensogl-hardcoded-theme", "ensogl-list-view", "ensogl-text-msdf", - "ide-view-execution-mode-selector", + "ide-view-execution-environment-selector", ] [[package]] @@ -1647,7 +1647,7 @@ dependencies = [ "ensogl-hardcoded-theme", "ensogl-text-msdf", "ide-view", - "ide-view-execution-mode-selector", + "ide-view-execution-environment-selector", "parser", "span-tree", "uuid 0.8.2", @@ -2109,7 +2109,7 @@ version = "0.1.0" dependencies = [ "debug-scene-component-list-panel-view", "debug-scene-documentation", - "debug-scene-execution-mode-dropdown", + "debug-scene-execution-environment-dropdown", "debug-scene-icons", "debug-scene-interface", "debug-scene-text-grid-visualization", @@ -2883,6 +2883,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ensogl-example-list-editor" +version = "0.1.0" +dependencies = [ + "enso-frp", + "ensogl-core", + "ensogl-list-editor", + "ensogl-slider", + "ensogl-text-msdf", +] + [[package]] name = "ensogl-example-list-view" version = "0.1.0" @@ -3008,15 +3019,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "ensogl-example-vector-editor" -version = "0.1.0" -dependencies = [ - "ensogl-core", - "ensogl-hardcoded-theme", - "wasm-bindgen", -] - [[package]] name = "ensogl-examples" version = "0.1.0" @@ -3034,6 +3036,7 @@ dependencies = [ "ensogl-example-focus-management", "ensogl-example-grid-view", "ensogl-example-instance-ordering", + "ensogl-example-list-editor", "ensogl-example-list-view", "ensogl-example-mouse-events", "ensogl-example-profiling-run-graph", @@ -3043,7 +3046,6 @@ dependencies = [ "ensogl-example-sprite-system", "ensogl-example-sprite-system-benchmark", "ensogl-example-text-area", - "ensogl-example-vector-editor", ] [[package]] @@ -4298,7 +4300,7 @@ dependencies = [ "ensogl-text-msdf", "ide-view-component-browser", "ide-view-documentation", - "ide-view-execution-mode-selector", + "ide-view-execution-environment-selector", "ide-view-graph-editor", "js-sys", "multi-map", @@ -4413,7 +4415,7 @@ dependencies = [ ] [[package]] -name = "ide-view-execution-mode-selector" +name = "ide-view-execution-environment-selector" version = "0.1.0" dependencies = [ "enso-frp", @@ -4446,7 +4448,7 @@ dependencies = [ "ensogl-hardcoded-theme", "ensogl-text-msdf", "failure", - "ide-view-execution-mode-selector", + "ide-view-execution-environment-selector", "indexmap", "js-sys", "nalgebra", diff --git a/app/gui/controller/engine-protocol/src/language_server.rs b/app/gui/controller/engine-protocol/src/language_server.rs index 5132b45cb46d..5ebe1784818c 100644 --- a/app/gui/controller/engine-protocol/src/language_server.rs +++ b/app/gui/controller/engine-protocol/src/language_server.rs @@ -157,7 +157,7 @@ trait API { /// Restart the program execution. #[MethodInput=RecomputeInput, rpc_name="executionContext/recompute"] - fn recompute(&self, context_id: ContextId, invalidated_expressions: InvalidatedExpressions) -> (); + fn recompute(&self, context_id: ContextId, invalidated_expressions: InvalidatedExpressions, mode: Option) -> (); /// Obtain the full suggestions database. #[MethodInput=GetSuggestionsDatabaseInput, rpc_name="search/getSuggestionsDatabase"] @@ -205,6 +205,11 @@ trait API { /// VCS snapshot if no `commit_id` is provided. #[MethodInput=VcsRestoreInput, rpc_name="vcs/restore"] fn restore_vcs(&self, root: Path, commit_id: Option) -> response::RestoreVcs; + + /// Set the execution environment of the context for future evaluations. + #[MethodInput=SetModeInput, rpc_name="executionContext/setExecutionEnvironment"] + fn set_execution_environment(&self, context_id: ContextId, execution_environment: ExecutionEnvironment) -> (); + }} 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 5a580451cdd9..aa890c766af8 100644 --- a/app/gui/controller/engine-protocol/src/language_server/types.rs +++ b/app/gui/controller/engine-protocol/src/language_server/types.rs @@ -1155,6 +1155,72 @@ pub struct LibraryComponentGroup { } + +// ============================= +// === Execution Environment === +// ============================= + +/// The execution environment which controls the global execution of functions with side effects. +/// +/// For more information, see +/// https://github.com/enso-org/design/blob/main/epics/basic-libraries/write-action-control/design.md. +#[derive(Hash, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display)] +pub enum ExecutionEnvironment { + /// Allows editing the graph, but the `Output` context is disabled, so it prevents accidental + /// changes. + Design, + /// Unrestricted, live editing of data. + Live, +} + +impl Default for ExecutionEnvironment { + fn default() -> Self { + ExecutionEnvironment::Design + } +} + +impl ExecutionEnvironment { + /// List all available execution environments. + pub fn list_all() -> Vec { + vec![ExecutionEnvironment::Design, ExecutionEnvironment::Live] + } + + /// List all available execution environments as ImStrings. Useful for UI. + pub fn list_all_as_imstrings() -> Vec { + Self::list_all().iter().map(|env| (*env).into()).collect() + } +} + +impl From for ImString { + fn from(env: ExecutionEnvironment) -> Self { + ImString::new(env.to_string()) + } +} + +impl TryFrom<&str> for ExecutionEnvironment { + type Error = (); + + fn try_from(value: &str) -> core::result::Result { + match value.to_lowercase().as_str() { + "design" => Ok(ExecutionEnvironment::Design), + "live" => Ok(ExecutionEnvironment::Live), + _ => Err(()), + } + } +} + +impl ExecutionEnvironment { + /// Returns whether the output context is enabled for this execution environment. + pub fn output_context_enabled(&self) -> bool { + match self { + Self::Design => false, + Self::Live => true, + } + } +} + + + // ====================== // === Test Utilities === // ====================== diff --git a/app/gui/docs/product/shortcuts.md b/app/gui/docs/product/shortcuts.md index 1d7a7edd64e4..d109fb96c52c 100644 --- a/app/gui/docs/product/shortcuts.md +++ b/app/gui/docs/product/shortcuts.md @@ -50,6 +50,7 @@ broken and require further investigation. | escape | Cancel current action. For example, drop currently dragged connection. | | cmd+shift+t | Terminate the program execution | | cmd+shift+r | Re-execute the program | +| cmd+shift+e | Toggle the execution environment between Live and Design. | #### Navigation diff --git a/app/gui/language/span-tree/src/action.rs b/app/gui/language/span-tree/src/action.rs index a09767e3157e..cdd94916342f 100644 --- a/app/gui/language/span-tree/src/action.rs +++ b/app/gui/language/span-tree/src/action.rs @@ -15,9 +15,9 @@ use ast::Ast; -/// ============== -/// === Errors === -/// ============== +// ============== +// === Errors === +// ============== /// Error returned when tried to perform an action which is not available for specific SpanTree /// node. @@ -35,9 +35,9 @@ pub struct AstSpanTreeMismatch; -/// ===================== -/// === Actions Trait === -/// ===================== +// ===================== +// === Actions Trait === +// ===================== /// Action enum used mainly for error messages. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] diff --git a/app/gui/language/span-tree/src/builder.rs b/app/gui/language/span-tree/src/builder.rs index 44c57d9dd89c..0fdfcaab610f 100644 --- a/app/gui/language/span-tree/src/builder.rs +++ b/app/gui/language/span-tree/src/builder.rs @@ -22,15 +22,23 @@ pub trait Builder: Sized { /// Add new AST-type child to node. Returns the child's builder which may be used to further /// extend this branch of the tree. fn add_child( - self, - offset: usize, + mut self, + parent_offset: usize, len: usize, kind: impl Into, crumbs: impl IntoCrumbs, ) -> ChildBuilder { let kind = kind.into(); let node = Node::::new().with_kind(kind).with_size(len.into()); - let child = node::Child { node, offset: offset.into(), ast_crumbs: crumbs.into_crumbs() }; + let prev_child = self.node_being_built().children.last(); + let prev_child_end = prev_child.map_or(0, |c| (c.parent_offset + c.node.size).as_usize()); + let sibling_offset = parent_offset.saturating_sub(prev_child_end); + let child = node::Child { + node, + parent_offset: parent_offset.into(), + sibling_offset: sibling_offset.into(), + ast_crumbs: crumbs.into_crumbs(), + }; ChildBuilder { built: child, parent: self } } @@ -46,14 +54,8 @@ pub trait Builder: Sized { } /// Add an Empty-type child to node. - fn add_empty_child(mut self, offset: usize, kind: impl Into) -> Self { - let child = node::Child { - node: Node::::new().with_kind(kind), - offset: offset.into(), - ast_crumbs: vec![], - }; - self.node_being_built().children.push(child); - self + fn add_empty_child(self, offset: usize, kind: impl Into) -> Self { + self.add_leaf(offset, 0, kind, ast::crumbs![]) } /// Set expression id for this node. @@ -65,9 +67,9 @@ pub trait Builder: Sized { -/// ================ -/// === Builders === -/// ================ +// ================ +// === Builders === +// ================ // === SpanTree Builder === diff --git a/app/gui/language/span-tree/src/generate.rs b/app/gui/language/span-tree/src/generate.rs index ae8b0c210464..16461bb056b4 100644 --- a/app/gui/language/span-tree/src/generate.rs +++ b/app/gui/language/span-tree/src/generate.rs @@ -93,6 +93,7 @@ impl SpanTreeGenerator for String { #[derivative(Default(bound = ""))] struct ChildGenerator { current_offset: ByteDiff, + sibling_offset: ByteDiff, children: Vec>, } @@ -100,7 +101,9 @@ impl ChildGenerator { /// Add spacing to current generator state. It will be taken into account for the next generated /// children's offsets fn spacing(&mut self, size: usize) { - self.current_offset += (size as i32).byte_diff(); + let offset = (size as i32).byte_diff(); + self.current_offset += offset; + self.sibling_offset += offset; } fn generate_ast_node( @@ -115,27 +118,26 @@ impl ChildGenerator { } fn add_node(&mut self, ast_crumbs: ast::Crumbs, node: Node) -> &mut node::Child { - let offset = self.current_offset; - let child = node::Child { node, offset, ast_crumbs }; + let parent_offset = self.current_offset; + let sibling_offset = self.sibling_offset; + let child = node::Child { node, parent_offset, sibling_offset, ast_crumbs }; self.current_offset += child.node.size; + self.sibling_offset = 0.byte_diff(); self.children.push(child); self.children.last_mut().unwrap() } fn generate_empty_node(&mut self, insert_type: InsertionPointType) -> &mut node::Child { - let child = node::Child { - node: Node::::new().with_kind(insert_type), - offset: self.current_offset, - ast_crumbs: vec![], - }; - self.children.push(child); - self.children.last_mut().unwrap() + self.add_node(vec![], Node::::new().with_kind(insert_type)) } fn reverse_children(&mut self) { self.children.reverse(); + let mut last_parent_offset = 0.byte_diff(); for child in &mut self.children { - child.offset = self.current_offset - child.offset - child.node.size; + child.parent_offset = self.current_offset - child.parent_offset - child.node.size; + child.sibling_offset = child.parent_offset - last_parent_offset; + last_parent_offset = child.parent_offset; } } @@ -149,9 +151,9 @@ impl ChildGenerator { -/// ============================= -/// === Trait Implementations === -/// ============================= +// ============================= +// === Trait Implementations === +// ============================= /// Helper structure constructed from Ast that consists base of prefix application. /// @@ -528,6 +530,10 @@ fn generate_node_for_prefix_chain( context: &impl Context, ) -> FallibleResult> { let app_base = ApplicationBase::from_prefix_chain(this); + + // If actual method arguments are not resolved, we still want to assign correct call ID to all + // argument spans. This is required for correct handling of span tree actions, as it is used to + // determine correct reinsertion point for removed span. let fallback_call_id = app_base.call_id; let mut application = app_base.resolve(context); @@ -811,29 +817,35 @@ fn tree_generate_node( if let Some(leaf_info) = &tree.leaf_info { size = ByteDiff::from(leaf_info.len()); } else { - let mut offset = ByteDiff::from(0); + let mut parent_offset = ByteDiff::from(0); + let mut sibling_offset = ByteDiff::from(0); for (index, raw_span_info) in tree.span_info.iter().enumerate() { match raw_span_info { - SpanSeed::Space(ast::SpanSeedSpace { space }) => offset += ByteDiff::from(space), + SpanSeed::Space(ast::SpanSeedSpace { space }) => { + parent_offset += ByteDiff::from(space); + sibling_offset += ByteDiff::from(space); + } SpanSeed::Token(ast::SpanSeedToken { token }) => { let kind = node::Kind::Token; let size = ByteDiff::from(token.len()); let ast_crumbs = vec![TreeCrumb { index }.into()]; let node = Node { kind, size, ..default() }; - children.push(node::Child { node, offset, ast_crumbs }); - offset += size; + children.push(node::Child { node, parent_offset, sibling_offset, ast_crumbs }); + parent_offset += size; + sibling_offset = 0.byte_diff(); } SpanSeed::Child(ast::SpanSeedChild { node }) => { let kind = node::Kind::argument(); let node = node.generate_node(kind, context)?; let child_size = node.size; let ast_crumbs = vec![TreeCrumb { index }.into()]; - children.push(node::Child { node, offset, ast_crumbs }); - offset += child_size; + children.push(node::Child { node, parent_offset, sibling_offset, ast_crumbs }); + parent_offset += child_size; + sibling_offset = 0.byte_diff(); } } } - size = offset; + size = parent_offset; } let payload = default(); Ok(Node { kind, parenthesized, size, children, ast_id, payload }) diff --git a/app/gui/language/span-tree/src/lib.rs b/app/gui/language/span-tree/src/lib.rs index ae14f7815780..2d35e79904d6 100644 --- a/app/gui/language/span-tree/src/lib.rs +++ b/app/gui/language/span-tree/src/lib.rs @@ -224,32 +224,32 @@ impl SpanTree { /// /// Example output with AST ids removed for clarity: /// ```text - /// operator6.join operator31 Join_Kind.Inner ["County"] Root - /// operator6.join operator31 Join_Kind.Inner ["County"] ├── Chained - /// operator6.join operator31 Join_Kind.Inner ["County"] │ ├── Chained - /// operator6.join operator31 Join_Kind.Inner │ │ ├── Chained - /// operator6.join operator31 │ │ │ ├── Chained - /// operator6.join │ │ │ │ ├── Operation - /// ▲ │ │ │ │ │ ├── InsertionPoint(BeforeTarget) - /// operator6 │ │ │ │ │ ├── This - /// ▲ │ │ │ │ │ ├── InsertionPoint(AfterTarget) - /// . │ │ │ │ │ ├── Operation - /// join │ │ │ │ │ ├── Argument - /// ▲ │ │ │ │ │ ╰── InsertionPoint(Append) - /// operator31 │ │ │ │ ╰── Argument name="right" - /// Join_Kind.Inner │ │ │ ╰── Argument name="join_kind" - /// ▲ │ │ │ ├── InsertionPoint(BeforeTarget) - /// Join_Kind │ │ │ ├── This - /// ▲ │ │ │ ├── InsertionPoint(AfterTarget) - /// . │ │ │ ├── Operation - /// Inner │ │ │ ├── Argument - /// ▲ │ │ │ ╰── InsertionPoint(Append) - /// ["County"] │ │ ╰── Argument name="on" - /// [ │ │ ├── Token - /// "County" │ │ ├── Argument - /// ] │ │ ╰── Token - /// ▲│ ╰── InsertionPoint(ExpectedArgument(3)) name="right_prefix" - /// ▲╰── InsertionPoint(ExpectedArgument(4)) name="on_problems" + /// ▷operator4.join operator2 Join_Kind.Inner ["County"]◁Root + /// ▷operator4.join operator2 Join_Kind.Inner ["County"]◁ ├─Chained + /// ▷operator4.join operator2 Join_Kind.Inner ["County"]◁ │ ├─Chained + /// ▷operator4.join operator2 Join_Kind.Inner◁ │ │ ├─Chained + /// ▷operator4.join operator2◁ │ │ │ ├─Chained + /// ▷operator4.join◁ │ │ │ │ ├─Operation + /// ▷◁ │ │ │ │ │ ├─InsertionPoint(BeforeArgument(0)) + /// ▷operator4◁ │ │ │ │ │ ├─Argument name="self" + /// ▷.◁ │ │ │ │ │ ├─Operation + /// ▷◁ │ │ │ │ │ ├─InsertionPoint(BeforeArgument(1)) + /// ▷join◁ │ │ │ │ │ ├─Argument + /// ▷◁ │ │ │ │ │ ╰─InsertionPoint(Append) + /// ▷operator2◁ │ │ │ │ ╰─Argument name="right" + /// ▷Join_Kind.Inner◁ │ │ │ ╰─Argument name="join_kind" + /// ▷◁ │ │ │ ├─InsertionPoint(BeforeArgument(0)) + /// ▷Join_Kind◁ │ │ │ ├─Argument + /// ▷.◁ │ │ │ ├─Operation + /// ▷◁ │ │ │ ├─InsertionPoint(BeforeArgument(1)) + /// ▷Inner◁ │ │ │ ├─Argument + /// ▷◁ │ │ │ ╰─InsertionPoint(Append) + /// ▷["County"]◁ │ │ ╰─Argument name="on" + /// ▷[◁ │ │ ├─Token + /// ▷"County"◁ │ │ ├─Argument + /// ▷]◁ │ │ ╰─Token + /// ▷◁ │ ╰─InsertionPoint(ExpectedArgument) name="right_prefix" + /// ▷◁ ╰─InsertionPoint(ExpectedArgument) name="on_problems" /// ``` pub fn debug_print(&self, code: &str) -> String { use std::fmt::Write; @@ -261,7 +261,7 @@ impl SpanTree { } let mut buffer = String::new(); - let span_padding = " ".repeat(code.len() + 1); + let span_padding = " ".repeat(code.len() + 2); struct PrintState { indent: String, @@ -271,21 +271,17 @@ impl SpanTree { self.root_ref().dfs_with_layer_data(state, |node, state| { let span = node.span(); let node_code = &code[span]; - buffer.push_str(&span_padding[0..node.span_offset.into()]); - let mut written = node.span_offset.into(); - if node_code.is_empty() { - buffer.push('▲'); - written += 1; - } else { - buffer.push_str(node_code); - written += node_code.len(); - } + buffer.push_str(&span_padding[0..node.span_offset.value]); + buffer.push('▷'); + buffer.push_str(node_code); + buffer.push('◁'); + let written = node.span_offset.value + node_code.len() + 2; buffer.push_str(&span_padding[written..]); let indent = if let Some(index) = node.crumbs.last() { let is_last = *index == state.num_children - 1; - let indent_targeted = if is_last { "╰── " } else { "├── " }; - let indent_continue = if is_last { " " } else { "│ " }; + let indent_targeted = if is_last { " ╰─" } else { " ├─" }; + let indent_continue = if is_last { " " } else { " │ " }; buffer.push_str(&state.indent); buffer.push_str(indent_targeted); diff --git a/app/gui/language/span-tree/src/node.rs b/app/gui/language/span-tree/src/node.rs index 79e6f179c611..ee6a30a2e72c 100644 --- a/app/gui/language/span-tree/src/node.rs +++ b/app/gui/language/span-tree/src/node.rs @@ -181,20 +181,23 @@ impl Node { #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct Child { /// A child node. - pub node: Node, + pub node: Node, /// An offset counted from the parent node starting index to the start of this node's span. - pub offset: ByteDiff, + pub parent_offset: ByteDiff, + /// The offset counted from the end of previous sibling node. + pub sibling_offset: ByteDiff, /// AST crumbs which lead from parent to child associated AST node. - pub ast_crumbs: ast::Crumbs, + pub ast_crumbs: ast::Crumbs, } impl Child { /// Payload mapping utility. pub fn map(self, f: impl Copy + Fn(T) -> S) -> Child { let node = self.node.map(f); - let offset = self.offset; + let parent_offset = self.parent_offset; let ast_crumbs = self.ast_crumbs; - Child { node, offset, ast_crumbs } + let sibling_offset = self.sibling_offset; + Child { node, parent_offset, sibling_offset, ast_crumbs } } } @@ -319,15 +322,17 @@ impl InvalidCrumb { #[derivative(Clone(bound = ""))] pub struct Ref<'a, T = ()> { /// The span tree that the node is a part of. - pub span_tree: &'a SpanTree, + pub span_tree: &'a SpanTree, /// The node's ref. - pub node: &'a Node, + pub node: &'a Node, /// Span begin's offset counted from the root expression. - pub span_offset: Byte, + pub span_offset: Byte, + /// The offset counted from the end of previous sibling node. + pub sibling_offset: ByteDiff, /// Crumbs specifying this node position related to root. - pub crumbs: Crumbs, + pub crumbs: Crumbs, /// Ast crumbs locating associated AST node, related to the root's AST node. - pub ast_crumbs: ast::Crumbs, + pub ast_crumbs: ast::Crumbs, } /// A result of `get_subnode_by_ast_crumbs` @@ -343,10 +348,11 @@ impl<'a, T> Ref<'a, T> { /// Constructor. pub fn root(span_tree: &'a SpanTree) -> Self { let span_offset = default(); + let sibling_offset = default(); let crumbs = default(); let ast_crumbs = default(); let node = &span_tree.root; - Self { span_tree, node, span_offset, crumbs, ast_crumbs } + Self { span_tree, node, span_offset, sibling_offset, crumbs, ast_crumbs } } /// Get span of current node. @@ -358,16 +364,17 @@ impl<'a, T> Ref<'a, T> { /// Get the reference to child with given index. Fails if index if out of bounds. pub fn child(self, index: usize) -> FallibleResult { - let Ref { span_tree, node, mut span_offset, crumbs, mut ast_crumbs } = self; + let Ref { span_tree, node, mut span_offset, crumbs, mut ast_crumbs, .. } = self; match node.children.get(index) { None => Err(InvalidCrumb::new(node.children.len(), index, &crumbs).into()), Some(child) => { let node = &child.node; - span_offset += child.offset; + span_offset += child.parent_offset; + let sibling_offset = child.sibling_offset; let crumbs = crumbs.into_sub(index); ast_crumbs.extend_from_slice(&child.ast_crumbs); - Ok(Self { span_tree, node, span_offset, crumbs, ast_crumbs }) + Ok(Self { span_tree, node, span_offset, sibling_offset, crumbs, ast_crumbs }) } } } @@ -402,7 +409,7 @@ impl<'a, T> Ref<'a, T> { /// Iterator over all children of operator/prefix chain starting from this node. See crate's /// documentation for more information about _chaining_. - pub fn chain_children_iter(self) -> impl Iterator> { + pub fn chain_children_iter(self) -> LeafIterator<'a, T> { LeafIterator::new(self, TreeFragment::ChainAndDirectChildren) } @@ -566,25 +573,25 @@ impl<'a, T> Ref<'a, T> { #[derive(Debug)] pub struct RefMut<'a, T = ()> { /// The node's ref. - node: &'a mut Node, + node: &'a mut Node, /// An offset counted from the parent node start to the start of this node's span. - pub offset: ByteDiff, + pub parent_offset: ByteDiff, /// Span begin's offset counted from the root expression. - pub span_offset: Byte, + pub span_offset: Byte, /// Crumbs specifying this node position related to root. - pub crumbs: Crumbs, + pub crumbs: Crumbs, /// Ast crumbs locating associated AST node, related to the root's AST node. - pub ast_crumbs: ast::Crumbs, + pub ast_crumbs: ast::Crumbs, } impl<'a, T> RefMut<'a, T> { /// Constructor. pub fn new(node: &'a mut Node) -> Self { - let offset = default(); + let parent_offset = default(); let span_begin = default(); let crumbs = default(); let ast_crumbs = default(); - Self { node, offset, span_offset: span_begin, crumbs, ast_crumbs } + Self { node, parent_offset, span_offset: span_begin, crumbs, ast_crumbs } } /// Payload accessor. @@ -606,16 +613,16 @@ impl<'a, T> RefMut<'a, T> { fn child_from_ref( index: usize, child: &'a mut Child, - mut span_begin: Byte, + span_begin: Byte, crumbs: Crumbs, mut ast_crumbs: ast::Crumbs, ) -> RefMut<'a, T> { - let offset = child.offset; + let parent_offset = child.parent_offset; let node = &mut child.node; - span_begin += child.offset; + let span_offset = span_begin + parent_offset; let crumbs = crumbs.into_sub(index); ast_crumbs.extend(child.ast_crumbs.iter().cloned()); - Self { node, offset, span_offset: span_begin, crumbs, ast_crumbs } + Self { node, parent_offset, span_offset, crumbs, ast_crumbs } } /// Get the reference to child with given index. Fails if index if out of bounds. diff --git a/app/gui/src/controller/graph/executed.rs b/app/gui/src/controller/graph/executed.rs index 96f286d77d38..580015b91b8f 100644 --- a/app/gui/src/controller/graph/executed.rs +++ b/app/gui/src/controller/graph/executed.rs @@ -16,6 +16,7 @@ use crate::model::execution_context::VisualizationId; use crate::model::execution_context::VisualizationUpdateData; use double_representation::name::QualifiedName; +use engine_protocol::language_server::ExecutionEnvironment; use engine_protocol::language_server::MethodPointer; use span_tree::generate::context::CalledMethodInfo; use span_tree::generate::context::Context; @@ -355,6 +356,15 @@ impl Handle { self.graph.borrow().disconnect(connection, self) } } + + /// Set the execution environment. + pub async fn set_execution_environment( + &self, + execution_environment: ExecutionEnvironment, + ) -> FallibleResult { + self.execution_ctx.set_execution_environment(execution_environment).await?; + Ok(()) + } } diff --git a/app/gui/src/controller/graph/widget.rs b/app/gui/src/controller/graph/widget.rs index b87648a78a54..2a15760e41de 100644 --- a/app/gui/src/controller/graph/widget.rs +++ b/app/gui/src/controller/graph/widget.rs @@ -1,7 +1,12 @@ //! Widget controller. //! //! The Widget Controller is responsible for querying the language server for information about -//! the node's widget metadata or resolving it from local cache. +//! the node's widget configuration or resolving it from local cache. + + + +mod configuration; +mod response; use crate::prelude::*; @@ -13,18 +18,17 @@ use crate::model::execution_context::VisualizationUpdateData; use engine_protocol::language_server::SuggestionId; use ensogl::define_endpoints_2; -use ide_view::graph_editor::component::node::input::widget; use ide_view::graph_editor::component::visualization; use ide_view::graph_editor::component::visualization::Metadata; use ide_view::graph_editor::data::enso::Code; -use ide_view::graph_editor::WidgetUpdate; -use ide_view::graph_editor::WidgetUpdates; +use ide_view::graph_editor::ArgumentWidgetConfig; +use ide_view::graph_editor::CallWidgetsConfig; -/// ================= -/// === Constants === -/// ================= +// ================= +// === Constants === +// ================= /// A module containing the widget visualization method. const WIDGET_VISUALIZATION_MODULE: &str = "Standard.Visualization.Widgets"; @@ -59,15 +63,15 @@ define_endpoints_2! { } Output { /// Emitted when the node's visualization has been set. - widget_data(NodeId, WidgetUpdates), + widget_data(NodeId, CallWidgetsConfig), } } -/// Graph widgets controller. Handles requests for widget metadata using visualizations. Maps +/// Graph widgets controller. Handles requests for widget configuration using visualizations. Maps /// response data to the relevant node Id updates, and dispatches them over the FRP output. /// Guarantees that each individual query eventually receives an update. It internally caches the -/// results of the last queries, so that the metadata can be delivered to the presenter even when no -/// visualization change is necessary. +/// results of the last queries, so that the configuration can be delivered to the presenter even +/// when no visualization change is necessary. #[derive(Debug, Deref)] pub struct Controller { #[deref] @@ -144,7 +148,7 @@ impl Model { fn handle_notification( &mut self, notification: Notification, - ) -> Option<(NodeId, WidgetUpdates)> { + ) -> Option<(NodeId, CallWidgetsConfig)> { let report_error = |message, error| { error!("{message}: {error}"); None @@ -167,26 +171,26 @@ impl Model { &mut self, target: ast::Id, data: VisualizationUpdateData, - ) -> Option<(NodeId, WidgetUpdates)> { + ) -> Option<(NodeId, CallWidgetsConfig)> { let query_data = self.widget_queries.get_mut(&target)?; - let (updates, errors) = VisualizationData::try_deserialize(&data); + let (definitions, errors) = configuration::deserialize_widget_definitions(&data); for error in errors { error!("{:?}", error); } - trace!("Widget updates: {updates:?}"); - let updates = Rc::new(updates); - query_data.last_updates = Some(updates.clone()); + trace!("Widget definitions: {definitions:?}"); + let definitions = Rc::new(definitions); + query_data.last_definitions = Some(definitions.clone()); let call_id = query_data.call_expression; - Some((query_data.node_id, WidgetUpdates { call_id, updates })) + Some((query_data.node_id, CallWidgetsConfig { call_id, definitions })) } /// Handle a widget request from presenter. Returns the widget updates if the request can be /// immediately fulfilled from the cache. - fn request_widget(&mut self, request: &Request) -> Option<(NodeId, WidgetUpdates)> { + fn request_widget(&mut self, request: &Request) -> Option<(NodeId, CallWidgetsConfig)> { let suggestion_db = self.graph.suggestion_db(); let suggestion = suggestion_db.lookup(request.call_suggestion).ok()?; @@ -212,7 +216,7 @@ impl Model { // the last known visualization data. Each widget request needs to be responded // to, otherwise the widget might not be displayed after the widget view has // been temporarily removed and created again. - query.last_updates() + query.last_definitions() } } Entry::Vacant(vacant) => { @@ -248,9 +252,9 @@ impl Model { -/// ============================ -/// === NodeToWidgetsMapping === -/// ============================ +// ============================ +// === NodeToWidgetsMapping === +// ============================ /// A map of widgets attached to nodes. Used to perform cleanup of node widget queries when node is /// removed. @@ -295,9 +299,9 @@ impl NodeToWidgetsMapping { -/// =============== -/// === Request === -/// =============== +// =============== +// === Request === +// =============== /// Definition of a widget request. Defines the node subexpression that the widgets will be attached /// to, and the method call that corresponds to that expression. @@ -316,19 +320,19 @@ pub struct Request { -/// ================= -/// === QueryData === -/// ================= +// ================= +// === QueryData === +// ================= /// Data of ongoing widget query. Defines which expressions a visualization query is attached to, /// and maintains enough data to correlate the response with respective widget view. #[derive(Debug)] struct QueryData { - node_id: NodeId, - call_expression: ExpressionId, - method_name: ImString, - arguments: Vec, - last_updates: Option>>, + node_id: NodeId, + call_expression: ExpressionId, + method_name: ImString, + arguments: Vec, + last_definitions: Option>>, } impl QueryData { @@ -337,8 +341,8 @@ impl QueryData { let arguments = suggestion.arguments.iter().map(|arg| arg.name.clone().into()).collect(); let method_name = suggestion.name.clone().into(); let call_expression = req.call_expression; - let last_updates = None; - QueryData { node_id, arguments, method_name, call_expression, last_updates } + let last_definitions = None; + QueryData { node_id, arguments, method_name, call_expression, last_definitions } } /// Update existing query data on new request. Returns true if the visualization query needs to @@ -365,18 +369,18 @@ impl QueryData { visualization_modified } - fn last_updates(&self) -> Option<(NodeId, WidgetUpdates)> { - self.last_updates.as_ref().map(|updates| { + fn last_definitions(&self) -> Option<(NodeId, CallWidgetsConfig)> { + self.last_definitions.as_ref().map(|definitions| { let call_id = self.call_expression; - let updates = WidgetUpdates { call_id, updates: updates.clone() }; - (self.node_id, updates) + let config = CallWidgetsConfig { call_id, definitions: definitions.clone() }; + (self.node_id, config) }) } fn request_visualization(&mut self, manager: &Rc, target_expression: ast::Id) { // When visualization is requested, remove stale queried value to prevent updates while // language server request is pending. - self.last_updates.take(); + self.last_definitions.take(); let vis_metadata = self.visualization_metadata(); manager.request_visualization(target_expression, vis_metadata); } @@ -416,77 +420,3 @@ impl QueryData { buffer } } - - - -/// =============================== -/// === WidgetVisualizationData === -/// =============================== - -/// A type representing the data received from the widget visualization for a single widget. -/// -/// The structure of this struct is dictated by the expected widget visualization JSON result shape. -#[derive(Debug, serde::Deserialize)] -struct VisualizationData { - constructor: widget::Kind, - display: VisualizationDataDisplay, - values: Vec, -} - -#[derive(Debug, serde::Deserialize)] -struct VisualizationDataDisplay { - constructor: widget::Display, -} - -#[derive(Debug, serde::Deserialize)] -struct VisualizationDataChoice { - value: String, - label: Option, -} - -impl From<&VisualizationDataChoice> for widget::Entry { - fn from(choice: &VisualizationDataChoice) -> Self { - let value: ImString = (&choice.value).into(); - let label = choice.label.as_ref().map_or_else(|| value.clone(), |label| label.into()); - Self { required_import: None, value, label } - } -} - -impl VisualizationData { - fn into_metadata(self) -> widget::Metadata { - let kind = self.constructor; - let display = self.display.constructor; - let dynamic_entries = self.values.iter().map(Into::into).collect(); - widget::Metadata { kind, display, dynamic_entries } - } - - /// Try to deserialize widget visualization update data. If deserialization fails for only part - /// of the response, the rest of the response is still processed, while errors are returned - /// separately for each failed widget. - fn try_deserialize(data: &VisualizationUpdateData) -> (Vec, Vec) { - let arguments: Vec<(String, serde_json::Value)> = match serde_json::from_slice(data) { - Ok(args) => args, - Err(err) => { - let err = err - .context("Failed to deserialize a list of arguments in widget response") - .into(); - return (default(), vec![err]); - } - }; - - let updates = arguments.into_iter().map( - |(argument_name, meta_json)| -> FallibleResult { - let deserialized = serde_json::from_value(meta_json); - let deserialized: Option = deserialized.map_err(|e| { - let message = - format!("Failed to deserialize widget data for argument '{argument_name}'"); - e.context(message) - })?; - let meta = deserialized.map(VisualizationData::into_metadata); - Ok(WidgetUpdate { argument_name, meta }) - }, - ); - - updates.partition_result() - } -} diff --git a/app/gui/src/controller/graph/widget/configuration.rs b/app/gui/src/controller/graph/widget/configuration.rs new file mode 100644 index 000000000000..880714b3a88d --- /dev/null +++ b/app/gui/src/controller/graph/widget/configuration.rs @@ -0,0 +1,84 @@ +//! This module contains the mappings of widget visualization responses into metadata structs used +//! by the views. + +use crate::prelude::*; + +use crate::model::execution_context::VisualizationUpdateData; + +use super::response; +use ide_view::graph_editor::component::node::input::widget; +use ide_view::graph_editor::ArgumentWidgetConfig; + + + +/// ===================================== +/// == deserialize_widget_definitions === +/// ===================================== + +/// Deserialize a list of widget configurations from definitions provided in visualization update +/// data. Allows for partial deserialization: if any of the widget definitions fails to deserialize, +/// it will be skipped, but the deserialization will continue. All errors are returned as a separate +/// list. +pub fn deserialize_widget_definitions( + data: &VisualizationUpdateData, +) -> (Vec, Vec) { + match serde_json::from_slice::(data) { + Ok(response) => { + let updates = response.into_iter().map( + |(argument_name, fallable_widget)| -> FallibleResult { + let widget: Option = + fallable_widget.widget.map_err(|e| { + let msg = "Failed to deserialize widget data for argument"; + e.context(format!("{msg} '{argument_name}'")) + })?; + let meta = widget.map(to_configuration); + let argument_name = argument_name.to_owned(); + Ok(ArgumentWidgetConfig { argument_name, config: meta }) + }, + ); + + updates.partition_result() + } + Err(err) => { + let msg = "Failed to deserialize a list of arguments in widget response"; + let err = err.context(msg).into(); + (default(), vec![err]) + } + } +} + +/// == Conversion to Widget Configuration IDE structs === + +/// Convert a widget definition from the engine response into a IDE internal widget configuration +/// struct. See [`widget::Configuration`] for more information. +fn to_configuration(resp: response::WidgetDefinition) -> widget::Configuration { + widget::Configuration { display: resp.display, kind: to_kind(resp.inner), has_port: true } +} + +fn to_kind(inner: response::WidgetKindDefinition) -> widget::DynConfig { + match inner { + response::WidgetKindDefinition::SingleChoice { label, values } => + widget::single_choice::Config { + label: label.map(Into::into), + entries: Rc::new(to_entries(&values)), + } + .into(), + response::WidgetKindDefinition::ListEditor { item_widget, item_default } => + widget::list_editor::Config { + item_widget: Some(Rc::new(to_configuration(*item_widget))), + item_default: item_default.into(), + } + .into(), + _ => widget::label::Config::default().into(), + } +} + +fn to_entries(choices: &[response::Choice]) -> Vec { + choices.iter().map(to_entry).collect() +} + +fn to_entry(choice: &response::Choice) -> widget::Entry { + let value: ImString = (&choice.value).into(); + let label = choice.label.as_ref().map_or_else(|| value.clone(), |label| label.into()); + widget::Entry { required_import: None, value, label } +} diff --git a/app/gui/src/controller/graph/widget/response.rs b/app/gui/src/controller/graph/widget/response.rs new file mode 100644 index 000000000000..1bde5a5c005c --- /dev/null +++ b/app/gui/src/controller/graph/widget/response.rs @@ -0,0 +1,139 @@ +//! The module containing the types used for deserializing language-server responses containing +//! widget configuration. + +use crate::prelude::*; + +use ide_view::graph_editor::component::node::input::widget; + + + +// ========================= +// === WidgetDefinitions === +// ========================= + +/// A top level object received from the widget visualization, which contains widget definitions for +/// all arguments of a single Enso method. Configurations are paired with the name of function +/// argument they are associated with. +pub(super) type WidgetDefinitions<'a> = Vec<(&'a str, FallableWidgetDefinition<'a>)>; + +/// A wrapper type that allows deserialization of a widget definitions to partially fail: failure +/// message of individual widget definition deserialization will be preserved and deserialization +/// will continue. +#[derive(Debug)] +pub(super) struct FallableWidgetDefinition<'a> { + pub(super) widget: FallibleResult>>, +} + +impl<'de: 'a, 'a> serde::Deserialize<'de> for FallableWidgetDefinition<'a> { + fn deserialize(deserializer: D) -> Result + where D: serde::Deserializer<'de> { + let widget = >::deserialize(deserializer) + .map_err(|e| failure::err_msg(e.to_string())); + Ok(Self { widget }) + } +} + + + +// ======================== +// === WidgetDefinition === +// ======================== + +/// Widget definition provided from the engine. It is used to define how to display a widget of +/// particular argument expression. When not provided, the default widget will be chosen based on +/// value or expected argument type. +/// +/// Must be kept in sync with `Widget` type definition in Enso's `Standard.Base.Metadata` module. +/// In order to not ruin forward compatibility, only fields that are currently used by the IDE are +/// specified and deserialized. +#[derive(Debug, serde::Deserialize)] +pub(super) struct WidgetDefinition<'a> { + /// The display mode of this widget. + #[serde(default)] + pub display: widget::Display, + #[serde(borrow, flatten)] + pub inner: WidgetKindDefinition<'a>, +} + +/// Part of [`WidgetDefinition`] that is dependant on widget kind. +#[derive(Debug, serde::Deserialize)] +#[serde(tag = "constructor")] +pub(super) enum WidgetKindDefinition<'a> { + /// A single value widget (dropdown). + #[serde(rename = "Single_Choice")] + SingleChoice { + /// The text that is displayed when no value is chosen. By default, the parameter name is + /// used. + #[serde(borrow, default)] + label: Option<&'a str>, + /// A list of choices to display. + #[serde(borrow, default)] + values: Vec>, + }, + + /// A list editor widget producing a Vector. Items can be dragged around to change the order, + /// or dragged out to be deleted from the Vector. + #[serde(rename = "List_Editor", alias = "Vector_Editor")] + ListEditor { + /// The widget to use for editing the items. + #[serde(borrow, alias = "item_editor")] + item_widget: Box>, + /// The default value for new items inserted when the user adds a new element. + #[serde(borrow)] + item_default: &'a str, + }, + + /// A multi value widget. + #[serde(rename = "Multi_Choice")] + MultipleChoice, + + /// A code parameter. + #[serde(rename = "Code_Input")] + CodeInput, + + /// A boolean parameter. + #[serde(rename = "Boolean_Input")] + BooleanInput, + + /// A numeric parameter. + #[serde(rename = "Numeric_Input")] + NumericInput, + + /// A text widget. + #[serde(rename = "Text_Input")] + TextInput, + + /// A folder chooser. + #[serde(rename = "Folder_Browse")] + FolderBrowse, + + /// A file chooser. + #[serde(rename = "File_Browse")] + FileBrowse, +} + +/// Widget display mode. Determines when the widget should be expanded. +#[derive(serde::Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)] +#[serde(tag = "constructor")] +pub enum Display { + /// The widget should always be in its expanded mode. + #[default] + Always, + /// The widget should only be in its expanded mode when it has non-default value. + #[serde(rename = "When_Modified")] + WhenModified, + /// The widget should only be in its expanded mode whe the whole node is expanded. + #[serde(rename = "Expanded_Only")] + ExpandedOnly, +} + +/// A choice in a single or multiselect widget. +#[derive(Debug, serde::Deserialize)] +pub(super) struct Choice<'a> { + /// The value of the choice. Must be a valid Enso expression. + pub value: &'a str, + /// Custom label to display in the dropdown. If not provided, IDE will create a label based on + /// value. + #[serde(borrow)] + pub label: Option<&'a str>, +} diff --git a/app/gui/src/lib.rs b/app/gui/src/lib.rs index 4d32d845f341..491ba641382e 100644 --- a/app/gui/src/lib.rs +++ b/app/gui/src/lib.rs @@ -64,6 +64,12 @@ extern crate core; use prelude::*; +use wasm_bindgen::prelude::*; + +mod profile_workflow; +#[cfg(test)] +mod tests; + // ============== // === Export === @@ -82,13 +88,9 @@ pub mod transport; pub use crate::ide::*; pub use engine_protocol; +use enso_executor::web::EventLoopExecutor; pub use ide_view as view; - - -#[cfg(test)] -mod tests; - /// Common types that should be visible across the whole IDE crate. pub mod prelude { pub use ast::prelude::*; @@ -126,6 +128,12 @@ pub mod prelude { pub use wasm_bindgen_test::wasm_bindgen_test_configure; } + + +// ==================== +// === Entry Points === +// ==================== + // These imports are required to have all entry points (such as examples) and `before_main` // functions (such as the dynamic-asset loader), available in the IDE. #[allow(unused_imports)] @@ -136,13 +144,23 @@ mod imported_for_entry_points { } #[allow(unused_imports)] use imported_for_entry_points::*; -mod profile_workflow; -// =================== -// === Entry Point === -// =================== +// ==================== +// === Global State === +// ==================== + +thread_local! { + static EXECUTOR: RefCell> = default(); + static IDE: RefCell>> = default(); +} + + + +// ======================= +// === IDE Entry Point === +// ======================= /// IDE startup function. #[entry_point(ide)] @@ -159,16 +177,43 @@ pub fn main() { "debug_mode_is_active", analytics::AnonymousData(debug_mode), ); - let config = - crate::config::Startup::from_web_arguments().expect("Failed to read configuration"); - let executor = crate::executor::setup_global_executor(); - let initializer = crate::ide::initializer::Initializer::new(config); + let config = config::Startup::from_web_arguments().expect("Failed to read configuration"); + let executor = executor::setup_global_executor(); + EXECUTOR.with(move |global_executor| global_executor.replace(Some(executor))); + let initializer = Initializer::new(config); executor::global::spawn(async move { let ide = initializer.start().await; ensogl::system::web::document .get_element_by_id("loader") .map(|t| t.parent_node().map(|p| p.remove_child(&t).unwrap())); - std::mem::forget(ide); + IDE.with(move |global_ide| global_ide.replace(Some(ide))); }); - std::mem::forget(executor); +} + + + +// ================ +// === IDE Drop === +// ================ + +/// Drop all structure created so far. +/// +/// All connections will be closed and all visuals will be removed. +#[wasm_bindgen] +pub fn drop() { + let ide = IDE.with(RefCell::take); + if let Some(Ok(ide)) = &ide { + //TODO[ao] #6420 We should not do this, but somehow the `dom` field in the scene is + // leaking. + ide.ensogl_app.display.default_scene.dom.root.remove(); + } + mem::drop(ide); + EXECUTOR.with(RefCell::take); + leak_detector::TRACKED_OBJECTS.with(|objects| { + let objects = objects.borrow(); + if !objects.is_empty() { + error!("Tracked objects leaked after dropping entire application!"); + error!("Leaked objects: {objects:#?}"); + } + }) } diff --git a/app/gui/src/model/execution_context.rs b/app/gui/src/model/execution_context.rs index 7a7682c538c4..7f3d4b3d2f4c 100644 --- a/app/gui/src/model/execution_context.rs +++ b/app/gui/src/model/execution_context.rs @@ -6,6 +6,7 @@ use double_representation::identifier::Identifier; use double_representation::name::project; use double_representation::name::QualifiedName; use engine_protocol::language_server; +use engine_protocol::language_server::ExecutionEnvironment; use engine_protocol::language_server::ExpressionUpdate; use engine_protocol::language_server::ExpressionUpdatePayload; use engine_protocol::language_server::MethodPointer; @@ -503,6 +504,13 @@ pub trait API: Debug { /// Adjust method pointers after the project rename action. fn rename_method_pointers(&self, old_project_name: String, new_project_name: String); + + /// Set the execution environment of the context. + #[allow(clippy::needless_lifetimes)] + fn set_execution_environment<'a>( + &'a self, + execution_environment: ExecutionEnvironment, + ) -> BoxFuture<'a, FallibleResult>; } // Note: Needless lifetimes diff --git a/app/gui/src/model/execution_context/plain.rs b/app/gui/src/model/execution_context/plain.rs index cf5123f2a3b7..057ed4a000af 100644 --- a/app/gui/src/model/execution_context/plain.rs +++ b/app/gui/src/model/execution_context/plain.rs @@ -11,6 +11,7 @@ use crate::model::execution_context::Visualization; use crate::model::execution_context::VisualizationId; use crate::model::execution_context::VisualizationUpdateData; +use engine_protocol::language_server::ExecutionEnvironment; use engine_protocol::language_server::MethodPointer; use engine_protocol::language_server::VisualisationConfiguration; use futures::future::LocalBoxFuture; @@ -61,6 +62,8 @@ pub struct ExecutionContext { pub is_ready: crate::sync::Synchronized, /// Component groups defined in libraries imported into the execution context. pub component_groups: RefCell>>, + /// Execution environment of the context. + pub execution_environment: Cell, } impl ExecutionContext { @@ -72,6 +75,7 @@ impl ExecutionContext { let computed_value_info_registry = default(); let is_ready = default(); let component_groups = default(); + let execution_environment = default(); Self { entry_point, stack, @@ -79,6 +83,7 @@ impl ExecutionContext { computed_value_info_registry, is_ready, component_groups, + execution_environment, } } @@ -273,6 +278,15 @@ impl model::execution_context::API for ExecutionContext { local_call.definition = update_method_pointer(&mut local_call.definition) }); } + + fn set_execution_environment( + &self, + environment: ExecutionEnvironment, + ) -> BoxFuture { + info!("Setting execution environment to {environment:?}."); + self.execution_environment.set(environment); + futures::future::ready(Ok(())).boxed_local() + } } diff --git a/app/gui/src/model/execution_context/synchronized.rs b/app/gui/src/model/execution_context/synchronized.rs index d20e79634a77..f055bd71ab28 100644 --- a/app/gui/src/model/execution_context/synchronized.rs +++ b/app/gui/src/model/execution_context/synchronized.rs @@ -11,6 +11,7 @@ use crate::model::execution_context::VisualizationId; use crate::model::execution_context::VisualizationUpdateData; use engine_protocol::language_server; +use engine_protocol::language_server::ExecutionEnvironment; @@ -298,7 +299,11 @@ impl model::execution_context::API for ExecutionContext { async move { self.language_server .client - .recompute(&self.id, &language_server::InvalidatedExpressions::All) + .recompute( + &self.id, + &language_server::InvalidatedExpressions::All, + &Some(self.model.execution_environment.get()), + ) .await?; Ok(()) } @@ -308,6 +313,22 @@ impl model::execution_context::API for ExecutionContext { fn rename_method_pointers(&self, old_project_name: String, new_project_name: String) { self.model.rename_method_pointers(old_project_name, new_project_name); } + + fn set_execution_environment( + &self, + execution_environment: ExecutionEnvironment, + ) -> BoxFuture { + self.model.execution_environment.set(execution_environment); + async move { + info!("Setting execution environment to {execution_environment:?}."); + self.language_server + .client + .set_execution_environment(&self.id, &execution_environment) + .await?; + Ok(()) + } + .boxed_local() + } } impl Drop for ExecutionContext { diff --git a/app/gui/src/presenter.rs b/app/gui/src/presenter.rs index 1a3123b2f8ba..8a3b2bac2867 100644 --- a/app/gui/src/presenter.rs +++ b/app/gui/src/presenter.rs @@ -167,7 +167,6 @@ impl Presenter { root_frp.switch_view_to_project <+ welcome_view_frp.open_project.constant(()); } - Self { model, network }.init() } diff --git a/app/gui/src/presenter/graph.rs b/app/gui/src/presenter/graph.rs index f8cad3e1e11c..d62c4396348c 100644 --- a/app/gui/src/presenter/graph.rs +++ b/app/gui/src/presenter/graph.rs @@ -12,6 +12,7 @@ use crate::presenter::graph::state::State; use double_representation::context_switch::Context; use double_representation::context_switch::ContextSwitch; use double_representation::context_switch::ContextSwitchExpression; +use engine_protocol::language_server::ExecutionEnvironment; use engine_protocol::language_server::SuggestionId; use enso_frp as frp; use futures::future::LocalBoxFuture; @@ -19,8 +20,7 @@ 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; use ide_view::graph_editor::EdgeEndpoint; -use view::graph_editor::ExecutionEnvironment; -use view::graph_editor::WidgetUpdates; +use view::graph_editor::CallWidgetsConfig; // ============== @@ -264,13 +264,13 @@ impl Model { } /// Map widget controller update data to the node views. - fn map_widget_update_data( + fn map_widget_configuration( &self, node_id: AstNodeId, - updates: WidgetUpdates, - ) -> Option<(ViewNodeId, WidgetUpdates)> { + config: CallWidgetsConfig, + ) -> Option<(ViewNodeId, CallWidgetsConfig)> { let node_id = self.state.view_id_of_ast_node(node_id)?; - Some((node_id, updates)) + Some((node_id, config)) } /// Node was removed in view. @@ -835,7 +835,7 @@ impl Graph { widget.request_widgets <+ widget_request; widget.retain_node_expressions <+ widget_refresh._0().unwrap(); view.update_node_widgets <+ widget.widget_data.filter_map( - f!(((id, updates)) model.map_widget_update_data(*id, updates.clone())) + f!(((id, data)) model.map_widget_configuration(*id, data.clone())) ); } } diff --git a/app/gui/src/presenter/project.rs b/app/gui/src/presenter/project.rs index b9490454c7a4..7f8ffc5f6689 100644 --- a/app/gui/src/presenter/project.rs +++ b/app/gui/src/presenter/project.rs @@ -7,6 +7,7 @@ use crate::executor::global::spawn_stream_handler; use crate::presenter; use crate::presenter::graph::ViewNodeId; +use engine_protocol::language_server::ExecutionEnvironment; use enso_frp as frp; use ensogl::system::js; use ide_view as view; @@ -287,6 +288,24 @@ impl Model { view.show_graph_editor(); }) } + + fn execution_environment_changed( + &self, + execution_environment: &ide_view::execution_environment_selector::ExecutionEnvironment, + ) { + if let Ok(execution_environment) = execution_environment.as_str().try_into() { + let graph_controller = self.graph_controller.clone_ref(); + executor::global::spawn(async move { + if let Err(err) = + graph_controller.set_execution_environment(execution_environment).await + { + error!("Error setting execution environment: {err}"); + } + }); + } else { + error!("Invalid execution environment: {execution_environment:?}"); + } + } } @@ -385,22 +404,22 @@ impl Project { eval_ view.execution_context_restart(model.execution_context_restart()); view.set_read_only <+ view.toggle_read_only.map(f_!(model.toggle_read_only())); + eval graph_view.execution_environment((env) model.execution_environment_changed(env)); } let graph_controller = self.model.graph_controller.clone_ref(); self.init_analytics() - .init_execution_modes() + .init_execution_environments() .setup_notification_handler() .attach_frp_to_values_computed_notifications(graph_controller, values_computed) } - /// Initialises execution modes. Currently a dummy implementqation to be replaced during - /// implementation of #5930. - fn init_execution_modes(self) -> Self { + /// Initialises execution environment. + fn init_execution_environments(self) -> Self { let graph = &self.model.view.graph(); - let entries = Rc::new(vec!["design".to_string(), "live".to_string()]); - graph.set_available_execution_modes(entries); + let entries = Rc::new(ExecutionEnvironment::list_all_as_imstrings()); + graph.set_available_execution_environments(entries); self } diff --git a/app/gui/view/Cargo.toml b/app/gui/view/Cargo.toml index c9d3c103bd11..73e0956319f5 100644 --- a/app/gui/view/Cargo.toml +++ b/app/gui/view/Cargo.toml @@ -23,7 +23,7 @@ ensogl-text = { path = "../../../lib/rust/ensogl/component/text" } ensogl-text-msdf = { path = "../../../lib/rust/ensogl/component/text/src/font/msdf" } ensogl-hardcoded-theme = { path = "../../../lib/rust/ensogl/app/theme/hardcoded" } ide-view-component-browser = { path = "component-browser" } -ide-view-execution-mode-selector = { path = "execution-mode-selector" } +ide-view-execution-environment-selector = { path = "execution-environment-selector" } ide-view-documentation = { path = "documentation" } ide-view-graph-editor = { path = "graph-editor" } span-tree = { path = "../language/span-tree" } diff --git a/app/gui/view/examples/Cargo.toml b/app/gui/view/examples/Cargo.toml index 2e6e9eb0e78f..0eae83f4486c 100644 --- a/app/gui/view/examples/Cargo.toml +++ b/app/gui/view/examples/Cargo.toml @@ -14,7 +14,7 @@ debug-scene-icons = { path = "icons" } debug-scene-interface = { path = "interface" } debug-scene-text-grid-visualization = { path = "text-grid-visualization" } debug-scene-visualization = { path = "visualization" } -debug-scene-execution-mode-dropdown = { path = "execution-mode-dropdown" } +debug-scene-execution-environment-dropdown = { path = "execution-environment-dropdown" } # 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] diff --git a/app/gui/view/examples/execution-mode-dropdown/Cargo.toml b/app/gui/view/examples/execution-environment-dropdown/Cargo.toml similarity index 79% rename from app/gui/view/examples/execution-mode-dropdown/Cargo.toml rename to app/gui/view/examples/execution-environment-dropdown/Cargo.toml index f4e5658753e2..914bf583983d 100644 --- a/app/gui/view/examples/execution-mode-dropdown/Cargo.toml +++ b/app/gui/view/examples/execution-environment-dropdown/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "debug-scene-execution-mode-dropdown" +name = "debug-scene-execution-environment-dropdown" version = "0.1.0" authors = ["Enso Team "] edition = "2021" @@ -13,4 +13,4 @@ ensogl-drop-down-menu = { path = "../../../../../lib/rust/ensogl/component/drop- ensogl-list-view = { path = "../../../../../lib/rust/ensogl/component/list-view" } ensogl-hardcoded-theme = { path = "../../../../../lib/rust/ensogl/app/theme/hardcoded" } ensogl-text-msdf = { path = "../../../../../lib/rust/ensogl/component/text/src/font/msdf" } -ide-view-execution-mode-selector = { path = "../../execution-mode-selector" } +ide-view-execution-environment-selector = { path = "../../execution-environment-selector" } diff --git a/app/gui/view/examples/execution-mode-dropdown/src/lib.rs b/app/gui/view/examples/execution-environment-dropdown/src/lib.rs similarity index 71% rename from app/gui/view/examples/execution-mode-dropdown/src/lib.rs rename to app/gui/view/examples/execution-environment-dropdown/src/lib.rs index e455ceb2ec1c..c7731b3e871c 100644 --- a/app/gui/view/examples/execution-mode-dropdown/src/lib.rs +++ b/app/gui/view/examples/execution-environment-dropdown/src/lib.rs @@ -19,7 +19,8 @@ use ensogl::prelude::*; use ensogl::animation; use ensogl::application::Application; use ensogl_text_msdf::run_once_initialized; -use ide_view_execution_mode_selector as execution_mode_selector; +use execution_environment_selector::make_dummy_execution_environments; +use ide_view_execution_environment_selector as execution_environment_selector; @@ -27,24 +28,23 @@ use ide_view_execution_mode_selector as execution_mode_selector; // === Initialisation === // ====================== -fn make_entries() -> execution_mode_selector::ExecutionModes { - Rc::new(vec!["design".to_string(), "live".to_string()]) -} fn init(app: &Application) { let app = app.clone_ref(); let world = &app.display; let _scene = &world.default_scene; - let execution_mode_selector = execution_mode_selector::ExecutionModeSelector::new(&app); - world.add_child(&execution_mode_selector); - execution_mode_selector.set_available_execution_modes(make_entries()); + let execution_environment_selector = + execution_environment_selector::ExecutionEnvironmentSelector::new(&app); + world.add_child(&execution_environment_selector); + execution_environment_selector + .set_available_execution_environments(make_dummy_execution_environments()); world .on .before_frame .add(move |_time_info: animation::TimeInfo| { - let _keep_alive = &execution_mode_selector; + let _keep_alive = &execution_environment_selector; }) .forget(); } diff --git a/app/gui/view/examples/interface/Cargo.toml b/app/gui/view/examples/interface/Cargo.toml index 3f40e579dd98..bb5d53a12b24 100644 --- a/app/gui/view/examples/interface/Cargo.toml +++ b/app/gui/view/examples/interface/Cargo.toml @@ -14,7 +14,7 @@ ensogl = { path = "../../../../../lib/rust/ensogl" } ensogl-hardcoded-theme = { path = "../../../../../lib/rust/ensogl/app/theme/hardcoded" } ensogl-text-msdf = { path = "../../../../../lib/rust/ensogl/component/text/src/font/msdf" } ide-view = { path = "../.." } -ide-view-execution-mode-selector = { path = "../../execution-mode-selector" } +ide-view-execution-environment-selector = { path = "../../execution-environment-selector" } parser = { path = "../../../language/parser" } span-tree = { path = "../../../language/span-tree" } uuid = { version = "0.8", features = ["v4", "wasm-bindgen"] } diff --git a/app/gui/view/examples/interface/src/lib.rs b/app/gui/view/examples/interface/src/lib.rs index ea20b7e01206..d8a9639068be 100644 --- a/app/gui/view/examples/interface/src/lib.rs +++ b/app/gui/view/examples/interface/src/lib.rs @@ -25,6 +25,7 @@ use ensogl::gui::text; use ensogl::system::web; use ensogl_hardcoded_theme as theme; use ensogl_text_msdf::run_once_initialized; +use ide_view::execution_environment_selector::make_dummy_execution_environments; use ide_view::graph_editor; use ide_view::graph_editor::component::node::vcs; use ide_view::graph_editor::component::node::Expression; @@ -256,7 +257,7 @@ fn init(app: &Application) { // === Execution Modes === - graph_editor.set_available_execution_modes(vec!["design".to_string(), "live".to_string()]); + graph_editor.set_available_execution_environments(make_dummy_execution_environments()); // === Rendering === @@ -418,18 +419,18 @@ pub fn expression_mock_trim() -> Expression { tag_values: vec![ TagValue { required_import: None, - expression: "Location.Start".into(), - label: Some("Start".into()), + expression: "Standard.Base.Data.Text.Location.Start".into(), + label: Some("Location.Start".into()), }, TagValue { required_import: None, - expression: "Location.End".into(), - label: Some("End".into()), + expression: "Standard.Base.Data.Text.Location.End".into(), + label: Some("Location.End".into()), }, TagValue { required_import: None, - expression: "Location.Both".into(), - label: Some("Both".into()), + expression: "Standard.Base.Data.Text.Location.Both".into(), + label: Some("Location.Both".into()), }, ], ..default() diff --git a/app/gui/view/examples/src/lib.rs b/app/gui/view/examples/src/lib.rs index 8fd4ae8c680d..3b18d962ff33 100644 --- a/app/gui/view/examples/src/lib.rs +++ b/app/gui/view/examples/src/lib.rs @@ -24,7 +24,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_mode_dropdown as execution_mode_dropdown; +pub use debug_scene_execution_environment_dropdown as execution_environment_dropdown; 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/execution-mode-selector/Cargo.toml b/app/gui/view/execution-environment-selector/Cargo.toml similarity index 93% rename from app/gui/view/execution-mode-selector/Cargo.toml rename to app/gui/view/execution-environment-selector/Cargo.toml index a67a694a9dd1..216223954cc4 100644 --- a/app/gui/view/execution-mode-selector/Cargo.toml +++ b/app/gui/view/execution-environment-selector/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "ide-view-execution-mode-selector" +name = "ide-view-execution-environment-selector" version = "0.1.0" authors = ["Enso Team "] edition = "2021" diff --git a/app/gui/view/execution-mode-selector/src/lib.rs b/app/gui/view/execution-environment-selector/src/lib.rs similarity index 78% rename from app/gui/view/execution-mode-selector/src/lib.rs rename to app/gui/view/execution-environment-selector/src/lib.rs index 9a179df48590..a43eac6ca09c 100644 --- a/app/gui/view/execution-mode-selector/src/lib.rs +++ b/app/gui/view/execution-environment-selector/src/lib.rs @@ -33,7 +33,7 @@ use ensogl::display::camera::Camera2d; use ensogl::display::shape::StyleWatchFrp; use ensogl_derive_theme::FromTheme; use ensogl_gui_component::component; -use ensogl_hardcoded_theme::graph_editor::execution_mode_selector as theme; +use ensogl_hardcoded_theme::graph_editor::execution_environment_selector as theme; @@ -41,7 +41,7 @@ use ensogl_hardcoded_theme::graph_editor::execution_mode_selector as theme; // === Style === // ============== -/// Theme specification for the execution mode selector. +/// Theme specification for the execution environment selector. #[derive(Debug, Clone, Copy, Default, FromTheme)] #[base_path = "theme"] pub struct Style { @@ -73,17 +73,23 @@ impl Style { // === FRP === // =========== -/// An identifier of a execution mode. -pub type ExecutionMode = String; -/// A list of execution modes. -pub type ExecutionModes = Rc>; +/// An identifier of a execution environment. +pub type ExecutionEnvironment = ImString; +/// A list of execution environments. +pub type ExecutionEnvironments = Rc>; + +/// Provide a dummy list of execution environments. Used for testing and demo scenes. +pub fn make_dummy_execution_environments() -> ExecutionEnvironments { + Rc::new(vec!["Design".to_string().into(), "Live".to_string().into()]) +} ensogl::define_endpoints_2! { Input { - set_available_execution_modes (ExecutionModes), + set_available_execution_environments (ExecutionEnvironments), + set_execution_environment (ExecutionEnvironment), } Output { - selected_execution_mode (ExecutionMode), + selected_execution_environment (ExecutionEnvironment), play_press(), size (Vector2), } @@ -95,13 +101,13 @@ ensogl::define_endpoints_2! { // === Model === // ============= -/// The model of the execution mode selector. +/// The model of the execution environment selector. #[derive(Debug, Clone, CloneRef)] pub struct Model { - /// Main root object for the execution mode selector exposed for external positioning. + /// Main root object for the execution environment selector exposed for external positioning. display_object: display::object::Instance, - /// Inner root that will be used for positioning the execution mode selector relative to the - /// window + /// Inner root that will be used for positioning the execution environment selector relative to + /// the window inner_root: display::object::Instance, background: display::shape::compound::rectangle::Rectangle, divider: display::shape::compound::rectangle::Rectangle, @@ -113,6 +119,7 @@ impl Model { fn update_dropdown_style(&self, style: &Style) { self.dropdown.set_menu_offset_y(style.menu_offset); self.dropdown.set_x(style.overall_width() / 2.0 - style.divider_offset); + self.dropdown.set_width(style.dropdown_width); self.dropdown.set_label_color(Rgba::white()); self.dropdown.set_icon_size(Vector2::new(1.0, 1.0)); self.dropdown.set_menu_alignment(ensogl_drop_down_menu::Alignment::Right); @@ -148,7 +155,7 @@ impl Model { self.inner_root.set_y(y.round()); } - fn set_entries(&self, entries: Rc>) { + fn set_entries(&self, entries: Rc>) { let provider = ensogl_list_view::entry::AnyModelProvider::from(entries.clone_ref()); self.dropdown.set_entries(provider); self.dropdown.set_selected(0); @@ -173,13 +180,13 @@ impl display::Object for Model { -// ============================= -// === ExecutionModeDropdown === -// ============================= +// ==================================== +// === ExecutionEnvironmentDropdown === +// ==================================== impl component::Model for Model { fn label() -> &'static str { - "ExecutionModeDropdown" + "ExecutionEnvironmentDropdown" } fn new(app: &Application) -> Self { @@ -244,23 +251,20 @@ impl component::Frp for Frp { // == Inputs == - eval input.set_available_execution_modes ((entries) model.set_entries(entries.clone())); + eval input.set_available_execution_environments ((entries) model.set_entries(entries.clone())); + + update_selected_entry <- input.set_execution_environment.map2(&input.set_available_execution_environments, |entry, entries| { + entries.iter().position(|mode| mode == entry) + }); + dropdown.frp.set_selected <+ update_selected_entry; selected_id <- dropdown.frp.chosen_entry.unwrap(); - selection <- all(input.set_available_execution_modes, selected_id); + selection <- all(input.set_available_execution_environments, selected_id); selected_entry <- selection.map(|(entries, entry_id)| entries[*entry_id].clone()); - output.selected_execution_mode <+ selected_entry; + output.selected_execution_environment <+ selected_entry; eval selected_entry ([model] (execution_mode) { - // TODO(#5930): Revisit when connecting with externally set execution mode - let play_button_visibility = match execution_mode.as_str() { - "design" => true, - "live" => false, - _ => { - error!("Play button: invalid execution mode"); - false - } - }; + let play_button_visibility = matches!(execution_mode.to_lowercase().as_str(), "design"); model.set_play_button_visibility(play_button_visibility); }); play_button.reset <+ selected_entry.constant(()); @@ -276,6 +280,6 @@ impl component::Frp for Frp { } } -/// ExecutionModeSelector is a component that allows the user to select the execution mode of the -/// graph. -pub type ExecutionModeSelector = component::ComponentView; +/// ExecutionEnvironmentSelector is a component that allows the user to select the execution +/// environment of the graph. +pub type ExecutionEnvironmentSelector = component::ComponentView; diff --git a/app/gui/view/execution-environment-selector/src/play_button.rs b/app/gui/view/execution-environment-selector/src/play_button.rs new file mode 100644 index 000000000000..85d3ae0b5eb4 --- /dev/null +++ b/app/gui/view/execution-environment-selector/src/play_button.rs @@ -0,0 +1,182 @@ +use enso_prelude::*; +use ensogl::prelude::*; + +use enso_frp as frp; +use ensogl::application::Application; +use ensogl::control::io::mouse; +use ensogl::display; +use ensogl::display::shape::StyleWatchFrp; +use ensogl_derive_theme::FromTheme; +use ensogl_gui_component::component; +use ensogl_hardcoded_theme::graph_editor::execution_environment_selector::play_button as theme; + + + +// ============= +// === Style === +// ============== + +#[derive(Debug, Clone, Copy, Default, FromTheme)] +#[base_path = "theme"] +pub struct Style { + triangle_size: f32, + offset: f32, + padding_x: f32, + padding_y: f32, +} + + + +// ============== +// === Shapes === +// ============== + +mod play_icon { + use super::*; + + use std::f32::consts::PI; + + ensogl::shape! { + above = [display::shape::compound::rectangle::shape]; + (style: Style) { + let triangle_size = style.get_number(theme::triangle_size); + let color = style.get_color(theme::color); + let triangle = Triangle(triangle_size, triangle_size).rotate((PI / 2.0).radians()); + let triangle = triangle.fill(color); + let bg_size = Var::canvas_size(); + let bg = Rect(bg_size).fill(INVISIBLE_HOVER_COLOR); + (bg + triangle).into() + } + } +} + +mod spinner_icon { + use super::*; + + use std::f32::consts::FRAC_PI_3; + + ensogl::shape! { + above = [display::shape::compound::rectangle::shape]; + (style: Style) { + let color = style.get_color(theme::spinner::color); + let speed = style.get_number(theme::spinner::speed); + let width = Var::::from("input_size.x"); + let time = Var::::from("input_time"); + let unit = &width / 16.0; + let arc = RoundedArc(&unit * 5.0, (4.0 * FRAC_PI_3).radians(), &unit * 2.0); + let rotated_arc = arc.rotate(time * speed); + rotated_arc.fill(color).into() + } + } +} + + + +// =========== +// === FRP === +// =========== + +ensogl::define_endpoints_2! { + Input { + reset (), + } + Output { + pressed (), + } +} + + + +// ============= +// === Model === +// ============= + +#[derive(Debug, Clone, CloneRef)] +pub struct Model { + display_object: display::object::Instance, + play_icon: play_icon::View, + spinner_icon: spinner_icon::View, +} + +impl Model { + fn update_style(&self, style: &Style) { + let triangle_size = Vector2::new(style.triangle_size, style.triangle_size); + let padding = Vector2::new(style.padding_x, style.padding_y); + let size = triangle_size + 2.0 * padding; + self.play_icon.set_size(size); + self.spinner_icon.set_size(size); + self.play_icon.set_x(-size.x / 2.0 - style.offset); + self.spinner_icon.set_x(-size.x / 2.0 - style.offset); + } + + fn set_playing(&self, playing: bool) { + if playing { + self.display_object.remove_child(&self.play_icon); + self.display_object.add_child(&self.spinner_icon); + } else { + self.display_object.remove_child(&self.spinner_icon); + self.display_object.add_child(&self.play_icon); + } + } +} + +impl display::Object for Model { + fn display_object(&self) -> &display::object::Instance { + &self.display_object + } +} + + + +// =================== +// === Play Button === +// =================== + +impl component::Model for Model { + fn label() -> &'static str { + "PlayButton" + } + + fn new(_app: &Application) -> Self { + let display_object = display::object::Instance::new(); + let play_icon = play_icon::View::new(); + let spinner_icon = spinner_icon::View::new(); + + display_object.add_child(&play_icon); + + Self { display_object, play_icon, spinner_icon } + } +} + +impl component::Frp for Frp { + fn init( + network: &enso_frp::Network, + frp: &::Private, + _app: &Application, + model: &Model, + style_watch: &StyleWatchFrp, + ) { + let play_icon = &model.play_icon; + let input = &frp.input; + let output = &frp.output; + + let style = Style::from_theme(network, style_watch); + + frp::extend! { network + eval style.update ((style) model.update_style(style)); + + eval_ input.reset (model.set_playing(false)); + + let play_icon_mouse_down = play_icon.on_event::(); + output.pressed <+ play_icon_mouse_down.constant(()); + + eval_ output.pressed (model.set_playing(true)); + } + style.init.emit(()); + } +} + +/// A button to execute the workflow in a fully enabled way within the current execution +/// environment. The button should be visible in any execution environment where one or more +/// contexts are disabled. +pub type PlayButton = component::ComponentView; diff --git a/app/gui/view/graph-editor/Cargo.toml b/app/gui/view/graph-editor/Cargo.toml index 3e13bca0b2cc..5566d5961139 100644 --- a/app/gui/view/graph-editor/Cargo.toml +++ b/app/gui/view/graph-editor/Cargo.toml @@ -24,7 +24,7 @@ ensogl-drop-manager = { path = "../../../../lib/rust/ensogl/component/drop-manag ensogl-hardcoded-theme = { path = "../../../../lib/rust/ensogl/app/theme/hardcoded" } ensogl-text-msdf = { path = "../../../../lib/rust/ensogl/component/text/src/font/msdf" } failure = { workspace = true } -ide-view-execution-mode-selector = { path = "../execution-mode-selector" } +ide-view-execution-environment-selector = { path = "../execution-environment-selector" } indexmap = "1.9.2" js-sys = { workspace = true } nalgebra = { workspace = true } 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 f12f628b4c61..49d5fa889792 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 @@ -32,6 +32,10 @@ class TableVisualization extends Visualization { } onDataReceived(data) { + function addRowIndex(data) { + return data.map((row, i) => ({ ['#']: i, ...row })) + } + function hasExactlyKeys(keys, obj) { return Object.keys(obj).length === keys.length && keys.every(k => obj.hasOwnProperty(k)) } @@ -65,44 +69,8 @@ class TableVisualization extends Visualization { if (content instanceof Object) { const type = content.type - if (type === 'BigInt') { - return BigInt(content.value) - } else if (content['_display_text_']) { + if (content['_display_text_']) { return content['_display_text_'] - } else if (type === 'Date') { - return new Date(content.year, content.month - 1, content.day) - .toISOString() - .substring(0, 10) - } else if (type === 'Time_Of_Day') { - const js_date = new Date( - 0, - 0, - 1, - content.hour, - content.minute, - content.second, - content.nanosecond / 1000000 - ) - return ( - js_date.toTimeString().substring(0, 8) + - (js_date.getMilliseconds() === 0 ? '' : '.' + js_date.getMilliseconds()) - ) - } else if (type === 'Date_Time') { - const js_date = new Date( - content.year, - content.month - 1, - content.day, - content.hour, - content.minute, - content.second, - content.nanosecond / 1000000 - ) - return ( - js_date.toISOString().substring(0, 10) + - ' ' + - js_date.toTimeString().substring(0, 8) + - (js_date.getMilliseconds() === 0 ? '' : '.' + js_date.getMilliseconds()) - ) } else { return `{ ${type} Object }` } @@ -136,7 +104,7 @@ class TableVisualization extends Visualization { sortable: true, filter: true, resizable: true, - minWidth: 50, + minWidth: 25, headerValueGetter: params => params.colDef.field, }, onColumnResized: e => this.lockColumnSize(e), @@ -158,22 +126,49 @@ class TableVisualization extends Visualization { }, ]) this.agGridOptions.api.setRowData([{ Error: parsedData.error }]) - } else if (parsedData.json != null && isMatrix(parsedData.json)) { - columnDefs = parsedData.json[0].map((_, i) => ({ field: i.toString() })) - rowData = parsedData.json + } else if (parsedData.type === 'Matrix') { + let defs = [{ field: '#' }] + for (let i = 0; i < parsedData.column_count; i++) { + defs.push({ field: i.toString() }) + } + columnDefs = defs + rowData = addRowIndex(parsedData.json) + dataTruncated = parsedData.all_rows_count !== parsedData.json.length + } else if (parsedData.type === 'Object_Matrix') { + let defs = [{ field: '#' }] + let keys = {} + parsedData.json.forEach(val => { + if (val) { + Object.keys(val).forEach(k => { + if (!keys[k]) { + keys[k] = true + defs.push({ field: k }) + } + }) + } + }) + columnDefs = defs + rowData = addRowIndex(parsedData.json) dataTruncated = parsedData.all_rows_count !== parsedData.json.length - } else if (parsedData.json != null && isObjectMatrix(parsedData.json)) { - let firstKeys = Object.keys(parsedData.json[0]) + } else if (isMatrix(parsedData.json)) { + // Kept to allow visualization from older versions of the backend. + columnDefs = [ + { field: '#' }, + ...parsedData.json[0].map((_, i) => ({ field: i.toString() })), + ] + rowData = addRowIndex(parsedData.json) + dataTruncated = parsedData.all_rows_count !== parsedData.json.length + } else if (isObjectMatrix(parsedData.json)) { + // Kept to allow visualization from older versions of the backend. + let firstKeys = [{ field: '#' }, ...Object.keys(parsedData.json[0])] columnDefs = firstKeys.map(field => ({ field })) - rowData = parsedData.json.map(obj => - firstKeys.reduce((acc, key) => ({ ...acc, [key]: toRender(obj[key]) }), {}) - ) + rowData = addRowIndex(parsedData.json) dataTruncated = parsedData.all_rows_count !== parsedData.json.length - } else if (parsedData.json != null && Array.isArray(parsedData.json)) { + } else if (Array.isArray(parsedData.json)) { columnDefs = [{ field: '#' }, { field: 'Value' }] rowData = parsedData.json.map((row, i) => ({ ['#']: i, Value: toRender(row) })) dataTruncated = parsedData.all_rows_count !== parsedData.json.length - } else if (parsedData.json != null) { + } else if (parsedData.json !== undefined) { columnDefs = [{ field: 'Value' }] rowData = [{ Value: toRender(parsedData.json) }] } else { diff --git a/app/gui/view/graph-editor/src/component/breadcrumbs.rs b/app/gui/view/graph-editor/src/component/breadcrumbs.rs index 83f3aac288af..25b23721fb4d 100644 --- a/app/gui/view/graph-editor/src/component/breadcrumbs.rs +++ b/app/gui/view/graph-editor/src/component/breadcrumbs.rs @@ -417,7 +417,7 @@ impl display::Object for BreadcrumbsModel { #[derive(Debug, Clone, CloneRef)] #[allow(missing_docs)] pub struct Breadcrumbs { - model: Rc, + model: BreadcrumbsModel, frp: Frp, } @@ -426,7 +426,7 @@ impl Breadcrumbs { pub fn new(app: Application) -> Self { let scene = app.display.default_scene.clone_ref(); let frp = Frp::new(); - let model = Rc::new(BreadcrumbsModel::new(app, &frp)); + let model = BreadcrumbsModel::new(app, &frp); let network = &frp.network; // === Breadcrumb selection === @@ -435,9 +435,8 @@ impl Breadcrumbs { // === Selecting === - _breadcrumb_selection <- frp.select_breadcrumb.map(f!([model,frp](index) - frp.source.breadcrumb_select.emit(model.select_breadcrumb(*index)); - )); + frp.source.breadcrumb_select <+ + frp.select_breadcrumb.map(f!((index) model.select_breadcrumb(*index))); // === Stack Operations === @@ -489,14 +488,14 @@ impl Breadcrumbs { // === User Interaction === - eval_ model.project_name.frp.output.mouse_down(frp.select_breadcrumb.emit(0)); - eval_ frp.cancel_project_name_editing(model.project_name.frp.cancel_editing.emit(())); - eval_ frp.outside_press(model.project_name.frp.outside_press.emit(())); + frp.select_breadcrumb <+ model.project_name.frp.output.mouse_down.constant(0); + model.project_name.frp.cancel_editing <+ frp.cancel_project_name_editing; + model.project_name.frp.outside_press <+ frp.outside_press; popped_count <= frp.output.breadcrumb_select.map(|selected| (0..selected.0).collect_vec()); local_calls <= frp.output.breadcrumb_select.map(|selected| selected.1.clone()); - eval_ popped_count(frp.source.breadcrumb_pop.emit(())); - eval local_calls((local_call) frp.source.breadcrumb_push.emit(local_call)); + frp.source.breadcrumb_pop <+ popped_count.constant(()); + frp.source.breadcrumb_push <+ local_calls; // === Select === @@ -511,8 +510,8 @@ impl Breadcrumbs { popped_count <= selected.map(|selected| (0..selected.0).collect_vec()); local_calls <= selected.map(|selected| selected.1.clone()); - eval_ popped_count(frp.input.debug_pop_breadcrumb.emit(())); - eval local_calls((local_call) frp.input.debug_push_breadcrumb.emit(local_call)); + frp.input.debug_pop_breadcrumb <+ popped_count.constant(()); + frp.input.debug_push_breadcrumb <+ local_calls; // === Relayout === diff --git a/app/gui/view/graph-editor/src/component/node.rs b/app/gui/view/graph-editor/src/component/node.rs index a1e096ff3ebb..ee555fd05b96 100644 --- a/app/gui/view/graph-editor/src/component/node.rs +++ b/app/gui/view/graph-editor/src/component/node.rs @@ -11,11 +11,11 @@ use crate::component::visualization; use crate::selection::BoundingBox; use crate::tooltip; use crate::view; -use crate::ExecutionEnvironment; +use crate::CallWidgetsConfig; use crate::Type; -use crate::WidgetUpdates; use super::edge; +use engine_protocol::language_server::ExecutionEnvironment; use enso_frp as frp; use enso_frp; use ensogl::animation::delayed::DelayedAnimation; @@ -109,6 +109,7 @@ pub mod background { use super::*; ensogl::shape! { + pointer_events = false; alignment = center; (style:Style, bg_color:Vector4) { let bg_color = Var::::from(bg_color); @@ -302,7 +303,7 @@ ensogl::define_endpoints_2! { disable_visualization (), set_visualization (Option), set_disabled (bool), - set_input_connected (span_tree::Crumbs,Option,bool), + set_input_connected (span_tree::Crumbs,Option), set_expression (Expression), edit_expression (text::Range, ImString), set_skip_macro (bool), @@ -315,8 +316,8 @@ ensogl::define_endpoints_2! { /// Set the expression USAGE type. This is not the definition type, which can be set with /// `set_expression` instead. In case the usage type is set to None, ports still may be /// colored if the definition type was present. - set_expression_usage_type (Crumbs,Option), - update_widgets (WidgetUpdates), + set_expression_usage_type (ast::Id, Option), + update_widgets (CallWidgetsConfig), set_output_expression_visibility (bool), set_vcs_status (Option), /// Show visualization preview until either editing of the node is finished or the @@ -332,6 +333,8 @@ ensogl::define_endpoints_2! { /// Indicate whether on hover the quick action icons should appear. show_quick_action_bar_on_hover (bool), set_execution_environment (ExecutionEnvironment), + + /// Set read-only mode for input ports. set_read_only (bool), } Output { @@ -499,9 +502,6 @@ impl NodeModel { background -> drag_area; drag_area -> edge::front::corner; drag_area -> edge::front::line; - edge::front::corner -> input::port::hover; - edge::front::line -> input::port::hover; - input::port::hover -> input::port::viz; } } @@ -564,13 +564,6 @@ impl NodeModel { .init() } - #[profile(Debug)] - #[allow(missing_docs)] // FIXME[everyone] All pub functions should have docs. - pub fn get_crumbs_by_id(&self, id: ast::Id) -> Option { - let input_crumbs = self.input.get_crumbs_by_id(id).map(Crumbs::input); - input_crumbs.or_else(|| self.output.get_crumbs_by_id(id).map(Crumbs::output)) - } - #[profile(Debug)] fn init(self) -> Self { self.set_expression(Expression::new_plain("empty")); @@ -637,11 +630,9 @@ impl NodeModel { } #[profile(Debug)] - fn set_expression_usage_type(&self, crumbs: &Crumbs, tp: &Option) { - match crumbs.endpoint { - Endpoint::Input => self.input.set_expression_usage_type(&crumbs.crumbs, tp), - Endpoint::Output => self.output.set_expression_usage_type(&crumbs.crumbs, tp), - } + fn set_expression_usage_type(&self, id: ast::Id, tp: &Option) { + self.input.set_expression_usage_type(id, tp); + self.output.set_expression_usage_type(id, tp); } #[profile(Debug)] @@ -726,6 +717,7 @@ impl Node { let action_bar = &model.action_bar.frp; frp::extend! { network + init <- source::<()>(); // Hook up the display object position updates to the node's FRP. Required to calculate // the bounding box. @@ -763,7 +755,7 @@ impl Node { filtered_usage_type <- input.set_expression_usage_type.filter( move |(_,tp)| *tp != unresolved_symbol_type ); - eval filtered_usage_type (((a,b)) model.set_expression_usage_type(a,b)); + eval filtered_usage_type (((a,b)) model.set_expression_usage_type(*a,b)); eval input.set_expression ((a) model.set_expression(a)); model.input.edit_expression <+ input.edit_expression; out.on_expression_modified <+ model.input.frp.on_port_code_update; @@ -801,7 +793,8 @@ impl Node { // === Size === - new_size <- model.input.frp.width.map(f!((w) model.set_width(*w))); + input_width <- all(&model.input.frp.width, &init)._0(); + new_size <- input_width.map(f!((w) model.set_width(*w))); model.output.frp.set_size <+ new_size; @@ -882,9 +875,9 @@ impl Node { hover_onset_delay.set_delay <+ preview_show_delay; hide_tooltip <- preview_show_delay.map(|&delay| delay <= EPSILON); - outout_hover <- model.output.on_port_hover.map(|s| s.is_on()); - hover_onset_delay.start <+ outout_hover.on_true(); - hover_onset_delay.reset <+ outout_hover.on_false(); + output_hover <- model.output.on_port_hover.map(|s| s.is_on()); + hover_onset_delay.start <+ output_hover.on_true(); + hover_onset_delay.reset <+ output_hover.on_false(); hover_onset_active <- bool(&hover_onset_delay.on_reset, &hover_onset_delay.on_end); hover_preview_visible <- has_expression && hover_onset_active; hover_preview_visible <- hover_preview_visible.on_change(); @@ -915,7 +908,6 @@ impl Node { eval visualization_visible_on_change ((is_visible) model.visualization.frp.set_visibility(is_visible) ); - init <- source::<()>(); out.visualization_path <+ model.visualization.frp.visualisation.all_with(&init,|def_opt,_| { def_opt.as_ref().map(|def| def.signature.path.clone_ref()) }); @@ -1116,13 +1108,9 @@ pub mod test_utils { /// 3. If the output port is [`MultiPortView`]. fn output_port_shape(&self) -> Option; - /// Return the `Shape` of the first input port of the node. - /// - /// Returns `None`: - /// 1. If there are no input ports. - /// 2. If the port does not have a `Shape`. Some port models does not initialize the - /// `Shape`, see [`input::port::Model::init_shape`]. - fn input_port_shape(&self) -> Option; + /// Return the `Shape` of the first input port of the node. Returns `None` if there are no + /// input ports. + fn input_port_hover_shape(&self) -> Option; } impl NodeModelExt for NodeModel { @@ -1137,10 +1125,9 @@ pub mod test_utils { } } - fn input_port_shape(&self) -> Option { - let ports = self.input.model.ports(); - let port = ports.first()?; - port.shape.as_ref().map(CloneRef::clone_ref) + fn input_port_hover_shape(&self) -> Option { + let shapes = self.input.model.port_hover_shapes(); + shapes.into_iter().next() } } } 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 b0ac9343b380..30662563950a 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 @@ -3,8 +3,7 @@ use crate::prelude::*; use ensogl::display::shape::*; -use crate::ExecutionEnvironment; - +use engine_protocol::language_server::ExecutionEnvironment; use enso_config::ARGS; use enso_frp as frp; use ensogl::application::tooltip; diff --git a/app/gui/view/graph-editor/src/component/node/growth_animation.rs b/app/gui/view/graph-editor/src/component/node/growth_animation.rs index add06cf2d00c..dc09d8c99af0 100644 --- a/app/gui/view/graph-editor/src/component/node/growth_animation.rs +++ b/app/gui/view/graph-editor/src/component/node/growth_animation.rs @@ -8,7 +8,7 @@ use ensogl::prelude::*; use crate::application::command::FrpNetworkProvider; -use crate::GraphEditorModelWithNetwork; +use crate::GraphEditorModel; use crate::NodeId; use enso_frp as frp; @@ -33,11 +33,7 @@ const ANIMATION_LENGTH_COEFFIENT: f32 = 15.0; /// Initialize edited node growth/shrink animator. It would handle scene layer change for the edited /// node as well. -pub fn initialize_edited_node_animator( - model: &GraphEditorModelWithNetwork, - frp: &crate::Frp, - scene: &Scene, -) { +pub fn initialize_edited_node_animator(model: &GraphEditorModel, frp: &crate::Frp, scene: &Scene) { let network = &frp.network(); let out = &frp.output; let searcher_cam = scene.layers.node_searcher.camera(); @@ -112,7 +108,7 @@ pub fn initialize_edited_node_animator( // === Helpers === -impl GraphEditorModelWithNetwork { +impl GraphEditorModel { /// Move node to the `edited_node` scene layer, so that it is rendered by the separate camera. #[profile(Debug)] fn move_node_to_edited_node_layer(&self, node_id: NodeId) { 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 57116779425f..986f4387b613 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 @@ -2,18 +2,17 @@ use crate::prelude::*; use enso_text::index::*; -use enso_text::unit::*; use ensogl::display::shape::*; use ensogl::display::traits::*; use crate::component::type_coloring; use crate::node; -use crate::node::input::port; use crate::node::input::widget; +use crate::node::input::widget::OverrideKey; use crate::node::profiling; use crate::view; +use crate::CallWidgetsConfig; use crate::Type; -use crate::WidgetUpdates; use enso_frp as frp; use enso_frp; @@ -36,21 +35,8 @@ use ensogl_hardcoded_theme as theme; /// An offset from the port area position to the text position. pub const TEXT_OFFSET: f32 = 10.0; -/// Width of a single glyph -// TODO: avoid using hardcoded value. See https://www.pivotaltracker.com/story/show/183567623. -pub const GLYPH_WIDTH: f32 = 7.224_609_4; - -/// Enable visual port debug mode and additional port creation logging. -pub const DEBUG: bool = false; - -/// Visual port offset for debugging purposes. Applied hierarchically. Applied only when `DEBUG` is -/// set to `true`. -pub const DEBUG_PORT_OFFSET: f32 = 5.0; - -/// Skip creating ports on all operations. For example, in expression `foo bar`, `foo` is considered -/// an operation. -const SKIP_OPERATIONS: bool = true; -const PORT_PADDING_X: f32 = 4.0; +/// Total height of the node input area. +pub const NODE_HEIGHT: f32 = 18.0; /// Text size used for input area text. pub const TEXT_SIZE: f32 = 12.0; @@ -63,12 +49,7 @@ pub const TEXT_SIZE: f32 = 12.0; pub use span_tree::Crumb; pub use span_tree::Crumbs; - -/// Specialized `SpanTree` for the input ports model. -pub type SpanTree = span_tree::SpanTree; - -/// Mutable reference to port inside of a `SpanTree`. -pub type PortRefMut<'a> = span_tree::node::RefMut<'a, port::Model>; +pub use span_tree::SpanTree; @@ -76,13 +57,10 @@ pub type PortRefMut<'a> = span_tree::node::RefMut<'a, port::Model>; // === Expression === // ================== -/// Specialized version of `node::Expression`, containing the port information. +/// Specialized version of `node::Expression`. #[derive(Clone, Default)] #[allow(missing_docs)] pub struct Expression { - /// Visual code representation. It can contain names of missing arguments, and thus can differ - /// from `code`. - pub viz_code: ImString, pub code: ImString, pub span_tree: SpanTree, } @@ -138,60 +116,10 @@ impl Expression { // === Conversions === -/// Helper struct used for `Expression` conversions. -#[derive(Debug, Default)] -struct ExprConversion { - prev_tok_local_index: Byte, - /// Index of the last traverse parent node in the `SpanTree`. - last_parent_tok_index: Byte, -} - -impl ExprConversion { - fn new(last_parent_tok_index: Byte) -> Self { - let prev_tok_local_index = default(); - Self { prev_tok_local_index, last_parent_tok_index } - } -} - impl From for Expression { - /// Traverses the `SpanTree` and constructs `viz_code` based on `code` and the `SpanTree` - /// structure. It also computes `port::Model` values in the `viz_code` representation. #[profile(Debug)] fn from(t: node::Expression) -> Self { - // The length difference between `code` and `viz_code` so far. - let mut shift = 0.byte(); - let mut span_tree: SpanTree = t.input_span_tree.map(|()| port::Model::default()); - let mut viz_code = String::new(); - let code = t.code; - span_tree.root_ref_mut().dfs_with_layer_data(ExprConversion::default(), |node, info| { - let is_expected_arg = node.is_expected_argument(); - let span = node.span(); - // TODO: remove unwrap. (https://www.pivotaltracker.com/story/show/183567590) - let mut size = Byte::try_from(span.size()).unwrap(); - let mut index = span.start; - let offset_from_prev_tok = node.offset - info.prev_tok_local_index.to_diff(); - info.prev_tok_local_index = size + node.offset; - viz_code += &" ".repeat(offset_from_prev_tok.as_usize()); - if node.children.is_empty() { - viz_code += &code.as_str()[enso_text::Range::new(index, index + size)]; - } - index += shift; - if is_expected_arg { - if let Some(name) = node.name() { - size = name.len().into(); - index += 1.byte(); - shift += 1.byte() + size; - viz_code += " "; - viz_code += name; - } - } - let port = node.payload_mut(); - port.local_index = index - info.last_parent_tok_index; - port.index = index.into(); - port.length = size.into(); - ExprConversion::new(index) - }); - Self { viz_code: viz_code.into(), code, span_tree } + Self { code: t.code, span_tree: t.input_span_tree } } } @@ -206,19 +134,11 @@ impl From for Expression { pub struct Model { app: Application, display_object: display::object::Instance, - ports: display::object::Instance, - header: display::object::Instance, - /// Text label used for displaying the ports. Contains both expression text and inserted - /// argument placeholders. The style is adjusted based on port types. - ports_label: text::Text, - /// Text label used during edit mode. Contains only the expression text without any - /// modifications. Handles user input in edit mode. edit_mode_label: text::Text, expression: RefCell, - id_crumbs_map: RefCell>, - widgets_map: RefCell>, styles: StyleWatch, styles_frp: StyleWatchFrp, + widget_tree: widget::Tree, } #[derive(Debug, Clone, Hash, PartialEq, Eq)] @@ -231,82 +151,30 @@ impl Model { /// Constructor. #[profile(Debug)] pub fn new(app: &Application) -> Self { - let display_object = display::object::Instance::new(); - let ports = display::object::Instance::new(); - let header = display::object::Instance::new(); let app = app.clone_ref(); + let display_object = display::object::Instance::new(); + let edit_mode_label = app.new_view::(); - let ports_label = app.new_view::(); - let id_crumbs_map = default(); let expression = default(); let styles = StyleWatch::new(&app.display.default_scene.style_sheet); let styles_frp = StyleWatchFrp::new(&app.display.default_scene.style_sheet); - let widgets_map = default(); - display_object.add_child(&ports); - ports.add_child(&header); - Self { - app, - display_object, - ports, - header, - edit_mode_label, - ports_label, - expression, - id_crumbs_map, - widgets_map, - styles, - styles_frp, - } - .init() + let widget_tree = widget::Tree::new(&app); + Self { app, display_object, edit_mode_label, expression, styles, styles_frp, widget_tree } + .init() } /// React to edit mode change. Shows and hides appropriate child views according to current /// mode. Sets cursor position when entering edit mode. pub fn set_edit_mode(&self, edit_mode_active: bool) { if edit_mode_active { - // When transitioning to edit mode, we need to find the code location that corresponds - // to the code at mouse position. First we search for the port at that position, then - // find the right character index within that port. - let expression = self.expression.borrow(); - let clicked_label_location = self.ports_label.location_at_mouse_position(); - let clicked_char_index = - expression.viz_code.char_indices().nth(clicked_label_location.offset.into()); - let location_to_set = clicked_char_index.and_then(|char_index| { - let loc_offset = char_index.0.byte().to_diff(); - let clicked_port = expression.span_tree.root_ref().leaf_iter().find(|node| { - let range = node.payload.range(); - range.contains(&loc_offset) - })?; - - let byte_offset_within_port = loc_offset - clicked_port.payload.index; - let byte_offset_within_port = byte_offset_within_port.min(clicked_port.size); - let final_code_byte_offset = clicked_port.span_offset + byte_offset_within_port; - - let final_code_column: Column = - expression.code[..final_code_byte_offset.into()].chars().count().into(); - let final_code_location = clicked_label_location.with_offset(final_code_column); - Some(final_code_location) - }); - self.edit_mode_label.set_content(expression.code.clone()); - self.display_object.remove_child(&self.ports); - self.display_object.remove_child(&self.ports_label); + self.display_object.remove_child(&self.widget_tree); self.display_object.add_child(&self.edit_mode_label); - if let Some(location) = location_to_set { - self.edit_mode_label.set_cursor(location); - } else { - // If we were unable to find a port under current mouse position, set the edit label - // cursor at the mouse position immediately after setting its content to the raw - // expression code. - self.edit_mode_label.set_cursor_at_mouse_position(); - } + self.edit_mode_label.set_cursor_at_mouse_position(); } else { self.display_object.remove_child(&self.edit_mode_label); - self.display_object.add_child(&self.ports); - self.display_object.add_child(&self.ports_label); - // When we exit the edit mode, clear the label. That way we don't have any extra glyphs - // to process during rendering in non-edit mode. + self.display_object.add_child(&self.widget_tree); self.edit_mode_label.set_content(""); } self.edit_mode_label.deprecated_set_focus(edit_mode_active); @@ -321,8 +189,6 @@ impl Model { self.set_label_layer(&scene.layers.label); let text_color = self.styles.get_color(theme::graph_editor::node::text); - self.ports_label.set_property_default(text_color); - self.ports_label.set_property_default(text::Size(TEXT_SIZE)); self.edit_mode_label.set_single_line_mode(true); self.edit_mode_label.disable_command("cursor_move_up"); @@ -332,557 +198,102 @@ impl Model { self.edit_mode_label.set_property_default(text::Size(TEXT_SIZE)); self.edit_mode_label.remove_all_cursors(); - let ports_origin = Vector2(TEXT_OFFSET, 0.0); + let widgets_origin = Vector2(0.0, -NODE_HEIGHT / 2.0); let label_origin = Vector2(TEXT_OFFSET, TEXT_SIZE / 2.0); - self.ports.set_xy(ports_origin); - self.ports_label.set_xy(label_origin); + self.widget_tree.set_xy(widgets_origin); self.edit_mode_label.set_xy(label_origin); self.set_edit_mode(false); self } - /// Return a list of Node's input ports. - pub fn ports(&self) -> Vec { - let expression = self.expression.borrow(); - let mut ports = Vec::new(); - expression.span_tree.root_ref().dfs(|n| ports.push(n.payload.clone())); - ports - } - - fn set_label_layer(&self, layer: &display::scene::Layer) { self.edit_mode_label.add_to_scene_layer(layer); - self.ports_label.add_to_scene_layer(layer); } - /// Run the provided function on the target port if exists. - fn with_port_mut(&self, crumbs: &Crumbs, f: impl FnOnce(PortRefMut)) { - let mut expression = self.expression.borrow_mut(); - if let Ok(node) = expression.span_tree.root_ref_mut().get_descendant(crumbs) { - f(node) - } - } - - /// Traverse all `SpanTree` leaves of the given port and emit hover style to set their colors. - fn set_port_hover(&self, target: &Switch) { - self.with_port_mut(&target.value, |t| t.set_hover(target.is_on())) - } - - /// Update expression type for the particular `ast::Id`. - #[profile(Debug)] - fn set_expression_usage_type(&self, crumbs: &Crumbs, tp: &Option) { - if let Ok(port) = self.expression.borrow().span_tree.root_ref().get_descendant(crumbs) { - port.set_usage_type(tp) - } + fn set_connected(&self, crumbs: &Crumbs, status: Option) { + self.widget_tree.set_connected(crumbs, status); } - /// Apply widget updates to widgets in this input area. - fn apply_widget_updates(&self, updates: &WidgetUpdates) { - let expression = self.expression.borrow(); - let widgets_map = self.widgets_map.borrow(); - let WidgetUpdates { call_id, updates } = updates; - for update in updates.iter() { - let argument_name = update.argument_name.to_string(); - let widget_id = WidgetBind { call_id: *call_id, argument_name }; - let crumbs = widgets_map.get(&widget_id); - - let root = expression.span_tree.root_ref(); - let port = crumbs.and_then(|crumbs| root.get_descendant(crumbs).ok()); - let widget = port.and_then(|port| port.payload.widget.clone_ref()); - - // When a widget is found, update it. Failing to find a widget is not an error, as it - // might be a widget that was removed from the expression while the request was pending. - // If it comes back, the widget data will be requested again. - if let Some(widget) = widget { - widget.set_metadata(update.meta.clone()); - } - } + fn set_expression_usage_type(&self, id: ast::Id, usage_type: Option) { + self.widget_tree.set_usage_type(id, usage_type); } - #[profile(Debug)] - fn set_label_on_new_expression(&self, expression: &Expression) { - self.ports_label.set_content(expression.viz_code.clone()); + fn body_hover_pointer_style(&self, hovered: &bool) -> cursor::Style { + hovered.then(cursor::Style::cursor).unwrap_or_default() } - #[profile(Debug)] - fn build_port_shapes_on_new_expression( - &self, - expression: &mut Expression, - area_frp: &FrpEndpoints, - call_info: &CallInfoMap, - ) { - let mut is_header = true; - - let mut id_crumbs_map = HashMap::new(); - let mut widgets_map = HashMap::new(); - let builder = PortLayerBuilder::empty(&self.ports); - let code = &expression.viz_code; - - expression.span_tree.root_ref_mut().dfs_with_layer_data(builder, |mut node, builder| { - let skip_opr = if SKIP_OPERATIONS { - node.is_operation() && !is_header - } else { - let crumb = ast::Crumb::Infix(ast::crumbs::InfixCrumb::Operator); - node.ast_crumbs.last().map(|t| t == &crumb) == Some(true) - }; - - let not_a_port = node.is_positional_insertion_point() - || node.is_chained() - || (node.is_root() && !node.children.is_empty()) - || skip_opr - || node.is_token() - || node.is_named_argument() - || builder.parent_parensed; - - if let Some(id) = node.ast_id { - if DEBUG { - debug!("New id mapping: {id} -> {:?}", node.crumbs); - } - id_crumbs_map.insert(id, node.crumbs.clone_ref()); - } - - if DEBUG { - let indent = " ".repeat(4 * builder.depth); - let skipped = if not_a_port { "(skip)" } else { "" }; - debug!( - "{indent}[{},{}] {skipped} {:?} (tp: {:?}) (id: {:?})", - node.payload.index, - node.payload.length, - node.kind.variant_name(), - node.tp(), - node.ast_id - ); - } - - let range_before_start = node.payload.index - node.payload.local_index; - let range_before_end = node.payload.index; - let range_before = enso_text::Range::new(range_before_start, range_before_end); - let local_char_offset = code[range_before].chars().count(); - - let new_parent = if not_a_port { - builder.parent.clone_ref() - } else { - let crumbs = node.crumbs.clone_ref(); - let port = &mut node; - - let index = local_char_offset + builder.shift; - let size = code[port.payload.range()].chars().count(); - let unit = GLYPH_WIDTH; - let width = unit * size as f32; - let width_padded = width + 2.0 * PORT_PADDING_X; - let height = 18.0; - let size = Vector2(width, height); - let padded_size = Vector2(width_padded, height); - let position_x = unit * index as f32; - - let port_shape = port.payload.init_shape(size, node::HEIGHT); - - port_shape.set_x(position_x); - if DEBUG { - port_shape.set_y(DEBUG_PORT_OFFSET); - } - - if is_header { - is_header = false; - self.header.add_child(&port_shape); - } else { - builder.parent.add_child(&port_shape); - } - - // TODO: StyleWatch is unsuitable here, as it was designed as an internal tool for - // shape system. (https://www.pivotaltracker.com/story/show/183567648) - let style_sheet = &self.app.display.default_scene.style_sheet; - let styles = StyleWatch::new(style_sheet); - let styles_frp = &self.styles_frp; - let any_type_sel_color = styles_frp.get_color(theme::code::types::any::selection); - let port_network = &port.network; - - frp::extend! { port_network - - // === Aliases === - - let mouse_over_raw = port_shape.hover.events_deprecated.mouse_over.clone_ref(); - let mouse_out = port_shape.hover.events_deprecated.mouse_out.clone_ref(); - let mouse_down_raw = port_shape.hover.events_deprecated.mouse_down_primary.clone_ref(); - - - // === Body Hover === - - // This is meant to be on top of FRP network. Read more about `Node` docs to - // learn more about the architecture and the importance of the hover - // functionality. - - // Please note, that this is computed first in order to compute `ports_visible` - // when needed, and thus it has to be run before the following lines. - area_frp.source.body_hover <+ bool(&mouse_out,&mouse_over_raw); - - // TODO[WD] for FRP3: Consider the following code. Here, we have to first - // handle `bg_down` and then `mouse_down`. Otherwise, `mouse_down` may - // trigger some events and can change `ports_visible` status, and thus make - // the `bg_down` emitted unnecessarily. For example, after plugging in - // connections to selected port, the `ports_visible` will be set to `false`, - // and `bg_down` will be emitted, causing the node to be selected. This can - // be solved by solving in the FRP engine all children first, and then their - // children (then both `bg_down` and `mouse_down` will be resolved before - // the `ports_visible` changes). - bg_down <- mouse_down_raw.gate_not(&area_frp.ports_visible); - mouse_down <- mouse_down_raw.gate(&area_frp.ports_visible); - mouse_over <- mouse_over_raw.gate(&area_frp.ports_visible); - area_frp.source.on_background_press <+ bg_down; - - - // === Press === - - area_frp.source.on_port_press <+ mouse_down.map(f_!([crumbs] crumbs.clone_ref())); - - // === Hover === - - hovered <- bool(&mouse_out,&mouse_over); - hover <- hovered.map (f!([crumbs](t) Switch::new(crumbs.clone_ref(),*t))); - area_frp.source.on_port_hover <+ hover; - - - // === Pointer Style === - - let port_shape_hover = port_shape.hover.clone_ref(); - pointer_style_out <- mouse_out.map(|_| default()); - - init_color <- source::<()>(); - any_type_sel_color <- all_with(&any_type_sel_color,&init_color, - |c,_| color::Lcha::from(c)); - tp <- all_with(&port.tp,&area_frp.set_ports_active, - |tp,(_,edge_tp)| tp.clone().or_else(||edge_tp.clone())); - tp_color <- tp.map( - f!([styles](tp) tp.map_ref(|tp| type_coloring::compute(tp,&styles)))); - tp_color <- all_with(&tp_color,&any_type_sel_color, - |tp_color,any_type_sel_color| tp_color.unwrap_or(*any_type_sel_color)); - in_profiling_mode <- area_frp.view_mode.map(|m| matches!(m,view::Mode::Profiling)); - pointer_color_over <- in_profiling_mode.switch(&tp_color,&any_type_sel_color); - pointer_style_over <- pointer_color_over.map(move |color| - cursor::Style::new_highlight(&port_shape_hover,padded_size,Some(color)) - ); - pointer_style_over <- pointer_style_over.sample(&mouse_over); - - pointer_style_hover <- any(pointer_style_over,pointer_style_out); - pointer_styles <- all[ - pointer_style_hover, - self.ports_label.pointer_style, - self.edit_mode_label.pointer_style - ]; - pointer_style <- pointer_styles.fold(); - area_frp.source.pointer_style <+ pointer_style; - } - - let port_range = port.span(); - let port_code = &expression.code[port_range]; - if let Some((widget_bind, widget)) = self.init_port_widget(port, size, call_info) { - widgets_map.insert(widget_bind, crumbs.clone_ref()); - widget.set_x(position_x); - builder.parent.add_child(&widget); - if port.is_argument() { - debug!("Setting current value while range is {port_range:?}, code is \"{port_code}\" \ - and full expression is \"{}\".", expression.code); - widget.set_current_value(Some(port_code.into())); - } else { - widget.set_current_value(None); - } - widget.set_visible(true); - - let port_network = &port.network; - frp::extend! { port_network - code_update <- widget.value_changed.map(f!([crumbs](value) { - let expression = value.clone().unwrap_or_default(); - (crumbs.clone_ref(), expression) - })); - area_frp.source.on_port_code_update <+ code_update; - area_frp.source.request_import <+ widget.request_import; - widget.set_read_only <+ area_frp.set_read_only; - } - } - - init_color.emit(()); - - port_shape.display_object().clone_ref() - }; - - if let Some(parent_frp) = &builder.parent_frp { - frp::extend! { port_network - node.frp.set_active <+ parent_frp.set_active; - node.frp.set_hover <+ parent_frp.set_hover; - node.frp.set_parent_connected <+ parent_frp.set_parent_connected; - } - } - let new_parent_frp = Some(node.frp.output.clone_ref()); - let new_shift = if !not_a_port { 0 } else { builder.shift + local_char_offset }; - let parenthesized = node.parenthesized(); - builder.nested(new_parent, new_parent_frp, parenthesized, new_shift) - }); - *self.id_crumbs_map.borrow_mut() = id_crumbs_map; - *self.widgets_map.borrow_mut() = widgets_map; - area_frp.set_view_mode.emit(area_frp.view_mode.value()); + fn port_hover_pointer_style(&self, hovered: &Switch) -> Option { + let crumbs = hovered.on()?; + let expr = self.expression.borrow(); + let port = expr.span_tree.get_node(crumbs).ok()?; + let display_object = self.widget_tree.get_port_display_object(&port)?; + let tp = port.tp().map(|t| t.into()); + let color = tp.as_ref().map(|tp| type_coloring::compute(tp, &self.styles)); + let pad_x = node::input::port::PORT_PADDING_X * 2.0; + let min_y = node::input::port::BASE_PORT_HEIGHT; + let computed_size = display_object.computed_size(); + let size = Vector2(computed_size.x + pad_x, computed_size.y.max(min_y)); + let radius = size.y / 2.0; + Some(cursor::Style::new_highlight(display_object, size, radius, color)) } - fn init_port_widget( - &self, - port: &mut PortRefMut, - port_size: Vector2, - call_info: &CallInfoMap, - ) -> Option<(WidgetBind, widget::View)> { - let call_id = port.kind.call_id().filter(|id| call_info.has_target(id))?; - let argument_name = port.kind.argument_name()?.to_owned(); - - let widget_bind = WidgetBind { call_id, argument_name }; - - - // Try getting the previous widget by exact target/argument ID first, which is - // necessary when the argument expression was replaced. This lookup can fail - // when the target expression was replaced, but the widget argument expression - // wasn't. In that case, try to reuse the widget from old argument node under - // the same ast ID. - let prev_widgets_map = self.widgets_map.borrow(); - let prev_id_crumbs_map = self.id_crumbs_map.borrow(); - let prev_crumbs = prev_widgets_map - .get(&widget_bind) - .or_else(|| port.ast_id.as_ref().and_then(|id| prev_id_crumbs_map.get(id))); - let prev_widget = prev_crumbs.and_then(|crumbs| { - let prev_expression = self.expression.borrow(); - let prev_root = prev_expression.span_tree.root_ref(); - let prev_node = prev_root.get_descendant(crumbs).ok()?; - let prev_widget = prev_node.payload.widget.as_ref()?.clone_ref(); - Some(prev_widget) - }); - - let widget = match prev_widget { - Some(prev_widget) => port.payload.use_existing_widget(prev_widget), - None => port.payload.init_widget(&self.app), - }; - - let tag_values = port.kind.tag_values().unwrap_or_default().to_vec(); - let tp = port.kind.tp().cloned(); - widget.set_node_data(widget::NodeData { tag_values, port_size, tp }); - - Some((widget_bind, widget)) + /// Configure widgets associated with single Enso call expression, overriding default widgets + /// generated from span tree. The provided widget configuration is merged with configurations + /// already present in the widget tree. Setting a widget configuration to `None` will remove + /// an override, and a default widget will be used. + fn apply_widget_configuration(&self, config: &CallWidgetsConfig) { + let CallWidgetsConfig { call_id, definitions } = config; + for definition in definitions.iter() { + let argument_name = definition.argument_name.clone().into(); + let override_key = OverrideKey { call_id: *call_id, argument_name }; + self.widget_tree.set_config_override(override_key, definition.config.clone()); + } } - /// Initializes FRP network for every port. Please note that the networks are connected - /// hierarchically (children get events from parents), so it is easier to init all networks - /// this way, rather than delegate it to every port. - #[profile(Debug)] - fn init_port_frp_on_new_expression( - &self, - expression: &mut Expression, - area_frp: &FrpEndpoints, - ) { - let model = &self; - - let parent_tp: Option>> = None; - expression.root_ref_mut().dfs_with_layer_data(parent_tp, |node, parent_tp| { - let frp = &node.frp; - let port_network = &frp.network; - let is_token = node.is_token(); - let crumbs = node.crumbs.clone(); - - - // === Type Computation === - let parent_tp = parent_tp.clone().unwrap_or_else(|| { - frp::extend! { port_network - empty_parent_tp <- source::>(); - } - empty_parent_tp.into() - }); - frp::extend! { port_network - final_tp <- all_with3(&parent_tp,&frp.set_definition_type,&frp.set_usage_type, - move |parent_tp,def_tp,usage_tp| { - usage_tp.clone().or_else(|| - if is_token {parent_tp.clone()} else {def_tp.clone()} - ) - } - ); - frp.source.tp <+ final_tp; - - area_frp.source.on_port_type_change <+ frp.tp.map(move |t|(crumbs.clone(),t.clone())); - } - - - // === Code Coloring === - - let styles = model.styles.clone_ref(); - let styles_frp = model.styles_frp.clone_ref(); - - if node.children.is_empty() { - let is_expected_arg = node.is_expected_argument(); - - use theme::code::syntax; - let selected_color = styles_frp.get_color(theme::code::types::selected); - let std_base_color = styles_frp.get_color(syntax::base); - let std_disabled_color = styles_frp.get_color(syntax::disabled); - let std_expected_color = styles_frp.get_color(syntax::expected); - let std_editing_color = styles_frp.get_color(syntax::base); - let profiled_base_color = styles_frp.get_color(syntax::profiling::base); - let profiled_disabled_color = styles_frp.get_color(syntax::profiling::disabled); - let profiled_expected_color = styles_frp.get_color(syntax::profiling::expected); - let profiled_editing_color = styles_frp.get_color(syntax::profiling::base); - - frp::extend! { port_network - in_profiling_mode <- area_frp.view_mode.map(|m| m.is_profiling()); - finished <- area_frp.set_profiling_status.map(|s| s.is_finished()); - profiled <- in_profiling_mode && finished; - selected <- frp.set_hover || frp.set_parent_connected; - - init_colors <- source::<()>(); - std_base_color <- all(std_base_color,init_colors)._0(); - profiled_base_color <- all(profiled_base_color,init_colors)._0(); - - profiling_color <- finished.switch(&std_base_color,&profiled_base_color); - normal_color <- frp.tp.map(f!([styles](t) - color::Rgba::from(type_coloring::compute_for_code(t.as_ref(),&styles)))); - base_color <- in_profiling_mode.switch(&normal_color,&profiling_color); - - disabled_color <- profiled.switch(&std_disabled_color,&profiled_disabled_color); - expected_color <- profiled.switch(&std_expected_color,&profiled_expected_color); - editing_color <- profiled.switch(&std_editing_color,&profiled_editing_color); - // TODO: `label_color` should be animated, when when we can set text colors - // more efficiently. (See https://www.pivotaltracker.com/story/show/183567665) - label_color <- all_with8( - &area_frp.editing, - &selected, - &area_frp.set_disabled, - &editing_color, - &selected_color, - &disabled_color, - &expected_color, - &base_color, - move |&editing, - &selected, - &disabled, - &editing_color, - &selected_color, - &disabled_color, - &expected_color, - &base_color| { - if editing { - color::Lcha::from(editing_color) - } else if selected { - color::Lcha::from(selected_color) - } else if disabled { - color::Lcha::from(disabled_color) - } else if is_expected_arg { - color::Lcha::from(expected_color) - } else { - color::Lcha::from(base_color) - } - }, - ); - } - - let index = node.payload.index; - let length = node.payload.length; - let label = model.ports_label.clone_ref(); - frp::extend! { port_network - eval label_color ([label](color) { - let range = enso_text::Range::new(index, index + length); - // TODO: remove unwrap. (https://www.pivotaltracker.com/story/show/183567590) - let range = enso_text::Range::::try_from(range).unwrap(); - label.set_property(range,color::Rgba::from(color)); - }); - } - - init_colors.emit(()); - area_frp.set_view_mode(area_frp.view_mode.value()); - } - - - // === Highlight Coloring === - - if let Some(port_shape) = &node.payload.shape { - let viz_color = color::Animation::new(port_network); - let any_type_sel_color = styles_frp.get_color(theme::code::types::any::selection); - - frp::extend! { port_network - normal_viz_color <- all_with(&frp.tp,&frp.set_connected, - f!([styles](port_tp,(_,edge_tp)) { - let tp = port_tp.as_ref().or(edge_tp.as_ref()); - select_color(&styles,tp) - })); - init_color <- source::<()>(); - profiling_viz_color <- all_with(&any_type_sel_color,&init_color, - |c,_| color::Lcha::from(c)); - profiling <- area_frp.view_mode.map(|m| m.is_profiling()); - connected_viz_color <- profiling.switch(&normal_viz_color,&profiling_viz_color); - is_connected <- frp.set_connected.map(|(is_connected,_)| *is_connected); - transparent <- init_color.constant(color::Lcha::transparent()); - viz_color_target <- is_connected.switch(&transparent,&connected_viz_color); - - // We need to make sure that the network contains correct values before we - // connect the `viz_color` animation. The reason is that the animation will - // start from the first value that it receives, and during initialization of the - // network, while some nodes are still set to their defaults, this first value - // would be incorrect, causing the animation in some cases to start from black - // (the default color) and animating towards the color that we really want to - // set. - init_color.emit(()); - - viz_color.target <+ viz_color_target; - eval viz_color.value ((t) - port_shape.viz.color.set(color::Rgba::from(t).into()) - ); - } - } - Some(frp.tp.clone_ref().into()) - }); - - area_frp.set_view_mode(area_frp.view_mode.value()); + /// If the widget tree was marked as dirty since its last update, rebuild it. + fn rebuild_widget_tree_if_dirty(&self) { + let expr = self.expression.borrow(); + self.widget_tree.rebuild_tree_if_dirty(&expr.span_tree, &expr.code, &self.styles); } - /// This function first assigns the new expression to the model and then emits the definition - /// type signals to all port FRP networks. + /// Scan node expressions for all known method calls, for which the language server can provide + /// widget configuration overrides. Emit a request for each such detected call, allowing the + /// controller to request the overrides and provide them. /// - /// As a design note, it is important to first assign the expression to the model, as the FRP - /// signals can cause other parts of the network to fire, which may query the expression types. - /// For example, firing the `port::set_definition_type` will fire `on_port_type_change`, which - /// may require some edges to re-color, which consequently will require to checking the current - /// expression types. + /// See also: [`controller::graph::widget`] module of `enso-gui` crate. #[profile(Debug)] - fn init_new_expression( - &self, - expression: Expression, - area_frp: &FrpEndpoints, - call_info: &CallInfoMap, - ) { - *self.expression.borrow_mut() = expression; - let expression = self.expression.borrow(); - expression.root_ref().dfs_with_layer_data((), |node, _| { - node.frp.set_definition_type(node.tp().cloned().map(|t| t.into())); - let call_id = node.kind.call_id(); - let widget_request = - call_id.and_then(|call_id| Some((call_id, call_info.target(&call_id)?))); - if let Some(widget_request) = widget_request { - area_frp.source.requested_widgets.emit(widget_request); + fn request_widget_config_overrides(&self, expression: &Expression, area_frp: &FrpEndpoints) { + let call_info = CallInfoMap::scan_expression(&expression.span_tree); + for (call_id, info) in call_info.iter() { + if let Some(target_id) = info.target_id { + area_frp.source.requested_widgets.emit((*call_id, target_id)); } - }); + } } /// Set a displayed expression, updating the input ports. `is_editing` indicates whether the /// expression is being edited by the user. #[profile(Debug)] fn set_expression(&self, new_expression: impl Into, area_frp: &FrpEndpoints) { - let mut new_expression = Expression::from(new_expression.into()); - if DEBUG { - debug!("set expression: \n{:?}", new_expression.tree_pretty_printer()); - } + let new_expression = Expression::from(new_expression.into()); + debug!("Set expression: \n{:?}", new_expression.tree_pretty_printer()); + + self.widget_tree.rebuild_tree( + &new_expression.span_tree, + &new_expression.code, + &self.styles, + ); - let call_info = CallInfoMap::scan_expression(&new_expression); - self.set_label_on_new_expression(&new_expression); - self.build_port_shapes_on_new_expression(&mut new_expression, area_frp, &call_info); - self.init_port_frp_on_new_expression(&mut new_expression, area_frp); - self.init_new_expression(new_expression.clone(), area_frp, &call_info); + self.request_widget_config_overrides(&new_expression, area_frp); + *self.expression.borrow_mut() = new_expression; } -} -fn select_color(styles: &StyleWatch, tp: Option<&Type>) -> color::Lcha { - let opt_color = tp.as_ref().map(|tp| type_coloring::compute(tp, styles)); - opt_color.unwrap_or_else(|| styles.get_color(theme::code::types::any::selection).into()) + /// Get hover shapes for all input ports of a node. Mainly used in tests to manually dispatch + /// mouse events. + pub fn port_hover_shapes(&self) -> Vec { + self.widget_tree.port_hover_shapes() + } } @@ -917,17 +328,15 @@ ensogl::define_endpoints! { /// Disable the node (aka "skip mode"). set_disabled (bool), - /// Set the connection status of the port indicated by the breadcrumbs. The optional type - /// is the type of the edge that was connected or disconnected if the edge was typed. - set_connected (Crumbs,Option,bool), + /// Set read-only mode for input ports. + set_read_only (bool), - /// Set the expression USAGE type. This is not the definition type, which can be set with - /// `set_expression` instead. In case the usage type is set to None, ports still may be - /// colored if the definition type was present. - set_expression_usage_type (Crumbs,Option), + /// Set the connection status of the port indicated by the breadcrumbs. For connected ports, + /// contains the color of connected edge. + set_connected (Crumbs, Option), - /// Update widget metadata for widgets already present in this input area. - update_widgets (WidgetUpdates), + /// Update widget configuration for widgets already present in this input area. + update_widgets (CallWidgetsConfig), /// Enable / disable port hovering. The optional type indicates the type of the active edge /// if any. It is used to highlight ports if they are missing type information or if their @@ -937,8 +346,10 @@ ensogl::define_endpoints! { set_view_mode (view::Mode), set_profiling_status (profiling::Status), - /// Set read-only mode for input ports. - set_read_only (bool), + /// Set the expression USAGE type. This is not the definition type, which can be set with + /// `set_expression` instead. In case the usage type is set to None, ports still may be + /// colored if the definition type was present. + set_expression_usage_type (ast::Id,Option), } Output { @@ -951,15 +362,16 @@ ensogl::define_endpoints! { body_hover (bool), on_port_press (Crumbs), on_port_hover (Switch), - on_port_type_change (Crumbs,Option), on_port_code_update (Crumbs,ImString), on_background_press (), view_mode (view::Mode), - /// A set of widgets attached to a method requires metadata to be queried. The tuple - /// contains the ID of the call expression the widget is attached to, and the ID of that - /// call's target expression (`self` or first argument). + /// A set of widgets attached to a method requests their definitions to be queried from an + /// external source. The tuple contains the ID of the call expression the widget is attached + /// to, and the ID of that call's target expression (`self` or first argument). requested_widgets (ast::Id, ast::Id), request_import (ImString), + /// A connected port within the node has been moved. Some edges might need to be updated. + input_edges_need_refresh (), } } @@ -974,17 +386,17 @@ ensogl::define_endpoints! { /// ## Origin /// Please note that the origin of the node is on its left side, centered vertically. To learn more /// about this design decision, please read the docs for the [`node::Node`]. -#[derive(Clone, CloneRef, Debug)] +#[derive(Clone, Deref, CloneRef, Debug)] pub struct Area { #[allow(missing_docs)] + #[deref] pub frp: Frp, pub(crate) model: Rc, } -impl Deref for Area { - type Target = Frp; - fn deref(&self) -> &Self::Target { - &self.frp +impl display::Object for Area { + fn display_object(&self) -> &display::object::Instance { + &self.model.display_object } } @@ -1005,66 +417,56 @@ impl Area { // This is meant to be on top of FRP network. Read more about `Node` docs to // learn more about the architecture and the importance of the hover // functionality. - - frp.output.source.body_hover <+ frp.set_hover; + frp.output.source.on_port_hover <+ model.widget_tree.on_port_hover; + frp.output.source.on_port_press <+ model.widget_tree.on_port_press; + port_hover <- frp.on_port_hover.map(|t| t.is_on()); + frp.output.source.body_hover <+ frp.set_hover || port_hover; // === Cursor setup === eval set_editing((is_editing) model.set_edit_mode(*is_editing)); - // Prevent text selection from being created right after entering edit mode. Otherwise, - // a selection would be created between the current mouse position (the position at - // which we clicked) and initial cursor position within edit mode label (the code - // position corresponding to clicked port). - start_editing <- set_editing.on_true(); - stop_editing <- set_editing.on_false(); - start_editing_delayed <- start_editing.debounce(); - reenable_selection_update <- any(&start_editing_delayed, &stop_editing); - selection_update_enabled <- bool(&start_editing, &reenable_selection_update); - eval selection_update_enabled([model] (enabled) { - let cmd_start = "start_newest_selection_end_follow_mouse"; - let cmd_stop = "stop_newest_selection_end_follow_mouse"; - model.edit_mode_label.set_command_enabled(cmd_start, *enabled); - model.edit_mode_label.set_command_enabled(cmd_stop, *enabled); - }); - // === Show / Hide Phantom Ports === let ports_active = &frp.set_ports_active; - edit_or_ready <- frp.set_edit_ready_mode || set_editing; + edit_or_ready <- frp.set_edit_ready_mode || set_editing; reacts_to_hover <- all_with(&edit_or_ready, ports_active, |e, (a, _)| *e && !a); - port_vis <- all_with(&edit_or_ready, ports_active, |e, (a, _)| !e && *a); + port_vis <- all_with(&edit_or_ready, ports_active, |e, (a, _)| !e && *a); frp.output.source.ports_visible <+ port_vis; - frp.output.source.editing <+ set_editing; - + frp.output.source.editing <+ set_editing; + model.widget_tree.set_ports_visible <+ frp.ports_visible; + refresh_edges <- model.widget_tree.connected_port_updated.debounce(); + frp.output.source.input_edges_need_refresh <+ refresh_edges; // === Label Hover === label_hovered <- reacts_to_hover && frp.output.body_hover; - not_editing <- set_editing.not(); - model.ports_label.set_hover <+ label_hovered && not_editing; model.edit_mode_label.set_hover <+ label_hovered && set_editing; + hovered_body_pointer <- label_hovered.map(f!((t) model.body_hover_pointer_style(t))); // === Port Hover === - eval frp.on_port_hover ((t) model.set_port_hover(t)); - - eval frp.set_connected ([model]((crumbs,edge_tp,is_connected)) { - model.with_port_mut(crumbs,|n|n.set_connected(is_connected,edge_tp)); - model.with_port_mut(crumbs,|n|n.set_parent_connected(is_connected)); - }); - + hovered_port_pointer <- model.widget_tree.on_port_hover.map( + f!((t) model.port_hover_pointer_style(t).unwrap_or_default()) + ); + pointer_style <- all[ + hovered_body_pointer, + model.widget_tree.pointer_style, + hovered_port_pointer + ].fold(); + frp.output.source.pointer_style <+ pointer_style; // === Properties === - - label_width <- set_editing.switch( - &model.ports_label.width, - &model.edit_mode_label.width + let widget_tree_object = model.widget_tree.display_object(); + widget_tree_width <- widget_tree_object.on_resized.map(|size| size.x()); + edit_label_width <- all(model.edit_mode_label.width, init)._0(); + padded_edit_label_width <- edit_label_width.map(|t| t + 2.0 * TEXT_OFFSET); + frp.output.source.width <+ set_editing.switch( + &widget_tree_width, + &padded_edit_label_width ); - frp.output.source.width <+ label_width.map(|t| t + 2.0 * TEXT_OFFSET); - // === Expression === @@ -1088,14 +490,21 @@ impl Area { (default(), e.into()) }); + widget_code_update <- model.widget_tree.value_changed.map(|(crumbs, value)| { + let expression = value.clone().unwrap_or_default(); + (crumbs.clone(), expression) + }); - // === Expression Type === - - eval frp.set_expression_usage_type (((a,b)) model.set_expression_usage_type(a,b)); + frp.output.source.on_port_code_update <+ widget_code_update; + frp.output.source.request_import <+ model.widget_tree.request_import; // === Widgets === - eval frp.update_widgets ((a) model.apply_widget_updates(a)); + eval frp.update_widgets((a) model.apply_widget_configuration(a)); + eval frp.set_connected(((crumbs,status)) model.set_connected(crumbs,*status)); + eval frp.set_expression_usage_type(((id,tp)) model.set_expression_usage_type(*id,tp.clone())); + eval frp.set_disabled ((disabled) model.widget_tree.set_disabled(*disabled)); + eval_ model.widget_tree.rebuild_required(model.rebuild_widget_tree_if_dirty()); // === View Mode === @@ -1105,21 +514,17 @@ impl Area { finished <- frp.set_profiling_status.map(|s| s.is_finished()); profiled <- in_profiling_mode && finished; + model.widget_tree.set_read_only <+ frp.set_read_only; + model.widget_tree.set_view_mode <+ frp.set_view_mode; + model.widget_tree.set_profiling_status <+ frp.set_profiling_status; + use theme::code::syntax; let std_selection_color = model.styles_frp.get_color(syntax::selection); let profiled_selection_color = model.styles_frp.get_color(syntax::profiling::selection); - let std_base_color = model.styles_frp.get_color(syntax::base); - let profiled_base_color = model.styles_frp.get_color(syntax::profiling::base); - selection_color_rgba <- profiled.switch(&std_selection_color,&profiled_selection_color); selection_color.target <+ selection_color_rgba.map(|c| color::Lcha::from(c)); - model.ports_label.set_selection_color <+ selection_color.value.map(|c| color::Lch::from(c)); - - std_base_color <- all(std_base_color,init)._0(); - profiled_base_color <- all(profiled_base_color,init)._0(); - base_color <- profiled.switch(&std_base_color,&profiled_base_color); - eval base_color ((color) model.ports_label.set_property_default(color)); + model.edit_mode_label.set_selection_color <+ selection_color.value.map(|c| color::Lch::from(c)); } init.emit(()); @@ -1128,28 +533,25 @@ impl Area { } /// An offset from node position to a specific port. - pub fn port_offset(&self, crumbs: &[Crumb]) -> Option> { + pub fn port_offset(&self, crumbs: &[Crumb]) -> Vector2 { let expr = self.model.expression.borrow(); - expr.root_ref().get_descendant(crumbs).ok().map(|node| { - let unit = GLYPH_WIDTH; - let range_before = enso_text::Range::new(ByteDiff(0), node.payload.index); - let char_offset = expr.viz_code[range_before].chars().count(); - let char_count = expr.viz_code[node.payload.range()].chars().count(); - let width = unit * (char_count as f32); - let x = width / 2.0 + unit * (char_offset as f32); - Vector2::new(TEXT_OFFSET + x, 0.0) + let port = expr + .get_node(crumbs) + .ok() + .and_then(|node| self.model.widget_tree.get_port_display_object(&node)); + let initial_position = Vector2(TEXT_OFFSET, NODE_HEIGHT / 2.0); + port.map_or(initial_position, |port| { + let pos = port.global_position(); + let node_pos = self.model.display_object.global_position(); + let size = port.computed_size(); + pos.xy() - node_pos.xy() + size * 0.5 }) } /// A type of the specified port. pub fn port_type(&self, crumbs: &Crumbs) -> Option { let expression = self.model.expression.borrow(); - expression.span_tree.root_ref().get_descendant(crumbs).ok().and_then(|t| t.tp.value()) - } - - /// A crumb by AST ID. - pub fn get_crumbs_by_id(&self, id: ast::Id) -> Option { - self.model.id_crumbs_map.borrow().get(&id).cloned() + expression.span_tree.get_node(crumbs).ok().and_then(|t| t.tp().map(|t| t.into())) } /// Set a scene layer for text rendering. @@ -1160,72 +562,9 @@ impl Area { -// ========================== -// === Expression Setting === -// ========================== - -/// Helper struct used to keep information about the current expression layer when building visual -/// port representation. A "layer" is a visual layer in terms of span tree. For example, given -/// expression `img.blur (foo (bar baz))`, we've got several layers, like the whole expression, -/// `img.blur`, `foo (bar baz)`, or `(bar baz)`. The layer builder keeps information passed from the -/// parent layer when building the nested one. -#[derive(Clone, Debug)] -struct PortLayerBuilder { - parent_frp: Option, - /// Parent port display object. - parent: display::object::Instance, - /// Information whether the parent port was a parensed expression. - parent_parensed: bool, - /// The number of chars the expression should be shifted. For example, consider - /// `(foo bar)`, where expression `foo bar` does not get its own port, and thus a 1 char - /// shift should be applied when considering its children. - shift: usize, - /// The depth at which the current expression is, where root is at depth 0. - depth: usize, -} - -impl PortLayerBuilder { - /// Constructor. - #[profile(Debug)] - fn new( - parent: impl display::Object, - parent_frp: Option, - parent_parensed: bool, - shift: usize, - depth: usize, - ) -> Self { - let parent = parent.display_object().clone_ref(); - Self { parent_frp, parent, parent_parensed, shift, depth } - } - - fn empty(parent: impl display::Object) -> Self { - Self::new(parent, default(), default(), default(), default()) - } - - /// Create a nested builder with increased depth and updated `parent_frp`. - #[profile(Debug)] - fn nested( - &self, - parent: display::object::Instance, - new_parent_frp: Option, - parent_parensed: bool, - shift: usize, - ) -> Self { - let depth = self.depth + 1; - let parent_frp = new_parent_frp.or_else(|| self.parent_frp.clone()); - Self::new(parent, parent_frp, parent_parensed, shift, depth) - } -} - -impl display::Object for Area { - fn display_object(&self) -> &display::object::Instance { - &self.model.display_object - } -} - -/// =================== -/// === CallInfoMap === -/// =================== +// =================== +// === CallInfoMap === +// =================== #[derive(Debug, Deref)] struct CallInfoMap { @@ -1254,12 +593,4 @@ impl CallInfoMap { Self { call_info } } - - fn has_target(&self, call_id: &ast::Id) -> bool { - self.call_info.get(call_id).map_or(false, |info| info.target_id.is_some()) - } - - fn target(&self, call_id: &ast::Id) -> Option { - self.call_info.get(call_id).and_then(|info| info.target_id) - } } diff --git a/app/gui/view/graph-editor/src/component/node/input/port.rs b/app/gui/view/graph-editor/src/component/node/input/port.rs index 94b88b1bcb80..6c119ab048a9 100644 --- a/app/gui/view/graph-editor/src/component/node/input/port.rs +++ b/app/gui/view/graph-editor/src/component/node/input/port.rs @@ -1,16 +1,21 @@ -//! FIXME[everyone] Modules should be documented. +//! Definition of all hardcoded node widget variants and common widget FRP API. use crate::prelude::*; -use enso_text::unit::*; -use ensogl::display::shape::*; -use crate::node::input::area; -use crate::node::input::widget; -use crate::Type; +use crate::component::node::input::widget::ConfigContext; +use crate::component::node::input::widget::DynConfig; +use crate::component::node::input::widget::DynWidget; +use crate::component::node::input::widget::EdgeData; +use crate::component::node::input::widget::SpanWidget; +use crate::component::node::input::widget::WidgetsFrp; +use enso_frp as frp; use ensogl::application::Application; +use ensogl::control::io::mouse; use ensogl::data::color; use ensogl::display; +use ensogl::display::scene::layer::LayerSymbolPartition; +use ensogl::display::shape; @@ -20,190 +25,253 @@ use ensogl::display; /// The horizontal padding of ports. It affects how the port hover should extend the target text /// boundary on both sides. -pub const PADDING_X: f32 = 4.0; - - - -// =================== -// === Hover Shape === -// =================== - -/// Port hover shape definition. -pub mod hover { - use super::*; - ensogl::shape! { - alignment = center; - (style:Style) { - let width : Var = "input_size.x".into(); - let height : Var = "input_size.y".into(); - let shape = Rect((&width,&height)); - if !area::DEBUG { - let color = Var::::from("srgba(1.0,1.0,1.0,0.00001)"); - shape.fill(color).into() - } else { - let shape = shape.corners_radius(6.px()); - let color = Var::::from("srgba(1.0,0.0,0.0,0.1)"); - shape.fill(color).into() - } - } - } -} +pub const PORT_PADDING_X: f32 = 4.0; +/// The minimum size of the port visual area. +pub const BASE_PORT_HEIGHT: f32 = 18.0; +/// The vertical hover padding of ports at low depth. It affects how the port hover should extend +/// the target text boundary on both sides. +pub const PRIMARY_PORT_HOVER_PADDING_Y: f32 = (crate::node::HEIGHT - BASE_PORT_HEIGHT) / 2.0; -// ============= -// === Shape === -// ============= -/// Port shape definition. -pub mod viz { - use super::*; - ensogl::shape! { - above = [hover]; - pointer_events = false; - alignment = center; - (style:Style, color:Vector4) { - let width : Var = "input_size.x".into(); - let height : Var = "input_size.y".into(); - let shape = Rect((&width,&height)).corners_radius(&height / 2.0); - shape.fill("srgba(input_color)").into() - } - } -} +// =========================== +// === Shapes / PortLayers === +// =========================== +type PortShape = shape::compound::rectangle::Rectangle; +type PortShapeView = shape::compound::rectangle::shape::Shape; -// ============= -// === Shape === -// ============= - -/// Shapes the port is build from. It consist of the `hover_shape`, which represents a hover area of -/// a full node height, and the `viz_shape`, which is a nice, visual highlight representation. -/// Both shapes are children of the `root` display object: -/// -/// ```text -/// hover_shape -/// ◄──────► -/// ╭───┬────────┬──┄ -/// │ │╭──────╮│▼ viz_shape -/// │ │╰──────╯│▲ (appears after mouse_hover) -/// ╰───┴────────┴──┄ -/// ``` -#[derive(Clone, CloneRef, Debug)] -#[allow(missing_docs)] -pub struct Shape { - pub root: display::object::Instance, - pub hover: hover::View, - pub viz: viz::View, +/// Shape used for handling mouse events in the port, such as hovering or dropping an edge. +pub type HoverShape = shape::compound::rectangle::Rectangle; +type HoverShapeView = shape::compound::rectangle::shape::Shape; + +/// An scene extension that maintains layer partitions for port shapes. It is shared by all ports in +/// the scene. The port selection and hover shapes are partitioned by span tree depth, so that the +/// ports deeper in the tree will always be displayed on top. For hover layers, that gives them +/// priority to receive mouse events. +#[derive(Clone, CloneRef)] +struct PortLayers { + port_layer: display::scene::Layer, + hover_layer: display::scene::Layer, + partitions: Rc< + RefCell, LayerSymbolPartition)>>, + >, } -impl Shape { - /// Constructor. - #[profile(Debug)] - pub fn new(size: Vector2, hover_height: f32) -> Self { - let root = display::object::Instance::new(); - let hover = hover::View::new(); - let viz = viz::View::new(); - - let width_padded = size.x + 2.0 * PADDING_X; - hover.set_size((width_padded, hover_height)); - viz.set_size((width_padded, size.y)); - hover.set_x(size.x / 2.0); - viz.set_x(size.x / 2.0); - viz.color.set(color::Rgba::transparent().into()); - - root.add_child(&hover); - root.add_child(&viz); - - Self { root, hover, viz } +impl display::scene::Extension for PortLayers { + fn init(scene: &display::Scene) -> Self { + let port_layer = scene.layers.port.clone_ref(); + let hover_layer = scene.layers.port_hover.clone_ref(); + Self { port_layer, hover_layer, partitions: default() } } } -impl display::Object for Shape { - fn display_object(&self) -> &display::object::Instance { - self.root.display_object() +impl PortLayers { + /// Add a display object to the partition at given depth, effectively setting its display order. + /// If the partition does not exist yet, it will be created. + fn add_to_partition( + &self, + port: &display::object::Instance, + hover: &display::object::Instance, + depth: usize, + ) { + let mut partitions = self.partitions.borrow_mut(); + if partitions.len() <= depth { + partitions.resize_with(depth + 1, || { + ( + self.port_layer.create_symbol_partition("input port"), + self.hover_layer.create_symbol_partition("input port hover"), + ) + }) + } + let (port_partition, hover_partition) = &partitions[depth]; + port_partition.add(port); + hover_partition.add(hover); } } -// ============= -// === Model === -// ============= +// ============ +// === Port === +// ============ + +/// Node of a widget tree that can be a source of an edge. Displays a visual representation of the +/// connection below the widget, and handles mouse hover and click events when an edge is dragged. +#[derive(Debug)] +pub struct Port { + /// Drop source must be kept at the top of the struct, so it will be dropped first. + _on_cleanup: frp::DropSource, + crumbs: Rc>, + port_root: display::object::Instance, + widget_root: display::object::Instance, + widget: DynWidget, + port_shape: PortShape, + hover_shape: HoverShape, + /// Last set tree depth of the port. Allows skipping layout update when the depth has not + /// changed during reconfiguration. + current_depth: usize, + /// Whether or not the port was configured as primary. Allows skipping layout update when the + /// hierarchy level has not changed significantly during reconfiguration. + current_primary: bool, +} -ensogl::define_endpoints! { - Input { - set_optional (bool), - set_disabled (bool), - set_active (bool), - set_hover (bool), - set_connected (bool,Option), - set_parent_connected (bool), - set_definition_type (Option), - set_usage_type (Option), - } +impl Port { + /// Create a new port for given widget. The widget will be placed as a child of the port's root + /// display object, and its layout size will be used to determine the port's size. + pub fn new(widget: DynWidget, app: &Application, frp: &WidgetsFrp) -> Self { + let port_root = display::object::Instance::new(); + let widget_root = widget.root_object().clone_ref(); + let port_shape = PortShape::new(); + let hover_shape = HoverShape::new(); + port_shape.set_corner_radius_max().set_pointer_events(false); + hover_shape.set_pointer_events(true).set_color(shape::INVISIBLE_HOVER_COLOR); + + port_root.add_child(&widget_root); + widget_root.set_margin_left(0.0); + port_shape + .set_size_y(BASE_PORT_HEIGHT) + .allow_grow() + .set_margin_left(-PORT_PADDING_X) + .set_margin_right(-PORT_PADDING_X) + .set_alignment_left_center(); + hover_shape + .set_size_y(BASE_PORT_HEIGHT) + .allow_grow() + .set_margin_left(-PORT_PADDING_X) + .set_margin_right(-PORT_PADDING_X) + .set_alignment_left_center(); + + let layers = app.display.default_scene.extension::(); + layers.add_to_partition(port_shape.display_object(), hover_shape.display_object(), 0); + + let mouse_enter = hover_shape.on_event::(); + let mouse_leave = hover_shape.on_event::(); + let mouse_down = hover_shape.on_event::(); + + let crumbs: Rc> = default(); + + if frp.set_ports_visible.value() { + port_root.add_child(&hover_shape); + } - Output { - tp (Option), - new_value (String), + let port_root_weak = port_root.downgrade(); + let network = &port_root.network; + + frp::extend! { network + on_cleanup <- on_drop(); + hovering <- bool(&mouse_leave, &mouse_enter); + cleanup_hovering <- on_cleanup.constant(false); + hovering <- any(&hovering, &cleanup_hovering); + hovering <- hovering.on_change(); + + frp.on_port_hover <+ hovering.map( + f!([crumbs](t) Switch::new(crumbs.borrow().clone(), *t)) + ); + + frp.on_port_press <+ mouse_down.map(f_!(crumbs.borrow().clone())); + eval frp.set_ports_visible([port_root_weak, hover_shape] (active) { + if let Some(port_root) = port_root_weak.upgrade() { + if *active { + port_root.add_child(&hover_shape); + } else { + port_root.remove_child(&hover_shape); + } + } + }); + + // Port shape is only connected to the display hierarchy when the port is connected. + // Thus the `on_transformed` event is automatically disabled when the port is not + // connected. + let shape_display_object = port_shape.display_object(); + frp.connected_port_updated <+ shape_display_object.on_transformed; + }; + + Self { + _on_cleanup: on_cleanup, + port_shape, + hover_shape, + widget, + widget_root, + port_root, + crumbs, + current_primary: false, + current_depth: 0, + } } -} -/// Input port model. Please note that this is not a component model. It is a `SpanTree` payload -/// model. -#[derive(Clone, Debug, Default)] -#[allow(missing_docs)] -pub struct Model { - pub frp: Frp, - pub shape: Option, - pub widget: Option, - pub index: ByteDiff, - pub local_index: ByteDiff, - pub length: ByteDiff, -} + /// Configure the port and its attached widget. If the widget has changed its root object after + /// reconfiguration, the port display object hierarchy will be updated to use it. + /// + /// See [`crate::component::node::input::widget`] module for more information about widget + /// lifecycle. + pub fn configure(&mut self, config: &DynConfig, ctx: ConfigContext) { + self.crumbs.replace(ctx.span_node.crumbs.clone()); + self.set_connected(ctx.info.connection); + self.set_port_layout(&ctx); + self.widget.configure(config, ctx); + self.update_root(); + } -impl Deref for Model { - type Target = Frp; - fn deref(&self) -> &Self::Target { - &self.frp + /// Update connection status of this port. Changing the connection status will add or remove the + /// port's visible shape from the display hierarchy. + fn set_connected(&self, status: Option) { + match status { + Some(data) => { + self.port_root.add_child(&self.port_shape); + self.port_shape.color.set(color::Rgba::from(data.color).into()) + } + None => { + self.port_root.remove_child(&self.port_shape); + } + }; } -} -impl Model { - /// Constructor. - pub fn new() -> Self { - default() + fn update_root(&mut self) { + let new_root = self.widget.root_object(); + if new_root != &self.widget_root { + self.port_root.remove_child(&self.widget_root); + self.port_root.add_child(new_root); + self.widget_root = new_root.clone_ref(); + } } - /// Shape initialization. Please note that not all port models get their shapes initialized, - /// as some are skipped. For example, given the expression `(((foo)))`, the inner parentheses - /// will be skipped, as there is no point in making them ports. The skip algorithm is - /// implemented as part of the port are initialization. - #[profile(Debug)] - pub fn init_shape(&mut self, size: Vector2, hover_height: f32) -> Shape { - let shape = Shape::new(size, hover_height); - self.shape = Some(shape); - self.shape.as_ref().unwrap().clone_ref() + fn set_port_layout(&mut self, ctx: &ConfigContext) { + let node_depth = ctx.span_node.crumbs.len(); + if self.current_depth != node_depth { + self.current_depth = node_depth; + let layers = ctx.app().display.default_scene.extension::(); + let port_shape = self.port_shape.display_object(); + let hover_shape = self.hover_shape.display_object(); + layers.add_to_partition(port_shape, hover_shape, node_depth); + } + + let is_primary = ctx.info.nesting_level.is_primary(); + if self.current_primary != is_primary { + self.current_primary = is_primary; + let margin = if is_primary { PRIMARY_PORT_HOVER_PADDING_Y } else { 0.0 }; + self.hover_shape.set_size_y(BASE_PORT_HEIGHT + 2.0 * margin); + self.hover_shape.set_margin_top(-margin); + self.hover_shape.set_margin_bottom(-margin); + } } - /// Widget initialization. Only nodes that represent function arguments or argument placeholders - /// will have widgets created for them. - pub fn init_widget(&mut self, app: &Application) -> widget::View { - let widget = widget::View::new(app); - self.widget = Some(widget.clone_ref()); - widget + /// Extract the widget out of the port, dropping the port specific display objects. The widget + /// can be reinserted into the display hierarchy of widget tree. + pub(super) fn into_widget(self) -> DynWidget { + self.widget } - /// Assign an existing widget to this port. - pub fn use_existing_widget(&mut self, widget: widget::View) -> widget::View { - self.widget = Some(widget.clone_ref()); - widget + /// Get the port's hover shape. Used for testing to simulate mouse events. + pub fn hover_shape(&self) -> &HoverShape { + &self.hover_shape } +} - /// The range of this port. - pub fn range(&self) -> enso_text::Range { - let start = self.index; - let end = self.index + self.length; - enso_text::Range::new(start, end) +impl display::Object for Port { + fn display_object(&self) -> &display::object::Instance { + self.port_root.display_object() } } diff --git a/app/gui/view/graph-editor/src/component/node/input/widget.rs b/app/gui/view/graph-editor/src/component/node/input/widget.rs index ee9fadb63e1d..d5f1e99aba20 100644 --- a/app/gui/view/graph-editor/src/component/node/input/widget.rs +++ b/app/gui/view/graph-editor/src/component/node/input/widget.rs @@ -1,34 +1,81 @@ -//! Definition of all hardcoded node widget variants and common widget FRP API. +//! Node widgets hierarchy. This module defines a widget [`Tree`] view, which manages all widgets +//! and edge ports for a given node. The widgets are organized in a tree structure, where each +//! widget can create multiple child widgets and organize their display objects according to its +//! needs. When node's expression is changed, the widget tree is rebuilt, attempting to preserve +//! as many widgets as possible, which allows widgets to maintain internal state. +//! +//! +//! # Widget Lifecycle +//! +//! The widget lifecycle is managed using [`SpanWidget`] trait. +//! +//! When a widget tree is built for the first time (or the expression has been completely changed), +//! all widgets are created from scratch, using the [`SpanWidget::new`] method. During this phase, +//! each widget can initialize its own view structure and create an FRP network. Immediately after +//! the widget is created, it is configured for the first time using [`SpanWidget::configure`] +//! method. +//! +//! During configuration, the widget should declare its child widgets and place them in its +//! view structure, as well as emit its own internal FRP events to update its view's state. +//! +//! For each subsequent expression change or configuration update, the widget tree is rebuilt, +//! reusing the same widgets for nodes that maintained their identity (see [`WidgetIdentity`]). +//! When a widget is reused, the [`SpanWidget::configure`] method is called, allowing the widget to +//! update its view and declare its child widgets again. Usually, the same children are declared, +//! allowing the build process to propagate down the tree and reuse existing child widgets as well. +//! +//! Whenever a configuration change causes a widget to change its kind (e.g. from a simple label to +//! a single choice dropdown), the widget is removed and a new one is created in its place. +//! +//! +//! # Widget Configuration +//! +//! Each widget kind has its own configuration type, which is used to pass additional data to the +//! widget, as inferred from the expression, or provided by external source as an override. The +//! configuration source is determined in order: +//! 1. If a parent widget has directly provided a configuration for its child, it is always used. +//! Parent widget can provide it by using [`TreeBuilder::child_widget_of_type`] method. +//! 2. If there is a configuration override that matches given span, it is used. The configuration +//! overrides are defined at the whole tree level, and can be provided using +//! [`Tree::set_config_override`] method. +//! 3. The default configuration for the node is created using [`Configuration::from_node`] method. +//! It uses the combination of span tree node kind data and type information to decide which +//! widget is the best fit for the node. use crate::prelude::*; +use crate::component::node::input::area::NODE_HEIGHT; +use crate::component::node::input::area::TEXT_OFFSET; +use crate::component::node::input::port::Port; + use enso_config::ARGS; use enso_frp as frp; +use enso_text as text; use ensogl::application::Application; use ensogl::data::color; use ensogl::display; -use ensogl::display::object::event; -use ensogl_component::drop_down::Dropdown; +use ensogl::display::shape::StyleWatch; +use ensogl::gui::cursor; use ensogl_component::drop_down::DropdownValue; +use span_tree::node::Ref as SpanRef; +use text::index::Byte; -// ============== -// === Export === -// ============== - -pub mod vector_editor; +// ================= +// === Constants === +// ================= +/// Spacing between sibling widgets per each code character that separates them. Current value is +/// based on the space glyph width at node's default font size, so that entering node edit mode +/// introduces the least amount of visual changes. The value can be adjusted once we implement +/// granular edit mode that works with widgets. +pub const WIDGET_SPACING_PER_OFFSET: f32 = 7.224_609_4; -/// ================= -/// === Constants === -/// ================= +/// The maximum depth of the widget port that is still considered primary. This is used to determine +/// the hover area of the port. +pub const PRIMARY_PORT_MAX_NESTING_LEVEL: usize = 0; -const ACTIVATION_SHAPE_COLOR: color::Lch = color::Lch::new(0.56708, 0.23249, 0.71372); -const ACTIVATION_SHAPE_Y_OFFSET: f32 = -5.0; -const ACTIVATION_SHAPE_SIZE: Vector2 = Vector2(15.0, 11.0); -/// Distance between the dropdown and the bottom of the port. -const DROPDOWN_Y_OFFSET: f32 = 5.0; // =========== @@ -37,34 +84,242 @@ const DROPDOWN_Y_OFFSET: f32 = 5.0; ensogl::define_endpoints_2! { Input { - set_metadata (Option), - set_node_data (NodeData), - set_current_value (Option), - set_focused (bool), - set_visible (bool), - set_read_only (bool), + set_ports_visible (bool), + set_read_only (bool), + set_view_mode (crate::view::Mode), + set_profiling_status (crate::node::profiling::Status), + set_disabled (bool), } Output { - value_changed(Option), - request_import(ImString), + value_changed (span_tree::Crumbs, Option), + request_import (ImString), + on_port_hover (Switch), + on_port_press (span_tree::Crumbs), + pointer_style (cursor::Style), + /// Any of the connected port's display object within the widget tree has been updated. This + /// signal is generated using the `on_updated` signal of the `display_object` of the widget, + /// all caveats of that signal apply here as well. + connected_port_updated (), + /// Tree data update recently caused it to be marked as dirty. Rebuild is required. + rebuild_required (), + /// Dirty flag has been marked. This signal is fired immediately after the update that + /// caused it. Prefer using `rebuild_required` signal instead, which is debounced. + marked_dirty_sync (), } } -/// Widget metadata that comes from an asynchronous visualization. Defines which widget should be -/// used and a set of options that it should allow to choose from. +/// A key used for overriding widget configuration. Allows locating the widget that should be +/// configured using provided external data. +#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)] +pub struct OverrideKey { + /// The function call associated with the widget. + pub call_id: ast::Id, + /// The name of function argument at which the widget is located. + pub argument_name: ImString, +} + + +// ====================== +// === Widget modules === +// ====================== + +/// Common trait for constructing and reconfiguring all widget variants. See "Widget Lifecycle" +/// section of the module documentation for more details. +pub trait SpanWidget { + /// Configuration associated with specific widget variant. + type Config: Debug + Clone + PartialEq; + /// Root display object of a widget. It is returned to the parent widget for positioning. + fn root_object(&self) -> &display::object::Instance; + /// Create a new widget with given configuration. + fn new(config: &Self::Config, ctx: &ConfigContext) -> Self; + /// Update configuration for existing widget. + fn configure(&mut self, config: &Self::Config, ctx: ConfigContext); +} + + +/// Generate implementation for [`DynWidget`] enum and its associated [`Config`] enum. Those enums +/// are used to represent any possible widget kind and its configuration. +macro_rules! define_widget_modules( + ($( + $(#[$meta:meta])* + $name:ident $module:ident, + )*) => { + $(pub mod $module;)* + + /// A widget configuration that determines the widget kind. + #[derive(Debug, Clone, PartialEq)] + #[allow(missing_docs)] + pub enum DynConfig { + $($name(<$module::Widget as SpanWidget>::Config),)* + } + + /// The node widget view. Represents one widget of any kind on the node input area. Can + /// change its appearance and behavior depending on the widget configuration updates, + /// without being recreated. New widget can be created using the `new` method, while the + /// existing widget can be reconfigured using the `configure` method. + /// + /// When a new configuration is applied, the existing widget will handle the update using + /// its `configure` method. If the new configuration requires a different widget kind, the + /// widget of new kind will be created and the old one will be dropped. + #[derive(Debug)] + #[allow(missing_docs)] + pub enum DynWidget { + $( + $(#[$meta])* + $name($module::Widget) + ),* + } + + $( + impl const From<<$module::Widget as SpanWidget>::Config> for DynConfig { + fn from(config: <$module::Widget as SpanWidget>::Config) -> Self { + Self::$name(config) + } + } + + impl const From<$module::Widget> for DynWidget { + fn from(config: $module::Widget) -> Self { + Self::$name(config) + } + } + )* + + impl SpanWidget for DynWidget { + type Config = DynConfig; + fn root_object(&self) -> &display::object::Instance { + match self { + $(DynWidget::$name(inner) => inner.root_object(),)* + } + } + + fn new(config: &DynConfig, ctx: &ConfigContext) -> Self { + match config { + $(DynConfig::$name(config) => DynWidget::$name(SpanWidget::new(config, ctx)),)* + } + } + + fn configure(&mut self, config: &DynConfig, ctx: ConfigContext) { + match (self, config) { + $((DynWidget::$name(model), DynConfig::$name(config)) => { + SpanWidget::configure(model, config, ctx); + },)* + (this, _) => { + *this = SpanWidget::new(config, &ctx); + this.configure(config, ctx) + }, + } + } + } + }; +); + +define_widget_modules! { + /// Default widget that only displays text. + Label label, + /// Empty widget that does not display anything, used for empty insertion points. + InsertionPoint insertion_point, + /// A widget for selecting a single value from a list of available options. + SingleChoice single_choice, + /// A widget for managing a list of values - adding, removing or reordering them. + ListEditor list_editor, + /// Default span tree traversal widget. + Hierarchy hierarchy, +} + +// ===================== +// === Configuration === +// ===================== + +/// The configuration of a widget and its display properties. Defines how the widget should be +/// displayed, if it should be displayed at all, and whether or not it should have a port. Widgets +/// that declare themselves as having a port will be able to handle edge connections and visually +/// indicate that they are connected. #[derive(Debug, Clone, PartialEq)] #[allow(missing_docs)] -pub struct Metadata { - pub kind: Kind, - pub display: Display, - /// Entries that should be displayed by the widget, as proposed by language server. This list - /// is not exhaustive. The widget implementation might present additional options or allow - /// arbitrary user input. - pub dynamic_entries: Vec, +pub struct Configuration { + /// Display mode of the widget: determines whether or not the widget should be displayed + /// depending on current tree display mode. + pub display: Display, + /// Whether or not the widget can receive a port. If `true`, the widget can be wrapped in a + /// [`Port`] struct, but it is not guaranteed. If multiple widgets created at single span node + /// declare themselves as wanting a port, only one of them will actually have one. + pub has_port: bool, + /// Configuration specific to given widget kind. + pub kind: DynConfig, +} + +impl Configuration { + /// Derive widget configuration from Enso expression, node data in span tree and inferred value + /// type. When no configuration is provided with an override, this function will be used to + /// create a default configuration. + fn from_node(span_node: &SpanRef, usage_type: Option, expression: &str) -> Self { + use span_tree::node::Kind; + + let kind = &span_node.kind; + let has_children = !span_node.children.is_empty(); + + const VECTOR_TYPE: &str = "Standard.Base.Data.Vector.Vector"; + let is_list_editor_enabled = ARGS.groups.feature_preview.options.vector_editor.value; + let is_vector = |arg: &span_tree::node::Argument| { + let type_matches = usage_type + .as_ref() + .map(|t| t.as_str()) + .or(arg.tp.as_deref()) + .map_or(false, |tp| tp.contains(VECTOR_TYPE)); + if type_matches { + let node_expr = &expression[span_node.span()]; + node_expr.starts_with('[') && node_expr.ends_with(']') + } else { + false + } + }; + + match kind { + Kind::Argument(arg) if !arg.tag_values.is_empty() => + Self::static_dropdown(arg.name.as_ref().map(Into::into), &arg.tag_values), + Kind::Argument(arg) if is_list_editor_enabled && is_vector(arg) => Self::list_editor(), + Kind::InsertionPoint(arg) if arg.kind.is_expected_argument() => + if !arg.tag_values.is_empty() { + Self::static_dropdown(arg.name.as_ref().map(Into::into), &arg.tag_values) + } else { + Self::always(label::Config::default()) + }, + Kind::Token | Kind::Operation if !has_children => Self::inert(label::Config::default()), + Kind::NamedArgument => Self::inert(hierarchy::Config), + Kind::InsertionPoint(_) => Self::inert(insertion_point::Config), + _ if has_children => Self::always(hierarchy::Config), + _ => Self::always(label::Config::default()), + } + } + + const fn always(kind: C) -> Self + where C: ~const Into { + Self { display: Display::Always, kind: kind.into(), has_port: true } + } + + const fn inert(kind: C) -> Self + where C: ~const Into { + Self { display: Display::Always, kind: kind.into(), has_port: false } + } + + /// Widget configuration for static dropdown, based on the tag values provided by suggestion + /// database. + fn static_dropdown( + label: Option, + tag_values: &[span_tree::TagValue], + ) -> Configuration { + let entries = Rc::new(tag_values.iter().map(Entry::from).collect()); + Self::always(single_choice::Config { label, entries }) + } + + fn list_editor() -> Configuration { + Self::always(list_editor::Config { item_widget: None, item_default: "_".into() }) + } } /// Widget display mode. Determines when the widget should be expanded. #[derive(serde::Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)] +#[serde(tag = "constructor")] pub enum Display { /// The widget should always be in its expanded mode. #[default] @@ -72,7 +327,7 @@ pub enum Display { /// The widget should only be in its expanded mode when it has non-default value. #[serde(rename = "When_Modified")] WhenModified, - /// The widget should only be in its expanded mode whe the whole node is expanded. + /// The widget should only be in its expanded mode when the whole node is expanded. #[serde(rename = "Expanded_Only")] ExpandedOnly, } @@ -123,508 +378,952 @@ impl DropdownValue for Entry { } } -/// The data of node port that this widget is attached to. Available immediately after widget -/// creation. Can be updated later when the node data changes. -#[derive(Debug, Clone, Default, PartialEq)] -#[allow(missing_docs)] -pub struct NodeData { - pub tag_values: Vec, - pub port_size: Vector2, - pub tp: Option, -} +// ================== +// === WidgetsFrp === +// ================== -/// ================== -/// === SampledFrp === -/// ================== - -/// Sampled version of widget FRP endpoints that can be used by widget views that are initialized -/// on demand after first interaction. Without samplers, when a widget view would be initialized -/// after the endpoints were set, it would not receive previously set endpoint values. +/// Widget FRP endpoints that can be used by widget views, and go straight to the root. #[derive(Debug, Clone, CloneRef)] -pub struct SampledFrp { - set_current_value: frp::Sampler>, - set_visible: frp::Sampler, - set_focused: frp::Sampler, - out_value_changed: frp::Any>, - out_request_import: frp::Any, - set_read_only: frp::Sampler, +pub struct WidgetsFrp { + pub(super) set_ports_visible: frp::Sampler, + pub(super) set_read_only: frp::Sampler, + pub(super) set_view_mode: frp::Sampler, + pub(super) set_profiling_status: frp::Sampler, + pub(super) value_changed: frp::Any<(span_tree::Crumbs, Option)>, + pub(super) request_import: frp::Any, + pub(super) on_port_hover: frp::Any>, + pub(super) on_port_press: frp::Any, + pub(super) pointer_style: frp::Any, + pub(super) connected_port_updated: frp::Any<()>, } -// ============== -// === Widget === -// ============== +// ============ +// === Tree === +// ============ -/// The node widget view. Represents one widget of any kind on the node input area. Can change its -/// appearance and behavior depending on the widget metadata updates, without being recreated. -#[derive(Debug, Clone, CloneRef)] -pub struct View { - frp: Frp, - model: Rc, +/// The node widget tree view. Contains all widgets created from the node's span tree, as well as +/// all input ports of a node. The tree is initialized to empty state, waiting for first +/// `rebuild_tree` call to build appropriate view hierarchy. +#[derive(Debug, Deref, Clone, CloneRef)] +pub struct Tree { + #[deref] + frp: Frp, + widgets_frp: WidgetsFrp, + model: Rc, +} + +impl display::Object for Tree { + fn display_object(&self) -> &display::object::Instance { + &self.model.display_object + } } -impl View { - /// Create a new node widget. The widget is initialized to empty state, waiting for widget - /// metadata to be provided using `set_node_data` and `set_metadata` FRP endpoints. +impl Tree { + /// Create a new node widget. The widget is initialized to empty state, waiting for first + /// `rebuild_tree` call to build appropriate view hierarchy. #[profile(Task)] pub fn new(app: &Application) -> Self { let frp = Frp::new(); - let model = Rc::new(Model::new(app)); - Self { frp, model }.init() + let model = Rc::new(TreeModel::new(app)); + + let network = &frp.network; + + frp::extend! { network + frp.private.output.rebuild_required <+ frp.marked_dirty_sync.debounce(); + + set_ports_visible <- frp.set_ports_visible.sampler(); + set_read_only <- frp.set_read_only.sampler(); + set_view_mode <- frp.set_view_mode.sampler(); + set_profiling_status <- frp.set_profiling_status.sampler(); + + on_port_hover <- any(...); + on_port_press <- any(...); + frp.private.output.on_port_hover <+ on_port_hover; + frp.private.output.on_port_press <+ on_port_press; + } + + let value_changed = frp.private.output.value_changed.clone_ref(); + let request_import = frp.private.output.request_import.clone_ref(); + let pointer_style = frp.private.output.pointer_style.clone_ref(); + let connected_port_updated = frp.private.output.connected_port_updated.clone_ref(); + let widgets_frp = WidgetsFrp { + set_ports_visible, + set_read_only, + set_view_mode, + set_profiling_status, + value_changed, + request_import, + on_port_hover, + on_port_press, + pointer_style, + connected_port_updated, + }; + + Self { frp, widgets_frp, model } } - /// Widget FRP API. Contains all endpoints that can be used to control the widget of any kind. - pub fn frp(&self) -> &Frp { - &self.frp + /// Override widget configuration. The configuration is used to determine the widget appearance + /// and behavior. By default, the widget configuration will be inferred from its span tree kind + /// and type. However, in some cases, we want to change the selected widget for a given span + /// tree node, and it can be done by calling this method. The set configuration is persistent, + /// and will be applied to any future widget of this node that matches given pointer. + pub fn set_config_override(&self, pointer: OverrideKey, config: Option) { + self.notify_dirty(self.model.set_config_override(pointer, config)); } - fn init(self) -> Self { - let model = &self.model; - let frp = &self.frp; - let network = &frp.network; - let input = &frp.input; + /// Set the inferred type of the expression for given ast ID. On rebuild, the type will be + /// linked with any widget created on any span with matching AST ID. It is used to determine the + /// widget appearance and default inferred widget configuration. + pub fn set_usage_type(&self, ast_id: ast::Id, usage_type: Option) { + self.notify_dirty(self.model.set_usage_type(ast_id, usage_type)); + } - frp::extend! { network - metadata_change <- input.set_metadata.on_change(); - node_data_change <- input.set_node_data.on_change(); - widget_data <- all(&metadata_change, &node_data_change).debounce(); - - set_current_value <- input.set_current_value.sampler(); - set_visible <- input.set_visible.sampler(); - set_focused <- input.set_focused.sampler(); - set_read_only <- input.set_read_only.sampler(); - let out_value_changed = frp.private.output.value_changed.clone_ref(); - let out_request_import = frp.private.output.request_import.clone_ref(); - let sampled_frp = SampledFrp { - set_current_value, - set_visible, - set_focused, - out_value_changed, - out_request_import, - set_read_only - }; + /// Set connection status for given span crumbs. The connected nodes will be highlighted with a + /// different color, and the widgets might change behavior depending on the connection + /// status. + pub fn set_connected(&self, crumbs: &span_tree::Crumbs, status: Option) { + self.notify_dirty(self.model.set_connected(crumbs, status)); + } + + /// Set disabled status for given span tree node. The disabled nodes will be grayed out. + /// The widgets might change behavior depending on the disabled status. + pub fn set_disabled(&self, disabled: bool) { + self.notify_dirty(self.model.set_disabled(disabled)); + } + + + /// Rebuild tree if it has been marked as dirty. The dirty flag is marked whenever more data + /// external to the span-tree is provided, using `set_config_override`, `set_usage_type`, + /// `set_connected` or `set_disabled` methods of the widget tree. + pub fn rebuild_tree_if_dirty( + &self, + tree: &span_tree::SpanTree, + node_expression: &str, + styles: &StyleWatch, + ) { + if self.model.tree_dirty.get() { + self.rebuild_tree(tree, node_expression, styles); + } + } + + /// Rebuild the widget tree using given span tree expression. All widgets necessary for the + /// provided expression will be created and added to the view hierarchy. If the tree has been + /// already built, existing widgets will be reused in the parts of the expression that did not + /// change since then. + pub fn rebuild_tree( + &self, + tree: &span_tree::SpanTree, + node_expression: &str, + styles: &StyleWatch, + ) { + self.model.rebuild_tree(self.widgets_frp.clone_ref(), tree, node_expression, styles) + } + + /// Get the root display object of the widget port for given span tree node. Not all nodes must + /// have a distinct widget, so the returned value might be [`None`]. + pub fn get_port_display_object( + &self, + span_node: &SpanRef, + ) -> Option { + let pointer = self.model.get_node_widget_pointer(span_node); + self.model.with_port(pointer, |w| w.display_object().clone()) + } + + /// Get hover shapes for all ports in the tree. Used in tests to manually dispatch mouse events. + pub fn port_hover_shapes(&self) -> Vec { + let nodes = self.model.nodes_map.borrow(); + self.model + .hierarchy + .borrow() + .iter() + .filter_map(|n| nodes.get(&n.identity)) + .filter_map(|e| Some(e.node.port()?.hover_shape().clone_ref())) + .collect_vec() + } + + fn notify_dirty(&self, dirty_flag_just_set: bool) { + if dirty_flag_just_set { + self.frp.private.output.marked_dirty_sync.emit(()); + } + } +} + + +// ================ +// === TreeNode === +// ================ - eval widget_data([model, sampled_frp]((meta, node_data)) { - model.set_widget_data(&sampled_frp, meta, node_data); - }); +/// A single entry in the widget tree. If the widget has an attached port, it will be wrapped in +/// `Port` struct and stored in `Port` variant. Otherwise, the widget will be stored directly using +/// the `Widget` node variant. +#[derive(Debug)] +pub(super) enum TreeNode { + /// A tree node that contains a port. The port wraps a widget. + Port(Port), + /// A tree node without a port, directly containing a widget. + Widget(DynWidget), +} + +impl TreeNode { + fn port(&self) -> Option<&Port> { + match self { + TreeNode::Port(port) => Some(port), + TreeNode::Widget(_) => None, } + } +} - self +impl display::Object for TreeNode { + fn display_object(&self) -> &display::object::Instance { + match self { + TreeNode::Port(port) => port.display_object(), + TreeNode::Widget(widget) => widget.root_object(), + } } } +/// Hierarchy structure that can be used to quickly navigate the tree. +#[derive(Debug, Clone, Copy)] +struct NodeHierarchy { + identity: WidgetIdentity, + parent_index: Option, + total_descendants: usize, +} + +/// Single entry in the tree. +#[derive(Debug)] +struct TreeEntry { + node: TreeNode, + /// Index in the `hierarchy` vector. + index: usize, +} + + + +// ================ +// === EdgeData === +// ================ + +/// Data associated with an edge connected to a port in the tree. It is accessible to the connected +/// port, its widget and all its descendants through `connection` and `subtree_connection` fields +/// of [`NodeState`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct EdgeData { + /// Color of an edge connected to the port. + pub color: color::Lcha, + /// Span tree depth at which the connection is made. + pub depth: usize, +} + -/// ============= -/// === Model === -/// ============= + +// ================= +// === TreeModel === +// ================= #[derive(Debug)] -struct Model { +struct TreeModel { app: Application, display_object: display::object::Instance, - kind_model: RefCell>, + /// A map from widget identity to the tree node and its index in the `hierarchy` vector. + nodes_map: RefCell>, + /// Hierarchy data for nodes, stored in node insertion order (effectively depth-first). It can + /// be used to quickly find the parent of a node, or iterate over all children or descendants + /// of a node. + hierarchy: RefCell>, + ports_map: RefCell>, + override_map: Rc>>, + connected_map: Rc>>, + usage_type_map: Rc>>, + node_disabled: Cell, + tree_dirty: Cell, } -impl Model { +impl TreeModel { /// Create a new node widget, selecting the appropriate widget type based on the provided /// argument info. fn new(app: &Application) -> Self { let app = app.clone_ref(); let display_object = display::object::Instance::new(); - let kind = default(); - Self { app, display_object, kind_model: kind } + display_object.use_auto_layout(); + display_object.set_children_alignment_left_center().justify_content_center_y(); + display_object.set_size_y(NODE_HEIGHT); + display_object.set_padding_left(TEXT_OFFSET); + display_object.set_padding_right(TEXT_OFFSET); + + Self { + app, + display_object, + node_disabled: default(), + nodes_map: default(), + hierarchy: default(), + ports_map: default(), + override_map: default(), + connected_map: default(), + usage_type_map: default(), + tree_dirty: default(), + } } - #[profile(Task)] - fn set_widget_data(&self, frp: &SampledFrp, meta: &Option, node_data: &NodeData) { - const VECTOR_TYPE: &str = "Standard.Base.Data.Vector.Vector"; - let is_array_enabled = ARGS.groups.feature_preview.options.vector_editor.value; - let is_array_type = node_data.tp.as_ref().map_or(false, |tp| tp.contains(VECTOR_TYPE)); - let has_tag_values = !node_data.tag_values.is_empty(); - let kind_fallback = (is_array_enabled && is_array_type) - .then_some(Kind::VectorEditor) - .or(has_tag_values.then_some(Kind::SingleChoice)); - - let desired_kind = meta.as_ref().map(|m| m.kind).or(kind_fallback); - let current_kind = self.kind_model.borrow().as_ref().map(|m| m.kind()); - - if current_kind != desired_kind { - *self.kind_model.borrow_mut() = desired_kind.map(|desired_kind| { - KindModel::new(&self.app, &self.display_object, desired_kind, frp, meta, node_data) - }); - } else if let Some(model) = self.kind_model.borrow().as_ref() { - model.update(meta, node_data); + /// Mark dirty flag if the tree has been modified. Return true if the flag has been changed. + fn mark_dirty_flag(&self, modified: bool) -> bool { + if modified && !self.tree_dirty.get() { + self.tree_dirty.set(true); + true + } else { + false } } -} -impl Deref for View { - type Target = Frp; - fn deref(&self) -> &Self::Target { - self.frp() + /// Set the configuration under given key. It may cause the tree to be marked as dirty. + fn set_config_override(&self, pointer: OverrideKey, config: Option) -> bool { + let mut map = self.override_map.borrow_mut(); + let dirty = map.synchronize_entry(pointer, config); + self.mark_dirty_flag(dirty) } -} -impl display::Object for View { - fn display_object(&self) -> &display::object::Instance { - &self.model.display_object + /// Set the connection status under given widget. It may cause the tree to be marked as dirty. + fn set_connected(&self, crumbs: &span_tree::Crumbs, status: Option) -> bool { + let mut map = self.connected_map.borrow_mut(); + let dirty = map.synchronize_entry(crumbs.clone(), status); + self.mark_dirty_flag(dirty) } -} + /// Set the usage type of an expression. It may cause the tree to be marked as dirty. + fn set_usage_type(&self, ast_id: ast::Id, usage_type: Option) -> bool { + let mut map = self.usage_type_map.borrow_mut(); + let dirty = map.synchronize_entry(ast_id, usage_type); + self.mark_dirty_flag(dirty) + } + /// Set the connection status under given widget. It may cause the tree to be marked as dirty. + fn set_disabled(&self, disabled: bool) -> bool { + let prev_disabled = self.node_disabled.replace(disabled); + self.mark_dirty_flag(prev_disabled != disabled) + } -// ======================== -// === KindModel / Kind === -// ======================== + /// Get parent of a node under given pointer, if exists. + #[allow(dead_code)] + pub fn parent(&self, pointer: WidgetIdentity) -> Option { + let hierarchy = self.hierarchy.borrow(); + let nodes = self.nodes_map.borrow(); + let index = nodes.get(&pointer).map(|entry| entry.index)?; + let parent_index = hierarchy[index].parent_index?; + Some(hierarchy[parent_index].identity) + } -/// Possible widgets for a node input. -/// -/// Currently, all widget types are hardcoded. This is likely to be a temporary solution. In the -/// future the widget types might be user-defined, similar to visualizations. -#[derive(serde::Deserialize, Clone, Copy, Debug, PartialEq, Eq)] -pub enum Kind { - /// A widget for selecting a single value from a list of available options. - #[serde(rename = "Single_Choice")] - SingleChoice, - /// A widget for constructing and modifying vector of various types. - #[serde(rename = "Vector_Editor")] - VectorEditor, -} + /// Iterate children of a node under given pointer, if any exist. + #[allow(dead_code)] + pub fn iter_children( + &self, + pointer: WidgetIdentity, + ) -> impl Iterator + '_ { + let hierarchy = self.hierarchy.borrow(); + let nodes = self.nodes_map.borrow(); + let mut total_range = nodes.get(&pointer).map_or(0..0, |entry| { + let start = entry.index + 1; + let total_descendants = hierarchy[entry.index].total_descendants; + start..start + total_descendants + }); + + std::iter::from_fn(move || { + let index = total_range.next()?; + let entry = hierarchy[index]; + // Skip all descendants of the child. The range is now at the next direct child. + if entry.total_descendants > 0 { + total_range.nth(entry.total_descendants - 1); + } + Some(entry.identity) + }) + } -/// A part of widget model that is dependant on the widget kind. -#[derive(Debug)] -pub enum KindModel { - /// A widget for selecting a single value from a list of available options. - SingleChoice(SingleChoiceModel), - /// A widget for constructing and modifying vector of various types. - VectorEditor(vector_editor::Model), -} - -impl KindModel { - fn new( - app: &Application, - display_object: &display::object::Instance, - kind: Kind, - frp: &SampledFrp, - meta: &Option, - node_data: &NodeData, - ) -> Self { - let this = match kind { - Kind::SingleChoice => - Self::SingleChoice(SingleChoiceModel::new(app, display_object, frp)), - Kind::VectorEditor => - Self::VectorEditor(vector_editor::Model::new(app, display_object, frp)), + #[profile(Task)] + fn rebuild_tree( + &self, + frp: WidgetsFrp, + tree: &span_tree::SpanTree, + node_expression: &str, + styles: &StyleWatch, + ) { + self.tree_dirty.set(false); + let app = self.app.clone(); + let override_map = self.override_map.borrow(); + let connected_map = self.connected_map.borrow(); + let usage_type_map = self.usage_type_map.borrow(); + let old_nodes = self.nodes_map.take(); + let node_disabled = self.node_disabled.get(); + + // Old hierarchy is not used during the rebuild, so we might as well reuse the allocation. + let mut hierarchy = self.hierarchy.take(); + hierarchy.clear(); + + let mut builder = TreeBuilder { + app, + frp, + node_disabled, + node_expression, + styles, + override_map: &override_map, + connected_map: &connected_map, + usage_type_map: &usage_type_map, + old_nodes, + hierarchy, + pointer_usage: default(), + new_nodes: default(), + parent_info: default(), + last_ast_depth: default(), + extensions: default(), }; - this.update(meta, node_data); - this - } + let child = builder.child_widget(tree.root_ref(), default()); + self.display_object.replace_children(&[child]); - fn update(&self, meta: &Option, node_data: &NodeData) { - match self { - KindModel::SingleChoice(inner) => { - let dynamic_entries = meta.as_ref().map(|meta| meta.dynamic_entries.clone()); - let entries = dynamic_entries - .unwrap_or_else(|| node_data.tag_values.iter().map(Into::into).collect()); + self.nodes_map.replace(builder.new_nodes); + self.hierarchy.replace(builder.hierarchy); + let mut ports_map_borrow = self.ports_map.borrow_mut(); + ports_map_borrow.clear(); + ports_map_borrow.extend( + builder.pointer_usage.into_iter().filter_map(|(k, v)| Some((k, v.port_index?))), + ); + } - inner.set_port_size(node_data.port_size); - inner.set_entries(entries); - } - KindModel::VectorEditor(inner) => { - warn!("VectorEditor updated with metadata {meta:#?} and node data {node_data:#?}."); - inner.set_port_size.emit(node_data.port_size); + /// Convert span tree node to a representation with stable identity across rebuilds. Every node + /// in the span tree has a unique representation in the form of a [`StableSpanIdentity`], which + /// is more stable across changes in the span tree than [`span_tree::Crumbs`]. The pointer is + /// used to identify the widgets or ports in the widget tree. + pub fn get_node_widget_pointer(&self, span_node: &SpanRef) -> StableSpanIdentity { + if let Some(id) = span_node.ast_id { + // This span represents an AST node, return a pointer directly to it. + StableSpanIdentity::new(Some(id), &[]) + } else { + let root = span_node.span_tree.root_ref(); + let root_ast_data = root.ast_id.map(|id| (id, 0)); + + // When the node does not represent an AST node, its widget will be identified by the + // closest parent AST node, if it exists. We have to find the closest parent node with + // AST ID, and then calculate the relative crumbs from it to the current node. + let (_, ast_parent_data) = span_node.crumbs.into_iter().enumerate().fold( + (root, root_ast_data), + |(node, last_seen), (index, crumb)| { + let ast_data = node.node.ast_id.map(|id| (id, index)).or(last_seen); + (node.child(*crumb).expect("Node ref must be valid"), ast_data) + }, + ); + + match ast_parent_data { + // Parent AST node found, return a pointer relative to it. + Some((ast_id, ast_parent_index)) => { + let crumb_slice = &span_node.crumbs[ast_parent_index..]; + StableSpanIdentity::new(Some(ast_id), crumb_slice) + } + // No parent AST node found. Return a pointer from root. + None => StableSpanIdentity::new(None, &span_node.crumbs), } } } - fn kind(&self) -> Kind { - match self { - Self::SingleChoice(_) => Kind::SingleChoice, - Self::VectorEditor(_) => Kind::VectorEditor, - } + /// Perform an operation on a shared reference to a tree port under given pointer. When there is + /// no port under provided pointer, the operation will not be performed and `None` will be + /// returned. + pub fn with_port( + &self, + pointer: StableSpanIdentity, + f: impl FnOnce(&Port) -> T, + ) -> Option { + let index = *self.ports_map.borrow().get(&pointer)?; + let unique_ptr = WidgetIdentity { main: pointer, index }; + self.nodes_map.borrow().get(&unique_ptr).and_then(|n| n.node.port()).map(f) } } +/// State of a node in the widget tree. Provides additional information about the node's current +/// state, such as its depth in the widget tree, if it's connected, disabled, etc. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct NodeInfo { + /// Unique identifier of this node within this widget tree. + pub identity: WidgetIdentity, + /// Index of node in the widget tree, in insertion order. + pub insertion_index: usize, + /// Logical nesting level of this widget, which was specified by the parent node during its + /// creation. Determines the mouse hover area size and widget indentation. + pub nesting_level: NestingLevel, + /// Data associated with an edge connected to this node's span. Only present at the exact node + /// that is connected, not at any of its children. + pub connection: Option, + /// Data associated with an edge connected to this subtree. Contains the status of this node's + /// connection, or its first parent that is connected. It is the same as `connection` for nodes + /// that are directly connected. + pub subtree_connection: Option, + /// Whether the node is disabled, i.e. its expression is not currently used in the computation. + /// Widgets of disabled nodes are usually grayed out. + pub disabled: bool, + /// Inferred type of Enso expression at this node's span. May differ from the definition type + /// stored in the span tree. + pub usage_type: Option, +} +/// A collection of common data used by all widgets and ports in the widget tree during +/// configuration. Provides the main widget's interface to the tree builder, allowing for creating +/// child widgets. +#[derive(Debug)] +pub struct ConfigContext<'a, 'b> { + builder: &'a mut TreeBuilder<'b>, + /// The span tree node corresponding to the widget being configured. + pub(super) span_node: span_tree::node::Ref<'a>, + /// Additional state associated with configured widget tree node, such as its depth, connection + /// status or parent node information. + pub(super) info: NodeInfo, + /// The length of tree extensions vector before the widget was configured. Used to determine + /// which extensions were added by the widget parents, and which are new. + parent_extensions_len: usize, +} -// ====================== -// === Triangle Shape === -// ====================== +impl<'a, 'b> ConfigContext<'a, 'b> { + /// Get the application instance, in which the widget tree is being built. + pub fn app(&self) -> &Application { + &self.builder.app + } + + /// Get the FRP endpoints shared by all widgets and ports in this tree. + pub fn frp(&self) -> &WidgetsFrp { + &self.builder.frp + } + + /// Get the code expression fragment represented by the given byte range. Can be combined with + /// [`span_tree::node::Ref`]'s `span` method to get the expression of a given span tree node. + pub fn expression_at(&self, range: text::Range) -> &str { + &self.builder.node_expression[range] + } + + /// Get the `StyleWatch` used by this node. + pub fn styles(&self) -> &StyleWatch { + self.builder.styles + } + + /// Set an extension object of specified type at the current tree position. Any descendant + /// widget will be able to access it, as long as it can name its type. This allows for + /// configure-time communication between any widgets inside the widget tree. + pub fn set_extension(&mut self, val: T) { + let id = std::any::TypeId::of::(); + match self.self_extension_index_by_type(id) { + Some(idx) => *self.builder.extensions[idx].downcast_mut().unwrap() = val, + None => { + self.builder.extensions.push(Box::new(val)); + } + } + } + + /// Get an extension object of specified type at the current tree position. The extension object + /// must have been created by any parent widget up in the hierarchy. If it does not exist, this + /// method will return `None`. + /// + /// See also: [`ConfigContext::get_extension_or_default`], [`ConfigContext::modify_extension`]. + pub fn get_extension(&self) -> Option<&T> { + self.any_extension_index_by_type(std::any::TypeId::of::()) + .map(|idx| self.builder.extensions[idx].downcast_ref().unwrap()) + } + + /// Get a clone of provided extension value, or a default value if it was not provided. + /// + /// See also: [`ConfigContext::get_extension`]. + pub fn get_extension_or_default(&self) -> T { + self.get_extension().map_or_default(Clone::clone) + } -/// Temporary dropdown activation shape definition. -pub mod triangle { - use super::*; - ensogl::shape! { - above = [ - crate::component::node::background, - crate::component::node::input::port::hover - ]; - alignment = center; - (style:Style, color:Vector4) { - let size = Var::canvas_size(); - let radius = 1.0.px(); - let shrink = &radius * 2.0; - let shape = Triangle(size.x() - &shrink, size.y() - &shrink) - .flip_y() - .grow(radius); - shape.fill(color).into() + /// Modify an extension object of specified type at the current tree position. The modification + /// will only be visible to the descendants of this widget, even if the extension was added + /// by one of its parents. + /// + /// See also: [`ConfigContext::get_extension`]. + pub fn modify_extension(&mut self, f: impl FnOnce(&mut T)) + where T: Any + Default + Clone { + match self.any_extension_index_by_type(std::any::TypeId::of::()) { + // This extension has been created by this widget, so we can modify it directly. + Some(idx) if idx >= self.parent_extensions_len => { + f(self.builder.extensions[idx].downcast_mut().unwrap()); + } + // The extension exist, but has been created by one of the parents. We need to clone it. + Some(idx) => { + let mut val: T = self.builder.extensions[idx].downcast_mut::().unwrap().clone(); + f(&mut val); + self.builder.extensions.push(Box::new(val)); + } + // The extension does not exist yet, so we need to create it from scratch. + None => { + let mut val = T::default(); + f(&mut val); + self.builder.extensions.push(Box::new(val)); + } } } + + fn any_extension_index_by_type(&self, id: std::any::TypeId) -> Option { + self.builder.extensions.iter().rposition(|ext| ext.deref().type_id() == id) + } + + fn self_extension_index_by_type(&self, id: std::any::TypeId) -> Option { + let self_extensions = &self.builder.extensions[self.parent_extensions_len..]; + self_extensions.iter().rposition(|ext| ext.deref().type_id() == id) + } } // ==================== -// === SingleChoice === +// === NestingLevel === // ==================== -/// A widget for selecting a single value from a list of available options. The options can be -/// provided as a static list of strings from argument `tag_values`, or as a dynamic expression. -#[derive(Debug)] -pub struct SingleChoiceModel { - #[allow(dead_code)] - network: frp::Network, - dropdown: Rc>, - /// temporary click handling - activation_shape: triangle::View, -} - -impl SingleChoiceModel { - fn new( - app: &Application, - display_object: &display::object::Instance, - frp: &SampledFrp, - ) -> Self { - let activation_shape = triangle::View::new(); - activation_shape.set_size(ACTIVATION_SHAPE_SIZE); - display_object.add_child(&activation_shape); - - frp::new_network! { network - init <- source_(); - let focus_in = display_object.on_event::(); - let focus_out = display_object.on_event::(); - is_focused <- bool(&focus_out, &focus_in); - is_open <- frp.set_visible && is_focused; - is_open <- is_open.sampler(); - }; +/// A logical nesting level associated with a widget which determines the mouse hover area size and +/// widget indentation. It is specified by the parent widget when creating a child widget, as an +/// argument to the '[`ConfigContext`]' method. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct NestingLevel { + level: usize, +} - let set_current_value = frp.set_current_value.clone_ref(); - let dropdown_output = frp.out_value_changed.clone_ref(); - let request_import = frp.out_request_import.clone_ref(); - let dropdown = LazyDropdown::new( - app, - display_object, - set_current_value, - is_open, - dropdown_output, - request_import, - ); - let dropdown = Rc::new(RefCell::new(dropdown)); +impl NestingLevel { + /// Create a deeper nesting level. The depth of the new level will be one greater than the + /// current one. + pub fn next(self) -> Self { + Self { level: self.level + 1 } + } - frp::extend! { network - clicked <- activation_shape.events_deprecated.mouse_down_primary.gate_not(&frp.set_read_only); - toggle_focus <- clicked.map(f!([display_object](()) !display_object.is_focused())); - set_focused <- any(toggle_focus, frp.set_focused); - eval set_focused([display_object](focus) match focus { - true => display_object.focus(), - false => display_object.blur(), - }); - - set_visible <- all(&frp.set_visible, &init)._0(); - shape_alpha <- set_visible.map(|visible| if *visible { 1.0 } else { 0.0 }); - shape_color <- shape_alpha.map(|a| ACTIVATION_SHAPE_COLOR.with_alpha(*a)); - eval shape_color([activation_shape] (color) { - activation_shape.color.set(color::Rgba::from(color).into()); - }); - - eval focus_in((_) dropdown.borrow_mut().initialize_on_open()); - } + /// Create an optionally deeper nesting level. When `condition` is `false`, the nesting level + /// will remain the same. + pub fn next_if(self, condition: bool) -> Self { + condition.as_some(self.next()).unwrap_or(self) + } + + /// Check if a port at this nesting level is still considered primary. Primary ports have wider + /// hover areas and are indented more. + #[allow(clippy::absurd_extreme_comparisons)] + pub fn is_primary(self) -> bool { + self.level <= PRIMARY_PORT_MAX_NESTING_LEVEL + } +} + + +// =========================================== +// === StableSpanIdentity / WidgetIdentity === +// =========================================== + +/// A stable identifier to a span tree node. Uniquely determines a main widget of specific node in +/// the span tree. It is a base of a widget stable identity, and allows widgets to be reused when +/// rebuilding the tree. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct StableSpanIdentity { + /// AST ID of either the node itself, or the closest ancestor node which has one. Is [`None`] + /// when there is no such parent with assigned AST id. + ast_id: Option, + /// A hash of remaining data used to distinguish between tree nodes. We store a hash instead of + /// the data directly, so the type can be trivially copied. The collision is extremely unlikely + /// due to u64 being extremely large hash space, compared to the size of the used data. Many + /// nodes are also already fully distinguished by the AST ID alone. + /// + /// Currently we are hashing a portion of span-tree crumbs, starting from the closest node with + /// assigned AST id up to this node. The widgets should not rely on the exact kind of data + /// used, as it may be extended to include more information in the future. + identity_hash: u64, +} + +impl StableSpanIdentity { + fn new(ast_id: Option, crumbs_since_ast: &[span_tree::Crumb]) -> Self { + let mut hasher = DefaultHasher::new(); + crumbs_since_ast.hash(&mut hasher); + let identity_hash = hasher.finish(); + Self { ast_id, identity_hash } + } - init.emit(()); + /// Convert this pointer to a stable identity of a widget, making it unique among all widgets. + fn to_identity(self, usage: &mut PointerUsage) -> WidgetIdentity { + WidgetIdentity { main: self, index: usage.next_index() } + } +} + +/// An unique identity of a widget in the widget tree. It is a combination of a [`SpanIdentity`] and +/// a sequential index of the widget assigned to the same span tree node. Any widget is allowed to +/// create a child widget on the same span tree node, so we need to be able to distinguish between +/// them. Note that only one widget created for a given span tree node will be able to receive a +/// port. The port is assigned to the first widget at given span that wants to receive it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deref)] +pub struct WidgetIdentity { + /// The pointer to the main widget of this widget's node. + #[deref] + main: StableSpanIdentity, + /// The sequential index of a widget assigned to the same span tree node. + index: usize, +} - Self { network, dropdown, activation_shape } +impl WidgetIdentity { + /// Whether this widget pointer represents first created widget for its span tree node. + fn is_first_widget_of_span(&self) -> bool { + self.index == 0 } +} + +/// Additional information about the usage of a widget pointer while building a tree. This is used +/// to determine which widget should receive a port, and to assign sequential indices to widgets +/// created for the same span tree node. Used to transform ambiguous [`SpanIdentity`] into +/// unique [`WidgetIdentity`]. +#[derive(Debug, Default)] +struct PointerUsage { + /// Next sequence index that will be assigned to a widget created for the same span tree node. + next_index: usize, + /// The pointer index of a widget on this span tree that received a port, if any exist already. + port_index: Option, +} - fn set_port_size(&self, port_size: Vector2) { - self.activation_shape.set_x(port_size.x() / 2.0); - self.activation_shape - .set_y(-port_size.y() / 2.0 - ACTIVATION_SHAPE_SIZE.y() - ACTIVATION_SHAPE_Y_OFFSET); - self.dropdown.borrow_mut().set_port_size(port_size); +impl PointerUsage { + fn next_index(&mut self) -> usize { + self.next_index += 1; + self.next_index - 1 } - fn set_entries(&self, entries: Vec) { - self.dropdown.borrow_mut().set_entries(entries); + fn request_port(&mut self, identity: &WidgetIdentity, wants_port: bool) -> bool { + let will_receive_port = wants_port && self.port_index.is_none(); + will_receive_port.then(|| self.port_index = Some(identity.index)); + will_receive_port } } -// ==================== -// === LazyDropdown === -// ==================== +// =================== +// === TreeBuilder === +// =================== -/// A lazy dropdown that is only initialized when it is opened for the first time. This prevents -/// very long initialization time, as dropdown view creation is currently a very slow process. -/// -/// FIXME [PG]: Improve grid-view creation performance, so that this is no longer needed. -/// https://www.pivotaltracker.com/story/show/184223891 -/// -/// Once grid-view creation is reasonably fast, this might be replaced by direct dropdown -/// initialization on widget creation. +/// A builder for the widget tree. Maintains transient state necessary during the tree construction, +/// and provides methods for creating child nodes of the tree. Maintains a map of all widgets +/// created so far, and is able to reuse existing widgets under the same location in the tree, only +/// updating their configuration as necessary. #[derive(Debug)] -enum LazyDropdown { - NotInitialized { - app: Application, - display_object: display::object::Instance, - dropdown_y: f32, - entries: Vec, - set_current_value: frp::Sampler>, - is_open: frp::Sampler, - output_value: frp::Any>, - request_import: frp::Any, - }, - Initialized { - _network: frp::Network, - dropdown: Dropdown, - set_entries: frp::Any>, - }, -} - -impl LazyDropdown { - fn new( - app: &Application, - display_object: &display::object::Instance, - set_current_value: frp::Sampler>, - is_open: frp::Sampler, - output_value: frp::Any>, - request_import: frp::Any, - ) -> Self { - let app = app.clone_ref(); - let display_object = display_object.clone_ref(); - let dropdown_y = default(); - let entries = default(); - LazyDropdown::NotInitialized { - app, - display_object, - dropdown_y, - entries, - set_current_value, - is_open, - output_value, - request_import, - } +struct TreeBuilder<'a> { + app: Application, + frp: WidgetsFrp, + node_disabled: bool, + node_expression: &'a str, + styles: &'a StyleWatch, + override_map: &'a HashMap, + connected_map: &'a HashMap, + usage_type_map: &'a HashMap, + old_nodes: HashMap, + new_nodes: HashMap, + hierarchy: Vec, + pointer_usage: HashMap, + parent_info: Option, + last_ast_depth: usize, + extensions: Vec>, +} + +impl<'a> TreeBuilder<'a> { + /// Create a new child widget, along with its whole subtree. The widget type will be + /// automatically inferred, either based on the node kind, or on the configuration provided + /// from the language server. If possible, an existing widget will be reused under the same + /// location in the tree, only updating its configuration as necessary. If no widget can be + /// reused, a new one will be created. + /// + /// The root display object of the created widget will be returned, and it must be inserted into + /// the display object hierarchy by the caller. It will be common that the returned display + /// object will frequently not change between subsequent widget `configure` calls, but it will + /// eventually happen if the child widget is relocated or changed its type. The caller must be + /// prepared to handle that situation, and never rely on it not changing. In order to handle + /// that efficiently, the caller can use the `replace_children` method of + /// [`display::object::InstanceDef`], which will only perform hierarchy updates if the children + /// list has been actually modified. + #[must_use] + pub fn child_widget( + &mut self, + span_node: span_tree::node::Ref<'_>, + nesting_level: NestingLevel, + ) -> display::object::Instance { + self.child_widget_of_type(span_node, nesting_level, None) } - fn set_port_size(&mut self, new_port_size: Vector2) { - let y = -new_port_size.y() - DROPDOWN_Y_OFFSET; - match self { - LazyDropdown::Initialized { dropdown, .. } => { - dropdown.set_y(y); + /// Create a new widget for given span tree node, recursively building a subtree of its + /// children. When a widget configuration is not provided, it is inferred automatically from the + /// span tree and expression value type. + /// + /// The returned value contains a root display object of created widget child, and it must be + /// inserted into the display hierarchy by the caller. The returned display object will + /// frequently not change between subsequent widget `configure` calls, as long as it can be + /// reused by the tree. The caller must not rely on it not changing. In order to handle that + /// efficiently, the caller can use the `replace_children` method of + /// [`display::object::InstanceDef`], which will only perform hierarchy updates if the children + /// list has been actually modified. + pub fn child_widget_of_type( + &mut self, + span_node: span_tree::node::Ref<'_>, + nesting_level: NestingLevel, + configuration: Option<&Configuration>, + ) -> display::object::Instance { + // This call can recurse into itself within the widget configuration logic. We need to save + // the current layer's state, so it can be restored later after visiting the child node. + let parent_last_ast_depth = self.last_ast_depth; + let depth = span_node.crumbs.len(); + + // Figure out the widget tree pointer for the current node. That pointer determines the + // widget identity, allowing it to maintain internal state. If the previous tree already + // contained a widget for this pointer, we have to reuse it. + let main_ptr = match span_node.ast_id { + Some(ast_id) => { + self.last_ast_depth = depth; + StableSpanIdentity::new(Some(ast_id), &[]) } - LazyDropdown::NotInitialized { dropdown_y, .. } => { - *dropdown_y = y; + None => { + let ast_id = self.parent_info.as_ref().and_then(|st| st.identity.main.ast_id); + let this_crumbs = &span_node.crumbs; + let crumbs_since_id = &this_crumbs[parent_last_ast_depth..]; + StableSpanIdentity::new(ast_id, crumbs_since_id) } - } - } + }; - fn set_entries(&mut self, new_entries: Vec) { - match self { - LazyDropdown::Initialized { set_entries, .. } => { - set_entries.emit(new_entries); - } - LazyDropdown::NotInitialized { entries, .. } => { - *entries = new_entries; + let ptr_usage = self.pointer_usage.entry(main_ptr).or_default(); + let widget_id = main_ptr.to_identity(ptr_usage); + + let is_placeholder = span_node.is_expected_argument(); + let sibling_offset = span_node.sibling_offset.as_usize(); + let usage_type = main_ptr.ast_id.and_then(|id| self.usage_type_map.get(&id)).cloned(); + + // Get widget configuration. There are three potential sources for configuration, that are + // used in order, whichever is available first: + // 1. The `config_override` argument, which can be set by the parent widget if it wants to + // override the configuration for its child. + // 2. The override stored in the span tree node, located using `OverrideKey`. This can be + // set by an external source, e.g. based on language server. + // 3. The default configuration for the widget, which is determined based on the node kind, + // usage type and whether it has children. + let kind = &span_node.kind; + let config_override = || { + self.override_map.get(&OverrideKey { + call_id: kind.call_id()?, + argument_name: kind.argument_name()?.into(), + }) + }; + let inferred_config; + let configuration = match configuration.or_else(config_override) { + Some(config) => config, + None => { + let ty = usage_type.clone(); + inferred_config = Configuration::from_node(&span_node, ty, self.node_expression); + &inferred_config } - } - } + }; - #[profile(Detail)] - fn initialize_on_open(&mut self) { - match self { - LazyDropdown::Initialized { .. } => {} - LazyDropdown::NotInitialized { - app, - display_object, - dropdown_y, - entries, - is_open, - set_current_value, - output_value, - request_import, - } => { - let dropdown = app.new_view::>(); - display_object.add_child(&dropdown); - app.display.default_scene.layers.above_nodes.add(&dropdown); - dropdown.set_y(*dropdown_y); - dropdown.set_max_open_size(Vector2(300.0, 500.0)); - dropdown.allow_deselect_all(true); - - frp::new_network! { network - init <- source_(); - set_entries <- any(...); - - dropdown.set_all_entries <+ set_entries; - entries_and_value <- all(&set_entries, set_current_value); - entries_and_value <- entries_and_value.debounce(); - - selected_entry <- entries_and_value.map(|(e, v)| entry_for_current_value(e, v)); - dropdown.set_selected_entries <+ selected_entry.map(|e| e.iter().cloned().collect()); - - dropdown_entry <- dropdown.selected_entries.map(|e| e.iter().next().cloned()); - // Emit the output value only after actual user action. This prevents the - // dropdown from emitting its initial value when it is opened, which can - // represent slightly different version of code than actually written. - submitted_entry <- dropdown_entry.sample(&dropdown.user_select_action); - dropdown_out_value <- submitted_entry.map(|e| e.as_ref().map(Entry::value)); - dropdown_out_import <- submitted_entry.map(|e| e.as_ref().and_then(Entry::required_import)); - request_import <+ dropdown_out_import.unwrap(); - output_value <+ dropdown_out_value.sample(&dropdown.user_select_action); - - is_open <- all(is_open, &init)._0(); - dropdown.set_open <+ is_open.on_change(); - - // Close the dropdown after a short delay after selection. Because the dropdown - // value application triggers operations that can introduce a few dropped frames, - // we want to delay the dropdown closing animation after that is handled. - // Otherwise the animation finishes within single frame, which looks bad. - let close_after_selection_timer = frp::io::timer::Timeout::new(&network); - close_after_selection_timer.restart <+ dropdown.user_select_action.constant(1); - eval close_after_selection_timer.on_expired((()) display_object.blur()); - } + let widget_has_port = ptr_usage.request_port(&widget_id, configuration.has_port); + + let insertion_index = self.hierarchy.len(); + self.hierarchy.push(NodeHierarchy { + identity: widget_id, + parent_index: self.parent_info.as_ref().map(|info| info.insertion_index), + // This will be updated later, after the child widgets are created. + total_descendants: 0, + }); + + let old_node = self.old_nodes.remove(&widget_id).map(|e| e.node); + + // Prepare the widget node info and build context. + let connection_color = self.connected_map.get(&span_node.crumbs); + let connection = connection_color.map(|&color| EdgeData { color, depth }); + let parent_connection = self.parent_info.as_ref().and_then(|info| info.connection); + let subtree_connection = connection.or(parent_connection); + + let disabled = self.node_disabled; + let info = NodeInfo { + identity: widget_id, + insertion_index, + nesting_level, + connection, + subtree_connection, + disabled, + usage_type, + }; - set_entries.emit(std::mem::take(entries)); - init.emit(()); - *self = LazyDropdown::Initialized { _network: network, dropdown, set_entries }; - } + let parent_info = std::mem::replace(&mut self.parent_info, Some(info.clone())); + let parent_extensions_len = self.extensions.len(); + + let ctx = ConfigContext { builder: &mut *self, span_node, info, parent_extensions_len }; + let app = ctx.app(); + let frp = ctx.frp(); + + // Widget creation/update can recurse into the builder. All borrows must be dropped + // at this point. The `configure` calls on the widgets are allowed to call back into the + // tree builder in order to create their child widgets. Those calls will change builder's + // state to reflect the correct parent node. We need to restore the state after the + // `configure` call has been done, so that the next sibling node will receive correct parent + // data. + let child_node = if widget_has_port { + let mut port = match old_node { + Some(TreeNode::Port(port)) => port, + Some(TreeNode::Widget(widget)) => Port::new(widget, app, frp), + None => Port::new(DynWidget::new(&configuration.kind, &ctx), app, frp), + }; + port.configure(&configuration.kind, ctx); + TreeNode::Port(port) + } else { + let mut widget = match old_node { + Some(TreeNode::Port(port)) => port.into_widget(), + Some(TreeNode::Widget(widget)) => widget, + None => DynWidget::new(&configuration.kind, &ctx), + }; + widget.configure(&configuration.kind, ctx); + TreeNode::Widget(widget) + }; + + // Once the node has been configured and all its children have been created, we can update + // the hierarchy data. + self.hierarchy[insertion_index].total_descendants = + self.hierarchy.len() - insertion_index - 1; + + // After visiting child node, restore previous layer's parent data. + self.parent_info = parent_info; + self.last_ast_depth = parent_last_ast_depth; + self.extensions.truncate(parent_extensions_len); + + // Apply left margin to the widget, based on its offset relative to the previous sibling. + let child_root = child_node.display_object().clone(); + let offset = match () { + _ if !widget_id.is_first_widget_of_span() => 0, + _ if is_placeholder => 1, + _ => sibling_offset, + }; + + let left_margin = offset as f32 * WIDGET_SPACING_PER_OFFSET; + if child_root.margin().x.start.as_pixels().map_or(true, |px| px != left_margin) { + child_root.set_margin_left(left_margin); } + + let entry = TreeEntry { node: child_node, index: insertion_index }; + self.new_nodes.insert(widget_id, entry); + child_root } } -fn entry_for_current_value( - all_entries: &[Entry], - current_value: &Option, -) -> Option { - let current_value = current_value.clone()?; - let found_entry = all_entries.iter().find(|entry| entry.value.as_ref() == current_value); - let with_partial_match = found_entry.or_else(|| { - // Handle parentheses in current value. Entries with parenthesized expressions will match if - // they start with the same expression as the current value. That way it is still matched - // once extra arguments are added to the nested function call. - if current_value.starts_with('(') { - let current_value = current_value.trim_start_matches('(').trim_end_matches(')'); - all_entries.iter().find(|entry| { - let trimmed_value = entry.value.trim_start_matches('(').trim_end_matches(')'); - current_value.starts_with(trimmed_value) - }) - } else { - None - } - }); - let with_fallback = - with_partial_match.cloned().unwrap_or_else(|| Entry::from_value(current_value.clone())); - Some(with_fallback) + +// ============= +// === Child === +// ============= + +/// A child structure returned from the tree builder. Contains information about just built widget, +/// which might be useful for the parent widget in order to correctly place it in its view +/// hierarchy. +#[derive(Debug, Clone, Deref)] +struct Child { + /// The widget identity that is stable across rebuilds. The parent might use it to associate + /// internal state with any particular child. When a new child is inserted between two existing + /// children, their identities will be maintained. + #[allow(dead_code)] + pub id: WidgetIdentity, + /// The root object of the widget. In order to make the widget visible, it must be added to the + /// parent's view hierarchy. Every time a widget is [`configure`d], its root object may change. + /// The parent must not assume ownership over a root object of a removed child. The widget + /// [`Tree`] is allowed to reuse any widgets and insert them into different branches. + /// + /// [`configure`d]: SpanWidget::configure + #[deref] + pub root_object: display::object::Instance, } diff --git a/app/gui/view/graph-editor/src/component/node/input/widget/hierarchy.rs b/app/gui/view/graph-editor/src/component/node/input/widget/hierarchy.rs new file mode 100644 index 000000000000..87413daf4126 --- /dev/null +++ b/app/gui/view/graph-editor/src/component/node/input/widget/hierarchy.rs @@ -0,0 +1,56 @@ +//! Definition of default hierarchy widget. This widget expands each child of its span tree into +//! a new widget. + +use crate::prelude::*; + +use ensogl::display::object; + + + +// =============== +// === Aliases === +// =============== + +/// A collection type used to collect a temporary list of node child widget roots, so that they can +/// be passed to `replace_children` method in one go. Avoids allocation for small number of +/// children, but also doesn't consume too much stack memory to avoid stack overflow in deep widget +/// hierarchies. +pub type CollectedChildren = SmallVec<[object::Instance; 4]>; + + + +// ================= +// === Hierarchy === +// ================= + +/// Label widget configuration options. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Config; + +/// Hierarchy widget. This widget expands each child of its span tree into a new widget. +#[derive(Clone, Debug)] +pub struct Widget { + display_object: object::Instance, +} + +impl super::SpanWidget for Widget { + type Config = Config; + + fn root_object(&self) -> &object::Instance { + &self.display_object + } + + fn new(_: &Config, _: &super::ConfigContext) -> Self { + let display_object = object::Instance::new(); + display_object.use_auto_layout(); + display_object.set_children_alignment_left_center().justify_content_center_y(); + Self { display_object } + } + + fn configure(&mut self, _: &Config, ctx: super::ConfigContext) { + let child_level = ctx.info.nesting_level.next_if(ctx.span_node.is_argument()); + let children_iter = ctx.span_node.children_iter(); + let children = children_iter.map(|node| ctx.builder.child_widget(node, child_level)); + self.display_object.replace_children(&children.collect::()); + } +} diff --git a/app/gui/view/graph-editor/src/component/node/input/widget/insertion_point.rs b/app/gui/view/graph-editor/src/component/node/input/widget/insertion_point.rs new file mode 100644 index 000000000000..3aa096cbafbd --- /dev/null +++ b/app/gui/view/graph-editor/src/component/node/input/widget/insertion_point.rs @@ -0,0 +1,42 @@ +//! Definition of empty widget that represents insertion point, which is a span node representing +//! a position where a new expression can be inserted. Does not correspond to any AST, but instead +//! is placed between spans for AST nodes. It is often used as an temporary edge endpoint when +//! dragging an edge. +//! +//! See also [`span_tree::node::InsertionPoint`]. + +use crate::prelude::*; + +use ensogl::display::object; + + + +// ====================== +// === InsertionPoint === +// ====================== + +/// Insertion point widget configuration options. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Config; + + +/// Insertion point widget. Displays nothing. +#[derive(Clone, Debug)] +pub struct Widget { + root: object::Instance, +} + +impl super::SpanWidget for Widget { + type Config = Config; + + fn root_object(&self) -> &object::Instance { + &self.root + } + + fn new(_: &Config, _: &super::ConfigContext) -> Self { + let root = object::Instance::new(); + Self { root } + } + + fn configure(&mut self, _: &Config, _: super::ConfigContext) {} +} diff --git a/app/gui/view/graph-editor/src/component/node/input/widget/label.rs b/app/gui/view/graph-editor/src/component/node/input/widget/label.rs new file mode 100644 index 000000000000..b8b3caba7d59 --- /dev/null +++ b/app/gui/view/graph-editor/src/component/node/input/widget/label.rs @@ -0,0 +1,178 @@ +//! Definition of static text label widget. + +use crate::prelude::*; + +use crate::component::node::input::area::TEXT_SIZE; + +use ensogl::data::color; +use ensogl::display::object; +use ensogl::display::shape::StyleWatch; +use ensogl_component::text; +use ensogl_hardcoded_theme as theme; + + + +// ============= +// === Label === +// ============= + +/// Label widget configuration options. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Config; + +ensogl::define_endpoints_2! { + Input { + content(ImString), + text_color(ColorState), + text_weight(text::Weight), + crumbs(span_tree::Crumbs), + } +} + +/// Label widget. Always displays the span tree node's expression as text. +#[derive(Clone, Debug)] +pub struct Widget { + frp: Frp, + root: object::Instance, + #[allow(dead_code)] + label: text::Text, +} + +impl super::SpanWidget for Widget { + type Config = Config; + + fn root_object(&self) -> &object::Instance { + &self.root + } + + fn new(_: &Config, ctx: &super::ConfigContext) -> Self { + // Embed the label in a vertically centered fixed height container, so that the label's + // baseline is properly aligned to center and lines up with other labels in the line. + let app = ctx.app(); + let widgets_frp = ctx.frp(); + let layers = &ctx.app().display.default_scene.layers; + let root = object::Instance::new(); + root.set_size_y(TEXT_SIZE); + let label = text::Text::new(app); + label.set_property_default(text::Size(TEXT_SIZE)); + label.set_y(TEXT_SIZE); + layers.label.add(&label); + root.add_child(&label); + let frp = Frp::new(); + let network = &frp.network; + + let styles = ctx.styles(); + frp::extend! { network + parent_port_hovered <- widgets_frp.on_port_hover.map2(&frp.crumbs, |h, crumbs| { + h.on().map_or(false, |h| crumbs.starts_with(h)) + }); + label_color <- frp.text_color.all_with4( + &parent_port_hovered, &widgets_frp.set_view_mode, &widgets_frp.set_profiling_status, + f!([styles](state, hovered, mode, status) { + state.to_color(*hovered, *mode, *status, &styles) + }) + ); + + label_color <- label_color.on_change(); + label_weight <- frp.text_weight.on_change(); + eval label_color((color) label.set_property_default(color)); + eval label_weight((weight) label.set_property_default(weight)); + content_change <- frp.content.on_change(); + eval content_change((content) label.set_content(content)); + + width <- label.width.on_change(); + eval width((w) root.set_size_x(*w); ); + } + + Self { frp, root, label } + } + + fn configure(&mut self, _: &Config, ctx: super::ConfigContext) { + let is_placeholder = ctx.span_node.is_expected_argument(); + + let content = if is_placeholder { + ctx.span_node.kind.argument_name().unwrap_or_default() + } else { + ctx.expression_at(ctx.span_node.span()) + }; + + let is_connected = ctx.info.subtree_connection.is_some(); + let color_state = match () { + _ if is_connected => ColorState::Connected, + _ if ctx.info.disabled => ColorState::Disabled, + _ if is_placeholder => ColorState::Placeholder, + _ => { + let span_node_type = ctx.span_node.kind.tp(); + let usage_type = ctx.info.usage_type.clone(); + let ty = usage_type.or_else(|| span_node_type.map(|t| crate::Type(t.into()))); + let color = crate::type_coloring::compute_for_code(ty.as_ref(), ctx.styles()); + ColorState::FromType(color) + } + }; + + let ext = ctx.get_extension_or_default::(); + let text_weight = if ext.bold { text::Weight::Bold } else { text::Weight::Normal }; + let input = &self.frp.public.input; + input.content.emit(content); + input.text_color.emit(color_state); + input.text_weight(text_weight); + input.crumbs.emit(ctx.span_node.crumbs.clone()); + } +} + + + +// ================= +// === Extension === +// ================= + +/// Label extension data that can be set by any of the parent widgets. +#[derive(Clone, Copy, Debug, Default)] +pub struct Extension { + /// Display all descendant labels with bold text weight. + pub bold: bool, +} + + + +// ================== +// === ColorState === +// ================== + +/// Configured color state of a label widget. +#[allow(missing_docs)] +#[derive(Debug, Clone, Copy, Default)] +pub enum ColorState { + #[default] + Connected, + Disabled, + Placeholder, + FromType(color::Lcha), +} + +impl ColorState { + fn to_color( + self, + is_hovered: bool, + view_mode: crate::view::Mode, + status: crate::node::profiling::Status, + styles: &StyleWatch, + ) -> color::Lcha { + use theme::code::syntax; + let profiling_mode = view_mode.is_profiling(); + let profiled = profiling_mode && status.is_finished(); + let color_path = match self { + _ if is_hovered => theme::code::types::selected, + ColorState::Connected => theme::code::types::selected, + ColorState::Disabled if profiled => syntax::profiling::disabled, + ColorState::Placeholder if profiled => syntax::profiling::expected, + ColorState::Disabled => syntax::disabled, + ColorState::Placeholder => syntax::expected, + ColorState::FromType(_) if profiled => syntax::profiling::base, + ColorState::FromType(_) if profiling_mode => syntax::base, + ColorState::FromType(typed) => return typed, + }; + + styles.get_color(color_path).into() + } +} diff --git a/app/gui/view/graph-editor/src/component/node/input/widget/vector_editor.rs b/app/gui/view/graph-editor/src/component/node/input/widget/list_editor.rs similarity index 50% rename from app/gui/view/graph-editor/src/component/node/input/widget/vector_editor.rs rename to app/gui/view/graph-editor/src/component/node/input/widget/list_editor.rs index 2a66f0dd2390..bc96b33d0683 100644 --- a/app/gui/view/graph-editor/src/component/node/input/widget/vector_editor.rs +++ b/app/gui/view/graph-editor/src/component/node/input/widget/list_editor.rs @@ -1,28 +1,37 @@ -//! Module dedicated to the Vector Editor widget. The main structure is [`Model`] which is one of +//! Module dedicated to the List Editor widget. The main structure is [`Model`] which is one of //! the [KindModel](crate::component::node::widget::KindModel) variants. //! -//! Currently the view is a simle [`Elements`] component, which will be replaced with a rich +//! Currently the view is a simple [`Elements`] component, which will be replaced with a rich //! view in [future tasks](https://github.com/enso-org/enso/issues/5631). use crate::prelude::*; -use crate::component::node::input::widget::triangle; -use crate::component::node::input::widget::SampledFrp; -use crate::component::node::input::widget::ACTIVATION_SHAPE_COLOR; -use crate::component::node::input::widget::ACTIVATION_SHAPE_SIZE; +use crate::component::node::input::widget::single_choice::triangle; +use crate::component::node::input::widget::single_choice::ACTIVATION_SHAPE_SIZE; +use crate::component::node::input::widget::Configuration; +use crate::component::node::input::widget::WidgetsFrp; use ensogl::application::Application; use ensogl::control::io::mouse; -use ensogl::data::color; use ensogl::display; +use ensogl::display::object::event; +use ensogl::display::shape::StyleWatch; use ensogl_component::list_editor::ListEditor; use ensogl_component::text::Text; +use ensogl_hardcoded_theme as theme; -// ============= -// === Model === -// ============= +// ============== +// === Widget === +// ============== + +ensogl::define_endpoints_2! { + Input { + current_value(Option), + current_crumbs(span_tree::Crumbs), + } +} /// A model for the vector editor widget. /// @@ -33,29 +42,32 @@ use ensogl_component::text::Text; /// The component does not handle nested arrays well. They should be fixed once [integrated into /// new widget hierarchy](https://github.com/enso-org/enso/issues/5923). #[derive(Clone, CloneRef, Debug)] -pub struct Model { - network: frp::Network, - display_object: display::object::Instance, - list_container: display::object::Instance, - activation_shape: triangle::View, - list: ListEditor, - /// FRP input informing about the port size. - pub set_port_size: frp::Source, +pub struct Widget { + config_frp: Frp, + display_object: display::object::Instance, + child_container: display::object::Instance, + list_container: display::object::Instance, + activation_shape: triangle::View, + list: ListEditor, } -impl Model { - /// A gap between the `activation_shape` and `elements` view. +impl Widget { + /// A gap between the `activation_shape` and `elements` view. const GAP: f32 = 3.0; /// Create Model for Vector Editor widget. - pub fn new(app: &Application, parent: &display::object::Instance, frp: &SampledFrp) -> Self { - let network = frp::Network::new("vector_editor::Model"); + pub fn new(app: &Application, widgets_frp: &WidgetsFrp, styles: &StyleWatch) -> Self { let display_object = display::object::Instance::new(); let list_container = display::object::Instance::new(); + let child_container = display::object::Instance::new(); let activation_shape = triangle::View::new(); let list = ListEditor::new(&app.cursor); + let toggle_color = styles.get_color(theme::widget::activation_shape::connected); activation_shape.set_size(ACTIVATION_SHAPE_SIZE); + activation_shape.color.set(toggle_color.into()); + + display_object.add_child(&child_container); display_object.add_child(&list_container); display_object.add_child(&activation_shape); display_object @@ -64,58 +76,51 @@ impl Model { .set_gap_y(Self::GAP) .set_children_alignment_center(); display_object.set_size_hug(); - parent.add_child(&display_object); - - frp::extend! { network - set_port_size <- source::(); - } - Self { network, display_object, list_container, activation_shape, list, set_port_size } - .init_toggle(frp) - .init_list_updates(app, frp) - .init_port_size_update() + let config_frp = Frp::new(); + Self { config_frp, display_object, child_container, list_container, activation_shape, list } + .init_toggle(widgets_frp) + .init_list_updates(app, widgets_frp) } - fn init_toggle(self, frp: &SampledFrp) -> Self { - let network = &self.network; + fn init_toggle(self, widgets_frp: &WidgetsFrp) -> Self { + let network = &self.config_frp.network; let display_object = &self.display_object; - let activation_shape = &self.activation_shape; let list_container = &self.list_container; let list = &self.list; let dot_clicked = self.activation_shape.on_event::(); + let focus_in = self.display_object.on_event::(); + let focus_out = self.display_object.on_event::(); frp::extend! { network init <- source_(); - toggle_focus <- dot_clicked.map(f!([display_object](_) !display_object.is_focused())); - set_focused <- any(toggle_focus, frp.set_focused); - eval set_focused([display_object, list_container, list](focus) match focus { - true => { - display_object.focus(); - list_container.add_child(&list); - }, - false => { - display_object.blur(); - list_container.remove_child(&list); - }, + set_focused <- dot_clicked.map(f!([display_object](_) !display_object.is_focused())); + eval set_focused([display_object](focus) match focus { + true => display_object.focus(), + false => display_object.blur(), }); - set_visible <- all(&frp.set_visible, &init)._0(); - shape_alpha <- set_visible.map(|visible| if *visible { 1.0 } else { 0.0 }); - shape_color <- shape_alpha.map(|a| ACTIVATION_SHAPE_COLOR.with_alpha(*a)); - eval shape_color([activation_shape] (color) { - activation_shape.color.set(color::Rgba::from(color).into()); + readonly_set <- widgets_frp.set_read_only.on_true(); + do_open <- focus_in.gate_not(&widgets_frp.set_read_only); + do_close <- any_(focus_out, readonly_set); + is_open <- bool(&do_close, &do_open).on_change(); + + eval is_open([list_container, list](open) match open { + true => list_container.add_child(&list), + false => list_container.remove_child(&list), }); } init.emit(()); self } - fn init_list_updates(self, app: &Application, frp: &SampledFrp) -> Self { - let network = &self.network; + fn init_list_updates(self, app: &Application, widgets_frp: &WidgetsFrp) -> Self { + let config_frp = &self.config_frp; + let network = &config_frp.network; let list = &self.list; frp::extend! { network init <- source_(); - value <- all(frp.set_current_value, init)._0(); + value <- all(config_frp.current_value, init)._0(); non_empty_value <- value.filter_map(|v| v.clone()); empty_value <- value.filter_map(|v| v.is_none().then_some(())); eval non_empty_value ([list, app](val) Self::update_list(&app, val.as_str(), &list)); @@ -123,27 +128,15 @@ impl Model { code_changed_by_user <- list.request_new_item.map(f_!([app, list] Self::push_new_element(&app, &list))); - frp.out_value_changed <+ code_changed_by_user.map(f_!([list] { + value_changed <- code_changed_by_user.map(f_!([list] { Some(ImString::new(Self::construct_code(&list))) })); - } - init.emit(()); - self - } + widgets_frp.value_changed <+ value_changed.map2(&config_frp.current_crumbs, + move |t: &Option, crumbs: &span_tree::Crumbs| (crumbs.clone(), t.clone()) + ); - fn init_port_size_update(self) -> Self { - let network = &self.network; - let display_object = &self.display_object; - let on_transformed = self.display_object.on_transformed.clone_ref(); - let set_port_size = &self.set_port_size; - frp::extend! { network - widget_size <- on_transformed.map(f!((()) display_object.computed_size())).on_change(); - port_and_widget_size <- all(set_port_size, &widget_size); - eval port_and_widget_size ([display_object]((port_sz, sz)) { - display_object.set_x(port_sz.x() / 2.0 - sz.x() / 2.0); - display_object.set_y(-port_sz.y() - sz.y() - 5.0); - }); } + init.emit(()); self } @@ -199,3 +192,38 @@ impl Model { opt_iterator.into_iter().flatten() } } + +#[derive(Debug, Clone, PartialEq)] +/// VectorEditor widget configuration options. +pub struct Config { + /// Configuration of inner element widgets. If not present, the child widget types have to be + /// automatically inferred. + #[allow(dead_code)] + pub item_widget: Option>, + /// Default expression to insert when adding new elements. + #[allow(dead_code)] + pub item_default: ImString, +} + +impl super::SpanWidget for Widget { + type Config = Config; + + fn root_object(&self) -> &display::object::Instance { + &self.display_object + } + + fn new(_: &Config, ctx: &super::ConfigContext) -> Self { + Self::new(ctx.app(), ctx.frp(), ctx.styles()) + } + + fn configure(&mut self, _: &Config, ctx: super::ConfigContext) { + let current_value: Option = Some(ctx.expression_at(ctx.span_node.span()).into()); + self.config_frp.current_value(current_value); + self.config_frp.current_crumbs(ctx.span_node.crumbs.clone()); + + let child_level = ctx.info.nesting_level.next_if(ctx.span_node.is_argument()); + let label_meta = super::Configuration::always(super::label::Config); + let child = ctx.builder.child_widget_of_type(ctx.span_node, child_level, Some(&label_meta)); + self.child_container.replace_children(&[child]); + } +} diff --git a/app/gui/view/graph-editor/src/component/node/input/widget/single_choice.rs b/app/gui/view/graph-editor/src/component/node/input/widget/single_choice.rs new file mode 100644 index 000000000000..bccc54aa7125 --- /dev/null +++ b/app/gui/view/graph-editor/src/component/node/input/widget/single_choice.rs @@ -0,0 +1,403 @@ +//! Definition of single choice widget. + +use crate::prelude::*; + +use crate::component::node::input::widget::Entry; + +use enso_frp as frp; +use ensogl::control::io::mouse; +use ensogl::display; +use ensogl::display::object::event; +use ensogl_component::drop_down::Dropdown; +use ensogl_hardcoded_theme as theme; + + + +// ================= +// === Constants === +// ================= + +/// Height of the activation triangle shape. +pub const ACTIVATION_SHAPE_SIZE: Vector2 = Vector2(15.0, 11.0); + +/// Gap between activation shape and the dropdown widget content. +pub const ACTIVATION_SHAPE_GAP: f32 = 5.0; + +/// Distance between the top of the dropdown list and the bottom of the widget. +const DROPDOWN_Y_OFFSET: f32 = -20.0; + +/// Maximum allowed size of the dropdown list. If the list needs to be longer or wider than allowed +/// by these values, it will receive a scroll bar. +const DROPDOWN_MAX_SIZE: Vector2 = Vector2(300.0, 500.0); + + + +// ====================== +// === Triangle Shape === +// ====================== + +/// Temporary dropdown activation shape definition. +pub mod triangle { + use super::*; + ensogl::shape! { + alignment = left_bottom; + (style:Style, color:Vector4) { + let size = Var::canvas_size(); + let radius = 1.0.px(); + let shrink = &radius * 2.0; + let shape = Triangle(size.x() - &shrink, size.y() - &shrink) + .flip_y() + .grow(radius); + shape.fill(color).into() + } + } +} + + + +// ==================== +// === SingleChoice === +// ==================== + +/// SingleChoice widget configuration options. +#[derive(Debug, Clone, PartialEq)] +pub struct Config { + /// Default label to display when no value is selected. Will use argument name if not provided. + pub label: Option, + /// Entries that should be displayed by the widget, as proposed by language server. This + /// list is not exhaustive. The widget implementation might present additional + /// options or allow arbitrary user input. + pub entries: Rc>, +} + +ensogl::define_endpoints_2! { + Input { + set_entries (Rc>), + current_value (Option), + current_crumbs (span_tree::Crumbs), + is_connected (bool), + } +} + +/// A widget for selecting a single value from a list of available options. The options can be +/// provided as a static list of strings from argument `tag_values`, or as a dynamic expression. +#[derive(Debug)] +#[allow(dead_code)] +pub struct Widget { + config_frp: Frp, + display_object: display::object::Instance, + content_wrapper: display::object::Instance, + dropdown_wrapper: display::object::Instance, + label_wrapper: display::object::Instance, + dropdown: Rc>, + activation_shape: triangle::View, +} + +impl super::SpanWidget for Widget { + type Config = Config; + + fn root_object(&self) -> &display::object::Instance { + &self.display_object + } + + fn new(_: &Config, ctx: &super::ConfigContext) -> Self { + let app = ctx.app(); + // ╭─display_object────────────────────╮ + // │╭─content_wrapper─────────────────╮│ + // ││ ╭ shape ╮ ╭ label_wrapper ────╮ ││ + // ││ │ │ │ │ ││ + // ││ │ │ │ │ ││ + // ││ ╰───────╯ ╰───────────────────╯ ││ + // │╰─────────────────────────────────╯│ + // ├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤ + // │ ◎ dropdown_wrapper size=0 │ + // ╰───────────────────────────────────╯ + + let activation_shape = triangle::View::new(); + activation_shape.set_size(ACTIVATION_SHAPE_SIZE); + + let layers = &app.display.default_scene.layers; + layers.label.add(&activation_shape); + + let display_object = display::object::Instance::new(); + let content_wrapper = display_object.new_child(); + content_wrapper.add_child(&activation_shape); + let label_wrapper = content_wrapper.new_child(); + let dropdown_wrapper = display_object.new_child(); + + display_object + .use_auto_layout() + .set_column_flow() + .set_children_alignment_left_center() + .justify_content_center_y(); + + content_wrapper + .use_auto_layout() + .set_gap_x(ACTIVATION_SHAPE_GAP) + .set_children_alignment_left_center() + .justify_content_center_y(); + + label_wrapper + .use_auto_layout() + .set_children_alignment_left_center() + .justify_content_center_y(); + + dropdown_wrapper.set_size((0.0, 0.0)).set_alignment_left_top(); + + let config_frp = Frp::new(); + let dropdown = LazyDropdown::new(app, &config_frp.network); + let dropdown = Rc::new(RefCell::new(dropdown)); + + Self { + config_frp, + display_object, + content_wrapper, + dropdown_wrapper, + label_wrapper, + dropdown, + activation_shape, + } + .init(ctx) + } + + fn configure(&mut self, config: &Config, mut ctx: super::ConfigContext) { + let input = &self.config_frp.public.input; + + let has_value = !ctx.span_node.is_insertion_point(); + let current_value: Option = + has_value.then(|| ctx.expression_at(ctx.span_node.span()).into()); + + input.current_crumbs(ctx.span_node.crumbs.clone()); + input.current_value(current_value); + input.set_entries(config.entries.clone()); + input.is_connected(ctx.info.subtree_connection.is_some()); + + if has_value { + ctx.modify_extension::(|ext| ext.bold = true); + } + + let config = match ctx.span_node.children.is_empty() { + true => super::Configuration::always(super::label::Config), + false => super::Configuration::always(super::hierarchy::Config), + }; + let child_level = ctx.info.nesting_level; + let child = ctx.builder.child_widget_of_type(ctx.span_node, child_level, Some(&config)); + self.label_wrapper.replace_children(&[child]); + } +} + +impl Widget { + fn init(self, ctx: &super::ConfigContext) -> Self { + let is_open = self.init_dropdown_focus(ctx); + self.init_dropdown_values(ctx, is_open); + self.init_activation_shape(ctx); + self + } + + fn init_dropdown_focus(&self, ctx: &super::ConfigContext) -> frp::Stream { + let widgets_frp = ctx.frp(); + let focus_receiver = self.display_object.clone_ref(); + let focus_in = focus_receiver.on_event::(); + let focus_out = focus_receiver.on_event::(); + let network = &self.config_frp.network; + let dropdown = &self.dropdown; + let dropdown_frp = &self.dropdown.borrow(); + let dropdown_wrapper = &self.dropdown_wrapper; + frp::extend! { network + eval focus_in([dropdown, dropdown_wrapper](_) { + dropdown.borrow_mut().lazy_init(&dropdown_wrapper); + }); + readonly_set <- widgets_frp.set_read_only.on_true(); + do_open <- focus_in.gate_not(&widgets_frp.set_read_only); + do_close <- any_(focus_out, readonly_set); + is_open <- bool(&do_close, &do_open); + dropdown_frp.set_open <+ is_open.on_change(); + + // Close the dropdown after a short delay after selection. Because the dropdown + // value application triggers operations that can introduce a few dropped frames, + // we want to delay the dropdown closing animation after that is handled. + // Otherwise the animation finishes within single frame, which looks bad. + let close_after_selection_timer = frp::io::timer::Timeout::new(network); + close_after_selection_timer.restart <+ dropdown_frp.user_select_action.constant(1); + eval close_after_selection_timer.on_expired((()) focus_receiver.blur()); + + } + is_open + } + + + fn init_dropdown_values(&self, ctx: &super::ConfigContext, is_open: frp::Stream) { + let network = &self.config_frp.network; + let dropdown_frp = &self.dropdown.borrow(); + let config_frp = &self.config_frp; + let widgets_frp = ctx.frp(); + + frp::extend! { network + current_value <- config_frp.current_value.on_change(); + entries <- config_frp.set_entries.on_change(); + entries_and_value <- all(&entries, ¤t_value); + entries_and_value <- entries_and_value.debounce(); + dropdown_frp.set_all_entries <+ entries_and_value.map(|(e, _)| e.deref().clone()); + entries_and_value <- entries_and_value.buffered_gate(&is_open); + + selected_entry <- entries_and_value.map(|(e, v)| entry_for_current_value(e, v)); + dropdown_frp.set_selected_entries <+ selected_entry.map(|e| e.iter().cloned().collect()); + + dropdown_entry <- dropdown_frp.selected_entries + .map(|e: &HashSet| e.iter().next().cloned()); + + // Emit the output value only after actual user action. This prevents the + // dropdown from emitting its initial value when it is opened, which can + // represent slightly different version of code than actually written. + submitted_entry <- dropdown_entry.sample(&dropdown_frp.user_select_action); + dropdown_out_value <- submitted_entry.map(|e| e.as_ref().map(Entry::value)); + dropdown_out_import <- submitted_entry.map(|e| e.as_ref().and_then(Entry::required_import)); + + widgets_frp.request_import <+ dropdown_out_import.unwrap(); + widgets_frp.value_changed <+ dropdown_out_value.map2(&config_frp.current_crumbs, + move |t: &Option, crumbs: &span_tree::Crumbs| (crumbs.clone(), t.clone()) + ); + + }; + } + + fn init_activation_shape(&self, ctx: &super::ConfigContext) { + let network = &self.config_frp.network; + let config_frp = &self.config_frp; + let widgets_frp = ctx.frp(); + let styles = ctx.styles(); + let activation_shape = &self.activation_shape; + let focus_receiver = &self.display_object; + frp::extend! { network + is_hovered <- widgets_frp.on_port_hover.map2(&config_frp.current_crumbs, |h, crumbs| { + h.on().map_or(false, |h| crumbs.starts_with(h)) + }); + is_connected_or_hovered <- config_frp.is_connected || is_hovered; + activation_shape_theme <- is_connected_or_hovered.map(|is_connected_or_hovered| { + if *is_connected_or_hovered { + Some(theme::widget::activation_shape::connected) + } else { + Some(theme::widget::activation_shape::base) + } + }); + activation_shape_theme <- activation_shape_theme.on_change(); + eval activation_shape_theme([styles, activation_shape](path) { + if let Some(path) = path { + let color = styles.get_color(path); + activation_shape.color.set(color.into()); + } + }); + + let dot_mouse_down = activation_shape.on_event::(); + dot_clicked <- dot_mouse_down.filter(mouse::is_primary); + set_focused <- dot_clicked.map(f!([focus_receiver](_) !focus_receiver.is_focused())); + eval set_focused([focus_receiver](focus) match focus { + true => focus_receiver.focus(), + false => focus_receiver.blur(), + }); + + }; + } +} + +fn entry_for_current_value( + all_entries: &[Entry], + current_value: &Option, +) -> Option { + let current_value = current_value.clone()?; + let found_entry = all_entries.iter().find(|entry| entry.value.as_ref() == current_value); + let with_partial_match = found_entry.or_else(|| { + // Handle parentheses in current value. Entries with parenthesized expressions will match if + // they start with the same expression as the current value. That way it is still matched + // once extra arguments are added to the nested function call. + current_value.starts_with('(').and_option_from(|| { + let current_value = current_value.trim_start_matches('(').trim_end_matches(')'); + all_entries.iter().find(|entry| { + let trimmed_value = entry.value.trim_start_matches('(').trim_end_matches(')'); + current_value.starts_with(trimmed_value) + }) + }) + }); + + let with_fallback = + with_partial_match.cloned().unwrap_or_else(|| Entry::from_value(current_value.clone())); + Some(with_fallback) +} + + + +// ==================== +// === LazyDropdown === +// ==================== + +/// A wrapper for dropdown that can be initialized lazily, with all required FRP endpoints to drive +/// it as if was just an ordinary view. Before calling `lazy_init` for the first time, the overhead +/// is minimal, as the actual dropdown view is not created. +#[derive(Debug)] +struct LazyDropdown { + app: ensogl::application::Application, + set_all_entries: frp::Any>, + set_selected_entries: frp::Any>, + set_open: frp::Any, + sampled_set_all_entries: frp::Sampler>, + sampled_set_selected_entries: frp::Sampler>, + sampled_set_open: frp::Sampler, + selected_entries: frp::Any>, + user_select_action: frp::Any<()>, + dropdown: Option>, +} + +impl LazyDropdown { + fn new(app: &ensogl::application::Application, network: &frp::Network) -> Self { + frp::extend! { network + set_all_entries <- any(...); + set_selected_entries <- any(...); + set_open <- any(...); + selected_entries <- any(...); + user_select_action <- any(...); + sampled_set_all_entries <- set_all_entries.sampler(); + sampled_set_selected_entries <- set_selected_entries.sampler(); + sampled_set_open <- set_open.sampler(); + } + + Self { + app: app.clone_ref(), + set_all_entries, + set_selected_entries, + set_open, + selected_entries, + user_select_action, + sampled_set_all_entries, + sampled_set_selected_entries, + sampled_set_open, + dropdown: None, + } + } + + /// Perform initialization that actually creates the dropdown. Should be done only once there is + /// a request to open the dropdown. + fn lazy_init(&mut self, parent: &display::object::Instance) { + if self.dropdown.is_some() { + return; + } + + let dropdown = self.dropdown.insert(self.app.new_view::>()); + parent.add_child(dropdown); + self.app.display.default_scene.layers.above_nodes.add(&*dropdown); + + frp::extend! { _network + dropdown.set_all_entries <+ self.sampled_set_all_entries; + dropdown.set_selected_entries <+ self.sampled_set_selected_entries; + dropdown.set_open <+ self.sampled_set_open; + self.selected_entries <+ dropdown.selected_entries; + self.user_select_action <+ dropdown.user_select_action; + } + + dropdown.set_y(DROPDOWN_Y_OFFSET); + dropdown.set_max_open_size(DROPDOWN_MAX_SIZE); + dropdown.allow_deselect_all(true); + dropdown.set_all_entries(self.sampled_set_all_entries.value()); + dropdown.set_selected_entries(self.sampled_set_selected_entries.value()); + dropdown.set_open(self.sampled_set_open.value()); + } +} diff --git a/app/gui/view/graph-editor/src/component/node/output/area.rs b/app/gui/view/graph-editor/src/component/node/output/area.rs index 910f39a7be17..19e603f86280 100644 --- a/app/gui/view/graph-editor/src/component/node/output/area.rs +++ b/app/gui/view/graph-editor/src/component/node/output/area.rs @@ -135,7 +135,7 @@ ensogl::define_endpoints! { /// Set the expression USAGE type. This is not the definition type, which can be set with /// `set_expression` instead. In case the usage type is set to None, ports still may be /// colored if the definition type was present. - set_expression_usage_type (Crumbs,Option), + set_expression_usage_type (ast::Id,Option), } Output { @@ -245,12 +245,13 @@ impl Model { /// Update expression type for the particular `ast::Id`. #[profile(Debug)] - fn set_expression_usage_type(&self, crumbs: &Crumbs, tp: &Option) { - if let Ok(port) = self.expression.borrow().span_tree.root_ref().get_descendant(crumbs) { - if let Some(frp) = &port.frp { - frp.set_usage_type(tp) - } - } + fn set_expression_usage_type(&self, id: ast::Id, tp: &Option) { + let crumbs_map = self.id_crumbs_map.borrow(); + let Some(crumbs) = crumbs_map.get(&id) else { return }; + let expression = self.expression.borrow(); + let Ok(port) = expression.span_tree.get_node(crumbs) else { return }; + let Some(frp) = &port.frp else { return }; + frp.set_usage_type(tp); } /// Traverse all span tree nodes that are considered ports. In case of empty span tree, include @@ -498,7 +499,7 @@ impl Area { // === Expression === eval frp.set_expression ((a) model.set_expression(a)); - eval frp.set_expression_usage_type (((a,b)) model.set_expression_usage_type(a,b)); + eval frp.set_expression_usage_type (((a,b)) model.set_expression_usage_type(*a,b)); // === Label Color === @@ -546,10 +547,6 @@ impl Area { .and_then(|t| t.frp.as_ref().and_then(|frp| frp.tp.value())) } - #[allow(missing_docs)] // FIXME[everyone] All pub functions should have docs. - pub fn get_crumbs_by_id(&self, id: ast::Id) -> Option { - self.model.id_crumbs_map.borrow().get(&id).cloned() - } #[allow(missing_docs)] // FIXME[everyone] All pub functions should have docs. pub fn whole_expr_id(&self) -> Option { diff --git a/app/gui/view/graph-editor/src/execution_environment.rs b/app/gui/view/graph-editor/src/execution_environment.rs new file mode 100644 index 000000000000..43bb57bb0a08 --- /dev/null +++ b/app/gui/view/graph-editor/src/execution_environment.rs @@ -0,0 +1,73 @@ +//! This module contains the logic for the execution environment selector. + +use super::*; + +use crate::Frp; + +use ide_view_execution_environment_selector::ExecutionEnvironment; + + + +// ============================= +// === Execution Environment === +// ============================= + +fn get_next_execution_environment( + current: &ExecutionEnvironment, + available: &[ExecutionEnvironment], +) -> Option { + let index = available.iter().position(|mode| mode == current)?; + let next_index = (index + 1) % available.len(); + Some(available[next_index].clone()) +} + +/// Initialise the FRP logic for the execution environment selector. +pub fn init_frp(frp: &Frp, model: &GraphEditorModelWithNetwork) { + let out = &frp.private.output; + let network = frp.network(); + let inputs = &frp.private.input; + let selector = &model.execution_environment_selector; + + frp::extend! { network + + + // === Execution Environment Changes === + + selector.set_available_execution_environments <+ frp.set_available_execution_environments; + selected_environment <- frp.set_execution_environment.map(|env| (*env).into()); + environment_state + <- all(out.execution_environment,frp.set_available_execution_environments); + + environment_toggled <- environment_state.sample(&frp.toggle_execution_environment); + toggled_execution_environment <- environment_toggled.map(|(mode,available)| + get_next_execution_environment(mode,available)).unwrap(); + + external_update <- any(selected_environment,toggled_execution_environment); + selector.set_execution_environment <+ external_update; + + execution_environment_update + <- any(selector.selected_execution_environment,external_update); + out.execution_environment <+ execution_environment_update; + out.execution_environment_play_button_pressed <+ selector.play_press; + + + // === Layout === + + init <- source::<()>(); + size_update <- all(init,selector.size,inputs.space_for_window_buttons); + eval size_update ([model]((_,size,gap_size)) { + let y_offset = MACOS_TRAFFIC_LIGHTS_VERTICAL_CENTER; + let traffic_light_width = traffic_lights_gap_width(); + + let execution_environment_selector_x = gap_size.x + traffic_light_width; + model.execution_environment_selector.set_x(execution_environment_selector_x); + let breadcrumb_gap_width = + execution_environment_selector_x + size.x + TOP_BAR_ITEM_MARGIN; + model.breadcrumbs.gap_width(breadcrumb_gap_width); + + model.execution_environment_selector.set_y(y_offset + size.y / 2.0); + model.breadcrumbs.set_y(y_offset + component::breadcrumbs::HEIGHT / 2.0); + }); + } + init.emit(()); +} diff --git a/app/gui/view/graph-editor/src/lib.rs b/app/gui/view/graph-editor/src/lib.rs index 0acc3081ed31..f78549af2d2b 100644 --- a/app/gui/view/graph-editor/src/lib.rs +++ b/app/gui/view/graph-editor/src/lib.rs @@ -4,6 +4,7 @@ // === Features === #![feature(associated_type_defaults)] +#![feature(const_trait_impl)] #![feature(drain_filter)] #![feature(entry_insert)] #![feature(fn_traits)] @@ -34,6 +35,7 @@ pub mod component; pub mod automation; pub mod builtin; pub mod data; +pub mod execution_environment; pub mod new_node_position; #[warn(missing_docs)] pub mod profiling; @@ -42,21 +44,21 @@ pub mod view; #[warn(missing_docs)] mod selection; +mod shortcuts; use crate::application::command::FrpNetworkProvider; use crate::component::node; use crate::component::type_coloring; use crate::component::visualization; use crate::component::visualization::instance::PreprocessorConfiguration; -use crate::component::visualization::MockDataGenerator3D; use crate::data::enso; pub use crate::node::profiling::Status as NodeProfilingStatus; +use engine_protocol::language_server::ExecutionEnvironment; use application::tooltip; use enso_config::ARGS; use enso_frp as frp; use ensogl::application; -use ensogl::application::shortcut; use ensogl::application::Application; use ensogl::data::color; use ensogl::display; @@ -76,7 +78,8 @@ use ensogl_component::text; use ensogl_component::text::buffer::selection::Selection; use ensogl_component::tooltip::Tooltip; use ensogl_hardcoded_theme as theme; -use ide_view_execution_mode_selector as execution_mode_selector; +use ide_view_execution_environment_selector as execution_environment_selector; +use ide_view_execution_environment_selector::ExecutionEnvironmentSelector; // =============== @@ -125,6 +128,7 @@ fn traffic_lights_gap_width() -> f32 { } + // ================= // === SharedVec === // ================= @@ -585,9 +589,11 @@ ensogl::define_endpoints_2! { // === Execution Environment === - set_execution_environment(ExecutionEnvironment), // TODO(#5930): Temporary shortcut for testing different execution environments toggle_execution_environment(), + /// Set the execution environmenta available to the graph. + set_available_execution_environments (Rc>), + set_execution_environment (ExecutionEnvironment), // === Debug === @@ -636,7 +642,7 @@ ensogl::define_endpoints_2! { set_node_comment ((NodeId,node::Comment)), set_node_position ((NodeId,Vector2)), set_expression_usage_type ((NodeId,ast::Id,Option)), - update_node_widgets ((NodeId,WidgetUpdates)), + update_node_widgets ((NodeId,CallWidgetsConfig)), cycle_visualization (NodeId), set_visualization ((NodeId, Option)), register_visualization (Option), @@ -656,10 +662,6 @@ ensogl::define_endpoints_2! { /// Drop an edge that is being dragged. drop_dragged_edge (), - - /// Set the execution modes available to the graph. - set_available_execution_modes (Rc>), - } Output { @@ -736,6 +738,8 @@ ensogl::define_endpoints_2! { node_action_skip ((NodeId, bool)), node_edit_mode (bool), nodes_labels_visible (bool), + node_incoming_edge_updates (NodeId), + node_outgoing_edge_updates (NodeId), /// `None` value as a visualization path denotes a disabled visualization. @@ -765,16 +769,16 @@ ensogl::define_endpoints_2! { default_y_gap_between_nodes (f32), min_x_spacing_for_new_nodes (f32), - /// The selected execution mode. - execution_mode (execution_mode_selector::ExecutionMode), - /// A press of the execution mode selector play button. - execution_mode_play_button_pressed (), + /// The selected environment mode. + execution_environment (execution_environment_selector::ExecutionEnvironment), + /// A press of the execution environment selector play button. + execution_environment_play_button_pressed (), } } impl FrpNetworkProvider for GraphEditor { fn network(&self) -> &frp::Network { - &self.model.network + &self.frp.network } } @@ -977,6 +981,18 @@ impl From for Type { } } +impl From<&String> for Type { + fn from(s: &String) -> Self { + Type(s.into()) + } +} + +impl From<&str> for Type { + fn from(s: &str) -> Self { + Type(s.into()) + } +} + impl Display for Type { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) @@ -1101,26 +1117,27 @@ impl Grid { -// ===================== -// === WidgetUpdates === -// ===================== +// ========================= +// === CallWidgetsConfig === +// ========================= -/// A structure describing a widget update batch for arguments of single function call. +/// Configuration for widgets of arguments at function call Enso expression. #[derive(Debug, Default, Clone)] -pub struct WidgetUpdates { +pub struct CallWidgetsConfig { /// The function call expression ID. - pub call_id: ast::Id, - /// Update of a widget for each function argument. - pub updates: Rc>, + pub call_id: ast::Id, + /// Definition of a widget for each function argument. + pub definitions: Rc>, } /// A structure describing a widget update for specific argument of a function call. #[derive(Debug)] -pub struct WidgetUpdate { +pub struct ArgumentWidgetConfig { /// The function argument name that this widget is for. pub argument_name: String, - /// Widget metadata queried from the language server. - pub meta: Option, + /// Widget configuration queried from the language server. When this is `None`, the widget + /// configuration should be inferred automatically. + pub config: Option, } @@ -1401,11 +1418,11 @@ pub fn crumbs_overlap(src: &[span_tree::Crumb], tgt: &[span_tree::Crumb]) -> boo // === GraphEditorModelWithNetwork === // =================================== -#[derive(Debug, Clone, CloneRef)] +#[derive(Clone, CloneRef, Debug)] #[allow(missing_docs)] // FIXME[everyone] Public-facing API should be documented. pub struct GraphEditorModelWithNetwork { pub model: GraphEditorModel, - pub network: frp::Network, + pub network: frp::WeakNetwork, } impl Deref for GraphEditorModelWithNetwork { @@ -1419,7 +1436,7 @@ impl Deref for GraphEditorModelWithNetwork { impl GraphEditorModelWithNetwork { /// Constructor. pub fn new(app: &Application, cursor: cursor::Cursor, frp: &Frp) -> Self { - let network = frp.network().clone_ref(); // FIXME make weak + let network = frp.network().clone_ref().downgrade(); let model = GraphEditorModel::new(app, cursor, frp); Self { model, network } } @@ -1454,11 +1471,12 @@ impl GraphEditorModelWithNetwork { let edge_id = edge.id(); self.add_child(&edge); self.edges.insert(edge.clone_ref()); - let network = &self.network; - 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)); + 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)); + } } edge_id } @@ -1473,7 +1491,7 @@ impl GraphEditorModelWithNetwork { let first_detached = self.edges.detached_target.is_empty(); self.edges.detached_target.insert(edge_id); if first_detached { - self.frp.private.output.on_some_edges_targets_unset.emit(()); + self.frp.output.on_some_edges_targets_unset.emit(()); } edge_id } @@ -1488,7 +1506,7 @@ impl GraphEditorModelWithNetwork { let first_detached = self.edges.detached_source.is_empty(); self.edges.detached_source.insert(edge_id); if first_detached { - self.frp.private.output.on_some_edges_sources_unset.emit(()); + self.frp.output.on_some_edges_sources_unset.emit(()); } edge_id } @@ -1571,198 +1589,203 @@ impl GraphEditorModelWithNetwork { let model = &self.model; let NodeCreationContext { pointer_style, output_press, input_press, output } = ctx; - frp::new_bridge_network! { [self.network, node_network] graph_node_bridge - eval_ node.background_press(touch.nodes.down.emit(node_id)); + if let Some(network) = self.network.upgrade_or_warn() { + frp::new_bridge_network! { [network, node_network] graph_node_bridge + eval_ node.background_press(touch.nodes.down.emit(node_id)); - hovered <- node.output.hover.map (move |t| Some(Switch::new(node_id,*t))); - output.node_hovered <+ hovered; + hovered <- node.output.hover.map (move |t| Some(Switch::new(node_id,*t))); + output.node_hovered <+ hovered; - eval node.comment ([model](comment) - model.frp.private.output.node_comment_set.emit((node_id,comment.clone())) - ); + eval node.comment ([model](comment) + model.frp.output.node_comment_set.emit((node_id,comment.clone())) + ); - node.set_output_expression_visibility <+ self.frp.nodes_labels_visible; + node.set_output_expression_visibility <+ self.frp.output.nodes_labels_visible; - pointer_style <+ node_model.input.frp.pointer_style; + pointer_style <+ node_model.input.frp.pointer_style; - eval node_model.output.frp.on_port_press ([output_press](crumbs){ - let target = EdgeEndpoint::new(node_id,crumbs.clone()); - output_press.emit(target); - }); + eval node_model.output.frp.on_port_press ([output_press](crumbs){ + let target = EdgeEndpoint::new(node_id,crumbs.clone()); + output_press.emit(target); + }); - eval node_model.input.frp.on_port_press ([input_press](crumbs) - let target = EdgeEndpoint::new(node_id,crumbs.clone()); - input_press.emit(target); - ); + eval node_model.input.frp.on_port_press ([input_press](crumbs) + let target = EdgeEndpoint::new(node_id,crumbs.clone()); + input_press.emit(target); + ); - eval node_model.input.frp.on_port_hover ([model](t) { - let crumbs = t.on(); - let target = crumbs.map(|c| EdgeEndpoint::new(node_id,c.clone())); - model.frp.private.output.hover_node_input.emit(target); - }); + eval node_model.input.frp.on_port_hover ([model](t) { + let crumbs = t.on(); + let target = crumbs.map(|c| EdgeEndpoint::new(node_id,c.clone())); + model.frp.output.hover_node_input.emit(target); + }); - eval node_model.output.frp.on_port_hover ([model](hover) { - let output = hover.on().map(|crumbs| EdgeEndpoint::new(node_id,crumbs.clone())); - model.frp.private.output.hover_node_output.emit(output); - }); + eval node_model.output.frp.on_port_hover ([model](hover) { + let output = hover.on().map(|crumbs| EdgeEndpoint::new(node_id,crumbs.clone())); + model.frp.output.hover_node_output.emit(output); + }); - let neutral_color = model.styles_frp.get_color(theme::code::types::any::selection); - - _eval <- all_with(&node_model.input.frp.on_port_type_change,&neutral_color, - f!(((crumbs,_),neutral_color) - model.with_input_edge_id(node_id,crumbs,|id| - model.refresh_edge_color(id,neutral_color.into()) - ) - )); - - _eval <- all_with(&node_model.input.frp.on_port_type_change,&neutral_color, - f!(((crumbs,_),neutral_color) - model.with_output_edge_id(node_id,crumbs,|id| - model.refresh_edge_color(id,neutral_color.into()) - ) - )); - - let is_editing = &node_model.input.frp.editing; - expression_change_temporary <- node.on_expression_modified.gate(is_editing); - expression_change_permanent <- node.on_expression_modified.gate_not(is_editing); - - temporary_expression <- expression_change_temporary.map2( - &node_model.input.set_expression, - move |(crumbs, code), expr| expr.code_with_replaced_span(crumbs, code) - ); - eval temporary_expression([model] (code) { - model.frp.private.output.node_expression_set.emit((node_id, code)); - }); - eval expression_change_permanent([model]((crumbs,code)) { - let args = (node_id, crumbs.clone(), code.clone()); - model.frp.private.output.node_expression_span_set.emit(args) - }); + eval_ node_model.input.frp.input_edges_need_refresh( + model.frp.output.node_incoming_edge_updates.emit(node_id) + ); + + eval_ node_model.input.frp.width( + model.frp.output.node_outgoing_edge_updates.emit(node_id) + ); + + let neutral_color = model.styles_frp.get_color(theme::code::types::any::selection); + + _eval <- node_model.output.frp.on_port_type_change.map2(&neutral_color, + f!(((crumbs,_),neutral_color) + model.with_output_edge_id(node_id,crumbs,|id| + model.refresh_edge_color(id,neutral_color.into()) + ) + )); + + let is_editing = &node_model.input.frp.editing; + expression_change_temporary <- node.on_expression_modified.gate(is_editing); + expression_change_permanent <- node.on_expression_modified.gate_not(is_editing); + + temporary_expression <- expression_change_temporary.map2( + &node_model.input.set_expression, + move |(crumbs, code), expr| expr.code_with_replaced_span(crumbs, code) + ); + eval temporary_expression([model] (code) { + model.frp.output.node_expression_set.emit((node_id, code)); + }); + eval expression_change_permanent([model]((crumbs,code)) { + let args = (node_id, crumbs.clone(), code.clone()); + model.frp.output.node_expression_span_set.emit(args) + }); - eval node.requested_widgets([model]((call_id, target_id)) { - let args = (node_id, *call_id, *target_id); - model.frp.private.output.widgets_requested.emit(args) - }); + eval node.requested_widgets([model]((call_id, target_id)) { + let args = (node_id, *call_id, *target_id); + model.frp.output.widgets_requested.emit(args) + }); - let node_expression_edit = node.model().input.expression_edit.clone_ref(); - model.frp.private.output.node_expression_edited <+ node_expression_edit.map( - move |(expr, selection)| (node_id, expr.clone_ref(), selection.clone()) - ); - model.frp.private.output.request_import <+ node.request_import; + let node_expression_edit = node.model().input.expression_edit.clone_ref(); + model.frp.output.node_expression_edited <+ node_expression_edit.map( + move |(expr, selection)| (node_id, expr.clone_ref(), selection.clone()) + ); + model.frp.output.request_import <+ node.request_import; - // === Actions === + // === Actions === - model.frp.private.output.node_action_context_switch <+ node.view.context_switch.map( - f!([] (active) (node_id, *active)) - ); + model.frp.output.node_action_context_switch <+ node.view.context_switch.map( + f!([] (active) (node_id, *active)) + ); - eval node.view.freeze ((is_frozen) { - model.frp.private.output.node_action_freeze.emit((node_id,*is_frozen)); - }); + eval node.view.freeze ((is_frozen) { + model.frp.output.node_action_freeze.emit((node_id,*is_frozen)); + }); - let set_node_disabled = &node.set_disabled; - eval node.view.skip ([set_node_disabled,model](is_skipped) { - model.frp.private.output.node_action_skip.emit((node_id,*is_skipped)); - set_node_disabled.emit(is_skipped); - }); + let set_node_disabled = &node.set_disabled; + eval node.view.skip ([set_node_disabled,model](is_skipped) { + model.frp.output.node_action_skip.emit((node_id,*is_skipped)); + set_node_disabled.emit(is_skipped); + }); - // === Visualizations === + // === Visualizations === - visualization_shown <- node.visualization_visible.gate(&node.visualization_visible); - visualization_hidden <- node.visualization_visible.gate_not(&node.visualization_visible); + visualization_shown <- node.visualization_visible.gate(&node.visualization_visible); + visualization_hidden <- node.visualization_visible.gate_not(&node.visualization_visible); - let vis_is_selected = node_model.visualization.frp.is_selected.clone_ref(); + let vis_is_selected = node_model.visualization.frp.is_selected.clone_ref(); - selected <- vis_is_selected.on_true(); - deselected <- vis_is_selected.on_false(); - output.on_visualization_select <+ selected.constant(Switch::On(node_id)); - output.on_visualization_select <+ deselected.constant(Switch::Off(node_id)); + selected <- vis_is_selected.on_true(); + deselected <- vis_is_selected.on_false(); + output.on_visualization_select <+ selected.constant(Switch::On(node_id)); + output.on_visualization_select <+ deselected.constant(Switch::Off(node_id)); - preprocessor_changed <- - node_model.visualization.frp.preprocessor.map(move |preprocessor| { - (node_id,preprocessor.clone()) - }); - output.visualization_preprocessor_changed <+ preprocessor_changed.gate(&node.visualization_visible); + preprocessor_changed <- + node_model.visualization.frp.preprocessor.map(move |preprocessor| { + (node_id,preprocessor.clone()) + }); + output.visualization_preprocessor_changed <+ preprocessor_changed.gate(&node.visualization_visible); - metadata <- any(...); - metadata <+ node_model.visualization.frp.preprocessor.map(visualization::Metadata::new); + metadata <- any(...); + metadata <+ node_model.visualization.frp.preprocessor.map(visualization::Metadata::new); - // Ensure the graph editor knows about internal changes to the visualisation. If the - // visualisation changes that should indicate that the old one has been disabled and a - // new one has been enabled. - // TODO: Create a better API for updating the controller about visualisation changes - // (see #896) - output.visualization_hidden <+ visualization_hidden.constant(node_id); - output.visualization_shown <+ - visualization_shown.map2(&metadata,move |_,metadata| (node_id,metadata.clone())); + // Ensure the graph editor knows about internal changes to the visualisation. If the + // visualisation changes that should indicate that the old one has been disabled and a + // new one has been enabled. + // TODO: Create a better API for updating the controller about visualisation changes + // (see #896) + output.visualization_hidden <+ visualization_hidden.constant(node_id); + output.visualization_shown <+ + visualization_shown.map2(&metadata,move |_,metadata| (node_id,metadata.clone())); - init <- source::<()>(); - enabled_visualization_path <- init.all_with3( - &node.visualization_enabled, &node.visualization_path, - move |_init, is_enabled, path| (node_id, is_enabled.and_option(path.clone())) - ); - output.enabled_visualization_path <+ enabled_visualization_path; + init <- source::<()>(); + enabled_visualization_path <- init.all_with3( + &node.visualization_enabled, &node.visualization_path, + move |_init, is_enabled, path| (node_id, is_enabled.and_option(path.clone())) + ); + output.enabled_visualization_path <+ enabled_visualization_path; - // === View Mode === + // === View Mode === - node.set_view_mode <+ self.model.frp.view_mode; + node.set_view_mode <+ self.model.frp.output.view_mode; - // === Read-only mode === + // === Read-only mode === - node.set_read_only <+ self.model.frp.set_read_only; + node.set_read_only <+ self.model.frp.input.set_read_only; - // === Profiling === + // === Profiling === - let profiling_min_duration = &self.model.profiling_statuses.min_duration; - node.set_profiling_min_global_duration <+ self.model.profiling_statuses.min_duration; - node.set_profiling_min_global_duration(profiling_min_duration.value()); - let profiling_max_duration = &self.model.profiling_statuses.max_duration; - node.set_profiling_max_global_duration <+ self.model.profiling_statuses.max_duration; - node.set_profiling_max_global_duration(profiling_max_duration.value()); + let profiling_min_duration = &self.model.profiling_statuses.min_duration; + node.set_profiling_min_global_duration <+ self.model.profiling_statuses.min_duration; + node.set_profiling_min_global_duration(profiling_min_duration.value()); + let profiling_max_duration = &self.model.profiling_statuses.max_duration; + node.set_profiling_max_global_duration <+ self.model.profiling_statuses.max_duration; + node.set_profiling_max_global_duration(profiling_max_duration.value()); - // === Execution Environment === + // === Execution Environment === - node.set_execution_environment <+ self.model.frp.set_execution_environment; - } + node.set_execution_environment <+ self.model.frp.input.set_execution_environment; + } - // === Panning camera to created node === - - // Node position and bounding box are not available immediately after the node is created, - // but only after the Node's display object is updated. Therefore, in order to pan the - // camera to the bounding box of a newly created node, we need to wait until: - // 1. the position of the newly created node becomes updated, and then - // 2. the bounding box of the node becomes updated. - // When the sequence is detected, and if the node is being edited, we pan the camera to it. - // Regardless whether the node is being edited, we drop the network, as we don't want to - // pan the camera for any later updates of the bounding box. - let pan_network = frp::Network::new("network_for_camera_pan_to_new_node"); - let pan_network_container = RefCell::new(Some(pan_network.clone())); - frp::new_bridge_network! { [self.network, node_network, pan_network] graph_node_pan_bridge - pos_updated <- node.output.position.constant(true); - bbox_updated_after_pos_updated <- node.output.bounding_box.gate(&pos_updated); - let node_being_edited = &self.frp.node_being_edited; - _eval <- bbox_updated_after_pos_updated.map2(node_being_edited, f!([model](_, node) { - pan_network_container.replace(None); - if *node == Some(node_id) { - model.pan_camera_to_node(node_id); - } - })); + // === Panning camera to created node === + + // Node position and bounding box are not available immediately after the node is + // created, but only after the Node's display object is updated. Therefore, + // in order to pan the camera to the bounding box of a newly created node, + // we need to wait until: 1. the position of the newly created node becomes + // updated, and then 2. the bounding box of the node becomes updated. + // When the sequence is detected, and if the node is being edited, we pan the camera to + // it. Regardless whether the node is being edited, we drop the network, as + // we don't want to pan the camera for any later updates of the bounding + // box. + let pan_network = frp::Network::new("network_for_camera_pan_to_new_node"); + let pan_network_container = RefCell::new(Some(pan_network.clone())); + frp::new_bridge_network! { [network, node_network, pan_network] graph_node_pan_bridge + pos_updated <- node.output.position.constant(true); + bbox_updated_after_pos_updated <- node.output.bounding_box.gate(&pos_updated); + let node_being_edited = &self.frp.output.node_being_edited; + _eval <- bbox_updated_after_pos_updated.map2(node_being_edited, f!([model](_, node) { + pan_network_container.replace(None); + if *node == Some(node_id) { + model.pan_camera_to_node(node_id); + } + })); + } + + node.set_view_mode(self.model.frp_public.output.view_mode.value()); + let initial_metadata = visualization::Metadata { + preprocessor: node_model.visualization.frp.preprocessor.value(), + }; + metadata.emit(initial_metadata); + init.emit(()); } - node.set_view_mode(self.model.frp.view_mode.value()); - let initial_metadata = visualization::Metadata { - preprocessor: node_model.visualization.frp.preprocessor.value(), - }; - metadata.emit(initial_metadata); - init.emit(()); self.nodes.insert(node_id, node.clone_ref()); node } @@ -1777,25 +1800,26 @@ impl GraphEditorModelWithNetwork { #[derive(Debug, Clone, CloneRef)] #[allow(missing_docs)] // FIXME[everyone] Public-facing API should be documented. pub struct GraphEditorModel { - pub display_object: display::object::Instance, - pub app: Application, - pub breadcrumbs: component::Breadcrumbs, - pub cursor: cursor::Cursor, - pub nodes: Nodes, - pub edges: Edges, - pub vis_registry: visualization::Registry, - pub drop_manager: ensogl_drop_manager::Manager, - pub navigator: Navigator, - pub add_node_button: Rc, - tooltip: Tooltip, - touch_state: TouchState, - visualisations: Visualisations, - frp: Frp, - profiling_statuses: profiling::Statuses, - profiling_button: component::profiling::Button, - styles_frp: StyleWatchFrp, - selection_controller: selection::Controller, - execution_mode_selector: execution_mode_selector::ExecutionModeSelector, + pub display_object: display::object::Instance, + pub app: Application, + pub breadcrumbs: component::Breadcrumbs, + pub cursor: cursor::Cursor, + pub nodes: Nodes, + pub edges: Edges, + pub vis_registry: visualization::Registry, + pub drop_manager: ensogl_drop_manager::Manager, + pub navigator: Navigator, + pub add_node_button: Rc, + tooltip: Tooltip, + touch_state: TouchState, + visualisations: Visualisations, + frp: api::Private, + frp_public: api::Public, + profiling_statuses: profiling::Statuses, + profiling_button: component::profiling::Button, + styles_frp: StyleWatchFrp, + selection_controller: selection::Controller, + execution_environment_selector: ExecutionEnvironmentSelector, } @@ -1813,10 +1837,10 @@ impl GraphEditorModel { let visualisations = default(); let touch_state = TouchState::new(network, &scene.mouse.frp_deprecated); let breadcrumbs = component::Breadcrumbs::new(app.clone_ref()); - let execution_mode_selector = execution_mode_selector::ExecutionModeSelector::new(app); + let execution_environment_selector = + execution_environment_selector::ExecutionEnvironmentSelector::new(app); let app = app.clone_ref(); - let frp = frp.clone_ref(); let navigator = Navigator::new(scene, &scene.camera()); let tooltip = Tooltip::new(&app); let profiling_statuses = profiling::Statuses::new(); @@ -1826,7 +1850,7 @@ impl GraphEditorModel { ensogl_drop_manager::Manager::new(&scene.dom.root.clone_ref().into(), scene); let styles_frp = StyleWatchFrp::new(&scene.style_sheet); let selection_controller = selection::Controller::new( - &frp, + frp, &app.cursor, &scene.mouse.frp_deprecated, &touch_state, @@ -1845,14 +1869,15 @@ impl GraphEditorModel { tooltip, touch_state, visualisations, - frp, navigator, profiling_statuses, profiling_button, add_node_button, + frp: frp.private.clone_ref(), + frp_public: frp.public.clone_ref(), styles_frp, selection_controller, - execution_mode_selector, + execution_environment_selector, } .init() } @@ -1860,7 +1885,7 @@ impl GraphEditorModel { fn init(self) -> Self { let x_offset = MACOS_TRAFFIC_LIGHTS_SIDE_OFFSET; - self.add_child(&self.execution_mode_selector); + self.add_child(&self.execution_environment_selector); self.add_child(&self.breadcrumbs); self.breadcrumbs.set_x(x_offset); @@ -1886,8 +1911,8 @@ impl GraphEditorModel { impl GraphEditorModel { /// Create a new node and return a unique identifier. pub fn add_node(&self) -> NodeId { - self.frp.add_node.emit(()); - let (node_id, _, _) = self.frp.node_added.value(); + self.frp_public.input.add_node.emit(()); + let (node_id, _, _) = self.frp_public.output.node_added.value(); node_id } @@ -1900,7 +1925,7 @@ impl GraphEditorModel { /// Create a new node and place it at `pos`. pub fn add_node_at(&self, pos: Vector2) -> NodeId { let node_id = self.add_node(); - self.frp.set_node_position((node_id, pos)); + self.frp_public.input.set_node_position.emit((node_id, pos)); node_id } } @@ -1919,7 +1944,7 @@ impl GraphEditorModel { } if let Some(target) = edge.take_target() { - self.set_input_connected(&target, None, false); // FIXME None + self.set_input_connected(&target, None); if let Some(target_node) = self.nodes.get_cloned_ref(&target.node_id) { target_node.in_edges.remove(&edge_id); } @@ -1927,21 +1952,32 @@ impl GraphEditorModel { } } - fn set_input_connected(&self, target: &EdgeEndpoint, tp: Option, status: bool) { + fn set_input_connected(&self, target: &EdgeEndpoint, status: Option) { if let Some(node) = self.nodes.get_cloned(&target.node_id) { - node.view.set_input_connected(&target.port, tp, status); + node.view.set_input_connected(&target.port, status); } } - fn set_edge_target_connection_status(&self, edge_id: EdgeId, status: bool) { + fn set_edge_target_connection_status( + &self, + edge_id: EdgeId, + status: bool, + neutral_color: color::Lcha, + ) { self.with_edge_target(edge_id, |tgt| { - self.set_endpoint_connection_status(edge_id, &tgt, status) + self.set_endpoint_connection_status(edge_id, &tgt, status, neutral_color) }); } - fn set_endpoint_connection_status(&self, edge_id: EdgeId, target: &EdgeEndpoint, status: bool) { - let tp = self.edge_source_type(edge_id); - self.set_input_connected(target, tp, status); + fn set_endpoint_connection_status( + &self, + edge_id: EdgeId, + target: &EdgeEndpoint, + status: bool, + neutral_color: color::Lcha, + ) { + let status = status.then(|| self.edge_color(edge_id, neutral_color)); + self.set_input_connected(target, status); } fn enable_visualization(&self, node_id: impl Into) { @@ -1988,7 +2024,7 @@ impl GraphEditorModel { let node_id = node_id.into(); self.nodes.remove(&node_id); self.nodes.selected.remove_item(&node_id); - self.frp.private.output.on_visualization_select.emit(Switch::Off(node_id)); + self.frp.output.on_visualization_select.emit(Switch::Off(node_id)); } fn node_in_edges(&self, node_id: impl Into) -> Vec { @@ -2015,9 +2051,6 @@ impl GraphEditorModel { if let Some(node) = self.nodes.get_cloned_ref(&node_id) { node.set_expression.emit(expr); } - for edge_id in self.node_out_edges(node_id) { - self.refresh_edge_source_size(edge_id); - } } fn edit_node_expression( @@ -2089,9 +2122,7 @@ impl GraphEditorModel { node.out_edges.insert(edge_id); edge.set_source(target); edge.view.frp.source_attached.emit(true); - // FIXME: both lines require edge to refresh. Let's make it more efficient. self.refresh_edge_position(edge_id); - self.refresh_edge_source_size(edge_id); } } } @@ -2104,11 +2135,9 @@ impl GraphEditorModel { edge.view.frp.source_attached.emit(false); let first_detached = self.edges.detached_source.is_empty(); self.edges.detached_source.insert(edge_id); - // FIXME: both lines require edge to refresh. Let's make it more efficient. self.refresh_edge_position(edge_id); - self.refresh_edge_source_size(edge_id); if first_detached { - self.frp.private.output.on_some_edges_sources_unset.emit(()); + self.frp.output.on_some_edges_sources_unset.emit(()); } } } @@ -2125,7 +2154,7 @@ impl GraphEditorModel { self.edges.detached_target.remove(&edge_id); let all_attached = self.edges.detached_target.is_empty(); if all_attached { - self.frp.private.output.on_all_edges_targets_set.emit(()); + self.frp.output.on_all_edges_targets_set.emit(()); } edge.view.frp.target_attached.emit(true); @@ -2145,7 +2174,7 @@ impl GraphEditorModel { edge.view.frp.target_attached.emit(false); self.refresh_edge_position(edge_id); if first_detached { - self.frp.private.output.on_some_edges_targets_unset.emit(()); + self.frp.output.on_some_edges_targets_unset.emit(()); } }; } @@ -2186,10 +2215,10 @@ impl GraphEditorModel { let no_detached_sources = self.edges.detached_source.is_empty(); let no_detached_targets = self.edges.detached_target.is_empty(); if no_detached_targets { - self.frp.private.output.on_all_edges_targets_set.emit(()); + self.frp.output.on_all_edges_targets_set.emit(()); } if no_detached_sources { - self.frp.private.output.on_all_edges_sources_set.emit(()); + self.frp.output.on_all_edges_sources_set.emit(()); } } @@ -2245,21 +2274,14 @@ impl GraphEditorModel { let node_id = node_id.into(); if let Some(node) = self.nodes.get_cloned_ref(&node_id) { if node.view.model().output.whole_expr_id().contains(&ast_id) { - // TODO[ao]: we must update root output port according to the whole expression type - // due to a bug in engine https://github.com/enso-org/enso/issues/1038. - let crumbs = span_tree::Crumbs::default(); - node.view.model().output.set_expression_usage_type(crumbs, maybe_type.clone()); let enso_type = maybe_type.as_ref().map(|tp| enso::Type::new(&tp.0)); node.view.model().visualization.frp.set_vis_input_type(enso_type); } - let crumbs = node.view.model().get_crumbs_by_id(ast_id); - if let Some(crumbs) = crumbs { - node.view.set_expression_usage_type.emit((crumbs, maybe_type)); - } + node.view.set_expression_usage_type.emit((ast_id, maybe_type)); } } - fn update_node_widgets(&self, node_id: NodeId, updates: &WidgetUpdates) { + fn update_node_widgets(&self, node_id: NodeId, updates: &CallWidgetsConfig) { if let Some(node) = self.nodes.get_cloned_ref(&node_id) { node.view.update_widgets.emit(updates.clone()); } @@ -2294,30 +2316,14 @@ impl GraphEditorModel { (node_id, new_position) } - #[allow(missing_docs)] // FIXME[everyone] All pub functions should have docs. - pub fn refresh_edge_position(&self, edge_id: EdgeId) { - self.refresh_edge_source_position(edge_id); - self.refresh_edge_target_position(edge_id); - } - - #[allow(missing_docs)] // FIXME[everyone] All pub functions should have docs. - pub fn refresh_edge_source_size(&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) { - edge.view.frp.source_width.emit(node.model().width()); - edge.view.frp.source_height.emit(node.model().height()); - edge.view.frp.redraw.emit(()); - } - } - }; - } - #[allow(missing_docs)] // FIXME[everyone] All pub functions should have docs. 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); + if let Some(target) = edge.target() { + self.set_input_connected(&target, Some(color)); + } }; } @@ -2327,33 +2333,91 @@ impl GraphEditorModel { } } - #[allow(missing_docs)] // FIXME[everyone] All pub functions should have docs. - pub fn refresh_edge_source_position(&self, edge_id: EdgeId) { + /// 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; 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) { - edge.modify_position(|p| { - p.x = node.position().x + node.model().width() / 2.0; - p.y = node.position().y; - }); + 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); + } } } - }; - } - - #[allow(missing_docs)] // FIXME[everyone] All pub functions should have docs. - pub fn refresh_edge_target_position(&self, edge_id: EdgeId) { - if let Some(edge) = self.edges.get_cloned_ref(&edge_id) { 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).unwrap_or_default(); - let pos = node.position().xy() + offset; - edge.view.frp.target_position.emit(pos); - edge.view.frp.redraw.emit(()); + 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); + } } } - }; + + 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; + for node_id in node_ids { + for edge_id in self.node_out_edges(node_id) { + updated |= 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; + for node_id in node_ids { + for edge_id in self.node_in_edges(node_id) { + updated |= 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 { @@ -2375,11 +2439,7 @@ impl GraphEditorModel { } fn with_edge_map_source(&self, id: EdgeId, f: impl FnOnce(EdgeEndpoint) -> T) -> Option { - self.with_edge(id, |edge| { - let edge = edge.source.borrow().deref().clone(); - edge.map(f) - }) - .flatten() + self.with_edge(id, |edge| edge.source.borrow().clone().map(f)).flatten() } fn with_edge_map_target(&self, id: EdgeId, f: impl FnOnce(EdgeEndpoint) -> T) -> Option { @@ -2394,29 +2454,6 @@ impl GraphEditorModel { self.with_edge_map_target(id, |endpoint| endpoint) } - // FIXME[WD]: This implementation is slow. Node should allow for easy mapping between Crumbs - // and edges. Should be part of https://github.com/enso-org/ide/issues/822. - fn with_input_edge_id( - &self, - id: NodeId, - crumbs: &span_tree::Crumbs, - f: impl FnOnce(EdgeId) -> T, - ) -> Option { - self.with_node(id, move |node| { - let mut target_edge_id = None; - for edge_id in node.in_edges.keys() { - self.with_edge(edge_id, |edge| { - let ok = edge.target().map(|tgt| tgt.port == crumbs) == Some(true); - if ok { - target_edge_id = Some(edge_id) - } - }); - } - target_edge_id.map(f) - }) - .flatten() - } - // FIXME[WD]: This implementation is slow. Node should allow for easy mapping between Crumbs // and edges. Should be part of https://github.com/enso-org/ide/issues/822. fn with_output_edge_id( @@ -2487,7 +2524,7 @@ impl GraphEditorModel { } fn edge_hover_type(&self) -> Option { - let hover_tgt = self.frp.hover_node_input.value(); + let hover_tgt = self.frp_public.output.hover_node_input.value(); hover_tgt.and_then(|tgt| { self.with_node(tgt.node_id, |node| node.model().input.port_type(&tgt.port)).flatten() }) @@ -2510,7 +2547,7 @@ impl GraphEditorModel { // FIXME : StyleWatch is unsuitable here, as it was designed as an internal tool for shape // system (#795) let styles = StyleWatch::new(&self.scene().style_sheet); - match self.frp.view_mode.value() { + match self.frp_public.output.view_mode.value() { view::Mode::Normal => { let edge_type = self .edge_hover_type() @@ -2672,67 +2709,8 @@ impl application::View for GraphEditor { } fn default_shortcuts() -> Vec { - use shortcut::ActionType::*; - [ - (Press, "!node_editing & !read_only", "tab", "start_node_creation"), - (Press, "!node_editing & !read_only", "enter", "start_node_creation"), - // === Drag === - (Press, "", "left-mouse-button", "node_press"), - (Release, "", "left-mouse-button", "node_release"), - (Press, "!node_editing & !read_only", "backspace", "remove_selected_nodes"), - (Press, "!node_editing & !read_only", "delete", "remove_selected_nodes"), - (Press, "has_detached_edge", "escape", "drop_dragged_edge"), - (Press, "!read_only", "cmd g", "collapse_selected_nodes"), - // === Visualization === - (Press, "!node_editing", "space", "press_visualization_visibility"), - (DoublePress, "!node_editing", "space", "double_press_visualization_visibility"), - (Release, "!node_editing", "space", "release_visualization_visibility"), - (Press, "", "cmd i", "reload_visualization_registry"), - (Press, "is_fs_visualization_displayed", "space", "close_fullscreen_visualization"), - (Press, "", "cmd", "enable_quick_visualization_preview"), - (Release, "", "cmd", "disable_quick_visualization_preview"), - // === Selection === - (Press, "", "shift", "enable_node_multi_select"), - (Press, "", "shift left-mouse-button", "enable_node_multi_select"), - (Release, "", "shift", "disable_node_multi_select"), - (Release, "", "shift left-mouse-button", "disable_node_multi_select"), - (Press, "", "shift ctrl", "toggle_node_merge_select"), - (Release, "", "shift ctrl", "toggle_node_merge_select"), - (Press, "", "shift alt", "toggle_node_subtract_select"), - (Release, "", "shift alt", "toggle_node_subtract_select"), - (Press, "", "shift ctrl alt", "toggle_node_inverse_select"), - (Release, "", "shift ctrl alt", "toggle_node_inverse_select"), - // === Navigation === - ( - Press, - "!is_fs_visualization_displayed", - "ctrl space", - "cycle_visualization_for_selected_node", - ), - (DoublePress, "!read_only", "left-mouse-button", "enter_hovered_node"), - (DoublePress, "!read_only", "left-mouse-button", "start_node_creation_from_port"), - (Press, "!read_only", "right-mouse-button", "start_node_creation_from_port"), - (Press, "!node_editing & !read_only", "cmd enter", "enter_selected_node"), - (Press, "!read_only", "alt enter", "exit_node"), - // === Node Editing === - (Press, "!read_only", "cmd", "edit_mode_on"), - (Release, "!read_only", "cmd", "edit_mode_off"), - (Press, "!read_only", "cmd left-mouse-button", "edit_mode_on"), - (Release, "!read_only", "cmd left-mouse-button", "edit_mode_off"), - (Press, "node_editing & !read_only", "cmd enter", "stop_editing"), - // === Profiling Mode === - (Press, "", "cmd p", "toggle_profiling_mode"), - // === Debug === - (Press, "debug_mode", "ctrl d", "debug_set_test_visualization_data_for_selected_node"), - (Press, "debug_mode", "ctrl shift enter", "debug_push_breadcrumb"), - (Press, "debug_mode", "ctrl shift up", "debug_pop_breadcrumb"), - (Press, "debug_mode", "ctrl n", "add_node_at_cursor"), - // TODO(#5930): Temporary shortcut for testing different execution environments - (Press, "", "cmd shift c", "toggle_execution_environment"), - ] - .iter() - .map(|(a, b, c, d)| Self::self_shortcut_when(*a, *c, *d, *b)) - .collect() + use crate::shortcuts::SHORTCUTS; + SHORTCUTS.iter().map(|(a, b, c, d)| Self::self_shortcut_when(*a, *c, *d, *b)).collect() } } @@ -2769,12 +2747,13 @@ fn new_graph_editor(app: &Application) -> GraphEditor { let network = frp.network(); let nodes = &model.nodes; let edges = &model.edges; - let inputs = &model.frp; + let inputs = &frp.private.input; let mouse = &scene.mouse.frp_deprecated; let touch = &model.touch_state; let vis_registry = &model.vis_registry; let out = &frp.private.output; let selection_controller = &model.selection_controller; + let neutral_color = model.model.styles_frp.get_color(theme::code::types::any::selection); @@ -2788,7 +2767,7 @@ fn new_graph_editor(app: &Application) -> GraphEditor { // Drop the currently dragged edge if read-only mode is enabled. read_only_enabled <- inputs.set_read_only.on_true(); - inputs.drop_dragged_edge <+ read_only_enabled; + frp.drop_dragged_edge <+ read_only_enabled; } @@ -2842,7 +2821,7 @@ fn new_graph_editor(app: &Application) -> GraphEditor { // Go level down on node double click. enter_on_node <= target_to_enter.map(|target| target.is_symbol().as_some(())); - output_port_is_hovered <- inputs.hover_node_output.map(Option::is_some); + output_port_is_hovered <- frp.output.hover_node_output.map(Option::is_some); enter_node <- enter_on_node.gate_not(&output_port_is_hovered); node_switch_to_enter <- out.node_hovered.sample(&enter_node).unwrap(); node_to_enter <- node_switch_to_enter.map(|switch| switch.on().cloned()).unwrap(); @@ -2918,12 +2897,12 @@ fn new_graph_editor(app: &Application) -> GraphEditor { on_connect_follow_mode <- any(on_output_connect_follow_mode,on_input_connect_follow_mode); connect_drag_mode <- any(on_connect_drag_mode,on_connect_follow_mode); - on_detached_edge <- any(&inputs.on_some_edges_targets_unset,&inputs.on_some_edges_sources_unset); + on_detached_edge <- any(&frp.private.output.on_some_edges_targets_unset,&frp.private.output.on_some_edges_sources_unset); has_detached_edge <- bool(&out.on_all_edges_endpoints_set,&on_detached_edge); out.has_detached_edge <+ has_detached_edge; - eval node_input_touch.down ((target) model.frp.press_node_input.emit(target)); - eval node_output_touch.down ((target) model.frp.press_node_output.emit(target)); + frp.press_node_input <+ node_input_touch.down; + frp.press_node_output <+ node_output_touch.down; } @@ -2987,10 +2966,10 @@ fn new_graph_editor(app: &Application) -> GraphEditor { output_down <- node_output_touch.down.constant(()); input_down <- node_input_touch.down.constant(()); - has_detached_edge_on_output_down <- has_detached_edge.sample(&inputs.hover_node_output); + has_detached_edge_on_output_down <- has_detached_edge.sample(&frp.output.hover_node_output); - port_input_mouse_up <- inputs.hover_node_input.sample(&mouse.up_primary).unwrap(); - port_output_mouse_up <- inputs.hover_node_output.sample(&mouse.up_primary).unwrap(); + port_input_mouse_up <- frp.output.hover_node_input.sample(&mouse.up_primary).unwrap(); + port_output_mouse_up <- frp.output.hover_node_output.sample(&mouse.up_primary).unwrap(); attach_all_edge_inputs <- any (port_input_mouse_up, inputs.press_node_input, inputs.set_detached_edge_targets); attach_all_edge_outputs <- any (port_output_mouse_up, inputs.press_node_output, inputs.set_detached_edge_sources); @@ -2999,7 +2978,7 @@ fn new_graph_editor(app: &Application) -> GraphEditor { create_edge_from_input <- node_input_touch.down.map(|value| value.clone()).gate_not(&inputs.set_read_only); on_new_edge <- any(&output_down,&input_down); - let selection_mode = selection::get_mode(network,inputs); + let selection_mode = selection::get_mode(network,&frp); keep_selection <- selection_mode.map(|t| *t != selection::Mode::Normal); deselect_edges <- on_new_edge.gate_not(&keep_selection); eval_ deselect_edges ( model.clear_all_detached_edges() ); @@ -3070,6 +3049,14 @@ fn new_graph_editor(app: &Application) -> GraphEditor { edge_to_remove_without_sources <= remove_all_detached_edges.map(f_!(model.take_edges_with_detached_sources())); edge_to_remove <- any(edge_to_remove_without_targets,edge_to_remove_without_sources); eval edge_to_remove ((id) model.remove_edge(id)); + + 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()); } // === Adding Node === @@ -3077,7 +3064,7 @@ fn new_graph_editor(app: &Application) -> GraphEditor { frp::extend! { network node_added_with_button <- model.add_node_button.clicked.gate_not(&inputs.set_read_only); - input_start_node_creation_from_port <- inputs.hover_node_output.sample( + input_start_node_creation_from_port <- out.hover_node_output.sample( &inputs.start_node_creation_from_port); start_node_creation_from_port <- input_start_node_creation_from_port.filter_map( |v| v.clone()); @@ -3220,13 +3207,12 @@ fn new_graph_editor(app: &Application) -> GraphEditor { // === Remove Node === frp::extend! { network + all_nodes <= inputs.remove_all_nodes . map(f_!(model.all_nodes())); + selected_nodes <= inputs.remove_selected_nodes . map(f_!(model.nodes.all_selected())); + nodes_to_remove <- any (all_nodes, selected_nodes); + frp.public.input.remove_all_node_edges <+ nodes_to_remove; - all_nodes <= inputs.remove_all_nodes . map(f_!(model.all_nodes())); - selected_nodes <= inputs.remove_selected_nodes . map(f_!(model.nodes.all_selected())); - nodes_to_remove <- any (all_nodes, selected_nodes); - eval nodes_to_remove ((node_id) inputs.remove_all_node_edges.emit(node_id)); - - out.node_removed <+ nodes_to_remove; + out.node_removed <+ nodes_to_remove; } @@ -3394,10 +3380,12 @@ fn new_graph_editor(app: &Application) -> GraphEditor { model.set_node_expression_usage_type(*node_id,*ast_id,maybe_type.clone()); *node_id })); - edges_to_refresh <= node_to_refresh.map(f!([nodes](node_id) - nodes.get_cloned_ref(node_id).map(|node| node.all_edges()) - )).unwrap(); - eval edges_to_refresh ((edge) model.refresh_edge_position(*edge)); + edges_to_refresh_batch <- node_to_refresh.map(f!((node_id) + nodes.get_cloned_ref(node_id).map(|node| node.all_edges())) + ).unwrap(); + edges_to_refresh <= edges_to_refresh_batch; + eval edges_to_refresh ([model, neutral_color] (edge) + model.refresh_edge_color(*edge, neutral_color.value().into())); eval inputs.update_node_widgets(((node, updates)) model.update_node_widgets(*node, updates)); } @@ -3406,13 +3394,13 @@ fn new_graph_editor(app: &Application) -> GraphEditor { frp::extend! { network - detached_edge <- any(&inputs.on_some_edges_targets_unset,&inputs.on_some_edges_sources_unset); + detached_edge <- any(&out.on_some_edges_targets_unset,&out.on_some_edges_sources_unset); update_edge <- any(detached_edge,on_new_edge_source,on_new_edge_target); cursor_pos_on_update <- cursor_pos_in_scene.sample(&update_edge); edge_refresh_cursor_pos <- any(cursor_pos_on_update,cursor_pos_in_scene); - is_hovering_output <- inputs.hover_node_output.map(|target| target.is_some()).sampler(); - hover_node <- inputs.hover_node_output.unwrap(); + is_hovering_output <- out.hover_node_output.map(|target| target.is_some()).sampler(); + hover_node <- out.hover_node_output.unwrap(); edge_refresh_on_node_hover <- all(edge_refresh_cursor_pos,hover_node).gate(&is_hovering_output); edge_refresh_cursor_pos_no_hover <- edge_refresh_cursor_pos.gate_not(&is_hovering_output); @@ -3508,17 +3496,6 @@ fn new_graph_editor(app: &Application) -> GraphEditor { // === Vis Update Data === frp::extend! { network - // TODO remove this once real data is available. - let sample_data_generator = MockDataGenerator3D::default(); - def _set_dumy_data = inputs.debug_set_test_visualization_data_for_selected_node.map(f!([nodes,inputs](_) { - for node_id in &*nodes.selected.raw.borrow() { - let data = Rc::new(sample_data_generator.generate_data()); // FIXME: why rc? - let content = serde_json::to_value(data).unwrap(); - let data = visualization::Data::from(content); - inputs.set_visualization_data.emit((*node_id,data)); - } - })); - eval inputs.set_visualization_data ([nodes]((node_id,data)) { if let Some(node) = nodes.get_cloned(node_id) { node.model().visualization.frp.set_data.emit(data); @@ -3633,9 +3610,9 @@ fn new_graph_editor(app: &Application) -> GraphEditor { node_to_enter <= inputs.enter_selected_node.map(f_!(model.nodes.last_selected())); out.node_entered <+ node_to_enter; - removed_edges_on_enter <= out.node_entered.map(f_!(model.model.clear_all_detached_edges())); + removed_edges_on_enter <= out.node_entered.map(f_!(model.clear_all_detached_edges())); out.node_exited <+ inputs.exit_node; - removed_edges_on_exit <= out.node_exited.map(f_!(model.model.clear_all_detached_edges())); + removed_edges_on_exit <= out.node_exited.map(f_!(model.clear_all_detached_edges())); out.on_edge_drop <+ any(removed_edges_on_enter,removed_edges_on_exit); @@ -3659,8 +3636,10 @@ fn new_graph_editor(app: &Application) -> GraphEditor { eval out.on_edge_source_set (((id,tgt)) model.set_edge_source(*id,tgt)); eval out.on_edge_target_set (((id,tgt)) model.set_edge_target(*id,tgt)); - eval out.on_edge_target_set (((id,tgt)) model.set_endpoint_connection_status(*id,tgt,true)); - eval out.on_edge_target_unset (((id,tgt)) model.set_endpoint_connection_status(*id,tgt,false)); + eval out.on_edge_target_set ([model, neutral_color] ((id,tgt)) + model.set_endpoint_connection_status(*id,tgt,true, neutral_color.value().into())); + eval out.on_edge_target_unset ([model, neutral_color] ((id,tgt)) + model.set_endpoint_connection_status(*id,tgt,false, neutral_color.value().into())); eval out.on_edge_source_unset (((id,_)) model.remove_edge_source(*id)); eval out.on_edge_target_unset (((id,_)) model.remove_edge_target(*id)); @@ -3677,7 +3656,6 @@ fn new_graph_editor(app: &Application) -> GraphEditor { out.on_edge_only_source_not_set <+ out.on_edge_target_set_with_source_not_set._0(); out.on_edge_only_source_not_set <+ out.on_edge_source_unset._0(); - let neutral_color = model.model.styles_frp.get_color(theme::code::types::any::selection); eval out.on_edge_source_set ([model,neutral_color]((id, _)) model.refresh_edge_color(*id,neutral_color.value().into())); eval out.on_edge_target_set ([model,neutral_color]((id, _)) @@ -3736,7 +3714,8 @@ fn new_graph_editor(app: &Application) -> GraphEditor { eval inputs.set_node_expression (((id, expr)) model.set_node_expression(id, expr)); eval inputs.edit_node_expression (((id, range, ins)) model.edit_node_expression(id, range, ins)); port_to_refresh <= inputs.set_node_expression.map(f!(((id, _))model.node_in_edges(id))); - eval port_to_refresh ((id) model.set_edge_target_connection_status(*id,true)); + eval port_to_refresh ([model, neutral_color] + (id) model.set_edge_target_connection_status(*id,true, neutral_color.value().into())); // === Remove implementation === out.node_removed <+ inputs.remove_node; @@ -3762,10 +3741,18 @@ fn new_graph_editor(app: &Application) -> GraphEditor { frp::extend! { network - cursor_style_edge_drag <- all_with(&out.some_edge_endpoints_unset,&out.view_mode, - f!([model,neutral_color](some_edges_detached,_) { + edges_refresh_when_detached <- edges_to_refresh_batch.gate(&out.some_edge_endpoints_unset); + refresh_detached_edge_cursor <- all( + out.some_edge_endpoints_unset, + out.view_mode, + edges_refresh_when_detached + )._0(); + + cursor_style_edge_drag <- refresh_detached_edge_cursor.map( + f!([model,neutral_color](some_edges_detached) { if *some_edges_detached { - if let Some(color) = model.first_detached_edge_color(neutral_color.value().into()) { + let color = model.first_detached_edge_color(neutral_color.value().into()); + if let Some(color) = color { cursor::Style::new_color(color).press() } else { cursor::Style::new_color_no_animation(neutral_color.value().into()).press() @@ -3912,30 +3899,7 @@ fn new_graph_editor(app: &Application) -> GraphEditor { // === Execution Mode Selection === // ================================ - let execution_mode_selector = &model.execution_mode_selector; - frp::extend! { network - - execution_mode_selector.set_available_execution_modes <+ frp.set_available_execution_modes; - out.execution_mode <+ execution_mode_selector.selected_execution_mode; - out.execution_mode_play_button_pressed <+ execution_mode_selector.play_press; - - // === Layout === - init <- source::<()>(); - size_update <- all(init,execution_mode_selector.size,inputs.space_for_window_buttons); - eval size_update ([model]((_,size,gap_size)) { - let y_offset = MACOS_TRAFFIC_LIGHTS_VERTICAL_CENTER; - let traffic_light_width = traffic_lights_gap_width(); - - let execution_mode_selector_x = gap_size.x + traffic_light_width; - model.execution_mode_selector.set_x(execution_mode_selector_x); - let breadcrumb_gap_width = execution_mode_selector_x + size.x + TOP_BAR_ITEM_MARGIN; - model.breadcrumbs.gap_width(breadcrumb_gap_width); - - model.execution_mode_selector.set_y(y_offset + size.y / 2.0); - model.breadcrumbs.set_y(y_offset + component::breadcrumbs::HEIGHT / 2.0); - }); - } - init.emit(()); + execution_environment::init_frp(&frp, &model); // ================== @@ -3967,48 +3931,6 @@ impl display::Object for GraphEditor { } - -// ============================= -// === Execution Environment === -// ============================= - -// TODO(#5930): Move me once we synchronise the execution environment with the language server. -/// The execution environment which controls the global execution of functions with side effects. -/// -/// For more information, see -/// https://github.com/enso-org/design/blob/main/epics/basic-libraries/write-action-control/design.md. -#[derive(Debug, Clone, CloneRef, Copy, Default)] -pub enum ExecutionEnvironment { - /// Allows editing the graph, but the `Output` context is disabled, so it prevents accidental - /// changes. - #[default] - Design, - /// Unrestricted, live editing of data. - Live, -} - -impl ExecutionEnvironment { - /// Returns whether the output context is enabled for this execution environment. - pub fn output_context_enabled(&self) -> bool { - match self { - Self::Design => false, - Self::Live => true, - } - } -} - -impl Display for ExecutionEnvironment { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let name = match self { - Self::Design => "design", - Self::Live => "live", - }; - write!(f, "{name}") - } -} - - - // ============= // === Tests === // ============= @@ -4017,6 +3939,7 @@ impl Display for ExecutionEnvironment { mod tests { use super::*; use application::test_utils::ApplicationExt; + use ensogl::control::io::mouse; use ensogl::control::io::mouse::PrimaryButton; use ensogl::display::scene::test_utils::MouseExt; use node::test_utils::NodeModelExt; @@ -4122,9 +4045,11 @@ mod tests { // Connecting edge. // We need to enable ports. Normally it is done by hovering the node. node_2.model().input.frp.set_ports_active(true, None); - let port = node_2.model().input_port_shape().expect("No input port."); - port.hover.events_deprecated.emit_mouse_down(PrimaryButton); - port.hover.events_deprecated.emit_mouse_up(PrimaryButton); + let port_hover = node_2.model().input_port_hover_shape().expect("No input port."); + + // Input ports already use new event API. + port_hover.emit_event(mouse::Down::default()); + port_hover.emit_event(mouse::Up::default()); assert_eq!(edge.source().map(|e| e.node_id), Some(node_id_1)); assert_eq!(edge.target().map(|e| e.node_id), Some(node_id_2)); } diff --git a/app/gui/view/graph-editor/src/new_node_position.rs b/app/gui/view/graph-editor/src/new_node_position.rs index 753a6b44967e..f56c1cfa8f6f 100644 --- a/app/gui/view/graph-editor/src/new_node_position.rs +++ b/app/gui/view/graph-editor/src/new_node_position.rs @@ -117,7 +117,7 @@ pub fn below_line_and_left_aligned( line_y: f32, align_x: f32, ) -> Vector2 { - let y_gap = graph_editor.frp.default_y_gap_between_nodes.value(); + let y_gap = graph_editor.frp_public.output.default_y_gap_between_nodes.value(); let y_offset = y_gap + node::HEIGHT / 2.0; let starting_point = Vector2(align_x, line_y - y_offset); let direction = Vector2(-1.0, 0.0); @@ -245,10 +245,10 @@ pub fn on_ray( starting_point: Vector2, direction: Vector2, ) -> Option { - let x_gap = graph_editor.frp.default_x_gap_between_nodes.value(); - let y_gap = graph_editor.frp.default_y_gap_between_nodes.value(); + let x_gap = graph_editor.frp_public.output.default_x_gap_between_nodes.value(); + let y_gap = graph_editor.frp_public.output.default_y_gap_between_nodes.value(); // This is how much horizontal space we are looking for. - let min_spacing = graph_editor.frp.min_x_spacing_for_new_nodes.value(); + let min_spacing = graph_editor.frp_public.output.min_x_spacing_for_new_nodes.value(); let nodes = graph_editor.nodes.all.raw.borrow(); // The "occupied area" for given node consists of: // - area taken by node view (obviously); diff --git a/app/gui/view/graph-editor/src/shortcuts.rs b/app/gui/view/graph-editor/src/shortcuts.rs new file mode 100644 index 000000000000..73ab6b14c048 --- /dev/null +++ b/app/gui/view/graph-editor/src/shortcuts.rs @@ -0,0 +1,68 @@ +//! Shortcuts used in the graph editor. + +use ensogl::application::shortcut::ActionType::*; + + + +// ======================================= +// === Shortcuts for the graph editor. === +// ======================================= + +/// The list of all shortcuts used in the graph editor. +pub const SHORTCUTS: &[(ensogl::application::shortcut::ActionType, &str, &str, &str)] = &[ + (Press, "!node_editing & !read_only", "tab", "start_node_creation"), + (Press, "!node_editing & !read_only", "enter", "start_node_creation"), + // === Drag === + (Press, "", "left-mouse-button", "node_press"), + (Release, "", "left-mouse-button", "node_release"), + (Press, "!node_editing & !read_only", "backspace", "remove_selected_nodes"), + (Press, "!node_editing & !read_only", "delete", "remove_selected_nodes"), + (Press, "has_detached_edge", "escape", "drop_dragged_edge"), + (Press, "!read_only", "cmd g", "collapse_selected_nodes"), + // === Visualization === + (Press, "!node_editing", "space", "press_visualization_visibility"), + (DoublePress, "!node_editing", "space", "double_press_visualization_visibility"), + (Release, "!node_editing", "space", "release_visualization_visibility"), + (Press, "", "cmd i", "reload_visualization_registry"), + (Press, "is_fs_visualization_displayed", "space", "close_fullscreen_visualization"), + (Press, "", "cmd", "enable_quick_visualization_preview"), + (Release, "", "cmd", "disable_quick_visualization_preview"), + // === Selection === + (Press, "", "shift", "enable_node_multi_select"), + (Press, "", "shift left-mouse-button", "enable_node_multi_select"), + (Release, "", "shift", "disable_node_multi_select"), + (Release, "", "shift left-mouse-button", "disable_node_multi_select"), + (Press, "", "shift ctrl", "toggle_node_merge_select"), + (Release, "", "shift ctrl", "toggle_node_merge_select"), + (Press, "", "shift alt", "toggle_node_subtract_select"), + (Release, "", "shift alt", "toggle_node_subtract_select"), + (Press, "", "shift ctrl alt", "toggle_node_inverse_select"), + (Release, "", "shift ctrl alt", "toggle_node_inverse_select"), + // === Navigation === + ( + Press, + "!is_fs_visualization_displayed", + "ctrl space", + "cycle_visualization_for_selected_node", + ), + (DoublePress, "!read_only", "left-mouse-button", "enter_hovered_node"), + (DoublePress, "!read_only", "left-mouse-button", "start_node_creation_from_port"), + (Press, "!read_only", "right-mouse-button", "start_node_creation_from_port"), + (Press, "!node_editing & !read_only", "cmd enter", "enter_selected_node"), + (Press, "!read_only", "alt enter", "exit_node"), + // === Node Editing === + (Press, "!read_only", "cmd", "edit_mode_on"), + (Release, "!read_only", "cmd", "edit_mode_off"), + (Press, "!read_only", "cmd left-mouse-button", "edit_mode_on"), + (Release, "!read_only", "cmd left-mouse-button", "edit_mode_off"), + (Press, "node_editing & !read_only", "cmd enter", "stop_editing"), + // === Profiling Mode === + (Press, "", "cmd p", "toggle_profiling_mode"), + // === Debug === + (Press, "debug_mode", "ctrl d", "debug_set_test_visualization_data_for_selected_node"), + (Press, "debug_mode", "ctrl shift enter", "debug_push_breadcrumb"), + (Press, "debug_mode", "ctrl shift up", "debug_pop_breadcrumb"), + (Press, "debug_mode", "ctrl n", "add_node_at_cursor"), + // === Execution Mode === + (Press, "", "shift ctrl e", "toggle_execution_environment"), +]; diff --git a/app/gui/view/src/lib.rs b/app/gui/view/src/lib.rs index f8f56740f9e3..ab816ae54bc7 100644 --- a/app/gui/view/src/lib.rs +++ b/app/gui/view/src/lib.rs @@ -41,7 +41,7 @@ pub mod window_control_buttons; pub use ide_view_component_browser as component_browser; pub use ide_view_documentation as documentation; -pub use ide_view_execution_mode_selector as execution_mode_selector; +pub use ide_view_execution_environment_selector as execution_environment_selector; pub use ide_view_graph_editor as graph_editor; pub use welcome_screen; diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index c4c4d2c70a9f..f3453bc427a3 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -269,6 +269,7 @@ export default [ }, ], '@typescript-eslint/no-confusing-void-expression': 'error', + '@typescript-eslint/no-empty-interface': 'off', '@typescript-eslint/no-extraneous-class': 'error', '@typescript-eslint/no-invalid-void-type': ['error', { allowAsThisParameter: true }], // React 17 and later supports async functions as event handlers, so we need to disable this diff --git a/app/ide-desktop/lib/client/src/authentication.ts b/app/ide-desktop/lib/client/src/authentication.ts index 59b49c2d21ea..2e83ff6dd8da 100644 --- a/app/ide-desktop/lib/client/src/authentication.ts +++ b/app/ide-desktop/lib/client/src/authentication.ts @@ -75,9 +75,6 @@ import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import opener from 'opener' - -import * as electron from 'electron' import * as electron from 'electron' import opener from 'opener' diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/forgotPassword.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/forgotPassword.tsx index 424076f51ecc..077e7301310e 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/forgotPassword.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/forgotPassword.tsx @@ -6,6 +6,7 @@ import * as router from 'react-router-dom' import * as app from '../../components/app' import * as auth from '../providers/auth' import * as svg from '../../components/svg' + import Input from './input' import SvgIcon from './svgIcon' diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/login.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/login.tsx index cf69cca78f53..734b6e06af48 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/login.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/login.tsx @@ -7,6 +7,7 @@ import * as fontawesomeIcons from '@fortawesome/free-brands-svg-icons' import * as app from '../../components/app' import * as auth from '../providers/auth' import * as svg from '../../components/svg' + import FontAwesomeIcon from './fontAwesomeIcon' import Input from './input' import SvgIcon from './svgIcon' @@ -15,9 +16,6 @@ import SvgIcon from './svgIcon' // === Constants === // ================= -const BUTTON_CLASS_NAME = - 'relative mt-6 border rounded-md py-2 text-sm text-gray-800 bg-gray-100 hover:bg-gray-200' - const LOGIN_QUERY_PARAMS = { email: 'email', } as const @@ -51,7 +49,7 @@ function Login() { event.preventDefault() await signInWithGoogle() }} - className={BUTTON_CLASS_NAME} + className="relative mt-6 border rounded-md py-2 text-sm text-gray-800 bg-gray-100 hover:bg-gray-200" > Login with Google @@ -61,7 +59,7 @@ function Login() { event.preventDefault() await signInWithGitHub() }} - className={BUTTON_CLASS_NAME} + className="relative mt-6 border rounded-md py-2 text-sm text-gray-800 bg-gray-100 hover:bg-gray-200" > Login with Github diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx index be1fbd181bb2..516f38dbe232 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx @@ -48,31 +48,12 @@ interface SessionProviderProps { export function SessionProvider(props: SessionProviderProps) { const { mainPageUrl, children, userSession, registerAuthEventListener } = props + const [refresh, doRefresh] = hooks.useRefresh() + /** Flag used to avoid rendering child components until we've fetched the user's session at least * once. Avoids flash of the login screen when the user is already logged in. */ const [initialized, setInitialized] = react.useState(false) - /** Produces a new object every time. - * This is not equal to any other empty object because objects are compared by reference. - * Because it is not equal to the old value, React re-renders the component. */ - function newRefresh() { - return {} - } - - /** State that, when set, forces a refresh of the user session. This is useful when a - * user has just logged in (so their cached credentials are out of date). Should be used via the - * `refreshSession` function. */ - const [refresh, setRefresh] = react.useState(newRefresh()) - - /** Forces a refresh of the user session. - * - * Should be called after any operation that **will** (not **might**) change the user's session. - * For example, this should be called after signing out. Calling this will result in a re-render - * of the whole page, which is why it should only be done when necessary. */ - const refreshSession = () => { - setRefresh(newRefresh()) - } - /** Register an async effect that will fetch the user's session whenever the `refresh` state is * incremented. This is useful when a user has just logged in (as their cached credentials are * out of date, so this will update them). */ @@ -83,7 +64,7 @@ export function SessionProvider(props: SessionProviderProps) { setInitialized(true) return innerSession }, - [refresh, userSession] + [userSession, refresh] ) /** Register an effect that will listen for authentication events. When the event occurs, we @@ -97,7 +78,7 @@ export function SessionProvider(props: SessionProviderProps) { switch (event) { case listen.AuthEvent.signIn: case listen.AuthEvent.signOut: { - refreshSession() + doRefresh() break } case listen.AuthEvent.customOAuthState: @@ -110,7 +91,7 @@ export function SessionProvider(props: SessionProviderProps) { * See: * https://github.com/aws-amplify/amplify-js/issues/3391#issuecomment-756473970 */ window.history.replaceState({}, '', mainPageUrl) - refreshSession() + doRefresh() break } default: { 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 7a62b529d5b2..c1ecda9868db 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 @@ -101,7 +101,7 @@ export const SECRET_ICON = ( ) @@ -164,6 +164,17 @@ export const ARROW_UP_ICON = ( ) +/** `+`-shaped icon representing creation of an item. */ +export const ADD_ICON = ( + + + + + + + +) + /** An icon representing creation of an item. */ export const CIRCLED_PLUS_ICON = ( - + ) @@ -213,6 +224,17 @@ export const SPEECH_BUBBLE_ICON = ( ) +/** `x`-shaped icon representing the closing of a window. */ +export const CLOSE_ICON = ( + + + + + + + +) + // =========== // === Svg === // =========== 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 new file mode 100644 index 000000000000..02a0140f444a --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx @@ -0,0 +1,62 @@ +/** @file Modal for confirming delete of any type of asset. */ +import toast from 'react-hot-toast' + +import * as modalProvider from '../../providers/modal' +import * as svg from '../../components/svg' + +import Modal from './modal' + +// ================= +// === Component === +// ================= + +export interface ConfirmDeleteModalProps { + assetType: string + name: string + doDelete: () => Promise + onSuccess: () => void +} + +function ConfirmDeleteModal(props: ConfirmDeleteModalProps) { + const { assetType, name, doDelete, onSuccess } = props + const { unsetModal } = modalProvider.useSetModal() + return ( + +
{ + event.stopPropagation() + }} + > + + Are you sure you want to delete the {assetType} '{name}'? +
+
{ + unsetModal() + await toast.promise(doDelete(), { + loading: `Deleting ${assetType}...`, + success: `Deleted ${assetType}.`, + error: `Could not delete ${assetType}.`, + }) + onSuccess() + }} + > + Delete +
+
+ Cancel +
+
+
+
+ ) +} + +export default ConfirmDeleteModal 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 new file mode 100644 index 000000000000..eb5855bfcf93 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenu.tsx @@ -0,0 +1,28 @@ +/** @file A context menu. */ + +import * as react from 'react' + +// ================= +// === Component === +// ================= + +export interface ContextMenuProps { + // `left: number` and `top: number` may be more correct, + // however passing an event eliminates the chance + // of passing the wrong coordinates from the event. + event: react.MouseEvent +} + +function ContextMenu(props: react.PropsWithChildren) { + const { children, event } = props + return ( +
+ {children} +
+ ) +} + +export default ContextMenu 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 new file mode 100644 index 000000000000..26fdc3695c00 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenuEntry.tsx @@ -0,0 +1,29 @@ +/** @file An entry in a context menu. */ + +import * as react from 'react' + +export interface ContextMenuEntryProps { + disabled?: boolean + onClick: (event: react.MouseEvent) => void +} + +// This component MUST NOT use `useState` because it is not rendered directly. +function ContextMenuEntry(props: react.PropsWithChildren) { + const { children, disabled, onClick } = props + return ( + + ) +} + +export default ContextMenuEntry diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/createForm.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/createForm.tsx new file mode 100644 index 000000000000..b99273ee142a --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/createForm.tsx @@ -0,0 +1,58 @@ +/** @file Base form to create an asset. + * This should never be used directly, but instead should be wrapped in a component + * that creates a specific asset type. */ + +import * as react from 'react' + +import * as modalProvider from '../../providers/modal' +import * as svg from '../../components/svg' + +import Modal from './modal' + +/** The props that should also be in the wrapper component. */ +export interface CreateFormPassthroughProps { + left: number + top: number +} + +/** `CreateFormPassthroughProps`, plus props that should be defined in the wrapper component. */ +export interface CreateFormProps extends CreateFormPassthroughProps, react.PropsWithChildren { + title: string + onSubmit: (event: react.FormEvent) => Promise +} + +function CreateForm(props: CreateFormProps) { + const { title, left, top, children, onSubmit: wrapperOnSubmit } = props + const { unsetModal } = modalProvider.useSetModal() + + async function onSubmit(event: react.FormEvent) { + event.preventDefault() + await wrapperOnSubmit(event) + } + + return ( + +
{ + event.stopPropagation() + }} + > + +

{title}

+ {children} + +
+
+ ) +} + +export default CreateForm diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx index 80ef19287bbe..9619f1b9b31a 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx @@ -1,26 +1,38 @@ /** @file Main dashboard component, responsible for listing user's projects as well as other * interactive components. */ import * as react from 'react' -import * as reactDom from 'react-dom' import * as projectManagerModule from 'enso-content/src/project_manager' import * as auth from '../../authentication/providers/auth' import * as backend from '../service' +import * as fileInfo from '../../fileInfo' +import * as hooks from '../../hooks' import * as loggerProvider from '../../providers/logger' import * as modalProvider from '../../providers/modal' import * as newtype from '../../newtype' import * as platformModule from '../../platform' import * as svg from '../../components/svg' +import * as uploadMultipleFiles from '../../uploadMultipleFiles' -import Label, * as label from './label' import PermissionDisplay, * as permissionDisplay from './permissionDisplay' +import ContextMenu from './contextMenu' +import ContextMenuEntry from './contextMenuEntry' import Ide from './ide' import ProjectActionButton from './projectActionButton' import Rows from './rows' import Templates from './templates' import TopBar from './topBar' +import ConfirmDeleteModal from './confirmDeleteModal' +import RenameModal from './renameModal' +import UploadFileModal from './uploadFileModal' + +import DirectoryCreateForm from './directoryCreateForm' +import FileCreateForm from './fileCreateForm' +import ProjectCreateForm from './projectCreateForm' +import SecretCreateForm from './secretCreateForm' + // ============= // === Types === // ============= @@ -32,9 +44,15 @@ export enum Tab { } enum ColumnDisplayMode { + /** Show only columns which are ready for release. */ + release = 'release', + /** Show all columns. */ all = 'all', + /** Show only name and metadata. */ compact = 'compact', + /** Show only columns relevant to documentation editors. */ docs = 'docs', + /** Show only name, metadata, and configuration options. */ settings = 'settings', } @@ -51,10 +69,29 @@ enum Column { ide = 'ide', } +/** Values provided to form creation dialogs. */ +export interface CreateFormProps { + left: number + top: number + backend: backend.Backend + directoryId: backend.DirectoryId + onSuccess: () => void +} + // ================= // === Constants === // ================= +/** Enables features which are not ready for release, + * and so are intentionally disabled for release builds. */ +// This type annotation is explicit to undo TypeScript narrowing to `false`, +// which result in errors about unused code. +// eslint-disable-next-line @typescript-eslint/no-inferrable-types +const EXPERIMENTAL: boolean = true + +/** The `localStorage` key under which the ID of the current directory is stored. */ +const DIRECTORY_STACK_KEY = 'enso-dashboard-directory-stack' + /** English names for the name column. */ const ASSET_TYPE_NAME: Record = { [backend.AssetType.project]: 'Projects', @@ -63,6 +100,14 @@ const ASSET_TYPE_NAME: Record = { [backend.AssetType.directory]: 'Folders', } as const +/** Forms to create each asset type. */ +const ASSET_TYPE_CREATE_FORM: Record JSX.Element> = { + [backend.AssetType.project]: ProjectCreateForm, + [backend.AssetType.file]: FileCreateForm, + [backend.AssetType.secret]: SecretCreateForm, + [backend.AssetType.directory]: DirectoryCreateForm, +} + /** English names for every column except for the name column. */ const COLUMN_NAME: Record, string> = { [Column.lastModified]: 'Last modified', @@ -75,8 +120,35 @@ const COLUMN_NAME: Record, string> = { [Column.ide]: 'IDE', } as const +/** The corresponding `Permissions` for each backend `PermissionAction`. */ +const PERMISSION: Record = { + [backend.PermissionAction.own]: { type: permissionDisplay.Permission.owner }, + [backend.PermissionAction.execute]: { + type: permissionDisplay.Permission.regular, + read: false, + write: false, + docsWrite: false, + exec: true, + }, + [backend.PermissionAction.edit]: { + type: permissionDisplay.Permission.regular, + read: false, + write: true, + docsWrite: false, + exec: false, + }, + [backend.PermissionAction.read]: { + type: permissionDisplay.Permission.regular, + read: true, + write: false, + docsWrite: false, + exec: false, + }, +} + /** The list of columns displayed on each `ColumnDisplayMode`. */ const COLUMNS_FOR: Record = { + [ColumnDisplayMode.release]: [Column.name, Column.lastModified, Column.sharedWith], [ColumnDisplayMode.all]: [ Column.name, Column.lastModified, @@ -104,64 +176,10 @@ const COLUMNS_FOR: Record = { ], } -/** React components for every column except for the name column. */ -const COLUMN_RENDERER: Record< - Exclude, - (project: backend.Asset) => JSX.Element -> = { - [Column.lastModified]: () => <>aa, - [Column.sharedWith]: () => <>aa, - [Column.docs]: () => <>aa, - [Column.labels]: () => ( - <> - - - - - ), - [Column.dataAccess]: () => ( - <> - - ./user_data - - - this folder - - - no access - - - ), - [Column.usagePlan]: () => <>aa, - [Column.engine]: () => <>aa, - [Column.ide]: () => <>aa, -} - // ======================== // === Helper functions === // ======================== -/** English names for every column. */ -function columnName(column: Column, assetType: backend.AssetType) { - return column === Column.name ? ASSET_TYPE_NAME[assetType] : COLUMN_NAME[column] -} - /** Returns the id of the root directory for a user or organization. */ function rootDirectoryId(userOrOrganizationId: backend.UserOrOrganizationId) { return newtype.asNewtype( @@ -169,16 +187,6 @@ function rootDirectoryId(userOrOrganizationId: backend.UserOrOrganizationId) { ) } -/** Returns the file extension of a file name. */ -function fileExtension(fileName: string) { - return fileName.match(/\.(.+?)$/)?.[1] ?? '' -} - -/** Returns the appropriate icon for a specific file extension. */ -function fileIcon(_extension: string) { - return svg.FILE_ICON -} - // ================= // === Dashboard === // ================= @@ -199,46 +207,135 @@ interface OtherDashboardProps extends BaseDashboardProps { export type DashboardProps = DesktopDashboardProps | OtherDashboardProps +// TODO[sb]: Implement rename when clicking name of a selected row. +// There is currently no way to tell whether a row is selected from a column. + function Dashboard(props: DashboardProps) { const { logger, platform } = props const { accessToken, organization } = auth.useFullUserSession() const backendService = backend.createBackend(accessToken, logger) - const { modal } = modalProvider.useModal() - const { unsetModal } = modalProvider.useSetModal() + const { setModal, unsetModal } = modalProvider.useSetModal() + + const [refresh, doRefresh] = hooks.useRefresh() - const [searchVal, setSearchVal] = react.useState('') + const [query, setQuery] = react.useState('') const [directoryId, setDirectoryId] = react.useState(rootDirectoryId(organization.id)) const [directoryStack, setDirectoryStack] = react.useState< backend.Asset[] >([]) - const [columnDisplayMode, setColumnDisplayMode] = react.useState(ColumnDisplayMode.compact) - const [selectedAssets, setSelectedAssets] = react.useState([]) + // Defined by the spec as `compact` by default, however it is not ready yet. + const [columnDisplayMode, setColumnDisplayMode] = react.useState(ColumnDisplayMode.release) - const [projectAssets, setProjectAssets] = react.useState< + const [projectAssets, setProjectAssetsRaw] = react.useState< + backend.Asset[] + >([]) + const [directoryAssets, setDirectoryAssetsRaw] = react.useState< + backend.Asset[] + >([]) + const [secretAssets, setSecretAssetsRaw] = react.useState< + backend.Asset[] + >([]) + const [fileAssets, setFileAssetsRaw] = react.useState[]>( + [] + ) + const [visibleProjectAssets, setVisibleProjectAssets] = react.useState< backend.Asset[] >([]) - const [directoryAssets, setDirectoryAssets] = react.useState< + const [visibleDirectoryAssets, setVisibleDirectoryAssets] = react.useState< backend.Asset[] >([]) - const [secretAssets, setSecretAssets] = react.useState< + const [visibleSecretAssets, setVisibleSecretAssets] = react.useState< backend.Asset[] >([]) - const [fileAssets, setFileAssets] = react.useState[]>([]) + const [visibleFileAssets, setVisibleFileAssets] = react.useState< + backend.Asset[] + >([]) const [tab, setTab] = react.useState(Tab.dashboard) const [project, setProject] = react.useState(null) + const [selectedAssets, setSelectedAssets] = react.useState([]) + const [isFileBeingDragged, setIsFileBeingDragged] = react.useState(false) + const directory = directoryStack[directoryStack.length - 1] const parentDirectory = directoryStack[directoryStack.length - 2] + function setProjectAssets(newProjectAssets: backend.Asset[]) { + setProjectAssetsRaw(newProjectAssets) + setVisibleProjectAssets(newProjectAssets.filter(asset => asset.title.includes(query))) + } + function setDirectoryAssets(newDirectoryAssets: backend.Asset[]) { + setDirectoryAssetsRaw(newDirectoryAssets) + setVisibleDirectoryAssets(newDirectoryAssets.filter(asset => asset.title.includes(query))) + } + function setSecretAssets(newSecretAssets: backend.Asset[]) { + setSecretAssetsRaw(newSecretAssets) + setVisibleSecretAssets(newSecretAssets.filter(asset => asset.title.includes(query))) + } + function setFileAssets(newFileAssets: backend.Asset[]) { + setFileAssetsRaw(newFileAssets) + setVisibleFileAssets(newFileAssets.filter(asset => asset.title.includes(query))) + } + + function exitDirectory() { + setDirectoryId(parentDirectory?.id ?? rootDirectoryId(organization.id)) + setDirectoryStack( + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + directoryStack.slice(0, -1) + ) + } + + function enterDirectory(directoryAsset: backend.Asset) { + setDirectoryId(directoryAsset.id) + setDirectoryStack([...directoryStack, directoryAsset]) + } + + react.useEffect(() => { + const cachedDirectoryStackJson = localStorage.getItem(DIRECTORY_STACK_KEY) + if (cachedDirectoryStackJson) { + // The JSON was inserted by the code below, so it will always have the right type. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const cachedDirectoryStack: backend.Asset[] = + JSON.parse(cachedDirectoryStackJson) + setDirectoryStack(cachedDirectoryStack) + const cachedDirectoryId = cachedDirectoryStack[cachedDirectoryStack.length - 1]?.id + if (cachedDirectoryId) { + setDirectoryId(cachedDirectoryId) + } + } + }, []) + + react.useEffect(() => { + if (directoryId === rootDirectoryId(organization.id)) { + localStorage.removeItem(DIRECTORY_STACK_KEY) + } else { + localStorage.setItem(DIRECTORY_STACK_KEY, JSON.stringify(directoryStack)) + } + }, [directoryStack]) + /** React components for the name column. */ const nameRenderers: { [Type in backend.AssetType]: (asset: backend.Asset) => JSX.Element } = { [backend.AssetType.project]: projectAsset => ( -
+
{ + if (event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) { + setModal(() => ( + Promise.resolve()} + onSuccess={doRefresh} + /> + )) + } + }} + > { @@ -252,43 +349,185 @@ function Dashboard(props: DashboardProps) { [backend.AssetType.directory]: directoryAsset => (
{ + if (event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) { + setModal(() => ( + Promise.resolve()} + onSuccess={doRefresh} + /> + )) + } + }} onDoubleClick={() => { - setDirectoryId(directoryAsset.id) - setDirectoryStack([...directoryStack, directoryAsset]) + enterDirectory(directoryAsset) }} > {svg.DIRECTORY_ICON} {directoryAsset.title}
), [backend.AssetType.secret]: secret => ( -
+
{ + if (event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) { + setModal(() => ( + Promise.resolve()} + onSuccess={doRefresh} + /> + )) + } + }} + > {svg.SECRET_ICON} {secret.title}
), [backend.AssetType.file]: file => ( -
- {fileIcon(fileExtension(file.title))} {file.title} +
{ + if (event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) { + setModal(() => ( + Promise.resolve()} + onSuccess={doRefresh} + /> + )) + } + }} + > + {fileInfo.fileIcon(fileInfo.fileExtension(file.title))}{' '} + {file.title}
), } - const renderer = (column: Column, assetType: Type) => { + /** React components for every column except for the name column. */ + const columnRenderer: Record< + Exclude, + (asset: backend.Asset) => JSX.Element + > = { + [Column.lastModified]: () => <>, + [Column.sharedWith]: asset => ( + <> + {(asset.permissions ?? []).map(user => ( + + + + ))} + + ), + [Column.docs]: () => <>, + [Column.labels]: () => { + // This is not a React component even though it contains JSX. + // eslint-disable-next-line no-restricted-syntax + function onContextMenu(event: react.MouseEvent) { + event.preventDefault() + event.stopPropagation() + setModal(() => ( + + { + // TODO: Wait for backend implementation. + }} + > + Rename label + + + )) + } + return <> + }, + [Column.dataAccess]: () => <>, + [Column.usagePlan]: () => <>, + [Column.engine]: () => <>, + [Column.ide]: () => <>, + } + + function renderer(column: Column, assetType: Type) { return column === Column.name ? // This is type-safe only if we pass enum literals as `assetType`. + // eslint-disable-next-line no-restricted-syntax (nameRenderers[assetType] as (asset: backend.Asset) => JSX.Element) - : COLUMN_RENDERER[column] + : columnRenderer[column] + } + + /** Heading element for every column. */ + function ColumnHeading(column: Column, assetType: backend.AssetType) { + return column === Column.name ? ( +
+ {ASSET_TYPE_NAME[assetType]} + +
+ ) : ( + <>{COLUMN_NAME[column]} + ) } // The purpose of this effect is to enable search action. react.useEffect(() => { - return () => { - // TODO - } - }, [searchVal]) + 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))) + }, [query]) - react.useEffect(() => { - void (async (): Promise => { + function setAssets(assets: backend.Asset[]) { + const newProjectAssets = assets.filter(backend.assetIsType(backend.AssetType.project)) + const newDirectoryAssets = assets.filter(backend.assetIsType(backend.AssetType.directory)) + const newSecretAssets = assets.filter(backend.assetIsType(backend.AssetType.secret)) + const newFileAssets = assets.filter(backend.assetIsType(backend.AssetType.file)) + setProjectAssets(newProjectAssets) + setDirectoryAssets(newDirectoryAssets) + setSecretAssets(newSecretAssets) + setFileAssets(newFileAssets) + } + + hooks.useAsyncEffect( + null, + async signal => { let assets: backend.Asset[] switch (platform) { @@ -308,22 +547,53 @@ function Dashboard(props: DashboardProps) { title: localProject.name, id: localProject.id, parentId: '', - permissions: [], + permissions: null, }) } break } } - reactDom.unstable_batchedUpdates(() => { - setProjectAssets(assets.filter(backend.assetIsType(backend.AssetType.project))) - setDirectoryAssets(assets.filter(backend.assetIsType(backend.AssetType.directory))) - setSecretAssets(assets.filter(backend.assetIsType(backend.AssetType.secret))) - setFileAssets(assets.filter(backend.assetIsType(backend.AssetType.file))) - }) - })() - }, [accessToken, directoryId]) - - const getNewProjectName = (templateName?: string | null): string => { + if (!signal.aborted) { + setAssets(assets) + } + }, + [accessToken, directoryId, refresh] + ) + + react.useEffect(() => { + function onBlur() { + setIsFileBeingDragged(false) + } + + window.addEventListener('blur', onBlur) + + return () => { + window.removeEventListener('blur', onBlur) + } + }, []) + + function handleEscapeKey(event: react.KeyboardEvent) { + if ( + event.key === 'Escape' && + !event.ctrlKey && + !event.shiftKey && + !event.altKey && + !event.metaKey + ) { + if (modal) { + event.preventDefault() + unsetModal() + } + } + } + + function openDropZone(event: react.DragEvent) { + if (event.dataTransfer.types.includes('Files')) { + setIsFileBeingDragged(true) + } + } + + function getNewProjectName(templateName?: string | null): string { const prefix = `${templateName ?? 'New_Project'}_` const projectNameTemplate = new RegExp(`^${prefix}(?\\d+)$`) let highestProjectIndex = 0 @@ -336,19 +606,22 @@ function Dashboard(props: DashboardProps) { return `${prefix}${highestProjectIndex + 1}` } - const handleCreateProject = async (templateName?: string | null) => { + async function handleCreateProject(templateName: string | null) { const projectName = getNewProjectName(templateName) switch (platform) { case platformModule.Platform.cloud: { const body: backend.CreateProjectRequestBody = { projectName, + projectTemplateName: + templateName?.replace(/_/g, '').toLocaleLowerCase() ?? null, + parentDirectoryId: directoryId, } if (templateName) { body.projectTemplateName = templateName.replace(/_/g, '').toLocaleLowerCase() } const projectAsset = await backendService.createProject(body) - setProjectAssets(oldProjectAssets => [ - ...oldProjectAssets, + setProjectAssets([ + ...projectAssets, { type: backend.AssetType.project, title: projectAsset.name, @@ -365,8 +638,8 @@ function Dashboard(props: DashboardProps) { ...(templateName ? { projectTemplate: templateName } : {}), }) const newProject = result.result - setProjectAssets(oldProjectAssets => [ - ...oldProjectAssets, + setProjectAssets([ + ...projectAssets, { type: backend.AssetType.project, title: projectName, @@ -381,8 +654,15 @@ function Dashboard(props: DashboardProps) { } return ( -
-
+
+

Drive

- {/* FIXME[sb]: Remove `|| true` when UI to create directory is implemented. */} - {/* eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition */} - {directory || true ? ( + {directory && ( <> - {svg.SMALL_RIGHT_ARROW_ICON} - ) : null} - {directory?.title ?? '~'} + )} + {directory?.title ?? '/'}
Shared with
@@ -435,11 +701,18 @@ function Dashboard(props: DashboardProps) {
-
+
-
- - - - -
+ {EXPERIMENTAL && ( + <> +
+ + + + +
+ + )}
- - + +
+ + > - items={projectAssets} + items={visibleProjectAssets} getKey={proj => proj.id} placeholder={ - <> + You have no project yet. Go ahead and create one using the form above. - + } columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ id: column, - name: columnName(column, backend.AssetType.project), + heading: ColumnHeading(column, backend.AssetType.project), render: renderer(column, backend.AssetType.project), }))} + onClick={projectAsset => { + setSelectedAssets([projectAsset]) + }} + onContextMenu={(projectAsset, event) => { + event.preventDefault() + event.stopPropagation() + function doOpenForEditing() { + // FIXME[sb]: Switch to IDE tab + // once merged with `show-and-open-workspace` branch. + } + function doOpenAsFolder() { + // FIXME[sb]: Uncomment once backend support + // is in place. + // The following code does not typecheck + // since `ProjectId`s are not `DirectoryId`s. + // enterDirectory(projectAsset) + } + // This is not a React component even though it contains JSX. + // eslint-disable-next-line no-restricted-syntax + function doRename() { + setModal(() => ( + Promise.resolve()} + onSuccess={doRefresh} + /> + )) + } + // This is not a React component even though it contains JSX. + // eslint-disable-next-line no-restricted-syntax + function doDelete() { + setModal(() => ( + + backendService.deleteProject(projectAsset.id) + } + onSuccess={doRefresh} + /> + )) + } + setModal(() => ( + + + Open for editing + + + Open as folder + + + Rename + + + Delete + + + )) + }} /> - - > - items={directoryAssets} - getKey={proj => proj.id} - placeholder={<>This directory does not contain any subdirectories.} - columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ - id: column, - name: columnName(column, backend.AssetType.directory), - render: renderer(column, backend.AssetType.directory), - }))} - /> - - > - items={secretAssets} - getKey={proj => proj.id} - placeholder={<>This directory does not contain any secrets.} - columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ - id: column, - name: columnName(column, backend.AssetType.secret), - render: renderer(column, backend.AssetType.secret), - }))} - /> - - > - items={fileAssets} - getKey={proj => proj.id} - placeholder={<>This directory does not contain any files.} - columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ - id: column, - name: columnName(column, backend.AssetType.file), - render: renderer(column, backend.AssetType.file), - }))} - /> -
-
-
- {project ? : <>} -
+ {platform === platformModule.Platform.cloud && ( + <> + + > + items={visibleDirectoryAssets} + getKey={dir => dir.id} + placeholder={ + + This directory does not contain any subdirectories + {query ? ' matching your query' : ''}. + + } + columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ + id: column, + heading: ColumnHeading(column, backend.AssetType.directory), + render: renderer(column, backend.AssetType.directory), + }))} + onClick={directoryAsset => { + setSelectedAssets([directoryAsset]) + }} + onContextMenu={(_directory, event) => { + event.preventDefault() + event.stopPropagation() + setModal(() => ) + }} + /> + + > + items={visibleSecretAssets} + getKey={secret => secret.id} + placeholder={ + + This directory does not contain any secrets + {query ? ' matching your query' : ''}. + + } + columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ + id: column, + heading: ColumnHeading(column, backend.AssetType.secret), + render: renderer(column, backend.AssetType.secret), + }))} + onClick={secret => { + setSelectedAssets([secret]) + }} + onContextMenu={(secret, event) => { + event.preventDefault() + event.stopPropagation() + // This is not a React component even though it contains JSX. + // eslint-disable-next-line no-restricted-syntax + function doDelete() { + setModal(() => ( + + backendService.deleteSecret(secret.id) + } + onSuccess={doRefresh} + /> + )) + } + setModal(() => ( + + + Delete + + + )) + }} + /> + + > + items={visibleFileAssets} + getKey={file => file.id} + placeholder={ + + This directory does not contain any files + {query ? ' matching your query' : ''}. + + } + columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ + id: column, + heading: ColumnHeading(column, backend.AssetType.file), + render: renderer(column, backend.AssetType.file), + }))} + onClick={file => { + setSelectedAssets([file]) + }} + onContextMenu={(file, event) => { + event.preventDefault() + event.stopPropagation() + function doCopy() { + /** TODO: Call endpoint for copying file. */ + } + function doCut() { + /** TODO: Call endpoint for downloading file. */ + } + // This is not a React component even though it contains JSX. + // eslint-disable-next-line no-restricted-syntax + function doDelete() { + setModal(() => ( + backendService.deleteFile(file.id)} + onSuccess={doRefresh} + /> + )) + } + function doDownload() { + /** TODO: Call endpoint for downloading file. */ + } + setModal(() => ( + + + Copy + + + Cut + + + Delete + + + Download + + + )) + }} + /> + + )} + + + {isFileBeingDragged ? ( +
{ + setIsFileBeingDragged(false) + }} + onDragOver={event => { + event.preventDefault() + }} + onDrop={async event => { + event.preventDefault() + setIsFileBeingDragged(false) + await uploadMultipleFiles.uploadMultipleFiles( + backendService, + directoryId, + Array.from(event.dataTransfer.files) + ) + doRefresh() + }} + > + Drop to upload files. +
+ ) : null} + {/* This should be just `{modal}`, however TypeScript incorrectly throws an error. */} + {project && } {modal && <>{modal}}
) 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 new file mode 100644 index 000000000000..8f13d6e4da19 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryCreateForm.tsx @@ -0,0 +1,63 @@ +/** @file Form to create a project. */ +import * as react from 'react' +import toast from 'react-hot-toast' + +import * as backendModule from '../service' +import * as error from '../../error' +import * as modalProvider from '../../providers/modal' +import CreateForm, * as createForm from './createForm' + +export interface DirectoryCreateFormProps extends createForm.CreateFormPassthroughProps { + backend: backendModule.Backend + directoryId: backendModule.DirectoryId + onSuccess: () => void +} + +function DirectoryCreateForm(props: DirectoryCreateFormProps) { + const { backend, directoryId, onSuccess, ...passThrough } = props + const { unsetModal } = modalProvider.useSetModal() + const [name, setName] = react.useState(null) + + async function onSubmit(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/fileCreateForm.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/fileCreateForm.tsx new file mode 100644 index 000000000000..20ae40b26c9b --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/fileCreateForm.tsx @@ -0,0 +1,90 @@ +/** @file Form to create a project. */ +import * as react from 'react' +import toast from 'react-hot-toast' + +import * as backendModule from '../service' +import * as error from '../../error' +import * as modalProvider from '../../providers/modal' +import CreateForm, * as createForm from './createForm' + +export interface FileCreateFormProps extends createForm.CreateFormPassthroughProps { + backend: backendModule.Backend + directoryId: backendModule.DirectoryId + onSuccess: () => void +} + +function FileCreateForm(props: FileCreateFormProps) { + const { backend, directoryId, onSuccess, ...passThrough } = props + const { unsetModal } = modalProvider.useSetModal() + const [name, setName] = react.useState(null) + const [file, setFile] = react.useState(null) + + async function onSubmit(event: react.FormEvent) { + event.preventDefault() + if (file == null) { + // TODO[sb]: Uploading a file may be a mistake when creating a new file. + toast.error('Please select a file to upload.') + } else { + unsetModal() + await toast + .promise( + backend.uploadFile( + { + parentDirectoryId: directoryId, + fileName: name ?? file.name, + }, + file + ), + { + loading: 'Uploading file...', + success: 'Sucessfully uploaded file.', + error: error.unsafeIntoErrorMessage, + } + ) + .then(onSuccess) + } + } + + return ( + +
+ + { + setName(event.target.value) + }} + defaultValue={name ?? file?.name ?? ''} + /> +
+
+
File
+
+ + { + setFile(event.target.files?.[0] ?? null) + }} + /> +
+
+
+ ) +} + +export default FileCreateForm diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/ide.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/ide.tsx index a149bf25b7bd..d453f5b67fb7 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/ide.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/ide.tsx @@ -7,6 +7,8 @@ import * as service from '../service' // === Constants === // ================= +/** The `id` attribute of the element that the IDE will be rendered into. */ +const IDE_ELEMENT_ID = 'root' const IDE_CDN_URL = 'https://ensocdn.s3.us-west-1.amazonaws.com/ide' // ================= @@ -19,7 +21,9 @@ interface Props { } /** Container that launches the IDE. */ -function Ide({ project, backendService }: Props) { +function Ide(props: Props) { + const { project, backendService } = props + const [ideElement] = react.useState(() => document.querySelector(IDE_ELEMENT_ID)) const [[loaded, resolveLoaded]] = react.useState((): [Promise, () => void] => { let resolve!: () => void const promise = new Promise(innerResolve => { @@ -28,6 +32,13 @@ function Ide({ project, backendService }: Props) { return [promise, resolve] }) + react.useEffect(() => { + document.getElementById(IDE_ELEMENT_ID)?.classList.remove('hidden') + return () => { + document.getElementById(IDE_ELEMENT_ID)?.classList.add('hidden') + } + }, []) + react.useEffect(() => { void (async () => { const ideVersion = ( @@ -53,6 +64,9 @@ function Ide({ project, backendService }: Props) { react.useEffect(() => { void (async () => { + while (ideElement?.firstChild) { + ideElement.removeChild(ideElement.firstChild) + } const ideVersion = ( await backendService.listVersions({ versionType: service.VersionType.ide, @@ -86,7 +100,7 @@ function Ide({ project, backendService }: Props) { })() }, [project]) - return
+ return <> } export default Ide diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/label.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/label.tsx index df1facc5da2a..d471866412fa 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/label.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/label.tsx @@ -38,13 +38,16 @@ const STATUS_ICON: Record = { export interface LabelProps { status?: Status + onContextMenu?: react.MouseEventHandler } /** A label, which may be either user-defined, or a system warning message. */ -function Label({ status = Status.none, children }: react.PropsWithChildren) { +function Label(props: react.PropsWithChildren) { + const { status = Status.none, children, onContextMenu } = props return (
{STATUS_ICON[status]}
{children}
diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/permissionDisplay.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/permissionDisplay.tsx index f1c978bbabb3..a130b81a93a6 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/permissionDisplay.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/permissionDisplay.tsx @@ -92,7 +92,7 @@ function PermissionDisplay(props: react.PropsWithChildren {permissionBorder} -
{children}
+
{children}
) } 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 new file mode 100644 index 000000000000..12b6a0ee486b --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectCreateForm.tsx @@ -0,0 +1,82 @@ +/** @file Form to create a project. */ +import * as react from 'react' +import toast from 'react-hot-toast' + +import * as backendModule from '../service' +import * as error from '../../error' +import * as modalProvider from '../../providers/modal' +import CreateForm, * as createForm from './createForm' + +export interface ProjectCreateFormProps extends createForm.CreateFormPassthroughProps { + backend: backendModule.Backend + directoryId: backendModule.DirectoryId + onSuccess: () => void +} + +// FIXME[sb]: Extract shared shape to a common component. +function ProjectCreateForm(props: ProjectCreateFormProps) { + const { backend, directoryId, onSuccess, ...passThrough } = props + const { unsetModal } = modalProvider.useSetModal() + + const [name, setName] = react.useState(null) + const [template, setTemplate] = react.useState(null) + + async function onSubmit(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 new file mode 100644 index 000000000000..41fed6f6a660 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx @@ -0,0 +1,79 @@ +/** @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' +import * as svg from '../../components/svg' + +import Modal from './modal' + +export interface RenameModalProps { + assetType: string + name: string + doRename: (newName: string) => Promise + onSuccess: () => void +} + +function RenameModal(props: RenameModalProps) { + const { assetType, name, doRename, onSuccess } = props + const { unsetModal } = modalProvider.useSetModal() + const [newName, setNewName] = react.useState(null) + return ( + +
{ + event.stopPropagation() + }} + > + + What do you want to rename the {assetType} '{name}' to? +
+ + { + setNewName(event.target.value) + }} + defaultValue={newName ?? ''} + /> +
+
+
{ + if (newName == null) { + toast.error('Please provide a new name.') + } else { + unsetModal() + await toast.promise(doRename(newName), { + loading: `Deleting ${assetType}...`, + success: `Deleted ${assetType}.`, + error: `Could not delete ${assetType}.`, + }) + onSuccess() + } + }} + > + Rename +
+
+ Cancel +
+
+
+
+ ) +} + +export default RenameModal diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/rows.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/rows.tsx index d6e06a472d05..867bf6764a90 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/rows.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/rows.tsx @@ -1,4 +1,5 @@ /** @file Table that projects an object into each column. */ +import * as react from 'react' // ============= // === Types === @@ -7,7 +8,7 @@ /** Metadata describing how to render a column of the table. */ export interface Column { id: string - name: string + heading: JSX.Element render: (item: T, index: number) => JSX.Element } @@ -16,32 +17,42 @@ export interface Column { // ================= interface Props { - columns: Column[] items: T[] getKey: (item: T) => string placeholder: JSX.Element + columns: Column[] + onClick: (item: T, event: react.MouseEvent) => void + onContextMenu: (item: T, event: react.MouseEvent) => void } /** Table that projects an object into each column. */ -function Rows({ columns, items, getKey, placeholder }: Props) { - const headerRow = columns.map(({ name }, index) => ( +function Rows(props: Props) { + const { columns, items, getKey, placeholder, onClick, onContextMenu } = props + const headerRow = columns.map(({ heading }, index) => ( - {name} + {heading} )) const itemRows = items.length === 0 ? ( - + {placeholder} ) : ( items.map((item, index) => ( { + onClick(item, event) + }} + onContextMenu={event => { + onContextMenu(item, event) + }} + className="h-10 transition duration-300 ease-in-out hover:bg-gray-100 focus:bg-gray-200" > {columns.map(({ id, render }) => ( diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretCreateForm.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretCreateForm.tsx new file mode 100644 index 000000000000..d6994a085a01 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretCreateForm.tsx @@ -0,0 +1,83 @@ +/** @file Form to create a project. */ +import * as react from 'react' +import toast from 'react-hot-toast' + +import * as backendModule from '../service' +import * as error from '../../error' +import * as modalProvider from '../../providers/modal' +import CreateForm, * as createForm from './createForm' + +export interface SecretCreateFormProps extends createForm.CreateFormPassthroughProps { + backend: backendModule.Backend + directoryId: backendModule.DirectoryId + onSuccess: () => void +} + +function SecretCreateForm(props: SecretCreateFormProps) { + const { backend, directoryId, onSuccess, ...passThrough } = props + const { unsetModal } = modalProvider.useSetModal() + + const [name, setName] = react.useState(null) + const [value, setValue] = react.useState(null) + + async function onSubmit(event: react.FormEvent) { + event.preventDefault() + if (!name) { + toast.error('Please provide a secret name.') + } else if (value == null) { + // Secret value explicitly can be empty. + toast.error('Please provide a secret value.') + } else { + unsetModal() + await toast + .promise( + backend.createSecret({ + parentDirectoryId: directoryId, + secretName: name, + secretValue: value, + }), + { + loading: 'Creating secret...', + success: 'Sucessfully created secret.', + error: error.unsafeIntoErrorMessage, + } + ) + .then(onSuccess) + } + } + + return ( + +
+ + { + setName(event.target.value) + }} + /> +
+
+ + { + setValue(event.target.value) + }} + /> +
+
+ ) +} + +export default SecretCreateForm 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 01cb40de01dd..d34a67373f3c 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 @@ -61,7 +61,7 @@ const TEMPLATES: Template[] = [ interface TemplatesRenderProps { // Later this data may be requested and therefore needs to be passed dynamically. templates: Template[] - onTemplateClick: (name?: string | null) => void + onTemplateClick: (name: string | null) => void } function TemplatesRender(props: TemplatesRenderProps) { @@ -71,7 +71,7 @@ function TemplatesRender(props: TemplatesRenderProps) { const CreateEmptyTemplate = ( +
+ + { + setName(event.target.value) + }} + defaultValue={name ?? file?.name ?? ''} + /> +
+
+ +
+
+ { + setFile(event.target.files?.[0] ?? null) + }} + /> +
+
+
{file?.name ?? 'No file selected'}
+
+ {file ? fileInfo.toReadableSize(file.size) : '\u00a0'} +
+
+
+ {file ? fileInfo.fileIcon(fileInfo.fileExtension(file.name)) : <>} +
+
+
+
+
+ Upload +
+
+ Cancel +
+
+ + + ) +} + +export default UploadFileModal diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts index 24fc9f476f91..e0727a5c75f6 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts @@ -303,7 +303,7 @@ export interface UserPermission { } /** Metadata uniquely identifying a directory entry. - * Thes can be Projects, Files, Secrets, or other directories. */ + * These can be Projects, Files, Secrets, or other directories. */ interface BaseAsset { title: string id: string @@ -326,15 +326,13 @@ export interface IdType { } /** Metadata uniquely identifying a directory entry. - * Thes can be Projects, Files, Secrets, or other directories. */ + * These can be Projects, Files, Secrets, or other directories. */ export interface Asset extends BaseAsset { type: Type id: IdType[Type] } -// This is an alias. -// It should be a separate type because it is the return value of one of the APIs. -// eslint-disable-next-line @typescript-eslint/no-empty-interface +/** The type returned from the "create directory" endpoint. */ export interface Directory extends Asset {} // ================= @@ -350,14 +348,14 @@ export interface CreateUserRequestBody { /** HTTP request body for the "create directory" endpoint. */ export interface CreateDirectoryRequestBody { title: string - parentId?: DirectoryId + parentId: DirectoryId | null } /** HTTP request body for the "create project" endpoint. */ export interface CreateProjectRequestBody { projectName: string - projectTemplateName?: string - parentDirectoryId?: DirectoryId + projectTemplateName: string | null + parentDirectoryId: DirectoryId | null } /** @@ -379,7 +377,7 @@ export interface OpenProjectRequestBody { export interface CreateSecretRequestBody { secretName: string secretValue: string - parentDirectoryId?: DirectoryId + parentDirectoryId: DirectoryId | null } /** HTTP request body for the "create tag" endpoint. */ diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts index a9c35c2ec44e..d3782264e599 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts @@ -1,5 +1,27 @@ /** @file Contains useful error types common across the module. */ +// ================================ +// === Type assertions (unsafe) === +// ================================ + +type MustBeAny = never extends T ? (T & 1 extends 0 ? T : never) : never + +export function unsafeAsError(error: MustBeAny) { + // This is UNSAFE - errors can be any value. + // Usually they *do* extend `Error`, + // however great care must be taken when deciding to use this. + // eslint-disable-next-line no-restricted-syntax + return error as Error +} + +export function unsafeIntoErrorMessage(error: MustBeAny) { + return unsafeAsError(error).message +} + +// ============================ +// === UnreachableCaseError === +// ============================ + /** An error used to indicate when an unreachable case is hit in a `switch` or `if` statement. * * TypeScript is sometimes unable to determine if we're exhaustively matching in a `switch` or `if` diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/fileInfo.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/fileInfo.ts new file mode 100644 index 000000000000..1febb6bad271 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/fileInfo.ts @@ -0,0 +1,29 @@ +/** @file Utility functions for extracting and manipulating file information. */ + +import * as svg from './components/svg' + +/** Returns the file extension of a file name. */ +export function fileExtension(fileName: string) { + return fileName.match(/\.(.+?)$/)?.[1] ?? '' +} + +/** Returns the appropriate icon for a specific file extension. */ +export function fileIcon(_extension: string) { + return svg.FILE_ICON +} + +export function toReadableSize(size: number) { + /* eslint-disable @typescript-eslint/no-magic-numbers */ + if (size < 2 ** 10) { + return String(size) + ' B' + } else if (size < 2 ** 20) { + return (size / 2 ** 10).toFixed(2) + ' kiB' + } else if (size < 2 ** 30) { + return (size / 2 ** 30).toFixed(2) + ' MiB' + } else if (size < 2 ** 40) { + return (size / 2 ** 40).toFixed(2) + ' GiB' + } else { + return (size / 2 ** 50).toFixed(2) + ' TiB' + } + /* eslint-enable @typescript-eslint/no-magic-numbers */ +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx index fc27e3716895..ea635c2c53c0 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx @@ -3,6 +3,17 @@ import * as react from 'react' import * as loggerProvider from './providers/logger' +// ================== +// === useRefresh === +// ================== + +/** A hook that contains no state, and is used only to tell React when to re-render. */ +export function useRefresh() { + // Uses an empty object literal because every distinct literal + // is a new reference and therefore is not equal to any other object literal. + return react.useReducer(() => ({}), {}) +} + // ====================== // === useAsyncEffect === // ====================== diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/uploadMultipleFiles.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/uploadMultipleFiles.ts new file mode 100644 index 000000000000..31bca4b83914 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/uploadMultipleFiles.ts @@ -0,0 +1,57 @@ +/** @file Helper function to upload multiple files, + * with progress being reported by a continually updating toast notification. */ + +import toast from 'react-hot-toast' + +import * as backend from './dashboard/service' + +export async function uploadMultipleFiles( + backendService: backend.Backend, + directoryId: backend.DirectoryId, + files: File[] +) { + const fileCount = files.length + if (fileCount === 0) { + toast.error('No files were dropped.') + return [] + } else { + let successfulUploadCount = 0 + let completedUploads = 0 + /** "file" or "files", whicheven is appropriate. */ + const filesWord = fileCount === 1 ? 'file' : 'files' + const toastId = toast.loading(`Uploading ${fileCount} ${filesWord}.`) + return await Promise.allSettled( + files.map(file => + backendService + .uploadFile( + { + fileName: file.name, + parentDirectoryId: directoryId, + }, + file + ) + .then(() => { + successfulUploadCount += 1 + }) + .catch(() => { + toast.error(`Could not upload file '${file.name}'.`) + }) + .finally(() => { + completedUploads += 1 + if (completedUploads === fileCount) { + const progress = + successfulUploadCount === fileCount + ? fileCount + : `${successfulUploadCount}/${fileCount}` + toast.success(`${progress} ${filesWord} uploaded.`, { id: toastId }) + } else { + toast.loading( + `${successfulUploadCount}/${fileCount} ${filesWord} uploaded.`, + { id: toastId } + ) + } + }) + ) + ) + } +} diff --git a/app/ide-desktop/lib/dashboard/src/tailwind.css b/app/ide-desktop/lib/dashboard/src/tailwind.css index 6d55769518cd..669f6d8c9211 100644 --- a/app/ide-desktop/lib/dashboard/src/tailwind.css +++ b/app/ide-desktop/lib/dashboard/src/tailwind.css @@ -4,7 +4,8 @@ body { margin: 0; } -/* These styles MUST still be copied as `.enso-dashboard body` and `.enso-dashboard html` make no sense. */ +/* These styles MUST still be copied + * as `.enso-dashboard body` and `.enso-dashboard html` make no sense. */ .enso-dashboard { line-height: 1.5; -webkit-text-size-adjust: 100%; diff --git a/app/ide-desktop/lib/dashboard/tailwind.config.ts b/app/ide-desktop/lib/dashboard/tailwind.config.ts index 738b17988a24..5e3b13e3d127 100644 --- a/app/ide-desktop/lib/dashboard/tailwind.config.ts +++ b/app/ide-desktop/lib/dashboard/tailwind.config.ts @@ -35,6 +35,9 @@ export const theme = { // Should be `#3e515f14`, but `bg-opacity` does not work with RGBA. 'perm-none': '#f0f1f3', }, + flexGrow: { + 2: '2', + }, fontSize: { vs: '0.8125rem', }, diff --git a/app/ide-desktop/package-lock.json b/app/ide-desktop/package-lock.json index 63dc601457f6..fc0bb568cfa3 100644 --- a/app/ide-desktop/package-lock.json +++ b/app/ide-desktop/package-lock.json @@ -413,15 +413,6 @@ "name": "enso-content-config", "version": "1.0.0" }, - "lib/copy-plugin": { - "name": "enso-copy-plugin", - "version": "1.0.0", - "extraneous": true, - "license": "Apache-2.0", - "devDependencies": { - "typescript": "^4.9.3" - } - }, "lib/dashboard": { "name": "enso-dashboard", "version": "0.1.0", @@ -3571,9 +3562,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.17.15", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.15.tgz", - "integrity": "sha512-NbImBas2rXwYI52BOKTW342Tm3LTeVlaOQ4QPZ7XuWNKiO226DisFk/RyPk3T0CKZkKMuU69yOvlapJEmax7cg==", + "version": "0.17.16", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.16.tgz", + "integrity": "sha512-SzBQtCV3Pdc9kyizh36Ol+dNVhkDyIrGb/JXZqFq8WL37LIyrXU0gUpADcNV311sCOhvY+f2ivMhb5Tuv8nMOQ==", "cpu": [ "x64" ], @@ -3737,11 +3728,10 @@ }, "node_modules/@esbuild/linux-x64": { "version": "0.17.15", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.15.tgz", - "integrity": "sha512-JsdS0EgEViwuKsw5tiJQo9UdQdUJYuB+Mf6HxtJSPN35vez1hlrNb1KajvKWF5Sa35j17+rW1ECEO9iNrIXbNg==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -4999,8 +4989,7 @@ }, "node_modules/@types/tar": { "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.4.tgz", - "integrity": "sha512-Cp4oxpfIzWt7mr2pbhHT2OTXGMAL0szYCzuf8lRWyIMCgsx6/Hfc3ubztuhvzXHXgraTQxyOCmmg7TDGIMIJJQ==", + "license": "MIT", "dependencies": { "@types/node": "*", "minipass": "^4.0.0" @@ -7636,9 +7625,8 @@ }, "node_modules/esbuild": { "version": "0.17.15", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.15.tgz", - "integrity": "sha512-LBUV2VsUIc/iD9ME75qhT4aJj0r75abCVS0jakhFzOtR7TQsqQA5w0tZ+KTKnwl3kXE0MhskNdHDh/I5aCR1Zw==", "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -7695,6 +7683,21 @@ "js-yaml": "^4.0.0" } }, + "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { + "version": "0.17.15", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.15.tgz", + "integrity": "sha512-NbImBas2rXwYI52BOKTW342Tm3LTeVlaOQ4QPZ7XuWNKiO226DisFk/RyPk3T0CKZkKMuU69yOvlapJEmax7cg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/escalade": { "version": "3.1.1", "license": "MIT", @@ -8725,8 +8728,7 @@ }, "node_modules/fs-minipass": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", "dependencies": { "minipass": "^3.0.0" }, @@ -8736,8 +8738,7 @@ }, "node_modules/fs-minipass/node_modules/minipass": { "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -11763,16 +11764,14 @@ }, "node_modules/minipass": { "version": "4.2.5", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.5.tgz", - "integrity": "sha512-+yQl7SX3bIT83Lhb4BVorMAHVuqsskxRdlmO9kTpyukp8vsm2Sn/fUOV9xlnG8/a5JsypJzap21lz/y3FBMJ8Q==", + "license": "ISC", "engines": { "node": ">=8" } }, "node_modules/minizlib": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -11783,8 +11782,7 @@ }, "node_modules/minizlib/node_modules/minipass": { "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -14934,8 +14932,7 @@ }, "node_modules/tar": { "version": "6.1.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz", - "integrity": "sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==", + "license": "ISC", "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -14976,16 +14973,14 @@ }, "node_modules/tar/node_modules/chownr": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", "engines": { "node": ">=10" } }, "node_modules/tar/node_modules/mkdirp": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" }, @@ -18145,9 +18140,9 @@ "optional": true }, "@esbuild/darwin-x64": { - "version": "0.17.15", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.15.tgz", - "integrity": "sha512-NbImBas2rXwYI52BOKTW342Tm3LTeVlaOQ4QPZ7XuWNKiO226DisFk/RyPk3T0CKZkKMuU69yOvlapJEmax7cg==", + "version": "0.17.16", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.16.tgz", + "integrity": "sha512-SzBQtCV3Pdc9kyizh36Ol+dNVhkDyIrGb/JXZqFq8WL37LIyrXU0gUpADcNV311sCOhvY+f2ivMhb5Tuv8nMOQ==", "optional": true }, "@esbuild/freebsd-arm64": { @@ -18212,8 +18207,6 @@ }, "@esbuild/linux-x64": { "version": "0.17.15", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.15.tgz", - "integrity": "sha512-JsdS0EgEViwuKsw5tiJQo9UdQdUJYuB+Mf6HxtJSPN35vez1hlrNb1KajvKWF5Sa35j17+rW1ECEO9iNrIXbNg==", "optional": true }, "@esbuild/netbsd-x64": { @@ -19104,8 +19097,6 @@ }, "@types/tar": { "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.4.tgz", - "integrity": "sha512-Cp4oxpfIzWt7mr2pbhHT2OTXGMAL0szYCzuf8lRWyIMCgsx6/Hfc3ubztuhvzXHXgraTQxyOCmmg7TDGIMIJJQ==", "requires": { "@types/node": "*", "minipass": "^4.0.0" @@ -21143,8 +21134,6 @@ }, "esbuild": { "version": "0.17.15", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.15.tgz", - "integrity": "sha512-LBUV2VsUIc/iD9ME75qhT4aJj0r75abCVS0jakhFzOtR7TQsqQA5w0tZ+KTKnwl3kXE0MhskNdHDh/I5aCR1Zw==", "requires": { "@esbuild/android-arm": "0.17.15", "@esbuild/android-arm64": "0.17.15", @@ -21168,6 +21157,14 @@ "@esbuild/win32-arm64": "0.17.15", "@esbuild/win32-ia32": "0.17.15", "@esbuild/win32-x64": "0.17.15" + }, + "dependencies": { + "@esbuild/darwin-x64": { + "version": "0.17.15", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.15.tgz", + "integrity": "sha512-NbImBas2rXwYI52BOKTW342Tm3LTeVlaOQ4QPZ7XuWNKiO226DisFk/RyPk3T0CKZkKMuU69yOvlapJEmax7cg==", + "optional": true + } } }, "esbuild-plugin-alias": { @@ -21869,16 +21866,12 @@ }, "fs-minipass": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", "requires": { "minipass": "^3.0.0" }, "dependencies": { "minipass": { "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "requires": { "yallist": "^4.0.0" } @@ -23935,14 +23928,10 @@ "version": "1.2.7" }, "minipass": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.5.tgz", - "integrity": "sha512-+yQl7SX3bIT83Lhb4BVorMAHVuqsskxRdlmO9kTpyukp8vsm2Sn/fUOV9xlnG8/a5JsypJzap21lz/y3FBMJ8Q==" + "version": "4.2.5" }, "minizlib": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", "requires": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -23950,8 +23939,6 @@ "dependencies": { "minipass": { "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "requires": { "yallist": "^4.0.0" } @@ -25978,8 +25965,6 @@ }, "tar": { "version": "6.1.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz", - "integrity": "sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==", "requires": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -25990,14 +25975,10 @@ }, "dependencies": { "chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" + "version": "2.0.0" }, "mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + "version": "1.0.4" } } }, diff --git a/app/ide-desktop/package.json b/app/ide-desktop/package.json index 5f49cb41c777..f9a45a0a4fed 100644 --- a/app/ide-desktop/package.json +++ b/app/ide-desktop/package.json @@ -36,6 +36,6 @@ "watch": "npm run watch --workspace enso-content", "watch-dashboard": "npm run watch --workspace enso-dashboard", "build-dashboard": "npm run build --workspace enso-dashboard", - "typecheck": "npm run typecheck --workspace enso; npm run typecheck --workspace enso-content; npm run typecheck --workspace enso-dashboard; npm run typecheck --workspace enso-authentication" + "typecheck": "npm run typecheck --workspace enso && npm run typecheck --workspace enso-content && npm run typecheck --workspace enso-dashboard && npm run typecheck --workspace enso-authentication" } } diff --git a/build-config.yaml b/build-config.yaml index d548959fdbec..a9aba3c081e0 100644 --- a/build-config.yaml +++ b/build-config.yaml @@ -1,6 +1,6 @@ # Options intended to be common for all developers. -wasm-size-limit: 15.75 MiB +wasm-size-limit: 15.80 MiB required-versions: # NB. The Rust version is pinned in rust-toolchain.toml. diff --git a/build/ci_utils/src/fs.rs b/build/ci_utils/src/fs.rs index 8b743e8867fa..12393462e855 100644 --- a/build/ci_utils/src/fs.rs +++ b/build/ci_utils/src/fs.rs @@ -85,8 +85,13 @@ pub async fn mirror_directory(source: impl AsRef, destination: impl AsRef< /// Get the size of a file after gzip compression. pub async fn compressed_size(path: impl AsRef) -> Result { - let file = ::tokio::io::BufReader::new(crate::fs::tokio::open(&path).await?); - let encoded_stream = GzipEncoder::with_quality(file, Level::Best); + // Read the file in chunks of 4MB. Our wasm files are usually way bigger than that, so this + // buffer gives very significant speedup over the default 8KB chunks. + const READER_CAPACITY: usize = 4096 * 1024; + + let file = crate::fs::tokio::open(&path).await?; + let buf_file = ::tokio::io::BufReader::with_capacity(READER_CAPACITY, file); + let encoded_stream = GzipEncoder::with_quality(buf_file, Level::Best); crate::io::read_length(encoded_stream).await.map(into) } diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Filter_Condition.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Filter_Condition.enso index ff3303eef0fc..5472f621b586 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Filter_Condition.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Filter_Condition.enso @@ -199,6 +199,37 @@ type Filter_Condition Is_In values -> values.contains Not_In values -> elem -> values.contains elem . not + ## PRIVATE + Convert to a display representation of this Filter_Condition. + to_display_text : Text + to_display_text self = + render_case case_sensitivity = + if case_sensitivity == Case_Sensitivity.Default then "" else " Case " + case_sensitivity.to_display_text + + condition = case self of + Less value -> "<" + value.to_display_text + Equal_Or_Less value -> "<=" + value.to_display_text + Equal value -> "==" + value.to_display_text + Equal_Or_Greater value -> ">=" + value.to_display_text + Greater value -> ">" + value.to_display_text + Not_Equal value -> "!=" + value.to_display_text + Between lower upper -> "Between " + lower.to_display_text + " And " + upper.to_display_text + Starts_With prefix case_sensitivity -> "Starts With " + prefix.to_display_text + (render_case case_sensitivity) + Ends_With suffix case_sensitivity -> "Ends With " + suffix.to_display_text + (render_case case_sensitivity) + Contains substring case_sensitivity -> "Contains " + substring.to_display_text + (render_case case_sensitivity) + Not_Contains substring case_sensitivity -> "Not Contains " + substring.to_display_text + (render_case case_sensitivity) + Is_Nothing -> "is Nothing" + Not_Nothing -> "is Not Nothing" + Is_True -> "is True" + Is_False -> "is False" + Is_Empty -> "is Empty" + Not_Empty -> "is Not Empty" + Like sql_pattern -> "Like " + sql_pattern.to_display_text + Not_Like sql_pattern -> "Not Like " + sql_pattern.to_display_text + Is_In values -> "is in " + values.to_display_text + Not_In values -> "is not in " + values.to_display_text + "Filter Condition: " + condition + ## PRIVATE Gets a widget set up for a Filter_Condition. default_widget = diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Index_Sub_Range.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Index_Sub_Range.enso index 9f70c32dbec7..ed5742ece304 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Index_Sub_Range.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Index_Sub_Range.enso @@ -2,6 +2,7 @@ import project.Any.Any import project.Data.Numbers.Integer import project.Data.Range.Range import project.Data.Range.Extensions +import project.Data.Text.Text import project.Data.Vector.Vector import project.Errors.Common.Index_Out_Of_Bounds import project.Error.Error @@ -55,6 +56,17 @@ type Index_Sub_Range input, an error is raised. Every (step:Integer) (first:Integer=0) + ## PRIVATE + Convert to a display representation of this Index_Sub_Range. + to_display_text : Text + to_display_text self = case self of + Index_Sub_Range.First count -> "First " + count.to_display_text + Index_Sub_Range.Last count -> "Last " + count.to_display_text + Index_Sub_Range.While f -> "While " + f.to_display_text + Index_Sub_Range.By_Index indexes -> "By_Index " + indexes.to_display_text + Index_Sub_Range.Sample count _ -> "Sample " + count.to_display_text + Index_Sub_Range.Every step first -> "Every " + step.to_display_text + (if first == 0 then "" else " from " + first.to_display_text) + ## PRIVATE Resolves a vector of ranges or indices into a vector of ranges that fit within a sequence. diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Interval.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Interval.enso index 6c1aefa71326..b6681a32b67a 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Interval.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Interval.enso @@ -1,4 +1,5 @@ import project.Data.Numbers.Number +import project.Data.Text.Text from project.Data.Boolean import Boolean, False @@ -136,3 +137,15 @@ type Interval example_not_empty = Interval.inclusive 0 0.001 . not_empty not_empty : Boolean not_empty self = self.is_empty.not + + ## PRIVATE + Convert to a display representation of this Interval. + to_display_text : Text + to_display_text self = + prefix = case self.start of + Bound.Exclusive s -> "(" + s.to_display_text + ", " + Bound.Inclusive s -> "[" + s.to_display_text + ", " + suffix = case self.end of + Bound.Exclusive e -> e.to_display_text + ")" + Bound.Inclusive e -> e.to_display_text + "]" + prefix + suffix diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Json.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Json.enso index fd71ad369db9..6e65dc862d88 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Json.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Json.enso @@ -28,7 +28,15 @@ from project.Data.Boolean import Boolean, True, False ## Methods for serializing from and to JSON. type Json - ## Parse a Text value into a `JS_Object` or an Enso primitive value (like `Text`, `Number`, `Boolean`, `Nothing`), or a `Vector` of values. + ## ALIAS From Text + + Parse a Text value into a `JS_Object` or an Enso primitive value (like + `Text`, `Number`, `Boolean`, `Nothing`), or a `Vector` of values. + + > Example + Parse the text "[null, null, true, false]". + + Json.parse "[null, null, true, false]" parse : Text -> JS_Object | Boolean | Number | Nothing | Text | Vector ! Invalid_JSON parse json = error_handler js_exception = @@ -172,6 +180,12 @@ type JS_Object to_text : Text to_text self = Json.stringify self + ## PRIVATE + Convert JS_Object to a friendly string. + to_display_text : Text + to_display_text self = + self.to_text.to_display_text + ## Convert to a JSON representation. to_json : Text to_json self = self.to_text diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Locale.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Locale.enso index 38777dc3207a..fb62a605659e 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Locale.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Locale.enso @@ -413,4 +413,11 @@ type Locale ## PRIVATE Converts the locale to text. to_text : Text | Nothing - to_text self = self.java_locale.toLanguageTag + to_text self = + tag = self.java_locale.toLanguageTag + if tag == "" then "Default" else tag + + ## PRIVATE + Convert Locale to a friendly string. + to_display_text : Text + to_display_text self = "Locale(" + self.to_text + ")" diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Maybe.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Maybe.enso index 314cff7325c9..59a38a883ab9 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Maybe.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Maybe.enso @@ -1,4 +1,5 @@ import project.Any.Any +import project.Data.Text.Text from project.Data.Boolean import Boolean, True, False @@ -54,5 +55,13 @@ type Maybe Maybe.None -> False Maybe.Some _ -> True + ## Check if the maybe value is `None`. is_none : Boolean is_none self = self.is_some.not + + ## PRIVATE + Convert Maybe to a friendly string. + to_display_text : Text + to_display_text self = case self of + Maybe.None -> "None" + Maybe.Some val -> "Some(" + val.to_display_text + ")" diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Noise.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Noise.enso index a5175f8d10f1..ea76fd74ef82 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Noise.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Noise.enso @@ -7,28 +7,7 @@ import project.Errors.Unimplemented.Unimplemented polyglot java import java.lang.Long polyglot java import java.util.Random -## PRIVATE - - The interface for the noise generator abstraction. - - To be a valid generator, it must provide the `step` method as described - below. -type Generator - ## PRIVATE - - Step the generator to produce the next value.. - - Arguments: - - The input number, which is intended for use as a seed. - - A range for output values, which should range over the chosen output - type. - - The return type may be chosen freely by the generator implementation, as - it usually depends on the generator and its intended use. - step : Number -> Interval -> Any - step self _ _ = Unimplemented.throw "Only intended to demonstrate an interface." - -## A noise generator that implements a seeded deterministic random peterbation +## A noise generator that implements a seeded deterministic random perturbation of the input. It produces what is commonly termed "white" noise, where any value in the @@ -73,6 +52,6 @@ type Deterministic_Random > Example Deterministically perturb the input number 1. 1.noise -Number.noise : Interval -> Generator -> Any +Number.noise : Interval -> Deterministic_Random -> Any Number.noise self (interval = Interval.exclusive 0 1) gen=Deterministic_Random = gen.step self interval diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Numbers.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Numbers.enso index 60cb49f039ca..48d998c9ecb1 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Numbers.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Numbers.enso @@ -10,9 +10,10 @@ from project.Data.Boolean import Boolean, True, False polyglot java import java.lang.Double polyglot java import java.lang.Math -polyglot java import java.lang.String polyglot java import java.lang.Long polyglot java import java.lang.NumberFormatException +polyglot java import java.text.DecimalFormat +polyglot java import java.text.DecimalFormatSymbols polyglot java import java.text.NumberFormat polyglot java import java.text.ParseException @@ -263,20 +264,21 @@ type Number log : Number -> Decimal log self base = self.ln / base.ln - ## UNSTABLE This API is not user-friendly and will be improved in the future. - - Converts a numeric value to a string, using the Java string formatting - syntax. + ## Converts a numeric value to a string, using the Java DecimalFormat + formatter. Arguments: - - fmt: The java-style formatting specifier. + - format: The java-style formatting specifier. > Example - Convert the value 5 to a string. + Convert the value 5000 to a string. - 5.format "%x" - format : Text -> Text - format self fmt = String.format fmt self + 5000.format "#,##0" + format : Text -> Locale -> Text + format self format locale=Locale.default = + symbols = DecimalFormatSymbols.new locale.java_locale + formatter = DecimalFormat.new format symbols + formatter.format self ## Checks equality of numbers, using an `epsilon` value. @@ -926,13 +928,14 @@ type Integer Arguments: - text: The text to parse into a integer. - - radix: The number base to use for parsing (defaults to 10). + - radix: The number base to use for parsing (defaults to 10). `radix` + must be between 2 and 36 (inclusive) -- see https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/lang/Character.html#MIN_RADIX. > Example Parse the text "20220216" into an integer number. Integer.parse "20220216" - parse : Text -> Text -> Integer ! Number_Parse_Error + parse : Text -> Integer -> Integer ! Number_Parse_Error parse text (radix=10) = Integer.parse_builtin text radix ## PRIVATE diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Pair.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Pair.enso index 31cbe778651a..0689b53f58c7 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Pair.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Pair.enso @@ -1,6 +1,8 @@ import project.Any.Any import project.Data.Boolean.Boolean import project.Data.Numbers.Integer +import project.Data.Text.Text +import project.Data.Text.Extensions import project.Data.Vector.Vector import project.Errors.Common.Index_Out_Of_Bounds import project.Errors.Common.Not_Found @@ -248,6 +250,17 @@ type Pair f self.second Nothing + ## PRIVATE + Convert to a display representation of this Pair. + to_display_text : Text + to_display_text self = + first = self.first.to_display_text + second = self.second.to_display_text + if first.length + second.length < 73 then "Pair(" + first + ", " + second + ")" else + first_trim = if first.length > 36 then first.take 34 + " …" else first + second_trim = if second.length > 36 then second.take 34 + " …" else second + "Pair(" + first_trim + ", " + second_trim + ")" + ## PRIVATE check_start_valid start function max=3 = used_start = if start < 0 then start + 2 else start diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Range.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Range.enso index 71f94fad3c02..23a9738bff70 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Range.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Range.enso @@ -430,6 +430,14 @@ type Range @Tail_Call fold_function (function current value) (value + self.step) fold_function self.start self.start+self.step + ## PRIVATE + Convert to a display representation of this Range. + to_display_text : Text + to_display_text self = + start = "[" + self.start.to_display_text + " .. " + self.end.to_display_text + step = if self.step.abs == 1 then "" else " by " + self.step.to_display_text + start + step + "]" + ## PRIVATE throw_zero_step_error = Error.throw (Illegal_State.Error "A range with step = 0 is ill-formed.") diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Regression.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Regression.enso index e99d545b0c2a..a4aa9c411188 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Regression.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Regression.enso @@ -24,6 +24,17 @@ type Model ## Fit a power series (y = A x ^ B) to the data. Power + ## PRIVATE + Convert to a display representation of this Model. + to_display_text : Text + to_display_text self = case self of + Model.Linear intercept -> + if intercept.is_nothing then "Linear" else "Linear(intercept: " + intercept.to_display_text + ")" + Model.Exponential intercept -> + if intercept.is_nothing then "Exponential" else "Exponential(intercept: " + intercept.to_display_text + ")" + Model.Logarithmic -> "Logarithmic" + Model.Power -> "Power" + ## PRIVATE Computes the R Squared value for a model and returns a new instance. fitted_model_with_r_squared : Any -> Number -> Number -> Vector -> Vector -> Fitted_Model @@ -33,7 +44,6 @@ type Model constructor a b r_squared ## PRIVATE - Computes the natural log series as long as all values are positive. ln_series : Vector -> Text -> Vector ! Illegal_Argument ln_series xs series_name="Values" = @@ -82,12 +92,16 @@ type Fitted_Model Display the fitted line. to_text : Text to_text self = - equation = case self of - Fitted_Model.Linear slope intercept _ -> slope.to_text + " * X + " + intercept.to_text - Fitted_Model.Exponential a b _ -> a.to_text + " * (" + b.to_text + " * X).exp" - Fitted_Model.Logarithmic a b _ -> a.to_text + " * X.ln + " + b.to_text - Fitted_Model.Power a b _ -> a.to_text + " * X ^ " + b.to_text - "Fitted_Model(" + equation + ")" + "Fitted_Model(" + self.to_display_text + ")" + + ## PRIVATE + Convert to a display representation of this Fitted_Model. + to_display_text : Text + to_display_text self = case self of + Fitted_Model.Linear slope intercept _ -> slope.to_text + " * X + " + intercept.to_text + Fitted_Model.Exponential a b _ -> a.to_text + " * (" + b.to_text + " * X).exp" + Fitted_Model.Logarithmic a b _ -> a.to_text + " * X.ln + " + b.to_text + Fitted_Model.Power a b _ -> a.to_text + " * X ^ " + b.to_text ## Use the model to predict a value. predict : Number -> Number @@ -108,7 +122,6 @@ type Fit_Error Error message ## PRIVATE - Converts the `Fit_Error` to a human-readable representation. to_display_text : Text to_display_text self = "Could not fit the model: " + self.message.to_text diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Case_Sensitivity.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Case_Sensitivity.enso index 236518aed7cf..ac7017aff1ba 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Case_Sensitivity.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Case_Sensitivity.enso @@ -23,6 +23,15 @@ type Case_Sensitivity - locale: The locale used for the comparison. Insensitive locale=Locale.default + ## PRIVATE + Convert Case_Sensitivity to a friendly string. + to_display_text : Text + to_display_text self = case self of + Case_Sensitivity.Default -> "Default" + Case_Sensitivity.Sensitive -> "Sensitive" + Case_Sensitivity.Insensitive locale -> + if locale == Locale.default then "Insensitive" else "Insensitive(" + locale.to_text + ")" + ## PRIVATE Creates a Java `TextFoldingStrategy` from the case sensitivity setting. folding_strategy : Case_Sensitivity -> TextFoldingStrategy @@ -43,7 +52,7 @@ type Case_Sensitivity Case_Sensitivity.Insensitive locale -> case locale == Locale.default of True -> True False -> - msg = "Custom locales are not supported for this operationc." + msg = "Custom locales are not supported for this operation." Error.throw (Illegal_Argument.Error msg) ## PRIVATE diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Encoding.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Encoding.enso index c9563c45c331..21749c2334ad 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Encoding.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Encoding.enso @@ -107,3 +107,8 @@ type Encoding ## Encoding for Vietnamese (Windows). windows_1258 : Encoding windows_1258 = Encoding.Value "windows-1258" + + ## PRIVATE + Convert Encoding to a friendly string. + to_display_text : Text + to_display_text self = self.character_set diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Extensions.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Extensions.enso index fef524fb9ae6..d1c7918dc9fb 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Extensions.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Extensions.enso @@ -4,7 +4,6 @@ import project.Any.Any import project.Data.Array.Array import project.Data.Index_Sub_Range.Index_Sub_Range import project.Data.Locale.Locale -import project.Data.Numbers.Integer import project.Data.Range.Extensions import project.Data.Range.Range import project.Data.Text.Case.Case @@ -20,16 +19,25 @@ import project.Data.Text.Span.Utf_16_Span import project.Data.Text.Text import project.Data.Text.Text_Sub_Range.Codepoint_Ranges import project.Data.Text.Text_Sub_Range.Text_Sub_Range +import project.Data.Time.Date.Date +import project.Data.Time.Date_Time.Date_Time +import project.Data.Time.Time_Of_Day.Time_Of_Day +import project.Data.Time.Time_Zone.Time_Zone import project.Data.Vector.Vector import project.Errors.Common.Index_Out_Of_Bounds +import project.Errors.Common.Syntax_Error import project.Error.Error import project.Errors.Encoding_Error.Encoding_Error import project.Errors.Illegal_Argument.Illegal_Argument import project.Errors.Problem_Behavior.Problem_Behavior +import project.Errors.Time_Error.Time_Error import project.Meta +import project.Network.URI.URI import project.Nothing.Nothing from project.Data.Boolean import Boolean, True, False +from project.Data.Json import Json, Invalid_JSON, JS_Object +from project.Data.Numbers import Decimal, Integer, Number, Number_Parse_Error from project.Data.Text.Text_Sub_Range import Codepoint_Ranges, Text_Sub_Range import project.Data.Index_Sub_Range as Index_Sub_Range_Module @@ -1362,6 +1370,317 @@ Text.last_index_of self term="" start=-1 case_sensitivity=Case_Sensitivity.Sensi span = used.locate term Matching_Mode.Last case_sensitivity if span.is_nothing then Nothing else span.start +## ALIAS Decimal From Text + + Parses a textual representation of a decimal into a decimal number, returning + a `Number_Parse_Error` if the text does not represent a valid decimal. + + Arguments: + - locale: The locale that specifies the format to use when parsing + + > Example + Parse the text "7.6" into a decimal number. + + "7.6".parse_decimal +Text.parse_decimal : Locale | Nothing -> Decimal ! Number_Parse_Error +Text.parse_decimal self locale=Nothing = Decimal.parse self locale + +## ALIAS Integer From Text + + Parses a textual representation of an integer into an integer number, returning + a `Number_Parse_Error` if the text does not represent a valid integer. + + Arguments: + - radix: The number base to use for parsing (defaults to 10). `radix` + must be between 2 and 36 (inclusive) -- see https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/lang/Character.html#MIN_RADIX. + + > Example + Parse the text "20220216" into an integer number. + + "20220216".parse +Text.parse_integer : Integer -> Integer ! Number_Parse_Error +Text.parse_integer self (radix=10) = Integer.parse_builtin self radix + +## ALIAS JSON From Text + + Parse a Text value into a `JS_Object` or an Enso primitive value (like + `Text`, `Number`, `Boolean`, `Nothing`), or a `Vector` of values. + + > Example + Parse the text "[null, null, true, false]". + + "[null, null, true, false]".parse_json +Text.parse_json : JS_Object | Boolean | Number | Nothing | Text | Vector ! Invalid_JSON +Text.parse_json self = Json.parse self + +## ALIAS Date from Text + + Converts text containing a date into a Date object. + + Arguments: + - format: An optional format describing how to parse the text. + + Returns a `Time_Error` if `self`` cannot be parsed using the provided + `format`. + + ? Format Syntax + A custom format string consists of one or more custom date and time format + specifiers. For example, "d MMM yyyy" will format "2011-12-03" as + "3 Dec 2011". See https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/time/format/DateTimeFormatter.html + for a complete format specification. + + ? Default Date Formatting + Unless you provide a custom format, the text must represent a valid date + that can be parsed using the ISO-8601 extended local date format. The + format consists of: + + - Four digits or more for the year. Years in the range 0000 to 9999 + will be pre-padded by zero to ensure four digits. Years outside + that range will have a prefixed positive or negative symbol. + - A dash + - Two digits for the month-of-year. This is pre-padded by zero to ensure + two digits. + - A dash + - Two digits for the day-of-month. This is pre-padded by zero to ensure two + digits. + + > Example + Parse the date of 23rd December 2020. + + import Standard.Base.Data.Text.Extensions + + example_parse = "2020-12-23".parse_date + + > Example + Recover from an error due to a wrong format. + + import Standard.Base.Data.Text.Extensions + from Standard.Base.Errors.Common import Time_Error + + example_parse_err = "my birthday".parse_date . catch Time_Error _-> + Date.new 2000 1 1 + + > Example + Parse "1999-1-1" as Date using a custom format. + + import Standard.Base.Data.Text.Extensions + + example_parse = "1999-1-1".parse_date "yyyy-M-d" + + > Example + Recover from the parse error. + + import Standard.Base.Data.Text.Extensions + from Standard.Base.Errors.Common import Time_Error + + example_parse_err = + date = "1999-1-1".parse_date "yyyy-MM-dd" + date.catch Time_Error (_->Date.new 2000 1 1) +Text.parse_date : (Text | Nothing) -> Date ! Time_Error +Text.parse_date self format=Nothing = Date.parse self format + +## ALIAS Date_Time from Text + + Obtains an instance of `Date_Time` from a text such as + "2007-12-03T10:15:30+01:00 Europe/Paris". + + Arguments: + - format: The format to use for parsing the input text. + - locale: The locale in which the format should be interpreted. + + ? Format Syntax + A custom format string consists of one or more custom date and time format + specifiers. For example, "d MMM yyyy" will format "2011-12-03" as + "3 Dec 2011". See https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/time/format/DateTimeFormatter.html + for a complete format specification. + + ? Default Date_Time Format + The text must represent a valid date-time as defined by the ISO-8601 + format. (See https://en.wikipedia.org/wiki/ISO_8601.) If a time zone is + present, it must be in the ISO-8601 Extended Date/Time Format (EDTF). + (See https://en.wikipedia.org/wiki/ISO_8601#EDTF.) The time zone format + consists of: + + - The ISO offset date time. + - If the zone ID is not available or is a zone offset then the format is + complete. + - An open square bracket '['. + - The zone ID. This is not part of the ISO-8601 standard. Parsing is case + sensitive. + - A close square bracket ']'. + + This method will return a `Time_Error` if the provided time cannot be parsed + using the above format. + + > Example + Parse UTC time. + + import Standard.Base.Data.Text.Extensions + + example_parse = "2020-10-01T04:11:12Z".parse_date_time + + > Example + Parse UTC-04:00 time. + + import Standard.Base.Data.Text.Extensions + + example_parse = "2020-10-01T04:11:12-04:00".parse_date_time + + > Example + Parse UTC-04:00 time specifying New York timezone. + + import Standard.Base.Data.Text.Extensions + + example_parse = "2020-10-01T04:11:12-04:00[America/New_York]".parse_date_time + + > Example + Parse UTC-04:00 time with nanoseconds. + + import Standard.Base.Data.Text.Extensions + + example_parse = "2020-10-01T04:11:12.177528-04:00".parse_date_time + + > Example + Recover from the parse error. + + import Standard.Base.Data.Text.Extensions + + example_parse = "2020-10-01".parse_date_time . catch Time_Error (_->Date_Time.now) + + > Example + Parse "2020-05-06 04:30:20" as Date_Time + + import Standard.Base.Data.Text.Extensions + + example_parse = "2020-05-06 04:30:20".parse_date_time "yyyy-MM-dd HH:mm:ss" + + > Example + Parse "06 of May 2020 at 04:30AM" as Date_Tme + + import Standard.Base.Data.Text.Extensions + + example_parse = + "06 of May 2020 at 04:30AM".parse_date_time "dd 'of' MMMM yyyy 'at' hh:mma" +Text.parse_date_time : Text | Nothing -> Locale -> Date_Time ! Time_Error +Text.parse_date_time self format=Nothing locale=Locale.default = Date_Time.parse self format locale + +## ALIAS Time_Of_Day from Text + + Obtains an instance of `Time_Of_Day` from a text such as "10:15". + + Arguments: + - format: The format to use for parsing the input text. + - locale: The locale in which the format should be interpreted. + + Returns a `Time_Error` if the provided text cannot be parsed using the + default format. + + ? Format Syntax + A custom format string consists of one or more custom date and time format + specifiers. For example, "d MMM yyyy" will format "2011-12-03" as + "3 Dec 2011". See https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/time/format/DateTimeFormatter.html + for a complete format specification. + + ? Default Time Format + The text must represent a valid time and is parsed using the ISO-8601 + extended local time format. The format consists of: + + - Two digits for the hour-of-day. This is pre-padded by zero to ensure two + digits. + - A colon + - Two digits for the minute-of-hour. This is pre-padded by zero to ensure + two digits. + - If the second-of-minute is not available then the format is complete. + - A colon + - Two digits for the second-of-minute. This is pre-padded by zero to ensure + two digits. + - If the nano-of-second is zero or not available then the format is + complete. + - A decimal point + - One to nine digits for the nano-of-second. As many digits will be output + as required. + + > Example + Get the time 15:05:30. + + import Standard.Base.Data.Text.Extensions + + example_parse = "15:05:30".parse_time_of_day + + > Example + Recover from the parse error. + + import Standard.Base.Data.Text.Extensions + from Standard.Base.Errors.Common import Time_Error + + example_parse = "half twelve".parse_time_of_day . catch Time_Error _-> + Time_Of_Day.new + + > Example + Parse "04:30:20" as Time_Of_Day. + + import Standard.Base.Data.Text.Extensions + + example_parse = "04:30:20".parse_time_of_day "HH:mm:ss" + + > Example + Parse "4:30AM" as Time_Of_Day + + import Standard.Base.Data.Text.Extensions + + example_parse = "4:30AM".parse_time_of_day "h:mma" +Text.parse_time_of_day : Text | Nothing -> Locale -> Time_Of_Day ! Time_Error +Text.parse_time_of_day self format=Nothing locale=Locale.default = Time_Of_Day.parse self format locale + +## ALIAS Time_Zone from Text + + This method parses the ID producing a `Time_Zone`. + + > Example + Get Central European Time. + + import Standard.Base.Data.Text.Extensions + + example_parse = "CET".parse_time_zone + + > Example + Get Moscow time. + + import Standard.Base.Data.Text.Extensions + + example_parse = "Europe/Moscow".parse_time_zone + + > Example + Get time zone -06:00. + + import Standard.Base.Data.Text.Extensions + + example_parse = "-06:00".parse_time_zone + + > Example + Get custom offset +03:02:01 of 3 hours 2 minutes an 1 second. + + import Standard.Base.Data.Text.Extensions + + example_parse = "+03:02:01".parse_time_zone +Text.parse_time_zone : Time_Zone ! Time_Error +Text.parse_time_zone self = Time_Zone.parse self + +## ALIAS URI from Text + + Parse a URI from a `Text`. + + Throws a Syntax_Error when the text cannot be parsed as a URI. + + > Example + Parse URI text. + + import Standard.Base.Data.Text.Extensions + + example_parse = "http://example.com".parse_uri +Text.parse_uri : URI ! Syntax_Error +Text.parse_uri self = URI.parse self + ## PRIVATE Returns a new Text constructed by slicing the input according to the provided ranges. The ranges are assumed to have step equal to 1 and bounds within the diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Regex.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Regex.enso index cf20040a833f..80e9a42d6159 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Regex.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Regex.enso @@ -78,3 +78,8 @@ type Regex_Syntax_Error Arguments: - message: A description of the erroneous syntax. Error message + + ## PRIVATE + Provides a human-readable representation of the `Regex_Syntax_Error`. + to_display_text : Text + to_display_text self = "Regex Syntax Error:" + self.message diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Span.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Span.enso index 9042434289ea..99b0059a0da3 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Span.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Span.enso @@ -95,6 +95,12 @@ type Span to_utf_16_span self = Utf_16_Span.Value (range_to_char_indices self.parent self.range) self.parent + ## PRIVATE + Convert to a display representation of this Span. + to_display_text : Text + to_display_text self = self.text + + type Utf_16_Span ## A representation of a span of UTF-16 code units in Enso's `Text` type. @@ -165,6 +171,11 @@ type Utf_16_Span grapheme_end = grapheme_last + 1 Span.Value (grapheme_first.up_to grapheme_end) self.parent + ## PRIVATE + Convert to a display representation of this Span. + to_display_text : Text + to_display_text self = self.text + ## PRIVATE Utility function taking a range pointing at grapheme clusters and converting to a range on the underlying code units. diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Text_Ordering.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Text_Ordering.enso index 37dd7e4a123e..939464e76119 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Text_Ordering.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Text_Ordering.enso @@ -1,5 +1,6 @@ import project.Data.Locale.Locale import project.Data.Text.Case_Sensitivity.Case_Sensitivity +import project.Data.Text.Text import project.Nothing.Nothing from project.Data.Boolean import Boolean, True, False @@ -46,3 +47,9 @@ type Text_Ordering Text_Ordering.Default _ -> Case_Sensitivity.Default Text_Ordering.Case_Sensitive _ -> Case_Sensitivity.Sensitive Text_Ordering.Case_Insensitive locale _ -> Case_Sensitivity.Insensitive locale + + ## PRIVATE + Convert Text_Ordering to a friendly string. + to_display_text : Text + to_display_text self = + self.case_sensitivity.to_display_text + if self.sort_digits_as_numbers then " (Natural Order)" else "" diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Text_Sub_Range.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Text_Sub_Range.enso index 20bd2394745f..81526f8d9387 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Text_Sub_Range.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Text/Text_Sub_Range.enso @@ -40,6 +40,15 @@ type Text_Sub_Range Select an empty string if the input does not contain `delimiter`. After_Last (delimiter : Text) + ## PRIVATE + Convert to a display representation of this Index_Sub_Range. + to_display_text : Text + to_display_text self = case self of + Text_Sub_Range.Before delimiter -> "Before " + delimiter.to_display_text + Text_Sub_Range.Before_Last delimiter -> "Before Last " + delimiter.to_display_text + Text_Sub_Range.After delimiter -> "After " + delimiter.to_display_text + Text_Sub_Range.After_Last delimiter -> "After Last " + delimiter.to_display_text + type Codepoint_Ranges ## PRIVATE A list of codepoint ranges corresponding to the matched parts of the diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Date.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Date.enso index 2ee29070e594..34907d65c092 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Date.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Date.enso @@ -59,6 +59,8 @@ new_builtin year month day = @Builtin_Method "Date.new_builtin" ? Pattern Syntax Patterns are based on a simple sequence of letters and symbols. For example, "d MMM yyyy" will format "2011-12-03" as "3 Dec 2011". + See https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/time/format/DateTimeFormatter.html + for a complete format specification. ? Default Date Formatting Unless you provide a custom format, the text must represent a valid date @@ -146,8 +148,10 @@ type Date provided `pattern`. ? Pattern Syntax - Patterns are based on a simple sequence of letters and symbols. For - example, "d MMM yyyy" will format "2011-12-03" as "3 Dec 2011". + A custom pattern string consists of one or more custom date and time + format specifiers. For example, "d MMM yyyy" will format "2011-12-03" + as "3 Dec 2011". See https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/time/format/DateTimeFormatter.html + for a complete format specification. ? Default Date Formatting Unless you provide a custom format, the text must represent a valid date @@ -539,6 +543,12 @@ type Date _ -> Error.throw (Illegal_Argument.Error "Illegal period argument") + ## PRIVATE + Convert to a display representation of this Date. + to_display_text : Text + to_display_text self = + self.format "yyyy-MM-dd" + ## PRIVATE Convert to a JS_Object representing this Date. @@ -558,8 +568,10 @@ type Date - pattern: The text specifying the format for formatting the date. ? Pattern Syntax - Patterns are based on a simple sequence of letters and symbols. For - example, "d MMM yyyy" will format "2011-12-03" as "3 Dec 2011". + A custom pattern string consists of one or more custom date and time + format specifiers. For example, "d MMM yyyy" will format "2011-12-03" + as "3 Dec 2011". See https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/time/format/DateTimeFormatter.html + for a complete format specification. > Example Format "2020-06-02" as "2 June 2020" @@ -590,7 +602,6 @@ type Date format : Text -> Text format self pattern = Time_Utils.local_date_format self pattern - ## PRIVATE week_days_between start end = ## We split the interval into 3 periods: the first week (containing the diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Date_Time.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Date_Time.enso index 43704d2cf194..e41da2a2f81d 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Date_Time.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Date_Time.enso @@ -172,12 +172,17 @@ type Date_Time - locale: The locale in which the pattern should be interpreted. ? Pattern Syntax - For the list of accepted symbols in pattern refer to `Time.format` doc. + A custom pattern string consists of one or more custom date and time + format specifiers. For example, "d MMM yyyy" will format "2011-12-03" + as "3 Dec 2011". See https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/time/format/DateTimeFormatter.html + for a complete format specification. ? Default Date_Time Format - The text must represent a valid date-time and is parsed using the ISO-8601 - extended offset date-time format to add the timezone. The section in square - brackets is not part of the ISO-8601 standard. The format consists of: + The text must represent a valid date-time as defined by the ISO-8601 + format. (See https://en.wikipedia.org/wiki/ISO_8601.) If a time zone is + present, it must be in the ISO-8601 Extended Date/Time Format (EDTF). + (See https://en.wikipedia.org/wiki/ISO_8601#EDTF.) The time zone format + consists of: - The ISO offset date time. - If the zone ID is not available or is a zone offset then the format is @@ -584,6 +589,13 @@ type Date_Time Time_Utils.datetime_adjust self Time_Utils.AdjustOp.MINUS period.internal_period ensure_in_epoch result result + ## PRIVATE + Convert to a display representation of this Date_Time. + to_display_text : Text + to_display_text self = + time_format = if self.nanosecond == 0 then "HH:mm:ss" else "HH:mm:ss.n" + self.format "yyyy-MM-dd "+time_format+" VV" + ## PRIVATE Convert to a JavaScript Object representing a Date_Time. @@ -603,50 +615,10 @@ type Date_Time - pattern: The pattern that specifies how to format the time. ? Pattern Syntax - Patterns are based on a simple sequence of letters and symbols. For - example, "d MMM uuuu" will format "2011-12-03" as "3 Dec 2011". - - The list of accepted symbols with examples: - - - 'G', era, "AD; Anno Domini" - - 'u', year, "2004; 04" - - 'y', year-of-era, "2004; 04" - - 'D', day-of-year, "189" - - 'M/L', month-of-year, "7; 07; Jul; July; J" - - 'd', day-of-month, "10" - - 'g', modified-julian-day, "2451334" - - 'Q/q', quarter-of-year, "3; 03; Q3; 3rd quarter" - - 'Y', week-based-year, "1996; 96" - - 'w', week-of-week-based-year, "27" - - 'W', week-of-month, "4" - - 'E', day-of-week, "Tue; Tuesday; T" - - 'e/c', localized day-of-week, "2; 02; Tue; Tuesday; T" - - 'F', day-of-week-in-month, "3" - - 'a', am-pm-of-day, "PM" - - 'h', clock-hour-of-am-pm (1-12), "12" - - 'K', hour-of-am-pm (0-11), "0" - - 'k', clock-hour-of-day (1-24), "24" - - 'H', hour-of-day (0-23), "0" - - 'm', minute-of-hour, "30" - - 's', second-of-minute, "55" - - 'S', fraction-of-second, "978" - - 'A', milli-of-day, "1234" - - 'n', nano-of-second, "987654321" - - 'N', nano-of-day, "1234000000" - - 'V', timezone ID, "America/Los_Angeles; Z; -08:30" - - 'v', generic timezone name, "Pacific Time; PT" - - 'z', timezone name, "Pacific Standard Time; PST" - - 'O', localized zone-offset, "GMT+8; GMT+08:00; UTC-08:00" - - 'X', zone-offset 'Z' for zero, "Z; -08; -0830; -08:30; -083015; -08:30:15" - - 'x', zone-offset, "+0000; -08; -0830; -08:30; -083015; -08:30:15" - - 'Z', zone-offset, "+0000; -0800; -08:00" - - 'p', pad next, "1" - - ''', (single quote) escape for text, "'Text'" - - '''', (double quote) single quote, "'" - - '[', optional section start - - ']', optional section end - - The count of pattern letters determines the format. + A custom pattern string consists of one or more custom date and time + format specifiers. For example, "d MMM yyyy" will format "2011-12-03" + as "3 Dec 2011". See https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/time/format/DateTimeFormatter.html + for a complete format specification. > Example Format "2020-10-08T16:41:13+03:00[Europe/Moscow]" as diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Duration.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Duration.enso index 0edc9007effe..b71cf1d4bc68 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Duration.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Duration.enso @@ -4,6 +4,8 @@ import project.Data.Numbers.Decimal import project.Data.Numbers.Integer import project.Data.Ordering.Comparable import project.Data.Pair.Pair +import project.Data.Text.Extensions +import project.Data.Text.Text import project.Data.Time.Date_Time.Date_Time import project.Data.Time.Period.Period import project.Data.Vector.Vector @@ -262,3 +264,17 @@ type Duration if self.milliseconds==0 . not then b.append ["milliseconds", self.milliseconds] if self.nanoseconds==0 . not then b.append ["nanoseconds", self.nanoseconds] JS_Object.from_pairs b.to_vector + + ## PRIVATE + Convert Duration to a friendly string. + to_display_text : Text + to_display_text self = if self == Duration.zero then "0s" else + h = if self.hours == 0 then "" else self.hours.to_text + "h " + + s = if self.seconds == 0 && self.nanoseconds == 0 then "" else + seconds = self.seconds + self.nanoseconds/1000000000 + seconds.format "00.##########" + "s" + + m = if self.minutes == 0 && (h=="" || s=="") then "" else self.minutes.to_text + "m " + + (h+m+s).trim diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Period.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Period.enso index e93430f79a00..ed1daadba25c 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Period.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Period.enso @@ -1,12 +1,14 @@ import project.Any.Any import project.Data.Numbers.Integer -import project.Data.Time.Date.Date -import project.Data.Time.Duration.Duration import project.Data.Ordering.Comparable +import project.Data.Text.Extensions import project.Data.Text.Text +import project.Data.Time.Date.Date +import project.Data.Time.Duration.Duration import project.Error.Error import project.Errors.Illegal_Argument.Illegal_Argument import project.Errors.Time_Error.Time_Error +import project.Math import project.Meta import project.Nothing.Nothing import project.Panic.Panic @@ -128,3 +130,17 @@ type Period case err of DateTimeException -> Error.throw Time_Error.Error "Period subtraction failed" ArithmeticException -> Error.throw Illegal_Argument.Error "Arithmetic error" + + ## PRIVATE + Convert Period to a friendly string. + to_display_text : Text + to_display_text self = if self == Period.new then "0D" else + years = self.years + (self.months/12).floor + y = if years == 0 then "" else years.to_text + "Y " + + d = if self.days == 0 then "" else self.days.to_text + "D " + + months = self.months % 12 + m = if months == 0 && (y=="" || d=="") then "" else months.to_text + "M " + + (y + m + d) . trim diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Time_Of_Day.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Time_Of_Day.enso index adcc5591342a..74f107a536f0 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Time_Of_Day.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Time_Of_Day.enso @@ -114,7 +114,10 @@ type Time_Of_Day default format. ? Pattern Syntax - For the list of accepted symbols in pattern refer to `Time.format` doc. + A custom pattern string consists of one or more custom date and time + format specifiers. For example, "d MMM yyyy" will format "2011-12-03" + as "3 Dec 2011". See https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/time/format/DateTimeFormatter.html + for a complete format specification. ? Default Time Format The text must represent a valid time and is parsed using the ISO-8601 @@ -297,17 +300,23 @@ type Time_Of_Day cons_pair = ["constructor", "new"] JS_Object.from_pairs [type_pair, cons_pair, ["hour", self.hour], ["minute", self.minute], ["second", self.second], ["nanosecond", self.nanosecond]] + ## PRIVATE + Convert to a display representation of this Time_Of_Day. + to_display_text : Text + to_display_text self = + if self.nanosecond == 0 then self.format "HH:mm:ss" else + self.format "HH:mm:ss.n" + ## Format this time of day using the provided formatter pattern. Arguments: - pattern: The pattern specifying how to format the time of day. ? Pattern Syntax - Patterns are based on a simple sequence of letters and symbols. For - example, "HH-mm-ss.SSS" will format "16:21:10" as "16-21-10.323". - - For the list of accepted symbols in pattern refer to the - `Base.Data.Time.format` doc. + A custom pattern string consists of one or more custom date and time + format specifiers. For example, "d MMM yyyy" will format "2011-12-03" + as "3 Dec 2011". See https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/time/format/DateTimeFormatter.html + for a complete format specification. > Example Format "16:21:10" as "16:21:00.1234" diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Time_Zone.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Time_Zone.enso index f9adbf6eff0a..3a5f72aa8b36 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Time_Zone.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Time_Zone.enso @@ -170,3 +170,8 @@ type Time_Zone type_pair = ["type", "Time_Zone"] cons_pair = ["constructor", "new"] JS_Object.from_pairs [type_pair, cons_pair, ["id", self.zone_id]] + + ## PRIVATE + Convert to a display representation of this Time_Zone. + to_display_text : Text + to_display_text self = self.to_text diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Errors/Common.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Errors/Common.enso index 2b5fa85f1a9b..f509acd7b384 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Errors/Common.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Errors/Common.enso @@ -3,6 +3,7 @@ import project.Error.Error import project.Meta import project.Nothing.Nothing import project.Panic.Panic + from project.Data.Boolean import True, False polyglot java import java.lang.ClassCastException @@ -161,7 +162,9 @@ type No_Such_Method ## PRIVATE Convert the No_Such_Method error to a human-readable format. to_display_text : Text - to_display_text self = "Method `"+self.method_name+"` of "+self.target.to_display_text+" could not be found." + to_display_text self = + target_type_name = if Meta.is_polyglot self.target then self.target.to_display_text else (Meta.type_of self.target).to_display_text + "Method `"+self.method_name+"` of type "+target_type_name+" could not be found." @Builtin_Type type No_Such_Field @@ -174,6 +177,12 @@ type No_Such_Field - field_name: The name of the field that was being accessed. Error value field_name + ## PRIVATE + Convert the No_Such_Method error to a human-readable format. + to_display_text : Text + to_display_text self = + value_type_name = if Meta.is_polyglot self.value then self.value.to_display_text else (Meta.type_of self.value).to_display_text + "Field `"+self.field_name+"` of "+value_type_name+" could not be found." @Builtin_Type type Module_Not_In_Package_Error @@ -214,7 +223,7 @@ type Incomparable_Values to_display_text : Text to_display_text self = case self.left.is_nothing && self.right.is_nothing of - True -> "Incomparable_Values.Error" + True -> "Incomparable_Values" False -> "Cannot compare `" + self.left.to_text + "` with `" + self.right.to_text + "`" ## PRIVATE diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Meta/Enso_Project.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Meta/Enso_Project.enso index 1ccb2edd5a3c..de3cdb8c2e46 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Meta/Enso_Project.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Meta/Enso_Project.enso @@ -12,7 +12,6 @@ type Project_Description - prim_config: The primitive config of the project. Value prim_root_file prim_config - ## Returns the root directory of the project. > Example diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Network/HTTP/HTTP_Status_Code.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Network/HTTP/HTTP_Status_Code.enso index 4fd194261460..a5fdf6e8bf18 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Network/HTTP/HTTP_Status_Code.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Network/HTTP/HTTP_Status_Code.enso @@ -1,4 +1,5 @@ import project.Data.Boolean.Boolean +import project.Data.Text.Text type HTTP_Status_Code ## 100 Continue. @@ -170,3 +171,49 @@ type HTTP_Status_Code ## Does the status code represent a successful response? is_success : Boolean is_success self = self.code >= 200 && self.code < 300 + + ## PRIVATE + Convert to a display representation of this HTTP_Status_Code. + to_display_text : Text + to_display_text self = case self.code of + 100 -> "Continue" + 101 -> "Switching Protocols" + 200 -> "OK" + 201 -> "Created" + 202 -> "Accepted" + 203 -> "Non-Authoritative Information" + 204 -> "No Content" + 205 -> "Reset Content" + 206 -> "Partial Content" + 300 -> "Multiple Choices" + 301 -> "Moved Permanently" + 302 -> "Found" + 303 -> "See Other" + 304 -> "Not Modified" + 305 -> "Use Proxy" + 307 -> "Temporary Redirect" + 400 -> "Bad Request" + 401 -> "Unauthorized" + 402 -> "Payment Required" + 403 -> "Forbidden" + 404 -> "Not Found" + 405 -> "Method Not Allowed" + 406 -> "Not Acceptable" + 407 -> "Proxy Authentication Required" + 408 -> "Request Timeout" + 409 -> "Conflict" + 410 -> "Gone" + 411 -> "Length Required" + 412 -> "Precondition Failed" + 413 -> "Request Entity Too Large" + 414 -> "Request-URI Too Long" + 415 -> "Unsupported Media Type" + 416 -> "Requested Range Not Satisfiable" + 417 -> "Expectation Failed" + 500 -> "Internal Server Error" + 501 -> "Not Implemented" + 502 -> "Bad Gateway" + 503 -> "Service Unavailable" + 504 -> "Gateway Timeout" + 505 -> "HTTP Version Not Supported" + _ -> "HTTP Status Code: " + self.code.to_text diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Network/HTTP/Header.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Network/HTTP/Header.enso index b931715bc73c..b96d82d1318b 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Network/HTTP/Header.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Network/HTTP/Header.enso @@ -173,6 +173,12 @@ type Header text_plain : Header text_plain = Header.content_type "text/plain" + + ## PRIVATE + Convert to a display representation of this Header. + to_display_text : Text + to_display_text self = self.name + ": " + self.value.to_display_text + ## PRIVATE type Header_Comparator ## PRIVATE diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Network/URI.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Network/URI.enso index 90a502fea549..1e37e2e52457 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Network/URI.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Network/URI.enso @@ -188,6 +188,11 @@ type URI to_text : Text to_text self = self.internal_uri.toString + ## PRIVATE + Convert to a display representation of this URI. + to_display_text : Text + to_display_text self = self.to_text.to_display_text + ## PRIVATE Convert to a JavaScript Object representing this URI. diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Random.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Random.enso index 19df6ba3c645..c673ae4587b6 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Random.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Random.enso @@ -3,6 +3,8 @@ import project.Data.Boolean.Boolean import project.Data.Numbers.Integer import project.Data.Numbers.Decimal import project.Data.Vector.Vector +import project.Data.Json.JS_Object +import project.Data.Text.Text import project.Error.Error import project.Errors.Illegal_Argument.Illegal_Argument import project.System @@ -53,6 +55,17 @@ type Random_Number_Generator if range < Java_Integer.MAX_VALUE then min + (self.java_random.nextInt range) else Error.throw (Illegal_Argument.Error "Currently only integer ranges of up to 2^31-1 are supported.") + ## PRIVATE + Serialise to JS_Object + to_js_object : JS_Object + to_js_object self = + JS_Object.from_pairs [["type", "Random_Number_Generator"], ["constructor", "new"]] + + ## PRIVATE + Convert to a display representation of this Random_Number_Generator. + to_display_text : Text + to_display_text self = "Random_Number_Generator" + ## Returns a new vector containing a random sample of the input vector, without replacement. diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/System/File.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/System/File.enso index e681d8e85d96..4a542edd7a60 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/System/File.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/System/File.enso @@ -676,6 +676,11 @@ type File to_text : Text to_text self = self.absolute . path + ## PRIVATE + Convert to a display representation of this File. + to_display_text : Text + to_display_text self = self.to_text + ## PRIVATE An output stream, allowing for interactive writing of contents into an open file. diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/System/File/File_Permissions.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/System/File/File_Permissions.enso index 314f6bd1125d..1d0d06dfc190 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/System/File/File_Permissions.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/System/File/File_Permissions.enso @@ -1,5 +1,6 @@ -import project.Data.Vector.Vector import project.Data.Boolean.Boolean +import project.Data.Text.Text +import project.Data.Vector.Vector polyglot java import java.nio.file.attribute.PosixFilePermission @@ -108,3 +109,12 @@ type File_Permissions others.append Permission.Execute File_Permissions.Value owner.to_vector group.to_vector others.to_vector + + ## PRIVATE + Convert to a display representation of this File_Permissions. + to_display_text : Text + to_display_text self = + owner = "Owner: " + (if self.owner_read then "r" else "-") + (if self.owner_write then "w" else "-") + (if self.owner_execute then "x" else "-") + group = "Group: " + (if self.group_read then "r" else "-") + (if self.group_write then "w" else "-") + (if self.group_execute then "x" else "-") + other = "Other: " + (if self.others_read then "r" else "-") + (if self.others_write then "w" else "-") + (if self.others_execute then "x" else "-") + owner + ", " + group + ", " + other diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/System/Process/Exit_Code.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/System/Process/Exit_Code.enso index 83a88bc9d779..ed0dcf3ed75d 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/System/Process/Exit_Code.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/System/Process/Exit_Code.enso @@ -1,4 +1,5 @@ import project.Data.Numbers.Integer +import project.Data.Text.Text ## The exit codes that the process can return. type Exit_Code @@ -39,3 +40,10 @@ type Exit_Code to_number self = case self of Exit_Code.Success -> 0 Exit_Code.Failure code -> code + + ## PRIVATE + Convert to a display representation of this Exit_Code. + to_display_text : Text + to_display_text self = case self of + Exit_Code.Success -> "Success" + Exit_Code.Failure code -> "Failure " + code.to_display_text diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/System/Process/Process_Builder.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/System/Process/Process_Builder.enso index 6d33b4a212f8..9ffc3177f13b 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/System/Process/Process_Builder.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/System/Process/Process_Builder.enso @@ -78,3 +78,10 @@ type Process_Builder type Process_Result ## PRIVATE Value exit_code:Exit_Code stdout:Text stderr:Text + + ## PRIVATE + Convert to a display representation of this Process_Result. + to_display_text : Text + to_display_text self = case self.exit_code of + Exit_Code.Success -> "Success(" + self.stdout.to_display_text + ")" + _ -> self.exit_code.to_display_text + "(" + self.stderr.to_display_text + ")" diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Warning.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Warning.enso index 08aade3b3a4c..0b7d54ccea20 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Warning.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Warning.enso @@ -28,7 +28,7 @@ type Warning ## ADVANCED Are any warnings attached to the value? - has_warnings : Any -> Boolean + has_warnings : Any -> Any -> Boolean has_warnings value warning_type=Any = Warning.get_all value . any (w-> w.value.is_a warning_type) @@ -36,7 +36,7 @@ type Warning Arguments: - warning_type: The type to remove if attached to the value. Defaults to all warnings. - remove_warnings : Any -> Any + remove_warnings : Any -> Any -> Any remove_warnings value warning_type=Any = Warning.detach_selected_warnings value (w-> w.is_a warning_type) . first diff --git a/distribution/lib/Standard/Database/0.0.0-dev/src/Data/Table.enso b/distribution/lib/Standard/Database/0.0.0-dev/src/Data/Table.enso index 85558e26e9da..a4bc3bda8920 100644 --- a/distribution/lib/Standard/Database/0.0.0-dev/src/Data/Table.enso +++ b/distribution/lib/Standard/Database/0.0.0-dev/src/Data/Table.enso @@ -1497,6 +1497,7 @@ type Table suffixing strategy. parse_to_columns : Text | Integer -> Text -> Case_Sensitivity -> Boolean -> Problem_Behavior -> Table parse_to_columns self column pattern="." case_sensitivity=Case_Sensitivity.Sensitive parse_values=True on_problems=Report_Error = + _ = [column, pattern, case_sensitivity, parse_values, on_problems] Error.throw (Unsupported_Database_Operation.Error "Table.parse_to_columns is not implemented yet for the Database backends.") ## PRIVATE diff --git a/distribution/lib/Standard/Table/0.0.0-dev/src/Data/Type/Value_Type.enso b/distribution/lib/Standard/Table/0.0.0-dev/src/Data/Type/Value_Type.enso index a172ddcaf796..219458e79ce3 100644 --- a/distribution/lib/Standard/Table/0.0.0-dev/src/Data/Type/Value_Type.enso +++ b/distribution/lib/Standard/Table/0.0.0-dev/src/Data/Type/Value_Type.enso @@ -300,7 +300,6 @@ type Value_Type to_js_object : JS_Object to_js_object self = constructor_name = Meta.meta self . constructor . name - display_text = self.to_display_text additional_fields = case self of Value_Type.Integer size -> [["bits", size.to_bits]] @@ -316,7 +315,7 @@ type Value_Type [["type_name", type_name]] _ -> [] JS_Object.from_pairs <| - [["type", "Value_Type"], ["constructor", constructor_name], ["_display_text_", display_text]] + additional_fields + [["type", "Value_Type"], ["constructor", constructor_name]] + additional_fields ## The type representing inferring the column type automatically based on values present in the column. diff --git a/distribution/lib/Standard/Table/0.0.0-dev/src/Errors.enso b/distribution/lib/Standard/Table/0.0.0-dev/src/Errors.enso index 903bd9163fbd..c834d1897996 100644 --- a/distribution/lib/Standard/Table/0.0.0-dev/src/Errors.enso +++ b/distribution/lib/Standard/Table/0.0.0-dev/src/Errors.enso @@ -50,7 +50,7 @@ type Too_Many_Column_Names_Provided ## One or more column names were invalid during a rename operation. type Invalid_Output_Column_Names - Error (column_names : [Text]) + Error (column_names : Vector Text) ## PRIVATE diff --git a/distribution/lib/Standard/Visualization/0.0.0-dev/src/Table/Visualization.enso b/distribution/lib/Standard/Visualization/0.0.0-dev/src/Table/Visualization.enso index b992b290c7a7..5660b70a26de 100644 --- a/distribution/lib/Standard/Visualization/0.0.0-dev/src/Table/Visualization.enso +++ b/distribution/lib/Standard/Visualization/0.0.0-dev/src/Table/Visualization.enso @@ -1,4 +1,5 @@ from Standard.Base import all +import Standard.Base.Data.Vector.Builder import Standard.Table.Data.Table.Table as Dataframe_Table import Standard.Table.Data.Column.Column as Dataframe_Column @@ -21,46 +22,109 @@ import project.Helpers prepare_visualization : Any -> Integer -> Text prepare_visualization y max_rows=1000 = Helpers.recover_errors <| x = Warning.set y [] - case x of + + result = case x of + _ : Vector -> make_json_for_vector x max_rows + _ : Array -> prepare_visualization x.to_vector max_rows + _ : Map -> make_json_for_map x max_rows + _ : JS_Object -> make_json_for_js_object x max_rows + _ : Dataframe_Column -> prepare_visualization x.to_table max_rows _ : Dataframe_Table -> - dataframe = x.take (First max_rows) + dataframe = x.take max_rows all_rows_count = x.row_count - included_rows = dataframe.row_count - index = Dataframe_Column.from_vector "#" (Vector.new included_rows i->i) - - make_json dataframe [index] all_rows_count - + index = Dataframe_Column.from_vector "#" (Vector.new dataframe.row_count i->i) + make_json_for_table dataframe [index] all_rows_count + _ : Database_Column -> prepare_visualization x.to_table max_rows _ : Database_Table -> - df = x.read max_rows + dataframe = x.read max_rows all_rows_count = x.row_count + make_json_for_table dataframe [] all_rows_count + _ -> + js_value = x.to_js_object + value = if js_value.is_a JS_Object . not then js_value else + pairs = [['_display_text_', x.to_display_text]] + js_value.field_names.map f-> [f, make_json_for_value (js_value.get f)] + JS_Object.from_pairs pairs + JS_Object.from_pairs [["json", value]] - make_json df [] all_rows_count + result.to_text - # We display columns as 1-column tables. - _ : Dataframe_Column -> - prepare_visualization x.to_table max_rows - _ : Database_Column -> - prepare_visualization x.to_table max_rows +## Column Limit +max_columns = 250 - # TODO [RW] Should we truncate Vectors? - # We also visualize Vectors and arrays - _ : Vector -> - truncated = x.take (First max_rows) - JS_Object.from_pairs [["json", truncated], ["all_rows_count", x.length]] . to_text - _ : Array -> - prepare_visualization (Vector.from_polyglot_array x) max_rows +## PRIVATE + Render Vector to JSON +make_json_for_vector : Vector -> Integer -> JS_Object +make_json_for_vector vector max_rows = + all_rows = ["all_rows_count", vector.length] + truncated = vector.take max_rows + + matrix = make_json_for_matrix (Vector.new_builder truncated.length) truncated + if matrix.is_nothing.not then JS_Object.from_pairs [["type", "Matrix"], all_rows, ["json", matrix], ["column_count", matrix.fold 0 c->v-> if v.is_nothing then c else c.max v.length]] else + object_matrix = make_json_for_object_matrix (Vector.new_builder truncated.length) truncated + if object_matrix.is_nothing.not then JS_Object.from_pairs [["type", "Object_Matrix"], all_rows, ["json", object_matrix]] else + JS_Object.from_pairs [["type", "Vector"], all_rows, ["json", truncated.map make_json_for_value]] - # Serialize Maps - _ : Map -> - map_vector = Warning.clear (x.to_vector.take max_rows) - header = ["header", ["key", "value"]] - data = ["data", [map_vector.map .first, map_vector.map .second]] - all_rows = ["all_rows_count", x.size] - JS_Object.from_pairs [header, data, all_rows] . to_text +## PRIVATE + Render Vector of Vector / Array to JSON +make_json_for_matrix : Builder -> Vector -> Integer -> Vector | Nothing +make_json_for_matrix current vector idx=0 = if idx == vector.length then current.to_vector else + row = vector.at idx + to_append = case row of + Nothing -> Nothing + _ : Vector -> row.take max_columns . map make_json_for_value + _ : Array -> row.to_vector.take max_columns . map make_json_for_value + _ -> False + if to_append == False then Nothing else + next = current.append to_append + @Tail_Call make_json_for_matrix next vector idx+1 - # Anything else will be visualized with the JSON or matrix visualization +## PRIVATE + Render Vector of Objects to JSON +make_json_for_object_matrix : Builder -> Vector -> Integer -> Vector | Nothing +make_json_for_object_matrix current vector idx=0 = if idx == vector.length then current.to_vector else + row = vector.at idx + to_append = case row of + Nothing -> Nothing + _ : Date -> False + _ : Time_Of_Day -> False + _ : Date_Time -> False + _ : Duration -> False + _ : Period -> False + _ : Map -> + pairs = row.keys.map k-> [k.to_text, make_json_for_value (row.get k)] + JS_Object.from_pairs pairs _ -> - JS_Object.from_pairs [["json", x]] . to_text + js_object = row.to_js_object + if js_object.is_a JS_Object . not then False else + if js_object.field_names.sort == ["type" , "constructor"] then False else + pairs = js_object.field_names.map f-> [f, make_json_for_value (js_object.get f)] + JS_Object.from_pairs pairs + if to_append == False then Nothing else + next = current.append to_append + @Tail_Call make_json_for_object_matrix next vector idx+1 + +## PRIVATE + Render Map to JSON +make_json_for_map : Map -> Integer -> JS_Object +make_json_for_map map max_items = + header = ["header", ["key", "value"]] + all_rows = ["all_rows_count", map.size] + map_vector = Warning.clear (map.to_vector.take max_items) + mapped = map_vector . map p-> [p.first.to_text, make_json_for_value p.second] + data = ["data", [mapped.map .first, mapped.map .second]] + JS_Object.from_pairs [header, data, all_rows, ["type", "Map"]] + +## PRIVATE + Render JS_Object to JSON +make_json_for_js_object : JS_Object -> Integer -> JS_Object +make_json_for_js_object js_object max_items = + fields = js_object.field_names + header = ["header", ["key", "value"]] + all_rows = ["all_rows_count", fields.length] + map_vector = Warning.clear (fields.take max_items) + mapped = map_vector . map p-> [p, make_json_for_value (js_object.get p)] + data = ["data", [mapped.map .first, mapped.map .second]] + JS_Object.from_pairs [header, data, all_rows, ["type", "Map"]] ## PRIVATE Creates a JSON representation for the visualizations. @@ -73,14 +137,51 @@ prepare_visualization y max_rows=1000 = Helpers.recover_errors <| `dataframe`. - all_rows_count: the number of all rows in the underlying data, useful if only a fragment is displayed. -make_json : (Dataframe_Table | Database_Table) -> Vector Dataframe_Column -> Integer -> Text -make_json dataframe indices all_rows_count = - get_vector c = Warning.set c.to_vector [] +make_json_for_table : Dataframe_Table -> Vector Dataframe_Column -> Integer -> JS_Object +make_json_for_table dataframe indices all_rows_count = + get_vector c = Warning.set (c.to_vector.map v-> make_json_for_value v) [] columns = dataframe.columns header = ["header", columns.map .name] data = ["data", columns.map get_vector] all_rows = ["all_rows_count", all_rows_count] ixes = ["indices", indices.map get_vector] ixes_header = ["indices_header", indices.map .name] - pairs = [header, data, all_rows, ixes, ixes_header] - JS_Object.from_pairs pairs . to_text + pairs = [header, data, all_rows, ixes, ixes_header, ["type", "Table"]] + JS_Object.from_pairs pairs + +## PRIVATE + Create JSON serialization of values for the table. +make_json_for_value : Any -> Integer -> Text +make_json_for_value val level=0 = case val of + Nothing -> Nothing + _ : Text -> val + _ : Number -> + js_version = val.to_js_object + if js_version.is_a JS_Object . not then js_version else + pairs = [['_display_text_', val.to_display_text]] + js_version.field_names.map f-> [f, js_version.get f] + JS_Object.from_pairs pairs + _ : Boolean -> val + _ : Vector -> + if level != 0 then "[… "+val.length.to_text+" items]" else + truncated = val.take 5 . map v-> (make_json_for_value v level+1).to_text + prepared = if val.length > 5 then truncated + ["… " + (val.length - 5).to_text+ " items"] else truncated + "[" + (prepared.join ", ") + "]" + _ : Array -> make_json_for_value val.to_vector level + _ : Map -> + if level != 0 then "{… "+val.size.to_text+" items}" else + truncated = val.keys.take 5 . map k-> k.to_text + ": " + (make_json_for_value (val.get k) level+1).to_text + prepared = if val.length > 5 then truncated + ["… " + (val.length - 5).to_text+ " items"] else truncated + "{" + (prepared.join ", ") + "}" + _ : Dataframe_Column -> make_json_for_value val.to_table level + _ : Database_Column -> make_json_for_value val.to_table level + _ : Dataframe_Table -> + if level != 0 then "Table{" + val.row_count + " rows x " + val.column_count + " columns}" else + truncated = val.columns.take 5 . map _.name + prepared = if val.column_count > 5 then truncated + ["… " + (val.column_count - 5).to_text+ " more"] else truncated + "Table{" + val.row_count.to_text + " rows x [" + (prepared.join ", ") + "]}" + _ : Database_Table -> + if level != 0 then "Table{" + val.row_count + " rows x " + val.column_count + " columns}" else + truncated = val.columns.take 5 . map _.name + prepared = if val.column_count > 5 then truncated + ["… " + (val.column_count - 5).to_text+ " more"] else truncated + "Table{" + val.row_count.to_text + " rows x [" + (prepared.join ", ") + "]}" + _ -> val.to_display_text diff --git a/engine/runtime-with-instruments/src/test/scala/org/enso/interpreter/test/instrument/RuntimeServerTest.scala b/engine/runtime-with-instruments/src/test/scala/org/enso/interpreter/test/instrument/RuntimeServerTest.scala index 0b2409eef170..dd3d4d19fa40 100644 --- a/engine/runtime-with-instruments/src/test/scala/org/enso/interpreter/test/instrument/RuntimeServerTest.scala +++ b/engine/runtime-with-instruments/src/test/scala/org/enso/interpreter/test/instrument/RuntimeServerTest.scala @@ -2989,7 +2989,7 @@ class RuntimeServerTest contextId, Seq( Api.ExecutionResult.Diagnostic.error( - "Type error: expected a function, but got 42 (Integer).", + "Type error: expected a function, but got 42.", Some(mainFile), Some(model.Range(model.Position(1, 7), model.Position(1, 19))), None, @@ -3136,7 +3136,7 @@ class RuntimeServerTest contextId, Seq( Api.ExecutionResult.Diagnostic.error( - "Method `+` of x (Unresolved_Symbol) could not be found.", + "Method `+` of type Function could not be found.", Some(mainFile), Some(model.Range(model.Position(3, 14), model.Position(3, 23))), None, @@ -3440,7 +3440,7 @@ class RuntimeServerTest contextId, Seq( Api.ExecutionResult.Diagnostic.error( - "Method `pi` of Number could not be found.", + "Method `pi` of type Number.type could not be found.", Some(mainFile), Some(model.Range(model.Position(3, 7), model.Position(3, 16))), None, diff --git a/engine/runtime-with-polyglot/src/test/scala/org/enso/interpreter/test/instrument/RuntimeVisualizationsTest.scala b/engine/runtime-with-polyglot/src/test/scala/org/enso/interpreter/test/instrument/RuntimeVisualizationsTest.scala index 1b0d23850fc6..96f20746b041 100644 --- a/engine/runtime-with-polyglot/src/test/scala/org/enso/interpreter/test/instrument/RuntimeVisualizationsTest.scala +++ b/engine/runtime-with-polyglot/src/test/scala/org/enso/interpreter/test/instrument/RuntimeVisualizationsTest.scala @@ -1733,10 +1733,11 @@ class RuntimeVisualizationsTest Api.Response( requestId, Api.VisualisationExpressionFailed( - "Method `does_not_exist` of Main could not be found.", + "Method `does_not_exist` of type Main could not be found.", Some( Api.ExecutionResult.Diagnostic.error( - message = "Method `does_not_exist` of Main could not be found.", + message = + "Method `does_not_exist` of type Main could not be found.", stack = Vector( Api.StackTraceElement("", None, None, None), Api.StackTraceElement("Debug.eval", None, None, None) @@ -1817,10 +1818,10 @@ class RuntimeVisualizationsTest contextId, visualisationId, idMain, - "Method `visualise_me` of 50 (Integer) could not be found.", + "Method `visualise_me` of type Integer could not be found.", Some( Api.ExecutionResult.Diagnostic.error( - "Method `visualise_me` of 50 (Integer) could not be found.", + "Method `visualise_me` of type Integer could not be found.", None, Some(model.Range(model.Position(0, 5), model.Position(0, 19))), None, @@ -1929,10 +1930,10 @@ class RuntimeVisualizationsTest contextId, visualisationId, idMain, - "Method `visualise_me` of 51 (Integer) could not be found.", + "Method `visualise_me` of type Integer could not be found.", Some( Api.ExecutionResult.Diagnostic.error( - "Method `visualise_me` of 51 (Integer) could not be found.", + "Method `visualise_me` of type Integer could not be found.", Some(visualisationFile), Some(model.Range(model.Position(1, 11), model.Position(1, 25))), None, @@ -2134,10 +2135,10 @@ class RuntimeVisualizationsTest contextId, visualisationId, idMain, - "42 (Integer)", + "42", Some( Api.ExecutionResult.Diagnostic.error( - message = "42 (Integer)", + message = "42", file = Some(mainFile), location = Some(model.Range(model.Position(3, 4), model.Position(3, 18))), diff --git a/engine/runtime/src/main/java/org/enso/compiler/TreeToIr.java b/engine/runtime/src/main/java/org/enso/compiler/TreeToIr.java index 13141b665bb6..67d7df1bd3ea 100644 --- a/engine/runtime/src/main/java/org/enso/compiler/TreeToIr.java +++ b/engine/runtime/src/main/java/org/enso/compiler/TreeToIr.java @@ -1084,6 +1084,9 @@ IR.Expression translateType(Tree tree, boolean insideTypeAscription) { } case Tree.OprApp app -> { var op = app.getOpr().getRight(); + if (op == null) { + yield translateSyntaxError(app, IR$Error$Syntax$UnexpectedExpression$.MODULE$); + } yield switch (op.codeRepr()) { case "." -> { final Option loc = getIdentifiedLocation(tree); diff --git a/engine/runtime/src/main/java/org/enso/interpreter/node/expression/builtin/text/AnyToDisplayTextNode.java b/engine/runtime/src/main/java/org/enso/interpreter/node/expression/builtin/text/AnyToDisplayTextNode.java index d2391a655b63..8538bfc32fdd 100644 --- a/engine/runtime/src/main/java/org/enso/interpreter/node/expression/builtin/text/AnyToDisplayTextNode.java +++ b/engine/runtime/src/main/java/org/enso/interpreter/node/expression/builtin/text/AnyToDisplayTextNode.java @@ -1,6 +1,5 @@ package org.enso.interpreter.node.expression.builtin.text; -import com.ibm.icu.text.BreakIterator; import com.oracle.truffle.api.CompilerDirectives; import com.oracle.truffle.api.dsl.Cached; import com.oracle.truffle.api.dsl.Fallback; @@ -13,6 +12,7 @@ import org.enso.interpreter.dsl.BuiltinMethod; import org.enso.interpreter.node.expression.builtin.text.util.TypeToDisplayTextNode; import org.enso.interpreter.runtime.data.text.Text; +import org.enso.interpreter.runtime.number.EnsoBigInteger; import org.enso.polyglot.common_utils.Core_Text_Utils; @BuiltinMethod(type = "Any", name = "to_display_text") @@ -35,6 +35,23 @@ Text showExceptions( } } + @Specialization + @CompilerDirectives.TruffleBoundary + Text convertInteger(long self) { + return Text.create(Long.toString(self)); + } + + @Specialization + @CompilerDirectives.TruffleBoundary + Text convertDouble(double self) { + return Text.create(Double.toString(self)); + } + + @Specialization + Text convertBigInteger(EnsoBigInteger bigInteger) { + return Text.create(bigInteger.toString()); + } + @Specialization Text convertText(Text self) { final var limit = 80; @@ -47,8 +64,8 @@ Text convertText(Text self) { @CompilerDirectives.TruffleBoundary private static Text takePrefix(Text self, final int limit) { - var prefix = Core_Text_Utils.take_prefix(self.toString(), limit); - return Text.create(prefix); + var prefix = Core_Text_Utils.take_prefix(self.toString(), limit - 2); + return Text.create(prefix + " …"); } @Fallback diff --git a/engine/runtime/src/main/scala/org/enso/interpreter/instrument/command/EditFileCmd.scala b/engine/runtime/src/main/scala/org/enso/interpreter/instrument/command/EditFileCmd.scala index 7591ae254f90..f05ab9d493b9 100644 --- a/engine/runtime/src/main/scala/org/enso/interpreter/instrument/command/EditFileCmd.scala +++ b/engine/runtime/src/main/scala/org/enso/interpreter/instrument/command/EditFileCmd.scala @@ -31,9 +31,6 @@ class EditFileCmd(request: Api.EditFileNotification) extends Command(None) { ctx.jobControlPlane.abortAllJobs() ctx.jobProcessor.run(new EnsureCompiledJob(Seq(request.path))) executeJobs.foreach(ctx.jobProcessor.run) - } else { - ctx.jobControlPlane.abortAllExcept(classOf[ExecuteJob]) - ctx.jobProcessor.run(new EnsureCompiledJob(Seq(request.path))) } Future.successful(()) } finally { diff --git a/engine/runtime/src/test/java/org/enso/compiler/ErrorCompilerTest.java b/engine/runtime/src/test/java/org/enso/compiler/ErrorCompilerTest.java index 4ea080cf5577..1d7172849ba6 100644 --- a/engine/runtime/src/test/java/org/enso/compiler/ErrorCompilerTest.java +++ b/engine/runtime/src/test/java/org/enso/compiler/ErrorCompilerTest.java @@ -192,6 +192,14 @@ public void malformedImport10() throws Exception { assertSingleSyntaxError(ir, IR$Error$Syntax$UnexpectedExpression$.MODULE$, "Unexpected expression", 0, 20); } + @Test + public void malformedTypeException() throws Exception { + var ir = parse(""" + fan_out_to_columns : Table -> Text | Integer -> (Any -> Vector Any) -> | Nothing -> Problem_Behavior -> Table | Nothing + """); + assertSingleSyntaxError(ir, IR$Error$Syntax$UnexpectedExpression$.MODULE$, "Unexpected expression", 48, 119); + } + @Test public void malformedImport11() throws Exception { var ir = parse("from import all"); diff --git a/engine/runtime/src/test/java/org/enso/interpreter/test/TypeMembersTest.java b/engine/runtime/src/test/java/org/enso/interpreter/test/TypeMembersTest.java index 7487d05f2935..f1e13ed129d3 100644 --- a/engine/runtime/src/test/java/org/enso/interpreter/test/TypeMembersTest.java +++ b/engine/runtime/src/test/java/org/enso/interpreter/test/TypeMembersTest.java @@ -118,7 +118,7 @@ private static void assertMembers(String msg, boolean invokeFails, Value v, Stri v.invokeMember(k); fail("Invoking " + k + " on " + v + " shall fail"); } catch (PolyglotException ex) { - assertEquals("No_Such_Field.Error", ex.getMessage()); + assertEquals("Field `" + k + "` of IntList could not be found.", ex.getMessage()); } } else { assertNotNull(msg + " - can be invoked", v.invokeMember(k)); diff --git a/engine/runtime/src/test/scala/org/enso/interpreter/test/semantic/ImportsTest.scala b/engine/runtime/src/test/scala/org/enso/interpreter/test/semantic/ImportsTest.scala index 018033c88f36..ff739d19d052 100644 --- a/engine/runtime/src/test/scala/org/enso/interpreter/test/semantic/ImportsTest.scala +++ b/engine/runtime/src/test/scala/org/enso/interpreter/test/semantic/ImportsTest.scala @@ -14,7 +14,7 @@ class ImportsTest extends PackageTest { "Overloaded methods" should "not be visible when not imported" in { the[InterpreterException] thrownBy evalTestProject( "TestNonImportedOverloads" - ) should have message "Method `method` of Mk_X could not be found." + ) should have message "Method `method` of type X could not be found." } "Import statements" should "report errors when they cannot be resolved" in { @@ -101,13 +101,13 @@ class ImportsTest extends PackageTest { "Importing module's types" should "not bring extension methods into the scope " in { the[InterpreterException] thrownBy evalTestProject( "Test_Extension_Methods_Failure" - ) should have message "Method `foo` of 1 (Integer) could not be found." + ) should have message "Method `foo` of type Integer could not be found." } "Compiler" should "detect name conflicts preventing users from importing submodules" in { the[InterpreterException] thrownBy evalTestProject( "TestSubmodulesNameConflict" - ) should have message "Method `c_mod_method` of C could not be found." + ) should have message "Method `c_mod_method` of type C.type could not be found." val outLines = consumeOut outLines(2) should include "Declaration of type C shadows module local.TestSubmodulesNameConflict.A.B.C making it inaccessible via a qualified name." @@ -183,7 +183,7 @@ class ImportsTest extends PackageTest { "Fully qualified names" should "detect conflicts with the exported types sharing the namespace" in { the[InterpreterException] thrownBy evalTestProject( "Test_Fully_Qualified_Name_Conflict" - ) should have message "Method `Foo` of Atom could not be found." + ) should have message "Method `Foo` of type Atom.type could not be found." val outLines = consumeOut outLines should have length 3 outLines( diff --git a/engine/runtime/src/test/scala/org/enso/interpreter/test/semantic/MethodsTest.scala b/engine/runtime/src/test/scala/org/enso/interpreter/test/semantic/MethodsTest.scala index 833047be0cfc..ef3bee9df5e5 100644 --- a/engine/runtime/src/test/scala/org/enso/interpreter/test/semantic/MethodsTest.scala +++ b/engine/runtime/src/test/scala/org/enso/interpreter/test/semantic/MethodsTest.scala @@ -107,7 +107,7 @@ class MethodsTest extends InterpreterTest { |""".stripMargin the[InterpreterException] thrownBy eval( code - ) should have message "Method `foo` of 7 (Integer) could not be found." + ) should have message "Method `foo` of type Integer could not be found." } "be callable for any type when defined on Any" in { @@ -216,7 +216,7 @@ class MethodsTest extends InterpreterTest { |""".stripMargin the[InterpreterException] thrownBy eval( code - ) should have message "Method `new` of Mk_Foo could not be found." + ) should have message "Method `new` of type Foo could not be found." } "not be callable on Nothing when non-static" in { diff --git a/engine/runtime/src/test/scala/org/enso/interpreter/test/semantic/TextTest.scala b/engine/runtime/src/test/scala/org/enso/interpreter/test/semantic/TextTest.scala index a2f350d0d6b6..8b313a0a9850 100644 --- a/engine/runtime/src/test/scala/org/enso/interpreter/test/semantic/TextTest.scala +++ b/engine/runtime/src/test/scala/org/enso/interpreter/test/semantic/TextTest.scala @@ -131,10 +131,10 @@ class TextTest extends InterpreterTest { "Syntax error: foo.", "Type error: expected `myvar` to be Nothing, but got List.", "Compile error: error :(.", - "Inexhaustive pattern match: no branch matches 32 (Integer).", + "Inexhaustive pattern match: no branch matches 32.", "Arithmetic error: cannot frobnicate quaternions.", "Type error: expected `that` to be Number, but got Text.", - "Type error: expected a function, but got 7 (Integer).", + "Type error: expected a function, but got 7.", "Wrong number of arguments. Expected 10, but got 20." ) } diff --git a/lib/rust/ensogl/app/theme/hardcoded/src/lib.rs b/lib/rust/ensogl/app/theme/hardcoded/src/lib.rs index d06e694346ee..92ee6f3b8501 100644 --- a/lib/rust/ensogl/app/theme/hardcoded/src/lib.rs +++ b/lib/rust/ensogl/app/theme/hardcoded/src/lib.rs @@ -642,12 +642,12 @@ define_themes! { [light:0, dark:1] color = Rgba(0.0, 0.451, 0.859, 1.0), Rgba(0.0, 0.451, 0.859, 1.0); } } - execution_mode_selector { + execution_environment_selector { background = Rgb::from_base_255(100.0, 181.0, 38.0), Rgb::from_base_255(100.0, 181.0, 38.0); divider = Rgba::black_with_alpha(0.12), Rgba::black_with_alpha(0.12); divider_offset = 32.5, 32.5; divider_padding = 10.0, 10.0; - dropdown_width = 95.0, 95.0; + dropdown_width = 75.0, 75.0; height = 24.0, 24.0; menu_offset = 20.0, 20.0; play_button { @@ -664,6 +664,10 @@ define_themes! { [light:0, dark:1] } } widget { + activation_shape { + base = Lcha(0.56708, 0.23249, 0.71372, 1.0), Lcha(0.56708, 0.23249, 0.71372, 1.0); + connected = graph_editor::node::background , graph_editor::node::background; + } list_view { background = graph_editor::node::background , graph_editor::node::background; highlight = Rgba(0.906,0.914,0.922,1.0) , Lcha(1.0,0.0,0.0,0.15); // rgb(231,233,235) diff --git a/lib/rust/ensogl/component/drop-down-menu/src/lib.rs b/lib/rust/ensogl/component/drop-down-menu/src/lib.rs index d97bbb9fcd83..76335a7a0c74 100644 --- a/lib/rust/ensogl/component/drop-down-menu/src/lib.rs +++ b/lib/rust/ensogl/component/drop-down-menu/src/lib.rs @@ -40,10 +40,8 @@ use ensogl_text as text; /// Invisible dummy color to catch hover events. const HOVER_COLOR: color::Rgba = color::Rgba::new(1.0, 0.0, 0.0, 0.000_001); -/// The width of the visualisation selection menu. -const MENU_WIDTH: f32 = 180.0; - - +/// The default width of the selection menu. +const DEFAULT_MENU_WIDTH: f32 = 180.0; // ============== // === Shapes === @@ -122,6 +120,7 @@ ensogl_core::define_endpoints! { set_menu_offset_y (f32), set_menu_alignment (Alignment), set_label_alignment (Alignment), + set_width (f32), } Output { menu_visible (bool), @@ -270,9 +269,11 @@ impl DropDownMenu { // === Layouting === let menu_height = DEPRECATED_Animation::::new(network); + let menu_width = frp.set_width.clone_ref(); - eval menu_height.value ([model](height) { - model.selection_menu.frp.resize.emit(Vector2::new(MENU_WIDTH,*height)); + resize_menu <- all(menu_width,menu_height.value); + eval resize_menu ([model]((width,height)) { + model.selection_menu.frp.resize.emit(Vector2::new(*width,*height)); if *height <= 0.0 { model.hide_selection_menu(); } else if *height > 0.0 { @@ -298,29 +299,29 @@ impl DropDownMenu { model.selection_menu.set_x(x_offset); }); - label_position <- all(model.label.frp.width,frp.input.set_icon_size,model.label.frp.height, - frp.input.set_label_alignment); - eval label_position ([model]((text_width,icon_size,text_height,alignment)) { + label_position <- all5(&model.label.frp.width,&frp.input.set_icon_size,&model.label.frp + .height,&frp.input.set_label_alignment,&menu_width); + eval label_position ([model]((text_width,icon_size,text_height,alignment,menu_width)) { let base_offset = match alignment { - Alignment::Left => -MENU_WIDTH/2.0+icon_size.x/2.0, - Alignment::Right => -text_width-icon_size.x/2.0, + Alignment::Left => -menu_width + icon_size.x / 2.0, + Alignment::Right => -text_width-icon_size.x / 2.0, }; model.label.set_x(base_offset); // Adjust for text offset, so this appears more centered. model.label.set_y(0.5 * text_height); }); - overlay_size <- all( - model.label.frp.width, - model.label.frp.height, - frp.input.set_icon_size, - frp.input.set_icon_padding); - eval overlay_size ([model]((text_width,text_height,icon_size,icon_padding)) { + overlay_size <- all4( + &model.label.frp.height, + &frp.input.set_icon_size, + &frp.input.set_icon_padding, + &menu_width); + eval overlay_size ([model]((text_height,icon_size,icon_padding,menu_width)) { let height = icon_size.y.max(*text_height); - let width = text_width + icon_size.x + icon_padding.x; + let width = *menu_width; let size = Vector2::new(width,height); model.click_overlay.set_size(size); - model.click_overlay.set_x(-width/2.0 + icon_size.x/2.0 - icon_padding.x); + model.click_overlay.set_x(-width / 2.0 + icon_size.x / 2.0 - icon_padding.x); }); @@ -417,7 +418,7 @@ impl DropDownMenu { let styles = StyleWatch::new(&app.display.default_scene.style_sheet); let text_color = styles.get_color(theme::widget::list_view::text); model.label.set_property_default(text_color); - + frp.set_width.emit(DEFAULT_MENU_WIDTH); self } diff --git a/lib/rust/ensogl/component/list-editor/src/lib.rs b/lib/rust/ensogl/component/list-editor/src/lib.rs index 63c9848d0a57..07c96aab0b4c 100644 --- a/lib/rust/ensogl/component/list-editor/src/lib.rs +++ b/lib/rust/ensogl/component/list-editor/src/lib.rs @@ -73,15 +73,11 @@ #![allow(clippy::bool_to_int_with_if)] #![allow(clippy::let_and_return)] -use ensogl_core::display::shape::compound::rectangle::*; use ensogl_core::display::world::*; use ensogl_core::prelude::*; -use ensogl_core::application::Application; use ensogl_core::control::io::mouse; -use ensogl_core::data::color; use ensogl_core::display; -use ensogl_core::display::navigation::navigator::Navigator; use ensogl_core::display::object::Event; use ensogl_core::display::object::ObjectOps; use ensogl_core::gui::cursor; @@ -106,13 +102,10 @@ pub mod placeholder; // === Constants === // ================= -// FIXME: to be parametrized -const GAP: f32 = 20.0; - /// If set to true, animations will be running slow. This is useful for debugging purposes. pub const DEBUG_ANIMATION_SLOWDOWN: bool = false; -pub const DEBUG_PLACEHOLDERS_VIZ: bool = true; +pub const DEBUG_PLACEHOLDERS_VIZ: bool = false; /// Spring factor for animations. If [`DEBUG_ANIMATION_SLOWDOWN`] is set to true, this value will be /// used for animation simulators. @@ -265,26 +258,50 @@ impl From for ItemOrPlaceholder { // === ListEditor === // ================== -ensogl_core::define_endpoints_2! { +ensogl_core::define_endpoints_2! { Input { /// Push a new element to the end of the list. - push(Weak), + push(Rc>>), - insert((Index, Weak)), + /// Insert a new element in the given position. If the index is bigger than the list length, + /// the item will be placed at the end of the list. + insert((Index, Rc>>)), /// Remove the element at the given index. If the index is invalid, nothing will happen. remove(Index), + /// Set the spacing between elements. + gap(f32), + + /// The distance the user needs to drag the element along secondary axis to start dragging + /// the element. See docs of this module to learn more. secondary_axis_drag_threshold(f32), + + /// The distance the user needs to drag the element along primary axis to consider it not a + /// drag movement and thus to pass mouse events to the item. See docs of this module to + /// learn more. primary_axis_no_drag_threshold(f32), + + /// The time in which the `primary_axis_no_drag_threshold` drops to zero. primary_axis_no_drag_threshold_decay_time(f32), + + /// Controls the distance an item needs to be dragged out of the list for it to be trashed. + /// See docs of this module to learn more. thrashing_offset_ratio(f32), + + /// Enable insertion points (plus icons) when moving mouse next to any of the list items. + enable_all_insertion_points(bool), + + /// Enable insertion points (plus icons) when moving mouse after the last list item. + enable_last_insertion_point(bool), } Output { /// Fires whenever a new element was added to the list. - on_item_added(Response<(Index, Weak)>), + on_item_added(Response), - // on_item_removed(Response<(Index, Weak)>), + /// Fires whenever an element was removed from the list. This can happen when dragging the + /// element to switch its position. + on_item_removed(Response<(Index, Rc>>)>), /// Request new item to be inserted at the provided index. In most cases, this happens after /// clicking a "plus" icon to add new element to the list. As a response, you should use the @@ -295,41 +312,37 @@ ensogl_core::define_endpoints_2! { #[derive(Derivative, CloneRef, Debug, Deref)] #[derivative(Clone(bound = ""))] -pub struct ListEditor { +pub struct ListEditor { #[deref] - pub frp: Frp, - root: display::object::Instance, - model: SharedModel, - add_elem_icon: Rectangle, - remove_elem_icon: Rectangle, + pub frp: Frp, + root: display::object::Instance, + model: SharedModel, } #[derive(Debug)] pub struct Model { + cursor: Cursor, items: VecIndexedBy, ItemOrPlaceholderIndex>, root: display::object::Instance, layout: display::object::Instance, + gap: f32, } impl Model { /// Constructor. - pub fn new() -> Self { + pub fn new(cursor: &Cursor) -> Self { + let cursor = cursor.clone_ref(); let items = default(); let root = display::object::Instance::new(); let layout = display::object::Instance::new(); + let gap = default(); layout.use_auto_layout(); root.add_child(&layout); - Self { items, root, layout } + Self { cursor, items, root, layout, gap } } } -impl Default for Model { - fn default() -> Self { - Self::new() - } -} - -#[derive(Derivative, CloneRef, Debug, Default, Deref)] +#[derive(Derivative, CloneRef, Debug, Deref)] #[derivative(Clone(bound = ""))] pub struct SharedModel { rc: Rc>>, @@ -342,31 +355,13 @@ impl From> for SharedModel { } -impl ListEditor { +impl ListEditor { pub fn new(cursor: &Cursor) -> Self { let frp = Frp::new(); - let model = Model::new(); + let model = Model::new(cursor); let root = model.root.clone_ref(); - let add_elem_icon = Rectangle().build(|t| { - t.set_size(Vector2::new(20.0, 20.0)) - .set_color(color::Rgba::new(0.0, 1.0, 0.0, 1.0)) - .set_inset_border(2.0) - .set_border_color(color::Rgba::new(0.0, 0.0, 0.0, 0.5)); - }); - - let remove_elem_icon = Rectangle().build(|t| { - t.set_size(Vector2::new(20.0, 20.0)) - .set_color(color::Rgba::new(1.0, 0.0, 0.0, 1.0)) - .set_inset_border(2.0) - .set_border_color(color::Rgba::new(0.0, 0.0, 0.0, 0.5)); - }); - add_elem_icon.set_y(-30.0); - root.add_child(&add_elem_icon); - remove_elem_icon.set_y(-30.0); - remove_elem_icon.set_x(30.0); - root.add_child(&remove_elem_icon); let model = model.into(); - Self { frp, root, model, add_elem_icon, remove_elem_icon }.init(cursor).init_frp_values() + Self { frp, root, model }.init(cursor).init_frp_values() } fn init(self, cursor: &Cursor) -> Self { @@ -375,37 +370,11 @@ impl ListEditor { let network = self.frp.network(); let model = &self.model; - let add_elem_icon_down = self.add_elem_icon.on_event::(); - let remove_elem_icon_down = self.remove_elem_icon.on_event::(); let on_down = model.borrow().layout.on_event_capturing::(); let on_up_source = scene.on_event::(); let on_move = scene.on_event::(); - frp::extend! { network - frp.private.output.request_new_item <+ add_elem_icon_down.map(f!([model] (_) { - let index = model.borrow_mut().items.len(); - Response::gui(index) - })); - - frp.remove <+ remove_elem_icon_down.constant(0); - - push_item_index <= frp.push.map(f!([model] (item) - item.upgrade().map(|t| model.borrow_mut().push((*t).clone())) - )); - on_item_pushed <- frp.push.map2(&push_item_index, |item, ix| Response::api((*ix, item.clone()))); - frp.private.output.on_item_added <+ on_item_pushed; - - insert_item_index <= frp.insert.map(f!([model] ((index, item)) - item.upgrade().map(|t| model.borrow_mut().insert(*index, (*t).clone())) - )); - on_item_inserted <- frp.insert.map2(&insert_item_index, |(_,item), ix| Response::api((*ix, item.clone()))); - frp.private.output.on_item_added <+ on_item_inserted; - - - // Do not pass events to children, as we don't know whether we are about to drag - // them yet. - eval on_down ([] (event) event.stop_propagation()); target <= on_down.map(|event| event.target()); on_up <- on_up_source.identity(); @@ -414,51 +383,123 @@ impl ListEditor { is_down <- bool(&on_up, &on_down); on_move_down <- on_move.gate(&is_down); glob_pos_on_down <- on_down.map(|event| event.client_centered()); - glob_pos_on_move <- on_move_down.map(|event| event.client_centered()); - pos_on_down <- glob_pos_on_down.map(f!([model] (p) model.screen_to_object_space(*p))); - pos_on_move <- glob_pos_on_move.map(f!([model] (p) model.screen_to_object_space(*p))); - pos_diff_on_move <- pos_on_move.map2(&pos_on_down, |a, b| a - b); - pos_diff_on_down <- on_down.constant(Vector2::new(0.0, 0.0)); - pos_diff_on_up <- on_up_cleaning_phase.constant(Vector2::new(0.0, 0.0)); + glob_pos_on_move_down <- on_move_down.map(|event| event.client_centered()); + glob_pos_on_move <- on_move.map(|event| event.client_centered()); + pos_on_down <- glob_pos_on_down.map(f!((p) model.screen_to_object_space(*p))); + pos_on_move_down <- glob_pos_on_move_down.map(f!((p) model.screen_to_object_space(*p))); + pos_on_move <- glob_pos_on_move.map(f!((p) model.screen_to_object_space(*p))); + pos_diff_on_move <- pos_on_move_down.map2(&pos_on_down, |a, b| a - b); + pos_diff_on_down <- on_down.constant(Vector2(0.0, 0.0)); + pos_diff_on_up <- on_up_cleaning_phase.constant(Vector2(0.0, 0.0)); pos_diff <- any3(&pos_diff_on_move, &pos_diff_on_down, &pos_diff_on_up); + + eval frp.gap((t) model.borrow_mut().set_gap(*t)); } - let (_is_dragging, drag_diff) = self.init_drag(&on_up, &on_down, &pos_diff); - let is_trashing = self.init_trashing(cursor, &on_up, &drag_diff); + self.init_add_and_remove(); + let (is_dragging, drag_diff, no_drag) = + self.init_dragging(&on_up, &on_down, &target, &pos_diff); + let (is_trashing, trash_pointer_style) = self.init_trashing(&on_up, &drag_diff); + self.init_dropping(&on_up, &pos_on_move_down, &is_trashing); + let insert_pointer_style = self.init_insertion_points(&on_up, &pos_on_move, &is_dragging); frp::extend! { network - status <- bool(&on_up, &drag_diff).on_change(); - start <- status.on_true(); - target_on_start <- target.sample(&start); + cursor.frp.set_style_override <+ all [insert_pointer_style, trash_pointer_style].fold(); + on_down_drag <- on_down.gate_not(&no_drag); + // Do not pass events to children, as we don't know whether we are about to drag + // them yet. + eval on_down_drag ([] (event) event.stop_propagation()); + _eval <- no_drag.on_true().map3(&on_down, &target, |_, event, target| { + target.emit_event(event.payload.clone()); + }); + } + self + } - eval frp.remove((index) model.borrow_mut().trash_item_at(*index)); + fn init_insertion_points( + &self, + on_up: &frp::Stream>, + pos_on_move: &frp::Stream, + is_dragging: &frp::Stream, + ) -> frp::Stream> { + let on_up = on_up.clone_ref(); + let pos_on_move = pos_on_move.clone_ref(); + let is_dragging = is_dragging.clone_ref(); - // Re-parent the dragged element. - eval target_on_start([model, cursor] (t) model.borrow_mut().start_item_drag(&cursor, t)); - // frp.private.output.on_item_removed <+ on_item_inserted; + let frp = &self.frp; + let network = self.frp.network(); + let model = &self.model; + let model_borrowed = model.borrow(); - pos_non_trash <- pos_on_move.gate_not(&is_trashing); - insert_index <- pos_non_trash.map(f!((pos) model.borrow().insert_index(pos.x))).on_change(); - insert_index_on_drop <- insert_index.sample(&on_up).gate_not(&is_trashing); + frp::extend! { network + gaps <- model_borrowed.layout.on_resized.map(f_!(model.gaps())); + opt_index <- all_with7( + &frp.gap, + &gaps, + &pos_on_move, + &model.borrow().layout.on_resized, + &is_dragging, + &frp.enable_all_insertion_points, + &frp.enable_last_insertion_point, + f!([model] (gap, gaps, pos, size, is_dragging, enable_all, enable_last) { + let is_close_x = pos.x > -gap && pos.x < size.x + gap; + let is_close_y = pos.y > -gap && pos.y < size.y + gap; + let is_close = is_close_x && is_close_y; + let opt_gap = gaps.find(pos.x); + opt_gap.and_then(|gap| { + let last_gap = *gap == gaps.len() - 1; + let enabled = is_close && !is_dragging; + let enabled = enabled && (*enable_all || (*enable_last && last_gap)); + enabled.and_option_from(|| model.item_or_placeholder_index_to_index(gap)) + }) + }) + ); + index <= opt_index; + enabled <- opt_index.is_some(); + pointer_style <- enabled.then_constant(cursor::Style::plus()); + on_up_in_gap <- on_up.gate(&enabled); + insert_in_gap <- index.sample(&on_up_in_gap); + frp.private.output.request_new_item <+ insert_in_gap.map(|t| Response::gui(*t)); + } + pointer_style + } - eval insert_index ([model, cursor] (i) model.borrow_mut().add_insertion_point(&cursor, *i)); + /// Implementation of adding and removing items logic. + fn init_add_and_remove(&self) { + let model = &self.model; + let frp = &self.frp; + let network = self.frp.network(); - eval insert_index_on_drop ([cursor, model] (index) - model.borrow_mut().place_dragged_item(&cursor, *index) - ); + frp::extend! { network + push_ix <= frp.push.map(f!((item) model.push_cell(item))); + on_pushed <- push_ix.map(|ix| Response::api(*ix)); + frp.private.output.on_item_added <+ on_pushed; + + insert_ix <= frp.insert.map(f!(((index, item)) model.insert_cell(*index, item))); + on_inserted <- insert_ix.map(|ix| Response::api(*ix)); + frp.private.output.on_item_added <+ on_inserted; + + let on_item_removed = &frp.private.output.on_item_removed; + eval frp.remove([model, on_item_removed] (index) { + if let Some(item) = model.borrow_mut().trash_item_at(*index) { + on_item_removed.emit(Response::api((*index, Rc::new(RefCell::new(Some(item)))))); + } + }); } - self } /// Implementation of item dragging logic. See docs of this crate to learn more. - fn init_drag( + fn init_dragging( &self, on_up: &frp::Stream>, on_down: &frp::Stream>, + target: &frp::Stream, pos_diff: &frp::Stream, - ) -> (frp::Stream, frp::Stream) { + ) -> (frp::Stream, frp::Stream, frp::Stream) { + let model = &self.model; let on_up = on_up.clone_ref(); let on_down = on_down.clone_ref(); + let target = target.clone_ref(); let pos_diff = pos_diff.clone_ref(); let frp = &self.frp; let network = self.frp.network(); @@ -478,17 +519,27 @@ impl ListEditor { init_drag_not_disabled <- init_drag.gate_not(&drag_disabled); is_dragging <- bool(&on_up, &init_drag_not_disabled).on_change(); drag_diff <- pos_diff.gate(&is_dragging); + no_drag <- drag_disabled.gate_not(&is_dragging).on_change(); + + status <- bool(&on_up, &drag_diff).on_change(); + start <- status.on_true(); + target_on_start <- target.sample(&start); + let on_item_removed = &frp.private.output.on_item_removed; + eval target_on_start([model, on_item_removed] (t) { + if let Some((index, item)) = model.borrow_mut().start_item_drag(t) { + on_item_removed.emit(Response::gui((index, Rc::new(RefCell::new(Some(item)))))); + } + }); } - (is_dragging, drag_diff) + (status, drag_diff, no_drag) } /// Implementation of item trashing logic. See docs of this crate to learn more. fn init_trashing( &self, - cursor: &Cursor, on_up: &frp::Stream>, drag_diff: &frp::Stream, - ) -> frp::Stream { + ) -> (frp::Stream, frp::Stream>) { let on_up = on_up.clone_ref(); let drag_diff = drag_diff.clone_ref(); let model = &self.model; @@ -502,26 +553,66 @@ impl ListEditor { status <- drag_diff.map2(&required_offset, |t, m| t.y.abs() >= *m).on_change(); status_on_up <- on_up.constant(false); status_cleaning_phase <- any(&status, &status_on_up).on_change(); - cursor.frp.set_style <+ status_cleaning_phase.default_or(cursor::Style::trash()); + cursor_style <- status_cleaning_phase.then_constant(cursor::Style::trash()); on <- status.on_true(); perform <- on_up.gate(&status); eval_ on (model.collapse_all_placeholders()); - eval_ perform ([model, cursor] model.borrow_mut().trash_dragged_item(&cursor)); + eval_ perform (model.borrow_mut().trash_dragged_item()); + } + (status, cursor_style) + } + + /// Implementation of dropping items logic, including showing empty placeholders when the item + /// is dragged over a place where it could be dropped. + fn init_dropping( + &self, + on_up: &frp::Stream>, + pos_on_move: &frp::Stream, + is_trashing: &frp::Stream, + ) { + let pos_on_move = pos_on_move.clone_ref(); + let is_trashing = is_trashing.clone_ref(); + + let model = &self.model; + let frp = &self.frp; + let network = self.frp.network(); + let model_borrowed = model.borrow(); + + frp::extend! { network + center_points <- model_borrowed.layout.on_resized.map(f_!(model.center_points())); + insert_index <- pos_on_move.map2(¢er_points, f!((p, c) model.insert_index(p.x, c))); + insert_index <- insert_index.on_change(); + insert_index_on_drop <- insert_index.sample(on_up).gate_not(&is_trashing); + insert_index_not_trashing <- insert_index.gate_not(&is_trashing); + + on_stop_trashing <- is_trashing.on_false(); + insert_index_on_stop_trashing <- insert_index.sample(&on_stop_trashing); + update_insert_index <- any(&insert_index_not_trashing, &insert_index_on_stop_trashing); + eval update_insert_index ((i) model.borrow_mut().add_insertion_point(*i)); + + let on_item_added = &frp.private.output.on_item_added; + eval insert_index_on_drop ([model, on_item_added] (index) + if let Some(index) = model.borrow_mut().place_dragged_item(*index) { + on_item_added.emit(Response::gui(index)); + } + ); } - status } /// Initializes default FRP values. See docs of this crate to learn more. fn init_frp_values(self) -> Self { + self.frp.gap(10.0); self.frp.secondary_axis_drag_threshold(4.0); self.frp.primary_axis_no_drag_threshold(4.0); self.frp.primary_axis_no_drag_threshold_decay_time(1000.0); self.frp.thrashing_offset_ratio(1.0); + self.frp.enable_all_insertion_points(true); + self.frp.enable_last_insertion_point(true); self } pub fn push(&self, item: T) { - self.frp.push(Rc::new(item).downgrade()); + self.frp.push(Rc::new(RefCell::new(Some(item)))); } pub fn items(&self) -> Vec { @@ -529,7 +620,7 @@ impl ListEditor { } } -impl SharedModel { +impl SharedModel { fn screen_to_object_space(&self, screen_pos: Vector2) -> Vector2 { self.borrow().screen_to_object_space(screen_pos) } @@ -537,9 +628,54 @@ impl SharedModel { fn collapse_all_placeholders(&self) { self.borrow_mut().collapse_all_placeholders() } + + fn push(&self, item: T) -> Index { + self.borrow_mut().push(item) + } + + fn push_cell(&self, item: &Rc>>) -> Option { + let item = mem::take(&mut *item.borrow_mut()); + item.map(|item| self.push(item)) + } + + fn insert(&self, index: Index, item: T) -> Index { + self.borrow_mut().insert(index, item) + } + + fn insert_cell(&self, index: Index, item: &Rc>>) -> Option { + let item = mem::take(&mut *item.borrow_mut()); + item.map(|item| self.insert(index, item)) + } + + fn insert_index(&self, x: f32, center_points: &[f32]) -> ItemOrPlaceholderIndex { + self.borrow().insert_index(x, center_points) + } + + fn gaps(&self) -> Gaps { + self.borrow().gaps() + } + + fn center_points(&self) -> Vec { + self.borrow().center_points() + } + + fn item_or_placeholder_index_to_index(&self, ix: ItemOrPlaceholderIndex) -> Option { + self.borrow().item_or_placeholder_index_to_index(ix) + } +} + +#[derive(Clone, Debug, Default, Deref)] +pub struct Gaps { + gaps: Vec>, +} + +impl Gaps { + pub fn find(&self, x: f32) -> Option { + self.gaps.iter().position(|gap| gap.contains(&x)).map(|t| t.into()) + } } -impl Model { +impl Model { // FIXME: refactor and generalize fn screen_to_object_space(&self, screen_pos: Vector2) -> Vector2 { let scene = scene(); @@ -561,18 +697,41 @@ impl Model { self.items.iter().filter(|t| t.is_item()).count() } + fn set_gap(&mut self, gap: f32) { + self.gap = gap; + self.recompute_margins(); + } + /// Find an element by the provided display object reference. - fn item_index_of(&mut self, obj: &display::object::Instance) -> Option { - self.items.iter().enumerate().find(|t| t.1.cmp_item_display_object(obj)).map(|t| t.0.into()) + fn item_index_of( + &self, + obj: &display::object::Instance, + ) -> Option<(Index, ItemOrPlaceholderIndex)> { + self.items + .iter() + .enumerate() + .map(|(i, t)| (ItemOrPlaceholderIndex::from(i), t)) + .filter(|(_, t)| t.is_item()) + .enumerate() + .find(|(_, (_, t))| t.cmp_item_display_object(obj)) + .map(|(i1, (i2, _))| (i1, i2)) } /// Convert the item index to item or placeholder index. - fn index_to_item_or_placeholder_index(&mut self, ix: Index) -> Option { + fn index_to_item_or_placeholder_index(&self, ix: Index) -> Option { self.items.iter().enumerate().filter(|(_, item)| item.is_item()).nth(ix).map(|t| t.0.into()) } - fn item_or_placeholder_index_to_index(&mut self, ix: ItemOrPlaceholderIndex) -> Option { - self.items.iter().enumerate().filter(|(_, item)| item.is_item()).position(|t| t.0 == *ix) + fn item_or_placeholder_index_to_index(&self, ix: ItemOrPlaceholderIndex) -> Option { + if *ix == self.items.len() { + Some(self.len()) + } else { + self.items + .iter() + .enumerate() + .filter(|(_, item)| item.is_item()) + .position(|t| t.0 == *ix) + } } fn push(&mut self, item: T) -> Index { @@ -616,7 +775,7 @@ impl Model { ItemOrPlaceholder::Placeholder(Placeholder::Weak(_)) => {} ItemOrPlaceholder::Placeholder(Placeholder::Strong(_)) => first_elem = false, ItemOrPlaceholder::Item(t) => { - t.set_margin_left(if first_elem { 0.0 } else { GAP }); + t.set_margin_left(if first_elem { 0.0 } else { self.gap }); first_elem = false; } } @@ -631,7 +790,7 @@ impl Model { /// Get the margin at the given insertion point. If the insertion point is before the first /// item, the margin will be 0. fn margin_at(&self, index: ItemOrPlaceholderIndex) -> f32 { - self.first_item_index().map_or(0.0, |i| if index <= i { 0.0 } else { GAP }) + self.first_item_index().map_or(0.0, |i| if index <= i { 0.0 } else { self.gap }) } /// Retain only items and placeholders that did not collapse yet (both strong and weak ones). @@ -704,19 +863,15 @@ impl Model { /// be reused and scaled to cover the size of the dragged element. /// /// See docs of [`Self::start_item_drag_at`] for more information. - fn start_item_drag( - &mut self, - cursor: &Cursor, - target: &display::object::Instance, - ) -> Option { - let index = self.item_index_of(target); - if let Some(index) = index { - self.start_item_drag_at(cursor, index); + fn start_item_drag(&mut self, target: &display::object::Instance) -> Option<(Index, T)> { + let objs = target.rev_parent_chain(); + let tarrget_index = objs.into_iter().find_map(|t| self.item_index_of(&t)); + if let Some((index, index_or_placeholder_index)) = tarrget_index { + self.start_item_drag_at(index_or_placeholder_index).map(|item| (index, item)) } else { - warn!("Requested to drag a non-existent item.") + warn!("Could not find the item to drag."); + None } - // Fixme: this could break easily during refactoring. - index.and_then(|t| self.item_or_placeholder_index_to_index(t)) } /// Remove the selected item from the item list and mark it as an element being dragged. In the @@ -758,10 +913,11 @@ impl Model { /// │ A │ ┆ ┆ │ X │ ┆ ┆ │ B │ ------> │ A │ ┆ ╰─────╯ ┆ │ B │ /// ╰─────╯ ╰╌╌╌╌╯ ╰─────╯ ╰╌╌╌╌╯ ╰─────╯ ╰─────╯ ╰╌╌╌╌╌╌╌╌╌╌╌╌◀╌╯ ╰─────╯ /// ``` - fn start_item_drag_at(&mut self, cursor: &Cursor, index: ItemOrPlaceholderIndex) { - if let Some(item) = self.replace_item_with_placeholder(index) { - cursor.start_drag(item); - } + fn start_item_drag_at(&mut self, index: ItemOrPlaceholderIndex) -> Option { + self.replace_item_with_placeholder(index).map(|item| { + self.cursor.start_drag(item.clone_ref()); + item + }) } fn replace_item_with_placeholder(&mut self, index: ItemOrPlaceholderIndex) -> Option { @@ -788,8 +944,10 @@ impl Model { /// Prepare place for the dragged item by creating or reusing a placeholder and growing it to /// the dragged object size. - fn add_insertion_point(&mut self, cursor: &Cursor, index: ItemOrPlaceholderIndex) { - if let Some(item) = cursor.with_dragged_item_if_is::(|t| t.display_object().clone()) { + fn add_insertion_point(&mut self, index: ItemOrPlaceholderIndex) { + if let Some(item) = + self.cursor.with_dragged_item_if_is::(|t| t.display_object().clone()) + { self.collapse_all_placeholders_no_margin_update(); let item_size = item.computed_size().x + self.margin_at(index); let placeholder = self.get_merged_placeholder_at(index).unwrap_or_else(|| { @@ -807,24 +965,27 @@ impl Model { /// Place the currently dragged element in the given index. The item will be enclosed in the /// [`Item`] object, will handles its animation. See the documentation of /// [`ItemOrPlaceholder`] to learn more. - fn place_dragged_item(&mut self, cursor: &Cursor, index: ItemOrPlaceholderIndex) { - if let Some(element) = cursor.stop_drag_if_is::() { + fn place_dragged_item(&mut self, index: ItemOrPlaceholderIndex) -> Option { + if let Some(item) = self.cursor.stop_drag_if_is::() { self.collapse_all_placeholders_no_margin_update(); if let Some((index, placeholder)) = self.get_indexed_merged_placeholder_at(index) { placeholder.set_target_size(placeholder.computed_size().x); - element.update_xy(|t| t - placeholder.global_position().xy()); - self.items[index] = Item::new_from_placeholder(element, placeholder).into(); + item.update_xy(|t| t - placeholder.global_position().xy()); + self.items[index] = + Item::new_from_placeholder(item.clone_ref(), placeholder).into(); } else { - // This branch should never be reached, as when dragging an element we always create + // This branch should never be reached, as when dragging an item we always create // a placeholder for it (see the [`Self::add_insertion_point`] function). However, // in case something breaks, we want it to still provide the user with the correct // outcome. - self.items.insert(index, Item::new(element).into()); + self.items.insert(index, Item::new(item.clone_ref()).into()); warn!("An element was inserted without a placeholder. This should not happen."); } self.reposition_items(); + self.item_or_placeholder_index_to_index(index) } else { - warn!("Called function to insert dragged element, but no element is being dragged.") + warn!("Called function to insert dragged element, but no element is being dragged."); + None } } @@ -833,21 +994,22 @@ impl Model { self.reposition_items(); } - pub fn trash_dragged_item(&mut self, cursor: &Cursor) { + pub fn trash_dragged_item(&mut self) { warn!("Trash dragged item."); - if let Some(item) = cursor.stop_drag_if_is::() { + if let Some(item) = self.cursor.stop_drag_if_is::() { self.trash_item(item) } } - pub fn trash_item_at(&mut self, index: Index) { - if let Some(item_index) = self.index_to_item_or_placeholder_index(index) { - if let Some(item) = self.replace_item_with_placeholder(item_index) { - self.collapse_all_placeholders_no_margin_update(); - self.trash_item(item); - } + pub fn trash_item_at(&mut self, index: Index) -> Option { + if let Some(item_index) = self.index_to_item_or_placeholder_index(index) + && let Some(item) = self.replace_item_with_placeholder(item_index) { + self.collapse_all_placeholders_no_margin_update(); + self.trash_item(item.clone_ref()); + Some(item) } else { warn!("Wrong index."); + None } } @@ -874,13 +1036,31 @@ impl Model { centers } + fn gaps(&self) -> Gaps { + let mut gaps = Vec::new(); + gaps.push(f32::NEG_INFINITY..=0.0); + let mut fist_gap = true; + let mut current = 0.0; + for item in &self.items { + let start = current; + current += item.margin_left(); + if !fist_gap { + gaps.push(start..=current); + } + fist_gap = false; + current += item.target_size2(); + } + gaps.push(current..=f32::INFINITY); + Gaps { gaps } + } + /// The insertion point of the given vertical offset. - fn insert_index(&self, x: f32) -> ItemOrPlaceholderIndex { - self.center_points().iter().position(|t| x < *t).unwrap_or(self.items.len()).into() + fn insert_index(&self, x: f32, center_points: &[f32]) -> ItemOrPlaceholderIndex { + center_points.iter().position(|t| x < *t).unwrap_or(self.items.len()).into() } } -impl display::Object for ListEditor { +impl display::Object for ListEditor { fn display_object(&self) -> &display::object::Instance { &self.root } @@ -936,92 +1116,3 @@ mod trash { } use crate::placeholder::WeakPlaceholder; use trash::Trash; - - -// =================== -// === Entry Point === -// =================== - -pub mod glob { - use super::*; - ensogl_core::define_endpoints_2! { - Input { - } - Output { - } - } -} - -/// The example entry point. -#[entry_point] -#[allow(dead_code)] -pub fn main() { - let app = Application::new("root"); - let world = app.display.clone(); - let scene = &world.default_scene; - - let camera = scene.camera().clone_ref(); - let navigator = Navigator::new(scene, &camera); - - let vector_editor = ListEditor::::new(&app.cursor); - - - let shape1 = Circle().build(|t| { - t.set_size(Vector2::new(60.0, 100.0)) - .set_color(color::Rgba::new(0.0, 0.0, 0.0, 0.1)) - .set_inset_border(2.0) - .set_border_color(color::Rgba::new(0.0, 0.0, 0.0, 0.5)) - .keep_bottom_left_quarter(); - }); - let shape2 = RoundedRectangle(10.0).build(|t| { - t.set_size(Vector2::new(120.0, 100.0)) - .set_color(color::Rgba::new(0.0, 0.0, 0.0, 0.1)) - .set_inset_border(2.0) - .set_border_color(color::Rgba::new(0.0, 0.0, 0.0, 0.5)); - }); - let shape3 = RoundedRectangle(10.0).build(|t| { - t.set_size(Vector2::new(240.0, 100.0)) - .set_color(color::Rgba::new(0.0, 0.0, 0.0, 0.1)) - .set_inset_border(2.0) - .set_border_color(color::Rgba::new(0.0, 0.0, 0.0, 0.5)); - }); - - - let glob_frp = glob::Frp::new(); - let glob_frp_network = glob_frp.network(); - - let shape1_down = shape1.on_event::(); - frp::extend! { glob_frp_network - eval_ shape1_down ([] { - warn!("Shape 1 down"); - }); - new_item <- vector_editor.request_new_item.map(|_| { - let shape = RoundedRectangle(10.0).build(|t| { - t.set_size(Vector2::new(100.0, 100.0)) - .set_color(color::Rgba::new(0.0, 0.0, 0.0, 0.1)) - .set_inset_border(2.0) - .set_border_color(color::Rgba::new(0.0, 0.0, 0.0, 0.5)); - }); - Rc::new(shape) - }); - vector_editor.push <+ vector_editor.request_new_item.map2(&new_item, |_, item| - item.downgrade() - ); - } - - vector_editor.push(shape1); - vector_editor.push(shape2); - vector_editor.push(shape3); - - let root = display::object::Instance::new(); - root.set_size(Vector2::new(300.0, 100.0)); - root.add_child(&vector_editor); - world.add_child(&root); - - world.keep_alive_forever(); - mem::forget(app); - mem::forget(glob_frp); - mem::forget(navigator); - mem::forget(root); - mem::forget(vector_editor); -} diff --git a/lib/rust/ensogl/component/slider/src/lib.rs b/lib/rust/ensogl/component/slider/src/lib.rs index 4e28a2154103..731d9868c8e6 100644 --- a/lib/rust/ensogl/component/slider/src/lib.rs +++ b/lib/rust/ensogl/component/slider/src/lib.rs @@ -1,4 +1,11 @@ //! A slider UI component that allows adjusting a value through mouse interaction. +//! +//! # Important [WD] +//! Please note that the implementation is not finished yet. It was refactored to make the slider +//! implementation use the newest EnsoGL API, however, not all functionality was restored yet. As +//! this component is not used in the application yet, it is kept as is, but should be updated +//! before the real usage. In particualar, vertical sliders and sliders that behave as scrollbars +//! are not working correctly now. #![recursion_limit = "512"] // === Standard Linter Configuration === @@ -27,10 +34,11 @@ use ensogl_core::application; use ensogl_core::application::shortcut; use ensogl_core::application::tooltip; use ensogl_core::application::Application; +use ensogl_core::control::io::mouse; use ensogl_core::data::color; use ensogl_core::display; use ensogl_core::Animation; -use ensogl_text::formatting; +use ensogl_text::formatting::Weight; // ============== @@ -45,30 +53,30 @@ pub mod model; // === Constants === // ================= -/// Default slider precision when slider dragging is initiated. The precision indicates both how +/// Default slider resolution when slider dragging is initiated. The resolution indicates both how /// much the value is changed per pixel dragged and how many digits are displayed after the decimal. -const PRECISION_DEFAULT: f32 = 1.0; +const RESOLUTION_DEFAULT: f32 = 1.0; /// Default upper limit of the slider value. -const MAX_VALUE_DEFAULT: f32 = 1.0; +const MAX_VALUE_DEFAULT: f32 = 100.0; /// Default for the maximum number of digits after the decimal point that is displayed. const MAX_DISP_DECIMAL_PLACES_DEFAULT: usize = 8; /// Margin above/below the component within which vertical mouse movement will not affect slider -/// precision. +/// resolution. const PRECISION_ADJUSTMENT_MARGIN: f32 = 10.0; -/// The vertical mouse movement (in pixels) needed to change the slider precision by one step. -/// Dragging the mouse upward beyond the margin will decrease the precision by one step for every +/// The vertical mouse movement (in pixels) needed to change the slider resolution by one step. +/// Dragging the mouse upward beyond the margin will decrease the resolution by one step for every /// `STEP_SIZE` pixels and adjust the slider value more quickly. Dragging the mouse downwards will -/// increase the precision and change the value more slowly. +/// increase the resolution and change the value more slowly. const PRECISION_ADJUSTMENT_STEP_SIZE: f32 = 50.0; -/// The actual slider precision changes exponentially with each adjustment step. When the adjustment -/// is changed by one step, the slider's precision is changed to the next power of `STEP_BASE`. A -/// `STEP_BASE` of 10.0 results in the precision being powers of 10 for consecutive steps, e.g [1.0, -/// 10.0, 100.0, ...] when decreasing the precision and [0.1, 0.01, 0.001, ...] when increasing the -/// precision. +/// The actual slider resolution changes exponentially with each adjustment step. When the +/// adjustment is changed by one step, the slider's resolution is changed to the next power of +/// `STEP_BASE`. A `STEP_BASE` of 10.0 results in the resolution being powers of 10 for consecutive +/// steps, e.g [1.0, 10.0, 100.0, ...] when decreasing the resolution and [0.1, 0.01, 0.001, ...] +/// when increasing the resolution. const PRECISION_ADJUSTMENT_STEP_BASE: f32 = 10.0; -/// Limit the number of precision steps to prevent overflow or rounding to zero of the precision. +/// Limit the number of resolution steps to prevent overflow or rounding to zero of the resolution. const MAX_PRECISION_ADJUSTMENT_STEPS: usize = 8; -/// A pop-up is displayed whenever the slider's precision is changed. This is the duration for +/// A pop-up is displayed whenever the slider's resolution is changed. This is the duration for /// which the pop-up is visible. const PRECISION_ADJUSTMENT_POPUP_DURATION: f32 = 1000.0; /// The delay before an information tooltip is displayed when hovering over a slider component. @@ -99,37 +107,20 @@ pub enum LabelPosition { -// ========================== -// === Slider orientation === -// ========================== +// ================== +// === DragHandle === +// ================== -/// The orientation of the slider component. -#[derive(Clone, Copy, Debug, Default)] -pub enum SliderOrientation { - #[default] - /// The slider value is changed by dragging the slider horizontally. - Horizontal, - /// The slider value is changed by dragging the slider vertically. - Vertical, -} - - - -// ================================= -// === Slider position indicator === -// ================================= - -/// The type of element that indicates the slider's value along its length. -#[derive(Clone, Copy, Debug, Default)] -pub enum ValueIndicator { +/// Defines which part of the slider is being dragged by the user. In case the slider allows +/// dragging both of its ends and the middle of the track, this struct determines which part is +/// being dragged. +#[allow(missing_docs)] +#[derive(Debug, Copy, Clone, Default)] +pub enum DragHandle { + Start, + Middle, #[default] - /// A track is a bar that fills the slider as the value increases. The track is empty when the - /// slider's value is at the lower limit and filled when the value is at the upper limit. - Track, - /// A thumb is a small element that moves across the slider as the value changes. The thumb is - /// on the left/lower end of the slider when the slider's value is at the lower limit and on - /// the right/upper end of the slider when the value is at the upper limit. - Thumb, + End, } @@ -160,15 +151,19 @@ pub enum SliderLimit { /// Adaptive upper limit adjustment. fn adapt_upper_limit( - &(value, min, max, max_ext, upper_limit): &(f32, f32, f32, f32, SliderLimit), + value: f32, + min: f32, + max: f32, + max_ext: f32, + upper_limit: SliderLimit, ) -> f32 { if upper_limit == SliderLimit::Adaptive && value > max { let range = max_ext - min; let extend = value > max_ext; let shrink = value < min + range * ADAPTIVE_LIMIT_SHRINK_THRESHOLD; let max_ext = match (extend, shrink) { - (true, _) => adapt_upper_limit(&(value, min, max, min + range * 2.0, upper_limit)), - (_, true) => adapt_upper_limit(&(value, min, max, min + range * 0.5, upper_limit)), + (true, _) => adapt_upper_limit(value, min, max, min + range * 2.0, upper_limit), + (_, true) => adapt_upper_limit(value, min, max, min + range * 0.5, upper_limit), _ => max_ext, }; max_ext.max(max) // Do no set extended limit below original `max`. @@ -179,15 +174,19 @@ fn adapt_upper_limit( /// Adaptive lower limit adjustment. fn adapt_lower_limit( - &(value, min, max, min_ext, lower_limit): &(f32, f32, f32, f32, SliderLimit), + value: f32, + min: f32, + max: f32, + min_ext: f32, + lower_limit: SliderLimit, ) -> f32 { if lower_limit == SliderLimit::Adaptive && value < min { let range = max - min_ext; let extend = value < min_ext; let shrink = value > max - range * ADAPTIVE_LIMIT_SHRINK_THRESHOLD; let min_ext = match (extend, shrink) { - (true, _) => adapt_lower_limit(&(value, min, max, max - range * 2.0, lower_limit)), - (_, true) => adapt_lower_limit(&(value, min, max, max - range * 0.5, lower_limit)), + (true, _) => adapt_lower_limit(value, min, max, max - range * 2.0, lower_limit), + (_, true) => adapt_lower_limit(value, min, max, max - range * 0.5, lower_limit), _ => min_ext, }; min_ext.min(min) // Do no set extended limit above original `min`. @@ -216,16 +215,16 @@ fn value_limit_clamp( ensogl_core::define_endpoints_2! { Input { - /// Set the width of the slider component. - set_width(f32), - /// Set the height of the slider component. - set_height(f32), - /// Set the type of the slider's value indicator. - set_value_indicator(ValueIndicator), /// Set the color of the slider's value indicator. set_value_indicator_color(color::Lcha), /// Set the color of the slider's background. set_background_color(color::Lcha), + /// Allow dragging the start point of sliders track. + enable_start_track_drag(bool), + /// Allow dragging the end point of sliders track. + enable_end_track_drag(bool), + /// Allow dragging the sliders track by pressing in the middle of it. + enable_middle_track_drag(bool), /// Set the slider value. set_value(f32), /// Set the default value to reset a slider to when `ctrl` + `click`-ed. @@ -239,23 +238,23 @@ ensogl_core::define_endpoints_2! { /// Set the color of the text displaying the current value. set_value_text_color(color::Lcha), /// Set whether the slider's value text is hidden. - set_value_text_hidden(bool), - /// Set the default precision at which the slider operates. The slider's precision + show_value(bool), + /// Set the default resolution at which the slider operates. The slider's resolution /// determines by what increment the value will be changed on mouse movement. It also /// affects the number of digits after the decimal point displayed. - set_default_precision(f32), - /// The slider's precision can be adjusted by dragging the mouse in the vertical direction. + set_default_resolution(f32), + /// The slider's resolution can be adjusted by dragging the mouse in the vertical direction. /// The `adjustment_margin` defines a margin above/below the slider within which no - /// precision adjustment will be performed. + /// resolution adjustment will be performed. set_precision_adjustment_margin(f32), - /// The slider's precision can be adjusted by dragging the mouse in the vertical direction. + /// The slider's resolution can be adjusted by dragging the mouse in the vertical direction. /// The `adjustment_step_size` defines the distance the mouse must be moved to increase or - /// decrease the precision by one step. + /// decrease the resolution by one step. set_precision_adjustment_step_size(f32), - /// Set the maximum number of precision steps to prevent overflow or rounding to zero of the - /// precision increments. + /// Set the maximum number of resolution steps to prevent overflow or rounding to zero of the + /// resolution increments. set_max_precision_adjustment_steps(usize), - /// Set whether the precision adjustment mechansim is disabled. + /// Set whether the resolution adjustment mechansim is disabled. set_precision_adjustment_disabled(bool), /// Set the slider's label. The label will be displayed to the left of the slider's value /// display. @@ -267,12 +266,12 @@ ensogl_core::define_endpoints_2! { /// Set the position of the slider's label. set_label_position(LabelPosition), /// Set the orientation of the slider component. - set_orientation(SliderOrientation), + orientation(Axis2), /// Set a tooltip that pops up when the mose hovers over the component. set_tooltip(ImString), /// Set the delay of the tooltip showing after the mouse hovers over the component. set_tooltip_delay(f32), - /// A pop-up is displayed whenever the slider's precision is changed. This is the duration + /// A pop-up is displayed whenever the slider's resolution is changed. This is the duration /// for which the pop-up is visible. set_precision_popup_duration(f32), /// Set whether the slider is disabled. When disabled, the slider's value cannot be changed @@ -299,10 +298,12 @@ ensogl_core::define_endpoints_2! { width(f32), /// The component's height. height(f32), - /// The slider's value. - value(f32), - /// The slider's precision. - precision(f32), + /// The slider track's start position. + start_value(f32), + /// The slider track's end position. + end_value(f32), + /// The slider's resolution. + resolution(f32), /// The slider value's lower limit. This takes into account limit extension if an adaptive /// slider limit is set. min_value(f32), @@ -310,15 +311,11 @@ ensogl_core::define_endpoints_2! { /// slider limit is set. max_value(f32), /// Indicates whether the mouse is currently hovered over the component. - hovered(bool), - /// Indicates whether the slider is currently being dragged. dragged(bool), /// Indicates whether the slider is disabled. disabled(bool), /// Indicates whether the slider's value is being edited currently. editing(bool), - /// The orientation of the slider, either horizontal or vertical. - orientation(SliderOrientation), } } @@ -332,9 +329,9 @@ ensogl_core::define_endpoints_2! { /// slider in a horizontal direction changes the value, limited to a range between `min_value` and /// `max_value`. The selected value is displayed, and a track fills the slider proportional to the /// value within the specified range. Dragging the slider in a vertical direction adjusts the -/// precision of the slider. The precision affects the increments by which the value changes when +/// resolution of the slider. The resolution affects the increments by which the value changes when /// the mouse is moved. -#[derive(Debug, Deref, Clone)] +#[derive(Debug, Deref, Clone, CloneRef)] pub struct Slider { /// Public FRP api of the component. #[deref] @@ -357,13 +354,14 @@ impl Slider { fn init(self) -> Self { self.init_value_update(); - self.init_value_editing(); self.init_limit_handling(); self.init_value_display(); + + self.init_value_editing(); self.init_precision_popup(); self.init_information_tooltip(); self.init_component_layout(); - self.init_component_colors(); + // self.init_component_colors(); self.init_slider_defaults(); self } @@ -371,182 +369,160 @@ impl Slider { /// Initialize the slider value update FRP network. fn init_value_update(&self) { let network = self.frp.network(); + let frp = &self.frp; let input = &self.frp.input; let output = &self.frp.private.output; let model = &self.model; let scene = &self.app.display.default_scene; let mouse = &scene.mouse.frp_deprecated; let keyboard = &scene.keyboard.frp; - let component_events = &model.background.events_deprecated; - frp::extend! { network - - // === User input === + let ptr_down_any = model.background.on_event::(); + let ptr_up_any = scene.on_event::(); + let obj = model.display_object(); - component_click <- component_events.mouse_down_primary - .gate_not(&input.set_slider_disabled); - component_click <- component_click.gate_not(&output.editing); - slider_disabled_is_true <- input.set_slider_disabled.on_true(); - slider_editing_is_true <- output.editing.on_true(); - component_release <- any3( - &component_events.mouse_release_primary, - &slider_disabled_is_true, - &slider_editing_is_true, - ); - component_drag <- bool(&component_release, &component_click); - component_drag <- component_drag.gate_not(&input.set_slider_disabled); - component_drag <- component_drag.gate_not(&keyboard.is_control_down); - component_ctrl_click <- component_click.gate(&keyboard.is_control_down); - drag_start_pos <- mouse.position.sample(&component_click); - drag_end_pos <- mouse.position.gate(&component_drag); - drag_end_pos <- any2(&drag_end_pos, &drag_start_pos); - drag_delta <- all2(&drag_end_pos, &drag_start_pos).map(|(end, start)| end - start); - drag_delta_primary <- all2(&drag_delta, &input.set_orientation); - drag_delta_primary <- drag_delta_primary.map( |(delta, orientation)| - match orientation { - SliderOrientation::Horizontal => delta.x, - SliderOrientation::Vertical => delta.y, - } - ).on_change(); - mouse_position_click <- mouse.position.sample(&component_click); - mouse_position_drag <- mouse.position.gate(&component_drag); - mouse_position_click_or_drag <- any2(&mouse_position_click, &mouse_position_drag); - mouse_local <- mouse_position_click_or_drag.map( - f!([scene, model] (pos) scene.screen_to_object_space(&model.background, *pos)) + frp::extend! { network + ptr_down <- ptr_down_any.map(|e| e.button() == mouse::PrimaryButton).on_true(); + ptr_up <- ptr_up_any.map(|e| e.button() == mouse::PrimaryButton).on_true(); + pos <- mouse.position.map( + f!([scene, model] (p) scene.screen_to_object_space(model.display_object(), *p)) ); - mouse_local_secondary <- all2(&mouse_local, &input.set_orientation); - mouse_local_secondary <- mouse_local_secondary.map( |(offset, orientation)| - match orientation { - SliderOrientation::Horizontal => offset.y, - SliderOrientation::Vertical => offset.x, + + orientation_orth <- frp.orientation.map(|o| o.orthogonal()); + length <- all_with(&obj.on_resized, &frp.orientation, |size, dim| size.get_dim(dim)); + width <- all_with(&obj.on_resized, &orientation_orth, |size, dim| size.get_dim(dim)); + + start_value_on_ptr_down <- output.start_value.sample(&ptr_down); + end_value_on_ptr_down <- output.end_value.sample(&ptr_down); + + ptr_down <- ptr_down.gate_not(&frp.set_slider_disabled); + ptr_down <- ptr_down.gate_not(&output.editing); + on_disabled <- input.set_slider_disabled.on_true(); + on_editing <- output.editing.on_true(); + on_drag_start <- ptr_down.gate_not(&keyboard.is_control_down); + on_drag_stop <- any3(&ptr_up, &on_disabled, &on_editing); + output.dragged <+ bool(&on_drag_stop, &on_drag_start); + drag_start <- pos.sample(&on_drag_start); + drag_end <- pos.gate(&output.dragged).any2(&drag_start); + drag_delta <- all2(&drag_end, &drag_start).map(|(end, start)| end - start); + drag_delta1 <- all_with(&drag_delta, &frp.orientation, |t, d| t.get_dim(d)).on_change(); + prec_delta <- all_with(&drag_end, &orientation_orth, |t, d| t.get_dim(d)).on_change(); + + handle <- drag_start.map9( + &length, + &start_value_on_ptr_down, + &end_value_on_ptr_down, + &output.min_value, + &output.max_value, + &frp.enable_start_track_drag, + &frp.enable_middle_track_drag, + &frp.enable_end_track_drag, + |pos, length, start, end, min, max, enable_start, enable_middle, enable_end| { + match (enable_start, enable_middle, enable_end) { + (false, false, false) => None, + (true, false, false) => Some(DragHandle::Start), + (false, true, false) => Some(DragHandle::Middle), + (false, false, true) => Some(DragHandle::End), + (true, true, false) => { + let val_range = max - min; + let start_pos = start / val_range * length; + if pos.x < start_pos { Some(DragHandle::Start) } + else { Some(DragHandle::Middle) } + } + (true, false, true) => { + let val_range = max - min; + let mid_pos = (start + end) / 2.0 / val_range * length; + if pos.x < mid_pos { Some(DragHandle::Start) } + else { Some(DragHandle::End) } + } + (false, true, true) => { + let val_range = max - min; + let end_pos = end / val_range * length; + if pos.x < end_pos { Some(DragHandle::Middle) } + else { Some(DragHandle::End) } + } + (true, true, true) => { + let val_range = max - min; + let start_pos = start / val_range * length; + let end_pos = end / val_range * length; + if pos.x < start_pos { Some(DragHandle::Start) } + else if pos.x > end_pos { Some(DragHandle::End) } + else { Some(DragHandle::Middle) } + } + } } ); - output.hovered <+ bool(&component_events.mouse_out, &component_events.mouse_over); - output.dragged <+ component_drag; - - - // === Get slider value on drag start === - - value_reset <- input.set_default_value.sample(&component_ctrl_click); - value_on_click <- output.value.sample(&component_click); - value_on_click <- any2(&value_reset, &value_on_click); // === Precision calculation === - slider_length <- all3(&input.set_orientation, &input.set_width, &input.set_height); - slider_length <- slider_length.map( |(orientation, width, height)| - match orientation { - SliderOrientation::Horizontal => *width, - SliderOrientation::Vertical => *height, - } - ); - slider_length <- all3( - &slider_length, - &input.set_value_indicator, - &input.set_thumb_size + native_resolution <- all_with3(&length, &output.max_value, &output.min_value, + |len, max, min| (max - min) / len ); - slider_length <- slider_length.map(|(length, indicator, thumb_size)| - match indicator { - ValueIndicator::Thumb => length * (1.0 - thumb_size), - ValueIndicator::Track => *length, - } - ); - min_value_on_click <- output.min_value.sample(&component_click); - min_value_on_click <- any2(&min_value_on_click, &input.set_min_value); - max_value_on_click <- output.max_value.sample(&component_click); - max_value_on_click <- any2(&max_value_on_click, &input.set_max_value); - slider_range <- all2(&min_value_on_click, &max_value_on_click); - slider_range <- slider_range.map(|(min, max)| max - min); - prec_at_mouse_speed <- all2(&slider_length, &slider_range).map(|(l, r)| r / l); - - output.precision <+ prec_at_mouse_speed.sample(&component_click); - precision_adjustment_margin <- all4( - &input.set_width, - &input.set_height, - &input.set_precision_adjustment_margin, - &input.set_orientation, - ); - precision_adjustment_margin <- precision_adjustment_margin.map( - |(width, height, margin, orientation)| match orientation { - SliderOrientation::Horizontal => height / 2.0 + margin, - SliderOrientation::Vertical => width / 2.0 + margin, - } - ); - precision_offset_steps <- all3( - &mouse_local_secondary, - &precision_adjustment_margin, - &input.set_precision_adjustment_step_size, - ); - precision_offset_steps <- precision_offset_steps.map( - |(offset, margin, step_size)| { - let sign = offset.signum(); - // Calculate mouse y-position offset beyond margin. - let offset = offset.abs() - margin; - if offset < 0.0 { return None } // No adjustment if offset is within margin. - // Calculate number of steps and direction of the precision adjustment. - let steps = (offset / step_size).ceil() * sign; - match steps { - // Step 0 is over the component, which returns early. Make step 0 be the - // first adjustment step above the component (precision = 1.0). - steps if steps > 0.0 => Some(steps - 1.0), - steps => Some(steps), - } + non_native_resolution <- all_with5( + &width, + &frp.set_precision_adjustment_margin, + &prec_delta, + &frp.set_precision_adjustment_step_size, + &frp.set_max_precision_adjustment_steps, + |width, margin, prec_delta, step_size, max_steps| { + let prec_margin = width / 2.0 + margin; + let sign = prec_delta.signum() as i32; + let offset = prec_delta.abs() - prec_margin; + let level = min(*max_steps as i32, (offset / step_size).ceil() as i32) * sign; + (level != 0).as_some_from(|| { + let exp = if level > 0 { level - 1 } else { level }; + 10.0_f32.powf(exp as f32) + }) } ).on_change(); - precision_offset_steps <- all2( - &precision_offset_steps, - &input.set_max_precision_adjustment_steps, - ); - precision_offset_steps <- precision_offset_steps.map(|(step, max_step)| - step.map(|step| step.clamp(- (*max_step as f32), *max_step as f32)) - ); - precision <- all4( - &prec_at_mouse_speed, - &input.set_default_precision, - &precision_offset_steps, - &input.set_precision_adjustment_disabled, - ); - precision <- precision.map( - |(mouse_prec, step_prec, offset, disabled)| match (offset, disabled) { - // Adjust the precision by the number of offset steps. - (Some(offset), false) => - *step_prec * (PRECISION_ADJUSTMENT_STEP_BASE).pow(*offset), - // Set the precision for 1:1 track movement to mouse movement. - _ => *mouse_prec, - } - ); + resolution <- all_with(&non_native_resolution, &native_resolution, |t,s| t.unwrap_or(*s)); + output.resolution <+ resolution; // === Value calculation === - update_value <- bool(&component_release, &value_on_click); - value <- all3(&value_on_click, &precision, &drag_delta_primary); - value <- value.gate(&update_value); - value <- value.map(|(value, precision, delta)| value + delta * precision); - value <- any2(&input.set_value, &value); - // Snap the slider's value to the nearest precision increment. - value <- all2(&value, &precision); - value <- value.map(|(value, precision)| (value / precision).round() * precision); - value <- all5( - &value, - &input.set_min_value, - &input.set_max_value, - &input.set_lower_limit_type, - &input.set_upper_limit_type, - ).map(value_limit_clamp); - output.value <+ value; - output.precision <+ precision; - - model.value_animation.target <+ value; - small_value_step <- all2(&precision, &prec_at_mouse_speed); - small_value_step <- small_value_step.map(|(prec, threshold)| prec <= threshold); - value_adjust <- drag_delta_primary.map(|x| *x != 0.0); - prec_adjust <- precision.on_change(); - prec_adjust <- bool(&value_adjust, &prec_adjust); - skip_value_anim <- value.constant(()).gate(&small_value_step); - skip_value_anim <- skip_value_anim.gate(&value_adjust).gate_not(&prec_adjust); - model.value_animation.skip <+ skip_value_anim; + values <- drag_delta1.map5( + &handle, + &start_value_on_ptr_down, + &end_value_on_ptr_down, + &resolution, + |delta, handle, start_value, end_value, resolution| { + let diff = delta * resolution; + if let Some(handle) = handle { + match handle { + DragHandle::Start => (Some(start_value + diff), None), + DragHandle::End => (None, Some(end_value + diff)), + DragHandle::Middle => (Some(start_value + diff), Some(end_value + diff)) + } + } else { + (None, None) + } + }); + start_value <= values._0(); + end_value <= values._1(); + value <- any2(&frp.set_value, &end_value); + // value <- all5( + // &value, + // &frp.set_min_value, + // &frp.set_max_value, + // &frp.set_lower_limit_type, + // &frp.set_upper_limit_type, + // ).map(value_limit_clamp); + output.start_value <+ start_value; + output.end_value <+ value; + + + // === Value Reset === + + reset_value <- ptr_down.gate(&keyboard.is_control_down); + value_on_reset <- input.set_default_value.sample(&reset_value); + output.end_value <+ value_on_reset; + + + // === Value Animation === + model.start_value_animation.target <+ output.start_value; + model.end_value_animation.target <+ output.end_value; }; } @@ -558,35 +534,35 @@ impl Slider { let model = &self.model; frp::extend! { network - min_value <- all5( - &output.value, + min_value <- all_with5( + &output.end_value, &input.set_min_value, &input.set_max_value, &output.min_value, &input.set_lower_limit_type, - ); - min_value <- min_value.map(adapt_lower_limit).on_change(); + |a,b,c,d,e| adapt_lower_limit(*a,*b,*c,*d,*e) + ).on_change(); output.min_value <+ min_value; - max_value<- all5( - &output.value, + + max_value <- all_with5( + &output.end_value, &input.set_min_value, &input.set_max_value, &output.max_value, &input.set_upper_limit_type, - ); - max_value <- max_value.map(adapt_upper_limit).on_change(); + |a,b,c,d,e|adapt_upper_limit(*a,*b,*c,*d,*e) + ).on_change(); output.max_value <+ max_value; - overflow_lower <- all2(&output.value, &output.min_value).map(|(val, min)| val < min ); - overflow_upper <- all2(&output.value, &output.max_value).map(|(val, max)| val > max ); - overflow_lower <- overflow_lower.on_change(); - overflow_upper <- overflow_upper.on_change(); + overflow_lower <- all_with(&output.end_value, &min_value, |v, min| v < min).on_change(); + overflow_upper <- all_with(&output.end_value, &max_value, |v, max| v > max).on_change(); eval overflow_lower((v) model.set_overflow_lower_visible(*v)); eval overflow_upper((v) model.set_overflow_upper_visible(*v)); }; } - /// Initialize the value display FRP network. + /// Initialize the value display FRP network. Sets text to bold if the value is not the default + /// one and manages the value display on the slider. fn init_value_display(&self) { let network = self.frp.network(); let input = &self.frp.input; @@ -594,28 +570,28 @@ impl Slider { let model = &self.model; frp::extend! { network - eval input.set_value_text_hidden((v) model.set_value_text_hidden(*v)); - value <- output.value.gate_not(&input.set_value_text_hidden).on_change(); - precision <- output.precision.gate_not(&input.set_value_text_hidden).on_change(); - value_is_default <- all2(&value, &input.set_default_value).map(|(val, def)| val==def); - value_is_default_true <- value_is_default.on_true(); - value_is_default_false <- value_is_default.on_false(); - eval_ value_is_default_true(model.set_value_text_property(formatting::Weight::Normal)); - eval_ value_is_default_false(model.set_value_text_property(formatting::Weight::Bold)); - - value_text_left_right <- all3(&value, &precision, &input.set_max_disp_decimal_places); - value_text_left_right <- value_text_left_right.map(value_text_truncate_split); - value_text_left <- value_text_left_right._0(); - value_text_right <- value_text_left_right._1(); - model.value_text_left.set_content <+ value_text_left; - value_text_right_is_visible <- value_text_right.map(|t| t.is_some()).on_change(); - value_text_right <- value_text_right.gate(&value_text_right_is_visible); - model.value_text_right.set_content <+ value_text_right.unwrap(); - eval value_text_right_is_visible((v) model.set_value_text_right_visible(*v)); + eval input.show_value((v) model.show_value(*v)); + + value <- output.end_value.sampled_gate(&input.show_value); + default_value <- input.set_default_value.sampled_gate(&input.show_value); + is_default <- all_with(&value, &default_value, |val, def| val == def); + text_weight <- switch_constant(&is_default, Weight::Bold, Weight::Normal); + eval text_weight ((v) model.set_value_text_property(*v)); + + resolution <- output.resolution.sampled_gate(&input.show_value); + max_decimal_places <- input.set_max_disp_decimal_places.sampled_gate(&input.show_value); + text <- all_with3(&value, &resolution, &max_decimal_places, display_value); + text_left <- text._0(); + text_right <- text._1(); + model.value_text_left.set_content <+ text_left; + text_right_visible <- text_right.map(|t| t.is_some()).on_change(); + new_text_right <= text_right.gate(&text_right_visible); + model.value_text_right.set_content <+ new_text_right; + eval text_right_visible((v) model.set_value_text_right_visible(*v)); }; } - /// Initialize the precision pop-up FRP network. + /// Initialize the resolution pop-up FRP network. fn init_precision_popup(&self) { let network = self.frp.network(); let input = &self.frp.input; @@ -630,16 +606,16 @@ impl Slider { &component_events.mouse_release_primary, &component_events.mouse_down_primary ); - precision <- output.precision.on_change().gate(&component_drag); - model.tooltip.frp.set_style <+ precision.map(|precision| { + resolution <- output.resolution.on_change().gate(&component_drag); + model.tooltip.frp.set_style <+ resolution.map(|resolution| { let prec_text = format!( - "Precision: {precision:.MAX_DISP_DECIMAL_PLACES_DEFAULT$}", + "Precision: {resolution:.MAX_DISP_DECIMAL_PLACES_DEFAULT$}", ); let prec_text = prec_text.trim_end_matches('0'); let prec_text = prec_text.trim_end_matches('.'); tooltip::Style::set_label(prec_text.into()) }); - precision_changed <- precision.constant(()); + precision_changed <- resolution.constant(()); popup_anim.reset <+ precision_changed; popup_anim.start <+ precision_changed; popup_hide <- any2(&popup_anim.on_end, &component_events.mouse_release_primary); @@ -689,26 +665,31 @@ impl Slider { let min_limit_anim = Animation::new_non_init(network); let max_limit_anim = Animation::new_non_init(network); + let obj = model.display_object(); + frp::extend! { network - comp_size <- all2(&input.set_width, &input.set_height).map(|(w, h)| Vector2(*w,*h)); - eval comp_size((size) model.update_size(*size)); - eval input.set_value_indicator((i) model.set_value_indicator(i)); - output.width <+ input.set_width; - output.height <+ input.set_height; + eval obj.on_resized((size) model.update_size(*size)); min_limit_anim.target <+ output.min_value; max_limit_anim.target <+ output.max_value; - indicator_pos <- all3(&model.value_animation.value, &min_limit_anim.value, &max_limit_anim.value); - indicator_pos <- indicator_pos.map(|(value, min, max)| (value - min) / (max - min)); - indicator_pos <- all3(&indicator_pos, &input.set_thumb_size, &input.set_orientation); - eval indicator_pos((v) model.set_indicator_position(v)); + indicator_pos <- all_with4( + &model.start_value_animation.value, + &model.end_value_animation.value, + &min_limit_anim.value, + &max_limit_anim.value, + |start_value, end_value, min, max| { + let total = max - min; + ((start_value - min) / total, (end_value - min) / total) + }); + _eval <- all_with(&indicator_pos, &input.orientation, + f!((a, c) model.set_indicator_position(a.0, a.1, *c))); value_text_left_pos_x <- all3( &model.value_text_left.width, &model.value_text_dot.width, - &output.precision, + &output.resolution, ); value_text_left_pos_x <- value_text_left_pos_x.map( - // Center text if precision higher than 1.0 (integer display), else align to dot. + // Center text if resolution higher than 1.0 (integer display), else align to dot. |(left, dot, prec)| if *prec >= 1.0 {- *left / 2.0} else {- *left - *dot / 2.0} ); eval value_text_left_pos_x((x) model.value_text_left.set_x(*x)); @@ -722,28 +703,28 @@ impl Slider { eval model.value_text_edit.width((w) model.value_text_edit.set_x(-*w / 2.0)); eval model.value_text_edit.height((h) model.value_text_edit.set_y(*h / 2.0)); - overflow_marker_position <- all3( - &input.set_width, - &input.set_height, - &input.set_orientation, - ); - eval overflow_marker_position((p) model.set_overflow_marker_position(p)); - overflow_marker_shape <- all2(&model.value_text_left.height, &input.set_orientation); - eval overflow_marker_shape((s) model.set_overflow_marker_shape(s)); - - eval input.set_label_hidden((v) model.set_label_hidden(*v)); - model.label.set_content <+ input.set_label; - label_position <- all6( - &input.set_width, - &input.set_height, - &model.label.width, - &model.label.height, - &input.set_label_position, - &input.set_orientation, - ); - eval label_position((p) model.set_label_position(p)); - - output.orientation <+ input.set_orientation; + // overflow_marker_position <- all3( + // &input.set_width, + // &input.set_height, + // &input.orientation, + // ); + // eval overflow_marker_position((p) model.set_overflow_marker_position(p)); + // overflow_marker_shape <- all2(&model.value_text_left.height, &input.orientation); + // eval overflow_marker_shape((s) model.set_overflow_marker_shape(s)); + // + // eval input.set_label_hidden((v) model.set_label_hidden(*v)); + // model.label.set_content <+ input.set_label; + // label_position <- all6( + // &input.set_width, + // &input.set_height, + // &model.label.width, + // &model.label.height, + // &input.set_label_position, + // &input.orientation, + // ); + // eval label_position((p) model.set_label_position(p)); + + // output.orientation <+ input.orientation; }; } @@ -785,9 +766,9 @@ impl Slider { frp::extend! { network start_editing <- input.start_value_editing.gate_not(&output.disabled); - start_editing <- start_editing.gate_not(&input.set_value_text_hidden); - value_on_edit <- output.value.sample(&start_editing); - prec_on_edit <- output.precision.sample(&start_editing); + start_editing <- start_editing.gate(&input.show_value); + value_on_edit <- output.end_value.sample(&start_editing); + prec_on_edit <- output.resolution.sample(&start_editing); max_places_on_edit <- input.set_max_disp_decimal_places.sample(&start_editing); value_text_on_edit <- all3(&value_on_edit, &prec_on_edit, &max_places_on_edit); @@ -803,7 +784,7 @@ impl Slider { edit_success <- value_after_edit.map(|v| v.is_some()); value_after_edit <- value_after_edit.map(|v| v.unwrap_or_default()); prec_after_edit <- value_text_after_edit.map(|s| get_value_text_precision(s)); - prec_after_edit <- all2(&prec_after_edit, &input.set_default_precision); + prec_after_edit <- all2(&prec_after_edit, &input.set_default_resolution); prec_after_edit <- prec_after_edit.map(|(prec, default_prec)| prec.min(*default_prec)); value_after_edit <- all5( &value_after_edit, @@ -814,19 +795,19 @@ impl Slider { ).map(value_limit_clamp); output.editing <+ editing; - output.precision <+ prec_after_edit.gate(&edit_success); + output.resolution <+ prec_after_edit.gate(&edit_success); value_after_edit <- value_after_edit.gate(&edit_success); - output.value <+ value_after_edit; - model.value_animation.target <+ value_after_edit; + output.end_value <+ value_after_edit; + model.end_value_animation.target <+ value_after_edit; editing_event <- any2(&start_editing, &stop_editing); - editing <- all2(&editing, &output.precision).sample(&editing_event); + editing <- all2(&editing, &output.resolution).sample(&editing_event); eval editing((t) model.set_edit_mode(t)); }; } /// Initialize the compinent with default values. fn init_slider_defaults(&self) { - self.frp.set_default_precision(PRECISION_DEFAULT); + self.frp.set_default_resolution(RESOLUTION_DEFAULT); self.frp.set_precision_adjustment_margin(PRECISION_ADJUSTMENT_MARGIN); self.frp.set_precision_adjustment_step_size(PRECISION_ADJUSTMENT_STEP_SIZE); self.frp.set_max_precision_adjustment_steps(MAX_PRECISION_ADJUSTMENT_STEPS); @@ -835,6 +816,11 @@ impl Slider { self.frp.set_tooltip_delay(INFORMATION_TOOLTIP_DELAY); self.frp.set_precision_popup_duration(PRECISION_ADJUSTMENT_POPUP_DURATION); self.frp.set_thumb_size(THUMB_SIZE_DEFAULT); + self.show_value(true); + self.orientation(Axis2::X); + self.enable_start_track_drag(true); + self.enable_end_track_drag(true); + self.enable_middle_track_drag(true); } } @@ -891,10 +877,10 @@ impl application::View for Slider { // === Value text formatting === // ============================= -/// Rounds and truncates a floating point value to a specified precision. -fn value_text_truncate((value, precision, max_digits): &(f32, f32, usize)) -> String { - if *precision < 1.0 || *max_digits == 0 { - let digits = (-precision.log10()).ceil() as usize; +/// Rounds and truncates a floating point value to a specified resolution. +fn value_text_truncate((value, resolution, max_digits): &(f32, f32, usize)) -> String { + if *resolution < 1.0 || *max_digits == 0 { + let digits = (-resolution.log10()).ceil() as usize; let digits = digits.min(*max_digits); format!("{value:.digits$}") } else { @@ -902,19 +888,21 @@ fn value_text_truncate((value, precision, max_digits): &(f32, f32, usize)) -> St } } -/// Rounds a floating point value to a specified precision and provides two strings: one with the +/// Rounds a floating point value to a specified resolution and provides two strings: one with the /// digits left of the decimal point, and one optional with the digits right of the decimal point. -fn value_text_truncate_split( - (value, precision, max_digits): &(f32, f32, usize), +fn display_value( + value: &f32, + resolution: &f32, + max_digits: &usize, ) -> (ImString, Option) { - let text = value_text_truncate(&(*value, *precision, *max_digits)); + let text = value_text_truncate(&(*value, *resolution, *max_digits)); let mut text_iter = text.split('.'); let text_left = text_iter.next().map(|s| s.to_im_string()).unwrap_or_default(); let text_right = text_iter.next().map(|s| s.to_im_string()); (text_left, text_right) } -/// Get the precision of a string containing a decimal value. +/// Get the resolution of a string containing a decimal value. fn get_value_text_precision(text: &str) -> f32 { let mut text_iter = text.split('.').skip(1); let text_right_len = text_iter.next().map(|t| t.len()); @@ -953,42 +941,42 @@ mod tests { #[test] fn test_high_precision() { - let (left, right) = value_text_truncate_split(&(123.4567, 0.01, 8)); + let (left, right) = display_value(&123.4567, &0.01, &8); assert_eq!(left, "123".to_im_string()); assert_eq!(right, Some("46".to_im_string())); } #[test] fn test_low_precision() { - let (left, right) = value_text_truncate_split(&(123.4567, 10.0, 8)); + let (left, right) = display_value(&123.4567, &10.0, &8); assert_eq!(left, "123".to_im_string()); assert_eq!(right, None); } #[test] fn test_precision_is_zero() { - let (left, right) = value_text_truncate_split(&(123.4567, 0.0, 8)); + let (left, right) = display_value(&123.4567, &0.0, &8); assert_eq!(left, "123".to_im_string()); assert_eq!(right, Some("45670319".to_im_string())); } #[test] fn test_precision_is_nan() { - let (left, right) = value_text_truncate_split(&(123.4567, NAN, 8)); + let (left, right) = display_value(&123.4567, &NAN, &8); assert_eq!(left, "123".to_im_string()); assert_eq!(right, None); } #[test] fn test_value_is_nan() { - let (left, right) = value_text_truncate_split(&(NAN, 0.01, 8)); + let (left, right) = display_value(&NAN, &0.01, &8); assert_eq!(left, "NaN".to_im_string()); assert_eq!(right, None); } #[test] fn test_zero_decimal_places() { - let (left, right) = value_text_truncate_split(&(123.4567, 0.01, 0)); + let (left, right) = display_value(&123.4567, &0.01, &0); assert_eq!(left, "123".to_im_string()); assert_eq!(right, None); } diff --git a/lib/rust/ensogl/component/slider/src/model.rs b/lib/rust/ensogl/component/slider/src/model.rs index 5e9195514ebd..f02dd82b7326 100644 --- a/lib/rust/ensogl/component/slider/src/model.rs +++ b/lib/rust/ensogl/component/slider/src/model.rs @@ -4,8 +4,6 @@ use ensogl_core::display::shape::*; use ensogl_core::prelude::*; use crate::LabelPosition; -use crate::SliderOrientation; -use crate::ValueIndicator; use ensogl_core::application::Application; use ensogl_core::data::color; @@ -22,8 +20,6 @@ use ensogl_tooltip::Tooltip; // === Constants === // ================= -/// Size of the margin around the component's shapes for proper anti-aliasing. -const COMPONENT_MARGIN: f32 = 4.0; /// Default component width on initialization. const COMPONENT_WIDTH_DEFAULT: f32 = 200.0; /// Default component height on initialization. @@ -48,8 +44,6 @@ impl Background { fn new() -> Self { let width: Var = "input_size.x".into(); let height: Var = "input_size.y".into(); - let width = width - COMPONENT_MARGIN.px() * 2.0; - let height = height - COMPONENT_MARGIN.px() * 2.0; let shape = Rect((&width, &height)).corners_radius(&height / 2.0); let shape = shape.into(); Background { width, height, shape } @@ -73,62 +67,33 @@ mod background { /// Track shape that fills the slider proportional to the slider value. mod track { use super::*; - ensogl_core::shape! { above = [background]; pointer_events = false; alignment = center; - (style:Style, slider_fraction_horizontal:f32, slider_fraction_vertical:f32, color:Vector4) { + (style:Style, start: f32, end:f32, color:Vector4) { let Background{width,height,shape: background} = Background::new(); - let track = Rect(( - &width * &slider_fraction_horizontal, - &height * &slider_fraction_vertical, - )); - let track = track.translate_x(&width * (&slider_fraction_horizontal - 1.0) * 0.5); - let track = track.translate_y(&height * (&slider_fraction_vertical - 1.0) * 0.5); + let length = &end - &start; + let track = Rect((&width * &length, &height)); + let track = track.translate_x(&width * (length - 1.0) * 0.5 + &width * start); let track = track.intersection(background).fill(color); track.into() } } } -/// Thumb shape that moves along the slider proportional to the slider value. -mod thumb { - use super::*; - - ensogl_core::shape! { - above = [background]; - pointer_events = false; - alignment = center; - (style:Style, slider_fraction:f32, thumb_width:f32, thumb_height:f32, color:Vector4) { - let Background{width,height,shape: background} = Background::new(); - let thumb_width = &width * &thumb_width; - let thumb_height = &height * &thumb_height; - let thumb = Rect((&thumb_width, &thumb_height)); - let thumb = thumb.corners_radius(&thumb_height / 2.0); - let range_x = &width - &thumb_width; - let range_y = &height - &thumb_height; - let thumb = thumb.translate_x(-&range_x * 0.5 + &range_x * &slider_fraction); - let thumb = thumb.translate_y(-&range_y * 0.5 + &range_y * &slider_fraction); - let thumb = thumb.intersection(background).fill(color); - thumb.into() - } - } -} /// Triangle shape used as an overflow indicator on either side of the range. mod overflow { use super::*; ensogl_core::shape! { - above = [background, track, thumb]; + above = [background, track]; pointer_events = false; alignment = center; (style:Style, color:Vector4) { let width: Var = "input_size.x".into(); let height: Var = "input_size.y".into(); - let width = width - COMPONENT_MARGIN.px() * 2.0; - let height = height - COMPONENT_MARGIN.px() * 2.0; let color = style.get_color(theme::overflow::color); let triangle = Triangle(width, height); @@ -149,70 +114,66 @@ mod overflow { #[derive(Debug)] pub struct Model { /// Background element - pub background: background::View, + pub background: background::View, /// Slider track element that fills the slider proportional to the slider value. - pub track: track::View, - /// Slider thumb element that moves across the slider proportional to the slider value. - pub thumb: thumb::View, + pub track: track::View, /// Indicator for overflow when the value is below the lower limit. - pub overflow_lower: overflow::View, + pub overflow_lower: overflow::View, /// Indicator for overflow when the value is above the upper limit. - pub overflow_upper: overflow::View, + pub overflow_upper: overflow::View, /// Slider label that is shown next to the slider. - pub label: text::Text, + pub label: text::Text, /// Textual representation of the slider value, only part left of the decimal point. - pub value_text_left: text::Text, + pub value_text_left: text::Text, /// Decimal point that is used to display non-integer slider values. - pub value_text_dot: text::Text, + pub value_text_dot: text::Text, /// Textual representation of the slider value, only part right of the decimal point. - pub value_text_right: text::Text, + pub value_text_right: text::Text, /// Textual representation of the slider value used when editing the value as text input. - pub value_text_edit: text::Text, + pub value_text_edit: text::Text, /// Tooltip component showing either a tooltip message or slider precision changes. - pub tooltip: Tooltip, - /// Animation component that smoothly adjusts the slider value on large jumps. - pub value_animation: Animation, + pub tooltip: Tooltip, + /// Animation component that smoothly adjusts the slider start value on large jumps. + pub start_value_animation: Animation, + /// Animation component that smoothly adjusts the slider end value on large jumps. + pub end_value_animation: Animation, /// Root of the display object. - pub root: display::object::Instance, + pub root: display::object::Instance, + /// The display object containing the text value of the slider. + pub value: display::object::Instance, } impl Model { /// Create a new slider model. pub fn new(app: &Application, frp_network: &frp::Network) -> Self { let root = display::object::Instance::new(); + let value = display::object::Instance::new(); let label = app.new_view::(); let value_text_left = app.new_view::(); let value_text_dot = app.new_view::(); let value_text_right = app.new_view::(); let value_text_edit = app.new_view::(); let tooltip = Tooltip::new(app); - let value_animation = Animation::new_non_init(frp_network); + let start_value_animation = Animation::new_non_init(frp_network); + let end_value_animation = Animation::new_non_init(frp_network); let background = background::View::new(); let track = track::View::new(); - let thumb = thumb::View::new(); let overflow_lower = overflow::View::new(); let overflow_upper = overflow::View::new(); - let scene = &app.display.default_scene; let style = StyleWatch::new(&app.display.default_scene.style_sheet); root.add_child(&background); root.add_child(&track); root.add_child(&label); - root.add_child(&value_text_left); - root.add_child(&value_text_dot); - root.add_child(&value_text_right); + root.add_child(&value); + value.add_child(&value_text_left); + value.add_child(&value_text_dot); + value.add_child(&value_text_right); app.display.default_scene.add_child(&tooltip); - value_text_left.add_to_scene_layer(&scene.layers.label); - value_text_dot.add_to_scene_layer(&scene.layers.label); - value_text_right.add_to_scene_layer(&scene.layers.label); - value_text_edit.add_to_scene_layer(&scene.layers.label); - label.add_to_scene_layer(&scene.layers.label); - let model = Self { background, track, - thumb, overflow_lower, overflow_upper, label, @@ -221,8 +182,10 @@ impl Model { value_text_right, value_text_edit, tooltip, - value_animation, + start_value_animation, + end_value_animation, root, + value, }; model.init(style) } @@ -238,7 +201,6 @@ impl Model { self.label.set_font(text::font::DEFAULT_FONT); self.background.color.set(background_color.into()); self.track.color.set(track_color.into()); - self.thumb.color.set(track_color.into()); self.update_size(Vector2(COMPONENT_WIDTH_DEFAULT, COMPONENT_HEIGHT_DEFAULT)); self.value_text_dot.set_content("."); self @@ -246,16 +208,16 @@ impl Model { /// Set the component size. pub fn update_size(&self, size: Vector2) { - let margin = Vector2(COMPONENT_MARGIN * 2.0, COMPONENT_MARGIN * 2.0); - self.background.set_size(size + margin); - self.track.set_size(size + margin); - self.thumb.set_size(size + margin); + self.background.set_size(size); + self.track.set_size(size); + self.background.set_x(size.x / 2.0); + self.track.set_x(size.x / 2.0); + self.value.set_x(size.x / 2.0); } /// Set the color of the slider track or thumb. pub fn set_indicator_color(&self, color: &color::Lcha) { self.track.color.set(color::Rgba::from(color).into()); - self.thumb.color.set(color::Rgba::from(color).into()); } /// Set the color of the slider background. @@ -263,54 +225,30 @@ impl Model { self.background.color.set(color::Rgba::from(color).into()); } - /// Set whether the lower overfow marker is visible. - pub fn set_value_indicator(&self, indicator: &ValueIndicator) { - match indicator { - ValueIndicator::Track => { - self.root.add_child(&self.track); - self.root.remove_child(&self.thumb); - } - ValueIndicator::Thumb => { - self.root.add_child(&self.thumb); - self.root.remove_child(&self.track); - } - } - } - /// Set the position of the value indicator. - pub fn set_indicator_position( - &self, - (fraction, size, orientation): &(f32, f32, SliderOrientation), - ) { - self.thumb.slider_fraction.set(*fraction); + pub fn set_indicator_position(&self, start: f32, fraction: f32, orientation: Axis2) { match orientation { - SliderOrientation::Horizontal => { - self.track.slider_fraction_horizontal.set(fraction.clamp(0.0, 1.0)); - self.track.slider_fraction_vertical.set(1.0); - self.thumb.thumb_width.set(*size); - self.thumb.thumb_height.set(1.0); + Axis2::X => { + self.track.start.set(start.clamp(0.0, 1.0)); + self.track.end.set(fraction.clamp(0.0, 1.0)); } - SliderOrientation::Vertical => { - self.track.slider_fraction_horizontal.set(1.0); - self.track.slider_fraction_vertical.set(fraction.clamp(0.0, 1.0)); - self.thumb.thumb_width.set(1.0); - self.thumb.thumb_height.set(*size); + Axis2::Y => { + self.track.end.set(1.0); } } } /// Set the size and orientation of the overflow markers. - pub fn set_overflow_marker_shape(&self, (size, orientation): &(f32, SliderOrientation)) { - let margin = Vector2(COMPONENT_MARGIN * 2.0, COMPONENT_MARGIN * 2.0); - let size = Vector2(*size, *size) * OVERFLOW_MARKER_SIZE + margin; + pub fn set_overflow_marker_shape(&self, (size, orientation): &(f32, Axis2)) { + let size = Vector2(*size, *size) * OVERFLOW_MARKER_SIZE; self.overflow_lower.set_size(size); self.overflow_upper.set_size(size); match orientation { - SliderOrientation::Horizontal => { + Axis2::X => { self.overflow_lower.set_rotation_z(std::f32::consts::FRAC_PI_2); self.overflow_upper.set_rotation_z(-std::f32::consts::FRAC_PI_2); } - SliderOrientation::Vertical => { + Axis2::Y => { self.overflow_lower.set_rotation_z(std::f32::consts::PI); self.overflow_upper.set_rotation_z(0.0); } @@ -320,17 +258,17 @@ impl Model { /// Set the position of the overflow markers. pub fn set_overflow_marker_position( &self, - (comp_width, comp_height, orientation): &(f32, f32, SliderOrientation), + (comp_width, comp_height, orientation): &(f32, f32, Axis2), ) { match orientation { - SliderOrientation::Horizontal => { + Axis2::X => { let pos_x = comp_width / 2.0 - comp_height / 4.0; self.overflow_lower.set_x(-pos_x); self.overflow_lower.set_y(0.0); self.overflow_upper.set_x(pos_x); self.overflow_upper.set_y(0.0); } - SliderOrientation::Vertical => { + Axis2::Y => { let pos_y = comp_height / 2.0 - comp_width / 4.0; self.overflow_lower.set_x(0.0); self.overflow_lower.set_y(-pos_y); @@ -367,19 +305,19 @@ impl Model { f32, f32, LabelPosition, - SliderOrientation, + Axis2, ), ) { let label_position_x = match orientation { - SliderOrientation::Horizontal => match position { + Axis2::X => match position { LabelPosition::Inside => -comp_width / 2.0 + comp_height / 2.0, LabelPosition::Outside => -comp_width / 2.0 - comp_height / 2.0 - lab_width, }, - SliderOrientation::Vertical => -lab_width / 2.0, + Axis2::Y => -lab_width / 2.0, }; let label_position_y = match orientation { - SliderOrientation::Horizontal => lab_height / 2.0, - SliderOrientation::Vertical => match position { + Axis2::X => lab_height / 2.0, + Axis2::Y => match position { LabelPosition::Inside => comp_height / 2.0 - comp_width / 2.0, LabelPosition::Outside => comp_height / 2.0 + comp_width / 2.0 + lab_height, }, @@ -388,15 +326,11 @@ impl Model { } /// Set whether the slider value text is hidden. - pub fn set_value_text_hidden(&self, hidden: bool) { - if hidden { - self.root.remove_child(&self.value_text_left); - self.root.remove_child(&self.value_text_dot); - self.root.remove_child(&self.value_text_right); + pub fn show_value(&self, visible: bool) { + if visible { + self.root.add_child(&self.value); } else { - self.root.add_child(&self.value_text_left); - self.root.add_child(&self.value_text_dot); - self.root.add_child(&self.value_text_right); + self.root.remove_child(&self.value); } } @@ -411,21 +345,19 @@ impl Model { /// Set whether the value is being edited. This hides the value display and shows a text editor /// field to enter a new value. - pub fn set_edit_mode(&self, (editing, precision): &(bool, f32)) { + pub fn set_edit_mode(&self, (editing, _precision): &(bool, f32)) { if *editing { - self.root.remove_child(&self.value_text_left); - self.root.remove_child(&self.value_text_dot); - self.root.remove_child(&self.value_text_right); + self.root.remove_child(&self.value); self.root.add_child(&self.value_text_edit); self.value_text_edit.deprecated_focus(); self.value_text_edit.add_cursor_at_front(); self.value_text_edit.cursor_select_to_text_end(); } else { - self.root.add_child(&self.value_text_left); - if *precision < 1.0 { - self.root.add_child(&self.value_text_dot); - self.root.add_child(&self.value_text_right); - } + self.root.add_child(&self.value); + // if *precision < 1.0 { + // self.root.add_child(&self.value_text_dot); + // self.root.add_child(&self.value_text_right); + // } self.root.remove_child(&self.value_text_edit); self.value_text_edit.deprecated_defocus(); self.value_text_edit.remove_all_cursors(); @@ -435,11 +367,11 @@ impl Model { /// Set whether the value display decimal point and the text right of it are visible. pub fn set_value_text_right_visible(&self, enabled: bool) { if enabled { - self.root.add_child(&self.value_text_dot); - self.root.add_child(&self.value_text_right); + self.value.add_child(&self.value_text_dot); + self.value.add_child(&self.value_text_right); } else { - self.root.remove_child(&self.value_text_dot); - self.root.remove_child(&self.value_text_right); + self.value.remove_child(&self.value_text_dot); + self.value.remove_child(&self.value_text_right); } } diff --git a/lib/rust/ensogl/component/text/src/component/text.rs b/lib/rust/ensogl/component/text/src/component/text.rs index 1436c42836e6..09e47ae0522a 100644 --- a/lib/rust/ensogl/component/text/src/component/text.rs +++ b/lib/rust/ensogl/component/text/src/component/text.rs @@ -740,11 +740,17 @@ impl TextModel { let glyph_system = RefCell::new(glyph_system); let buffer = buffer::Buffer::new(buffer::BufferModel::new()); let layer = CloneRefCell::new(scene.layers.main.clone_ref()); - let lines = Lines::new(Self::new_line_helper( + + let default_size = buffer.formatting.font_size().default.value; + let first_line = Self::new_line_helper( &app.display.default_scene.frp.frame_time, &display_object, - buffer.formatting.font_size().default.value, - )); + default_size, + ); + first_line.set_baseline((-default_size).round()); + first_line.skip_baseline_animation(); + + let lines = Lines::new(first_line); let width_dirty = default(); let height_dirty = default(); let shaped_lines = default(); diff --git a/lib/rust/ensogl/core/src/animation/loops.rs b/lib/rust/ensogl/core/src/animation/loops.rs index b13ea5fccb84..d129fa16c8a7 100644 --- a/lib/rust/ensogl/core/src/animation/loops.rs +++ b/lib/rust/ensogl/core/src/animation/loops.rs @@ -171,6 +171,7 @@ crate::define_endpoints_2! { on_frame_start(Duration), on_before_animations(TimeInfo), on_after_animations(TimeInfo), + on_before_layout(TimeInfo), on_before_rendering(TimeInfo), frame_end(TimeInfo), } @@ -202,6 +203,11 @@ pub fn on_after_animations() -> enso_frp::Sampler { LOOP_REGISTRY.with(|registry| registry.on_after_animations.clone_ref()) } +/// Fires before the layout is performed. +pub fn on_before_layout() -> enso_frp::Sampler { + LOOP_REGISTRY.with(|registry| registry.on_before_layout.clone_ref()) +} + /// Fires before the rendering is performed. pub fn on_before_rendering() -> enso_frp::Sampler { LOOP_REGISTRY.with(|registry| registry.on_before_rendering.clone_ref()) @@ -302,8 +308,9 @@ fn on_frame_closure( let on_frame_start = output.on_frame_start.clone_ref(); let on_before_animations = output.on_before_animations.clone_ref(); let on_after_animations = output.on_after_animations.clone_ref(); + let on_before_layout = output.on_before_layout.clone_ref(); + let on_before_rendering = output.on_before_rendering.clone_ref(); let frame_end = output.frame_end.clone_ref(); - let output = output.clone_ref(); let before_animations = before_animations.clone_ref(); let animations = animations.clone_ref(); let _profiler = profiler::start_debug!(profiler::APP_LIFETIME, "@on_frame"); @@ -316,8 +323,9 @@ fn on_frame_closure( .then(move || fixed_fps_sampler.borrow_mut().run(time_info, |t| animations.run_all(t))) .then(move || on_after_animations.emit(time_info)) .then(move || frame_end.emit(time_info)) + .then(move || on_before_layout.emit(time_info)) .then(move || { - output.on_before_rendering.emit(time_info); + on_before_rendering.emit(time_info); drop(_profiler); }) .schedule(); diff --git a/lib/rust/ensogl/core/src/control/io/mouse/event.rs b/lib/rust/ensogl/core/src/control/io/mouse/event.rs index 094f3c221e9f..27e7f82011d0 100644 --- a/lib/rust/ensogl/core/src/control/io/mouse/event.rs +++ b/lib/rust/ensogl/core/src/control/io/mouse/event.rs @@ -54,7 +54,6 @@ where JsEvent: AsRef { /// Constructor. pub fn new(js_event: JsEvent, shape: Shape) -> Self { - js_event.as_ref().prevent_default(); let js_event = Some(js_event); let event_type = default(); Self { js_event, shape, event_type } @@ -175,6 +174,11 @@ where JsEvent: AsRef self.js_event.as_ref().map(|t| t.as_ref().ctrl_key()).unwrap_or_default() } + /// Prevent the default action of the event. + pub fn prevent_default(&self) { + self.js_event.as_ref().map(|t| t.as_ref().prevent_default()); + } + /// Convert the event to a different type. No checks will be performed during this action. pub fn unchecked_convert_to( self, @@ -186,6 +190,27 @@ where JsEvent: AsRef } } +// =============== +// === Filters === +// =============== + +type FanMouseEvent = crate::event::Event>; + +/// Indicates whether the primary mouse button was pressed when the event was triggered. +pub fn is_primary(event: &FanMouseEvent) -> bool { + event.button() == mouse::PrimaryButton +} + +/// Indicates whether the primary mouse button was pressed when the event was triggered. +pub fn is_middle(event: &FanMouseEvent) -> bool { + event.button() == mouse::MiddleButton +} + +/// Indicates whether the primary mouse button was pressed when the event was triggered. +pub fn is_secondary(event: &FanMouseEvent) -> bool { + event.button() == mouse::SecondaryButton +} + // ============== @@ -225,13 +250,6 @@ define_events! { // - https://developer.mozilla.org/en-US/docs/Web/API/Element/mouseover_event // - https://developer.mozilla.org/en-US/docs/Web/API/Element/mouseup_event // - https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event - // - // ## Preventing default - // - // To avoid triggerring any builtin bevavior of the browser, we call [`preventDefault`] on all - // mouse events. - // - // [`preventDefault`]: https://developer.mozilla.org/en-US/docs/Web/API/Event/preventDefault /// The [`Down`] event is fired at an element when a button on a pointing device (such as a /// mouse or trackpad) is pressed while the pointer is inside the element. diff --git a/lib/rust/ensogl/core/src/data/dirty.rs b/lib/rust/ensogl/core/src/data/dirty.rs index 9a020147b38a..06709da8b4a1 100644 --- a/lib/rust/ensogl/core/src/data/dirty.rs +++ b/lib/rust/ensogl/core/src/data/dirty.rs @@ -67,7 +67,6 @@ pub mod traits { fn unset(&mut self); } - // === Arity-1 Operations === /// Abstraction for dirty flags which can perform a dirty check by providing a single argument. @@ -88,6 +87,12 @@ pub mod traits { fn unset(&mut self, arg: &Self::Arg); } + /// Abstraction for dirty flags which can swap the dirtiness state of two elements. Does not + /// trigger any updates. If you want to trigger the update, use `unset` and `check` instead. + #[allow(missing_docs)] + pub trait HasSwap1: HasArg { + fn swap(&mut self, a: Self::Arg, b: Self::Arg); + } // === Shared Global Operations === @@ -132,6 +137,13 @@ pub mod traits { fn unset(&self, arg: &Self::Arg); } + /// Abstraction for dirty flags which can swap the dirtiness state of two elements. Does not + /// trigger any updates. If you want to trigger the update, use `unset` and `check` instead. + #[allow(missing_docs)] + pub trait SharedHasSwap1: HasArg { + fn swap(&self, a: Self::Arg, b: Self::Arg); + } + // === Type Aliases === /// Trait alias for bounds required by all dirty flags. @@ -254,21 +266,26 @@ impl HasSet1 for Flag { impl HasUnset0 for Flag { fn unset(&mut self) { - trace!("Unsetting."); self.data.unset() } } -impl HasUnset1 for Flag -where Arg: Display -{ +impl HasUnset1 for Flag { fn unset(&mut self, arg: &Self::Arg) { - trace!("Unsetting {arg}."); self.data.unset(arg) } } +// === Swap === + +impl HasSwap1 for Flag { + fn swap(&mut self, a: Self::Arg, b: Self::Arg) { + self.data.swap(a, b) + } +} + + // ================== // === RefCellFlag === @@ -367,14 +384,20 @@ impl SharedHasUnset0 for RefCellFlag { } } -impl SharedHasUnset1 for RefCellFlag -where Arg: Display -{ +impl SharedHasUnset1 for RefCellFlag { fn unset(&self, arg: &Self::Arg) { self.data.borrow_mut().unset(arg) } } +// === Swap === + +impl SharedHasSwap1 for RefCellFlag { + fn swap(&self, a: Self::Arg, b: Self::Arg) { + self.data.borrow_mut().swap(a, b) + } +} + // ================== @@ -463,9 +486,7 @@ impl SharedHasUnset0 for SharedFlag { } } -impl SharedHasUnset1 for SharedFlag -where Arg: Display -{ +impl SharedHasUnset1 for SharedFlag { fn unset(&self, arg: &Self::Arg) { self.rc.unset(arg) } @@ -711,6 +732,24 @@ impl HasUnset1 for SetData { } } +impl HasSwap1 for SetData { + fn swap(&mut self, a: Item, b: Item) { + let a_dirty = self.set.contains(&a); + let b_dirty = self.set.contains(&b); + match (a_dirty, b_dirty) { + (true, false) => { + self.set.remove(&a); + self.set.insert(b); + } + (false, true) => { + self.set.remove(&b); + self.set.insert(a); + } + _ => {} + } + } +} + impl Display for SetData { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{:?}", self.set) diff --git a/lib/rust/ensogl/core/src/debug/stats.rs b/lib/rust/ensogl/core/src/debug/stats.rs index 9cc515ce732d..0f2401ff808f 100644 --- a/lib/rust/ensogl/core/src/debug/stats.rs +++ b/lib/rust/ensogl/core/src/debug/stats.rs @@ -143,7 +143,7 @@ impl FramedStatsData { /// Clean the per-frame statistics, such as the per-frame number of draw calls. This function /// should be called before any rendering calls were made. fn reset_per_frame_statistics(&mut self) { - self.stats_data.draw_calls = default(); + self.stats_data.draw_calls.clear(); self.stats_data.shader_compile_count = 0; self.stats_data.data_upload_count = 0; self.stats_data.data_upload_size = 0; diff --git a/lib/rust/ensogl/core/src/display/navigation/navigator/events.rs b/lib/rust/ensogl/core/src/display/navigation/navigator/events.rs index 8be201e456c8..7e8b6ddfa5c4 100644 --- a/lib/rust/ensogl/core/src/display/navigation/navigator/events.rs +++ b/lib/rust/ensogl/core/src/display/navigation/navigator/events.rs @@ -239,6 +239,10 @@ impl NavigatorEvents { let listener = self.mouse_manager.on_wheel.add(move |event: &mouse::Wheel| { if let Some(data) = data.upgrade() { if event.ctrl_key() { + // Prevent zoom event to be handed to the browser. This avoids browser scaling + // being applied to the whole IDE, thus we need to do this always when ctrl is + // pressed. + event.prevent_default(); let position = data.mouse_position(); let zoom_speed = data.zoom_speed(); let movement = Vector2::new(event.delta_x() as f32, -event.delta_y() as f32); diff --git a/lib/rust/ensogl/core/src/display/object/event.rs b/lib/rust/ensogl/core/src/display/object/event.rs index e989ec486a60..a6894a3dbe99 100644 --- a/lib/rust/ensogl/core/src/display/object/event.rs +++ b/lib/rust/ensogl/core/src/display/object/event.rs @@ -37,12 +37,13 @@ pub enum State { #[allow(missing_docs)] #[derive(Clone, CloneRef, Debug)] pub struct SomeEvent { - pub data: frp::AnyData, - state: Rc>, + pub data: frp::AnyData, + state: Rc>, + current_target: Rc>>, /// Indicates whether the event participates in the capturing phase. - pub captures: Rc>, + pub captures: Rc>, /// Indicates whether the event participates in the bubbling phase. - pub bubbles: Rc>, + pub bubbles: Rc>, } impl SomeEvent { @@ -50,9 +51,10 @@ impl SomeEvent { pub fn new(target: Option, payload: T) -> Self { let event = Event::new(target, payload); let state = event.state.clone_ref(); + let current_target = event.current_target.clone_ref(); let captures = Rc::new(Cell::new(true)); let bubbles = Rc::new(Cell::new(true)); - Self { data: frp::AnyData::new(event), state, captures, bubbles } + Self { data: frp::AnyData::new(event), state, current_target, captures, bubbles } } /// The [`State]` of the event. @@ -69,6 +71,12 @@ impl SomeEvent { pub fn set_bubbling(&self, value: bool) { self.bubbles.set(value); } + + /// Set the current target of the event. This is internal function and should not be used + /// directly. + pub(crate) fn set_current_target(&self, target: Option<&Instance>) { + self.current_target.replace(target.map(|t| t.downgrade())); + } } impl Default for SomeEvent { @@ -113,9 +121,10 @@ impl Debug for Event { #[derivative(Default(bound = "T: Default"))] pub struct EventData { #[deref] - pub payload: T, - target: Option, - state: Rc>, + pub payload: T, + target: Option, + current_target: Rc>>, + state: Rc>, } impl Debug for EventData { @@ -130,7 +139,8 @@ impl Debug for EventData { impl Event { fn new(target: Option, payload: T) -> Self { let state = default(); - let data = Rc::new(EventData { payload, target, state }); + let current_target = Rc::new(RefCell::new(target.clone())); + let data = Rc::new(EventData { payload, target, current_target, state }); Self { data } } @@ -152,6 +162,18 @@ impl Event { pub fn target(&self) -> Option { self.data.target.as_ref().and_then(|t| t.upgrade()) } + + /// The current target for the event, as the event traverses the display object hierarchy. It + /// always refers to the element to which the event handler has been attached, as opposed to + /// [`Self::target`], which identifies the element on which the event occurred and which may be + /// its descendant. + /// + /// # Important Note + /// The value of [`Self::current_target`] is only available while the event is being handled. If + /// store the event in a variable and read this property later, the value will be [`None`]. + pub fn current_target(&self) -> Option { + self.data.current_target.borrow().as_ref().and_then(|t| t.upgrade()) + } } diff --git a/lib/rust/ensogl/core/src/display/object/instance.rs b/lib/rust/ensogl/core/src/display/object/instance.rs index 2bb60206e77d..7cdb66f00bb7 100644 --- a/lib/rust/ensogl/core/src/display/object/instance.rs +++ b/lib/rust/ensogl/core/src/display/object/instance.rs @@ -1144,10 +1144,7 @@ pub struct ChildIndex(usize); // ============= /// The main display object structure. Read the docs of [this module](self) to learn more. -#[derive(Derivative)] -#[derive(CloneRef, Deref, From)] -#[derivative(Clone(bound = ""))] -#[derivative(Default(bound = ""))] +#[derive(Clone, CloneRef, Default, Deref, From)] #[repr(transparent)] pub struct Instance { def: InstanceDef, @@ -1163,9 +1160,7 @@ pub struct Instance { /// not caught by rustc yet: https://github.com/rust-lang/rust/issues/57965). This struct allows the /// implementation to be written as [`self.display_object().def.add_child(child)`] instead, which /// will fail to compile after renaming the function in [`InstanceDef`]. -#[derive(Derivative)] -#[derive(CloneRef, Deref)] -#[derivative(Clone(bound = ""))] +#[derive(Clone, CloneRef, Deref)] #[repr(transparent)] pub struct InstanceDef { rc: Rc, @@ -1284,9 +1279,7 @@ impl Display for InstanceDef { // ==================== /// Weak display object instance. -#[derive(Derivative)] -#[derivative(Clone(bound = ""))] -#[derivative(Debug(bound = ""))] +#[derive(Debug, Clone, CloneRef)] pub struct WeakInstance { weak: Weak, } @@ -1404,17 +1397,34 @@ impl ParentBind { fn parent(&self) -> Option { self.parent.upgrade() } + + // Drop this [`ParentBind`] using provided borrows for its parent and its removed child entry. + // This allows clearing the parent children in a batch more efficiently. + fn drop_with_removed_element( + mut self, + parent: &InstanceDef, + removed_children_entry: WeakInstance, + ) { + self.notify_on_drop(parent, removed_children_entry); + // The list is already maintained. Drop the bind without doing it again. + mem::forget(self); + } + + fn notify_on_drop(&mut self, parent: &InstanceDef, removed_children_entry: WeakInstance) { + debug_assert!(parent.downgrade() == self.parent); + parent.dirty.modified_children.unset(&self.child_index); + if let Some(child) = removed_children_entry.upgrade() { + child.dirty.new_parent.set(); + } + parent.dirty.removed_children.set(removed_children_entry); + } } impl Drop for ParentBind { fn drop(&mut self) { if let Some(parent) = self.parent() { if let Some(weak_child) = parent.children.borrow_mut().remove(&self.child_index) { - parent.dirty.modified_children.unset(&self.child_index); - if let Some(child) = weak_child.upgrade() { - child.dirty.new_parent.set(); - parent.dirty.removed_children.set(weak_child); - } + self.notify_on_drop(&parent, weak_child); } } } @@ -1457,6 +1467,13 @@ impl SharedParentBind { self.data.borrow().as_ref().and_then(|t| t.parent().map(|s| (s, t.child_index))) } + fn matches(&self, parent: &WeakInstance, child_index: ChildIndex) -> bool { + self.data + .borrow() + .as_ref() + .map_or(false, |t| t.child_index == child_index && &t.parent == parent) + } + fn child_index(&self) -> Option { self.data.borrow().as_ref().map(|t| t.child_index) } @@ -1838,8 +1855,9 @@ impl Model { #[profile(Detail)] pub fn update(&self, scene: &Scene) { self.refresh_layout(); - let origin0 = Matrix4::identity(); - self.update_with_origin(scene, origin0, false, false, None); + let parent_origin = + self.parent().map_or(Matrix4::identity(), |parent| parent.transformation_matrix()); + self.update_with_origin(scene, parent_origin, false, false, None); } /// Update the display object tree transformations based on the parent object origin. See docs @@ -1924,7 +1942,7 @@ impl Model { } if !self.children.borrow().is_empty() { debug_span!("Updating all children.").in_scope(|| { - let children = self.children.borrow().clone(); + let children = self.children.borrow(); children.values().for_each(|weak_child| { weak_child.upgrade().for_each(|child| { child.update_with_origin( @@ -2044,15 +2062,146 @@ impl InstanceDef { children.into_iter().for_each(|child| self.add_child(child.display_object())); } - fn replace_children(&self, children: impl IntoIterator) { - self.remove_all_children(); - self.add_children(children); + /// Replace children with object from the provided list. Objects that are already children of + /// this instance will be moved to the new position. Objects that are not children of this + /// instance will change their parent and will be inserted in the right position. + /// + /// This method avoids unnecessary dirty flag updates and is more efficient than removing all + /// children and adding them again. Children that only swapped their position will be marked + /// as modified, but not as having a new parent. Children that are already under the right index + /// will not be marked as modified. + /// + /// Has no effect if the provided list matches the current children list, as long as the + /// internal child indices were already sequential starting from 0. If that's not the case, + /// the children will be marked as updated. + /// + /// NOTE: If the list contain duplicated objects (instances that are clones of the same ref), + /// the behavior is undefined. It will however not cause any memory unsafety and all objects + /// will remain in some valid state. + fn replace_children(&self, new_children: &[T]) { + let this_weak = self.downgrade(); + let mut children_borrow = self.children.borrow_mut(); + let num_children_before = children_borrow.len(); + + let mut pushed_out_children = false; + let mut added_children = 0; + let mut next_free_index = new_children.len().max(*self.next_child_index.get()); + let starting_free_index = next_free_index; + + // Update child indices of existing children, maintain their dirty flags. + for (index, child) in new_children.iter().enumerate() { + let child = child.display_object(); + let new_child_index = ChildIndex(index); + + let mut bind_borrow = child.parent_bind.data.borrow_mut(); + let same_parent_bind = bind_borrow.as_mut().filter(|bind| bind.parent == this_weak); + + let free_index = match same_parent_bind { + Some(bind) => { + // The child is already a child of this parent. Update its index. + + if bind.child_index == new_child_index { + // The child is already at its destination index. No need to update it. + continue; + } + + // Move the child to its destination index. In case the newly taken spot was + // occupied, use a swap. The occupied entry will later be moved to the spot + // freed by this element. + let old_index = bind.child_index; + bind.child_index = new_child_index; + + // If the old index was higher than the starting number of children, it means + // that this element was previously pushed out by a swap. We are reusing it, but + // not cleaning up the space it occupied. The cleanup is instead deferred. + pushed_out_children |= *old_index >= starting_free_index; + + old_index + } + None => { + added_children += 1; + // This was not a child of this instance, so it needs to be added as one. Move + // it from its existing parent to this one. + drop(bind_borrow); + drop(child.take_parent_bind()); + let new_parent_bind = + ParentBind { parent: this_weak.clone(), child_index: new_child_index }; + child.set_parent_bind(new_parent_bind); + self.dirty.removed_children.unset(&child.downgrade()); + let free_index = ChildIndex(next_free_index); + next_free_index += 1; + free_index + } + }; + + // If there already was a child present at the destination index, swap them. That child + // will be either maintained in future iterations or deleted. + // + // Note that we want to always attempt BTreeMap insertions before deletions, so we can + // avoid unnecessary tree structure manipulations. When inserting to previously occupied + // element, the tree structure is not modified. + self.dirty.modified_children.swap(free_index, new_child_index); + self.dirty.modified_children.set(new_child_index); + let child_at_dest = children_borrow.insert(new_child_index, child.downgrade()); + if let Some(child_at_dest) = child_at_dest { + if let Some(strong) = child_at_dest.upgrade() { + let mut bind = strong.parent_bind.data.borrow_mut(); + let bind = bind.as_mut().expect("Child should always have a parent bind."); + bind.child_index = free_index; + children_borrow.insert(free_index, child_at_dest); + // In case we just put a child in its final spot, we have to mark as modified. + // If it ends up being deleted, the flag will be cleared anyway. + if bind.parent == this_weak { + self.dirty.modified_children.set(free_index); + } + } + } + } + + // At this point, all children that were in the new list are in the right position. We + // only need to remove the children that were not in the new list. All of them are still + // in the children list, and their indices are past the inserted indices. + let has_stale_indices = pushed_out_children || starting_free_index > new_children.len(); + let retained_children = new_children.len() - added_children; + let has_elements_to_remove = retained_children < num_children_before; + let need_cleanup = has_elements_to_remove || has_stale_indices; + + if need_cleanup { + let mut binds_to_drop = SmallVec::<[(ParentBind, WeakInstance); 8]>::new(); + + // Drop the instances that were removed from the children list. Note that the drop may + // cause the instance to be removed from the children list, so we need to drop the + // instances without holding to borrows. + children_borrow.retain(|index, weak_instance| { + let to_retain = **index < new_children.len(); + if !to_retain { + let instance = weak_instance.upgrade(); + // We do not immediately remove old keys containing pushed-out children when + // they have been reinserted to their appropriate position. To avoid treating + // them as removed, we have to filter them out. Only children that are at their + // correct position should be removed. + let instance = instance.filter(|i| i.parent_bind.child_index() == Some(*index)); + let bind = instance.and_then(|i| i.take_parent_bind()); + let bind_with_instance = bind.map(|bind| (bind, weak_instance.clone())); + binds_to_drop.extend(bind_with_instance); + } + to_retain + }); + + drop(children_borrow); + + self.next_child_index.set(ChildIndex(new_children.len())); + for (bind, weak) in binds_to_drop { + bind.drop_with_removed_element(self, weak) + } + } } fn register_child(&self, child: &InstanceDef) -> ChildIndex { let index = self.next_child_index.get(); self.next_child_index.set(ChildIndex(*index + 1)); self.children.borrow_mut().insert(index, child.downgrade()); + self.dirty.removed_children.unset(&child.downgrade()); self.dirty.modified_children.set(index); index } @@ -2068,7 +2217,7 @@ impl InstanceDef { /// Get reversed parent chain of this display object (`[root, child_of root, ..., parent, /// self]`). The last item is this object. - fn rev_parent_chain(&self) -> Vec { + pub fn rev_parent_chain(&self) -> Vec { let mut vec = default(); Self::build_rev_parent_chain(&mut vec, Some(self.clone_ref().into())); vec @@ -2266,6 +2415,7 @@ impl InstanceDef { if event.captures.get() { for object in &rev_parent_chain { if !event.is_cancelled() { + event.set_current_target(Some(object)); object.event.capturing_fan.emit(&event.data); } else { break; @@ -2281,12 +2431,14 @@ impl InstanceDef { if event.bubbles.get() { for object in rev_parent_chain.iter().rev() { if !event.is_cancelled() { + event.set_current_target(Some(object)); object.event.bubbling_fan.emit(&event.data); } else { break; } } } + event.set_current_target(None); } fn new_event(&self, payload: T) -> event::SomeEvent @@ -2556,6 +2708,12 @@ pub trait LayoutOps: Object { self.display_object().def.layout.size.get() } + /// Get the margin of the object. Please note that this is user-set margin, not the computed + /// one. + fn margin(&self) -> Vector2 { + self.display_object().def.layout.margin.get() + } + /// Modify the size of the object. By default, the size is set to hug the children. You can set /// the size either to a fixed pixel value, a percentage parent container size, or to a fraction /// of the free space left after placing siblings with fixed sizes. @@ -2728,10 +2886,11 @@ pub trait LayoutOps: Object { right: impl Into, bottom: impl Into, left: impl Into, - ) { + ) -> &Self { let horizontal = SideSpacing::new(left.into(), right.into()); let vertical = SideSpacing::new(bottom.into(), top.into()); self.display_object().layout.margin.set(Vector2(horizontal, vertical)); + self } /// Set padding of all sides of the object. Padding is the free space inside the object. @@ -3303,7 +3462,9 @@ impl Model { } else { let child_pos = child.position().get_dim(x); let child_size = child.computed_size().get_dim(x); - max_x = max_x.max(child_pos + child_size); + let child_margin = + child.layout.margin.get_dim(x).end.resolve_pixels_or_default(); + max_x = max_x.max(child_pos + child_size + child_margin); } } else { has_grow_children = true; @@ -3321,10 +3482,11 @@ impl Model { let to_grow = child.layout.grow_factor.get_dim(x) > 0.0; if let Some(alignment) = *child.layout.alignment.get().get_dim(x) && !to_grow { let child_size = child.computed_size().get_dim(x); - let remaining_size = base_size - child_size; - let aligned_position = remaining_size * alignment.normalized(); - child.set_position_dim(x, aligned_position); - max_x = max_x.max(aligned_position + child_size); + let child_margin = child.layout.margin.get_dim(x).resolve_pixels_or_default(); + let remaining_size = base_size - child_size - child_margin.total(); + let aligned_x = remaining_size * alignment.normalized() + child_margin.start; + child.set_position_dim(x, aligned_x); + max_x = max_x.max(aligned_x + child_size + child_margin.end); } } if hug_children { @@ -3339,16 +3501,26 @@ impl Model { for child in &children { let to_grow = child.layout.grow_factor.get_dim(x) > 0.0; if to_grow { - let current_child_size = child.computed_size().get_dim(x); - if self_size != current_child_size || child.should_refresh_layout() { - child.layout.computed_size.set_dim(x, self_size); + let can_shrink = child.layout.shrink_factor.get_dim(x) > 0.0; + let child_size = child.computed_size().get_dim(x); + let child_margin = child.layout.margin.get_dim(x).resolve_pixels_or_default(); + let mut desired_child_size = self_size - child_margin.total(); + + if !can_shrink { + let child_static_size = child.resolve_size_static_values(x, self_size); + desired_child_size = desired_child_size.max(child_static_size); + } + + if desired_child_size != child_size || child.should_refresh_layout() { + child.layout.computed_size.set_dim(x, desired_child_size); child.refresh_layout_internal(x, PassConfig::DoNotHugDirectChildren); } - if child.layout.alignment.get().get_dim(x).is_some() { - // If child is set to grow, there will never be any leftover space to align - // it. It should always be positioned at 0.0 relative to its parent. - child.set_position_dim(x, 0.0); + if let Some(alignment) = *child.layout.alignment.get().get_dim(x) { + let remaining_size = self_size - desired_child_size - child_margin.total(); + let aligned_x = + remaining_size * alignment.normalized() + child_margin.start; + child.set_position_dim(x, aligned_x); } } } @@ -3954,7 +4126,7 @@ pub trait ObjectOps: Object + AutoLayoutOps + LayoutOps { self.display_object().def.add_children(children); } - fn replace_children(&self, children: impl IntoIterator) { + fn replace_children(&self, children: &[T]) { self.display_object().def.replace_children(children); } @@ -4114,10 +4286,245 @@ mod hierarchy_tests { node1.add_child(&node3); assert_eq!(node3.my_index(), Some(ChildIndex(2))); + node1.add_child(&node2); + assert_eq!(node2.my_index(), Some(ChildIndex(3))); + node1.remove_child(&node3); assert_eq!(node3.my_index(), None); } + struct ReplaceChildrenTest { + root: Instance, + nodes: [Instance; N], + } + + impl ReplaceChildrenTest { + fn new() -> (Instance, [Instance; N], Self) { + let root = Instance::new_named("root"); + let nodes = std::array::from_fn(|n| { + Instance::new_named(Box::leak(format!("{n}").into_boxed_str())) + }); + let nodes_clone = std::array::from_fn(|i| nodes[i].clone()); + (root.clone(), nodes_clone, Self { root, nodes }) + } + + fn prepare_clear_flags(&self) { + self.root.dirty.modified_children.unset_all(); + self.root.dirty.removed_children.unset_all(); + for node in self.nodes.iter() { + node.dirty.new_parent.unset(); + } + } + + #[track_caller] + fn new_node_parents(&self, node_has_new_parent: [bool; N]) { + let status = std::array::from_fn(|n| self.nodes[n].dirty.new_parent.take().check()); + assert_eq!(status, node_has_new_parent); + } + + #[track_caller] + fn children(&self, expected: &[&'static str]) { + let names = self.root.children().iter().map(|node| node.name).collect_vec(); + assert_eq!(names, expected); + } + + #[track_caller] + fn child_indices(&self, expected: &[usize]) { + let indices = self + .root + .children() + .iter() + .map(|node| node.my_index().expect("No index").0) + .collect_vec(); + assert_eq!(indices, expected); + } + + #[track_caller] + fn modified_children(&self, indices: &[usize]) { + let modified = self.root.dirty.modified_children.take().set; + let mut modified = modified.into_iter().map(|idx| idx.0).collect_vec(); + modified.sort(); + assert_eq!(modified, indices); + } + + #[track_caller] + fn removed_children(&self, instances: &[T]) { + let mut removed = self.root.dirty.removed_children.take().set; + for instance in instances { + let instance = instance.display_object(); + let is_removed = removed.remove(&instance.downgrade()); + assert!(is_removed, "Missing removed instance: {:?}", instance.name); + } + assert!( + removed.is_empty(), + "Unexpected removed children: {:?}", + removed.iter().map(|i| i.upgrade().map(|i| i.name)).collect_vec() + ); + } + + #[track_caller] + fn no_removed_children(&self) { + self.removed_children::(&[]); + } + } + + #[test] + fn replace_children_identical_test() { + let (root, nodes, assert) = ReplaceChildrenTest::<5>::new(); + root.replace_children(&nodes); + assert.children(&["0", "1", "2", "3", "4"]); + assert.child_indices(&[0, 1, 2, 3, 4]); + assert.modified_children(&[0, 1, 2, 3, 4]); + assert.removed_children::(&[]); + assert.new_node_parents([true, true, true, true, true]); + + root.replace_children(&nodes); + assert.children(&["0", "1", "2", "3", "4"]); + assert.child_indices(&[0, 1, 2, 3, 4]); + assert.modified_children(&[]); + assert.removed_children::(&[]); + assert.new_node_parents([false, false, false, false, false]); + + root.replace_children::(&[]); + assert.child_indices(&[]); + assert.modified_children(&[]); + assert.removed_children(&nodes); + assert.new_node_parents([true, true, true, true, true]); + } + + #[test] + fn replace_children_subset_test() { + let (root, nodes, assert) = ReplaceChildrenTest::<5>::new(); + root.replace_children(&nodes); + assert.prepare_clear_flags(); + + root.replace_children(&nodes[0..4]); + assert.children(&["0", "1", "2", "3"]); + assert.child_indices(&[0, 1, 2, 3]); + assert.modified_children(&[]); + assert.removed_children(&[&nodes[4]]); + assert.new_node_parents([false, false, false, false, true]); + + + root.replace_children(&nodes[1..4]); + assert.children(&["1", "2", "3"]); + assert.child_indices(&[0, 1, 2]); + assert.modified_children(&[0, 1, 2]); + assert.removed_children(&[&nodes[0]]); + assert.new_node_parents([true, false, false, false, false]); + + root.replace_children(&nodes[2..5]); + assert.children(&["2", "3", "4"]); + assert.child_indices(&[0, 1, 2]); + assert.modified_children(&[0, 1, 2]); + assert.removed_children(&[&nodes[1]]); + assert.new_node_parents([false, true, false, false, true]); + + root.replace_children(&nodes); + assert.children(&["0", "1", "2", "3", "4"]); + assert.modified_children(&[0, 1, 2, 3, 4]); + assert.no_removed_children(); + assert.new_node_parents([true, true, false, false, false]); + + root.replace_children(&[&nodes[0], &nodes[2], &nodes[4]]); + assert.children(&["0", "2", "4"]); + assert.child_indices(&[0, 1, 2]); + assert.modified_children(&[1, 2]); + assert.removed_children(&[&nodes[1], &nodes[3]]); + assert.new_node_parents([false, true, false, true, false]); + + root.replace_children(&nodes); + assert.children(&["0", "1", "2", "3", "4"]); + assert.modified_children(&[1, 2, 3, 4]); + assert.no_removed_children(); + assert.new_node_parents([false, true, false, true, false]); + } + + #[test] + fn replace_children_shuffle_test() { + let (root, nodes, assert) = ReplaceChildrenTest::<5>::new(); + root.replace_children(&nodes); + assert.prepare_clear_flags(); + + root.replace_children(&[&nodes[2..=4], &nodes[0..=1]].concat()); + assert.children(&["2", "3", "4", "0", "1"]); + assert.child_indices(&[0, 1, 2, 3, 4]); + assert.modified_children(&[0, 1, 2, 3, 4]); + assert.no_removed_children(); + assert.new_node_parents([false, false, false, false, false]); + + root.replace_children(&nodes[0..=3]); + assert.children(&["0", "1", "2", "3"]); + assert.child_indices(&[0, 1, 2, 3]); + assert.modified_children(&[0, 1, 2, 3]); + assert.removed_children(&[&nodes[4]]); + assert.new_node_parents([false, false, false, false, true]); + + root.replace_children(&[&nodes[4..=4], &nodes[1..=3], &nodes[0..=0]].concat()); + assert.children(&["4", "1", "2", "3", "0"]); + assert.child_indices(&[0, 1, 2, 3, 4]); + assert.modified_children(&[0, 4]); + assert.no_removed_children(); + assert.new_node_parents([false, false, false, false, true]); + + root.replace_children(&nodes[1..=3]); + assert.children(&["1", "2", "3"]); + assert.child_indices(&[0, 1, 2]); + assert.modified_children(&[0, 1, 2]); + assert.removed_children(&[&nodes[0], &nodes[4]]); + assert.new_node_parents([true, false, false, false, true]); + + root.replace_children(&nodes[1..=4]); + assert.children(&["1", "2", "3", "4"]); + assert.child_indices(&[0, 1, 2, 3]); + assert.modified_children(&[3]); + assert.no_removed_children(); + assert.new_node_parents([false, false, false, false, true]); + } + + #[test] + fn replace_children_keep_flags_test() { + let (root, nodes, assert) = ReplaceChildrenTest::<5>::new(); + root.replace_children(&nodes); + assert.prepare_clear_flags(); + + assert.children(&["0", "1", "2", "3", "4"]); + root.dirty.modified_children.set(ChildIndex(1)); + root.replace_children(&[&nodes[0..=2], &nodes[4..=4]].concat()); + assert.children(&["0", "1", "2", "4"]); + root.replace_children(&nodes); + assert.children(&["0", "1", "2", "3", "4"]); + assert.modified_children(&[1, 3, 4]); + assert.no_removed_children(); + assert.new_node_parents([false, false, false, true, false]); + } + + fn replace_children_replace_all_test() { + let (root, nodes, assert) = ReplaceChildrenTest::<5>::new(); + root.replace_children(&nodes); + assert.prepare_clear_flags(); + + let new_nodes: [_; 10] = std::array::from_fn(|_| Instance::new()); + root.replace_children(&new_nodes); + assert_eq!(root.children(), &new_nodes); + assert.child_indices(&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + assert.modified_children(&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + assert.removed_children(&nodes); + assert.new_node_parents([true, true, true, true, true]); + + new_nodes.iter().enumerate().for_each(|(i, node)| { + assert_eq!(node.my_index(), Some(ChildIndex(i))); + }); + nodes.iter().for_each(|node| assert_eq!(node.my_index(), None)); + + root.replace_children(&nodes); + assert.children(&["0", "1", "2", "3", "4"]); + assert.child_indices(&[0, 1, 2, 3, 4]); + assert.modified_children(&[0, 1, 2, 3, 4]); + assert.removed_children(&new_nodes); + assert.new_node_parents([true, true, true, true, true]); + } + #[test] fn transformation_test() { let world = World::new(); @@ -5873,6 +6280,32 @@ mod layout_tests { }); } + #[test] + fn test_manual_layout_margin_alignment() { + let test = TestFlatChildren3::new(); + test.root.set_size((10.0, 10.0)); + test.node1.allow_grow().set_margin_trbl(1.0, 2.0, 3.0, 4.0).set_alignment_left_bottom(); + test.node2 + .set_size((3.0, 3.0)) + .set_margin_trbl(1.0, 2.0, 3.0, 4.0) + .set_alignment_left_center(); + test.node3 + .set_size((3.0, 3.0)) + .set_margin_trbl(1.0, 2.0, 3.0, 4.0) + .set_alignment_right_bottom(); + + test.run(|| { + test.assert_root_computed_size(10.0, 10.0) + .assert_node1_computed_size(4.0, 6.0) + .assert_node2_computed_size(3.0, 3.0) + .assert_node3_computed_size(3.0, 3.0) + .assert_root_position(0.0, 0.0) + .assert_node1_position(4.0, 3.0) + .assert_node2_position(4.0, 4.5) + .assert_node3_position(5.0, 3.0); + }); + } + /// ```text /// ╭─root─────────────╮ /// │╭─node1──╮ │ @@ -5946,8 +6379,8 @@ mod layout_tests { test.root.add_child(&test.node1); test.run(|| { test.assert_root_computed_size(20.0, 10.0) - .assert_node2_position(0.0, 0.0) - .assert_node1_position(10.0, 0.0); + .assert_node1_position(10.0, 0.0) + .assert_node2_position(0.0, 0.0); }); let node3 = Instance::new(); @@ -5955,8 +6388,8 @@ mod layout_tests { test.root.add_child(&node3); test.run(|| { test.assert_root_computed_size(32.0, 10.0) - .assert_node2_position(0.0, 0.0) - .assert_node1_position(10.0, 0.0); + .assert_node1_position(10.0, 0.0) + .assert_node2_position(0.0, 0.0); }); assert_eq!(node3.position().xy(), Vector2(20.0, 0.0)); } diff --git a/lib/rust/ensogl/core/src/display/object/transformation.rs b/lib/rust/ensogl/core/src/display/object/transformation.rs index fe1e968b512c..8c12d9abef8e 100644 --- a/lib/rust/ensogl/core/src/display/object/transformation.rs +++ b/lib/rust/ensogl/core/src/display/object/transformation.rs @@ -207,7 +207,7 @@ impl CachedTransformation { } pub fn global_position(&self) -> Vector3 { - (self.matrix * Vector4::new(0.0, 0.0, 0.0, 1.0)).xyz() + self.matrix.column(3).xyz() } } diff --git a/lib/rust/ensogl/core/src/display/scene.rs b/lib/rust/ensogl/core/src/display/scene.rs index 3b2ff0eaf999..6af004a1a104 100644 --- a/lib/rust/ensogl/core/src/display/scene.rs +++ b/lib/rust/ensogl/core/src/display/scene.rs @@ -326,8 +326,9 @@ impl Default for Keyboard { // === Dom === // =========== -/// DOM element manager -#[derive(Clone, CloneRef, Debug)] +/// DOM element manager. Creates root div element containing [`DomLayers`] upon construction and +/// removes them once dropped. +#[derive(Clone, Debug)] pub struct Dom { /// Root DOM element of the scene. pub root: web::dom::WithKnownShape, @@ -363,6 +364,12 @@ impl Default for Dom { } } +impl Drop for Dom { + fn drop(&mut self) { + self.root.remove(); + } +} + // ================= @@ -496,14 +503,14 @@ impl Dirty { #[derive(Clone, CloneRef, Debug)] #[allow(missing_docs)] pub struct Renderer { - dom: Dom, + dom: Rc, variables: UniformScope, pub pipeline: Rc>, pub composer: Rc>>, } impl Renderer { - fn new(dom: &Dom, variables: &UniformScope) -> Self { + fn new(dom: &Rc, variables: &UniformScope) -> Self { let dom = dom.clone_ref(); let variables = variables.clone_ref(); let pipeline = default(); @@ -595,8 +602,10 @@ pub struct HardcodedLayers { pub viz: Layer, pub below_main: Layer, pub main: Layer, + pub port: Layer, pub port_selection: Layer, pub label: Layer, + pub port_hover: Layer, pub above_nodes: Layer, pub above_nodes_text: Layer, /// `panel` layer contains all panels with fixed position (not moving with the panned scene) @@ -636,9 +645,11 @@ impl HardcodedLayers { let viz = root.create_sublayer("viz"); let below_main = root.create_sublayer("below_main"); let main = root.create_sublayer("main"); + let port = root.create_sublayer("port"); let port_selection = root.create_sublayer_with_camera("port_selection", &port_selection_cam); let label = root.create_sublayer("label"); + let port_hover = root.create_sublayer("port_hover"); let above_nodes = root.create_sublayer("above_nodes"); let above_nodes_text = root.create_sublayer("above_nodes_text"); let panel_background = root.create_sublayer_with_camera("panel_background", &panel_cam); @@ -660,8 +671,10 @@ impl HardcodedLayers { viz, below_main, main, + port, port_selection, label, + port_hover, above_nodes, above_nodes_text, panel_background, @@ -701,6 +714,7 @@ pub struct Frp { camera_changed_source: frp::Source, frame_time_source: frp::Source, focused_source: frp::Source, + post_update: frp::Source, } impl Frp { @@ -710,6 +724,7 @@ impl Frp { camera_changed_source <- source(); frame_time_source <- source(); focused_source <- source(); + post_update <- source(); } let shape = shape.clone_ref(); let camera_changed = camera_changed_source.clone_ref().into(); @@ -724,6 +739,7 @@ impl Frp { camera_changed_source, frame_time_source, focused_source, + post_update, } } } @@ -773,10 +789,10 @@ pub struct UpdateStatus { // === SceneData === // ================= -#[derive(Clone, CloneRef, Debug)] +#[derive(Debug)] pub struct SceneData { pub display_object: display::object::Root, - pub dom: Dom, + pub dom: Rc, pub context: Rc>>, pub context_lost_handler: Rc>>, pub variables: UniformScope, @@ -810,7 +826,7 @@ impl SceneData { ) -> Self { debug!("Initializing."); let display_mode = display_mode.clone_ref(); - let dom = Dom::new(); + let dom = default(); let display_object = display::object::Root::new_named("Scene"); let variables = world::with_context(|t| t.variables.clone_ref()); let dirty = Dirty::new(on_mut); @@ -1008,15 +1024,21 @@ impl SceneData { let layer = object.display_layer(); let camera = layer.map_or(self.camera(), |l| l.camera()); let origin_clip_space = camera.view_projection_matrix() * origin_world_space; - let inv_object_matrix = object.transformation_matrix().try_inverse().unwrap(); - - let shape = camera.screen(); - let clip_space_z = origin_clip_space.z; - let clip_space_x = origin_clip_space.w * 2.0 * screen_pos.x / shape.width; - let clip_space_y = origin_clip_space.w * 2.0 * screen_pos.y / shape.height; - let clip_space = Vector4(clip_space_x, clip_space_y, clip_space_z, origin_clip_space.w); - let world_space = camera.inversed_view_projection_matrix() * clip_space; - (inv_object_matrix * world_space).xy() + if let Some(inv_object_matrix) = object.transformation_matrix().try_inverse() { + let shape = camera.screen(); + let clip_space_z = origin_clip_space.z; + let clip_space_x = origin_clip_space.w * 2.0 * screen_pos.x / shape.width; + let clip_space_y = origin_clip_space.w * 2.0 * screen_pos.y / shape.height; + let clip_space = Vector4(clip_space_x, clip_space_y, clip_space_z, origin_clip_space.w); + let world_space = camera.inversed_view_projection_matrix() * clip_space; + (inv_object_matrix * world_space).xy() + } else { + warn!( + "The object transformation matrix is not invertible, \ + this can cause visual artifacts." + ); + default() + } } } @@ -1085,7 +1107,7 @@ impl display::Object for SceneData { #[derive(Clone, CloneRef, Debug)] pub struct Scene { - no_mut_access: SceneData, + no_mut_access: Rc, } impl Scene { @@ -1095,7 +1117,7 @@ impl Scene { display_mode: &Rc>, ) -> Self { let no_mut_access = SceneData::new(stats, on_mut, display_mode); - let this = Self { no_mut_access }; + let this = Self { no_mut_access: Rc::new(no_mut_access) }; this } @@ -1207,31 +1229,58 @@ impl Deref for Scene { } impl Scene { + /// Perform layout phase of scene update. This includes updating camera and the layout of all + /// display objects. No GPU buffers are updated yet, giving the opportunity to perform + /// additional updates that affect the layout of display objects after the main scene layout + /// has been performed. + /// + /// During this phase, the layout updates can be observed using `on_transformed` FRP events on + /// each individual display object. Any further updates to the scene may require the `update` + /// method to be manually called on affected objects in order to affect rendering + /// during this frame. #[profile(Debug)] - // FIXME: - #[allow(unused_assignments)] - pub fn update(&self, time: animation::TimeInfo) -> UpdateStatus { - if let Some(context) = &*self.context.borrow() { - debug_span!("Updating.").in_scope(|| { + pub fn update_layout(&self, time: animation::TimeInfo) -> UpdateStatus { + if self.context.borrow().is_some() { + debug_span!("Early update.").in_scope(|| { let mut scene_was_dirty = false; self.frp.frame_time_source.emit(time.since_animation_loop_started.unchecked_raw()); // Please note that `update_camera` is called first as it may trigger FRP events // which may change display objects layout. - scene_was_dirty = self.update_camera(self) || scene_was_dirty; + scene_was_dirty |= self.update_camera(self); self.display_object.update(self); - scene_was_dirty = self.layers.update() || scene_was_dirty; - scene_was_dirty = self.update_shape() || scene_was_dirty; - scene_was_dirty = self.update_symbols() || scene_was_dirty; + UpdateStatus { scene_was_dirty, pointer_position_changed: false } + }) + } else { + default() + } + } + + /// Perform rendering phase of scene update. At this point, all display object state is being + /// committed for rendering. This includes updating the layer stack, refreshing GPU buffers and + /// handling mouse events. + #[profile(Debug)] + pub fn update_rendering( + &self, + time: animation::TimeInfo, + early_status: UpdateStatus, + ) -> UpdateStatus { + if let Some(context) = &*self.context.borrow() { + debug_span!("Late update.").in_scope(|| { + let UpdateStatus { mut scene_was_dirty, mut pointer_position_changed } = + early_status; + scene_was_dirty |= self.layers.update(); + scene_was_dirty |= self.update_shape(); + scene_was_dirty |= self.update_symbols(); self.handle_mouse_over_and_out_events(); - scene_was_dirty = self.shader_compiler.run(context, time) || scene_was_dirty; + scene_was_dirty |= self.shader_compiler.run(context, time); - let pointer_position_changed = self.pointer_position_changed.get(); + pointer_position_changed |= self.pointer_position_changed.get(); self.pointer_position_changed.set(false); // FIXME: setting it to true for now in order to make cursor blinking work. // Text cursor animation is in GLSL. To be handled properly in this PR: // #183406745 - let scene_was_dirty = true; + scene_was_dirty |= true; UpdateStatus { scene_was_dirty, pointer_position_changed } }) } else { diff --git a/lib/rust/ensogl/core/src/display/shape/compound/rectangle.rs b/lib/rust/ensogl/core/src/display/shape/compound/rectangle.rs index 5e8b849de5ac..9bc44da15182 100644 --- a/lib/rust/ensogl/core/src/display/shape/compound/rectangle.rs +++ b/lib/rust/ensogl/core/src/display/shape/compound/rectangle.rs @@ -106,7 +106,9 @@ impl Rectangle { /// Constructor. pub fn new() -> Self { - Self::default() + Self::default().build(|r| { + r.set_border_color(display::shape::INVISIBLE_HOVER_COLOR); + }) } /// Builder-style modifier, allowing setting shape properties without creating a temporary @@ -145,13 +147,15 @@ impl Rectangle { /// Set the border size of the shape. If you want to use border, you should always set the inset /// at least of the size of the border. If you do not want the border to be animated, you can - /// use [`Self::set_inset_border`] instead. + /// use [`Self::set_inset_border`] instead. To make the border visible, you also need to set the + /// border color using [`Self::set_border_color`]. pub fn set_border(&self, border: f32) -> &Self { self.modify_view(|view| view.border.set(border)) } /// Set both the inset and border at once. See documentation of [`Self::set_border`] and - /// [`Self::set_inset`] to learn more. + /// [`Self::set_inset`] to learn more. To make the border visible, you also need to set the + /// border color using [`Self::set_border_color`]. pub fn set_inset_border(&self, border: f32) -> &Self { self.set_inset(border).set_border(border) } diff --git a/lib/rust/ensogl/core/src/display/world.rs b/lib/rust/ensogl/core/src/display/world.rs index 64cb68f073a5..8614d653ce42 100644 --- a/lib/rust/ensogl/core/src/display/world.rs +++ b/lib/rust/ensogl/core/src/display/world.rs @@ -21,6 +21,7 @@ use crate::display::render::cache_shapes::CacheShapesPass; use crate::display::render::passes::SymbolsRenderPass; use crate::display::scene::DomPath; use crate::display::scene::Scene; +use crate::display::scene::UpdateStatus; use crate::display::shape::primitive::glsl; use crate::display::symbol::registry::RunMode; use crate::display::symbol::registry::SymbolRegistry; @@ -329,11 +330,15 @@ impl WorldDataWithLoop { let frp = Frp::new(); let data = WorldData::new(&frp.private.output); let on_frame_start = animation::on_frame_start(); + let on_before_layout = animation::on_before_layout(); let on_before_rendering = animation::on_before_rendering(); let network = frp.network(); crate::frp::extend! {network eval on_frame_start ((t) data.run_stats(*t)); - eval on_before_rendering ((t) data.run_next_frame(*t)); + layout_update <- on_before_layout.map(f!((t) data.run_next_frame_layout(*t))); + _eval <- on_before_rendering.map2(&layout_update, + f!((t, early) data.run_next_frame_rendering(*t, *early)) + ); } Self { frp, data } @@ -554,18 +559,29 @@ impl WorldData { } } - /// Perform to the next frame with the provided time information. + /// Perform to the layout step of next frame simulation with the provided time information. + /// See [`Scene::update_layout`] for information about actions performed in this step. /// /// Please note that the provided time information from the [`requestAnimationFrame`] JS /// function is more precise than time obtained from the [`window.performance().now()`] one. /// Follow this link to learn more: /// https://stackoverflow.com/questions/38360250/requestanimationframe-now-vs-performance-now-time-discrepancy. #[profile(Objective)] - pub fn run_next_frame(&self, time: animation::TimeInfo) { + pub fn run_next_frame_layout(&self, time: animation::TimeInfo) -> UpdateStatus { self.on.before_frame.run_all(time); self.uniforms.time.set(time.since_animation_loop_started.unchecked_raw()); self.scene_dirty.unset_all(); - let update_status = self.default_scene.update(time); + self.default_scene.update_layout(time) + } + + /// perform to the rendering step of next frame simulation with the provided time information. + /// See [`Scene::update_rendering`] for information about actions performed in this step. + /// + /// Apart from the scene late update, this function also performs garbage collection and actual + /// rendering of the scene using updated GPU buffers. + #[profile(Objective)] + pub fn run_next_frame_rendering(&self, time: animation::TimeInfo, early_status: UpdateStatus) { + let update_status = self.default_scene.update_rendering(time, early_status); self.garbage_collector.mouse_events_handled(); self.default_scene.render(update_status); self.on.after_frame.run_all(time); diff --git a/lib/rust/ensogl/core/src/gui/cursor.rs b/lib/rust/ensogl/core/src/gui/cursor.rs index bc0602abf34a..094c27485dae 100644 --- a/lib/rust/ensogl/core/src/gui/cursor.rs +++ b/lib/rust/ensogl/core/src/gui/cursor.rs @@ -54,6 +54,7 @@ define_style! { press: f32, port_selection_layer : bool, trash: f32, + plus: f32, } @@ -64,6 +65,7 @@ impl Style { pub fn new_highlight>( host: H, size: Vector2, + radius: f32, color: Option, ) -> Self where @@ -71,12 +73,14 @@ impl Style { { let host = Some(StyleValue::new(host.display_object().clone_ref())); let size = Some(StyleValue::new(size)); + let radius = Some(StyleValue::new(radius)); + let press = Some(StyleValue::new(0.0)); let color = color.map(|color| { let color = color.into(); StyleValue::new(color) }); let port_selection_layer = Some(StyleValue::new_no_animation(true)); - Self { host, size, color, port_selection_layer, ..default() } + Self { host, size, radius, color, port_selection_layer, press, ..default() } } pub fn new_color(color: color::Lcha) -> Self { @@ -106,6 +110,11 @@ impl Style { let trash = Some(StyleValue::new(1.0)); Self { trash, ..default() } } + + pub fn plus() -> Self { + let plus = Some(StyleValue::new(1.0)); + Self { plus, ..default() } + } } @@ -127,16 +136,6 @@ impl Style { } -// === Getters === - -#[allow(missing_docs)] -impl Style { - pub fn host_position(&self) -> Option> { - self.host.as_ref().and_then(|t| t.value.as_ref().map(|t| t.position())) - } -} - - // ================== // === CursorView === @@ -153,13 +152,14 @@ pub mod shape { radius: f32, color: Vector4, trash: f32, + plus: f32, ) { let width : Var = "input_size.x".into(); let height : Var = "input_size.y".into(); let press_side_shrink = 2.px(); let press_diff = press_side_shrink * &press; - let radius = 1.px() * radius - &press_diff; - let sides_padding = 1.px() * SIDES_PADDING; + let radius = radius.px() - &press_diff; + let sides_padding = SIDES_PADDING.px(); let width = &width - &press_diff * 2.0 - &sides_padding; let height = &height - &press_diff * 2.0 - &sides_padding; let cursor = Rect((&width,&height)).corners_radius(radius); @@ -167,13 +167,24 @@ pub mod shape { let color: Var = color.into(); let trash_color: Var = color::Rgba::new(0.91, 0.32, 0.32, 1.0).into(); let color = color.mix(&trash_color, &trash); + + let plus_color: Var = color::Rgba::new(0.39, 0.71, 0.15, 1.0).into(); + let color = color.mix(&plus_color, &plus); + + let cursor = cursor.fill(color); let trash_bar1 = Rect((2.px(), (&height - 4.px()) * &trash - 1.px())); let trash_bar2 = trash_bar1.rotate((PI/2.0).radians()); let trash_bar_x = (trash_bar1 + trash_bar2).rotate((PI/4.0).radians()); let trash_bar_x = trash_bar_x.fill(color::Rgba::new(1.0,1.0,1.0,0.8)); - let cursor = cursor + trash_bar_x; + + let plus_bar1 = Rect((2.px(), (&height - 4.px()) * &plus - 1.px())); + let plus_bar2 = plus_bar1.rotate((PI/2.0).radians()); + let plus_sign = plus_bar1 + plus_bar2; + let plus_sign = plus_sign.fill(color::Rgba::new(1.0,1.0,1.0,0.8)); + + let cursor = cursor + trash_bar_x + plus_sign; cursor.into() } } @@ -187,6 +198,7 @@ pub mod shape { crate::define_endpoints_2! { Input { + set_style_override (Option