SIGN IN SIGN UP

input: Make the three input states one engine, keyed by kind (#2716)

## Description

Replaces #2715, which took the facade route and could not get there, and
subsumes #2714 — its commit is included here, so that one can be closed.

`InputState`, `TextareaState` and `EditorState` were facades over
`InputBaseState`: each held an `Entity<InputBaseState>`, mirrored its
value,
forwarded a subset of its methods, and applied builder options later in
`prepare`. That layer was written three times, leaked the engine through
`base_state`, and could not answer a reader without either a `cx` or a
cached
copy that goes stale.

They are now aliases of the engine itself, separated by a mode marker:

```rust
pub type InputState    = InputBaseState<InputMode>;
pub type TextareaState = InputBaseState<TextareaMode>;
pub type EditorState   = InputBaseState<EditorMode>;
```

`value()` reads `&self` — no `cx`, no second copy of the text, nothing
to keep in
step. One marker per state, so the vocabulary matches.

Net −493 lines.

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

### Methods are gated by kind, not by assertion

| `impl` block | What only it has |
|---|---|
| `impl<M>` | shared text, focus, editing, selection and
render-integration APIs |
| `impl<M: MultiLineMode>` | `soft_wrap`, `wrapping_indent`,
`searchable`, `tab_size` |
| `InputBaseState<InputMode>` | `new(window, cx)`, `masked`, `pattern`,
`validate`, number stepping |
| `InputBaseState<TextareaMode>` | `new(window, cx)`, `auto_grow`,
`rows` |
| `InputBaseState<EditorMode>` | `new(window, cx)`, `language`, folding,
line numbers, code actions, the LSP drive |

`InputState` has no `auto_grow` or `rows`; only `EditorState` performs
code
actions or reaches an LSP. Not a `debug_assert` — the method does not
exist on
the type.

Twenty-five methods that used to open with
`debug_assert!(self.mode.is_…())`
have moved into the block of the kind they belong to, so
`InputState::soft_wrap`
and `EditorState::masked` are now compile errors rather than a debug
panic and a
silent no-op in release. **No `debug_assert!` on the input mode is
left.** The two
multi-line kinds share several of these, so a sealed `MultiLineMode`
marker
bounds one `impl` block for both rather than the methods being written
twice.
No call site in the library, the stories or the examples needed
changing, which
is the evidence that nothing relied on the runtime check.

The marker set is sealed: the engine branches on a closed set of runtime
layouts, so the three above are all of them.

### The marker also carries the data

`InputBaseState` had 64 fields, and all three states were the same 2544
bytes: a
single-line field in a form still held an `Lsp` with its three tasks,
the
decoration collections, the inline completion, and the hover and
context-menu
state. Those move into `InputModeKind::Extras`, so each state carries
only what
its mode uses:

| | before | after |
|---|---|---|
| `InputState` | 2544 | **1856** (−27%) |
| `TextareaState` | 2544 | 2088 (−18%) |
| `EditorState` | 2544 | 2776 (one more indirection) |

Twenty text fields in a form now save ~13.7KB, and what they save is
exactly the
machinery they never use. The single-line payload (masking, validation,
number
stepping) stays on the engine: it is ~120 bytes and its access sites sit
inside
the shared edit path, so separating it would cost more in dispatch than
it saves.

### The mode hook, and why the LSP seams need no back-reference

The engine's edit and render paths are generic over the kind, so they
cannot name
a specific state type. `InputModeKind` is the seam: the editor registers
its own
actions, drives its highlighter, and supplies the decorations, semantic
tokens
and hover geometry the shared renderer paints. Inside those
implementations
`Self` is concrete. That is also what makes the LSP signatures fall out
for free — in the
editor's own context `cx.entity()` already is an `Entity<EditorState>`,
so
`CodeActionProvider` and `InputHighlighter` simply take it. #2715 needed
a weak
back-reference and a `HighlighterHost` to reach the same place; both are
gone.

`gpui-component` follows suit, but only where it has to. Of the twelve
things
the `Input` element does to its state, every one is a method the engine
offers
for every kind; only the overlay registry needs to know which kind it
is. So
`Input` holds a three-variant enum and dispatches, rather than carrying
a type
parameter — one instantiation instead of three, and `OverlayMode`,
`LspOverlays`
and `LspSnapshot` become `pub(crate)`, since nothing public is generic
over the
kind. `InputBaseState` no longer appears in any public signature in
`gpui-component`.

### Control → state

| Control | State | Was |
|---|---|---|
| `inspector` rust/json editors | `EditorState` | `InputBaseState` |
| `examples/html`, `examples/markdown` | `EditorState` |
`InputBaseState` |
| `examples/editor` main editor | `EditorState` | `InputBaseState` |
| `examples/large-text` body | `TextareaState` | `InputBaseState` |
| go-to-line fields | `InputState` | `InputBaseState` |

`large-text` was built with `multi_line(true)` rather than as a code
editor, so
it is a textarea.

### Kept compatible on purpose

`validate` and `step_by` keep the `&mut App` closures the facades
exposed, so
callers are unaffected even though the engine's own context is now
available.
`prepare` stays as a no-op, since configuration applies as it is set —
the call
can be deleted at leisure. `value()`, `set_value` and
`diagnostics_mut()` keep
their facade signatures. Readers that previously needed a `cx` only to
reach
the engine now read directly from `&self`; those migrations are
documented
below.

### Two regressions this refactor introduced, and fixed

Splitting the constructor per kind moved the shared defaults into a
helper where
`soft_wrap` was written as `false`, and none of the three `new`
functions set it
back — every `Textarea` and `Editor` had stopped wrapping while the doc
comments
still promised the old default. Restored, and pinned with a test, since
the value
now lives one level away from the constructors a reader would check.

Blur called `reset_annotations`, which drops the hover popover *and*
clears every
decoration, where it used to drop only the popover — so clicking away
threw out
decorations the application had installed and never asked to remove.
`clear_hover_state` is the hook for this; its own doc says "when the
pointer
leaves or focus moves".

### Overlay sync no longer pays per frame

It ran every frame and compared the popovers against `format!("{:?}",
…)` of
their own content, re-serialising the whole completion list — each entry
with its
documentation — once per frame per popover. The snapshot it compared was
just as
expensive: it cloned the completion and code-action item lists
unconditionally,
including on the early-out path taken when no overlay is showing at all,
and
since `hide_context_menu` only clears `open`, a closed menu kept paying
for a
full clone of the list from the last time it opened.

The menus now carry a revision the engine bumps when it swaps their
content, and
the popovers are keyed on cheap identity instead: the revision for the
menus, the
anchor range for hover, `Rc` pointer equality for the diagnostic. The
snapshot
carries no content, and the item lists are read only on the frames where
they
actually changed.

### Data and behavior, separated

`InputModeKind` had grown to 29 methods, 28 of which existed only for
the editor,
and they were two different things under one name: points where the
engine hands
control back mid-edit, and plain reads of fields the renderer cannot
reach
because it is generic over the kind. The reads move to `InputExtras`,
implemented
on the extras type itself, so they are ordinary methods on ordinary
data:

```diff
- M::decoration_layers(&state.extras)
+ state.extras.decoration_layers()
```

Adding a field an editor renders now touches only that trait and leaves
the
engine's callbacks alone. Three methods had no callers at all — `lsp`,
`lsp_mut`
and `hover_popover`, each shadowed by an inherent method on
`EditorState` — and
are gone. 29 methods become 19 callbacks plus 6 accessors.

### Layout stops answering what kind of input this is

`LayoutMode` answered two questions: how many rows to show and how to
grow them,
and what kind of input this is. The second already had an answer at the
type
level, so the two could disagree — and did:

```rust
TextareaState::new(w, cx).auto_grow(1, 1)
// compile time: TextareaMode, every multi-line method reachable
// run time:     is_multi_line() == false
```

because auto-grow derived multi-line from `max_rows > 1`. Soft wrap
could be set
on that state and silently do nothing, and the debug assertions guarding
the
multi-line methods fired on a configuration that was perfectly legal.
The kind
moves onto `InputModeKind` as `MULTI_LINE` and `CODE_EDITOR` associated
constants; `LayoutMode` keeps the row counts and the code-editor extras
and
loses its `multi_line` fields along with the three predicates derived
from them.

### Also fixed

The WASM build: `syntect_highlighter.rs` and the `editor_story` WASM
branch
implement `InputHighlighter::update` against a signature that only
`cfg(wasm)`
ever compiled, so the mismatch was never seen. And a `typos` CI failure
in the
Collapsible story's fake API key.

## Breaking Changes

- `InputBaseState` is no longer exported from `gpui_component::input`.
Use the
  state of the control you are building; it is the same engine.

```diff
- use gpui_component::input::{Input, InputBaseState};
- let state = cx.new(|cx| InputBaseState::new(window, cx).code_editor("rust"));
- Input::from_base(&state)
+ use gpui_component::input::{Editor, EditorState};
+ let state = cx.new(|cx| EditorState::new(window, cx).language("rust"));
+ Editor::new(&state)
```

```diff
- input_state: Entity<InputBaseState>,
+ editor_state: Entity<EditorState>,
```

- `base_state()` is gone: the state *is* the engine.
- The mode builders are gone; the kind is chosen by the constructor.

```diff
- InputBaseState::new(window, cx).multi_line(true)
+ TextareaState::new(window, cx)
- InputBaseState::new(window, cx).code_editor("rust")
+ EditorState::new(window, cx).language("rust")
```

- `lsp` is no longer a public field. It moved into the editor's payload,
so it is
reached through a method that exists on `EditorState` alone — where the
field
  was reachable from every input.

```diff
- state.lsp.completion_provider = Some(provider.clone());
- state.lsp.hover_provider = Some(provider.clone());
+ state.lsp_mut().completion_provider = Some(provider.clone());
+ state.lsp_mut().hover_provider = Some(provider.clone());
```

- `CodeActionProvider` receives an `Entity<EditorState>`.

```diff
  fn perform_code_action(
      &self,
-     state: Entity<InputBaseState>,
+     state: Entity<EditorState>,
      action: CodeAction,
      push_to_history: bool,
      window: &mut Window,
      cx: &mut App,
  ) -> Task<Result<()>>;
```

- The language moved out of `EditorState::new`, so all three states are
built the
  same way. It is a property like folding or line numbers.

```diff
- EditorState::new("rust", window, cx)
+ EditorState::new(window, cx).language("rust")
```

- `WindowExt::focused_input` returns `AnyInputState`, which covers
`Textarea`,
  `Editor` and `OtpInput` rather than only `Input`.

```diff
- let state: Option<Entity<InputState>> = window.focused_input(cx);
+ let state: Option<AnyInputState> = window.focused_input(cx);
+ // then `.as_input()`, `.as_textarea()`, `.as_editor()`, `.as_otp()`, or
+ // `.value(cx)` / `.focus_handle(cx)` when the kind does not matter.
```

- The four `CompletionProvider` methods now take `&mut App` instead of
`&mut Context<InputBaseState>`, matching the other LSP provider traits.
This
applies to `completions`, `inline_completion`, `resolve_completions` and
  `is_completion_trigger`.

```diff
  fn completions(
      &self,
      text: &Rope,
      offset: usize,
      trigger: CompletionContext,
      window: &mut Window,
-     cx: &mut Context<InputBaseState>,
+     cx: &mut App,
  ) -> Task<Result<CompletionResponse>>;
```

- `OtpState::compat_input_state` is gone. It existed only to feed the
old
focused-input registry; `AnyInputState` now tracks `OtpState` directly.

- Readers that formerly crossed a facade boundary no longer take an
`App` just
  to reach the engine.

```diff
- state.selected_value(cx)
+ state.selected_value()

- state.cursor_position(cx)
+ state.cursor_position()
```

The `cursor_position` change applies to `TextareaState` and
`EditorState`.

- Direct users of `gpui_base::input::InputBaseState` must update
validator
callbacks from `&mut Context<InputBaseState>` to `&mut App`. The public
  `InputState` facade already exposed the `&mut App` form, so ordinary
  `gpui_component` callers are unaffected.

```diff
- InputBaseState::new(window, cx).validate(|value, cx: &mut Context<InputBaseState>| { ... })
+ InputState::new(window, cx).validate(|value, cx: &mut App| { ... })
```

- Methods that apply to one kind are now rejected at compile time on the
others,
  where they used to compile and fire a debug assertion. Correct code is
  unaffected.

```diff
- InputState::new(window, cx).soft_wrap(false)
+ TextareaState::new(window, cx).soft_wrap(false)
```

- `InputHighlighter::update` takes the editor's context.

```diff
  fn update(
      &mut self,
      edit: Option<InputEdit>,
      text: &Rope,
      folding: bool,
      window: &mut Window,
-     cx: &mut Context<InputBaseState>,
+     cx: &mut Context<EditorState>,
  );
```

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
J
Jason Lee committed
ec6b6b51d556ca1573f19a79c68550c2b881f531
Parent: f3ba893
Committed by GitHub <noreply@github.com> on 8/15/2026, 5:45:07 AM