fluidattacks-designs 0.11.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
//! One body row, and the cells it shows.
//!
//! Rows are addressed by their place in the current view, which the state maps
//! back to the row the app handed over.

use fluidattacks_designs_domain::table::{
    marks_selection, row_fill, tinted_text, RowFill, BORDER, CELL_DIVIDER, CELL_PADDING_X,
    ROW_HEIGHT, ROW_HOVER_BG, SELECTED_MARKER, SELECTED_MARKER_PX, SELECTED_TINT,
    SELECTED_TINT_ALPHA, SELECTION_PADDING, SELECTION_WIDTH, TEXT_SIZE,
};
use fluidattacks_designs_domain::typography::FONT_PRIMARY;
use gpui::prelude::FluentBuilder as _;
use gpui::{
    div, px, relative, rgb, rgba, App, Div, Entity, InteractiveElement as _, MouseButton,
    MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, SharedString, Stateful,
    Styled as _,
};

use crate::checkbox::Checkbox;
use crate::table::state::TableState;
use crate::theme::{ActiveTheme as _, Theme};

pub fn body_row(
    state: &Entity<TableState>,
    position: usize,
    width: f32,
    cx: &App,
) -> Stateful<Div> {
    let id = crate::table::part(state, format!("tr-{position}"));
    let read = state.read(cx);
    let theme = cx.theme();
    let hover_bg = theme.shade(ROW_HOVER_BG);
    let Some(&row) = read.view().get(position) else {
        // a row the view no longer holds keeps the least height a row has
        return div().id(id).min_h(px(ROW_HEIGHT));
    };
    let checked = read.is_selected(row);
    let selected = marks_selection(checked, read.checkboxes());
    let fill = row_fill(read.row_color(row), selected);
    let checkbox = read
        .checkboxes()
        .then(|| selection_cell(state, position, checked, theme));
    let cells = shown_cells(read, row, theme);
    div()
        .id(id)
        .flex()
        .flex_none()
        // the web's `width: 100%; min-width: 100%`: fill the frame, and
        // overflow into the sideways scroll rather than being squeezed
        .w(px(width))
        .min_w(relative(1.0))
        // a minimum, as an HTML row's height is: the tallest cell sets it, and
        // the others stretch to it so their dividers and tints run full height
        .min_h(px(ROW_HEIGHT))
        .border_1()
        .border_color(rgb(theme.shade(BORDER)))
        .map(|item| match fill {
            RowFill::Tint(color) => {
                // a tint is a fill on the table's own surface, so it is
                // re-anchored like any other; the ink then follows the tint
                // rather than the surface, since an app may hand over a shade
                // the mirror leaves alone
                let tint = theme.shade(color);
                item.bg(rgb(tint)).text_color(rgb(tinted_text(tint)))
            }
            RowFill::Selection => item.bg(rgba(
                theme.shade(SELECTED_TINT) << 8 | u32::from(SELECTED_TINT_ALPHA),
            )),
            RowFill::Plain => item,
        })
        // the marker is a border, not a fill, so a tinted row keeps it
        .when(selected, |item| {
            item.border_l(px(SELECTED_MARKER_PX))
                .border_color(rgb(theme.shade(SELECTED_MARKER)))
        })
        .when(fill.hovers(), |item| {
            item.hover(move |hovered| hovered.bg(rgb(hover_bg)))
        })
        .children(checkbox)
        .children(cells)
        .map(|item| selects(item, state, position))
}

fn selection_cell(
    state: &Entity<TableState>,
    position: usize,
    checked: bool,
    theme: &Theme,
) -> Div {
    let toggling = state.clone();
    div()
        .flex()
        .flex_none()
        .items_center()
        .w(px(SELECTION_WIDTH))
        .pl(px(SELECTION_PADDING))
        .border_r_1()
        .border_color(rgb(theme.shade(CELL_DIVIDER)))
        // reaching the row would replace the selection before the box toggles
        .on_mouse_down(MouseButton::Left, |_, _win, app| app.stop_propagation())
        .child(
            Checkbox::new(
                crate::table::part(state, format!("pick-{position}")),
                checked,
            )
            .on_change(move |_, _win, app| {
                toggling.update(app, |table, inner| table.toggle_row(position, inner));
            }),
        )
}

/// Press, drag and release over one row.
fn selects(row: Stateful<Div>, state: &Entity<TableState>, position: usize) -> Stateful<Div> {
    let pressing = state.clone();
    let dragging = state.clone();
    let releasing = state.clone();
    row.on_mouse_down(
        MouseButton::Left,
        move |event: &MouseDownEvent, _win, app| {
            let held = event.modifiers;
            pressing.update(app, |table, inner| {
                table.press_row(position, held.shift, held.secondary(), inner);
            });
        },
    )
    .on_mouse_move(move |event: &MouseMoveEvent, _win, app| {
        let held = event.dragging();
        dragging.update(app, |table, inner| {
            table.drag_over_row(position, held, inner);
        });
    })
    .on_mouse_up(MouseButton::Left, move |_: &MouseUpEvent, _win, app| {
        releasing.update(app, |table, _| table.end_row_drag());
    })
}

fn shown_cells(read: &TableState, row: usize, theme: &Theme) -> Vec<Div> {
    read.order()
        .iter()
        .enumerate()
        .filter_map(|(place, &column)| {
            let entry = read.columns().get(column)?;
            entry.visible.then(|| {
                cell(
                    read.cell(row, column),
                    entry.width,
                    read.cell_color(row, column),
                    place == 0,
                    theme,
                )
            })
        })
        .collect()
}

/// As the web's `white-space: pre-line` does, the text wraps within the column
/// and keeps the line breaks it was handed; runs of spaces are not collapsed.
fn cell(value: &str, width: f32, tint: Option<u32>, first: bool, theme: &Theme) -> Div {
    div()
        .flex()
        .flex_none()
        .items_center()
        .w(px(width))
        .px(px(CELL_PADDING_X))
        .overflow_hidden()
        .when(first, |item| {
            item.border_r_1()
                .border_color(rgb(theme.shade(CELL_DIVIDER)))
        })
        .when_some(tint, |item, color| {
            let painted = theme.shade(color);
            item.bg(rgb(painted)).text_color(rgb(tinted_text(painted)))
        })
        .font_family(FONT_PRIMARY)
        .text_size(px(TEXT_SIZE.px()))
        .line_height(px(TEXT_SIZE.line_height_px()))
        // text asks for its whole line as its least width; only a box allowed
        // below that lets it wrap at the column's edge
        .child(
            div()
                .flex_1()
                .min_w_0()
                .child(SharedString::from(value.to_owned())),
        )
}