From 63de18e3676bd71a4b7c690da980e666c7ff8718 Mon Sep 17 00:00:00 2001 From: Jaroslav Tulach Date: Tue, 25 Apr 2023 16:49:26 +0200 Subject: [PATCH 01/14] Turn null into UnexpectedExpression when Union type is incomplete (#6415) Test and fix for #6401. --- .../runtime/src/main/java/org/enso/compiler/TreeToIr.java | 3 +++ .../test/java/org/enso/compiler/ErrorCompilerTest.java | 8 ++++++++ 2 files changed, 11 insertions(+) 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/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"); From 952de24e79212469826b2cc679be677e8fdf543e Mon Sep 17 00:00:00 2001 From: Adam Obuchowicz Date: Tue, 25 Apr 2023 17:38:29 +0200 Subject: [PATCH 02/14] Drop method exported from WASM + removing leaks. (#6365) Fixes #6317 The `drop` method is available in the WASM object. This can be tested by typing `ensoglApp.wasm.drop()` in the dev console - all objects should be removed and all connections closed. # Important Notes * This PR fixed serveral leaks by this occasion * A new tool for tracking leaks was added to prelude's `debug` module. --- app/gui/src/lib.rs | 75 +++- app/gui/src/presenter.rs | 1 - .../graph-editor/src/component/breadcrumbs.rs | 23 +- .../src/component/node/growth_animation.rs | 10 +- app/gui/view/graph-editor/src/lib.rs | 414 +++++++++--------- .../graph-editor/src/new_node_position.rs | 8 +- lib/rust/ensogl/core/src/display/scene.rs | 19 +- lib/rust/executor/src/web.rs | 14 +- lib/rust/prelude/src/debug.rs | 93 +++- 9 files changed, 396 insertions(+), 261 deletions(-) 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/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/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/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/lib.rs b/app/gui/view/graph-editor/src/lib.rs index 0acc3081ed31..be5015aa7e8c 100644 --- a/app/gui/view/graph-editor/src/lib.rs +++ b/app/gui/view/graph-editor/src/lib.rs @@ -48,7 +48,6 @@ 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; @@ -125,6 +124,7 @@ fn traffic_lights_gap_width() -> f32 { } + // ================= // === SharedVec === // ================= @@ -774,7 +774,7 @@ ensogl::define_endpoints_2! { impl FrpNetworkProvider for GraphEditor { fn network(&self) -> &frp::Network { - &self.model.network + &self.frp.network } } @@ -1401,11 +1401,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 +1419,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 +1454,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 +1474,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 +1489,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 +1572,202 @@ 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) - }); + 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.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 } @@ -1790,7 +1795,8 @@ pub struct GraphEditorModel { tooltip: Tooltip, touch_state: TouchState, visualisations: Visualisations, - frp: Frp, + frp: api::Private, + frp_public: api::Public, profiling_statuses: profiling::Statuses, profiling_button: component::profiling::Button, styles_frp: StyleWatchFrp, @@ -1816,7 +1822,6 @@ impl GraphEditorModel { let execution_mode_selector = execution_mode_selector::ExecutionModeSelector::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 +1831,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,11 +1850,12 @@ 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, @@ -1886,8 +1892,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 +1906,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 } } @@ -1988,7 +1994,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 { @@ -2108,7 +2114,7 @@ impl GraphEditorModel { 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 +2131,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 +2151,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 +2192,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(()); } } @@ -2487,7 +2493,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 +2516,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() @@ -2769,7 +2775,7 @@ 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; @@ -2788,7 +2794,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 +2848,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 +2924,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 +2993,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 +3005,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() ); @@ -3077,7 +3083,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 +3226,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; } @@ -3406,13 +3411,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 +3513,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 +3627,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); @@ -3677,7 +3671,7 @@ 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); + let neutral_color = 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, _)) 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/lib/rust/ensogl/core/src/display/scene.rs b/lib/rust/ensogl/core/src/display/scene.rs index 3b2ff0eaf999..a1e9cb8282f3 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(); @@ -776,7 +783,7 @@ pub struct UpdateStatus { #[derive(Clone, CloneRef, 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 +817,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); diff --git a/lib/rust/executor/src/web.rs b/lib/rust/executor/src/web.rs index a8b9d3208564..79eb282830d8 100644 --- a/lib/rust/executor/src/web.rs +++ b/lib/rust/executor/src/web.rs @@ -62,14 +62,18 @@ impl EventLoopExecutor { /// attempt achieving as much progress on this executor's tasks as possible /// without stalling. pub fn runner(&self) -> impl FnMut(animation::TimeInfo) { - let executor = self.executor.clone(); + // We pass weak handle to the runner to ensure all scheduled futures will be dropped + // once executor is dropped. + let executor = Rc::downgrade(&self.executor); move |_| { let _profiler = profiler::start_debug!(profiler::APP_LIFETIME, "EventLoopExecutor::runner"); - // Safe, because this is the only place borrowing executor and loop - // callback shall never be re-entrant. - let mut executor = executor.borrow_mut(); - executor.run_until_stalled(); + if let Some(executor) = executor.upgrade() { + // Safe, because this is the only place borrowing executor and loop + // callback shall never be re-entrant. + let mut executor = executor.borrow_mut(); + executor.run_until_stalled(); + } } } diff --git a/lib/rust/prelude/src/debug.rs b/lib/rust/prelude/src/debug.rs index a8487fc0ba9a..3e5af343bd01 100644 --- a/lib/rust/prelude/src/debug.rs +++ b/lib/rust/prelude/src/debug.rs @@ -56,7 +56,7 @@ pub use internal::backtrace; /// mark each copy with unique id (the original copy has id of 0). Once enabled, it will print /// backtrace of each clone, clone_ref or drop operation with assigned name (the same for all /// copies) and copy id. -#[derive(Debug, Default)] +#[derive(Debug)] pub struct TraceCopies { clone_id: u64, handle: Rc>>, @@ -75,6 +75,10 @@ fn next_clone_id() -> u64 { } impl TraceCopies { + pub fn new() -> Self { + Self { clone_id: next_clone_id(), handle: default() } + } + /// Create enabled structure with appointed entity name (shared between all copies). pub fn enabled(name: impl Into) -> Self { let this: Self = default(); @@ -90,6 +94,12 @@ impl TraceCopies { } } +impl Default for TraceCopies { + fn default() -> Self { + Self::new() + } +} + impl Clone for TraceCopies { fn clone(&self) -> Self { let borrow = self.handle.borrow(); @@ -126,3 +136,84 @@ impl Drop for TraceCopies { } } } + + + +// ==================== +// === LeakDetector === +// ==================== + +/// A module containing an utility for detecting leaks. +/// +/// If you suspect a particular struct is leaking, i.e. its instances are still present when we +/// expect them to be removed, you may add a [`Trace`] field to it. Then, at the point where we +/// expect all instances to be dropped, we may check the [`TRACKED_OBJECTS`] global variable, what +/// instances are still alive and their creation backtraces. +pub mod leak_detector { + use crate::*; + + thread_local! { + /// The structure mapping the existing tracking copies with [`Trace`] structure to their + /// creation backtraces. + /// + /// You may check/print it at various points where you expect no traced objects should + /// persist. + pub static TRACKED_OBJECTS: RefCell> = default(); + } + + /// A utility for tracing all copies of CloneRef-able entity and keeping list of existing ones. + /// + /// This is a wrapper for [`TraceCopies`] which also register each enabled copy in + /// [`TRACKED_OBJECTS`] global variable. The variable may be then checked for leaks in moments + /// when we expect it to be empty. + #[derive(Debug, Default)] + pub struct Trace { + instance: TraceCopies, + } + + impl Trace { + /// Create enabled structure with appointed entity name (shared between all copies). + /// + /// See [`TraceCopies::enabled`] and [`Trace::enable`]. + pub fn enabled(name: impl Into) -> Self { + let instance = TraceCopies::enabled(name); + Self::register_tracked_object(&instance); + Self { instance } + } + + /// Assign a name to the entity (shared between all copies), start printing logs and + /// register its creation backtrace in [`TRACKED_OBJECTS`]. + /// + /// See [`TraceCopies::enable`]. + pub fn enable(&self, name: impl Into) { + self.instance.enable(name); + Self::register_tracked_object(&self.instance); + } + + fn register_tracked_object(instance: &TraceCopies) { + let id = instance.clone_id; + let bt = backtrace(); + TRACKED_OBJECTS.with(|objs| objs.borrow_mut().insert(id, bt)); + } + } + + impl Clone for Trace { + fn clone(&self) -> Self { + let instance = self.instance.clone(); + let enabled = instance.handle.borrow().is_some(); + if enabled { + Self::register_tracked_object(&instance); + } + Self { instance } + } + } + + impl_clone_ref_as_clone!(Trace); + + impl Drop for Trace { + fn drop(&mut self) { + let id = self.instance.clone_id; + TRACKED_OBJECTS.with(|objs| objs.borrow_mut().remove(&id)); + } + } +} From 115e9b4ffda411fd19aabcd88f781e1a43064cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20Dani=C5=82o?= Date: Tue, 25 Apr 2023 18:06:11 +0200 Subject: [PATCH 03/14] Implementation of elements adding to List Editor and a lot of internal API (#6390) --- .../ensogl/component/list-editor/src/lib.rs | 438 ++++++++++++------ lib/rust/ensogl/component/slider/src/lib.rs | 431 ++++++++--------- lib/rust/ensogl/component/slider/src/model.rs | 52 +-- lib/rust/ensogl/core/src/gui/cursor.rs | 27 +- lib/rust/ensogl/examples/slider/src/lib.rs | 74 ++- lib/rust/frp/src/nodes.rs | 203 ++++++++ lib/rust/prelude/src/option.rs | 11 + lib/rust/types/src/dim.rs | 97 ++++ 8 files changed, 878 insertions(+), 455 deletions(-) diff --git a/lib/rust/ensogl/component/list-editor/src/lib.rs b/lib/rust/ensogl/component/list-editor/src/lib.rs index 63c9848d0a57..f3cd7e4bf8b3 100644 --- a/lib/rust/ensogl/component/list-editor/src/lib.rs +++ b/lib/rust/ensogl/component/list-editor/src/lib.rs @@ -106,13 +106,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. @@ -275,16 +272,20 @@ ensogl_core::define_endpoints_2! { /// Remove the element at the given index. If the index is invalid, nothing will happen. remove(Index), + gap(f32), + secondary_axis_drag_threshold(f32), primary_axis_no_drag_threshold(f32), primary_axis_no_drag_threshold_decay_time(f32), thrashing_offset_ratio(f32), + enable_all_insertion_points(bool), + enable_last_insertion_point(bool), } Output { /// Fires whenever a new element was added to the list. on_item_added(Response<(Index, Weak)>), - // on_item_removed(Response<(Index, Weak)>), + on_item_removed(Response<(Index, Weak)>), /// 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 @@ -297,39 +298,35 @@ ensogl_core::define_endpoints_2! { #[derivative(Clone(bound = ""))] 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 +339,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,33 +354,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. @@ -414,51 +371,115 @@ 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) = 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 <+ all [insert_pointer_style, trash_pointer_style].fold(); + } + 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 <- opt_index.map(|t| t.if_some_or_default(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_weak(item))); + on_pushed <- frp.push.map2(&push_ix, |t, ix| Response::api((*ix, t.clone()))); + frp.private.output.on_item_added <+ on_pushed; + + insert_ix <= frp.insert.map(f!(((index, item)) model.insert_weak(*index, item))); + on_inserted <- frp.insert.map2(&insert_ix, |t, ix| Response::api((*ix, t.1.clone()))); + 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(item).downgrade()))); + } + }); } - 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) { + 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 +499,26 @@ 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); + + 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(item).downgrade()))); + } + }); } - (is_dragging, drag_diff) + (status, drag_diff) } /// 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,21 +532,61 @@ 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.default_or(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, item)) = model.borrow_mut().place_dragged_item(*index) { + on_item_added.emit(Response::gui((index, Rc::new(item).downgrade()))); + } + ); } - 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 } @@ -529,7 +599,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 +607,52 @@ 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_weak(&self, item: &Weak) -> Option { + item.upgrade().map(|item| self.push((*item).clone_ref())) + } + + fn insert(&self, index: Index, item: T) -> Index { + self.borrow_mut().insert(index, item) + } + + fn insert_weak(&self, index: Index, item: &Weak) -> Option { + item.upgrade().map(|item| self.insert(index, (*item).clone_ref())) + } + + 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 +674,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( + &mut 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 +752,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 +767,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 +840,14 @@ 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 { + fn start_item_drag(&mut self, target: &display::object::Instance) -> Option<(Index, T)> { let index = self.item_index_of(target); - if let Some(index) = index { - self.start_item_drag_at(cursor, index); + if let Some((index, index_or_placeholder_index)) = index { + self.start_item_drag_at(index_or_placeholder_index).map(|item| (index, item)) } else { - warn!("Requested to drag a non-existent item.") + warn!("Requested to drag a non-existent item."); + 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 +889,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 +920,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 +941,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<(Index, T)> { + 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).map(|index| (index, item)) } 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 +970,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,9 +1012,27 @@ 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() } } @@ -967,20 +1123,20 @@ pub fn main() { let shape1 = Circle().build(|t| { - t.set_size(Vector2::new(60.0, 100.0)) + t.set_size(Vector2(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)) + t.set_size(Vector2(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)) + t.set_size(Vector2(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)); @@ -997,15 +1153,15 @@ pub fn main() { }); new_item <- vector_editor.request_new_item.map(|_| { let shape = RoundedRectangle(10.0).build(|t| { - t.set_size(Vector2::new(100.0, 100.0)) + t.set_size(Vector2(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.insert <+ vector_editor.request_new_item.map2(&new_item, |index, item| + (**index, item.downgrade()) ); } @@ -1014,7 +1170,7 @@ pub fn main() { vector_editor.push(shape3); let root = display::object::Instance::new(); - root.set_size(Vector2::new(300.0, 100.0)); + root.set_size(Vector2(300.0, 100.0)); root.add_child(&vector_editor); world.add_child(&root); diff --git a/lib/rust/ensogl/component/slider/src/lib.rs b/lib/rust/ensogl/component/slider/src/lib.rs index 4e28a2154103..f7b689677c86 100644 --- a/lib/rust/ensogl/component/slider/src/lib.rs +++ b/lib/rust/ensogl/component/slider/src/lib.rs @@ -27,10 +27,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; // ============== @@ -49,7 +50,7 @@ pub mod model; /// much the value is changed per pixel dragged and how many digits are displayed after the decimal. const PRECISION_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 @@ -103,15 +104,15 @@ pub enum LabelPosition { // === Slider orientation === // ========================== -/// 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, -} +// /// The orientation of the slider component. +// #[derive(Clone, Copy, Debug, Default)] +// pub enum Axis2 { +// #[default] +// /// The slider value is changed by dragging the slider horizontally. +// Horizontal, +// /// The slider value is changed by dragging the slider vertically. +// Vertical, +// } @@ -121,15 +122,15 @@ pub enum SliderOrientation { /// The type of element that indicates the slider's value along its length. #[derive(Clone, Copy, Debug, Default)] -pub enum ValueIndicator { +pub enum Kind { #[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, + SingleValue, /// 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, + Scrollbar(f32), } @@ -160,15 +161,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 +184,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,12 +225,12 @@ 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 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), + kind(Kind), /// Set the color of the slider's value indicator. set_value_indicator_color(color::Lcha), /// Set the color of the slider's background. @@ -239,7 +248,7 @@ 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), + show_value(bool), /// Set the default precision at which the slider operates. The slider's precision /// determines by what increment the value will be changed on mouse movement. It also /// affects the number of digits after the decimal point displayed. @@ -267,7 +276,7 @@ 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. @@ -317,8 +326,8 @@ ensogl_core::define_endpoints_2! { 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), + // /// The orientation of the slider, either horizontal or vertical. + // orientation(Axis2), } } @@ -357,13 +366,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 +381,109 @@ 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 + let ptr_down_any = model.background.on_event::(); + let ptr_up_any = scene.on_event::(); + let ptr_out = model.background.on_event::(); + let ptr_over = model.background.on_event::(); - // === User input === + 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)) - ); - 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, - } + 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.background, *p)) ); - 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); + value_on_ptr_down <- output.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); + dragging <- bool(&on_drag_stop, &on_drag_start); + drag_start <- pos.sample(&on_drag_start); + drag_end <- pos.gate(&dragging).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(); + orientation_orth <- frp.orientation.map(|o| o.orthogonal()); + prec_delta <- all_with(&drag_end, &orientation_orth, |t, d| t.get_dim(d)).on_change(); + + output.hovered <+ bool(&ptr_out, &ptr_over); + output.dragged <+ dragging; // === 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 - ); - slider_length <- slider_length.map(|(length, indicator, thumb_size)| + 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)); + + empty_space <- all_with3(&length, &frp.kind, &frp.set_thumb_size, + |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, + Kind::Scrollbar(thumb_size) => length * (1.0 - thumb_size), + Kind::SingleValue => *length, } ); - 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), - } + + slider_range <- all_with(&output.max_value, &output.min_value, |max, min| *max - *min); + native_precision <- all2(&empty_space, &slider_range).map(|(l, r)| r / l); + + non_native_precision <- 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 }; + let precision = 10.0_f32.powf(exp as f32); + precision + }) } ).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, - } - ); + precision <- all_with(&non_native_precision, &native_precision, |t,s| t.unwrap_or(*s)); + output.precision <+ precision; // === 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 <- drag_delta1.map3(&value_on_ptr_down, &precision, + |delta, value, precision| value + delta * precision); + value <- any2(&frp.set_value, &value); value <- all5( &value, - &input.set_min_value, - &input.set_max_value, - &input.set_lower_limit_type, - &input.set_upper_limit_type, + &frp.set_min_value, + &frp.set_max_value, + &frp.set_lower_limit_type, + &frp.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; + + // === Value Reset === + + reset_value <- ptr_down.gate(&keyboard.is_control_down); + value_on_reset <- input.set_default_value.sample(&reset_value); + output.value <+ value_on_reset; + + + // === Value Animation === + model.value_animation.target <+ output.value; }; } @@ -558,35 +495,35 @@ impl Slider { let model = &self.model; frp::extend! { network - min_value <- all5( + min_value <- all_with5( &output.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( + + max_value <- all_with5( &output.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.value, &min_value, |v, min| v < min).on_change(); + overflow_upper <- all_with(&output.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,24 +531,24 @@ 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.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)); + + precision <- output.precision.sampled_gate(&input.show_value); + max_decimal_places <- input.set_max_disp_decimal_places.sampled_gate(&input.show_value); + text <- all_with3(&value, &precision, &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)); }; } @@ -689,17 +626,19 @@ 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; + // comp_size <- all2(&input.set_width, &input.set_height).map(|(w, h)| Vector2(*w,*h)); + eval obj.on_resized((size) model.update_size(*size)); + eval input.kind((i) model.kind(i)); + // output.width <+ input.set_width; + // output.height <+ input.set_height; 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); + indicator_pos <- all3(&indicator_pos, &input.set_thumb_size, &input.orientation); eval indicator_pos((v) model.set_indicator_position(v)); value_text_left_pos_x <- all3( @@ -722,28 +661,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,7 +724,7 @@ 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); + start_editing <- start_editing.gate(&input.show_value); value_on_edit <- output.value.sample(&start_editing); prec_on_edit <- output.precision.sample(&start_editing); max_places_on_edit <- @@ -835,6 +774,8 @@ 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); } } @@ -904,9 +845,7 @@ 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 /// 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), -) -> (ImString, Option) { +fn display_value(value: &f32, precision: &f32, max_digits: &usize) -> (ImString, Option) { let text = value_text_truncate(&(*value, *precision, *max_digits)); let mut text_iter = text.split('.'); let text_left = text_iter.next().map(|s| s.to_im_string()).unwrap_or_default(); @@ -953,42 +892,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..82a217e8dcee 100644 --- a/lib/rust/ensogl/component/slider/src/model.rs +++ b/lib/rust/ensogl/component/slider/src/model.rs @@ -3,9 +3,8 @@ use ensogl_core::display::shape::*; use ensogl_core::prelude::*; +use crate::Kind; use crate::LabelPosition; -use crate::SliderOrientation; -use crate::ValueIndicator; use ensogl_core::application::Application; use ensogl_core::data::color; @@ -264,13 +263,13 @@ impl Model { } /// Set whether the lower overfow marker is visible. - pub fn set_value_indicator(&self, indicator: &ValueIndicator) { + pub fn kind(&self, indicator: &Kind) { match indicator { - ValueIndicator::Track => { + Kind::SingleValue => { self.root.add_child(&self.track); self.root.remove_child(&self.thumb); } - ValueIndicator::Thumb => { + Kind::Scrollbar(_) => { self.root.add_child(&self.thumb); self.root.remove_child(&self.track); } @@ -278,19 +277,16 @@ impl Model { } /// Set the position of the value indicator. - pub fn set_indicator_position( - &self, - (fraction, size, orientation): &(f32, f32, SliderOrientation), - ) { + pub fn set_indicator_position(&self, (fraction, size, orientation): &(f32, f32, Axis2)) { self.thumb.slider_fraction.set(*fraction); match orientation { - SliderOrientation::Horizontal => { + Axis2::X => { 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); } - SliderOrientation::Vertical => { + Axis2::Y => { 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); @@ -300,17 +296,17 @@ impl Model { } /// Set the size and orientation of the overflow markers. - pub fn set_overflow_marker_shape(&self, (size, orientation): &(f32, SliderOrientation)) { + pub fn set_overflow_marker_shape(&self, (size, orientation): &(f32, Axis2)) { let margin = Vector2(COMPONENT_MARGIN * 2.0, COMPONENT_MARGIN * 2.0); let size = Vector2(*size, *size) * OVERFLOW_MARKER_SIZE + margin; 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 +316,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 +363,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 +384,15 @@ 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); - } else { + pub fn show_value(&self, visible: bool) { + if visible { self.root.add_child(&self.value_text_left); self.root.add_child(&self.value_text_dot); self.root.add_child(&self.value_text_right); + } else { + self.root.remove_child(&self.value_text_left); + self.root.remove_child(&self.value_text_dot); + self.root.remove_child(&self.value_text_right); } } diff --git a/lib/rust/ensogl/core/src/gui/cursor.rs b/lib/rust/ensogl/core/src/gui/cursor.rs index bc0602abf34a..191f4337469e 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, } @@ -106,6 +107,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() } + } } @@ -153,6 +159,7 @@ 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(); @@ -167,13 +174,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() } } @@ -298,6 +316,7 @@ impl Cursor { let host_attached_weight = Easing::new(network); let port_selection_layer_weight = Animation::::new(network); let trash = Animation::::new(network); + let plus = Animation::::new(network); host_attached_weight.set_duration(300.0); color_lab.set_target_value(DEFAULT_COLOR.opaque.into()); @@ -316,6 +335,7 @@ impl Cursor { model.for_each_view(|vw| {vw.set_size(dim);}); }); eval trash.value ((v) model.for_each_view(|vw| vw.trash.set(*v))); + eval plus.value ((v) model.for_each_view(|vw| vw.plus.set(*v))); alpha <- all_with(&color_alpha.value,&inactive_fade.value,|s,t| s*t); @@ -394,6 +414,11 @@ impl Cursor { Some(t) => trash.target.emit(t.value.unwrap_or(0.0)), } + match &new_style.plus { + None => plus.target.emit(0.0), + Some(t) => plus.target.emit(t.value.unwrap_or(0.0)), + } + *model.style.borrow_mut() = new_style.clone(); }); diff --git a/lib/rust/ensogl/examples/slider/src/lib.rs b/lib/rust/ensogl/examples/slider/src/lib.rs index 503946f27b57..8b01e8c83a85 100644 --- a/lib/rust/ensogl/examples/slider/src/lib.rs +++ b/lib/rust/ensogl/examples/slider/src/lib.rs @@ -29,6 +29,7 @@ use ensogl_core::application::Application; use ensogl_core::application::View; use ensogl_core::data::color; use ensogl_core::display; +use ensogl_core::display::navigation::navigator::Navigator; use ensogl_slider as slider; use ensogl_text_msdf::run_once_initialized; @@ -41,10 +42,10 @@ use ensogl_text_msdf::run_once_initialized; /// Create a basic slider. fn make_slider(app: &Application) -> slider::Slider { let slider = app.new_view::(); - slider.frp.set_background_color(color::Lcha(0.8, 0.0, 0.0, 1.0)); - slider.frp.set_max_value(5.0); - slider.frp.set_default_value(1.0); - slider.frp.set_value(1.0); + // slider.frp.set_background_color(color::Lcha(0.8, 0.0, 0.0, 1.0)); + // slider.frp.set_max_value(5.0); + // slider.frp.set_default_value(1.0); + // slider.frp.set_value(1.0); slider } @@ -58,17 +59,22 @@ fn make_slider(app: &Application) -> slider::Slider { #[derive(Debug, Clone, CloneRef)] pub struct Model { /// Vector that holds example sliders until they are dropped. - sliders: Rc>>, - app: Application, - root: display::object::Instance, + sliders: Rc>>, + app: Application, + root: display::object::Instance, + navigator: Navigator, } impl Model { fn new(app: &Application) -> Self { let app = app.clone_ref(); + let world = app.display.clone(); + let scene = &world.default_scene; + let camera = scene.camera().clone_ref(); + let navigator = Navigator::new(scene, &camera); let sliders = Rc::new(RefCell::new(Vec::new())); let root = display::object::Instance::new(); - let model = Self { app, sliders, root }; + let model = Self { app, sliders, root, navigator }; model.init_sliders(); model } @@ -76,8 +82,7 @@ impl Model { /// Add example sliders to scene. fn init_sliders(&self) { let slider1 = make_slider(&self.app); - slider1.frp.set_width(400.0); - slider1.frp.set_height(50.0); + slider1.set_size((200.0, 24.0)); slider1.set_y(-120.0); slider1.frp.set_value_indicator_color(color::Lcha(0.4, 0.7, 0.7, 1.0)); slider1.frp.set_label("Soft limits + tooltip"); @@ -88,8 +93,7 @@ impl Model { self.sliders.borrow_mut().push(slider1); let slider2 = make_slider(&self.app); - slider2.frp.set_width(400.0); - slider2.frp.set_height(50.0); + slider2.set_size((400.0, 50.0)); slider2.set_y(-60.0); slider2.frp.set_value_indicator_color(color::Lcha(0.4, 0.7, 0.7, 1.0)); slider2.frp.set_slider_disabled(true); @@ -98,8 +102,7 @@ impl Model { self.sliders.borrow_mut().push(slider2); let slider3 = make_slider(&self.app); - slider3.frp.set_width(400.0); - slider3.frp.set_height(50.0); + slider3.set_size((400.0, 50.0)); slider3.set_y(0.0); slider3.frp.set_value_indicator_color(color::Lcha(0.4, 0.7, 0.7, 1.0)); slider3.frp.set_default_value(100.0); @@ -111,8 +114,7 @@ impl Model { self.sliders.borrow_mut().push(slider3); let slider4 = make_slider(&self.app); - slider4.frp.set_width(400.0); - slider4.frp.set_height(50.0); + slider4.set_size((400.0, 50.0)); slider4.set_y(60.0); slider4.frp.set_value_indicator_color(color::Lcha(0.4, 0.7, 0.7, 1.0)); slider4.frp.set_label("Adaptive upper limit"); @@ -122,20 +124,18 @@ impl Model { self.sliders.borrow_mut().push(slider4); let slider5 = make_slider(&self.app); - slider5.frp.set_width(75.0); - slider5.frp.set_height(230.0); + slider5.set_size((75.0, 230.0)); slider5.set_y(-35.0); slider5.set_x(275.0); slider5.frp.set_value_indicator_color(color::Lcha(0.4, 0.7, 0.7, 1.0)); slider5.frp.set_label("Hard limits"); - slider5.frp.set_orientation(slider::SliderOrientation::Vertical); + slider5.frp.orientation(Axis2::Y); slider5.frp.set_max_disp_decimal_places(4); self.root.add_child(&slider5); self.sliders.borrow_mut().push(slider5); let slider6 = make_slider(&self.app); - slider6.frp.set_width(75.0); - slider6.frp.set_height(230.0); + slider6.set_size((75.0, 230.0)); slider6.set_y(-35.0); slider6.set_x(375.0); slider6.frp.set_value_indicator_color(color::Lcha(0.4, 0.7, 0.7, 1.0)); @@ -143,57 +143,53 @@ impl Model { slider6.frp.set_label_position(slider::LabelPosition::Inside); slider6.frp.set_lower_limit_type(slider::SliderLimit::Soft); slider6.frp.set_upper_limit_type(slider::SliderLimit::Soft); - slider6.frp.set_orientation(slider::SliderOrientation::Vertical); + slider6.frp.orientation(Axis2::Y); slider6.frp.set_max_disp_decimal_places(4); self.root.add_child(&slider6); self.sliders.borrow_mut().push(slider6); let slider7 = make_slider(&self.app); - slider7.frp.set_width(400.0); - slider7.frp.set_height(10.0); + slider7.set_size((400.0, 10.0)); slider7.set_y(-160.0); slider7.frp.set_value_indicator_color(color::Lcha(0.4, 0.7, 0.7, 1.0)); - slider7.frp.set_value_text_hidden(true); + slider7.frp.show_value(false); slider7.frp.set_precision_adjustment_disabled(true); - slider7.frp.set_value_indicator(slider::ValueIndicator::Thumb); + slider7.frp.kind(slider::Kind::Scrollbar(0.1)); slider7.frp.set_thumb_size(0.1); self.root.add_child(&slider7); self.sliders.borrow_mut().push(slider7); let slider8 = make_slider(&self.app); - slider8.frp.set_width(400.0); - slider8.frp.set_height(10.0); + slider8.set_size((400.0, 10.0)); slider8.set_y(-180.0); slider8.frp.set_value_indicator_color(color::Lcha(0.4, 0.7, 0.7, 1.0)); - slider8.frp.set_value_text_hidden(true); + slider8.frp.show_value(false); slider8.frp.set_precision_adjustment_disabled(true); - slider8.frp.set_value_indicator(slider::ValueIndicator::Thumb); + slider8.frp.kind(slider::Kind::Scrollbar(0.25)); slider8.frp.set_thumb_size(0.25); self.root.add_child(&slider8); self.sliders.borrow_mut().push(slider8); let slider9 = make_slider(&self.app); - slider9.frp.set_width(400.0); - slider9.frp.set_height(10.0); + slider9.set_size((400.0, 10.0)); slider9.set_y(-200.0); slider9.frp.set_value_indicator_color(color::Lcha(0.4, 0.7, 0.7, 1.0)); - slider9.frp.set_value_text_hidden(true); + slider9.frp.show_value(false); slider9.frp.set_precision_adjustment_disabled(true); - slider9.frp.set_value_indicator(slider::ValueIndicator::Thumb); + slider9.frp.kind(slider::Kind::Scrollbar(0.5)); slider9.frp.set_thumb_size(0.5); self.root.add_child(&slider9); self.sliders.borrow_mut().push(slider9); let slider10 = make_slider(&self.app); - slider10.frp.set_width(10.0); - slider10.frp.set_height(230.0); + slider10.set_size((10.0, 230)); slider10.set_y(-35.0); slider10.set_x(430.0); slider10.frp.set_value_indicator_color(color::Lcha(0.4, 0.7, 0.7, 1.0)); - slider10.frp.set_value_text_hidden(true); + slider10.frp.show_value(false); slider10.frp.set_precision_adjustment_disabled(true); - slider10.frp.set_value_indicator(slider::ValueIndicator::Thumb); - slider10.frp.set_orientation(slider::SliderOrientation::Vertical); + slider10.frp.kind(slider::Kind::Scrollbar(0.1)); + slider10.frp.orientation(Axis2::Y); self.root.add_child(&slider10); self.sliders.borrow_mut().push(slider10); } diff --git a/lib/rust/frp/src/nodes.rs b/lib/rust/frp/src/nodes.rs index 1d3fc45a8355..2f4ef9a790dd 100644 --- a/lib/rust/frp/src/nodes.rs +++ b/lib/rust/frp/src/nodes.rs @@ -126,6 +126,22 @@ impl Network { self.register(OwnedGate::new(label, event, behavior)) } + pub fn sampled_gate( + &self, + label: Label, + event: &T1, + behavior: &T2, + ) -> Stream> + where + T1: EventOutput, + T2: EventOutput, + { + let value = self.gate(label, event, behavior); + let on_gate_pass = self.on_true(label, behavior); + let value2 = self.sample(label, event, &on_gate_pass); + self.any(label, &value, &value2) + } + /// Like `gate` but passes the value when the condition is `false`. pub fn gate_not(&self, label: Label, event: &T1, behavior: &T2) -> Stream> where @@ -366,6 +382,16 @@ impl Network { self.all_with(label, t1, t2, |a, b| *a && *b) } + pub fn is_some(&self, label: Label, src: &T) -> Stream + where T: EventOutput> { + self.map(label, src, |t| t.is_some()) + } + + pub fn is_none(&self, label: Label, src: &T) -> Stream + where T: EventOutput> { + self.map(label, src, |t| t.is_none()) + } + /// Redirect second or third input to the output when the value of the first input is `false` or /// `true` respectively. The redirection is persistent. The first input doesn't have to fire to /// propagate the events fromm second and third input streams. Moreover, when first input @@ -1067,6 +1093,42 @@ impl Network { self.register(OwnedAllWith6::new(label, t1, t2, t3, t4, t5, t6, f)) } + /// Specialized version `all_with`. + pub fn all_with7( + &self, + label: Label, + t1: &T1, + t2: &T2, + t3: &T3, + t4: &T4, + t5: &T5, + t6: &T6, + t7: &T7, + f: F, + ) -> Stream + where + T1: EventOutput, + T2: EventOutput, + T3: EventOutput, + T4: EventOutput, + T5: EventOutput, + T6: EventOutput, + T7: EventOutput, + T: Data, + F: 'static + + Fn( + &Output, + &Output, + &Output, + &Output, + &Output, + &Output, + &Output, + ) -> T, + { + self.register(OwnedAllWith7::new(label, t1, t2, t3, t4, t5, t6, t7, f)) + } + /// Specialized version `all_with`. pub fn all_with8( &self, @@ -4321,6 +4383,147 @@ impl Debug for AllWith6Data { + src1: watch::Ref, + src2: watch::Ref, + src3: watch::Ref, + src4: watch::Ref, + src5: watch::Ref, + src6: watch::Ref, + src7: watch::Ref, + function: F, +} +pub type OwnedAllWith7 = + stream::Node>; +pub type AllWith7 = + stream::WeakNode>; + +impl HasOutput for AllWith7Data +where + T1: EventOutput, + T2: EventOutput, + T3: EventOutput, + T4: EventOutput, + T5: EventOutput, + T6: EventOutput, + T7: EventOutput, + Out: Data, + F: 'static + + Fn( + &Output, + &Output, + &Output, + &Output, + &Output, + &Output, + &Output, + ) -> Out, +{ + type Output = Out; +} + +impl OwnedAllWith7 +where + T1: EventOutput, + T2: EventOutput, + T3: EventOutput, + T4: EventOutput, + T5: EventOutput, + T6: EventOutput, + T7: EventOutput, + Out: Data, + F: 'static + + Fn( + &Output, + &Output, + &Output, + &Output, + &Output, + &Output, + &Output, + ) -> Out, +{ + /// Constructor. + pub fn new( + label: Label, + t1: &T1, + t2: &T2, + t3: &T3, + t4: &T4, + t5: &T5, + t6: &T6, + t7: &T7, + function: F, + ) -> Self { + let src1 = watch_stream(t1); + let src2 = watch_stream(t2); + let src3 = watch_stream(t3); + let src4 = watch_stream(t4); + let src5 = watch_stream(t5); + let src6 = watch_stream(t6); + let src7 = watch_stream(t7); + let def = AllWith7Data { src1, src2, src3, src4, src5, src6, src7, function }; + let this = Self::construct(label, def); + let weak = this.downgrade(); + t1.register_target(weak.clone_ref().into()); + t2.register_target(weak.clone_ref().into()); + t3.register_target(weak.clone_ref().into()); + t4.register_target(weak.clone_ref().into()); + t5.register_target(weak.clone_ref().into()); + t6.register_target(weak.clone_ref().into()); + t7.register_target(weak.into()); + this + } +} + +impl stream::EventConsumer + for OwnedAllWith7 +where + T1: EventOutput, + T2: EventOutput, + T3: EventOutput, + T4: EventOutput, + T5: EventOutput, + T6: EventOutput, + T7: EventOutput, + Out: Data, + F: 'static + + Fn( + &Output, + &Output, + &Output, + &Output, + &Output, + &Output, + &Output, + ) -> Out, +{ + fn on_event(&self, stack: CallStack, _: &T) { + let value1 = self.src1.value(); + let value2 = self.src2.value(); + let value3 = self.src3.value(); + let value4 = self.src4.value(); + let value5 = self.src5.value(); + let value6 = self.src6.value(); + let value7 = self.src7.value(); + + let out = (self.function)(&value1, &value2, &value3, &value4, &value5, &value6, &value7); + self.emit_event(stack, &out); + } +} + +impl Debug for AllWith7Data { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "AllWith8Data") + } +} + + + // ================ // === AllWith8 === // ================ diff --git a/lib/rust/prelude/src/option.rs b/lib/rust/prelude/src/option.rs index 8f18b5f308ba..47bb61789ef7 100644 --- a/lib/rust/prelude/src/option.rs +++ b/lib/rust/prelude/src/option.rs @@ -13,6 +13,10 @@ pub trait OptionOps { where U: Default, F: FnOnce(Self::Item) -> U; + fn if_some_or_default(self, f: F) -> U + where + U: Default, + F: FnOnce() -> U; fn map_ref_or_default(&self, f: F) -> U where U: Default, @@ -51,6 +55,13 @@ impl OptionOps for Option { self.map_or_else(U::default, f) } + fn if_some_or_default(self, f: F) -> U + where + U: Default, + F: FnOnce() -> U, { + self.map_or_else(U::default, |_| f()) + } + fn map_ref_or_default(&self, f: F) -> U where U: Default, diff --git a/lib/rust/types/src/dim.rs b/lib/rust/types/src/dim.rs index 6c142d1da8a3..548bffa3b6bb 100644 --- a/lib/rust/types/src/dim.rs +++ b/lib/rust/types/src/dim.rs @@ -342,6 +342,30 @@ pub struct Z; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct W; +/// An axis in 2D space. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[allow(missing_docs)] +pub enum Axis2 { + X, + Y, +} + +impl Default for Axis2 { + fn default() -> Self { + Self::X + } +} + +impl Axis2 { + /// The orthogonal axis to the current one. + pub fn orthogonal(self) -> Self { + match self { + Self::X => Self::Y, + Self::Y => Self::X, + } + } +} + /// Component getter for the given dimension. #[allow(missing_docs)] pub trait Dim: Dim1 { @@ -432,6 +456,79 @@ gen_dim_impl_for_vector!(Vector2, x, y); gen_dim_impl_for_vector!(Vector3, x, y, z); gen_dim_impl_for_vector!(Vector4, x, y, z, w); +impl Dim for Vector2 { + fn get_dim(&self, dim: Axis2) -> Self::Dim1Type { + match dim { + Axis2::X => self.x(), + Axis2::Y => self.y(), + } + } +} + +impl DimSetter for Vector2 { + fn set_dim(&mut self, dim: Axis2, value: Self::Dim1Type) { + match dim { + Axis2::X => self.set_x(value), + Axis2::Y => self.set_y(value), + } + } + + fn set_dim_checked(&mut self, dim: Axis2, value: Self::Dim1Type) -> bool { + match dim { + Axis2::X => + if self.x() == value { + false + } else { + self.set_x(value); + true + }, + Axis2::Y => + if self.y() == value { + false + } else { + self.set_y(value); + true + }, + } + } +} + +impl Dim<&Axis2> for Vector2 { + fn get_dim(&self, dim: &Axis2) -> Self::Dim1Type { + match dim { + Axis2::X => self.x(), + Axis2::Y => self.y(), + } + } +} + +impl DimSetter<&Axis2> for Vector2 { + fn set_dim(&mut self, dim: &Axis2, value: Self::Dim1Type) { + match dim { + Axis2::X => self.set_x(value), + Axis2::Y => self.set_y(value), + } + } + + fn set_dim_checked(&mut self, dim: &Axis2, value: Self::Dim1Type) -> bool { + match dim { + Axis2::X => + if self.x() == value { + false + } else { + self.set_x(value); + true + }, + Axis2::Y => + if self.y() == value { + false + } else { + self.set_y(value); + true + }, + } + } +} // ===================================================== From 0d84a601dde48af9701b6be891fb754ec3aa26ee Mon Sep 17 00:00:00 2001 From: Michael Mauderer Date: Tue, 25 Apr 2023 22:28:15 +0200 Subject: [PATCH 04/14] Ability to change the execution environment between design and live. (#6341) Integrate the UI for electing the Execution Environment with the Language Server and unify existing uses. Implements #5930 + actual integration instead of just mocking it. https://user-images.githubusercontent.com/1428930/232919438-6e1e295a-34fe-4756-86a4-5f5d8f718fa0.mp4 # Important Notes The console output is only emitted as part of the `INFO` level. A better check would be to look at the messages sent to the backend in the developer console. --- CHANGELOG.md | 3 + Cargo.lock | 14 +- .../engine-protocol/src/language_server.rs | 7 +- .../src/language_server/types.rs | 66 ++++++ app/gui/docs/product/shortcuts.md | 1 + app/gui/src/controller/graph/executed.rs | 10 + app/gui/src/model/execution_context.rs | 8 + app/gui/src/model/execution_context/plain.rs | 14 ++ .../model/execution_context/synchronized.rs | 23 +- app/gui/src/presenter/graph.rs | 2 +- app/gui/src/presenter/project.rs | 31 ++- app/gui/view/Cargo.toml | 2 +- app/gui/view/examples/Cargo.toml | 2 +- .../Cargo.toml | 4 +- .../src/lib.rs | 16 +- app/gui/view/examples/interface/Cargo.toml | 2 +- app/gui/view/examples/interface/src/lib.rs | 3 +- app/gui/view/examples/src/lib.rs | 2 +- .../Cargo.toml | 2 +- .../src/lib.rs | 68 +++--- .../src/play_button.rs | 182 ++++++++++++++++ app/gui/view/graph-editor/Cargo.toml | 2 +- .../view/graph-editor/src/component/node.rs | 2 +- .../src/component/node/action_bar.rs | 3 +- .../graph-editor/src/execution_environment.rs | 73 +++++++ app/gui/view/graph-editor/src/lib.rs | 200 ++++-------------- app/gui/view/graph-editor/src/shortcuts.rs | 68 ++++++ app/gui/view/src/lib.rs | 2 +- .../ensogl/app/theme/hardcoded/src/lib.rs | 4 +- .../component/drop-down-menu/src/lib.rs | 41 ++-- 30 files changed, 605 insertions(+), 252 deletions(-) rename app/gui/view/examples/{execution-mode-dropdown => execution-environment-dropdown}/Cargo.toml (79%) rename app/gui/view/examples/{execution-mode-dropdown => execution-environment-dropdown}/src/lib.rs (71%) rename app/gui/view/{execution-mode-selector => execution-environment-selector}/Cargo.toml (93%) rename app/gui/view/{execution-mode-selector => execution-environment-selector}/src/lib.rs (78%) create mode 100644 app/gui/view/execution-environment-selector/src/play_button.rs create mode 100644 app/gui/view/graph-editor/src/execution_environment.rs create mode 100644 app/gui/view/graph-editor/src/shortcuts.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a3b3d90e2d0..185520cdc6e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,8 @@ 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. +- [The IDE UI element for selecting the execution mode of the project is now + sending messages to the backend.][6341]. #### EnsoGL (rendering engine) @@ -194,6 +196,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 diff --git a/Cargo.lock b/Cargo.lock index 8a2cc5970d00..d1b1ef04b395 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", @@ -4298,7 +4298,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 +4413,7 @@ dependencies = [ ] [[package]] -name = "ide-view-execution-mode-selector" +name = "ide-view-execution-environment-selector" version = "0.1.0" dependencies = [ "enso-frp", @@ -4446,7 +4446,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/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/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/graph.rs b/app/gui/src/presenter/graph.rs index f8cad3e1e11c..99d7a4c1d99e 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,7 +20,6 @@ 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; 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..ccaa4d31ef24 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 === 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/component/node.rs b/app/gui/view/graph-editor/src/component/node.rs index a1e096ff3ebb..41294e6ac264 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::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; 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/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 be5015aa7e8c..fd99f9058edc 100644 --- a/app/gui/view/graph-editor/src/lib.rs +++ b/app/gui/view/graph-editor/src/lib.rs @@ -34,6 +34,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,6 +43,7 @@ pub mod view; #[warn(missing_docs)] mod selection; +mod shortcuts; use crate::application::command::FrpNetworkProvider; use crate::component::node; @@ -50,12 +52,12 @@ use crate::component::visualization; use crate::component::visualization::instance::PreprocessorConfiguration; 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; @@ -75,7 +77,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; // =============== @@ -585,9 +588,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 === @@ -656,10 +661,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 { @@ -765,10 +766,10 @@ 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 (), } } @@ -1782,26 +1783,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: api::Private, - frp_public: api::Public, - 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, } @@ -1819,7 +1820,8 @@ 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 navigator = Navigator::new(scene, &scene.camera()); @@ -1858,7 +1860,7 @@ impl GraphEditorModel { frp_public: frp.public.clone_ref(), styles_frp, selection_controller, - execution_mode_selector, + execution_environment_selector, } .init() } @@ -1866,7 +1868,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); @@ -2678,67 +2680,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() } } @@ -3906,30 +3849,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); // ================== @@ -3961,48 +3881,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 === // ============= 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/lib/rust/ensogl/app/theme/hardcoded/src/lib.rs b/lib/rust/ensogl/app/theme/hardcoded/src/lib.rs index d06e694346ee..332f77a97d7c 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 { 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 } From 3d045a7ceb6e00ad95e8c427c484f3b0649b24b8 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 26 Apr 2023 19:52:13 +1000 Subject: [PATCH 05/14] Dashboard directory interactivity (#6279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * turn object into var * add todo * fix style * fixes * remove forgot password + reset password * remove signout * remove setusername * remove login * remove registration * fix comments * re-enable flag * rename div * add comment * fix lints * remove tailwind conf * Revert "remove registration" This reverts commit 02439c9b709f5f7bdab72171d2706bec45cdfe6e. * Revert "remove login" This reverts commit 8e6f9c11122142c41faddef8a857cd3546d81fab. * Revert "remove setusername" This reverts commit 84721bcccd12b83c05f79505a5e7b444505e7917. * Revert "remove signout" This reverts commit 08a96d3796108f32d3f26317b01248bdfe59c5fa. * Revert "remove forgot password + reset password" This reverts commit e52f51a76253894041c32f9bff4e33690b354176. * remove opener * move opener * tmp * prettier * expand docs * tmp * replace react-scripts with craco * add tailwindcss * switch to brands * tmp * tmp * tmp * fixmes * fixmes * fixmes * fixmes * fixmes * fixes for e-hern's comments * use abortcontroller * add docs * fixes * revert craco, fix windows build * remove from gitignore * remove unnecessary check * tmp * augment window * tmptmp * split errors back up * tmp * tmp * prettier * fix * Fix lints * Prepare for addition for `as T` lint * Add lint for early returns * Address review issues * Fix lints * remove withrouter * fix file length * fixes * fixes * remove dashboard * fix * use switch * prettier * fixes * prettier * fixes * run prettier * run prettier * run prettier * fix main page url * allow node.js debugging * fix lints * change not equal * prettier * Remove references to withRouter; fix lints * Run prettier * Add cloud endpoints * Add JSON-RPC endpoints * Add dashboard skeleton * Add components and edit dashboard * Run prettier * (WIP) Add cloud endpoints * Add rpc endpoints * Address review issues * Formatting and minor fixes for `newtype.ts` * Address review issues * Rename `Brand` to `NewtypeVariant` * Rename `Brand` to `NewtypeVariant` * Fix formatting in `newtype.ts` * Switch dashboard to esbuild * Minor fixes; move Tailwind generation into esbuild-config * Fix watching `content/` and `client/` * Bump esbuild binary versions; minor dependency list fixes * Add dashboard skeleton * Run prettier * Fixes; rename "npm run dev" to "npm run watch-dashboard" * Avoid writing esbuild outputs to disk for `dashboard/` * Convert watch-dashboard to be fully in-memory; rebuild css files on change * Remove obsolete FIXME * Remove unused constants * Run prettier * add missing styles * Fixes * Fix the fixes * Run prettier * Fixes; use nesting plugin to wrap tailwind preflight * Remove testing flag from client/watch * Minor fixes * Run prettier * Export newtypes * Make css rebuild when tailwind config changes * Fix endpoints * Finish copying changes over * Remove duplicate type definitions * Fix bundling for dashboard * Fix dashboard/bundle.ts erroring when build directory does not exist * Move CSS to Tailwind config * Run prettier * Update endpoints * Fix esbuild binary package names * Remove redundant "npx" prefix from build scripts * Remove unused dependency * Begin adding interactivity * workaround for mac freeze * Fix modal bugs * Begin implementing forms, split forms and modals into new files * Get form UI working * add missing sections * Minor fixes, save current directory to localStorage * Fixes for drop-to-upload Note: currently it is opening in a new tab instead of actually uploading * Address review issue * Fix prettier config; run prettier * Fix live-reload of `npm run watch-dashboard` * (WIP) * Fix service worker for client-side routing * Add close button to asset creation forms; fix saving directory to localStorage * Remove workaround for backend bug when listing directories * Fix drop-to-upload * Fix sizing * Fix spacing, add fixed paths * WIP: fix toast notification styles, begin adding context menu * WIP: Add context menu, minor fixes * Fix authentication on desktop IDE * Allow unused locals and parameters in tsconfig.json * Run prettier * Fix TypeScript errors * Fix modals; minor refactor * Implement context menus for labels; fixes * Add modal provider and switch all modals to provider; fixes * Fix modals and user icon size * Fixes * Remove obsolete files from incorrect merge * Address review issues * Fix type error * Stop removing `#root` * Fixes for cloud * Implement search on frontend side * Fix race condition related to `directoryId` * Hide directories, files and secrets tables on desktop IDE * Fix lint errors * Properly update visible projects when a project is created * Pass directory id to create project * Hide column display switcher; remove placeholder column data --------- Co-authored-by: Nikita Pekin Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Paweł Buchowski --- app/ide-desktop/eslint.config.js | 1 + .../lib/client/src/authentication.ts | 3 - .../components/forgotPassword.tsx | 1 + .../src/authentication/components/login.tsx | 8 +- .../src/authentication/providers/session.tsx | 29 +- .../src/authentication/src/components/svg.tsx | 26 +- .../components/confirmDeleteModal.tsx | 62 ++ .../src/dashboard/components/contextMenu.tsx | 28 + .../dashboard/components/contextMenuEntry.tsx | 29 + .../src/dashboard/components/createForm.tsx | 58 ++ .../src/dashboard/components/dashboard.tsx | 915 +++++++++++++----- .../components/directoryCreateForm.tsx | 63 ++ .../dashboard/components/fileCreateForm.tsx | 90 ++ .../src/dashboard/components/ide.tsx | 18 +- .../src/dashboard/components/label.tsx | 5 +- .../components/permissionDisplay.tsx | 2 +- .../components/projectCreateForm.tsx | 82 ++ .../src/dashboard/components/renameModal.tsx | 79 ++ .../src/dashboard/components/rows.tsx | 25 +- .../dashboard/components/secretCreateForm.tsx | 83 ++ .../src/dashboard/components/templates.tsx | 6 +- .../src/dashboard/components/topBar.tsx | 12 +- .../dashboard/components/uploadFileModal.tsx | 117 +++ .../authentication/src/dashboard/service.ts | 16 +- .../dashboard/src/authentication/src/error.ts | 22 + .../src/authentication/src/fileInfo.ts | 29 + .../src/authentication/src/hooks.tsx | 11 + .../authentication/src/uploadMultipleFiles.ts | 57 ++ .../lib/dashboard/src/tailwind.css | 3 +- .../lib/dashboard/tailwind.config.ts | 3 + app/ide-desktop/package-lock.json | 105 +- app/ide-desktop/package.json | 2 +- 32 files changed, 1637 insertions(+), 353 deletions(-) create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenu.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenuEntry.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/createForm.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryCreateForm.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/fileCreateForm.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectCreateForm.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretCreateForm.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/uploadFileModal.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/fileInfo.ts create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/uploadMultipleFiles.ts 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" } } From 72f83c2dd578e6ac1812e647785e8a42f0997ee3 Mon Sep 17 00:00:00 2001 From: Ilya Bogdanov Date: Wed, 26 Apr 2023 15:26:51 +0300 Subject: [PATCH 06/14] Rollback event handling changes for the mouse (#6396) Fixes #6385 Partial rollback of #6364 It turns out that preventing default for mouse events is a bad idea in general. It shouldn't affect other fixed bugs because (afaik) all of them were caused by keyboard events. We're still preventing default for keyboard events. --- lib/rust/ensogl/core/src/control/io/mouse/event.rs | 13 +++++-------- .../core/src/display/navigation/navigator/events.rs | 4 ++++ 2 files changed, 9 insertions(+), 8 deletions(-) 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..f6476f7b06ac 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, @@ -225,13 +229,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/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); From dcfbf841b30765f9c511bc0e4a48cc64f6f73154 Mon Sep 17 00:00:00 2001 From: GregoryTravis Date: Wed, 26 Apr 2023 10:01:49 -0400 Subject: [PATCH 07/14] Replace Table should_equal with should_equal_verbose (#6405) Table.should_equal_verbose shows the contents of the tables on failure; let's make this the standard comparison. --- .../src/In_Memory/Parse_To_Table_Spec.enso | 14 ++-- .../src/In_Memory/Split_Tokenize_Spec.enso | 68 +++++++++---------- test/Table_Tests/src/Util.enso | 26 +++---- 3 files changed, 52 insertions(+), 56 deletions(-) diff --git a/test/Table_Tests/src/In_Memory/Parse_To_Table_Spec.enso b/test/Table_Tests/src/In_Memory/Parse_To_Table_Spec.enso index 1fb189cf7ee7..47510dfa02bb 100644 --- a/test/Table_Tests/src/In_Memory/Parse_To_Table_Spec.enso +++ b/test/Table_Tests/src/In_Memory/Parse_To_Table_Spec.enso @@ -18,48 +18,48 @@ spec = expected = Table.from_rows ["Column"] [["a"], ["ab12"], ["bt100"], ["c12"], ["d20"], ["q"]] actual = "a 7 ab12 bt100 c12d20q 12".parse_to_table "[a-z]+\d*" - actual.should_equal_verbose expected + actual.should_equal expected Test.group "Text.parse_to_table with groups" <| Test.specify "with groups" <| expected = Table.from_rows ["Column 1", "Column 2"] [["ab", 12], ["bt", 100], ["c", 12], ["d", 20]] actual = "a 7 ab-12 bt-100 c-12d-20q q8 12".parse_to_table "([a-z]+)-(\d*)" - actual.should_equal_verbose expected + actual.should_equal expected Test.specify "with named groups" <| expected = Table.from_rows ["letters", "Column 2"] [["ab", 12], ["bt", 100], ["c", 12], ["d", 20]] actual = "a 7 ab-12 bt-100 c-12d-20q q8 12".parse_to_table "(?[a-z]+)-(\d*)" - actual.should_equal_verbose expected + actual.should_equal expected Test.group "Text.parse_to_table with case-insensitivity" <| Test.specify "case insensitivity" <| expected = Table.from_rows ["Column 1", "Column 2"] [["a", "B"], ["A", "b"], ["a", "b"], ["A", "B"]] actual = "xy aB Ab ab AB".parse_to_table "(a)(b)" case_sensitivity=Case_Sensitivity.Insensitive - actual.should_equal_verbose expected + actual.should_equal expected Test.group "Text.parse_to_table parsing" <| Test.specify "parsing on" <| expected = Table.from_rows ["Column 1", "Column 2"] [["ab", 12], ["bt", 100], ["c", 12], ["d", 20]] actual = "a 7 ab-12 bt-100 c-12d-20q q8 12".parse_to_table "([a-z]+)-(\d*)" - actual.should_equal_verbose expected + actual.should_equal expected actual.columns.map .value_type . should_equal [Value_Type.Char Nothing True, Value_Type.Integer Bits.Bits_64] Test.specify "parsing on, with a mixed column" <| expected = Table.from_rows ["Column 1", "Column 2"] [["ab", "12"], ["bt", "100"], ["c", "012"], ["d", "20"]] actual = "a 7 ab-12 bt-100 c-012d-20q q8 12".parse_to_table "([a-z]+)-(\d*)" - actual.should_equal_verbose expected + actual.should_equal expected actual.columns.map .value_type . should_equal [Value_Type.Char Nothing True, Value_Type.Char Nothing True] Test.specify "parsing off" <| expected = Table.from_rows ["Column 1", "Column 2"] [["ab", "12"], ["bt", "100"], ["c", "12"], ["d", "20"]] actual = "a 7 ab-12 bt-100 c-12d-20q q8 12".parse_to_table "([a-z]+)-(\d*)" parse_values=False - actual.should_equal_verbose expected + actual.should_equal expected actual.columns.map .value_type . should_equal [Value_Type.Char Nothing True, Value_Type.Char Nothing True] Test.group "Text.parse_to_table errors" <| diff --git a/test/Table_Tests/src/In_Memory/Split_Tokenize_Spec.enso b/test/Table_Tests/src/In_Memory/Split_Tokenize_Spec.enso index 31f4a3d18a1c..07f866df508c 100644 --- a/test/Table_Tests/src/In_Memory/Split_Tokenize_Spec.enso +++ b/test/Table_Tests/src/In_Memory/Split_Tokenize_Spec.enso @@ -16,7 +16,7 @@ spec = expected_rows = [[0, "a", "c", Nothing], [1, "c", "d", "ef"], [2, "gh", "ij", "u"]] expected = Table.from_rows ["foo", "bar 0", "bar 1", "bar 2"] expected_rows t2 = t.split_to_columns "bar" "b" - t2.should_equal_verbose expected + t2.should_equal expected Test.specify "can do split_to_rows" <| cols = [["foo", [0, 1, 2]], ["bar", ["abc", "cbdbef", "ghbijbu"]]] @@ -24,7 +24,7 @@ spec = expected_rows = [[0, "a"], [0, "c"], [1, "c"], [1, "d"], [1, "ef"], [2, "gh"], [2, "ij"], [2, "u"]] expected = Table.from_rows ["foo", "bar"] expected_rows t2 = t.split_to_rows "bar" "b" - t2.should_equal_verbose expected + t2.should_equal expected Test.specify "can do split_to_columns with some Nothings" <| cols = [["foo", [0, 1, 2, 3]], ["bar", ["abc", "cbdbef", Nothing, "ghbijbu"]]] @@ -32,7 +32,7 @@ spec = expected_rows = [[0, "a", "c", Nothing], [1, "c", "d", "ef"], [2, Nothing, Nothing, Nothing], [3, "gh", "ij", "u"]] expected = Table.from_rows ["foo", "bar 0", "bar 1", "bar 2"] expected_rows t2 = t.split_to_columns "bar" "b" - t2.should_equal_verbose expected + t2.should_equal expected Test.specify "can do split_to_rows with some Nothings" <| cols = [["foo", [0, 1, 2, 3]], ["bar", ["abc", "cbdbef", Nothing, "ghbijbu"]]] @@ -40,7 +40,7 @@ spec = expected_rows = [[0, "a"], [0, "c"], [1, "c"], [1, "d"], [1, "ef"], [3, "gh"], [3, "ij"], [3, "u"]] expected = Table.from_rows ["foo", "bar"] expected_rows t2 = t.split_to_rows "bar" "b" - t2.should_equal_verbose expected + t2.should_equal expected Test.group "Table.tokenize" <| Test.specify "can do tokenize_to_columns" <| @@ -49,7 +49,7 @@ spec = expected_rows = [[0, "12", "34", "5"], [1, "23", Nothing, Nothing], [2, "2", "4", "55"]] expected = Table.from_rows ["foo", "bar 0", "bar 1", "bar 2"] expected_rows t2 = t.tokenize_to_columns "bar" "\d+" - t2.should_equal_verbose expected + t2.should_equal expected Test.specify "can do tokenize_to_rows" <| cols = [["foo", [0, 1, 2]], ["bar", ["a12b34r5", "23", "2r4r55"]]] @@ -57,7 +57,7 @@ spec = expected_rows = [[0, "12"], [0, "34"], [0, "5"], [1, "23"], [2, "2"], [2, "4"], [2, "55"]] expected = Table.from_rows ["foo", "bar"] expected_rows t2 = t.tokenize_to_rows "bar" "\d+" - t2.should_equal_verbose expected + t2.should_equal expected Test.specify "can do tokenize_to_columns with some nothings" <| cols = [["foo", [0, 1, 2, 3]], ["bar", ["a12b34r5", Nothing, "23", "2r4r55"]]] @@ -65,7 +65,7 @@ spec = expected_rows = [[0, "12", "34", "5"], [1, Nothing, Nothing, Nothing], [2, "23", Nothing, Nothing], [3, "2", "4", "55"]] expected = Table.from_rows ["foo", "bar 0", "bar 1", "bar 2"] expected_rows t2 = t.tokenize_to_columns "bar" "\d+" - t2.should_equal_verbose expected + t2.should_equal expected Test.specify "can do tokenize_to_rows with some Nothings" <| cols = [["foo", [0, 1, 2, 3]], ["bar", ["a12b34r5", Nothing, "23", "2r4r55"]]] @@ -73,7 +73,7 @@ spec = expected_rows = [[0, "12"], [0, "34"], [0, "5"], [2, "23"], [3, "2"], [3, "4"], [3, "55"]] expected = Table.from_rows ["foo", "bar"] expected_rows t2 = t.tokenize_to_rows "bar" "\d+" - t2.should_equal_verbose expected + t2.should_equal expected Test.specify "can do tokenize_to_rows with some rows that have no matches" <| cols = [["foo", [0, 1, 2, 3]], ["bar", ["a12b34r5", "23", "q", "2r4r55"]]] @@ -81,7 +81,7 @@ spec = expected_rows = [[0, "12"], [0, "34"], [0, "5"], [1, "23"], [3, "2"], [3, "4"], [3, "55"]] expected = Table.from_rows ["foo", "bar"] expected_rows t2 = t.tokenize_to_rows "bar" "\d+" - t2.should_equal_verbose expected + t2.should_equal expected Test.specify "can do tokenize_to_columns with groups" <| cols = [["foo", [0, 1]], ["bar", ["r a-1, b-12,qd-50", "ab-10:bc-20c"]]] @@ -89,7 +89,7 @@ spec = expected_rows = [[0, "a1", "b12", "d50"], [1, "b10", "c20", Nothing]] expected = Table.from_rows ["foo", "bar 0", "bar 1", "bar 2"] expected_rows t2 = t.tokenize_to_columns "bar" "([a-z]).(\d+)" - t2.should_equal_verbose expected + t2.should_equal expected Test.specify "can do tokenize_to_rows with groups" <| cols = [["foo", [0, 1]], ["bar", ["r a-1, b-12,qd-50", "ab-10:bc-20c"]]] @@ -97,7 +97,7 @@ spec = expected_rows = [[0, "a1"], [0, "b12"], [0, "d50"], [1, "b10"], [1, "c20"]] expected = Table.from_rows ["foo", "bar"] expected_rows t2 = t.tokenize_to_rows "bar" "([a-z]).(\d+)" - t2.should_equal_verbose expected + t2.should_equal expected Test.specify "can do tokenize_to_columns case-insensitively" <| cols = [["foo", [0, 1, 2]], ["bar", ["aBqcE", "qcBr", "cCb"]]] @@ -105,7 +105,7 @@ spec = expected_rows = [[0, "B", "c", Nothing], [1, "c", "B", Nothing], [2, "c", "C", "b"]] expected = Table.from_rows ["foo", "bar 0", "bar 1", "bar 2"] expected_rows t2 = t.tokenize_to_columns "bar" "[bc]" case_sensitivity=Case_Sensitivity.Insensitive - t2.should_equal_verbose expected + t2.should_equal expected Test.specify "can do tokenize_to_rows case-insensitively" <| cols = [["foo", [0, 1, 2]], ["bar", ["aBqcE", "qcBr", "cCb"]]] @@ -113,7 +113,7 @@ spec = expected_rows = [[0, "B"], [0, "c"], [1, "c"], [1, "B"], [2, "c"], [2, "C"], [2, "b"]] expected = Table.from_rows ["foo", "bar"] expected_rows t2 = t.tokenize_to_rows "bar" "[bc]" case_sensitivity=Case_Sensitivity.Insensitive - t2.should_equal_verbose expected + t2.should_equal expected Test.group "Table.split/tokenize column count" <| Test.specify "should generate extra empty columns if column_count is set" <| @@ -122,7 +122,7 @@ spec = expected_rows = [[0, "a", "c", Nothing, Nothing], [1, "c", "d", "ef", Nothing], [2, "gh", "ij", "u", Nothing]] expected = Table.from_rows ["foo", "bar 0", "bar 1", "bar 2", "bar 3"] expected_rows t2 = t.split_to_columns "bar" "b" column_count=4 - t2.should_equal_verbose expected + t2.should_equal expected t2.at "bar 3" . value_type . is_text . should_be_true Test.specify "split should limit columns and return problems when exceeding the column limit" <| @@ -131,7 +131,7 @@ spec = expected_rows = [[0, "a", "c"], [1, "c", "d"], [2, "gh", "ij"]] expected = Table.from_rows ["foo", "bar 0", "bar 1"] expected_rows action = t.split_to_columns "bar" "b" column_count=2 on_problems=_ - tester = t-> t.should_equal_verbose expected + tester = t-> t.should_equal expected problems = [Column_Count_Exceeded.Error 2 3] Problems.test_problem_handling action problems tester @@ -141,7 +141,7 @@ spec = expected_rows = [[0, "a1", "b12", "d50"], [1, "b10", "c20", Nothing]] expected = Table.from_rows ["foo", "bar 0", "bar 1"] expected_rows action = t.tokenize_to_columns "bar" "([a-z]).(\d+)" column_count=2 on_problems=_ - tester = t-> t.should_equal_verbose expected + tester = t-> t.should_equal expected problems = [Column_Count_Exceeded.Error 2 3] Problems.test_problem_handling action problems tester @@ -151,7 +151,7 @@ spec = expected_rows = [[0, "gh", "ij", "u", Nothing], [1, "c", "d", "ef", Nothing], [2, "a", "c", Nothing, Nothing]] expected = Table.from_rows ["foo", "bar 0", "bar 1", "bar 2", "bar 3"] expected_rows t2 = t.split_to_columns "bar" "b" column_count=4 - t2.should_equal_verbose expected + t2.should_equal expected t2.at "bar 3" . value_type . is_text . should_be_true Test.group "Table.split/tokenize errors" <| @@ -188,7 +188,7 @@ spec = expected_rows = [[0, "a", "c", Nothing, "a"], [1, "c", "d", "ef", "b"], [2, "gh", "ij", "u", "c"]] expected = Table.from_rows ["foo", "bar 0", "bar 1_1", "bar 2", "bar 1"] expected_rows action = t.split_to_columns "bar" "b" on_problems=_ - tester = t-> t.should_equal_verbose expected + tester = t-> t.should_equal expected problems = [Duplicate_Output_Column_Names.Error ["bar 1"]] Problems.test_problem_handling action problems tester @@ -198,7 +198,7 @@ spec = expected_rows = [[0, "12", "34", "5", "a"], [1, "23", Nothing, Nothing, "b"], [2, "2", "4", "55", "c"]] expected = Table.from_rows ["foo", "bar 0", "bar 1_1", "bar 2", "bar 1"] expected_rows action = t.tokenize_to_columns "bar" "\d+" on_problems=_ - tester = t-> t.should_equal_verbose expected + tester = t-> t.should_equal expected problems = [Duplicate_Output_Column_Names.Error ["bar 1"]] Problems.test_problem_handling action problems tester @@ -209,91 +209,91 @@ spec = expected_rows = [[0, "a", "c", Nothing, 1], [1, "c", "d", "ef", 2], [2, "gh", "ij", "u", 3]] expected = Table.from_rows ["foo", "bar 0", "bar 1", "bar 2", "baz"] expected_rows t2 = t.split_to_columns "bar" "b" - t2.should_equal_verbose expected + t2.should_equal expected Test.group "Table.parse_to_columns" <| Test.specify "can parse to columns" <| t = Table.from_rows ["foo", "bar", "baz"] [["x", "12 34p q56", "y"], ["xx", "a48 59b", "yy"]] expected = Table.from_rows ["foo", "bar 0", "bar 1", "baz"] [["x", 1, 2, "y"], ["x", 3, 4, "y"], ["x", 5, 6, "y"], ["xx", 4, 8, "yy"], ["xx", 5, 9, "yy"]] actual = t.parse_to_columns "bar" "(\d)(\d)" - actual.should_equal_verbose expected + actual.should_equal expected Test.specify "no regex groups" <| t = Table.from_rows ["foo", "bar", "baz"] [["x", "12 34p q56", "y"], ["xx", "a48 59b", "yy"]] expected = Table.from_rows ["foo", "bar", "baz"] [["x", 12, "y"], ["x", 34, "y"], ["x", 56, "y"], ["xx", 48, "yy"], ["xx", 59, "yy"]] actual = t.parse_to_columns "bar" "\d\d" - actual.should_equal_verbose expected + actual.should_equal expected Test.specify "named groups" <| t = Table.from_rows ["foo", "bar", "baz"] [["x", "12 34p q56", "y"], ["xx", "a48 59b", "yy"]] expected = Table.from_rows ["foo", "xomt", "biff", "baz"] [["x", 1, 2, "y"], ["x", 3, 4, "y"], ["x", 5, 6, "y"], ["xx", 4, 8, "yy"], ["xx", 5, 9, "yy"]] actual = t.parse_to_columns "bar" "(?\d)(?\d)" - actual.should_equal_verbose expected + actual.should_equal expected Test.specify "non-participating groups" <| t = Table.from_rows ["foo", "bar", "baz"] [["x", "q1", "y"], ["xx", "qp", "yy"]] expected = Table.from_rows ["foo", "bar 0", "bar 1", "bar 2", "baz"] [["x", "1", 1, Nothing, "y"], ["xx", "p", Nothing, "p", "yy"]] actual = t.parse_to_columns "bar" "q((\d)|([a-z]))" - actual.should_equal_verbose expected + actual.should_equal expected Test.specify "case-insensitive" <| t = Table.from_rows ["foo", "bar", "baz"] [["x", "qq", "y"], ["xx", "qQ", "yy"]] expected = Table.from_rows ["foo", "bar 0", "baz"] [["x", "q", "y"], ["xx", "Q", "yy"]] actual = t.parse_to_columns "bar" "q(q)" case_sensitivity=Case_Sensitivity.Insensitive - actual.should_equal_verbose expected + actual.should_equal expected Test.specify "no post-parsing" <| t = Table.from_rows ["foo", "bar", "baz"] [["x", "12 34p q56", "y"], ["xx", "a48 59b", "yy"]] expected = Table.from_rows ["foo", "bar 0", "bar 1", "baz"] [["x", "1", "2", "y"], ["x", "3", "4", "y"], ["x", "5", "6", "y"], ["xx", "4", "8", "yy"], ["xx", "5", "9", "yy"]] actual = t.parse_to_columns "bar" "(\d)(\d)" parse_values=False - actual.should_equal_verbose expected + actual.should_equal expected Test.specify "column name clash" <| t = Table.from_rows ["foo", "bar", "bar 1"] [["x", "12 34p q56", "y"], ["xx", "a48 59b", "yy"]] expected = Table.from_rows ["foo", "bar 0", "bar 1_1", "bar 1"] [["x", 1, 2, "y"], ["x", 3, 4, "y"], ["x", 5, 6, "y"], ["xx", 4, 8, "yy"], ["xx", 5, 9, "yy"]] actual = t.parse_to_columns "bar" "(\d)(\d)" - actual.should_equal_verbose expected + actual.should_equal expected Test.specify "column and group name clash" <| t = Table.from_rows ["foo", "bar", "baz"] [["x", "123", "y"]] expected = Table.from_rows ["foo", "bar", "baz_1", "quux", "baz"] [["x", 1, 2, 3, "y"]] actual = t.parse_to_columns "bar" "(?\d)(?\d)(?\d)" - actual.should_equal_verbose expected + actual.should_equal expected Test.specify "empty table" <| t = Table.from_rows ["foo", "bar", "baz"] [["x", "a", "y"]] . take 0 expected = Table.from_rows ["foo", "bar", "baz"] [] actual = t.parse_to_columns "bar" "\d+" - actual.should_equal_verbose expected + actual.should_equal expected Test.specify "empty table, with regex groups" <| t = Table.from_rows ["foo", "bar", "baz"] [["x", "a", "y"]] . take 0 expected = Table.from_rows ["foo", "bar 0", "bar 1", "baz"] [["x", "a", "a", "y"]] . take 0 actual = t.parse_to_columns "bar" "(\d)(\d)" - actual.should_equal_verbose expected + actual.should_equal expected Test.specify "empty table, with named and unnamed regex groups" <| t = Table.from_rows ["foo", "bar", "baz"] [["x", "a", "y"]] . take 0 expected = Table.from_rows ["foo", "quux", "bar 0", "foo_1", "bar 1", "baz"] [["x", "a", "a", "a", "a", "y"]] . take 0 actual = t.parse_to_columns "bar" "(?)(\d)(?\d)(\d)" - actual.should_equal_verbose expected + actual.should_equal expected Test.specify "input with no matches" <| t = Table.from_rows ["foo", "bar", "baz"] [["x", "a", "y"]] expected = Table.from_rows ["foo", "bar", "baz"] [] actual = t.parse_to_columns "bar" "\d+" - actual.should_equal_verbose expected + actual.should_equal expected Test.specify "input with no matches, with regex groups" <| t = Table.from_rows ["foo", "bar", "baz"] [["x", "a", "y"]] expected = Table.from_rows ["foo", "bar 0", "bar 1", "baz"] [] actual = t.parse_to_columns "bar" "(\d)(\d)" - actual.should_equal_verbose expected + actual.should_equal expected Test.specify "input with no matches, with named and unnamed regex groups" <| t = Table.from_rows ["foo", "bar", "baz"] [["x", "a", "y"]] expected = Table.from_rows ["foo", "quux", "bar 0", "foo_1", "bar 1", "baz"] [] actual = t.parse_to_columns "bar" "(?)(\d)(?\d)(\d)" - actual.should_equal_verbose expected + actual.should_equal expected main = Test_Suite.run_main spec diff --git a/test/Table_Tests/src/Util.enso b/test/Table_Tests/src/Util.enso index 048d59261506..aa20695bda50 100644 --- a/test/Table_Tests/src/Util.enso +++ b/test/Table_Tests/src/Util.enso @@ -7,21 +7,17 @@ import Standard.Test.Extensions polyglot java import org.enso.base_test_helpers.FileSystemHelper -Table.should_equal self expected = - self_cols = self.columns - that_cols = expected.columns - self_cols.map .name . should_equal (that_cols.map .name) frames_to_skip=1 - self_cols.map .to_vector . should_equal (that_cols.map .to_vector) frames_to_skip=1 - -Table.should_equal_verbose self expected = - tables_equal t0 t1 = - same_headers = (t0.columns.map .name) == (t1.columns.map .name) - same_columns = (t0.columns.map .to_vector) == (t1.columns.map .to_vector) - same_headers && same_columns - equal = tables_equal self expected - if equal.not then - msg = 'Tables differ.\nActual:\n' + self.display + '\nExpected:\n' + expected.display - Test.fail msg +Table.should_equal self expected = case expected of + _ : Table -> + tables_equal t0 t1 = + same_headers = (t0.columns.map .name) == (t1.columns.map .name) + same_columns = (t0.columns.map .to_vector) == (t1.columns.map .to_vector) + same_headers && same_columns + equal = tables_equal self expected + if equal.not then + msg = 'Tables differ.\nActual:\n' + self.display + '\nExpected:\n' + expected.display + Test.fail msg + _ -> Test.fail "Got a Table, but expected a "+expected.to_display_text Column.should_equal self expected = if self.name != expected.name then From 400cdbe893f10348a2758bb6258f6b68132768dc Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 27 Apr 2023 00:59:39 +1000 Subject: [PATCH 08/14] Cloud/desktop mode switcher (#6308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fixmes * fixmes * fixmes * fixmes * fixmes * fixes for e-hern's comments * use abortcontroller * add docs * fixes * revert craco, fix windows build * remove from gitignore * remove unnecessary check * tmp * augment window * tmptmp * split errors back up * tmp * tmp * prettier * fix * Fix lints * Prepare for addition for `as T` lint * Add lint for early returns * Address review issues * Fix lints * remove withrouter * fix file length * fixes * fixes * remove dashboard * fix * use switch * prettier * fixes * prettier * fixes * run prettier * run prettier * run prettier * fix main page url * allow node.js debugging * fix lints * change not equal * prettier * Remove references to withRouter; fix lints * Run prettier * Add cloud endpoints * Add JSON-RPC endpoints * Add dashboard skeleton * Add components and edit dashboard * Run prettier * (WIP) Add cloud endpoints * Add rpc endpoints * Address review issues * Formatting and minor fixes for `newtype.ts` * Address review issues * Rename `Brand` to `NewtypeVariant` * Rename `Brand` to `NewtypeVariant` * Fix formatting in `newtype.ts` * Switch dashboard to esbuild * Minor fixes; move Tailwind generation into esbuild-config * Fix watching `content/` and `client/` * Bump esbuild binary versions; minor dependency list fixes * Add dashboard skeleton * Run prettier * Fixes; rename "npm run dev" to "npm run watch-dashboard" * Avoid writing esbuild outputs to disk for `dashboard/` * Convert watch-dashboard to be fully in-memory; rebuild css files on change * Remove obsolete FIXME * Remove unused constants * Run prettier * add missing styles * Fixes * Fix the fixes * Run prettier * Fixes; use nesting plugin to wrap tailwind preflight * Remove testing flag from client/watch * Minor fixes * Run prettier * Export newtypes * Make css rebuild when tailwind config changes * Fix endpoints * Finish copying changes over * Remove duplicate type definitions * Feat: top-bar styles and changePassword feature * Fix: remove eslint disable comments * Fix bundling for dashboard * Fix dashboard/bundle.ts erroring when build directory does not exist * Move CSS to Tailwind config * Run prettier * Copy changes from old branch * Update endpoints * Fix esbuild binary package names * Remove redundant "npx" prefix from build scripts * Remove unused dependency * workaround for mac freeze * Fix bug * add missing sections * Fix: bug * Address review issue * Fix prettier config; run prettier * Fix live-reload of `npm run watch-dashboard` * Fix service worker for client-side routing * Remove workaround for backend bug when listing directories * Fix sizing * Fix spacing, add fixed paths * Address review issues; minor fixes * Fix authentication on desktop IDE * Run prettier * Allow unused locals and parameters in tsconfig.json * Run prettier * Fix TypeScript errors * Run prettier * Fix eslint errors, restructure code * Minor fixes and other changes * Revert incorrect change to `cognito/changePassword` * Remove unused file * Run prettier * Merge with top bar; implement switching between IDE and dashboard * Animate project switcher (WIP) * Fix IDE and project switcher animation * Fix eslint errors * Change `#dashboard` to `.dashboard` for Tailwind * Split `-authentication` option into `-cloud.authentication` and `-cloud.dashboard` * Address review issues * Add description for cloud option group * Extract custom CSS values into Tailwind config; use ModalProvider * Hide topbar in IDE view * Add project manager backend service * Begin fixing IDE open * (WIP) * Clean up unused code * Minor fixes * Fix * Fix local backend's usage of project manager (WIP) * WIP * Minor fixes * Fix scrollbar showing because of margins * Expose `runProject` instead of `main` * Keep persistent websocket to Project Manager * Fix spinner state bug * Fix race condition when switching between backends * Fix loading local projects * Make switching local projects work * Fix opening cloud projects * Add shortcut to switch back to dashboard * Fix templates for desktop; other fixes * Fix status check polling in `ProjectActionButton` not being stopped * Run prettier * Retry reconnecting to Project Manager * fix back to dashboard shortcut on macOS * Fixes * More fixes * Rename `backendModule` to `cloudService` --------- Co-authored-by: Nikita Pekin Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Paweł Buchowski Co-authored-by: Nctdtman <137032445@qq.com> --- app/ide-desktop/eslint.config.js | 4 + app/ide-desktop/lib/content/esbuild-config.ts | 9 +- app/ide-desktop/lib/content/src/index.html | 1 - app/ide-desktop/lib/content/src/index.ts | 208 ++++---- app/ide-desktop/lib/content/src/newtype.ts | 39 -- .../lib/content/src/project_manager.ts | 166 ------- app/ide-desktop/lib/content/src/run.js | 4 - app/ide-desktop/lib/content/src/style.css | 5 - .../src/authentication/providers/auth.tsx | 13 +- .../src/authentication/src/components/app.tsx | 34 +- .../src/authentication/src/components/svg.tsx | 65 +++ .../dashboard/{service.ts => cloudService.ts} | 43 +- .../src/dashboard/components/dashboard.tsx | 444 +++++++++++------- .../components/directoryCreateForm.tsx | 6 +- .../dashboard/components/fileCreateForm.tsx | 6 +- .../src/dashboard/components/ide.tsx | 150 +++--- .../components/projectActionButton.tsx | 146 +++--- .../components/projectCreateForm.tsx | 6 +- .../dashboard/components/secretCreateForm.tsx | 6 +- .../src/dashboard/components/templates.tsx | 67 ++- .../src/dashboard/components/topBar.tsx | 46 +- .../dashboard/components/uploadFileModal.tsx | 6 +- .../src/dashboard/components/userMenu.tsx | 1 + .../src/dashboard/localService.ts | 150 ++++++ .../src/dashboard/projectManager.ts | 227 +++++++++ .../src/authentication/src/index.tsx | 12 +- .../authentication/src/providers/backend.tsx | 43 ++ .../authentication/src/uploadMultipleFiles.ts | 6 +- .../lib/dashboard/src/serviceWorker.ts | 14 + app/ide-desktop/lib/types/globals.d.ts | 7 +- 30 files changed, 1210 insertions(+), 724 deletions(-) delete mode 100644 app/ide-desktop/lib/content/src/newtype.ts delete mode 100644 app/ide-desktop/lib/content/src/project_manager.ts delete mode 100644 app/ide-desktop/lib/content/src/run.js rename app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/{service.ts => cloudService.ts} (95%) create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localService.ts create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager.ts create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/providers/backend.tsx diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index f3453bc427a3..9c820d608023 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -198,6 +198,10 @@ const RESTRICTED_SYNTAXES = [ 'TSAsExpression:has(TSUnknownKeyword, TSNeverKeyword, TSAnyKeyword) > TSAsExpression', message: 'Use type assertions to specific types instead of `unknown`, `any` or `never`', }, + { + selector: 'IfStatement > ExpressionStatement', + message: 'Wrap `if` branches in `{}`', + }, ] /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/app/ide-desktop/lib/content/esbuild-config.ts b/app/ide-desktop/lib/content/esbuild-config.ts index f7a620ebfd68..3db5b078676b 100644 --- a/app/ide-desktop/lib/content/esbuild-config.ts +++ b/app/ide-desktop/lib/content/esbuild-config.ts @@ -96,7 +96,6 @@ export function bundlerOptions(args: Arguments) { entryPoints: [ pathModule.resolve(THIS_PATH, 'src', 'index.ts'), pathModule.resolve(THIS_PATH, 'src', 'index.html'), - pathModule.resolve(THIS_PATH, 'src', 'run.js'), pathModule.resolve(THIS_PATH, 'src', 'style.css'), pathModule.resolve(THIS_PATH, 'src', 'docsStyle.css'), ...wasmArtifacts.split(pathModule.delimiter), @@ -108,13 +107,9 @@ export function bundlerOptions(args: Arguments) { outbase: 'src', plugins: [ { - // This is a workaround that is needed - // because esbuild panics when using `loader: { '.js': 'copy' }`. - // See https://github.com/evanw/esbuild/issues/3041. - // Setting `loader: 'copy'` prevents this file from being converted to ESM - // because of the `"type": "module"` in the `package.json`. // This file MUST be in CommonJS format because it is loaded using `Function()` - // in `ensogl/pack/js/src/runner/index.ts` + // in `ensogl/pack/js/src/runner/index.ts`. + // All other files are ESM because of `"type": "module"` in `package.json`. name: 'pkg-js-is-cjs', setup: build => { build.onLoad({ filter: /[/\\]pkg.js$/ }, async ({ path }) => ({ diff --git a/app/ide-desktop/lib/content/src/index.html b/app/ide-desktop/lib/content/src/index.html index 65625b65e350..d4e33dfb5555 100644 --- a/app/ide-desktop/lib/content/src/index.html +++ b/app/ide-desktop/lib/content/src/index.html @@ -37,7 +37,6 @@ -
diff --git a/app/ide-desktop/lib/content/src/index.ts b/app/ide-desktop/lib/content/src/index.ts index e1d0145b2787..3b5da038dcf0 100644 --- a/app/ide-desktop/lib/content/src/index.ts +++ b/app/ide-desktop/lib/content/src/index.ts @@ -8,8 +8,6 @@ import * as authentication from 'enso-authentication' import * as contentConfig from 'enso-content-config' import * as app from '../../../../../target/ensogl-pack/linked-dist/index' -import * as projectManager from './project_manager' -import GLOBAL_CONFIG from '../../../../gui/config.yaml' assert { type: 'yaml' } const logger = app.log.logger @@ -25,6 +23,8 @@ const ESBUILD_EVENT_NAME = 'change' const SECOND = 1000 /** Time in seconds after which a `fetchTimeout` ends. */ const FETCH_TIMEOUT = 300 +/** The `id` attribute of the element that the IDE will be rendered into. */ +const IDE_ELEMENT_ID = 'root' // =================== // === Live reload === @@ -119,104 +119,132 @@ function displayDeprecatedVersionDialog() { } // ======================== -// === Main Entry Point === +// === Main entry point === // ======================== interface StringConfig { [key: string]: StringConfig | string } -class Main { - async main(inputConfig: StringConfig) { - const config = Object.assign( - { - loader: { - wasmUrl: 'pkg-opt.wasm', - jsUrl: 'pkg.js', - assetsUrl: 'dynamic-assets', - }, - }, - inputConfig - ) - - const appInstance = new app.App({ - config, - configOptions: contentConfig.OPTIONS, - packageInfo: { - version: BUILD_INFO.version, - engineVersion: BUILD_INFO.engineVersion, +// Hack to mutate `configOptions.OPTIONS` +let currentAppInstance: app.App | null = new app.App({ + config: { + loader: { + wasmUrl: 'pkg-opt.wasm', + jsUrl: 'pkg.js', + assetsUrl: 'dynamic-assets', + }, + }, + configOptions: contentConfig.OPTIONS, + packageInfo: { + version: BUILD_INFO.version, + engineVersion: BUILD_INFO.engineVersion, + }, +}) + +function tryStopProject() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + currentAppInstance?.wasm?.drop?.() +} + +async function runProject(inputConfig?: StringConfig) { + tryStopProject() + const rootElement = document.getElementById(IDE_ELEMENT_ID) + if (!rootElement) { + logger.error(`The root element (the element with ID '${IDE_ELEMENT_ID}') was not found.`) + } else { + while (rootElement.firstChild) { + rootElement.removeChild(rootElement.firstChild) + } + } + + const config = Object.assign( + { + loader: { + wasmUrl: 'pkg-opt.wasm', + jsUrl: 'pkg.js', + assetsUrl: 'dynamic-assets', }, - }) + }, + inputConfig + ) - if (appInstance.initialized) { - if (contentConfig.OPTIONS.options.dataCollection.value) { - // TODO: Add remote-logging here. - } - if (!(await checkMinSupportedVersion(contentConfig.OPTIONS))) { - displayDeprecatedVersionDialog() - } else { - if ( - (contentConfig.OPTIONS.options.authentication.value || - contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value) && - contentConfig.OPTIONS.groups.startup.options.entry.value === - contentConfig.OPTIONS.groups.startup.options.entry.default - ) { - const hideAuth = () => { - const auth = document.getElementById('dashboard') - const ide = document.getElementById('root') - if (auth) auth.style.display = 'none' - if (ide) ide.style.display = '' - } - /** This package is an Electron desktop app (i.e., not in the Cloud), so - * we're running on the desktop. */ - /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/345 - * `content` and `dashboard` packages **MUST BE MERGED INTO ONE**. The IDE - * should only have one entry point. Right now, we have two. One for the cloud - * and one for the desktop. Once these are merged, we can't hardcode the - * platform here, and need to detect it from the environment. */ - const platform = authentication.Platform.desktop - /** FIXME [PB]: https://github.com/enso-org/cloud-v2/issues/366 - * React hooks rerender themselves multiple times. It is resulting in multiple - * Enso main scene being initialized. As a temporary workaround we check whether - * appInstance was already ran. Target solution should move running appInstance - * where it will be called only once. */ - let appInstanceRan = false - const onAuthenticated = () => { - if ( - !contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value - ) { - hideAuth() - if (!appInstanceRan) { - appInstanceRan = true - void appInstance.run() - } - } - } - authentication.run({ - logger, - platform, - projectManager: projectManager.ProjectManager.default(), - showDashboard: - contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value, - onAuthenticated, - }) - } else { - void appInstance.run() - } - const email = contentConfig.OPTIONS.groups.authentication.options.email.value - // The default value is `""`, so a truthiness check is most appropriate here. - if (email) { - logger.log(`User identified as '${email}'.`) - } - } + currentAppInstance = new app.App({ + config, + configOptions: contentConfig.OPTIONS, + packageInfo: { + version: BUILD_INFO.version, + engineVersion: BUILD_INFO.engineVersion, + }, + }) + console.log('bruh', currentAppInstance) + + if (!currentAppInstance.initialized) { + console.error('Failed to initialize the application.') + } else { + if (contentConfig.OPTIONS.options.dataCollection.value) { + // TODO: Add remote-logging here. + } + if (!(await checkMinSupportedVersion(contentConfig.OPTIONS))) { + displayDeprecatedVersionDialog() } else { - console.error('Failed to initialize the application.') + const email = contentConfig.OPTIONS.groups.authentication.options.email.value + // The default value is `""`, so a truthiness check is most appropriate here. + if (email) { + logger.log(`User identified as '${email}'.`) + } + void currentAppInstance.run() } } } -const API = new Main() - -// @ts-expect-error `globalConfig.windowAppScopeName` is not known at typecheck time. -// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -window[GLOBAL_CONFIG.windowAppScopeName] = API +if ( + (contentConfig.OPTIONS.options.authentication.value || + contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value) && + contentConfig.OPTIONS.groups.startup.options.entry.value === + contentConfig.OPTIONS.groups.startup.options.entry.default +) { + window.tryStopProject = tryStopProject + window.runProject = runProject + const hideAuth = () => { + const auth = document.getElementById('dashboard') + const ide = document.getElementById('root') + if (auth) { + auth.style.display = 'none' + } + if (ide) { + ide.hidden = false + } + } + /** This package is an Electron desktop app (i.e., not in the Cloud), so + * we're running on the desktop. */ + /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/345 + * `content` and `dashboard` packages **MUST BE MERGED INTO ONE**. The IDE + * should only have one entry point. Right now, we have two. One for the cloud + * and one for the desktop. Once these are merged, we can't hardcode the + * platform here, and need to detect it from the environment. */ + const platform = authentication.Platform.desktop + /** FIXME [PB]: https://github.com/enso-org/cloud-v2/issues/366 + * React hooks rerender themselves multiple times. It is resulting in multiple + * Enso main scene being initialized. As a temporary workaround we check whether + * appInstance was already ran. Target solution should move running appInstance + * where it will be called only once. */ + let appInstanceRan = false + const onAuthenticated = () => { + if (!contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value) { + hideAuth() + if (!appInstanceRan) { + appInstanceRan = true + void runProject() + } + } + } + authentication.run({ + logger, + platform, + showDashboard: contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value, + onAuthenticated, + }) +} else { + void runProject() +} diff --git a/app/ide-desktop/lib/content/src/newtype.ts b/app/ide-desktop/lib/content/src/newtype.ts deleted file mode 100644 index b3a85ec61518..000000000000 --- a/app/ide-desktop/lib/content/src/newtype.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** @file TypeScript's closest equivalent of `newtype`s. */ - -interface NewtypeVariant { - // eslint-disable-next-line @typescript-eslint/naming-convention - _$type: TypeName -} - -/** Used to create a "branded type", - * which contains a property that only exists at compile time. - * - * `Newtype` and `Newtype` are not compatible with each other, - * however both are regular `string`s at runtime. - * - * This is useful in parameters that require values from a certain source, - * for example IDs for a specific object type. - * - * It is similar to a `newtype` in other languages. - * Note however because TypeScript is structurally typed, - * a branded type is assignable to its base type: - * `a: string = asNewtype>(b)` successfully typechecks. */ -export type Newtype = NewtypeVariant & T - -interface NotNewtype { - // eslint-disable-next-line @typescript-eslint/naming-convention - _$type?: never -} - -export function asNewtype>( - s: NotNewtype & Omit -): T { - // This cast is unsafe. - // `T` has an extra property `_$type` which is used purely for typechecking - // and does not exist at runtime. - // - // The property name is specifically chosen to trigger eslint's `naming-convention` lint, - // so it should not be possible to accidentally create a value with such a type. - // eslint-disable-next-line no-restricted-syntax - return s as unknown as T -} diff --git a/app/ide-desktop/lib/content/src/project_manager.ts b/app/ide-desktop/lib/content/src/project_manager.ts deleted file mode 100644 index 58777d0551db..000000000000 --- a/app/ide-desktop/lib/content/src/project_manager.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** @file This module defines the Project Manager endpoint. */ -import * as newtype from './newtype' - -const PROJECT_MANAGER_ENDPOINT = 'ws://127.0.0.1:30535' - -// ============= -// === Types === -// ============= - -export enum MissingComponentAction { - fail = 'Fail', - install = 'Install', - forceInstallBroken = 'ForceInstallBroken', -} - -interface Result { - result: T -} - -// This intentionally has the same brand as in the cloud backend API. -export type ProjectId = newtype.Newtype -export type ProjectName = newtype.Newtype -export type UTCDateTime = newtype.Newtype - -interface ProjectMetadata { - name: ProjectName - namespace: string - id: ProjectId - engineVersion: string | null - lastOpened: UTCDateTime | null -} - -interface IpWithSocket { - host: string - port: number -} - -interface ProjectList { - projects: ProjectMetadata[] -} - -interface CreateProject { - projectId: ProjectId -} - -interface OpenProject { - engineVersion: string - languageServerJsonAddress: IpWithSocket - languageServerBinaryAddress: IpWithSocket - projectName: ProjectName - projectNamespace: string -} - -// ================================ -// === Parameters for endpoints === -// ================================ - -export interface OpenProjectParams { - projectId: ProjectId - missingComponentAction: MissingComponentAction -} - -export interface CloseProjectParams { - projectId: ProjectId -} - -export interface ListProjectsParams { - numberOfProjects?: number -} - -export interface CreateProjectParams { - name: ProjectName - projectTemplate?: string - version?: string - missingComponentAction?: MissingComponentAction -} - -export interface RenameProjectParams { - projectId: ProjectId - name: ProjectName -} - -export interface DeleteProjectParams { - projectId: ProjectId -} - -export interface ListSamplesParams { - projectId: ProjectId -} - -// ======================= -// === Project Manager === -// ======================= - -/** A WebSocket endpoint to the project manager. */ -export class ProjectManager { - constructor(protected readonly connectionUrl: string) {} - - static default() { - return new ProjectManager(PROJECT_MANAGER_ENDPOINT) - } - - public async sendRequest(method: string, params: unknown): Promise> { - const req = { - jsonrpc: '2.0', - id: 0, - method, - params, - } - - const ws = new WebSocket(this.connectionUrl) - return new Promise>((resolve, reject) => { - ws.onopen = () => { - ws.send(JSON.stringify(req)) - } - ws.onmessage = event => { - // There is no way to avoid this; `JSON.parse` returns `any`. - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - resolve(JSON.parse(event.data)) - } - ws.onerror = error => { - reject(error) - } - }).finally(() => { - ws.close() - }) - } - - /** * Open an existing project. */ - public async openProject(params: OpenProjectParams): Promise> { - return this.sendRequest('project/open', params) - } - - /** * Close an open project. */ - public async closeProject(params: CloseProjectParams): Promise> { - return this.sendRequest('project/close', params) - } - - /** * Get the projects list, sorted by open time. */ - public async listProjects(params: ListProjectsParams): Promise> { - return this.sendRequest('project/list', params) - } - - /** * Create a new project. */ - public async createProject(params: CreateProjectParams): Promise> { - return this.sendRequest('project/create', { - missingComponentAction: MissingComponentAction.install, - ...params, - }) - } - - /** * Rename a project. */ - public async renameProject(params: RenameProjectParams): Promise> { - return this.sendRequest('project/rename', params) - } - - /** * Delete a project. */ - public async deleteProject(params: DeleteProjectParams): Promise> { - return this.sendRequest('project/delete', params) - } - - /** * Get the list of sample projects that are available to the user. */ - public async listSamples(params: ListSamplesParams): Promise> { - return this.sendRequest('project/listSample', params) - } -} diff --git a/app/ide-desktop/lib/content/src/run.js b/app/ide-desktop/lib/content/src/run.js deleted file mode 100644 index 91a503a25ef6..000000000000 --- a/app/ide-desktop/lib/content/src/run.js +++ /dev/null @@ -1,4 +0,0 @@ -/** @file This file is used to simply run the IDE. It can be not invoked if the IDE needs to be used - * as a library. */ - -void window.enso.main() diff --git a/app/ide-desktop/lib/content/src/style.css b/app/ide-desktop/lib/content/src/style.css index 6e0fcf478620..e8afd0d5b252 100644 --- a/app/ide-desktop/lib/content/src/style.css +++ b/app/ide-desktop/lib/content/src/style.css @@ -73,11 +73,6 @@ /* End of fonts */ -html, -body { - height: 100vh; -} - body { margin: 0; overscroll-behavior: none; diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx index a6f5bac986bd..87e63b82d123 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx @@ -9,7 +9,8 @@ import toast from 'react-hot-toast' import * as app from '../../components/app' import * as authServiceModule from '../service' -import * as backendService from '../../dashboard/service' +import * as backendProvider from '../../providers/backend' +import * as cloudService from '../../dashboard/cloudService' import * as errorModule from '../../error' import * as loggerProvider from '../../providers/logger' import * as newtype from '../../newtype' @@ -49,7 +50,7 @@ export interface FullUserSession { /** User's email address. */ email: string /** User's organization information. */ - organization: backendService.UserOrOrganization + organization: cloudService.UserOrOrganization } /** Object containing the currently signed-in user's session data, if the user has not yet set their @@ -138,6 +139,7 @@ export function AuthProvider(props: AuthProviderProps) { const { authService, children } = props const { cognito } = authService const { session } = sessionProvider.useSession() + const { setBackend } = backendProvider.useSetBackend() const logger = loggerProvider.useLogger() const navigate = router.useNavigate() const onAuthenticated = react.useCallback(props.onAuthenticated, []) @@ -157,7 +159,8 @@ export function AuthProvider(props: AuthProviderProps) { } else { const { accessToken, email } = session.val - const backend = backendService.createBackend(accessToken, logger) + const backend = cloudService.createBackend(accessToken, logger) + setBackend(backend) const organization = await backend.usersMe() let newUserSession: UserSession if (!organization) { @@ -253,11 +256,11 @@ export function AuthProvider(props: AuthProviderProps) { /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/343 * The API client is reinitialised on every request. That is an inefficient way of usage. * Fix it by using React context and implementing it as a singleton. */ - const backend = backendService.createBackend(accessToken, logger) + const backend = cloudService.createBackend(accessToken, logger) await backend.createUser({ userName: username, - userEmail: newtype.asNewtype(email), + userEmail: newtype.asNewtype(email), }) navigate(app.DASHBOARD_PATH) toast.success(MESSAGES.setUsernameSuccess) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx index 279cd22ee9ff..bfc148108d13 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx @@ -38,12 +38,13 @@ import * as react from 'react' import * as router from 'react-router-dom' import * as toast from 'react-hot-toast' -import * as projectManagerModule from 'enso-content/src/project_manager' +import * as app from '../../../../../../../../target/ensogl-pack/linked-dist/index' import * as authService from '../authentication/service' import * as platformModule from '../platform' import * as authProvider from '../authentication/providers/auth' +import * as backendProvider from '../providers/backend' import * as loggerProvider from '../providers/logger' import * as modalProvider from '../providers/modal' import * as sessionProvider from '../authentication/providers/session' @@ -79,26 +80,16 @@ export const SET_USERNAME_PATH = '/set-username' // === App === // =========== -interface BaseAppProps { +/** Global configuration for the `App` component. */ +export interface AppProps { logger: loggerProvider.Logger platform: platformModule.Platform /** Whether the dashboard should be rendered. */ showDashboard: boolean + ide?: app.App onAuthenticated: () => void } -interface DesktopAppProps extends BaseAppProps { - platform: platformModule.Platform.desktop - projectManager: projectManagerModule.ProjectManager -} - -interface OtherAppProps extends BaseAppProps { - platform: Exclude -} - -/** Global configuration for the `App` component. */ -export type AppProps = DesktopAppProps | OtherAppProps - /** Component called by the parent module, returning the root React component for this * package. * @@ -171,12 +162,15 @@ function AppRouter(props: AppProps) { userSession={userSession} registerAuthEventListener={registerAuthEventListener} > - - {routes} - + {/* @ts-expect-error Auth will always set this before dashboard is rendered. */} + + + {routes} + + ) 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 c1ecda9868db..a00b416e20e9 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 @@ -235,6 +235,71 @@ export const CLOSE_ICON = ( ) +export const CLOUD_ICON = ( + + + +) + +export const COMPUTER_ICON = ( + + + +) + +export interface StopIconProps { + className?: string +} + +/** Icon displayed when a project is ready to stop. */ +export function StopIcon(props: StopIconProps) { + const { className } = props + return ( + + + + + + ) +} + // =========== // === Svg === // =========== diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/cloudService.ts similarity index 95% rename from app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts rename to app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/cloudService.ts index e0727a5c75f6..e7fa1a07c28d 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/cloudService.ts @@ -158,9 +158,15 @@ export interface CreatedProject extends BaseProject { packageName: string } +/** A `Project` returned by the `listProjects` endpoint. */ +export interface ListedProjectRaw extends CreatedProject { + address: Address | null +} + /** A `Project` returned by `listProjects`. */ export interface ListedProject extends CreatedProject { - address: Address | null + binaryAddress: Address | null + jsonAddress: Address | null } /** A `Project` returned by `updateProject`. */ @@ -170,6 +176,12 @@ export interface UpdatedProject extends BaseProject { engineVersion: VersionNumber | null } +/** A user/organization's project containing and/or currently executing code. */ +export interface ProjectRaw extends ListedProjectRaw { + ideVersion: VersionNumber | null + engineVersion: VersionNumber | null +} + /** A user/organization's project containing and/or currently executing code. */ export interface Project extends ListedProject { ideVersion: VersionNumber | null @@ -417,7 +429,7 @@ interface ListDirectoryResponseBody { /** HTTP response body for the "list projects" endpoint. */ interface ListProjectsResponseBody { - projects: ListedProject[] + projects: ListedProjectRaw[] } /** HTTP response body for the "list files" endpoint. */ @@ -542,7 +554,17 @@ export class Backend { if (response.status !== STATUS_OK) { return this.throw('Unable to list projects.') } else { - return (await response.json()).projects + return (await response.json()).projects.map(project => ({ + ...project, + jsonAddress: + project.address != null + ? newtype.asNewtype
(`${project.address}json`) + : null, + binaryAddress: + project.address != null + ? newtype.asNewtype
(`${project.address}binary`) + : null, + })) } } @@ -574,11 +596,22 @@ export class Backend { * * @throws An error if a 401 or 404 status code was received. */ async getProjectDetails(projectId: ProjectId): Promise { - const response = await this.get(getProjectDetailsPath(projectId)) + const response = await this.get(getProjectDetailsPath(projectId)) if (response.status !== STATUS_OK) { return this.throw(`Unable to get details of project with ID '${projectId}'.`) } else { - return await response.json() + const project = await response.json() + return { + ...project, + jsonAddress: + project.address != null + ? newtype.asNewtype
(`${project.address}json`) + : null, + binaryAddress: + project.address != null + ? newtype.asNewtype
(`${project.address}binary`) + : null, + } } } 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 9619f1b9b31a..a87f55139f44 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 @@ -2,19 +2,20 @@ * interactive components. */ import * as react from 'react' -import * as projectManagerModule from 'enso-content/src/project_manager' - -import * as auth from '../../authentication/providers/auth' -import * as backend from '../service' +import * as cloudService from '../cloudService' import * as fileInfo from '../../fileInfo' import * as hooks from '../../hooks' -import * as loggerProvider from '../../providers/logger' -import * as modalProvider from '../../providers/modal' +import * as localService from '../localService' import * as newtype from '../../newtype' import * as platformModule from '../../platform' import * as svg from '../../components/svg' import * as uploadMultipleFiles from '../../uploadMultipleFiles' +import * as auth from '../../authentication/providers/auth' +import * as backendProvider from '../../providers/backend' +import * as loggerProvider from '../../providers/logger' +import * as modalProvider from '../../providers/modal' + import PermissionDisplay, * as permissionDisplay from './permissionDisplay' import ContextMenu from './contextMenu' import ContextMenuEntry from './contextMenuEntry' @@ -73,8 +74,8 @@ enum Column { export interface CreateFormProps { left: number top: number - backend: backend.Backend - directoryId: backend.DirectoryId + backend: cloudService.Backend + directoryId: cloudService.DirectoryId onSuccess: () => void } @@ -89,23 +90,28 @@ export interface CreateFormProps { // eslint-disable-next-line @typescript-eslint/no-inferrable-types const EXPERIMENTAL: boolean = true +/** The `id` attribute of the element into which the IDE will be rendered. */ +const IDE_ELEMENT_ID = 'root' /** 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', - [backend.AssetType.file]: 'Files', - [backend.AssetType.secret]: 'Secrets', - [backend.AssetType.directory]: 'Folders', +const ASSET_TYPE_NAME: Record = { + [cloudService.AssetType.project]: 'Projects', + [cloudService.AssetType.file]: 'Files', + [cloudService.AssetType.secret]: 'Secrets', + [cloudService.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, +const ASSET_TYPE_CREATE_FORM: Record< + cloudService.AssetType, + (props: CreateFormProps) => JSX.Element +> = { + [cloudService.AssetType.project]: ProjectCreateForm, + [cloudService.AssetType.file]: FileCreateForm, + [cloudService.AssetType.secret]: SecretCreateForm, + [cloudService.AssetType.directory]: DirectoryCreateForm, } /** English names for every column except for the name column. */ @@ -121,23 +127,23 @@ const COLUMN_NAME: Record, string> = { } as const /** The corresponding `Permissions` for each backend `PermissionAction`. */ -const PERMISSION: Record = { - [backend.PermissionAction.own]: { type: permissionDisplay.Permission.owner }, - [backend.PermissionAction.execute]: { +const PERMISSION: Record = { + [cloudService.PermissionAction.own]: { type: permissionDisplay.Permission.owner }, + [cloudService.PermissionAction.execute]: { type: permissionDisplay.Permission.regular, read: false, write: false, docsWrite: false, exec: true, }, - [backend.PermissionAction.edit]: { + [cloudService.PermissionAction.edit]: { type: permissionDisplay.Permission.regular, read: false, write: true, docsWrite: false, exec: false, }, - [backend.PermissionAction.read]: { + [cloudService.PermissionAction.read]: { type: permissionDisplay.Permission.regular, read: true, write: false, @@ -181,100 +187,128 @@ const COLUMNS_FOR: Record = { // ======================== /** Returns the id of the root directory for a user or organization. */ -function rootDirectoryId(userOrOrganizationId: backend.UserOrOrganizationId) { - return newtype.asNewtype( - userOrOrganizationId.replace(/^organization-/, `${backend.AssetType.directory}-`) +function rootDirectoryId(userOrOrganizationId: cloudService.UserOrOrganizationId) { + return newtype.asNewtype( + userOrOrganizationId.replace(/^organization-/, `${cloudService.AssetType.directory}-`) ) } +// FIXME[sb]: While this works, throwing a runtime error can be avoided +// if types are properly narrowed, e.g. using a type guard instead. +function asCloudBackend( + backend: cloudService.Backend | localService.Backend +): cloudService.Backend { + if (!('checkResources' in backend)) { + throw new Error('This functionality only works with the cloud backend.') + } else { + return backend + } +} + // ================= // === Dashboard === // ================= -interface BaseDashboardProps { - logger: loggerProvider.Logger +export interface DashboardProps { platform: platformModule.Platform } -interface DesktopDashboardProps extends BaseDashboardProps { - platform: platformModule.Platform.desktop - projectManager: projectManagerModule.ProjectManager -} - -interface OtherDashboardProps extends BaseDashboardProps { - platform: Exclude -} - -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 { platform } = props + const logger = loggerProvider.useLogger() const { accessToken, organization } = auth.useFullUserSession() - const backendService = backend.createBackend(accessToken, logger) + const { backend } = backendProvider.useBackend() + const { setBackend } = backendProvider.useSetBackend() const { modal } = modalProvider.useModal() const { setModal, unsetModal } = modalProvider.useSetModal() + const [backendPlatform, setBackendPlatform] = react.useState(platformModule.Platform.cloud) const [refresh, doRefresh] = hooks.useRefresh() const [query, setQuery] = react.useState('') const [directoryId, setDirectoryId] = react.useState(rootDirectoryId(organization.id)) const [directoryStack, setDirectoryStack] = react.useState< - backend.Asset[] + cloudService.Asset[] >([]) // Defined by the spec as `compact` by default, however it is not ready yet. const [columnDisplayMode, setColumnDisplayMode] = react.useState(ColumnDisplayMode.release) + 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 [projectAssets, setProjectAssetsRaw] = react.useState< - backend.Asset[] + cloudService.Asset[] >([]) const [directoryAssets, setDirectoryAssetsRaw] = react.useState< - backend.Asset[] + cloudService.Asset[] >([]) const [secretAssets, setSecretAssetsRaw] = react.useState< - backend.Asset[] + cloudService.Asset[] + >([]) + const [fileAssets, setFileAssetsRaw] = react.useState< + cloudService.Asset[] >([]) - const [fileAssets, setFileAssetsRaw] = react.useState[]>( - [] - ) const [visibleProjectAssets, setVisibleProjectAssets] = react.useState< - backend.Asset[] + cloudService.Asset[] >([]) const [visibleDirectoryAssets, setVisibleDirectoryAssets] = react.useState< - backend.Asset[] + cloudService.Asset[] >([]) const [visibleSecretAssets, setVisibleSecretAssets] = react.useState< - backend.Asset[] + cloudService.Asset[] >([]) const [visibleFileAssets, setVisibleFileAssets] = react.useState< - backend.Asset[] + cloudService.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[]) { + react.useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if ( + // On macOS, we need to check for combination of `alt` + `d` which is `∂` (`del`). + (event.key === 'd' || event.key === '∂') && + event.ctrlKey && + event.altKey && + !event.shiftKey && + !event.metaKey + ) { + setTab(Tab.dashboard) + const ideElement = document.getElementById(IDE_ELEMENT_ID) + if (ideElement) { + ideElement.hidden = true + } + } + } + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('keydown', onKeyDown) + } + }, []) + + function setProjectAssets( + newProjectAssets: cloudService.Asset[] + ) { setProjectAssetsRaw(newProjectAssets) setVisibleProjectAssets(newProjectAssets.filter(asset => asset.title.includes(query))) } - function setDirectoryAssets(newDirectoryAssets: backend.Asset[]) { + function setDirectoryAssets( + newDirectoryAssets: cloudService.Asset[] + ) { setDirectoryAssetsRaw(newDirectoryAssets) setVisibleDirectoryAssets(newDirectoryAssets.filter(asset => asset.title.includes(query))) } - function setSecretAssets(newSecretAssets: backend.Asset[]) { + function setSecretAssets(newSecretAssets: cloudService.Asset[]) { setSecretAssetsRaw(newSecretAssets) setVisibleSecretAssets(newSecretAssets.filter(asset => asset.title.includes(query))) } - function setFileAssets(newFileAssets: backend.Asset[]) { + function setFileAssets(newFileAssets: cloudService.Asset[]) { setFileAssetsRaw(newFileAssets) setVisibleFileAssets(newFileAssets.filter(asset => asset.title.includes(query))) } @@ -287,7 +321,7 @@ function Dashboard(props: DashboardProps) { ) } - function enterDirectory(directoryAsset: backend.Asset) { + function enterDirectory(directoryAsset: cloudService.Asset) { setDirectoryId(directoryAsset.id) setDirectoryStack([...directoryStack, directoryAsset]) } @@ -297,7 +331,7 @@ function Dashboard(props: DashboardProps) { 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[] = + const cachedDirectoryStack: cloudService.Asset[] = JSON.parse(cachedDirectoryStackJson) setDirectoryStack(cachedDirectoryStack) const cachedDirectoryId = cachedDirectoryStack[cachedDirectoryStack.length - 1]?.id @@ -317,9 +351,9 @@ function Dashboard(props: DashboardProps) { /** React components for the name column. */ const nameRenderers: { - [Type in backend.AssetType]: (asset: backend.Asset) => JSX.Element + [Type in cloudService.AssetType]: (asset: cloudService.Asset) => JSX.Element } = { - [backend.AssetType.project]: projectAsset => ( + [cloudService.AssetType.project]: projectAsset => (
{ @@ -340,13 +374,17 @@ function Dashboard(props: DashboardProps) { project={projectAsset} openIde={async () => { setTab(Tab.ide) - setProject(await backendService.getProjectDetails(projectAsset.id)) + setProject(await backend.getProjectDetails(projectAsset.id)) + const ideElement = document.getElementById(IDE_ELEMENT_ID) + if (ideElement) { + ideElement.hidden = false + } }} /> {projectAsset.title}
), - [backend.AssetType.directory]: directoryAsset => ( + [cloudService.AssetType.directory]: directoryAsset => (
{ @@ -369,7 +407,7 @@ function Dashboard(props: DashboardProps) { {svg.DIRECTORY_ICON} {directoryAsset.title}
), - [backend.AssetType.secret]: secret => ( + [cloudService.AssetType.secret]: secret => (
{ @@ -389,7 +427,7 @@ function Dashboard(props: DashboardProps) { {svg.SECRET_ICON} {secret.title}
), - [backend.AssetType.file]: file => ( + [cloudService.AssetType.file]: file => (
{ @@ -415,7 +453,7 @@ function Dashboard(props: DashboardProps) { /** React components for every column except for the name column. */ const columnRenderer: Record< Exclude, - (asset: backend.Asset) => JSX.Element + (asset: cloudService.Asset) => JSX.Element > = { [Column.lastModified]: () => <>, [Column.sharedWith]: asset => ( @@ -461,17 +499,16 @@ function Dashboard(props: DashboardProps) { [Column.ide]: () => <>, } - function renderer(column: Column, assetType: Type) { + 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) + (nameRenderers[assetType] as (asset: cloudService.Asset) => JSX.Element) : columnRenderer[column] } /** Heading element for every column. */ - function ColumnHeading(column: Column, assetType: backend.AssetType) { + function ColumnHeading(column: Column, assetType: cloudService.AssetType) { return column === Column.name ? (
{ASSET_TYPE_NAME[assetType]} @@ -491,7 +528,7 @@ function Dashboard(props: DashboardProps) { left={buttonPosition.left} top={buttonPosition.top} // FIXME[sb]: Don't pass outdated `doRefresh` - maybe `backendService` too. - backend={backendService} + backend={asCloudBackend(backend)} directoryId={directoryId} onSuccess={doRefresh} /> @@ -514,11 +551,17 @@ function Dashboard(props: DashboardProps) { setVisibleFileAssets(fileAssets.filter(asset => asset.title.includes(query))) }, [query]) - 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)) + function setAssets(assets: cloudService.Asset[]) { + const newProjectAssets = assets.filter( + cloudService.assetIsType(cloudService.AssetType.project) + ) + const newDirectoryAssets = assets.filter( + cloudService.assetIsType(cloudService.AssetType.directory) + ) + const newSecretAssets = assets.filter( + cloudService.assetIsType(cloudService.AssetType.secret) + ) + const newFileAssets = assets.filter(cloudService.assetIsType(cloudService.AssetType.file)) setProjectAssets(newProjectAssets) setDirectoryAssets(newDirectoryAssets) setSecretAssets(newSecretAssets) @@ -528,36 +571,12 @@ function Dashboard(props: DashboardProps) { hooks.useAsyncEffect( null, async signal => { - let assets: backend.Asset[] - - switch (platform) { - case platformModule.Platform.cloud: { - assets = await backendService.listDirectory({ - parentId: directoryId, - }) - break - } - case platformModule.Platform.desktop: { - const result = await props.projectManager.listProjects({}) - const localProjects = result.result.projects - assets = [] - for (const localProject of localProjects) { - assets.push({ - type: backend.AssetType.project, - title: localProject.name, - id: localProject.id, - parentId: '', - permissions: null, - }) - } - break - } - } + const assets = await backend.listDirectory({ parentId: directoryId }) if (!signal.aborted) { setAssets(assets) } }, - [accessToken, directoryId, refresh] + [accessToken, directoryId, refresh, backend] ) react.useEffect(() => { @@ -606,51 +625,24 @@ function Dashboard(props: DashboardProps) { return `${prefix}${highestProjectIndex + 1}` } - async function handleCreateProject(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([ - ...projectAssets, - { - type: backend.AssetType.project, - title: projectAsset.name, - id: projectAsset.projectId, - parentId: '', - permissions: [], - }, - ]) - break - } - case platformModule.Platform.desktop: { - const result = await props.projectManager.createProject({ - name: newtype.asNewtype(projectName), - ...(templateName ? { projectTemplate: templateName } : {}), - }) - const newProject = result.result - setProjectAssets([ - ...projectAssets, - { - type: backend.AssetType.project, - title: projectName, - id: newProject.projectId, - parentId: '', - permissions: [], - }, - ]) - break - } + const body: cloudService.CreateProjectRequestBody = { + projectName, + projectTemplateName: templateName?.replace(/_/g, '').toLocaleLowerCase() ?? null, + parentDirectoryId: directoryId, } + const projectAsset = await backend.createProject(body) + setProjectAssets([ + ...projectAssets, + { + type: cloudService.AssetType.project, + title: projectAsset.name, + id: projectAsset.projectId, + parentId: '', + permissions: [], + }, + ]) } return ( @@ -664,19 +656,47 @@ function Dashboard(props: DashboardProps) { >
{ if (project && tab === Tab.dashboard) { setTab(Tab.ide) + const ideElement = document.getElementById(IDE_ELEMENT_ID) + if (ideElement) { + ideElement.hidden = false + } } else { setTab(Tab.dashboard) + const ideElement = document.getElementById(IDE_ELEMENT_ID) + if (ideElement) { + ideElement.hidden = true + } + } + }} + backendPlatform={backendPlatform} + setBackendPlatform={newBackendPlatform => { + setBackendPlatform(newBackendPlatform) + setProjectAssets([]) + setDirectoryAssets([]) + setSecretAssets([]) + setFileAssets([]) + switch (newBackendPlatform) { + case platformModule.Platform.desktop: + setBackend(localService.createBackend()) + break + case platformModule.Platform.cloud: + setBackend(cloudService.createBackend(accessToken, logger)) + break } }} query={query} setQuery={setQuery} /> - +

Drive

@@ -703,12 +723,16 @@ function Dashboard(props: DashboardProps) {
+
+ + +
+
+ + + + +
- +
- > + > items={visibleProjectAssets} getKey={proj => proj.id} placeholder={ @@ -801,8 +894,8 @@ function Dashboard(props: DashboardProps) { } columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ id: column, - heading: ColumnHeading(column, backend.AssetType.project), - render: renderer(column, backend.AssetType.project), + heading: ColumnHeading(column, cloudService.AssetType.project), + render: renderer(column, cloudService.AssetType.project), }))} onClick={projectAsset => { setSelectedAssets([projectAsset]) @@ -843,7 +936,7 @@ function Dashboard(props: DashboardProps) { name={projectAsset.title} assetType={projectAsset.type} doDelete={() => - backendService.deleteProject(projectAsset.id) + asCloudBackend(backend).deleteProject(projectAsset.id) } onSuccess={doRefresh} /> @@ -867,10 +960,10 @@ function Dashboard(props: DashboardProps) { )) }} /> - {platform === platformModule.Platform.cloud && ( + {backendPlatform === platformModule.Platform.cloud && ( <> - > + > items={visibleDirectoryAssets} getKey={dir => dir.id} placeholder={ @@ -881,8 +974,11 @@ function Dashboard(props: DashboardProps) { } columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ id: column, - heading: ColumnHeading(column, backend.AssetType.directory), - render: renderer(column, backend.AssetType.directory), + heading: ColumnHeading( + column, + cloudService.AssetType.directory + ), + render: renderer(column, cloudService.AssetType.directory), }))} onClick={directoryAsset => { setSelectedAssets([directoryAsset]) @@ -894,7 +990,7 @@ function Dashboard(props: DashboardProps) { }} /> - > + > items={visibleSecretAssets} getKey={secret => secret.id} placeholder={ @@ -905,8 +1001,8 @@ function Dashboard(props: DashboardProps) { } columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ id: column, - heading: ColumnHeading(column, backend.AssetType.secret), - render: renderer(column, backend.AssetType.secret), + heading: ColumnHeading(column, cloudService.AssetType.secret), + render: renderer(column, cloudService.AssetType.secret), }))} onClick={secret => { setSelectedAssets([secret]) @@ -922,7 +1018,7 @@ function Dashboard(props: DashboardProps) { name={secret.title} assetType={secret.type} doDelete={() => - backendService.deleteSecret(secret.id) + asCloudBackend(backend).deleteSecret(secret.id) } onSuccess={doRefresh} /> @@ -938,7 +1034,7 @@ function Dashboard(props: DashboardProps) { }} /> - > + > items={visibleFileAssets} getKey={file => file.id} placeholder={ @@ -949,8 +1045,8 @@ function Dashboard(props: DashboardProps) { } columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ id: column, - heading: ColumnHeading(column, backend.AssetType.file), - render: renderer(column, backend.AssetType.file), + heading: ColumnHeading(column, cloudService.AssetType.file), + render: renderer(column, cloudService.AssetType.file), }))} onClick={file => { setSelectedAssets([file]) @@ -971,7 +1067,9 @@ function Dashboard(props: DashboardProps) { backendService.deleteFile(file.id)} + doDelete={() => + asCloudBackend(backend).deleteFile(file.id) + } onSuccess={doRefresh} /> )) @@ -1001,7 +1099,7 @@ function Dashboard(props: DashboardProps) { )}
- {isFileBeingDragged ? ( + {isFileBeingDragged && backendPlatform === platformModule.Platform.cloud ? (
{ @@ -1014,7 +1112,7 @@ function Dashboard(props: DashboardProps) { event.preventDefault() setIsFileBeingDragged(false) await uploadMultipleFiles.uploadMultipleFiles( - backendService, + asCloudBackend(backend), directoryId, Array.from(event.dataTransfer.files) ) @@ -1025,7 +1123,7 @@ function Dashboard(props: DashboardProps) {
) : null} {/* This should be just `{modal}`, however TypeScript incorrectly throws an error. */} - {project && } + {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 index 8f13d6e4da19..e3b8f617227b 100644 --- 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 @@ -2,14 +2,14 @@ import * as react from 'react' import toast from 'react-hot-toast' -import * as backendModule from '../service' +import * as cloudService from '../cloudService' 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 + backend: cloudService.Backend + directoryId: cloudService.DirectoryId onSuccess: () => void } 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 index 20ae40b26c9b..b41008190b2e 100644 --- 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 @@ -2,14 +2,14 @@ import * as react from 'react' import toast from 'react-hot-toast' -import * as backendModule from '../service' +import * as cloudService from '../cloudService' 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 + backend: cloudService.Backend + directoryId: cloudService.DirectoryId onSuccess: () => void } 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 d453f5b67fb7..0fe9ef42edb9 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 @@ -1,36 +1,35 @@ /** @file Container that launches the IDE. */ import * as react from 'react' -import * as service from '../service' +import * as backendProvider from '../../providers/backend' +import * as cloudService from '../cloudService' +import * as platformModule from '../../platform' // ================= // === Constants === // ================= -/** The `id` attribute of the element that the IDE will be rendered into. */ +/** The `id` attribute of the element into which the IDE will be rendered. */ const IDE_ELEMENT_ID = 'root' const IDE_CDN_URL = 'https://ensocdn.s3.us-west-1.amazonaws.com/ide' +const JS_EXTENSION: Record = { + [platformModule.Platform.cloud]: '.js.gz', + [platformModule.Platform.desktop]: '.js', +} as const // ================= // === Component === // ================= interface Props { - project: service.Project - backendService: service.Backend + project: cloudService.Project + backendPlatform: platformModule.Platform } /** Container that launches the IDE. */ 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 => { - resolve = innerResolve - }) - return [promise, resolve] - }) + const { project, backendPlatform } = props + const { backend } = backendProvider.useBackend() react.useEffect(() => { document.getElementById(IDE_ELEMENT_ID)?.classList.remove('hidden') @@ -41,62 +40,77 @@ function Ide(props: Props) { react.useEffect(() => { void (async () => { - const ideVersion = ( - await backendService.listVersions({ - versionType: service.VersionType.ide, - default: true, - }) - )[0] - const projectIdeVersion = project.ideVersion?.value ?? ideVersion.number.value - const stylesheetLink = document.createElement('link') - stylesheetLink.rel = 'stylesheet' - stylesheetLink.href = `${IDE_CDN_URL}/${projectIdeVersion}/style.css` - const indexScript = document.createElement('script') - indexScript.src = `${IDE_CDN_URL}/${projectIdeVersion}/index.js.gz` - indexScript.addEventListener('load', () => { - console.log('loaded') - resolveLoaded() - }) - document.head.append(stylesheetLink) - document.body.append(indexScript) - })() - }, []) - - react.useEffect(() => { - void (async () => { - while (ideElement?.firstChild) { - ideElement.removeChild(ideElement.firstChild) + const ideVersion = + project.ideVersion?.value ?? + ('listVersions' in backend + ? await backend.listVersions({ + versionType: cloudService.VersionType.ide, + default: true, + }) + : null)?.[0].number.value + const engineVersion = + project.engineVersion?.value ?? + ('listVersions' in backend + ? await backend.listVersions({ + versionType: cloudService.VersionType.backend, + default: true, + }) + : null)?.[0].number.value + const jsonAddress = project.jsonAddress + const binaryAddress = project.binaryAddress + if (ideVersion == null) { + throw new Error('Could not get the IDE version of the project.') + } else if (engineVersion == null) { + throw new Error('Could not get the engine version of the project.') + } else if (jsonAddress == null) { + throw new Error("Could not get the address of the project's JSON endpoint.") + } else if (binaryAddress == null) { + throw new Error("Could not get the address of the project's binary endpoint.") + } else { + const assetsRoot = (() => { + switch (backendPlatform) { + case platformModule.Platform.cloud: + return `${IDE_CDN_URL}/${ideVersion}/` + case platformModule.Platform.desktop: + return '' + } + })() + const runNewProject = async () => { + const originalUrl = window.location.href + // The URL query contains commandline options when running in the desktop, + // which will break the entrypoint for opening a fresh IDE instance. + history.replaceState(null, '', new URL('.', originalUrl)) + await window.runProject({ + loader: { + assetsUrl: `${assetsRoot}dynamic-assets`, + wasmUrl: `${assetsRoot}pkg-opt.wasm`, + jsUrl: `${assetsRoot}pkg${JS_EXTENSION[backendPlatform]}`, + }, + engine: { + rpcUrl: jsonAddress, + dataUrl: binaryAddress, + preferredVersion: engineVersion, + }, + startup: { + project: project.packageName, + }, + }) + // Restore original URL so that initialization works correctly on refresh. + history.replaceState(null, '', originalUrl) + } + if (backendPlatform === platformModule.Platform.desktop) { + await runNewProject() + } else { + const script = document.createElement('script') + script.src = `${IDE_CDN_URL}/${engineVersion}/index.js.gz` + script.onload = async () => { + document.body.removeChild(script) + await runNewProject() + } + document.body.appendChild(script) + } + return } - const ideVersion = ( - await backendService.listVersions({ - versionType: service.VersionType.ide, - default: true, - }) - )[0] - const backendVersion = ( - await backendService.listVersions({ - versionType: service.VersionType.backend, - default: true, - }) - )[0] - const projectIdeVersion = project.ideVersion?.value ?? ideVersion.number.value - const projectEngineVersion = project.engineVersion?.value ?? backendVersion.number.value - await loaded - await window.enso.main({ - loader: { - assetsUrl: `${IDE_CDN_URL}/${projectIdeVersion}/dynamic-assets`, - wasmUrl: `${IDE_CDN_URL}/${projectIdeVersion}/pkg-opt.wasm`, - jsUrl: `${IDE_CDN_URL}/${projectIdeVersion}/pkg.js.gz`, - }, - engine: { - rpcUrl: `${project.address!}json`, - dataUrl: `${project.address!}binary`, - preferredVersion: projectEngineVersion, - }, - startup: { - project: project.packageName, - }, - }) })() }, [project]) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx index 95764520bd71..575d458ef20c 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx @@ -1,10 +1,8 @@ /** @file An interactive button displaying the status of a project. */ import * as react from 'react' -import * as reactDom from 'react-dom' -import * as auth from '../../authentication/providers/auth' -import * as backend from '../service' -import * as loggerProvider from '../../providers/logger' +import * as backendProvider from '../../providers/backend' +import * as cloudService from '../cloudService' import * as svg from '../../components/svg' // ============= @@ -23,7 +21,7 @@ enum SpinnerState { // ================= /** The interval between requests checking whether the IDE is ready. */ -const STATUS_CHECK_INTERVAL = 10000 +const CHECK_STATUS_INTERVAL = 10000 const SPINNER_CSS_CLASSES: Record = { [SpinnerState.initial]: 'dasharray-5 ease-linear', @@ -31,86 +29,68 @@ const SPINNER_CSS_CLASSES: Record = { [SpinnerState.done]: 'dasharray-100 duration-1000 ease-in', } as const -/** Displayed when a project is ready to stop. */ -function StopIcon(spinnerState: SpinnerState) { - return ( - - - - - - ) -} - // ================= // === Component === // ================= export interface ProjectActionButtonProps { - project: backend.Asset + project: cloudService.Asset openIde: () => void } /** An interactive button displaying the status of a project. */ function ProjectActionButton(props: ProjectActionButtonProps) { const { project, openIde } = props - const { accessToken } = auth.useFullUserSession() - const logger = loggerProvider.useLogger() - const backendService = backend.createBackend(accessToken, logger) + const { backend } = backendProvider.useBackend() - const [state, setState] = react.useState(backend.ProjectState.created) - const [checkStatusInterval, setCheckStatusInterval] = react.useState(null) + const [state, setState] = react.useState(cloudService.ProjectState.created) + const [isCheckingStatus, setIsCheckingStatus] = react.useState(false) const [spinnerState, setSpinnerState] = react.useState(SpinnerState.done) + react.useEffect(() => { + async function checkProjectStatus() { + const response = await backend.getProjectDetails(project.id) + + setState(response.state.type) + + if (response.state.type === cloudService.ProjectState.opened) { + setSpinnerState(SpinnerState.done) + setIsCheckingStatus(false) + } + } + if (!isCheckingStatus) { + return + } else { + const handle = window.setInterval( + () => void checkProjectStatus(), + CHECK_STATUS_INTERVAL + ) + return () => { + clearInterval(handle) + } + } + }, [isCheckingStatus]) + react.useEffect(() => { void (async () => { - const projectDetails = await backendService.getProjectDetails(project.id) + const projectDetails = await backend.getProjectDetails(project.id) setState(projectDetails.state.type) + if (projectDetails.state.type === cloudService.ProjectState.openInProgress) { + setSpinnerState(SpinnerState.initial) + setIsCheckingStatus(true) + } })() }, []) function closeProject() { - setState(backend.ProjectState.closed) - void backendService.closeProject(project.id) - - reactDom.unstable_batchedUpdates(() => { - setCheckStatusInterval(null) - if (checkStatusInterval != null) { - clearInterval(checkStatusInterval) - } - }) + setState(cloudService.ProjectState.closed) + window.tryStopProject() + void backend.closeProject(project.id) + setIsCheckingStatus(false) } function openProject() { - setState(backend.ProjectState.openInProgress) + setState(cloudService.ProjectState.openInProgress) setSpinnerState(SpinnerState.initial) // The `setTimeout` is required so that the completion percentage goes from // the `initial` fraction to the `loading` fraction, @@ -118,41 +98,27 @@ function ProjectActionButton(props: ProjectActionButtonProps) { setTimeout(() => { setSpinnerState(SpinnerState.loading) }, 0) - - void backendService.openProject(project.id) - - const checkProjectStatus = async () => { - const response = await backendService.getProjectDetails(project.id) - - setState(response.state.type) - - if (response.state.type === backend.ProjectState.opened) { - setCheckStatusInterval(null) - if (checkStatusInterval != null) { - clearInterval(checkStatusInterval) - } - setSpinnerState(SpinnerState.done) - } - } - - reactDom.unstable_batchedUpdates(() => { - setCheckStatusInterval( - window.setInterval(() => void checkProjectStatus(), STATUS_CHECK_INTERVAL) - ) - }) + void backend.openProject(project.id) + setIsCheckingStatus(true) } switch (state) { - case backend.ProjectState.created: - case backend.ProjectState.new: - case backend.ProjectState.closed: + case cloudService.ProjectState.created: + case cloudService.ProjectState.new: + case cloudService.ProjectState.closed: return - case backend.ProjectState.openInProgress: - return - case backend.ProjectState.opened: + case cloudService.ProjectState.openInProgress: + return ( + + ) + case cloudService.ProjectState.opened: return ( <> - + ) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectCreateForm.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectCreateForm.tsx index 12b6a0ee486b..68dd4bf13f93 100644 --- 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 @@ -2,14 +2,14 @@ import * as react from 'react' import toast from 'react-hot-toast' -import * as backendModule from '../service' +import * as cloudService from '../cloudService' 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 + backend: cloudService.Backend + directoryId: cloudService.DirectoryId onSuccess: () => void } 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 index d6994a085a01..c77ee7d32b21 100644 --- 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 @@ -2,14 +2,14 @@ import * as react from 'react' import toast from 'react-hot-toast' -import * as backendModule from '../service' +import * as cloudService from '../cloudService' 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 + backend: cloudService.Backend + directoryId: cloudService.DirectoryId onSuccess: () => void } 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 d34a67373f3c..0bdfca9c6dfe 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 @@ -1,18 +1,7 @@ /** @file Renders the list of templates from which a project can be created. */ +import * as platformModule from '../../platform' import * as svg from '../../components/svg' -// ================= -// === Constants === -// ================= - -/** - * Dash border spacing is not supported by native CSS. - * Therefore, use a background image to create the border. - * It is essentially an SVG image that was generated by the website. - * @see {@link https://kovart.github.io/dashed-border-generator} - */ -const BORDER = `url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%233e515f' stroke-width='4' stroke-dasharray='15%2c 15' stroke-dashoffset='0' stroke-linecap='butt'/%3e%3c/svg%3e")` - // ================= // === Templates === // ================= @@ -22,37 +11,70 @@ interface Template { title: string description: string id: string + background: string } -/** All templates for creating projects that have contents. */ -const TEMPLATES: Template[] = [ +/** The full list of templates available to cloud projects. */ +const CLOUD_TEMPLATES: Template[] = [ { title: 'Colorado COVID', id: 'Colorado_COVID', description: 'Learn to glue multiple spreadsheets to analyses all your data at once.', + background: '#6b7280', }, { title: 'KMeans', id: 'Kmeans', description: 'Learn where to open a coffee shop to maximize your income.', + background: '#6b7280', }, { title: 'NASDAQ Returns', id: 'NASDAQ_Returns', description: 'Learn how to clean your data to prepare it for advanced analysis.', + background: '#6b7280', }, { title: 'Restaurants', id: 'Orders', description: 'Learn how to clean your data to prepare it for advanced analysis.', + background: '#6b7280', }, { title: 'Github Stars', id: 'Stargazers', description: 'Learn how to clean your data to prepare it for advanced analysis.', + background: '#6b7280', + }, +] + +/** The full list of templates available to local projects. */ +const DESKTOP_TEMPLATES: Template[] = [ + { + title: 'Combine spreadsheets', + id: 'Orders', + description: 'Glue multiple spreadsheets together to analyse all your data at once.', + background: 'url("/spreadsheets.png") 50% 20% / 80% no-repeat, #479366', + }, + { + title: 'Geospatial analysis', + id: 'Restaurants', + description: 'Learn where to open a coffee shop to maximize your income.', + background: 'url("/geo.png") center / cover', + }, + { + title: 'Analyze GitHub stars', + id: 'Stargazers', + description: "Find out which of Enso's repositories are most popular over time.", + background: 'url("/visualize.png") center / cover', }, ] +const TEMPLATES: Record = { + [platformModule.Platform.cloud]: CLOUD_TEMPLATES, + [platformModule.Platform.desktop]: DESKTOP_TEMPLATES, +} + // ======================= // === TemplatesRender === // ======================= @@ -95,7 +117,12 @@ function TemplatesRender(props: TemplatesRenderProps) { onTemplateClick(template.id) }} > -
+

{template.title}

@@ -115,16 +142,20 @@ function TemplatesRender(props: TemplatesRenderProps) { /** The `TemplatesRender`'s container. */ interface TemplatesProps { - onTemplateClick: (name: string | null) => void + backendPlatform: platformModule.Platform + onTemplateClick: (name?: string | null) => void } function Templates(props: TemplatesProps) { - const { onTemplateClick } = props + const { backendPlatform, onTemplateClick } = props return (
- +
diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/topBar.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/topBar.tsx index b4adfdebf138..133d8d151417 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/topBar.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/topBar.tsx @@ -1,8 +1,10 @@ /** @file The top-bar of dashboard. */ import * as dashboard from './dashboard' -import * as modalProvider from '../../providers/modal' +import * as platformModule from '../../platform' import * as svg from '../../components/svg' +import * as modalProvider from '../../providers/modal' + import UserMenu from './userMenu' // ============== @@ -10,9 +12,12 @@ import UserMenu from './userMenu' // ============== interface TopBarProps { + platform: platformModule.Platform projectName: string | null tab: dashboard.Tab toggleTab: () => void + backendPlatform: platformModule.Platform + setBackendPlatform: (backendPlatform: platformModule.Platform) => void query: string setQuery: (value: string) => void } @@ -22,12 +27,49 @@ interface TopBarProps { * because `searchVal` may change parent component's project list. */ function TopBar(props: TopBarProps) { - const { projectName, tab, toggleTab, query, setQuery } = props + const { + platform, + projectName, + tab, + toggleTab, + backendPlatform, + setBackendPlatform, + query, + setQuery, + } = props const { setModal } = modalProvider.useSetModal() return (
+ {platform === platformModule.Platform.desktop && ( +
+ + +
+ )}
void } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/userMenu.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/userMenu.tsx index 3cc1dfbf42e5..61b205bc9401 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/userMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/userMenu.tsx @@ -35,6 +35,7 @@ function UserMenuItem(props: react.PropsWithChildren) { function UserMenu() { const { signOut } = auth.useAuth() const { accessToken, organization } = auth.useFullUserSession() + const { setModal } = modalProvider.useSetModal() const goToProfile = () => { diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localService.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localService.ts new file mode 100644 index 000000000000..8a399353be51 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/localService.ts @@ -0,0 +1,150 @@ +/** @file Module containing the API client for the local backend API. + * + * Each exported function in the {@link Backend} in this module corresponds to an API endpoint. The + * functions are asynchronous and return a `Promise` that resolves to the response from the API. */ +import * as cloudService from './cloudService' +import * as newtype from '../newtype' +import * as projectManager from './projectManager' + +// ======================== +// === Helper functions === +// ======================== + +function ipWithSocketToAddress(ipWithSocket: projectManager.IpWithSocket) { + return newtype.asNewtype(`ws://${ipWithSocket.host}:${ipWithSocket.port}`) +} + +// =============== +// === Backend === +// =============== + +interface CurrentlyOpenProjectInfo { + id: projectManager.ProjectId + project: projectManager.OpenProject +} + +export class Backend implements Partial { + private readonly projectManager = projectManager.ProjectManager.default + private currentlyOpenProject: CurrentlyOpenProjectInfo | null = null + + async listDirectory(): Promise { + const result = await this.projectManager.listProjects({}) + return result.projects.map(project => ({ + type: cloudService.AssetType.project, + title: project.name, + id: project.id, + parentId: '', + permissions: [], + })) + } + + async listProjects(): Promise { + const result = await this.projectManager.listProjects({}) + return result.projects.map(project => ({ + name: project.name, + organizationId: '', + projectId: project.id, + packageName: project.name, + state: { + type: cloudService.ProjectState.created, + }, + jsonAddress: null, + binaryAddress: null, + })) + } + + async createProject( + body: cloudService.CreateProjectRequestBody + ): Promise { + const project = await this.projectManager.createProject({ + name: newtype.asNewtype(body.projectName), + projectTemplate: body.projectTemplateName ?? '', + missingComponentAction: projectManager.MissingComponentAction.install, + }) + return { + name: body.projectName, + organizationId: '', + projectId: project.projectId, + packageName: body.projectName, + state: { + type: cloudService.ProjectState.created, + }, + } + } + + async closeProject(projectId: cloudService.ProjectId): Promise { + await this.projectManager.closeProject({ projectId }) + this.currentlyOpenProject = null + } + + async getProjectDetails(projectId: cloudService.ProjectId): Promise { + if (projectId !== this.currentlyOpenProject?.id) { + const result = await this.projectManager.listProjects({}) + const project = result.projects.find(listedProject => listedProject.id === projectId) + const engineVersion = project?.engineVersion + if (project == null) { + throw new Error(`The project ID '${projectId}' is invalid.`) + } else if (engineVersion == null) { + throw new Error(`The project '${projectId}' does not have an engine version.`) + } else { + return Promise.resolve({ + name: project.name, + engineVersion: { + lifecycle: cloudService.VersionLifecycle.stable, + value: engineVersion, + }, + ideVersion: { + lifecycle: cloudService.VersionLifecycle.stable, + value: engineVersion, + }, + jsonAddress: null, + binaryAddress: null, + organizationId: '', + packageName: project.name, + projectId, + state: { + type: cloudService.ProjectState.closed, + }, + }) + } + } else { + const project = this.currentlyOpenProject.project + return Promise.resolve({ + name: project.projectName, + engineVersion: { + lifecycle: cloudService.VersionLifecycle.stable, + value: project.engineVersion, + }, + ideVersion: { + lifecycle: cloudService.VersionLifecycle.stable, + value: project.engineVersion, + }, + jsonAddress: ipWithSocketToAddress(project.languageServerJsonAddress), + binaryAddress: ipWithSocketToAddress(project.languageServerBinaryAddress), + organizationId: '', + packageName: project.projectName, + projectId, + state: { + type: cloudService.ProjectState.opened, + }, + }) + } + } + + async openProject(projectId: cloudService.ProjectId): Promise { + const project = await this.projectManager.openProject({ + projectId, + missingComponentAction: projectManager.MissingComponentAction.install, + }) + this.currentlyOpenProject = { id: projectId, project } + } +} + +// ===================== +// === createBackend === +// ===================== + +/** Shorthand method for creating a new instance of the backend API. */ +export function createBackend(): Backend { + return new Backend() +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager.ts new file mode 100644 index 000000000000..c507c6a39916 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager.ts @@ -0,0 +1,227 @@ +/** @file This module defines the Project Manager endpoint. */ +import * as newtype from '../newtype' + +// ================= +// === Constants === +// ================= + +const PROJECT_MANAGER_ENDPOINT = 'ws://127.0.0.1:30535' +/** Duration before the {@link ProjectManager} tries to create a WebSocket again. */ +const RETRY_INTERVAL = 1000 +/** Duration after which the {@link ProjectManager} stops re-trying to create a WebSocket. */ +const STOP_TRYING_AFTER = 10000 + +// ============= +// === Types === +// ============= + +export enum MissingComponentAction { + fail = 'Fail', + install = 'Install', + forceInstallBroken = 'ForceInstallBroken', +} + +interface JSONRPCError { + code: number + message: string + data?: unknown +} + +interface JSONRPCBaseResponse { + jsonrpc: '2.0' + id: number +} + +interface JSONRPCSuccessResponse extends JSONRPCBaseResponse { + result: T +} + +interface JSONRPCErrorResponse extends JSONRPCBaseResponse { + error: JSONRPCError +} + +type JSONRPCResponse = JSONRPCErrorResponse | JSONRPCSuccessResponse + +// This intentionally has the same brand as in the cloud backend API. +export type ProjectId = newtype.Newtype +export type ProjectName = newtype.Newtype +export type UTCDateTime = newtype.Newtype + +export interface ProjectMetadata { + name: ProjectName + namespace: string + id: ProjectId + engineVersion: string | null + lastOpened: UTCDateTime | null +} + +export interface IpWithSocket { + host: string + port: number +} + +export interface ProjectList { + projects: ProjectMetadata[] +} + +export interface CreateProject { + projectId: ProjectId +} + +export interface OpenProject { + engineVersion: string + languageServerJsonAddress: IpWithSocket + languageServerBinaryAddress: IpWithSocket + projectName: ProjectName + projectNamespace: string +} + +// ================================ +// === Parameters for endpoints === +// ================================ + +export interface OpenProjectParams { + projectId: ProjectId + missingComponentAction: MissingComponentAction +} + +export interface CloseProjectParams { + projectId: ProjectId +} + +export interface ListProjectsParams { + numberOfProjects?: number +} + +export interface CreateProjectParams { + name: ProjectName + projectTemplate?: string + version?: string + missingComponentAction?: MissingComponentAction +} + +export interface RenameProjectParams { + projectId: ProjectId + name: ProjectName +} + +export interface DeleteProjectParams { + projectId: ProjectId +} + +export interface ListSamplesParams { + projectId: ProjectId +} + +// ======================= +// === Project Manager === +// ======================= + +/** A WebSocket endpoint to the project manager. */ +export class ProjectManager { + static default = new ProjectManager(PROJECT_MANAGER_ENDPOINT) + protected id = 0 + protected resolvers = new Map void>() + protected rejecters = new Map void>() + protected socketPromise: Promise + + constructor(protected readonly connectionUrl: string) { + const createSocket = () => { + this.resolvers = new Map() + const oldRejecters = this.rejecters + this.rejecters = new Map() + for (const reject of oldRejecters.values()) { + reject() + } + this.socketPromise = new Promise((resolve, reject) => { + const handle = setInterval(() => { + try { + const socket = new WebSocket(this.connectionUrl) + clearInterval(handle) + socket.onmessage = event => { + // There is no way to avoid this as `JSON.parse` returns `any`. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument + const message: JSONRPCResponse = JSON.parse(event.data) + if ('result' in message) { + this.resolvers.get(message.id)?.(message.result) + } else { + this.rejecters.get(message.id)?.(message.error) + } + } + socket.onerror = createSocket + socket.onclose = createSocket + resolve(socket) + } catch { + // Ignored; the `setInterval` will retry again eventually. + } + }, RETRY_INTERVAL) + setTimeout(() => { + clearInterval(handle) + reject() + }, STOP_TRYING_AFTER) + }) + return this.socketPromise + } + this.socketPromise = createSocket() + } + + /** Open an existing project. */ + public async openProject(params: OpenProjectParams): Promise { + return this.sendRequest('project/open', params) + } + + /** Close an open project. */ + public async closeProject(params: CloseProjectParams): Promise { + return this.sendRequest('project/close', params) + } + + /** Get the projects list, sorted by open time. */ + public async listProjects(params: ListProjectsParams): Promise { + return this.sendRequest('project/list', params) + } + + /** Create a new project. */ + public async createProject(params: CreateProjectParams): Promise { + return this.sendRequest('project/create', { + missingComponentAction: MissingComponentAction.install, + ...params, + }) + } + + /** Rename a project. */ + public async renameProject(params: RenameProjectParams): Promise { + return this.sendRequest('project/rename', params) + } + + /** Delete a project. */ + public async deleteProject(params: DeleteProjectParams): Promise { + return this.sendRequest('project/delete', params) + } + + /** Get the list of sample projects that are available to the user. */ + public async listSamples(params: ListSamplesParams): Promise { + return this.sendRequest('project/listSample', params) + } + + private cleanup(id: number) { + this.resolvers.delete(id) + this.rejecters.delete(id) + } + + /** Send a JSON-RPC request to the project manager. */ + private async sendRequest(method: string, params: unknown): Promise { + const socket = await this.socketPromise + const id = this.id++ + socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })) + return new Promise((resolve, reject) => { + this.resolvers.set(id, value => { + this.cleanup(id) + resolve(value) + }) + this.rejecters.set(id, value => { + this.cleanup(id) + reject(value) + }) + }) + } +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx index ae802a6a5023..3ceecf34906b 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx @@ -40,17 +40,13 @@ export function run(props: app.AppProps) { logger.log('Starting authentication/dashboard UI.') /** The root element that the authentication/dashboard app will be rendered into. */ const root = document.getElementById(ROOT_ELEMENT_ID) + const ideElement = document.getElementById(IDE_ELEMENT_ID) if (root == null) { logger.error(`Could not find root element with ID '${ROOT_ELEMENT_ID}'.`) + } else if (ideElement == null) { + logger.error(`Could not find IDE element with ID '${IDE_ELEMENT_ID}'.`) } else { - // FIXME: https://github.com/enso-org/cloud-v2/issues/386 - // Temporary workaround on hiding the Enso root element preventing it from - // rendering next to authentication templates. We are uncovering this once the - // authentication library sets the user session. - const ide = document.getElementById(IDE_ELEMENT_ID) - if (ide != null) { - ide.style.display = 'none' - } + ideElement.hidden = true reactDOM.createRoot(root).render() } } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/providers/backend.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/providers/backend.tsx new file mode 100644 index 000000000000..e497503f7aa3 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/providers/backend.tsx @@ -0,0 +1,43 @@ +/** @file */ +import * as react from 'react' + +import * as cloudService from '../dashboard/cloudService' +import * as localService from '../dashboard/localService' + +export interface BackendContextType { + backend: cloudService.Backend | localService.Backend + setBackend: (backend: cloudService.Backend | localService.Backend) => void +} + +// @ts-expect-error The default value will never be exposed +// as `backend` will always be accessed using `useBackend`. +const BackendContext = react.createContext(null) + +// React components should always have a sibling `Props` interface +// if they accept props. +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface BackendProviderProps extends React.PropsWithChildren { + initialBackend: cloudService.Backend | localService.Backend +} + +export function BackendProvider(props: BackendProviderProps) { + const { initialBackend, children } = props + const [backend, setBackend] = react.useState( + initialBackend + ) + return ( + + {children} + + ) +} + +export function useBackend() { + const { backend } = react.useContext(BackendContext) + return { backend } +} + +export function useSetBackend() { + const { setBackend } = react.useContext(BackendContext) + return { setBackend } +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/uploadMultipleFiles.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/uploadMultipleFiles.ts index 31bca4b83914..1f5e48d11a21 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/uploadMultipleFiles.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/uploadMultipleFiles.ts @@ -3,11 +3,11 @@ import toast from 'react-hot-toast' -import * as backend from './dashboard/service' +import * as cloudService from './dashboard/cloudService' export async function uploadMultipleFiles( - backendService: backend.Backend, - directoryId: backend.DirectoryId, + backendService: cloudService.Backend, + directoryId: cloudService.DirectoryId, files: File[] ) { const fileCount = files.length diff --git a/app/ide-desktop/lib/dashboard/src/serviceWorker.ts b/app/ide-desktop/lib/dashboard/src/serviceWorker.ts index b1e5f72a8c3d..a242b1648cc9 100644 --- a/app/ide-desktop/lib/dashboard/src/serviceWorker.ts +++ b/app/ide-desktop/lib/dashboard/src/serviceWorker.ts @@ -1,6 +1,17 @@ /** @file A service worker that redirects paths without extensions to `/index.html`. */ /// +// ================= +// === Constants === +// ================= + +const IDE_CDN_URL = 'https://ensocdn.s3.us-west-1.amazonaws.com/ide' +const FALLBACK_VERSION = '2023.1.1-nightly.2023.4.13' + +// ===================== +// === Fetch handler === +// ===================== + // We `declare` a variable here because Service Workers have a different global scope. // eslint-disable-next-line no-restricted-syntax declare const self: ServiceWorkerGlobalScope @@ -14,6 +25,9 @@ self.addEventListener('fetch', event => { ) { event.respondWith(fetch('/index.html')) return + } else if (url.hostname === 'localhost' && url.pathname === '/style.css') { + event.respondWith(fetch(`${IDE_CDN_URL}/${FALLBACK_VERSION}/style.css`)) + return } else { return false } diff --git a/app/ide-desktop/lib/types/globals.d.ts b/app/ide-desktop/lib/types/globals.d.ts index 27ca6b15a947..962502fa7c62 100644 --- a/app/ide-desktop/lib/types/globals.d.ts +++ b/app/ide-desktop/lib/types/globals.d.ts @@ -8,10 +8,6 @@ interface StringConfig { [key: string]: StringConfig | string } -interface Enso { - main: (inputConfig?: StringConfig) => Promise -} - interface BuildInfo { commit: string version: string @@ -44,7 +40,8 @@ interface AuthenticationApi { declare global { interface Window { - enso: Enso + tryStopProject: () => void + runProject: (inputConfig?: StringConfig) => Promise authenticationApi: AuthenticationApi } From 255edda14670d13bdcee774999d26cd3f98b94e8 Mon Sep 17 00:00:00 2001 From: GregoryTravis Date: Wed, 26 Apr 2023 11:37:09 -0400 Subject: [PATCH 09/14] Add parse extensions to Text type. #6330 (#6404) Add type-specific parse stubs to Text, e.g.: Text.parse_json self -> Json = Text.parse_url self -> Url = Text.parse_number self -> Number = --- CHANGELOG.md | 3 + .../Base/0.0.0-dev/src/Data/Json.enso | 10 +- .../Base/0.0.0-dev/src/Data/Numbers.enso | 5 +- .../0.0.0-dev/src/Data/Text/Extensions.enso | 321 +++++++++++++++++- .../Base/0.0.0-dev/src/Data/Time/Date.enso | 14 +- .../0.0.0-dev/src/Data/Time/Date_Time.enso | 61 +--- .../0.0.0-dev/src/Data/Time/Time_Of_Day.enso | 14 +- test/Tests/src/Data/Text/Parse_Spec.enso | 55 +++ test/Tests/src/Main.enso | 2 + 9 files changed, 423 insertions(+), 62 deletions(-) create mode 100644 test/Tests/src/Data/Text/Parse_Spec.enso diff --git a/CHANGELOG.md b/CHANGELOG.md index 185520cdc6e3..2b786b7fd320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -392,6 +392,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 +595,7 @@ [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 #### Enso Compiler 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..c74c5270d3cc 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 = 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..183710f49c35 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 @@ -926,13 +926,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/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/Time/Date.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Date.enso index 2ee29070e594..3633dd911c1e 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 @@ -558,8 +562,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" 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..0c911b976362 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 @@ -603,50 +608,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/Time_Of_Day.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Time_Of_Day.enso index adcc5591342a..38f58eb58c99 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 @@ -303,11 +306,10 @@ type Time_Of_Day - 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/test/Tests/src/Data/Text/Parse_Spec.enso b/test/Tests/src/Data/Text/Parse_Spec.enso new file mode 100644 index 000000000000..15173ae02eeb --- /dev/null +++ b/test/Tests/src/Data/Text/Parse_Spec.enso @@ -0,0 +1,55 @@ +from Standard.Base import all + +import Standard.Base.Data.Text.Extensions +import Standard.Base.Data.Time.Date.Date +import Standard.Base.Data.Time.Time_Zone.Time_Zone +import Standard.Base.Errors.Common.Syntax_Error +import Standard.Base.Errors.Time_Error.Time_Error +import Standard.Base.Network.URI.URI +import Standard.Test.Extensions + +from Standard.Base.Data.Json import Json, Invalid_JSON, JS_Object +from Standard.Base.Data.Numbers import Decimal, Integer, Number_Parse_Error +from Standard.Test import Test, Test_Suite + +spec = + Test.group "parse" <| + Test.specify "Decimal" <| + "32.5".parse_decimal . should_equal <| Decimal.parse "32.5" + l = Locale.new "cs" + "32,5".parse_decimal l . should_equal <| Decimal.parse "32,5" l + "abc".parse_decimal . should_fail_with Number_Parse_Error + + Test.specify "Integer" <| + "12343456".parse_integer . should_equal <| Integer.parse "12343456" + "ABC123".parse_integer 16 . should_equal <| Integer.parse "ABC123" 16 + "abc".parse_integer . should_fail_with Number_Parse_Error + + Test.specify "Json" <| + "[null, null, true, false]".parse_json . should_equal <| Json.parse "[null, null, true, false]" + "[[".parse_json . should_fail_with Invalid_JSON + + Test.specify "Date" <| + "1999-01-01".parse_date . should_equal <| Date.parse "1999-01-01" + "1999 1 1".parse_date "yyyy M d" . should_equal <| Date.parse "1999 1 1" "yyyy M d" + "1999-01-01".parse_date "yyyy M d" . should_fail_with Time_Error + + Test.specify "Date_Time" <| + "2020-10-01T04:11:12-04:00".parse_date_time . should_equal <| Date_Time.parse "2020-10-01T04:11:12-04:00" + "2020-05-06 04:30:20".parse_date_time "yyyy-MM-dd HH:mm:ss" . should_equal <| Date_Time.parse "2020-05-06 04:30:20" "yyyy-MM-dd HH:mm:ss" + "asdf".parse_date_time . should_fail_with Time_Error + + Test.specify "Time_Of_Day" <| + "15:05:30".parse_time_of_day . should_equal <| Time_Of_Day.parse "15:05:30" + "4:30AM".parse_time_of_day "h:mma" . should_equal <| Time_Of_Day.parse "4:30AM" "h:mma" + "half twelve".parse_time_of_day . should_fail_with Time_Error + + Test.specify "Time_Zone" <| + "CET".parse_time_zone . should_equal <| Time_Zone.parse "CET" + "foo".parse_time_zone . should_fail_with Time_Error + + Test.specify "URI" <| + "http://example.com".parse_uri . should_equal <| URI.parse "http://example.com" + ":::".parse_uri . should_fail_with Syntax_Error + +main = Test_Suite.run_main spec diff --git a/test/Tests/src/Main.enso b/test/Tests/src/Main.enso index 6e0d35ebfa57..1c5db730d991 100644 --- a/test/Tests/src/Main.enso +++ b/test/Tests/src/Main.enso @@ -51,6 +51,7 @@ import project.Data.Regression_Spec import project.Data.Text_Spec import project.Data.Text.Text_Sub_Range_Spec import project.Data.Text.Encoding_Spec +import project.Data.Text.Parse_Spec import project.Data.Text.Regex_Spec import project.Data.Text.Span_Spec import project.Data.Text.Utils_Spec @@ -121,6 +122,7 @@ main = Test_Suite.run_main <| Python_Interop_Spec.spec R_Interop_Spec.spec Pair_Spec.spec + Parse_Spec.spec Problems_Spec.spec Range_Spec.spec Ref_Spec.spec From 0e5ee35abade3fb9facf92d543cefff23cd7b018 Mon Sep 17 00:00:00 2001 From: Dmitry Bushev Date: Wed, 26 Apr 2023 18:53:01 +0100 Subject: [PATCH 10/14] Skip redundant compilations (#6436) related #6323 The engine can skip compilation when applying changes that do not require execution. It is more efficient to process the changes in a batch, than triggering compilations every time such edit is received. --- .../org/enso/interpreter/instrument/command/EditFileCmd.scala | 3 --- 1 file changed, 3 deletions(-) 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 { From 0e511318099850625039dfe054c947fff8293d11 Mon Sep 17 00:00:00 2001 From: James Dunkerley Date: Wed, 26 Apr 2023 19:15:48 +0100 Subject: [PATCH 11/14] Table Visualization and display text changes. (#6382) - Missing tests from number parsing. - Fix type signature on some warning methods. - Fix warnings on `Standard.Database.Data.Table.parse_values`. - Added test for `Nothing` and empty string on `use_first_row_as_names`. - New API for `Number.format` taking a simple format string and `Locale`. - Add ellipsis to truncated `Text.to_display_text`. - Adjusted built-in `to_display_text` for numbers to not include type (but also to display BigInteger as value). - Remove `Noise.Generator` interface type. - Json: Added `to_display_text` to `JS_Object`. - Time: Added `to_display_text` for `Date`, `Time_Of_Day`, `Date_Time`, `Duration` and `Period`. - Text: Added `to_display_text` to `Locale`, `Case_Sensitivity`, `Encoding`, `Text_Sub_Range`, `Span`, `Utf_16_Span`. - System: Added `to_display_text` to `File`, `File_Permissions`, `Process_Result` and `Exit_Code`. - Network: Added `to_display_text` to `URI`, `HTTP_Status_Code` and `Header`. - Added `to_display_text` to `Maybe`, `Regression`, `Pair`, `Range`, `Filter_Condition`. - Added support for `to_js_object` and `to_display_text` to `Random_Number_Generator`. - Verified all error types have `to_display_text`. - Removed `BigInt`, `Date`, `Date_Time` and `Time_Of_Day` JS based rendering as using `to_display_text` now. - Added support for rendering nested structures in the table viz. --- .github/CODEOWNERS | 8 +- .../visualization/java_script/table.js | 91 +++++----- .../0.0.0-dev/src/Data/Filter_Condition.enso | 31 ++++ .../0.0.0-dev/src/Data/Index_Sub_Range.enso | 12 ++ .../Base/0.0.0-dev/src/Data/Interval.enso | 13 ++ .../Base/0.0.0-dev/src/Data/Json.enso | 6 + .../Base/0.0.0-dev/src/Data/Locale.enso | 9 +- .../Base/0.0.0-dev/src/Data/Maybe.enso | 9 + .../Base/0.0.0-dev/src/Data/Noise.enso | 25 +-- .../Base/0.0.0-dev/src/Data/Numbers.enso | 22 ++- .../Base/0.0.0-dev/src/Data/Pair.enso | 13 ++ .../Base/0.0.0-dev/src/Data/Range.enso | 8 + .../Base/0.0.0-dev/src/Data/Regression.enso | 29 ++- .../src/Data/Text/Case_Sensitivity.enso | 11 +- .../0.0.0-dev/src/Data/Text/Encoding.enso | 5 + .../Base/0.0.0-dev/src/Data/Text/Regex.enso | 5 + .../Base/0.0.0-dev/src/Data/Text/Span.enso | 11 ++ .../src/Data/Text/Text_Ordering.enso | 7 + .../src/Data/Text/Text_Sub_Range.enso | 9 + .../Base/0.0.0-dev/src/Data/Time/Date.enso | 7 +- .../0.0.0-dev/src/Data/Time/Date_Time.enso | 7 + .../0.0.0-dev/src/Data/Time/Duration.enso | 16 ++ .../Base/0.0.0-dev/src/Data/Time/Period.enso | 20 +- .../0.0.0-dev/src/Data/Time/Time_Of_Day.enso | 7 + .../0.0.0-dev/src/Data/Time/Time_Zone.enso | 5 + .../Base/0.0.0-dev/src/Errors/Common.enso | 13 +- .../Base/0.0.0-dev/src/Meta/Enso_Project.enso | 1 - .../src/Network/HTTP/HTTP_Status_Code.enso | 47 +++++ .../0.0.0-dev/src/Network/HTTP/Header.enso | 6 + .../Base/0.0.0-dev/src/Network/URI.enso | 5 + .../Standard/Base/0.0.0-dev/src/Random.enso | 13 ++ .../Base/0.0.0-dev/src/System/File.enso | 5 + .../src/System/File/File_Permissions.enso | 12 +- .../src/System/Process/Exit_Code.enso | 8 + .../src/System/Process/Process_Builder.enso | 7 + .../Standard/Base/0.0.0-dev/src/Warning.enso | 4 +- .../Database/0.0.0-dev/src/Data/Table.enso | 1 + .../0.0.0-dev/src/Data/Type/Value_Type.enso | 3 +- .../Standard/Table/0.0.0-dev/src/Errors.enso | 2 +- .../0.0.0-dev/src/Table/Visualization.enso | 171 ++++++++++++++---- .../test/instrument/RuntimeServerTest.scala | 6 +- .../RuntimeVisualizationsTest.scala | 17 +- .../builtin/text/AnyToDisplayTextNode.java | 23 ++- .../interpreter/test/TypeMembersTest.java | 2 +- .../test/semantic/ImportsTest.scala | 8 +- .../test/semantic/MethodsTest.scala | 4 +- .../interpreter/test/semantic/TextTest.scala | 4 +- .../src/Formatting/Parse_Values_Spec.enso | 14 ++ .../Table_Tests/src/In_Memory/Table_Spec.enso | 6 +- test/Tests/src/Data/Map_Spec.enso | 4 +- test/Tests/src/Data/Noise/Generator_Spec.enso | 6 - test/Tests/src/Data/Text/Utils_Spec.enso | 8 +- test/Tests/src/Data/Time/Date_Time_Spec.enso | 3 + test/Tests/src/Data/Time/Duration_Spec.enso | 9 + test/Tests/src/Data/Time/Period_Spec.enso | 9 + test/Visualization_Tests/src/Table_Spec.enso | 28 +-- .../Base/0.0.0-dev/src/Errors/Common.enso | 8 +- .../lib/Standard/Base/0.0.0-dev/src/Meta.enso | 2 + 58 files changed, 651 insertions(+), 194 deletions(-) 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/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/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 c74c5270d3cc..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 @@ -180,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 183710f49c35..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. 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/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 3633dd911c1e..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 @@ -543,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. @@ -596,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 0c911b976362..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 @@ -589,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. 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 38f58eb58c99..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 @@ -300,6 +300,13 @@ 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: 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/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/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/test/Table_Tests/src/Formatting/Parse_Values_Spec.enso b/test/Table_Tests/src/Formatting/Parse_Values_Spec.enso index 918b896affae..cd9881e87c35 100644 --- a/test/Table_Tests/src/Formatting/Parse_Values_Spec.enso +++ b/test/Table_Tests/src/Formatting/Parse_Values_Spec.enso @@ -413,6 +413,7 @@ spec = pUS2.to_vector . should_equal [1, 123, -1234, 1234567, Nothing] Problems.expect_warning (Invalid_Format.Error "ints" Value_Type.Integer ["12,34,56"]) pUS2 + ## Reject minus sign if already seen a bracket form cUS3 = Column.from_vector "ints" ["1", "(000,123)", "-1,234", "1,234,567", "12,34,56"] pUS3 = cUS3.parse type=Value_Type.Integer pUS3.to_vector . should_equal [1, -123, Nothing, 1234567, Nothing] @@ -423,6 +424,12 @@ spec = pUS4.to_vector . should_equal [1234, 1234, 1234567, -1234, Nothing] Problems.expect_warning (Invalid_Format.Error "ints" Value_Type.Integer ["($1,234,567)"]) pUS4 + ## Reject bracket notation for negative numbers if already seen a minus sign + cUS5 = Column.from_vector "ints" ["1", "000123", "-1,234", "1,234,567", "(123,456)"] + pUS5 = cUS5.parse type=Value_Type.Integer + pUS5.to_vector . should_equal [1, 123, -1234, 1234567, Nothing] + Problems.expect_warning (Invalid_Format.Error "ints" Value_Type.Integer ["(123,456)"]) pUS5 + Test.specify "should correctly parse integers in European formats" <| cDE = Column.from_vector "ints" ["1", "000123", "-1.234", "1.234.567", "12.34.56"] pDE = cDE.parse type=Value_Type.Integer @@ -470,6 +477,7 @@ spec = pUS2.to_vector . should_equal [1.23, 123, -1234.567, 1234567.789, Nothing] Problems.expect_warning (Invalid_Format.Error "floats" Value_Type.Float ["12,34.56"]) pUS2 + ## Reject minus sign if already seen a bracket form cUS3 = Column.from_vector "floats" ["1.23", "(000,123)", "-1,234.567", "1,234,567.789", "12,34.56"] pUS3 = cUS3.parse type=Value_Type.Float pUS3.to_vector . should_equal [1.23, -123, Nothing, 1234567.789, Nothing] @@ -480,6 +488,12 @@ spec = pUS4.to_vector . should_equal [12.34, 1234, 1234567.789, -1234.96, Nothing] Problems.expect_warning (Invalid_Format.Error "floats" Value_Type.Float ["($1,234,567)"]) pUS4 + ## Reject bracket notation for negative numbers if already seen a minus sign + cUS5 = Column.from_vector "floats" ["1.23", "000123", "-1,234.567", "1,234,567.789", "(123,456)"] + pUS5 = cUS5.parse type=Value_Type.Float + pUS5.to_vector . should_equal [1.23, 123, -1234.567, 1234567.789, Nothing] + Problems.expect_warning (Invalid_Format.Error "floats" Value_Type.Float ["(123,456)"]) pUS5 + Test.specify "should correctly parse decimals in European formats" <| cDE = Column.from_vector "floats" ["1,23", "000123", "-1.234,567", "1.234.567,789", "12.34,56"] pDE = cDE.parse type=Value_Type.Float diff --git a/test/Table_Tests/src/In_Memory/Table_Spec.enso b/test/Table_Tests/src/In_Memory/Table_Spec.enso index fb5d3d2cb470..239190e18f6f 100644 --- a/test/Table_Tests/src/In_Memory/Table_Spec.enso +++ b/test/Table_Tests/src/In_Memory/Table_Spec.enso @@ -525,12 +525,14 @@ spec = Test.specify "should work happily with mixed types" <| c_0 = ['A', ["H", "B", "C"]] + c_00 = ['AA', ["", "B", "C"]] c_1 = ['B', [Date.new 1980, Date.new 1979, Date.new 2000]] c_2 = ['x', [1, 2, 3]] c_3 = ['Y', [5.3, 56.2, 6.3]] c_4 = ['Z', [True, False, True]] - table = Table.new [c_0, c_1, c_2, c_3, c_4] - expect_column_names ["H", "1980-01-01", "1", "5.3", "True"] <| table.use_first_row_as_names + c_5 = ['ZZ', [Nothing, False, True]] + table = Table.new [c_0, c_00, c_1, c_2, c_3, c_4, c_5] + expect_column_names ["H", "Column_1", "1980-01-01", "1", "5.3", "True", "Column_2"] table.use_first_row_as_names Test.specify "should correctly handle problems: invalid names ''" <| c_0 = ['A', ["", "B", "C"]] diff --git a/test/Tests/src/Data/Map_Spec.enso b/test/Tests/src/Data/Map_Spec.enso index 132d4896ac53..e544a77d7474 100644 --- a/test/Tests/src/Data/Map_Spec.enso +++ b/test/Tests/src/Data/Map_Spec.enso @@ -250,7 +250,7 @@ spec = vec = [[0, 0], [3, -5], [1, 2], [0, 1]] m1 = Map.from_vector vec m1.should_fail_with Illegal_Argument - m1.catch.message . should_equal "`Map.from_vector` encountered duplicate key: 0 (Integer)" + m1.catch.message . should_equal "`Map.from_vector` encountered duplicate key: 0" m2 = Map.from_vector vec allow_duplicates=True Problems.assume_no_problems m2 @@ -304,7 +304,7 @@ spec = m = Map.from_vector [[1, 2], [11, 3]] m2 = m.transform (k -> v -> [k % 10, v*2]) m2.should_fail_with Illegal_Argument - m2.catch.message . should_equal "`Map.transform` encountered duplicate key: 1 (Integer)" + m2.catch.message . should_equal "`Map.transform` encountered duplicate key: 1" Test.specify "should allow mapping over values" <| m = Map.empty . insert 1 2 . insert 2 4 diff --git a/test/Tests/src/Data/Noise/Generator_Spec.enso b/test/Tests/src/Data/Noise/Generator_Spec.enso index 72ad49a31d09..708d1c4cc056 100644 --- a/test/Tests/src/Data/Noise/Generator_Spec.enso +++ b/test/Tests/src/Data/Noise/Generator_Spec.enso @@ -1,18 +1,12 @@ from Standard.Base import all import Standard.Base.Errors.Unimplemented.Unimplemented -import Standard.Base.Data.Noise.Generator import Standard.Base.Data.Noise.Deterministic_Random from Standard.Test import Test, Test_Suite import Standard.Test.Extensions spec = - Test.group "Generator Interface" <| - gen = Generator - Test.specify "should not be invokable" <| - interval = Interval.inclusive 0 1 - Test.expect_panic_with (gen.step 1 interval) Unimplemented Test.group "Deterministic Random Noise Generator" <| gen = Deterministic_Random Test.specify "should always return the same output for the same input" <| diff --git a/test/Tests/src/Data/Text/Utils_Spec.enso b/test/Tests/src/Data/Text/Utils_Spec.enso index 671f06ff32ad..97d08dcd987c 100644 --- a/test/Tests/src/Data/Text/Utils_Spec.enso +++ b/test/Tests/src/Data/Text/Utils_Spec.enso @@ -98,18 +98,18 @@ spec = disp = long.to_display_text disp.length . should_equal 80 disp.characters.take (First 5) . should_equal [ 'H', 'e', 'l', 'l', 'o' ] - disp.characters.take (Last 6) . should_equal ['l', 'd', '!', ' ', 'H', 'e'] + disp.characters.take (Last 6) . should_equal ['l', 'd', '!', ' ', ' ', '…'] Test.specify "grapheme 1 conversion" <| txt = 'a\u0321\u0302'*100 - txt.to_display_text . should_equal 'a\u0321\u0302'*80 + txt.to_display_text . should_equal ('a\u0321\u0302'*78 + ' …') Test.specify "grapheme 2 conversion" <| txt = '\u0915\u094D\u0937\u093F'*100 - txt.to_display_text . should_equal '\u0915\u094D\u0937\u093F'*80 + txt.to_display_text . should_equal ('\u0915\u094D\u0937\u093F'*78 + ' …') Test.specify "grapheme 3 conversion" <| txt = '\u{1F926}\u{1F3FC}\u200D\u2642\uFE0F'*100 - txt.to_display_text . should_equal '\u{1F926}\u{1F3FC}\u200D\u2642\uFE0F'*80 + txt.to_display_text . should_equal ('\u{1F926}\u{1F3FC}\u200D\u2642\uFE0F'*78 + ' …') main = Test_Suite.run_main spec diff --git a/test/Tests/src/Data/Time/Date_Time_Spec.enso b/test/Tests/src/Data/Time/Date_Time_Spec.enso index 254d314f2565..b739b85e7c01 100644 --- a/test/Tests/src/Data/Time/Date_Time_Spec.enso +++ b/test/Tests/src/Data/Time/Date_Time_Spec.enso @@ -142,6 +142,7 @@ spec_with name create_new_datetime parse_datetime nanoseconds_loss_in_precision= time . second . should_equal 1 time . nanosecond . should_equal 0 time . zone . zone_id . should_equal "Europe/Paris" + time.to_display_text . should_equal "1970-01-01 00:00:01 Europe/Paris" Test.specify "should throw error when parsing invalid time" <| case parse_datetime "2008-1-1" . catch of @@ -199,6 +200,7 @@ spec_with name create_new_datetime parse_datetime nanoseconds_loss_in_precision= time . second . should_equal 1 time . nanosecond . should_equal 0 time . zone . zone_id . should_equal tz.zone_id + time.to_display_text . should_equal "1970-01-01 01:01:01 +01:01:01" Test.specify "should set id-based timezone" <| tz = Time_Zone.parse "Europe/Moscow" @@ -211,6 +213,7 @@ spec_with name create_new_datetime parse_datetime nanoseconds_loss_in_precision= time . second . should_equal 0 time . nanosecond . should_equal 0 time . zone . zone_id . should_equal tz.zone_id + time.to_display_text . should_equal "1970-01-01 03:00:00 Europe/Moscow" Test.specify "should get time of day from offsed-based time" <| time = parse_datetime "1970-01-01T00:00:01+01:00" . time_of_day diff --git a/test/Tests/src/Data/Time/Duration_Spec.enso b/test/Tests/src/Data/Time/Duration_Spec.enso index bda77111a0f7..0d0d45a136bb 100644 --- a/test/Tests/src/Data/Time/Duration_Spec.enso +++ b/test/Tests/src/Data/Time/Duration_Spec.enso @@ -34,6 +34,15 @@ spec = duration = (Duration.new hours=13) - (Duration.new hours=1) duration.hours . should_equal 12 + Test.specify "should render a friendly to display text" <| + Duration.new . to_display_text . should_equal "0s" + Duration.new seconds=30 . to_display_text . should_equal "30s" + Duration.new seconds=30 milliseconds=500 . to_display_text . should_equal "30.5s" + Duration.new hours=1 . to_display_text . should_equal "1h" + Duration.new hours=1 minutes=30 . to_display_text . should_equal "1h 30m" + Duration.new hours=1 seconds=30 . to_display_text . should_equal "1h 0m 30s" + Duration.new hours=1 minutes=30 seconds=30 . to_display_text . should_equal "1h 30m 30s" + Test.specify "should convert to Json" <| interval = (Duration.new nanoseconds=120) + (Duration.new seconds=30) + (Duration.new hours=14) interval.to_json.should_equal <| diff --git a/test/Tests/src/Data/Time/Period_Spec.enso b/test/Tests/src/Data/Time/Period_Spec.enso index 667ed04c90fe..9895ae126861 100644 --- a/test/Tests/src/Data/Time/Period_Spec.enso +++ b/test/Tests/src/Data/Time/Period_Spec.enso @@ -41,4 +41,13 @@ spec = ((Period.new years=1 days=10) == (Period.new years=1 days=10)) . should_be_true ((Period.new days=1) != (Period.new months=1)) . should_be_true + Test.specify "should render a friendly to display text" <| + Period.new . to_display_text . should_equal "0D" + Period.new years=2 . to_display_text . should_equal "2Y" + Period.new months=24 . to_display_text . should_equal "2Y" + Period.new months=4 . to_display_text . should_equal "4M" + Period.new months=18 . to_display_text . should_equal "1Y 6M" + Period.new years=2 days=3 . to_display_text . should_equal "2Y 0M 3D" + Period.new days=18 . to_display_text . should_equal "18D" + main = Test_Suite.run_main spec diff --git a/test/Visualization_Tests/src/Table_Spec.enso b/test/Visualization_Tests/src/Table_Spec.enso index 8899dcb13432..2927ebb3f11d 100644 --- a/test/Visualization_Tests/src/Table_Spec.enso +++ b/test/Visualization_Tests/src/Table_Spec.enso @@ -31,14 +31,14 @@ visualization_spec connection = p_all_rows = ["all_rows_count", all_rows] p_ixes = ["indices", ixes] p_ixes_header = ["indices_header", ixes_header] - pairs = [p_header, p_data, p_all_rows, p_ixes, p_ixes_header] + pairs = [p_header, p_data, p_all_rows, p_ixes, p_ixes_header, ["type", "Table"]] JS_Object.from_pairs pairs . to_text Test.group "Table Visualization" <| Test.specify "should wrap internal errors" <| bad_table = Database_Table.Value Nothing Nothing Nothing Nothing vis = Visualization.prepare_visualization bad_table 2 - json = JS_Object.from_pairs [["error", "Method `set_limit` of Nothing could not be found."]] + json = JS_Object.from_pairs [["error", "Method `set_limit` of type Nothing could not be found."]] vis . should_equal json.to_text Test.specify "should visualize database tables" <| @@ -69,30 +69,34 @@ visualization_spec connection = Test.specify "should handle Vectors" <| vis = Visualization.prepare_visualization [1, 2, 3] 2 - json = JS_Object.from_pairs [["json", [1, 2]], ["all_rows_count", 3]] + json = JS_Object.from_pairs [["type", "Vector"], ["all_rows_count", 3], ["json", [1, 2]]] vis . should_equal json.to_text vis2 = Visualization.prepare_visualization [[1, 2], [3, 4]] 2 - json2 = JS_Object.from_pairs [["json", [[1, 2], [3, 4]]], ["all_rows_count", 2]] + json2 = JS_Object.from_pairs [["type", "Matrix"], ["all_rows_count", 2], ["json", [[1, 2], [3, 4]]], ["column_count", 2]] vis2 . should_equal json2.to_text Test.specify "should handle Arrays" <| vis = Visualization.prepare_visualization ([1, 2, 3] . to_array) 2 - json = JS_Object.from_pairs [["json", [1, 2]], ["all_rows_count", 3]] + json = JS_Object.from_pairs [["type", "Vector"], ["all_rows_count", 3], ["json", [1, 2]]] vis . should_equal json.to_text Test.specify "should handle other datatypes" <| vis = Visualization.prepare_visualization (Foo.Value 42) 2 - json = JS_Object.from_pairs [["json", (Foo.Value 42)]] + json = JS_Object.from_pairs [["json", JS_Object.from_pairs [["_display_text_", (Foo.Value 42).to_display_text],["x", 42]]]] vis . should_equal json.to_text Test.specify "should visualize value type info" <| - Value_Type.Boolean.to_json . should_equal '{"type":"Value_Type","constructor":"Boolean","_display_text_":"Boolean"}' - Value_Type.Float.to_json . should_equal '{"type":"Value_Type","constructor":"Float","_display_text_":"Float (64 bits)","bits":64}' - Value_Type.Decimal.to_json . should_equal '{"type":"Value_Type","constructor":"Decimal","_display_text_":"Decimal (precision=Nothing, scale=Nothing)","precision":null,"scale":null}' - Value_Type.Char.to_json . should_equal '{"type":"Value_Type","constructor":"Char","_display_text_":"Char (max_size=Nothing, variable_length=True)","size":null,"variable_length":true}' - Value_Type.Unsupported_Data_Type.to_json . should_equal '{"type":"Value_Type","constructor":"Unsupported_Data_Type","_display_text_":"Unsupported_Data_Type","type_name":null}' - + make_json vt = + js_object = vt.to_js_object + pairs = [["_display_text_", vt.to_display_text]] + vt.to_js_object.field_names.map f-> [f, js_object.get f] + JS_Object.from_pairs [["json", JS_Object.from_pairs pairs]] . to_text + + Visualization.prepare_visualization Value_Type.Boolean . should_equal (make_json Value_Type.Boolean) + Visualization.prepare_visualization Value_Type.Float . should_equal (make_json Value_Type.Float) + Visualization.prepare_visualization Value_Type.Decimal . should_equal (make_json Value_Type.Decimal) + Visualization.prepare_visualization Value_Type.Char . should_equal (make_json Value_Type.Char) + Visualization.prepare_visualization Value_Type.Unsupported_Data_Type . should_equal (make_json Value_Type.Unsupported_Data_Type) spec = enso_project.data.create_directory diff --git a/test/micro-distribution/lib/Standard/Base/0.0.0-dev/src/Errors/Common.enso b/test/micro-distribution/lib/Standard/Base/0.0.0-dev/src/Errors/Common.enso index 58897ef33a62..205a654c70ca 100644 --- a/test/micro-distribution/lib/Standard/Base/0.0.0-dev/src/Errors/Common.enso +++ b/test/micro-distribution/lib/Standard/Base/0.0.0-dev/src/Errors/Common.enso @@ -1,6 +1,6 @@ -import project.Meta -import project.Error.Error import project.Any.Any +import project.Error.Error +import project.Meta @Builtin_Type @@ -50,7 +50,9 @@ type No_Such_Method method_name self = Meta.meta self.symbol . name - 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 Module_Not_In_Package_Error diff --git a/test/micro-distribution/lib/Standard/Base/0.0.0-dev/src/Meta.enso b/test/micro-distribution/lib/Standard/Base/0.0.0-dev/src/Meta.enso index 199c427f5a79..2837626e4213 100644 --- a/test/micro-distribution/lib/Standard/Base/0.0.0-dev/src/Meta.enso +++ b/test/micro-distribution/lib/Standard/Base/0.0.0-dev/src/Meta.enso @@ -10,6 +10,8 @@ type Primitive type_of value = @Builtin_Method "Meta.type_of" +is_polyglot value = @Builtin_Method "Meta.is_polyglot" + # A **very** minimal version of meta for the purpose of tests meta value = if is_unresolved_symbol value then Unresolved_Symbol.Value value else From a00efb28f3cca317464a71c1aa0bad63f2e2724a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grabarz?= Date: Wed, 26 Apr 2023 21:37:54 +0200 Subject: [PATCH 12/14] Widgets integrated with graph nodes (#6347) Rewrites node input component. Now the input is composed of multiple widget components arranged in a tree of views with automatic layout. That allows creating complex UI elements on top of the node itself, and further widget positions will be automatically adapted to that. The tree roughly follow the span tree, as it is built by consuming its nodes and eagerly creating widgets from them. The tree is rebuilt every time the expression changes, but that rebuild process reuses as much previously created widgets as possible, and only updates their configuration as needed. Each widget type can have its own configuration options that can be passed to it from the parent, or assigned based on configuration received from the language server. image # Important Notes For now, all span-tree updates are sent over to the shared Frp endpoint of the whole tree, so there is no mechanism for intercepting them by the parent widgets. One idea would be to use existing bubbling/capturing events on widget display objects for that purpose, but I think existing implementation is simpler and more convenient, and we can always easily change that if we have a use for it. There are some issues with performance due to much more display objects being created on the graph. Expect it to be a little worse, especially at initialization time. --- CHANGELOG.md | 3 + app/gui/language/span-tree/src/action.rs | 12 +- app/gui/language/span-tree/src/builder.rs | 30 +- app/gui/language/span-tree/src/generate.rs | 54 +- app/gui/language/span-tree/src/lib.rs | 72 +- app/gui/language/span-tree/src/node.rs | 59 +- app/gui/src/controller/graph/widget.rs | 160 +- .../controller/graph/widget/configuration.rs | 84 + .../src/controller/graph/widget/response.rs | 139 ++ app/gui/src/presenter/graph.rs | 12 +- app/gui/view/examples/interface/src/lib.rs | 12 +- .../view/graph-editor/src/component/node.rs | 59 +- .../src/component/node/input/area.rs | 981 ++-------- .../src/component/node/input/port.rs | 386 ++-- .../src/component/node/input/widget.rs | 1573 ++++++++++++----- .../component/node/input/widget/hierarchy.rs | 56 + .../node/input/widget/insertion_point.rs | 42 + .../src/component/node/input/widget/label.rs | 178 ++ .../{vector_editor.rs => list_editor.rs} | 168 +- .../node/input/widget/single_choice.rs | 403 +++++ .../src/component/node/output/area.rs | 21 +- app/gui/view/graph-editor/src/lib.rs | 299 ++-- build-config.yaml | 2 +- build/ci_utils/src/fs.rs | 9 +- .../ensogl/app/theme/hardcoded/src/lib.rs | 4 + .../ensogl/component/list-editor/src/lib.rs | 10 +- .../component/text/src/component/text.rs | 12 +- lib/rust/ensogl/core/src/animation/loops.rs | 12 +- .../ensogl/core/src/control/io/mouse/event.rs | 21 + lib/rust/ensogl/core/src/data/dirty.rs | 63 +- lib/rust/ensogl/core/src/debug/stats.rs | 2 +- .../core/src/display/object/instance.rs | 508 +++++- .../core/src/display/object/transformation.rs | 2 +- lib/rust/ensogl/core/src/display/scene.rs | 66 +- .../src/display/shape/compound/rectangle.rs | 10 +- lib/rust/ensogl/core/src/display/world.rs | 24 +- lib/rust/ensogl/core/src/gui/cursor.rs | 45 +- lib/rust/ensogl/pack/js/src/runner/index.ts | 3 +- lib/rust/frp/src/nodes.rs | 251 +++ lib/rust/frp/src/stream.rs | 54 +- lib/rust/prelude/src/collections.rs | 27 + 41 files changed, 3917 insertions(+), 2011 deletions(-) create mode 100644 app/gui/src/controller/graph/widget/configuration.rs create mode 100644 app/gui/src/controller/graph/widget/response.rs create mode 100644 app/gui/view/graph-editor/src/component/node/input/widget/hierarchy.rs create mode 100644 app/gui/view/graph-editor/src/component/node/input/widget/insertion_point.rs create mode 100644 app/gui/view/graph-editor/src/component/node/input/widget/label.rs rename app/gui/view/graph-editor/src/component/node/input/widget/{vector_editor.rs => list_editor.rs} (50%) create mode 100644 app/gui/view/graph-editor/src/component/node/input/widget/single_choice.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b786b7fd320..24b8f08f9a37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,8 @@ 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]. @@ -596,6 +598,7 @@ [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/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/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/presenter/graph.rs b/app/gui/src/presenter/graph.rs index 99d7a4c1d99e..d62c4396348c 100644 --- a/app/gui/src/presenter/graph.rs +++ b/app/gui/src/presenter/graph.rs @@ -20,7 +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::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/view/examples/interface/src/lib.rs b/app/gui/view/examples/interface/src/lib.rs index ccaa4d31ef24..d8a9639068be 100644 --- a/app/gui/view/examples/interface/src/lib.rs +++ b/app/gui/view/examples/interface/src/lib.rs @@ -419,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/graph-editor/src/component/node.rs b/app/gui/view/graph-editor/src/component/node.rs index 41294e6ac264..ee555fd05b96 100644 --- a/app/gui/view/graph-editor/src/component/node.rs +++ b/app/gui/view/graph-editor/src/component/node.rs @@ -11,8 +11,8 @@ use crate::component::visualization; use crate::selection::BoundingBox; use crate::tooltip; use crate::view; +use crate::CallWidgetsConfig; use crate::Type; -use crate::WidgetUpdates; use super::edge; use engine_protocol::language_server::ExecutionEnvironment; @@ -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/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/lib.rs b/app/gui/view/graph-editor/src/lib.rs index fd99f9058edc..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)] @@ -641,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), @@ -737,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. @@ -978,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) @@ -1102,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, } @@ -1609,16 +1625,17 @@ impl GraphEditorModelWithNetwork { model.frp.output.hover_node_output.emit(output); }); - let neutral_color = model.styles_frp.get_color(theme::code::types::any::selection); + eval_ node_model.input.frp.input_edges_need_refresh( + model.frp.output.node_incoming_edge_updates.emit(node_id) + ); - _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_ 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 <- all_with(&node_model.input.frp.on_port_type_change,&neutral_color, + _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()) @@ -1927,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); } @@ -1935,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) { @@ -2023,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( @@ -2097,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); } } } @@ -2112,9 +2135,7 @@ 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.output.on_some_edges_sources_unset.emit(()); } @@ -2253,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()); } @@ -2302,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)); + } }; } @@ -2335,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 { @@ -2383,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 { @@ -2402,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( @@ -2724,6 +2753,7 @@ fn new_graph_editor(app: &Application) -> GraphEditor { 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); @@ -3019,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 === @@ -3342,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)); } @@ -3596,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)); @@ -3614,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.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, _)) @@ -3673,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; @@ -3699,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() @@ -3889,6 +3939,7 @@ impl display::Object for GraphEditor { 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; @@ -3994,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/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/lib/rust/ensogl/app/theme/hardcoded/src/lib.rs b/lib/rust/ensogl/app/theme/hardcoded/src/lib.rs index 332f77a97d7c..92ee6f3b8501 100644 --- a/lib/rust/ensogl/app/theme/hardcoded/src/lib.rs +++ b/lib/rust/ensogl/app/theme/hardcoded/src/lib.rs @@ -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/list-editor/src/lib.rs b/lib/rust/ensogl/component/list-editor/src/lib.rs index f3cd7e4bf8b3..a8ed5176e557 100644 --- a/lib/rust/ensogl/component/list-editor/src/lib.rs +++ b/lib/rust/ensogl/component/list-editor/src/lib.rs @@ -391,7 +391,7 @@ impl ListEditor { let insert_pointer_style = self.init_insertion_points(&on_up, &pos_on_move, &is_dragging); frp::extend! { network - cursor.frp.set_style <+ all [insert_pointer_style, trash_pointer_style].fold(); + cursor.frp.set_style_override <+ all [insert_pointer_style, trash_pointer_style].fold(); } self } @@ -401,7 +401,7 @@ impl ListEditor { on_up: &frp::Stream>, pos_on_move: &frp::Stream, is_dragging: &frp::Stream, - ) -> 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(); @@ -436,7 +436,7 @@ impl ListEditor { ); index <= opt_index; enabled <- opt_index.is_some(); - pointer_style <- opt_index.map(|t| t.if_some_or_default(cursor::Style::plus)); + 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)); @@ -518,7 +518,7 @@ impl ListEditor { &self, on_up: &frp::Stream>, drag_diff: &frp::Stream, - ) -> (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; @@ -532,7 +532,7 @@ 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_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()); 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 f6476f7b06ac..27e7f82011d0 100644 --- a/lib/rust/ensogl/core/src/control/io/mouse/event.rs +++ b/lib/rust/ensogl/core/src/control/io/mouse/event.rs @@ -190,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 +} + // ============== 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/object/instance.rs b/lib/rust/ensogl/core/src/display/object/instance.rs index 2bb60206e77d..7d9933b095be 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 } @@ -2556,6 +2705,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 +2883,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 +3459,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 +3479,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 +3498,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 +4123,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 +4283,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 +6277,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 +6376,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 +6385,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 a1e9cb8282f3..9f0888fabe07 100644 --- a/lib/rust/ensogl/core/src/display/scene.rs +++ b/lib/rust/ensogl/core/src/display/scene.rs @@ -602,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) @@ -643,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); @@ -667,8 +671,10 @@ impl HardcodedLayers { viz, below_main, main, + port, port_selection, label, + port_hover, above_nodes, above_nodes_text, panel_background, @@ -708,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 { @@ -717,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(); @@ -731,6 +739,7 @@ impl Frp { camera_changed_source, frame_time_source, focused_source, + post_update, } } } @@ -780,7 +789,7 @@ pub struct UpdateStatus { // === SceneData === // ================= -#[derive(Clone, CloneRef, Debug)] +#[derive(Debug)] pub struct SceneData { pub display_object: display::object::Root, pub dom: Rc, @@ -1092,7 +1101,7 @@ impl display::Object for SceneData { #[derive(Clone, CloneRef, Debug)] pub struct Scene { - no_mut_access: SceneData, + no_mut_access: Rc, } impl Scene { @@ -1102,7 +1111,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 } @@ -1214,31 +1223,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 191f4337469e..094c27485dae 100644 --- a/lib/rust/ensogl/core/src/gui/cursor.rs +++ b/lib/rust/ensogl/core/src/gui/cursor.rs @@ -65,6 +65,7 @@ impl Style { pub fn new_highlight>( host: H, size: Vector2, + radius: f32, color: Option, ) -> Self where @@ -72,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 { @@ -133,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 === @@ -165,8 +158,8 @@ pub mod shape { 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); @@ -205,6 +198,7 @@ pub mod shape { crate::define_endpoints_2! { Input { + set_style_override (Option