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

widgets/cells.rs

//! The `cells` element: one screenful of a terminal as a grid — a
//! character, a colour and a background per cell, laid out row-major in
//! a monospace style, with a block cursor and an `origin_line` that says
//! where this screenful sits in the session's own history. The grid is
//! data the app rebuilds every frame from its own screen model; nothing
//! is retained in the core but what a `cells` node keeps for itself.
//!
//! Its selection is in *cells*, not in bytes (ADR 0017): drag out a block
//! of the screen, hold Alt for a rectangle, double-click a word,
//! triple-click a row. What a copy takes is what a terminal copies — the
//! lines, each one's trailing blanks trimmed — and its ends are absolute
//! lines, so the readout says which lines of the session they are and
//! not which rows of the screen. The screen scrolls through the app: the
//! grid declares `on_scroll`, so the wheel over it — and a drag-select
//! held past its top or bottom edge — arrives as a `scroll` event whose
//! `lines` the app adds to its own `top` (ADR 0029). A selection's ends
//! stay where they were through it, which the readout shows.
//!
//! The bench table's frame is box drawing and its bars are block
//! elements, and neither comes from the font: a `cells` node draws
//! U+2500–U+259F from the cell box, so every `│` is the row's full
//! height and the frame has no seams (backlog F66) — through the font
//! they were 1.25 em tall in a 20 px row, a dash with a gap under it.
//!
//! Run: cargo run -p kui-native --example cells [-- --headless]

use kui_devtools::{Drive, Example};
use kui_native::cells::flags;
use kui_native::{
    Align, App, Cell, CellCursor, CellGrid, Core, FontFamily, NodeSpec, TextStyle, Theme, Ui,
    UiEvent,
};

/// What a line of the fake session is *for*. A terminal's palette is the
/// app's, but these three are roles the theme already names — so the
/// screen reads on a light desktop instead of staying the grey it was
/// picked to be on a dark one.
#[derive(Clone, Copy)]
enum Ink {
    /// A prompt, a command, ordinary output.
    Plain,
    /// The compiler's own chatter.
    Quiet,
    /// A passing test run.
    Good,
}

impl Ink {
    fn of(self, t: &Theme) -> u32 {
        match self {
            Ink::Plain => t.fg,
            Ink::Quiet => t.muted,
            Ink::Good => t.success,
        }
        .to_hex()
    }
}

/// The fake session: a prompt, a command, its output, twice over, so the
/// screen can scroll through it. Trailing blanks are the app's own
/// padding, which is exactly what a copy has to trim.
const SESSION: [(&str, Ink); 12] = [
    ("~/kui $ cargo test -p kui-core", Ink::Plain),
    ("   Compiling kui-core v0.1.0-alpha.10", Ink::Quiet),
    ("    Finished `test` profile in 3.42s", Ink::Quiet),
    ("running 23 tests ..............", Ink::Plain),
    ("test result: ok. 23 passed; 0 failed", Ink::Good),
    ("~/kui $ cargo bench -p kui-core -- layout", Ink::Plain),
    ("   Compiling kui-core v0.1.0-alpha.10", Ink::Quiet),
    ("┌────────────────┬──────────┬───────┐", Ink::Plain),
    ("│ deep_nesting   │  41.2 µs │ ▁▂▃▅▇ │", Ink::Plain),
    ("│ list_10k_rows  │  2.98 ms │ ▇▅▃▂▁ │", Ink::Plain),
    ("└────────────────┴──────────┴───────┘", Ink::Plain),
    ("~/kui $ ", Ink::Plain),
];
/// Where the session's first line sits in its history: an end of a
/// selection is an *absolute* line, so scrolling the screen under it does
/// not move it (`origin_line`).
const FIRST_LINE: u64 = 1_204;
const TERM_COLS: usize = 44;
/// Rows on screen: fewer than the session has, so it scrolls.
const TERM_ROWS: usize = 6;

#[derive(Default)]
struct Cells {
    /// The first session line on screen.
    top: usize,
}

