SIGN IN SIGN UP

input: Split themed styles out of StyledExt, and publish the focus ring (#2726)

## Description

An application building a control that should sit alongside `Input` or
`Select`
had no way to draw the same focus ring — the helper was `pub(crate)`, so
the
only option was re-deriving the width, colour and geometry by eye.

`StyledExt` in `gpui-base` was the wrong home for it. That trait holds
neutral
helpers — `h_flex`, `refine_style`, `corner_radii` — that make no visual
decisions, which is what lets it live in the base layer. Two of its
methods did
not fit: the focus ring and `popover_style` both read `cx.theme()` and
produce a
specific look.

They move into `ThemeStyled` in `gpui-component`, where deciding what
things
look like is the job, and are named alike:

```rust
use gpui_component::ThemeStyled as _;

div()
    .border_1()
    .border_color(cx.theme().input)
    .when(focused, |this| this.focus_ring_style(window, cx))

div().popover_style(cx)
```

`StyledExt` no longer mentions `cx.theme()` anywhere, so the base layer
is
visually unopinionated in fact and not just in intent.

`focus_ring_style` gives the whole focus appearance, not half of it: the
border
tinted with the focus colour **and** the ring outside it. The
framework's own
controls do both — `Input` tints through its focused style and then
draws the
ring — so a caller drawing only the ring would not match them.

### The ring's API changes with the move

**The offset is gone.** It was `px(0.)` at seven of the nine call sites;
checkbox and radio passed `px(2.)` and now sit at zero like the rest.

**`visible` is a `when` at the call site.** Calling the method means the
ring is
on, which reads the way the rest of the builder API does, and keeps
`FocusableExt::focus_ring` — the component-level switch — as one of the
conditions rather than something the ring has to reach into:

```diff
- .draw_focus_ring(is_focused && self.focus_ring_enabled, px(0.), window, cx)
+ .when(is_focused && self.focus_ring_enabled, |this| {
+     this.focus_ring_style(window, cx)
+ })
```

That matters because the conditions differ per control — `appearance`,
`bordered`, `!disabled` and the focus state all take part — and they
belong
where they are known.

`focus_ring_style` also stays clear of `FocusableExt::focus_ring`, the
component-level switch. `Button` satisfies both traits, so a shared name
would
make `Button::new(..).focus_ring(false)` ambiguous for anyone importing
both.

### Known limitation

The ring is an absolutely positioned child offset outwards, so an
ancestor that
clips its content cuts it off — the same behaviour as CSS `outline`
under
`overflow: hidden`. Now documented on the method: containers holding
focusable
controls need a few pixels of room, or no clipping.

> AI-generated with Claude Code, reviewed and adjusted by hand.

## Breaking Changes

- `popover_style` moved from `StyledExt` to `ThemeStyled`. Both are
exported
from `gpui_component`, so this is an import change — but keep
`StyledExt`
  where the same file also uses its neutral helpers.

```diff
- use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
+ use gpui_component::{ActiveTheme, ThemeStyled, h_flex, v_flex};
  div().popover_style(cx)
```

```diff
  // a file using both
- use gpui_component::{ActiveTheme, Sizable, StyledExt, WindowExt};
+ use gpui_component::{ActiveTheme, Sizable, StyledExt, ThemeStyled, WindowExt};
  div().popover_style(cx).child(div().font_semibold())
```

Checked against a downstream application: four call sites, three needing
only
the swap and one needing both traits.

- The focus ring helper was `pub(crate)` and shipped one day ago, so
nothing
  downstream can be relying on it. Listed for completeness.

```diff
- element.draw_focus_ring(visible, px(0.), window, cx)
+ element.when(visible, |this| this.focus_ring_style(window, cx))

- element.focused_border(cx)
+ element.border_1().border_color(cx.theme().ring)
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
J
Jason Lee committed
b8e71a437d76595b07c408591062c808f918adea
Parent: 65450dc
Committed by GitHub <noreply@github.com> on 8/15/2026, 7:46:11 AM