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 ropey for 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 F1 palettes, :goto-line bottom bars, and fuzzy-filtered item lists.
  • Multi-Click Selection: Word double-click, line triple-click, and boundary-snapping drag selection.

Where to Start

  1. Getting Started: Add twrite to your Cargo.toml and launch your first editor window in under 35 lines of code.
  2. Recipes:
  3. Reference:

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:

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.

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

ModeBehavior
ConcealMode::Dimmed (default)Syntax markers are drawn in a subtle, dimmed color to keep content legible while keeping raw characters visible.
ConcealMode::HiddenSyntax markers are hidden completely. When the cursor enters a line, markers un-conceal so you can edit them directly.
ConcealMode::OffAll 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

ShortcutAction
Ctrl+FOpen find bar (seeds query with any active selection). Focuses the find input.
Ctrl+HOpen/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.
TabSwitch focus between Find and Replace input boxes.
F3 / DownJump to next match.
Shift+F3 / UpJump to previous match.
Alt+Up / Alt+DownCycle through previous search query history.
Alt+CToggle Match Case.
Alt+WToggle Whole Words.
Alt+HToggle Highlight All (canvas background wash).
Alt+AReplace All matches as a single undoable transaction.
EscClose 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 (or Alt+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 (^ and v): Click to navigate to the previous or next match.
  • Checkboxes: Click Highlight All, Match Case, or Whole Words to toggle search criteria with immediate rescanning.
  • Action Buttons: Click Replace to replace the current match, or Replace All to 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:

  1. The first hook whose on_key returns HookOutcome::Consumed halts the key pipeline.
  2. 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.
  3. 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: Up and Down recall previously submitted inputs (unless item rows are visible).
  • Tab completion: Pressing Tab fills 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:

idLabelEnabled when
undoUndo (Ctrl+Z)undo history is non-empty
redoRedo (Ctrl+Y)redo history is non-empty
cutCut (Ctrl+X)a non-empty selection exists
copyCopy (Ctrl+C)a non-empty selection exists
pastePaste (Ctrl+V)the OS clipboard holds text
deleteDeletea non-empty selection exists
select_allSelect 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/Down move across enabled rows, Enter activates 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 to false.
  • 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). When i or a is pressed, switch to Insert.
  • In Insert Mode: pass keys through so characters are typed into the buffer. When Escape is pressed, consume it and switch back to Normal.
  • In Visual Mode: move selection range endpoints. When Escape or y is pressed, return to Normal. The full example adds a linewise variant: V selects whole lines (-- VISUAL LINE --), motions grow by line, and G / gg extend to the document bottom / top — so ggVG selects 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:

  1. Hooks run first via on_key; the first Consumed wins.
  2. While a prompt is open, unconsumed keys never reach the buffer.
  3. 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; return Consumed to 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):

  1. Create batteries/<name>.rs (promote to batteries/<name>/mod.rs when it outgrows one file) with a fixed template: Config (plain Clone data + Default), Highlighter (new + with_config), Hook (new + with_config). Hook-only batteries omit the highlighter.
  2. Declare the feature in twrite-core/Cargo.toml (<name> = [...], with dep:<parser-crate> only if the battery needs a parser dependency).
  3. Register the one-line path shim in the crate root so the public path stays twrite_core::<name> regardless of file layout.
  4. Re-export from the twrite facade as twrite::<name> behind the same feature name, so users write twrite = { features = ["<name>"] }.
  5. Add colocated unit tests in the battery module and an examples/<name>.rs demo (with required-features only 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

FieldTypeDefaultDescription
line_numbersboolfalseWhen true, displays a left gutter with line numbers matching the document rope row.
cursor_blinkbooltrueWhen true, the text cursor blinks periodically. Blinking pauses and remains fully visible while typing or navigating.
font_sizePixelspx(14.0)Base font size used for buffer text rendering.
line_heightPixelspx(22.0)Vertical height of each line in pixels.
font_familyOption<SharedString>NoneMonospace font family name. When None, TWrite auto-detects the first monospace font with bold and italic faces on the system.
markdown.conceal_modeConcealModeDimmedControls 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:

  1. simple: bare window plus stock find. Start here.
  2. hooks: a custom hook (Ctrl+D duplicate-line, list continuation, live word-count status) without overlapping battery territory.
  3. syntax: a hand-written SyntaxHighlighter with custom theme tags.
  4. prompt: goto-line bottom bar plus a live-filtered command palette, both on ctx.prompt with no frontend code.
  5. markdown: the battery pattern: one enable_markdown() call plus a stock SearchHook (needs --features markdown).
  6. vim: the full modal system on hooks alone; read last.
  7. context_menu (supplemental, after hooks): 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 editor
  • twrite-core: headless buffer, syntax, movement, hooks, prompt, search
  • twrite-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+Down cursor 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 (Vim Ctrl+E / Ctrl+Y style). If the cursor is already visible it is not moved. Ctrl+Shift+Up / Ctrl+Shift+Down extend the selection while scrolling. Fixes #40.

[0.9.1] - 2026-09-13

Fixed

  • Crates.io packaging: point all three crates at the workspace README.md so the registry page renders documentation (previously shipped no readme), and expand the twrite facade crate docs (layout overview, quick start, feature list).
  • Heading click mapping in Hidden conceal mode (twrite-gpui): offset_for_position now shapes hit-test text with the same LineMetrics font 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 KeyCode enum 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 is Char(' '), not a named variant.
    • KeyEvent { key: String } becomes { code: KeyCode, … }; translate_key_down is now the single GPUI-string boundary (verified against gpui 0.2.2's Linux keysym table).
    • ContextMenuItem::with_hint takes KeyHint; 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 dedicated SearchAction channel (EditorHook::on_search_action, Editor::press_search_action).
    • EditorHook::before_insert(&str) becomes before_insert(char); Modifiers gains ctrl/alt/shift/meta constructors.

[0.8.0] - 2026-09-13

Changed

  • Migrated from git-pinned GPUI to official gpui 0.2.2 from crates.io:
    • Workspace gpui/gpui_platform git pins (zed-industries/zed@d85eade) replaced by registry gpui = "0.2.2"; the gpui_platform crate is gone (official gpui folds the platform backends in as cargo features).
    • App entry becomes Application::new().run(|cx: &mut App| …) instead of gpui_platform::application().run(…) (all 7 examples + docs updated).
    • FocusHandle::focus(window, cx) becomes focus(window); ShapedLine::paint drops its explicit TextAlign/bounds args (0.2.2 bakes in TextAlign::Left — same pixels for gutter numbers).
    • Font::default() and KeyDownEvent::prefer_character_input do not exist in the 0.2.2 API: font probing goes through SharedString::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_platform dependency and gpui rev pins; gpui = "0.2" + twrite = "0.8" share a single gpui via semver unification.

Added

  • Platform-backend passthrough features (twrite): wayland, x11, font-kit map to gpui/.... Wayland-only example: twrite = { version = "0.8", default-features = false, features = ["wayland"] } alongside matching gpui features.

[0.7.0] - 2026-09-11

Added

  • Expandable right-click context menu (twrite-core, twrite-gpui):
    • Headless context_menu module in twrite-core: ContextMenuItem (id, label, hint, enabled, divider), ContextMenuCaps (host-supplied clipboard/undo/selection capabilities), ContextMenuContext (click row/col snapshot for hooks), and ContextMenuState (open/items store mirroring PromptState).
    • Built-in edit rows with enablement matrix: Undo, Redo, Cut, Copy, Paste, Delete, Select All.
    • New EditorHook methods: context_menu_items (hook rows append after built-ins; reusing a well-known id overrides that default in place) and on_context_menu_action (runs before built-in dispatch; Consumed halts).
    • 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, Escape dismisses), and EditorConfig::{context_menu, show_default_menu_items} flags.
    • New EditorBuffer::{can_undo, can_redo} helpers.
    • New context_menu example (hook-contributed UPPERCASE/separator actions) and recipe-context-menu.md docs page; hooks example gains demo menu rows.

[0.6.0] - 2026-09-11

Added

  • Blinking cursor support (twrite-gpui):
    • Configurable periodic cursor blinking via EditorConfig::cursor_blink (defaults to true).
    • Cursor blink resets to fully visible immediately on keyboard typing, mouse navigation/selection, and scrolling.
    • New Editor::reset_blink_cursor and Editor::set_cursor_blink API methods for external control and configuration.
  • 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 methods EditorBuffer::{word_range_at, line_range_at}.
    • New twrite_gpui::SelectionGranularity enum (Character, Word, Line) tracking active drag granularity on Editor.
    • 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 page placeholder, caret steppers (^, v), and close button ().
    • Added expandable replace row with Replace with input box and interactive Replace and Replace All action buttons.
  • Modularized editor and markdown syntax engines (twrite-core, twrite-gpui):
    • Modularized crates/twrite-gpui/src/editor.rs into focused submodules: mod, mouse, keyboard, clipboard, geometry, and render.
    • Extracted inline markdown syntax parser into crates/twrite-core/src/batteries/markdown/inline.rs.

Fixed

  • Find & replace UX fixes (twrite-core, twrite-gpui):
    • Fixed keyboard submit on replace: pressing Enter while 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), and Alt/Meta equivalents.
  • Find & replace functionality (twrite-core, twrite-gpui):
    • Fixed replacement text not updating while editing in the replace prompt.
    • Fixed replace_current and replace_all failing to read live input from the active prompt.
    • Added wrap-around match replacement in replace_current when the cursor is past the last match.
    • Added Tab shortcut and click-to-focus navigation between find and replace fields.
    • Exposed replace_mode, is_replace_prompt, query, and replacement on SearchSnapshot.