impl App for Cells {
    fn view(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        // One screenful of cells from the session at `top`. The app pads
        // its own lines, which is why a copy trims them.
        let mut cells = vec![Cell::new(' ', t.muted.to_hex(), 0); TERM_ROWS * TERM_COLS];
        for r in 0..TERM_ROWS {
            let Some((line, ink)) = SESSION.get(self.top + r) else {
                break;
            };
            let fg = ink.of(&t);
            for (c, ch) in line.chars().take(TERM_COLS).enumerate() {
                cells[r * TERM_COLS + c] = Cell::new(ch, fg, 0);
            }
            // A shell's "did you mean": the word after `--` on the bench
            // line carries an undercurl in the accent (SGR 4:3 + 58,
            // `flags::WAVY` + `ul`; backlog K4).
            if let Some(at) = line.find("-- ") {
                let word = at + 3..line.len().min(TERM_COLS);
                for c in word {
                    cells[r * TERM_COLS + c] = cells[r * TERM_COLS + c]
                        .with(flags::WAVY)
                        .underline_color(t.accent.to_hex());
                }
            }
        }
        // The cursor sits after the last prompt, when it is on screen.
        let cursor = (SESSION.len() - 1)
            .checked_sub(self.top)
            .filter(|r| *r < TERM_ROWS)
            .map(|r| (r, 8, CellCursor::Block, t.accent.with_alpha(0.7)));
        let grid = CellGrid {
            rows: TERM_ROWS,
            cols: TERM_COLS,
            cells: &cells,
            style: TextStyle::new(13.0).family(FontFamily::Mono).color(t.fg),
            cursor,
            origin_line: FIRST_LINE + self.top as u64,
        };
        // What is selected: which *lines* of the session, since the ends
        // are absolute and the rows are not.
        let selected = ui.cell_selection().map(|sel| {
            let (a, b) = sel.ordered();
            format!("lines {}–{} of the session selected", a.line, b.line)
        });
        ui.with(
            NodeSpec::column()
                .fill()
                .cross_align(Align::Center)
                .pad(24.0)
                .gap(12.0),
            |ui| {
                ui.with(
                    NodeSpec::column()
                        .grow_width()
                        .max_width(620.0)
                        .pad(12.0)
                        .gap(8.0)
                        .bg(t.surface)
                        .radius(10.0)
                        .border(1.0, t.border),
                    |ui| {
                        ui.text(
                            "a terminal selects in cells — double-click a word, Alt-drag a rectangle",
                            TextStyle::new(13.0).color(t.muted),
                        );
                        ui.cells_keyed(
                            "term",
                            &grid,
                            NodeSpec::column()
                                .grow_width()
                                .pad(10.0)
                                .radius(6.0)
                                .bg(t.sunken)
                                .border(1.0, t.border)
                                // A scope of one grid: the drag selects
                                // cells, and the stock menu's Select All
                                // takes the whole screen.
                                .selectable()
                                // The wheel, and a drag held past the
                                // edge, ask the app to scroll: the
                                // screen is the app's, so the core
                                // cannot.
                                .on_scroll("scroll"),
                        );
                        ui.with(
                            NodeSpec::row()
                                .grow_width()
                                .gap(8.0)
                                .cross_align(Align::Center),
                            |ui| {
                                ui.text(
                                    &format!(
                                        "lines {}–{} on screen",
                                        FIRST_LINE + self.top as u64,
                                        FIRST_LINE + (self.top + TERM_ROWS) as u64 - 1
                                    ),
                                    TextStyle::new(12.0).color(t.muted),
                                );
                                ui.leaf(NodeSpec::row().grow_width());
                                ui.text(
                                    selected.as_deref().unwrap_or("nothing selected"),
                                    TextStyle::new(12.0).color(t.accent),
                                );
                            },
                        );
                    },
                );
            },
        );
    }

    fn on_event(&mut self, ev: UiEvent) {
        // The wheel over the grid, or a drag-select held past its edge:
        // `lines` is how many rows later (positive) or earlier the screen
        // should move — the whole lines the delta covered, the fraction
        // carried by the core to the next notch.
        if ev.kind() == Some("scroll") {
            let lines = ev.payload.get_int("lines").unwrap_or(0);
            self.top =
                (self.top as i64 + lines).clamp(0, (SESSION.len() - TERM_ROWS) as i64) as usize;
        }
    }
}

