Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

apps/modal_editor.rs

//! A helix-flavored modal editor with the app — not kui — owning the
//! document, the keymap, and the modes. The whole keyboard arrives through
//! one `on_key` sink as `{kind="key"}` events, so the same modal dispatch
//! would work verbatim from Lua or C. kui's part is turning the state into
//! rows of text runs: selection as background segments, the caret as an
//! inline node, no text measurement anywhere.
//!
//! The document model is a deliberately dumb Vec<String> — the point is the
//! boundary, not the rope. An IO/LSP/undo layer replaces the model; the view
//! code stays.
//!
//! The mouse and the clipboard are the same shape as the keyboard: a press
//! or drag inside the sink arrives as a `drag` event carrying `line`,
//! `byte` and `clicks` (backlog C34), so click-to-caret, drag-select and
//! double-click-word are arithmetic in `on_event`; `y` and `p` go through
//! `ui.set_clipboard` / `ui.request_paste` (backlog C33), and the paste
//! comes back as a `text` event the way an IME's commit does.
//!
//! Run: cargo run -p kui-native --example modal_editor
//!
//! Keys: see the buffer text (`:help` puts a summary in the minibuffer).

use kui_devtools::Example;
use kui_native::widgets;
use kui_native::{
    Align, App, Color, Core, NodeSpec, Role, TextStyle, Theme, Ui, UiEvent, Value, WindowCommand,
};

const FONT: f32 = 13.5;
const LH: f32 = 20.0;
const GUTTER_W: f32 = 52.0;
const STATUS_H: f32 = 24.0;
const MINIBUF_H: f32 = 26.0;

// ---------------------------------------------------------------- palette

#[derive(Clone, Copy)]
struct Pal {
    bg: Color,
    bg2: Color,
    panel: Color,
    status: Color,
    fg: Color,
    dim: Color,
    faint: Color,
    accent: Color,
    select: Color,
    insert: Color,
    command: Color,
}

impl From<Theme> for Pal {
    /// Every field is a theme role, including the three that look like
    /// app colours: a mode indicator is a status, and the theme already
    /// names the three statuses an editor has anything to say with.
    fn from(t: Theme) -> Self {
        Self {
            bg: t.bg,
            bg2: t.sunken,
            panel: t.surface,
            status: t.sunken,
            fg: t.fg,
            dim: t.muted,
            faint: t.faint,
            accent: t.focus_ring,
            select: t.selection,
            insert: t.success,
            command: t.warning,
        }
    }
}

// ---------------------------------------------------------------- model

#[derive(Clone, Copy, PartialEq)]
enum Mode {
    Normal,
    Insert,
    Command,
}

struct Doc {
    name: String,
    lines: Vec<String>,
    modified: bool,
}

impl Doc {
    fn new(name: &str, text: &str) -> Self {
        let lines = text.split('\n').map(|l| l.replace('\t', "    ")).collect();
        Self {
            name: name.into(),
            lines,
            modified: false,
        }
    }
}

#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
struct Pos {
    line: usize,
    col: usize,
}

/// The view of the document: cursor, selection anchor, scroll. `rows` is
/// written during render (the view fn knows the editor's height) and read by
/// the keymap for paging; scroll-into-view happens in render too, so the
/// caret can never leave the screen.
struct View {
    cur: Pos,
    anchor: Option<Pos>,
    top: usize,
    rows: usize,
}

impl Default for View {
    fn default() -> Self {
        Self {
            cur: Pos::default(),
            anchor: None,
            top: 0,
            rows: 24,
        }
    }
}

// ---------------------------------------------------------------- app

struct ModalEditor {
    pal: Pal,
    doc: Doc,
    view: View,
    mode: Mode,
    cmd: String,
    message: String,
    pending: Option<char>,
    /// Text to put on the clipboard at the next view — `on_event` has no
    /// `Ui`, and the runner draws right after an event, so this is one
    /// frame away. Linewise text ends with a newline.
    clip_out: Option<String>,
    /// `p` asked for the clipboard: the next `text` event is the paste.
    awaiting_paste: bool,
    /// Where the button went down, for a drag-select.
    drag_from: Option<Pos>,
    quit: bool,
}

/// The key event, back out of `Value` form.
struct KeyEv {
    code: String,
    text: Option<String>,
}

impl KeyEv {
    /// A press, from a `{kind="key"}` payload. Modal editing is a keymap,
    /// not a held-key interaction: the releases the same sink delivers
    /// (`phase="up"`) are not commands, so they stop here.
    /// Every key event is a press: the sink never asked for releases
    /// (`key_up`), so a keymap needs no phase check.
    fn from_payload(p: &Value) -> Option<Self> {
        Some(Self {
            code: p.get("code")?.as_str()?.to_string(),
            text: p.get_str("text").map(str::to_string),
        })
    }
}

impl ModalEditor {
    fn new() -> Self {
        Self {
            pal: Theme::default().into(),
            doc: Doc::new("*scratch*", SAMPLE),
            view: View::default(),
            mode: Mode::Normal,
            cmd: String::new(),
            message: "modal editing demo — the buffer text is the keymap".into(),
            pending: None,
            clip_out: None,
            awaiting_paste: false,
            drag_from: None,
            quit: false,
        }
    }

    // ------------------------------------------------------------ keymap

    fn on_key(&mut self, k: KeyEv) {
        match self.mode {
            Mode::Command => self.key_command(k),
            Mode::Insert => self.key_insert(k),
            Mode::Normal => self.key_normal(k),
        }
    }

