fluidattacks-designs 0.11.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
//! The page-size chooser: the web's `PaginationDropdown`, not its `Select`.

use fluidattacks_designs_domain::icon::Style as IconStyle;
use fluidattacks_designs_domain::table::{
    offered_sizes, PAGE_MENU_OFFSET, PAGE_MENU_RADIUS, PAGE_MENU_SHADOW, PAGE_OPTION_CHOSEN_BG,
    PAGE_OPTION_CHOSEN_INK, PAGE_OPTION_WIDTH, PAGE_SIZE_BG, PAGE_SIZE_BORDER,
    PAGE_SIZE_CLOSED_ICON, PAGE_SIZE_GLYPH, PAGE_SIZE_HOVER_BG, PAGE_SIZE_ICON, PAGE_SIZE_INK,
    PAGE_SIZE_MIN_HEIGHT, PAGE_SIZE_OPEN_ICON, PAGE_SIZE_PADDING, PAGE_SIZE_RADIUS, PAGE_SIZE_TEXT,
};
use fluidattacks_designs_domain::typography::FONT_PRIMARY;
use gpui::prelude::FluentBuilder as _;
use gpui::{
    anchored, deferred, div, point, px, rgb, Anchor, App, Div, Entity, InteractiveElement as _,
    MouseButton, ParentElement as _, SharedString, Stateful, StatefulInteractiveElement as _,
    Styled as _,
};

use crate::icon::Icon;
use crate::table::state::{PageHandler, TableState};
use crate::theme::{ActiveTheme as _, Theme};
use crate::widget::elevation;

pub fn page_size(state: &Entity<TableState>, on_page: &PageHandler, cx: &App) -> Div {
    let read = state.read(cx);
    let open = read.sizes_open();
    let closing = state.clone();
    div()
        .relative()
        .flex_none()
        .child(size_box(state, read.page().size, open, cx.theme()))
        .when(open, |el| {
            el.child(deferred(hung(menu(state, on_page, cx))))
        })
        .on_mouse_down_out(move |_, _win, app| {
            closing.update(app, TableState::close_sizes);
        })
}

fn size_box(state: &Entity<TableState>, size: usize, open: bool, theme: &Theme) -> Div {
    let toggling = state.clone();
    let hover_bg = theme.shade(PAGE_SIZE_HOVER_BG);
    div()
        .flex()
        .items_center()
        .min_h(px(PAGE_SIZE_MIN_HEIGHT))
        .px(px(PAGE_SIZE_PADDING))
        .bg(rgb(theme.shade(PAGE_SIZE_BG)))
        .border_1()
        .border_color(rgb(theme.shade(PAGE_SIZE_BORDER)))
        .rounded(px(PAGE_SIZE_RADIUS))
        .child(number(size, PAGE_SIZE_INK, theme).pr(px(PAGE_SIZE_PADDING)))
        .child(
            div()
                .id(crate::table::part(state, "page-size-toggle"))
                .flex()
                .p(px(PAGE_SIZE_PADDING))
                .rounded(px(PAGE_SIZE_RADIUS))
                .cursor_pointer()
                .hover(move |hovered| hovered.bg(rgb(hover_bg)))
                .on_click(move |_, _win, app| {
                    toggling.update(app, TableState::toggle_sizes);
                })
                .child(
                    Icon::new(if open {
                        PAGE_SIZE_OPEN_ICON
                    } else {
                        PAGE_SIZE_CLOSED_ICON
                    })
                    .style(IconStyle::Light)
                    .size(PAGE_SIZE_GLYPH)
                    .color(theme.shade(PAGE_SIZE_ICON)),
                ),
        )
}

/// A sizeless strip pins the menu's corner to the box's, the web's `top-end`.
fn hung(menu: Div) -> Div {
    div().absolute().top_0().right_0().size_0().child(
        anchored()
            .anchor(Anchor::BottomRight)
            .offset(point(px(0.0), px(-PAGE_MENU_OFFSET)))
            .child(menu),
    )
}

fn menu(state: &Entity<TableState>, on_page: &PageHandler, cx: &App) -> Div {
    let read = state.read(cx);
    let theme = cx.theme();
    let current = read.page().size;
    div()
        .flex()
        .flex_col()
        .p(px(PAGE_SIZE_PADDING))
        .bg(rgb(theme.shade(PAGE_SIZE_BG)))
        .border_1()
        .border_color(rgb(theme.shade(PAGE_SIZE_BORDER)))
        .rounded(px(PAGE_MENU_RADIUS))
        .shadow(elevation(PAGE_MENU_SHADOW))
        .occlude()
        .children(
            offered_sizes(read.page_counts(), current)
                .map(|size| option(state, on_page, size, size == current, theme)),
        )
}

fn option(
    state: &Entity<TableState>,
    on_page: &PageHandler,
    size: usize,
    chosen: bool,
    theme: &Theme,
) -> Stateful<Div> {
    let choosing = state.clone();
    let handler = on_page.clone();
    let (fill, ink) = if chosen {
        (Some(PAGE_OPTION_CHOSEN_BG), PAGE_OPTION_CHOSEN_INK)
    } else {
        (None, PAGE_SIZE_INK)
    };
    let hover_bg = theme.shade(fill.unwrap_or(PAGE_SIZE_HOVER_BG));
    div()
        .id(crate::table::part(state, format!("page-size-{size}")))
        .flex()
        .justify_center()
        .w(px(PAGE_OPTION_WIDTH))
        .p(px(PAGE_SIZE_PADDING))
        .rounded(px(PAGE_SIZE_RADIUS))
        .cursor_pointer()
        .when_some(fill, |el, color| el.bg(rgb(theme.shade(color))))
        .hover(move |hovered| hovered.bg(rgb(hover_bg)))
        .child(number(size, ink, theme))
        // the menu lies outside the box's hitbox, so a click would never land
        .on_mouse_down(MouseButton::Left, move |_, _win, app| {
            let page = choosing.update(app, |table, inner| {
                table.close_sizes(inner);
                table.set_page_size(size, inner);
                table.page()
            });
            handler(page, app);
        })
}

fn number(size: usize, ink: u32, theme: &Theme) -> Div {
    div()
        .font_family(FONT_PRIMARY)
        .text_size(px(PAGE_SIZE_TEXT.px()))
        .line_height(px(PAGE_SIZE_TEXT.line_height_px()))
        .text_color(rgb(theme.shade(ink)))
        .child(SharedString::from(size.to_string()))
}