From 4b28f8f8f034304d15ebbf5a74de73a6006292df Mon Sep 17 00:00:00 2001 From: Galin Bajlekov <117099775+galin-enso@users.noreply.github.com> Date: Mon, 19 Dec 2022 22:22:33 +0100 Subject: [PATCH] Visual indication of outdated VCS snapshot (#3950) This PR provides a visual indication of whether the project's current state differs from the most recent snapshot saved in the VCS. The project name displayed in the IDE changes to a darker text to indicate that the VCS snapshot is outdated, and back to a lighter text when the current project state corresponds to the last saved VCS snapshot. https://user-images.githubusercontent.com/117099775/208088438-20dfc2aa-2a7d-47bf-bc12-3d3dff7a4974.mp4 The outdated project snapshot indicator is set when: * A node is moved. * A node is added or removed. * The text editor is used to edit the text. * The project is auto-saved, and the auto-saved project state does not correspond to the last saved snapshot in the VCS. The outdated project snapshot indicator is cleared when: * A new project snapshot is successfully saved using `ctrl+s`. * The project is auto-saved, and the auto-saved project state is confirmed to correspond to the last saved snapshot in the VCS. This occurs, for example, when a project change is undone and the project is reverted to the last saved snapshot state. The auto-save events do not occur immediately after a project change but have a short delay, thus the VCS status update is affected by the same delay when triggered by an auto-save event. --- CHANGELOG.md | 5 + .../engine-protocol/src/common/error.rs | 3 + .../engine-protocol/src/language_server.rs | 6 +- .../src/language_server/response.rs | 14 +- app/gui/src/controller/project.rs | 81 +----------- app/gui/src/model/project.rs | 12 ++ app/gui/src/model/project/synchronized.rs | 123 +++++++++++++++++- app/gui/src/presenter/project.rs | 40 +++++- .../graph-editor/src/component/breadcrumbs.rs | 3 + .../src/component/breadcrumbs/project_name.rs | 46 ++++--- .../ensogl/app/theme/hardcoded/src/lib.rs | 12 +- 11 files changed, 236 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04f3698dc9e..cc321a7398e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,10 @@ - [Added scroll bounce animation][3836] which activates when scrolling past the end of scrollable content. - [Added project snapshot saving on shortcut][3923] +- [The color of the displayed project name indicates whether the project's + current state is saved in a snapshot.][3950] The project name is darker when + the project is changed from the last saved snapshot and lighter when the + snapshot matches the current project state. - [Added shortcut to interrupt the program][3967] #### EnsoGL (rendering engine) @@ -405,6 +409,7 @@ [3885]: https://github.com/enso-org/enso/pull/3885 [3919]: https://github.com/enso-org/enso/pull/3919 [3923]: https://github.com/enso-org/enso/pull/3923 +[3950]: https://github.com/enso-org/enso/pull/3950 [3964]: https://github.com/enso-org/enso/pull/3964 [3967]: https://github.com/enso-org/enso/pull/3967 diff --git a/app/gui/controller/engine-protocol/src/common/error.rs b/app/gui/controller/engine-protocol/src/common/error.rs index a5798f08505..ea842f8c46c 100644 --- a/app/gui/controller/engine-protocol/src/common/error.rs +++ b/app/gui/controller/engine-protocol/src/common/error.rs @@ -27,4 +27,7 @@ pub mod code { /// Signals that requested file doesn’t exist. pub const FILE_NOT_FOUND: i64 = 1003; + + /// Signals that requested project is already under version control. + pub const VCS_ALREADY_EXISTS: i64 = 1005; } diff --git a/app/gui/controller/engine-protocol/src/language_server.rs b/app/gui/controller/engine-protocol/src/language_server.rs index 0536c2d63c1..ad10e3e1728 100644 --- a/app/gui/controller/engine-protocol/src/language_server.rs +++ b/app/gui/controller/engine-protocol/src/language_server.rs @@ -194,6 +194,10 @@ trait API { /// Return a list of all project states that are saved to the VCS. #[MethodInput=VcsListInput, rpc_name="vcs/list"] fn list_vcs(&self, root: Path, limit: Option) -> response::ListVcs; + + /// Returns the current status of the changes made to the project. + #[MethodInput=VcsStatusInput, rpc_name="vcs/status"] + fn vcs_status(&self, root: Path) -> response::VcsStatus; }} @@ -203,7 +207,7 @@ trait API { // ============== /// Check if the given `Error` value corresponds to an RPC call timeout. -/// +/// /// Recognizes both client- and server-side timeouts. #[rustfmt::skip] pub fn is_timeout_error(error: &failure::Error) -> bool { diff --git a/app/gui/controller/engine-protocol/src/language_server/response.rs b/app/gui/controller/engine-protocol/src/language_server/response.rs index abcebf4c702..c9e38f5bcfe 100644 --- a/app/gui/controller/engine-protocol/src/language_server/response.rs +++ b/app/gui/controller/engine-protocol/src/language_server/response.rs @@ -108,7 +108,7 @@ pub struct GetComponentGroups { } /// Response of `save_vcs` method. -#[derive(Hash, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Hash, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] #[allow(missing_docs)] pub struct SaveVcs { @@ -117,9 +117,19 @@ pub struct SaveVcs { } /// Response of `list_vcs` method. -#[derive(Hash, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Hash, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] #[allow(missing_docs)] pub struct ListVcs { pub saves: Vec, } + +/// Response of `vcs_status` method. +#[derive(Hash, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +#[allow(missing_docs)] +pub struct VcsStatus { + pub dirty: bool, + pub changed: Vec, + pub last_save: SaveVcs, +} diff --git a/app/gui/src/controller/project.rs b/app/gui/src/controller/project.rs index 423606c507c..0994d11b515 100644 --- a/app/gui/src/controller/project.rs +++ b/app/gui/src/controller/project.rs @@ -8,12 +8,10 @@ use crate::controller::ide::StatusNotificationPublisher; use double_representation::import; use double_representation::name::project; use double_representation::name::QualifiedName; -use engine_protocol::common::error::code; use engine_protocol::language_server::MethodPointer; use engine_protocol::language_server::Path; use enso_frp::web::platform; use enso_frp::web::platform::Platform; -use json_rpc::error::RpcError; use parser_scala::Parser; @@ -245,18 +243,7 @@ impl Project { let root_path = Path::new(project_root_id, &path_segments); let language_server = self.model.json_rpc(); async move { - let response = language_server.save_vcs(&root_path, &None).await; - if let Err(RpcError::RemoteError(json_rpc::messages::Error { - code: error_code, .. - })) = response - { - if error_code == code::FILE_NOT_FOUND { - language_server.init_vcs(&root_path).await?; - language_server.save_vcs(&root_path, &None).await?; - return Ok(()); - } - } - response?; + language_server.save_vcs(&root_path, &None).await?; Ok(()) } } @@ -322,65 +309,29 @@ mod tests { // === Project Snapshotting === - /// Structure that keeps track of whether the VCS is initialized and how many commits are made. + /// Structure that keeps track of how many commits are made. struct VcsMockState { - init: Cell, commit_count: Cell, } /// Mock project that updates a VcsMockState on relevant language server API calls. fn setup_mock_project(vcs: Rc) -> model::Project { let json_client = language_server::MockClient::default(); - let project_root_id = crate::test::mock::data::ROOT_ID; let path_segments: [&str; 0] = []; - let root_path = Path::new(project_root_id, &path_segments); - let vcs_entry = language_server::response::SaveVcs { - commit_id: "commit_id".into(), - message: "message".into(), - }; - let vcs_err = RpcError::RemoteError(json_rpc::messages::Error { - code: code::FILE_NOT_FOUND, - message: "VCS is not initialized.".into(), - data: None, - }); + let root_path = Path::new(Uuid::default(), &path_segments); - let vcs_clone = vcs.clone(); - let root_path_clone = root_path.clone(); - json_client.expect.init_vcs(move |path| { - assert_eq!(path, &root_path_clone); - assert!(!vcs_clone.init.get(), "VCS is already initialized."); - vcs_clone.init.set(true); - Ok(()) - }); - - let vcs_clone = vcs.clone(); - let root_path_clone = root_path.clone(); - let vcs_entry_clone = vcs_entry.clone(); - json_client.expect.save_vcs(move |path, name| { - assert_eq!(path, &root_path_clone); - assert_eq!(name, &None); - if vcs_clone.init.get() { - let count = vcs_clone.commit_count.get(); - vcs_clone.commit_count.set(count + 1); - Ok(vcs_entry_clone) - } else { - Err(vcs_err) - } - }); - - // Expect a 2nd call to `write_vcs` if the first failed because the VCS was not initialized. + let vcs_entry = language_server::response::SaveVcs::default(); json_client.expect.save_vcs(move |path, name| { assert_eq!(path, &root_path); assert_eq!(name, &None); - assert!(vcs.init.get(), "VCS is not initialized."); let count = vcs.commit_count.get(); vcs.commit_count.set(count + 1); Ok(vcs_entry) }); - let ls = engine_protocol::language_server::Connection::new_mock_rc(json_client); + let ls = language_server::Connection::new_mock_rc(json_client); let mut project = model::project::MockAPI::new(); - model::project::test::expect_root_id(&mut project, project_root_id); + model::project::test::expect_root_id(&mut project, Uuid::default()); project.expect_json_rpc().returning_st(move || ls.clone_ref()); Rc::new(project) } @@ -388,30 +339,12 @@ mod tests { #[wasm_bindgen_test] fn save_project_snapshot() { TestWithLocalPoolExecutor::set_up().run_task(async move { - let vcs = - Rc::new(VcsMockState { init: Cell::new(true), commit_count: Cell::new(2) }); + let vcs = Rc::new(VcsMockState { commit_count: Cell::new(2) }); let project = setup_mock_project(vcs.clone()); let project_controller = controller::Project::new(project, default()); let result = project_controller.save_project_snapshot().await; assert_matches!(result, Ok(())); - assert!(vcs.init.get(), "VCS is not initialized."); assert_eq!(vcs.commit_count.get(), 3); }); } - - #[wasm_bindgen_test] - fn save_project_snapshot_vcs_not_init() { - TestWithLocalPoolExecutor::set_up().run_task(async move { - let vcs = Rc::new(VcsMockState { - init: Cell::new(false), - commit_count: Cell::new(0), - }); - let project = setup_mock_project(vcs.clone()); - let project_controller = controller::Project::new(project, default()); - let result = project_controller.save_project_snapshot().await; - assert_matches!(result, Ok(())); - assert!(vcs.init.get(), "VCS is not initialized."); - assert_eq!(vcs.commit_count.get(), 1); - }); - } } diff --git a/app/gui/src/model/project.rs b/app/gui/src/model/project.rs index ca2d5a866f6..c92a836ee41 100644 --- a/app/gui/src/model/project.rs +++ b/app/gui/src/model/project.rs @@ -168,6 +168,8 @@ pub type Synchronized = synchronized::Project; pub enum Notification { /// One of the backend connections has been lost. ConnectionLost(BackendConnection), + /// Indicates that the project VCS status has changed. + VcsStatusChanged(VcsStatus), } /// Denotes one of backend connections used by a project. @@ -179,6 +181,16 @@ pub enum BackendConnection { LanguageServerBinary, } +/// The VCS status indicates whether the project has been modified compared the most recent VCS +/// snapshot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VcsStatus { + /// The project has been modified since the last VCS snapshot. + Dirty, + /// The project is in the same state as the last VCS snapshot. + Clean, +} + // ============ diff --git a/app/gui/src/model/project/synchronized.rs b/app/gui/src/model/project/synchronized.rs index 2446a6df284..da379ec889e 100644 --- a/app/gui/src/model/project/synchronized.rs +++ b/app/gui/src/model/project/synchronized.rs @@ -14,7 +14,9 @@ use crate::transport::web::WebSocket; use double_representation::name::project; use engine_protocol::binary; use engine_protocol::binary::message::VisualisationContext; +use engine_protocol::common::error::code; use engine_protocol::language_server; +use engine_protocol::language_server::response; use engine_protocol::language_server::CapabilityRegistration; use engine_protocol::language_server::ContentRoot; use engine_protocol::language_server::ExpressionUpdates; @@ -23,6 +25,7 @@ use engine_protocol::project_manager; use engine_protocol::project_manager::MissingComponentAction; use engine_protocol::project_manager::ProjectName; use flo_stream::Subscriber; +use json_rpc::error::RpcError; use parser_scala::Parser; @@ -167,6 +170,32 @@ impl ContentRoots { } + +// ======================== +// === VCS status check === +// ======================== + +/// Check whether the current state of the project differs from the most recent snapshot in the VCS, +/// and emit a notification. +#[profile(Detail)] +async fn check_vcs_status_and_notify( + project_root_id: Uuid, + language_server: Rc, + publisher: notification::Publisher, +) -> json_rpc::Result { + let path_segments: [&str; 0] = []; + let root_path = language_server::Path::new(project_root_id, &path_segments); + let status = language_server.vcs_status(&root_path).await; + let notify_status = match &status { + Ok(response::VcsStatus { dirty: true, .. }) | Err(_) => model::project::VcsStatus::Dirty, + Ok(response::VcsStatus { dirty: false, .. }) => model::project::VcsStatus::Clean, + }; + publisher.notify(model::project::Notification::VcsStatusChanged(notify_status)); + status +} + + + // ============= // === Model === // ============= @@ -306,6 +335,7 @@ impl Project { let json_rpc_handler = ret.json_event_handler(); crate::executor::global::spawn(json_rpc_events.for_each(json_rpc_handler)); + ret.initialize_vcs().await.map_err(|err| wrap(err.into()))?; ret.acquire_suggestion_db_updates_capability().await.map_err(|err| wrap(err.into()))?; Ok(ret) } @@ -448,6 +478,8 @@ impl Project { // This generalization should be reconsidered once the old JSON-RPC handler is phased out. // See: https://github.com/enso-org/ide/issues/587 let publisher = self.notifications.clone_ref(); + let project_root_id = self.project_content_root_id(); + let language_server = self.json_rpc().clone_ref(); let weak_suggestion_db = Rc::downgrade(&self.suggestion_db); let weak_content_roots = Rc::downgrade(&self.content_roots); let execution_update_handler = self.execution_update_handler(); @@ -465,7 +497,20 @@ impl Project { // Event Handling match event { Event::Notification(Notification::FileEvent(_)) => {} - Event::Notification(Notification::TextAutoSave(_)) => {} + Event::Notification(Notification::TextAutoSave(_)) => { + let publisher = publisher.clone_ref(); + let language_server = language_server.clone_ref(); + executor::global::spawn(async move { + let status = check_vcs_status_and_notify( + project_root_id, + language_server, + publisher, + ); + if let Err(err) = status.await { + error!("Error while checking project VCS status: {err}"); + } + }); + } Event::Notification(Notification::ExpressionUpdates(updates)) => { let ExpressionUpdates { context_id, updates } = updates; let execution_update = ExecutionUpdate::ExpressionUpdates(updates); @@ -533,6 +578,25 @@ impl Project { .acquire_capability(&capability.method, &capability.register_options) } + /// Initialize the VCS if it was not already initialized. + #[profile(Detail)] + async fn initialize_vcs(&self) -> json_rpc::Result<()> { + let project_root_id = self.project_content_root_id(); + let path_segments: [&str; 0] = []; + let root_path = language_server::Path::new(project_root_id, &path_segments); + let language_server = self.json_rpc().clone_ref(); + let response = language_server.init_vcs(&root_path).await; + if let Err(RpcError::RemoteError(json_rpc::messages::Error { + code: code::VCS_ALREADY_EXISTS, + .. + })) = response + { + Ok(()) + } else { + response + } + } + #[profile(Task)] fn load_module( &self, @@ -663,6 +727,7 @@ mod test { use engine_protocol::types::Sha3_224; use futures::SinkExt; use json_rpc::expect_call; + use std::assert_matches::assert_matches; #[allow(unused)] @@ -687,16 +752,17 @@ mod test { binary_client.expect_event_stream().return_once(|| binary_events.boxed_local()); let json_events_sender = json_client.setup_events(); - let initial_suggestions_db = language_server::response::GetSuggestionDatabase { - entries: vec![], - current_version: 0, - }; + let initial_suggestions_db = + response::GetSuggestionDatabase { entries: vec![], current_version: 0 }; expect_call!(json_client.get_suggestions_database() => Ok(initial_suggestions_db)); let capability_reg = CapabilityRegistration::create_receives_suggestions_database_updates(); let method = capability_reg.method; let options = capability_reg.register_options; expect_call!(json_client.acquire_capability(method,options) => Ok(())); + let path_segments: [&str; 0] = []; + let root_path = language_server::Path::new(Uuid::default(), &path_segments); + expect_call!(json_client.init_vcs(root_path) => Ok(())); setup_mock_json(&mut json_client); setup_mock_binary(&mut binary_client); @@ -838,4 +904,51 @@ mod test { assert_eq!(value_info.typename, value_update.typename.clone().map(ImString::new)); assert_eq!(value_info.method_call, value_update.method_pointer); } + + + // === VCS status check === + + #[wasm_bindgen_test] + fn check_project_vcs_status() { + TestWithLocalPoolExecutor::set_up().run_task(async move { + let json_client = language_server::MockClient::default(); + let path_segments: [&str; 0] = []; + let root_path = language_server::Path::new(Uuid::default(), &path_segments); + + let vcs_status = response::VcsStatus::default(); + let root_path_clone = root_path.clone(); + expect_call!(json_client.vcs_status(root_path_clone) => Ok(vcs_status)); + + let vcs_status = response::VcsStatus { dirty: true, ..Default::default() }; + expect_call!(json_client.vcs_status(root_path) => Ok(vcs_status)); + + let ls = language_server::Connection::new_mock_rc(json_client); + let publisher = notification::Publisher::default(); + let mut subscriber = publisher.subscribe(); + + let result = + check_vcs_status_and_notify(Uuid::default(), ls.clone_ref(), publisher.clone_ref()) + .await; + let message = subscriber.next().await; + assert_matches!(result, Ok(response::VcsStatus { dirty: false, .. })); + assert_matches!( + message, + Some(model::project::Notification::VcsStatusChanged( + model::project::VcsStatus::Clean + )) + ); + + let result = + check_vcs_status_and_notify(Uuid::default(), ls.clone_ref(), publisher.clone_ref()) + .await; + let message = subscriber.next().await; + assert_matches!(result, Ok(response::VcsStatus { dirty: true, .. })); + assert_matches!( + message, + Some(model::project::Notification::VcsStatusChanged( + model::project::VcsStatus::Dirty + )) + ); + }); + } } diff --git a/app/gui/src/presenter/project.rs b/app/gui/src/presenter/project.rs index 78514f18f4e..ddfc5f51193 100644 --- a/app/gui/src/presenter/project.rs +++ b/app/gui/src/presenter/project.rs @@ -10,6 +10,9 @@ use crate::presenter::graph::ViewNodeId; use enso_frp as frp; use ide_view as view; use ide_view::project::SearcherParams; +use model::module::NotificationKind; +use model::project::Notification; +use model::project::VcsStatus; @@ -171,13 +174,20 @@ impl Model { fn save_project_snapshot(&self) { let controller = self.controller.clone_ref(); + let breadcrumbs = self.view.graph().model.breadcrumbs.clone_ref(); executor::global::spawn(async move { if let Err(err) = controller.save_project_snapshot().await { - error!("Error while saving module: {err}"); + error!("Error while saving project snapshot: {err}"); + } else { + breadcrumbs.set_project_changed(false); } }) } + fn set_project_changed(&self, changed: bool) { + self.view.graph().model.breadcrumbs.set_project_changed(changed); + } + fn execution_context_interrupt(&self) { let controller = self.graph_controller.clone_ref(); executor::global::spawn(async move { @@ -301,14 +311,32 @@ impl Project { let weak = Rc::downgrade(&self.model); spawn_stream_handler(weak, notifications, |notification, model| { info!("Processing notification {notification:?}"); - let message = match notification { - model::project::Notification::ConnectionLost(_) => - crate::BACKEND_DISCONNECTED_MESSAGE, + match notification { + Notification::ConnectionLost(_) => { + let message = crate::BACKEND_DISCONNECTED_MESSAGE; + let message = view::status_bar::event::Label::from(message); + model.status_bar.add_event(message); + } + Notification::VcsStatusChanged(VcsStatus::Dirty) => { + model.set_project_changed(true); + } + Notification::VcsStatusChanged(VcsStatus::Clean) => { + model.set_project_changed(false); + } }; - let message = view::status_bar::event::Label::from(message); - model.status_bar.add_event(message); std::future::ready(()) }); + + let notifications = self.model.module_model.subscribe(); + let weak = Rc::downgrade(&self.model); + spawn_stream_handler(weak, notifications, move |notification, model| { + match notification.kind { + NotificationKind::Invalidate + | NotificationKind::CodeChanged { .. } + | NotificationKind::MetadataChanged => model.set_project_changed(true), + } + futures::future::ready(()) + }); self } diff --git a/app/gui/view/graph-editor/src/component/breadcrumbs.rs b/app/gui/view/graph-editor/src/component/breadcrumbs.rs index e9652355841..7e7b0610c3d 100644 --- a/app/gui/view/graph-editor/src/component/breadcrumbs.rs +++ b/app/gui/view/graph-editor/src/component/breadcrumbs.rs @@ -135,6 +135,8 @@ ensogl::define_endpoints! { /// The `gap_width` describes an empty space on the left of all the content. This space will /// be covered by the background and is intended to make room for windows control buttons. gap_width (f32), + /// Set whether the project was changed since the last snapshot save. + set_project_changed(bool), } Output { /// Signalizes when a new breadcrumb is pushed. @@ -517,6 +519,7 @@ impl Breadcrumbs { frp.source.project_name_hovered <+ model.project_name.is_hovered; frp.source.project_mouse_down <+ model.project_name.mouse_down; + eval frp.input.set_project_changed((v) model.project_name.set_project_changed(v)); // === User Interaction === diff --git a/app/gui/view/graph-editor/src/component/breadcrumbs/project_name.rs b/app/gui/view/graph-editor/src/component/breadcrumbs/project_name.rs index 022c1d8c431..962c2a10064 100644 --- a/app/gui/view/graph-editor/src/component/breadcrumbs/project_name.rs +++ b/app/gui/view/graph-editor/src/component/breadcrumbs/project_name.rs @@ -18,7 +18,7 @@ use ensogl::gui::cursor; use ensogl::DEPRECATED_Animation; use ensogl_component::text; use ensogl_component::text::formatting::Size as TextSize; -use ensogl_hardcoded_theme as theme; +use ensogl_hardcoded_theme::graph_editor::breadcrumbs as breadcrumbs_theme; @@ -75,6 +75,8 @@ ensogl::define_endpoints! { /// Indicates the IDE is in edit mode. This means a click on some editable text should /// start editing it. ide_text_edit_mode (bool), + /// Set whether the project was changed since the last snapshot save. + set_project_changed(bool), } Output { @@ -136,7 +138,7 @@ impl ProjectNameModel { // FIXME : StyleWatch is unsuitable here, as it was designed as an internal tool for shape // system (#795) let style = StyleWatch::new(&scene.style_sheet); - let base_color = style.get_color(theme::graph_editor::breadcrumbs::transparent); + let base_color = style.get_color(breadcrumbs_theme::transparent); let text_size: TextSize = TEXT_SIZE.into(); let text_field = app.new_view::(); text_field.set_property_default(base_color); @@ -250,9 +252,13 @@ impl ProjectName { // FIXME : StyleWatch is unsuitable here, as it was designed as an internal tool for shape // system (#795) let styles = StyleWatch::new(&scene.style_sheet); - let hover_color = styles.get_color(theme::graph_editor::breadcrumbs::hover); - let deselected_color = styles.get_color(theme::graph_editor::breadcrumbs::deselected::left); - let selected_color = styles.get_color(theme::graph_editor::breadcrumbs::selected); + let saved_hover_color = styles.get_color(breadcrumbs_theme::hover); + let saved_deselected_color = styles.get_color(breadcrumbs_theme::deselected::left); + let saved_selected_color = styles.get_color(breadcrumbs_theme::selected); + let unsaved_hover_color = styles.get_color(breadcrumbs_theme::unsaved::hover); + let unsaved_deselected_color = + styles.get_color(breadcrumbs_theme::unsaved::deselected::left); + let unsaved_selected_color = styles.get_color(breadcrumbs_theme::unsaved::selected); let animations = Animations::new(network); frp::extend! { network @@ -264,16 +270,19 @@ impl ProjectName { &model.view.events.mouse_over); frp.source.mouse_down <+ model.view.events.mouse_down_primary; - not_selected <- frp.output.selected.map(|selected| !selected); - mouse_over_if_not_selected <- model.view.events.mouse_over.gate(¬_selected); - mouse_out_if_not_selected <- model.view.events.mouse_out.gate(¬_selected); - eval_ mouse_over_if_not_selected( - animations.color.set_target_value(hover_color); - ); - eval_ mouse_out_if_not_selected( - animations.color.set_target_value(deselected_color); - ); - on_deselect <- not_selected.gate(¬_selected).constant(()); + text_color <- all3( + &frp.output.selected, + &frp.source.is_hovered, + &frp.input.set_project_changed, + ).map(move |(selected, hovered, changed)| match (*selected, *hovered, *changed) { + (true, _, true) => unsaved_selected_color, + (true, _, false) => saved_selected_color, + (false, false, true) => unsaved_deselected_color, + (false, false, false) => saved_deselected_color, + (false, true, true) => unsaved_hover_color, + (false, true, false) => saved_hover_color, + }); + eval text_color((&color) animations.color.set_target_value(color)); edit_click <- mouse_down.gate(&frp.ide_text_edit_mode); start_editing <- any(edit_click,frp.input.start_editing); @@ -307,19 +316,18 @@ impl ProjectName { eval commit_text((text) model.commit(text)); on_commit <- commit_text.constant(()); + not_selected <- frp.output.selected.map(|selected| !selected); + on_deselect <- not_selected.gate(¬_selected).constant(()); frp.output.source.edit_mode <+ on_deselect.to_false(); // === Selection === - eval_ frp.select( animations.color.set_target_value(selected_color) ); frp.output.source.selected <+ frp.select.to_true(); - set_inactive <- any(&frp.deselect,&on_commit); - eval_ set_inactive ([text,animations]{ + eval_ set_inactive ([text] { text.deprecated_set_focus(false); text.remove_all_cursors(); - animations.color.set_target_value(deselected_color); }); frp.output.source.selected <+ set_inactive.to_false(); diff --git a/lib/rust/ensogl/app/theme/hardcoded/src/lib.rs b/lib/rust/ensogl/app/theme/hardcoded/src/lib.rs index ee53652eecd..a32c4e52b1e 100644 --- a/lib/rust/ensogl/app/theme/hardcoded/src/lib.rs +++ b/lib/rust/ensogl/app/theme/hardcoded/src/lib.rs @@ -587,11 +587,19 @@ define_themes! { [light:0, dark:1] full = Lcha(0.0,0.0,0.0,0.7) , Lcha(1.0,0.0,0.0,0.7); transparent = Lcha(0.0,0.0,0.0,0.4) , Lcha(1.0,0.0,0.0,0.4); selected = Lcha(0.0,0.0,0.0,0.7) , Lcha(1.0,0.0,0.0,0.7); - hover = Lcha(0.0,0.0,0.0,0.7) , Lcha(1.0,0.0,0.0,0.7); + hover = Lcha(0.0,0.0,0.0,0.6) , Lcha(1.0,0.0,0.0,0.6); deselected { - left = Lcha(0.0,0.0,0.0,0.5) , Lcha(1.0,0.0,0.0,0.5); + left = Lcha(0.0,0.0,0.0,0.4) , Lcha(1.0,0.0,0.0,0.4); right = Lcha(0.0,0.0,0.0,0.2) , Lcha(1.0,0.0,0.0,0.2); } + unsaved { + selected = Lcha(0.0,0.0,0.0,1.0) , Lcha(1.0,0.0,0.0,1.0); + hover = Lcha(0.0,0.0,0.0,1.0) , Lcha(1.0,0.0,0.0,1.0); + deselected { + left = Lcha(0.0,0.0,0.0,0.8) , Lcha(1.0,0.0,0.0,0.8); + right = Lcha(0.0,0.0,0.0,0.6) , Lcha(1.0,0.0,0.0,0.6); + } + } background = application::background , application::background; background { corner_radius = 8.0 , 8.0;