Compare commits

...

16 Commits

Author SHA1 Message Date
Nate Butler
5caf6eb041 Clean up unused 2024-10-15 17:02:38 -04:00
Nate Butler
879f850f4f tidy 2024-10-15 16:01:49 -04:00
Nate Butler
e6e72f9f2d rename dismiss 2024-10-15 15:20:33 -04:00
Nate Butler
30d91429e7 IT WORKS 2024-10-15 15:09:14 -04:00
Nate Butler
e255389cbe DON'T MERGE THIS 2024-10-13 21:05:39 -04:00
Nate Butler
eeba056b84 wip - panic! 2024-10-13 21:05:29 -04:00
Nate Butler
f2338025b7 ui_feedback -> alert_dialog 2024-10-13 20:15:52 -04:00
Nate Butler
e29f4e6632 wip 2024-10-11 22:28:05 -04:00
Nate Butler
dd6f0dfc04 📎
Co-Authored-By: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
2024-10-11 12:28:09 -04:00
Nate Butler
a84eb44564 Allow showing dialog in a modal
Co-Authored-By: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
Co-Authored-By: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
2024-10-11 12:25:37 -04:00
Nate Butler
bd1e57bd4a Update typography.rs 2024-10-11 12:24:20 -04:00
Nate Butler
cd811a2014 Remove checkbox for now 2024-10-11 09:59:02 -04:00
Nate Butler
74a5f6a464 Implement Display for Selection
Useful for debugging selected states
2024-10-11 09:53:48 -04:00
Nate Butler
c916f5d476 wip 2024-10-11 09:47:39 -04:00
Nate Butler
43ddcdef84 Pass on_click through alert dialog actions 2024-10-11 08:35:45 -04:00
Nate Butler
be6d13b6d8 Add alert_dialog 2024-10-10 21:31:14 -04:00
12 changed files with 542 additions and 1 deletions

15
Cargo.lock generated
View File

