Files
coop/.agents/skills/gpui-element/references/best-practices.md
Ren Amamiya 40d726c986 feat: refactor to use gpui event instead of local state (#18)
Reviewed-on: #18
Co-authored-by: Ren Amamiya <reya@lume.nu>
Co-committed-by: Ren Amamiya <reya@lume.nu>
2026-03-10 08:19:02 +00:00

13 KiB

Element Best Practices

Guidelines and best practices for implementing high-quality GPUI elements.

State Management

Using Associated Types Effectively

Good: Use associated types to pass meaningful data between phases

// Good: Structured state with type safety
type RequestLayoutState = (StyledText, Vec<ChildLayout>);
type PrepaintState = (Hitbox, Vec<ChildBounds>);

Bad: Using empty state when you need data

// Bad: No state when you need to pass data
type RequestLayoutState = ();
type PrepaintState = ();
// Now you can't pass layout info to paint phase!

Managing Complex State

For elements with complex state, create dedicated structs:

// Good: Dedicated struct for complex state
pub struct TextElementState {
    pub styled_text: StyledText,
    pub text_layout: TextLayout,
    pub child_states: Vec<ChildState>,
}

type RequestLayoutState = TextElementState;

Benefits:

  • Clear documentation of state structure
  • Easy to extend
  • Type-safe access

State Lifecycle

Golden Rule: State flows in one direction through the phases

request_layout → RequestLayoutState →
prepaint → PrepaintState →
paint

Don't:

  • Store state in the element struct that should be in associated types
  • Try to mutate element state in paint phase (use cx.notify() to schedule re-render)
  • Pass mutable references across phase boundaries

Performance Considerations

Minimize Allocations in Paint Phase

Critical: Paint phase is called every frame during animations. Minimize allocations.

Good: Pre-allocate in request_layout or prepaint

impl Element for MyElement {
    fn request_layout(&mut self, .., window: &mut Window, cx: &mut App)
        -> (LayoutId, Vec<StyledText>)
    {
        // Allocate once during layout
        let styled_texts = self.children
            .iter()
            .map(|child| StyledText::new(child.text.clone()))
            .collect();

        (layout_id, styled_texts)
    }

    fn paint(&mut self, .., styled_texts: &mut Vec<StyledText>, ..) {
        // Just use pre-allocated styled_texts
        for text in styled_texts {
            text.paint(..);
        }
    }
}

Bad: Allocate in paint phase

fn paint(&mut self, ..) {
    // Bad: Allocation in paint phase!
    let styled_texts: Vec<_> = self.children
        .iter()
        .map(|child| StyledText::new(child.text.clone()))
        .collect();
}

Cache Expensive Computations

Use memoization for expensive operations:

pub struct CachedElement {
    // Cache key
    last_text: Option<SharedString>,
    last_width: Option<Pixels>,

    // Cached result
    cached_layout: Option<TextLayout>,
}

impl Element for CachedElement {
    fn request_layout(&mut self, .., window: &mut Window, cx: &mut App)
        -> (LayoutId, TextLayout)
    {
        let current_width = window.bounds().width();

        // Check if cache is valid
        if self.last_text.as_ref() != Some(&self.text)
            || self.last_width != Some(current_width)
            || self.cached_layout.is_none()
        {
            // Recompute expensive layout
            self.cached_layout = Some(self.compute_text_layout(current_width));
            self.last_text = Some(self.text.clone());
            self.last_width = Some(current_width);
        }

        // Use cached layout
        let layout = self.cached_layout.as_ref().unwrap();
        (layout_id, layout.clone())
    }
}

Lazy Child Rendering

Only render visible children in scrollable containers:

fn paint(&mut self, .., bounds: Bounds<Pixels>, paint_state: &mut Self::PrepaintState, ..) {
    for (i, child) in self.children.iter_mut().enumerate() {
        let child_bounds = paint_state.child_bounds[i];

        // Only paint visible children
        if self.is_visible(&child_bounds, &bounds) {
            child.paint(..);
        }
    }
}

fn is_visible(&self, child_bounds: &Bounds<Pixels>, container_bounds: &Bounds<Pixels>) -> bool {
    child_bounds.bottom() >= container_bounds.top() &&
    child_bounds.top() <= container_bounds.bottom()
}

Interaction Handling

Proper Event Bubbling

Always check phase and bounds before handling events:

fn paint(&mut self, .., window: &mut Window, cx: &mut App) {
    window.on_mouse_event({
        let hitbox = self.hitbox.clone();
        move |event: &MouseDownEvent, phase, window, cx| {
            // Check phase first
            if !phase.bubble() {
                return;
            }

            // Check if event is within bounds
            if !hitbox.is_hovered(window) {
                return;
            }

            // Handle event
            self.handle_click(event);

            // Stop propagation if handled
            cx.stop_propagation();
        }
    });
}

Don't forget:

  • Check phase.bubble() or phase.capture() as appropriate
  • Check hitbox hover state or bounds
  • Call cx.stop_propagation() if you handle the event

Hitbox Management

Create hitboxes in prepaint phase, not paint:

Good:

fn prepaint(&mut self, .., bounds: Bounds<Pixels>, window: &mut Window, ..) -> Hitbox {
    // Create hitbox in prepaint
    window.insert_hitbox(bounds, HitboxBehavior::Normal)
}

fn paint(&mut self, .., hitbox: &mut Hitbox, window: &mut Window, ..) {
    // Use hitbox in paint
    window.set_cursor_style(CursorStyle::PointingHand, hitbox);
}

Hitbox Behaviors:

// Normal: Blocks events from passing through
HitboxBehavior::Normal

// Transparent: Allows events to pass through to elements below
HitboxBehavior::Transparent

Cursor Style Guidelines

Set appropriate cursor styles for interactivity cues:

// Text selection
window.set_cursor_style(CursorStyle::IBeam, &hitbox);

// Clickable elements (desktop convention: use default, not pointing hand)
window.set_cursor_style(CursorStyle::Arrow, &hitbox);

// Links (web convention: use pointing hand)
window.set_cursor_style(CursorStyle::PointingHand, &hitbox);

// Resizable edges
window.set_cursor_style(CursorStyle::ResizeLeftRight, &hitbox);

Desktop vs Web Convention:

  • Desktop apps: Use Arrow for buttons
  • Web apps: Use PointingHand for links only

Layout Strategies

Fixed Size Elements

For elements with known, unchanging size:

fn request_layout(&mut self, .., window: &mut Window, cx: &mut App) -> (LayoutId, ()) {
    let layout_id = window.request_layout(
        Style {
            size: size(px(200.), px(100.)),
            ..default()
        },
        vec![], // No children
        cx
    );
    (layout_id, ())
}

Content-Based Sizing

For elements sized by their content:

fn request_layout(&mut self, .., window: &mut Window, cx: &mut App)
    -> (LayoutId, Size<Pixels>)
{
    // Measure content
    let text_bounds = self.measure_text(window);
    let padding = px(16.);

    let layout_id = window.request_layout(
        Style {
            size: size(
                text_bounds.width() + padding * 2.,
                text_bounds.height() + padding * 2.,
            ),
            ..default()
        },
        vec![],
        cx
    );

    (layout_id, text_bounds)
}

Flexible Layouts

For elements that adapt to available space:

fn request_layout(&mut self, .., window: &mut Window, cx: &mut App)
    -> (LayoutId, Vec<LayoutId>)
{
    let mut child_layout_ids = Vec::new();

    for child in &mut self.children {
        let (layout_id, _) = child.request_layout(window, cx);
        child_layout_ids.push(layout_id);
    }

    let layout_id = window.request_layout(
        Style {
            flex_direction: FlexDirection::Row,
            gap: px(8.),
            size: Size {
                width: relative(1.0),  // Fill parent width
                height: auto(),        // Auto height
            },
            ..default()
        },
        child_layout_ids.clone(),
        cx
    );

    (layout_id, child_layout_ids)
}

Error Handling

Graceful Degradation

Handle errors gracefully, don't panic:

fn request_layout(&mut self, .., window: &mut Window, cx: &mut App)
    -> (LayoutId, Option<TextLayout>)
{
    // Try to create styled text
    match StyledText::new(self.text.clone()).request_layout(None, None, window, cx) {
        Ok((layout_id, text_layout)) => {
            (layout_id, Some(text_layout))
        }
        Err(e) => {
            // Log error
            eprintln!("Failed to layout text: {}", e);

            // Fallback to simple text
            let fallback_text = StyledText::new("(Error loading text)".into());
            let (layout_id, _) = fallback_text.request_layout(None, None, window, cx);
            (layout_id, None)
        }
    }
}

Defensive Bounds Checking

Always validate bounds and indices:

fn paint_selection(&self, selection: &Selection, text_layout: &TextLayout, ..) {
    // Validate selection bounds
    let start = selection.start.min(self.text.len());
    let end = selection.end.min(self.text.len());

    if start >= end {
        return; // Invalid selection
    }

    let rects = text_layout.rects_for_range(start..end);
    // Paint selection...
}

Testing Element Implementations

Layout Tests

Test that layout calculations are correct:

#[cfg(test)]
mod tests {
    use super::*;
    use gpui::TestAppContext;

    #[gpui::test]
    fn test_element_layout(cx: &mut TestAppContext) {
        cx.update(|cx| {
            let mut window = cx.open_window(Default::default(), |_, _| ()).unwrap();

            window.update(cx, |window, cx| {
                let mut element = MyElement::new();
                let (layout_id, layout_state) = element.request_layout(
                    None,
                    None,
                    window,
                    cx
                );

                // Assert layout properties
                let bounds = window.layout_bounds(layout_id);
                assert_eq!(bounds.size.width, px(200.));
                assert_eq!(bounds.size.height, px(100.));
            });
        });
    }
}

Interaction Tests

Test that interactions work correctly:

#[gpui::test]
fn test_element_click(cx: &mut TestAppContext) {
    cx.update(|cx| {
        let mut window = cx.open_window(Default::default(), |_, cx| {
            cx.new(|_| MyElement::new())
        }).unwrap();

        window.update(cx, |window, cx| {
            let view = window.root_view().unwrap();

            // Simulate click
            let position = point(px(10.), px(10.));
            window.dispatch_event(MouseDownEvent {
                position,
                button: MouseButton::Left,
                modifiers: Modifiers::default(),
            });

            // Assert element responded
            view.read(cx).assert_clicked();
        });
    });
}

Common Pitfalls

Storing Layout State in Element Struct

Bad:

pub struct MyElement {
    id: ElementId,
    // Bad: This should be in RequestLayoutState
    cached_layout: Option<TextLayout>,
}

Good:

pub struct MyElement {
    id: ElementId,
    text: SharedString,
}

type RequestLayoutState = TextLayout; // Good: State in associated type

Mutating Element in Paint Phase

Bad:

fn paint(&mut self, ..) {
    self.counter += 1; // Bad: Mutating element in paint
}

Good:

fn paint(&mut self, .., window: &mut Window, cx: &mut App) {
    window.on_mouse_event(move |event, phase, window, cx| {
        if phase.bubble() {
            self.counter += 1;
            cx.notify(); // Schedule re-render
        }
    });
}

Creating Hitboxes in Paint Phase

Bad:

fn paint(&mut self, .., bounds: Bounds<Pixels>, window: &mut Window, ..) {
    // Bad: Creating hitbox in paint
    let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
}

Good:

fn prepaint(&mut self, .., bounds: Bounds<Pixels>, window: &mut Window, ..) -> Hitbox {
    // Good: Creating hitbox in prepaint
    window.insert_hitbox(bounds, HitboxBehavior::Normal)
}

Ignoring Event Phase

Bad:

window.on_mouse_event(move |event, phase, window, cx| {
    // Bad: Not checking phase
    self.handle_click(event);
});

Good:

window.on_mouse_event(move |event, phase, window, cx| {
    // Good: Checking phase
    if !phase.bubble() {
        return;
    }
    self.handle_click(event);
});

Performance Checklist

Before shipping an element implementation, verify:

  • No allocations in paint phase (except event handlers)
  • Expensive computations are cached/memoized
  • Only visible children are rendered in scrollable containers
  • Hitboxes created in prepaint, not paint
  • Event handlers check phase and bounds
  • Layout state is passed through associated types, not stored in element
  • Element implements proper error handling with fallbacks
  • Tests cover layout calculations and interactions