guise
Docs / Components
Previewrendered with guise's palette
Results128 rows
Card chrome, header actions, footer.
PanelControlled collapse, like Modal.
SplitPanelDraggable divider, Resized events.

Panels

Panel is a stateless builder — Card chrome plus header/footer framing and a controlled collapse. SplitPanel is a stateful entity: two live panes with a draggable divider, emitting Resized events.

Panel

A titled surface: Card chrome plus a header row (collapse chevron, icon, title/description on the left, actions on the right), the body, and an optional footer. The header gets a bottom divider whenever the body is visible. Collapsing is controlled, like Modal — the parent owns the flag and flips it in on_toggle. Implements ParentElement for the body.

Panel::new()
    .id("status")
    .title("Project status")
    .description("Weekly summary")
    .icon(ThemeIcon::new("▦").color(ColorName::Blue))
    .action(ActionIcon::new("status-more", "…").size(Size::Sm))
    .collapsible()
    .collapsed(self.collapsed)
    .on_toggle(cx.listener(|this, _ev, _window, cx| {
        this.collapsed = !this.collapsed;
        cx.notify();
    }))
    .footer(Text::new("Updated 5 minutes ago").size(Size::Xs).dimmed())
    .child(Text::new("Everything on track."))
Method Default Notes
new() body content via .child/.children
id(impl Into<ElementId>) none scopes the chevron's element id; set one when several collapsible panels are siblings
title(text) none semibold header title
description(text) none dimmed line under the title
icon(impl IntoElement) none leading header content (e.g. a ThemeIcon)
action(impl IntoElement) appends one trailing header action
actions(Vec<AnyElement>) replaces the trailing actions
footer(impl IntoElement) none rendered under the body behind a top divider
padding(Size) Lg body padding; header/footer use 0.75× vertically
radius(Size) Md
with_border(bool) true
shadow(Size) Sm
collapsible() off shows the chevron ActionIcon in the header
collapsed(bool) false hides body + footer; the parent owns the flag
on_toggle(handler) Fn(&ClickEvent, &mut Window, &mut App); flip the parent's flag
Note

collapsed(true) only takes effect together with collapsible() — a non-collapsible panel always shows its body.

SplitPanel (entity)

Two live panes separated by a draggable divider. Pane content is a builder closure re-invoked every render (like Tabs panels), so panes show live data — including another SplitPanel's element for nested layouts. Give the element a sized parent; the panel fills it. Dragging clamps to min_first/min_second and emits SplitPanelEvent::Resized(ratio).

let split = cx.new(|cx| {
    SplitPanel::new(cx)
        .direction(SplitDirection::Horizontal)
        .ratio(0.35)
        .min_first(140.0)
        .min_second(200.0)
        .first(|_w, _cx| Text::new("Sidebar"))
        .second(|_w, _cx| Text::new("Main content"))
});
cx.subscribe(&split, |_this, _split, event: &SplitPanelEvent, cx| {
    let SplitPanelEvent::Resized(_ratio) = event; // persist it, relayout, …
    cx.notify();
})
.detach();

// In render:
div().h(px(300.0)).w_full().child(self.split.clone())
Method Default Notes
new(cx)
direction(SplitDirection) Horizontal Horizontal = side by side (col-resize cursor), Vertical = stacked (row-resize)
first(closure) / second(closure) Fn(&mut Window, &mut App) -> impl IntoElement, rebuilt each frame
ratio(f32) 0.5 initial first-pane share, clamped to 0..=1
min_first(f32) / min_second(f32) 40.0 minimum pane size in px while dragging
handle_size(f32) 6.0 divider grab-area thickness (min 1)
current_ratio() read the live ratio

Events: SplitPanelEvent::Resized(f32) — emitted continuously while dragging.

PaneGroup (entity)

A recursive tree of tabbed panes — the Zed / VS Code editor-group workspace model, where splits contain tabs (not tabs containing splits). The window is one tree of splits whose leaves are panes, and each pane has its own tab bar. Users drag a tab to reorder it, drop it on another pane's center to move it there, drop it on a pane edge to split, and resize with the dividers.

