Introducing new notifications to the GUI (#7458)

This PR replaces statusbar and popup components with the new notification API.

I have moved debug mode status notification to the bottom of the window, so it is separate from other notifications.

![obraz](https://github.com/enso-org/enso/assets/1548407/0e28bdd5-3567-4d8c-b580-d53099a00715)

![obraz](https://github.com/enso-org/enso/assets/1548407/47468d56-c4fc-4a0a-876d-2b059509a29c)

![obraz](https://github.com/enso-org/enso/assets/1548407/359dd764-305a-4a75-b9a4-75674bcc9990)
This commit is contained in:
Michał Wawrzyniec Urbańczyk 2023-08-04 16:48:23 +02:00 committed by GitHub
parent 6534d0a925
commit 90e3f9ccef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1025 additions and 943 deletions

View File

@ -27,7 +27,7 @@ pub use engine_protocol::project_manager::ProjectName;
// ============================
/// The handle used to pair the ProcessStarted and ProcessFinished notifications.
pub type BackgroundTaskHandle = usize;
pub type BackgroundTaskHandle = Uuid;
/// A notification which should be displayed to the User on the status bar.
#[allow(missing_docs)]
@ -44,8 +44,7 @@ pub enum StatusNotification {
/// A publisher for status notification events.
#[derive(Clone, CloneRef, Debug, Default)]
pub struct StatusNotificationPublisher {
publisher: notification::Publisher<StatusNotification>,
next_process_handle: Rc<Cell<usize>>,
publisher: notification::Publisher<StatusNotification>,
}
impl StatusNotificationPublisher {
@ -68,8 +67,7 @@ impl StatusNotificationPublisher {
#[profile(Debug)]
pub fn publish_background_task(&self, label: impl Into<String>) -> BackgroundTaskHandle {
let label = label.into();
let handle = self.next_process_handle.get();
self.next_process_handle.set(handle + 1);
let handle = Uuid::new_v4();
let notification = StatusNotification::BackgroundTaskStarted { label, handle };
executor::global::spawn(self.publisher.publish(notification));
handle

View File

@ -24,6 +24,8 @@ pub use initializer::Initializer;
// === Constants ===
// =================
/// Constant notification ID, so we can reuse the same notification.
pub const BACKEND_DISCONNECTED_NOTIFICATION_ID: &str = "backend-disconnected-toast";
/// Text that shows up in the statusbar when any of the backend connections is lost.
pub const BACKEND_DISCONNECTED_MESSAGE: &str =
"Connection to the backend has been lost. Please try restarting IDE.";

View File

@ -88,7 +88,6 @@ impl Initializer {
if enso_config::ARGS.groups.profile.options.emit_user_timing_measurements.value {
ensogl_app.display.connect_profiler_to_user_timing();
}
let status_bar = view.status_bar().clone_ref();
ensogl_app.display.add_child(&view);
// TODO [mwu] Once IDE gets some well-defined mechanism of reporting
// issues to user, such information should be properly passed
@ -102,7 +101,7 @@ impl Initializer {
}
Err(error) => {
let message = format!("Failed to initialize application: {error}");
status_bar.add_event(ide_view::status_bar::event::Label::new(message));
ide_view::notification::logged::error(message, &None);
Err(FailedIde { view })
}
}

View File

@ -10,7 +10,6 @@ use crate::presenter;
use enso_frp as frp;
use ide_view as view;
use ide_view::graph_editor::SharedHashMap;
// ==============
@ -55,19 +54,14 @@ impl Model {
// We know the name of new project before it loads. We set it right now to avoid
// displaying a placeholder on the scene during loading.
let project_view = self.view.project();
let status_bar = self.view.status_bar().clone_ref();
let project_name = project_view.top_bar().project_name();
project_name.set_name(project_model.name().to_string());
let status_notifications = self.controller.status_notifications().clone_ref();
let ide_controller = self.controller.clone_ref();
let project_controller = controller::Project::new(project_model, status_notifications);
let project_presenter = presenter::Project::initialize(
ide_controller,
project_controller,
project_view,
status_bar,
);
let project_presenter =
presenter::Project::initialize(ide_controller, project_controller, project_view);
crate::executor::global::spawn(async move {
match project_presenter.await {
Ok(project) => {
@ -172,44 +166,41 @@ impl Presenter {
#[profile(Detail)]
fn init(self) -> Self {
self.setup_status_bar_notification_handler();
self.setup_controller_notification_handler();
self.setup_user_facing_notification_handler();
self.setup_controller_internal_notification_handler();
self.model.clone_ref().setup_and_display_new_project();
executor::global::spawn(self.clone_ref().set_projects_list_on_welcome_screen());
self
}
fn setup_status_bar_notification_handler(&self) {
use controller::ide::BackgroundTaskHandle as ControllerHandle;
use ide_view::status_bar::process::Id as ViewHandle;
fn setup_user_facing_notification_handler(&self) {
use view::notification::logged as notification;
let process_map = SharedHashMap::<ControllerHandle, ViewHandle>::new();
let status_bar = self.model.view.status_bar().clone_ref();
let status_notifications = self.model.controller.status_notifications().subscribe();
let weak = Rc::downgrade(&self.model);
spawn_stream_handler(weak, status_notifications, move |notification, _| {
match notification {
StatusNotification::Event { label } => {
status_bar.add_event(ide_view::status_bar::event::Label::new(label));
notification::info(label, &None);
}
StatusNotification::BackgroundTaskStarted { label, handle } => {
status_bar.add_process(ide_view::status_bar::process::Label::new(label));
let view_handle = status_bar.last_process.value();
process_map.insert(handle, view_handle);
let id = notification::Id::from(handle);
let notification = notification::Notification::from(id);
notification.update(|opts| {
opts.set_always_present();
opts.set_raw_text_content(label);
});
notification.show();
}
StatusNotification::BackgroundTaskFinished { handle } => {
if let Some(view_handle) = process_map.remove(&handle) {
status_bar.finish_process(view_handle);
} else {
warn!("Controllers finished process not displayed in view");
}
notification::Id::from(handle).dismiss();
}
}
futures::future::ready(())
});
}
fn setup_controller_notification_handler(&self) {
fn setup_controller_internal_notification_handler(&self) {
let stream = self.model.controller.subscribe();
let weak = Rc::downgrade(&self.model);
spawn_stream_handler(weak, stream, move |notification, model| {

View File

@ -8,6 +8,7 @@ use crate::presenter;
use crate::presenter::searcher::ai::AISearcher;
use crate::presenter::searcher::SearcherPresenter;
use crate::presenter::ComponentBrowserSearcher;
use crate::EXECUTION_FAILED_MESSAGE;
use engine_protocol::language_server::ExecutionEnvironment;
use enso_frp as frp;
@ -18,6 +19,7 @@ use ide_view::project::SearcherType;
use model::module::NotificationKind;
use model::project::Notification;
use model::project::VcsStatus;
use view::notification::logged as notification;
@ -44,13 +46,12 @@ struct Model {
graph_controller: controller::ExecutedGraph,
ide_controller: controller::Ide,
view: view::project::View,
status_bar: view::status_bar::View,
graph: presenter::Graph,
code: presenter::Code,
searcher: RefCell<Option<Box<dyn SearcherPresenter>>>,
available_projects: Rc<RefCell<Vec<(ImString, Uuid)>>>,
shortcut_transaction: RefCell<Option<Rc<model::undo_redo::Transaction>>>,
execution_failed_process_id: Rc<Cell<Option<view::status_bar::process::Id>>>,
execution_failed_notification: notification::Notification,
}
impl Model {
@ -60,7 +61,6 @@ impl Model {
controller: controller::Project,
init_result: controller::project::InitializationResult,
view: view::project::View,
status_bar: view::status_bar::View,
) -> Self {
let graph_controller = init_result.main_graph;
let text_controller = init_result.main_module_text;
@ -74,20 +74,27 @@ impl Model {
let searcher = default();
let available_projects = default();
let shortcut_transaction = default();
let execution_failed_process_id = default();
let options = notification::UpdateOptions {
render: Some(EXECUTION_FAILED_MESSAGE.into()),
options: notification::Options {
auto_close: Some(notification::AutoClose::Never()),
r#type: Some(notification::Type::Error),
..default()
},
};
let execution_failed_notification = notification::Notification::new(options);
Model {
controller,
module_model,
graph_controller,
ide_controller,
view,
status_bar,
graph,
code,
searcher,
available_projects,
shortcut_transaction,
execution_failed_process_id,
execution_failed_notification,
}
}
@ -220,17 +227,11 @@ impl Model {
fn execution_complete(&self) {
self.view.graph().frp.set_read_only(false);
self.view.graph().frp.execution_complete.emit(());
if let Some(id) = self.execution_failed_process_id.get() {
self.status_bar.finish_process(id);
}
self.execution_failed_notification.dismiss();
}
fn execution_failed(&self) {
let message = crate::EXECUTION_FAILED_MESSAGE;
let message = view::status_bar::process::Label::from(message);
self.status_bar.add_process(message);
let id = self.status_bar.last_process.value();
self.execution_failed_process_id.set(Some(id));
self.execution_failed_notification.show();
}
fn execution_context_interrupt(&self) {
@ -281,7 +282,6 @@ impl Model {
let controller = self.ide_controller.clone_ref();
let projects_list = self.available_projects.clone_ref();
let view = self.view.clone_ref();
let status_bar = self.status_bar.clone_ref();
let id = *id_in_list;
executor::global::spawn(async move {
let app = js::app_or_panic();
@ -292,14 +292,17 @@ impl Model {
let uuid = projects_list.borrow().get(id).map(|(_name, uuid)| *uuid);
if let Some(uuid) = uuid {
if let Err(error) = api.open_project(uuid).await {
error!("Error opening project: {error}.");
status_bar.add_event(format!("Error opening project: {error}."));
let message = format!("Error opening project: {error}");
notification::error(message, &None);
}
} else {
error!("Project with id {id} not found.");
notification::error(format!("Project with id {id} not found."), &None);
}
} else {
error!("Project Manager API not available, cannot open project.");
notification::error(
"Project Manager API not available, cannot open project.",
&None,
);
}
app.hide_progress_indicator();
view.show_graph_editor();
@ -363,10 +366,9 @@ impl Project {
controller: controller::Project,
init_result: controller::project::InitializationResult,
view: view::project::View,
status_bar: view::status_bar::View,
) -> Self {
let network = frp::Network::new("presenter::Project");
let model = Model::new(ide_controller, controller, init_result, view, status_bar);
let model = Model::new(ide_controller, controller, init_result, view);
let model = Rc::new(model);
Self { network, model }.init()
}
@ -481,8 +483,12 @@ impl Project {
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);
let options = notification::Options {
auto_close: Some(notification::AutoClose::Never()),
toast_id: Some(crate::BACKEND_DISCONNECTED_NOTIFICATION_ID.into()),
..Default::default()
};
notification::error(message, &Some(options));
}
Notification::VcsStatusChanged(VcsStatus::Dirty) => {
model.set_project_changed(true);
@ -539,13 +545,11 @@ impl Project {
ide_controller: controller::Ide,
controller: controller::Project,
view: view::project::View,
status_bar: view::status_bar::View,
) -> FallibleResult<Self> {
debug!("Initializing project controller...");
let init_result = controller.initialize().await?;
debug!("Project controller initialized.");
let presenter =
Self::new(ide_controller, controller.clone(), init_result, view, status_bar);
let presenter = Self::new(ide_controller, controller.clone(), init_result, view);
debug!("Project presenter created.");
// Following the project initialization, the Undo/Redo stack should be empty.
// This makes sure that any initial modifications resulting from the GUI initialization

View File

@ -19,7 +19,6 @@ use ensogl::prelude::*;
use enso_frp as frp;
use ensogl::application::Application;
use ensogl::control::io::mouse;
use ensogl::display::object::ObjectOps;
use ensogl::display::shape::StyleWatch;
use ensogl::gui::text;
@ -35,7 +34,6 @@ use ide_view::graph_editor::NodeProfilingStatus;
use ide_view::graph_editor::Type;
use ide_view::project;
use ide_view::root;
use ide_view::status_bar;
use parser::Parser;
use span_tree::TagValue;
@ -115,7 +113,6 @@ fn init(app: &Application) {
code_editor.text_area().set_content(STUB_MODULE.to_owned());
root_view.status_bar().add_event(status_bar::event::Label::new("This is a status message."));
project_view.debug_push_breadcrumb();
root_view.switch_view_to_project();
@ -250,23 +247,6 @@ fn init(app: &Application) {
graph_editor.set_available_execution_environments(make_dummy_execution_environments());
// === Pop-up ===
// Create node to trigger a pop-up.
let node_id = graph_editor.model.add_node();
graph_editor.frp.set_node_position.emit((node_id, Vector2(-300.0, -100.0)));
let expression = expression_mock_string("Click me to show a pop-up");
graph_editor.frp.set_node_expression.emit((node_id, expression));
graph_editor.model.with_node(node_id, |node| {
let popup = project_view.popup();
let network = node.network();
let node_clicked = node.on_event::<mouse::Down>();
frp::extend! { network
eval_ node_clicked (popup.set_label.emit("This is a test pop-up."));
}
});
// === Rendering ===
// let tgt_type = dummy_type_generator.get_dummy_type();

View File

@ -1,83 +0,0 @@
//! A pop-up that signals about enabling/disabling Debug Mode of Graph Editor.
use crate::prelude::*;
use crate::popup;
use ensogl::application::Application;
use ensogl::display;
use frp::stream::EventOutput;
use frp::HasLabel;
// =================
// === Constants ===
// =================
/// Mitigate limitations of constant strings concatenation.
macro_rules! define_debug_mode_shortcut {
($shortcut:literal) => {
/// A keyboard shortcut used to enable/disable Debug Mode.
pub const DEBUG_MODE_SHORTCUT: &str = $shortcut;
const DEBUG_MODE_ENABLED: &str =
concat!("Debug Mode enabled. To disable, press `", $shortcut, "`.");
};
}
define_debug_mode_shortcut!("ctrl shift d");
const DEBUG_MODE_DISABLED: &str = "Debug Mode disabled.";
const LABEL_VISIBILITY_DELAY_MS: f32 = 3_000.0;
// ===========
// === FRP ===
// ===========
ensogl::define_endpoints! {
Input {
// Debug Mode was enabled.
enabled(),
// Debug Mode was disabled.
disabled(),
}
Output {}
}
// ============
// === View ===
// ============
/// A pop-up that signals about enabling/disabling Debug Mode of Graph Editor.
#[derive(Debug, Clone, CloneRef, Deref, display::Object)]
pub struct View {
#[deref]
frp: Frp,
#[display_object]
popup: popup::View,
}
impl View {
/// Constructor.
pub fn new(app: &Application) -> Self {
let frp = Frp::new();
let network = &frp.network;
let popup = popup::View::new(app);
popup.set_delay(LABEL_VISIBILITY_DELAY_MS);
frp::extend! { network
eval_ frp.enabled (popup.set_label(DEBUG_MODE_ENABLED.to_string()));
eval_ frp.disabled (popup.set_label(DEBUG_MODE_DISABLED.to_string()));
}
Self { frp, popup }
}
/// Get the FRP node for the content of the pop-up, for testing purposes.
pub fn content_frp_node(&self) -> impl EventOutput<Output = String> + HasLabel {
self.popup.content_frp_node()
}
}

View File

@ -8,6 +8,8 @@
#![feature(associated_type_defaults)]
#![feature(drain_filter)]
#![feature(fn_traits)]
#![feature(let_chains)]
#![feature(once_cell)]
#![feature(option_result_contains)]
#![feature(specialization)]
#![feature(trait_alias)]
@ -31,14 +33,11 @@
#[allow(clippy::option_map_unit_fn)]
pub mod code_editor;
pub mod debug_mode_popup;
pub mod notification;
pub mod popup;
pub mod project;
pub mod project_list;
pub mod root;
pub mod searcher;
pub mod status_bar;
pub use ide_view_component_browser as component_browser;
pub use ide_view_documentation as documentation;

View File

@ -1,391 +1,77 @@
//! This is Rust wrapper for the [`react-toastify`](https://fkhadra.github.io/react-toastify) library.
//! A pop-up notification that can be controlled by FRP.
//!
//! # Example
//!
//! ```no_run
//! use ide_view::notification;
//! # fn main() -> Result<(), wasm_bindgen::JsValue> {
//! use ide_view::notification::UpdateOptions;
//! let handle = notification::info(
//! "Undo triggered in UI.",
//! &Some(notification::Options {
//! theme: Some(notification::Theme::Dark),
//! auto_close: Some(notification::AutoClose::Never()),
//! draggable: Some(false),
//! close_on_click: Some(false),
//! ..Default::default()
//! }),
//! )?;
//! handle.update(&UpdateOptions::default().render_string("Undo done."))?;
//! # Ok(())
//! # }
//! ```
//! Non-FRP, foundational API is available in the [api](crate::notification::api) module.
use crate::prelude::*;
use wasm_bindgen::prelude::*;
use gloo_utils::format::JsValueSerdeExt;
use serde::Deserialize;
use serde::Serialize;
use crate::notification::logged::Notification;
use crate::notification::logged::UpdateOptions;
use ensogl::application::Application;
// ==============
// === Export ===
// ==============
pub mod api;
pub mod js;
pub use js::Id;
pub mod logged;
// ===================
// === Primary API ===
// ===================
// ===========
// === FRP ===
// ===========
/// Send any kind of notification.
pub fn send_any(message: &str, r#type: Type, options: &Option<Options>) -> Result<Id, JsValue> {
let options = match options {
Some(options) => options.try_into()?,
None => JsValue::UNDEFINED,
};
js::toast(message, r#type, &options)
}
/// Send an info notification.
pub fn info(message: &str, options: &Option<Options>) -> Result<Id, JsValue> {
send_any(message, Type::Info, options)
}
/// Send a warning notification.
pub fn warning(message: &str, options: &Option<Options>) -> Result<Id, JsValue> {
send_any(message, Type::Warning, options)
}
/// Send a error notification.
pub fn error(message: &str, options: &Option<Options>) -> Result<Id, JsValue> {
send_any(message, Type::Error, options)
}
/// Send a success notification.
pub fn success(message: &str, options: &Option<Options>) -> Result<Id, JsValue> {
send_any(message, Type::Success, options)
}
// =============================
// === JS-conversion helpers ===
// =============================
// === Rust->JS error ===
/// Convert arbitrary Rust error value into JsValue-based error.
pub fn to_js_error(error: impl std::error::Error) -> JsValue {
js_sys::Error::new(&error.to_string()).into()
}
// === Rust->JS conversion ===
/// Macro that implements TryFrom for given `type` to/from JsValue using
/// [`gloo-utils::format::JsValueSerdeExt`].
///
/// Implements:
/// - `TryFrom<&type> for JsValue`
/// - `TryFrom<JsValue> for type`
macro_rules! impl_try_from_jsvalue {
($type:ty) => {
impl TryFrom<&$type> for JsValue {
type Error = JsValue;
fn try_from(value: &$type) -> Result<Self, Self::Error> {
JsValue::from_serde(value).map_err($crate::notification::to_js_error)
}
}
impl TryFrom<JsValue> for $type {
type Error = JsValue;
fn try_from(js_value: JsValue) -> Result<Self, Self::Error> {
js_value.into_serde().map_err($crate::notification::to_js_error)
}
}
};
}
// === SerializableJsValue ===
/// A wrapper around a `JsValue` that implements the `Serialize` and `Deserialize` traits.
#[derive(Clone, Debug, Deref, DerefMut, AsRef, From)]
pub struct SerializableJsValue(pub JsValue);
impl Serialize for SerializableJsValue {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let value = js_sys::JSON::stringify(self).map(String::from).map_err(|e| {
serde::ser::Error::custom(format!("Failed to stringify JsValue: {e:?}"))
})?;
let value = serde_json::from_str::<serde_json::Value>(&value)
.map_err(|e| serde::ser::Error::custom(format!("Failed to parse JSON: {e:?}")))?;
value.serialize(serializer)
ensogl::define_endpoints! {
Input {
/// Toggles visibility.
is_enabled(bool),
/// Customize notification.
set_options(UpdateOptions),
}
}
impl<'de> Deserialize<'de> for SerializableJsValue {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
JsValue::from_serde(&value).map(SerializableJsValue).map_err(serde::de::Error::custom)
}
}
// ===============
// === Options ===
// ===============
// === AutoClose ===
/// Helper structure for [`AutoClose`] so it serializes in a way compatible with the JS library.
///
/// Users of this module should not deal directly with this type. Instead, rely on
/// [`AutoClose::After`] or [`AutoClose::Never`]
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AutoCloseInner {
/// Auto close after a delay expressed in milliseconds.
Delay(u32),
/// Do not auto close. The boolean value must be `false`.
ShouldEver(bool),
}
/// Represents the auto-close delay of a notification.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Deref)]
#[serde(transparent)]
pub struct AutoClose(AutoCloseInner);
impl AutoClose {
/// Auto close after a delay expressed in milliseconds.
#[allow(non_snake_case)]
pub fn After(delay_ms: u32) -> Self {
Self(AutoCloseInner::Delay(delay_ms))
}
/// Do not auto close.
#[allow(non_snake_case)]
pub fn Never() -> Self {
Self(AutoCloseInner::ShouldEver(false))
}
}
// === Type ===
/// Represents the type of a notification.
///
/// Affects styling and icon.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, strum::AsRefStr)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
#[allow(missing_docs)]
pub enum Type {
Info,
Success,
Warning,
Error,
Default,
}
// === Position ===
/// Represents the position of a notification on the screen.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[allow(missing_docs)]
pub enum Position {
TopRight,
TopCenter,
TopLeft,
BottomRight,
BottomCenter,
BottomLeft,
}
// === DraggableDirection ===
/// Direction that the notification can be dragged (swiped) to dismiss it.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[allow(missing_docs)]
pub enum DraggableDirection {
X,
Y,
Output {}
}
// === Theme ===
// ============
// === View ===
// ============
/// Themes supported by the library.
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[allow(missing_docs)]
pub enum Theme {
Light,
Dark,
Colored,
}
// === Options ===
/// Customization options for the notification.
///
/// Note that it is not necessary to set any of these. All options marked as `None` will be
/// auto-filled with default values.
#[derive(Debug, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[allow(missing_docs)]
pub struct Options {
/// Unique notification identifier.
pub toast_id: Option<String>,
/// Type of the notification, affecting styling and icon. Default: `Type::Default`.
pub r#type: Option<Type>,
/// The position where the toast should appear. Default: `bottom-right`.
pub position: Option<Position>,
/// Time in milliseconds after the toast is removed. Default: `5000`.
pub auto_close: Option<AutoClose>,
/// Whether to show a close button. Default: `true`.
pub close_button: Option<bool>,
// pub transition: Option<ToastTransition>,
/// Hide or show the progress bar. `Default: false`
pub hide_progress_bar: Option<bool>,
/// Pause the timer when the mouse hover the toast. Default: `true`.
pub pause_on_hover: Option<bool>,
/// Pause the timer when the window loses focus. Default: `true`.
pub pause_on_focus_loss: Option<bool>,
/// Remove the toast when clicked. Default: `true`.
pub close_on_click: Option<bool>,
/// An optional css class to set.
pub class_name: Option<String>,
/// An optional css class to set for the toast content.
pub body_class_name: Option<String>,
/// An optional inline style to apply. This should be a JS object with CSS properties.
pub style: Option<SerializableJsValue>,
/// An optional inline style to apply for the toast content. This should be a JS object with
/// CSS properties.
pub body_style: Option<SerializableJsValue>,
/// An optional css class to set for the progress bar.
pub progress_class_name: Option<String>,
/// An optional inline style to apply for the progress bar. This should be a JS object with
/// CSS properties.
pub progress_style: Option<SerializableJsValue>,
/// Allow toast to be draggable. `Default: true`.
pub draggable: Option<bool>,
/// The percentage of the toast's width it takes for a drag to dismiss a toast. `Default: 80`.
pub draggable_percent: Option<f32>,
/// Specify in which direction should you swipe to dismiss the toast. `Default: "x"`.
pub draggable_direction: Option<DraggableDirection>,
/// Set id to handle multiple `ToastContainer` instances.
pub container_id: Option<String>,
/// Define the [ARIA role](https://www.w3.org/WAI/PF/aria/roles) for the notification. `Default: "alert"`.
pub role: Option<String>,
/// Add a delay in ms before the toast appear.
pub delay: Option<u32>,
/// Whether the ongoing action is still in progress.
///
/// The auto-close timer will not start if this is set to `true`.
pub is_loading: Option<bool>,
/// Support right to left display content. `Default: false`.
pub rtl: Option<bool>,
/// Theme to use.
pub theme: Option<Theme>,
/// Used to display a custom icon. Set it to `false` to prevent the icons from being displayed
pub icon: Option<bool>,
/// Any additional data to pass to the toast.
pub data: Option<SerializableJsValue>,
}
impl_try_from_jsvalue! {Options}
// === Update ===
/// Update options for a toast.
///
/// Just like [`Options`], but also includes a `render` field that allows to update the
/// notification message.
#[derive(Debug, Serialize, Deserialize, Default, Deref, DerefMut)]
#[serde(rename_all = "camelCase")]
pub struct UpdateOptions {
/// New state of the toast.
/// A reusable popup notification.
#[derive(Debug, Clone, CloneRef, Deref)]
pub struct View {
#[deref]
#[deref_mut]
#[serde(flatten)]
pub options: Options,
/// Used to update a toast. Pass any valid ReactNode(string, number, component).
pub render: Option<SerializableJsValue>,
frp: Frp,
notification: Notification,
}
impl_try_from_jsvalue! { UpdateOptions }
impl View {
/// Constructor.
pub fn new(_app: &Application) -> Self {
let frp = Frp::new();
let network = &frp.network;
let notification = Notification::default();
impl UpdateOptions {
/// Allows set a new message in the notification.
///
/// Useful only for update a toast.
pub fn render_string(mut self, render: impl AsRef<str>) -> Self {
let message = render.as_ref();
let value = JsValue::from_str(message);
self.render = Some(value.into());
self
}
}
// =============
// === Tests ===
// =============
#[cfg(test)]
mod tests {
use super::*;
use wasm_bindgen_test::wasm_bindgen_test;
use wasm_bindgen_test::wasm_bindgen_test_configure;
wasm_bindgen_test_configure!(run_in_browser);
#[test]
fn test_auto_close_delay() {
let auto_close = AutoClose::After(5000);
let serialized = serde_json::to_string(&auto_close).unwrap();
assert_eq!(serialized, "5000");
}
#[test]
fn test_auto_close_never() {
let auto_close = AutoClose::Never();
let serialized = serde_json::to_string(&auto_close).unwrap();
assert_eq!(serialized, "false");
}
#[wasm_bindgen_test]
fn test_options_marshalling() {
let options = Options { theme: Some(Theme::Dark), ..default() };
let js_value = JsValue::try_from(&options).unwrap();
// Make sure that `js_value` is a valid JS object.
assert!(js_value.is_object());
let js_object = js_value.dyn_into::<js_sys::Object>().unwrap();
// Make sure that `js_object` has a `theme` property.
assert!(js_object.has_own_property(&"theme".into()));
// Make sure that `js_object.theme` is a string.
let theme = js_sys::Reflect::get(&js_object, &"theme".into()).unwrap();
assert!(theme.is_string());
// Make sure that `js_object.theme` is equal to "dark".
let theme = theme.as_string().unwrap();
assert_eq!(theme, "dark");
frp::extend! { network
eval frp.is_enabled ([notification] (enabled) {
if *enabled {
notification.show()
} else {
notification.dismiss()
}
});
eval frp.set_options ([notification] (options) {
notification.update(|opts| {
*opts = options.clone();
})
});
}
Self { frp, notification }
}
}

View File

@ -0,0 +1,579 @@
//! This is Rust wrapper for the [`react-toastify`](https://fkhadra.github.io/react-toastify) library.
//!
//! # Example
//!
//! ```no_run
//! use ide_view::notification::api::*;
//! # fn main() -> Result<(), wasm_bindgen::JsValue> {
//! let handle = info(
//! "Undo triggered in UI.",
//! &Some(Options {
//! theme: Some(Theme::Dark),
//! auto_close: Some(AutoClose::Never()),
//! draggable: Some(false),
//! close_on_click: Some(false),
//! ..Default::default()
//! }),
//! )?;
//! handle.update(&UpdateOptions::default().raw_text_content("Undo done."))?;
//! # Ok(())
//! # }
//! ```
use crate::prelude::*;
use wasm_bindgen::prelude::*;
use crate::notification::js;
use gloo_utils::format::JsValueSerdeExt;
use serde::Deserialize;
use serde::Serialize;
// ==============
// === Export ===
// ==============
pub use crate::notification::js::Id;
// ===================
// === Primary API ===
// ===================
/// Send any kind of notification.
pub fn send_any(message: &Content, r#type: Type, options: &Option<Options>) -> Result<Id, JsValue> {
let options = match options {
Some(options) => options.try_into()?,
None => JsValue::UNDEFINED,
};
js::toast(message, r#type, &options)
}
/// Send an info notification.
pub fn info(message: impl Into<Content>, options: &Option<Options>) -> Result<Id, JsValue> {
send_any(&message.into(), Type::Info, options)
}
/// Send a warning notification.
pub fn warning(message: impl Into<Content>, options: &Option<Options>) -> Result<Id, JsValue> {
send_any(&message.into(), Type::Warning, options)
}
/// Send a error notification.
pub fn error(message: impl Into<Content>, options: &Option<Options>) -> Result<Id, JsValue> {
send_any(&message.into(), Type::Error, options)
}
/// Send a success notification.
pub fn success(message: impl Into<Content>, options: &Option<Options>) -> Result<Id, JsValue> {
send_any(&message.into(), Type::Success, options)
}
// =============================
// === JS-conversion helpers ===
// =============================
// === Rust->JS error ===
/// Convert arbitrary Rust error value into JsValue-based error.
pub fn to_js_error(error: impl std::error::Error) -> JsValue {
js_sys::Error::new(&error.to_string()).into()
}
// === Rust->JS conversion ===
/// Macro that implements TryFrom for given `type` to/from JsValue using
/// [`gloo-utils::format::JsValueSerdeExt`].
///
/// Implements:
/// - `TryFrom<&type> for JsValue`
/// - `TryFrom<JsValue> for type`
macro_rules! impl_try_from_jsvalue {
($type:ty) => {
impl TryFrom<&$type> for JsValue {
type Error = JsValue;
fn try_from(value: &$type) -> Result<Self, Self::Error> {
JsValue::from_serde(value).map_err($crate::notification::api::to_js_error)
}
}
impl TryFrom<JsValue> for $type {
type Error = JsValue;
fn try_from(js_value: JsValue) -> Result<Self, Self::Error> {
js_value.into_serde().map_err($crate::notification::api::to_js_error)
}
}
};
}
// === SerializableJsValue ===
/// A wrapper around a `JsValue` that implements the `Serialize` and `Deserialize` traits.
#[derive(Clone, Debug, Deref, DerefMut, AsRef, From, PartialEq)]
pub struct SerializableJsValue(pub JsValue);
impl Serialize for SerializableJsValue {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let value = js_sys::JSON::stringify(self).map(String::from).map_err(|e| {
serde::ser::Error::custom(format!("Failed to stringify JsValue: {e:?}"))
})?;
let value = serde_json::from_str::<serde_json::Value>(&value)
.map_err(|e| serde::ser::Error::custom(format!("Failed to parse JSON: {e:?}")))?;
value.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for SerializableJsValue {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = serde_json::Value::deserialize(deserializer)?;
JsValue::from_serde(&value).map(SerializableJsValue).map_err(serde::de::Error::custom)
}
}
// ===============
// === Content ===
// ===============
/// The notification content.
///
/// In future this will be extended to support more than just plain text (e.g. HTML).
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum Content {
/// Plain text.
Text(String),
}
impl From<&str> for Content {
fn from(text: &str) -> Self {
Content::Text(text.to_owned())
}
}
impl From<String> for Content {
fn from(text: String) -> Self {
Content::Text(text)
}
}
impl From<&String> for Content {
fn from(text: &String) -> Self {
Content::Text(text.to_owned())
}
}
// ===============
// === Options ===
// ===============
// === AutoClose ===
/// Helper structure for [`AutoClose`] so it serializes in a way compatible with the JS library.
///
/// Users of this module should not deal directly with this type. Instead, rely on
/// [`AutoClose::After`] or [`AutoClose::Never`]
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AutoCloseInner {
/// Auto close after a delay expressed in milliseconds.
Delay(u32),
/// Do not auto close. The boolean value must be `false`.
ShouldEver(bool),
}
/// Represents the auto-close delay of a notification.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Deref)]
#[serde(transparent)]
pub struct AutoClose(AutoCloseInner);
impl AutoClose {
/// Auto close after a delay expressed in milliseconds.
#[allow(non_snake_case)]
pub fn After(delay_ms: u32) -> Self {
Self(AutoCloseInner::Delay(delay_ms))
}
/// Do not auto close.
#[allow(non_snake_case)]
pub fn Never() -> Self {
Self(AutoCloseInner::ShouldEver(false))
}
}
// === Type ===
/// Represents the type of a notification.
///
/// Affects styling and icon.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, strum::AsRefStr, strum::Display)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
#[allow(missing_docs)]
pub enum Type {
Info,
Success,
Warning,
Error,
Default,
}
// === Position ===
/// Represents the position of a notification on the screen.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[allow(missing_docs)]
pub enum Position {
TopRight,
TopCenter,
TopLeft,
BottomRight,
BottomCenter,
BottomLeft,
}
// === DraggableDirection ===
/// Direction that the notification can be dragged (swiped) to dismiss it.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[allow(missing_docs)]
pub enum DraggableDirection {
X,
Y,
}
// === Theme ===
/// Themes supported by the library.
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[allow(missing_docs)]
pub enum Theme {
Light,
Dark,
Colored,
}
// === Options ===
/// Customization options for the notification.
///
/// Note that it is not necessary to set any of these. All options marked as `None` will be
/// auto-filled with default values.
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[allow(missing_docs)]
pub struct Options {
/// Unique notification identifier.
pub toast_id: Option<String>,
/// Type of the notification, affecting styling and icon. Default: `Type::Default`.
pub r#type: Option<Type>,
/// The position where the toast should appear. Default: `bottom-right`.
pub position: Option<Position>,
/// Time in milliseconds after the toast is removed. Default: `5000`.
pub auto_close: Option<AutoClose>,
/// Whether to show a close button. Default: `true`.
pub close_button: Option<bool>,
// pub transition: Option<ToastTransition>,
/// Hide or show the progress bar. `Default: false`
pub hide_progress_bar: Option<bool>,
/// Pause the timer when the mouse hover the toast. Default: `true`.
pub pause_on_hover: Option<bool>,
/// Pause the timer when the window loses focus. Default: `true`.
pub pause_on_focus_loss: Option<bool>,
/// Remove the toast when clicked. Default: `true`.
pub close_on_click: Option<bool>,
/// An optional css class to set.
pub class_name: Option<String>,
/// An optional css class to set for the toast content.
pub body_class_name: Option<String>,
/// An optional inline style to apply. This should be a JS object with CSS properties.
pub style: Option<SerializableJsValue>,
/// An optional inline style to apply for the toast content. This should be a JS object with
/// CSS properties.
pub body_style: Option<SerializableJsValue>,
/// An optional css class to set for the progress bar.
pub progress_class_name: Option<String>,
/// An optional inline style to apply for the progress bar. This should be a JS object with
/// CSS properties.
pub progress_style: Option<SerializableJsValue>,
/// Allow toast to be draggable. `Default: true`.
pub draggable: Option<bool>,
/// The percentage of the toast's width it takes for a drag to dismiss a toast. `Default: 80`.
pub draggable_percent: Option<f32>,
/// Specify in which direction should you swipe to dismiss the toast. `Default: "x"`.
pub draggable_direction: Option<DraggableDirection>,
/// Set id to handle multiple `ToastContainer` instances.
pub container_id: Option<String>,
/// Define the [ARIA role](https://www.w3.org/WAI/PF/aria/roles) for the notification. `Default: "alert"`.
pub role: Option<String>,
/// Add a delay in ms before the toast appear.
pub delay: Option<u32>,
/// Whether the ongoing action is still in progress.
///
/// The auto-close timer will not start if this is set to `true`.
pub is_loading: Option<bool>,
/// Support right to left display content. `Default: false`.
pub rtl: Option<bool>,
/// Theme to use.
pub theme: Option<Theme>,
/// Used to display a custom icon. Set it to `false` to prevent the icons from being displayed
pub icon: Option<bool>,
/// Any additional data to pass to the toast.
pub data: Option<SerializableJsValue>,
}
impl_try_from_jsvalue! {Options}
impl Options {
/// Popup will not disappear on itself nor allow user to dismiss it.
///
/// The only way fot this notification to disappear is to call `dismiss()` manually. Otherwise,
/// it will stay on screen indefinitely.
pub fn always_present(mut self) -> Self {
self.set_always_present();
self
}
/// Mutable version of [`Options::always_present`].
pub fn set_always_present(&mut self) {
self.auto_close = Some(AutoClose::Never());
self.close_button = Some(false);
self.close_on_click = Some(false);
self.draggable = Some(false);
}
}
// === Update ===
/// Update options for a toast.
///
/// Just like [`Options`], but also includes a `render` field that allows to update the
/// notification message.
#[derive(Clone, Debug, Serialize, Deserialize, Default, Deref, DerefMut)]
#[serde(rename_all = "camelCase")]
pub struct UpdateOptions {
/// New state of the toast.
#[deref]
#[deref_mut]
#[serde(flatten)]
pub options: Options,
/// Used to update a toast content.
pub render: Option<Content>,
}
impl_try_from_jsvalue! { UpdateOptions }
impl UpdateOptions {
/// Allows set a new message in the notification.
///
/// Useful only for update a toast.
pub fn raw_text_content(mut self, render: impl AsRef<str>) -> Self {
let message = render.as_ref();
self.render = Some(message.into());
self
}
/// Allows set a new message in the notification.
///
/// This sets is as plain text (it will not be interpreted as HTML).
pub fn set_raw_text_content(&mut self, render: impl Into<String>) {
let content = Content::Text(render.into());
self.render = Some(content);
}
}
// ====================
// === Notification ===
// ====================
/// A persistent notification.
///
/// It can be updated or dismissed.
#[derive(Clone, CloneRef, Debug)]
pub struct Notification {
/// The unique notification id. We use it to dismiss or update the notification.
id: Rc<RefCell<Id>>,
/// The notification options. They will be used to show/update the notification.
options: Rc<RefCell<UpdateOptions>>,
/// The options that we have used to show the notification the last time.
last_shown: Rc<RefCell<Option<UpdateOptions>>>,
}
impl Default for Notification {
fn default() -> Self {
Self::new(default())
}
}
impl From<Id> for Notification {
fn from(id: Id) -> Self {
let id = Rc::new(RefCell::new(id));
let options = default();
let last_shown = default();
Self { id, options, last_shown }
}
}
impl Display for Notification {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
Display::fmt(&*self.id.borrow(), f)
}
}
impl Notification {
/// Create a new notification archetype.
///
/// It will not be shown until you call [`Notification::show`].
pub fn new(options: UpdateOptions) -> Self {
let id = Id::new_unique();
let id = Rc::new(RefCell::new(id));
let options = Rc::new(RefCell::new(options));
let last_shown = default();
Self { id, options, last_shown }
}
/// Update the notification state.
///
/// If the visualization is being shown, it will be updated. If not, changes will appear the
/// next time the notification is [`shown`](Notification::show).
pub fn set_options(&self, options: UpdateOptions) -> Result<(), JsValue> {
self.options.replace(options);
let ret = self.id.borrow().update(&self.options.borrow());
if ret.is_ok() {
let new_options = self.options.borrow().clone();
// If the ID was updated, we need to keep track of it.
if let Some(new_id) = new_options.toast_id.as_ref() {
self.id.replace(new_id.as_str().into());
}
self.last_shown.replace(Some(new_options));
}
ret
}
/// Convenience wrapper for [`Notification::set_options`].
///
/// Allows to modify the options in place.
pub fn update(&self, f: impl FnOnce(&mut UpdateOptions)) -> Result<(), JsValue> {
let mut options = self.options.take();
f(&mut options);
self.set_options(options)
}
/// Display the notification.
///
/// If it is already being shown, nothing will happen.
pub fn show(&self) -> Result<(), JsValue> {
if !self.id.borrow().is_active()? {
let options = self.options.borrow_mut();
let content = options.render.as_ref();
let options_js = (&*options).try_into()?;
let toast_id_field = JsValue::from_str(js::TOAST_ID_FIELD_IN_OPTIONS);
let toast_type = options.r#type.unwrap_or(Type::Info);
js_sys::Reflect::set(&options_js, &toast_id_field, &self.id.borrow())?;
let toast = |content| js::toast(content, toast_type, &options_js);
if let Some(content) = content {
toast(content)?;
} else {
const EMPTY_MESSAGE: &str = "";
toast(&EMPTY_MESSAGE.into())?;
}
}
Ok(())
}
/// Dismiss the notification.
pub fn dismiss(&self) -> Result<(), JsValue> {
let ret = self.id.borrow().dismiss();
if ret.is_ok() {
// We generate a new ID. This is because we don't want to reuse the same ID.
// Otherwise, while the old notification is still fading out, an attempt to
// show a new notification with the same ID will silently fail.
let id = Id::new_unique();
self.id.replace(id);
}
ret
}
}
// =============
// === Tests ===
// =============
#[cfg(test)]
mod tests {
use super::*;
use wasm_bindgen_test::wasm_bindgen_test;
use wasm_bindgen_test::wasm_bindgen_test_configure;
wasm_bindgen_test_configure!(run_in_browser);
#[test]
fn auto_close_delay() {
let auto_close = AutoClose::After(5000);
let serialized = serde_json::to_string(&auto_close).unwrap();
assert_eq!(serialized, "5000");
}
#[test]
fn auto_close_never() {
let auto_close = AutoClose::Never();
let serialized = serde_json::to_string(&auto_close).unwrap();
assert_eq!(serialized, "false");
}
#[wasm_bindgen_test]
fn options_marshalling() {
let options = Options { theme: Some(Theme::Dark), ..default() };
let js_value = JsValue::try_from(&options).unwrap();
// Make sure that `js_value` is a valid JS object.
assert!(js_value.is_object());
let js_object = js_value.dyn_into::<js_sys::Object>().unwrap();
// Make sure that `js_object` has a `theme` property.
assert!(js_object.has_own_property(&"theme".into()));
// Make sure that `js_object.theme` is a string.
let theme = js_sys::Reflect::get(&js_object, &"theme".into()).unwrap();
assert!(theme.is_string());
// Make sure that `js_object.theme` is equal to "dark".
let theme = theme.as_string().unwrap();
assert_eq!(theme, "dark");
}
#[wasm_bindgen_test]
fn contents_marshalling() {
let text_contents = Content::Text("Hello, world!".into());
let js_value = JsValue::from_serde(&text_contents).unwrap();
// Make sure that `js_value` is a valid JS String.
assert!(js_value.is_string());
let js_string = js_value.as_string().unwrap();
// Make sure that `js_string` is equal to "Hello, world!".
assert_eq!(js_string, "Hello, world!");
// Check for round-trip.
let back = js_value.into_serde::<Content>().unwrap();
assert_eq!(back, text_contents);
}
}

View File

@ -4,8 +4,12 @@
use crate::prelude::*;
use wasm_bindgen::prelude::*;
use crate::notification::Type;
use crate::notification::UpdateOptions;
use crate::notification::api::Content;
use crate::notification::api::Type;
use crate::notification::api::UpdateOptions;
use gloo_utils::format::JsValueSerdeExt;
use uuid::Uuid;
@ -15,8 +19,12 @@ use crate::notification::UpdateOptions;
/// Toastify's [`toast API`](https://fkhadra.github.io/react-toastify/api/toast) field name within
/// our application JS object.
const TOAST_FIELD_NAME: &str = "toast";
pub const TOAST_FIELD_NAME: &str = "toast";
/// The name of the field with the toast ID in the options object.
///
/// See [the documentation](https://fkhadra.github.io/react-toastify/api/toast/#props) for details.
pub const TOAST_ID_FIELD_IN_OPTIONS: &str = "toastId";
// ========================
@ -40,12 +48,34 @@ pub fn get_toast() -> Result<ToastAPI, JsValue> {
// === JS bindings ===
// ===================
// Wrappers for [`toast`](https://react-hot-toast.com/docs/toast) API.
/// Wrappers for [`toast`](https://react-hot-toast.com/docs/toast) API and related helpers.
#[wasm_bindgen(inline_js = r#"
export function sendToast(toast, message, method, options) {
const target = toast[method];
return target(message, options);
}
// See [`pretty_print`] Rust wrapper below for the documentation.
export function prettyPrint(value) {
try {
// If it's an `Error`, print its standard properties.
if (value instanceof Error) {
const { name, message, stack } = value;
const errorDetails = { name, message, stack };
return JSON.stringify(errorDetails, null, 2);
}
// If it's an object (not `null` and not an `Array`), pretty-print its properties.
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return JSON.stringify(value, null, 2);
}
// If it's a primitive value or an `Array`, just convert it to a string.
return String(value);
} catch (error) {
return prettyPrint(error);
}
}
"#)]
extern "C" {
/// The wrapper for the toastify API.
@ -60,7 +90,7 @@ extern "C" {
#[derive(Clone, Debug)]
pub type Id;
/// The unique identifier of a toast.
/// The unique identifier of a toast container.
#[derive(Clone, Debug)]
pub type ContainerId;
@ -69,7 +99,7 @@ extern "C" {
#[allow(unsafe_code)]
pub fn sendToast(
this: &ToastAPI,
message: &str,
message: &JsValue,
method: &str,
options: &JsValue,
) -> Result<Id, JsValue>;
@ -119,6 +149,18 @@ extern "C" {
#[wasm_bindgen(catch, method)]
#[allow(unsafe_code)]
pub fn done(this: &ToastAPI, id: &Id) -> Result<(), JsValue>;
/// Pretty-prints any value to a string.
///
/// This function's primary purpose is to pretty-print `Error` values, including all
/// accessible details. However, it can also handle other types of values, converting
/// them to strings as accurately as possible. For `Error` instances, it outputs standard
/// properties (`name`, `message`, `stack`). For non-null objects and arrays, it converts
/// them to a JSON string with indentation. For all other types of values, it uses
/// JavaScript's default string conversion.
#[wasm_bindgen(js_name = prettyPrint)]
#[allow(unsafe_code)]
pub fn pretty_print(value: &JsValue) -> String;
}
@ -129,12 +171,46 @@ extern "C" {
impl ToastAPI {
/// Send the toast notification.
pub fn send(&self, message: &str, method: &str, options: &JsValue) -> Result<Id, JsValue> {
sendToast(self, message, method, options)
pub fn send(&self, message: &Content, method: &str, options: &JsValue) -> Result<Id, JsValue> {
let message =
JsValue::from_serde(message).map_err(crate::notification::api::to_js_error)?;
sendToast(self, &message, method, options)
}
}
impl Display for Id {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
Display::fmt(&pretty_print(self), f)
}
}
impl From<&str> for Id {
fn from(id: &str) -> Self {
let js_value = JsValue::from_str(id);
js_value.into()
}
}
impl From<Uuid> for Id {
fn from(id: Uuid) -> Self {
Self::new(id.to_string())
}
}
impl Id {
/// Create a new identifier, using the given string.
///
/// The two identifiers are equal if their string representations are equal. Please note, that
/// identifiers of different notifications must not be equal.
pub fn new(id: impl AsRef<str>) -> Self {
id.as_ref().into()
}
/// Generate a new, unique identifier.
pub fn new_unique() -> Self {
let uuid = uuid::Uuid::new_v4();
Self::new(uuid.to_string())
}
/// Dismisses the toast.
pub fn dismiss(&self) -> Result<(), JsValue> {
get_toast()?.dismiss(self)
@ -156,7 +232,6 @@ impl Id {
}
}
impl ContainerId {
/// Clear queue of notifications for this container (relevant if limit is set).
pub fn clear_waiting_queue(&self) -> Result<(), JsValue> {
@ -165,7 +240,44 @@ impl ContainerId {
}
/// Wrapper for sending arbitrary kind of toast.
pub fn toast(message: &str, r#type: Type, options: &JsValue) -> Result<Id, JsValue> {
pub fn toast(message: &Content, r#type: Type, options: &JsValue) -> Result<Id, JsValue> {
let method: &str = r#type.as_ref();
get_toast()?.send(message, method, options)
}
// ======================
// === Error handling ===
// ======================
/// Extension trait for `Result<T, JsValue>` that provides a method for handling JavaScript errors.
pub trait HandleJsError<T>: Sized {
/// Format pretty error message.
fn pretty_print_error(message: Option<&str>, error: &JsValue) -> String {
let mut ret = String::from("Error received from JavaScript.");
if let Some(message) = message {
ret.push(' ');
ret.push_str(message);
}
ret.push(' ');
ret.push_str(&pretty_print(error));
ret
}
/// Handle JS error by logging it and returning `None`.
fn handle_js_err(self, message: &str) -> Option<T>;
/// Handle JS error by logging it along with dynamically generated message and returning `None`.
fn handle_js_err_with(self, message_provider: impl FnOnce() -> String) -> Option<T> {
self.handle_js_err(&message_provider())
}
}
impl<T> HandleJsError<T> for Result<T, JsValue> {
fn handle_js_err(self, message: &str) -> Option<T> {
self.handle_err(|e| {
error!("{}", Self::pretty_print_error(Some(message), &e));
})
}
}

View File

@ -0,0 +1,181 @@
//! This module provides a [notification API](crate::notification::api) wrapper that:
//! * Logs most of the actions.
//! * Handles errors by logging and dropping them.
//!
//! The API returns [`Result`]s with [`JsValue`] as an error type which is often inconvenient for
//! our use-cases. We often use notifications to display errors and we don't have a way to handle
//! errors from the notifications themselves.
//!
//! In general, where the original API returns [`Result`]s, this wrapper returns [`Option`]s.
use crate::prelude::*;
use crate::notification;
use crate::notification::js::HandleJsError;
use uuid::Uuid;
// ==============
// === Export ===
// ==============
pub use crate::notification::api::AutoClose;
pub use crate::notification::api::Content;
pub use crate::notification::api::Options;
pub use crate::notification::api::Type;
pub use crate::notification::api::UpdateOptions;
// ===================
// === Primary API ===
// ===================
/// Send any kind of notification.
pub fn send_any(message: &Content, r#type: Type, options: &Option<Options>) -> Option<Id> {
let log_content = format!("Sending notification with message {message:?} and type {type:?}");
match r#type {
Type::Warning => warn!("{log_content}"),
Type::Error => error!("{log_content}"),
_ => info!("{log_content}"),
};
notification::api::send_any(message, r#type, options).map(Id).handle_js_err_with(|| {
format!("Failed to send {type} notification with message {message:?}.")
})
}
/// Send an info notification.
pub fn info(message: impl Into<Content>, options: &Option<Options>) -> Option<Id> {
send_any(&message.into(), Type::Info, options)
}
/// Send a warning notification.
pub fn warning(message: impl Into<Content>, options: &Option<Options>) -> Option<Id> {
send_any(&message.into(), Type::Warning, options)
}
/// Send a error notification.
pub fn error(message: impl Into<Content>, options: &Option<Options>) -> Option<Id> {
send_any(&message.into(), Type::Error, options)
}
/// Send a success notification.
pub fn success(message: impl Into<Content>, options: &Option<Options>) -> Option<Id> {
send_any(&message.into(), Type::Success, options)
}
// ==========
// === Id ===
// ==========
/// The unique identifier of a toast.
#[derive(Clone, Debug, Display)]
pub struct Id(notification::api::Id);
impl From<notification::api::Id> for Id {
fn from(id: notification::api::Id) -> Self {
Self(id)
}
}
impl From<&str> for Id {
fn from(id: &str) -> Self {
Self(id.into())
}
}
impl From<Uuid> for Id {
fn from(id: Uuid) -> Self {
Self(id.into())
}
}
impl Id {
/// Close the notification.
pub fn dismiss(&self) {
info!("Dismissing the notification {self}.");
self.0.dismiss().handle_js_err_with(|| format!("Failed to dismiss notification {self:?}."));
}
/// Completes the controlled progress bar.
pub fn done(&self) {
info!("Completing the notification {self}.");
self.0.done().handle_js_err_with(|| format!("Failed to complete notification {self:?}."));
}
/// Check if a toast is displayed or not.
pub fn is_active(&self) -> bool {
self.0
.is_active()
.handle_js_err_with(|| format!("Failed to check if notification {self:?} is active."))
.unwrap_or(false)
}
/// Update a toast.
pub fn update(&self, options: &UpdateOptions) {
info!("Updating the notification {self} with new options {options:?}.");
self.0
.update(options)
.handle_js_err_with(|| format!("Failed to update notification {self:?}."));
}
}
// ====================
// === Notification ===
// ====================
/// Same as super::Notification, but with all errors handled by logging them using error! macro.
/// A persistent notification.
#[derive(Clone, CloneRef, Debug, Display)]
pub struct Notification(notification::api::Notification);
impl Default for Notification {
fn default() -> Self {
Self::new(default())
}
}
impl From<Id> for Notification {
fn from(id: Id) -> Self {
Self(notification::api::Notification::from(id.0))
}
}
impl Notification {
/// Create a new notification archetype.
///
/// It will not be shown until you call [`Notification::show`].
pub fn new(options: UpdateOptions) -> Self {
info!("Creating a new notification with options {options:?}.");
Self(notification::api::Notification::new(options))
}
/// Update the notification state.
///
/// If the visualization is being shown, it will be updated. If not, changes will appear the
/// next time the notification is [
pub fn update(&self, f: impl FnOnce(&mut UpdateOptions)) {
info!("Updating the notification {self}.");
self.0.update(f).handle_js_err_with(|| format!("Failed to update notification {self}."));
}
/// Display the notification.
///
/// If it is already being shown, nothing will happen.
pub fn show(&self) {
info!("Showing the notification {self}.");
self.0.show().handle_js_err_with(|| format!("Failed to show notification {self}."));
}
/// Dismiss the notification.
pub fn dismiss(&self) {
info!("Dismissing the notification {self}.");
self.0.dismiss().handle_js_err_with(|| format!("Failed to dismiss notification {self}."));
}
}

View File

@ -7,13 +7,10 @@ use ensogl::system::web::traits::*;
use crate::code_editor;
use crate::component_browser;
use crate::component_browser::component_list_panel;
use crate::debug_mode_popup;
use crate::debug_mode_popup::DEBUG_MODE_SHORTCUT;
use crate::graph_editor::component::node::Expression;
use crate::graph_editor::component::visualization;
use crate::graph_editor::GraphEditor;
use crate::graph_editor::NodeId;
use crate::popup;
use crate::project_list::ProjectList;
use enso_config::ARGS;
@ -50,6 +47,18 @@ use ide_view_project_view_top_bar::ProjectViewTopBar;
const INPUT_CHANGE_DELAY_MS: i32 = 200;
/// Mitigate limitations of constant strings concatenation.
macro_rules! define_debug_mode_shortcut {
($shortcut:literal) => {
/// A keyboard shortcut used to enable/disable Debug Mode.
pub const DEBUG_MODE_SHORTCUT: &str = $shortcut;
const DEBUG_MODE_ENABLED: &str =
concat!("Debug Mode enabled. To disable, press `", $shortcut, "`.");
};
}
define_debug_mode_shortcut!("ctrl shift d");
// ===========
// === FRP ===
@ -191,8 +200,7 @@ struct Model {
code_editor: code_editor::View,
fullscreen_vis: Rc<RefCell<Option<visualization::fullscreen::Panel>>>,
project_list: Rc<ProjectList>,
debug_mode_popup: debug_mode_popup::View,
popup: popup::View,
debug_mode_popup: Rc<crate::notification::View>,
}
impl Model {
@ -202,16 +210,13 @@ impl Model {
let graph_editor = app.new_view::<GraphEditor>();
let code_editor = app.new_view::<code_editor::View>();
let fullscreen_vis = default();
let debug_mode_popup = debug_mode_popup::View::new(app);
let popup = popup::View::new(app);
let debug_mode_popup = Rc::new(crate::notification::View::new(app));
let project_view_top_bar = ProjectViewTopBar::new(app);
let project_list = Rc::new(ProjectList::new(app));
display_object.add_child(&graph_editor);
display_object.add_child(&code_editor);
display_object.add_child(&searcher);
display_object.add_child(&debug_mode_popup);
display_object.add_child(&popup);
display_object.add_child(&project_view_top_bar);
display_object.remove_child(&searcher);
@ -225,7 +230,6 @@ impl Model {
fullscreen_vis,
project_list,
debug_mode_popup,
popup,
}
}
@ -465,7 +469,6 @@ impl View {
disable_navigation <- searcher_active || frp.project_list_shown;
graph.set_navigator_disabled <+ disable_navigation;
model.popup.set_label <+ graph.visualization_update_error._1();
graph.set_read_only <+ frp.set_read_only;
graph.set_debug_mode <+ frp.source.debug_mode;
@ -745,11 +748,17 @@ impl View {
let frp = &self.frp;
let network = &frp.network;
let popup = &self.model.debug_mode_popup;
frp::extend! { network
frp.source.debug_mode <+ bool(&frp.disable_debug_mode, &frp.enable_debug_mode);
popup.enabled <+ frp.enable_debug_mode;
popup.disabled <+ frp.disable_debug_mode;
let mut options = crate::notification::api::UpdateOptions::default();
options.set_always_present();
options.set_raw_text_content(DEBUG_MODE_ENABLED);
options.position = Some(crate::notification::api::Position::BottomRight);
popup.set_options(options);
frp::extend! { network
debug_mode <- bool(&frp.disable_debug_mode, &frp.enable_debug_mode);
frp.source.debug_mode <+ debug_mode;
popup.is_enabled <+ debug_mode;
}
self
}
@ -807,16 +816,6 @@ impl View {
pub fn project_list(&self) -> &ProjectList {
&self.model.project_list
}
/// Debug Mode Popup
pub fn debug_mode_popup(&self) -> &debug_mode_popup::View {
&self.model.debug_mode_popup
}
/// Pop-up
pub fn popup(&self) -> &popup::View {
&self.model.popup
}
}
impl FrpNetworkProvider for View {

View File

@ -10,7 +10,6 @@ use enso_frp as frp;
use ensogl::application;
use ensogl::application::Application;
use ensogl::display;
use ensogl::display::shape::StyleWatchFrp;
use std::rc::Rc;
@ -36,7 +35,6 @@ pub struct Model {
app: Application,
display_object: display::object::Instance,
state: Rc<CloneCell<State>>,
status_bar: crate::status_bar::View,
welcome_view: crate::welcome_screen::View,
project_view: Rc<CloneCell<Option<crate::project::View>>>,
}
@ -47,13 +45,11 @@ impl Model {
let app = app.clone_ref();
let display_object = display::object::Instance::new();
let state = Rc::new(CloneCell::new(State::WelcomeScreen));
let status_bar = crate::status_bar::View::new(&app);
display_object.add_child(&status_bar);
let welcome_view = app.new_view::<crate::welcome_screen::View>();
let project_view = Rc::new(CloneCell::new(None));
display_object.add_child(&welcome_view);
Self { app, display_object, state, status_bar, welcome_view, project_view }
Self { app, display_object, state, welcome_view, project_view }
}
/// Switch displayed view from Project View to Welcome Screen. Project View will not be
@ -83,15 +79,6 @@ impl Model {
fn init_project_view(&self) {
if self.project_view.get().is_none() {
let view = self.app.new_view::<crate::project::View>();
let network = &view.network;
let status_bar = &self.status_bar;
let display_object = &self.display_object;
frp::extend! { network
fs_vis_shown <- view.fullscreen_visualization_shown.on_true();
fs_vis_hidden <- view.fullscreen_visualization_shown.on_false();
eval fs_vis_shown ((_) status_bar.unset_parent());
eval fs_vis_hidden ([display_object, status_bar](_) display_object.add_child(&status_bar));
}
self.project_view.set(Some(view));
}
}
@ -136,30 +123,20 @@ impl View {
let model = Model::new(app);
let frp = Frp::new();
let network = &frp.network;
let style = StyleWatchFrp::new(&app.display.default_scene.style_sheet);
let offset_y = style.get_number(ensogl_hardcoded_theme::application::status_bar::offset_y);
frp::extend! { network
init <- source::<()>();
eval_ frp.switch_view_to_project(model.switch_view_to_project());
eval_ frp.switch_view_to_welcome_screen(model.switch_view_to_welcome_screen());
offset_y <- all(&init,&offset_y)._1();
eval offset_y ((offset_y) model.status_bar.set_y(*offset_y));
model.status_bar.add_event <+ app.frp.show_notification.map(|message| {
message.into()
eval app.frp.show_notification([](message) {
crate::notification::logged::info(message, &None);
});
}
init.emit(());
Self { model, frp }
}
/// Status Bar view from Project View.
pub fn status_bar(&self) -> &crate::status_bar::View {
&self.model.status_bar
}
/// Lazily initializes Project View.
pub fn project(&self) -> crate::project::View {
self.model.get_or_init_project_view()

View File

@ -1,308 +0,0 @@
//! A module containing IDE status bar component definitions (frp, model, view, etc.)
//!
//! The component is currently rather a stub: it has endpoints for setting many events and
//! processes and keep them in a list, but it shows only a label of the last event/process
//! added.
//TODO[ao] Implement the status bar according to https://github.com/enso-org/ide/issues/1193
// description
use crate::prelude::*;
use ensogl::display::shape::*;
use crate::graph_editor::component::node::input::area::TEXT_SIZE;
use ensogl::application::Application;
use ensogl::display;
use ensogl::display::camera::Camera2d;
use ensogl_hardcoded_theme as theme;
use ensogl_text as text;
use std::future::Future;
// =================
// === Constants ===
// =================
/// The height of the status bar.
const HEIGHT: f32 = 28.0;
/// Padding inside the status bar.
pub const PADDING: f32 = 12.0;
/// Margin between status bar and edge of the screen
const MARGIN: f32 = 12.0;
// =============
// === Event ===
// =============
/// Structures related to events in a status bar.
pub mod event {
use crate::prelude::*;
/// An id of some event displayed in a status bar.
#[derive(Clone, CloneRef, Copy, Debug, Default, Eq, From, Hash, Into, PartialEq)]
pub struct Id(pub usize);
im_string_newtype! {
/// A label assigned to some event displayed in a status bar.
Label
}
}
// ===============
// === Process ===
// ===============
/// Structures related to processes in a status bar.
pub mod process {
use crate::prelude::*;
/// An id of some process displayed in a status bar.
#[derive(Clone, CloneRef, Copy, Debug, Default, Eq, From, Hash, Into, PartialEq)]
pub struct Id(pub u64);
impl Id {
/// Return the next id.
pub fn next(self) -> Id {
Id(self.0 + 1)
}
}
im_string_newtype! {
/// A label assigned to some process displayed in a status bar.
Label
}
}
// ===========
// === FRP ===
// ===========
ensogl::define_endpoints! {
Input {
add_event (event::Label),
add_process (process::Label),
finish_process (process::Id),
clear_all (),
}
Output {
last_event (event::Id),
last_process (process::Id),
displayed_event (Option<event::Id>),
displayed_process (Option<process::Id>),
}
}
// =============
// === Model ===
// =============
/// An internal model of Status Bar component
#[derive(Clone, CloneRef, Debug, display::Object)]
struct Model {
display_object: display::object::Instance,
root: display::object::Instance,
background: Rectangle,
label: text::Text,
events: Rc<RefCell<Vec<event::Label>>>,
processes: Rc<RefCell<HashMap<process::Id, process::Label>>>,
next_process_id: Rc<RefCell<process::Id>>,
camera: Camera2d,
}
impl Model {
fn new(app: &Application) -> Self {
let scene = &app.display.default_scene;
let display_object = display::object::Instance::new();
let root = display::object::Instance::new();
let background: Rectangle = default();
let label = text::Text::new(app);
let events = default();
let processes = default();
let next_process_id = Rc::new(RefCell::new(process::Id(1)));
let camera = scene.camera();
scene.layers.panel.add(&background);
scene.layers.panel_text.add(&label);
use theme::application::status_bar;
let text_color_path = status_bar::text;
let style = StyleWatch::new(&app.display.default_scene.style_sheet);
let text_color = style.get_color(text_color_path);
label.frp.set_property(.., text_color);
label.frp.set_property_default(text_color);
let style = StyleWatchFrp::new(&app.display.default_scene.style_sheet);
background.set_style(status_bar::background::HERE, &style);
Self { display_object, root, background, label, events, processes, next_process_id, camera }
.init()
}
fn init(self) -> Self {
self.display_object.add_child(&self.root);
self.root.add_child(&self.background);
self.root.add_child(&self.label);
self.update_layout();
self.camera_changed();
self
}
fn camera_changed(&self) {
let screen = self.camera.screen();
let y = screen.height / 2.0 - MARGIN;
self.root.set_y(y.round());
}
fn update_layout(&self) {
let label_width = self.label.width.value();
self.label.set_x(-label_width / 2.0);
self.label.set_y(-HEIGHT / 2.0 + TEXT_SIZE / 2.0);
let bg_width = if label_width > 0.0 { label_width + 2.0 * PADDING } else { 0.0 };
let bg_height = HEIGHT;
self.background.set_size(Vector2(bg_width, bg_height));
self.background.set_x(-bg_width / 2.0);
self.background.set_y(-HEIGHT / 2.0 - bg_height / 2.0);
}
fn add_event(&self, label: &event::Label) -> event::Id {
let mut events = self.events.borrow_mut();
let new_id = event::Id(events.len());
events.push(label.clone_ref());
new_id
}
fn add_process(&self, label: &process::Label) -> process::Id {
let mut processes = self.processes.borrow_mut();
let mut next_process_id = self.next_process_id.borrow_mut();
let new_id = *next_process_id;
*next_process_id = next_process_id.next();
processes.insert(new_id, label.clone_ref());
new_id
}
/// Returns true if there was process with given id.
fn finish_process(&self, id: process::Id) -> bool {
self.processes.borrow_mut().remove(&id).is_some()
}
/// Returns empty string if no event received so far.
fn last_event_message(&self) -> event::Label {
self.events.borrow().last().cloned().unwrap_or_default()
}
fn clear_all(&self) {
self.events.borrow_mut().clear();
self.processes.borrow_mut().clear();
}
}
// ============
// === View ===
// ============
/// The StatusBar component view.
///
/// The status bar gathers information about events and processes occurring in the Application.
// TODO: This is a stub. Extend it when doing https://github.com/enso-org/ide/issues/1193
#[derive(Clone, CloneRef, Debug, display::Object)]
pub struct View {
frp: Frp,
#[display_object]
model: Model,
}
impl View {
/// Create new StatusBar view.
pub fn new(app: &Application) -> Self {
let frp = Frp::new();
let model = Model::new(app);
let network = &frp.network;
let scene = &app.display.default_scene;
enso_frp::extend! { network
event_added <- frp.add_event.map(f!((label) model.add_event(label)));
process_added <- frp.add_process.map(f!((label) model.add_process(label)));
_process_finished <- frp.finish_process.filter_map(f!((id)
model.finish_process(*id).as_some(*id)
));
displayed_process_finished <- frp.finish_process
.map2(&frp.output.displayed_process, |fin,dis|(*fin,*dis))
.filter(|(fin,dis)| dis.contains(fin));
label_after_adding_event <- frp.add_event.map(
|label| AsRef::<ImString>::as_ref(label).clone_ref()
);
label_after_adding_process <- frp.add_process.map(
|label| AsRef::<ImString>::as_ref(label).clone_ref()
);
label_after_finishing_process <- displayed_process_finished.map(
f_!([model] AsRef::<ImString>::as_ref(&model.last_event_message()).clone_ref())
);
label <- any(label_after_adding_event,label_after_adding_process,label_after_finishing_process);
eval label ((label) model.label.set_content(label.to_string()));
eval_ frp.clear_all (model.clear_all());
frp.source.last_event <+ event_added;
frp.source.last_process <+ process_added;
frp.source.displayed_event <+ event_added.map(|id| Some(*id));
frp.source.displayed_event <+ process_added.constant(None);
frp.source.displayed_event <+ frp.clear_all.constant(None);
frp.source.displayed_process <+ process_added.map(|id| Some(*id));
frp.source.displayed_process <+ event_added.constant(None);
frp.source.displayed_process <+ displayed_process_finished.constant(None);
frp.source.displayed_process <+ frp.clear_all.constant(None);
eval_ model.label.output.width (model.update_layout());
eval_ scene.frp.camera_changed (model.camera_changed());
}
Self { frp, model }
}
/// Returns a future will add a new process to the status bar, evaluate `f` and then mark the
/// process as finished.
pub fn process<'f, F>(
&self,
label: process::Label,
f: F,
) -> impl Future<Output = F::Output> + 'f
where
F: Future + 'f,
{
let add_process = self.frp.add_process.clone_ref();
let last_process = self.frp.last_process.clone_ref();
let finish_process = self.frp.finish_process.clone_ref();
async move {
add_process.emit(label);
let id = last_process.value();
let result = f.await;
finish_process.emit(id);
result
}
}
}
impl Deref for View {
type Target = Frp;
fn deref(&self) -> &Self::Target {
&self.frp
}
}

View File

@ -132,7 +132,14 @@ export default function App(props: AppProps) {
* will redirect the user between the login/register pages and the dashboard. */
return (
<>
<toastify.ToastContainer position="top-center" theme="light" closeOnClick={false} />
<toastify.ToastContainer
position="top-center"
theme="light"
closeOnClick={false}
draggable={false}
toastClassName="text-sm leading-170 bg-frame-selected rounded-2xl backdrop-blur-3xl"
transition={toastify.Zoom}
/>
<Router basename={getMainPageUrl().pathname}>
<AppRouter {...props} />
</Router>

View File

@ -8,6 +8,10 @@ body {
background-blend-mode: lighten;
}
.Toastify--animate {
animation-duration: 0.2s;
}
/* These styles MUST still be copied
* as `.enso-dashboard body` and `.enso-dashboard html` make no sense. */
.enso-dashboard,

View File

@ -42,43 +42,6 @@ async fn create_new_project_and_add_nodes() {
assert_eq!(added_node.view.set_expression.value().code, "");
}
#[wasm_bindgen_test]
async fn debug_mode() {
let test = Fixture::setup_new_project().await;
let project = test.project_view();
let graph_editor = test.graph_editor();
assert!(!graph_editor.debug_mode.value());
// Turning On
let expect_mode = project.debug_mode.next_event();
let expect_popup_message = project.debug_mode_popup().content_frp_node().next_event();
project.enable_debug_mode();
assert!(expect_mode.expect());
let message = expect_popup_message.expect();
assert!(
message.contains("Debug Mode enabled"),
"Message \"{message}\" does not mention enabling Debug mode"
);
assert!(
message.contains(enso_gui::view::debug_mode_popup::DEBUG_MODE_SHORTCUT),
"Message \"{message}\" does not inform about shortcut to turn mode off"
);
assert!(graph_editor.debug_mode.value());
// Turning Off
let expect_mode = project.debug_mode.next_event();
let expect_popup_message = project.debug_mode_popup().content_frp_node().next_event();
project.disable_debug_mode();
assert!(!expect_mode.expect());
let message = expect_popup_message.expect();
assert!(
message.contains("Debug Mode disabled"),
"Message \"{message}\" does not mention disabling of debug mode"
);
assert!(!graph_editor.debug_mode.value());
}
#[wasm_bindgen_test]
async fn zooming() {
let test = Fixture::setup_new_project().await;

View File

@ -475,14 +475,6 @@ define_themes! { [light:0, dark:1]
}
}
}
status_bar {
offset_y = -30.0, -30.0;
text = text, text;
background {
color = graph_editor::node::background , graph_editor::node::background;
corner_radius = 14.0 , 14.0;
}
}
}
code {
types {