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.
This commit is contained in:
Galin Bajlekov 2022-12-19 22:22:33 +01:00 committed by GitHub
parent 49204e92cf
commit 4b28f8f8f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 236 additions and 109 deletions

View File

@ -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

View File

@ -27,4 +27,7 @@ pub mod code {
/// Signals that requested file doesnt exist.
pub const FILE_NOT_FOUND: i64 = 1003;
/// Signals that requested project is already under version control.
pub const VCS_ALREADY_EXISTS: i64 = 1005;
}

View File

@ -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<usize>) -> 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 {

View File

@ -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<SaveVcs>,
}
/// 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<Path>,
pub last_save: SaveVcs,
}

View File

@ -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<bool>,
commit_count: Cell<usize>,
}
/// Mock project that updates a VcsMockState on relevant language server API calls.
fn setup_mock_project(vcs: Rc<VcsMockState>) -> 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);
});
}
}

View File

@ -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,
}
// ============

View File

@ -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<language_server::Connection>,
publisher: notification::Publisher<model::project::Notification>,
) -> json_rpc::Result<response::VcsStatus> {
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
))
);
});
}
}

View File

@ -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
}

View File

@ -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 ===

View File

@ -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::Text>();
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(&not_selected);
mouse_out_if_not_selected <- model.view.events.mouse_out.gate(&not_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(&not_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(&not_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();

View File

@ -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;