@@ -108,6 +108,19 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "alert_dialog"
version = "0.1.0"
dependencies = [
"gpui",
"menu",
"settings",
"story",
"theme",
"ui",
"workspace",
]
[[package]]
name = "aliasable"
version = "0.1.3"
@@ -10864,6 +10877,7 @@ dependencies = [
name = "storybook"
version = "0.1.0"
dependencies = [
"alert_dialog",
"anyhow",
"clap",
"collab_ui",
@@ -11728,6 +11742,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
name = "title_bar"
version = "0.1.0"
dependencies = [
"alert_dialog",
"auto_update",
"call",
"client",

View File

@@ -120,6 +120,7 @@ members = [
"crates/time_format",
"crates/title_bar",
"crates/ui",
"crates/alert_dialog",
"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" }
alert_dialog = { path = "crates/alert_dialog" }
ui_input = { path = "crates/ui_input" }
ui_macros = { path = "crates/ui_macros" }
util = { path = "crates/util" }

View File

@@ -0,0 +1,25 @@
[package]
name = "alert_dialog"
version = "0.1.0"
edition = "2021"
publish = false
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/alert_dialog.rs"
[dependencies]
gpui.workspace = true
menu.workspace = true
settings.workspace = true
story = { workspace = true, optional = true }
theme.workspace = true
ui.workspace = true
workspace.workspace = true
[features]
default = []
stories = ["dep:story"]

View File

@@ -0,0 +1,420 @@
#![deny(missing_docs)]
//! Provides the Alert Dialog UI component A modal dialog that interrupts the user's workflow to convey critical information.
use std::sync::Arc;
use gpui::{
Action, AppContext, ClickEvent, DismissEvent, EventEmitter, FocusHandle, FocusableView, View,
WeakView,
};
use ui::{
div, px, v_flex, vh, ActiveTheme, Button, ButtonCommon, ButtonSize, Clickable, ElementId,
ElevationIndex, FluentBuilder, Headline, HeadlineSize, InteractiveElement, IntoElement,
ParentElement, Render, RenderOnce, SharedString, Spacing, Styled, StyledTypography,
ViewContext, VisualContext, WindowContext,
};
use workspace::{ModalView, Workspace};
#[derive(Clone, IntoElement)]
struct AlertDialogButton {
id: ElementId,
label: SharedString,
on_click: Option<Arc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
}
impl AlertDialogButton {
fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
on_click: None,
}
}
fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self {
self.on_click = Some(Arc::new(handler));
self
}
}
impl RenderOnce for AlertDialogButton {
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
Button::new(self.id, self.label)
.size(ButtonSize::Large)
.layer(ElevationIndex::ModalSurface)
.when_some(self.on_click, |this, on_click| {
this.on_click(move |event, cx| {
on_click(event, cx);
})
})
}
}
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.
#[derive(Clone)]
pub struct AlertDialog {
/// The title of the alert dialog
pub title: SharedString,
/// The main message or content of the alert
pub message: Option<SharedString>,
/// The primary action the user can take
primary_action: AlertDialogButton,
/// The secondary action the user can take
secondary_action: AlertDialogButton,
focus_handle: FocusHandle,
}
impl EventEmitter<DismissEvent> for AlertDialog {}
impl FocusableView for AlertDialog {
fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
impl ModalView for AlertDialog {
fn fade_out_background(&self) -> bool {
true
}
}
impl AlertDialog {
/// Create a new alert dialog
pub fn new(
cx: &mut WindowContext,
f: impl FnOnce(Self, &mut ViewContext<Self>) -> Self,
) -> View<Self> {
cx.new_view(|cx| {
let focus_handle = cx.focus_handle();
f(
Self {
title: "Untitled Alert".into(),
message: None,
primary_action: AlertDialogButton::new("primary-action", "OK"),
secondary_action: AlertDialogButton::new("secondary-action", "Cancel"),
focus_handle,
},
cx,
)
})
}
/// Set the title of the alert dialog
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = title.into();
self
}
/// Set the main message or content of the alert dialog
pub fn message(mut self, message: impl Into<SharedString>) -> Self {
self.message = Some(message.into());
self
}
/// Set the primary action the user can take
pub fn primary_action(
mut self,
label: impl Into<SharedString>,
handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
) -> Self {
self.primary_action = AlertDialogButton::new("primary-action", label).on_click(handler);
self
}
/// Set the secondary action the user can take
pub fn secondary_action(
mut self,
label: impl Into<SharedString>,
handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
) -> Self {
self.secondary_action = AlertDialogButton::new("secondary-action", label).on_click(handler);
self
}
/// Set the secondary action to a dismiss action with a custom label
///
/// Example: "Close", "Dismiss", "No"
pub fn secondary_dismiss_action(mut self, label: impl Into<SharedString>) -> Self {
self.secondary_action = AlertDialogButton::new("secondary-action", label)
.on_click(|_, cx| cx.dispatch_action(menu::Cancel.boxed_clone()));
self
}
fn dialog_layout(&self) -> AlertDialogLayout {
let title_len = self.title.len();
let primary_action_len = self.primary_action.label.len();
let secondary_action_len = self.secondary_action.label.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
}
}
/// Spawns the alert dialog in a new modal
pub fn show(&self, workspace: WeakView<Workspace>, cx: &mut ViewContext<Self>) {
let this = self.clone();
let focus_handle = self.focus_handle.clone();
cx.spawn(|_, mut cx| async move {
workspace.update(&mut cx, |workspace, cx| {
workspace.toggle_modal(cx, |_cx| this);
cx.focus(&focus_handle);
})
})
.detach();
}
fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
cx.emit(DismissEvent)
}
}
impl Render for AlertDialog {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let layout = self.dialog_layout();
let spacing = if layout == AlertDialogLayout::Horizontal {
Spacing::Large4X.rems(cx)
} else {
Spacing::XLarge.rems(cx)
};
v_flex()
.key_context("Alert")
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::cancel))
.occlude()
.min_w(px(MIN_DIALOG_WIDTH))
.max_w(if layout == AlertDialogLayout::Horizontal {
px(MAX_DIALOG_WIDTH)
} else {
px(MIN_DIALOG_WIDTH)
})
.max_h(vh(0.75, cx))
.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()
// This is a flex hack. Layout breaks without it ¯\_(ツ)_/¯
.min_h(px(1.))
.max_w_full()
.flex_grow()
.when(layout == AlertDialogLayout::Vertical, |this| {
// If we had `.text_center()` we would use it here instead of centering the content
// since this approach will only work as long as the content is a single line
this.justify_center().mx_auto()
})
.child(
div()
// Same as above, if `.text_center()` is supported in the future, use here.
.when(layout == AlertDialogLayout::Vertical, |this| this.mx_auto())
.child(Headline::new(self.title.clone()).size(HeadlineSize::Small)),
)
.when_some(self.message.clone(), |this, message| {
// TODO: When content will be long (example: a document, log or stack trace)
// we should render some sort of styled container, as well as allow the content to scroll
this.child(
div()
// Same as above, if `.text_center()` is supported in the future, use here.
.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())
.child(
div()
.flex()
.gap(Spacing::Medium.rems(cx))
.when(layout == AlertDialogLayout::Vertical, |this| {
this.flex_col_reverse().w_full()
})
.when(layout == AlertDialogLayout::Horizontal, |this| {
this.items_center()
})
.child(self.secondary_action.clone())
.child(self.primary_action.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, StorySection};
use ui::{prelude::*, ElevationIndex};
use super::AlertDialog;
pub struct AlertDialogStory {
vertical_alert_dialog: View<AlertDialog>,
horizontal_alert_dialog: View<AlertDialog>,
long_content_alert_dialog: View<AlertDialog>,
}
impl AlertDialogStory {
pub fn new(cx: &mut WindowContext) -> Self {
let vertical_alert_dialog = AlertDialog::new(cx, |dialog, _cx| {
dialog
.title("Discard changes?")
.message("Something bad could happen...")
.primary_action("Discard", |_, _| {
println!("Discarded!");
})
.secondary_action("Cancel", |_, _| {
println!("Cancelled!");
})
});
let horizontal_alert_dialog = AlertDialog::new(cx, |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.")
.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, |dialog, _cx| {
dialog
.title("A RuntimeError occurred")
.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<Self>) -> 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()),
),
)
}
}
}

