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

//! The `edit` element: a multiline document and the single-line
//! `widgets::text_input` beside it, both the same widget under two
//! options. Cursor motion, selection (shift+arrows, drag, double-click a
//! word), clipboard (Cmd/Ctrl C/X/V/A), undo, and scrolling as the
//! document grows are the core's; the view declares the editor and reads
//! its text back (`ui.edit_text`) — the buffer lives in the core keyed by
//! the widget's identity, so it survives every rebuild of the tree.
//!
//! What arrives in `on_event`: `changed` on every edit and `submit` from
//! the single-line field on Enter, both carrying the editor's key and
//! nothing else — the text is the core's, read back with `edit_text`.
//!
//! Run: cargo run -p kui-native --example edit [-- --headless]

use kui_devtools::{Drive, Example};
use kui_native::{
    Align, App, Core, EditOptions, FontFamily, Key, NodeSpec, TextStyle, Ui, UiEvent,
};

const INITIAL: &str = "\
kui edit

Type here. Everything works the way you'd expect:

  - arrows, home/end, page up/down (+shift to select)
  - alt+arrows for word motion, cmd+arrows for line/document
  - click to place the caret, drag to select, double-click a word
  - cmd+c / cmd+x / cmd+v / cmd+a, cmd+z / cmd+shift+z
  - enter, tab, backspace, delete

The buffer lives in the core keyed by widget identity, so this
text survives every rebuild of the UI tree - the view below is
regenerated from scratch every frame, like any other kui view.
";

#[derive(Default)]
struct Edit {
    doc: Option<Key>,
    chars: usize,
    lines: usize,
    edited: bool,
    seen_version: u64,
    /// The field a `submit` arrived on, read back on the next view.
    submit: Option<Key>,
    /// What the single-line field last submitted.
    submitted: Option<String>,
}

impl App for Edit {
    fn view(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        ui.with(NodeSpec::column().fill(), |ui| {
            // The single-line field: `widgets::text_input` is the same
            // element with `multiline: false`, chrome, and a focus ring.
            ui.with(
                NodeSpec::row()
                    .grow_width()
                    .pad(12.0)
                    .gap(12.0)
                    .cross_align(Align::Center)
                    .bg(t.surface)
                    .border(1.0, t.border),
                |ui| {
                    ui.text("title", TextStyle::new(12.0).color(t.muted));
                    let title = kui_native::widgets::text_input(ui, "title", "");
                    if self.submit.take() == Some(title) {
                        self.submitted = ui.edit_text(title);
                    }
                    ui.text(
                        &match &self.submitted {
                            Some(s) => format!("submitted: {s:?}"),
                            None => "Enter submits".into(),
                        },
                        TextStyle::new(12.0).color(t.muted),
                    );
                },
            );

            // The document: the edit node grows its height with content
            // inside a scroll container, so the document scrolls as it
            // grows.
            ui.with(NodeSpec::column().fill().scroll_y(), |ui| {
                let key = ui.text_edit(
                    "doc",
                    INITIAL,
                    &EditOptions {
                        style: TextStyle::new(14.0)
                            .family(FontFamily::Mono)
                            .line_height(22.0),
                        multiline: true,
                        autofocus: true,
                        ..Default::default()
                    },
                    // Nothing inside an editor names it, so
                    // `control-without-name` is right to ask: a screen
                    // reader would say "text input".
                    NodeSpec::column().grow_width().pad(20.0).label("document"),
                );
                self.doc = Some(key);
                // Recount only when the document actually changed —
                // pulling the full text out every frame would be
                // O(doc) per keystroke.
                let version = ui.core().edit.version(key);
                if version != self.seen_version || self.chars == 0 {
                    self.recount(ui.edit_text(key));
                    self.seen_version = version;
                }
            });

            // Status bar.
            ui.with(
                NodeSpec::row()
                    .grow_width()
                    .pad_xy(12.0, 6.0)
                    .gap(16.0)
                    .bg(t.surface)
                    .border(1.0, t.border)
                    .cross_align(Align::Center),
                |ui| {
                    let muted = TextStyle::new(12.0).color(t.muted);
                    ui.text(&format!("{} lines", self.lines), muted);
                    ui.text(&format!("{} chars", self.chars), muted);
                    ui.leaf(NodeSpec::row().grow_width());
                    ui.text(
                        if self.edited { "edited" } else { "saved" },
                        TextStyle::new(12.0).color(if self.edited { t.warning } else { t.success }),
                    );
                },
            );
        });
    }

    fn on_event(&mut self, ev: UiEvent) {
        match ev.kind() {
            Some("changed") if Some(ev.key) == self.doc => {
                self.edited = true;
                self.chars = 0; // recount next view
            }
            Some("submit") => self.submit = Some(ev.key),
            _ => {}
        }
    }
}

impl Edit {
    fn recount(&mut self, text: Option<String>) {
        if let Some(t) = text {
            self.chars = t.chars().count();
            self.lines = t.lines().count().max(1);
        }
    }
}

impl Example for Edit {
    const KEYS: &'static [(&'static str, &'static str)] = &[
        ("Shift-arrows", "select"),
        ("⌥/⌘ arrows", "word / line motion"),
        ("⌘C ⌘X ⌘V", "clipboard"),
        ("⌘Z", "undo"),
        ("Enter", "submits the field"),
    ];

    fn headless(&mut self, core: &mut Core) -> Result<(), String> {
        let mut d = Drive::new(core, 640.0, 400.0);
        d.frame(self);
        let doc = self.doc.ok_or("no document")?;
        let lines = self.lines;
        d.check(
            lines == INITIAL.lines().count(),
            "the initial text is counted",
        )?;

        // Typing lands in the document (it autofocused) and the status
        // bar sees the change through `changed`.
        d.text(self, "x");
        d.frame(self);
        d.check(self.edited, "a keystroke reports `changed`")?;
        let text = d.core.edit_text(doc).unwrap_or_default();
        d.check(text.contains('x'), "and the text read back has it")?;

        // The single-line field submits on Enter.
        let title = d.key_of("title").ok_or("no title field")?;
        d.focus(self, title);
        d.text(self, "hello");
        d.key(self, "enter", Default::default());
        d.frame(self);
        d.check(
            self.submitted.as_deref() == Some("hello"),
            "Enter submits the field's text",
        )
    }
}

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