fluidattacks-designs 0.11.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
//! The header row: one cell per shown column, each a sort control, a resize
//! grip and a reorder handle at once.

use fluidattacks_designs_domain::icon::Style as IconStyle;
use fluidattacks_designs_domain::table::{
    Sort, BORDER_PX, CELL_PADDING_X, HEADER_BG, HEADER_DIVIDER, HEADER_DIVIDER_BESIDE_SELECTION,
    HEADER_HEIGHT, HEADER_HOVER_BG, HEADER_TEXT, HEADER_TEXT_SIZE, HEADER_WEIGHT, RESIZER_BORDER,
    RESIZER_WIDTH, SELECTION_PADDING, SELECTION_WIDTH, SORT_ASC_ICON, SORT_DESC_ICON, SORT_GLYPH,
    SORT_ICON_COLOR, SORT_ICON_GAP, SORT_ICON_HEIGHT, SORT_ICON_WIDTH,
};
use fluidattacks_designs_domain::typography::FONT_PRIMARY;
use gpui::prelude::FluentBuilder as _;
use gpui::{
    div, px, relative, rgb, App, AppContext as _, CursorStyle, Div, DragMoveEvent, Empty, Entity,
    FontWeight, InteractiveElement as _, MouseButton, MouseDownEvent, ParentElement as _,
    SharedString, Stateful, StatefulInteractiveElement as _, Styled as _,
};

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

/// Marks a drag as a resize rather than a reorder. Which column is moving
/// lives in the state, recorded when the grip was pressed, because a move
/// event says only where the pointer is.
#[derive(Clone)]
pub struct Resize;

/// Dragging a header cell drops it into another column's place.
#[derive(Clone)]
pub struct Reorder {
    position: usize,
}

/// What one header cell needs to paint itself.
struct Cell {
    position: usize,
    column: usize,
    label: SharedString,
    width: f32,
    sort: Sort,
    divider: u32,
}

pub fn header_row(state: &Entity<TableState>, cx: &App) -> Div {
    let read = state.read(cx);
    let theme = cx.theme();
    let (sorted, sort) = read.sort();
    let divider = if read.checkboxes() {
        HEADER_DIVIDER_BESIDE_SELECTION
    } else {
        HEADER_DIVIDER
    };
    let shown: Vec<Cell> = read
        .order()
        .iter()
        .enumerate()
        .filter_map(|(position, &column)| {
            let entry = read.columns().get(column)?;
            entry.visible.then(|| Cell {
                position,
                column,
                label: entry.header.clone(),
                width: entry.width,
                sort: if column == sorted { sort } else { Sort::None },
                divider,
            })
        })
        .collect();
    // the web's `width: 100%; min-width: 100%`: fill the frame, and overflow
    // into the sideways scroll rather than squeezing the chosen widths
    let mut row = div()
        .flex()
        .flex_none()
        .w(px(read.row_width()))
        .min_w(relative(1.0))
        .bg(rgb(theme.shade(HEADER_BG)))
        .children(
            read.checkboxes()
                .then(|| selection_head(state, read, theme)),
        );
    for cell in shown {
        row = row.child(header_cell(state, cell, theme));
    }
    row
}

fn header_cell(state: &Entity<TableState>, cell: Cell, theme: &Theme) -> Stateful<Div> {
    let hover_bg = theme.shade(HEADER_HOVER_BG);
    let sorting = state.clone();
    let resizing = state.clone();
    let dropping = state.clone();
    let Cell {
        position,
        column,
        label,
        width,
        sort,
        divider,
    } = cell;
    div()
        .id(crate::table::part(state, format!("th-{column}")))
        .relative()
        .flex()
        .flex_none()
        .items_center()
        .justify_between()
        .gap(px(SORT_ICON_GAP))
        .w(px(width))
        .h(px(HEADER_HEIGHT))
        .px(px(CELL_PADDING_X))
        .when(position == 0, |head| {
            head.border_r_1().border_color(rgb(theme.shade(divider)))
        })
        .hover(move |head| head.bg(rgb(hover_bg)))
        .cursor_pointer()
        .child(label_text(&label, theme))
        .child(sort_indicator(sort, theme))
        .child(resizer(state, column, theme))
        .on_drag(Reorder { position }, |_, _, _, app| app.new(|_| Empty))
        .on_drop(move |dragged: &Reorder, _window, app| {
            let from = dragged.position;
            dropping.update(app, |table, app| table.move_column(from, position, app));
        })
        .on_drag_move(move |event: &DragMoveEvent<Resize>, _window, app| {
            let reached = f32::from(event.event.position.x);
            resizing.update(app, |table, inner| table.drag_resize(reached, inner));
        })
        .on_click(move |_, _window, app| {
            sorting.update(app, |table, app| table.cycle_sort(column, app));
        })
}