    fn key_command(&mut self, k: KeyEv) {
        match k.code.as_str() {
            "escape" => {
                self.mode = Mode::Normal;
                self.cmd.clear();
            }
            "enter" => {
                self.mode = Mode::Normal;
                let cmd = std::mem::take(&mut self.cmd);
                self.exec_command(&cmd);
            }
            "backspace" => {
                if self.cmd.pop().is_none() {
                    self.mode = Mode::Normal;
                }
            }
            _ => {
                if let Some(t) = &k.text {
                    self.cmd.push_str(t);
                }
            }
        }
    }

    fn key_normal(&mut self, k: KeyEv) {
        let pending = self.pending.take();
        let (doc, view) = (&mut self.doc, &mut self.view);

        if let Some(p) = pending {
            match (p, k.code.as_str()) {
                ('g', "g") => {
                    view.cur = Pos::default();
                    view.anchor = None;
                }
                ('g', "e") => {
                    view.cur.line = doc.lines.len().saturating_sub(1);
                    clamp_col(doc, view);
                }
                ('d', "d") => {
                    self.clip_out = Some(doc.lines[view.cur.line].clone() + "\n");
                    delete_line(doc, view);
                    self.message = "deleted line (p puts it back)".into();
                }
                _ => self.message = format!("{p}{} is not a thing here", k.code),
            }
            return;
        }

        match k.code.as_str() {
            "h" | "left" => move_h(doc, view, -1),
            "l" | "right" => move_h(doc, view, 1),
            "j" | "down" => move_v(doc, view, 1),
            "k" | "up" => move_v(doc, view, -1),
            "w" => word_fwd(doc, view),
            "b" => word_back(doc, view),
            "0" | "home" => view.cur.col = 0,
            "$" | "end" => view.cur.col = line_len(doc, view.cur.line),
            "pageup" => move_v(doc, view, -(view.rows as i64 - 1)),
            "pagedown" => move_v(doc, view, view.rows as i64 - 1),
            "G" => {
                view.cur.line = doc.lines.len().saturating_sub(1);
                clamp_col(doc, view);
            }
            "g" => self.pending = Some('g'),
            "d" => {
                if view.anchor.is_some() {
                    self.clip_out = Some(sel_lines(doc, view).join("\n"));
                    delete_sel(doc, view);
                } else {
                    self.pending = Some('d');
                }
            }
            "i" => self.mode = Mode::Insert,
            "a" => {
                view.cur.col = (view.cur.col + 1).min(line_len(doc, view.cur.line));
                self.mode = Mode::Insert;
            }
            "I" => {
                view.cur.col = 0;
                self.mode = Mode::Insert;
            }
            "A" => {
                view.cur.col = line_len(doc, view.cur.line);
                self.mode = Mode::Insert;
            }
            "o" => {
                open_line(doc, view, 1);
                self.mode = Mode::Insert;
            }
            "O" => {
                open_line(doc, view, 0);
                self.mode = Mode::Insert;
            }
            "v" => {
                view.anchor = if view.anchor.is_some() {
                    None
                } else {
                    Some(view.cur)
                }
            }
            "x" => {
                if !delete_sel(doc, view) {
                    delete_char(doc, view);
                }
            }
            "y" => {
                // A whole line when nothing is selected, and linewise:
                // the newline says so to the paste.
                let lines = sel_lines(doc, view);
                let n = lines.len();
                let mut text = lines.join("\n");
                if view.anchor.is_none() {
                    text.push('\n');
                }
                self.clip_out = Some(text);
                view.anchor = None;
                self.message = format!("yanked {n} line(s) to the clipboard");
            }
            "p" => {
                self.awaiting_paste = true;
            }
            "u" => self.message = "undo is where the demo ends and your app begins".into(),
            ":" => {
                self.mode = Mode::Command;
                self.cmd.clear();
            }
            "escape" => view.anchor = None,
            _ => {}
        }
    }

    fn key_insert(&mut self, k: KeyEv) {
        let (doc, view) = (&mut self.doc, &mut self.view);
        match k.code.as_str() {
            "escape" => {
                self.mode = Mode::Normal;
                clamp_col(doc, view);
            }
            "enter" => insert_text(doc, view, "\n"),
            "backspace" => backspace(doc, view),
            "delete" => delete_char(doc, view),
            "tab" => insert_text(doc, view, "    "),
            "left" => move_h(doc, view, -1),
            "right" => move_h(doc, view, 1),
            "up" => move_v(doc, view, -1),
            "down" => move_v(doc, view, 1),
            "home" => view.cur.col = 0,
            "end" => view.cur.col = line_len(doc, view.cur.line),
            _ => {
                if let Some(t) = k.text.clone() {
                    insert_text(doc, view, &t);
                }
            }
        }
    }

    fn exec_command(&mut self, cmd: &str) {
        match cmd.trim() {
            "" => {}
            "q" | "q!" | "qa" | "qa!" => self.quit = true,
            "w" | "write" => self.write_doc(),
            "wq" => {
                self.write_doc();
                self.quit = true;
            }
            "help" => {
                self.message =
                    "h j k l · w b · gg G · i a o · v x d dd y p · :w :q — see the buffer".into()
            }
            other => self.message = format!("not a command: {other} (:help for the keymap)"),
        }
    }

    fn write_doc(&mut self) {
        self.doc.modified = false;
        self.message = format!(
            "wrote {} — well, pretended to; IO belongs to your app",
            self.doc.name
        );
    }

    // ------------------------------------------------------------ view