impl Example for Cells {
    const KEYS: &'static [(&'static str, &'static str)] = &[
        ("drag", "select cells; past the edge scrolls"),
        ("Alt-drag", "a rectangle"),
        ("⇧-click", "extend the selection"),
        ("double / triple click", "a word / a row"),
        ("wheel", "scroll the session"),
        ("⌘C", "copy, trailing blanks trimmed"),
    ];

    fn window(&self) -> kui_devtools::Window {
        kui_devtools::Window::default().size(680.0, 300.0)
    }

    /// Selects a block by dragging across the grid, then scrolls the
    /// screen under it with the wheel: the ends are absolute lines and
    /// stay where they were. Then a drag held past the bottom edge asks
    /// the app to scroll, a frame at a time, and the live end follows.
    fn headless(&mut self, core: &mut Core) -> Result<(), String> {
        let mut d = Drive::new(core, 680.0, 300.0);
        d.frame(self);
        let term = d.key_of("term").ok_or("no grid")?;
        let r = d.rect_of(term).ok_or("the grid has no rect")?;
        // A drag from the second row to the fourth.
        let (x0, y0) = (r.x + 30.0, r.y + 10.0 + 1.5 * 18.0);
        let (x1, y1) = (r.x + 200.0, r.y + 10.0 + 3.5 * 18.0);
        d.input(
            self,
            kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(x0, y0)),
        );
        d.input(self, kui_native::InputEvent::mouse_down(1));
        d.input(
            self,
            kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(x1, y1)),
        );
        d.input(self, kui_native::InputEvent::mouse_up());
        d.frame(self);
        let sel = d.core.cell_selection().ok_or("the drag selected nothing")?;
        let (a, b) = sel.ordered();
        d.check(a.line < b.line, "a drag selects a block of cells")?;
        d.check(
            a.line >= FIRST_LINE && b.line < FIRST_LINE + TERM_ROWS as u64,
            "and its ends are absolute session lines",
        )?;
        let copied = d.core.copy_selection().unwrap_or_default();
        d.check(
            !copied.is_empty() && !copied.lines().any(|l| l.ends_with(' ')),
            "a copy is the lines with their trailing blanks trimmed",
        )?;

        // Scroll the screen under the selection: the wheel over the grid
        // is a `scroll` event with the lines it covers, the app moves its
        // `top`, and the ends stay put.
        let top = self.top;
        d.wheel(self, x1, y1, 0.0, -2.0 * 18.0);
        d.frame(self);
        d.check(
            self.top == top + 2,
            "a two-row wheel notch is two lines the app scrolls by",
        )?;
        let after = d
            .core
            .cell_selection()
            .ok_or("scrolling lost the selection")?;
        let (a2, b2) = after.ordered();
        d.check(
            (a2.line, b2.line) == (a.line, b.line),
            "scrolling the screen leaves the selection on its lines",
        )?;

        // A drag held past the bottom edge: the core asks the app for
        // lines every frame, at a rate from how far past, and the live
        // end follows the pointer onto the moved screen (ADR 0029).
        d.input(
            self,
            kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(x0, y0)),
        );
        d.input(self, kui_native::InputEvent::mouse_down(1));
        let below = r.y + r.h + 80.0;
        d.input(
            self,
            kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(x1, below)),
        );
        let anchor = d.core.cell_selection().ok_or("no drag")?.anchor.line;
        let top = self.top;
        for _ in 0..30 {
            d.advance(1.0 / 60.0);
            d.frame(self);
        }
        d.check(
            self.top > top,
            "half a second past the edge scrolled the screen",
        )?;
        d.input(self, kui_native::InputEvent::mouse_up());
        d.frame(self);
        let held = d.core.cell_selection().ok_or("the drag is gone")?;
        d.check(held.anchor.line == anchor, "the anchor kept its line")?;
        d.check(
            held.focus.line == FIRST_LINE + (self.top + TERM_ROWS) as u64 - 1,
            "and the live end is on the last row of the moved screen",
        )
    }
}

kui_devtools::main!(Cells::default());