PaneGroup owns the layout; the host owns the items (their real content entities) and supplies, per item, a content element and a title via builder closures (re-invoked each render, so content stays live). The host reacts to PaneGroupEvents and drives the layout through the model methods. Items are opaque ItemIds — the unit that moves between panes; the host maps them to whatever it owns (editors, terminals, views).

let group = cx.new(|cx| {
    PaneGroup::new(first_item_id, cx)
        .on_item_title(|item, cx| my_title(item, cx))         // -> SharedString
        .on_render_item(|item, window, cx| my_view(item, window, cx)) // -> AnyElement
});
cx.subscribe(&group, |this, group, event: &PaneGroupEvent, cx| match event {
    PaneGroupEvent::NewRequested(pane) => {
        let item = this.create_item(cx);                       // host makes the item
        group.update(cx, |g, cx| g.add_item(*pane, item, cx));
    }
    PaneGroupEvent::CloseRequested(item) => {
        this.drop_item(*item);                                 // host tears it down
        group.update(cx, |g, cx| g.close_item(*item, cx));
    }
    PaneGroupEvent::Activated(_) | PaneGroupEvent::FocusChanged(_) => cx.notify(),
})
.detach();

// In render: give it a sized parent; it fills it.
div().size_full().child(self.group.clone())
Method Notes
new(first_item, cx) one pane holding first_item
on_render_item(closure) Fn(ItemId, &mut Window, &mut App) -> AnyElement, per item, each frame
on_item_title(closure) Fn(ItemId, &App) -> SharedString, the tab title
add_item(pane, item, cx) add a tab to a pane and activate it
add_to_focused(item, cx) add a tab to the focused pane
split(pane, dir, first, item, cx) split a pane, put item in the new pane
activate(pane, item, cx) activate an item and focus its pane
close_item(item, cx) remove an item; collapse its pane if it empties
move_item(item, to_pane, edge, cx) move an item onto a pane (edge = Some splits, None adds as a tab)
reorder_in_pane(item, index, cx) reorder a tab within its pane
set_ratio(split, ratio, cx) set a divider ratio
focus_direction(dir, cx) focus the pane up/down/left/right (via layout geometry)
activate_next(cx) / activate_prev(cx) cycle tabs in the focused pane
equalize(cx) reset every divider to an even split
resize_focused(dir, step, cx) nudge the divider adjacent to the focused pane
toggle_zoom(cx) / is_zoomed() focused pane fills the group
close_focused(cx) request close of the focused pane's active item
tear_off(item, cx) detach an item and emit TearOff for the host to re-home
tree() / pane_items(pane) read the layout for persistence
items() / pane_of(item) / focused_pane() / active_item() queries

Events: PaneGroupEvent::{Activated(ItemId), CloseRequested(ItemId), NewRequested(PaneId), FocusChanged(PaneId), TearOff(ItemId)}. Window creation for a torn-off tab stays a host concern — subscribe to TearOff and open a window with the item's content. The host wires the tear-off gesture (a tab dragged outside the window, or a menu item) and calls tear_off.

Titlebar integration. .titlebar(leading, trailing) makes the group double as the window titlebar (Zed-style — the top pane's tab bar is the top row, no separate strip). The top-left pane's tab bar reserves leading px on the left (for the macOS traffic lights), the top-right pane's reserves trailing px on the right (for custom controls), and the filler after the top-right tabs becomes a window-drag region (double-click zooms). Render the group flush to the window top and overlay your own controls in the reserved insets.

Nesting works out of the box: gpui delivers on_drag_move for every active drag of a payload type anywhere in the window, so each divider's drag payload carries its owning entity id and an inner divider never resizes the outer panel. Nest by returning the inner entity's clone from a pane closure:

let inner = cx.new(|cx| {
    SplitPanel::new(cx)
        .direction(SplitDirection::Vertical)
        .first(|_w, _cx| Text::new("Editor"))
        .second(|_w, _cx| Text::new("Terminal"))
});
let outer = cx.new(|cx| {
    SplitPanel::new(cx)
        .first(|_w, _cx| Text::new("Sidebar"))
        .second(move |_w, _cx| inner.clone())
});