From qol-langs
Use when writing gpui UI code in this workspace. Verified patterns and gotchas for gpui 0.2 + gpui-component, built through hands-on exploration — NOT a generic gpui reference. For canonical gpui docs, use context7 (though coverage is limited; this skill is the better source for real-world patterns). Covers views, input, lists, focus, window management, testing.
How this skill is triggered — by the user, by Claude, or both
Slash command
/qol-langs:gpui-conventionsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Bespoke documentation for gpui, built through hands-on exploration.
Bespoke documentation for gpui, built through hands-on exploration.
[dependencies]
gpui = "0.2"
gpui-component = "0.5.0"
Requires: Rust stable, macOS or Linux.
sudo apt install gcc g++ libasound2-dev libfontconfig-dev libwayland-dev \
libx11-xcb-dev libxkbcommon-x11-dev libssl-dev libzstd-dev libvulkan1 \
libgit2-dev make cmake clang mold libstdc++-14-dev
use gpui::*;
actions!(launcher, [Quit]);
struct MyView;
impl Render for MyView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.bg(rgb(0x1e1e2e))
.child("Hello gpui")
}
}
fn main() {
Application::new().run(|cx: &mut App| {
cx.bind_keys([KeyBinding::new("escape", Quit, None)]);
cx.on_action(|_: &Quit, cx: &mut App| cx.quit());
let bounds = Bounds::centered(None, size(px(600.), px(42.)), cx);
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: None,
focus: true,
..Default::default()
};
cx.open_window(options, |_, cx| cx.new(|_| MyView)).unwrap();
cx.activate(true);
});
}
Top-level application context in run() callback. Used to:
cx.new(|_| MyView)cx.open_window(options, |window, cx| { ... })cx.bind_keys([...])cx.on_action(|action, cx| { ... })View-specific context passed to render(). Used for state updates and notifications.
Passed to render(). Used for window operations like window.resize().
Views implement this to draw UI:
impl Render for MyView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().child("content")
}
}
Chain methods on elements:
div()
.flex() // display: flex
.flex_col() // flex-direction: column
.gap_2() // gap: 0.5rem (2 * 0.25rem)
.p_4() // padding: 1rem
.px_2() // padding-x: 0.5rem
.bg(rgb(0x1e1e2e)) // background color
.text_color(white()) // text color
.rounded_md() // border-radius
.shadow_lg() // box-shadow
.w(px(600.)) // width: 600px
.h(px(42.)) // height: 42px
.size_full() // width: 100%, height: 100%
Use gpui-component for a robust input field.
use gpui_component::input::{Input, InputState};
struct MyView {
input: Entity<InputState>,
}
impl MyView {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
// Create state (needs window access)
let input = cx.new(|cx|
InputState::new(window, cx)
.placeholder("Search...")
);
// Listen to changes
cx.subscribe_in(&input, window, |_, _, event, _, _| {
if let gpui_component::input::InputEvent::Change = event {
println!("Input changed");
}
}).detach();
Self { input }
}
}
impl Render for MyView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
Input::new(&self.input)
}
}
Use gpui-component's List for virtualized lists with keyboard navigation.
use gpui_component::list::{List, ListDelegate, ListState, ListItem};
struct MyDelegate {
items: Vec<String>,
selected_index: Option<IndexPath>,
}
impl ListDelegate for MyDelegate {
type Item = ListItem;
fn items_count(&self, _section: usize, _cx: &App) -> usize {
self.items.len()
}
fn render_item(&mut self, ix: IndexPath, _window: &mut Window, _cx: &mut Context<ListState<Self>>) -> Option<Self::Item> {
Some(ListItem::new(("item", ix.row))
.child(self.items[ix.row].clone()))
}
fn set_selected_index(&mut self, ix: Option<IndexPath>, _window: &mut Window, _cx: &mut Context<ListState<Self>>) {
self.selected_index = ix;
}
}
// In your view:
let list_state = cx.new(|cx| ListState::new(delegate, window, cx));
// Render:
List::new(&list_state).h_full()
gpui-component::List handles Up/Down/Enter automatically if focused.
To confirm selection:
// In MyDelegate
fn confirm(&mut self, _secondary: bool, _window: &mut Window, _cx: &mut Context<ListState<Self>>) {
if let Some(ix) = self.selected_index {
println!("Confirmed item at index {:?}", ix);
}
}
GPUI uses FocusHandle to track focus. gpui-component manages this internally for Input and List.
To focus an element programmatically:
// Focus input
self.input_state.update(cx, |state, cx| state.focus(window, cx));
// Focus list
self.list_state.update(cx, |state, cx| state.focus(window, cx));
Resize the window based on content (e.g., search results).
fn update_window_height(&self, item_count: usize, window: &mut Window) {
let item_height = 24.0;
let input_height = 40.0;
let max_height = 400.0;
let content_height = input_height + (item_count as f32 * item_height);
let new_height = content_height.min(max_height);
window.resize(size(px(300.0), px(new_height)));
}
Use cx.notify() to trigger a re-render of the current view.
When updating Entity state (like ListState), use .update():
self.list_state.update(cx, |state, cx| {
state.delegate_mut().items = new_items;
cx.notify(); // Notify the ListState view
});
[dependencies]
gpui = { version = "0.2", features = ["test-support"] }
[dev-dependencies]
proptest = "1.0"
We use property-based testing to automatically generate hundreds of test cases. Define properties that must always hold, and proptest generates random inputs to verify them.
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn prop_filter_matches_contain_query(
query in "[a-zA-Z0-9]{0,20}",
items in prop::collection::vec("[a-zA-Z0-9]{1,30}", 0..50)
) {
let mut delegate = FilterDelegate::new(items);
delegate.filter(&query);
for item in &delegate.matches {
prop_assert!(item.to_lowercase().contains(&query.to_lowercase()));
}
}
#[test]
fn prop_nav_selection_always_in_bounds(
item_count in 1usize..100,
moves in prop::collection::vec(prop::bool::ANY, 0..200)
) {
let mut state = ListNavState::new(item_count);
for go_down in moves {
if go_down { state.move_down(); } else { state.move_up(); }
prop_assert!(state.selected <= state.max_index);
}
}
}
Properties we test:
Use #[gpui::test] only for testing actual gpui integration (focus, blur, keystrokes). Keep these minimal as they're
expensive.
#[gpui::test]
fn test_blur_callback_fires(cx: &mut TestAppContext) {
let blur_fired = Rc::new(RefCell::new(false));
let flag = blur_fired.clone();
let window_handle = cx.update(|cx| {
cx.open_window(WindowOptions::default(), |window, cx| {
let view = cx.new(|cx| BlurTestView::new(cx, flag));
window.focus(&view.focus_handle(cx));
view
}).unwrap()
});
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
cx.update(|window, _| { window.blur(); });
assert!(*blur_fired.borrow());
}
#[gpui::test]
fn test_keystrokes_captured(cx: &mut TestAppContext) {
// ... setup ...
cx.simulate_keystrokes("a b c up down enter escape");
assert_eq!(captured_keys, vec!["a", "b", "c", "up", "down", "enter", "escape"]);
}
cargo test # All tests
cargo test prop_ # Only property tests
cargo test --test integration_tests # Integration test file
Toasts, settings panels, and dropdowns for qol plugins come from the shared
kit in libs/qol-gpui (surface.rs, dropdown.rs, scroll_list.rs) - do
not hand-roll windows per plugin. Wiring, invariants, and the qol-shot
reference implementation: qol-project:qol-plugin-gpui-surfaces.
gpui::* glob hijacks #[test]use gpui::* exports a test attribute macro. A #[cfg(test)] mod tests
doing use super::* inherits it and every #[test] explodes with
"recursion limit reached while expanding #[test]". Import test-module
items explicitly instead of use super::*.
WindowHandle::update fails silentlyCalling handle.update(cx, |_, window, _| window.remove_window()) from
inside that window's own event dispatch (e.g. an on_key_down listener)
hits gpui's re-entrancy guard and returns an Err that is easy to drop.
Defer the close: cx.defer(move |cx| { let _ = handle.update(...); }).
SurfaceDismisser in qol-gpui already does this.
deferred(anchored().child(...))An absolutely-positioned child paints in tree order, so later siblings
draw over it. Wrap popover-like overlays (dropdowns, menus) in
deferred(anchored() ...) - deferred paints after everything else and
anchored keeps it inside the window (snap_to_window_with_margin).
Muffin ignores requested origins for WindowKind::Normal windows and
places them itself; moving after map produces a visible jump. Either use
per-monitor pre-created ghosts (launcher/alt-tab) or map the window
painting nothing, assert the origin via X11, and reveal once the move
lands (qol-gpui Surface panels).
gpui reports one merged virtual screen (e.g. 4480x1440 for dual monitors). Use xrandr --current to get real
per-monitor geometry, then xdotool getactivewindow getwindowgeometry --shell to find which monitor has focus.
cx.on_blur triggers the instant a PopUp opens. Add a delay guard (e.g. skip blur events within 100ms of window
creation) before subscribing.
Don't create a tiny window and resize in render() — you get a glitched square. Set the final size in
WindowOptions::window_bounds upfront.
Pixels and DisplayId fields are privateCan't access .0 on these types. Use {:?} Debug formatting or accessor methods like .to_f64().
Context<Self> has .quit() directlyNo need for cx.app_mut(). Call cx.quit() from any view context.
cx.listener(Self::handle_key) — cleaner than inline closures, and the method gets &mut self automatically.
on_click or overflow_y_scroll not found on DivIf you get a compiler error that .on_click() or .overflow_y_scroll() doesn't exist on div(), it is because they require the element to preserve state (i.e. implement StatefulInteractiveElement). You MUST give the div an element ID first via .id("some-id") to unlock these methods.
.hover() closure argument is a StyleRefinementThe .hover(|h| ...) closure on elements does not pass a Div, it passes a blank StyleRefinement. You cannot use builder methods like .bg(). You must modify the properties directly:
.hover(|mut h| {
h.background = Some(rgb(0x1e2640).into());
h
})
Wrapping elements in gpui_component::Scrollable violently breaks native GPUI flex-wrap width resolution because it intercepts constraints. If you just need a standard scrolling grid, drop Scrollable and use a native div().id("grid").overflow_y_scroll().
If you launch a popup window and it doesn't immediately receive input (you have to click it first), ensure you are calling BOTH window.focus(handle) AND window.activate_window() when opening it. activate_window tells the GPUI platform layer to physically raise and steal focus from the compositor.
This is about your OWN window. To foreground ANOTHER app's window on macOS (alt-tab/window-actions), window.activate_window() does not apply — see the macos-window-activation skill (SkyLight, AXRaise).
MetalAtlas::remove double-decrements a texture's refcount if called twice for the same ImageId. Every cache that holds an Arc<RenderImage> must route inserts/removals through ONE registry that guarantees App::drop_image runs exactly once per ImageId on last release. Two rules fall out:
Arc<RenderImage>s must drain them back to the registry in Context::on_release, or refcounts stay inflated and drop_image never fires for successor windows. The dying window's GPU atlas is GC'd by the platform regardless, so passing window = None to the release path during teardown is correct.WindowHandle::update lease the leased window is None in App::windows. Any &mut App op that walks platform windows (e.g. drop_image) must run with the right context: pass None at the App level (no window leased), Some(window) inside handle.update.window_bounds() can report a stale tiny size during warmupBeyond "set full size at creation": a pre-created / ghost window can have window.window_bounds() return a stale tiny size (e.g. 720x321) during the warmup render before a programmatic resize lands. Never feed window_bounds() into layout math on the first frames — use your intended size, not the reported one.
GPUI caches a window's backing scale factor. When the window moves to a monitor with a different DPI the cache drifts (blurry / mis-scaled render). Query the real NSWindow.backingScaleFactor and re-sync when a reposition crosses monitors.
Alternative to gpui-component for full control.
WindowOptions {
titlebar: None,
window_decorations: Some(WindowDecorations::Client),
kind: WindowKind::PopUp,
focus: true,
..Default::default()
}
Views that need focus must implement Focusable:
impl Focusable for MyView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
cx.open_window(options, |window, cx| {
let view = cx.new(|cx| MyView::new(cx));
window.focus(&view.focus_handle(cx)); // 1 arg only
view
})
div()
.id("my-element")
.track_focus(&self.focus_handle)
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
match event.keystroke.key.as_str() {
"backspace" => { this.query.pop(); cx.notify(); }
"a" => { this.query.push('a'); cx.notify(); }
_ => {}
}
}))
A fully functional launcher with Input, List, and dynamic resizing.
use gpui::*;
use gpui_component::{
input::{Input, InputState},
list::{List, ListDelegate, ListState, ListItem},
IndexPath, Sizable,
};
struct MyDelegate {
items: Vec<String>,
matches: Vec<String>,
selected_index: Option<IndexPath>,
}
impl MyDelegate {
fn new(items: Vec<String>) -> Self {
Self {
matches: items.clone(),
items,
selected_index: None,
}
}
fn filter(&mut self, query: &str) {
if query.is_empty() {
self.matches = self.items.clone();
} else {
self.matches = self.items
.iter()
.filter(|i| i.to_lowercase().contains(&query.to_lowercase()))
.cloned()
.collect();
}
}
}
impl ListDelegate for MyDelegate {
type Item = ListItem;
fn items_count(&self, _section: usize, _cx: &App) -> usize {
self.matches.len()
}
fn render_item(&mut self, ix: IndexPath, _window: &mut Window, _cx: &mut Context<ListState<Self>>) -> Option<Self::Item> {
Some(ListItem::new(("item", ix.row))
.child(self.matches[ix.row].clone()))
}
fn set_selected_index(&mut self, ix: Option<IndexPath>, _window: &mut Window, _cx: &mut Context<ListState<Self>>) {
self.selected_index = ix;
}
}
struct AppView {
input_state: Entity<InputState>,
list_state: Entity<ListState<MyDelegate>>,
}
impl AppView {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let items = vec!["Apple", "Banana", "Cherry"].into_iter().map(String::from).collect();
let delegate = MyDelegate::new(items);
let list_state = cx.new(|cx| ListState::new(delegate, window, cx));
let input_state = cx.new(|cx| InputState::new(window, cx).placeholder("Search..."));
Self { input_state, list_state }
}
fn on_input_change(&mut self, _: &gpui_component::input::InputEvent, window: &mut Window, cx: &mut Context<Self>) {
let query = self.input_state.read(cx).value();
self.list_state.update(cx, |state, cx| {
state.delegate_mut().filter(&query);
cx.notify();
});
let count = self.list_state.read(cx).delegate().matches.len();
let height = 40.0 + (count as f32 * 24.0).min(300.0);
window.resize(size(px(300.0), px(height)));
}
}
impl Render for AppView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.flex()
.flex_col()
.child(Input::new(&self.input_state))
.child(List::new(&self.list_state).h_full())
}
}
fn main() {
Application::new().run(|cx: &mut App| {
cx.open_window(WindowOptions::default(), |window, cx| {
cx.new(|cx| {
let mut view = AppView::new(window, cx);
let input = view.input_state.clone();
cx.subscribe_in(&input, window, |this: &mut AppView, _, event, window, cx| {
this.on_input_change(event, window, cx);
}).detach();
view
})
}).unwrap();
});
}
npx claudepluginhub qol-tools/qol-skills --plugin qol-langsCreates structured, bite-sized implementation plans from specs or requirements before writing code. Useful for breaking down multi-step tasks into testable steps with file structure and task boundaries.