    fn minibuffer(&self, ui: &mut Ui<'_>) {
        let pal = self.pal;
        ui.with(
            NodeSpec::row()
                .grow_width()
                .height(MINIBUF_H)
                .bg(pal.bg2)
                .pad_xy(10.0, 0.0)
                .gap(2.0)
                .cross_align(Align::Center),
            |ui| {
                if self.mode == Mode::Command {
                    ui.text(":", TextStyle::new(FONT).mono().color(pal.command));
                    ui.text(&self.cmd, TextStyle::new(FONT).mono().color(pal.fg));
                    caret_bar(ui, pal.command);
                } else {
                    ui.text(&self.message, TextStyle::new(12.0).color(pal.dim));
                }
                ui.leaf(NodeSpec::row().grow_width());
                let hint = match self.mode {
                    Mode::Insert => "esc → normal",
                    Mode::Command => "enter run · esc cancel",
                    Mode::Normal => "i insert · v select · : command · :help",
                };
                ui.text(hint, TextStyle::new(11.0).color(pal.faint));
            },
        );
    }
}

impl App for ModalEditor {
    fn view(&mut self, ui: &mut Ui<'_>) {
        if self.quit {
            ui.window_command(WindowCommand::Close(ui.env().window.id));
        }
        // Rebuilt from the theme each frame, so the window follows the OS.
        self.pal = ui.theme().into();
        let pal = self.pal;
        // What the keymap queued for the clipboard, and the paste it asked
        // for: both are the host's, so both go out through `Ui`.
        if let Some(text) = self.clip_out.take() {
            ui.set_clipboard(text, None);
        }
        // Asked on every frame the answer is outstanding: the core queues
        // one ask at a time, so this is one paste and not one per frame
        // (backlog AR34).
        if self.awaiting_paste {
            ui.request_paste();
        }
        // Normal mode is a keymap, not text: the platform's input method is
        // off while it holds (backlog F125), so a held `j` repeats where a
        // Mac's press-and-hold would have swallowed it, and an IME left on
        // does not eat the keys. Insert and command modes type text, and
        // get accents, dead keys and the IME back by not declaring it.
        ui.ime_off(self.mode == Mode::Normal);
        ui.with(NodeSpec::column().fill().bg(pal.bg), |ui| {
            widgets::titlebar(ui, "kui — modal editor (the app owns the keymap)");

            let vp = ui.viewport();
            let editor_h = (vp.h - widgets::TITLEBAR_H - MINIBUF_H).max(LH);
            let mode = self.mode;
            let (doc, view) = (&self.doc, &mut self.view);
            let sink = ui.with_keyed(
                "editor",
                NodeSpec::column()
                    .fill()
                    .bg(pal.panel)
                    .clip()
                    .key_sink()
                    // The mouse: a press or drag anywhere in the sink says
                    // which line and byte it landed on (`on_drag`).
                    .on_drag(Value::Null)
                    // The app owns the text, so it says what the sink is; the
                    // lines it draws (`Role::Line` rows) are the editor's text
                    // to a screen reader, and selection requests come back as
                    // `{kind="access"}` events (see `on_access`).
                    .role(Role::MultilineTextInput)
                    .label(doc.name.as_str()),
                |ui| render_editor(ui, &pal, doc, view, mode, editor_h),
            );
            ui.take_key_focus(sink);

            self.minibuffer(ui);
        });
    }

    fn on_event(&mut self, ev: UiEvent) {
        match ev.kind() {
            Some("key") => {
                if let Some(k) = KeyEv::from_payload(&ev.payload) {
                    self.on_key(k);
                }
            }
            // The clipboard's answer, or an IME's commit: the paste `p`
            // asked for, else typed text in insert mode.
            Some("text") => {
                let text = ev.payload.get_str("text").unwrap_or("");
                if std::mem::take(&mut self.awaiting_paste) {
                    paste(&mut self.doc, &mut self.view, text);
                } else if self.mode == Mode::Insert {
                    insert_text(&mut self.doc, &mut self.view, text);
                }
            }
            Some("drag") => self.on_drag(&ev.payload),
            Some("access") => self.on_access(&ev.payload),
            _ => {}
        }
    }
}

impl ModalEditor {
    /// The mouse, as the sink's `drag` events carry it: `line` is the
    /// ordinal among the drawn lines (so `view.top` maps it back into the
    /// document), `byte` is into the line's drawn text, `clicks` the
    /// press's count. One click places the caret, two take the word,
    /// three the line; the pointer moving extends from where it pressed.
    fn on_drag(&mut self, p: &Value) {
        let Some(pos) = self.drag_pos(p) else {
            return;
        };
        let clicks = p.get_int("clicks").unwrap_or(1);
        let (doc, view) = (&self.doc, &mut self.view);
        match p.get_str("phase") {
            Some("start") => {
                self.drag_from = Some(pos);
                match clicks {
                    1 => {
                        view.cur = pos;
                        view.anchor = None;
                    }
                    2 => {
                        let (a, b) = word_at(doc, pos);
                        view.anchor = Some(a);
                        view.cur = b;
                    }
                    _ => {
                        view.anchor = Some(Pos {
                            line: pos.line,
                            col: 0,
                        });
                        view.cur = Pos {
                            line: pos.line,
                            col: line_len(doc, pos.line),
                        };
                    }
                }
            }
            Some("move") => {
                if let Some(from) = self.drag_from
                    && pos != from
                {
                    view.anchor.get_or_insert(from);
                    view.cur = pos;
                }
            }
            _ => self.drag_from = None,
        }
        if self.mode == Mode::Command {
            self.mode = Mode::Normal;
        }
    }

