fluidattacks-designs 0.11.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
//! `Modal` and `ConfirmDialog` — the web dialog: a scrim over the window and a
//! box centered on it, with a title, whatever the caller puts in the body, and
//! a footer of actions.
//!
//! It renders while the app says it is open, the way the web returns `null`
//! when it is not, and reports how it closed:
//!
//! ```ignore
//! Modal::new("delete-root")
//!     .title("Delete root")
//!     .description("This cannot be undone.")
//!     .confirm_text("Delete")
//!     .cancel_text("Cancel")
//!     .on_outcome(|outcome, _, app| finish(outcome, app))
//!     .child(form)
//! ```
//!
//! Host it as the **last child** of the view that owns the window, rather than
//! from the tab that opens it. Two reasons, and both are how the web's portal
//! behaves: the scrim covers its parent, so a parent that scrolls or clips
//! takes the dialog with it; and gpui paints siblings in order, so an earlier
//! one is painted over.
//!
//! Last child rather than deferred, because a dialog hosts what it is given.
//! `deferred` would raise it over any sibling, but gpui panics on a `deferred`
//! inside another, and `Select` and `Tooltip` defer their floating parts — a
//! deferred dialog would abort the moment a form in its body opened a menu.
//!
//! The body scrolls itself once it outgrows the box, as the web's does, so the
//! 80% cap is a cap and not a guillotine. The caller hands over content and
//! nothing else.
//!
//! What the web has and this does not: the tabbed and image variants and the
//! columns modal built over them; the header's `otherActions` slot beside the
//! dismiss control; the footer's `alert` slot above the buttons; a title or a
//! description as arbitrary content rather than a string; and `ModalConfirm`'s
//! `onConfirm: "submit"`, which has no form element to submit to here. Signals
//! needs none of them, and each is additive on what is here.

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

// the actions! expansion derives PartialEq without Eq; not ours to fix
#[allow(clippy::derive_partial_eq_without_eq)]
mod action_defs {
    gpui::actions!(designs_modal, [Dismiss]);
}

use action_defs::Dismiss;

/// Bind the dialog's keymap. [`crate::Theme::init`] calls this; an app that
/// installs the theme needs nothing of its own.
pub fn bind_keys(cx: &mut App) {
    cx.bind_keys([KeyBinding::new("escape", Dismiss, Some(CONTEXT))]);
}

/// Shared rather than boxed: one dialog hands the same handler to its confirm,
/// its cancel and its dismissal, and only the outcome tells them apart.
type OutcomeHandler = Rc<dyn Fn(&Outcome, &mut Window, &mut App)>;

/// One control's press, boxed so the three controls can be built apart from
/// the render that owns the handler they all report through.
type Press = Box<dyn Fn(&mut Window, &mut App)>;

/// What an open dialog holds across frames: the caret, where the caret came
/// from, and how far its body has been scrolled.
///
/// The web needs none of it. A browser modal leaves focus where it was and
/// traps nothing, and the browser owns the scroll offset. A desktop one has to
/// take the caret, or Escape reaches no keymap, has to give it back, or the
/// window is left with nowhere to type, and has to carry the offset itself.
struct Held {
    handle: FocusHandle,
    returning: Option<FocusHandle>,
    body: ScrollHandle,
}

impl Held {
    /// Runs once, on the frame the dialog first appears.
    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(),
        }
    }
}

/// What the domain reads to answer whether a control reports.
#[derive(Clone, Copy)]
struct Standing {
    confirm_disabled: bool,
    dismissable: bool,
}

/// One control's press: ask the domain whether it reports, hand the caret
/// back, then tell the app. A control the domain swallows does none of the
/// three, so a disabled confirm cannot close the dialog by the back door.
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)
    }
}

/// An id per part, so two dialogs never share one element's state.
fn part(id: &SharedString, name: &str) -> SharedString {
    SharedString::from(format!("{id}-{name}"))
}

/// The header's dismiss control — the web's ghost `IconButton`.
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))
}

/// The title row, the description under it, and the control that closes.
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)),
            )
        })
}

/// Whatever the caller put in the dialog, under the hairline the web draws
/// when a footer follows it.
///
/// It scrolls, as the web's `overflow: hidden auto` does, which is what makes
/// the box's 80% cap a cap rather than a guillotine: the header and footer keep
/// their own height and the body takes what is left. `min_h` of zero is what
/// lets it — flexbox gives an item an automatic minimum of its content size,
/// so without it the body would push the box past the cap and be clipped.
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))
        // after the zero, so a declared height wins it: the scrolling box takes
        // this from the wrapper the scroll moves it to
        .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))
}

/// The footer's two answers, each reporting through `press`.
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)
        }),
    )
}

/// The actions, in the web's two nodes rather than one.
///
/// The inset and the cap cannot share a node: gpui sizes by border-box, as the
/// web's `Container` does, so a single div capped at 392 with 20 of padding
/// leaves 352 for a row that needs 190 + 12 + 190. Flex would take the missing
/// 40 out of the buttons, and both would come out narrower than the web's.
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),
    )
}

/// The box: white, elevated, and either a share of the window or content-sized
/// behind a floor.
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))
        })
}

/// The scrim, the keymap and the caret's home — everything around the box.
///
/// `occlude` is what makes it modal: without it the pointer reaches straight
/// through the dim into the window it is dimming. It is not deferred; see the
/// module docs for why the caller places it last instead.
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)
}

/// The web `Modal`, minus the tabbed and image variants.
#[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(),
        }
    }

    /// The heading. A dialog without one renders no header at all, the way the
    /// web skips it on an empty `title`.
    #[must_use]
    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
        self.title = Some(title.into());
        self
    }

    /// The line under the title (web `description`).
    #[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
    }

    /// Whether Escape and the header's control close it (web `dismissable`).
    #[must_use]
    pub const fn dismissable(mut self, dismissable: bool) -> Self {
        self.dismissable = dismissable;
        self
    }

    /// The confirm button. No footer renders until this or [`Self::cancel_text`]
    /// is given, which is the web's rule.
    #[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
    }

    /// Grey the confirm button out and stop it reporting, for a form that is
    /// not yet answerable.
    #[must_use]
    pub const fn confirm_disabled(mut self, disabled: bool) -> Self {
        self.confirm_disabled = disabled;
        self
    }

    /// How tall the body is, for a caller whose content is what decides it.
    ///
    /// Asked for rather than inherited, and this is why: the body scrolls, and
    /// taffy 0.12 — which arrived with gpui-ce — stopped sizing a scrolling box
    /// from its content when the box holding it has no height of its own. A
    /// dialog's box has none by design, since it grows with what it holds, so
    /// without this the body reports nothing, takes nothing, and scrolls what
    /// would have fitted. Anything under the cap the box keeps is honoured; a
    /// body left undeclared behaves as it always did.
    #[must_use]
    pub const fn body_height(mut self, height: f32) -> Self {
        self.body_height = Some(height);
        self
    }

    /// How it closed. Cancel and dismiss stay apart here, where the web folds
    /// both into one `close()`: Signals treats a person who said no
    /// differently from one who walked away.
    #[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)
    }
}

/// The web `useConfirmDialog`'s dialog: a title, a message and the two
/// answers, for the question that needs nothing else.
///
/// The web resolves its promise `false` for the header's control as well as
/// for cancel; here the two stay apart, so a caller that only wants the answer
/// reads `Outcome::Confirm` and treats the rest alike.
#[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
    }
}