SIGN IN SIGN UP

base, ui: Close the pub-field seam types behind builders (#2706)

A public struct with `pub` fields cannot grow. Adding a field breaks
every struct literal, so each new capability of a control turns into a
breaking change to the state it hands out. Adding `readonly` in #2705
hit this twice.

A full sweep of `crates/base` and `crates/ui` found **100 structs with
`pub` fields**. Most are exempt for good reasons; this PR closes the
ones that are not, and records the rule so the next one does not reopen
it.

## Closed here

| Type | Role | Shape |
| --- | --- | --- |
| `InputContextMenuCapabilities` | capability set, passed to the menu
builder | builder + readers |
| `CalendarItemState` | state snapshot, passed to the item slot |
builder + readers |
| `ComboboxTriggerCtx` → `ComboboxTriggerContext` | render context,
passed to `render_trigger` | private fields + readers |
| `RenderOptions` (setting) | option set, threaded through the item
renderers | `with_`-prefixed builder + readers |
| `InputPresentation` | state snapshot, built only by `InputBaseState` |
private fields + readers |

`RenderOptions` is threaded down four nesting levels with functional
update syntax, which stops compiling once fields are private. A
`with_`-setter taking `self` by value replaces it exactly, and reads
better.

`InputPresentation` gets no public builder. It is built by
`InputBaseState::presentation` and nowhere else, so private fields
already close the hole, and a public builder would hand out a
construction path the seam does not want.

`is_editable()` (`!disabled && !readonly`) is published on both
capability types, so the "may the user write" rule has one definition
instead of being re-derived at each call site.

## The rules

`docs/ARCHITECTURE.md` gains a **Public Data Types Across the Seam**
section and a design invariant; `CLAUDE.md` gains two matching
principles.

**Encapsulation.** A public struct crossing the seam keeps private
fields, is built with a builder, and is read through methods. Setters
and readers must not collide, which decides the naming:

- all-boolean types name setters after the field and readers
`is_`/`has_`/`can_`, matching how elements read;
- types with non-boolean fields prefix every setter with `with_`,
keeping the plain field name for readers — following
`Sizable::with_size`.

**Naming.** Spell `Context` out — `ComboboxTriggerContext`, never
`…Ctx`. In a GPUI codebase `cx` is reserved for `App`, `Context<T>`, and
`AsyncApp`, so an abbreviated `ctx` for anything else reads as a second,
competing context. A callback receiving both takes the GPUI one as `cx`
and names the other after what it holds (`trigger`).

## Exempt, and why

- **Value types** whose fields *are* the definition and cannot grow:
`Point`, `Selection`, `Edges`, `IndexPath`, `FoldRange`, `Span`,
`SelectIndex`, and the plot geometry (`StackPoint`, `SankeyLink`,
`ArcData`).
- **Serde schemas** where fields are the on-disk contract and growth is
handled by `#[serde(default)]`: `DockAreaState`, `PanelState`,
`ThemeConfig`, `Semantic*Config`.
- **Mirrors of external schemas**: the LSP `Diagnostic`, tree-sitter's
`InputEdit`, the markdown AST nodes.
- **GPUI action structs**: `Confirm`, `Enter`.

## Still open, deliberately

Two groups are left for follow-ups, because the right fix differs and
the blast radius is larger:

1. **Token records** — `ThemeColor` (139 fields), `ThemeConfigColors`
(128), `SyntaxColors` (41), `ColorTokens` (17), and the rest of
`theme_tokens`. A builder with 139 setters is not the answer; these want
`#[non_exhaustive]` with a construction path, and every theme in
`gpui-component` constructs them.
2. **Internal state exposed as `pub`** — `TableState` (11 pub fields),
`Lsp` (8), `SearchSession` (7), `InputBaseState.lsp`,
`CalendarState.focus_handle`, `Root.notification`, `NativeMenu.items`.
These are a *stronger* problem than a future breaking change: callers
can currently violate invariants the type maintains. The fix is
tightening visibility, not adding a builder.

Also unconverted, lower priority: `InputEditorStyle` (12 fields),
`Column` (12), `ToastMotion`, `ToastOptions`, `ToastAdvance`,
`ListSettings`, `NotificationSettings`, `TextViewStyle`, `TooltipState`.

## Breaking Changes

### `InputContextMenuCapabilities`

- Fields are read through methods.

```diff
- capabilities.disabled
- capabilities.selection
- capabilities.go_to_definition
- capabilities.code_actions
+ capabilities.is_disabled()
+ capabilities.has_selection()
+ capabilities.can_go_to_definition()
+ capabilities.has_code_actions()
```

- Built with `new()` instead of a struct literal.
> `is_editable()` is `!disabled && !readonly`. Use it for the items that
write: Cut, Paste, Show Code Actions.

```diff
- InputContextMenuCapabilities { code_editor: true, selection: true, ..Default::default() }
+ InputContextMenuCapabilities::new().code_editor(true).selection(true)
```

### `InputPresentation`

- Fields are read through methods.

```diff
- presentation.multi_line
- presentation.disabled
- presentation.focus_handle
- presentation.placeholder.clone()
- presentation.value.clone()
+ presentation.is_multi_line()
+ presentation.is_disabled()
+ presentation.focus_handle()
+ presentation.placeholder().clone()
+ presentation.value().to_owned()
```

### `CalendarItemState`

- Fields are read through methods.

```diff
- state.kind
- state.active
- state.today
+ state.kind()
+ state.is_active()
+ state.is_today()
```

- Built with `new(kind)` instead of a struct literal.
> Every flag defaults to off, so only the ones actually set need naming.

```diff
- CalendarItemState { kind: CalendarItemKind::Month, active: month == current, in_range: false, muted: false, disabled: false, today: false }
+ CalendarItemState::new(CalendarItemKind::Month).active(month == current)
```

### `ComboboxTriggerCtx` → `ComboboxTriggerContext`

- Renamed to spell `Context` out.

```diff
- use gpui_component::combobox::ComboboxTriggerCtx;
+ use gpui_component::combobox::ComboboxTriggerContext;
```

- Fields are read through methods.
> Name the closure parameter after what it holds, so `cx` stays the GPUI
context.

```diff
- combobox.render_trigger(|ctx, window, cx| Caret::new(ctx.size).into_any_element())
+ combobox.render_trigger(|trigger, window, cx| Caret::new(trigger.size()).into_any_element())
```

```diff
- ctx.selection
- ctx.placeholder
- ctx.open
- ctx.disabled
+ trigger.selection()
+ trigger.placeholder()
+ trigger.is_open()
+ trigger.is_disabled()
```

### `RenderOptions` (setting)

- Fields are read through methods.

```diff
- options.page_ix
- options.group_ix
- options.item_ix
- options.size
- options.layout
- options.disabled
+ options.page_ix()
+ options.group_ix()
+ options.item_ix()
+ options.size()
+ options.layout()
+ options.is_disabled()
```

- Narrowed with `with_` setters instead of functional update syntax.
> The setters take `self` by value, so a nested renderer gets its own
copy.

```diff
- item.render_item(&RenderOptions { item_ix, ..*options }, window, cx)
+ item.render_item(&options.with_item_ix(item_ix), window, cx)
```

## Testing

`cargo clippy --workspace --all-targets` clean, `cargo test --workspace`
green, `typos` clean.

---

Written with Claude Code, reviewed and adjusted by hand.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
J
Jason Lee committed
1dbd54ff459f6603fa5e4dd4b15eb3e6c837b6f9
Parent: bc1b70d
Committed by GitHub <noreply@github.com> on 8/14/2026, 7:59:36 AM