View File

@@ -36,6 +36,7 @@ strum = { workspace = true, features = ["derive"] }
theme.workspace = true
title_bar = { workspace = true, features = ["stories"] }
ui = { workspace = true, features = ["stories"] }
alert_dialog = { workspace = true, features = ["stories"] }
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }

View File

@@ -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,9 @@ pub enum ComponentStory {
impl ComponentStory {
pub fn story(&self, cx: &mut WindowContext) -> AnyView {
match self {
Self::AlertDialog => cx
.new_view(|cx| alert_dialog::alert_dialog_stories::AlertDialogStory::new(cx))
.into(),
Self::ApplicationMenu => cx
.new_view(|cx| title_bar::ApplicationMenuStory::new(cx))
.into(),

View File

@@ -29,6 +29,7 @@ test-support = [
]
[dependencies]
alert_dialog.workspace = true
auto_update.workspace = true
call.workspace = true
client.workspace = true

View File

@@ -8,6 +8,7 @@ mod stories;
use crate::application_menu::ApplicationMenu;
use crate::platforms::{platform_linux, platform_mac, platform_windows};
use alert_dialog::AlertDialog;
use auto_update::AutoUpdateStatus;
use call::ActiveCall;
use client::{Client, UserStore};
@@ -67,6 +68,7 @@ pub struct TitleBar {
should_move: bool,
application_menu: Option<View<ApplicationMenu>>,
_subscriptions: Vec<Subscription>,
test_dialog: View<AlertDialog>,
}
impl Render for TitleBar {
@@ -84,6 +86,8 @@ impl Render for TitleBar {
} else {
cx.theme().colors().title_bar_background
};
let workspace = self.workspace.clone();
let test_dialog = self.test_dialog.clone();
h_flex()
.id("titlebar")
@@ -139,6 +143,13 @@ impl Render for TitleBar {
.on_mouse_down(MouseButton::Left, |_, cx| cx.stop_propagation()),
)
.child(self.render_collaborator_list(cx))
.child(
Button::new("test-dialog", "Test Dialog").on_click(move |_, cx| {
let workspace = workspace.clone();
test_dialog.update(cx, move |dialog, cx| dialog.show(workspace, cx));
}),
)
.child(
h_flex()
.gap_1()
@@ -220,6 +231,14 @@ impl TitleBar {
}
};
let test_dialog = AlertDialog::new(cx, |dialog, _| {
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.")
.primary_action("Leave Call", |_, _| {})
.secondary_dismiss_action("Cancel")
});
let mut subscriptions = Vec::new();
subscriptions.push(
cx.observe(&workspace.weak_handle().upgrade().unwrap(), |_, _, cx| {
@@ -242,6 +261,7 @@ impl TitleBar {
user_store,
client,
_subscriptions: subscriptions,
test_dialog,
}
}

View File

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

View File

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

View File

@@ -174,6 +174,8 @@ impl HeadlineSize {
}
}
// TODO: Just added these to get stared with headlines but these
// but we should have a per-headline size line height.
/// Returns the line height for the headline size.
pub fn line_height(self) -> Rems {
match self {
@@ -200,6 +202,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())

View File

@@ -1,3 +1,5 @@
use std::fmt::{Display, Formatter};
/// A trait for elements that can be selected.
///
/// Generally used to enable "toggle" or "active" behavior and styles on an element through the [`Selection`] status.
@@ -30,6 +32,16 @@ impl Selection {
}
}
impl Display for Selection {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unselected => write!(f, "Unselected"),
Self::Indeterminate => write!(f, "Indeterminate"),
Self::Selected => write!(f, "Selected"),
}
}
}
impl From<bool> for Selection {
fn from(selected: bool) -> Self {
if selected {
@@ -49,3 +61,12 @@ impl From<Option<bool>> for Selection {
}
}
}
impl Into<bool> for Selection {
fn into(self) -> bool {
match self {
Self::Selected => true,
Self::Unselected | Self::Indeterminate => false,
}
}
}