fluidattacks-designs 0.11.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
//! `ImageView` — a picture the app already holds, from an API response's bytes
//! or from a file the person picked. [`crate::CloudImage`] is the remote
//! sibling, which resolves a Cloudinary public id instead.
//!
//! ```ignore
//! ImageView::new("evidence-1")
//!     .bytes(ImageFormat::Png, response)
//!     .alt("The login form, with the session token in the query string")
//!     .width(320.0)
//!     .on_click(open_the_viewer)
//! ```
//!
//! It fetches nothing, writes no file and logs nothing: the caller hands over
//! bytes it already has, and gpui decodes them in memory. Decoded frames do
//! sit in gpui's process-wide asset cache, keyed by the bytes themselves —
//! an app's own cache of *encoded* evidence, keyed by identifier, sits above
//! that and is none of this widget's business. A cache inside a widget rebuilt
//! every frame would only fight it.
//!
//! Sizing is the web's: `width` and `height` are the box, the picture keeps its
//! aspect ratio inside it (`object-fit: contain`), and an axis left unset is
//! computed from the other, which is what `height: auto` means there.
//!
//! Three things stand in for a picture. No source at all is the caller still
//! fetching, and shows [`Loading`]. Bytes that will not decode, and a video,
//! show the notice box `image_view::Notice` describes — gpui has no video
//! element, and Signals does not play evidence inline either, it hands the file
//! to the desktop, so a video source says so rather than painting a square that
//! reads as missing evidence.
//!
//! A thumbnail is too small to read evidence in, so [`ImageViewer`] opens the
//! same picture as a dialog. The view itself only reports the press: a dialog
//! has to be hosted where a dialog can be (see [`Modal`]), which is the view
//! that owns the window and not the row a thumbnail sits in.
//!
//! What the web has and this does not: the `<video controls>` player and the
//! link around it; and `fileType`, which the caller resolves with
//! `tokens::image_view::file_type` and answers by choosing a constructor.
//!
//! `alt` has no accessibility sink in gpui, as [`crate::CloudImage`] notes for
//! its own. It becomes a [`Tooltip`] instead, which is the one place a desktop
//! toolkit can put a description of a picture today.

use std::path::Path;
use std::rc::Rc;
use std::sync::Arc;

use fluidattacks_designs_domain::alert::colors;
use fluidattacks_designs_domain::image_view::{
    viewer_body, viewer_box, Notice, BORDER_WIDTH, DEFAULT_SCRIM_ALPHA, GLYPH_GAP, GLYPH_SIZE,
    GLYPH_STYLE, LINE_HEIGHT, PADDING, RADIUS, SCRIM, TEXT_SIZE, VIEWER_SIZE,
};
use fluidattacks_designs_domain::modal::Outcome;
use fluidattacks_designs_domain::theme::Colors;
use fluidattacks_designs_domain::typography::FONT_PRIMARY;
use gpui::prelude::FluentBuilder as _;
use gpui::{
    div, img, px, rgb, AnyElement, App, Div, Image, ImageFormat, Img, InteractiveElement as _,
    IntoElement, ObjectFit, ParentElement as _, RenderOnce, Rgba, SharedString, Stateful,
    StatefulInteractiveElement as _, Styled, StyledImage as _, Window,
};

use crate::icon::Icon;
use crate::loading::Loading;
use crate::modal::Modal;
use crate::theme::ActiveTheme as _;
use crate::tooltip::Tooltip;

/// Shared rather than boxed: a press handler outlives the frame that built it.
type Handler = Rc<dyn Fn(&mut Window, &mut App)>;

/// Where the picture comes from. Both sources are bytes the app owns; neither
/// is a fetch this widget performs.
enum Source {
    /// What an API response gives: the encoded bytes and the format they are in.
    Bytes(Arc<Image>),
    /// What a file picker gives: a path on disk.
    Path(Arc<Path>),
    /// A video, which has no picture to render — see the module docs.
    Video,
}

/// The box the caller asked for. An axis left unset stays `auto`, which gpui
/// resolves from the other one and the picture's own ratio.
fn sized<T: Styled + IntoElement>(element: T, (width, height): (Option<f32>, Option<f32>)) -> T {
    element
        .when_some(width, |el, value| el.w(px(value)))
        .when_some(height, |el, value| el.h(px(value)))
}

