# bezel documentation for LLMs > Design tokens, motion, and materials for native Rust apps, built on gpui. The README, then every page in the order the site presents them. Each is also served on its own at https://bezel.gallery/docs/.md --- # bezel [![crates.io](https://img.shields.io/crates/v/bezel.svg?style=flat-square)](https://crates.io/crates/bezel) [![license](https://img.shields.io/crates/l/bezel.svg?style=flat-square)](LICENSE) [![gpui](https://img.shields.io/crates/v/bezel-gpui.svg?style=flat-square&label=gpui)](https://crates.io/crates/bezel-gpui) A gpui component library, SwiftUI-lean: style flows through the environment, never through parameters, and its numbers are measured rather than chosen — the type ladder is `NSFont.preferredFont(forTextStyle:)`, the gap between siblings is `NSStackView().spacing`. [CONTRIBUTING.md](CONTRIBUTING.md) has the laws. https://github.com/user-attachments/assets/34861f29-004f-47f0-89e6-42cc8772749f ```rust use bezel::ui::widgets::{ButtonStyle, Buttons}; theme.button("Save", ButtonStyle::Prominent, None) ``` ## Install ```toml [dependencies] bezel = "0.1" ``` An app also names the two crates the facade cannot cover — `gpui` because `actions!` expands to literal `gpui::` paths, and `gpui_platform` because the facade re-exports gpui but not the platform. Both are our fork of gpui, published under `bezel-gpui*`; the `package` key keeps the `gpui::` paths the macros and gpui's own docs expect: ```toml gpui = { package = "bezel-gpui", version = "0.3" } gpui_platform = { package = "bezel-gpui-platform", version = "0.3", features = ["font-kit"] } ``` ## Theme Most apps want the shipped palette in their own hues. That is a `Brand` — one hue for the greys, one for the accent, one base radius: ```rust use bezel::theme::{self, Brand, Tint}; // before appearance::init theme::set_brand( Brand { tint: Tint::new(257.417, 0.046), accent: Tint::new(276.935, 0.182), radius: 8.0, }, cx, ); ``` Lightness is not a knob, so a branded palette keeps the contrast ratios the shipped one was verified at. The gallery's **Theme** page is this struct with sliders on it, and prints the call back. For colors a hue rotation cannot reach, register a palette builder instead — it runs first, and a brand rotates whatever it returns: ```rust use bezel::theme::{Theme, set_palette}; theme::set_palette(|appearance| { let mut theme = Theme::for_appearance(appearance); theme.danger = my_red(appearance); theme }, cx); ``` ## Build an app `apps/hello` is the smallest consumer — one window, a button, a toggle. Every gpui *type* path goes through `bezel::gpui`, so a second gpui cannot creep into the graph. The bootstrap, which is the part no snippet can skip: ```rust use bezel::gpui::{App, AppContext as _, Bounds, WindowBounds, WindowOptions, px, size}; use bezel::theme::{self, Theme}; use bezel::ui; fn main() { gpui_platform::application() .run(|cx: &mut App| { if let Err(err) = ui::register_fonts(cx) { eprintln!("FONT REGISTRATION FAILED: {err:?}"); } theme::appearance::init(theme::appearance::AppearanceMode::System, cx); let bounds = Bounds::centered(None, size(px(520.0), px(360.0)), cx); cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), ..Default::default() }, |window, cx| { theme::appearance::observe_window(window, cx).detach(); cx.new(Hello::new) }, ) .unwrap(); cx.activate(true); }); } ``` `Hello` is a struct implementing `Render`; its `render` reads the theme with `Theme::of(cx)` and builds elements through the `widgets` traits. Run `cargo run -p hello` and read `apps/hello/src/main.rs` for the rest. ## Provenance | What | From | License | | ---------------------- | ---------------------------------- | ----------- | | Initial components | [comet] | MIT | | Thinking orbs | [gpui-thinking-orbs] | MIT | | Blob avatars | [blobatar] | MIT | | Syntax highlighting | tree-sitter core and grammars | MIT | | TypeScript/TSX queries | [nvim-treesitter] | Apache-2.0 | | Icons | [Lucide], via `icondata_lu` | ISC | | Fonts | Geist and Geist Mono © Vercel Inc. | SIL OFL 1.1 | [gpui]: https://github.com/zed-industries/gpui [comet]: https://github.com/zeronsh/comet [gpui-thinking-orbs]: https://github.com/FrancoEscob/gpui-thinking-orbs [blobatar]: https://github.com/Alain00/blobatar [nvim-treesitter]: https://github.com/nvim-treesitter/nvim-treesitter [lucide]: https://lucide.dev --- # Theme Source: https://github.com/crabtalk/bezel/blob/main/apps/gallery/src/brand.rs A `Brand` is what an app changes about the shipped palette without redesigning it — one hue for the greys, one for the accent, one base radius: ```rust use bezel::theme::{self, Brand, Tint}; theme::set_brand( Brand { tint: Tint::new(257.417, 0.046), accent: Tint::new(276.935, 0.182), radius: 8.0, }, cx, ); // before appearance::init ``` `Tint` is an oklch hue and how much of it. At `chroma: 0.0` it is the neutral the library ships, so `Brand::default()` reproduces the built-in palette exactly — the same bytes, not a close match. Lightness is never a knob. Every tone in `Theme::dark` and `Theme::light` was tuned against a measured contrast ratio, so a brand rotates hue and leaves those ratios where they were: `text` on `bg` is 16.09:1 unbranded and stays within a tenth of that at any hue. The page prints the four pairings a brand can break, in both appearances, so you watch the numbers rather than trust them. One rule decides which tokens take the tint: a token that is already grey takes the hue, and one that already carries a hue — `danger`, `warning`, `success` — is semantic and keeps it. Translucent ink is left alone, because it paints over a surface that is tinted already. The base colours are Tailwind's five neutral families at the chroma each carries mid-ramp. Chroma is constant across the ramp here rather than tapered per step, and falls off only where sRGB runs out — near black and near white the gamut is a needle, and asking for a mid-ramp chroma there would shift the hue rather than the saturation. An accent moves two tokens beyond `accent` itself. `accent_strong` is the plate, at the lightness the palette's existing chromatic plate uses, and `on_accent` is whichever of the palette's two extremes that plate can actually hold — measured, so a yellow plate takes a dark label and a blue one takes a light label without either being written down. `radius` is the button corner, and every other corner is a ratio of it: the bubble at 2×, floating surfaces at 1.5×, panels at 1.25×, small controls at 0.75×. Moving one number moves the set together and keeps the concentric relationships intact. The page keeps no palette of its own. Knobs write the theme global, the readouts build from `Theme::branded`, and the snippet prints the same `Brand` both of those used — so the code you copy is the thing on screen. It emits three files: the `set_brand` call, a `Cargo.toml`, and a `main.rs` that opens a window with your palette already installed. For colours a hue rotation cannot reach, register a palette builder with `set_palette`. It runs first and a brand rotates whatever it returns. --- # Color Source: https://github.com/crabtalk/bezel/blob/main/crates/theme/src/lib.rs `Theme` is a plain struct of `gpui::Hsla` fields installed as a gpui `Global`. Components read it at paint time and never take a color parameter: ```rust use theme::Theme; let theme = Theme::of(cx); div().bg(theme.surface).text_color(theme.text_muted) ``` Install it once at boot, before the first window opens — later than that and the first frame paints in the wrong palette: ```rust use theme::appearance::{self, AppearanceMode}; appearance::init(AppearanceMode::System, cx); ``` `AppearanceMode` is `System`, `Light` or `Dark`, and it is serde-serializable so you persist it wherever your settings live. `appearance::observe_window(window, cx)` subscribes to the OS notification; `appearance::set_mode` changes the preference and repaints. Light is designed, not inverted. Mirroring lightness gets three things backwards: surface order (dark's content panel is the *darkest* plane, light's is white and the chrome goes grey), elevation (a faint white wash means "raised" on dark and "recessed" on light), and accents (the 400-level tones fall below 4.5:1 on white, so light uses the 600-level siblings at the same hue). Each light text token lands within ~0.5 of its dark counterpart's contrast ratio, and a test in the crate asserts it. `accent` is neutral by default. A library that ships a hue puts that hue in every app that installs it, so the default is the accent's lightness with the chroma at zero. Branding it is a [Brand](/docs/theme), which rotates hue without moving any of the lightnesses above. `set_palette` is the way in for colours a hue rotation cannot reach — a retuned `danger`, a wholesale replacement. Register the builder rather than installing one theme: `appearance::apply` rebuilds the palette from scratch on every light/dark switch, and a theme installed on its own lasts only until then. ```rust theme::set_palette(|appearance| { let mut theme = Theme::for_appearance(appearance); theme.danger = my_red(appearance); theme }, cx); // before appearance::init ``` --- # Typography Source: https://github.com/crabtalk/bezel/blob/main/crates/theme/src/theme/typography.rs Both families are bundled in the `ui` crate and registered with the text system at boot: ```rust ui::register_fonts(cx).ok(); ``` Failure is non-fatal — the theme's `font_sans_fallback` / `font_mono_fallback` name the system faces, so text still paints if registration fails. Everything else reads the family off the theme: ```rust div().font_family(theme.font_mono.clone()) ``` Five files ship, not two: the variable Geist and Geist Mono, plus static Medium, SemiBold and Bold. gpui's cosmic-text path rasterizes a variable font at its default instance only and never applies `wght`, so on Linux every weight above 400 would silently paint at 400 with just the variable file registered. CoreText applies the axis natively and never falls through to the statics. The three cover the sans only — Geist Mono ships as its variable file alone, so bold monospace still paints at 400 off CoreText. Each of the three is its own gate, so an app pays for the faces it paints and no more: `geist-sans` is the variable Geist at 165 KB, `geist-mono` the variable Geist Mono at 168 KB, `geist-weights` the three statics at 375 KB. All are on by default. `geist-weights` implies `geist-sans`, since it is that family's weights — a macOS-only build can drop it, and a terminal app that never paints proportional text can take `geist-mono` alone. Your own type goes in through the same two seams, because there is nothing Geist-specific about either. Bytes go to the gpui text system, which takes any font; the theme names which family the components then paint with: ```rust use std::borrow::Cow; static INTER: &[u8] = include_bytes!("../assets/Inter.ttf"); cx.text_system().add_fonts(vec![Cow::Borrowed(INTER)]).ok(); theme::set_palette(|appearance| { let mut theme = Theme::for_appearance(appearance); theme.font_sans = "Inter".into(); theme }, cx); ``` The string is the family name the file itself declares, not a path — the text system resolves it, and a name nothing registered falls through to the fallback. Go through `set_palette` rather than mutating the installed theme: a light/dark switch rebuilds the palette from scratch, and only the registered builder is rerun. An app that brings its own type can then stop paying for ours, in whole or per family: ```toml bezel = { version = "0.0.2", default-features = false } bezel = { version = "0.0.2", default-features = false, features = ["geist-mono"] } ``` The facade forwards all three gates. With none of them `register_fonts` registers nothing, it still returns `Ok`, and the fallback families are all that paints. Sizes are not a scale on the theme. The library paints between 10px and 16px — mostly in half-point steps — and each site names the size it wants, rather than reaching a number it already knows through a token. --- # Layout Source: https://github.com/crabtalk/bezel/blob/main/crates/theme/src/lib.rs Law 4: numbers drive layout, colors are paint. Spacing and chrome heights are `f32` associated consts on `Theme` and the radii are `f32` functions, so a layout number resolves without reading the theme global at all: ```rust div() .gap(px(Theme::SPACE_SM)) .rounded(px(Theme::button_radius())) .h(px(Theme::HEADER_HEIGHT)) ``` Spacing is `SPACE_XS` 4, `SPACE_SM` 8, `SPACE_MD` 12, `SPACE_LG` 16. Radii are functions rather than consts, because every one of them is a ratio of `BASE_RADIUS` 8 and `Brand::radius` moves the whole set together. They run `control_radius()` 0.75x for things that sit inside a control, `button_radius()` 1x for controls themselves, `panel_radius()` 1.25x for cards, `surface_radius()` 1.5x for floating surfaces, `bubble_radius()` 2x for message bubbles. Chrome heights — `TITLEBAR_HEIGHT`, `HEADER_HEIGHT`, `STATUS_STRIP_HEIGHT` — are named for the same reason: a status strip that is reserved rather than conditional keeps the composer from shifting when it fills. `surface_radius()` is read at both ends of a glass surface: the border paints it, and `ui::material` cuts the backdrop blur to it. A blur cut to a different radius frosts square corners outside a round border, visible only on glass and only at the corners — which is why the number is named once instead of written twice. Nested corners come out of arithmetic rather than a second constant: ```rust Theme::inset_radius(Theme::surface_radius(), 4.0) // 8.0 ``` That is SwiftUI's `ContainerRelativeShape` rule. gpui has no container shape to inherit at paint time, so the relationship is stated where the child is defined — a container that changes its padding carries its rows with it, and the derived value never hardens into a constant of its own. --- # Materials Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/surface.rs Both surfaces come off the `Glass` trait, so any element carrying a corner radius has them. The radius is read from the element's own style, so the blur and the lens are cut to the corners the caller just asked for: ```rust use ui::material::{self, Glass as _, GlassStyle}; card.rounded(px(Theme::surface_radius())).material(material::MENU_BLUR) card.rounded(px(Theme::surface_radius())).glass_effect(&theme, GlassStyle::Regular) ``` `material::material(radius, blur, child)` is the free-function form, for a child that is not itself `Styled`. ## Frost `material` wraps a floating card so its entire subtree paints inside one gpui scene layer, with the backdrop blur painted first, structurally under the content. One layer is the point. With per-primitive bounds-tree ordering a hover repaint elsewhere can reassign the card's quads relative to its siblings; inside a single layer the card's stacking is structural. The cost is that everything in the card shares one draw order, and equal orders render grouped by primitive kind — quads, then icons, then images — so a close button's circle painted "after" a thumbnail still lands under it. `material::layered` opens a nested layer to restore the intended stacking: ```rust material::layered(close_button) ``` ## Liquid glass > **macOS only.** The lens is a Metal primitive from bezel's gpui fork. Everywhere else — web, Linux, Windows — `glass_effect` falls back to the backdrop tint: the card and its shape, without the refraction at the rim. `material::lensed(&theme)` answers it at runtime. `glass_effect` paints the card as a refracting surface: a bevel at the rim that displaces what is behind it, a per-channel fringe across that displacement, and a transfer line applied to the interior. It takes the theme and a **closed variant** — the two shipped looks, named for SwiftUI's own: ```rust card.glass_effect(&theme, GlassStyle::Regular) card.glass_effect(&theme, GlassStyle::Clear).tint(theme.accent.opacity(0.3)) ``` There is no blur parameter, and no gain, bevel or magnify parameter. Apple exposes none either — `Glass` is `.regular`, `.clear`, `.identity`, plus `.tint` and `.interactive` — so blur belongs to the look rather than to the caller. `tint` stands in for the look's own tone instead of adding to it, so a heavy alpha reads as paint and a light one as glass. The numbers live on `Theme` as `glass_regular` and `glass_clear`, both `GlassSpec { gain, tint, blur, rim }`, alongside the shared `glass_magnify` and `glass_dispersion`. A caller who wants different glass hands over a different theme; nothing is a knob on the component. Measured 2026-08-30 on macOS 26.3 off a real `NSGlassEffectView` — a nine-step grey staircase read through the flat interior for the line, 48pt bands for the sigma: | appearance | look | interior | blur | | --- | --- | --- | --- | | dark | `Clear` | `0.712 * backdrop + 25/255` | none | | dark | `Regular` | `0.139 * backdrop + 41/255` | 3.5pt | | light | `Clear` | `1.041 * backdrop + 19/255` | none | | light | `Regular` | `0.142 * backdrop + 212/255` | 6.0pt | The transfer line is fit in **sRGB**, not linear light: refitting in linear space is 30x worse on residual (rms 3–9 levels against 0.14–0.34), so the material composites in gamma space. Note light `Clear`'s slope is above 1, which no alpha composite can produce — it brightens and slightly expands contrast rather than dimming, which is why the field is `gain` and not `dim`. The material is not a tone-flip of itself. `Regular` keeps its opacity across both — 86% — and swaps a 19% grey base for a 97% white one, which is why in light it reads as ordinary frost: a near-white panel over a blur is what frost is. `Clear` changes character instead: in dark it compresses toward its tint, lifting black to 25 and dropping white to 207 with a crossover at backdrop 87; in light it stops compressing and is very nearly a pure lift. The rim is measured off a real `NSGlassEffectView` over a position-coded backdrop — green ramping once across it, red sawtoothing every 32pt — so a pixel under the glass names the backdrop position it came from and the displacement is read rather than inferred. It falls from ~47pt at the outermost pixel to nothing by 19pt, and it is the same curve on a 96pt box and a 320pt one, at r24 and at r84 — so `GlassSpec::rim` is a length, not a share of the box. Blur is uniform across the surface in both looks; neither sharpens toward the rim. `glass_effect` clears the card's own `bg`, because the lens paints the fill. Painting both buries the lens, and a caller who has to remember that is a caller who will forget. ## Where it runs Both need `Window::paint_backdrop_blur` from bezel's gpui fork, which is macOS Metal only — any macOS version, since the lens is our own shader and not `NSGlassEffectView`. Off macOS `glass_chrome` starts false — it follows the frost alpha, which is 1.0 there — so neither surface composites. `material` becomes a pass-through and the caller's own fill shows through unchanged. `glass_effect` has moved the fill inside the lens, so where the lens cannot run it paints `Theme::glass_overlay` — the same tint a floating card carries over a blur, without the blur under it. The card keeps its shape and the page still shows through; what is lost is the refraction at the rim, not the surface. A `tint` is painted over that backing, so a glass control that carries colour still carries it. Gate glass-only recipes on `theme.glass_chrome`, never on the platform and never on the window's frost. `Theme::glass_window()` is the separate question of whether the window itself composites translucent, and an app moves the two independently: `Brand { glass: 1.0, glass_chrome: true }` is an opaque window still carrying layered chrome, which is what a Reduce-transparency setting asks for. --- # Curves Source: https://github.com/crabtalk/bezel/blob/main/crates/motion/src/lib.rs `CubicBezier` is a CSS `cubic-bezier(x1, y1, x2, y2)` with the endpoints fixed at (0,0) and (1,1). `eval` solves x(t) = input by Newton iteration with a bisection fallback — the standard UnitBezier approach — so a curve copied out of a stylesheet plays the same shape here: ```rust use motion::{CubicBezier, EASE_OUT_EXPO}; EASE_OUT_EXPO.eval(0.5); // eased progress element.with_animation(id, animation.with_easing(EASE_OUT_EXPO.easing()), ..) ``` The named curves are `EASE`, `EASE_OUT`, `EASE_IN_OUT`, `EASE_OUT_EXPO` (the signature entrance, `cubic-bezier(0.16, 1, 0.3, 1)`), `EASE_RESORT` for list reordering, and `EASE_TAILWIND` — `cubic-bezier(0.4, 0, 0.2, 1)`, the curve every `transition-colors` hover wash rides in the reference app. `eval` clamps its output hard. f32 rounding can push the sample a hair past 1.0 — 1.000000119 was observed near the end of a menu animation — and gpui's animation element asserts its delta is in [0,1] and aborts. The plots on this page are drawn from each curve's own `eval`, and the ones on the catalog page from `MotionSpec::progress`. Both are pure functions of a float, unit-tested without a window. --- # Catalog Source: https://github.com/crabtalk/bezel/blob/main/crates/motion/src/lib.rs Law 3: no component inlines a duration or a curve, it names a spec. A `MotionSpec` is a duration, an optional delay and a curve, and it hands gpui a ready animation: ```rust use motion::{MotionSpec, MENU_IN}; element.with_animation("menu", MENU_IN.animation(), |el, t| el.opacity(t)) ``` gpui `Animation` has no native delay, so a spec with one folds it into the timeline: the animation runs for `delay + duration` and `progress` holds 0 until the delay has elapsed. `progress(raw_delta)` is pure, which is what makes the catalog testable. The common entrances are already wrapped, so a caller names the element rather than the tween — `fade_in`, `fade_quick`, `menu_in`, `dialog_in`, `splash_out`, and `menu_out`, which takes its progress from the caller because `with_animation`'s clock replays from 0 on remount and a replay mid-exit is a full-opacity flash. Hover washes are colors computed at paint time, blended through a per-fade store: ```rust let fade = Fade::new(cx.entity_id(), "row-3"); div() .on_hover(motion::hover_listener(fade.clone())) .bg(motion::hover_blend(&fade, theme.surface, theme.element_hover)) ``` A [`Fade`] is which view paints the wash and which element inside it — the view is half the identity, so two views using `"row-3"` never trade each other's fade. The frames come from the same clock everything else in the library repeats on. `motion::lease(view, fps, until, cx)` claims a rate for one view: the app runs a single timer, wakes only when some view is owed a frame, notifies that view alone, and parks when the last claim lapses. A hover fade leases for its own 150ms and stops; a spinner renews its claim every render and drops off the moment it unmounts. That is the whole reason not to reach for `with_animation(…).repeat()`. Its request is the *window's*, at the display's rate, for as long as the element stays mounted — one spinner row measured 36% CPU at 120Hz, almost all of it the window rebuilding its element tree. Your own repeating animation belongs on the clock too: `MotionSpec::new` is `const` and its fields are public, so naming a spec is all it takes. ```rust const BREATHE: MotionSpec = MotionSpec::new(1800, motion::EASE_IN_OUT); let phase = motion::pulse_delta(&BREATHE, cx.entity_id(), cx); ``` `set_speed(10.0)` stretches every timeline in the catalog, which is how a screenshot burst samples a 200ms tween frame by frame. The app-level settings hang off `App` itself, through `AppExt`: ```rust use motion::AppExt as _; cx.set_reduced_motion(true); cx.set_pause_when_inactive(false); ``` Reduced motion is gpui's own flag: `with_animation` elements snap to their end state and schedule nothing, and `pulse_delta` returns a static 0. `pause_when_inactive` is on by default, and stops the clock while no window is active. Nothing above stops on its own — a spinner in a backgrounded window held 30fps and 22% of a core indefinitely (2026-08, debug build), against 2% with this on. The claim is refused rather than cancelled, so nothing has to resume it: the ones already held lapse, the loop runs out of work and parks, and the refresh gpui does on activation brings the renders that claim again. Turn it off for a window that must keep moving while something else has focus — a side panel, a floating HUD. --- # Icons Source: https://github.com/crabtalk/bezel/blob/main/crates/icons/src/lib.rs Register the asset source when the app starts, then name an icon by its category and constant: ```rust use ui::icons::{self, system}; Application::new().with_assets(icons::Assets) icons::icon(system::MAGNIFER).size(px(16.0)).text_color(theme.text_muted) ``` `icon` returns a gpui `Svg`, so it colors with `text_color` and sizes like any element. The paths are `&'static str` — that is what makes the set browsable: `icons::CATEGORIES` is every category and its `(constant name, asset path)` pairs, which is what this page renders. ## Nothing is checked in No SVG lives in the repository. The set is declared in `crates/icons/src/lib.rs`, a line per icon naming ours and the [Lucide](https://lucide.dev) glyph behind it: ```rust icon_set! { /// Transport, and the three volume glyphs a level control swaps between. media = "media" { (PLAY, "play", LuPlay), (PLAY_BOLD, "play-bold", LuPlay, solid), } } ``` `icon_set!` is a `macro_rules!` in that same file, expanding the declaration into the modules, the constants and the `AssetSource`; `document`, also there, turns a glyph into the SVG gpui asks for. Nothing is generated out of band — rustfmt formats it, rust-analyzer expands it, and `LuPlay` is resolved by the compiler, so a glyph that does not exist upstream is an error naming the bad identifier rather than a blank square at runtime. An icon writes its name twice because building an identifier out of a string is the one thing only a procedural macro can do, and that is not worth a second published crate. A test asserts the two halves agree, which is the drift that matters: the older vendored set had grown a `DOWNLOAD` constant pointing at `download-minimalistic.svg`. Nothing is fetched while building — cargo has `icondata_lu` in its registry cache before rustc starts — so the set works offline, under `cargo vendor` and on docs.rs, pinned byte-for-byte by `Cargo.lock`. We name 84 of that crate's 1599 statics and the linker drops the rest. Lucide is ISC-licensed and asks for no attribution in a shipped binary. ## Paying for what you paint Each category — `arrows`, `media`, `files`, `devices`, `editing`, `status`, `system` — is a module and a cargo feature, all on by default. `default-features = false` narrows the set, and the icons you leave out are neither generated nor embedded: ```toml bezel-icons = { version = "0.1", default-features = false, features = ["arrows", "system"] } ``` Cargo unions features across a graph, so a category a dependency turns on is one you cannot turn off. `ui` asks for four of the seven to compile its own components, and those four are the floor for anything depending on it. ## Bold twins Lucide draws one weight. The `_BOLD` icons — `media::PLAY_BOLD`, `status::STAR_BOLD` — are the same path painted solid, filled *and* stroked so the outer edge lands exactly where the outline twin's does. A control swapping between them does not jump. --- # Buttons Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/buttons.rs Three buttons, and the difference between them is only ever emphasis. Each returns a `gpui::Div`, so you wrap it in whatever handles the click — bezel does not own your interaction. ```rust use ui::popover; popover::button(&theme, "Cancel", "dialog-no") popover::button_prominent(&theme, "Save") popover::button_destructive(&theme, "Discard") ``` `button` takes a fade key. It identifies the button to the motion system so a hover that starts and a hover that ends belong to the same element across frames; two buttons sharing a key will trade one another's animation state. The click handler is yours to attach: ```rust div() .id("dialog-confirm") .on_click(cx.listener(|view, _, _, cx| view.close(cx))) .child(popover::button_prominent(&theme, "Save")) ``` --- # Text field Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/input.rs The one component in the library that is not a plain function. Editing needs state — content, selection, the IME marked range, a focus handle — so a field is an entity the caller holds, the way SwiftUI's `TextField` binds to `@State`: ```rust use ui::input::{self, TextField}; input::init(cx); // once, at startup let field = cx.new(|cx| TextField::new(cx).with_placeholder("Search…")); // …then render it: .child(field.clone()) ``` Read and write it through the entity — `content()`, `set_content()`, `clear()`, `cursor()` for the byte offset the caret sits at. `set_content` clears the undo history: a programmatic reset is not something the user did, so there is nothing to walk back past. `init` is a convenience, not a requirement. Every action is a public type and every binding is scoped to the field's key context, so `cmd-a` never comes to mean "select all text" for the whole application: ```rust use ui::input::{self, Home, KEY_CONTEXT}; cx.bind_keys([KeyBinding::new("ctrl-a", Home, Some(KEY_CONTEXT))]); ``` It is all-or-nothing — take the defaults or bind the lot yourself. Undo steps are runs, not keystrokes: a stretch of typing coalesces into one step, and the run ends when the caret moves or you switch between typing and deleting. That is structural rather than a millisecond threshold — adjacency is what actually separates a run of typing from a fresh thought somewhere else. The default ceiling is ten steps, which is deeper than it sounds for that reason; `with_undo_limit` moves it. A field is not a document, and nobody walks a search box back through a long history. Motion follows the platform. On macOS `cmd` is line, `option` is word, and the emacs chords every native field honours — `ctrl-a`, `ctrl-e`, `ctrl-k`, `ctrl-b`/`ctrl-f`/`ctrl-h`/`ctrl-d` — come along; elsewhere `ctrl` is word. Word bounds are Unicode UAX#29 segments, so `foo.bar` and `foo_bar` are one word while `path/to/file` breaks. Arrows and backspace step by grapheme, so a flag emoji moves as a unit instead of shattering. `offset_bounds(offset, window)` returns where a byte offset sits on screen. That is the anchor for anything hanging off a position in the *text* rather than off the field — a mention picker under the `#` that opened it, handed to `popover::menu_at`. It is `None` until the field has painted once, since there is no shaped layout before then. --- # Textarea Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/input.rs A textarea is a `TextField` with a different `Shape`. Editing is identical across all three shapes — every action works on the content and a byte range, and none of them cares where the lines break. What the shape decides is the box: ```rust use ui::input::{Shape, TextField}; TextField::new(cx).with_shape(Shape::Rows(4)) TextField::new(cx).with_shape(Shape::Grow { min: 3, max: 12 }) ``` `Rows(n)` is exactly `n` lines tall and scrolls past that. `Grow { min, max }` is the composer shape: it grows with the content and scrolls once it hits `max`. `Shape::Line` is the single-line default, where a pasted newline becomes a space rather than silently truncating what was pasted. Multi-line fields claim a second key context, `MULTILINE_KEY_CONTEXT`, and `enter`, `up`, `down` and their shift variants are bound there rather than on every field. A single-line field is routinely nested inside something that has already claimed those keys — the command palette and the combobox both drive their lists with `up`/`down`/`enter`, and their query field sits *deeper* in the focus path, so a binding on every field would win the dispatch and break list navigation in both. When `enter` needs to mean something else in one particular box, give that box a context of its own: ```rust const COMPOSER: &str = "Composer"; cx.bind_keys([ KeyBinding::new("enter", Send, Some(COMPOSER)), KeyBinding::new("shift-enter", input::InsertNewline, Some(COMPOSER)), ]); let field = cx.new(|cx| { TextField::new(cx) .with_shape(Shape::Grow { min: 3, max: 12 }) .with_key_context(COMPOSER) }); ``` gpui resolves a keystroke to the binding whose context matches deepest in the focus path, and nothing is deeper than the focused field — so a container around it cannot win `enter`, however it is bound. Rebinding the shared multi-line context would win, and would take the newline away from every other textarea in the app. `home`/`ctrl-a` goes to the start of the logical line — the byte after the previous newline — not to the start of the visual row a soft wrap put you on. That is emacs' `C-a`, and a deliberate divergence from `NSTextView`. --- # Select Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/controls.rs There is no `Select` component. A select *is* a trigger plus an anchored menu, and the caller already owns the open state and the selection: ```rust use ui::{popover, widgets}; div() .id("theme-select") .on_click(cx.listener(|view, _, _, cx| view.toggle_menu(cx))) .child(widgets::select_trigger(&theme, SELECT_CHOICES[self.choice], open)) .when(open, |trigger| { trigger.child(popover::anchored_menu_below( "theme-select-menu", popover::popover_card(&theme) .w(px(200.0)) .children(SELECT_CHOICES.iter().enumerate().map(|(index, label)| { popover::menu_row(&theme, index == self.choice, format!("row-{index}")) .child(label) })), )) }) ``` Wrapping that in a struct would buy an abstraction and cost the caller its control over both halves. `select_trigger` is shaped and toned like a `TextField`, so a form of fields and selects reads as one system. It takes the current label and whether the menu is open — the chevron follows. Dismissal is the caller's. `.on_mouse_down_out` on the card is what closes it; without one, clicking away leaves the menu open. Pair that with `popover::Popup` if you want the menu to animate out rather than vanish. --- # Combobox Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/combobox.rs ```rust use ui::combobox::{self, Combobox, ComboboxEvent}; combobox::init(cx); // once, at startup, alongside input::init let language = cx.new(|cx| Combobox::new(LANGUAGES.to_vec(), "Language", cx)); cx.subscribe(&language, |_, _, event, _| match event { ComboboxEvent::Selected(index) => { /* item `index` */ } }) .detach(); ``` An entity for the same reason the command palette is one — it owns a query `TextField`. The two share `popover::Filter` and differ only in frame: the palette is a modal over every command, this hangs under a trigger and remembers what was chosen. The reported index is into the **original** item list, never into the filtered view. The menu matches the trigger's width, measured from the last frame's layout. An anchored layer sizes to its own content, so without measuring, a combobox's menu could not line up with its own face. Keys are the palette's set — `up`/`down`, `ctrl-p`/`ctrl-n`, `enter`, `escape` — scoped to a context that wraps the query field's, so typing reaches the field and navigation falls through. --- # Checkbox & radio Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/controls.rs Both are `fn(&Theme, bool) -> Div`. They paint the mark and nothing else; whether a box is checked lives in the app: ```rust use ui::{focus, widgets}; widgets::checkbox(&theme, self.checked[index]) widgets::radio_button(&theme, self.radio == index) ``` Radios are a *set*, so the caller owns which index is on — passing `self.radio == index` is the whole of it. Nothing here groups them, because a group would need to own the answer. Keyboard support is one wrapper. `focus::focusable` puts the control in the tab order, paints the focus ring, and lets `enter`/`space` press it: ```rust focus::focusable(&theme, &self.checkboxes[index], widgets::checkbox(&theme, checked)) .id("checkbox-0") .on_click(cx.listener(..)) .on_action(cx.listener(|view, _: &focus::Activate, _, cx| ..)) ``` The click and the key press are handled separately on purpose. A control pressed by mouse and by key is doing the same thing, but only the caller knows what that is, and a keyboard affordance that silently diverges from the click is worse than none. Every control here carries a 1px border even where it paints nothing in it. gpui sizes border-box, so a border that appeared only on focus would move the tick under it by a pixel as you tab onto it. --- # Toggle Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/controls.rs ```rust use ui::widgets; widgets::toggle(&theme, self.enabled) ``` Display-only, like the rest of `widgets`: the caller adds `.id(..)` and `.on_click(..)`, and holds the bool. Tab focus and `space`/`enter` come from the same wrapper every stateless control uses: ```rust focus::focusable(&theme, &self.switch, widgets::toggle(&theme, self.enabled)) ``` --- # Toggle group Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/controls.rs The track is a container and each segment is a child, so the caller keeps the list and the selection: ```rust use ui::widgets; widgets::toggle_group(&theme).children( ["Day", "Week", "Month"].into_iter().enumerate().map(|(index, label)| { widgets::toggle_group_item(&theme, label, self.segment == index) }), ) ``` Exactly one segment reads as pressed: the selected one gets the raised plate, the rest stay bare. The track sets `self_start`. A segmented control has to hug its segments, and dropped into a `flex_col`, flexbox's default `align-items: stretch` would blow it out to the column's full width. Segment corners are derived from the track's radius and the inset it comes in by — both numbers are read at both ends, so a segment cannot stop being concentric with the track it sits in. Reach for this over a select when there are few enough choices that a menu would be overkill. --- # Slider Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/controls.rs ```rust use ui::widgets::{self, SliderDrag}; focus::focusable(&theme, &self.slider, widgets::slider(&theme, self.level)) .id("slider") .on_drag(SliderDrag, |_, _, _, cx| cx.new(|_| gpui::Empty)) .on_drag_move(cx.listener(|view, event: &DragMoveEvent, _, cx| { view.level = widgets::axis_fraction( event.event.position, event.bounds, Axis::Horizontal, 0.0, ); cx.notify(); })) ``` The element *is* the drag source, so the gesture is grab-anywhere-and-slide rather than aim-at-the-knob. `axis_fraction` turns a pointer position into the value: where the pointer falls along an axis as a fraction of the bounds, clamped to `min..=1-min`. A slider passes `0.0` because it has no dead zone; a split passes one so neither pane can be squeezed away. On a zero-extent container — the frame before layout has run — it answers `min` instead of dividing by zero. `SliderDrag` is a type of its own so two sliders in one window never answer each other's `on_drag_move`. Keyboard is `←`/`→`, which arrive as `focus::Decrement` and `focus::Increment`: ```rust .on_action(cx.listener(|view, _: &focus::Decrement, _, cx| view.nudge(-STEP, cx))) .on_action(cx.listener(|view, _: &focus::Increment, _, cx| view.nudge(STEP, cx))) ``` The actions carry no step. Only the caller knows the range, and a library that picked one would be picking it for a percentage and a font size alike. --- # Date picker Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/date.rs ```rust use ui::date::{self, Calendar, CalendarEvent, Date}; date::init(cx); // once, at startup let picker = cx.new(|cx| Calendar::new(today, cx)); cx.subscribe(&picker, |_, _, event, _| match event { CalendarEvent::Selected(date) => { /* the chosen day */ } }) .detach(); ``` `today` comes from the app. bezel carries no clock, and the only thing that knows which day it is where you are is the app that has a time source. `Date` is bezel's own, deliberately. chrono is already in the graph under gpui, so taking it would cost nothing to compile — and would make it a *public* dependency, so a consumer declaring its own chrono would end up with two incompatible ones. That is the split-graph failure `bezel::gpui` exists to prevent, and it buys nothing here: a picker needs no timezones, no parsing and no formatting. It needs the civil calendar, which is pure and testable without a window. `Date::new(year, month, day)` is checked and answers `None` unless the day exists, so 29 February depends on the year — which is the whole point of asking. Fields are private and ordering is chronological, so nothing downstream ever has to ask whether a date is real. A month is always drawn in six rows: ```rust date::month_grid(month, date::Weekday::Monday) // [Date; 42] ``` Six even for a February that fits in four, so the card never changes height as you page — a popover that resizes under the pointer moves the day you were about to click. The leading and trailing cells are real dates from the neighbouring months rather than blanks, which makes `cell.month() != month.month()` the only test a cell needs and leaves clicking one meaningful. Arrows walk days and weeks because the grid is two-dimensional, `pageup`/`pagedown` page months — the chords a browser's own date input uses — and the cursor is a single `Date`, so walking off the end of a month and paging to the next are the same operation and cannot disagree about where you are. `CalendarEvent::Selected` fires on choosing a day, never on moving the cursor over one. --- # Menu Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/popover.rs A menu is a card and a list of rows, assembled by the caller: ```rust use ui::popover; popover::popover_card(&theme).w(px(240.0)).children([ popover::menu_heading(&theme, "Section").into_any_element(), popover::menu_row(&theme, false, "m-one").child("First item").into_any_element(), popover::menu_row(&theme, true, "m-two").child("Active item").into_any_element(), popover::divider().into_any_element(), ]) ``` `menu_row` takes a fade key — unique app-wide and stable across frames; the row's id string is a good choice — which is what the hover wash blends against. `menu_row_nav` distinguishes the keyboard cursor from the selection, so two rows never look selected at once. To float it, hang an anchored layer off the trigger while open: ```rust trigger.child(popover::anchored_menu_below("theme-menu", card)) ``` `anchored_menu` pins to the trigger's top-left, which reads right for a context-style menu and covers a button-shaped trigger — hence `anchored_menu_below` for dropdowns, `anchored_menu_above` for anything near the window's bottom edge, and `anchored_menu_above_end` when a right-side trigger would otherwise run off the window. gpui's `anchored` does not flip sides for you; the caller picks. Every layer occludes. Hitboxes are paint-order only in gpui, so without it a click on a menu row would *also* fire whatever clickable sits underneath. Dismissal is the caller's `.on_mouse_down_out` on the card. To animate the close rather than have the menu vanish, hold the state in a `Popup`: ```rust if self.menu.begin_close() { popover::reap_popup(cx, |view: &mut Self| &mut view.menu); } ``` gpui unmounts an element the frame its state drops, so a closing animation needs the state held alive while `menu-out` plays. `Popup` is that hold: `is_open` for logic — a closing popup already reads as closed — and `get`/`is_closing` for rendering, with `reap_popup` scheduling the drop once the exit's span is up. The pure parts are separate and tested on their own: `menu_step` wraps the active row at both ends, `filter_indices` ranks prefix matches ahead of substring matches, and `Filter` holds the items, the ranked view and the active row for every picker in the library. --- # Context menu Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/popover.rs A context menu is `menu_at`: the card, positioned by a point rather than by a trigger. ```rust use ui::popover; .on_mouse_down(MouseButton::Right, cx.listener(|view, event: &MouseDownEvent, _, cx| { view.context_menu.open(event.position); cx.notify(); })) ``` Then render it while the popup holds a position: ```rust popover::menu_at( "gallery-context", position, popover::popover_card(&theme) .w(px(180.0)) .children(rows) .on_mouse_down_out(cx.listener(|view, _, _, cx| view.close_context_menu(cx))) .into_any_element(), closing, ) ``` The last argument is the `Popup`'s `closing_since()`. Pass it and the menu plays `menu-out` on the way away; pass `None` and it disappears the frame its state drops. Like every floating layer here it occludes, so rows never leak their clicks to the elements underneath. Dismissal is still the caller's `.on_mouse_down_out` — nothing in the library decides when your menu should go away. --- # Command palette Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/palette.rs ```rust use ui::palette::{self, CommandPalette, PaletteEvent}; palette::init(cx); // once, at startup, alongside input::init let palette = cx.new(|cx| CommandPalette::new(COMMANDS.to_vec(), cx)); cx.subscribe(&palette, |_, _, event, _| match event { PaletteEvent::Selected(index) => { /* run command `index` */ } PaletteEvent::Dismissed => { /* unmount */ } }) .detach(); ``` Stateful for the same reason a text field is: it owns a query, a filtered view and an active row. It reports outcomes as gpui events rather than taking a callback, so the host decides what a selection *means* and the palette never knows about the app's actions. Indices are into the **original** item list, never into the filtered view. A caller matching on a filtered index would run the wrong command the moment a query is typed. Navigation is `up`/`down`, `ctrl-p`/`ctrl-n`, `enter` and `escape`, all scoped to the palette's key context. That context wraps the query field's own, so typing goes to the field while the navigation keys fall through — which is why `TextField` does not bind `up`/`down` itself. Mounting is the caller's: the palette is an entity you render where you want it, usually centred over a scrim. `popover::modal_glass` is the frame for that. The filtering underneath is `popover::Filter`, shared with the combobox: prefix matches first, then substring matches, stable within each rank. --- # Menubar Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/menubar.rs Not the *native* bar. On macOS that is `cx.set_menus` and four lines in `main`, which is where it belongs. This is the bar an app with a custom titlebar draws for itself, and the one every other platform expects to see inside the window. ```rust use ui::menubar::{self, Item, Menu, Menubar, MenubarEvent}; menubar::init(cx); // once, at startup let bar = cx.new(|cx| Menubar::new(vec![ Menu::new("File", vec![ Item::action("New Window").with_keystroke("⌘N"), Item::Separator, Item::action("Close").with_keystroke("⌘W").disabled(), ]), ], cx)); cx.subscribe(&bar, |_, _, event, _| match event { MenubarEvent::Selected { menu, item } => { /* dispatch */ } }) .detach(); ``` What makes it a menubar rather than a row of dropdowns: sliding the pointer onto a sibling title switches to it with no click, and `left`/`right` cross between menus without leaving the keyboard. The menus are data you hand over, shaped like gpui's own `Menu` and `MenuItem` so an app drawing both bars writes them the same way. It does not *take* those types — they carry a boxed action, and reporting an index leaves dispatch with the app. The keystroke on an item is the accelerator to **print**. The binding itself is the app's and bezel never dispatches it; a menu that showed a keystroke it did not own would be documenting a lie. `Item` is an enum rather than a struct with an `is_separator` flag: a separator has no label, no accelerator and nothing to enable, and every one of those fields would have to be answered anyway. `menubar::next_selectable(items, from, delta)` is the row-stepping rule — separators and disabled rows are stepped straight over, both ends wrap, and `None` back means nothing in the menu can be landed on, which is the one shape that would otherwise spin forever. --- # Dialog Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/popover.rs `modal` is the scrim and the layer; `dialog_card` and its pieces are what you put in it: ```rust use ui::popover; popover::modal( "gallery-dialog", window.viewport_size(), popover::dialog_card(&theme) .gap(px(12.0)) .child(popover::dialog_title(&theme, "Discard changes?")) .child(popover::dialog_body(&theme, "This cannot be undone.")) .child( div().flex().flex_row().justify_end().gap(px(8.0)) .child(cancel_button) .child(confirm_button), ) .into_any_element(), cx.listener(|view, _, _, cx| view.close_dialog(cx)), ) ``` `viewport_size` is required: an `anchored` layer sizes to its children, so the scrim needs explicit dimensions to cover the window. The last argument is the scrim press, and it is a parameter rather than the caller's `.on_mouse_down_out` because the scrim lives *inside* this deferred layer — nothing outside can reach it. That is not hypothetical: the first version shipped without it, and what it looked like was a dialog that only closed on its own buttons. `modal_glass` is the variant for glass-tinted cards. Its scrim is lighter, because the standard dim buries the backdrop hue under the blur and the card comes out a flat grey slab next to the hue-inheriting menus. Its radius is not a parameter — a glass-tinted modal *is* a popover surface, and the parameter it used to take carried the doc line "must match the card's rounding", which is a footgun handed to the caller in writing. The card enters over `DIALOG_IN`. Everything else — which buttons, what they do, whether `esc` closes it — is the caller's. --- # Sheet Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/popover.rs ```rust use ui::popover::{self, Side}; popover::sheet( "gallery-sheet", window.viewport_size(), Side::Right, px(320.0), popover::sheet_panel(&theme, Side::Right) .p(px(20.0)) .child(popover::dialog_title(&theme, "Details")) .child(popover::dialog_body(&theme, "…")) .into_any_element(), self.sheet.closing_since(), cx.listener(|view, _, _, cx| view.close_sheet(cx)), ) ``` `sheet_panel` rounds and hairlines its *inner* edge only — the two corners on the window edge are off screen — so the panel reads as pulled out of the side of the window rather than floating near it. It shares one rounding constant with `dialog_card`, because a sheet is the dialog card pinned to an edge, and that number is read three times over: the card, the panel, and the blur under each. It slides in over `DIALOG_IN` and back out over `MENU_OUT`. The exit is not optional — `Popup::finish_close` reaps on that spec's span, so a sheet that ignored `closing_since` would be unmounted mid-slide. As with `modal`, the scrim press is a parameter. The scrim is inside the deferred layer, so `.on_mouse_down_out` from outside can never reach it. The slide itself is written in the component rather than as a motion helper: only the *spec* is motion, and which inset carries it is layout that differs per side. --- # Tooltip Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/tooltip.rs ```rust use ui::tooltip::Tooltip; div() .id("copy") .tooltip(|window, cx| Tooltip::text("Copy path", window, cx)) .child("⌘C") ``` An entity rather than a plain function, because gpui's `.tooltip(..)` takes a builder returning an `AnyView` — the tooltip is mounted in its own layer after the hover delay, so it cannot be an inline element. `Tooltip::with_keystroke("Copy path", "⌘C", window, cx)` shows the shortcut right-aligned in the same card. That pairing is how a keyboard affordance stays discoverable without opening a menu. The delay is gpui's, not bezel's — `.tooltip_show_delay(..)` on the element changes it. The card is the popover surface with tighter padding and no menu rhythm: a tooltip holds a label, not rows. --- # Hover card Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/hover_card.rs ```rust use ui::hover_card::HoverCard; div() .id("clearloop") .hoverable_tooltip(|window, cx| { HoverCard::summary("clearloop", "Builds desktop software in Rust.", window, cx) }) .child("@clearloop") ``` `hoverable_tooltip` rather than `tooltip` is the whole difference, and it means there is no open/close state machine here: gpui owns the delay and keeps the card alive while the pointer is inside it, which is what lets a preview hold a link you can click. Two constructors. `summary` is a heading and a line or two of prose; `person` adds avatar initials beside the name and a meta line under the body — a role, a path, a timestamp. The card is wider and airier than a tooltip's because it holds prose rather than a label. --- # Group box Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/scaffolding.rs ```rust use ui::{icons, widgets}; widgets::group_box(&theme) .child( widgets::card_row(&theme, true) .child(widgets::row_tile(&theme, icons::devices::MONITOR)) .child(widgets::row_title(&theme, "Appearance")), ) .child( widgets::card_row(&theme, false) .child(widgets::row_tile(&theme, icons::files::FOLDER)) .child(widgets::row_title(&theme, "Storage")), ) ``` `card_row`'s `first` flag is what suppresses the top hairline on the row that opens the card — CSS would write that as `first:border-t-0`, and gpui has no sibling selectors, so the caller says which row is first. The card's fill comes from `Theme::card_glass_bg`: the opaque card tone on an opaque appearance, thinned to a translucent tint over glass, where the solid tone read as a slab floating on the frosted blur. Around it, the page rhythm is `page_column` (a centred reading column), `page_header` for the headline and its count sharing a baseline, `page_subtitle`, and `field_label` for the small caption over a control. --- # Tabs Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/layout.rs ```rust use ui::widgets; widgets::tab_bar(&theme).children(TABS.iter().enumerate().map(|(index, label)| { widgets::tab(&theme, *label, index == self.tab) .id(SharedString::from(format!("tab-{index}"))) .on_click(cx.listener(move |view, _, _, cx| view.select(index, cx))) })) ``` The active tab carries the text tone, a medium weight, and a 2px underline that sits *over* the bar's hairline rather than under it. Nothing about it changes the row's height, so switching tabs never nudges the content below. Like every control in `widgets`, a tab keeps a 1px border it usually paints nothing into — that is the slot `focus::focusable` fills with the focus ring, and it is always there so the label never shifts by a pixel when focus arrives. The underline's insets carry that pixel too, which is why it still spans the tab's full width. Which panel a tab shows is the caller's: `tab_bar` is a strip, not a container that swallows its content. --- # Nav row Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/layout.rs ```rust use ui::{icons, widgets::Layout}; theme .nav_row(Some(icons::system::WIDGET), "Home", self.route == Route::Home, "nav-home") .id("nav-home") .on_click(cx.listener(|view, _, _, cx| view.go(Route::Home, cx))) ``` The label is a parameter rather than a child because it carries the truncation. Hand it out and the first long project name pushes the count and the chevron off the row instead of shortening itself. Trailing content is the caller's: a count, a chevron, a control that appears under the pointer. That last one shares the row's `fade_key` — gpui allows one hover listener per element and the row has claimed it, so a trailing button paints its own tint with `motion::hover_blend` on the same key instead of adding an `on_hover` of its own. Selection paints the wash `popover::menu_row` and `tree::tree_row` paint. A sidebar, a menu and a tree are three lists of the same kind, and they say "this one" the same way. Two lines of text is a different row: `Scaffolding::row_title` over `meta_line`, inside a `card_row`. --- # Collapsible Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/layout.rs ```rust use ui::widgets; div() .child( div() .id("collapse") .on_click(cx.listener(|view, _, _, cx| { view.expanded = !view.expanded; cx.notify(); })) .child(widgets::collapsible_header(&theme, "Advanced", self.expanded)), ) .when(self.expanded, |el| el.child(body)) ``` The header is a row, not a container. Swallowing the children would mean re-implementing layout for them, and the body of a collapsible is usually the most layout-specific thing on the page. `widgets::disclosure(&theme, expanded)` is the chevron on its own — two assets rather than one rotated, because gpui has no transform for `div`s at the pinned rev. When the section should open itself while something runs and close when it stops, `Takeover` is the flag: ```rust let open = self.details.get(self.running); // auto until touched self.details.toggle(self.running); // the click wins from here ``` Auto-follow is right until the first press and wrong immediately after — whatever the flag does next, the person who clicked has to win. Nothing agent-shaped about it: a build log that unfolds while it runs and a detail pane that follows the selection both want exactly this. --- # Resizable split Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/layout.rs ```rust use ui::widgets::{self, Layout as _, SplitDrag, SplitStyle}; div() .id("split") .on_drag_move(cx.listener(|view, event: &DragMoveEvent, _, cx| { view.fraction = widgets::axis_fraction( event.event.position, event.bounds, Axis::Horizontal, 0.15, ); cx.notify(); })) .child(div().w(relative(self.fraction)).child(left)) .child( theme .split_handle(Axis::Horizontal, SplitStyle::Line { dragging: self.dragging }) .id("split-handle") .on_drag(SplitDrag, |_, _, _, cx| cx.new(|_| gpui::Empty)), ) .child(div().flex_1().child(right)) ``` The gesture stays with the caller because the fraction does. `split_handle` centres its line in a grab strip — the line plus 4px of slack each side, the same hitbox zed uses, because a 1px target is unhittable — and lights while `dragging`. The strip's width is `widgets::SPLIT_HANDLE_HIT`, for a caller laying out around it. `SplitStyle::Ghost` takes the same drag and paints nothing, for a pane that already draws the edge itself. Two hairlines a pixel apart read as a seam rather than a divider. `axis_fraction`'s last argument is the dead zone: `0.15` here keeps either pane from being squeezed away, clamping the answer to `0.15..=0.85`. On a zero-extent container, the frame before layout has run, it returns the minimum rather than dividing by zero. `SplitDrag` is a distinct payload type so `on_drag_move::` on one container never fires for an unrelated split's gesture. `SliderDrag` exists for the same reason. --- # Titlebar Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/titlebar.rs ```rust use ui::titlebar; titlebar::titlebar("titlebar", &self.drag, true, window) .px(px(8.0)) .child(title) .child(actions) ``` `DragState` is one field on the view — an `Rc>` like `scroll::FollowState`, so the element carries the gesture and you wire no listeners. The window moves on the first **motion** after a press, never on the press itself. A bar that moved on mouse-down would swallow every click on the buttons sitting in it, and that is the bug the strip exists to not have: the browser version of this in `../desktop` needs a selector listing every interactive descendant to work around it. `traffic_lights` reserves the leading inset for the macOS buttons. Pass it on the one strip they sit over — the leftmost — and it stands down in full screen, where AppKit takes the lights away and the gap would be a hole. The number is `Theme::TRAFFIC_LIGHT_INSET`, and it clears the lights where AppKit puts them: an app that moves them with `TitlebarOptions::traffic_light_position` owns the inset too. Open the window with `appears_transparent: true` **and** `app_owns_titlebar_drag: true`. The second one stops AppKit from dragging the window itself and from delaying titlebar clicks while it waits to see whether a double-click is coming. A double click runs the system's own titlebar gesture — zoom, minimise, or nothing, whichever the user has set. It is a no-op off macOS. --- # Control bar Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/control_bar.rs ```rust use ui::control_bar::{self, Shape}; div().relative().size_full() .child(page) .child( div().absolute().bottom(px(20.0)).left_0().right_0() .flex().justify_center() .child(div().w_full().max_w(px(880.0)).child( control_bar::control_bar(&theme, Shape::Pill, leading, Some(centre), trailing), )), ) ``` Apple Music's transport, an agent app's composer, a floating toolbar — `Shape` is the only thing that differs between them. `Pill` is a stadium, its radius half the bar's height; `Rounded` is the rounded rectangle at `BUBBLE_RADIUS`, which is what most composers want. Two things it exists to get right. **The blur corners follow the border.** One radius comes out of `Shape` and feeds both the border and the backdrop blur, so there is no second number to keep in step — a mismatch frosts square corners outside a round border. **The centre is centred on the bar, not on what the clusters leave.** The two rails are equal-flex and the centre is not, so clusters of five controls and three still keep the middle on axis. Flexing the centre between them is the classic toolbar bug: it lands wherever the wider cluster pushes it. That second rule is why the bar takes the width it is *given* rather than hugging its controls. Equal rails need free space to be equal about, and a shrink-to-fit bar has none. So width and placement are the caller's, and a `max_w` is how a wide window gets a floating bar instead of a docked one. This bar floats over content and must never reflow it — a bar that does reflow is a dock, which is a different thing with no blur and no float. `bar_button(icon, diameter, tint)` is the circular control inside it. The diameter is a parameter because a transport's primary action is deliberately bigger than its neighbours, and that difference is what makes the cluster readable at a glance. It builds the icon rather than taking one, because gpui reads an svg's color off that element's own style and paints nothing when it is unset — a tint set on the button would silently never reach the glyph. Add your own `.hover(..)`: gpui panics on a second hover call, and `Theme::glass_hover` is the wash to reach for. --- # Floating panel Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/floating.rs ```rust use ui::floating::{self, Floating}; // The host holds the state, like a scrollbar's. meter_at: Floating, // Floating::new(Painter::of(cx)) div().relative().size_full() .child(page) .child(floating::panel("meter", &self.meter_at, home, child)) ``` The panel lays a full-size layer over its container and places the box inside it, because the drag has to be heard somewhere larger than the thing being dragged. A pointer that outruns a frame is outside the box for most of the gesture, and a listener mounted on the box would go quiet and leave it stranded behind the cursor. `scroll` hangs its thumb drag off the track for the same reason. **It does not go through gpui's `on_drag`.** That refreshes the entire window on every mouse-move event, and a pointer reports far faster than a window can paint — dragging a panel that way cost a full core. This claims frames from the shared clock instead, at a 60fps ceiling, so the rate belongs to the library rather than to the mouse. A claim is not a redraw: the pointer sample records where the panel now is and schedules, and the clock paints whatever the latest sample said. Movement is carried as a delta from the last sample rather than as an offset from the box's corner. A delta reads the same from the window's origin or the container's, so the panel needs no element bounds to place itself — and a plain mouse listener is not offered any. `home` is where it opens, and it is passed every render rather than stored, so a host can read it off the viewport and a window that grows never strands the panel out of reach. Once dragged, the panel holds a position of its own. Two states, because they answer different questions. `held` is the pointer pressed on it, travelling or not — that is the closed hand, which closes on the press the way every other grabbable surface does. `dragging` is the panel actually moving, past the two pixels that separate a drag from a click — that is the lift, the shadow, whatever a host shows for a thing in flight. It clamps nothing, snaps to nothing and remembers nothing across launches. A panel dragged half off the window stays there, and the point it was grabbed by is under the pointer, so it can always be dragged back. `at()` and `move_to()` are there for a host that wants to persist a position across sessions. --- # Scroll area Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/scroll.rs gpui scrolls a `div` perfectly well and draws nothing while it does. This is that bar, and only that bar: ```rust use ui::scroll; div().relative() // the bar is absolute in here .child( div() .id("pane") .size_full() .overflow_y_scroll() .track_scroll(&self.scroll) // gpui's handle, the app's field .child(content), ) .child(scroll::scrollbar("pane-bar", &self.scroll, &self.scroll_bar)) ``` The bar must span the container it reports on — its track *is* the viewport. A wrapper that swallowed the content would have to re-implement layout for it, which is why there is none. `ScrollbarState` is a field on your view. It holds where in the thumb a drag was grabbed, shaped like gpui's `ScrollHandle` — an `Rc` cell that mutates through `&self` — so the bar carries its whole gesture without the view wiring a single listener. Without it the thumb would jump its middle to the pointer on every press. The bar is an overlay, not a gutter, so one arriving or leaving never reflows the content beneath it. It shows nothing when the content fits, and it takes no `&Theme`: a scrollbar is a neutral overlay, so the thumb is `ink`, which follows the appearance on its own. The geometry is two pure functions, and both of gpui's conventions are easy to get backwards: `max_offset` is the *overflow* — content minus viewport, not content — and `offset` is **negative** as you scroll down. ```rust scroll::thumb(viewport, max_offset, offset, scroll::MIN_THUMB) // -> Option> scroll::offset_for_thumb(top, viewport, max_offset, size) // the inverse ``` `thumb` answers `None` when there is nothing to scroll, when the viewport is zero — the frame before layout has run — and when the thumb would be shorter than `MIN_THUMB` could travel in. --- # Follow scroll Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/scroll.rs Drop it in beside the scrollbar, over the same container: ```rust use ui::scroll; div().relative() .child(div().id("log").size_full().overflow_y_scroll().track_scroll(&self.scroll).child(rows)) .child(scroll::follow(&self.scroll, &self.follow)) .child(scroll::scrollbar("log-bar", &self.scroll, &self.bar)) ``` Telling appended content from a user scroll is the whole problem, and neither is an event to subscribe to — both surface as the same handle reading differently than last frame. The overflow is what separates them: if it changed, the content grew, and the pin stays as the user last set it; if it did not, the offset moved because the *user* moved it, and being at the end is what re-pins. So scrolling up releases and scrolling back down re-attaches, with no gesture to hook. `FollowState` starts pinned — a transcript or a log opens on its newest line — and `state.follow()` re-pins it from a "jump to bottom" button. `scroll::at_bottom(max_offset, offset, slack)` is the predicate, exposed because a jump-to-bottom pill needs the same answer the follow element uses. Content that fits is always at the bottom: there is nowhere else to be, and answering `false` would unpin an empty log. The slack matters — a wheel lands on fractional offsets and a re-layout can move the end by a hair, and without it a view would unpin itself over a rounding error. The correction lands a frame late, since the scrolling div was laid out with the old offset before this runs, which is why the element asks for that frame. At a streaming cadence it is invisible, and it converges: once pinned and at the end, nothing is requested. --- # Table Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/table.rs Reach for a table when the third column of every row has to line up, because reading *down* it is the point. Most lists of things are records, and a record reads better as a `group_box` of `card_row`s. ```rust use ui::table::{self, Align, Column, Width}; const COLUMNS: &[Column] = ..; // one declaration table::table(&theme) .child(table::header(&theme).children(COLUMNS.iter().enumerate().map(|(index, column)| { table::header_cell(&theme, column, sorted_direction(index)) .id(("column", index)) .on_click(cx.listener(move |view, _, _, cx| view.sort_by(index, cx))) }))) .children(rows.iter().enumerate().map(|(index, item)| { table::row(&theme, COLUMNS, index == 0, false, vec![ item.name.clone().into_any_element(), item.kind.clone().into_any_element(), ]) })) ``` A header and a body that size their own cells drift apart the moment either changes, and nothing catches it — both halves look right on their own. So `row` zips its cells onto the same `Column` list the header used, and a cell is never sized where it is written. Cells shorter than columns is a bug in the caller: debug builds assert, release truncates rather than panicking at a user. `Width` is `Fixed(px)` or `Flex(share)` — a share of what is left after the fixed columns have taken theirs. `Align` is `Start` or `End`; there is no `Center`, because in a column of data it is almost always wrong and offering it is how tables end up with one. `Column::align_end()` is what a number wants, so its digits line up by place value rather than by however wide the last one was. Sorting is the caller's. `next_sort` says what a click means, the caller sorts its own rows, and the module paints the arrow: ```rust let sort = table::next_sort(self.sort, column); ``` The sorted column reverses; any other column starts ascending. Inheriting the previous column's direction would mean clicking a fresh heading can sort it descending, which reads as the table ignoring the click. Nothing here holds data, so nothing here can hold it out of date. --- # Tree view Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/tree.rs bezel cannot walk your tree. It has no idea what a node is, and a trait or a callback to find out would be a data model this library does not want to own. So the app flattens what is currently visible — which it has to do to render it anyway: ```rust use ui::tree::{self, Row}; tree::init(cx); // once, at startup let rows = self.flatten(); // Vec<(Row, label)> tree::tree().children(rows.iter().enumerate().map(|(index, (row, label))| { tree::tree_row(&theme, row, self.selected == Some(index), self.cursor == index) .id(("row", index)) .child(label.clone()) })) ``` A depth-annotated flat list is a complete navigation model. Everything a tree does falls out of `Row { depth, expanded }` with no parent pointers and no traversal: down and up are neighbouring indices, a first child is simply the next row, and a parent is the nearest row above with a smaller depth. `expanded` is `None` for a leaf, which is a different thing from a closed branch — the difference is what stops `right` pretending a file can open. Keys report an intent rather than performing one, because applying it means touching the expansion set the app owns: ```rust match tree::step(&rows, self.cursor, tree::Direction::Right) { Some(tree::Move::To(index)) => self.cursor = index, Some(tree::Move::Expand(index)) => self.open.insert(index), Some(tree::Move::Collapse(index)) => self.open.remove(&index), None => {} } ``` Neither end wraps. A menu wraps because it is a ring of choices; a tree is a document, and arriving back at the top because you pressed down once too often loses your place in it. `tree_row` takes two flags: `selected` is what the app considers chosen, `cursor` is where the keyboard is. They are the same pair `popover::menu_row_nav` uses, so a tree and a menu never look like two different products. Scrolling is the caller's, through `scroll`. Expansion stays with the app because it *is* app data — a file tree's open folders often outlive the window. --- # Virtualized list Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/list.rs ```rust use ui::{list, scroll}; div().relative().h(px(240.0)) .child(list::virtual_list("rows", rows.len(), px(28.0), &self.rows_scroll, { let rows = rows.clone(); move |range, _, _| range.map(|ix| row(&rows[ix])).collect() })) .child(scroll::scrollbar( "rows-bar", &list::scroll_handle(&self.rows_scroll), &self.rows_bar, )) ``` Thin on purpose — gpui already does the hard part. The module exists for two things it can guarantee that a caller otherwise has to know. **The row height.** `uniform_list` measures the *first* row it renders and lays every other one out at that height. Hand it rows of different heights and nothing errors: the content simply overlaps at a size nobody chose. `virtual_list` takes the height and applies it to every row it hands back. **The scroll handle.** A `UniformListScrollHandle` wraps a real `ScrollHandle`, and the bar's geometry is all there — behind `handle.0.borrow().base_handle`, which is not something a consumer should have to find by reading gpui's source. `list::scroll_handle` is that reach, named. The clone shares state rather than copying it, so the bar reports on the list the list actually scrolls. The list fills its parent. A virtualized list is bounded by definition, and one with no height of its own collapses — a collapsed list builds a single row to measure and then nothing, which looks like an empty box with no error and no clue. Set your own size after the call if you want otherwise; the later call wins. gpui's other virtualizer, `list()`, handles rows of varying height and cannot carry a proportional scrollbar: `ListState` speaks in `ListOffset { item_ix, offset_in_item }` — logical position, not pixels — with no maximum offset and no viewport. A thumb's length is the visible share of a total height, and a variable-height list cannot know its total without measuring every row, which is the work virtualization exists to skip. --- # Badge Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/content.rs ```rust use ui::widgets; widgets::badge(&theme, "badge") widgets::badge_active(&theme, "active") ``` The plain badge is a hairline pill in the muted text tone; `badge_active` is the emerald "connected / running / on" pill. Both are plain `Div`s, so a badge with an icon in it is a child you add. --- # Tag Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/content.rs ```rust use ui::widgets; widgets::tag(&theme, "rust") ``` The chip paints its own ✕; the click handler for it is yours, because only the caller knows what removing a token means for the list behind it. It sets `self_start`, so dropping one into a column does not stretch it to the column's width. --- # Avatar Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/content.rs ```rust use ui::widgets; widgets::avatar(&theme, "TC") widgets::avatar(&theme, "K") ``` One or two initials. There is no image variant: an avatar with a picture in it is `div().rounded_full().overflow_hidden()` around a gpui `img`, and the interesting part — where the image comes from, what happens while it loads, what happens when it fails — belongs to the app. This is the part that is always the same. --- # Breadcrumb Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/content.rs ```rust use ui::widgets; widgets::breadcrumb() .child(widgets::breadcrumb_item(&theme, "crates", false)) .child(widgets::breadcrumb_separator(&theme)) .child(widgets::breadcrumb_item(&theme, "ui", false)) .child(widgets::breadcrumb_separator(&theme)) .child(widgets::breadcrumb_item(&theme, "widgets.rs", true)) ``` The separators are children rather than something the container inserts, because a trail that collapses in the middle — `crates / … / widgets.rs` — is the caller's decision about its own path, not a rule the container could apply. A `current` crumb takes the text tone and drops the pointer cursor. The rest truncate individually, and the container sets `min_w_0` so a long path shortens rather than pushing its row wide. --- # Pagination Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/pagination.rs A long list is answered by `scroll` and `list`, which will show ten thousand rows and build nine of them. A paginator earns its place only when the *data* is paged and the client cannot hold the whole set: an API that answers "page 4 of 87", a report with a fixed page size, a backend that will not stream. There the page number is not a scrolling affordance, it is the query. The fiddly part is one function: ```rust use ui::pagination::{self, Slot}; pagination::window(6, 20, 2) // 1 … 4 5 [6] 7 8 … 20 ``` ```text current = 6, total = 20 → 1 … 4 5 [6] 7 8 … 20 current = 2, total = 20 → 1 [2] 3 4 5 … 20 current = 3, total = 5 → 1 2 [3] 4 5 ``` Pages are **1-based**, unlike the indices everywhere else in the library: a page number is a label a person reads, not an offset into a slice, and a paginator that can say "page 0" is a bug waiting to be filed. A `current` out of range is clamped rather than trusted — it arrives from the caller's state, and a paint is no place to panic. Two rules earn their tests. A gap that hides exactly **one** page is worse than the page, so that page is shown instead — an ellipsis standing for a single number tells you less while taking the same room. And the window **slides** at the ends rather than shrinking, so walking to the last page never narrows the control under the pointer. Which page you are on, how many there are and how to fetch one are all the caller's. Like the table's sort, this module reports and paints. --- # Empty state Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/content.rs ```rust use ui::{icons, widgets}; widgets::empty_state( &theme, icons::files::FOLDER, "No repositories", "Open a folder to get started.", ) ``` Three fixed slots: a 24px icon, a headline, and one line of hint saying what to do next. It fills its parent's width and centres in it, which is why it usually goes inside a `group_box`. There is no action slot — it returns a plain `Div`, so a button under the hint is a child you add. --- # Skeleton Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/popover.rs ```rust use ui::popover; popover::redacted_rows("recent-sessions", &theme, 3, cx.entity_id(), cx) ``` It takes the calling view's `EntityId` because the pulse is driven by a shared 30fps clock rather than by a per-element animation: the id is what leases this view onto the tick list and what lets the clock park when the last skeleton unmounts. Every row across every view shares one epoch, so nothing beats out of phase with anything else. Rows are staggered — each one enters the wave a little after the one above it — which is what makes a stack read as loading rather than as three boxes blinking together. `popover::Loadable` is the state this pairs with: `Idle` (never requested) → `Loading` (these rows) → `Ready(T)` or `Error(String)`, with `popover::error_row` painting the last one — a plain `Div`, so the retry control is a child you add and wire. --- # Progress Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/controls.rs ```rust use ui::widgets; widgets::progress_bar(&theme, 0.35) ``` The fraction is clamped to `0..=1`, and the track keeps its full width whatever the value, so a row never reflows as progress moves. There is no indeterminate mode here — that is what the loaders are for. --- # Status dot Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/mod.rs ```rust use ui::widgets; widgets::status_dot(theme.success) widgets::status_dot(theme.busy) widgets::status_dot(theme.danger) ``` The only parameter is the color. "Working", "idle", "failed" are the caller's domain, so the mapping from a state to a tone stays there rather than becoming an enum in the library that every app has to translate into. The palette carries the tones worth using: `success`, `busy`, `warning`, `danger`, and `text_faint` for a bead that means nothing in particular yet. --- # Alert strips Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/status.rs ```rust use ui::widgets; widgets::error_strip(&theme, "Something went wrong.") widgets::warning_strip(&theme, "Heads up, check this.") ``` Both return a plain `Div`, so a dismiss control is a child you add and a click handler you attach. The message aligns to the top of the icon rather than centring on it, which is what keeps a two-line message from pushing the triangle into the middle of the strip. Two tones, not a level enum. A strip is either the thing that failed or the thing to watch, and the palette's `danger` and `warning` families are already paired with muted variants for the copy on top. --- # Step row Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/widgets/status.rs A tool call in a transcript, a step in a CI run, a file in a migration: the shape is the same everywhere, which is why this takes strings rather than a type that knows what any of them mean. ```rust use ui::widgets; widgets::step_row( &theme, icons::devices::TERMINAL, "Bash", Some("cargo test -p ui".into()), // truncating middle Some("1.4s".into()), // right-aligned, never truncates false, // failed Some(open), // has an output to disclose ) .id("step-3") .on_click(cx.listener(..)) ``` `detail` is the middle that truncates — a query, a path, a `· 3` count. `meta` is the right-aligned figure that does not: a duration, a size, a row count. `expanded` is `None` when there is nothing under the row, and then the chevron is simply absent. A disclosure that opens onto nothing is worse than no disclosure. Add `.id(..)` and `.on_click(..)` **to the row itself**, never to a wrapper around it, or the hitbox ends up narrower than what it paints. What it opens onto is `step_output`: ```rust widgets::step_output(&theme, "step-3-out", stdout) ``` Monospaced and capped in height, because the thing being shown is a program's stdout and the row it hangs off is one line tall — a 900-line stack trace pushing the next step off screen is the failure the cap exists for. Past the cap it scrolls, which is why it takes an id. No scrollbar: the wheel reaches it anyway, and a bar would demand a `ScrollHandle` and a `ScrollbarState` from every caller for a box that is usually four lines long. A row that opens itself while work streams in wants `Takeover`: ```rust let open = self.details.get(self.running); // follows `running` until touched self.details.toggle(self.running); // …and the click wins from here ``` It is an `Option` rather than the two flags it reads as, because "untouched, and here is the manual value" is a state that cannot mean anything — this way it cannot be written. --- # Loaders Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/loaders.rs The orbs are bezel's own, and what a thinking surface should reach for: ```rust use ui::loaders::{self, Orb}; loaders::orb(Orb::Cluster, "thinking", 44.0, &theme, cx.entity_id(), cx) ``` One function with a shape parameter rather than four functions: they are the same operation, and the thing that differs is an argument. `Cluster` is blobs whose sizes swing so the count you perceive changes; `Ring` is dots on a circle with the brightness chasing round; `Converge` gathers them to a point and opens back out; `Bloom` is rings leaving the centre and fading before the edge — the only one that travels outward, which is what makes it read as a signal rather than a wait. Everything is circles, because that is the vocabulary gpui gives at the pinned rev: no rotation transform, no conic gradient, no blur filter on an element. So the glow is a `BoxShadow`, the ring is eight positioned dots rather than a swept arc, and every position is arithmetic — all of it pure and unit-tested in `motion::phase`. One tint, from the theme's accent. In three hues this would be the gradient spinner wearing a different shape. The older three are grids of cells: `pulse_loader` (a row), `gradient_spinner` (3×3) and `mini_gradient_spinner` (2×3). `loading_word` is the spaced "L O A D I N G" caption that goes under one. They all take the calling view's `EntityId` and drive off the shared 30fps pulse clock rather than a per-element repeating animation, so instances stay phase-locked and the clock parks when the last one unmounts. Cells animate inside fixed-size slots — opacity and inner size are paint-local and never move the layout around them. Reduced motion snaps every cell to its rest state. --- # Stats Source: https://github.com/crabtalk/bezel/blob/main/crates/ui/src/stats.rs ```rust use ui::{ floating::{self, Floating}, stats::Stats, }; // Two fields on the view that mounts it: the meter, and where it floats. meter: Entity, meter_at: Floating, // In render, inside a `relative()` container. floating::panel("meter", &self.meter_at, home, self.meter.clone()) ``` **A window at rest reads `0`.** That is the number the whole thing exists for. Anything above it while nothing on screen is moving means something is asking for frames, and a single element can hold a whole window at the display's rate — one mounted spinner drawn at 120Hz cost 36% of a core on the machine this library was built on. The count is this view's own renders, which is the same number as the window's draws: gpui re-renders every uncached view once per draw. The one render it does not count is the one its own tick provoked, and the clock says which that was rather than anything here inferring it from a stopwatch. Those two draws a second are what the meter itself costs, and the CPU figure includes them. **CPU** is the whole process — user plus system, every thread — as a percentage of one core, which is the figure Activity Monitor prints. It comes from `getrusage`, so it reads `—` where the platform has no such call, including the web build. **GPU** is the time the GPU spent on this window's frames over the same interval. It reads `—` on a renderer that does not report it. Placement is the caller's, as it is for the control bar. Mounting it in a [floating panel](/docs/floating) is what makes it draggable; a corner works just as well if you would rather it stayed put. --- # Activity Source: https://github.com/crabtalk/bezel/blob/main/apps/gallery/src/patterns/agent.rs The one screen an agent app cannot borrow from anything else, and the pattern that proved two pieces belonged in the library rather than in an agent crate. `scroll::follow` pins the reasoning box to its newest line while the run writes into it. `widgets::Takeover` opens the section while that is happening and hands it over the moment you press the header: ```rust let open = self.thinking.get(self.running); // follows the run… self.thinking.toggle(self.running); // …until the header is pressed ``` Everything else on the page is a `div`. That is the finding: composing `LiveActivity` and `Thought` from the app this was extracted from produced exactly two library pieces, and both are general — a terminal wants follow-scroll, a build log wants a section that unfolds while it runs. The source is at `apps/gallery/src/patterns/agent.rs`. Copy the file. The answer zone is plain text on purpose. Streaming markdown is the `markdown` crate's job, and this page claims the *activity* zone works, not the answer zone — which is also why there is one exchange here rather than a transcript. --- # Tool calls Source: https://github.com/crabtalk/bezel/blob/main/apps/gallery/src/patterns/agent.rs What looked like a component — a "tool group" — is `slice::chunk_by` over consecutive calls of the same verb: ```rust for run in CALLS.chunk_by(|a, b| a.verb == b.verb) { // one row, or a folded group of them } ``` bezel wrote nothing for that. The rows are `widgets::step_row`, the output under an open row is `widgets::step_output`, and the grouping is std. The two shapes are here as well, and they are not a parameter anywhere in the library: a lone call is a bordered card, a grouped one is a bare row, and the group's box owns the border and the hairlines between its rows. `step_row` takes strings, so bezel never learns what a tool call *is*. The icon, the verb, the detail and the duration are the app's vocabulary; the row is the shape they share with a CI step and a migration. The source is at `apps/gallery/src/patterns/agent.rs`. --- # Composer Source: https://github.com/crabtalk/bezel/blob/main/apps/gallery/src/patterns/agent.rs The prompt box is one library call — `Shape::Grow { min, max }`, the field that grows with what you type and scrolls past its maximum — on a card of `Theme::card_glass_bg` with a row of controls under it. `enter` sends and `shift-enter` breaks a line, which is a key context of the field's own: ```rust TextField::new(cx) .with_shape(Shape::Grow { min: 3, max: 12 }) .with_key_context(COMPOSER_CONTEXT) .with_placeholder("Ask anything, or # to attach a file") ``` So the only thing this pattern had to invent is the mention picker, and that is `popover::Filter` — the combobox's own state — mounted at a caret instead of under a trigger. Its trigger is a *read* of the text rather than a key handler: the `#` nearest behind the caret, if nothing since it has been whitespace. Typing, pasting, arrowing back into a word and deleting the `#` then all agree without any of them being special-cased, and the picker closes on a backspace over the `#` without anything having to tell it. It hangs under the `#` itself: ```rust let anchor = self.field.read(cx).offset_bounds(hash, window)?; popover::menu_at( "composer-mentions", gpui::point(anchor.left(), anchor.bottom() + px(4.0)), card, None, ) ``` `offset_bounds` is the same measurement the IME candidate panel anchors to, so the menu follows the caret down as the box grows a row at a time. What `#` offers is a `Vec`. The app searches its own store; bezel takes a list of strings, which is the whole difference between a library and an app. The source is at `apps/gallery/src/patterns/agent.rs`. --- # Transcript Source: https://github.com/crabtalk/bezel/blob/main/apps/gallery/src/patterns/transcript.rs `scroll::follow` pins it to the newest line, `widgets::Takeover` runs each turn's work zone, `widgets::step_row` draws the tool calls, and `markdown::markdown` renders the answers — which is why this page could not be honest until that crate existed. The screen it was ported from is 943 lines and produced no library code. That was the prediction, and the measurement is that its three reducers are all standard library: - **Turns** — a question and the answer it drew — are `chunk_by`: start a chunk at every question. - **The zone split** is `rposition`. The answer is the prose after the last tool call; everything before it is interim. That one sentence is the entire rule, and it is what stops a model's thinking-out-loud from being presented as its reply. - **A run of adjacent tool calls** is `chunk_by` again, and the `Verb · N` fold inside it is the same `chunk_by` the tool calls page uses. What is left of the 943 lines is IPC, project lookups, sticky-scroll measurement and error parsing — an app's job, all of it. The beat type is page-local and deliberately not a library type: `step_row` takes strings, so bezel never learns what a tool call is. The source is at `apps/gallery/src/patterns/transcript.rs`. Copy the file. --- # Diff Source: https://github.com/crabtalk/bezel/blob/main/apps/gallery/src/patterns/diff.rs A diff row is two numbers, a sign and a line of text: three `div`s and a color, with no reducer, no state and nothing to measure. Next to `tree` (flattening plus an arrow-key walk) or `table` (a sort reducer and a cell-count guard) it would be a paint helper wearing a component's badge — so it lives in the file you would copy rather than in `crates/ui`. The attempt is the finding. What is actually hard about a diff view — folding hunks, word-level marks inside a changed line, syntax highlighting, two panes scrolling together — is either the app's or waits on a syntax crate. None of it is here, and calling this a component would have promised all four. The rows arrive already decided. bezel never computes a diff; whatever produced it is the app's business, the same line `tree` holds about your file system. The header over it is `widgets` chrome, and the tones are the palette's `diff_add`, `diff_del` and `diff_hunk_bg`. The source is at `apps/gallery/src/patterns/diff.rs`. --- # Blob avatars Source: https://github.com/crabtalk/bezel/blob/main/apps/gallery/src/patterns/avatar.rs ```rust use agent::Face; // The face is the seed; `pose(t)` samples it at an instant. `t` is your own // clock, and a face that never moves is `pose(0.)`. The canvas fills its // layout bounds and centers the design space inside. div() .w(px(48.)) .h(px(48.)) .child(agent::avatar(Face::from("Sara").pose(t))) ``` `Face::from(name)` derives the silhouette and the eyes from the name, so the same person is recognizably the same on every surface. Color is the caller's: left unset it follows `theme.accent`, and the painter takes the eye ink from whichever end of the theme reads as a hole in that body. ## Pixel The same face on an eight-cell grid, for the sizes a spline cannot survive: ```rust div().w(px(13.)).h(px(13.)).child(agent::mascot(&Face::from("Sara"), t)) ``` `mascot` takes the `Face` rather than a `Pose` because it samples the silhouette per cell instead of tracing an outline — which is also why it cannot draw a blend of two faces, where the spline painter can. Eyes are punched through rather than painted, so a row dims whole instead of the eyes fighting the body on the way down. Two deliberate deviations from the reference: input is trimmed and lowercased but not NFC-normalized, and the trait reader skips the `pick`/`bool` pair the ported geometry never calls. Pinned byte-for-byte against the reference's golden fixture in `crates/agent/tests/avatar.rs`, so a future drift must be a deliberate change. --- # Document Source: https://github.com/crabtalk/bezel/blob/main/apps/gallery/src/patterns/document.rs Nothing on this page is library code. The reader is an outline, a scroll area and a segmented toggle; the calls into the library are `markdown::render` and `markdown::serialize`. Typing into a document is the `editor` crate, one page along. **The outline is a `filter`, not a walk.** A `Doc` is a flat list of blocks carrying their own indent, so the table of contents is one pass picking out headings: ```rust doc.blocks .iter() .filter_map(|block| match &block.kind { BlockKind::Heading { level, text } => Some((*level, text.text.clone())), _ => None, }) .collect() ``` On a nested document tree the same list costs a recursive descent that has to reconstruct depth on the way down. That is the whole argument for the flat model, and it is the same reason the editor's Enter and Backspace are list operations rather than restructures. **Source view is the round trip.** The Source segment does not show the string the file holds — it shows `serialize(&doc)`, the document written back out, and it matches the original byte for byte. That is what makes an edit/save cycle safe, and it is the one property worth *seeing* rather than reading about in a test. `parse` and `serialize` are inverses up to a fixed point: parse, serialize, parse again, and the document is unchanged. Byte-identical round tripping is deliberately not promised, because a flat model cannot represent arbitrarily nested CommonMark. The source is at `apps/gallery/src/patterns/document.rs`. Copy the file. --- # Editor Source: https://github.com/crabtalk/bezel/blob/main/apps/gallery/src/patterns/editor.rs `markdown` holds the document and paints it; `editor::Editor` is the surface you type on — focus, keys, the mouse, undo and the menus. ```rust use editor::Editor; editor::init(cx); // once, at startup let scroll = ScrollHandle::new(); let editor = cx.new({ let scroll = scroll.clone(); |cx| Editor::new("# Notes", cx).with_scroll(scroll) }); cx.observe(&editor, |_, _, cx| cx.notify()).detach(); ``` Pass the scroll handle of the pane the document sits in, not one of the editor's own, or the caret cannot follow typing down the page. Typing notifies the editor, so anything a host reads off it needs that `observe`. Without it the markdown pane on this page would freeze on the opening text and the toolbar would never appear at all. `editor.source()` is the document written back to markdown, normalized, on every keystroke — what a save would write, and what fills the right pane here. A toolbar is three calls: `selection_bounds()` for where to float it, `toggle_mark` for what a button does, and `covered_by` for whether it is lit. That is the same entry point cmd-B takes, so a button and a chord cannot disagree. ```rust let lit = editor.doc().covered_by(editor.selection(), &mark); editor.update(cx, |editor, cx| editor.toggle_mark(mark, cx)); ``` `Mark::Code` over a selection spanning more than one line makes a fence instead of an inline span, and the same call takes it back out. The slash menu, the gutter handle, drag-to-reorder, the language picker on a fence, the menu that turns a pasted URL into a chip, a bookmark, an embed or a picture, undo and the clipboard need no wiring — the source behind this page contains not one line for any of them. Undo keeps 100 steps, coalesced so a run of typing comes back as a word rather than a character; `with_undo_limit` for more. An image arrives four ways: a pasted URL that names one, `/image` and the row it leaves asking for a URL, a file dragged in from the desktop, and a screenshot off the clipboard. Only the last needs wiring, because bytes have no address and a document holds one: ```rust editor::set_image_store(cx, |source| match source { editor::Source::File(path) => Some(path.to_string_lossy().into_owned()), editor::Source::Bytes(image) => save_somewhere(image), // your assets, your URL }); ``` A dropped file is offered to the store first so an app that keeps its own asset directory can copy it in; answer `None` and the picture paints from where it already is. With no store installed a screenshot cannot be pasted at all. The caption under a picture is its alt text, and a caret sits in it like any other line. `init` binds `ui::input::TextField`'s chords inside the editor's own key context, so `tab` indents a list here and means nothing outside one. Replace that call for a different keymap. Moving, duplicating and deleting a block ship as actions with no chord for an app to bind as it likes; the block menu on the gutter handle reaches them meanwhile. `editor` is a peer crate you name yourself, alongside `markdown` and `syntax`. The source is at `apps/gallery/src/patterns/editor.rs`. Copy the file. --- # Syntax Source: https://github.com/crabtalk/bezel/blob/main/apps/gallery/src/patterns/syntax.rs Nothing on this page is library code. It wraps a sample in a fence and hands the result to `markdown::render`. The call underneath is the crate's whole surface: ```rust syntax::highlight(code, "rs") // -> Option, HighlightKind)>> ``` Spans in document order, in bytes, and everything outside them is plain text. No colour and no rendering here — kinds become colours through `SyntaxPalette`, and a capture name with no slot in the bezel vocabulary degrades to `Variable`, which paints as body text. A tag naming no grammar returns `None` and the block renders plain. There is no injection machinery either: the fence already names the grammar, so a block is one parse with one query. Eight languages ship, each answering to its fence aliases — `rust`/`rs`, `python`/`py`, `typescript`/`ts`, `tsx`/`jsx`/`javascript`/`js`, `json`/`jsonc`, `go`/`golang`, `bash`/`sh`/`shell`/`zsh`/`console`, `toml`. JavaScript rides the TSX grammar, because TSX parses JS and a second grammar would buy only the `<`-ambiguity cases a highlighted sample does not hinge on. **One feature per language, all on by default.** A grammar is a C compile, so an app that only ever shows Rust should only ever build one: ```toml syntax = { version = "0.0.2", default-features = false, features = ["rust"] } ``` That is seven grammars down to one, and a measured 12.4s of clean build down to 3.5s. The `typescript` feature carries both the TypeScript and TSX rows, since they are one grammar crate. The language table is a slice and not a fixed-size array precisely so its length can follow the features; with none of them on it is empty, every tag resolves to `None`, and every block paints plain. **`markdown` names no highlighter.** It does not depend on `syntax` at all — the two meet at one function pointer, installed at boot like the theme palette: ```rust fn spans(language: &str, code: &str) -> Option, HighlightKind)>> { syntax::highlight(code, language) } markdown::set_highlighter(cx, spans); ``` Note the argument order flips: `Highlighter` takes the language first, `syntax::highlight` takes the source first. Both are `&str`, so a swap compiles and silently colours nothing. **`syntax` is a peer crate, not part of the `bezel` facade.** You name it yourself, and an app that highlights nothing never compiles a grammar — the facade carried it once, which cost every consumer seven C grammar builds and made `bezel` unbuildable for `wasm32-unknown-unknown` outright. **A language the table does not carry is a `static` of your own.** `Lang::new` is `const`, so it sits beside the built-in rows and reaches the same `highlight` method — the query cache, the capture-name filter and the `HighlightKind` vocabulary all come with it, and none of it has to be rebuilt: ```rust use syntax::lang::Lang; static ZIG: Lang = Lang::new( "zig", &["zig"], tree_sitter_zig::LANGUAGE, include_str!("../queries/zig.scm"), ); ZIG.highlight(code) ``` Take the grammar's `LanguageFn` through `syntax::tree_sitter_language` rather than declaring your own tree-sitter. Two versions in one graph are two unrelated types with the same name, and `Lang::new` will reject the stranger — the same hazard `bezel::gpui` exists to prevent, one layer down. That extension point and the seam above it are different tools. `Lang` is for another tree-sitter grammar; the function pointer is for another *engine*, and nothing about it is tree-sitter at all — `Range` and `HighlightKind` are the entire vocabulary: ```rust fn spans(language: &str, code: &str) -> Option, HighlightKind)>> { match language { "zig" => ZIG.highlight(code), _ => syntax::highlight(code, language), } } ``` Swapping the engine wholesale — syntect, a regex pass, nothing at all — is the same function with a different body. This is why the grammar table stays private to `syntax`: opening it would put tree-sitter's own types in the public API, and an app carrying its own tree-sitter would then have two. **A browser cannot run any of it.** tree-sitter is C, and `wasm32-unknown-unknown` has no libc to compile it against, so a web build would carry a dependency it can never link — which is the reason `markdown` names no highlighter in the first place. A build script runs on the host whatever the target is, so the gallery highlights its samples ahead of time and the wasm build looks the answer up by `(tag, source)`; a block the build script never saw paints plain, which is what an unknown language does anyway. Highlighting recolours runs and never moves layout: the block is laid out line by line, so a build with no highlighter installed paints the same shape in one plain run. The source is at `apps/gallery/src/patterns/syntax.rs`, and the highlighter it installs at `apps/gallery/src/highlight.rs`. Copy the file.