    /// The document position a pointer payload names. The drawn text is
    /// the line as it is — a run's spaces measure at the face's advance
    /// (backlog K3) — so the byte offset into it counts back to a column
    /// directly.
    fn drag_pos(&self, p: &Value) -> Option<Pos> {
        let line = p.get("line")?.as_int()? as usize + self.view.top;
        let line = line.min(self.doc.lines.len().saturating_sub(1));
        let byte = p.get("byte")?.as_int()? as usize;
        Some(Pos {
            line,
            col: col_at(&self.doc.lines[line], byte),
        })
    }
}

impl ModalEditor {
    /// A screen reader's text requests, in the app's own terms: lines are
    /// ordinals among the drawn lines (so `view.top` maps them back into
    /// the document), offsets are bytes.
    fn on_access(&mut self, p: &Value) {
        let text = p.get_str("text").unwrap_or("");
        match p.get_str("action") {
            Some("setTextSelection") => {
                let (Some(anchor), Some(focus)) = (
                    p.get("anchor").and_then(|v| self.access_pos(v)),
                    p.get("focus").and_then(|v| self.access_pos(v)),
                ) else {
                    return;
                };
                self.view.cur = focus;
                self.view.anchor = (anchor != focus).then_some(anchor);
            }
            Some("replaceSelectedText") => {
                delete_sel(&mut self.doc, &mut self.view);
                insert_text(&mut self.doc, &mut self.view, text);
            }
            Some("setValue") => {
                self.doc.lines = text.split('\n').map(String::from).collect();
                self.doc.modified = true;
                self.view.cur = Pos::default();
                self.view.anchor = None;
            }
            _ => {}
        }
    }

    fn access_pos(&self, v: &Value) -> Option<Pos> {
        let line = v.get("line")?.as_int()? as usize + self.view.top;
        let line = line.min(self.doc.lines.len().saturating_sub(1));
        let offset = v.get("offset")?.as_int()? as usize;
        Some(Pos {
            line,
            col: col_at(&self.doc.lines[line], offset),
        })
    }
}

/// Byte offset of character column `col` in `text` (its length past the end).
fn byte_at(text: &str, col: usize) -> u32 {
    text.char_indices().nth(col).map_or(text.len(), |(b, _)| b) as u32
}

/// Character column of byte offset `offset` in `text`.
fn col_at(text: &str, offset: usize) -> usize {
    text[..offset.min(text.len())].chars().count()
}

// ---------------------------------------------------------------- rendering

fn mono(pal: &Pal) -> TextStyle {
    TextStyle::new(FONT).mono().line_height(LH).color(pal.fg)
}

fn caret_bar(ui: &mut Ui<'_>, color: Color) {
    ui.leaf(NodeSpec::column().size(2.0, LH - 4.0).bg(color));
}

#[derive(Clone, Copy, PartialEq)]
enum Caret {
    Bar,
    Block,
    /// The block without the keyboard: an outline over the cell.
    Hollow,
}

fn render_editor(ui: &mut Ui<'_>, pal: &Pal, doc: &Doc, view: &mut View, mode: Mode, h: f32) {
    // The blink: the runner's clock, armed on the `caret` row the line
    // below declares (backlog C35). On the off phase the caret node is
    // not drawn — the row stays, which is what keeps the clock armed and
    // the IME anchored — and in a window without the keyboard the runner
    // parks the phase off, so the bar is not drawn at all. Only insert
    // mode's bar blinks: the block of normal and command mode is solid,
    // and its row says so (`caret_solid`, backlog F68), so the clock is
    // not armed and an editor idling in normal mode asks for no frame.
    // A solid caret's phase stays on without the keyboard too — the
    // clock touches only what it blinks — so the block's unfocused look
    // is this view's: hollow, the way a GUI editor's block goes when the
    // window loses the keyboard (`env.focused`; backlog RG14).
    let blink_on = ui.caret_visible();
    let solid = mode != Mode::Insert;
    let hollow = solid && !ui.env().focused;
    let rows = (((h - STATUS_H - 8.0) / LH).max(1.0)) as usize;
    view.rows = rows;
    // Scroll the caret into view — the app's job, and two lines of it.
    if view.cur.line < view.top {
        view.top = view.cur.line;
    }
    if view.cur.line >= view.top + rows {
        view.top = view.cur.line + 1 - rows;
    }
    let last = (view.top + rows).min(doc.lines.len());

    ui.with(NodeSpec::row().fill().pad_xy(0.0, 4.0), |ui| {
        // Gutter.
        ui.with(
            NodeSpec::column()
                .width(GUTTER_W)
                .grow_height()
                .pad_xy(12.0, 0.0)
                // Decoration: not part of the editor's text.
                .role(Role::None),
            |ui| {
                for ln in view.top..last {
                    let color = if ln == view.cur.line {
                        pal.dim
                    } else {
                        pal.faint
                    };
                    ui.text_in(
                        NodeSpec::row()
                            .grow_width()
                            .height(LH)
                            .main_align(Align::End)
                            .cross_align(Align::Center),
                        &format!("{}", ln + 1),
                        TextStyle::new(11.0).mono().color(color),
                    );
                }
            },
        );
        // Text.
        ui.with(NodeSpec::column().fill().clip(), |ui| {
            for ln in view.top..last {
                let kind = match (solid, hollow) {
                    (false, _) => Caret::Bar,
                    (true, false) => Caret::Block,
                    (true, true) => Caret::Hollow,
                };
                let caret =
                    (ln == view.cur.line && (solid || blink_on)).then_some((view.cur.col, kind));
                // Where the caret and the selection's other end
                // sit on this line, as byte offsets, for the
                // access tree.
                let access = (
                    (ln == view.cur.line).then(|| byte_at(&doc.lines[ln], view.cur.col)),
                    view.anchor
                        .filter(|a| a.line == ln)
                        .map(|a| byte_at(&doc.lines[ln], a.col)),
                );
                emit_line(
                    ui,
                    pal,
                    &doc.lines[ln],
                    sel_on_line(view, doc, ln),
                    caret,
                    access,
                    solid,
                );
            }
        });
    });

    status_line(ui, pal, mode, doc, view);
}

