diff --git a/Cargo.lock b/Cargo.lock index c69bf1b..55d8f86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,7 @@ dependencies = [ "serde", "thiserror", "toml", + "unicode-segmentation", "unicode-width", ] diff --git a/Cargo.toml b/Cargo.toml index e16a234..3fa9e08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,9 @@ ratatui = { version = "0.30", default-features = false, features = ["crossterm_0 # How many terminal columns text takes (accents, Asian scripts, emoji), for # sizing node boxes. unicode-width = "0.2" +# Where one character ends and the next begins, so the cursor steps over an +# accented letter or an emoji in one go. +unicode-segmentation = "1" [lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index ed2311e..901926e 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,6 @@ cargo fmt # format the code While the app is being built, the examples try out each part: ```sh -cargo run --example canvas # the canvas with vim keys and which-key +cargo run --example canvas # the canvas, vim keys, which-key, :commands cargo test show_canvas -- --ignored --nocapture # print it at each zoom, as text ``` diff --git a/examples/canvas.rs b/examples/canvas.rs index 46af482..c8b17e4 100644 --- a/examples/canvas.rs +++ b/examples/canvas.rs @@ -1,6 +1,6 @@ -//! A playground for the canvas (stages 2 and 3 of the build): a sample -//! document to move around, zoom, fold and rearrange, with the real vim keys -//! and the which-key popup. The command line, text editing and the other +//! A playground for the canvas (stages 2 to 4 of the build): a sample +//! document to move around, zoom, fold and rearrange, with the real vim keys, +//! the which-key popup and the `:` command line. Text editing and the other //! views come in later stages. //! //! Run it with `cargo run --example canvas`. A few keys to start with: @@ -16,6 +16,7 @@ //! | `M` | move mode: hjkl moves the branch, Enter ends | //! | `u` `Ctrl-r` | undo / redo | //! | `` | the action menu (`w`: key hints on or off) | +//! | `:` | the command line: try `:fold auto`, `:color teal`, `:in an idea`, `:link rain`, `:find soil` (Tab completes) | //! | `q`, `ZZ` | quit | //! //! Mouse: click selects, dragging a node moves its branch, dragging empty @@ -24,6 +25,8 @@ use std::io; use std::time::{Duration, Instant}; +use cumulus::command::history::History as CommandHistory; +use cumulus::command::parse::{Call, Sources, TagChange, parse}; use cumulus::input::bindings::Context; use cumulus::input::command::{Bound, Command, Motion, Operator, Target, help_for}; use cumulus::input::engine::{KeyEngine, Outcome, Typed}; @@ -31,15 +34,16 @@ use cumulus::input::key::Key; use cumulus::input::keymap::Keymap; use cumulus::input::mouse::{Cell, Gesture, MouseReader}; use cumulus::model::{ - CanvasOffset, Change, Direction, Document, Edit, EditError, Fold, History, Node, NodeId, Spot, Title, - Visible, + CanvasOffset, CanvasPos, CanvasSize, Change, Content, Direction, Document, Edit, EditError, Fold, + History, Link, LinkKey, Node, NodeId, Spot, Title, Visible, }; use cumulus::storage::document_file; +use cumulus::ui::command_line::{CommandLine, LineOutcome}; use cumulus::ui::theme::Theme; use cumulus::ui::which_key::WhichKey; use cumulus::view::canvas::boxes::{NodeBox, box_rects}; use cumulus::view::canvas::navigate::nearest; -use cumulus::view::canvas::placement::{below_spot, child_spot}; +use cumulus::view::canvas::placement::{below_spot, child_spot, free_spot}; use cumulus::view::canvas::{Camera, Scene, SceneOptions, ScreenPos}; use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind}; use ratatui::crossterm::execute; @@ -111,6 +115,9 @@ struct Playground { mode: Mode, mouse: MouseReader, drag: Option, + /// The command line, while it's open. + command_line: Option, + command_history: CommandHistory, /// The last frame drawn, to match clicks against. scene: Scene, canvas_area: Rect, @@ -135,6 +142,8 @@ impl Playground { mode: Mode::Normal, mouse: MouseReader::default(), drag: None, + command_line: None, + command_history: CommandHistory::default(), scene: Scene::default(), canvas_area: Rect::default(), follow: true, @@ -187,6 +196,11 @@ impl Playground { } } frame.render_widget(&self.scene, canvas); + // While the command line is open, it takes the status line's place. + if let Some(line) = &self.command_line { + line.render(frame, status, canvas, &self.theme); + return; + } if self.which_key_due() && let Some(hints) = self.keys.hints(&self.keymap.tables(self.contexts())) { @@ -205,7 +219,7 @@ impl Playground { let (mode, hints) = match self.mode { Mode::Normal => ( " NORMAL ", - "hjkl move · g go to · z folds · +/- zoom · a/o add · x/dd delete · M move · u undo · menu · q quit", + "hjkl move · g go to · z folds · +/- zoom · a/o add · x/dd delete · M move · u undo · : command · menu · q quit", ), Mode::Move { whole: true, .. } => { (" MOVE ", "hjkl moves the branch (HJKL by 5) · M just this node · Enter done") @@ -248,6 +262,9 @@ impl Playground { /// Handles a key press. Returns false to quit. fn key(&mut self, key: Key) -> bool { self.message.clear(); + if self.command_line.is_some() { + return self.command_line_key(key); + } let outcome = self.keys.press(key, &self.keymap.tables(self.contexts())); match outcome { Outcome::Run(typed) => return self.run_command(typed), @@ -295,6 +312,9 @@ impl Playground { Command::HalfPageDown => self.camera.pan(0, i32::from(self.canvas_area.height / 2)), Command::HalfPageUp => self.camera.pan(0, -i32::from(self.canvas_area.height / 2)), Command::MoveMode => self.mode = Mode::Move { whole: true, change: None }, + Command::CommandLine => { + self.command_line = Some(CommandLine::new(&self.sources(), &self.command_history)); + } Command::ToggleWhichKey => { self.which_key = !self.which_key; self.message = format!("key hints {}", if self.which_key { "on" } else { "off" }); @@ -379,6 +399,173 @@ impl Playground { } } + // ----- The command line ----------------------------------------------- + + /// A key while the command line is open. Returns false to quit. + fn command_line_key(&mut self, key: Key) -> bool { + let sources = self.sources(); + let Some(line) = &mut self.command_line else { return true }; + match line.key(key, &sources, &self.command_history) { + LineOutcome::Typing => true, + LineOutcome::Close => { + self.command_line = None; + true + } + LineOutcome::Run(text) => { + self.command_line = None; + if text.trim().is_empty() { + return true; + } + self.command_history.add(&text); + match parse(&text, &sources) { + Ok(call) => self.run_call(call), + Err(error) => { + self.message = error.to_string(); + true + } + } + } + } + } + + /// What the command line can name: this document, its nodes, the + /// palette's colors, its tags. + fn sources(&self) -> Sources { + let doc = &self.doc; + let nodes = doc.outline().into_iter().filter_map(|id| Some((id, doc.node(id)?.title().to_string()))); + let mut tags: Vec = doc + .outline() + .into_iter() + .filter_map(|id| doc.node(id)) + .flat_map(|node| node.tags.iter().map(|tag| tag.name().to_string()).collect::>()) + .collect(); + tags.sort(); + tags.dedup(); + Sources { + documents: vec![doc.title().to_string()], + nodes: nodes.collect(), + colors: self.theme.palette.keys().map(ToString::to_string).collect(), + styles: self.theme.node_styles.keys().map(ToString::to_string).collect(), + themes: vec!["default".into()], + settings: Vec::new(), + tags, + folder: std::env::current_dir().unwrap_or_default(), + } + } + + /// Runs a command line. Returns false to quit. + fn run_call(&mut self, call: Call) -> bool { + let id = self.cursor; + match call { + Call::Quit { .. } | Call::QuitAll { .. } | Call::WriteQuit | Call::ClosePane => return false, + Call::Fold(fold) => self.set_fold(fold), + Call::Focus => { + self.options.focus = !self.options.focus; + self.message = format!("focus {}", if self.options.focus { "on" } else { "off" }); + } + Call::Color(color) => { + self.edit(|edit| edit.update(id, |node| node.look.color = color)); + } + Call::Border(border) => { + self.edit(|edit| edit.update(id, |node| node.look.border = Some(border))); + } + Call::Tags(changes) => { + self.edit(|edit| { + edit.update(id, |node| { + for change in changes { + match change { + TagChange::Add(tag) => node.tags.insert(tag), + TagChange::Remove(tag) => node.tags.remove(&tag), + }; + } + }) + }); + } + Call::Rename(title) => { + let root = self.doc.root(); + self.edit(|edit| { + edit.update(root, |node| { + if let Content::Idea(idea) = &mut node.content { + idea.title = title; + } + }) + }); + } + Call::Capture(text) => self.capture(&text), + Call::Link(target) => match Link::new(id, target) { + Some(link) => { + self.edit(|edit| edit.link(link)); + } + None => self.message = "a node can't link to itself".into(), + }, + Call::Unlink(target) => { + let keys: Vec = match target.and_then(|target| LinkKey::new(id, target)) { + Some(key) => vec![key], + None => self.doc.links_of(id).map(Link::key).collect(), + }; + self.edit(|edit| keys.iter().try_for_each(|&key| edit.unlink(key).map(drop))); + } + Call::Find(text) => self.find(&text), + Call::Version => self.message = cumulus::version(), + Call::Help(_) => { + self.message = "the help screen comes in stage 7; Tab completes any command".into() + } + other => self.message = format!("{other:?} comes in a later stage of the build"), + } + true + } + + /// `:in`: adds an idea under the Inbox, a loose node made the first time. + fn capture(&mut self, text: &str) { + let Ok(title) = Title::parse(text) else { return }; + let inbox = self + .doc + .loose() + .iter() + .copied() + .find(|&id| self.doc.node(id).is_some_and(|node| node.title().as_str() == "Inbox")); + let rects = box_rects(&self.doc, self.options.max_width, self.theme.glyphs); + let below_everything = rects.values().map(|rect| rect.bottom()).max().unwrap_or(0) + 2; + let size = CanvasSize::new(30, 3); + let added = self.edit(|edit| { + let inbox = match inbox { + Some(inbox) => inbox, + None => { + let pos = free_spot(CanvasPos::new(0, below_everything), size, &rects); + let title = Title::parse("Inbox").map_err(|_| EditError::Full)?; + edit.add(Node::idea(title, pos), Spot::Loose)? + } + }; + let pos = child_spot(edit.doc(), inbox, size, &rects); + edit.add(Node::idea(title, pos), Spot::LastChildOf(inbox)) + }); + if added.is_some() { + self.message = format!("captured \"{text}\" to the Inbox"); + } + } + + /// `:find`: goes to the next node whose title has the text in it. + fn find(&mut self, text: &str) { + let wanted = text.to_lowercase(); + let order = self.doc.outline(); + let here = order.iter().position(|&id| id == self.cursor).unwrap_or(0); + let matches: Vec = order + .iter() + .copied() + .filter(|&id| { + self.doc.node(id).is_some_and(|node| node.title().as_str().to_lowercase().contains(&wanted)) + }) + .collect(); + let next = matches.iter().copied().find(|&id| order.iter().position(|&o| o == id) > Some(here)); + match next.or_else(|| matches.first().copied()) { + Some(id) => { + self.jump(Some(id)); + self.message = format!("{} match(es) for \"{text}\"", matches.len()); + } + None => self.message = format!("nothing matches \"{text}\""), + } + } + // ----- The mouse ------------------------------------------------------ fn gesture(&mut self, gesture: Gesture) { diff --git a/src/command/complete.rs b/src/command/complete.rs new file mode 100644 index 0000000..cf894ea --- /dev/null +++ b/src/command/complete.rs @@ -0,0 +1,317 @@ +//! Completion: what can finish the word being typed on the command line. +//! Command names complete by prefix, as in vim (`:e` → `:edit`); documents +//! and nodes fuzzily (`:b gpl` → Garden plans); files from the folders on +//! disk; everything else from its list of choices. + +use std::path::{Path, PathBuf}; + +use super::parse::Sources; +use super::registry::{self, ArgKind, Spec}; + +/// At most this many candidates are offered. +const MAX_CANDIDATES: usize = 50; + +/// A way to finish the word being typed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + /// What replaces the word. + pub text: String, + /// What it is or does, shown beside it. + pub help: String, +} + +/// The completion of a line: where the word being completed starts (a byte +/// offset in the line) and the candidates, best first. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Completion { + pub start: usize, + pub candidates: Vec, +} + +impl Completion { + /// What the best candidate adds to the word typed so far, when it + /// carries on from it: the ghost text after the cursor. + pub fn ghost<'a>(&'a self, line: &str) -> Option<&'a str> { + let typed = line.get(self.start..)?; + let best = self.candidates.first()?; + best.text.strip_prefix(typed).filter(|rest| !rest.is_empty()) + } +} + +/// Completes the word at the end of `line` (the text after `:`). +pub fn complete(line: &str, sources: &Sources) -> Completion { + let name_start = line.len() - line.trim_start().len(); + let name_end = + line[name_start..].find(|c: char| !c.is_ascii_alphabetic()).map_or(line.len(), |at| name_start + at); + let name = &line[name_start..name_end]; + if name_end == line.len() { + return Completion { start: name_start, candidates: command_names(name) }; + } + let Some(spec) = registry::find(name) else { return Completion::default() }; + let after = line[name_end..].strip_prefix('!').unwrap_or(&line[name_end..]); + if !after.starts_with(char::is_whitespace) { + return Completion::default(); + } + let args_start = line.len() - after.trim_start().len(); + let Some((kind, start)) = current_argument(spec, line, args_start) else { return Completion::default() }; + let typed = &line[start..]; + let mut candidates = match kind { + ArgKind::Choice(choices) => choices + .iter() + .filter(|choice| starts_with_ignoring_case(choice.word, typed)) + .map(|choice| candidate(choice.word, choice.help)) + .collect(), + ArgKind::File => paths(typed, &sources.folder), + ArgKind::Document => rank(typed, sources.documents.iter().map(String::as_str)) + .into_iter() + .map(|name| candidate(name, "")) + .collect(), + ArgKind::Node => rank(typed, sources.nodes.iter().map(|(_, title)| title.as_str())) + .into_iter() + .map(|title| candidate(title, "")) + .collect(), + ArgKind::Color => std::iter::once("none") + .chain(sources.colors.iter().map(String::as_str)) + .filter(|name| starts_with_ignoring_case(name, typed)) + .map(|name| candidate(name, if name == "none" { "no color" } else { "" })) + .collect(), + ArgKind::Style => names(typed, &sources.styles), + ArgKind::Theme => names(typed, &sources.themes), + ArgKind::Setting => names(typed, &sources.settings), + ArgKind::Topic => registry::SPECS + .iter() + .filter(|spec| spec.name.starts_with(typed)) + .map(|spec| candidate(spec.name, spec.help)) + .collect(), + ArgKind::Tags => { + let (sign, name) = typed.split_at(usize::from(typed.starts_with(['+', '-']))); + sources + .tags + .iter() + .filter(|tag| starts_with_ignoring_case(tag, name)) + .map(|tag| candidate(&format!("{sign}{tag}"), "")) + .collect() + } + ArgKind::Text => Vec::new(), + }; + candidates.truncate(MAX_CANDIDATES); + Completion { start, candidates } +} + +/// Which argument the end of the line is in, and where its word starts. +/// Tags and settings complete their last word; other kinds that take the +/// rest of the line complete all of it. +fn current_argument(spec: &Spec, line: &str, args_start: usize) -> Option<(ArgKind, usize)> { + let mut start = args_start; + for arg in spec.args { + let rest = &line[start..]; + match arg.kind { + ArgKind::Tags | ArgKind::Setting => { + let last_word = rest.rfind(char::is_whitespace).map_or(0, |space| space + 1); + return Some((arg.kind, start + last_word)); + } + kind if kind.takes_the_rest() => return Some((kind, start)), + kind => match rest.find(char::is_whitespace) { + // The word is done: completion moves on to the next argument. + Some(end) => start += end + (rest.len() - end - rest[end..].trim_start().len()), + None => return Some((kind, start)), + }, + } + } + None +} + +/// Command names starting with `typed`. A name whose short form is exactly +/// what's typed comes first (`:e` offers `edit` before `export`). +fn command_names(typed: &str) -> Vec { + let mut specs: Vec<&Spec> = registry::SPECS.iter().filter(|spec| spec.name.starts_with(typed)).collect(); + specs.sort_by_key(|spec| (spec.short != typed, spec.name)); + let mut candidates: Vec = specs.iter().map(|spec| candidate(spec.name, spec.help)).collect(); + // Aliases, unless their command is listed already (`colour` beside `color`). + for spec in registry::SPECS.iter().filter(|spec| !spec.name.starts_with(typed)) { + if let Some(alias) = spec.aliases.iter().find(|alias| alias.starts_with(typed) && !typed.is_empty()) { + candidates.push(candidate(alias, spec.help)); + } + } + candidates +} + +/// Names from a list that start with `typed`. +fn names(typed: &str, names: &[String]) -> Vec { + names + .iter() + .filter(|name| starts_with_ignoring_case(name, typed)) + .map(|name| candidate(name, "")) + .collect() +} + +/// Files and folders that finish a typed path. Folders come first and end +/// with `/`; hidden ones only show when the name typed starts with a dot. +fn paths(typed: &str, folder: &Path) -> Vec { + let split = typed.rfind(['/', '\\']).map_or(0, |slash| slash + 1); + let (dir, start) = typed.split_at(split); + let dir_path = if Path::new(dir).is_absolute() { PathBuf::from(dir) } else { folder.join(dir) }; + let Ok(entries) = std::fs::read_dir(dir_path) else { return Vec::new() }; + let mut found: Vec<(bool, String)> = entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name().into_string().ok()?; + let shown = !name.starts_with('.') || start.starts_with('.'); + let is_folder = entry.file_type().ok()?.is_dir(); + (shown && starts_with_ignoring_case(&name, start)).then_some((is_folder, name)) + }) + .collect(); + found.sort_by_key(|(is_folder, name)| (!is_folder, name.to_lowercase())); + found + .into_iter() + .map(|(is_folder, name)| { + let help = if is_folder { + "folder" + } else if name.ends_with(".cum") { + "document" + } else { + "file" + }; + candidate(&format!("{dir}{name}{}", if is_folder { "/" } else { "" }), help) + }) + .collect() +} + +fn candidate(text: &str, help: &str) -> Candidate { + Candidate { text: text.to_string(), help: help.to_string() } +} + +fn starts_with_ignoring_case(text: &str, start: &str) -> bool { + text.to_lowercase().starts_with(&start.to_lowercase()) +} + +/// How well `query` matches `text`: `None` unless its characters all appear +/// in `text`, in order, ignoring case. Matches at the start of a word and +/// matches in a row score higher, so "gpl" picks "Garden plans". +pub fn fuzzy_score(query: &str, text: &str) -> Option { + let mut wanted = query.chars().flat_map(char::to_lowercase).peekable(); + let mut score = 0; + let mut previous: Option = None; + let mut previous_matched = false; + for c in text.chars() { + let Some(&next) = wanted.peek() else { break }; + let matched = c.to_lowercase().eq(std::iter::once(next)); + if matched { + score += 1; + if previous.is_none_or(|before| !before.is_alphanumeric()) { + score += 8; + } + if previous_matched { + score += 4; + } + wanted.next(); + } + previous_matched = matched; + previous = Some(c); + } + if wanted.peek().is_some() { + return None; + } + // Among equal matches, the shorter text is the closer one. + Some(score * 16 - text.chars().count() as i32) +} + +/// The texts that `query` fuzzily matches, best first. An empty query +/// matches everything, in the order given. +pub fn rank<'a>(query: &str, texts: impl IntoIterator) -> Vec<&'a str> { + let mut scored: Vec<(i32, usize, &str)> = texts + .into_iter() + .enumerate() + .filter_map(|(order, text)| Some((fuzzy_score(query, text)?, order, text))) + .collect(); + if !query.is_empty() { + scored.sort_by_key(|&(score, order, _)| (-score, order)); + } + scored.into_iter().map(|(_, _, text)| text).collect() +} + +/// How many letters to add, remove or change to turn `a` into `b` +/// (Levenshtein distance): how far a typo is from a name. +pub fn edit_distance(a: &str, b: &str) -> usize { + let b: Vec = b.chars().collect(); + let mut row: Vec = (0..=b.len()).collect(); + for (i, x) in a.chars().enumerate() { + let mut diagonal = row[0]; + row[0] = i + 1; + for (j, y) in b.iter().enumerate() { + let above = row[j + 1]; + row[j + 1] = (above + 1).min(row[j] + 1).min(diagonal + usize::from(x != *y)); + diagonal = above; + } + } + row[b.len()] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::NodeId; + + fn sources() -> Sources { + let names = |list: &[&str]| list.iter().map(|name| name.to_string()).collect(); + Sources { + documents: names(&["Garden plans", "Kitchen", "Grocery list"]), + nodes: vec![(NodeId::new(1), "Raised beds".into()), (NodeId::new(2), "Rain barrel".into())], + colors: names(&["red", "orange", "teal"]), + tags: names(&["todo", "later"]), + ..Sources::default() + } + } + + fn texts(line: &str) -> Vec { + complete(line, &sources()).candidates.into_iter().map(|candidate| candidate.text).collect() + } + + #[test] + fn command_names_complete_by_prefix() { + assert_eq!(texts("e"), ["edit", "export"], "the short form's command first"); + assert_eq!(texts("fo"), ["fold", "focus"]); + assert_eq!(texts("colo"), ["color", "colorscheme"]); + let all = texts(""); + assert_eq!(all.len(), registry::SPECS.len()); + } + + #[test] + fn arguments_complete_by_kind() { + assert_eq!(texts("fold a"), ["auto"]); + assert_eq!(texts("b gl"), ["Grocery list", "Garden plans"], "fuzzy: word starts in a row first"); + assert_eq!(texts("link rb"), ["Raised beds", "Rain barrel"], "equally good: their own order"); + assert_eq!(texts("color "), ["none", "red", "orange", "teal"]); + assert_eq!(texts("tag +idea -t"), ["-todo"]); + assert_eq!(texts("export md "), Vec::::new(), "a file in the current folder (none here)"); + assert_eq!(texts("q! x"), Vec::::new(), "nothing to complete"); + let completion = complete("vs out", &sources()); + assert_eq!((completion.start, completion.ghost("vs out")), (3, Some("line"))); + } + + #[test] + fn paths_complete_from_the_folder() { + let folder = std::env::temp_dir().join(format!("cumulus-complete-{}", std::process::id())); + std::fs::create_dir_all(folder.join("notes")).unwrap(); + std::fs::write(folder.join("garden.cum"), "").unwrap(); + std::fs::write(folder.join("Garage.txt"), "").unwrap(); + std::fs::write(folder.join(".hidden"), "").unwrap(); + let sources = Sources { folder: folder.clone(), ..Sources::default() }; + let found = |line: &str| -> Vec { + complete(line, &sources).candidates.into_iter().map(|candidate| candidate.text).collect() + }; + assert_eq!(found("e "), ["notes/", "Garage.txt", "garden.cum"], "folders first, hidden files hidden"); + assert_eq!(found("e ga"), ["Garage.txt", "garden.cum"]); + assert_eq!(found("e notes/"), Vec::::new()); + std::fs::remove_dir_all(folder).unwrap(); + } + + #[test] + fn fuzzy_and_typo_matching() { + assert!(fuzzy_score("gpl", "Garden plans") > fuzzy_score("gpl", "a grape pile")); + assert_eq!(fuzzy_score("xyz", "Garden plans"), None); + assert_eq!(edit_distance("folx", "fold"), 1); + assert_eq!(edit_distance("", "abc"), 3); + assert_eq!(edit_distance("kitten", "sitting"), 3); + } +} diff --git a/src/command/history.rs b/src/command/history.rs new file mode 100644 index 0000000..ef24db6 --- /dev/null +++ b/src/command/history.rs @@ -0,0 +1,100 @@ +//! Earlier command lines, for Up and Down and for the ghost text. + +/// How many lines are kept. +const LIMIT: usize = 200; + +/// Earlier command lines, oldest first. A line is kept once: running it +/// again moves it to the end. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct History { + lines: Vec, +} + +impl History { + /// A history holding these lines, oldest first (as loaded from disk). + pub fn from_lines(lines: Vec) -> History { + let mut history = History::default(); + for line in lines { + history.add(&line); + } + history + } + + /// The lines, oldest first (to save them). + pub fn lines(&self) -> &[String] { + &self.lines + } + + /// Remembers a line that was run. + pub fn add(&mut self, line: &str) { + let line = line.trim(); + if line.is_empty() { + return; + } + self.lines.retain(|earlier| earlier != line); + self.lines.push(line.to_string()); + if self.lines.len() > LIMIT { + self.lines.remove(0); + } + } + + /// The line at an index (from [`older`](History::older) or + /// [`newer`](History::newer)). + pub fn get(&self, index: usize) -> Option<&str> { + self.lines.get(index).map(String::as_str) + } + + /// The newest line before `before` (or before the end, for `None`) that + /// starts with `start`, as Up finds it. + pub fn older(&self, start: &str, before: Option) -> Option { + let end = before.unwrap_or(self.lines.len()).min(self.lines.len()); + self.lines[..end].iter().rposition(|line| line.starts_with(start)) + } + + /// The oldest line after `after` that starts with `start`, as Down finds + /// it. + pub fn newer(&self, start: &str, after: usize) -> Option { + let from = after + 1; + self.lines.get(from..)?.iter().position(|line| line.starts_with(start)).map(|offset| from + offset) + } + + /// The newest line that starts with `start` and goes further: what the + /// ghost text suggests, like fish's. + pub fn suggestion(&self, start: &str) -> Option<&str> { + if start.trim().is_empty() { + return None; + } + self.lines + .iter() + .rev() + .find(|line| line.starts_with(start) && line.len() > start.len()) + .map(String::as_str) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn up_and_down_only_see_matching_lines() { + let history = + History::from_lines(vec!["fold open".into(), "e garden.cum".into(), "fold auto".into()]); + let up = history.older("fold", None).unwrap(); + assert_eq!(history.get(up), Some("fold auto")); + let up_again = history.older("fold", Some(up)).unwrap(); + assert_eq!(history.get(up_again), Some("fold open")); + assert_eq!(history.older("fold", Some(up_again)), None); + assert_eq!(history.newer("fold", up_again), Some(up)); + assert_eq!(history.suggestion("e g"), Some("e garden.cum")); + assert_eq!(history.suggestion(""), None); + } + + #[test] + fn running_a_line_again_moves_it_to_the_end() { + let mut history = History::from_lines(vec!["q".into(), "w".into()]); + history.add("q"); + history.add(" "); + assert_eq!(history.lines(), ["w", "q"]); + } +} diff --git a/src/command/mod.rs b/src/command/mod.rs new file mode 100644 index 0000000..bb2f116 --- /dev/null +++ b/src/command/mod.rs @@ -0,0 +1,16 @@ +//! The command line, opened with `:` like vim's. +//! +//! - [`registry`]: every command, with how short it can be typed, its +//! arguments and what it does. +//! - [`parse`]: a line of text to a typed [`Call`](parse::Call), with clear +//! errors. +//! - [`complete`]: what can finish the word being typed. +//! - [`history`]: earlier lines, for Up and Down. +//! +//! The line being typed and its popup are in +//! [`ui::command_line`](crate::ui::command_line). + +pub mod complete; +pub mod history; +pub mod parse; +pub mod registry; diff --git a/src/command/parse.rs b/src/command/parse.rs new file mode 100644 index 0000000..dcd19f3 --- /dev/null +++ b/src/command/parse.rs @@ -0,0 +1,570 @@ +//! A command line, understood: [`parse`] turns its text into a typed +//! [`Call`], checking every argument, or says clearly what's wrong. + +use std::path::PathBuf; + +use thiserror::Error; + +use super::complete::{edit_distance, rank}; +use super::registry::{self, Choice, CommandId as Id, Spec}; +use crate::model::{Border, ColorName, Fold, NodeId, StyleName, Tag, Title}; +use crate::view::ViewKind; + +/// How `:tidy` arranges a branch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Layout { + /// A mind map, growing to the right. + Map, + /// An org chart, growing down. + Chart, + /// A grid, for idea dumps. + Grid, +} + +/// Which edges `:align` lines up. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + Left, + Right, + Top, + Bottom, + Center, +} + +/// What `:export` writes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportFormat { + Markdown, + Text, +} + +/// What the board's columns are. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BoardColumns { + Tags, + Colors, +} + +/// One change `:tag` makes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TagChange { + Add(Tag), + Remove(Tag), +} + +/// What `:set` does with a setting, the way vim's `:set` reads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SetArg { + /// `name?`: show its value. + Show(String), + /// `name&`: back to the default. + Reset(String), + /// `name`: turn an on/off setting on. + On(String), + /// `noname`: turn it off. + Off(String), + /// `name!`: flip it. + Toggle(String), + /// `name=value`. + Value(String, String), +} + +/// What a command line asks for, with its arguments checked and typed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Call { + Edit { + path: String, + force: bool, + }, + New(Option), + Write, + WriteAll, + Quit { + force: bool, + }, + QuitAll { + force: bool, + }, + WriteQuit, + Buffers, + /// Switch to the open document with this name. + Buffer(String), + CloseDocument { + force: bool, + }, + Rename(Title), + Split(Option<ViewKind>), + VSplit(Option<ViewKind>), + Only, + ClosePane, + Show(ViewKind), + Board(Option<BoardColumns>), + Capture(String), + Link(NodeId), + Unlink(Option<NodeId>), + Portal(String), + BranchOut, + Tags(Vec<TagChange>), + /// A palette color, or `None` to clear it. + Color(Option<ColorName>), + Style(StyleName), + Border(Border), + Fold(Fold), + Focus, + Tidy(Layout), + Align(Side), + Find(String), + Backlinks, + Jumps, + Marks, + Registers, + Set(SetArg), + Config, + Help(Option<String>), + Version, + Theme(String), + Export { + format: ExportFormat, + path: Option<String>, + }, +} + +/// What the command line can name, from the app: names are checked against +/// these when parsing, and offered when completing. +#[derive(Debug, Clone, Default)] +pub struct Sources { + /// The open documents' names. + pub documents: Vec<String>, + /// This document's nodes, with their titles. + pub nodes: Vec<(NodeId, String)>, + /// The palette's colors. + pub colors: Vec<String>, + /// The theme's named styles. + pub styles: Vec<String>, + pub themes: Vec<String>, + /// The settings `:set` can change. + pub settings: Vec<String>, + /// The tags used in this document. + pub tags: Vec<String>, + /// Where relative file paths start: the document's folder. + pub folder: PathBuf, +} + +/// Why a command line can't run. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum CallError { + #[error("type a command, like :help")] + Empty, + #[error("there's no command :{name}{}", did_you_mean(.suggestion))] + Unknown { name: String, suggestion: Option<&'static str> }, + #[error(":{0} doesn't take a !")] + NoBang(&'static str), + #[error("{usage}: the {what} is missing")] + Missing { usage: String, what: &'static str }, + #[error("{usage}: {extra:?} is more than it takes")] + TooMuch { usage: String, extra: String }, + #[error("{given:?} isn't a {what}: use {choices}")] + NotAChoice { what: &'static str, given: String, choices: String }, + #[error("there's no {what} like {given:?}")] + NoMatch { what: &'static str, given: String }, + #[error("{0}")] + Invalid(String), +} + +fn did_you_mean(suggestion: &Option<&str>) -> String { + suggestion.map(|name| format!("; did you mean :{name}?")).unwrap_or_default() +} + +/// Reads a command line (without its `:`). +pub fn parse(line: &str, sources: &Sources) -> Result<Call, CallError> { + let line = line.trim().trim_start_matches(':').trim_start(); + let name_end = line.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(line.len()); + let (name, after) = line.split_at(name_end); + if name.is_empty() { + return Err(CallError::Empty); + } + let (bang, rest) = match after.strip_prefix('!') { + Some(rest) => (true, rest), + None => (false, after), + }; + let spec = registry::find(name) + .ok_or_else(|| CallError::Unknown { name: name.to_string(), suggestion: suggestion(name) })?; + if bang && !spec.bang { + return Err(CallError::NoBang(spec.name)); + } + let args = read_args(spec, rest.trim())?; + build(spec, bang, &args, sources) +} + +/// A command name close to a mistyped one: a letter or two off. +fn suggestion(name: &str) -> Option<&'static str> { + registry::SPECS + .iter() + .map(|spec| (edit_distance(name, spec.name), spec.name)) + .filter(|(distance, _)| *distance <= 2) + .min() + .map(|(_, name)| name) +} + +/// Splits what follows the command's name into its arguments: one word +/// each, or the rest of the line for the kinds that take it. An optional +/// argument left out is `None`. +fn read_args<'a>(spec: &Spec, mut rest: &'a str) -> Result<Vec<Option<&'a str>>, CallError> { + let mut values = Vec::new(); + for arg in spec.args { + let value = if rest.is_empty() { + None + } else if arg.kind.takes_the_rest() { + Some(std::mem::take(&mut rest)) + } else { + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + let (word, after) = rest.split_at(end); + rest = after.trim_start(); + Some(word) + }; + if value.is_none() && arg.required { + return Err(CallError::Missing { usage: spec.usage(), what: arg.name }); + } + values.push(value); + } + if !rest.is_empty() { + return Err(CallError::TooMuch { usage: spec.usage(), extra: rest.to_string() }); + } + Ok(values) +} + +/// The call, from a command and its arguments as typed. (Required +/// arguments are there: `read_args` checked.) +fn build(spec: &Spec, bang: bool, args: &[Option<&str>], sources: &Sources) -> Result<Call, CallError> { + let first = args.first().copied().flatten(); + let second = args.get(1).copied().flatten(); + let text = first.unwrap_or_default(); + Ok(match spec.id { + Id::Edit => Call::Edit { path: text.to_string(), force: bang }, + Id::New => Call::New(first.map(title).transpose()?), + Id::Write => Call::Write, + Id::WriteAll => Call::WriteAll, + Id::Quit => Call::Quit { force: bang }, + Id::QuitAll => Call::QuitAll { force: bang }, + Id::WriteQuit => Call::WriteQuit, + Id::Buffers => Call::Buffers, + Id::Buffer => Call::Buffer(best("open document", text, &sources.documents)?.to_string()), + Id::CloseDocument => Call::CloseDocument { force: bang }, + Id::Rename => Call::Rename(title(text)?), + Id::Split => Call::Split(first.map(view).transpose()?), + Id::VSplit => Call::VSplit(first.map(view).transpose()?), + Id::Only => Call::Only, + Id::ClosePane => Call::ClosePane, + Id::Canvas => Call::Show(ViewKind::Canvas), + Id::Outline => Call::Show(ViewKind::Outline), + Id::Note => Call::Show(ViewKind::Note), + Id::Board => Call::Board(first.map(board_columns).transpose()?), + Id::Capture => Call::Capture(text.to_string()), + Id::Link => Call::Link(node(text, &sources.nodes)?), + Id::Unlink => Call::Unlink(first.map(|query| node(query, &sources.nodes)).transpose()?), + Id::Portal => Call::Portal(text.to_string()), + Id::BranchOut => Call::BranchOut, + Id::Tags => Call::Tags(tag_changes(text)?), + Id::Color => Call::Color(color(text, &sources.colors)?), + Id::Style => Call::Style(style(text, &sources.styles)?), + Id::Border => Call::Border(border(text)?), + Id::Fold => Call::Fold(fold(text)?), + Id::Focus => Call::Focus, + Id::Tidy => Call::Tidy(first.map(layout).transpose()?.unwrap_or(Layout::Map)), + Id::Align => Call::Align(side(text)?), + Id::Find => Call::Find(text.to_string()), + Id::Backlinks => Call::Backlinks, + Id::Jumps => Call::Jumps, + Id::Marks => Call::Marks, + Id::Registers => Call::Registers, + Id::Set => Call::Set(set_arg(text, &sources.settings)?), + Id::Config => Call::Config, + Id::Help => Call::Help(first.map(str::to_string)), + Id::Version => Call::Version, + Id::Theme => Call::Theme(exactly("theme", text, &sources.themes)?.to_string()), + Id::Export => Call::Export { format: export_format(text)?, path: second.map(str::to_string) }, + }) +} + +fn title(text: &str) -> Result<Title, CallError> { + Title::parse(text).map_err(|error| CallError::Invalid(error.to_string())) +} + +/// The best fuzzy match for `query` among `names`. +fn best<'a>(what: &'static str, query: &str, names: &'a [String]) -> Result<&'a str, CallError> { + rank(query, names.iter().map(String::as_str)) + .first() + .copied() + .ok_or_else(|| CallError::NoMatch { what, given: query.to_string() }) +} + +/// The node whose title matches `query`: an exact title first, then the best +/// fuzzy match. +fn node(query: &str, nodes: &[(NodeId, String)]) -> Result<NodeId, CallError> { + let exact = nodes.iter().find(|(_, title)| title.eq_ignore_ascii_case(query)); + let fuzzy = || { + let best = rank(query, nodes.iter().map(|(_, title)| title.as_str())).first().copied()?; + nodes.iter().find(|(_, title)| title == best) + }; + exact + .or_else(fuzzy) + .map(|(id, _)| *id) + .ok_or_else(|| CallError::NoMatch { what: "node", given: query.to_string() }) +} + +/// A name that has to be one of `names` exactly (ignoring case). +fn exactly<'a>(what: &'static str, given: &str, names: &'a [String]) -> Result<&'a str, CallError> { + names.iter().find(|name| name.eq_ignore_ascii_case(given)).map(String::as_str).ok_or_else(|| { + if names.is_empty() { + CallError::NoMatch { what, given: given.to_string() } + } else { + CallError::NotAChoice { what, given: given.to_string(), choices: names.join(", ") } + } + }) +} + +fn color(given: &str, colors: &[String]) -> Result<Option<ColorName>, CallError> { + if given.eq_ignore_ascii_case("none") { + return Ok(None); + } + let name = exactly("color", given, colors)?; + ColorName::parse(name).map(Some).map_err(|error| CallError::Invalid(error.to_string())) +} + +fn style(given: &str, styles: &[String]) -> Result<StyleName, CallError> { + let name = exactly("style", given, styles)?; + StyleName::parse(name).map_err(|error| CallError::Invalid(error.to_string())) +} + +fn tag_changes(text: &str) -> Result<Vec<TagChange>, CallError> { + let invalid = |error: crate::model::TextError| CallError::Invalid(error.to_string()); + text.split_whitespace() + .map(|word| match word.strip_prefix('-') { + Some(name) => Tag::parse(name).map(TagChange::Remove).map_err(invalid), + None => Tag::parse(word.trim_start_matches('+')).map(TagChange::Add).map_err(invalid), + }) + .collect() +} + +fn set_arg(text: &str, settings: &[String]) -> Result<SetArg, CallError> { + let known = |name: &str| settings.iter().any(|setting| setting == name); + let check = |name: &str| { + if known(name) { + Ok(name.to_string()) + } else { + Err(CallError::NoMatch { what: "setting", given: name.into() }) + } + }; + if let Some((name, value)) = text.split_once('=') { + return Ok(SetArg::Value(check(name.trim())?, value.trim().to_string())); + } + if let Some(name) = text.strip_suffix('?') { + return Ok(SetArg::Show(check(name)?)); + } + if let Some(name) = text.strip_suffix('&') { + return Ok(SetArg::Reset(check(name)?)); + } + if let Some(name) = text.strip_suffix('!') { + return Ok(SetArg::Toggle(check(name)?)); + } + if let Some(name) = text.strip_prefix("no") + && known(name) + { + return Ok(SetArg::Off(name.to_string())); + } + Ok(SetArg::On(check(text)?)) +} + +/// The error for a word that isn't one of the choices. +fn not_a_choice(what: &'static str, given: &str, choices: &[Choice]) -> CallError { + let words: Vec<&str> = choices.iter().map(|choice| choice.word).collect(); + CallError::NotAChoice { what, given: given.to_string(), choices: words.join(", ") } +} + +fn view(word: &str) -> Result<ViewKind, CallError> { + ViewKind::ALL + .into_iter() + .find(|kind| kind.name() == word.to_ascii_lowercase()) + .ok_or_else(|| not_a_choice("view", word, registry::VIEWS)) +} + +fn board_columns(word: &str) -> Result<BoardColumns, CallError> { + match word.to_ascii_lowercase().as_str() { + "tag" | "tags" => Ok(BoardColumns::Tags), + "color" | "colors" => Ok(BoardColumns::Colors), + _ => Err(not_a_choice("way to make columns", word, registry::BOARD_COLUMNS)), + } +} + +fn border(word: &str) -> Result<Border, CallError> { + match word.to_ascii_lowercase().as_str() { + "rounded" => Ok(Border::Rounded), + "square" => Ok(Border::Square), + "double" => Ok(Border::Double), + "thick" => Ok(Border::Thick), + "dashed" => Ok(Border::Dashed), + "hidden" | "none" => Ok(Border::Hidden), + _ => Err(not_a_choice("border", word, registry::BORDERS)), + } +} + +fn fold(word: &str) -> Result<Fold, CallError> { + match word.to_ascii_lowercase().as_str() { + "open" => Ok(Fold::Open), + "auto" => Ok(Fold::Auto), + "closed" | "close" => Ok(Fold::Closed), + _ => Err(not_a_choice("way to fold", word, registry::FOLDS)), + } +} + +fn layout(word: &str) -> Result<Layout, CallError> { + match word.to_ascii_lowercase().as_str() { + "map" => Ok(Layout::Map), + "chart" => Ok(Layout::Chart), + "grid" => Ok(Layout::Grid), + _ => Err(not_a_choice("layout", word, registry::LAYOUTS)), + } +} + +fn side(word: &str) -> Result<Side, CallError> { + match word.to_ascii_lowercase().as_str() { + "left" => Ok(Side::Left), + "right" => Ok(Side::Right), + "top" => Ok(Side::Top), + "bottom" => Ok(Side::Bottom), + "center" | "middle" => Ok(Side::Center), + _ => Err(not_a_choice("side", word, registry::SIDES)), + } +} + +fn export_format(word: &str) -> Result<ExportFormat, CallError> { + match word.to_ascii_lowercase().as_str() { + "md" | "markdown" => Ok(ExportFormat::Markdown), + "txt" | "text" => Ok(ExportFormat::Text), + _ => Err(not_a_choice("format", word, registry::FORMATS)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sources() -> Sources { + let names = |list: &[&str]| list.iter().map(|name| name.to_string()).collect(); + Sources { + documents: names(&["Garden plans", "Kitchen", "Reading list"]), + nodes: vec![ + (NodeId::new(1), "Garden plans".into()), + (NodeId::new(2), "Raised beds".into()), + (NodeId::new(3), "Rain barrel".into()), + ], + colors: names(&["red", "orange", "teal"]), + styles: names(&["question"]), + themes: names(&["default", "light"]), + settings: names(&["autosave", "mouse"]), + tags: names(&["todo"]), + folder: PathBuf::from("."), + } + } + + fn call(line: &str) -> Result<Call, CallError> { + parse(line, &sources()) + } + + #[test] + fn names_bangs_and_arguments() { + assert_eq!(call("q"), Ok(Call::Quit { force: false })); + assert_eq!(call(":qa!"), Ok(Call::QuitAll { force: true })); + assert_eq!(call("x"), Ok(Call::WriteQuit)); + assert_eq!( + call("e notes/garden.cum"), + Ok(Call::Edit { path: "notes/garden.cum".into(), force: false }) + ); + assert_eq!(call("vs outline"), Ok(Call::VSplit(Some(ViewKind::Outline)))); + assert_eq!(call("in buy a rain barrel"), Ok(Call::Capture("buy a rain barrel".into()))); + assert_eq!(call("fold auto"), Ok(Call::Fold(Fold::Auto))); + assert_eq!(call("tidy"), Ok(Call::Tidy(Layout::Map))); + assert_eq!(call("color none"), Ok(Call::Color(None))); + assert_eq!( + call("export md out.md"), + Ok(Call::Export { format: ExportFormat::Markdown, path: Some("out.md".into()) }) + ); + } + + #[test] + fn names_are_matched_fuzzily_where_it_helps() { + assert_eq!(call("b gpl"), Ok(Call::Buffer("Garden plans".into()))); + assert_eq!(call("link rbar"), Ok(Call::Link(NodeId::new(3)))); + assert_eq!(call("link raised beds"), Ok(Call::Link(NodeId::new(2))), "an exact title wins"); + let changes = call("tag +idea -todo later").unwrap(); + let tag = |name| Tag::parse(name).unwrap(); + assert_eq!( + changes, + Call::Tags(vec![ + TagChange::Add(tag("idea")), + TagChange::Remove(tag("todo")), + TagChange::Add(tag("later")) + ]) + ); + assert_eq!(call("set nomouse"), Ok(Call::Set(SetArg::Off("mouse".into())))); + assert_eq!(call("set autosave?"), Ok(Call::Set(SetArg::Show("autosave".into())))); + } + + #[test] + fn mistakes_say_what_to_do() { + let message = |line: &str| call(line).unwrap_err().to_string(); + assert_eq!(message(""), "type a command, like :help"); + assert_eq!(message("folx open"), "there's no command :folx; did you mean :fold?"); + assert_eq!(message("w!"), ":write doesn't take a !"); + assert_eq!(message("e"), ":e[dit][!] <file>: the file is missing"); + assert_eq!(message("fold up"), "\"up\" isn't a way to fold: use open, auto, closed"); + assert_eq!(message("fold open now"), ":fo[ld] <open|auto|closed>: \"now\" is more than it takes"); + assert_eq!(message("color corla"), "\"corla\" isn't a color: use red, orange, teal"); + assert_eq!(message("b xyz"), "there's no open document like \"xyz\""); + assert_eq!(message("set colour=1"), "there's no setting like \"colour\""); + } + + #[test] + fn every_choice_word_is_understood() { + let check = |choices: &[Choice], read: &dyn Fn(&str) -> bool| { + for choice in choices { + assert!(read(choice.word), "{}", choice.word); + } + }; + check(registry::VIEWS, &|word| view(word).is_ok()); + check(registry::BORDERS, &|word| border(word).is_ok()); + check(registry::FOLDS, &|word| fold(word).is_ok()); + check(registry::LAYOUTS, &|word| layout(word).is_ok()); + check(registry::SIDES, &|word| side(word).is_ok()); + check(registry::FORMATS, &|word| export_format(word).is_ok()); + check(registry::BOARD_COLUMNS, &|word| board_columns(word).is_ok()); + } + + #[test] + fn every_command_can_be_typed() { + let samples = [ + ("edit", "x.cum"), + ("buffer", "kitchen"), + ("rename", "New name"), + ("in", "an idea"), + ("link", "rain"), + ("portal", "x.cum"), + ("tag", "+x"), + ("color", "red"), + ("style", "question"), + ("border", "thick"), + ("fold", "open"), + ("align", "left"), + ("find", "beds"), + ("set", "mouse"), + ("theme", "light"), + ("export", "md"), + ]; + for spec in registry::SPECS { + let argument = + samples.iter().find(|(name, _)| *name == spec.name).map_or("", |(_, argument)| argument); + let line = format!("{} {argument}", spec.name); + assert!(call(&line).is_ok(), ":{line} → {:?}", call(&line)); + } + } +} diff --git a/src/command/registry.rs b/src/command/registry.rs new file mode 100644 index 0000000..b65bb39 --- /dev/null +++ b/src/command/registry.rs @@ -0,0 +1,371 @@ +//! Every command of the command line: its name, how short it can be typed, +//! its arguments and what it does. Parsing, completion and the help all read +//! this one table. + +/// Which command a spec describes, so code matches on this (and the compiler +/// checks every command is handled) instead of on names. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CommandId { + Edit, + New, + Write, + WriteAll, + Quit, + QuitAll, + WriteQuit, + Buffers, + Buffer, + CloseDocument, + Rename, + Split, + VSplit, + Only, + ClosePane, + Canvas, + Outline, + Note, + Board, + Capture, + Link, + Unlink, + Portal, + BranchOut, + Tags, + Color, + Style, + Border, + Fold, + Focus, + Tidy, + Align, + Find, + Backlinks, + Jumps, + Marks, + Registers, + Set, + Config, + Help, + Version, + Theme, + Export, +} + +/// A word an argument can be, and what it means. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Choice { + pub word: &'static str, + pub help: &'static str, +} + +/// What kind of value an argument takes: it decides how the argument is read +/// and what completion offers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArgKind { + /// One of a few words. + Choice(&'static [Choice]), + /// A path to a file, completed from the folders on disk. + File, + /// An open document, by name. + Document, + /// A node of this document, by title. + Node, + /// A color from the palette, or `none`. + Color, + /// A named style from the theme. + Style, + /// A theme, by name. + Theme, + /// Tags to add (`+idea`, or just `idea`) and remove (`-todo`). + Tags, + /// A setting, vim style: `name=value`, `name?`, `name&`, `noname`, `name!`. + Setting, + /// A help topic: a command's name. + Topic, + /// Any text: the rest of the line. + Text, +} + +impl ArgKind { + /// Whether the argument is the rest of the line (spaces included), + /// rather than one word. + pub fn takes_the_rest(self) -> bool { + matches!( + self, + ArgKind::File + | ArgKind::Document + | ArgKind::Node + | ArgKind::Text + | ArgKind::Tags + | ArgKind::Setting + ) + } +} + +/// One argument of a command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Arg { + pub name: &'static str, + pub kind: ArgKind, + pub required: bool, +} + +/// A command of the command line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Spec { + pub id: CommandId, + /// The full name: `edit`. + pub name: &'static str, + /// The shortest way to type it, as in vim: `e` for `:e[dit]`. Anything + /// from this to the full name works. + pub short: &'static str, + /// Other names, typed in full: `x` for `wq`. + pub aliases: &'static [&'static str], + /// Whether it takes a `!`, like `:q!`. + pub bang: bool, + pub args: &'static [Arg], + pub help: &'static str, +} + +impl Spec { + /// Whether `typed` names this command. + pub fn is_named(&self, typed: &str) -> bool { + (self.name.starts_with(typed) && typed.starts_with(self.short)) || self.aliases.contains(&typed) + } + + /// How to type it, for errors and the help: `:e[dit][!] <file>`. + pub fn usage(&self) -> String { + let rest = &self.name[self.short.len()..]; + let mut usage = format!(":{}", self.short); + if !rest.is_empty() { + usage.push_str(&format!("[{rest}]")); + } + if self.bang { + usage.push_str("[!]"); + } + for arg in self.args { + let name = match arg.kind { + ArgKind::Choice(choices) => { + choices.iter().map(|choice| choice.word).collect::<Vec<_>>().join("|") + } + _ => arg.name.to_string(), + }; + usage.push_str(&if arg.required { format!(" <{name}>") } else { format!(" [{name}]") }); + } + usage + } +} + +/// The command a typed name stands for. +pub fn find(typed: &str) -> Option<&'static Spec> { + SPECS.iter().find(|spec| spec.is_named(typed)) +} + +const fn choice(word: &'static str, help: &'static str) -> Choice { + Choice { word, help } +} + +pub const VIEWS: &[Choice] = &[ + choice("canvas", "the 2D map"), + choice("outline", "the indented tree"), + choice("note", "one node, to write in"), + choice("board", "columns by tag or color"), +]; + +pub const BORDERS: &[Choice] = &[ + choice("rounded", "╭─╮"), + choice("square", "┌─┐"), + choice("double", "╔═╗"), + choice("thick", "┏━┓"), + choice("dashed", "┌╌┐"), + choice("hidden", "no border"), +]; + +pub const FOLDS: &[Choice] = &[ + choice("open", "always shows its children"), + choice("auto", "shows them while you're inside"), + choice("closed", "hides them"), +]; + +pub const LAYOUTS: &[Choice] = &[ + choice("map", "a mind map, growing to the right"), + choice("chart", "an org chart, growing down"), + choice("grid", "a grid, for idea dumps"), +]; + +pub const SIDES: &[Choice] = &[ + choice("left", "left edges in a line"), + choice("right", "right edges in a line"), + choice("top", "top edges in a line"), + choice("bottom", "bottom edges in a line"), + choice("center", "centers in a column"), +]; + +pub const FORMATS: &[Choice] = &[choice("md", "Markdown"), choice("txt", "plain text")]; + +pub const BOARD_COLUMNS: &[Choice] = + &[choice("tag", "a column per tag"), choice("color", "a column per color")]; + +const fn required(name: &'static str, kind: ArgKind) -> Arg { + Arg { name, kind, required: true } +} + +const fn optional(name: &'static str, kind: ArgKind) -> Arg { + Arg { name, kind, required: false } +} + +const fn spec( + id: CommandId, + name: &'static str, + short: &'static str, + args: &'static [Arg], + help: &'static str, +) -> Spec { + Spec { id, name, short, aliases: &[], bang: false, args, help } +} + +const fn with_bang(spec: Spec) -> Spec { + Spec { bang: true, ..spec } +} + +const fn with_aliases(aliases: &'static [&'static str], spec: Spec) -> Spec { + Spec { aliases, ..spec } +} + +use ArgKind as K; +use CommandId as Id; + +/// Every command, in the order the help lists them. +pub const SPECS: &[Spec] = &[ + // Files and documents. + with_bang(spec( + Id::Edit, + "edit", + "e", + &[required("file", K::File)], + "open a document, or make it (! reloads it)", + )), + spec(Id::New, "new", "new", &[optional("title", K::Text)], "start a new document next to this one"), + spec(Id::Write, "write", "w", &[], "save now (cumulus saves by itself, too)"), + spec(Id::WriteAll, "wall", "wa", &[], "save every document"), + with_bang(spec( + Id::Quit, + "quit", + "q", + &[], + "close the pane; the last one quits (! drops unsaved changes)", + )), + with_bang(with_aliases( + &["quitall"], + spec(Id::QuitAll, "qall", "qa", &[], "quit cumulus (! drops unsaved changes)"), + )), + with_aliases(&["x", "xit"], spec(Id::WriteQuit, "wq", "wq", &[], "save, then close the pane")), + with_aliases(&["ls", "files"], spec(Id::Buffers, "buffers", "buffers", &[], "list the open documents")), + spec(Id::Buffer, "buffer", "b", &[required("document", K::Document)], "switch to an open document"), + with_bang(spec(Id::CloseDocument, "bdelete", "bd", &[], "close this document (! drops unsaved changes)")), + spec( + Id::Rename, + "rename", + "ren", + &[required("title", K::Text)], + "rename the document (its root's title)", + ), + // Panes and views. + spec(Id::Split, "split", "sp", &[optional("view", K::Choice(VIEWS))], "split the pane, above and below"), + spec(Id::VSplit, "vsplit", "vs", &[optional("view", K::Choice(VIEWS))], "split the pane, side by side"), + spec(Id::Only, "only", "on", &[], "close the other panes"), + spec(Id::ClosePane, "close", "clo", &[], "close this pane"), + spec(Id::Canvas, "canvas", "can", &[], "show the canvas"), + spec(Id::Outline, "outline", "out", &[], "show the outline"), + spec(Id::Note, "note", "not", &[], "show the Note view"), + spec(Id::Board, "board", "boa", &[optional("columns", K::Choice(BOARD_COLUMNS))], "show the board"), + // Nodes. + spec(Id::Capture, "in", "in", &[required("idea", K::Text)], "capture an idea to the inbox"), + spec(Id::Link, "link", "li", &[required("node", K::Node)], "link this node to another one"), + spec(Id::Unlink, "unlink", "unl", &[optional("node", K::Node)], "remove a link (or all of this node's)"), + spec(Id::Portal, "portal", "por", &[required("file", K::File)], "add a portal to another document"), + spec(Id::BranchOut, "branchout", "bran", &[], "move this branch into a document of its own"), + with_aliases( + &["tags"], + spec(Id::Tags, "tag", "ta", &[required("tags", K::Tags)], "add and remove tags: +idea -todo"), + ), + with_aliases( + &["colour"], + spec(Id::Color, "color", "col", &[required("color", K::Color)], "color this node (none: no color)"), + ), + spec(Id::Style, "style", "sty", &[required("style", K::Style)], "give this node a named style"), + spec(Id::Border, "border", "bor", &[required("border", K::Choice(BORDERS))], "this node's border"), + spec(Id::Fold, "fold", "fo", &[required("fold", K::Choice(FOLDS))], "how this branch folds"), + spec(Id::Focus, "focus", "foc", &[], "focus on or off: fold the other branches"), + spec(Id::Tidy, "tidy", "tid", &[optional("layout", K::Choice(LAYOUTS))], "arrange this branch"), + spec(Id::Align, "align", "al", &[required("side", K::Choice(SIDES))], "line up the selected nodes"), + // Finding. + spec(Id::Find, "find", "fin", &[required("text", K::Text)], "search titles, bodies and #tags"), + spec(Id::Backlinks, "backlinks", "back", &[], "the portals that lead here"), + spec(Id::Jumps, "jumps", "ju", &[], "the jump list"), + spec(Id::Marks, "marks", "marks", &[], "the marks"), + with_aliases(&["display"], spec(Id::Registers, "registers", "reg", &[], "what the registers hold")), + // Settings and help. + spec( + Id::Set, + "set", + "se", + &[required("setting", K::Setting)], + "change a setting: name=value, name?, noname", + ), + spec(Id::Config, "config", "conf", &[], "open the config files"), + spec(Id::Help, "help", "h", &[optional("topic", K::Topic)], "every key and command"), + spec(Id::Version, "version", "ve", &[], "which cumulus this is"), + with_aliases( + &["colorscheme"], + spec(Id::Theme, "theme", "them", &[required("theme", K::Theme)], "switch the theme"), + ), + // Export. + spec( + Id::Export, + "export", + "exp", + &[required("format", K::Choice(FORMATS)), optional("file", K::File)], + "export the document", + ), +]; + +#[cfg(test)] +mod tests { + use super::*; + + /// Every way to type a command (its short form up to its full name, and + /// its aliases) names that command and no other. + #[test] + fn every_name_is_unambiguous() { + for spec in SPECS { + let mut ways: Vec<&str> = + (spec.short.len()..=spec.name.len()).map(|end| &spec.name[..end]).collect(); + ways.extend(spec.aliases); + for typed in ways { + let named: Vec<&str> = + SPECS.iter().filter(|other| other.is_named(typed)).map(|s| s.name).collect(); + assert_eq!(named, [spec.name], ":{typed}"); + } + } + } + + #[test] + fn short_forms_work_like_vim() { + assert_eq!(find("e").map(|spec| spec.id), Some(CommandId::Edit)); + assert_eq!(find("edi").map(|spec| spec.id), Some(CommandId::Edit)); + assert_eq!(find("x").map(|spec| spec.id), Some(CommandId::WriteQuit)); + assert_eq!(find("colorscheme").map(|spec| spec.id), Some(CommandId::Theme)); + assert_eq!(find("colo").map(|spec| spec.id), Some(CommandId::Color)); + assert_eq!(find("s"), None, "too short to tell split, set and style apart"); + assert_eq!(find("edits"), None); + } + + #[test] + fn usage_shows_how_to_type_it() { + assert_eq!(find("e").unwrap().usage(), ":e[dit][!] <file>"); + assert_eq!(find("fold").unwrap().usage(), ":fo[ld] <open|auto|closed>"); + assert_eq!(find("export").unwrap().usage(), ":exp[ort] <md|txt> [file]"); + assert_eq!(find("in").unwrap().usage(), ":in <idea>"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 46d6959..70948c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,10 +8,12 @@ //! |-------------|--------------------------------------------------------------| //! | [`model`] | the data and its rules: documents, nodes, links, undo, folds | //! | [`input`] | keys and the mouse: vim keys, which-key, keys.toml | +//! | [`command`] | the `:` command line: commands, parsing, completion, history | //! | [`storage`] | files: documents (RON) | //! | [`view`] | the views, one module each: so far, the canvas | -//! | [`ui`] | what views share: glyphs, the theme, the which-key popup | +//! | [`ui`] | what views share: glyphs, theme, which-key, the command line | +pub mod command; pub mod input; pub mod model; pub mod storage; diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs new file mode 100644 index 0000000..0541e93 --- /dev/null +++ b/src/ui/command_line.rs @@ -0,0 +1,326 @@ +//! The command line at the bottom of the screen, opened with `:`: the line +//! being typed, a completion popup above it, ghost text after the cursor, +//! and Up and Down through the history. +//! +//! ```text +//! ╭─────────────────────────────────╮ +//! │ fold how this branch folds │ +//! │ focus focus on or off: fold … │ +//! ╰─────────────────────────────────╯ +//! :fo▏ld +//! ``` +//! +//! Tab and Shift-Tab put the candidates in the line one by one; → (or +//! `Ctrl-e`, End) at the end of the line takes the ghost text; Enter runs +//! the line; Esc closes it. + +use ratatui::Frame; +use ratatui::layout::{Position, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Clear, Paragraph}; +use unicode_width::UnicodeWidthStr; + +use super::line_input::LineInput; +use super::theme::Theme; +use crate::command::complete::{Completion, complete}; +use crate::command::history::History; +use crate::command::parse::Sources; +use crate::input::key::{Key, KeyName}; + +/// How many candidates the popup shows at once. +const POPUP_ROWS: usize = 8; + +/// The command line while it's open. +#[derive(Debug, Clone, Default)] +pub struct CommandLine { + input: LineInput, + completion: Completion, + /// The candidate Tab put in the line, while Tab cycles through them. + selected: Option<usize>, + /// While browsing the history: what was typed first (only lines starting + /// with it show), and which line is in the input. + browsing: Option<(String, usize)>, + /// What the ghost text would add after the cursor. + ghost: Option<String>, +} + +/// What a key in the command line leads to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LineOutcome { + /// Still typing. + Typing, + /// Enter: run this line. + Run(String), + /// Esc, or Backspace on an empty line: close without running anything. + Close, +} + +impl CommandLine { + /// An empty command line. + pub fn new(sources: &Sources, history: &History) -> CommandLine { + let mut line = CommandLine::default(); + line.changed(sources, history); + line + } + + /// What's typed so far. + pub fn text(&self) -> &str { + self.input.text() + } + + /// Handles a key. + pub fn key(&mut self, key: Key, sources: &Sources, history: &History) -> LineOutcome { + if key.is_escape() { + return LineOutcome::Close; + } + match (key.name, key.ctrl) { + (KeyName::Enter, _) => return LineOutcome::Run(self.input.text().to_string()), + (KeyName::Backspace, _) if self.input.text().is_empty() => return LineOutcome::Close, + (KeyName::Tab, false) => self.cycle(true), + (KeyName::BackTab, _) => self.cycle(false), + (KeyName::Up, _) => self.browse(true, sources, history), + (KeyName::Down, _) => self.browse(false, sources, history), + (KeyName::Right | KeyName::End, _) | (KeyName::Char('e'), true) + if self.input.at_end() && self.ghost.is_some() => + { + if let Some(ghost) = self.ghost.take() { + self.input.insert(&ghost); + self.changed(sources, history); + } + } + _ => { + if self.input.edit(key) { + self.changed(sources, history); + } + } + } + LineOutcome::Typing + } + + /// After the text changed by typing: new candidates and ghost text, and + /// any cycling or browsing stops. + fn changed(&mut self, sources: &Sources, history: &History) { + self.selected = None; + self.browsing = None; + self.refresh(sources, history); + } + + fn refresh(&mut self, sources: &Sources, history: &History) { + let text = self.input.text(); + self.completion = complete(text, sources); + self.ghost = if self.input.at_end() && !text.is_empty() { + match history.suggestion(text) { + Some(line) => Some(line[text.len()..].to_string()), + None => self.completion.ghost(text).map(str::to_string), + } + } else { + None + }; + } + + /// Tab: puts the next candidate (or the previous one, for Shift-Tab) in + /// place of the word being completed. + fn cycle(&mut self, forward: bool) { + let count = self.completion.candidates.len(); + if count == 0 { + return; + } + let next = match (self.selected, forward) { + (None, true) => 0, + (None, false) => count - 1, + (Some(index), true) => (index + 1) % count, + (Some(index), false) => (index + count - 1) % count, + }; + let text = &self.completion.candidates[next].text; + self.input.replace(self.completion.start, self.input.text().len(), text); + self.selected = Some(next); + self.ghost = None; + } + + /// Up and Down: older and newer lines that start with what was typed. + fn browse(&mut self, older: bool, sources: &Sources, history: &History) { + let (typed, at) = match &self.browsing { + Some((typed, at)) => (typed.clone(), Some(*at)), + None => (self.input.text().to_string(), None), + }; + let found = match (older, at) { + (true, _) => history.older(&typed, at), + (false, Some(at)) => history.newer(&typed, at), + (false, None) => None, + }; + match (found.and_then(|index| Some((index, history.get(index)?))), older) { + (Some((index, line)), _) => { + self.input.set(line); + self.refresh(sources, history); + self.selected = None; + self.browsing = Some((typed, index)); + } + // Down past the newest line: back to what was typed. + (None, false) if at.is_some() => { + self.input.set(&typed); + self.changed(sources, history); + } + (None, _) => {} + } + } + + /// Draws the line in `line` (one row), and the popup in `above` (right + /// above it). Places the terminal's cursor in the line. + pub fn render(&self, frame: &mut Frame, line: Rect, above: Rect, theme: &Theme) { + let prompt = Span::styled(":", Style::new().fg(theme.accent).add_modifier(Modifier::BOLD)); + let mut spans = vec![prompt, Span::raw(self.input.text())]; + if let Some(ghost) = &self.ghost { + spans.push(Span::styled(ghost.as_str(), Style::new().fg(theme.muted))); + } + frame.render_widget(Clear, line); + frame.render_widget(Paragraph::new(Line::from(spans)), line); + let column = u16::try_from(1 + self.input.cursor_column()).unwrap_or(u16::MAX); + frame.set_cursor_position(Position::new( + line.x.saturating_add(column).min(line.right().saturating_sub(1)), + line.y, + )); + self.render_popup(frame, line, above, theme); + } + + /// The candidates, in a box above the word they'd finish. + fn render_popup(&self, frame: &mut Frame, line: Rect, above: Rect, theme: &Theme) { + let candidates = &self.completion.candidates; + let wanted = !self.input.text().is_empty() || self.selected.is_some(); + if candidates.is_empty() || !wanted || above.height < 3 { + return; + } + let rows = candidates.len().min(POPUP_ROWS).min(usize::from(above.height - 2)); + // Scroll so the candidate Tab picked stays in view. + let first = self.selected.map_or(0, |selected| selected.saturating_sub(rows - 1)); + let shown = &candidates[first..(first + rows).min(candidates.len())]; + let text_width = shown.iter().map(|candidate| candidate.text.width()).max().unwrap_or(0); + let help_width = shown.iter().map(|candidate| candidate.help.width()).max().unwrap_or(0); + let inner = text_width + if help_width > 0 { help_width + 2 } else { 0 } + 2; + let width = u16::try_from(inner + 2).unwrap_or(u16::MAX).min(above.width); + let height = u16::try_from(rows + 2).unwrap_or(u16::MAX); + // Line the candidates up with the word they finish. + let word_column = 1 + self.input.text().get(..self.completion.start).unwrap_or("").width(); + let x = (line.x + u16::try_from(word_column).unwrap_or(0)).saturating_sub(2); + let rect = Rect { + x: x.max(above.x).min(above.right().saturating_sub(width)), + y: above.bottom().saturating_sub(height), + width, + height, + }; + + let lines: Vec<Line> = shown + .iter() + .enumerate() + .map(|(row, candidate)| { + let picked = self.selected == Some(first + row); + let (text_style, help_style) = if picked { + let style = Style::new().fg(Color::Black).bg(theme.accent); + (style, style) + } else { + (Style::new(), Style::new().fg(theme.muted)) + }; + let mut spans = vec![Span::styled(format!(" {:<text_width$} ", candidate.text), text_style)]; + if help_width > 0 { + spans.push(Span::styled(format!(" {:<help_width$} ", candidate.help), help_style)); + } + Line::from(spans) + }) + .collect(); + let block = + Block::bordered().border_type(BorderType::Rounded).border_style(Style::new().fg(theme.muted)); + frame.render_widget(Clear, rect); + frame.render_widget(Paragraph::new(lines).block(block), rect); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::key::parse_keys; + + fn sources() -> Sources { + Sources { colors: vec!["red".into(), "orange".into()], ..Sources::default() } + } + + #[test] + fn the_popup_sits_above_the_word_it_finishes() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let history = History::default(); + let mut line = CommandLine::new(&sources(), &history); + press(&mut line, "fold a", &history); + let mut terminal = Terminal::new(TestBackend::new(60, 8)).unwrap(); + terminal + .draw(|frame| { + let area = frame.area(); + let bottom = Rect { y: area.bottom() - 1, height: 1, ..area }; + let above = Rect { height: area.height - 1, ..area }; + line.render(frame, bottom, above, &Theme::default()); + }) + .unwrap(); + let buffer = terminal.backend().buffer().clone(); + let row = |y: u16| (0..60).map(|x| buffer[(x, y)].symbol().to_string()).collect::<String>(); + assert_eq!(row(4).trim_end(), " ╭──────────────────────────────────────╮"); + assert_eq!(row(5).trim_end(), " │ auto shows them while you're inside │"); + assert_eq!(row(7).trim_end(), ":fold auto", "\"uto\" is the ghost text"); + assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(7, 7)); + } + + fn press(line: &mut CommandLine, keys: &str, history: &History) -> LineOutcome { + let mut outcome = LineOutcome::Typing; + for key in parse_keys(keys, Key::char(' ')).unwrap() { + outcome = line.key(key, &sources(), history); + } + outcome + } + + #[test] + fn tab_cycles_through_the_candidates() { + let history = History::default(); + let mut line = CommandLine::new(&sources(), &history); + press(&mut line, "fo", &history); + press(&mut line, "<Tab>", &history); + assert_eq!(line.text(), "fold"); + press(&mut line, "<Tab>", &history); + assert_eq!(line.text(), "focus"); + press(&mut line, "<S-Tab>", &history); + assert_eq!(line.text(), "fold"); + assert_eq!(press(&mut line, " a<Tab><CR>", &history), LineOutcome::Run("fold auto".into())); + } + + #[test] + fn ghost_text_comes_from_history_first() { + let history = History::from_lines(vec!["color orange".into()]); + let mut line = CommandLine::new(&sources(), &history); + press(&mut line, "color o", &history); + assert_eq!(line.ghost.as_deref(), Some("range")); + press(&mut line, "<Right>", &history); + assert_eq!(line.text(), "color orange"); + let mut fresh = CommandLine::new(&sources(), &History::default()); + press(&mut fresh, "col", &History::default()); + assert_eq!(fresh.ghost.as_deref(), Some("or"), "no history: the best candidate"); + } + + #[test] + fn up_and_down_browse_matching_lines() { + let history = History::from_lines(vec!["fold open".into(), "q".into(), "fold auto".into()]); + let mut line = CommandLine::new(&sources(), &history); + press(&mut line, "fo<Up>", &history); + assert_eq!(line.text(), "fold auto"); + press(&mut line, "<Up>", &history); + assert_eq!(line.text(), "fold open"); + press(&mut line, "<Down><Down>", &history); + assert_eq!(line.text(), "fo", "past the newest: back to what was typed"); + assert_eq!(press(&mut line, "<Esc>", &history), LineOutcome::Close); + } + + #[test] + fn backspace_on_an_empty_line_closes_it() { + let history = History::default(); + let mut line = CommandLine::new(&sources(), &history); + assert_eq!(press(&mut line, "q<BS>", &history), LineOutcome::Typing); + assert_eq!(press(&mut line, "<BS>", &history), LineOutcome::Close); + } +} diff --git a/src/ui/line_input.rs b/src/ui/line_input.rs new file mode 100644 index 0000000..5a97592 --- /dev/null +++ b/src/ui/line_input.rs @@ -0,0 +1,159 @@ +//! One line of text being typed, with its cursor: the command line now, and +//! titles later. The cursor moves a whole character at a time, so an +//! accented letter or an emoji made of several code points is one step. + +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use crate::input::key::{Key, KeyName}; + +/// A line of text and a cursor in it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LineInput { + text: String, + /// A byte offset in `text`, always between two characters. + cursor: usize, +} + +impl LineInput { + /// A line holding `text`, with the cursor at its end. + pub fn new(text: &str) -> LineInput { + LineInput { text: text.to_string(), cursor: text.len() } + } + + pub fn text(&self) -> &str { + &self.text + } + + /// Where the cursor is: a byte offset in the text. + pub fn cursor(&self) -> usize { + self.cursor + } + + /// Whether the cursor is after the last character. + pub fn at_end(&self) -> bool { + self.cursor == self.text.len() + } + + /// The screen column of the cursor: how wide the text before it is. + pub fn cursor_column(&self) -> usize { + self.text[..self.cursor].width() + } + + /// Replaces the text, and puts the cursor at its end. + pub fn set(&mut self, text: &str) { + *self = LineInput::new(text); + } + + /// Replaces the bytes `from..to` with `text`, leaving the cursor after it. + pub fn replace(&mut self, from: usize, to: usize, text: &str) { + self.text.replace_range(from..to, text); + self.cursor = from + text.len(); + } + + /// Types text at the cursor. + pub fn insert(&mut self, text: &str) { + self.text.insert_str(self.cursor, text); + self.cursor += text.len(); + } + + /// Edits the line with one of the keys every text field shares. Returns + /// false for keys it doesn't use, for the caller to handle. + /// + /// | Key | What it does | + /// |----------------------|--------------------------------------| + /// | a character | types it | + /// | Backspace, Del | deletes before / after the cursor | + /// | ← →, Home, End | move the cursor | + /// | `Ctrl-b`, `Ctrl-e` | to the start / the end (as in vim's command line) | + /// | `Ctrl-w` | deletes the word before the cursor | + /// | `Ctrl-u` | deletes everything before the cursor | + pub fn edit(&mut self, key: Key) -> bool { + match (key.name, key.ctrl) { + (KeyName::Char(c), false) if !key.alt => self.insert(c.encode_utf8(&mut [0; 4])), + (KeyName::Backspace, _) => { + let start = self.previous_boundary(); + self.text.replace_range(start..self.cursor, ""); + self.cursor = start; + } + (KeyName::Delete, _) => { + let end = self.next_boundary(); + self.text.replace_range(self.cursor..end, ""); + } + (KeyName::Left, _) => self.cursor = self.previous_boundary(), + (KeyName::Right, _) => self.cursor = self.next_boundary(), + (KeyName::Home, _) | (KeyName::Char('b'), true) => self.cursor = 0, + (KeyName::End, _) | (KeyName::Char('e'), true) => self.cursor = self.text.len(), + (KeyName::Char('w'), true) => { + let start = self.word_start_before(); + self.text.replace_range(start..self.cursor, ""); + self.cursor = start; + } + (KeyName::Char('u'), true) => { + self.text.replace_range(..self.cursor, ""); + self.cursor = 0; + } + _ => return false, + } + true + } + + /// Where the character before the cursor starts. + fn previous_boundary(&self) -> usize { + self.text[..self.cursor].grapheme_indices(true).next_back().map_or(0, |(start, _)| start) + } + + /// Where the character after the cursor ends. + fn next_boundary(&self) -> usize { + self.text[self.cursor..].graphemes(true).next().map_or(self.cursor, |next| self.cursor + next.len()) + } + + /// Where `Ctrl-w` deletes back to, as in vim: past any spaces, then a run + /// of letters and digits, or else a run of other signs (so `notes/` goes + /// in two steps). + fn word_start_before(&self) -> usize { + let before = &self.text[..self.cursor]; + let end = before.trim_end().len(); + let is_word = |c: char| c.is_alphanumeric() || c == '_'; + let last_is_word = before[..end].chars().next_back().is_some_and(is_word); + before[..end] + .char_indices() + .rev() + .take_while(|&(_, c)| !c.is_whitespace() && is_word(c) == last_is_word) + .last() + .map_or(end, |(start, _)| start) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::key::parse_keys; + + fn typed(start: &str, keys: &str) -> LineInput { + let mut line = LineInput::new(start); + for key in parse_keys(keys, Key::char(' ')).unwrap() { + line.edit(key); + } + line + } + + #[test] + fn typing_and_moving() { + let line = typed("", "e garden<Home><Right><Del>x<End>!"); + assert_eq!(line.text(), "exgarden!"); + assert_eq!(line.cursor(), line.text().len()); + assert_eq!(typed("café", "<BS>e").text(), "cafe", "é is one character"); + let flag = typed("ok 👍🏽", "<Left><Left>"); + assert_eq!(flag.cursor(), 2, "an emoji with a skin tone is one step"); + assert_eq!(flag.cursor_column(), 2); + } + + #[test] + fn deleting_words_and_lines() { + assert_eq!(typed("e notes/garden", "<C-w>").text(), "e notes/"); + assert_eq!(typed("e notes/", "<C-w>").text(), "e notes"); + assert_eq!(typed("fold open ", "<C-w>").text(), "fold "); + assert_eq!(typed("fold open", "<Left><Left><C-u>").text(), "en"); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 040b1e9..7c08222 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,7 +1,11 @@ //! What the views share: the characters they draw with ([`glyphs`]), the -//! colors and looks ([`theme`]) and the which-key popup ([`which_key`]). The -//! command line, the bars and the other shared widgets come in later stages. +//! colors and looks ([`theme`]), the which-key popup ([`which_key`]), and the +//! command line ([`command_line`]) with the line input it types into +//! ([`line_input`]). The bars and the other shared widgets come in later +//! stages. +pub mod command_line; pub mod glyphs; +pub mod line_input; pub mod theme; pub mod which_key; diff --git a/src/view/mod.rs b/src/view/mod.rs index d2c1c9a..40ebf77 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -2,3 +2,30 @@ //! all share; for now there's the canvas. pub mod canvas; + +/// The kinds of view a pane can show. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ViewKind { + /// The 2D map. + Canvas, + /// The tree as an indented list. + Outline, + /// One node at full size, to write in. + Note, + /// Columns by tag or by color. + Board, +} + +impl ViewKind { + pub const ALL: [ViewKind; 4] = [ViewKind::Canvas, ViewKind::Outline, ViewKind::Note, ViewKind::Board]; + + /// The view's name, as the status bar and the command line say it. + pub fn name(self) -> &'static str { + match self { + ViewKind::Canvas => "canvas", + ViewKind::Outline => "outline", + ViewKind::Note => "note", + ViewKind::Board => "board", + } + } +}