Skip to content

gpui-editor

A from-scratch, multi-line text editor for GPUI — the basis for Zorite’s note editor.

Host-agnostic: it depends only on gpui (+ unicode-segmentation), not on gpui-component. It’s built directly on GPUI’s text primitives — an EntityInputHandler for keyboard + IME input, shape_line for per-line text shaping, and a custom Element that lays out and paints the lines, caret, and selection.

  • Auto-grows to its content height (no inner scrollbar), so a host can stack many editors in one scroll view (e.g. a journal feed).
  • Editing: insert / backspace / delete / newline, arrow + Home/End + word-wise navigation, visual-row up/down, copy / cut / paste, IME, undo / redo (coalesced), click + drag selection, double-click word / triple-click line.
  • Soft-wrap with content-driven height.
  • Spell-check squiggles: the host feeds in misspelled byte ranges (Diagnostic); a right-click menu offers replacements via a lazy provider.
  • Live-preview Markdown (“WYSIWYG”): with a SyntaxStyle installed, the editor styles its own content as you type — headings (variable line height), bold / italic / strikethrough, inline code, links / wiki-links / tags, blockquotes, lists, task checkboxes, fenced code blocks, thematic rules, footnotes, reference links, <mark> — with the raw Markdown markers hidden and revealed only around the caret.
  • Block widgets: standalone images, file chips (e.g. PDF embeds), and mermaid diagrams render in place via host-supplied providers (raw source under the caret).
  • Tables: rendered as a grid and edited in the cells; host-driven column alignment and row/column insert/delete.

It’s a path/git crate (not on crates.io — gpui is a git-only dependency):

[dependencies]
gpui-editor = { path = "crates/gpui-editor" } # or a git dependency

gpui revision: this crate pins gpui = { git = ".../zed" } with no rev and relies on the workspace’s single lockfile to unify everything onto one gpui. In a separate workspace, keep the gpui rev in lockstep with this crate’s lock or you’ll get two gpui versions in one build (won’t compile).

use gpui::*;
use gpui_editor::{EditorState, EditorEvent};
// 1. Once at startup, bind the editing keys (scoped to the editor's key context).
gpui_editor::bind_keys(cx);
// 2. Create the editor entity.
let editor = cx.new(|cx| {
EditorState::new(window, cx)
.with_placeholder("Type here…")
.with_text("# Hello\n\nSome **markdown**.")
});
// 3. Focus it to start editing.
editor.update(cx, |ed, cx| ed.focus(window, cx));
// 4. React to edits (e.g. save, re-run spell-check).
cx.subscribe(&editor, |_host, editor, event: &EditorEvent, cx| {
if let EditorEvent::Changed = event {
let text = editor.read(cx).text().to_string();
// …save `text`…
}
})
.detach();

EditorState is a GPUI entity that renders itself, so a host just renders it as a child. The editor has no chrome of its own and inherits the ambient text style (size + color) from its wrapper — set those on the parent:

div()
.text_size(px(16.))
.text_color(rgb(0xe6e6e6))
.child(editor.clone())

bind_keys(cx) binds these in the editor’s "Editor" key context (so they don’t shadow the host’s shortcuts). cmd-* is macOS; ctrl-* is the cross-platform equivalent.

KeysAction
typing, backspace, delete, enteredit text
, home, endmove caret
alt-← / alt-→word left / right
shift- + any moveextend selection
cmd-aselect all
tab / shift-tabindent / outdent (list-aware)
cmd-c / cmd-x / cmd-vcopy / cut / paste
cmd-z / cmd-shift-z (ctrl-y)undo / redo
ctrl-cmd-spacemacOS character palette
escapedismiss the right-click suggestions menu

tab/shift-tab indent or outdent the caret’s list item by set_tab_indent spaces (or insert/remove that many spaces elsewhere), and move between cells when the caret is in a table.

Subscribe with cx.subscribe(&editor, …). EditorEvent:

VariantMeaning
ChangedThe text changed via a user edit (typing, delete, paste, IME, applying a suggestion). Not emitted for programmatic set_text.
OpenLink(SharedString)A file chip was left-clicked — the host should open the src. The chip stays in the document.
SelectionChangedThe caret/selection moved without a text change — for updating a caret-anchored affordance (e.g. a table-alignment toolbar).

Bind the editor’s editing keys. Call once at startup. Bindings are scoped to the "Editor" key context.

fn mermaid_sources(content: &str) -> Vec<SharedString>

Section titled “fn mermaid_sources(content: &str) -> Vec<SharedString>”

