How to create a control specialization
Create a template: it defines the visual appearance. Use it with the existing control, and you’re done.
Create your template
Implement the existing control’s template trait and install your template with .template(...). This SplitButton example styles interaction states and connects events to a small in-memory editor:
use std::sync::Arc;
use gpui::{App, Context, Div, Entity, Render, Stateful, Window, div, prelude::*, px, rgb};
use xgpui_luma::controls::split_button::{
SplitButton, SplitButtonRenderModel, SplitButtonTemplate,
SplitButtonTemplateHandlers, ThemedSplitButtonTemplate,
};
use xgpui_luma::infra::menu_item::MenuItem;
use xgpui_luma_look_shadcn::ShadcnLook;
struct SaveTemplate(ThemedSplitButtonTemplate);
impl SplitButtonTemplate for SaveTemplate {
fn render(
&self,
model: &SplitButtonRenderModel<'_>,
handlers: SplitButtonTemplateHandlers,
window: &mut Window,
cx: &mut App,
) -> Stateful<Div> {
let primary = model.split_state.primary;
let disclosure = model.split_state.disclosure;
let accent = if !model.enabled {
rgb(0x94a3b8)
} else if primary.pressed || disclosure.pressed {
rgb(0x1d4ed8)
} else if model.state.focused {
rgb(0x2563eb)
} else if primary.hovered || disclosure.hovered || model.open {
rgb(0x60a5fa)
} else {
rgb(0xcbd5e1)
};
let hint = if !model.enabled {
"Saving is unavailable"
} else if model.open {
"Choose an alternative"
} else if disclosure.pressed || disclosure.hovered {
"More save options"
} else if primary.disabled {
"No changes to save; alternatives are available"
} else if primary.pressed {
"Release to save"
} else {
"Save changes · Enter, or Down for options"
};
// The base paints each face from its own state and binds the handlers:
// action_click -> primary; trigger_click -> disclosure; item_clicks -> menu.
// Keep its focus ring, accessibility, and popup lifecycle intact.
self.0.render(model, handlers, window, cx)
.border_1().border_color(accent).rounded(px(10.0))
.child(div().px(px(8.0)).py(px(4.0)).text_size(px(11.0))
.text_color(accent).child(hint))
}
}
// A small in-memory editor: Save commits the draft; the menu can copy or revert it.
struct Editor {
save: Entity<SplitButton>,
draft: String,
saved: String,
copies: Vec<String>,
status: String,
}
impl Editor {
fn new(look: &ShadcnLook, cx: &mut Context<Self>) -> Self {
let template = SaveTemplate(
ThemedSplitButtonTemplate::new(look.split_button_theme()),
);
let save = SplitButton::new("save")
.label("Save")
.primary_enabled(false)
.template(Arc::new(template))
.items([
MenuItem::new("save-copy").label("Save a copy"),
MenuItem::new("revert").label("Revert changes"),
])
.on_action_click(cx, |editor: &mut Self, cx| {
editor.saved = editor.draft.clone();
editor.status = "Saved".into();
editor.sync_save_state(cx);
})
.on_select(cx, |editor, item_id, _, cx| {
match item_id.as_ref() {
"save-copy" => {
editor.copies.push(editor.draft.clone());
editor.status = "Copy saved".into();
}
"revert" => {
editor.draft = editor.saved.clone();
editor.status = "Changes reverted".into();
}
_ => return,
}
editor.sync_save_state(cx);
})
.spawn(cx);
Self { save, draft: String::new(), saved: String::new(),
copies: Vec::new(), status: "No changes".into() }
}
// Call from the application's text-change callback.
fn edit(&mut self, text: String, cx: &mut Context<Self>) {
self.draft = text;
self.status = "Draft updated".into();
self.sync_save_state(cx);
}
fn sync_save_state(&mut self, cx: &mut Context<Self>) {
let dirty = self.draft != self.saved;
self.save.update(cx, |save, cx| save.set_primary_enabled(dirty, cx));
cx.notify();
}
}
impl Render for Editor {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().flex().flex_col().items_start().gap(px(8.0))
.child(self.save.clone())
.child(self.status.clone())
}
}The template reads split_state.primary and split_state.disclosure independently, plus composite focus, enablement, and menu-open state. The base renderer keeps each face’s feedback and input bindings; the custom template adds a state-dependent frame and hint. Pass the handlers through so keyboard, pointer, and menu events retain their existing behavior.
Application work stays in on_action_click and on_select. Editing enables Save; saving or reverting disables only the primary face, leaving alternatives available. Render Editor inside your normal SDK focus scope with default control/focus key bindings.
That’s all you need for use in your application. Add behavior functions only if the interaction changes. SplitButton’s primary action calls activate_primary; its disclosure reuses the existing menu-toggle behavior.
Optional: contribute the control to the repository
To make the specialization reusable for other applications, add the supporting SDK and Look integration:
- Builder: expose construction, options, and
.spawn(cx). - Model and behavior: expose any new state or behavior the existing control cannot express; reuse its infrastructure.
- Events and callbacks: expose new semantic actions, such as SplitButton’s separate primary activation and menu selection.
- Look integration: provide the template/theme factory and export the public types.
- Tests: cover changed pointer, keyboard, disabled-state, and focus behavior.
- Studio and documentation: add an example, control reference, and Look support entry.
SplitButton’s SDK module shows this packaging. See its control reference for API and migration details.