[0.5.0] - 2026-09-10

Added

  • Getting-started pass (examples/, README.md): numbered example order (simple to vim) with header pointers, a README run map, a battery-neutral hooks demo, and a new prompt example (goto-line plus command palette on ctx.prompt).
  • Headless find & replace engine (twrite-core): new search module with SearchQuery (literal + regex, case and whole-word toggles), find_matches / find_next / find_prev (wrapping), version-cached SearchState for interactive find, and single-undo batch replace (EditorBuffer::replace_many, replace_all_query with $1 / $name capture expansion, replace_one_query, line-scoped collect_replacements). New regex workspace dependency; EditorError::{EmptySearchPattern, InvalidRegex}.
  • Headless prompt primitive (twrite-core): new prompt module with PromptState (UTF-8-safe single-line editing, Ctrl+W/U/A/E shortcuts, submitted-input history with draft restore, item list + wrapping selection, Tab completion, fuzzy_score / fuzzy_filter) in BottomBar and TopPalette placements, plus HookEffect::{Save, Load, Quit, Message} for app-level requests frontends drain.
  • Stock SearchHook (twrite-core): Ctrl+F find (selection seeds the query) with live refresh, Enter / F3 next, Shift+F3 previous, Ctrl+H replace field, Ctrl+Enter replace-one, Alt+A single-undo replace-all, Escape close, and SEARCH n/m / REPLACE status text. Exposes navigate_next_from / navigate_prev_from for vim * / #.
  • Vim search & ex-commands (examples/vim.rs): / / ? live search with n / N following the direction, * / # word under cursor, and a : prompt supporting :w [path], :q[!], :wq, :e path, :<num>, and :s / :%s with [g][i] flags (regex, $1 captures, single undo).
  • Prompt box renderer (twrite-gpui): new PromptBar drawing the shared PromptState as a bottom line or floating F1 palette (input with block cursor, item rows, message line). Editor owns the state, swallows buffer keys while it is open, and flush_effects() executes file effects inline (take_effects() leaves Quit / Message for hosts). The markdown example wires Ctrl+F / Ctrl+H for free.
  • Search toggles + highlight-all (twrite-core, twrite-gpui): SearchHook gains Match Case / Whole Word / Highlight All flags (status shows [Aa] [w] [H]), Alt+C / Alt+W / Alt+H shortcuts, Up / Down match stepping (Alt+Up / Alt+Down still reach input history), and a SearchSnapshot bridge (EditorHook::search_snapshot, default None) that composite hooks forward. Editor syncs search_matches after input and PromptBar renders clickable Aa / W / All chips plus / steppers that dispatch through the same key path; the canvas paints a viewport-clipped theme.search_match wash under the text.