/// One line as a row of runs: selection as background segments, the caret as
/// an inline node. No text measurement needed anywhere — the row *is* the
/// layout. (The syntax_view example adds per-char colors to this same shape.)
fn emit_line(
    ui: &mut Ui<'_>,
    pal: &Pal,
    text: &str,
    sel: Option<(usize, usize)>,
    caret: Option<(usize, Caret)>,
    access: (Option<u32>, Option<u32>),
    solid: bool,
) {
    let chars: Vec<char> = text.chars().collect();
    let (caret_col, caret_kind) = match caret {
        Some((c, k)) => (Some(c), Some(k)),
        None => (None, None),
    };
    let at_sel = |i: usize| sel.is_some_and(|(a, b)| i >= a && i < b);

    // One line of the editor's text to assistive technology: the text
    // nodes inside this row, whatever they are split into for drawing.
    let mut row = NodeSpec::row()
        .grow_width()
        .height(LH)
        .cross_align(Align::Center)
        .role(Role::Line);
    if let Some(c) = access.0 {
        row = row.caret(c);
        if solid {
            row = row.caret_solid();
        }
    }
    if let Some(a) = access.1 {
        row = row.selection_anchor(a);
    }
    ui.with(row, |ui| {
        let mut i = 0;
        let mut bar_done = false;
        while i < chars.len() {
            if !bar_done && caret_col == Some(i) && caret_kind == Some(Caret::Bar) {
                caret_bar(ui, pal.accent);
                bar_done = true;
            }
            if caret_col == Some(i) && caret_kind == Some(Caret::Block) {
                // Block caret: one inverted cell.
                ui.text_in(
                    NodeSpec::row()
                        .height(LH)
                        .cross_align(Align::Center)
                        .bg(pal.accent),
                    &chars[i].to_string(),
                    mono(pal).color(pal.bg),
                );
                i += 1;
                continue;
            }
            if caret_col == Some(i) && caret_kind == Some(Caret::Hollow) {
                // The block without the keyboard: the cell outlined, its
                // glyph as it is.
                ui.text_in(
                    NodeSpec::row()
                        .height(LH)
                        .cross_align(Align::Center)
                        .border(1.0, pal.accent),
                    &chars[i].to_string(),
                    mono(pal),
                );
                i += 1;
                continue;
            }
            // Extend a run of chars sharing selection state, breaking at
            // the caret cell so it can be emitted inline.
            let selected = at_sel(i);
            let start = i;
            i += 1;
            while i < chars.len() && at_sel(i) == selected && caret_col != Some(i) {
                i += 1;
            }
            let run: String = chars[start..i].iter().collect();
            if selected {
                ui.text_in(
                    NodeSpec::row()
                        .height(LH)
                        .cross_align(Align::Center)
                        .bg(pal.select),
                    &run,
                    mono(pal),
                );
            } else {
                ui.text(&run, mono(pal));
            }
        }
        // Caret at end of line.
        if caret_col == Some(chars.len()) {
            match caret_kind {
                Some(Caret::Bar) => caret_bar(ui, pal.accent),
                Some(Caret::Block) => {
                    ui.leaf(NodeSpec::column().size(8.0, LH - 4.0).bg(pal.accent));
                }
                Some(Caret::Hollow) => {
                    ui.leaf(
                        NodeSpec::column()
                            .size(8.0, LH - 4.0)
                            .border(1.0, pal.accent),
                    );
                }
                None => {}
            }
        }
        // Selection running past the newline.
        if sel.is_some_and(|(_, b)| b > chars.len()) && caret_col != Some(chars.len()) {
            ui.leaf(NodeSpec::column().size(8.0, LH).bg(pal.select));
        }
    });
}

fn status_line(ui: &mut Ui<'_>, pal: &Pal, mode: Mode, doc: &Doc, view: &View) {
    ui.with(
        NodeSpec::row()
            .grow_width()
            .height(STATUS_H)
            .bg(pal.status)
            .pad_xy(8.0, 0.0)
            .gap(8.0)
            .cross_align(Align::Center),
        |ui| {
            let (label, color) = match mode {
                Mode::Normal => ("NOR", pal.accent),
                Mode::Insert => ("INS", pal.insert),
                Mode::Command => ("CMD", pal.command),
            };
            ui.text_in(
                NodeSpec::row().pad_xy(8.0, 2.0).radius(4.0).bg(color),
                label,
                TextStyle::new(10.0).mono().color(pal.bg),
            );
            ui.text(&doc.name, TextStyle::new(12.0).color(pal.fg));
            if doc.modified {
                ui.text("●", TextStyle::new(10.0).color(pal.command));
            }
            ui.leaf(NodeSpec::row().grow_width());
            let total = doc.lines.len();
            let pct = if total <= 1 {
                100
            } else {
                (view.cur.line * 100) / (total - 1)
            };
            ui.text(
                &format!("{}:{}  {pct}%", view.cur.line + 1, view.cur.col + 1),
                TextStyle::new(11.0).mono().color(pal.dim),
            );
        },
    );
}