/// The box that stands in for a picture: an alert's severity at the size of the
/// slot the picture would have filled.
fn notice_box(notice: Notice, theme: &Colors) -> Div {
    let palette = colors(notice.variant(), theme);
    div()
        .flex()
        .items_center()
        .justify_center()
        .gap(px(GLYPH_GAP))
        .p(px(PADDING))
        .rounded(px(RADIUS))
        .border(px(BORDER_WIDTH))
        .border_color(rgb(palette.border))
        .bg(rgb(palette.bg))
        .text_color(rgb(palette.fg))
        .font_family(FONT_PRIMARY)
        .text_size(px(TEXT_SIZE.px()))
        .line_height(px(LINE_HEIGHT))
        .child(
            div().flex_none().child(
                Icon::new(SharedString::from(notice.variant().glyph()))
                    .style(GLYPH_STYLE)
                    .size(GLYPH_SIZE)
                    .color(palette.fg),
            ),
        )
        .child(notice.text())
}

/// The spinner the view shows while the caller is still fetching.
fn loading_box() -> Div {
    div()
        .flex()
        .items_center()
        .justify_center()
        .child(Loading::new())
}

/// The picture, with the two stand-ins gpui swaps in itself: the spinner while
/// it decodes, and the failure notice when the bytes will not decode at all.
///
/// Both replacements need the element to carry an id — that is where gpui keeps
/// the state that tells "still decoding" from "never will".
fn picture(element: Img, id: SharedString, theme: Colors) -> Stateful<Img> {
    element
        .id(id)
        .object_fit(ObjectFit::Contain)
        .with_loading(|| loading_box().into_any_element())
        .with_fallback(move || notice_box(Notice::Failure, &theme).into_any_element())
}

/// The picture, or whichever of the three stand-ins takes its place.
fn body(
    source: Option<Source>,
    id: SharedString,
    theme: Colors,
    size: (Option<f32>, Option<f32>),
) -> AnyElement {
    match source {
        None => sized(loading_box(), size).into_any_element(),
        Some(Source::Video) => sized(notice_box(Notice::Video, &theme), size).into_any_element(),
        Some(Source::Bytes(image)) => {
            sized(picture(img(image), id, theme), size).into_any_element()
        }
        Some(Source::Path(path)) => sized(picture(img(path), id, theme), size).into_any_element(),
    }
}

/// The box the picture and its wash share, and what a press on it reports.
///
/// The wash is the web's `::after`, and not a lid: a plain div takes no hit
/// test, which is what `pointer-events: none` buys there.
fn frame(id: SharedString, inner: AnyElement, scrim: f32, press: Option<Handler>) -> Stateful<Div> {
    let wash = Rgba {
        alpha: scrim,
        ..rgb(SCRIM)
    };
    div()
        .id(id)
        .relative()
        .when_some(press, |el, handler| {
            el.cursor_pointer().on_click(move |_event, window, app| {
                // a thumbnail in a row is not the row: the press that opens the
                // picture must not also be the row's own click
                app.stop_propagation();
                handler(window, app);
            })
        })
        .child(inner)
        .when(scrim > DEFAULT_SCRIM_ALPHA, |el| {
            el.child(
                div()
                    .absolute()
                    .top_0()
                    .left_0()
                    .right_0()
                    .bottom_0()
                    .bg(wash),
            )
        })
}

#[derive(IntoElement)]
pub struct ImageView {
    id: SharedString,
    source: Option<Source>,
    alt: Option<SharedString>,
    width: Option<f32>,
    height: Option<f32>,
    scrim: f32,
    on_click: Option<Handler>,
}

impl ImageView {
    /// A view with no source yet: the spinner, until a source arrives. The id
    /// keys the decode state, so sibling pictures stay apart — Signals has one
    /// per evidence identifier already.
    #[must_use]
    pub fn new(id: impl Into<SharedString>) -> Self {
        Self {
            id: id.into(),
            source: None,
            alt: None,
            width: None,
            height: None,
            scrim: DEFAULT_SCRIM_ALPHA,
            on_click: None,
        }
    }

    /// Bytes the app already holds, in the format they arrived as.
    #[must_use]
    pub fn bytes(mut self, format: ImageFormat, bytes: impl Into<Vec<u8>>) -> Self {
        self.source = Some(Source::Bytes(Arc::new(Image::from_bytes(
            format,
            bytes.into(),
        ))));
        self
    }