The diagram sources of every ```mermaid block in content, so a host can pre-render them off-thread before the editor’s mermaid provider is consulted.

The editor: text + caret/selection state, undo/redo history, and a cached layout (the wrapped lines from the last paint, for hit-testing + IME). Implements Render and Focusable.

fn new(window: &mut Window, cx: &mut Context<Self>) -> Self
fn with_text(self, text: impl Into<String>) -> Self // builder; caret at start
fn with_placeholder(self, text: impl Into<SharedString>) -> Self // builder; shown when empty
fn text(&self) -> &str // borrowed
fn value(&self) -> SharedString // owned
fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>)

set_text replaces the whole document, resets the caret to the start, and clears undo history. It does not emit Changed (it’s a programmatic load).

fn cursor(&self) -> usize // caret byte offset
fn set_cursor(&mut self, offset: usize, cx: &mut Context<Self>) // clamped to a char boundary
fn focus(&self, window: &mut Window, cx: &mut Context<Self>) // enter edit mode
fn bounds_for_offset(&self, offset: usize) -> Option<Bounds<Pixels>> // window-space caret box
fn last_edit_was_keystroke(&self) -> bool // gate auto-pairing on this
  • set_cursor only moves the caret; call focus to actually receive keyboard input (e.g. when entering edit mode from clicked rendered text).
  • bounds_for_offset reads the last paint’s layout — None before the first paint. Use it to anchor a popup (slash menu, toolbar) at a document offset.
  • last_edit_was_keystroke is true only after a single typed character or a single-character backspace — not after a programmatic / multi-char edit (table ops, paste). A host that does its own auto-pairing should gate it on this.
fn set_diagnostics(&mut self, diagnostics: Vec<Diagnostic>, cx: &mut Context<Self>)
fn on_suggest(&mut self, provider: impl Fn(&str) -> Vec<String> + 'static)

The host computes Diagnostic spans (e.g. with the os-spellcheck crate) and feeds them in — each underlined with a red squiggle. on_suggest installs the provider consulted only on right-click of a flagged word (kept lazy because the OS suggestion call can be slow); it returns replacements, best first, shown in a popup menu that applies the chosen one on click.

fn set_markdown_style(&mut self, style: SyntaxStyle, cx: &mut Context<Self>)
fn clear_markdown_style(&mut self, cx: &mut Context<Self>)

With a SyntaxStyle installed, the editor renders Markdown live (markers hidden except around the caret). clear_markdown_style falls back to plain text (spell squiggles only) — e.g. when the host’s WYSIWYG setting is toggled off.

Standalone ![](src) lines and ```mermaid blocks render as widgets when the caret is elsewhere (raw source under the caret). The host owns loading/caching/rendering and supplies a provider:

fn set_block_image_provider(&mut self, provider: impl Fn(&str) -> Option<Arc<RenderImage>> + 'static)
fn set_block_chip_provider(&mut self, provider: impl Fn(&str) -> Option<SharedString> + 'static)
fn set_block_mermaid_provider(&mut self, provider: impl Fn(&str) -> Option<Arc<RenderImage>> + 'static)
  • Image: resolve src → a decoded RenderImage (or None while loading → the line shows raw ![](src)).
  • Chip: classify an ![](src) as a file chip (e.g. a PDF) and return its label → the line renders as a clickable chip; a left-click emits EditorEvent::OpenLink(src), a right-click places the caret to edit.
  • Mermaid: resolve a fenced block’s source → a rendered diagram bitmap. Pre-render with mermaid_sources.
fn set_tab_indent(&mut self, spaces: usize) // spaces per Tab / list-nesting level (min 1)

The editor renders GFM tables as a grid and edits inside the cells. These let a host drive column alignment and structural edits (e.g. from a toolbar or right-click menu); each is a no-op when the caret isn’t in a table.

fn caret_table_align(&self) -> Option<CellAlign> // current column's alignment (header row only)
fn set_caret_table_align(&mut self, align: CellAlign, cx: &mut Context<Self>) // rewrites the `|---|` separator
fn insert_table_row(&mut self, below: bool, cx: &mut Context<Self>) // above / below the caret's row
fn delete_table_row(&mut self, cx: &mut Context<Self>) // body rows only
fn insert_table_column(&mut self, right: bool, cx: &mut Context<Self>) // left / right of the caret's column
fn delete_table_column(&mut self, cx: &mut Context<Self>) // not the last column

caret_table_align returns Some only while the caret is in the header row (alignment is a per-column property, set once from the header), so it doubles as “should I show the alignment control?”.


Colors + monospace font for the live-preview styling, supplied by the host so the editor stays theme-agnostic. All fields are gpui::Hsla except mono: gpui::Font.

FieldStyles
markerdimmed syntax markers (**, `, [, ](…), …)
codeinline `code` text
code_bginline-code background (also the table row-shade tint)
link[text](url), [[wiki-links]], footnote/reference refs
tag#tags
quoteblockquote text + left border (a muted tone)
mark_bg<mark> highlight background
monomonospace font for inline code + code blocks
pub struct Diagnostic { pub range: Range<usize> } // byte range in the document

A flagged span to underline. &text[range] is the offending word.

Changed · OpenLink(SharedString) · SelectionChanged — see Events.

Left · Center · Right — a table column’s text alignment, for caret_table_align / set_caret_table_align.


A standalone window wired to the real OS spell checker (via the os-spellcheck crate), live Markdown styling, a PDF-style file chip, and styled tables:

Terminal window
cargo run -p gpui-editor --example demo

Type to watch the spell squiggles update; right-click a flagged word for suggestions.

  • Main thread: like all GPUI UI, drive the editor on the main thread.
  • No styling without a SyntaxStyle: absent set_markdown_style, the editor is a plain-text editor (with spell squiggles if diagnostics are fed in).
  • The editor caches the last paint’s layout for hit-testing, bounds_for_offset, and IME — those return None/defaults before the first paint.

GPL-3.0-or-later.