Skip to content

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

Open
huacnlee wants to merge 13 commits into
mainfrom
input-state-typestate
Open

input: Make the three input states one engine, keyed by kind#2716
huacnlee wants to merge 13 commits into
mainfrom
input-state-typestate

Conversation

@huacnlee

@huacnlee huacnlee commented Aug 14, 2026

Copy link
Copy Markdown
Member

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:

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> value, cursor, text, focus, editing, selection
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(), cursor_position(), set_value and
diagnostics_mut() are unchanged.

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:

- 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:

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.
- 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)
- 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.
- 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.
- 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>.
  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.
- 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.
- 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.
  • 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.
- InputState::new(window, cx).soft_wrap(false)
+ TextareaState::new(window, cx).soft_wrap(false)
  • InputHighlighter::update takes the editor's context.
  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

`WindowExt::focused_input` returned `Option<Entity<InputState>>`, so after
the Input/Textarea/Editor split there was no way to reach the focused state
of anything but a single-line `Input`.

Add `AnyInputState`, one enum covering every input state, and return it from
`focused_input`. `Textarea` and `Editor` render through `Input::from_base`
and were never registered at all, so `has_focused_input` was always `false`
for them; all four kinds now register in their own `render`.

`CompletionProvider` was the only LSP provider trait still taking
`&mut Context<InputBaseState>` while the other five take `&mut App`. No
implementation used the entity context, so it now matches the rest.

Remove `OtpState::compat_input_state`, which existed only to feed the old
registry.

