TWrite Documentation
TWrite is a fast, modular text editor engine for Rust, built on the GPU-accelerated GPUI framework. It provides an extensible foundation for building everything from simple single-line inputs to full-featured Markdown note-taking tools and modal code editors.
Highlights
- GPU Acceleration: Fluid 60+ FPS text rendering and smooth scrolling powered by GPUI.
- Rope Buffer Engine: Backed by
ropeyfor fast inserts, deletes, and multi-megabyte document handling with transaction-based undo/redo. - Extensible Hook Architecture: Custom keybindings, auto-pairs, status indicators, and modal editing via pure-Rust hooks with zero GPUI boilerplate.
- Batteries-Included Markdown: Live syntax highlighting, concealed formatting (
Dimmed,Hidden,Off), interactive task checkboxes, tables, and hyperlinks. - Interactive Find & Replace: Built-in search engine with regex support, whole-word and case toggles, caret steppers, and single-undo replace all.
- Command Palettes & Prompts: Headless prompt engine for floating
F1palettes,:goto-linebottom bars, and fuzzy-filtered item lists. - Multi-Click Selection: Word double-click, line triple-click, and boundary-snapping drag selection.
Where to Start
- Getting Started: Add
twriteto yourCargo.tomland launch your first editor window in under 35 lines of code. - Recipes:
- Simple Text Editor: Basic editor setup, configuration, and buffer operations.
- Markdown Note Editor: Enable Markdown formatting, task lists, and conceal modes.
- Find & Replace: Wire up the built-in search toolbar.
- Custom Hooks & Shortcuts: Intercept keys, track edits, and add status bars.
- Command Palettes & Prompts: Build floating palettes and command bars.
- Modal & Vim Editing: Build modal editors with hooks alone.
- Reference:
- Configuration Reference: Full options table for
EditorConfig. - API Reference: Links to rustdoc API reference.
- Changelog: Release history and notes.
- Configuration Reference: Full options table for
Getting Started
This guide walks you through embedding TWrite into a GPUI application from scratch.
1. System Prerequisites
GPUI renders natively using Vulkan or Metal, with Wayland/X11 on Linux. On Linux systems, install the windowing and font development packages:
sudo apt-get install -y pkg-config libfontconfig1-dev libwayland-dev \
libx11-xcb-dev libxkbcommon-x11-dev libxkbcommon-dev
2. Add Dependencies to Cargo.toml
Add twrite and GPUI to your application manifest:
[package]
name = "my-editor-app"
version = "0.1.0"
edition = "2024"
[dependencies]
gpui = "0.2"
twrite = "0.9"
# Optional: To enable the full Markdown battery, use:
# twrite = { version = "0.9", features = ["markdown"] }
# Wayland-only (or otherwise trimmed) backends: official `gpui` folds the old
# gpui_platform crate into itself, so select features on both lines.
# gpui = { version = "0.2", default-features = false, features = ["wayland"] }
# twrite = { version = "0.9", default-features = false, features = ["wayland"] }
3. Your First Editor Application
Create src/main.rs with the following minimal app:
use gpui::*; use twrite::Editor; struct AppView { editor: Entity<Editor>, } impl Render for AppView { fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement { div() .size_full() .bg(rgb(0x1e1e2e)) .child(self.editor.clone()) } } fn main() { Application::new().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(800.0), px(600.0)), cx); cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), titlebar: Some(TitlebarOptions { title: Some("My First TWrite Editor".into()), ..Default::default() }), ..Default::default() }, |_window, cx| { let editor = cx.new(|cx| { let mut ed = Editor::new("Hello from TWrite!\nStart typing here...\n", cx); ed.config.line_numbers = true; ed }); cx.new(|_cx| AppView { editor }) }, ) .unwrap(); }); }
Run your application:
cargo run
You now have a fully functional text editor window with smooth scrolling, text selection, undo/redo (Ctrl+Z / Ctrl+Y), and line numbers.
4. Next Steps
Check out the step-by-step recipes:
- Simple Text Editor: Configure fonts, line numbers, cursor blinking, and read/write buffer content.
- Markdown Note Editor: Add syntax highlighting, task checkboxes, and conceal formatting.
- Find & Replace: Drop in the interactive search and replace toolbar with one line of code.
- Custom Hooks & Shortcuts: Bind custom keyboard shortcuts and status bar info.
Recipe: Simple Text Editor
This recipe shows how to configure a clean, lightweight plain text editor and interact with its text buffer from your GPUI application.
Basic Setup
Initialize an Editor entity inside a GPUI window:
#![allow(unused)] fn main() { use gpui::*; use twrite::Editor; struct EditorApp { editor: Entity<Editor>, } impl EditorApp { pub fn new(cx: &mut Context<Self>) -> Self { let editor = cx.new(|cx| { let mut ed = Editor::new("Initial document text...\n", cx); // Configure editor display settings: ed.config.line_numbers = true; ed.config.cursor_blink = true; ed.config.font_size = px(15.0); ed.config.line_height = px(24.0); // Optional: override monospace font family (auto-selected by default): // ed.config.font_family = Some("JetBrains Mono".into()); ed }); Self { editor } } } }
Reading and Writing Buffer Content
The underlying text is held by ed.buffer (an EditorBuffer powered by a rope structure):
#![allow(unused)] fn main() { impl EditorApp { /// Reads the complete document text as a String. pub fn get_content(&self, cx: &App) -> String { self.editor.read(cx).buffer.text() } /// Replaces the document text cleanly (resets undo history). pub fn set_content(&mut self, text: &str, cx: &mut Context<Self>) { self.editor.update(cx, |ed, cx| { ed.buffer.set_text(text); cx.notify(); }); } /// Inspects document size without copying text. pub fn print_stats(&self, cx: &App) { let ed = self.editor.read(cx); println!("Lines: {}", ed.buffer.line_count()); println!("Characters: {}", ed.buffer.len_chars()); println!("Bytes: {}", ed.buffer.len_bytes()); println!("Edit Version: {}", ed.buffer.version()); } } }
Adding a Status Bar
To build a professional editor layout with a status line displaying cursor row, column, and match count:
#![allow(unused)] fn main() { impl Render for EditorApp { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { let ed = self.editor.read(cx); let point = ed.buffer.cursor_point(); let status = format!( "Ln {}, Col {} | {} lines", point.row + 1, point.col + 1, ed.buffer.line_count() ); div() .size_full() .flex() .flex_col() .bg(rgb(0x1e1e2e)) // Main editor area: .child(div().flex_1().child(self.editor.clone())) // Bottom status bar: .child( div() .h(px(24.0)) .bg(rgb(0x181825)) .border_t_1() .border_color(rgb(0x313244)) .px(px(8.0)) .flex() .items_center() .justify_between() .text_xs() .text_color(rgb(0xa6adc8)) .child(status), ) } } }
Built-In Keyboard Shortcuts
The basic editor comes equipped with standard desktop shortcuts out of the box:
- Arrow Keys: Move cursor by character / line.
- Ctrl+Left / Ctrl+Right (or Alt+Left / Alt+Right): Jump word-by-word.
- Home / End: Jump to line start or line end.
- Ctrl+Z: Undo edit.
- Ctrl+Y (or Ctrl+Shift+Z): Redo edit.
- Ctrl+A: Select all.
- Backspace / Delete: Single-character deletion.
- Ctrl+Backspace / Ctrl+Delete: Delete previous or next word.
- Double-click: Select word under mouse cursor.
- Triple-click: Select entire line.
- Click and Drag: Selection expands with boundary snapping.
Recipe: Markdown Note Editor
TWrite includes a built-in Markdown battery that provides live syntax highlighting, concealed markup tokens, interactive task lists, and clickable hyperlinks.
1. Enable the markdown Feature
In your Cargo.toml, enable the optional markdown feature flag:
[dependencies]
twrite = { version = "0.9", features = ["markdown"] }
2. One-Line Initialization
Call ed.enable_markdown() when creating the editor:
#![allow(unused)] fn main() { use gpui::*; use twrite::Editor; let editor = cx.new(|cx| { let mut ed = Editor::new("# Welcome to TWrite\n\n- [ ] Task item\n", cx); // Activates markdown syntax highlighting, task lists, and shortcuts: ed.enable_markdown(); ed.config.line_numbers = true; ed }); }
3. Features Included Out of the Box
When ed.enable_markdown() is called, the following behaviors are activated automatically:
Interactive Task Checkboxes
Lines starting with - [ ] or - [x] (or numbered 1. [ ]) render as clickable checkboxes. Single-clicking the checkbox with the mouse toggles its state between checked and unchecked without having to type.
Clickable Hyperlinks
Inline links formatted as [Link text](https://example.com) are detected. Single-clicking a link opens the URL in the system default browser. Double-clicking still selects the text for editing.
Smart List Continuation
Pressing Enter at the end of a list item automatically inserts the next bullet point (- or * ) or increments ordered list numbers (1. , 2. ). Pressing Enter on an empty list bullet removes the prefix.
Formatting Shortcuts
Selecting text and pressing formatting keys wraps the selection automatically:
Ctrl+B: Toggles bold (**text**)Ctrl+I: Toggles italic (*text*)- Backtick (
`): Wraps selection in code span (`text`) - Auto-pairs: Typing
(,[,{,",'wraps the selection or inserts matching pairs.
4. Conceal Modes
TWrite supports three conceal levels for Markdown syntax markers (such as **, #, and backticks):
| Mode | Behavior |
|---|---|
ConcealMode::Dimmed (default) | Syntax markers are drawn in a subtle, dimmed color to keep content legible while keeping raw characters visible. |
ConcealMode::Hidden | Syntax markers are hidden completely. When the cursor enters a line, markers un-conceal so you can edit them directly. |
ConcealMode::Off | All characters are rendered at full normal opacity. |
To change or toggle conceal mode dynamically:
#![allow(unused)] fn main() { use twrite::markdown::{ConcealMode, MarkdownHighlighter}; ed.update(cx, |ed, cx| { ed.config.markdown.conceal_mode = ConcealMode::Hidden; ed.set_highlighter(MarkdownHighlighter::with_config(ed.config.markdown)); cx.notify(); }); }
5. Complete Markdown Example
Here is a full view component demonstrating Markdown setup with an active status badge:
#![allow(unused)] fn main() { use gpui::*; use twrite::Editor; use twrite::SearchHook; struct MarkdownEditorApp { editor: Entity<Editor>, } impl MarkdownEditorApp { pub fn new(cx: &mut Context<Self>) -> Self { let editor = cx.new(|cx| { let initial_content = r#"# Project Notes # Features - [x] GPU-accelerated canvas - [ ] Customizable theme colors - [x] Interactive task lists Visit the [TWrite GitHub](https://github.com/ToonionOfficial/twrite) for updates. "#; let mut ed = Editor::new(initial_content, cx); ed.enable_markdown(); ed.add_hook(SearchHook::new()); ed }); Self { editor } } } impl Render for MarkdownEditorApp { fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement { div() .size_full() .bg(rgb(0x1e1e2e)) .child(self.editor.clone()) } } }
Recipe: Find & Replace
TWrite provides a complete, interactive Find & Replace toolbar as a drop-in hook. It includes regex pattern support, whole-word filtering, case sensitivity toggles, caret steppers, and single-undo batch replacements.
1. Drop-In Registration
To enable find and replace, register SearchHook on your editor:
#![allow(unused)] fn main() { use twrite::Editor; use twrite::SearchHook; let mut ed = Editor::new("Initial document content...\n", cx); // Add SearchHook (register early so its shortcuts take priority): ed.add_hook(SearchHook::new()); }
That is all that is needed. The editor will automatically render the interactive search toolbar when triggered.
2. Keyboard Controls
| Shortcut | Action |
|---|---|
Ctrl+F | Open find bar (seeds query with any active selection). Focuses the find input. |
Ctrl+H | Open/expand the replace row. Focuses the replace input. |
Enter (Find input) | Advance to the next match. |
Enter (Replace input) | Replace current match and advance to the next match. |
Tab | Switch focus between Find and Replace input boxes. |
F3 / Down | Jump to next match. |
Shift+F3 / Up | Jump to previous match. |
Alt+Up / Alt+Down | Cycle through previous search query history. |
Alt+C | Toggle Match Case. |
Alt+W | Toggle Whole Words. |
Alt+H | Toggle Highlight All (canvas background wash). |
Alt+A | Replace All matches as a single undoable transaction. |
Esc | Close search toolbar and return focus to the editor buffer. |
3. Input Editing Shortcuts
Inside both the Find and Replace input boxes, standard desktop text editing shortcuts are fully supported:
Ctrl+Left/Ctrl+Right(orAlt+Left/Alt+Right): Move cursor word-by-word.Ctrl+Backspace/Alt+Backspace: Delete previous word.Ctrl+Delete/Alt+Delete: Delete next word.Ctrl+K: Clear input from cursor to end of line.Ctrl+U: Clear input from cursor to start of line.Home/End: Jump to start or end of input.
4. Mouse Controls
The search bar renders an interactive UI with mouse support:
- Expand Arrow (
▶/▼): Expands or collapses the replace row. - Find and Replace Boxes: Click either input box directly to focus it without closing or losing state.
- Steppers (
^andv): Click to navigate to the previous or next match. - Checkboxes: Click
Highlight All,Match Case, orWhole Wordsto toggle search criteria with immediate rescanning. - Action Buttons: Click
Replaceto replace the current match, orReplace Allto replace every match across the document. - Close Button (
✕): Dismisses the search toolbar.
5. Headless Search Engine API
If you want to perform programmatic search or replace operations without opening the prompt toolbar, you can access the headless engine directly:
#![allow(unused)] fn main() { use twrite::search::{SearchQuery, find_matches, replace_all_query}; let text = ed.buffer.text(); let query = SearchQuery::literal("foo").case_sensitive(true); // Collect all matching byte ranges: let matches = find_matches(&text, &query); println!("Found {} matches", matches.len()); // Programmatically replace all occurrences in a single undo step: let count = replace_all_query(&mut ed.buffer, &query, "bar").unwrap(); println!("Replaced {} occurrences", count); }
Recipe: Custom Hooks & Shortcuts
Hooks (EditorHook) are the primary extension mechanism in TWrite. They are pure Rust traits with no direct GPUI dependencies, meaning your editor logic remains testable, decoupled, and reusable across frontends.
1. The EditorHook Trait
Implement EditorHook to tap into editor lifecycle events:
#![allow(unused)] fn main() { use twrite::{EditorHook, HookContext, HookOutcome, KeyCode, KeyEvent}; pub struct MyCustomHook; impl EditorHook for MyCustomHook { /// Intercepts keyboard input before the editor buffer processes it. fn on_key(&mut self, ctx: &mut HookContext, event: &KeyEvent) -> HookOutcome { if event.code == KeyCode::Char('s') && event.modifiers.ctrl { println!("Custom Ctrl+S intercepted! Buffer has {} bytes", ctx.buffer.len_bytes()); return HookOutcome::Consumed; } HookOutcome::PassThrough } /// Feeds custom status text to the editor status bar. fn status_text(&self) -> Option<&str> { Some("MY-HOOK ACTIVE") } } }
Register the hook on your editor instance:
#![allow(unused)] fn main() { ed.add_hook(MyCustomHook); }
2. Hook Context Capabilities
HookContext provides mutable access to the editor core without exposing raw GPUI internals:
ctx.buffer: Read or edit text, inspect lines, get/set cursor position.ctx.selection: Inspect or modify active text selection range.ctx.cursor_style: Change the cursor shape (CursorStyle::Bar,Block,Underline,Hidden).ctx.prompt: Open or manage headless single-line inputs and command palettes.ctx.effects: Push application-level events (HookEffect::Save,HookEffect::Message, etc.) for the GPUI host to handle.
3. Practical Example: Word Counter and Auto-Save Trigger
Here is a practical hook that calculates word count on edits and dispatches a save effect on Ctrl+S:
#![allow(unused)] fn main() { use twrite::{EditorHook, HookContext, HookEffect, HookOutcome, KeyEvent}; pub struct WordCountAndSaveHook { word_count: usize, status_display: String, } impl WordCountAndSaveHook { pub fn new() -> Self { Self { word_count: 0, status_display: "0 words".to_string(), } } fn recalculate(&mut self, text: &str) { self.word_count = text.split_whitespace().count(); self.status_display = format!("{} words", self.word_count); } } impl EditorHook for WordCountAndSaveHook { fn on_key(&mut self, ctx: &mut HookContext, event: &KeyEvent) -> HookOutcome { if event.code == KeyCode::Char('s') && event.modifiers.ctrl && !event.modifiers.alt { ctx.effects.push(HookEffect::Save); ctx.effects.push(HookEffect::Message("Document saved".to_string())); return HookOutcome::Consumed; } HookOutcome::PassThrough } fn after_edit(&mut self, ctx: &mut HookContext) { let text = ctx.buffer.text(); self.recalculate(&text); } fn status_text(&self) -> Option<&str> { Some(&self.status_display) } } }
4. Context Menu Items
Right-click opens a menu of built-in edit rows (Undo/Redo/Cut/Copy/Paste/Delete/Select All) followed by hook rows. Contribute rows with context_menu_items and handle clicks with on_context_menu_action (run before built-in dispatch; return Consumed to halt, including to suppress a default by reusing its id like "copy"):
#![allow(unused)] fn main() { use twrite::{ContextMenuContext, ContextMenuItem, EditorHook, HookContext, HookOutcome, KeyCode, KeyHint}; impl EditorHook for MyCustomHook { fn context_menu_items(&self, _ctx: &ContextMenuContext) -> Vec<ContextMenuItem> { vec![ContextMenuItem::with_hint( "my-action", "Do thing", KeyHint::ctrl(KeyCode::Char('D')), )] } fn on_context_menu_action(&mut self, ctx: &mut HookContext, id: &str) -> HookOutcome { if id == "my-action" { // ... mutate ctx.buffer / ctx.selection ... return HookOutcome::Consumed; } HookOutcome::PassThrough } } }
Toggle the menu with EditorConfig.context_menu and the built-in rows with EditorConfig.show_default_menu_items.
5. Hook Execution Order
Hooks run in the order they were added to Editor:
- The first hook whose
on_keyreturnsHookOutcome::Consumedhalts the key pipeline. - If no hook consumes the key, the key falls through to the active prompt (if open), and finally to the default buffer text editing handlers.
- Therefore, register high-priority modal hooks (like search or Vim emulation) first.
Recipe: Command Palettes & Prompts
TWrite provides a headless prompt system (PromptState) capable of driving bottom-line inputs (such as :goto-line) and floating top command palettes (such as an F1 or Ctrl+P quick picker).
1. Two Prompt Shapes
Prompts are configured using PromptPlacement:
PromptPlacement::BottomBar: Single line anchored to the bottom border with optional leading prefix (for example,:or/).PromptPlacement::TopPalette: Centered floating modal overlay with item list and fuzzy search filtering.
2. Opening and Routing a Prompt
A hook opens a prompt via ctx.prompt.open(...), and routes incoming keystrokes through ctx.prompt.handle_key(event):
#![allow(unused)] fn main() { use twrite::{ EditorHook, HookContext, HookOutcome, KeyCode, KeyEvent, PromptAction, PromptPlacement, PromptSpec, }; pub struct GotoLineHook; impl EditorHook for GotoLineHook { fn on_key(&mut self, ctx: &mut HookContext, event: &KeyEvent) -> HookOutcome { // Open on Ctrl+G: if event.code == KeyCode::Char('g') && event.modifiers.ctrl { ctx.prompt.open( PromptSpec::new("goto-line", ":", "Enter line number", PromptPlacement::BottomBar, false), "", ); return HookOutcome::Consumed; } // When our prompt is open, route keys to it: if ctx.prompt.spec().is_some_and(|s| s.id == "goto-line") { match ctx.prompt.handle_key(event) { PromptAction::Submitted(input) => { if let Ok(line_num) = input.trim().parse::<usize>() { let target_row = line_num.saturating_sub(1); ctx.buffer.set_cursor_point(twrite::Point::new(target_row, 0)); ctx.prompt.close(); } else { ctx.prompt.set_message("Invalid line number".to_string()); } return HookOutcome::Consumed; } PromptAction::Cancelled => { return HookOutcome::Consumed; } PromptAction::Editing => { return HookOutcome::Consumed; } PromptAction::Ignored => {} } } HookOutcome::PassThrough } } }
3. Fuzzy-Filtered Command Palette
To build a floating F1 command palette with live fuzzy filtering, populate item rows whenever the input changes:
#![allow(unused)] fn main() { use twrite::prompt::{PromptItem, fuzzy_filter}; use twrite::{ EditorHook, HookContext, HookOutcome, KeyCode, KeyEvent, PromptAction, PromptPlacement, PromptSpec, }; pub struct CommandPaletteHook { available_commands: Vec<PromptItem>, } impl CommandPaletteHook { pub fn new() -> Self { Self { available_commands: vec![ PromptItem::with_hint("Toggle Line Numbers", "Alt+L"), PromptItem::with_hint("Enable Markdown", "Alt+M"), PromptItem::with_hint("Save File", "Ctrl+S"), PromptItem::with_hint("Find in Page", "Ctrl+F"), ], } } fn update_filtered_items(&self, ctx: &mut HookContext) { let query = ctx.prompt.input(); let ranked = fuzzy_filter(&self.available_commands, query); let items: Vec<PromptItem> = ranked .into_iter() .map(|(idx, _score)| self.available_commands[idx].clone()) .collect(); ctx.prompt.set_items(items); } } impl EditorHook for CommandPaletteHook { fn on_key(&mut self, ctx: &mut HookContext, event: &KeyEvent) -> HookOutcome { if event.code == KeyCode::F(1) || (event.code == KeyCode::Char('p') && event.modifiers.ctrl) { ctx.prompt.open( PromptSpec::new( "command-palette", ">", "Type a command...", PromptPlacement::TopPalette, true, ), "", ); self.update_filtered_items(ctx); return HookOutcome::Consumed; } if ctx.prompt.spec().is_some_and(|s| s.id == "command-palette") { match ctx.prompt.handle_key(event) { PromptAction::Editing => { self.update_filtered_items(ctx); return HookOutcome::Consumed; } PromptAction::Submitted(_input) => { if let Some(selected) = ctx.prompt.selected_item() { println!("Executing command: {}", selected.label); } ctx.prompt.close(); return HookOutcome::Consumed; } PromptAction::Cancelled => return HookOutcome::Consumed, PromptAction::Ignored => {} } } HookOutcome::PassThrough } } }
4. Built-in Features of All Prompts
Every open prompt automatically includes:
- Vertical bar cursor: Thin cursor with full UTF-8 character boundary safety.
- Word navigation & deletion:
Ctrl+Left/Ctrl+Right,Ctrl+Backspace/Ctrl+Delete,Ctrl+K. - History navigation:
UpandDownrecall previously submitted inputs (unless item rows are visible). - Tab completion: Pressing
Tabfills the input with the highlighted row label. - Escape: Automatically cancels and closes the prompt.
Recipe: Right-Click Context Menu
Right-click opens an expandable context menu. The editor supplies built-in edit rows; any hook can append its own rows via EditorHook::context_menu_items and handle them via EditorHook::on_context_menu_action. Like PromptState, the state is headless (twrite_core::ContextMenuState) and the GPUI layer only draws rows.
1. Built-in Rows
With EditorConfig.show_default_menu_items (default true), the menu leads with:
| id | Label | Enabled when |
|---|---|---|
undo | Undo (Ctrl+Z) | undo history is non-empty |
redo | Redo (Ctrl+Y) | redo history is non-empty |
cut | Cut (Ctrl+X) | a non-empty selection exists |
copy | Copy (Ctrl+C) | a non-empty selection exists |
paste | Paste (Ctrl+V) | the OS clipboard holds text |
delete | Delete | a non-empty selection exists |
select_all | Select All (Ctrl+A) | always (disabled when the whole document is already selected) |
Disabled rows render dimmed and ignore clicks. The clipboard check happens once per right-click in the GPUI host and is passed into the core as ContextMenuCaps, so item logic stays headless-testable.
2. Contributing Rows
Return rows from context_menu_items. You get a read-only snapshot: the click's buffer coordinates (clicked_row, clicked_col), the selection, and the caps above, so rows can be position-aware (e.g. only offer "Toggle task" on a task line):
#![allow(unused)] fn main() { use twrite::{ContextMenuContext, ContextMenuItem, EditorHook, KeyCode, KeyHint}; pub struct MyMenuHook; impl EditorHook for MyMenuHook { fn context_menu_items(&self, ctx: &ContextMenuContext) -> Vec<ContextMenuItem> { let has_selection = ctx.selection.is_some_and(|s| !s.byte_range().is_empty()); let mut shout = ContextMenuItem::with_hint( "my.uppercase", "UPPERCASE selection", KeyHint::ctrl(KeyCode::Char('U')), ); shout.enabled = has_selection; vec![shout] } } }
Rows append after the built-ins (and after earlier hooks' rows) in hook registration order. A separator divides the built-in block from hook rows automatically. Returning an item with a well-known id such as "copy" overrides that default in place — same position, your label and handler.
3. Handling Actions
Activations run hooks first, then fall through to the built-in edit dispatch. Return Consumed to halt (this also suppresses a same-id default):
#![allow(unused)] fn main() { use twrite::{EditorHook, HookContext, HookOutcome}; impl EditorHook for MyMenuHook { fn on_context_menu_action(&mut self, ctx: &mut HookContext, id: &str) -> HookOutcome { if id == "my.uppercase" { if let Some(sel) = ctx.selection.take() { let range = sel.byte_range(); if !range.is_empty() { let text = ctx.buffer.text().byte_slice(range.clone()).to_string(); ctx.buffer.replace_range(range, &text.to_uppercase()); } } return HookOutcome::Consumed; } HookOutcome::PassThrough } } }
HookContext gives the same mutable access as key handling (buffer, selection, cursor_style, prompt, effects), and the editor runs the usual post-processing (after_edit, selection callbacks, scrolling, effect flushing) after your action.
4. Interaction Contract
- Selection policy: a right-click inside the active selection keeps it (so Cut/Copy act on it); otherwise the cursor moves to the click and the selection collapses.
- Dismissal: left-click anywhere, mouse-wheel scroll,
Escape, or picking a row closes the menu. Typing any other key closes it and the keystroke still lands in the buffer. - Keyboard:
Up/Downmove across enabled rows,Enteractivates the highlighted row. - Positioning: the popup anchors at the click and clamps into the viewport.
5. Configuration
EditorConfig.context_menu(true): master switch for right-click menus.EditorConfig.show_default_menu_items(true): include the built-in edit block; hooks-only menus set this tofalse.EditorTheme.menu_bg,menu_border,menu_hover,menu_fg,menu_hint: popup styling.
6. Try It
Run the dedicated demo (custom UPPERCASE + separator actions on top of the defaults):
cargo run --example context_menu
Recipe: Modal & Vim Editing
Because TWrite routes all user input through EditorHook before keys reach the text buffer, you can implement complete modal editing systems (like Vim or Kakoune) purely in Rust without modifying the GPUI renderer.
1. The Modal State Pattern
A modal editor maintains an active state enum:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VimMode { Normal, Insert, Visual, } }
2. Cursor Style Synchronization
Set ctx.cursor_style dynamically to reflect the current mode visually:
#![allow(unused)] fn main() { use twrite::{CursorStyle, HookContext}; fn sync_cursor(mode: VimMode, ctx: &mut HookContext) { match mode { VimMode::Normal => *ctx.cursor_style = CursorStyle::Block, VimMode::Insert => *ctx.cursor_style = CursorStyle::Bar, VimMode::Visual => *ctx.cursor_style = CursorStyle::Underline, } } }
3. Handling Keystrokes by Mode
In on_key:
- In Normal Mode: consume keys to execute commands (
h,j,k,l,w,b,x,u). Wheniorais pressed, switch toInsert. - In Insert Mode: pass keys through so characters are typed into the buffer. When
Escapeis pressed, consume it and switch back toNormal. - In Visual Mode: move selection range endpoints. When
Escapeoryis pressed, return toNormal. The full example adds a linewise variant:Vselects whole lines (-- VISUAL LINE --), motions grow by line, andG/ggextend to the document bottom / top — soggVGselects everything.
Here is a minimal demonstration:
#![allow(unused)] fn main() { use twrite::{ CursorStyle, EditorHook, HookContext, HookOutcome, KeyEvent, Selection, }; pub struct MinimalVimHook { mode: VimMode, } impl MinimalVimHook { pub fn new() -> Self { Self { mode: VimMode::Normal, } } } impl EditorHook for MinimalVimHook { fn on_key(&mut self, ctx: &mut HookContext, event: &KeyEvent) -> HookOutcome { match self.mode { VimMode::Normal => { match &event.code { KeyCode::Char('i') => { self.mode = VimMode::Insert; *ctx.cursor_style = CursorStyle::Bar; return HookOutcome::Consumed; } KeyCode::Char('h') | KeyCode::Left => { ctx.buffer.move_cursor_left(); return HookOutcome::Consumed; } KeyCode::Char('l') | KeyCode::Right => { ctx.buffer.move_cursor_right(); return HookOutcome::Consumed; } KeyCode::Char('k') | KeyCode::Up => { ctx.buffer.move_cursor_up(); return HookOutcome::Consumed; } KeyCode::Char('j') | KeyCode::Down => { ctx.buffer.move_cursor_down(); return HookOutcome::Consumed; } KeyCode::Char('x') => { ctx.buffer.delete(); return HookOutcome::Consumed; } KeyCode::Char('u') => { ctx.buffer.undo(); return HookOutcome::Consumed; } _ => { // In normal mode, consume unhandled keys to prevent typing: return HookOutcome::Consumed; } } } VimMode::Insert => { if event.code == KeyCode::Escape { self.mode = VimMode::Normal; *ctx.cursor_style = CursorStyle::Block; return HookOutcome::Consumed; } // Allow characters to type naturally into buffer: HookOutcome::PassThrough } VimMode::Visual => { if event.code == KeyCode::Escape { self.mode = VimMode::Normal; *ctx.selection = None; *ctx.cursor_style = CursorStyle::Block; return HookOutcome::Consumed; } HookOutcome::Consumed } } } fn status_text(&self) -> Option<&str> { match self.mode { VimMode::Normal => Some("-- NORMAL --"), VimMode::Insert => Some("-- INSERT --"), VimMode::Visual => Some("-- VISUAL --"), } } } }
4. Search Submit and n / N
The stock SearchHook is a persistent toolbar: Enter navigates but leaves the prompt open. For vim-modal / / ?, track a search_modal flag when opening the search and, on plain Enter, delegate to the hook and then close just the prompt (keeping the query, matches, and highlight wash for n / N). The shared Ctrl+F toolbar path leaves the flag unset, so it keeps its stay-open behavior. * / # jump immediately and dismiss at once, so a later Enter can't skip a match. Since submit leaves the hook active, Escape (with the prompt closed) deactivates it again — clearing the highlight wash and the SEARCH status.
5. Full Vim Implementation Reference
For a complete implementation that includes operators (d, c, y), motions, multi-key sequences (gg, dd), count prefixes (3w), ex-commands (:w, :q), / search forwarding, and linewise Visual mode (V, ggVG), inspect the repository example:
cargo run --example vim
Located in examples/vim.rs.
Concepts
Four ideas compose the whole crate. Learn these and every example reads like prose.
Editor
Editor (in twrite-gpui) is the GPUI view and controller: it owns the
EditorBuffer, the theme and config, the hook chain, and (since the prompt
work) the shared PromptState plus the pending HookEffect queue. Its key
handling has a fixed order:
- Hooks run first via
on_key; the firstConsumedwins. - While a prompt is open, unconsumed keys never reach the buffer.
- Otherwise the built-in editing keys apply (typing, undo, selection, ...).
Hooks
EditorHook (in twrite-core) is the only extension point, and it's
GPUI-free. The methods you'll touch most:
on_key: intercept input; returnConsumedto halt the chain.before_insert: filter or rewrite typed text.after_edit: react to any buffer mutation.on_selection_change: track cursor/selection.status_text: feed the status bar.search_snapshot: expose search state to renderers (only search hooks).
HookContext hands every hook the same five handles: buffer, selection,
cursor_style, prompt, and effects. If you can express it through those,
it works on every frontend.
Prompt
PromptState is the headless input box behind the bottom bar and the F1
palette. Open one with a PromptSpec (your own id, prefix, placeholder,
placement), route keys through handle_key, and read the Submitted input:
#![allow(unused)] fn main() { ctx.prompt.open( PromptSpec::new("goto-line", ":", "Line number", PromptPlacement::BottomBar, false), "", ); // later, inside on_key while your spec is open: match ctx.prompt.handle_key(event) { PromptAction::Editing => { /* live-update here */ } PromptAction::Submitted(input) => { /* interpret input, then close */ } PromptAction::Cancelled => { /* Esc already closed it */ } PromptAction::Ignored => { /* swallow while yours is open */ } } }
Typing, history, Tab completion, and Esc come free; PromptBar draws
whatever is open with no per-client rendering code. See
Command Palettes & Prompts for the full pattern.
Batteries
Batteries are optional feature-gated packs (twrite_core::batteries) built
only on the public core API: the same surface you get. Markdown is the
reference battery: Editor::enable_markdown() wires its highlighter plus
hooks in one call. Writing a Battery documents the contract.
Writing a Battery
Batteries are optional feature-gated packs built only on the public core
API: the same surface external users get. The contract (from
twrite_core::batteries):
- Create
batteries/<name>.rs(promote tobatteries/<name>/mod.rswhen it outgrows one file) with a fixed template:Config(plainClonedata +Default),Highlighter(new+with_config),Hook(new+with_config). Hook-only batteries omit the highlighter. - Declare the feature in
twrite-core/Cargo.toml(<name> = [...], withdep:<parser-crate>only if the battery needs a parser dependency). - Register the one-line path shim in the crate root so the public path stays
twrite_core::<name>regardless of file layout. - Re-export from the
twritefacade astwrite::<name>behind the same feature name, so users writetwrite = { features = ["<name>"] }. - Add colocated unit tests in the battery module and an
examples/<name>.rsdemo (withrequired-featuresonly if the demo needs the battery).
Further rules: batteries emit only existing HighlightTag variants (never
add new ones per battery) and never require twrite-gpui-side code; they
wire up via set_highlighter / add_hook, as demonstrated by
examples/vim.rs.
Markdown (twrite_core::markdown, re-exported as twrite::markdown) is the
reference implementation: highlighter, MarkdownHook (shortcuts, lists,
tables, task toggles), and MarkdownConfig toggles.
Configuration Reference
The EditorConfig struct in twrite-gpui controls all visual and typographic settings for an Editor instance.
Overview
You can adjust settings either on initialization or dynamically at runtime:
#![allow(unused)] fn main() { use gpui::*; use twrite::Editor; use twrite::markdown::ConcealMode; let editor = cx.new(|cx| { let mut ed = Editor::new("Hello world\n", cx); // Typography ed.config.font_size = px(15.0); ed.config.line_height = px(24.0); ed.config.font_family = Some("JetBrains Mono".into()); // Appearance ed.config.line_numbers = true; ed.config.cursor_blink = true; // Markdown (when feature enabled) ed.config.markdown.conceal_mode = ConcealMode::Dimmed; ed }); }
Options Table
| Field | Type | Default | Description |
|---|---|---|---|
line_numbers | bool | false | When true, displays a left gutter with line numbers matching the document rope row. |
cursor_blink | bool | true | When true, the text cursor blinks periodically. Blinking pauses and remains fully visible while typing or navigating. |
font_size | Pixels | px(14.0) | Base font size used for buffer text rendering. |
line_height | Pixels | px(22.0) | Vertical height of each line in pixels. |
font_family | Option<SharedString> | None | Monospace font family name. When None, TWrite auto-detects the first monospace font with bold and italic faces on the system. |
markdown.conceal_mode | ConcealMode | Dimmed | Controls Markdown token visibility (Dimmed, Hidden, or Off). |
Font Auto-Selection
When config.font_family is set to None, TWrite automatically queries the platform font kit and selects the first available monospace font family that provides both full bold and italic faces (for example, Fira Code, JetBrains Mono, Menlo, Consolas, or Liberation Mono).
You can inspect the selected font and its face availability directly:
#![allow(unused)] fn main() { let ed = editor.read(cx); println!("Selected family: {:?}", ed.selected_font_family); println!("Face availability: {:?}", ed.face_availability); }
Cursor Blinking Control
In addition to config.cursor_blink = false, you can control or reset the cursor blink state programmatically:
#![allow(unused)] fn main() { // Temporarily reset blink timer so the cursor is immediately solid: ed.reset_blink_cursor(cx); // Dynamically toggle cursor blinking on or off: ed.set_cursor_blink(true, cx); }
Examples Tour
The examples in examples/ are the recommended reading order: the
numbered 1–6 sequence plus one supplemental demo. Each file header repeats
its number and prerequisites:
simple: bare window plus stock find. Start here.hooks: a custom hook (Ctrl+Dduplicate-line, list continuation, live word-count status) without overlapping battery territory.syntax: a hand-writtenSyntaxHighlighterwith custom theme tags.prompt: goto-line bottom bar plus a live-filtered command palette, both onctx.promptwith no frontend code.markdown: the battery pattern: oneenable_markdown()call plus a stockSearchHook(needs--features markdown).vim: the full modal system on hooks alone; read last.context_menu(supplemental, afterhooks): the expandable right-click menu — built-in edit rows plus hook-contributed UPPERCASE/separator actions.
Run any of them with cargo run --example <name> (add --features markdown for the markdown demo).
API Reference
The full type- and function-level reference, generated from the doc comments
by rustdoc:
twrite: facade: buffer, hooks, GPUI editortwrite-core: headless buffer, syntax, movement, hooks, prompt, searchtwrite-gpui: canvas, theme, config, editor
Rebuilt on every main push, so it always matches the guides above. Start
with twrite_core::{EditorBuffer, EditorHook, HookContext, PromptState} and
twrite_gpui::Editor.
Changelog
All notable changes to the twrite editor engine will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.9.2] - 2026-09-13
Fixed
Ctrl+Up/Ctrl+Downcursor follow (twrite-gpui): scrolling the viewport with these keys now keeps the cursor visible. When the scroll pushes the cursor out of view the cursor moves to the first or last visible row while preserving the column (VimCtrl+E/Ctrl+Ystyle). If the cursor is already visible it is not moved.Ctrl+Shift+Up/Ctrl+Shift+Downextend the selection while scrolling. Fixes #40.
[0.9.1] - 2026-09-13
Fixed
- Crates.io packaging: point all three crates at the workspace
README.mdso the registry page renders documentation (previously shipped no readme), and expand thetwritefacade crate docs (layout overview, quick start, feature list). - Heading click mapping in Hidden conceal mode (
twrite-gpui):offset_for_positionnow shapes hit-test text with the sameLineMetricsfont size and code-block flag used by paint, so mouse clicks on concealed headings (#through######) land on the clicked glyph instead of shifting right.
[0.9.0] - 2026-09-13
Changed
- Structured
KeyCodeenum replaces stringly key names (breaking):- New crossterm-style
twrite_core::KeyCode(Char,Enter/Tab/Escape/Backspace/Delete/Insert, arrows,Home/End/PageUp/PageDown,F(u8),Unidentified). Space isChar(' '), not a named variant. KeyEvent { key: String }becomes{ code: KeyCode, … };translate_key_downis now the single GPUI-string boundary (verified against gpui 0.2.2's Linux keysym table).ContextMenuItem::with_hinttakesKeyHint; hints render as kbd chips (one per modifier + key) instead of free text.- Synthetic prompt-bar actions (
focus_search, …) leave the key channel for a dedicatedSearchActionchannel (EditorHook::on_search_action,Editor::press_search_action). EditorHook::before_insert(&str)becomesbefore_insert(char);Modifiersgainsctrl/alt/shift/metaconstructors.
- New crossterm-style
[0.8.0] - 2026-09-13
Changed
- Migrated from git-pinned GPUI to official
gpui 0.2.2from crates.io:- Workspace
gpui/gpui_platformgit pins (zed-industries/zed@d85eade) replaced by registrygpui = "0.2.2"; thegpui_platformcrate is gone (officialgpuifolds the platform backends in as cargo features). - App entry becomes
Application::new().run(|cx: &mut App| …)instead ofgpui_platform::application().run(…)(all 7 examples + docs updated). FocusHandle::focus(window, cx)becomesfocus(window);ShapedLine::paintdrops its explicitTextAlign/boundsargs (0.2.2 bakes inTextAlign::Left— same pixels for gutter numbers).Font::default()andKeyDownEvent::prefer_character_inputdo not exist in the 0.2.2 API: font probing goes throughSharedString::new, and Alt-modified keys always keep physical key names (AltGr/macOS Option accents fall back; revisit when upgrading past 0.2.2).- Downstream migration: delete any
gpui_platformdependency andgpuirevpins;gpui = "0.2"+twrite = "0.8"share a singlegpuivia semver unification.
- Workspace
Added
- Platform-backend passthrough features (
twrite):wayland,x11,font-kitmap togpui/.... Wayland-only example:twrite = { version = "0.8", default-features = false, features = ["wayland"] }alongside matchinggpuifeatures.
[0.7.0] - 2026-09-11
Added
- Expandable right-click context menu (
twrite-core,twrite-gpui):- Headless
context_menumodule intwrite-core:ContextMenuItem(id, label, hint, enabled, divider),ContextMenuCaps(host-supplied clipboard/undo/selection capabilities),ContextMenuContext(click row/col snapshot for hooks), andContextMenuState(open/items store mirroringPromptState). - Built-in edit rows with enablement matrix: Undo, Redo, Cut, Copy, Paste, Delete, Select All.
- New
EditorHookmethods:context_menu_items(hook rows append after built-ins; reusing a well-known id overrides that default in place) andon_context_menu_action(runs before built-in dispatch;Consumedhalts). - GPUI right-click handling with VS Code-style selection policy (click inside selection keeps it, else cursor moves), custom themed overlay (
EditorTheme::menu_*) with viewport clamping, keyboard navigation (Up/Down+Enter,Escapedismisses), andEditorConfig::{context_menu, show_default_menu_items}flags. - New
EditorBuffer::{can_undo, can_redo}helpers. - New
context_menuexample (hook-contributed UPPERCASE/separator actions) andrecipe-context-menu.mddocs page;hooksexample gains demo menu rows.
- Headless
[0.6.0] - 2026-09-11
Added
- Blinking cursor support (
twrite-gpui):- Configurable periodic cursor blinking via
EditorConfig::cursor_blink(defaults totrue). - Cursor blink resets to fully visible immediately on keyboard typing, mouse navigation/selection, and scrolling.
- New
Editor::reset_blink_cursorandEditor::set_cursor_blinkAPI methods for external control and configuration.
- Configurable periodic cursor blinking via
- Multi-click selection & drag-expansion snapping (
twrite-core,twrite-gpui):- Double-click highlights the clicked word under the cursor; triple-click highlights the entire line (including trailing line terminator).
- Multi-click drag selection extends with boundary snapping: dragging after double-click expands word-by-word; dragging after triple-click expands line-by-line.
- New boundary helpers in
twrite-core:find_word_range_at,find_line_range_at, and convenience methodsEditorBuffer::{word_range_at, line_range_at}. - New
twrite_gpui::SelectionGranularityenum (Character,Word,Line) tracking active drag granularity onEditor. - Hyperlink opening and task checkbox toggling restricted to single clicks (
click_count == 1) so double-clicking links selects text without re-opening browsers.
- Find & replace toolbar UI redesign (
twrite-gpui):- Replaced abbreviated chips (
Aa,W,All) with full-word checkboxes (Highlight All,Match Case,Whole Words) with keyboard mnemonic underlines. - Redesigned search bar with rounded framed input box,
Find in pageplaceholder, caret steppers (^,v), and close button (✕). - Added expandable replace row with
Replace withinput box and interactiveReplaceandReplace Allaction buttons.
- Replaced abbreviated chips (
- Modularized editor and markdown syntax engines (
twrite-core,twrite-gpui):- Modularized
crates/twrite-gpui/src/editor.rsinto focused submodules:mod,mouse,keyboard,clipboard,geometry, andrender. - Extracted inline markdown syntax parser into
crates/twrite-core/src/batteries/markdown/inline.rs.
- Modularized
Fixed
- Find & replace UX fixes (
twrite-core,twrite-gpui):- Fixed keyboard submit on replace: pressing
Enterwhile focused in the replace input replaces the current match and advances to the next match. - Fixed mouse click closing replace box: clicking into the replace box focuses it cleanly without toggling/closing, and toolbar background clicks no longer propagate to the editor canvas.
- Normal bar cursor in prompt inputs: replaced inverted block cursor with a standard vertical bar cursor in text inputs (
find,replace, and prompt line). - Extended text navigation & editing shortcuts in prompts: added
Ctrl+Left/Ctrl+Right(word movement),Ctrl+Backspace/Ctrl+Delete(word deletion),Ctrl+K(clear to end), andAlt/Metaequivalents.
- Fixed keyboard submit on replace: pressing
- Find & replace functionality (
twrite-core,twrite-gpui):- Fixed replacement text not updating while editing in the replace prompt.
- Fixed
replace_currentandreplace_allfailing to read live input from the active prompt. - Added wrap-around match replacement in
replace_currentwhen the cursor is past the last match. - Added
Tabshortcut and click-to-focus navigation between find and replace fields. - Exposed
replace_mode,is_replace_prompt,query, andreplacementonSearchSnapshot.
[0.5.0] - 2026-09-10
Added
- Getting-started pass (
examples/,README.md): numbered example order (simpletovim) with header pointers, a README run map, a battery-neutral hooks demo, and a newpromptexample (goto-line plus command palette onctx.prompt). - Headless find & replace engine (
twrite-core): newsearchmodule withSearchQuery(literal + regex, case and whole-word toggles),find_matches/find_next/find_prev(wrapping), version-cachedSearchStatefor interactive find, and single-undo batch replace (EditorBuffer::replace_many,replace_all_querywith$1/$namecapture expansion,replace_one_query, line-scopedcollect_replacements). Newregexworkspace dependency;EditorError::{EmptySearchPattern, InvalidRegex}. - Headless prompt primitive (
twrite-core): newpromptmodule withPromptState(UTF-8-safe single-line editing,Ctrl+W/U/A/Eshortcuts, submitted-input history with draft restore, item list + wrapping selection,Tabcompletion,fuzzy_score/fuzzy_filter) inBottomBarandTopPaletteplacements, plusHookEffect::{Save, Load, Quit, Message}for app-level requests frontends drain. - Stock
SearchHook(twrite-core):Ctrl+Ffind (selection seeds the query) with live refresh,Enter/F3next,Shift+F3previous,Ctrl+Hreplace field,Ctrl+Enterreplace-one,Alt+Asingle-undo replace-all,Escapeclose, andSEARCH n/m/REPLACEstatus text. Exposesnavigate_next_from/navigate_prev_fromfor vim*/#. - Vim search & ex-commands (
examples/vim.rs)://?live search withn/Nfollowing the direction,*/#word under cursor, and a:prompt supporting:w [path],:q[!],:wq,:e path,:<num>, and:s/:%swith[g][i]flags (regex,$1captures, single undo). - Prompt box renderer (
twrite-gpui): newPromptBardrawing the sharedPromptStateas a bottom line or floatingF1palette (input with block cursor, item rows, message line).Editorowns the state, swallows buffer keys while it is open, andflush_effects()executes file effects inline (take_effects()leavesQuit/Messagefor hosts). Themarkdownexample wiresCtrl+F/Ctrl+Hfor free. - Search toggles + highlight-all (
twrite-core,twrite-gpui):SearchHookgains Match Case / Whole Word / Highlight All flags (status shows[Aa] [w] [H]),Alt+C/Alt+W/Alt+Hshortcuts,Up/Downmatch stepping (Alt+Up/Alt+Downstill reach input history), and aSearchSnapshotbridge (EditorHook::search_snapshot, defaultNone) that composite hooks forward.Editorsyncssearch_matchesafter input andPromptBarrenders clickableAa/W/Allchips plus↑/↓steppers that dispatch through the same key path; the canvas paints a viewport-clippedtheme.search_matchwash under the text.
Changed
- BREAKING:
HookContextgainsprompt: &mut PromptStateandeffects: &mut Vec<HookEffect>; constructors take five arguments. - BREAKING:
Editorgainsprompt,pending_effects, andfile_pathfields (struct literals need updating);load_filerecords the path forSave { path: None }. - Multi-edit undo/redo now track length shifts, so growing batch replacements round-trip exactly in one undo step.
Fixed
- Capital and shifted-symbol input (
twrite-gpui):translate_key_downnow preferskey_char(the typed character) over the physical key name, so Shift+letter, shifted symbols, and non-US layouts type correctly everywhere. Command combos keep physical names; Option/Alt keeps toggle bindings unless the platform prefers character input. Also revives Shift-bound keys (G,N,*,:,$) in the vim example.
[0.4.0] - 2026-09-07
Added
- FPS counter & performance HUD (
twrite-gpui): newfpsmodule withFrameStats(rolling 120-frame window tracking FPS, average frame duration, and worst frame hitch) andfps_badge(color-coded GPUI status element: green ≥ 50fps, amber ≥ 25fps, red below).Editor::frame_statsrecords frame intervals on paint without forcing repaints; integrated into themarkdownandsimpleexamples. - Indexed fence query helpers (
twrite-core):fence_rows,is_fenced_row,table_block_at_with_fences, andtable_layouts_with_fencesallow callers to hoist theO(N)fence scan out of hot per-row loops and evaluate fenced status inO(log F)time via binary search.
Fixed
- Markdown selection recalculation & table query jank:
MarkdownHighlighter::highlight_line,should_wrap_line, andexpand_linenow serve point queries from a shared, version-cached table layout pass (cached_layouts), eliminating upward buffer walks and per-row allocations that previously caused multi-millisecond hitches when selection changes flipped line cache states in large tables.- Table fence detection shares one cached linear fence scan per document
version (
cached_fence_rows), removing quadratic0..rowfence rescanning.
[0.3.0] - 2026-09-04
Added
- Markdown GFM tables (battery-only, no core changes):
table_block_at,parse_delimiter_row,find_unescaped_pipes,split_table_cells,TableBlock/TableRowKind/TableAlignment, andTABLE_{HEADER,CELL,DELIMITER}_TAGcustom tags (markdown.table.*, styled with existingPunctuation/Bold/Dimmed(pipes are neverHidden, so concealment preserves column mapping).MarkdownConfig::{visual_tables, table_navigation}(both defaulttrue) andMarkdownHookTab/Shift+Tabcell navigation (appends a skeleton row past the last cell) plusEnterrow continuation / blank-row table exit. - Aligned table columns:
MarkdownConfig::table_alignment(defaulttrue) pads every column to the block's max display width (measured on unconcealed source text, so widths are cursor-stable; delimiter dashes are extended and:--/--:/:-:alignment is honored), and table rows opt out of soft-wrapping so the grid never breaks mid-row. - Generic display-expansion engine:
ConcealedLine::{expanded, DisplayPad},display_width(Unicode column widths), and defaultedSyntaxHighlighter::{expand_line, should_wrap_line}hooks wired throughLayoutCache::CachedInput::{concealed, allow_wrap}into canvas prepaint, hit-testing, and scroll estimation. Markdown tables are the first client; other highlighters are unaffected (defaults are no-ops). - New
unicode-widthworkspace dependency backingdisplay_width.
Changed
- Battery layout: the Markdown battery is now
batteries/markdown/(config,table,highlight,hook,linksmodules with colocated tests); public pathstwrite_core::markdown/twrite::markdownunchanged.
[0.2.0] - 2026-09-03
Added
- Syntax-agnostic core: new
HighlightTag::{Blockquote, HorizontalRule, TaskUnchecked, TaskChecked}structural tags;SyntaxHighlighter::extract_linksandEditorHook::on_clickextension points (both with default impls). - Battery registry:
twrite_core::batteriesmodule documenting the contract and checklist for adding feature-gated batteries; Markdown moved tobatteries/markdown.rswith the publictwrite_core::markdown/twrite::markdownpaths unchanged. - Viewport input cache: new
twrite_gpui::{LayoutCache, CachedInput}sharing highlight/conceal/link work across prepaint and hit-testing, with hit/miss stats. - Thousand-line perf proofs: headless
highlight_perf/layout_perfintegration tests (deterministic fixtures,#[ignore]d timing cases) asserting cache hit rates. MarkdownHook::with_configandinteractive_taskstoggle.- Font handling:
EditorConfig::{font_family, code_font_family}(Noneinherits the host GPUI text style);Editor::face_availabilitysurfacing bold/italic face probe results from prepaint;RunFontsparam bundle forbuild_line_text_runs(code spans use the code font). - Font auto-select:
EditorConfig::platform_monospace_candidatespluspick_familychoosing the first candidate with bold + italic faces at paint time; explicitfont_familyis trusted verbatim;Editor::selected_font_familyand family-aware status reporting; examples use the default auto-select path. - Tag extensibility:
HighlightTag::Heading(u8)levels and openHighlightTag::Custom(&'static str)extension point withSyntaxTheme::set_custom_tag_color(unregistered names fall back to the foreground); heading metrics use a single min-level scan.
Changed
- BREAKING:
Editor::offset_for_positionnow takes&mut self. - BREAKING:
VisibleLineLayoutgainstask_state;Editorgainslayout_cacheandhighlighter_rev(struct literals need updating). - BREAKING: new
HighlightTagvariants (exhaustive matches need new arms); theme maps them to comment/punctuation/string. - BREAKING:
HighlightTag::{Heading1..Heading6, Speaker, Dialogue, Choice}removed in favor ofHeading(u8)andCustom(&'static str);SyntaxTheme::{speaker, dialogue, choice}fields removed (register custom colors instead). LineMetrics::for_linederives quote/divider/task state solely from tags (no raw string checks); code-block detection via full-lineCodespans.- Hit-testing (
offset_for_position, link and checkbox lookup) uses binary search over visible lines. - Click handling dispatches task toggles to hooks with
after_edit/on_selection_changenotifications; cursor offset preserved.
[0.1.0] - 2026-09-03
Added
- Core Buffer Engine (
twrite-core):- Rope-backed text storage with byte and char indexing.
- Granular undo and redo transaction history.
- Multi-line cursor navigation, word boundaries, and line-end movements.
- Monotonic document version counter.
- Styling & Syntax Engine:
- Multi-span interval splitting algorithm (
split_line_intervals). - Semantic highlight tags (
HighlightTag) and explicit font styles (TextStyle). - Catppuccin Mocha syntax theme integration in GPUI canvas.
- Live shaping with bold weights, italic fonts, and underline decorations.
- Multi-span interval splitting algorithm (
- Versatile Hook System:
HookContextproviding mutable access to text buffer, selection, and cursor styles.EditorHooklifecycle (on_key,before_insert,after_edit,on_selection_change,status_text).- Built-in
AutoPairsHookwith auto-closing, selection wrapping, and smart backspacing. - Dynamic cursor shapes (
Bar,Block,Underline,Hidden).
- Examples:
simple: Minimal baseline editor.syntax: Markdown headings, inline code, and story script dialogue.hooks: Concurrent hook composition with auto-pairs and markdown shortcuts.vim: Full modal editing (Normal, Insert, Visual) built 100% via hooks.
- Package Architecture:
- Centralized workspace versioning with
version.workspace = true. - Single top-level facade import (
use twrite::Editor;). - Pinned GPUI dependency revision for reproducible downstream builds.
- Centralized workspace versioning with