gpui-box-kit 0.1.0

GPUI Box Kit design-system components and interaction primitives
Documentation
//! In-page navigation over caller-owned document anchors.
//!
//! `AnchorList` reports navigation intent; it never changes the active anchor.
//! Overflow is declarative and truthful: anchors move only when both a cut and
//! a caller-owned menu are supplied.

use std::rc::Rc;

use gpui::{
    App, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
    StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TypeScale};

use crate::foundation::direction::{ActiveDirection, DirectionalExt};
use crate::foundation::stepping::bounded_step;
use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, text};
use crate::overlay::{Menu, MenuItem};
use crate::strings::{ActiveStrings, StringKey};

type NavigateHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;

/// One caller-owned document destination, identified independently of order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Anchor {
    id: SharedString,
    label: SharedString,
    disabled: bool,
}

impl Anchor {
    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
        Self {
            id: id.into(),
            label: label.into(),
            disabled: false,
        }
    }

    pub fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }

    pub fn id(&self) -> &SharedString {
        &self.id
    }

    pub fn label(&self) -> &SharedString {
        &self.label
    }

    pub fn is_disabled(&self) -> bool {
        self.disabled
    }

    fn menu_row(&self, active: bool) -> MenuItem {
        if active {
            MenuItem::check(self.id.clone(), self.label.clone(), true).disabled(self.disabled)
        } else {
            MenuItem::command(self.id.clone(), self.label.clone()).disabled(self.disabled)
        }
    }
}

/// A horizontal reading-order list of links to sections in the current page.
#[derive(IntoElement)]
pub struct AnchorList {
    ident: Ident,
    anchors: Vec<Anchor>,
    active: Option<SharedString>,
    disabled: bool,
    size: ControlSize,
    on_navigate: Option<NavigateHandler>,
    overflow_after: Option<usize>,
    overflow_menu: Option<Entity<Menu>>,
}

impl std::fmt::Debug for AnchorList {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("AnchorList")
            .field("ident", &self.ident)
            .field("anchors", &self.anchors.len())
            .field("active", &self.active)
            .field("disabled", &self.disabled)
            .field("overflow_after", &self.overflow_after)
            .finish()
    }
}

impl AnchorList {
    pub fn new(ident: impl Into<Ident>) -> Self {
        Self {
            ident: ident.into(),
            anchors: Vec::new(),
            active: None,
            disabled: false,
            size: ControlSize::Md,
            on_navigate: None,
            overflow_after: None,
            overflow_menu: None,
        }
    }

    pub fn anchor(mut self, anchor: Anchor) -> Self {
        self.anchors.push(anchor);
        self
    }

    pub fn anchors(mut self, anchors: impl IntoIterator<Item = Anchor>) -> Self {
        self.anchors.extend(anchors);
        self
    }

    pub fn active(mut self, id: impl Into<SharedString>) -> Self {
        self.active = Some(id.into());
        self
    }

    pub fn on_navigate(
        mut self,
        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
    ) -> Self {
        self.on_navigate = Some(Rc::new(handler));
        self
    }

    /// Keeps `count` anchors inline and moves the remainder into the supplied
    /// menu. Without a menu this declaration hides nothing.
    pub fn overflow_after(mut self, count: usize) -> Self {
        self.overflow_after = Some(count);
        self
    }

    /// Supplies the caller-owned menu that can hold overflowed anchors.
    /// The menu reports an overflowed anchor through
    /// [`crate::overlay::MenuEvent::Invoked`] with the same stable id that
    /// `on_navigate` reports for inline and keyboard navigation.
    pub fn overflow_menu(mut self, menu: Entity<Menu>) -> Self {
        self.overflow_menu = Some(menu);
        self
    }

    fn cut(&self) -> usize {
        match (
            self.disabled,
            self.overflow_after,
            self.overflow_menu.is_some(),
        ) {
            (false, Some(cut), true) => cut,
            _ => usize::MAX,
        }
    }

