diff --git a/Cargo.lock b/Cargo.lock index b3aafb5d62..7bee629768 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10887,6 +10887,7 @@ dependencies = [ "theme", "title_bar", "ui", + "ui_feedback", ] [[package]] @@ -12417,6 +12418,17 @@ dependencies = [ "windows 0.58.0", ] +[[package]] +name = "ui_feedback" +version = "0.1.0" +dependencies = [ + "gpui", + "settings", + "story", + "theme", + "ui", +] + [[package]] name = "ui_input" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 887d9fb55a..d8023294ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,6 +120,7 @@ members = [ "crates/time_format", "crates/title_bar", "crates/ui", + "crates/ui_feedback", "crates/ui_input", "crates/ui_macros", "crates/util", @@ -298,6 +299,7 @@ theme_selector = { path = "crates/theme_selector" } time_format = { path = "crates/time_format" } title_bar = { path = "crates/title_bar" } ui = { path = "crates/ui" } +ui_feedback = { path = "crates/ui_feedback" } ui_input = { path = "crates/ui_input" } ui_macros = { path = "crates/ui_macros" } util = { path = "crates/util" } diff --git a/crates/storybook/Cargo.toml b/crates/storybook/Cargo.toml index f8e78acad3..46cceae077 100644 --- a/crates/storybook/Cargo.toml +++ b/crates/storybook/Cargo.toml @@ -36,6 +36,7 @@ strum = { workspace = true, features = ["derive"] } theme.workspace = true title_bar = { workspace = true, features = ["stories"] } ui = { workspace = true, features = ["stories"] } +ui_feedback = { workspace = true, features = ["stories"] } [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/storybook/src/story_selector.rs b/crates/storybook/src/story_selector.rs index 3a1c2f5630..8d8b0c3e97 100644 --- a/crates/storybook/src/story_selector.rs +++ b/crates/storybook/src/story_selector.rs @@ -12,6 +12,7 @@ use ui::prelude::*; #[derive(Debug, PartialEq, Eq, Clone, Copy, strum::Display, EnumString, EnumIter)] #[strum(serialize_all = "snake_case")] pub enum ComponentStory { + AlertDialog, ApplicationMenu, AutoHeightEditor, Avatar, @@ -46,6 +47,11 @@ pub enum ComponentStory { impl ComponentStory { pub fn story(&self, cx: &mut WindowContext) -> AnyView { match self { + Self::AlertDialog => cx + .new_view(|cx| { + ui_feedback::alert_dialog::alert_dialog_stories::AlertDialogStory::new(cx) + }) + .into(), Self::ApplicationMenu => cx .new_view(|cx| title_bar::ApplicationMenuStory::new(cx)) .into(), diff --git a/crates/ui/src/styles/elevation.rs b/crates/ui/src/styles/elevation.rs index 722111b46c..79ffaef0fe 100644 --- a/crates/ui/src/styles/elevation.rs +++ b/crates/ui/src/styles/elevation.rs @@ -1,5 +1,6 @@ -use gpui::{hsla, point, px, BoxShadow}; +use gpui::{hsla, point, px, BoxShadow, Hsla, WindowContext}; use smallvec::{smallvec, SmallVec}; +use theme::{color_alpha, ActiveTheme}; /// Today, elevation is primarily used to add shadows to elements, and set the correct background for elements like buttons. /// @@ -62,4 +63,18 @@ impl ElevationIndex { _ => smallvec![], } } + + /// Returns an appropriate background color for the given elevation index. + pub fn bg(self, cx: &WindowContext) -> Hsla { + match self { + ElevationIndex::Background => cx.theme().colors().background, + ElevationIndex::Surface => cx.theme().colors().surface_background, + ElevationIndex::ElevatedSurface => cx.theme().colors().elevated_surface_background, + ElevationIndex::Wash => hsla(0., 0., 0., 0.3), + ElevationIndex::ModalSurface => cx.theme().colors().elevated_surface_background, + ElevationIndex::DraggedElement => { + color_alpha(cx.theme().colors().elevated_surface_background, 0.3) + } + } + } } diff --git a/crates/ui/src/styles/spacing.rs b/crates/ui/src/styles/spacing.rs index a2089d9586..efa56a419a 100644 --- a/crates/ui/src/styles/spacing.rs +++ b/crates/ui/src/styles/spacing.rs @@ -40,6 +40,14 @@ pub enum Spacing { /// /// Relative to the user's `ui_font_size` and [UiDensity] setting. XXLarge, + /// 3X Large spacing - @16px/rem: `16px`|`20px`|`24px` + /// + /// Relative to the user's `ui_font_size` and [UiDensity] setting. + Large3X, + /// 4X Large spacing - @16px/rem: `20px`|`24px`|`28px` + /// + /// Relative to the user's `ui_font_size` and [UiDensity] setting. + Large4X, } impl Spacing { @@ -55,6 +63,8 @@ impl Spacing { Spacing::Large => 4. / BASE_REM_SIZE_IN_PX, Spacing::XLarge => 8. / BASE_REM_SIZE_IN_PX, Spacing::XXLarge => 12. / BASE_REM_SIZE_IN_PX, + Spacing::Large3X => 16. / BASE_REM_SIZE_IN_PX, + Spacing::Large4X => 20. / BASE_REM_SIZE_IN_PX, }, UiDensity::Default => match self { Spacing::None => 0., @@ -65,6 +75,8 @@ impl Spacing { Spacing::Large => 8. / BASE_REM_SIZE_IN_PX, Spacing::XLarge => 12. / BASE_REM_SIZE_IN_PX, Spacing::XXLarge => 16. / BASE_REM_SIZE_IN_PX, + Spacing::Large3X => 20. / BASE_REM_SIZE_IN_PX, + Spacing::Large4X => 24. / BASE_REM_SIZE_IN_PX, }, UiDensity::Comfortable => match self { Spacing::None => 0., @@ -75,6 +87,8 @@ impl Spacing { Spacing::Large => 10. / BASE_REM_SIZE_IN_PX, Spacing::XLarge => 16. / BASE_REM_SIZE_IN_PX, Spacing::XXLarge => 20. / BASE_REM_SIZE_IN_PX, + Spacing::Large3X => 24. / BASE_REM_SIZE_IN_PX, + Spacing::Large4X => 28. / BASE_REM_SIZE_IN_PX, }, } } diff --git a/crates/ui/src/styles/typography.rs b/crates/ui/src/styles/typography.rs index ef9c946ed5..ab27d0c5a8 100644 --- a/crates/ui/src/styles/typography.rs +++ b/crates/ui/src/styles/typography.rs @@ -200,6 +200,7 @@ impl RenderOnce for Headline { let ui_font = ThemeSettings::get_global(cx).ui_font.clone(); div() + .flex_none() .font(ui_font) .line_height(self.size.line_height()) .text_size(self.size.rems()) diff --git a/crates/ui_feedback/Cargo.toml b/crates/ui_feedback/Cargo.toml new file mode 100644 index 0000000000..c5ea145bf0 --- /dev/null +++ b/crates/ui_feedback/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ui_feedback" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/ui_feedback.rs" + +[dependencies] +gpui.workspace = true +settings.workspace = true +theme.workspace = true +ui.workspace = true +story = { workspace = true, optional = true } + +[features] +default = [] +stories = ["dep:story"] diff --git a/crates/ui_feedback/src/alert_dialog.rs b/crates/ui_feedback/src/alert_dialog.rs new file mode 100644 index 0000000000..e0f0d734d9 --- /dev/null +++ b/crates/ui_feedback/src/alert_dialog.rs @@ -0,0 +1,369 @@ +#![allow(unused, dead_code)] + +use std::sync::Arc; + +use gpui::{AppContext, FocusHandle, FocusableView, View}; +use ui::{ + div, px, relative, v_flex, vh, ActiveTheme, Button, ButtonCommon, ButtonSize, + CheckboxWithLabel, Color, ElementId, ElevationIndex, FixedWidth, FluentBuilder, Headline, + HeadlineSize, InteractiveElement, IntoElement, Label, LabelCommon, ParentElement, Render, + Selection, SharedString, Spacing, StatefulInteractiveElement, Styled, StyledExt, + StyledTypography, ViewContext, VisualContext, WindowContext, +}; + +const MAX_DIALOG_WIDTH: f32 = 440.0; +const MIN_DIALOG_WIDTH: f32 = 260.0; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +enum AlertDialogLayout { + /// For dialogs short titles and action names. + /// + /// Example: + /// + /// Title: "Discard changes?" + /// + /// Actions: "Cancel" | "Discard" + #[default] + Vertical, + /// For dialogs with long titles or action names, + /// or large amounts of content. + /// + /// As titles, action names or content get longer, the dialog + /// automatically switches to this layout + Horizontal, +} + +/// An alert dialog that interrupts the user's workflow to convey critical information. +/// +/// Use this component when immediate user attention or action is required. +/// +/// It blocks all other interactions until the user responds, making it suitable +/// for important confirmations or critical error messages. +pub struct AlertDialog { + /// The title of the alert dialog + pub title: SharedString, + + /// The main message or content of the alert + pub message: Option, + + /// The primart action the user can take + pub primary_action: SharedString, + + /// The secondary action the user can take + pub secondary_action: SharedString, + + /// A optional checkbox to show in the dialog + /// + /// Used to allow the user to opt-in or out of a secondary action, + /// such as "Don't ask again" or "Suggest extensions automatically" + pub checkbox: Option<(SharedString)>, + + focus_handle: FocusHandle, +} + +struct AlertDialogView { + dialog: AlertDialog, + focus_handle: FocusHandle, +} + +impl AlertDialog { + /// Create a new alert dialog + pub fn new( + cx: &mut WindowContext, + f: impl FnOnce(Self, &mut ViewContext) -> Self, + ) -> View { + cx.new_view(|cx| { + let focus_handle = cx.focus_handle(); + + f( + Self { + title: "Untitled Alert".into(), + message: None, + primary_action: "OK".into(), + secondary_action: "Cancel".into(), + checkbox: None, + focus_handle, + }, + cx, + ) + }) + } + + /// Set the title of the alert dialog + pub fn title(mut self, title: impl Into) -> Self { + self.title = title.into(); + self + } + + /// Set the main message or content of the alert dialog + pub fn message(mut self, message: impl Into) -> Self { + self.message = Some(message.into()); + self + } + + /// Set the primary action the user can take + pub fn primary_action(mut self, primary_action: impl Into) -> Self { + self.primary_action = primary_action.into(); + self + } + + /// Set the secondary action the user can take + pub fn secondary_action(mut self, secondary_action: impl Into) -> Self { + self.secondary_action = secondary_action.into(); + self + } + + /// Sets the checkbox to show in the dialog + pub fn checkbox(mut self, checkbox: impl Into) -> Self { + self.checkbox = Some(checkbox.into()); + self + } + + fn dialog_layout(&self) -> AlertDialogLayout { + let title_len = self.title.len(); + let primary_action_len = self.primary_action.len(); + let secondary_action_len = self.secondary_action.len(); + let message_len = self.message.as_ref().map_or(0, |m| m.len()); + + if title_len > 35 + || primary_action_len > 14 + || secondary_action_len > 14 + || message_len > 80 + { + AlertDialogLayout::Horizontal + } else { + AlertDialogLayout::Vertical + } + } + + fn render_button(&self, cx: &WindowContext, label: SharedString) -> impl IntoElement { + let id_string: SharedString = format!("action-{}-button", label).into(); + let id: ElementId = ElementId::Name(id_string); + + Button::new(id, label) + .size(ButtonSize::Large) + .layer(ElevationIndex::ModalSurface) + .when( + self.dialog_layout() == AlertDialogLayout::Vertical, + |this| this.full_width(), + ) + } +} + +impl Render for AlertDialog { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let layout = self.dialog_layout(); + let spacing = if layout == AlertDialogLayout::Horizontal { + Spacing::Large4X.rems(cx) + } else { + Spacing::XLarge.rems(cx) + }; + + v_flex() + .min_w(px(MIN_DIALOG_WIDTH)) + .max_w(if layout == AlertDialogLayout::Horizontal { + px(MAX_DIALOG_WIDTH) + } else { + px(MIN_DIALOG_WIDTH) + }) + .max_h(relative(0.75)) + .flex_none() + .overflow_hidden() + .p(spacing) + .gap(spacing) + .rounded_lg() + .font_ui(cx) + .bg(ElevationIndex::ModalSurface.bg(cx)) + .shadow(ElevationIndex::ModalSurface.shadow()) + // Title and message + .child( + v_flex() + .w_full() + .min_h(px(1.)) + .max_w_full() + .flex_grow() + .when(layout == AlertDialogLayout::Vertical, |this| { + this.justify_center().mx_auto() + }) + .child( + div() + .when(layout == AlertDialogLayout::Vertical, |this| this.mx_auto()) + .child(Headline::new(self.title.clone()).size(HeadlineSize::Small)), + ) + .when_some(self.message.clone(), |this, message| { + this.child( + div() + .when(layout == AlertDialogLayout::Vertical, |this| this.mx_auto()) + .text_color(cx.theme().colors().text_muted) + .text_ui(cx) + .child(message.clone()), + ) + }), + ) + // Actions & checkbox + .child( + div() + .flex() + .w_full() + .items_center() + // Force buttons to stack for Horizontal layout + .when(layout == AlertDialogLayout::Vertical, |this| { + this.flex_col() + }) + .when(layout == AlertDialogLayout::Horizontal, |this| { + this.justify_between() + .h(ButtonSize::Large.rems()) + .gap(Spacing::Medium.rems(cx)) + }) + .child(div().flex_shrink_0().when_some( + self.checkbox.clone(), + |this, (label)| { + this.child(CheckboxWithLabel::new( + ElementId::Name(label.clone()), + Label::new(label).color(Color::Muted), + false.into(), + // TODO: Pass on_click through self.checkbox + |_, _| {}, + )) + }, + )) + .child( + div() + .flex() + .gap(Spacing::Medium.rems(cx)) + .when(layout == AlertDialogLayout::Vertical, |this| { + this.flex_col().w_full() + }) + .when(layout == AlertDialogLayout::Horizontal, |this| { + this.items_center() + }) + .child(self.render_button(cx, self.secondary_action.clone())) + .child(self.render_button(cx, self.primary_action.clone())), + ), + ) + } +} + +impl FocusableView for AlertDialog { + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +/// Example stories for [AlertDialog] +/// +/// Run with `script/storybook alert_dialog` +#[cfg(feature = "stories")] +pub mod alert_dialog_stories { + #![allow(missing_docs)] + + use gpui::{Render, View}; + use story::{Story, StoryItem, StorySection}; + use ui::{prelude::*, ElevationIndex}; + + use super::AlertDialog; + + pub struct AlertDialogStory { + vertical_alert_dialog: View, + horizontal_alert_dialog: View, + long_content_alert_dialog: View, + } + + impl AlertDialogStory { + pub fn new(cx: &mut WindowContext) -> Self { + let vertical_alert_dialog = AlertDialog::new(cx, |mut dialog, cx| { + dialog + .title("Discard changes?") + .message("Something bad could happen...") + .primary_action("Discard") + .secondary_action("Cancel") + }); + + let horizontal_alert_dialog = AlertDialog::new(cx, |mut dialog, cx| { + dialog + .title("Do you want to leave the current call?") + .message("The current window will be closed, and connections to any shared projects will be terminated.") + .checkbox("Don't show again") + .primary_action("Leave Call") + .secondary_action("Cancel") + }); + + let long_content = r#"{ + "error": "RuntimeError", + "message": "An unexpected error occurred during execution", + "stackTrace": [ + { + "fileName": "main.rs", + "lineNumber": 42, + "functionName": "process_data" + }, + { + "fileName": "utils.rs", + "lineNumber": 23, + "functionName": "validate_input" + }, + { + "fileName": "core.rs", + "lineNumber": 105, + "functionName": "execute_operation" + } + ] +}"#; + + let long_content_alert_dialog = AlertDialog::new(cx, |mut dialog, cx| { + dialog + .title("A RuntimeError occured") + .message(long_content) + .primary_action("Send Report") + .secondary_action("Close") + }); + + Self { + vertical_alert_dialog, + horizontal_alert_dialog, + long_content_alert_dialog, + } + } + } + + impl Render for AlertDialogStory { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + Story::container().child( + StorySection::new() + .child( + div() + .flex() + .items_center() + .justify_center() + .w(px(780.)) + .h(px(380.)) + .overflow_hidden() + .bg(ElevationIndex::Background.bg(cx)) + .child(self.vertical_alert_dialog.clone()), + ) + .child( + div() + .flex() + .items_center() + .justify_center() + .w(px(580.)) + .h(px(420.)) + .overflow_hidden() + .bg(ElevationIndex::Background.bg(cx)) + .child(self.horizontal_alert_dialog.clone()), + ) + .child( + div() + .flex() + .items_center() + .justify_center() + .w(px(580.)) + .h(px(780.)) + .overflow_hidden() + .bg(ElevationIndex::Background.bg(cx)) + .child(self.long_content_alert_dialog.clone()), + ), + ) + } + } +} diff --git a/crates/ui_feedback/src/ui_feedback.rs b/crates/ui_feedback/src/ui_feedback.rs new file mode 100644 index 0000000000..ecf6c53ce9 --- /dev/null +++ b/crates/ui_feedback/src/ui_feedback.rs @@ -0,0 +1,8 @@ +#![deny(missing_docs)] + +//! # UI – Feedback +//! +//! This crate provides a set of UI components for providing feedback to the user via various means, such as alerts, notifications, status messages, etc. + +/// A dialog that displays an alert to the user. +pub mod alert_dialog;