fluidattacks-designs 0.12.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
//! `Table` — the web data table: sortable, filterable, selectable rows over a
//! set of typed columns.
//!
//! [`TableState`] holds the data and the standings as an entity the app owns;
//! [`Table`] renders it:
//!
//! ```ignore
//! let table = cx.new(|cx| TableState::new(columns, window, cx));
//! table.update(cx, |state, cx| state.set_rows(rows, cx));
//! Table::new(&table).height(320.0).search(true)
//! ```
//!
//! The table sizes itself to its columns rather than to its parent, so a set
//! wider than the window goes inside a [`crate::Container`] with `Scroll::X`
//! and gets the design system's bar.

mod body;
mod footer;
mod header;
mod page_size;
mod state;

pub use state::{Column, PageHandler, TableState};

use fluidattacks_designs_domain::search;
use fluidattacks_designs_domain::table::{
    Page, BODY_BG, DEFAULT_HEIGHT, EMPTY_DESCRIPTION, EMPTY_TITLE, HEADER_HEIGHT, SEARCH_MIN_WIDTH,
    SEARCH_ROW_GAP, TOP_RADIUS,
};
use gpui::prelude::FluentBuilder as _;
use gpui::{
    div, px, relative, rgb, AnyElement, App, Div, ElementId, Entity, Focusable as _,
    InteractiveElement as _, IntoElement, KeyBinding, ListState, MouseButton, MouseUpEvent,
    ParentElement as _, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _,
    Styled as _, Window,
};

use crate::empty_state::EmptyState;
use crate::scrollbar::Scrollbar;
use crate::text_input::TextInput;
use crate::theme::ActiveTheme as _;

const CONTEXT: &str = "DesignsTable";

/// An element id for one part of *this* table. gpui keys element state by the
/// ancestors carrying an id and a table's root carries none, so without the
/// entity two tables would share one scroll offset and one drag.
pub fn part(state: &Entity<TableState>, name: impl Into<SharedString>) -> ElementId {
    ElementId::NamedChild(
        std::sync::Arc::new(ElementId::View(state.entity_id())),
        name.into(),
    )
}

#[allow(
    clippy::derive_partial_eq_without_eq,
    reason = "the actions! expansion derives PartialEq without Eq"
)]
mod action_defs {
    gpui::actions!(designs_table, [Copy]);
}

use action_defs::Copy;

/// Bind the table'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("cmd-c", Copy, Some(CONTEXT)),
        KeyBinding::new("ctrl-c", Copy, Some(CONTEXT)),
    ]);
}

#[derive(IntoElement)]
pub struct Table {
    state: Entity<TableState>,
    height: f32,
    empty_title: SharedString,
    empty_description: SharedString,
    search: bool,
    controls: Vec<AnyElement>,
    on_page: Option<PageHandler>,
}

impl Table {
    #[must_use]
    pub fn new(state: &Entity<TableState>) -> Self {
        Self {
            state: state.clone(),
            height: DEFAULT_HEIGHT,
            empty_title: SharedString::new_static(EMPTY_TITLE),
            empty_description: SharedString::new_static(EMPTY_DESCRIPTION),
            search: false,
            controls: Vec::new(),
            on_page: None,
        }
    }

    /// Mount the paging footer, as the web's `TableFooter` does.
    ///
    /// Server-side, mirroring `manualPagination`: fetching the page `on_page`
    /// names is the caller's, followed by [`TableState::set_rows`] and
    /// [`TableState::set_page_counts`] — without the counts the next arrow has
    /// nothing to go on and stays spent.
    #[must_use]
    pub fn pagination(mut self, on_page: impl Fn(Page, &mut App) + 'static) -> Self {
        self.on_page = Some(std::rc::Rc::new(on_page));
        self
    }

    /// Mount the search box above the header, as the web's `TableOptions`
    /// does when it is given an `onSearch`. It composes with
    /// [`TableState::set_filter`] rather than replacing it: a row has to
    /// survive both.
    #[must_use]
    pub const fn search(mut self, search: bool) -> Self {
        self.search = search;
        self
    }

    /// Extra controls beside the search box — the slot the web fills with
    /// filter dropdowns and a report button (`TableOptions`' children).
    #[must_use]
    pub fn control(mut self, control: impl IntoElement) -> Self {
        self.controls.push(control.into_any_element());
        self
    }

    /// How tall the table sits. The rows virtualize against this, so it is a
    /// number the caller owns rather than one the content decides.
    #[must_use]
    pub const fn height(mut self, height: f32) -> Self {
        self.height = height;
        self
    }

    /// What the [`crate::EmptyState`] says when the filters leave no rows.
    #[must_use]
    pub fn empty(
        mut self,
        title: impl Into<SharedString>,
        description: impl Into<SharedString>,
    ) -> Self {
        self.empty_title = title.into();
        self.empty_description = description.into();
        self
    }
}