Changed

  • BREAKING: HookContext gains prompt: &mut PromptState and effects: &mut Vec<HookEffect>; constructors take five arguments.
  • BREAKING: Editor gains prompt, pending_effects, and file_path fields (struct literals need updating); load_file records the path for Save { 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_down now prefers key_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): new fps module with FrameStats (rolling 120-frame window tracking FPS, average frame duration, and worst frame hitch) and fps_badge (color-coded GPUI status element: green ≥ 50fps, amber ≥ 25fps, red below). Editor::frame_stats records frame intervals on paint without forcing repaints; integrated into the markdown and simple examples.
  • Indexed fence query helpers (twrite-core): fence_rows, is_fenced_row, table_block_at_with_fences, and table_layouts_with_fences allow callers to hoist the O(N) fence scan out of hot per-row loops and evaluate fenced status in O(log F) time via binary search.

Fixed

  • Markdown selection recalculation & table query jank:
    • MarkdownHighlighter::highlight_line, should_wrap_line, and expand_line now 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 quadratic 0..row fence 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, and TABLE_{HEADER,CELL,DELIMITER}_TAG custom tags (markdown.table.*, styled with existing Punctuation / Bold / Dimmed (pipes are never Hidden, so concealment preserves column mapping). MarkdownConfig::{visual_tables, table_navigation} (both default true) and MarkdownHook Tab / Shift+Tab cell navigation (appends a skeleton row past the last cell) plus Enter row continuation / blank-row table exit.
  • Aligned table columns: MarkdownConfig::table_alignment (default true) 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 defaulted SyntaxHighlighter::{expand_line, should_wrap_line} hooks wired through LayoutCache::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-width workspace dependency backing display_width.

Changed

  • Battery layout: the Markdown battery is now batteries/markdown/ (config, table, highlight, hook, links modules with colocated tests); public paths twrite_core::markdown / twrite::markdown unchanged.

[0.2.0] - 2026-09-03

Added

  • Syntax-agnostic core: new HighlightTag::{Blockquote, HorizontalRule, TaskUnchecked, TaskChecked} structural tags; SyntaxHighlighter::extract_links and EditorHook::on_click extension points (both with default impls).
  • Battery registry: twrite_core::batteries module documenting the contract and checklist for adding feature-gated batteries; Markdown moved to batteries/markdown.rs with the public twrite_core::markdown / twrite::markdown paths 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_perf integration tests (deterministic fixtures, #[ignore]d timing cases) asserting cache hit rates.
  • MarkdownHook::with_config and interactive_tasks toggle.
  • Font handling: EditorConfig::{font_family, code_font_family} (None inherits the host GPUI text style); Editor::face_availability surfacing bold/italic face probe results from prepaint; RunFonts param bundle for build_line_text_runs (code spans use the code font).
  • Font auto-select: EditorConfig::platform_monospace_candidates plus pick_family choosing the first candidate with bold + italic faces at paint time; explicit font_family is trusted verbatim; Editor::selected_font_family and family-aware status reporting; examples use the default auto-select path.
  • Tag extensibility: HighlightTag::Heading(u8) levels and open HighlightTag::Custom(&'static str) extension point with SyntaxTheme::set_custom_tag_color (unregistered names fall back to the foreground); heading metrics use a single min-level scan.

Changed

  • BREAKING: Editor::offset_for_position now takes &mut self.
  • BREAKING: VisibleLineLayout gains task_state; Editor gains layout_cache and highlighter_rev (struct literals need updating).
  • BREAKING: new HighlightTag variants (exhaustive matches need new arms); theme maps them to comment/punctuation/string.
  • BREAKING: HighlightTag::{Heading1..Heading6, Speaker, Dialogue, Choice} removed in favor of Heading(u8) and Custom(&'static str); SyntaxTheme::{speaker, dialogue, choice} fields removed (register custom colors instead).
  • LineMetrics::for_line derives quote/divider/task state solely from tags (no raw string checks); code-block detection via full-line Code spans.
  • 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_change notifications; 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.
  • Versatile Hook System:
    • HookContext providing mutable access to text buffer, selection, and cursor styles.
    • EditorHook lifecycle (on_key, before_insert, after_edit, on_selection_change, status_text).
    • Built-in AutoPairsHook with 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.