fn selection_head(state: &Entity<TableState>, read: &TableState, theme: &Theme) -> Div {
    let toggling = state.clone();
    div()
        .flex()
        .flex_none()
        .items_center()
        .w(px(SELECTION_WIDTH))
        .h(px(HEADER_HEIGHT))
        .pl(px(SELECTION_PADDING))
        .border_r_1()
        .border_color(rgb(theme.shade(HEADER_DIVIDER)))
        .when(read.multi(), |head| {
            head.child(
                Checkbox::new(
                    crate::table::part(state, "pick-shown"),
                    read.all_shown_selected(),
                )
                .on_change(move |_, _win, app| {
                    toggling.update(app, TableState::toggle_shown);
                }),
            )
        })
}

fn label_text(label: &SharedString, theme: &Theme) -> Div {
    div()
        .overflow_hidden()
        .font_family(FONT_PRIMARY)
        .text_size(px(HEADER_TEXT_SIZE.px()))
        .line_height(px(HEADER_TEXT_SIZE.line_height_px()))
        .font_weight(FontWeight(f32::from(HEADER_WEIGHT.value())))
        .text_color(rgb(theme.shade(HEADER_TEXT)))
        .child(label.clone())
}

/// Both carets, always: the web fades the one the order rules out rather than
/// removing it, so the header never reflows on a click.
fn sort_indicator(sort: Sort, theme: &Theme) -> Div {
    let (up, down) = sort.caret_opacity();
    div()
        .flex()
        .flex_col()
        .flex_none()
        .items_center()
        .justify_center()
        .gap(px(SORT_ICON_GAP))
        .w(px(SORT_ICON_WIDTH))
        .h(px(SORT_ICON_HEIGHT))
        .child(caret(SORT_ASC_ICON, up, theme))
        .child(caret(SORT_DESC_ICON, down, theme))
}

fn caret(glyph: &'static str, opacity: f32, theme: &Theme) -> Icon {
    let mut color = rgb(theme.shade(SORT_ICON_COLOR));
    color.alpha = opacity;
    Icon::new(glyph)
        .style(IconStyle::Solid)
        .size(SORT_GLYPH)
        .color_rgba(color)
}

/// The grab strip on the cell's right edge. It paints nothing until hovered,
/// which is when the web shows its line.
fn resizer(state: &Entity<TableState>, column: usize, theme: &Theme) -> Stateful<Div> {
    let line = theme.shade(RESIZER_BORDER);
    let starting = state.clone();
    div()
        .id(crate::table::part(state, format!("grip-{column}")))
        .absolute()
        .top_0()
        // pulled out by the hairline it sits on, so the grip's own line lands
        // exactly on the cell's edge rather than a pixel inside it
        .right(px(-BORDER_PX))
        .h_full()
        .w(px(RESIZER_WIDTH))
        .cursor(CursorStyle::ResizeLeftRight)
        .border_r_1()
        .border_color(gpui::transparent_black())
        .hover(move |grip| grip.border_color(rgb(line)))
        // the press is where both the column and its current width are known;
        // every move after it only reports a pointer
        .on_mouse_down(
            MouseButton::Left,
            move |event: &MouseDownEvent, _win, app| {
                // the grip covers most of a column at its minimum width, so a
                // press that reached the header cell underneath would sort the
                // column every time someone went to resize it
                app.stop_propagation();
                let pressed = f32::from(event.position.x);
                starting.update(app, |table, _| table.begin_resize(column, pressed));
            },
        )
        .on_drag(Resize, |_, _, _, app| app.new(|_| Empty))
}