Fixes #2711

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@huacnlee huacnlee closed this Aug 14, 2026
@huacnlee huacnlee reopened this Aug 14, 2026
@huacnlee
huacnlee force-pushed the input-state-typestate branch 3 times, most recently from e7430c9 to cc2730b Compare August 14, 2026 20:19
`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:

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

So `value()` reads `&self` — no `cx`, no copy, nothing to keep in step — and the
marker decides which methods exist. `step_by` is single-line only, `auto_grow`
and `rows` are multi-line only, and code actions and the LSP drive belong to the
editor. The marker set is sealed, since the engine branches on a closed set of
runtime layouts.

The marker also carries the state only its mode needs, through
`InputModeKind::Extras`, so a form full of text fields no longer carries an
editor's worth of machinery. `Lsp` with its three tasks, the decoration
collections, the inline completion, and the hover and context-menu state move
into `EditorExtras`:

    InputState     2544 -> 1856 bytes  (-27%)
    TextareaState  2544 -> 2088 bytes  (-18%)
    EditorState    2544 -> 2776 bytes

Because that data is no longer on the engine, `lsp` becomes `EditorState::lsp`
and `lsp_mut` — a method that exists on the editor alone, where the old public
field was reachable from every input.

The engine's generic paths dispatch mode-specific work through `InputModeKind`:
the editor registers its own actions, drives its highlighter, and supplies the
decorations, semantic tokens and hover geometry the shared renderer paints. That
is also why the LSP seams need no back-reference: inside the editor's own
context, `cx.entity()` already is an `Entity<EditorState>`, so
`CodeActionProvider` and `InputHighlighter` take it directly.

`gpui-component` follows suit. `Input`, `SearchPanel` and the overlay registry
are generic over the mode; `OverlayMode` decides which overlays a mode shows and
reads its language-feature snapshot, so the popovers are built for the editor
alone. The inspector's editors, and the html/markdown/editor examples, now use
`EditorState`; `large-text` was `multi_line(true)` rather than a code editor, so
it becomes `TextareaState`; the go-to-line fields become `InputState`.

`InputBaseState` is no longer exported from `gpui_component::input`.

`validate` and `step_by` keep the `&mut App` closures the facades exposed, so
callers are unaffected. `prepare` stays as a no-op, since configuration now
applies as it is set.

Also fixes the WASM build, whose `syntect` highlighters implemented `update`
against a signature only `cfg(wasm)` ever compiled, and a `typos` failure in the
Collapsible story's fake API key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@huacnlee
huacnlee force-pushed the input-state-typestate branch from cc2730b to caa25ad Compare August 14, 2026 21:04
huacnlee and others added 11 commits August 15, 2026 11:15
Splitting the constructor into one `new` per mode moved the shared
defaults into `new_in_mode`, where `soft_wrap` was written as `false`.
None of the three `new` functions set it back, so every Textarea and
Editor stopped wrapping, while the doc comments still promised the old
default.

Set it back to `true` and pin it with a test, since the value now lives
one level away from the constructors that a reader would check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twenty-one methods that only apply to one kind of input sat in the
shared `impl` block, each opening with a `debug_assert!` on the runtime
layout mode. `InputState::soft_wrap(false)` and
`EditorState::masked(true)` compiled, then panicked in debug and passed
silently in release.

Move each one into the `impl` block of the mode it belongs to, so the
compiler rejects it instead. The two multi-line modes share several of
these, so add a sealed `MultiLineMode` marker for `TextareaMode` and
`EditorMode` to bound one `impl` block on, rather than writing the
methods twice.

`toggle_masked` stays generic: the reveal button renders from the shared
path, and the toggle can only be switched on through `InputState`.

No call site needed changing, in the library, the stories, or the
examples, which is the evidence that nothing relied on the old runtime
check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Overlay sync runs on every frame. It compared the popovers against
`format!("{:?}", …)` of their own content, so an editor with the
completion menu open re-serialised the whole item 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. Since `hide_context_menu`
only clears `open` and leaves the items in place, a closed menu kept
paying for a full clone of the list from the last time it opened.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two calls in the edit path were reaching for the wrong annotation hook.

On blur the engine called `reset_annotations`, which drops the hover
popover *and* clears every decoration. Blur used to drop only the hover
popover, so clicking away now 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".

The other call lost its body during the same refactor, leaving

    if mask_changed {
    } else {
        M::adjust_annotations(..);
    }

where the taken branch used to clear the decorations. Masking rewrites
the whole document, so ranges recorded against the old text point at
nothing; `reset_annotations` is what that branch wants. Harmless today,
since masking is single-line and a single-line input holds no
decorations, but it read as if it handled a case it dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`LayoutMode` answered two different questions: how many rows to show and
how to grow them, and what kind of input this is. The second one already
had an answer at the type level, so the two could disagree — and did:

    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.

Move the kind onto `InputModeKind` as `MULTI_LINE` and `CODE_EDITOR`
associated constants and answer from there. `LayoutMode` keeps the row
counts, the growth policy and the code-editor extras, and loses its
`multi_line` fields along with the three predicates derived from them.
Where the layout still needs the distinction — auto-grow, indenting,
indent guides — the caller passes it in.

Also moves `indent_guides` and `tab_size` into the mode blocks they
belong to; they were the same debug-assert pattern one file over, missed
by the earlier pass. No `debug_assert!` on the input mode is left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`InputModeKind` had grown to 29 methods, 28 of which existed only for
the editor, and they were two different things wearing one name: places
where the engine hands control back mid-edit, and plain reads of fields
the renderer cannot reach because it is generic over the mode.

Split the reads out into `InputExtras`, implemented on the extras type
itself, so they are ordinary methods on ordinary data:

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

`()` implements it with empty answers, which is what a plain input and a
textarea have to say. Adding a field an editor renders now touches only
this trait, and leaves the engine's callbacks alone.

Three methods had no callers at all — `lsp`, `lsp_mut` and
`hover_popover`, all shadowed by inherent methods on `EditorState` —
so they are gone.

`hover_definition_style` and `hover_definition_hitbox` were associated
functions on `TextElement<EditorMode>` that never touched the element,
reached through a hop from the engine's trait. They move onto
`InputBaseState<EditorMode>` where their data lives.

29 methods become 19 callbacks plus 6 accessors. The remaining 19 are
genuine re-entry points; removing those means the renderer no longer
being generic over the mode, which is a separate change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`EditorState::new` took a language ahead of `window` and `cx`, so the
three states were built three different ways and the editor's was the
odd one out. The language is a property of the editor like folding or
line numbers are, not something its construction depends on.

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

All three now read `new(window, cx)`, with the rest set through
builders. `LayoutMode::code_editor` loses its argument for the same
reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Input::focused_as` asked the caller to hand over the `AnyInputState` to
register while the element holds focus. Every caller passed a value the
type already determined — `Input<TextareaMode>` can only be
`AnyInputState::Textarea` — and the field being an `Option` made "do not
register at all" a reachable state that nothing wanted.