// ---------------------------------------------------------------- text ops (the toy model)

fn line_len(doc: &Doc, line: usize) -> usize {
    doc.lines[line].chars().count()
}

fn clamp_col(doc: &Doc, view: &mut View) {
    view.cur.col = view.cur.col.min(line_len(doc, view.cur.line));
}

fn move_h(doc: &Doc, view: &mut View, dx: i64) {
    if dx < 0 {
        if view.cur.col > 0 {
            view.cur.col -= 1;
        } else if view.cur.line > 0 {
            view.cur.line -= 1;
            view.cur.col = line_len(doc, view.cur.line);
        }
    } else if view.cur.col < line_len(doc, view.cur.line) {
        view.cur.col += 1;
    } else if view.cur.line + 1 < doc.lines.len() {
        view.cur.line += 1;
        view.cur.col = 0;
    }
}

fn move_v(doc: &Doc, view: &mut View, dy: i64) {
    let line = view.cur.line as i64 + dy;
    view.cur.line = line.clamp(0, doc.lines.len() as i64 - 1) as usize;
    clamp_col(doc, view);
}

fn is_word(c: char) -> bool {
    c.is_alphanumeric() || c == '_'
}

fn word_fwd(doc: &Doc, view: &mut View) {
    let chars: Vec<char> = doc.lines[view.cur.line].chars().collect();
    let mut c = view.cur.col;
    while c < chars.len() && is_word(chars[c]) {
        c += 1;
    }
    while c < chars.len() && !is_word(chars[c]) {
        c += 1;
    }
    if c == view.cur.col && view.cur.line + 1 < doc.lines.len() {
        view.cur.line += 1;
        view.cur.col = 0;
    } else {
        view.cur.col = c;
    }
}

fn word_back(doc: &Doc, view: &mut View) {
    if view.cur.col == 0 {
        if view.cur.line > 0 {
            view.cur.line -= 1;
            view.cur.col = line_len(doc, view.cur.line);
        }
        return;
    }
    let chars: Vec<char> = doc.lines[view.cur.line].chars().collect();
    let mut c = view.cur.col;
    while c > 0 && !is_word(chars[c - 1]) {
        c -= 1;
    }
    while c > 0 && is_word(chars[c - 1]) {
        c -= 1;
    }
    view.cur.col = c;
}

fn char_to_byte(s: &str, col: usize) -> usize {
    s.char_indices().nth(col).map(|(b, _)| b).unwrap_or(s.len())
}

fn insert_text(doc: &mut Doc, view: &mut View, s: &str) {
    doc.modified = true;
    for part in s.split_inclusive('\n') {
        let (text, newline) = match part.strip_suffix('\n') {
            Some(t) => (t, true),
            None => (part, false),
        };
        if !text.is_empty() {
            let line = &mut doc.lines[view.cur.line];
            let byte = char_to_byte(line, view.cur.col);
            line.insert_str(byte, text);
            view.cur.col += text.chars().count();
        }
        if newline {
            let line = doc.lines[view.cur.line].clone();
            let byte = char_to_byte(&line, view.cur.col);
            let (a, b) = line.split_at(byte);
            doc.lines[view.cur.line] = a.to_string();
            doc.lines.insert(view.cur.line + 1, b.to_string());
            view.cur.line += 1;
            view.cur.col = 0;
        }
    }
}

fn backspace(doc: &mut Doc, view: &mut View) {
    doc.modified = true;
    if view.cur.col > 0 {
        let byte = char_to_byte(&doc.lines[view.cur.line], view.cur.col - 1);
        doc.lines[view.cur.line].remove(byte);
        view.cur.col -= 1;
    } else if view.cur.line > 0 {
        let tail = doc.lines.remove(view.cur.line);
        view.cur.line -= 1;
        view.cur.col = line_len(doc, view.cur.line);
        doc.lines[view.cur.line].push_str(&tail);
    }
}

fn delete_char(doc: &mut Doc, view: &mut View) {
    doc.modified = true;
    if view.cur.col < line_len(doc, view.cur.line) {
        let byte = char_to_byte(&doc.lines[view.cur.line], view.cur.col);
        doc.lines[view.cur.line].remove(byte);
    } else if view.cur.line + 1 < doc.lines.len() {
        let tail = doc.lines.remove(view.cur.line + 1);
        doc.lines[view.cur.line].push_str(&tail);
    }
}

fn delete_line(doc: &mut Doc, view: &mut View) {
    doc.modified = true;
    if doc.lines.len() > 1 {
        doc.lines.remove(view.cur.line);
        view.cur.line = view.cur.line.min(doc.lines.len() - 1);
    } else {
        doc.lines[0].clear();
    }
    clamp_col(doc, view);
    view.anchor = None;
}

fn sel_range(view: &View) -> Option<(Pos, Pos)> {
    let a = view.anchor?;
    Some(if a <= view.cur {
        (a, view.cur)
    } else {
        (view.cur, a)
    })
}

