use std::rc::Rc;
use fluidattacks_designs_domain::button::{Justify, Variant};
use fluidattacks_designs_domain::container::Scroll;
use fluidattacks_designs_domain::modal::{
report, Outcome, Size, BG, BORDER, CANCEL_TEXT, CONFIRM_TEXT, CONTENT_BORDER,
CONTENT_PADDING_BOTTOM, CONTENT_PADDING_X, DESCRIPTION_MARGIN_TOP, DESCRIPTION_SIZE,
DISMISS_COLOR, DISMISS_GLYPH, DISMISS_PADDING, DISMISS_SIDE, DISMISS_STYLE, FIXED_MIN_WIDTH,
FIXED_PADDING, FOOTER_BUTTON_MAX_WIDTH, FOOTER_GAP, FOOTER_MAX_WIDTH, FOOTER_PADDING,
HEADER_PADDING, HEADER_PADDING_BOTTOM, MAX_HEIGHT, OVERLAY, OVERLAY_ALPHA, RADIUS, SHADOW,
TEXT, TEXT_SIZE, TITLE_GAP, TITLE_SIZE,
};
use fluidattacks_designs_domain::typography::FONT_PRIMARY;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, px, relative, rgb, AnyElement, App, Context, Div, Entity, FocusHandle,
InteractiveElement as _, IntoElement, KeyBinding, ParentElement, RenderOnce, Rgba,
ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, Styled as _, Window,
};
use crate::button::Button;
use crate::container::Container;
use crate::icon::Icon;
use crate::text::{Heading, Text};
use crate::theme::{ActiveTheme as _, Theme};
use crate::widget::{elevation, ghost_icon_button};
const CONTEXT: &str = "DesignsModal";
#[allow(clippy::derive_partial_eq_without_eq)]
mod action_defs {
gpui::actions!(designs_modal, [Dismiss]);
}
use action_defs::Dismiss;
pub fn bind_keys(cx: &mut App) {
cx.bind_keys([KeyBinding::new("escape", Dismiss, Some(CONTEXT))]);
}
type OutcomeHandler = Rc<dyn Fn(&Outcome, &mut Window, &mut App)>;
type Press = Box<dyn Fn(&mut Window, &mut App)>;
struct Held {
handle: FocusHandle,
returning: Option<FocusHandle>,
body: ScrollHandle,
}
impl Held {
fn open(window: &mut Window, cx: &mut Context<Self>) -> Self {
let handle = cx.focus_handle();
let returning = window.focused(cx);
handle.focus(window, cx);
Self {
handle,
returning,
body: ScrollHandle::new(),
}
}
}
#[derive(Clone, Copy)]
struct Standing {
confirm_disabled: bool,
dismissable: bool,
}
fn reporter(
control: Outcome,
standing: Standing,
focus: &Entity<Held>,
handler: Option<&OutcomeHandler>,
) -> impl Fn(&mut Window, &mut App) + 'static {
let focus = focus.clone();
let handler = handler.cloned();
move |window, app| {
let Some(outcome) = report(control, standing.confirm_disabled, standing.dismissable) else {
return;
};
let returning = focus.read(app).returning.clone();
if let Some(handle) = &returning {
handle.focus(window, app);
}
if let Some(handler) = &handler {
handler(&outcome, window, app);
}
}
}
fn scrim() -> Rgba {
Rgba {
alpha: OVERLAY_ALPHA,
..rgb(OVERLAY)
}
}
fn part(id: &SharedString, name: &str) -> SharedString {
SharedString::from(format!("{id}-{name}"))
}
fn dismiss_control(
id: SharedString,
press: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
) -> Stateful<Div> {
let glyph = Icon::new(DISMISS_GLYPH)
.style(DISMISS_STYLE)
.size_px(DISMISS_SIDE)
.color(cx.theme().shade(DISMISS_COLOR));
ghost_icon_button(id, glyph, DISMISS_PADDING, cx)
.on_click(move |_, window, app| press(window, app))
}
fn header(
title: SharedString,
description: Option<SharedString>,
dismiss: Option<Stateful<Div>>,
) -> Div {
div()
.flex()
.flex_col()
.p(px(HEADER_PADDING))
.pb(px(HEADER_PADDING_BOTTOM))
.child(
div()
.flex()
.items_center()
.justify_between()
.gap(px(TITLE_GAP))
.child(Heading::new(title).size(TITLE_SIZE))
.children(dismiss),
)
.when_some(description, |el, text| {
el.child(
div()
.mt(px(DESCRIPTION_MARGIN_TOP))
.child(Text::new(text).size(DESCRIPTION_SIZE)),
)
})
}
fn body(
id: &SharedString,
children: Vec<AnyElement>,
shape: (bool, Option<f32>),
scroll: &ScrollHandle,
theme: &Theme,
) -> Container {
let (footed, height) = shape;
Container::new()
.scroll(part(id, "body"), Scroll::Y, scroll)
.min_h(px(0.0))
.when_some(height, |el, height| el.min_h(px(height)))
.px(px(CONTENT_PADDING_X))
.pb(px(CONTENT_PADDING_BOTTOM))
.when(footed, |el| {
el.border_b_1()
.border_color(rgb(theme.shade(CONTENT_BORDER)))
})
.children(children)
}
fn action(
id: SharedString,
label: SharedString,
variant: Variant,
press: impl Fn(&mut Window, &mut App) + 'static,
) -> Button {
Button::new(id, label)
.variant(variant)
.justify(Justify::Center)
.width(FOOTER_BUTTON_MAX_WIDTH)
.on_click(move |_, window, app| press(window, app))
}
fn answers(
id: &SharedString,
labels: (Option<SharedString>, Option<SharedString>),
confirm_disabled: bool,
press: impl Fn(Outcome) -> Press,
) -> (Option<Button>, Option<Button>) {
let (cancel, confirm) = labels;
(
cancel.map(|label| {
action(
part(id, "cancel"),
label,
Variant::Tertiary,
press(Outcome::Cancel),
)
}),
confirm.map(|label| {
action(
part(id, "confirm"),
label,
Variant::Primary,
press(Outcome::Confirm),
)
.disabled(confirm_disabled)
}),
)
}
fn footer(cancel: Option<Button>, confirm: Option<Button>) -> Div {
div().p(px(FOOTER_PADDING)).child(
div()
.flex()
.flex_row()
.gap(px(FOOTER_GAP))
.max_w(px(FOOTER_MAX_WIDTH))
.children(cancel)
.children(confirm),
)
}
fn box_of(size: Size, window: &Window, theme: &Theme) -> Div {
let share = size.width_fraction(f32::from(window.viewport_size().width));
div()
.flex()
.flex_col()
.overflow_hidden()
.bg(rgb(theme.shade(BG)))
.border_1()
.border_color(rgb(theme.shade(BORDER)))
.rounded(px(RADIUS))
.shadow(elevation(SHADOW))
.max_h(relative(MAX_HEIGHT))
.font_family(FONT_PRIMARY)
.text_size(px(TEXT_SIZE.px()))
.line_height(px(TEXT_SIZE.line_height_px()))
.text_color(rgb(theme.shade(TEXT)))
.when_some(share, |el, share| el.w(relative(share)))
.when(share.is_none(), |el| {
el.min_w(px(FIXED_MIN_WIDTH)).p(px(FIXED_PADDING))
})
}
fn layer(
focus: &Entity<Held>,
dismissing: impl Fn(&mut Window, &mut App) + 'static,
dialog: Div,
cx: &App,
) -> Div {
div()
.key_context(CONTEXT)
.track_focus(&focus.read(cx).handle)
.on_action(move |_: &Dismiss, window, app| dismissing(window, app))
.absolute()
.top_0()
.left_0()
.size_full()
.flex()
.items_center()
.justify_center()
.bg(scrim())
.occlude()
.child(dialog)
}
#[derive(IntoElement)]
pub struct Modal {
id: SharedString,
title: Option<SharedString>,
description: Option<SharedString>,
size: Size,
dismissable: bool,
confirm: Option<SharedString>,
cancel: Option<SharedString>,
confirm_disabled: bool,
body_height: Option<f32>,
on_outcome: Option<OutcomeHandler>,
children: Vec<AnyElement>,
}
impl Modal {
#[must_use]
pub fn new(id: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
title: None,
description: None,
size: Size::default(),
dismissable: true,
confirm: None,
cancel: None,
confirm_disabled: false,
body_height: None,
on_outcome: None,
children: Vec::new(),
}
}
#[must_use]
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub const fn size(mut self, size: Size) -> Self {
self.size = size;
self
}
#[must_use]
pub const fn dismissable(mut self, dismissable: bool) -> Self {
self.dismissable = dismissable;
self
}
#[must_use]
pub fn confirm_text(mut self, text: impl Into<SharedString>) -> Self {
self.confirm = Some(text.into());
self
}
#[must_use]
pub fn cancel_text(mut self, text: impl Into<SharedString>) -> Self {
self.cancel = Some(text.into());
self
}
#[must_use]
pub const fn confirm_disabled(mut self, disabled: bool) -> Self {
self.confirm_disabled = disabled;
self
}
#[must_use]
pub const fn body_height(mut self, height: f32) -> Self {
self.body_height = Some(height);
self
}
#[must_use]
pub fn on_outcome(
mut self,
handler: impl Fn(&Outcome, &mut Window, &mut App) + 'static,
) -> Self {
self.on_outcome = Some(Rc::new(handler));
self
}
}
impl ParentElement for Modal {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for Modal {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let Self {
id,
title,
description,
size,
dismissable,
confirm,
cancel,
confirm_disabled,
body_height,
on_outcome,
children,
} = self;
let focus = window.use_keyed_state(id.clone(), cx, Held::open);
let standing = Standing {
confirm_disabled,
dismissable,
};
let press = |control| -> Press {
Box::new(reporter(control, standing, &focus, on_outcome.as_ref()))
};
let dismiss =
dismissable.then(|| dismiss_control(part(&id, "dismiss"), press(Outcome::Dismiss), cx));
let (cancel, confirm) = answers(&id, (cancel, confirm), confirm_disabled, press);
let footed = cancel.is_some() || confirm.is_some();
let dialog = box_of(size, window, cx.theme())
.when_some(title, |el, title| {
el.child(header(title, description, dismiss))
})
.when(!children.is_empty(), |el| {
el.child(body(
&id,
children,
(footed, body_height),
&focus.read(cx).body,
cx.theme(),
))
})
.when(footed, |el| el.child(footer(cancel, confirm)));
layer(&focus, press(Outcome::Dismiss), dialog, cx)
}
}
#[derive(IntoElement)]
pub struct ConfirmDialog {
modal: Modal,
}
impl ConfirmDialog {
#[must_use]
pub fn new(
id: impl Into<SharedString>,
title: impl Into<SharedString>,
message: impl Into<SharedString>,
) -> Self {
Self {
modal: Modal::new(id)
.title(title)
.description(message)
.confirm_text(CONFIRM_TEXT)
.cancel_text(CANCEL_TEXT),
}
}
#[must_use]
pub fn confirm_text(mut self, text: impl Into<SharedString>) -> Self {
self.modal = self.modal.confirm_text(text);
self
}
#[must_use]
pub fn cancel_text(mut self, text: impl Into<SharedString>) -> Self {
self.modal = self.modal.cancel_text(text);
self
}
#[must_use]
pub fn size(mut self, size: Size) -> Self {
self.modal = self.modal.size(size);
self
}
#[must_use]
pub fn confirm_disabled(mut self, disabled: bool) -> Self {
self.modal = self.modal.confirm_disabled(disabled);
self
}
#[must_use]
pub fn on_outcome(
mut self,
handler: impl Fn(&Outcome, &mut Window, &mut App) + 'static,
) -> Self {
self.modal = self.modal.on_outcome(handler);
self
}
}
impl RenderOnce for ConfirmDialog {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
self.modal
}
}