Derive it instead, through the UI layer's existing per-mode seam, and
drop the field, the builder and its three call sites. The `From` impls
on `AnyInputState` are the mapping; `OverlayMode::any_state` is how the
generic renderer reaches them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Input` was generic over the mode, and so were `Sizable`, `Selectable`,
`FocusableExt`, `Styled` and `RenderOnce` on it. Of the twelve things it
does to its state, every one is a method the shared engine offers for
every mode; only three places actually needed to know which mode it was
— the overlay registry, the focused-input identity, and handing the
state to the frame as a child.

Twelve generic call sites paid for by three. Hold a three-variant enum
instead and dispatch, and none of it needs a type parameter:

    -pub struct Input<M: OverlayMode = InputMode> {
    -    state: Entity<InputBaseState<M>>,
    +pub struct Input {
    +    state: TextInputState,

`AnyInputState` could not serve, because its fourth variant is
`OtpState`, a different engine with none of these methods; forcing it in
would leave Otp as a no-op on half of them. `TextInputState` is the
narrower enum, with `AnyInputState` derived from it where the window
registry needs the wider one.

Three things follow. `Input` is one instantiation rather than three.
`OverlayMode`, `LspOverlays` and `LspSnapshot` are `pub(crate)`, since
nothing public is generic over the mode any more — which means
`InputBaseState` no longer appears in any public signature in
`crates/ui`, only in one `pub(crate) use`. And `OverlayMode::any_state`,
added a commit ago, is gone: the enum's own `From` impl replaced it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rivate

`HighlighterUpdate` was `#[doc(hidden)] pub` while its only consumer,
`LayoutMode::update_highlighter`, is `pub(crate)` and both modules on
the path to it are private. It was never reachable from outside; the
`pub` said otherwise. Now `pub(crate)`, and the `#[doc(hidden)]` that
was hiding a name nobody could write goes with it.

`InputBaseState` is the opposite case: its doc comment claimed the name
was not part of the public API, and that is not true. The three states
are type aliases of it, and an alias is only as usable as the type
behind it — making it `pub(crate)` leaves `InputState` unable to do
anything, which the compiler confirms with `private_interfaces` on
every alias and accessor. Say what is actually true instead, and point
readers at the aliases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Forty-eight items across the input module were `pub` while sitting
behind a private module path, so nothing outside the crate could name
them. The `pub` claimed an API surface that did not exist, and reading
the module gave no way to tell which items were genuinely public.

Restrict them to `pub(crate)`, and put `#![warn(unreachable_pub)]` on
the module so the next one is caught at the point it is written rather
than by a later audit. The mechanical part is `cargo fix`'s work; the
lint is what keeps it done.

Two `#[doc(hidden)]` attributes go with them: once an item cannot be
named from outside, hiding it from the docs is describing a name nobody
can write. Four test helpers lose their `pub` for the same reason.

Scoped to `input/`. The rest of the crate has about six more, left for
a separate pass so this one stays reviewable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@huacnlee
huacnlee changed the base branch from input-focused-any-state to main August 15, 2026 04:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant