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/syntax_view.rs

//! A read-only syntax-highlighted code view: the app's highlighter assigns
//! each char a color, and every visible line becomes a row of coalesced
//! style runs — one text node per token run, no text measurement anywhere;
//! the row *is* the layout. Tree-sitter goes where `highlight()` sits in the
//! real app; `emit_line` wouldn't notice.
//!
//! This is the frame shape the kui-core `highlight` bench measures.
//!
//! One line carries a diagnostic: the runs under it are underlined by a
//! red wave in their own colour (`underline_color` + `underline_style`,
//! backlog K4), the way an editor marks an unused field — no `line` float
//! under the run, no rect arithmetic.
//!
//! Run: cargo run -p kui-native --example syntax_view
//!
//! Keys: j/k or arrows move · pageup/pagedown · g/G ends · tab next buffer.

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

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

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

#[derive(Clone, Copy)]
struct Pal {
    bg: Color,
    panel: Color,
    line: Color,
    status: Color,
    fg: Color,
    dim: Color,
    faint: Color,
    accent: Color,
    kw: Color,
    string: Color,
    number: Color,
    comment: Color,
    ty: Color,
    mac: Color,
    /// The diagnostic wave: the theme's danger role.
    error: Color,
}

impl From<Theme> for Pal {
    /// The chrome comes off the theme; the six syntax hues are the app's
    /// own, which is the honest split — a keyword's purple is authored,
    /// the way ADR 0017 says a span's colour is, and no UI role names it.
    /// They still come in two sets, because a hue picked to read on
    /// `#0f1117` does not read on white: same families, darkened for the
    /// light base, each checked past 4.5:1 on the surface it lands on.
    fn from(t: Theme) -> Self {
        let dark = t.is_dark();
        let hue = |d: u32, l: u32| Color::hex(if dark { d } else { l });
        Self {
            bg: t.bg,
            panel: t.surface,
            line: t.sunken,
            status: t.sunken,
            fg: t.fg,
            dim: t.muted,
            faint: t.faint,
            accent: t.focus_ring,
            kw: hue(0xc78fe8ff, 0x7c3aabff),
            string: hue(0x9cc87aff, 0x35701cff),
            number: hue(0xd9a14dff, 0x8a5c08ff),
            // A comment is meant to recede, so it is the theme's own
            // "barely there" tier rather than a seventh hue.
            comment: t.faint,
            ty: hue(0x6fc3d6ff, 0x17697dff),
            mac: hue(0xe09a6aff, 0xa1541cff),
            error: t.danger,
        }
    }
}

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

#[derive(Clone, Copy, PartialEq)]
enum Lang {
    Rust,
    Text,
}

struct Doc {
    name: String,
    lines: Vec<String>,
    lang: Lang,
}

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

    /// The columns a diagnostic marks on `line`, if any: the Rust sample
    /// has one, on the field its counter never reads — the columns of
    /// `count` on the line that declares it.
    fn diagnostic_on(&self, line: usize) -> Option<std::ops::Range<usize>> {
        if self.lang != Lang::Rust {
            return None;
        }
        let text = self.lines.get(line)?;
        let at = text.find("count: i64")?;
        Some(at..at + "count".len())
    }
}

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

struct SyntaxView {
    pal: Pal,
    docs: Vec<Doc>,
    cur: usize,
    line: usize,
    top: usize,
    rows: usize,
}

impl SyntaxView {
    fn new() -> Self {
        Self {
            pal: Theme::default().into(),
            docs: vec![
                Doc::new("main.rs", SAMPLE_RS, Lang::Rust),
                Doc::new("NOTES", NOTES, Lang::Text),
            ],
            cur: 0,
            line: 0,
            top: 0,
            rows: 24,
        }
    }

    fn move_line(&mut self, dy: i64) {
        let total = self.docs[self.cur].lines.len() as i64;
        self.line = (self.line as i64 + dy).clamp(0, total - 1) as usize;
    }

    fn on_key(&mut self, code: &str) {
        match code {
            "j" | "down" => self.move_line(1),
            "k" | "up" => self.move_line(-1),
            "pagedown" => self.move_line(self.rows as i64 - 1),
            "pageup" => self.move_line(-(self.rows as i64 - 1)),
            "g" | "home" => self.line = 0,
            "G" | "end" => self.line = self.docs[self.cur].lines.len() - 1,
            "tab" => {
                self.cur = (self.cur + 1) % self.docs.len();
                self.line = 0;
                self.top = 0;
            }
            _ => {}
        }
    }
}

impl App for SyntaxView {
    fn view(&mut self, ui: &mut Ui<'_>) {
        // Rebuilt from the theme each frame, so the window follows the OS.
        self.pal = ui.theme().into();
        let pal = self.pal;
        ui.with(NodeSpec::column().fill().bg(pal.bg), |ui| {
            widgets::titlebar(
                ui,
                "kui — syntax view (highlighting is coalesced style runs)",
            );

            let vp = ui.viewport();
            let h = (vp.h - widgets::TITLEBAR_H).max(LH);
            self.rows = (((h - STATUS_H - 8.0) / LH).max(1.0)) as usize;
            // Scroll the cursor line into view.
            if self.line < self.top {
                self.top = self.line;
            }
            if self.line >= self.top + self.rows {
                self.top = self.line + 1 - self.rows;
            }
            let doc = &self.docs[self.cur];
            let last = (self.top + self.rows).min(doc.lines.len());
            let (top, cur_line) = (self.top, self.line);

            let sink = ui.with_keyed(
                "view",
                NodeSpec::column().fill().bg(pal.panel).clip().key_sink(),
                |ui| {
                    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),
                            |ui| {
                                for ln in top..last {
                                    let color = if ln == 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 top..last {
                                emit_line(
                                    ui,
                                    &pal,
                                    &doc.lines[ln],
                                    doc.lang,
                                    ln == cur_line,
                                    doc.diagnostic_on(ln),
                                );
                            }
                        });
                    });
                    status_line(ui, &pal, doc, cur_line);
                },
            );
            ui.take_key_focus(sink);
        });
    }

    fn on_event(&mut self, ev: UiEvent) {
        // Presses only, because the sink never asked for releases (no
        // `key_up`): this pane scrolls on a chord, and nothing arrives on
        // the way up to be filtered out.
        if ev.kind() == Some("key")
            && let Some(code) = ev.payload.get_str("code")
        {
            let code = code.to_string();
            self.on_key(&code);
        }
    }
}

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

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

/// One line as a row of coalesced color runs: adjacent chars sharing a color
/// become one text node. The cache in the core is keyed by (content, style,
/// scale) — color excluded — so token runs dedupe across lines and colors.
fn emit_line(
    ui: &mut Ui<'_>,
    pal: &Pal,
    text: &str,
    lang: Lang,
    current: bool,
    diagnostic: Option<std::ops::Range<usize>>,
) {
    let chars: Vec<char> = text.chars().collect();
    let colors = highlight(pal, &chars, lang);
    // A run breaks where the diagnostic starts and ends, so the wave
    // covers the marked columns and nothing beside them.
    let marked = |i: usize| diagnostic.as_ref().is_some_and(|d| d.contains(&i));
    let mut row = NodeSpec::row()
        .grow_width()
        .height(LH)
        .cross_align(Align::Center);
    if current {
        row = row.bg(pal.line);
    }
    ui.with(row, |ui| {
        let mut i = 0;
        while i < chars.len() {
            let start = i;
            let color = colors[i];
            let mark = marked(i);
            i += 1;
            while i < chars.len() && colors[i] == color && marked(i) == mark {
                i += 1;
            }
            let run: String = chars[start..i].iter().collect();
            let mut style = mono(pal).color(color);
            if mark {
                style = style
                    .underline_color(pal.error)
                    .underline_style(UnderlineStyle::Wavy);
            }
            ui.text(&run, style);
        }
    });
}

fn status_line(ui: &mut Ui<'_>, pal: &Pal, doc: &Doc, line: usize) {
    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| {
            ui.with(
                NodeSpec::row().pad_xy(8.0, 2.0).radius(4.0).bg(pal.accent),
                |ui| {
                    let label = match doc.lang {
                        Lang::Rust => "RUST",
                        Lang::Text => "TEXT",
                    };
                    ui.text(label, TextStyle::new(10.0).mono().color(pal.bg));
                },
            );
            ui.text(&doc.name, TextStyle::new(12.0).color(pal.fg));
            ui.leaf(NodeSpec::row().grow_width());
            ui.text("tab switches buffer", TextStyle::new(11.0).color(pal.faint));
            let total = doc.lines.len();
            let pct = if total <= 1 {
                100
            } else {
                (line * 100) / (total - 1)
            };
            ui.text(
                &format!("{}/{total}  {pct}%", line + 1),
                TextStyle::new(11.0).mono().color(pal.dim),
            );
        },
    );
}

// ---------------------------------------------------------------- highlighter (a stand-in)

const RUST_KW: &[&str] = &[
    "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
    "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
    "ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "type",
    "unsafe", "use", "where", "while",
];

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

/// Per-char colors for one line. Tree-sitter goes here in the real app; the
/// view code (`emit_line`) doesn't care who colored the chars.
fn highlight(pal: &Pal, chars: &[char], lang: Lang) -> Vec<Color> {
    let mut out = vec![pal.fg; chars.len()];
    match lang {
        Lang::Text => {
            if chars.first() == Some(&'#') {
                out.fill(pal.accent);
            } else if chars.first() == Some(&'—') {
                out.fill(pal.faint);
            }
        }
        Lang::Rust => {
            let mut i = 0;
            while i < chars.len() {
                let c = chars[i];
                if c == '/' && chars.get(i + 1) == Some(&'/') {
                    out[i..].fill(pal.comment);
                    break;
                }
                if c == '"' {
                    let start = i;
                    i += 1;
                    while i < chars.len() && chars[i] != '"' {
                        i += if chars[i] == '\\' { 2 } else { 1 };
                    }
                    i = (i + 1).min(chars.len());
                    out[start..i].fill(pal.string);
                    continue;
                }
                if c.is_ascii_digit() && (i == 0 || !is_word(chars[i - 1])) {
                    let start = i;
                    while i < chars.len()
                        && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '.')
                    {
                        i += 1;
                    }
                    out[start..i].fill(pal.number);
                    continue;
                }
                if is_word(c) && (i == 0 || !is_word(chars[i - 1])) {
                    let start = i;
                    while i < chars.len() && is_word(chars[i]) {
                        i += 1;
                    }
                    let word: String = chars[start..i].iter().collect();
                    let color = if chars.get(i) == Some(&'!') {
                        Some(pal.mac)
                    } else if RUST_KW.contains(&word.as_str()) {
                        Some(pal.kw)
                    } else if word.chars().next().is_some_and(char::is_uppercase) {
                        Some(pal.ty)
                    } else {
                        None
                    };
                    if let Some(color) = color {
                        out[start..i].fill(color);
                    }
                    continue;
                }
                i += 1;
            }
        }
    }
    out
}

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

const NOTES: &str = "\
syntax_view

Per-char colors come from the app's highlighter;
kui just draws coalesced runs of styled text.

Every visible line is one row node; every token
run is one text node. No measurement, no spans
API — the row is the layout.

— swap highlight() for tree-sitter and emit_line
  never knows the difference.
— the kui-core `highlight` bench builds frames of
  exactly this shape to keep the cost honest.";

const SAMPLE_RS: &str = "\
//! Minimal Elm-ish counter: view() rebuilds the tree
//! from state, clicks arrive as data in on_event.

use kui_native::widgets;
use kui_native::{App, NodeSpec, TextStyle, Ui, UiEvent, Value};

#[derive(Default)]
struct Counter {
    count: i64,
}

impl App for Counter {
    fn view(&mut self, ui: &mut Ui<'_>) {
        ui.configure_root(NodeSpec::column().fill().center().gap(24.0));
        ui.with(
            NodeSpec::column().pad(32.0).gap(20.0).radius(12.0),
            |ui| {
                ui.text(\"kui counter\", TextStyle::new(14.0));
                ui.text(&self.count.to_string(), TextStyle::new(56.0));
                widgets::button(ui, \"+1\", Value::map([(\"kind\", \"inc\".into())]));
            },
        );
    }

    fn on_event(&mut self, ev: UiEvent) {
        match ev.payload.get(\"kind\").and_then(Value::as_str) {
            Some(\"inc\") => self.count += 1,
            Some(\"dec\") => self.count -= 1,
            _ => {}
        }
    }
}

fn main() {
    kui_native::run(\"kui — counter\", Counter::default(), vec![]).unwrap();
}";

impl Example for SyntaxView {
    const KEYS: &'static [(&'static str, &'static str)] = &[
        ("j / k, arrows", "move"),
        ("PgUp / PgDn", "page"),
        ("g / G", "ends"),
        ("Tab", "next buffer"),
    ];

    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 keys, driven (backlog C36): `j`, `G` and `tab` move the line,
    /// the view and the buffer — and the view keeps the line on screen.
    fn headless(&mut self, core: &mut Core) -> Result<(), String> {
        use kui_devtools::Drive;
        use kui_native::KeyMods;
        let mut d = Drive::new(core, 900.0, 700.0);
        d.frame(self);
        d.check(
            self.cur == 0 && self.line == 0 && self.top == 0,
            "the first buffer, at its top",
        )?;
        d.key(self, "j", KeyMods::default());
        d.frame(self);
        d.check(self.line == 1 && self.top == 0, "j moves down a line")?;
        d.key(self, "G", KeyMods::default());
        d.frame(self);
        let last = self.docs[0].lines.len() - 1;
        d.check(self.line == last, "G goes to the last line")?;
        d.check(
            self.top > 0 && self.top + self.rows > last,
            "and the view scrolled so the line is on screen",
        )?;
        d.key(self, "k", KeyMods::default());
        d.frame(self);
        d.check(self.line == last - 1, "k moves up")?;
        // Tab is the sink's, not the ring's: a sink that holds focus keeps
        // every key (ADR 0002, decision 3).
        d.key(self, "tab", KeyMods::default());
        d.frame(self);
        d.check(
            self.cur == 1 && self.line == 0 && self.top == 0,
            "tab switches to the next buffer, at its top",
        )?;
        d.key(self, "tab", KeyMods::default());
        d.frame(self);
        d.check(self.cur == 0, "and wraps around")
    }
}

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