/// The selection's intersection with one display line, as a half-open char
/// range; the end can run one past the line for the picked-up newline.
fn sel_on_line(view: &View, doc: &Doc, line: usize) -> Option<(usize, usize)> {
    let (s, e) = sel_range(view)?;
    if line < s.line || line > e.line {
        return None;
    }
    let a = if line == s.line { s.col } else { 0 };
    let b = if line == e.line {
        e.col + 1
    } else {
        line_len(doc, line) + 1
    };
    Some((a, b.min(line_len(doc, line) + 1)))
}

fn sel_lines(doc: &Doc, view: &View) -> Vec<String> {
    match sel_range(view) {
        None => vec![doc.lines[view.cur.line].clone()],
        Some((s, e)) if s.line == e.line => {
            let chars: Vec<char> = doc.lines[s.line].chars().collect();
            let hi = (e.col + 1).min(chars.len());
            vec![chars[s.col.min(hi)..hi].iter().collect()]
        }
        Some((s, e)) => {
            let mut out = Vec::new();
            let first: Vec<char> = doc.lines[s.line].chars().collect();
            out.push(first[s.col.min(first.len())..].iter().collect());
            for l in s.line + 1..e.line {
                out.push(doc.lines[l].clone());
            }
            let last: Vec<char> = doc.lines[e.line].chars().collect();
            out.push(last[..(e.col + 1).min(last.len())].iter().collect());
            out
        }
    }
}

fn delete_sel(doc: &mut Doc, view: &mut View) -> bool {
    let Some((s, e)) = sel_range(view) else {
        return false;
    };
    doc.modified = true;
    let end_tail: String = {
        let chars: Vec<char> = doc.lines[e.line].chars().collect();
        chars[(e.col + 1).min(chars.len())..].iter().collect()
    };
    let start_keep: String = {
        let chars: Vec<char> = doc.lines[s.line].chars().collect();
        chars[..s.col.min(chars.len())].iter().collect()
    };
    doc.lines.splice(s.line..=e.line, [start_keep + &end_tail]);
    view.cur = s;
    view.anchor = None;
    clamp_col(doc, view);
    true
}

fn open_line(doc: &mut Doc, view: &mut View, offset: usize) {
    doc.modified = true;
    doc.lines.insert(view.cur.line + offset, String::new());
    view.cur.line += offset;
    view.cur.col = 0;
    view.anchor = None;
}

/// Linewise text (ending in a newline, as `dd` and a bare `y` leave it)
/// goes in below the current line; anything else at the caret.
fn paste(doc: &mut Doc, view: &mut View, text: &str) {
    match text.strip_suffix('\n') {
        Some(lines) => {
            doc.modified = true;
            for (i, line) in lines.split('\n').enumerate() {
                doc.lines.insert(view.cur.line + 1 + i, line.to_string());
            }
            view.cur.line += 1;
            view.cur.col = 0;
        }
        _ => insert_text(doc, view, text),
    }
}

/// The word around `pos` as an inclusive selection — a double click's.
/// Not on a word: the run of non-word characters there.
fn word_at(doc: &Doc, pos: Pos) -> (Pos, Pos) {
    let chars: Vec<char> = doc.lines[pos.line].chars().collect();
    if chars.is_empty() {
        return (pos, pos);
    }
    let c = pos.col.min(chars.len() - 1);
    let class = is_word(chars[c]);
    let mut a = c;
    while a > 0 && is_word(chars[a - 1]) == class {
        a -= 1;
    }
    let mut b = c;
    while b + 1 < chars.len() && is_word(chars[b + 1]) == class {
        b += 1;
    }
    (
        Pos {
            line: pos.line,
            col: a,
        },
        Pos {
            line: pos.line,
            col: b,
        },
    )
}

// ---------------------------------------------------------------- content

const SAMPLE: &str = "\
modal editor

An editor keymap that isn't kui's business. This app
owns the document, the modes, and (in real life)
IO, LSP, and undo. kui turns that state into pixels.

# normal mode
h j k l   move        i a I A   insert
w b       words       o O       open line
0 $       line ends   v         select
gg ge G   document    x d dd    delete
                      y p       yank/paste

# commands (:)
:w   write (pretend)  :q :qa   quit
:help                 esc      back out

The caret block is normal mode; the bar is insert.
Try dd on this line, then p a few times.

— events are data: every key you press arrives as
  {kind=key, phase=down, code=..} on one on_key sink;
  a sink that also says key_up hears the release.
  The same dispatch would run from Lua or C.";