    /// A file on disk, which is what a file picker hands over.
    #[must_use]
    pub fn path(mut self, path: impl Into<Arc<Path>>) -> Self {
        self.source = Some(Source::Path(path.into()));
        self
    }

    /// A video: it shows the notice rather than a picture.
    #[must_use]
    pub fn video(mut self) -> Self {
        self.source = Some(Source::Video);
        self
    }

    /// What the picture shows, for the reader who cannot see it (web `alt`).
    #[must_use]
    pub fn alt(mut self, alt: impl Into<SharedString>) -> Self {
        self.alt = Some(alt.into());
        self
    }

    /// The box's width (web `width`).
    #[must_use]
    pub const fn width(mut self, width: f32) -> Self {
        self.width = Some(width);
        self
    }

    /// The box's height (web `height`).
    #[must_use]
    pub const fn height(mut self, height: f32) -> Self {
        self.height = Some(height);
        self
    }

    /// How far the picture is dimmed, from 0 to 1 (web `opacity`, which is the
    /// alpha of a black wash *over* the picture rather than the element's own
    /// opacity).
    #[must_use]
    pub const fn scrim(mut self, alpha: f32) -> Self {
        self.scrim = alpha;
        self
    }

    /// Reported when someone presses the picture, and what makes it look
    /// pressable. An app answers by opening an [`ImageViewer`] on the same
    /// source, which is the whole reason this reports at all.
    #[must_use]
    pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
        self.on_click = Some(Rc::new(handler));
        self
    }
}

impl RenderOnce for ImageView {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = cx.theme().colors;
        // the wash and the description hang off the same id, one element out
        let frame_id = SharedString::from(format!("{}-frame", self.id));
        let tip_id = SharedString::from(format!("{}-alt", self.id));
        let alt = self.alt.unwrap_or_default();
        let undescribed = alt.is_empty();
        let inner = body(self.source, self.id, theme, (self.width, self.height));
        Tooltip::new(tip_id, alt).disabled(undescribed).child(frame(
            frame_id,
            inner,
            self.scrim,
            self.on_click,
        ))
    }
}

/// The expanded picture: the same view inside a [`Modal`], at the size
/// `viewer_box` gives it, with no footer — a viewer has nothing to answer.
///
/// ```ignore
/// ImageViewer::new("viewer", "evidence.png", ImageView::new("full").path(file))
///     .on_close(|_, app| stop_drawing_it(app))
/// ```
///
/// Host it where the modal's docs say: as the last child of the view that owns
/// the window, drawn while the app's own state says a picture is open. How big
/// the picture gets is this component's decision, so hand it a source and let it
/// answer for the box — a [`ImageView::width`] or [`ImageView::height`] set on
/// the view it is given is replaced.
///
/// Out of scope, as they are for the view itself: zoom, pan, and stepping from
/// one picture to the next.
#[derive(IntoElement)]
pub struct ImageViewer {
    id: SharedString,
    title: SharedString,
    picture: ImageView,
    on_close: Option<Handler>,
}

impl ImageViewer {
    /// The title is the header the dismiss control sits in, so it is not
    /// optional: a dialog without one leaves Escape as the only way out. The
    /// file's own name is what a pentester recognizes a picture by.
    #[must_use]
    pub fn new(
        id: impl Into<SharedString>,
        title: impl Into<SharedString>,
        picture: ImageView,
    ) -> Self {
        Self {
            id: id.into(),
            title: title.into(),
            picture,
            on_close: None,
        }
    }

    /// Reported however it closed — the control or Escape. A viewer has one
    /// outcome, so the app's answer is always to stop drawing it.
    #[must_use]
    pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
        self.on_close = Some(Rc::new(handler));
        self
    }
}

impl RenderOnce for ImageViewer {
    fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
        let viewport = window.viewport_size();
        let (width, height) = viewer_box(f32::from(viewport.width), f32::from(viewport.height));
        let closed = self.on_close;
        Modal::new(self.id)
            .title(self.title)
            .size(VIEWER_SIZE)
            // a picture is content, and a body no longer sizes itself from it
            .body_height(viewer_body(height))
            .on_outcome(move |_outcome: &Outcome, outcome_window, app| {
                if let Some(handler) = &closed {
                    handler(outcome_window, app);
                }
            })
            .child(self.picture.width(width).height(height))
    }
}