    fn anchor_element(&self, anchor: &Anchor, cx: &mut App) -> impl IntoElement {
        let theme = cx.theme().clone();
        let metrics = theme.control.get(self.size);
        let ident = self.ident.child(anchor.id.as_ref());
        let active = self.active.as_ref() == Some(&anchor.id);
        let disabled = self.disabled || anchor.disabled;
        let actionable = !disabled && self.on_navigate.is_some();
        let color = if disabled {
            theme.colors.text_faint
        } else if active {
            theme.colors.text
        } else {
            theme.colors.text_muted
        };

        let mut element = div()
            .id(ident.element_id())
            .flex_none()
            .h(px(metrics.height))
            .px(px(metrics.padding_x))
            .flex()
            .items_center()
            .rounded(px(theme.radii.control))
            .child(
                text(&theme, TypeScale::Label, anchor.label.clone())
                    .text_size(px(metrics.font_size))
                    .text_color(color),
            )
            .when(active, |element| element.bg(theme.colors.selected))
            .when(disabled, |element| element.opacity(theme.opacity.disabled))
            .when(actionable, |element| {
                element
                    .cursor_pointer()
                    .tab_index(0)
                    .pressable(cx)
                    .hover(|style| style.bg(theme.colors.hover))
                    .focus_ring(&theme)
            });

        if let (true, Some(handler)) = (actionable, self.on_navigate.clone()) {
            let id = anchor.id.clone();
            let clicked = id.clone();
            let click = Rc::clone(&handler);
            element = element
                .on_click(move |_, window, cx| click(clicked.clone(), window, cx))
                .on_key_down(move |event, window, cx| {
                    if matches!(event.keystroke.key.as_str(), "enter" | "space") {
                        handler(id.clone(), window, cx);
                        cx.stop_propagation();
                    }
                });
        }

        element.semantic_in(
            cx,
            NodeSpec::new(ident.semantic_id(), Role::Link)
                .parent(self.ident.semantic_id())
                .selected(active)
                .disabled(disabled)
                .text(anchor.label.clone()),
        )
    }
}

impl Disableable for AnchorList {
    fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }
}

impl Sizable for AnchorList {
    fn control_size(mut self, size: ControlSize) -> Self {
        self.size = size;
        self
    }
}

impl RenderOnce for AnchorList {
    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = cx.theme().clone();
        let direction = cx.layout_direction();
        if self.disabled
            && let Some(menu) = self.overflow_menu.as_ref()
            && menu.read(cx).is_open()
        {
            menu.update(cx, |menu, cx| menu.close(window, cx));
        }
        let mut strip = div()
            .id(self.ident.element_id())
            .row_reading(direction)
            .items_center()
            .flex_wrap()
            .gap(px(theme.space(Space::Xs)));

        if let (false, Some(handler)) = (self.disabled, self.on_navigate.clone()) {
            let anchors = self.anchors.clone();
            let active = self.active.clone();
            strip = strip.on_key_down(move |event, window, cx| {
                let key = event.keystroke.key.as_str();
                let next = match direction.arrow_step(key) {
                    Some(delta) => step(&anchors, active.as_ref(), delta as isize),
                    None => match key {
                        "home" => edge(&anchors, -1),
                        "end" => edge(&anchors, 1),
                        _ => return,
                    },
                };
                let Some(next) = next.filter(|next| Some(next) != active.as_ref()) else {
                    return;
                };
                handler(next, window, cx);
                cx.stop_propagation();
            });
        }

        let cut = self.cut();
        let mut hidden = Vec::new();
        for (index, anchor) in self.anchors.iter().enumerate() {
            if index >= cut {
                hidden.push(anchor.menu_row(self.active.as_ref() == Some(&anchor.id)));
            } else {
                strip = strip.child(self.anchor_element(anchor, cx));
            }
        }

        let hidden_count = hidden.len();
        let overflow = self
            .overflow_menu
            .clone()
            .filter(|_| hidden_count > 0)
            .map(|menu| {
                if menu.read(cx).offered() != hidden.as_slice() {
                    menu.update(cx, |menu, cx| menu.set_items(hidden, cx));
                }
                let ident = self.ident.child("overflow");
                div().flex().flex_none().child(menu).semantic_in(
                    cx,
                    NodeSpec::new(ident.semantic_id(), Role::Group)
                        .parent(self.ident.semantic_id())
                        .text(cx.strings().text(StringKey::AnchorMoreSections))
                        .value(hidden_count.to_string()),
                )
            });

        strip.children(overflow).semantic_in(
            cx,
            NodeSpec::new(self.ident.semantic_id(), Role::List)
                .disabled(self.disabled)
                .value(self.anchors.len().to_string()),
        )
    }
}

fn step(anchors: &[Anchor], active: Option<&SharedString>, delta: isize) -> Option<SharedString> {
    let from = active.and_then(|id| anchors.iter().position(|anchor| &anchor.id == id));
    bounded_step(anchors.len(), from, delta, |index| anchors[index].disabled)
        .map(|index| anchors[index].id.clone())
}

fn edge(anchors: &[Anchor], delta: isize) -> Option<SharedString> {
    step(anchors, None, -delta)
}