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

features/selection.rs

//! Selection as a scope (`docs/adr/0017-selection-as-a-scope.md`), over
//! every kind of text it touches. One `selectable` row on a container
//! makes everything inside it one selection — plain `text`, a rich
//! paragraph of spans, and whatever else is in the box — so a drag across
//! the card takes the heading, both paragraphs and the footer as one run;
//! double-click takes a word, triple-click a line; Cmd/Ctrl-C copies the
//! plain text with its bold and italic carried beside it. A `cells` grid
//! is a scope too, selecting in cells rather than bytes; an `edit` has a
//! selection of its own, the caret's, which the same keys and drags move.
//!
//! There is one selection per window: starting one in any scope clears
//! the others, and `Ui::selection_text` / `Ui::cell_selection` read back
//! whichever it is. The readouts sit *outside* the scopes on purpose:
//! inside, a label that reports the selection's length would be part of
//! what Select All selects, and the number would chase itself.
//!
//! Force-clicking a word (a Force Touch trackpad) selects it and opens
//! the system's Look Up panel.
//!
//! Run: cargo run -p kui-native --example selection [-- --headless]

use kui_devtools::{Drive, Example};
use kui_native::{
    Align, App, Cell, CellGrid, Core, EditOptions, FontFamily, NodeSpec, Span, TextStyle, Theme, Ui,
};

const LINES: [&str; 4] = [
    "~/kui $ cargo test -p kui-core",
    "running 23 tests ..............",
    "test result: ok. 23 passed; 0 failed",
    "~/kui $ ",
];
const COLS: usize = 40;

struct Selection;

fn card(t: &Theme) -> NodeSpec {
    NodeSpec::column()
        .grow_width()
        .max_width(560.0)
        .pad(20.0)
        .gap(10.0)
        .bg(t.surface)
        .radius(12.0)
        .border(1.0, t.border)
}

impl App for Selection {
    fn view(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        // What is selected, whichever scope holds it.
        let readout = match ui.cell_selection() {
            Some(sel) => {
                let (a, b) = sel.ordered();
                format!("cells: lines {}–{} selected", a.line, b.line)
            }
            None => ui
                .selection_text()
                .filter(|s| !s.is_empty())
                .map(|s| format!("text: {} characters selected", s.chars().count()))
                .unwrap_or_else(|| "drag across anything below".into()),
        };
        ui.with(
            NodeSpec::column()
                .fill()
                .cross_align(Align::Center)
                .pad(24.0)
                .gap(12.0)
                .scroll_y(),
            |ui| {
                // One scope over a heading, a rich paragraph and a plain
                // line: the row is on the container, and every run inside
                // selects as one.
                ui.with_keyed("article", card(&t).selectable(), |ui| {
                    ui.text("Selectable article", TextStyle::new(22.0));
                    ui.rich_text(
                        &[
                            Span::new("Drag across this paragraph and the whole card selects as "),
                            Span::new("one run of text").bold().color(t.accent),
                            Span::new(
                                " — three labels, one selection. Double-click a word, \
                                 triple-click a line; Cmd-C copies, with the ",
                            ),
                            Span::new("bold").bold(),
                            Span::new(" and the "),
                            Span::new("italic").italic(),
                            Span::new(" carried beside the plain text."),
                        ],
                        TextStyle::new(15.0).line_height(24.0),
                    );
                    ui.text(
                        "The footer is inside the scope too, so Select All takes it.",
                        TextStyle::new(13.0).color(t.muted),
                    );
                });

                // A grid is its own scope, in cells.
                let mut cells = vec![Cell::new(' ', t.muted.to_hex(), 0); LINES.len() * COLS];
                for (r, line) in LINES.iter().enumerate() {
                    for (c, ch) in line.chars().take(COLS).enumerate() {
                        cells[r * COLS + c] = Cell::new(ch, t.fg.to_hex(), 0);
                    }
                }
                let grid = CellGrid {
                    rows: LINES.len(),
                    cols: COLS,
                    cells: &cells,
                    style: TextStyle::new(13.0).family(FontFamily::Mono).color(t.fg),
                    cursor: None,
                    origin_line: 100,
                };
                ui.with(card(&t).pad(12.0), |ui| {
                    ui.text(
                        "A terminal selects in cells (Alt-drag a rectangle)",
                        TextStyle::new(12.0).color(t.muted),
                    );
                    ui.cells_keyed(
                        "term",
                        &grid,
                        NodeSpec::column()
                            .grow_width()
                            .pad(10.0)
                            .radius(6.0)
                            .bg(t.sunken)
                            .selectable(),
                    );
                });

                // An editor's selection is the caret's: the same drag, the
                // same double-click, and Shift with the arrows.
                ui.with(card(&t).pad(12.0), |ui| {
                    ui.text(
                        "An editor's selection is its own",
                        TextStyle::new(12.0).color(t.muted),
                    );
                    ui.text_edit(
                        "note",
                        "Select in here with a drag, a double-click, or Shift and the arrows.",
                        &EditOptions::default(),
                        NodeSpec::column()
                            .grow_width()
                            .pad(10.0)
                            .radius(6.0)
                            .bg(t.bg)
                            .border(1.0, t.border)
                            .label("note"),
                    );
                });

                ui.text_in(
                    NodeSpec::row()
                        .grow_width()
                        .max_width(560.0)
                        .main_align(Align::End),
                    &readout,
                    TextStyle::new(12.0).color(t.accent),
                );
            },
        );
    }
}

impl Example for Selection {
    const KEYS: &'static [(&'static str, &'static str)] = &[
        ("drag", "select"),
        ("double / triple click", "a word / a line"),
        ("⌘A", "everything in the scope"),
        ("⌘C", "copy"),
    ];

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

    /// Select All in the article takes all three runs as one; a drag in
    /// the grid clears it and selects cells instead — one selection per
    /// window.
    fn headless(&mut self, core: &mut Core) -> Result<(), String> {
        let mut d = Drive::new(core, 620.0, 620.0);
        d.frame(self);
        let article = d.key_of("article").ok_or("no article")?;
        d.core.select_all_in(article);
        d.frame(self);
        let text = d.core.selection_text().unwrap_or_default();
        d.check(
            text.contains("Selectable article") && text.contains("Select All takes it"),
            "Select All takes the heading, the paragraph and the footer as one",
        )?;
        let html = d.core.selection_html().unwrap_or_default();
        d.check(
            html.contains("<b>") || html.contains("bold"),
            "and the copy carries the bold",
        )?;

        let term = d.key_of("term").ok_or("no grid")?;
        let r = d.rect_of(term).ok_or("the grid has no rect")?;
        d.input(
            self,
            kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 20.0, r.y + 14.0)),
        );
        d.input(self, kui_native::InputEvent::mouse_down(1));
        d.input(
            self,
            kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(r.x + 200.0, r.y + 40.0)),
        );
        d.input(self, kui_native::InputEvent::mouse_up());
        d.frame(self);
        d.check(
            d.core.cell_selection().is_some(),
            "a drag in the grid selects cells",
        )?;
        d.check(
            d.core.selection_text().is_none_or(|s| s.is_empty()),
            "and the article's selection is gone: one selection per window",
        )
    }
}

kui_devtools::main!(Selection);