impl Example for ModalEditor {
    const KEYS: &'static [(&'static str, &'static str)] = &[
        ("i / Esc", "insert / normal"),
        ("h j k l", "move"),
        ("w b", "words"),
        (":help", "the rest, in the minibuffer"),
    ];

    fn window(&self) -> kui_devtools::Window {
        kui_devtools::Window::default()
            .size(900.0, 700.0)
            .custom_titlebar()
    }

    fn dock(&self) -> kui_devtools::Dock {
        kui_devtools::Dock::Bottom
    }

    /// The keymap, the mouse and the clipboard, driven (backlog C36) —
    /// what the smoke round once checked by hand with real keystrokes.
    fn headless(&mut self, core: &mut Core) -> Result<(), String> {
        use kui_devtools::Drive;
        use kui_native::{InputEvent, KeyCode, KeyMods, KeyPress, MenuAction, Vec2};
        let mut d = Drive::new(core, 900.0, 700.0);
        d.frame(self);
        // Typed characters, as a driver reports them: the code and the
        // text the press would insert, which the minibuffer reads.
        let keys = |d: &mut Drive<'_>, app: &mut Self, seq: &str| {
            for c in seq.chars().filter(|c| *c != ' ') {
                let kp =
                    KeyPress::new(KeyCode::Char(c), KeyMods::default()).with_text(c.to_string());
                d.input(app, InputEvent::KeyDown(kp.clone()));
                d.input(app, InputEvent::KeyUp(kp.released()));
                d.frame(app);
            }
        };
        // `jjj ww v lll`: down three, two words on, select, right three —
        // "docu" of "document" on line 4.
        keys(&mut d, self, "jjj ww v lll");
        d.check(
            self.view.cur.line == 3 && self.view.anchor.is_some(),
            "jjj ww v lll leaves a selection on line 4",
        )?;
        d.check(
            sel_lines(&self.doc, &self.view) == ["docu"],
            "which is the first four letters of \"document\"",
        )?;
        // `dd` takes the line to the clipboard, and `p` twice asks for it
        // back: the clipboard is the host's, so the drive plays the host.
        let lines = self.doc.lines.len();
        d.key(self, "escape", KeyMods::default());
        d.frame(self);
        d.check(self.view.anchor.is_none(), "escape drops the selection")?;
        keys(&mut d, self, "dd");
        d.check(self.doc.lines.len() == lines - 1, "dd deletes the line")?;
        let queued = d.core.take_menu_actions();
        let linewise = matches!(&queued[..], [MenuAction::SetClipboard { text, .. }] if text.starts_with("owns the document") && text.ends_with('\n'));
        d.check(linewise, "and hands it to the clipboard, linewise")?;
        for _ in 0..2 {
            keys(&mut d, self, "p");
            let asked = d.core.take_menu_actions() == vec![MenuAction::Paste];
            d.check(asked, "p asks the host for the clipboard")?;
            d.input(
                self,
                InputEvent::Commit("owns the document, the modes, and (in real life)\n".into()),
            );
            d.frame(self);
        }
        d.check(
            self.doc.lines.len() == lines + 1,
            "and each paste puts the line back below the caret",
        )?;
        // `:help` fills the minibuffer.
        keys(&mut d, self, ":help");
        d.check(
            self.mode == Mode::Command && self.cmd == "help",
            ": enters command mode and the letters go to the minibuffer",
        )?;
        d.key(self, "enter", KeyMods::default());
        d.frame(self);
        d.check(
            self.mode == Mode::Normal && self.message.contains("h j k l"),
            "enter runs it: the keymap summary is the message",
        )?;
        // The mouse (backlog C34): a press inside the sink carries the
        // line, the byte and the click count; a double click takes a word.
        let editor = d.key_of("editor").ok_or("no editor")?;
        let r = d.rect_of(editor).ok_or("the editor has no rect")?;
        let cell = d.core.measure_text("M", &mono(&self.pal), None).width;
        let x = r.x + GUTTER_W + 12.5 * cell; // the thirteenth column: inside "document"
        let y = r.y + 4.0 + 4.0 * LH + LH / 2.0; // the fifth drawn line
        d.input(self, InputEvent::CursorMoved(Vec2::new(x, y)));
        d.input(self, InputEvent::mouse_down(1));
        d.input(self, InputEvent::mouse_up());
        d.frame(self);
        d.check(
            self.view.cur.line == 4 && self.view.anchor.is_none() && self.view.cur.col > 0,
            "a click places the caret on the line and column it landed on",
        )?;
        d.input(self, InputEvent::mouse_down(2));
        d.input(self, InputEvent::mouse_up());
        d.frame(self);
        d.check(
            self.view.anchor.is_some() && sel_lines(&self.doc, &self.view) == ["document"],
            "a double click selects the word under it",
        )?;
        // Normal mode's block is solid (backlog F68): its row is the
        // caret — the IME's anchor — but not one to blink, so the clock
        // is not armed and an idle editor asks for no frame.
        d.check(
            self.mode == Mode::Normal && !d.core.has_caret() && d.core.ime_rect().is_some(),
            "a solid block caret anchors the IME and arms no clock",
        )?;
        d.check(d.core.ime_off(), "normal mode turns the input method off")?;
        // Without the keyboard the solid caret is still declared and its
        // phase still on — the runner hides only what it blinks — and
        // the block is this view's to draw hollow (backlog RG14, in F68).
        let outlined = |d: &Drive<'_>| d.core.nodes().iter().filter(|n| n.border_w == 1.0).count();
        d.core.set_inspect(true);
        d.frame(self);
        let focused_outlines = outlined(&d);
        d.core.env.focused = false;
        d.frame(self);
        d.check(
            !d.core.has_caret() && d.core.ime_rect().is_some() && d.core.caret_visible(),
            "unfocused: the solid caret is still the IME's anchor, its phase on",
        )?;
        d.check(
            outlined(&d) == focused_outlines + 1,
            "and the block is drawn hollow — one outlined cell",
        )?;
        d.core.env.focused = true;
        d.frame(self);
        d.check(
            outlined(&d) == focused_outlines,
            "focused again: the block is filled",
        )?;
        d.core.set_inspect(false);
        // Insert mode's bar blinks (backlog C35): on the off phase the
        // caret node is gone and the `caret` row stays.
        keys(&mut d, self, "i");
        d.core.set_caret_visible(false);
        d.frame(self);
        d.check(
            self.mode == Mode::Insert && d.core.has_caret(),
            "the caret row is declared through the off phase",
        )?;
        d.check(!d.core.ime_off(), "insert mode gives the input method back")?;
        d.core.set_caret_visible(true);
        Ok(())
    }
}

kui_devtools::main!(ModalEditor::new());