bezel-ui 0.1.12

SwiftUI-flavored components for gpui — popovers, menus, buttons, toggles, and loaders
Documentation
//! The page skeleton — column, header, section cards, rows — the shared
//! rhythm of the settings pages (the reference settings.devices.tsx /
//! settings.agents.tsx / settings.archived.tsx).
//!
//! A catalog trait, like every widget group: import it to unlock
//! `theme.group_box()`, `theme.page_header(..)`, `theme.card_row(..)`.
//! Extends [`ThemeExt`], which carries the environment; the `Theme` impl is
//! empty because every method below has a default. Not object-safe — its
//! methods are statically dispatched onto [`Theme`].

use crate::stack;
use gpui::{AnyElement, Div, SharedString, div, prelude::*, px};
use icons::Icon;
use theme::{TextStyle, Theme, ThemeExt, Typeset};

/// Default height of an [`Scaffolding::option_card`] preview frame.
pub const OPTION_CARD_HEIGHT: f32 = 148.0;
/// Corner radius of the preview frame.
///
/// Public because the preview has to round *itself* to this. gpui content masks
/// are axis-aligned rectangles, so `overflow_hidden` on the frame clips to its
/// bounding box and not to its corner radius — a preview that paints its own
/// background will square off the corners and cover the frame's border with it.
pub const OPTION_CARD_RADIUS: f32 = 10.0;
/// A card row's horizontal padding — `../desktop`'s `Row`: `px-4`.
const ROW_INSET: f32 = 16.0;
/// The leading symbol's size.
const ROW_ICON: f32 = 16.0;
/// Between a row's leading symbol and its text.
const ROW_GAP: f32 = Theme::SPACE;
/// A card row's vertical padding. Measured 2026-09-01 off the macOS General
/// pane: a ~40pt row over `TextStyle::Body`'s 21pt line box.
const ROW_PAD_Y: f32 = 10.0;
/// Clear space between the frame and the selection ring.
const RING_GAP: f32 = 2.0;
/// Thickness of the selection ring.
const RING_WIDTH: f32 = 2.0;

pub trait Scaffolding: ThemeExt {
    /// Centered page column: `mx-auto w-full max-w-3xl px-6 pb-16 pt-8`.
    fn page_column(&self) -> Div {
        div()
            .w_full()
            .max_w(px(768.0))
            .mx_auto()
            .px(px(24.0))
            .pt(px(32.0))
            .pb(px(64.0))
            .flex()
            .flex_col()
    }

    /// Page headline row: `flex items-baseline gap-2.5` — title and count
    /// sharing a baseline (the reference settings.devices.tsx).
    fn page_header(&self, title: impl Into<SharedString>, count: Option<usize>) -> Div {
        let theme = self.theme();
        div()
            .flex()
            .flex_row()
            .items_baseline()
            .gap(px(10.0))
            .child(
                div()
                    .text_style(TextStyle::Title2)
                    .text_color(theme.text)
                    .child(title.into()),
            )
            .when_some(count, |el, count| {
                el.child(
                    div()
                        .text_style(TextStyle::Body)
                        .text_color(theme.text_muted.opacity(0.7))
                        .child(format!("{count}")),
                )
            })
    }

    /// Subtitle under the headline: `mt-1 text-muted-foreground`.
    fn page_subtitle(&self, copy: impl Into<SharedString>) -> Div {
        let theme = self.theme();
        div()
            .mt(px(4.0))
            .text_style(TextStyle::Body)
            .text_color(theme.text_muted)
            .child(copy.into())
    }

    /// Small label above a group of controls (`font-medium`) — the
    /// "Theme" caption over a picker, not a page headline.
    fn field_label(&self, label: impl Into<SharedString>) -> Div {
        let theme = self.theme();
        div()
            .text_style(TextStyle::Body)
            .font_weight(gpui::FontWeight::MEDIUM)
            .text_color(theme.text)
            .child(label.into())
    }

    /// A row of equally-sized preview cards for picking one of N *visual* options.
    ///
    /// Deliberately knows nothing about themes: the caller supplies each preview as
    /// an arbitrary element and picks however many cards it wants, so the same
    /// control works for a density picker, a layout picker or anything else where
    /// the choice is easier to show than to describe. Pair with [`Self::option_card`].
    fn option_card_row(&self) -> Div {
        div().flex().flex_row().items_start().gap(px(16.0)).w_full()
    }