impl RenderOnce for Table {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let focus = self.state.read(cx).focus_handle(cx);
        let rows = self.state.read(cx).visible_rows();
        let list = self.state.read(cx).list().clone();
        let copying = self.state.clone();
        let options = options_row(&self.state, self.search, self.controls, cx);
        let frame = div()
            .flex()
            .flex_col()
            .flex_none()
            .h(px(self.height))
            .bg(rgb(cx.theme().shade(BODY_BG)))
            // cells and the range label carry no color of their own, the way
            // the web's inherit the page's. Naming the role here is what gives
            // them one: without it they take gpui's near-black default, which
            // only looks right because a light surface is white.
            .text_color(rgb(cx.theme().colors.text))
            .rounded_t(px(TOP_RADIUS))
            .overflow_hidden()
            .child(if rows == 0 {
                empty_frame(&self.state, self.empty_title, self.empty_description, cx)
            } else {
                filled_frame(&self.state, &list, cx)
            })
            .children(
                self.on_page
                    .as_ref()
                    .map(|on_page| footer::footer(&self.state, on_page, cx)),
            );
        let releasing = self.state;
        div()
            .track_focus(&focus)
            .key_context(CONTEXT)
            .on_action(move |_: &Copy, _win, app| {
                copying.update(app, |table, inner| table.copy_selection(inner));
            })
            // gpui splits the release by where the pointer was, and a row
            // catches its own; these two catch the rest, so no run outlives
            // the button that drew it
            .on_mouse_up(MouseButton::Left, {
                let releasing = releasing.clone();
                move |_: &MouseUpEvent, _win, app| {
                    releasing.update(app, |table, _| table.end_row_drag());
                }
            })
            .on_mouse_up_out(MouseButton::Left, move |_: &MouseUpEvent, _win, app| {
                releasing.update(app, |table, _| table.end_row_drag());
            })
            .flex()
            .flex_col()
            .flex_none()
            .gap(px(SEARCH_ROW_GAP))
            .children(options)
            .child(frame)
    }
}

/// The web's `TableOptions`: the search box, then whatever the caller put
/// beside it. Absent entirely when neither was asked for, so a plain table
/// keeps starting at its header.
fn options_row(
    state: &Entity<TableState>,
    search: bool,
    controls: Vec<AnyElement>,
    cx: &App,
) -> Option<Div> {
    if !search && controls.is_empty() {
        return None;
    }
    let mut row = div().flex().items_center().gap(px(SEARCH_ROW_GAP));
    if search {
        row = row.child(
            div()
                .flex_none()
                .w(px(SEARCH_MIN_WIDTH))
                .child(TextInput::new(state.read(cx).search_box()).leading_glyph(search::GLYPH)),
        );
    }
    Some(row.children(controls))
}

/// The header and whatever follows it, sharing one sideways offset so a
/// column heading never parts from the cells under it.
fn across_scroll(state: &Entity<TableState>, below: AnyElement, cx: &App) -> Stateful<Div> {
    div()
        .id(part(state, "across"))
        .flex()
        .flex_col()
        .size_full()
        .overflow_x_scroll()
        .map(keep_the_wheel_off_x)
        .track_scroll(state.read(cx).across_handle())
        .child(header::header_row(state, cx))
        .child(below)
}

/// The rows, with both bars painted over the frame. The bars sit outside the
/// sideways scroll, or they would travel with the content and slide out of
/// reach.
fn filled_frame(state: &Entity<TableState>, list: &ListState, cx: &App) -> AnyElement {
    let across = state.read(cx).across_handle().clone();
    let width = state.read(cx).row_width();
    div()
        .relative()
        .flex_grow(1.0)
        .min_h_0()
        .child(across_scroll(
            state,
            body_slot(state, list, width).into_any_element(),
            cx,
        ))
        // the web drops the track behind a table's bar to transparent
        .child(
            Scrollbar::rows(part(state, "down-bar"), list)
                .track_transparent()
                .reserve_corner(),
        )
        .child(
            Scrollbar::horizontal(part(state, "across-bar"), &across)
                .track_transparent()
                .reserve_corner(),
        )
        .into_any_element()
}

/// Leave the sideways offset to the bar and to a sideways gesture, never to a
/// wheel. A container scrolling one axis takes the other axis's delta as its
/// own, and a wheel reports nothing but vertical.
fn keep_the_wheel_off_x(mut across: Stateful<Div>) -> Stateful<Div> {
    across.style().restrict_scroll_to_axis = Some(true);
    across
}

/// The virtualized rows, sized to the columns rather than to the frame:
/// `size_full` would end the rows mid-table while the header spanned the
/// columns. Each row is measured, so one whose cells wrap stands taller, as an
/// HTML row does.
fn body_slot(state: &Entity<TableState>, list: &ListState, width: f32) -> Div {
    let listing = state.clone();
    div().flex_grow(1.0).min_h_0().child(
        gpui::list(list.clone(), move |position, _win, app| {
            body::body_row(&listing, position, width, app).into_any_element()
        })
        .h_full()
        .w(px(width))
        .min_w(relative(1.0)),
    )
}

/// The no-rows case still shows its headers, because a reader needs to know
/// what the table would have held — all of them, which is why this scrolls
/// sideways too.
fn empty_frame(
    state: &Entity<TableState>,
    title: SharedString,
    description: SharedString,
    cx: &App,
) -> AnyElement {
    let across = state.read(cx).across_handle().clone();
    div()
        .relative()
        .flex_grow(1.0)
        .min_h_0()
        .child(across_scroll(state, gpui::Empty.into_any_element(), cx))
        .child(empty_slot(title, description))
        // no vertical bar here, so this one keeps its whole track
        .child(Scrollbar::horizontal(part(state, "across-bar"), &across).track_transparent())
        .into_any_element()
}

fn empty_slot(title: SharedString, description: SharedString) -> gpui::AnyElement {
    div()
        // pinned to the frame, not the sideways scroll, so it centers on what is shown
        .absolute()
        .top(px(HEADER_HEIGHT))
        .left_0()
        .right_0()
        .bottom_0()
        // a row would hand the state's own `mx_auto` all the free space
        .flex()
        .flex_col()
        .overflow_hidden()
        .items_center()
        .justify_center()
        .child(
            EmptyState::new(title, description)
                .size(fluidattacks_designs_domain::empty_state::Size::Sm),
        )
        .into_any_element()
}