    /// One card in an [`Self::option_card_row`]: a fixed-height preview frame
    /// that carries the selection ring, with a caption underneath.
    ///
    /// `preview` fills the frame and **must round its own corners** to
    /// [`OPTION_CARD_RADIUS`] if it paints a background — see that constant.
    ///
    /// Returns a plain `Div` like the rest of this module — the caller adds
    /// `.id(..)` and `.on_click(..)`, so selection behaviour stays with the
    /// page that owns the state.
    fn option_card(
        &self,
        label: impl Into<SharedString>,
        selected: bool,
        preview: AnyElement,
    ) -> Div {
        let theme = self.theme();
        let frame = div()
            .h(px(OPTION_CARD_HEIGHT))
            .w_full()
            .rounded(px(OPTION_CARD_RADIUS))
            .overflow_hidden()
            .border_1()
            .border_color(theme.border)
            .child(preview);

        // The ring is a *wrapper border*, not a spread shadow. A shadow's spread
        // grows the rectangle without growing its corner radius, so the halo's
        // corners tighten relative to the frame's and the two visibly drift apart by
        // a pixel at each rounded corner. Concentric borders can't do that: each
        // element rounds itself, and the outer radius is the inner one plus the gap
        // it sits behind. Always present, transparent when unselected, so selecting a
        // card never reflows the row.
        stack::column()
            .flex_1()
            .min_w_0()
            .items_center()
            .cursor_pointer()
            .child(
                div()
                    .w_full()
                    .rounded(px(OPTION_CARD_RADIUS + RING_GAP + RING_WIDTH))
                    .p(px(RING_GAP))
                    .border_2()
                    .border_color(if selected {
                        theme.accent
                    } else {
                        gpui::transparent_black()
                    })
                    .child(frame),
            )
            .child(
                div()
                    .text_style(TextStyle::Body)
                    .text_color(if selected {
                        theme.text
                    } else {
                        theme.text_muted
                    })
                    .child(label.into()),
            )
    }

    /// Section card: a rounded plate at [`Theme::card_glass_bg`], its own tone
    /// carrying the edge the way a SwiftUI grouped `Form` section does.
    fn group_box(&self) -> Div {
        let theme = self.theme();
        div()
            .rounded(px(Theme::surface_radius()))
            .border_1()
            .border_color(theme.border)
            .bg(theme.card_glass_bg())
            .overflow_hidden()
            .flex()
            .flex_col()
    }

    /// One card row, split from the row above by a full-width hairline. Hover is
    /// caller-owned — gpui panics on a second hover, so the wash to chain is
    /// [`Theme::element_hover`].
    fn card_row(&self, first: bool) -> Div {
        let theme = self.theme();
        div()
            .px(px(ROW_INSET))
            .py(px(ROW_PAD_Y))
            .when(!first, |el| el.border_t_1().border_color(theme.border))
            .flex()
            .flex_row()
            .items_center()
            .gap(px(ROW_GAP))
    }

    /// The leading symbol on a row: a bare glyph, sized to the text beside it,
    /// the way the macOS General pane carries one.
    fn row_icon(&self, icon: impl Into<Icon>) -> Div {
        let theme = self.theme();
        div()
            .flex_none()
            .size(px(ROW_ICON))
            .flex()
            .items_center()
            .justify_center()
            .child(
                crate::icons::icon(icon)
                    .size(px(ROW_ICON))
                    .text_color(theme.text_muted),
            )
    }

    /// Row title. Clipped rather than ellipsised: an ellipsis sets
    /// `text_overflow`, which opts the label out of gpui's measure cache, and a
    /// list of them re-measures every row on every frame it scrolls.
    fn row_title(&self, title: impl Into<SharedString>) -> Div {
        let theme = self.theme();
        div()
            .min_w_0()
            .overflow_hidden()
            .whitespace_nowrap()
            .text_style(TextStyle::Body)
            .text_color(theme.text)
            .child(title.into())
    }

    /// The quiet meta line under a row title: `text-muted-foreground/65`
    /// fragments joined by dots.
    fn meta_line(&self, fragments: Vec<AnyElement>) -> Div {
        let theme = self.theme();
        let mut line = div()
            .mt(px(4.0))
            .flex()
            .flex_row()
            .flex_wrap()
            .items_center()
            .gap_x(px(Theme::SPACE))
            .gap_y(px(2.0))
            .text_style(TextStyle::Subheadline)
            .text_color(theme.text_muted.opacity(0.65));
        let mut first = true;
        for fragment in fragments {
            if !first {
                line = line.child(
                    div()
                        .text_color(theme.text_muted.opacity(0.3))
                        .child(SharedString::from("·")),
                );
            }
            line = line.child(fragment);
            first = false;
        }
        line
    }
}

impl Scaffolding for Theme {}