diff --git a/README.md b/README.md index 52b87ea..5f2c688 100644 --- a/README.md +++ b/README.md @@ -44,17 +44,21 @@ lim is that editor with all of it **built in**: of Lerna: one body, many heads, like one editor working many windows. It greets you, in braille, on the start screen. -![Editing: file tree, git signs, indent guides, TODO and color highlights](docs/screenshots/editor.png) +![Editing: file tree, indent guides, TODO, URL and color highlights](docs/screenshots/editor.png) -| Start screen | Find files | +| **Start screen** (the hydra and nvdash's buttons) | **Find files** (with preview) | |---|---| | ![Start screen with the hydra](docs/screenshots/dashboard.png) | ![File picker with preview](docs/screenshots/picker.png) | -| **Grep** | **which-key over two splits** | +| **Live grep** | **which-key** (``) | | ![Live grep](docs/screenshots/grep.png) | ![which-key menu](docs/screenshots/whichkey.png) | -| **Cheat sheet** (``) | **Theme switcher** (`ft`, live preview) | +| **Cheat sheet** (``) | **Theme switcher** (`ft`, live on a code sample) | | ![Cheat sheet](docs/screenshots/cheatsheet.png) | ![Theme picker](docs/screenshots/themes.png) | -| **Completion** (words from open buffers) | **Command-line completion** (tabs aligned with the windows) | +| **Completion** (words from open buffers) | **Command-line completion** | | ![Completion menu](docs/screenshots/completion.png) | ![Command-line completion](docs/screenshots/cmdline.png) | +| **Tools** (`xc`, a popup that keeps its history) | **Visual line selection** | +| ![Calculator popup](docs/screenshots/tools.png) | ![Visual line selection](docs/screenshots/visual.png) | +| **Settings** (`,`, every option, searchable) | | +| ![Settings](docs/screenshots/settings.png) | | ## Features @@ -63,7 +67,7 @@ the start screen. | Vim editing | Normal, insert, replace, visual, visual-line and visual-block modes · counts · operators `d c y > < gu gU g~ gc` over any motion or text object · `.` · macros · undo/redo (1000 levels) · named registers (`"a`, `"A` append, `"0`, `"1`–`"9` shifting, `"-`, `"_`, `"+`) · marks and jump list that follow edits · Vim regexes in `/`, `?`, `*`, `:s`, `:g` · `:g`/`:v`, `:norm`, `:sort`, `:m`, `:t`, `:r` · line ranges with offsets | | Motions | `h j k l w b e ge W B E gE 0 ^ $ g_ { } % gg G H M L f F t T ; , n N * #`, `gn` (`cgn` then `.`), page and line scrolling, `` (word / page), `zz zt zb` · `:` and `/` history | | Text objects | `iw` `aw` `iW` `aW` `ip` `ap` `it` `at` (HTML/XML tags) `i(` `a(` `i[` `a[` `i{` `a{` `i<` `a<` `i"` `a"` `i'` `a'` `` i` `` `` a` `` | -| AstroNvim look | 30 colorschemes (NvChad's onedark by default) · NvChad-style statusline (rounded, slanted, arrow or flat ends) · NvChad tabufline with jump labels and clickable tabs, close marks, light/dark and close-all buttons, aligned with the windows when the file tree is open · git branch and +/~/- counts in the statusline, and a hydra spinner while background commands (`:upgrade`, config commands) run · nvim-tree look for the file tree · optional winbar path and relative numbers · sign column · cursorline · borderless NvChad-style floats with pill titles · notifications · start screen with the hydra and nvdash buttons | +| NvChad look | 30 colorschemes (NvChad's onedark by default) · NvChad-style statusline (rounded, slanted, arrow or flat ends) · NvChad tabufline with jump labels and clickable tabs, close marks, light/dark and close-all buttons, aligned with the windows when the file tree is open · git branch and +/~/- counts in the statusline, and a hydra spinner while background commands (`:upgrade`, config commands) run · nvim-tree look for the file tree · optional winbar path and relative numbers · sign column · cursor line number (or the whole cursorline) · borderless NvChad-style floats with pill titles · notifications · start screen with the hydra and nvdash buttons | | Code | keyword highlighting for 40+ languages and formats (Rust, Python, JS/TS, C/C++, C#, Java, Kotlin, Go, PHP, Ruby, Swift, Dart, Scala, Lua, SQL, HTML/XML, CSS/SCSS, Markdown, JSON/YAML/TOML, shell, PowerShell, batch, Makefile, Dockerfile, CMake, Zig, Haskell, Elixir, R, Perl, asm, Nix, Terraform...) with multi-line comments and strings, HTML tags and config keys · blink.cmp-style completion from the words of open buffers · autopairs · auto-indent · comment toggling | | Helpers | indent guides · matching bracket · references of the word under the cursor (`]r`) · URLs (`gx`) · `#RRGGBB` shown in its color · `TODO:`/`FIX:`/`HACK:`… highlighted (`]T`) · Markdown preview beside the file, live as you type (`um`) | | Git | signs against the index · hunk jumps · graph of every branch, log, file log, status and stash pickers with diffs · `git browse` · status colors in the file tree | diff --git a/docs/architecture.md b/docs/architecture.md index 00f8a16..603513a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,13 +16,18 @@ src/ ├── editor/ modes, operators, registers, keymaps, the built-in binding table, windows, picker, │ file tree, which-key, cheat sheet, completion, sessions, prompts, clipboard, macros, │ text tools, reverse-engineering commands, config commands +│ └── tools/ the x tools, one module each (calc, units, bases, time, color, json, case, +│ generated text), and their popup ├── syntax/ keyword highlighter with multi-line state, language tables, todo-comments ├── config/ JSON reader, key notation, options/palette/highlights/keymaps/commands └── render/ cell grid, themes and palettes, icon sets, windows, bars, floats, picker, tree, start screen ``` -- **Rendering** draws each frame into a grid of cells (floats are later writes), encodes it to ANSI and sends - only the rows that changed; frames are skipped while keys are queued. +- **Rendering** draws each frame into a grid of cells, back to front (`render/mod.rs`, `compose`): the tabline, + the side panels, the windows, the bars, then the floats over them. It encodes the grid to ANSI and sends only + the rows that changed; frames are skipped while keys are queued. A line of text is drawn in layers + (`render/view.rs`, `LinePaint`): its syntax color, then URL, word, TODO, hex color, bracket and selection or + search highlights over it. - **Built-in keys** live in one table (`editor/bindings.rs`): each prefixed key's action and description. The dispatcher, which-key, the cheat sheet and the keymaps picker all read it, so they cannot disagree. - **Undo** stores the lines an edit replaced, not copies of the file, and keeps 1000 steps. The same diff --git a/docs/development.md b/docs/development.md index 717e26f..353c2ae 100644 --- a/docs/development.md +++ b/docs/development.md @@ -3,7 +3,7 @@ Rust 1.88+, no dependencies. The code layout and design are in [How lim is built](architecture.md). ```sh -cargo test # 125+ tests: behaviour, fuzzers on every input, perf budgets +cargo test # 160+ tests: behaviour, fuzzers on every input, perf budgets cargo clippy --all-targets # pedantic, zero warnings; unwrap/expect/panic are denied cargo fmt --check # rustfmt.toml, 120 columns cargo test --release perf -- --nocapture # the benchmark table: run it before and after a change @@ -80,4 +80,14 @@ of 5, Neovim 0.12 with `-u NONE` (no config, no plugins: its fastest). lim must (Linux in WSL, release build.) -Screenshots are real lim frames captured in a pseudo-terminal and rasterised. +## Screenshots + +The README's screenshots and the gallery of [Themes and icons](themes.md) are real lim frames, drawn by the +renderer itself. `cargo test gallery -- --ignored` rewrites the gallery's SVGs in `docs/gallery`. +`cargo test screenshots -- --ignored` plays each README scene with real keys in a small demo project and writes its +frame as SVG to `target/screenshots`; run it on Linux (in WSL), so paths read `src/main.rs`. Each SVG is then +rasterised to `docs/screenshots/.png` at its own size by a headless browser, with a Nerd Font installed: + +```powershell +msedge --headless --disable-gpu --hide-scrollbars --screenshot=docs\screenshots\editor.png --window-size=1080,612 target\screenshots\editor.svg +``` diff --git a/docs/screenshots/cheatsheet.png b/docs/screenshots/cheatsheet.png index 64b9e71..8634eeb 100644 Binary files a/docs/screenshots/cheatsheet.png and b/docs/screenshots/cheatsheet.png differ diff --git a/docs/screenshots/cmdline.png b/docs/screenshots/cmdline.png index 81730dd..c85cc07 100644 Binary files a/docs/screenshots/cmdline.png and b/docs/screenshots/cmdline.png differ diff --git a/docs/screenshots/completion.png b/docs/screenshots/completion.png index 305b018..82b27c1 100644 Binary files a/docs/screenshots/completion.png and b/docs/screenshots/completion.png differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png index 1913321..2851cad 100644 Binary files a/docs/screenshots/dashboard.png and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/editor.png b/docs/screenshots/editor.png index a5e58a9..1c15cb3 100644 Binary files a/docs/screenshots/editor.png and b/docs/screenshots/editor.png differ diff --git a/docs/screenshots/grep.png b/docs/screenshots/grep.png index 692026a..3212f6c 100644 Binary files a/docs/screenshots/grep.png and b/docs/screenshots/grep.png differ diff --git a/docs/screenshots/picker.png b/docs/screenshots/picker.png index 09a25be..a88de74 100644 Binary files a/docs/screenshots/picker.png and b/docs/screenshots/picker.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png new file mode 100644 index 0000000..bdc4a43 Binary files /dev/null and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/themes.png b/docs/screenshots/themes.png index 0dbd0d4..755e61f 100644 Binary files a/docs/screenshots/themes.png and b/docs/screenshots/themes.png differ diff --git a/docs/screenshots/tools.png b/docs/screenshots/tools.png new file mode 100644 index 0000000..374d05f Binary files /dev/null and b/docs/screenshots/tools.png differ diff --git a/docs/screenshots/visual.png b/docs/screenshots/visual.png index 37ba6d7..28fcf99 100644 Binary files a/docs/screenshots/visual.png and b/docs/screenshots/visual.png differ diff --git a/docs/screenshots/whichkey.png b/docs/screenshots/whichkey.png index ba38abc..d4bd440 100644 Binary files a/docs/screenshots/whichkey.png and b/docs/screenshots/whichkey.png differ diff --git a/src/buffer/text.rs b/src/buffer/text.rs index ab19c43..f6597b9 100644 --- a/src/buffer/text.rs +++ b/src/buffer/text.rs @@ -155,12 +155,16 @@ pub fn find_word(line: &str, word: &str) -> Vec> { /// URLs (`http://`, `https://`, `file://`) as char ranges, for `highlighturl`. pub fn find_urls(line: &str) -> Vec> { + // Runs on every shown line of every frame, and almost none holds a URL: one substring search rules it out. + if !line.contains("://") { + return Vec::new(); + } let chars: Vec = line.chars().collect(); let mut urls: Vec> = Vec::new(); let mut i: usize = 0; while i < chars.len() { - let rest: String = chars[i..chars.len().min(i + 8)].iter().collect(); - if ["https://", "http://", "file://"].iter().any(|p| rest.starts_with(p)) { + let at = |prefix: &str| prefix.chars().enumerate().all(|(k, c)| chars.get(i + k) == Some(&c)); + if ["https://", "http://", "file://"].into_iter().any(at) { let start: usize = i; while i < chars.len() && !chars[i].is_whitespace() && !"\"'<>`".contains(chars[i]) { i += 1; @@ -180,6 +184,9 @@ pub fn find_urls(line: &str) -> Vec> { /// `#RRGGBB` color literals (highlight-colors) as char ranges with their value. pub fn find_hex_colors(line: &str) -> Vec<(Range, u32)> { + if !line.contains('#') { + return Vec::new(); + } let chars: Vec = line.chars().collect(); let mut found: Vec<(Range, u32)> = Vec::new(); for i in 0..chars.len() { diff --git a/src/editor/complete.rs b/src/editor/complete.rs index 57e67f2..12a878e 100644 --- a/src/editor/complete.rs +++ b/src/editor/complete.rs @@ -30,26 +30,40 @@ fn is_word(c: char) -> bool { /// Candidates for `prefix` from word lists (current buffer first), smartcase, best first. pub fn candidates<'a>(prefix: &str, sources: impl Iterator) -> Vec { let sensitive: bool = prefix.chars().any(char::is_uppercase); + // Plain ASCII on both sides compares bytes; anything else lowercases (the Kelvin sign is a `k`). + let ascii: bool = prefix.is_ascii(); // Compared char by char: lowercasing every word of every buffer on each key was the slow part. let matches = |w: &str| { if sensitive { return w.starts_with(prefix); } + if ascii && w.is_ascii() { + return w.len() >= prefix.len() && w.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes()); + } let mut word = w.chars().flat_map(char::to_lowercase); prefix.chars().flat_map(char::to_lowercase).all(|p| word.next() == Some(p)) }; - let mut seen: HashSet = HashSet::new(); - let mut found: Vec<(usize, String)> = Vec::new(); + let mut seen: HashSet<&str> = HashSet::new(); + // Sort key: same-case prefix first, then the current buffer, then shorter words, then the word. + let mut found: Vec<(bool, usize, usize, &str)> = Vec::new(); for (rank, words) in sources.enumerate() { + // A buffer's words are unique already (`words_of`): only the later buffers need checking. + if rank == 1 { + seen.extend(found.iter().map(|f| f.3)); + } for w in words.iter().filter(|w| w.as_str() != prefix && w.len() > prefix.len() && matches(w)) { - if seen.insert(w.clone()) { - found.push((rank, w.clone())); + if rank == 0 || seen.insert(w) { + found.push((!w.starts_with(prefix), rank, w.chars().count(), w)); } } } - // Same-case prefix first, then the current buffer, then shorter words. - found.sort_by_key(|(rank, w)| (!w.starts_with(prefix), *rank, w.chars().count(), w.clone())); - found.into_iter().map(|(_, w)| w).take(MAX_ITEMS).collect() + // The menu shows MAX_ITEMS: pick those, then sort only them (a big file matches thousands on each key). + if found.len() > MAX_ITEMS { + found.select_nth_unstable(MAX_ITEMS); + found.truncate(MAX_ITEMS); + } + found.sort_unstable(); + found.into_iter().map(|(.., w)| w.to_owned()).collect() } impl Editor { @@ -177,5 +191,15 @@ mod tests { let found: Vec = candidates("res", [here.as_slice(), other.as_slice()].into_iter()); assert_eq!(found, vec!["restock", "result", "resolve", "Restore"]); assert_eq!(candidates("Res", [here.as_slice()].into_iter()), vec!["Restore"]); + // More matches than the menu holds: the same first ones, in order, as a full sort gives. + let many: Vec = (0..500).rev().map(|i| format!("item{i}")).collect(); + let shown: Vec = candidates("it", [many.as_slice()].into_iter()); + let mut all: Vec = many.clone(); + all.sort_by_key(|w| (w.len(), w.clone())); + assert_eq!(shown, all[..MAX_ITEMS]); + // Non-ASCII words still compare by lowercasing: the Kelvin sign is a `k`. + let kelvin: Vec = vec!["\u{212a}elvin".into(), "kettle".into()]; + assert_eq!(candidates("ke", [kelvin.as_slice()].into_iter()), vec!["kettle", "\u{212a}elvin"]); + assert_eq!(candidates("re", [here.as_slice(), here.as_slice()].into_iter()).len(), 3, "no duplicates"); } } diff --git a/src/editor/mod.rs b/src/editor/mod.rs index b4f60b3..6e2073f 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -50,7 +50,7 @@ use crate::term::Key; pub use cheatsheet::{Card, Cheatsheet}; use cmdhistory::CmdHistory; pub use complete::{Completion, MENU_HEIGHT, words_of}; -pub use explorer::{Explorer, GitStatus, WIDTH as EXPLORER_WIDTH}; +pub use explorer::{Entry, Explorer, GitStatus, WIDTH as EXPLORER_WIDTH}; use motion::Motion; pub use mouse::{Pane, TabClick, TabHit}; use operator::Operator; diff --git a/src/editor/picker.rs b/src/editor/picker.rs index 7453a5b..2430b1a 100644 --- a/src/editor/picker.rs +++ b/src/editor/picker.rs @@ -336,58 +336,10 @@ impl Corpus { } impl Editor { + /// The `f…` pickers: lists what `kind` picks from, filtered as you type. pub(super) fn open_picker(&mut self, kind: PickerKind) { - let items: Vec = match kind { - PickerKind::Files { all } => file_items(list_files(all)), - PickerKind::GitFiles => match git_files() { - Some(files) => file_items(files), - None => return self.info("Not a git repository"), - }, - PickerKind::Grep { .. } => Vec::new(), - PickerKind::Buffers => self - .buffers - .iter() - .enumerate() - .map(|(i, b)| Item { - label: b.path.as_ref().map_or_else(|| b.name(), |p| p.display().to_string()), - target: Target::Buffer(i), - }) - .collect(), - PickerKind::Recent => file_items(self.recent.iter().filter(|p| p.is_file()).cloned().collect()), - PickerKind::Keymaps => self.keymap_items(), - PickerKind::Commands => super::command::COMMANDS - .iter() - .copied() - .chain(self.config.commands.iter().map(|c| (c.name.as_str(), c.desc.as_str()))) - .map(|(cmd, desc)| Item { label: format!("{cmd:<12}{desc}"), target: Target::Command(cmd.to_owned()) }) - .collect(), - PickerKind::Lines => self - .buf() - .lines() - .iter() - .enumerate() - .filter(|(_, l)| !l.trim().is_empty()) - .map(|(y, l)| Item { label: format!("{:>5} {}", y + 1, l.trim()), target: Target::Line(y) }) - .collect(), - PickerKind::Smart => { - let mut seen: HashSet = HashSet::new(); - let open = self.buffers.iter().filter_map(|b| b.path.clone()); - let recent = self.recent.iter().filter(|p| p.is_file()).cloned(); - let cwd: PathBuf = std::env::current_dir().unwrap_or_default(); - let all: Vec = open - .chain(recent) - .map(|p| p.strip_prefix(&cwd).map(Path::to_path_buf).unwrap_or(p)) - .chain(list_files(false)) - .filter(|p| seen.insert(p.clone())) - .collect(); - file_items(all) - } - PickerKind::Todos => todo_items(&list_files(false)), - other => match self.extra_items(other) { - Some(items) => items, - None => return, - }, - }; + let Some(items): Option> = self.picker_items(kind) else { return }; + // Live grep searches as you type: it starts with the files, not with items. let files: Vec = match kind { PickerKind::Grep { all } => list_files(all), _ => Vec::new(), @@ -410,6 +362,67 @@ impl Editor { self.selection_changed(); } + /// What picker `kind` lists; None when it cannot open (outside a git repository, for the git files). + fn picker_items(&mut self, kind: PickerKind) -> Option> { + Some(match kind { + PickerKind::Files { all } => file_items(list_files(all)), + PickerKind::GitFiles => { + let Some(files): Option> = git_files() else { + self.info("Not a git repository"); + return None; + }; + file_items(files) + } + PickerKind::Grep { .. } => Vec::new(), + PickerKind::Buffers => self.buffer_items(), + PickerKind::Recent => file_items(self.recent.iter().filter(|p| p.is_file()).cloned().collect()), + PickerKind::Keymaps => self.keymap_items(), + PickerKind::Commands => self.command_items(), + PickerKind::Lines => self.line_items(), + PickerKind::Smart => self.smart_items(), + PickerKind::Todos => todo_items(&list_files(false)), + other => self.extra_items(other)?, + }) + } + + /// The open buffers, by path (or name for an unnamed one). + fn buffer_items(&self) -> Vec { + let label = |b: &Buffer| b.path.as_ref().map_or_else(|| b.name(), |p| p.display().to_string()); + self.buffers.iter().enumerate().map(|(i, b)| Item { label: label(b), target: Target::Buffer(i) }).collect() + } + + /// The Ex commands lim knows, then the config's own, each with what it does. + fn command_items(&self) -> Vec { + let own = self.config.commands.iter().map(|c| (c.name.as_str(), c.desc.as_str())); + super::command::COMMANDS + .iter() + .copied() + .chain(own) + .map(|(cmd, desc)| Item { label: format!("{cmd:<12}{desc}"), target: Target::Command(cmd.to_owned()) }) + .collect() + } + + /// The current buffer's lines that are not blank, numbered. + fn line_items(&self) -> Vec { + let lines = self.buf().lines().iter().enumerate().filter(|(_, l)| !l.trim().is_empty()); + lines.map(|(y, l)| Item { label: format!("{:>5} {}", y + 1, l.trim()), target: Target::Line(y) }).collect() + } + + /// snacks' smart picker: open buffers, then recent files, then the project's, each file once. + fn smart_items(&self) -> Vec { + let mut seen: HashSet = HashSet::new(); + let open = self.buffers.iter().filter_map(|b| b.path.clone()); + let recent = self.recent.iter().filter(|p| p.is_file()).cloned(); + let cwd: PathBuf = std::env::current_dir().unwrap_or_default(); + let all: Vec = open + .chain(recent) + .map(|p| p.strip_prefix(&cwd).map(Path::to_path_buf).unwrap_or(p)) + .chain(list_files(false)) + .filter(|p| seen.insert(p.clone())) + .collect(); + file_items(all) + } + /// `fc`: grep for the word under the cursor. pub(super) fn grep_word(&mut self) { let buf: &Buffer = self.buf(); diff --git a/src/editor/prompt.rs b/src/editor/prompt.rs index d322de8..dbbfa1c 100644 --- a/src/editor/prompt.rs +++ b/src/editor/prompt.rs @@ -104,34 +104,31 @@ impl Editor { } fn submit(&mut self, action: PromptAction, input: &str) { - let touched: Option = match &action { - PromptAction::TreeAdd { dir, .. } => Some(dir.join(input)), - PromptAction::TreeRename { from } | PromptAction::TreeMove { from } | PromptAction::TreeCopy { from } => { - Some(from.clone()) - } - PromptAction::TreeDelete { path } => Some(path.clone()), - PromptAction::RenameFile => self.buf().path.clone(), - _ => None, - }; - if touched.is_some_and(|p| !super::may_touch(&p)) { + if touched_path(&action, self.buf().path.as_deref(), input).is_some_and(|p| !super::may_touch(&p)) { return; } - let result: Result = match action { - _ if input.is_empty() && !matches!(action, PromptAction::TreeDelete { .. }) => return, + // An empty answer cancels, except to the delete question, where it means no. + if input.is_empty() && !matches!(action, PromptAction::TreeDelete { .. }) { + return; + } + let Some(result): Option> = self.run_prompt(action, input) else { return }; + if let Some(tree) = &mut self.explorer { + tree.refresh(); + } + match result { + Ok(done) => self.set_message(Message::Info(done)), + Err(e) => self.set_message(Message::Error(e)), + } + } + + /// Does what the prompt asked; None when there is nothing to report (a copy outside the allowed paths, a + /// delete not confirmed). + fn run_prompt(&mut self, action: PromptAction, input: &str) -> Option> { + Some(match action { PromptAction::Case => self.case_word(input), PromptAction::RenameFile => self.rename_current(input), PromptAction::IndentWidth => self.set_indent(input), - PromptAction::TreeAdd { dir, directory } => { - let path: PathBuf = dir.join(input); - let made: io::Result<()> = if directory || input.ends_with('/') { - fs::create_dir_all(&path) - } else { - path.parent() - .map_or(Ok(()), fs::create_dir_all) - .and_then(|()| fs::File::create_new(&path).map(drop)) - }; - made.map(|()| format!("Created {}", path.display())).map_err(|e| format!("{}: {e}", path.display())) - } + PromptAction::TreeAdd { dir, directory } => tree_add(&dir.join(input), directory || input.ends_with('/')), PromptAction::TreeRename { from } => { let to: PathBuf = sibling(&from, input); self.move_path(&from, &to).map(|()| format!("Renamed to {}", to.display())) @@ -140,32 +137,10 @@ impl Editor { let to: PathBuf = self.tree_target(input); self.move_path(&from, &to).map(|()| format!("Moved to {}", to.display())) } - PromptAction::TreeCopy { from } => { - let to: PathBuf = self.tree_target(input); - if !super::may_touch(&to) { - return; - } - copy_recursive(&from, &to) - .map(|()| format!("Copied to {}", to.display())) - .map_err(|e| format!("{}: {e}", to.display())) - } - PromptAction::TreeDelete { path } => { - if !input.eq_ignore_ascii_case("y") { - return; - } - let removed: io::Result<()> = - if path.is_dir() { fs::remove_dir_all(&path) } else { fs::remove_file(&path) }; - removed.map(|()| format!("Deleted {}", path.display())).map_err(|e| format!("{}: {e}", path.display())) - } + PromptAction::TreeCopy { from } => tree_copy(&from, &self.tree_target(input))?, + PromptAction::TreeDelete { path } => tree_delete(&path, input)?, PromptAction::SessionSave => self.save_session(input).map(|()| format!("Session \"{input}\" saved")), - }; - if let Some(tree) = &mut self.explorer { - tree.refresh(); - } - match result { - Ok(done) => self.set_message(Message::Info(done)), - Err(e) => self.set_message(Message::Error(e)), - } + }) } /// `R` and `:rename`: renames the file on disk (if it exists) and the buffer. @@ -252,3 +227,44 @@ fn copy_recursive(from: &Path, to: &Path) -> io::Result<()> { fs::copy(from, to).map(drop) } } + +/// The path a prompt's action will write to, so it can be checked first; `current` is the buffer's file. +fn touched_path(action: &PromptAction, current: Option<&Path>, input: &str) -> Option { + match action { + PromptAction::TreeAdd { dir, .. } => Some(dir.join(input)), + PromptAction::TreeRename { from } | PromptAction::TreeMove { from } | PromptAction::TreeCopy { from } => { + Some(from.clone()) + } + PromptAction::TreeDelete { path } => Some(path.clone()), + PromptAction::RenameFile => current.map(Path::to_path_buf), + _ => None, + } +} + +/// `a` in the tree: a folder (named with a trailing `/`), or an empty file with its missing folders. +fn tree_add(path: &Path, directory: bool) -> Result { + let made: io::Result<()> = if directory { + fs::create_dir_all(path) + } else { + path.parent().map_or(Ok(()), fs::create_dir_all).and_then(|()| fs::File::create_new(path).map(drop)) + }; + made.map(|()| format!("Created {}", path.display())).map_err(|e| format!("{}: {e}", path.display())) +} + +/// `c` in the tree: copies a file or a folder; None when the target is not allowed. +fn tree_copy(from: &Path, to: &Path) -> Option> { + if !super::may_touch(to) { + return None; + } + let copied: io::Result<()> = copy_recursive(from, to); + Some(copied.map(|()| format!("Copied to {}", to.display())).map_err(|e| format!("{}: {e}", to.display()))) +} + +/// `d` in the tree, once confirmed with `y`; None otherwise. +fn tree_delete(path: &Path, answer: &str) -> Option> { + if !answer.eq_ignore_ascii_case("y") { + return None; + } + let removed: io::Result<()> = if path.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }; + Some(removed.map(|()| format!("Deleted {}", path.display())).map_err(|e| format!("{}: {e}", path.display()))) +} diff --git a/src/editor/re.rs b/src/editor/re.rs index 2962db0..51d34f8 100644 --- a/src/editor/re.rs +++ b/src/editor/re.rs @@ -242,77 +242,86 @@ fn u32_at(bytes: &[u8], at: usize) -> Option { /// The file type from its first bytes, with the architecture of executables. fn detect(bytes: &[u8]) -> String { if bytes.starts_with(b"\x7fELF") { - let bits: &str = if bytes.get(4) == Some(&2) { "64" } else { "32" }; - let big: bool = bytes.get(5) == Some(&2); - let machine: &str = match u16_at(bytes, 18, big) { - Some(3) => "x86", - Some(0x3e) => "x86-64", - Some(0x28) => "ARM", - Some(0xb7) => "AArch64", - Some(0xf3) => "RISC-V", - Some(8) => "MIPS", - Some(0x14 | 0x15) => "PowerPC", - _ => "unknown machine", - }; - let kind: &str = match u16_at(bytes, 16, big) { - Some(1) => "relocatable", - Some(2) => "executable", - Some(3) => "shared object", - Some(4) => "core", - _ => "", - }; - return format!("ELF {bits}-bit {} {kind} {machine}", if big { "MSB" } else { "LSB" }); + return elf(bytes); } if bytes.starts_with(b"MZ") { - let pe: Option = u32_at(bytes, 0x3c).and_then(|o| usize::try_from(o).ok()); - let Some(pe): Option = pe.filter(|o| bytes.get(*o..).is_some_and(|r| r.starts_with(b"PE\0\0"))) else { - return "MS-DOS executable".into(); - }; - let machine: &str = match u16_at(bytes, pe + 4, false) { - Some(0x14c) => "x86", - Some(0x8664) => "x86-64", - Some(0xaa64) => "ARM64", - Some(0x1c0 | 0x1c4) => "ARM", - _ => "unknown machine", - }; - let dll: bool = u16_at(bytes, pe + 22, false).is_some_and(|c| c & 0x2000 != 0); - let plus: bool = u16_at(bytes, pe + 24, false) == Some(0x20b); - let dotnet: bool = find(bytes, b"mscoree.dll").is_some() || find(bytes, b"#Strings").is_some(); - return format!( - "PE32{} {} {machine}{}", - if plus { "+" } else { "" }, - if dll { "DLL" } else { "executable" }, - if dotnet { " .NET" } else { "" } - ); + return pe(bytes); + } + match MAGICS.iter().find(|(magic, _)| bytes.starts_with(magic)) { + Some((_, name)) => (*name).to_owned(), + None if std::str::from_utf8(bytes).is_ok() => "text".into(), + None => "data".into(), } - let magics: &[(&[u8], &str)] = &[ - (b"\xcf\xfa\xed\xfe", "Mach-O 64-bit"), - (b"\xce\xfa\xed\xfe", "Mach-O 32-bit"), - (b"\xca\xfe\xba\xbe", "Mach-O universal or Java class"), - (b"dex\n", "Android DEX"), - (b"\0asm", "WebAssembly"), - (b"PK\x03\x04", "ZIP (also jar, apk, docx)"), - (b"\x1f\x8b", "gzip"), - (b"BZh", "bzip2"), - (b"\xfd7zXZ\0", "xz"), - (b"\x28\xb5\x2f\xfd", "zstd"), - (b"7z\xbc\xaf\x27\x1c", "7-Zip"), - (b"Rar!", "RAR"), - (b"MSCF", "Microsoft Cabinet"), - (b"\xd0\xcf\x11\xe0", "OLE compound file (doc, xls, msi)"), - (b"%PDF", "PDF"), - (b"\x89PNG", "PNG"), - (b"\xff\xd8\xff", "JPEG"), - (b"GIF8", "GIF"), - (b"SQLite format 3\0", "SQLite database"), - (b"#!", "script"), - ]; - magics.iter().find(|(magic, _)| bytes.starts_with(magic)).map_or_else( - || if std::str::from_utf8(bytes).is_ok() { "text".into() } else { "data".into() }, - |(_, name)| (*name).to_owned(), - ) } +/// An ELF header: class, byte order, file kind and machine. +fn elf(bytes: &[u8]) -> String { + let bits: &str = if bytes.get(4) == Some(&2) { "64" } else { "32" }; + let big: bool = bytes.get(5) == Some(&2); + let machine: &str = match u16_at(bytes, 18, big) { + Some(3) => "x86", + Some(0x3e) => "x86-64", + Some(0x28) => "ARM", + Some(0xb7) => "AArch64", + Some(0xf3) => "RISC-V", + Some(8) => "MIPS", + Some(0x14 | 0x15) => "PowerPC", + _ => "unknown machine", + }; + let kind: &str = match u16_at(bytes, 16, big) { + Some(1) => "relocatable", + Some(2) => "executable", + Some(3) => "shared object", + Some(4) => "core", + _ => "", + }; + format!("ELF {bits}-bit {} {kind} {machine}", if big { "MSB" } else { "LSB" }) +} + +/// An `MZ` file: a PE (32 or 32+, executable or DLL, machine, .NET), or a plain MS-DOS executable. +fn pe(bytes: &[u8]) -> String { + let header: Option = u32_at(bytes, 0x3c).and_then(|o| usize::try_from(o).ok()); + let Some(pe): Option = header.filter(|o| bytes.get(*o..).is_some_and(|r| r.starts_with(b"PE\0\0"))) else { + return "MS-DOS executable".into(); + }; + let machine: &str = match u16_at(bytes, pe + 4, false) { + Some(0x14c) => "x86", + Some(0x8664) => "x86-64", + Some(0xaa64) => "ARM64", + Some(0x1c0 | 0x1c4) => "ARM", + _ => "unknown machine", + }; + let dll: bool = u16_at(bytes, pe + 22, false).is_some_and(|c| c & 0x2000 != 0); + let plus: &str = if u16_at(bytes, pe + 24, false) == Some(0x20b) { "+" } else { "" }; + let dotnet: bool = find(bytes, b"mscoree.dll").is_some() || find(bytes, b"#Strings").is_some(); + let kind: &str = if dll { "DLL" } else { "executable" }; + format!("PE32{plus} {kind} {machine}{}", if dotnet { " .NET" } else { "" }) +} + +/// Other formats by their magic bytes. +const MAGICS: &[(&[u8], &str)] = &[ + (b"\xcf\xfa\xed\xfe", "Mach-O 64-bit"), + (b"\xce\xfa\xed\xfe", "Mach-O 32-bit"), + (b"\xca\xfe\xba\xbe", "Mach-O universal or Java class"), + (b"dex\n", "Android DEX"), + (b"\0asm", "WebAssembly"), + (b"PK\x03\x04", "ZIP (also jar, apk, docx)"), + (b"\x1f\x8b", "gzip"), + (b"BZh", "bzip2"), + (b"\xfd7zXZ\0", "xz"), + (b"\x28\xb5\x2f\xfd", "zstd"), + (b"7z\xbc\xaf\x27\x1c", "7-Zip"), + (b"Rar!", "RAR"), + (b"MSCF", "Microsoft Cabinet"), + (b"\xd0\xcf\x11\xe0", "OLE compound file (doc, xls, msi)"), + (b"%PDF", "PDF"), + (b"\x89PNG", "PNG"), + (b"\xff\xd8\xff", "JPEG"), + (b"GIF8", "GIF"), + (b"SQLite format 3\0", "SQLite database"), + (b"#!", "script"), +]; + /// Runs of 4+ printable ASCII chars, each `width` bytes (2: UTF-16LE, the high byte zero), with their offsets. fn strings(bytes: &[u8], width: usize) -> Vec<(usize, String)> { let mut found: Vec<(usize, String)> = Vec::new(); diff --git a/src/editor/tools.rs b/src/editor/tools.rs deleted file mode 100644 index 864773c..0000000 --- a/src/editor/tools.rs +++ /dev/null @@ -1,1506 +0,0 @@ -//! `x` tools: a calculator, a unit converter, number bases, timestamps, colors, word case, JSON layout and -//! generated text (dates, UUIDs, passwords, lorem ipsum). Each tool is a pure function of the typed text; the editor -//! shows the answer, yanks it into `"` and keeps the prompt open for the next question. - -use std::collections::hash_map::RandomState; -use std::fmt::Write; -use std::hash::BuildHasher; -use std::time::{SystemTime, UNIX_EPOCH}; - -use super::Editor; -use super::prompt::PromptAction; -use super::registers::Register; -use crate::buffer::{Buffer, Pos}; -use crate::config::json; -use crate::term; - -/// The tools that answer a line of input, each in its own window (a notebook buffer). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Tool { - Calc, - Convert, - Base, - Time, - Color, -} - -/// Text the `x` keys insert at the cursor. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Generated { - Date, - DateTime, - Uuid, - Password, - Lorem, -} - -/// Identifier and phrase cases for `xk` and `:case`. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum WordCase { - Snake, - Camel, - Pascal, - Kebab, - Constant, - Title, - Lower, - Upper, -} - -impl WordCase { - pub const NAMES: [(&'static str, Self); 8] = [ - ("snake", Self::Snake), - ("camel", Self::Camel), - ("pascal", Self::Pascal), - ("kebab", Self::Kebab), - ("constant", Self::Constant), - ("title", Self::Title), - ("lower", Self::Lower), - ("upper", Self::Upper), - ]; - - /// The case named `name`, or by a prefix of its name (`sn`, `k`). - pub fn parse(name: &str) -> Option { - let name: String = name.trim().to_ascii_lowercase(); - Self::NAMES.iter().find(|(n, _)| !name.is_empty() && n.starts_with(name.as_str())).map(|&(_, case)| case) - } - - /// `text` split into words (at blanks, punctuation and camelCase humps), joined in this case. - pub fn apply(self, text: &str) -> String { - let words: Vec = words(text); - let capital = |w: &String| -> String { - let mut chars = w.chars(); - chars - .next() - .map(|c| c.to_uppercase().chain(chars.flat_map(char::to_lowercase)).collect()) - .unwrap_or_default() - }; - let lower: Vec = words.iter().map(|w| w.to_lowercase()).collect(); - match self { - Self::Snake => lower.join("_"), - Self::Kebab => lower.join("-"), - Self::Constant => words.iter().map(|w| w.to_uppercase()).collect::>().join("_"), - Self::Lower => lower.join(" "), - Self::Upper => words.iter().map(|w| w.to_uppercase()).collect::>().join(" "), - Self::Title => words.iter().map(capital).collect::>().join(" "), - Self::Pascal => words.iter().map(capital).collect(), - Self::Camel => { - let mut out: String = lower.first().cloned().unwrap_or_default(); - out.extend(words.iter().skip(1).map(capital)); - out - } - } - } -} - -/// The words of `text`: runs of letters and digits, also split where a lowercase letter meets an uppercase one -/// (`fooBar`) and before the last capital of an acronym (`HTTPServer`). -fn words(text: &str) -> Vec { - let chars: Vec = text.chars().collect(); - let mut words: Vec = Vec::new(); - let mut word: String = String::new(); - for (i, &c) in chars.iter().enumerate() { - if !c.is_alphanumeric() { - if !word.is_empty() { - words.push(std::mem::take(&mut word)); - } - continue; - } - if !word.is_empty() && c.is_uppercase() { - let prev: char = chars[i - 1]; - let hump: bool = prev.is_lowercase() || prev.is_numeric(); - let acronym_end: bool = prev.is_uppercase() && chars.get(i + 1).is_some_and(|n| n.is_lowercase()); - if hump || acronym_end { - words.push(std::mem::take(&mut word)); - } - } - word.push(c); - } - if !word.is_empty() { - words.push(word); - } - words -} - -impl Tool { - pub const ALL: [Self; 5] = [Self::Calc, Self::Convert, Self::Base, Self::Time, Self::Color]; - - /// The window's buffer name. - pub fn title(self) -> &'static str { - match self { - Self::Calc => "[Calculator]", - Self::Convert => "[Convert]", - Self::Base => "[Bases]", - Self::Time => "[Time]", - Self::Color => "[Color]", - } - } - - /// What to type, shown in the popup's bottom border. - fn hint(self) -> &'static str { - match self { - Self::Calc => "2+3*4, sqrt(2), 2pi, ans*2", - Self::Convert => "5 ft to cm, 70 kg en lb, 100 f c, 2 m", - Self::Base => "255, 0xff, 0b1010, 2^40", - Self::Time => "now (or an empty line), 1700000000, 2026-10-18 14:30, two dates", - Self::Color => "#f80, rgb(255, 136, 0), hsl(32, 100%, 50%)", - } - } - - /// The answer to `input`; `ans` is the calculator's previous result. - pub fn run(self, input: &str, ans: Option) -> Result { - match self { - Self::Calc => evaluate(input, ans).map(format_number), - Self::Convert => convert(input), - Self::Base => bases(input, ans), - Self::Time => time(input, now()), - Self::Color => color(input), - } - } -} - -// --- Calculator --------------------------------------------------------------------------------------------- - -/// `expr` evaluated: `+ - * / % ^ !`, `x%` (a percentage), `√`, `²`, `³`, parentheses (also `[ ]` and `{ }`; the -/// ones left open at the end are closed), `0x`/`0b`/`0o` literals, `1e3`, `1,5` (a decimal comma outside function -/// arguments), functions (`sqrt(2)`, `log(8, 2)`, `max(1, 2, 3)`), constants (`pi`, `e`, `tau`, `phi`) and `ans`. -/// `×` `·` `÷` `−` and `**` are read as `*` `*` `/` `-` `^`. A value next to a name or a parenthesis multiplies -/// (`2pi`, `3(4+1)`, `(1+1)(2)`). The error says what is wrong and where. -pub fn evaluate(expr: &str, ans: Option) -> Result { - let mut tokens: Vec<(char, usize)> = Vec::new(); - for (col, c) in expr.chars().enumerate() { - let c: char = match c { - '[' | '{' => '(', - ']' | '}' => ')', - '×' | '·' | '∙' => '*', - '÷' | '∶' => '/', - '−' | '–' => '-', - // `**` is a power: the second `*` turns the pair into `^`. - '*' if tokens.last().is_some_and(|&(prev, _)| prev == '*') => { - tokens.pop(); - '^' - } - c if c.is_whitespace() || c == '_' => continue, - c => c, - }; - tokens.push((c, col + 1)); - } - if tokens.is_empty() { - return Err("type an expression, e.g. 2+3*4".into()); - } - let mut parser: Calc = Calc { tokens, pos: 0, depth: 0, args: 0, ans }; - let value: f64 = parser.sum()?; - if let Some(&(c, col)) = parser.tokens.get(parser.pos) { - return Err(if c == ')' { format!("unmatched ) at column {col}") } else { parser.unexpected() }); - } - if value.is_nan() { - Err("undefined result (like sqrt(-1), log(0) or 0/0)".into()) - } else if value.is_infinite() { - Err("infinite result (a division by zero or an overflow)".into()) - } else { - Ok(value) - } -} - -/// `evaluate` for the callers that only need the value. -pub fn calc(expr: &str, ans: Option) -> Option { - evaluate(expr, ans).ok() -} - -/// How deep parentheses, signs and powers may nest: `((((…` or `----…` must not overflow the stack. -const MAX_DEPTH: usize = 64; - -struct Calc { - /// The chars without blanks, each with its column in the input (1-based), for error messages. - tokens: Vec<(char, usize)>, - pos: usize, - depth: usize, - /// Function calls open around the current token: inside one, `,` separates arguments. - args: usize, - ans: Option, -} - -type Value = Result; - -impl Calc { - fn peek(&self) -> Option { - self.tokens.get(self.pos).map(|&(c, _)| c) - } - - fn peek_at(&self, ahead: usize) -> Option { - self.tokens.get(self.pos + ahead).map(|&(c, _)| c) - } - - fn eat(&mut self, ops: &str) -> Option { - let c: char = self.peek().filter(|c| ops.contains(*c))?; - self.pos += 1; - Some(c) - } - - /// The error for the token at `pos`: an unexpected char, or the input ending too soon. - fn unexpected(&self) -> String { - match self.tokens.get(self.pos) { - Some(&(c, col)) => format!("unexpected {c} at column {col}"), - None => format!("incomplete: something is missing after {}", self.tokens.last().map_or(' ', |t| t.0)), - } - } - - /// Whether an operand starts here: a number, a name, a parenthesis, `π` or `√`. - fn at_operand(&self) -> bool { - self.peek().is_some_and(|c| c.is_alphanumeric() || matches!(c, '.' | '(' | 'π' | '√')) - } - - /// Enters one nesting level, failing past `MAX_DEPTH`. - fn deeper(&mut self) -> Result<(), String> { - self.depth += 1; - if self.depth > MAX_DEPTH { Err("too deeply nested".into()) } else { Ok(()) } - } - - fn sum(&mut self) -> Value { - let mut value: f64 = self.product()?; - while let Some(op) = self.eat("+-") { - let rhs: f64 = self.product()?; - value = if op == '+' { value + rhs } else { value - rhs }; - } - Ok(value) - } - - fn product(&mut self) -> Value { - let mut value: f64 = self.unary()?; - loop { - // `%` before an operand is a remainder; `postfix` already read any other `%` as a percentage. - let op: char = match self.eat("*/%") { - Some(op) => op, - None if self.at_operand() => '*', - None => return Ok(value), - }; - let rhs: f64 = self.unary()?; - value = match op { - '*' => value * rhs, - '/' => value / rhs, - _ => value % rhs, - }; - } - } - - /// Signs bind looser than `^`: `-2^2` is -4. - fn unary(&mut self) -> Value { - self.deeper()?; - let value: Value = match self.eat("+-") { - Some('-') => self.unary().map(|v| -v), - Some(_) => self.unary(), - None => self.power(), - }; - self.depth -= 1; - value - } - - /// `^` binds right: `2^3^2` is `2^9`. - fn power(&mut self) -> Value { - let base: f64 = self.postfix()?; - if self.eat("^").is_some() { - return Ok(base.powf(self.unary()?)); - } - Ok(base) - } - - fn postfix(&mut self) -> Value { - let mut value: f64 = self.primary()?; - loop { - match self.peek() { - Some('!') => value = factorial(value)?, - Some('²') => value = value.powi(2), - Some('³') => value = value.powi(3), - Some('%') if !self.peek_at(1).is_some_and(|c| c.is_alphanumeric() || ".(π√+-".contains(c)) => { - value /= 100.0; - } - _ => return Ok(value), - } - self.pos += 1; - } - } - - /// `)` or the end of the input, which closes what is still open. - fn close(&mut self) -> Result<(), String> { - if self.eat(")").is_some() || self.peek().is_none() { Ok(()) } else { Err(self.unexpected()) } - } - - fn primary(&mut self) -> Value { - let Some(c): Option = self.peek() else { return Err(self.unexpected()) }; - match c { - '(' => { - self.pos += 1; - if self.peek() == Some(')') { - return Err(format!("empty parentheses at column {}", self.tokens[self.pos - 1].1)); - } - self.deeper()?; - let saved: usize = std::mem::take(&mut self.args); - let value: Value = self.sum(); - self.args = saved; - self.depth -= 1; - let value: f64 = value?; - self.close()?; - Ok(value) - } - 'π' => { - self.pos += 1; - Ok(std::f64::consts::PI) - } - '√' => { - self.pos += 1; - self.deeper()?; - let value: Value = self.postfix().map(f64::sqrt); - self.depth -= 1; - value - } - c if c.is_ascii_digit() || c == '.' => self.number(), - c if c.is_alphabetic() => self.name(), - _ => Err(self.unexpected()), - } - } - - /// A constant, `ans`, or a function call. - fn name(&mut self) -> Value { - let start: usize = self.pos; - while self.peek().is_some_and(char::is_alphanumeric) { - self.pos += 1; - } - let name: String = self.tokens[start..self.pos].iter().map(|&(c, _)| c).collect::().to_lowercase(); - // A constant before `(` multiplies it (`2pi(1+1)`); any other name before `(` is a function call. - match name.as_str() { - "pi" => return Ok(std::f64::consts::PI), - "e" => return Ok(std::f64::consts::E), - "tau" => return Ok(std::f64::consts::TAU), - "phi" => return Ok(1.618_033_988_749_895), - "ans" => return self.ans.ok_or_else(|| "no previous answer for ans yet".into()), - _ => {} - } - if self.eat("(").is_none() { - return Err(format!("unknown name {name} at column {}", self.tokens[start].1)); - } - self.deeper()?; - self.args += 1; - let mut args: Vec = Vec::new(); - let parsed: Result<(), String> = loop { - match self.sum() { - Ok(value) => args.push(value), - Err(e) => break Err(e), - } - if self.eat(",").is_none() { - break Ok(()); - } - }; - self.args -= 1; - self.depth -= 1; - parsed?; - self.close()?; - function(&name, &args) - } - - /// `12`, `1.5`, `.5`, `1,5`, `1e-3`, `0xff`, `0b101`, `0o17`. - fn number(&mut self) -> Value { - let start: usize = self.pos; - let radix: Option = match (self.peek(), self.peek_at(1).map(|c| c.to_ascii_lowercase())) { - (Some('0'), Some('x')) => Some(16), - (Some('0'), Some('b')) => Some(2), - (Some('0'), Some('o')) => Some(8), - _ => None, - }; - if let Some(radix) = radix { - self.pos += 2; - let first: usize = self.pos; - while self.peek().is_some_and(|c| c.is_digit(radix)) { - self.pos += 1; - } - let digits: String = self.tokens[first..self.pos].iter().map(|&(c, _)| c).collect(); - return u64::from_str_radix(&digits, radix) - .map(u64_to_f64) - .map_err(|_| format!("bad number at column {}", self.tokens[start].1)); - } - let mut text: String = String::new(); - loop { - match self.peek() { - Some(c) if c.is_ascii_digit() || c == '.' => text.push(c), - // A decimal comma, unless it separates function arguments. - Some(',') if self.args == 0 && self.peek_at(1).is_some_and(|c| c.is_ascii_digit()) => text.push('.'), - _ => break, - } - self.pos += 1; - } - // An exponent only when digits follow, so `2e` stays 2 times e. - let sign: usize = usize::from(matches!(self.peek_at(1), Some('+' | '-'))); - if matches!(self.peek(), Some('e' | 'E')) && self.peek_at(1 + sign).is_some_and(|c| c.is_ascii_digit()) { - text.push('e'); - self.pos += 1; - while self.peek().is_some_and(|c| c.is_ascii_digit() || c == '+' || c == '-') { - let c: char = self.peek().unwrap_or('0'); - // Only the sign right after `e` belongs to the number. - if !c.is_ascii_digit() && !text.ends_with('e') { - break; - } - text.push(c); - self.pos += 1; - } - } - text.parse().map_err(|_| format!("bad number {text} at column {}", self.tokens[start].1)) - } -} - -#[expect(clippy::cast_precision_loss, reason = "a calculator works in f64; huge literals round like any float")] -fn u64_to_f64(n: u64) -> f64 { - n as f64 -} - -fn factorial(n: f64) -> Value { - if n.fract() != 0.0 || !(0.0..=170.0).contains(&n) { - return Err("! needs a whole number from 0 to 170".into()); - } - let mut product: f64 = 1.0; - let mut k: f64 = 2.0; - while k <= n { - product *= k; - k += 1.0; - } - Ok(product) -} - -fn function(name: &str, args: &[f64]) -> Value { - let wrong = || format!("wrong number of arguments for {name}"); - let one = |f: fn(f64) -> f64| match args { - [x] => Ok(f(*x)), - _ => Err(wrong()), - }; - let two = |f: fn(f64, f64) -> f64| match args { - [x, y] => Ok(f(*x, *y)), - _ => Err(wrong()), - }; - match name { - "sqrt" => one(f64::sqrt), - "cbrt" => one(f64::cbrt), - "abs" => one(f64::abs), - "floor" => one(f64::floor), - "ceil" => one(f64::ceil), - "round" => one(f64::round), - "trunc" => one(f64::trunc), - "sign" => one(f64::signum), - "exp" => one(f64::exp), - "ln" => one(f64::ln), - "log2" => one(f64::log2), - "log" if args.len() == 2 => two(f64::log), - "log" | "log10" => one(f64::log10), - "sin" => one(f64::sin), - "cos" => one(f64::cos), - "tan" => one(f64::tan), - "asin" => one(f64::asin), - "acos" => one(f64::acos), - "atan" => one(f64::atan), - "sinh" => one(f64::sinh), - "cosh" => one(f64::cosh), - "tanh" => one(f64::tanh), - "rad" => one(f64::to_radians), - "deg" => one(f64::to_degrees), - "hypot" => two(f64::hypot), - "pow" => two(f64::powf), - "min" => args.iter().copied().reduce(f64::min).ok_or_else(wrong), - "max" => args.iter().copied().reduce(f64::max).ok_or_else(wrong), - _ => Err(format!("unknown function {name}")), - } -} - -/// A result as people write it: whole numbers without `.0`, at most 10 decimals, very large or small ones in -/// scientific notation. -pub fn format_number(v: f64) -> String { - let abs: f64 = v.abs(); - if abs != 0.0 && !(1e-4..1e15).contains(&abs) { - let text: String = format!("{v:.11e}"); - let (mantissa, exponent): (&str, &str) = text.split_once('e').unwrap_or((&text, "0")); - return format!("{}e{exponent}", trim_decimals(mantissa)); - } - let text: String = trim_decimals(&format!("{v:.10}")).to_owned(); - if text == "-0" { "0".into() } else { text } -} - -fn trim_decimals(text: &str) -> &str { - if text.contains('.') { text.trim_end_matches('0').trim_end_matches('.') } else { text } -} - -// --- Units -------------------------------------------------------------------------------------------------- - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Dim { - Length, - Area, - Volume, - Mass, - Time, - Speed, - Temperature, - Data, - Energy, - Power, - Pressure, - Angle, -} - -impl Dim { - fn name(self) -> &'static str { - match self { - Self::Length => "length", - Self::Area => "area", - Self::Volume => "volume", - Self::Mass => "mass", - Self::Time => "time", - Self::Speed => "speed", - Self::Temperature => "temperature", - Self::Data => "data", - Self::Energy => "energy", - Self::Power => "power", - Self::Pressure => "pressure", - Self::Angle => "angle", - } - } -} - -/// A unit: its names (the first is shown), what it measures, and `base = value * factor + offset` in the -/// dimension's base unit (m, m², L, kg, s, m/s, K, byte, J, W, Pa, rad). -struct Unit { - names: &'static [&'static str], - dim: Dim, - factor: f64, - offset: f64, -} - -macro_rules! u { - ($names:expr, $dim:expr, $factor:expr) => { - Unit { names: $names, dim: $dim, factor: $factor, offset: 0.0 } - }; -} - -const MILE: f64 = 1609.344; -const FOOT: f64 = 0.3048; -const INCH: f64 = 0.0254; -const POUND: f64 = 0.453_592_37; -const GALLON: f64 = 3.785_411_784; -const DAY: f64 = 86_400.0; - -/// Names match exactly first (`Mb` megabit, `MB` megabyte), then ignoring case. -const UNITS: &[Unit] = &[ - u!(&["m", "meter", "meters", "metre", "metres", "mètre", "mètres"], Dim::Length, 1.0), - u!(&["km", "kilometer", "kilometers", "kilometre", "kilomètre", "kilomètres"], Dim::Length, 1000.0), - u!(&["cm", "centimeter", "centimeters", "centimètre", "centimètres"], Dim::Length, 0.01), - u!(&["mm", "millimeter", "millimeters", "millimètre", "millimètres"], Dim::Length, 0.001), - u!(&["µm", "um", "micron", "microns"], Dim::Length, 1e-6), - u!(&["nm", "nanometer", "nanometers"], Dim::Length, 1e-9), - u!(&["in", "inch", "inches", "pouce", "pouces", "\""], Dim::Length, INCH), - u!(&["ft", "foot", "feet", "pied", "pieds", "'"], Dim::Length, FOOT), - u!(&["yd", "yard", "yards"], Dim::Length, 0.9144), - u!(&["mi", "mile", "miles"], Dim::Length, MILE), - u!(&["nmi"], Dim::Length, 1852.0), - u!(&["au"], Dim::Length, 149_597_870_700.0), - u!(&["ly", "lightyear", "lightyears", "al"], Dim::Length, 9_460_730_472_580_800.0), - u!(&["m2", "m²", "sqm"], Dim::Area, 1.0), - u!(&["km2", "km²"], Dim::Area, 1e6), - u!(&["cm2", "cm²"], Dim::Area, 1e-4), - u!(&["mm2", "mm²"], Dim::Area, 1e-6), - u!(&["ha", "hectare", "hectares"], Dim::Area, 1e4), - u!(&["acre", "acres"], Dim::Area, 4_046.856_422_4), - u!(&["ft2", "ft²", "sqft"], Dim::Area, FOOT * FOOT), - u!(&["in2", "in²", "sqin"], Dim::Area, INCH * INCH), - u!(&["mi2", "mi²", "sqmi"], Dim::Area, MILE * MILE), - u!(&["l", "L", "liter", "liters", "litre", "litres"], Dim::Volume, 1.0), - u!(&["ml", "mL", "milliliter", "millilitre"], Dim::Volume, 0.001), - u!(&["cl", "cL", "centilitre"], Dim::Volume, 0.01), - u!(&["dl", "dL", "decilitre"], Dim::Volume, 0.1), - u!(&["m3", "m³"], Dim::Volume, 1000.0), - u!(&["cm3", "cm³", "cc"], Dim::Volume, 0.001), - u!(&["gal", "gallon", "gallons"], Dim::Volume, GALLON), - u!(&["ukgal"], Dim::Volume, 4.546_09), - u!(&["qt", "quart", "quarts"], Dim::Volume, GALLON / 4.0), - u!(&["pt", "pint", "pints"], Dim::Volume, GALLON / 8.0), - u!(&["cup", "cups", "tasse", "tasses"], Dim::Volume, GALLON / 16.0), - u!(&["floz"], Dim::Volume, GALLON / 128.0), - u!(&["tbsp", "tablespoon", "càs"], Dim::Volume, GALLON / 256.0), - u!(&["tsp", "teaspoon", "càc"], Dim::Volume, GALLON / 768.0), - u!(&["kg", "kilogram", "kilograms", "kilo", "kilos", "kilogramme", "kilogrammes"], Dim::Mass, 1.0), - u!(&["g", "gram", "grams", "gramme", "grammes"], Dim::Mass, 0.001), - u!(&["mg", "milligram", "milligrams", "milligramme"], Dim::Mass, 1e-6), - u!(&["t", "tonne", "tonnes", "ton"], Dim::Mass, 1000.0), - u!(&["lb", "lbs", "pound", "pounds", "livre", "livres"], Dim::Mass, POUND), - u!(&["oz", "ounce", "ounces", "once", "onces"], Dim::Mass, POUND / 16.0), - u!(&["st", "stone", "stones"], Dim::Mass, POUND * 14.0), - u!(&["ct", "carat", "carats"], Dim::Mass, 0.0002), - u!(&["s", "sec", "second", "seconds", "seconde", "secondes"], Dim::Time, 1.0), - u!(&["ms", "millisecond", "milliseconds", "milliseconde"], Dim::Time, 0.001), - u!(&["µs", "us", "microsecond", "microseconds"], Dim::Time, 1e-6), - u!(&["ns", "nanosecond", "nanoseconds"], Dim::Time, 1e-9), - u!(&["min", "minute", "minutes"], Dim::Time, 60.0), - u!(&["h", "hr", "hour", "hours", "heure", "heures"], Dim::Time, 3600.0), - u!(&["d", "day", "days", "jour", "jours"], Dim::Time, DAY), - u!(&["wk", "week", "weeks", "semaine", "semaines"], Dim::Time, 7.0 * DAY), - u!(&["mo", "month", "months", "mois"], Dim::Time, 30.436_875 * DAY), - u!(&["yr", "year", "years", "an", "ans", "année", "années"], Dim::Time, 365.2425 * DAY), - u!(&["m/s", "mps"], Dim::Speed, 1.0), - u!(&["km/h", "kmh", "kph"], Dim::Speed, 1.0 / 3.6), - u!(&["mph", "mi/h"], Dim::Speed, MILE / 3600.0), - u!(&["kn", "knot", "knots", "noeud", "noeuds", "nœud", "nœuds"], Dim::Speed, 1852.0 / 3600.0), - u!(&["ft/s", "fps"], Dim::Speed, FOOT), - Unit { names: &["c", "°c", "celsius"], dim: Dim::Temperature, factor: 1.0, offset: 273.15 }, - Unit { - names: &["f", "°f", "fahrenheit"], - dim: Dim::Temperature, - factor: 5.0 / 9.0, - offset: 273.15 - 32.0 * 5.0 / 9.0, - }, - u!(&["k", "kelvin"], Dim::Temperature, 1.0), - u!(&["B", "byte", "bytes", "octet", "octets", "o"], Dim::Data, 1.0), - u!(&["bit", "bits", "b"], Dim::Data, 0.125), - u!(&["KB", "kB", "kilobyte", "Ko"], Dim::Data, 1e3), - u!(&["MB", "megabyte", "Mo"], Dim::Data, 1e6), - u!(&["GB", "gigabyte", "Go"], Dim::Data, 1e9), - u!(&["TB", "terabyte", "To"], Dim::Data, 1e12), - u!(&["PB", "petabyte"], Dim::Data, 1e15), - u!(&["KiB", "kibibyte", "Kio"], Dim::Data, 1024.0), - u!(&["MiB", "mebibyte", "Mio"], Dim::Data, 1_048_576.0), - u!(&["GiB", "gibibyte", "Gio"], Dim::Data, 1_073_741_824.0), - u!(&["TiB", "tebibyte", "Tio"], Dim::Data, 1_099_511_627_776.0), - u!(&["Kb", "kbit", "kilobit"], Dim::Data, 125.0), - u!(&["Mb", "mbit", "megabit"], Dim::Data, 125_000.0), - u!(&["Gb", "gbit", "gigabit"], Dim::Data, 125_000_000.0), - u!(&["J", "joule", "joules"], Dim::Energy, 1.0), - u!(&["kJ", "kilojoule", "kilojoules"], Dim::Energy, 1000.0), - u!(&["cal", "calorie", "calories"], Dim::Energy, 4.184), - u!(&["kcal", "kilocalorie", "kilocalories", "Cal"], Dim::Energy, 4184.0), - u!(&["Wh", "watt-hour"], Dim::Energy, 3600.0), - u!(&["kWh", "kilowatt-hour"], Dim::Energy, 3.6e6), - u!(&["eV", "electronvolt"], Dim::Energy, 1.602_176_634e-19), - u!(&["BTU", "btu"], Dim::Energy, 1055.06), - u!(&["W", "watt", "watts"], Dim::Power, 1.0), - u!(&["kW", "kilowatt", "kilowatts"], Dim::Power, 1000.0), - u!(&["MW", "megawatt", "megawatts"], Dim::Power, 1e6), - u!(&["hp", "horsepower"], Dim::Power, 745.699_872), - u!(&["ch", "cv", "cheval", "chevaux"], Dim::Power, 735.498_75), - u!(&["Pa", "pascal", "pascals"], Dim::Pressure, 1.0), - u!(&["hPa", "hectopascal"], Dim::Pressure, 100.0), - u!(&["kPa", "kilopascal"], Dim::Pressure, 1000.0), - u!(&["bar", "bars"], Dim::Pressure, 1e5), - u!(&["mbar", "millibar"], Dim::Pressure, 100.0), - u!(&["atm", "atmosphere", "atmosphère"], Dim::Pressure, 101_325.0), - u!(&["psi"], Dim::Pressure, 6_894.757_293_168), - u!(&["mmHg", "torr"], Dim::Pressure, 133.322_387_415), - u!(&["rad", "radian", "radians"], Dim::Angle, 1.0), - u!(&["deg", "°", "degree", "degrees", "degré", "degrés"], Dim::Angle, std::f64::consts::PI / 180.0), - u!(&["grad", "gon"], Dim::Angle, std::f64::consts::PI / 200.0), - u!(&["turn", "turns", "tour", "tours"], Dim::Angle, std::f64::consts::TAU), -]; - -fn unit(name: &str) -> Option<&'static Unit> { - UNITS - .iter() - .find(|u| u.names.contains(&name)) - .or_else(|| UNITS.iter().find(|u| u.names.iter().any(|n| n.eq_ignore_ascii_case(name)))) -} - -/// Words that join the value and the target unit: `5 ft to cm`, `70 kg en lb`. -const JOINS: [&str; 7] = ["to", "in", "en", "as", "into", "->", "="]; - -/// `5 ft to cm`, `70kg lb`, `100 f in c`, `1.5 GiB MB`; without a target, the value in every unit of its kind. -pub fn convert(input: &str) -> Result { - let text: &str = input.trim(); - // The value is the leading arithmetic; the unit starts at the first other char (a letter, `°`, `µ`, `'`). - let mut end: usize = 0; - let chars: Vec<(usize, char)> = text.char_indices().collect(); - for (k, &(i, c)) in chars.iter().enumerate() { - let exponent: bool = matches!(c, 'e' | 'E') - && k > 0 - && chars[k - 1].1.is_ascii_digit() - && chars.get(k + 1).is_some_and(|&(_, n)| n.is_ascii_digit() || n == '-' || n == '+'); - if !(c.is_ascii_digit() || "+-*/^.() ,_".contains(c) || exponent) { - break; - } - end = i + c.len_utf8(); - } - let (number, rest): (&str, &str) = text.split_at(end); - let value: f64 = calc(&number.replace(',', "."), None).ok_or("start with a number: 5 ft to cm")?; - // A joining word only between two units, so `in` stays inches in `1 in cm` and `1 in in cm`. - let words: Vec<&str> = rest.split_whitespace().collect(); - let (from, to): (&str, Option<&str>) = match words[..] { - [from] => (from, None), - [from, to] => (from, Some(to)), - [from, join, to] if JOINS.contains(&join.to_ascii_lowercase().as_str()) => (from, Some(to)), - [] => return Err("which unit? 5 ft to cm".into()), - _ => return Err("one unit to convert from, one to convert to: 5 ft to cm".into()), - }; - let from_unit: &Unit = unit(from).ok_or_else(|| format!("unknown unit: {from}"))?; - let base: f64 = value * from_unit.factor + from_unit.offset; - let show = |u: &Unit| format!("{} {}", format_number((base - u.offset) / u.factor), u.names[0]); - let shown: String = match to { - Some(to) => { - let to_unit: &Unit = unit(to).ok_or_else(|| format!("unknown unit: {to}"))?; - if to_unit.dim != from_unit.dim { - return Err(format!("{from} is a {}, {to} a {}", from_unit.dim.name(), to_unit.dim.name())); - } - show(to_unit) - } - None => UNITS - .iter() - .filter(|u| u.dim == from_unit.dim && !std::ptr::eq(*u, from_unit)) - .map(show) - .collect::>() - .join(" · "), - }; - Ok(shown) -} - -// --- Number bases ------------------------------------------------------------------------------------------- - -/// `255`, `0xff`, `0b1010`, `0o17`, `-42` or any whole calculation: the number in decimal, hex, octal and binary, -/// and as a character when it is one. -pub fn bases(input: &str, ans: Option) -> Result { - let text: String = input.trim().replace('_', ""); - let (negative, digits): (bool, &str) = text.strip_prefix('-').map_or((false, text.as_str()), |d| (true, d)); - let lower: String = digits.to_ascii_lowercase(); - let parsed: Option = match lower.get(..2) { - Some("0x") => u128::from_str_radix(&lower[2..], 16).ok(), - Some("0b") => u128::from_str_radix(&lower[2..], 2).ok(), - Some("0o") => u128::from_str_radix(&lower[2..], 8).ok(), - _ => lower.parse().ok(), - }; - let (negative, magnitude): (bool, u128) = if let Some(n) = parsed { - (negative, n) - } else { - let value: f64 = calc(input, ans).filter(|v| v.fract() == 0.0 && v.abs() < 1e30).ok_or("not a whole number")?; - let magnitude: u128 = format!("{:.0}", value.abs()).parse().map_err(|_| "not a whole number")?; - (value < 0.0, magnitude) - }; - let sign: &str = if negative && magnitude != 0 { "-" } else { "" }; - let binary: String = format!("{magnitude:b}"); - // Binary digits in groups of four, from the right. - let grouped: String = binary.chars().enumerate().fold(String::new(), |mut out, (i, c)| { - if i > 0 && (binary.len() - i).is_multiple_of(4) { - out.push('_'); - } - out.push(c); - out - }); - let mut shown: String = - format!("{sign}{magnitude} · {sign}0x{magnitude:x} · {sign}0o{magnitude:o} · {sign}0b{grouped}"); - if !negative && let Some(c) = u32::try_from(magnitude).ok().and_then(char::from_u32).filter(|c| !c.is_control()) { - let _: std::fmt::Result = write!(shown, " · '{c}' U+{magnitude:04X}"); - } - Ok(shown) -} - -// --- Time --------------------------------------------------------------------------------------------------- - -fn now() -> i64 { - SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(0)) -} - -/// Days since 1970-01-01 of a civil date (proleptic Gregorian), after Howard Hinnant's `days_from_civil`. -fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { - let y: i64 = if month <= 2 { year - 1 } else { year }; - let era: i64 = y.div_euclid(400); - let yoe: i64 = y - era * 400; - let doy: i64 = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1; - let doe: i64 = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146_097 + doe - 719_468 -} - -/// The civil date of a day count since 1970-01-01: `(year, month, day)`. -fn civil_from_days(days: i64) -> (i64, i64, i64) { - let z: i64 = days + 719_468; - let era: i64 = z.div_euclid(146_097); - let doe: i64 = z - era * 146_097; - let yoe: i64 = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy: i64 = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp: i64 = (5 * doy + 2) / 153; - let day: i64 = doy - (153 * mp + 2) / 5 + 1; - let month: i64 = if mp < 10 { mp + 3 } else { mp - 9 }; - (yoe + era * 400 + i64::from(month <= 2), month, day) -} - -const WEEKDAYS: [&str; 7] = ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"]; - -/// `YYYY-MM-DD HH:MM:SS` of unix time `ts` shifted by `offset` seconds. -fn datetime(ts: i64, offset: i64) -> String { - let local: i64 = ts + offset; - let (days, secs): (i64, i64) = (local.div_euclid(86_400), local.rem_euclid(86_400)); - let (y, m, d): (i64, i64, i64) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}", secs / 3600, secs / 60 % 60, secs % 60) -} - -fn zone(offset: i64) -> String { - let sign: char = if offset < 0 { '-' } else { '+' }; - format!("UTC{sign}{:02}:{:02}", offset.abs() / 3600, offset.abs() / 60 % 60) -} - -/// A unix time described: local and UTC dates, weekday. -fn describe(ts: i64) -> String { - let offset: i64 = term::utc_offset(ts); - let weekday: &str = WEEKDAYS[usize::try_from(ts.div_euclid(86_400).rem_euclid(7)).unwrap_or(0)]; - format!("{ts} · {} {} · {} UTC · {weekday}", datetime(ts, offset), zone(offset), datetime(ts, 0)) -} - -/// `2026-10-18`, `2026-10-18 14:30`, `2026-10-18T14:30:05Z` (Z: UTC, else local time) as a unix time; the number -/// of words it used. -fn parse_date(words: &[&str]) -> Option<(i64, usize)> { - let first: &str = words.first()?; - let (date, mut clock): (&str, Option<&str>) = match first.split_once('T') { - Some((date, clock)) => (date, Some(clock)), - None => (first, None), - }; - let mut used: usize = 1; - if clock.is_none() - && let Some(next) = words.get(1).copied().filter(|w| w.contains(':') && !w.contains('-')) - { - (clock, used) = (Some(next), 2); - } - let parts: Vec = - date.trim_end_matches('Z').split('-').map(str::parse).collect::, _>>().ok()?; - let [y, m, d]: [i64; 3] = parts.try_into().ok()?; - if !(1..=12).contains(&m) || !(1..=31).contains(&d) { - return None; - } - let utc: bool = clock.is_some_and(|c| c.ends_with('Z')) || date.ends_with('Z'); - let clock: Vec = match clock.map(|c| c.trim_end_matches('Z')) { - Some(c) => c.split(':').map(str::parse).collect::, _>>().ok()?, - None => Vec::new(), - }; - if clock.len() > 3 || clock.iter().any(|n| !(0..60).contains(n)) || clock.first().is_some_and(|h| *h > 23) { - return None; - } - let secs: i64 = clock.iter().chain([&0, &0, &0]).take(3).fold(0, |acc, n| acc * 60 + n); - let local: i64 = days_from_civil(y, m, d) * 86_400 + secs; - // The offset in effect at that local time: taken at the UTC guess, which is right outside the hour of a switch. - Some((if utc { local } else { local - term::utc_offset(local) }, used)) -} - -/// `now`, a unix time (seconds, or milliseconds when it is that large), a date, or two dates (their difference). -pub fn time(input: &str, now: i64) -> Result { - let text: &str = input.trim(); - if text.is_empty() || text.eq_ignore_ascii_case("now") { - return Ok(describe(now)); - } - if let Ok(n) = text.parse::() { - return Ok(if n.abs() > 100_000_000_000 { - format!("{} ms · {}", n, describe(n.div_euclid(1000))) - } else { - describe(n) - }); - } - let words: Vec<&str> = text - .split_whitespace() - .filter(|w| !matches!(w.to_ascii_lowercase().as_str(), "to" | "-" | "à" | "->")) - .collect(); - let (start, used): (i64, usize) = parse_date(&words).ok_or("a date is YYYY-MM-DD [HH:MM[:SS]]")?; - if used == words.len() { - return Ok(describe(start)); - } - let (end, more): (i64, usize) = parse_date(&words[used..]).ok_or("a date is YYYY-MM-DD [HH:MM[:SS]]")?; - if used + more != words.len() { - return Err("at most two dates".into()); - } - let secs: i64 = (end - start).abs(); - let sign: &str = if end < start { "-" } else { "" }; - Ok(format!("{sign}{} days {} h {} min · {sign}{secs} s", secs / 86_400, secs / 3600 % 24, secs / 60 % 60)) -} - -// --- Colors ------------------------------------------------------------------------------------------------- - -/// `#f80`, `#ff8800`, `ff8800`, `rgb(255, 136, 0)`, `255 136 0`, `hsl(32, 100%, 50%)`: the color in all three forms. -pub fn color(input: &str) -> Result { - let text: String = input.trim().to_ascii_lowercase(); - let numbers = |inner: &str| -> Option> { - inner - .split([',', ' ', '/']) - .filter(|p| !p.is_empty()) - .map(|p| p.trim_end_matches(['%', '°']).parse().ok()) - .collect() - }; - let rgb: [u8; 3] = if let Some(inner) = text.strip_prefix("hsl(").and_then(|t| t.strip_suffix(')')) { - let [h, s, l]: [f64; 3] = numbers(inner).and_then(|n| n.try_into().ok()).ok_or("hsl(h, s%, l%)")?; - hsl_to_rgb(h, s / 100.0, l / 100.0) - } else if let Some(hex) = - text.strip_prefix('#').or_else(|| text.chars().all(|c| c.is_ascii_hexdigit()).then_some(&text[..])) - { - let full: String = match hex.len() { - 3 => hex.chars().flat_map(|c| [c, c]).collect(), - 6 => hex.to_owned(), - _ => return Err("#rgb or #rrggbb".into()), - }; - let value: u32 = u32::from_str_radix(&full, 16).map_err(|_| "#rgb or #rrggbb")?; - let [_, r, g, b]: [u8; 4] = value.to_be_bytes(); - [r, g, b] - } else { - let inner: &str = text.strip_prefix("rgb(").and_then(|t| t.strip_suffix(')')).unwrap_or(&text); - let parts: Vec = numbers(inner).ok_or("rgb(r, g, b) with 0-255")?; - let [r, g, b]: [f64; 3] = parts.try_into().map_err(|_| "rgb(r, g, b) with 0-255")?; - if [r, g, b].iter().any(|c| !(0.0..=255.0).contains(c)) { - return Err("rgb(r, g, b) with 0-255".into()); - } - [channel(r / 255.0), channel(g / 255.0), channel(b / 255.0)] - }; - let [r, g, b]: [u8; 3] = rgb; - let (h, s, l): (f64, f64, f64) = rgb_to_hsl(rgb); - Ok(format!("#{r:02x}{g:02x}{b:02x} · rgb({r}, {g}, {b}) · hsl({:.0}, {:.0}%, {:.0}%)", h, s * 100.0, l * 100.0)) -} - -/// A 0..=1 fraction as a 0..=255 channel. -#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "clamped to 0..=255 first")] -fn channel(x: f64) -> u8 { - (x.clamp(0.0, 1.0) * 255.0).round() as u8 -} - -fn hsl_to_rgb(h: f64, s: f64, l: f64) -> [u8; 3] { - let (s, l): (f64, f64) = (s.clamp(0.0, 1.0), l.clamp(0.0, 1.0)); - let c: f64 = (1.0 - (2.0 * l - 1.0).abs()) * s; - let h: f64 = h.rem_euclid(360.0) / 60.0; - let x: f64 = c * (1.0 - (h % 2.0 - 1.0).abs()); - let (r, g, b): (f64, f64, f64) = match h { - h if h < 1.0 => (c, x, 0.0), - h if h < 2.0 => (x, c, 0.0), - h if h < 3.0 => (0.0, c, x), - h if h < 4.0 => (0.0, x, c), - h if h < 5.0 => (x, 0.0, c), - _ => (c, 0.0, x), - }; - let m: f64 = l - c / 2.0; - [channel(r + m), channel(g + m), channel(b + m)] -} - -fn rgb_to_hsl([r, g, b]: [u8; 3]) -> (f64, f64, f64) { - let (r, g, b): (f64, f64, f64) = (f64::from(r) / 255.0, f64::from(g) / 255.0, f64::from(b) / 255.0); - let (max, min): (f64, f64) = (r.max(g).max(b), r.min(g).min(b)); - let l: f64 = f64::midpoint(max, min); - let d: f64 = max - min; - if d == 0.0 { - return (0.0, 0.0, l); - } - let s: f64 = d / (1.0 - (2.0 * l - 1.0).abs()); - // `max` is one of the three channels exactly, so comparing floats for equality is right here. - let h: f64 = if max.to_bits() == r.to_bits() { - 60.0 * ((g - b) / d).rem_euclid(6.0) - } else if max.to_bits() == g.to_bits() { - 60.0 * ((b - r) / d + 2.0) - } else { - 60.0 * ((r - g) / d + 4.0) - }; - (h, s, l) -} - -// --- JSON --------------------------------------------------------------------------------------------------- - -/// `text` re-laid out (two-space indent, or `minify`d), token by token, so numbers and strings stay exactly as -/// written. Invalid JSON is refused. `//` and `/* */` comments (JSONC, as config files) are kept: one after a -/// token stays on its line, one on its own line keeps it; minifying drops them, a `//` would swallow the rest. -pub fn json(text: &str, minify: bool) -> Result { - json::parse(text).map_err(|e| format!("invalid JSON: {e}"))?; - let chars: Vec = text.chars().collect(); - let mut out: String = String::with_capacity(text.len()); - let (mut depth, mut in_string, mut escaped, mut empty): (usize, bool, bool, bool) = (0, false, false, false); - // A line break waits for the next token, so a comment after a token can still join its line. - let (mut pending, mut broke): (bool, bool) = (false, false); - let flush = |out: &mut String, pending: &mut bool, depth: usize| { - if std::mem::take(pending) && !minify { - out.push('\n'); - out.push_str(&" ".repeat(depth)); - } - }; - let mut i: usize = 0; - while let Some(&c) = chars.get(i) { - i += 1; - if in_string { - out.push(c); - if escaped { - escaped = false; - } else if c == '\\' { - escaped = true; - } else if c == '"' { - in_string = false; - } - continue; - } - if c == '/' && matches!(chars.get(i), Some('/' | '*')) { - let line: bool = chars.get(i) == Some(&'/'); - // The parser accepted the text, so a block comment is closed. - let end: usize = if line { - chars[i..].iter().position(|&c| c == '\n').map_or(chars.len(), |p| i + p) - } else { - chars[i + 1..].windows(2).position(|w| w == ['*', '/']).map_or(chars.len(), |p| i + 1 + p + 2) - }; - let comment: String = chars[i - 1..end].iter().collect::().trim_end().to_owned(); - i = end; - if minify { - continue; - } - // On its own line in the source: keeps one. Otherwise it joins the token before it. - let own_line: bool = broke || out.is_empty(); - if own_line { - pending |= !out.is_empty(); - flush(&mut out, &mut pending, depth); - } else if !out.ends_with(' ') { - out.push(' '); - } - out.push_str(&comment); - if line || own_line { - pending = true; - } else if !pending { - // Inside a line (`"a": /* x */ 1`): stays there. - out.push(' '); - } - continue; - } - match c { - '\n' => broke = true, - c if c.is_whitespace() => {} - '{' | '[' => { - flush(&mut out, &mut pending, depth); - out.push(c); - depth += 1; - empty = chars[i..].iter().find(|c| !c.is_whitespace()).is_some_and(|n| matches!(n, '}' | ']')); - pending = !empty; - } - '}' | ']' => { - depth = depth.saturating_sub(1); - pending |= !std::mem::take(&mut empty); - flush(&mut out, &mut pending, depth); - out.push(c); - } - ',' => { - flush(&mut out, &mut pending, depth); - out.push(c); - pending = true; - } - ':' => { - flush(&mut out, &mut pending, depth); - out.push_str(if minify { ":" } else { ": " }); - } - c => { - flush(&mut out, &mut pending, depth); - in_string = c == '"'; - out.push(c); - } - } - if !c.is_whitespace() { - broke = false; - } - } - Ok(out) -} - -// --- Generated text ----------------------------------------------------------------------------------------- - -/// Random bytes from the system's source (`/dev/urandom`, `RtlGenRandom`); if that fails, `SipHash` over a counter -/// keyed by std's per-process random keys. -fn random_bytes(n: usize) -> Vec { - random_bytes_from(n, term::random) -} - -/// `random_bytes` with the system source given, so the fallback can be tested. -fn random_bytes_from(n: usize, system: fn(&mut [u8]) -> bool) -> Vec { - let mut bytes: Vec = vec![0; n]; - if !system(&mut bytes) { - let keys: RandomState = RandomState::new(); - bytes = (0..).map(|i: u64| keys.hash_one(i)).flat_map(u64::to_le_bytes).take(n).collect(); - } - bytes -} - -fn uuid() -> String { - let mut b: Vec = random_bytes(16); - b[6] = (b[6] & 0x0f) | 0x40; - b[8] = (b[8] & 0x3f) | 0x80; - let hex: String = b.iter().fold(String::new(), |mut s, x| { - let _: std::fmt::Result = write!(s, "{x:02x}"); - s - }); - format!("{}-{}-{}-{}-{}", &hex[..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..]) -} - -/// 20 chars: letters, digits and symbols, picked without bias (bytes past the last whole set are redrawn). -fn password() -> String { - const SET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*-_=+?"; - let limit: usize = 256 - 256 % SET.len(); - let mut out: String = String::new(); - while out.len() < 20 { - for b in random_bytes(32) { - let b: usize = usize::from(b); - if b < limit && out.len() < 20 { - out.push(char::from(SET[b % SET.len()])); - } - } - } - out -} - -const LOREM: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et \ -dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo \ -consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur."; - -impl Generated { - pub fn text(self) -> String { - let now: i64 = now(); - let local: String = datetime(now, term::utc_offset(now)); - match self { - Self::Date => local[..10].to_owned(), - Self::DateTime => local, - Self::Uuid => uuid(), - Self::Password => password(), - Self::Lorem => LOREM.to_owned(), - } - } -} - -// --- In the editor ------------------------------------------------------------------------------------------ - -/// A tool window's answer line: `= 42` or `! why`. -fn is_answer(line: &str) -> bool { - line.starts_with("= ") || line.starts_with("! ") -} - -impl Editor { - /// `xc`…: the tool's popup over the windows (one popup, shared by the tools), focused, in insert mode - /// on its last line. - pub(super) fn open_tool(&mut self, tool: Tool) { - let existing: Option = - self.buffers.iter().position(|b| b.throwaway && b.title.as_deref() == Some(tool.title())); - let index: usize = existing.unwrap_or_else(|| { - let mut buf: Buffer = Buffer::scratch(); - buf.title = Some(tool.title().to_owned()); - buf.throwaway = true; - self.buffers.push(buf); - self.buffers.len() - 1 - }); - if let Some(window) = self.windows.iter().position(|w| w.buffer == index) { - self.focus(window); - } else if let Some(popup) = self.windows.iter().position(|w| w.floating) { - self.focus(popup); - self.switch_to(index); - } else { - let mut popup: super::Window = - super::Window::new(self.next_window_id, index, super::View::of(&self.buffers[index])); - popup.floating = true; - self.next_window_id += 1; - self.windows.push(popup); - self.focus(self.windows.len() - 1); - } - self.dashboard = false; - let buf: &mut Buffer = self.buf_mut(); - let last: usize = buf.last_line(); - buf.cursor = Pos::new(last, buf.line_len(last)); - self.mode = super::Mode::Insert; - } - - /// The help a tool's popup shows in its bottom border, when `buffer` is a tool's. - pub fn popup_hint(&self, buffer: usize) -> Option { - let buf: &Buffer = self.buffers.get(buffer)?; - let tool: Tool = Tool::ALL.into_iter().find(|t| buf.throwaway && buf.title.as_deref() == Some(t.title()))?; - Some(format!("{} · answers", tool.hint())) - } - - /// The tool whose window holds the current buffer. - fn current_tool(&self) -> Option { - let buf: &Buffer = self.buf(); - Tool::ALL.into_iter().find(|t| buf.throwaway && buf.title.as_deref() == Some(t.title())) - } - - /// `` in a tool window: answers the line under the cursor on the line below it (`= …`, or `! …` for an - /// error, replacing an older answer there), yanks the answer into `"`, and goes on at an empty last line. - /// False outside tool windows. On an answer line it answers the question above. - pub(super) fn tool_enter(&mut self) -> bool { - let Some(tool): Option = self.current_tool() else { return false }; - let mut line: usize = self.buf().cursor.line; - while line > 0 && is_answer(self.buf().line(line)) { - line -= 1; - } - let input: String = self.buf().line(line).trim().to_owned(); - if is_answer(&input) || (input.is_empty() && tool != Tool::Time) { - return true; - } - let shown: String = match tool.run(&input, self.calc_ans) { - Ok(answer) => { - if tool == Tool::Calc { - self.calc_ans = calc(&input, self.calc_ans); - } - self.store_register(Register { text: answer.clone(), linewise: false, block: false }, true); - format!("= {answer}") - } - Err(why) => format!("! {why}"), - }; - let buf: &mut Buffer = self.buf_mut(); - buf.seal(); - let next: usize = line + 1; - if next <= buf.last_line() && is_answer(buf.line(next)) { - buf.set_line(next, shown); - } else { - let end: usize = buf.line_len(line); - buf.insert(Pos::new(line, end), &format!("\n{shown}")); - } - let last: usize = buf.last_line(); - if !buf.line(last).is_empty() { - let end: usize = buf.line_len(last); - buf.insert(Pos::new(last, end), "\n"); - } - buf.cursor = Pos::new(buf.last_line(), 0); - true - } - - pub(super) fn open_case_prompt(&mut self) { - self.open_prompt("Case: snake camel pascal kebab constant title lower upper", "", PromptAction::Case); - } - - /// `xk`: the WORD under the cursor (blank-separated) in the typed case. - pub(super) fn case_word(&mut self, input: &str) -> Result { - let case: WordCase = WordCase::parse(input).ok_or_else(|| format!("unknown case: {input}"))?; - let buf: &mut Buffer = self.buf_mut(); - let Pos { line, col }: Pos = buf.cursor; - let chars: Vec = buf.line(line).chars().collect(); - if chars.get(col).is_none_or(|c| c.is_whitespace()) { - return Err("no word under the cursor".into()); - } - let start: usize = chars[..col].iter().rposition(|c| c.is_whitespace()).map_or(0, |i| i + 1); - let end: usize = chars[col..].iter().position(|c| c.is_whitespace()).map_or(chars.len(), |i| col + i); - let word: String = chars[start..end].iter().collect(); - let new: String = case.apply(&word); - if new != word && !new.is_empty() { - buf.seal(); - buf.remove(Pos::new(line, start), Pos::new(line, end)); - buf.insert(Pos::new(line, start), &new); - buf.cursor = Pos::new(line, start); - } - Ok(new) - } - - /// `xd`, `xi`…: the text goes in at the cursor, as one undo step. - pub(super) fn insert_generated(&mut self, what: Generated) { - let text: String = what.text(); - let buf: &mut Buffer = self.buf_mut(); - let at: Pos = buf.cursor; - buf.seal(); - buf.insert(at, &text); - buf.cursor = Pos::new(at.line, at.col + text.chars().count().saturating_sub(1)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn eval(expr: &str) -> String { - calc(expr, Some(10.0)).map_or_else(|| "none".into(), format_number) - } - - #[test] - fn calculator() { - assert_eq!(eval("1 + 2 * 3"), "7"); - assert_eq!(eval("0.1 + 0.2"), "0.3"); - assert_eq!(eval("-2^2"), "-4"); - assert_eq!(eval("2^3^2"), "512"); - assert_eq!(eval("2pi"), eval("2 * pi")); - assert_eq!(eval("3(4+1)"), "15"); - assert_eq!(eval("2pi(1+1)"), eval("4pi"), "a constant before ( multiplies"); - assert_eq!(eval("ans(2)"), "20"); - assert_eq!(eval("sqrt(16) + abs(-3)"), "7"); - assert_eq!(eval("log(8, 2)"), "3"); - assert_eq!(eval("max(1, 7, 3)"), "7"); - assert_eq!(eval("5!"), "120"); - assert_eq!(eval("200 * 15%"), "30"); - assert_eq!(eval("10 % 3"), "1"); - assert_eq!(eval("0xff + 0b11 + 0o10"), "266"); - assert_eq!(eval("1.5e3"), "1500"); - assert_eq!(eval("ans * 2"), "20"); - assert_eq!(eval("1e20"), "1e20"); - assert_eq!(eval("1/3"), "0.3333333333"); - assert_eq!(eval("10 / 0"), "none"); - assert_eq!(eval("2 +"), "none"); - assert_eq!(eval("foo"), "none"); - assert_eq!(eval(&"(".repeat(200)), "none"); - } - - #[test] - fn units() { - assert_eq!(convert("5 ft to cm").unwrap(), "152.4 cm"); - assert_eq!(convert("70kg en lb").unwrap(), "154.3235835294 lb"); - assert_eq!(convert("100 f in c").unwrap(), "37.7777777778 c"); - assert_eq!(convert("-40 c f").unwrap(), "-40 f"); - assert_eq!(convert("1 GiB MB").unwrap(), "1073.741824 MB"); - assert_eq!(convert("8 Mb to MB").unwrap(), "1 MB"); - assert_eq!(convert("90 km/h mph").unwrap(), "55.9234073014 mph"); - assert_eq!(convert("1,5 h min").unwrap(), "90 min"); - assert!(convert("2 m").unwrap().contains("200 cm"), "every unit of the kind"); - assert_eq!(convert("1 m to kg").unwrap_err(), "m is a length, kg a mass"); - // Names are unique (case aside, which tells `Mb` from `MB`), so none hides another. - let names: Vec<&str> = UNITS.iter().flat_map(|u| u.names.iter().copied()).collect(); - for (i, name) in names.iter().enumerate() { - assert!(!names[i + 1..].contains(name), "{name} names two units"); - } - // Every unit converts to the first of its kind, and names its kind when they differ. - for unit in UNITS { - let first: &Unit = UNITS.iter().find(|u| u.dim == unit.dim).unwrap(); - for name in unit.names { - assert!(convert(&format!("2 {name} to {}", first.names[0])).is_ok(), "{name}"); - } - let other: &Unit = UNITS.iter().find(|u| u.dim != unit.dim).unwrap(); - let message: String = convert(&format!("1 {} {}", unit.names[0], other.names[0])).unwrap_err(); - assert!(message.contains(unit.dim.name()), "{message}"); - } - assert!(convert("5").is_err()); - assert!(convert("5 ft cm in").is_err()); - assert_eq!(convert("1 in cm").unwrap(), "2.54 cm", "in is inches"); - assert_eq!(convert("1 in in cm").unwrap(), "2.54 cm"); - assert_eq!(convert("1 ft to in").unwrap(), "12 in"); - assert!(convert("1 parsec cm").is_err()); - assert!(convert("ft to cm").is_err()); - } - - #[test] - fn bases_and_colors() { - assert_eq!(bases("255", None).unwrap(), "255 · 0xff · 0o377 · 0b1111_1111 · 'ÿ' U+00FF"); - assert_eq!(bases("0x41", None).unwrap(), "65 · 0x41 · 0o101 · 0b100_0001 · 'A' U+0041"); - assert_eq!(bases("-0b101", None).unwrap(), "-5 · -0x5 · -0o5 · -0b101"); - assert_eq!(bases("2^40", None).unwrap().split(" · ").nth(1), Some("0x10000000000")); - assert!(bases("1.5", None).is_err()); - assert_eq!(color("#f80").unwrap(), "#ff8800 · rgb(255, 136, 0) · hsl(32, 100%, 50%)"); - // Every sixth of the hue circle, and grey, both ways. - for hue in [0, 90, 150, 210, 270, 330] { - let shown: String = color(&format!("hsl({hue}, 100%, 50%)")).unwrap(); - assert!(shown.ends_with(&format!("hsl({hue}, 100%, 50%)")), "{shown}"); - } - assert_eq!(color("#808080").unwrap(), "#808080 · rgb(128, 128, 128) · hsl(0, 0%, 50%)"); - assert!(color("hsl(1, 2)").is_err()); - assert!(color("1 2").is_err()); - assert_eq!(color("rgb(255, 136, 0)").unwrap(), color("ff8800").unwrap()); - assert_eq!(color("hsl(32, 100%, 50%)").unwrap().get(..7), Some("#ff8800")); - assert!(color("#12").is_err()); - assert!(color("300 0 0").is_err()); - } - - #[test] - fn dates() { - assert_eq!(civil_from_days(0), (1970, 1, 1)); - assert_eq!(days_from_civil(2000, 2, 29), 11_016); - for days in [-800_000, -1, 0, 59, 11_016, 20_000, 3_000_000] { - let (y, m, d): (i64, i64, i64) = civil_from_days(days); - assert_eq!(days_from_civil(y, m, d), days); - } - assert_eq!(datetime(1_700_000_000, 0), "2023-11-14 22:13:20"); - assert!(time("1700000000", 0).unwrap().contains("2023-11-14 22:13:20 UTC · Tuesday")); - assert!(time("1700000000000", 0).unwrap().starts_with("1700000000000 ms · 1700000000")); - assert!(time("2023-11-14T22:13:20Z", 0).unwrap().starts_with("1700000000 ·")); - assert_eq!(time("2026-01-01Z to 2026-01-08T12:00Z", 0).unwrap(), "7 days 12 h 0 min · 648000 s"); - assert!(time("2026-13-01", 0).is_err()); - assert_eq!(time("2026-01-01Z 2026-01-02Z 2026-01-03Z", 0).unwrap_err(), "at most two dates"); - assert!(time("2026-01-01 25:00", 0).is_err()); - assert!(time("2026-01-01 1:2:3:4", 0).is_err()); - assert!(time("2026-01-01 x:00", 0).is_err()); - assert!(time("2026-01-02Z 2026-01-01Z", 0).unwrap().starts_with("-1 days")); - assert!(time("now", 1_700_000_000).unwrap().starts_with("1700000000 ·")); - assert!(time("", 1_700_000_000).unwrap().starts_with("1700000000 ·")); - assert!(time("soon", 0).is_err()); - } - - /// Every syntax the calculator reads, and a clear message for every way it can fail. - #[test] - fn calculator_reads_everything_and_explains_errors() { - let ok = |expr: &str| evaluate(expr, Some(10.0)).map(format_number); - assert_eq!(ok("[1+2]*{3}"), Ok("9".into()), "other brackets"); - assert_eq!(ok("2×3·4∙1 ÷ 4 − 1"), Ok("5".into()), "typographic operators"); - assert_eq!(ok("2**3**2"), Ok("512".into()), "** is a power"); - assert_eq!(ok("√16 + √(9)²"), Ok("13".into())); - assert_eq!(ok("2³ + π - pi"), Ok("8".into())); - assert_eq!(ok("sqrt(2"), ok("sqrt(2)"), "open parentheses close at the end"); - assert_eq!(ok("((1+2"), Ok("3".into())); - assert_eq!(ok("1,5 * 2"), Ok("3".into()), "decimal comma"); - assert_eq!(ok("max(1,5)"), Ok("5".into()), "a comma between arguments"); - assert_eq!(ok("1e5-3"), Ok("99997".into()), "the sign after e only"); - assert_eq!(ok("1E+2 + 2e"), ok("100 + 2e")); - assert_eq!(ok("(1+1)(2)"), Ok("4".into())); - assert_eq!(ok("tau/2 - phi + phi"), ok("pi")); - let err = |expr: &str| evaluate(expr, None).unwrap_err(); - assert_eq!(err(""), "type an expression, e.g. 2+3*4"); - assert_eq!(err(" _ "), "type an expression, e.g. 2+3*4"); - assert_eq!(err("2+3)"), "unmatched ) at column 4"); - assert_eq!(err("2 + $"), "unexpected $ at column 5"); - assert_eq!(err("2 +"), "incomplete: something is missing after +"); - assert_eq!(err("()"), "empty parentheses at column 1"); - assert_eq!(err("foo + 1"), "unknown name foo at column 1"); - assert_eq!(err("foo(1)"), "unknown function foo"); - assert_eq!(err("sqrt(1, 2)"), "wrong number of arguments for sqrt"); - assert_eq!(err("hypot(1)"), "wrong number of arguments for hypot"); - assert_eq!(err("max()"), "unexpected ) at column 5"); - assert_eq!(err("ans"), "no previous answer for ans yet"); - assert_eq!(err("2.5!"), "! needs a whole number from 0 to 170"); - assert_eq!(err("171!"), "! needs a whole number from 0 to 170"); - assert_eq!(err("sqrt(-1)"), "undefined result (like sqrt(-1), log(0) or 0/0)"); - assert_eq!(err("1/0"), "infinite result (a division by zero or an overflow)"); - assert_eq!(err("1.2.3"), "bad number 1.2.3 at column 1"); - assert_eq!(err("0x"), "bad number at column 1"); - assert_eq!(err("0xffffffffffffffffff"), "bad number at column 1"); - assert_eq!(err(&"(".repeat(100)), "too deeply nested"); - assert_eq!(err(&"√".repeat(100)), "too deeply nested"); - assert_eq!(err(&format!("{}1", "sqrt(".repeat(100))), "too deeply nested"); - assert_eq!(err("sqrt(1+)"), "unexpected ) at column 8"); - assert_eq!(ok("(1 000 000"), Ok("1000000".into()), "blanks inside a number are thousands separators"); - for function in [ - "sqrt", "cbrt", "abs", "floor", "ceil", "round", "trunc", "sign", "exp", "ln", "log2", "log", "log10", - "sin", "cos", "tan", "asin", "acos", "atan", "sinh", "cosh", "tanh", "rad", "deg", - ] { - assert!(evaluate(&format!("{function}(0.5)"), None).is_ok(), "{function}"); - } - assert_eq!(ok("pow(2, 10) + hypot(3, 4) + min(4, 2)"), Ok("1031".into())); - } - - #[test] - fn cases_json_and_generated() { - assert_eq!(words("parseHTTPServer_v2-x"), ["parse", "HTTP", "Server", "v2", "x"]); - let all: Vec = WordCase::NAMES.iter().map(|(_, c)| c.apply("fooBar baz")).collect(); - assert_eq!( - all, - [ - "foo_bar_baz", - "fooBarBaz", - "FooBarBaz", - "foo-bar-baz", - "FOO_BAR_BAZ", - "Foo Bar Baz", - "foo bar baz", - "FOO BAR BAZ" - ] - ); - assert_eq!(WordCase::parse("k"), Some(WordCase::Kebab)); - assert_eq!(WordCase::parse(""), None); - let text: &str = r#"{"a": [1, 2.50, {}], "b": {"c": "x,{y}"}, "d": []}"#; - assert_eq!(json(text, true).unwrap(), r#"{"a":[1,2.50,{}],"b":{"c":"x,{y}"},"d":[]}"#); - assert_eq!( - json(text, false).unwrap(), - "{\n \"a\": [\n 1,\n 2.50,\n {}\n ],\n \"b\": {\n \"c\": \"x,{y}\"\n },\n \"d\": []\n}" - ); - assert!(json("{\"a\": }", false).is_err()); - // JSONC: comments keep their place, trailing or on their own line; minifying drops them. - let commented: &str = - "// top\n{\"a\": 1, // one\n /* own line */\n\"b\": /* inline */ [2,\n3], \"c\": 4 // last\n}"; - assert_eq!( - json(commented, false).unwrap(), - "// top\n{\n \"a\": 1, // one\n /* own line */\n \"b\": /* inline */ [\n 2,\n 3\n ],\n \"c\": 4 // last\n}" - ); - assert_eq!(json(commented, true).unwrap(), r#"{"a":1,"b":[2,3],"c":4}"#); - assert_eq!( - json("{\"url\": \"http://x/*y*/\"}", true).unwrap(), - "{\"url\":\"http://x/*y*/\"}", - "not in strings" - ); - let formatted: String = json(commented, false).unwrap(); - assert_eq!(json(&formatted, false).unwrap(), formatted, "formatting twice changes nothing"); - let config: String = json(crate::config::EXAMPLE, false).unwrap(); - assert_eq!(config.matches("//").count(), crate::config::EXAMPLE.matches("//").count(), "{config}"); - assert_eq!(json(&config, false).unwrap(), config); - assert_eq!(json(r#"["a\"b", "c\\"]"#, true).unwrap(), r#"["a\"b","c\\"]"#, "escapes inside strings"); - assert_eq!(random_bytes_from(20, |_| false).len(), 20, "falls back when the system source fails"); - assert_ne!(random_bytes_from(8, |_| false), random_bytes_from(8, |_| false)); - let id: String = uuid(); - assert_eq!((id.len(), &id[14..15]), (36, "4")); - assert_ne!(uuid(), id); - assert_eq!(password().len(), 20); - assert_eq!(Generated::Date.text().len(), 10); - } -} diff --git a/src/editor/tools/bases.rs b/src/editor/tools/bases.rs new file mode 100644 index 0000000..15df3d4 --- /dev/null +++ b/src/editor/tools/bases.rs @@ -0,0 +1,56 @@ +//! Number bases (`xb`): a number in decimal, hex, octal and binary, and as a character. + +use std::fmt::Write; + +use super::calc::calc; + +/// `255`, `0xff`, `0b1010`, `0o17`, `-42` or any whole calculation: the number in decimal, hex, octal and binary, +/// and as a character when it is one. +pub fn bases(input: &str, ans: Option) -> Result { + let text: String = input.trim().replace('_', ""); + let (negative, digits): (bool, &str) = text.strip_prefix('-').map_or((false, text.as_str()), |d| (true, d)); + let lower: String = digits.to_ascii_lowercase(); + let parsed: Option = match lower.get(..2) { + Some("0x") => u128::from_str_radix(&lower[2..], 16).ok(), + Some("0b") => u128::from_str_radix(&lower[2..], 2).ok(), + Some("0o") => u128::from_str_radix(&lower[2..], 8).ok(), + _ => lower.parse().ok(), + }; + let (negative, magnitude): (bool, u128) = if let Some(n) = parsed { + (negative, n) + } else { + let value: f64 = calc(input, ans).filter(|v| v.fract() == 0.0 && v.abs() < 1e30).ok_or("not a whole number")?; + let magnitude: u128 = format!("{:.0}", value.abs()).parse().map_err(|_| "not a whole number")?; + (value < 0.0, magnitude) + }; + let sign: &str = if negative && magnitude != 0 { "-" } else { "" }; + let binary: String = format!("{magnitude:b}"); + // Binary digits in groups of four, from the right. + let grouped: String = binary.chars().enumerate().fold(String::new(), |mut out, (i, c)| { + if i > 0 && (binary.len() - i).is_multiple_of(4) { + out.push('_'); + } + out.push(c); + out + }); + let mut shown: String = + format!("{sign}{magnitude} · {sign}0x{magnitude:x} · {sign}0o{magnitude:o} · {sign}0b{grouped}"); + if !negative && let Some(c) = u32::try_from(magnitude).ok().and_then(char::from_u32).filter(|c| !c.is_control()) { + let _: std::fmt::Result = write!(shown, " · '{c}' U+{magnitude:04X}"); + } + Ok(shown) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shows_every_base() { + assert_eq!(bases("255", None).unwrap(), "255 · 0xff · 0o377 · 0b1111_1111 · 'ÿ' U+00FF"); + assert_eq!(bases("0x41", None).unwrap(), "65 · 0x41 · 0o101 · 0b100_0001 · 'A' U+0041"); + assert_eq!(bases("-0b101", None).unwrap(), "-5 · -0x5 · -0o5 · -0b101"); + assert_eq!(bases("2^40", None).unwrap().split(" · ").nth(1), Some("0x10000000000")); + assert!(bases("1.5", None).is_err()); + } +} diff --git a/src/editor/tools/calc.rs b/src/editor/tools/calc.rs new file mode 100644 index 0000000..53e7c40 --- /dev/null +++ b/src/editor/tools/calc.rs @@ -0,0 +1,451 @@ +//! The calculator (`xc`, `:calc`): an expression reader with functions, constants and `ans`. + +/// `expr` evaluated: `+ - * / % ^ !`, `x%` (a percentage), `√`, `²`, `³`, parentheses (also `[ ]` and `{ }`; the +/// ones left open at the end are closed), `0x`/`0b`/`0o` literals, `1e3`, `1,5` (a decimal comma outside function +/// arguments), functions (`sqrt(2)`, `log(8, 2)`, `max(1, 2, 3)`), constants (`pi`, `e`, `tau`, `phi`) and `ans`. +/// `×` `·` `÷` `−` and `**` are read as `*` `*` `/` `-` `^`. A value next to a name or a parenthesis multiplies +/// (`2pi`, `3(4+1)`, `(1+1)(2)`). The error says what is wrong and where. +pub fn evaluate(expr: &str, ans: Option) -> Result { + let mut tokens: Vec<(char, usize)> = Vec::new(); + for (col, c) in expr.chars().enumerate() { + let c: char = match c { + '[' | '{' => '(', + ']' | '}' => ')', + '×' | '·' | '∙' => '*', + '÷' | '∶' => '/', + '−' | '–' => '-', + // `**` is a power: the second `*` turns the pair into `^`. + '*' if tokens.last().is_some_and(|&(prev, _)| prev == '*') => { + tokens.pop(); + '^' + } + c if c.is_whitespace() || c == '_' => continue, + c => c, + }; + tokens.push((c, col + 1)); + } + if tokens.is_empty() { + return Err("type an expression, e.g. 2+3*4".into()); + } + let mut parser: Calc = Calc { tokens, pos: 0, depth: 0, args: 0, ans }; + let value: f64 = parser.sum()?; + if let Some(&(c, col)) = parser.tokens.get(parser.pos) { + return Err(if c == ')' { format!("unmatched ) at column {col}") } else { parser.unexpected() }); + } + if value.is_nan() { + Err("undefined result (like sqrt(-1), log(0) or 0/0)".into()) + } else if value.is_infinite() { + Err("infinite result (a division by zero or an overflow)".into()) + } else { + Ok(value) + } +} + +/// `evaluate` for the callers that only need the value. +pub fn calc(expr: &str, ans: Option) -> Option { + evaluate(expr, ans).ok() +} + +/// How deep parentheses, signs and powers may nest: `((((…` or `----…` must not overflow the stack. +const MAX_DEPTH: usize = 64; + +struct Calc { + /// The chars without blanks, each with its column in the input (1-based), for error messages. + tokens: Vec<(char, usize)>, + pos: usize, + depth: usize, + /// Function calls open around the current token: inside one, `,` separates arguments. + args: usize, + ans: Option, +} + +type Value = Result; + +impl Calc { + fn peek(&self) -> Option { + self.tokens.get(self.pos).map(|&(c, _)| c) + } + + fn peek_at(&self, ahead: usize) -> Option { + self.tokens.get(self.pos + ahead).map(|&(c, _)| c) + } + + fn eat(&mut self, ops: &str) -> Option { + let c: char = self.peek().filter(|c| ops.contains(*c))?; + self.pos += 1; + Some(c) + } + + /// The error for the token at `pos`: an unexpected char, or the input ending too soon. + fn unexpected(&self) -> String { + match self.tokens.get(self.pos) { + Some(&(c, col)) => format!("unexpected {c} at column {col}"), + None => format!("incomplete: something is missing after {}", self.tokens.last().map_or(' ', |t| t.0)), + } + } + + /// Whether an operand starts here: a number, a name, a parenthesis, `π` or `√`. + fn at_operand(&self) -> bool { + self.peek().is_some_and(|c| c.is_alphanumeric() || matches!(c, '.' | '(' | 'π' | '√')) + } + + /// Enters one nesting level, failing past `MAX_DEPTH`. + fn deeper(&mut self) -> Result<(), String> { + self.depth += 1; + if self.depth > MAX_DEPTH { Err("too deeply nested".into()) } else { Ok(()) } + } + + fn sum(&mut self) -> Value { + let mut value: f64 = self.product()?; + while let Some(op) = self.eat("+-") { + let rhs: f64 = self.product()?; + value = if op == '+' { value + rhs } else { value - rhs }; + } + Ok(value) + } + + fn product(&mut self) -> Value { + let mut value: f64 = self.unary()?; + loop { + // `%` before an operand is a remainder; `postfix` already read any other `%` as a percentage. + let op: char = match self.eat("*/%") { + Some(op) => op, + None if self.at_operand() => '*', + None => return Ok(value), + }; + let rhs: f64 = self.unary()?; + value = match op { + '*' => value * rhs, + '/' => value / rhs, + _ => value % rhs, + }; + } + } + + /// Signs bind looser than `^`: `-2^2` is -4. + fn unary(&mut self) -> Value { + self.deeper()?; + let value: Value = match self.eat("+-") { + Some('-') => self.unary().map(|v| -v), + Some(_) => self.unary(), + None => self.power(), + }; + self.depth -= 1; + value + } + + /// `^` binds right: `2^3^2` is `2^9`. + fn power(&mut self) -> Value { + let base: f64 = self.postfix()?; + if self.eat("^").is_some() { + return Ok(base.powf(self.unary()?)); + } + Ok(base) + } + + fn postfix(&mut self) -> Value { + let mut value: f64 = self.primary()?; + loop { + match self.peek() { + Some('!') => value = factorial(value)?, + Some('²') => value = value.powi(2), + Some('³') => value = value.powi(3), + Some('%') if !self.peek_at(1).is_some_and(|c| c.is_alphanumeric() || ".(π√+-".contains(c)) => { + value /= 100.0; + } + _ => return Ok(value), + } + self.pos += 1; + } + } + + /// `)` or the end of the input, which closes what is still open. + fn close(&mut self) -> Result<(), String> { + if self.eat(")").is_some() || self.peek().is_none() { Ok(()) } else { Err(self.unexpected()) } + } + + fn primary(&mut self) -> Value { + let Some(c): Option = self.peek() else { return Err(self.unexpected()) }; + match c { + '(' => { + self.pos += 1; + if self.peek() == Some(')') { + return Err(format!("empty parentheses at column {}", self.tokens[self.pos - 1].1)); + } + self.deeper()?; + let saved: usize = std::mem::take(&mut self.args); + let value: Value = self.sum(); + self.args = saved; + self.depth -= 1; + let value: f64 = value?; + self.close()?; + Ok(value) + } + 'π' => { + self.pos += 1; + Ok(std::f64::consts::PI) + } + '√' => { + self.pos += 1; + self.deeper()?; + let value: Value = self.postfix().map(f64::sqrt); + self.depth -= 1; + value + } + c if c.is_ascii_digit() || c == '.' => self.number(), + c if c.is_alphabetic() => self.name(), + _ => Err(self.unexpected()), + } + } + + /// A constant, `ans`, or a function call. + fn name(&mut self) -> Value { + let start: usize = self.pos; + while self.peek().is_some_and(char::is_alphanumeric) { + self.pos += 1; + } + let name: String = self.tokens[start..self.pos].iter().map(|&(c, _)| c).collect::().to_lowercase(); + // A constant before `(` multiplies it (`2pi(1+1)`); any other name before `(` is a function call. + match name.as_str() { + "pi" => return Ok(std::f64::consts::PI), + "e" => return Ok(std::f64::consts::E), + "tau" => return Ok(std::f64::consts::TAU), + "phi" => return Ok(1.618_033_988_749_895), + "ans" => return self.ans.ok_or_else(|| "no previous answer for ans yet".into()), + _ => {} + } + if self.eat("(").is_none() { + return Err(format!("unknown name {name} at column {}", self.tokens[start].1)); + } + self.deeper()?; + self.args += 1; + let mut args: Vec = Vec::new(); + let parsed: Result<(), String> = loop { + match self.sum() { + Ok(value) => args.push(value), + Err(e) => break Err(e), + } + if self.eat(",").is_none() { + break Ok(()); + } + }; + self.args -= 1; + self.depth -= 1; + parsed?; + self.close()?; + function(&name, &args) + } + + /// `12`, `1.5`, `.5`, `1,5`, `1e-3`, `0xff`, `0b101`, `0o17`. + fn number(&mut self) -> Value { + let start: usize = self.pos; + let radix: Option = match (self.peek(), self.peek_at(1).map(|c| c.to_ascii_lowercase())) { + (Some('0'), Some('x')) => Some(16), + (Some('0'), Some('b')) => Some(2), + (Some('0'), Some('o')) => Some(8), + _ => None, + }; + if let Some(radix) = radix { + self.pos += 2; + let first: usize = self.pos; + while self.peek().is_some_and(|c| c.is_digit(radix)) { + self.pos += 1; + } + let digits: String = self.tokens[first..self.pos].iter().map(|&(c, _)| c).collect(); + return u64::from_str_radix(&digits, radix) + .map(u64_to_f64) + .map_err(|_| format!("bad number at column {}", self.tokens[start].1)); + } + let mut text: String = String::new(); + loop { + match self.peek() { + Some(c) if c.is_ascii_digit() || c == '.' => text.push(c), + // A decimal comma, unless it separates function arguments. + Some(',') if self.args == 0 && self.peek_at(1).is_some_and(|c| c.is_ascii_digit()) => text.push('.'), + _ => break, + } + self.pos += 1; + } + // An exponent only when digits follow, so `2e` stays 2 times e. + let sign: usize = usize::from(matches!(self.peek_at(1), Some('+' | '-'))); + if matches!(self.peek(), Some('e' | 'E')) && self.peek_at(1 + sign).is_some_and(|c| c.is_ascii_digit()) { + text.push('e'); + self.pos += 1; + while self.peek().is_some_and(|c| c.is_ascii_digit() || c == '+' || c == '-') { + let c: char = self.peek().unwrap_or('0'); + // Only the sign right after `e` belongs to the number. + if !c.is_ascii_digit() && !text.ends_with('e') { + break; + } + text.push(c); + self.pos += 1; + } + } + text.parse().map_err(|_| format!("bad number {text} at column {}", self.tokens[start].1)) + } +} + +#[expect(clippy::cast_precision_loss, reason = "a calculator works in f64; huge literals round like any float")] +fn u64_to_f64(n: u64) -> f64 { + n as f64 +} + +fn factorial(n: f64) -> Value { + if n.fract() != 0.0 || !(0.0..=170.0).contains(&n) { + return Err("! needs a whole number from 0 to 170".into()); + } + let mut product: f64 = 1.0; + let mut k: f64 = 2.0; + while k <= n { + product *= k; + k += 1.0; + } + Ok(product) +} + +fn function(name: &str, args: &[f64]) -> Value { + let wrong = || format!("wrong number of arguments for {name}"); + let one = |f: fn(f64) -> f64| match args { + [x] => Ok(f(*x)), + _ => Err(wrong()), + }; + let two = |f: fn(f64, f64) -> f64| match args { + [x, y] => Ok(f(*x, *y)), + _ => Err(wrong()), + }; + match name { + "sqrt" => one(f64::sqrt), + "cbrt" => one(f64::cbrt), + "abs" => one(f64::abs), + "floor" => one(f64::floor), + "ceil" => one(f64::ceil), + "round" => one(f64::round), + "trunc" => one(f64::trunc), + "sign" => one(f64::signum), + "exp" => one(f64::exp), + "ln" => one(f64::ln), + "log2" => one(f64::log2), + "log" if args.len() == 2 => two(f64::log), + "log" | "log10" => one(f64::log10), + "sin" => one(f64::sin), + "cos" => one(f64::cos), + "tan" => one(f64::tan), + "asin" => one(f64::asin), + "acos" => one(f64::acos), + "atan" => one(f64::atan), + "sinh" => one(f64::sinh), + "cosh" => one(f64::cosh), + "tanh" => one(f64::tanh), + "rad" => one(f64::to_radians), + "deg" => one(f64::to_degrees), + "hypot" => two(f64::hypot), + "pow" => two(f64::powf), + "min" => args.iter().copied().reduce(f64::min).ok_or_else(wrong), + "max" => args.iter().copied().reduce(f64::max).ok_or_else(wrong), + _ => Err(format!("unknown function {name}")), + } +} + +/// A result as people write it: whole numbers without `.0`, at most 10 decimals, very large or small ones in +/// scientific notation. +pub fn format_number(v: f64) -> String { + let abs: f64 = v.abs(); + if abs != 0.0 && !(1e-4..1e15).contains(&abs) { + let text: String = format!("{v:.11e}"); + let (mantissa, exponent): (&str, &str) = text.split_once('e').unwrap_or((&text, "0")); + return format!("{}e{exponent}", trim_decimals(mantissa)); + } + let text: String = trim_decimals(&format!("{v:.10}")).to_owned(); + if text == "-0" { "0".into() } else { text } +} + +fn trim_decimals(text: &str) -> &str { + if text.contains('.') { text.trim_end_matches('0').trim_end_matches('.') } else { text } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn eval(expr: &str) -> String { + calc(expr, Some(10.0)).map_or_else(|| "none".into(), format_number) + } + + #[test] + fn calculator() { + assert_eq!(eval("1 + 2 * 3"), "7"); + assert_eq!(eval("0.1 + 0.2"), "0.3"); + assert_eq!(eval("-2^2"), "-4"); + assert_eq!(eval("2^3^2"), "512"); + assert_eq!(eval("2pi"), eval("2 * pi")); + assert_eq!(eval("3(4+1)"), "15"); + assert_eq!(eval("2pi(1+1)"), eval("4pi"), "a constant before ( multiplies"); + assert_eq!(eval("ans(2)"), "20"); + assert_eq!(eval("sqrt(16) + abs(-3)"), "7"); + assert_eq!(eval("log(8, 2)"), "3"); + assert_eq!(eval("max(1, 7, 3)"), "7"); + assert_eq!(eval("5!"), "120"); + assert_eq!(eval("200 * 15%"), "30"); + assert_eq!(eval("10 % 3"), "1"); + assert_eq!(eval("0xff + 0b11 + 0o10"), "266"); + assert_eq!(eval("1.5e3"), "1500"); + assert_eq!(eval("ans * 2"), "20"); + assert_eq!(eval("1e20"), "1e20"); + assert_eq!(eval("1/3"), "0.3333333333"); + assert_eq!(eval("10 / 0"), "none"); + assert_eq!(eval("2 +"), "none"); + assert_eq!(eval("foo"), "none"); + assert_eq!(eval(&"(".repeat(200)), "none"); + } + + /// Every syntax the calculator reads, and a clear message for every way it can fail. + #[test] + fn calculator_reads_everything_and_explains_errors() { + let ok = |expr: &str| evaluate(expr, Some(10.0)).map(format_number); + assert_eq!(ok("[1+2]*{3}"), Ok("9".into()), "other brackets"); + assert_eq!(ok("2×3·4∙1 ÷ 4 − 1"), Ok("5".into()), "typographic operators"); + assert_eq!(ok("2**3**2"), Ok("512".into()), "** is a power"); + assert_eq!(ok("√16 + √(9)²"), Ok("13".into())); + assert_eq!(ok("2³ + π - pi"), Ok("8".into())); + assert_eq!(ok("sqrt(2"), ok("sqrt(2)"), "open parentheses close at the end"); + assert_eq!(ok("((1+2"), Ok("3".into())); + assert_eq!(ok("1,5 * 2"), Ok("3".into()), "decimal comma"); + assert_eq!(ok("max(1,5)"), Ok("5".into()), "a comma between arguments"); + assert_eq!(ok("1e5-3"), Ok("99997".into()), "the sign after e only"); + assert_eq!(ok("1E+2 + 2e"), ok("100 + 2e")); + assert_eq!(ok("(1+1)(2)"), Ok("4".into())); + assert_eq!(ok("tau/2 - phi + phi"), ok("pi")); + let err = |expr: &str| evaluate(expr, None).unwrap_err(); + assert_eq!(err(""), "type an expression, e.g. 2+3*4"); + assert_eq!(err(" _ "), "type an expression, e.g. 2+3*4"); + assert_eq!(err("2+3)"), "unmatched ) at column 4"); + assert_eq!(err("2 + $"), "unexpected $ at column 5"); + assert_eq!(err("2 +"), "incomplete: something is missing after +"); + assert_eq!(err("()"), "empty parentheses at column 1"); + assert_eq!(err("foo + 1"), "unknown name foo at column 1"); + assert_eq!(err("foo(1)"), "unknown function foo"); + assert_eq!(err("sqrt(1, 2)"), "wrong number of arguments for sqrt"); + assert_eq!(err("hypot(1)"), "wrong number of arguments for hypot"); + assert_eq!(err("max()"), "unexpected ) at column 5"); + assert_eq!(err("ans"), "no previous answer for ans yet"); + assert_eq!(err("2.5!"), "! needs a whole number from 0 to 170"); + assert_eq!(err("171!"), "! needs a whole number from 0 to 170"); + assert_eq!(err("sqrt(-1)"), "undefined result (like sqrt(-1), log(0) or 0/0)"); + assert_eq!(err("1/0"), "infinite result (a division by zero or an overflow)"); + assert_eq!(err("1.2.3"), "bad number 1.2.3 at column 1"); + assert_eq!(err("0x"), "bad number at column 1"); + assert_eq!(err("0xffffffffffffffffff"), "bad number at column 1"); + assert_eq!(err(&"(".repeat(100)), "too deeply nested"); + assert_eq!(err(&"√".repeat(100)), "too deeply nested"); + assert_eq!(err(&format!("{}1", "sqrt(".repeat(100))), "too deeply nested"); + assert_eq!(err("sqrt(1+)"), "unexpected ) at column 8"); + assert_eq!(ok("(1 000 000"), Ok("1000000".into()), "blanks inside a number are thousands separators"); + for function in [ + "sqrt", "cbrt", "abs", "floor", "ceil", "round", "trunc", "sign", "exp", "ln", "log2", "log", "log10", + "sin", "cos", "tan", "asin", "acos", "atan", "sinh", "cosh", "tanh", "rad", "deg", + ] { + assert!(evaluate(&format!("{function}(0.5)"), None).is_ok(), "{function}"); + } + assert_eq!(ok("pow(2, 10) + hypot(3, 4) + min(4, 2)"), Ok("1031".into())); + } +} diff --git a/src/editor/tools/case.rs b/src/editor/tools/case.rs new file mode 100644 index 0000000..ecc5c0a --- /dev/null +++ b/src/editor/tools/case.rs @@ -0,0 +1,115 @@ +//! Identifier and phrase cases (`xk`, `:case`): snake, camel, kebab and the rest. + +/// Identifier and phrase cases for `xk` and `:case`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WordCase { + Snake, + Camel, + Pascal, + Kebab, + Constant, + Title, + Lower, + Upper, +} + +impl WordCase { + pub const NAMES: [(&'static str, Self); 8] = [ + ("snake", Self::Snake), + ("camel", Self::Camel), + ("pascal", Self::Pascal), + ("kebab", Self::Kebab), + ("constant", Self::Constant), + ("title", Self::Title), + ("lower", Self::Lower), + ("upper", Self::Upper), + ]; + + /// The case named `name`, or by a prefix of its name (`sn`, `k`). + pub fn parse(name: &str) -> Option { + let name: String = name.trim().to_ascii_lowercase(); + Self::NAMES.iter().find(|(n, _)| !name.is_empty() && n.starts_with(name.as_str())).map(|&(_, case)| case) + } + + /// `text` split into words (at blanks, punctuation and camelCase humps), joined in this case. + pub fn apply(self, text: &str) -> String { + let words: Vec = words(text); + let capital = |w: &String| -> String { + let mut chars = w.chars(); + chars + .next() + .map(|c| c.to_uppercase().chain(chars.flat_map(char::to_lowercase)).collect()) + .unwrap_or_default() + }; + let lower: Vec = words.iter().map(|w| w.to_lowercase()).collect(); + match self { + Self::Snake => lower.join("_"), + Self::Kebab => lower.join("-"), + Self::Constant => words.iter().map(|w| w.to_uppercase()).collect::>().join("_"), + Self::Lower => lower.join(" "), + Self::Upper => words.iter().map(|w| w.to_uppercase()).collect::>().join(" "), + Self::Title => words.iter().map(capital).collect::>().join(" "), + Self::Pascal => words.iter().map(capital).collect(), + Self::Camel => { + let mut out: String = lower.first().cloned().unwrap_or_default(); + out.extend(words.iter().skip(1).map(capital)); + out + } + } + } +} + +/// The words of `text`: runs of letters and digits, also split where a lowercase letter meets an uppercase one +/// (`fooBar`) and before the last capital of an acronym (`HTTPServer`). +fn words(text: &str) -> Vec { + let chars: Vec = text.chars().collect(); + let mut words: Vec = Vec::new(); + let mut word: String = String::new(); + for (i, &c) in chars.iter().enumerate() { + if !c.is_alphanumeric() { + if !word.is_empty() { + words.push(std::mem::take(&mut word)); + } + continue; + } + if !word.is_empty() && c.is_uppercase() { + let prev: char = chars[i - 1]; + let hump: bool = prev.is_lowercase() || prev.is_numeric(); + let acronym_end: bool = prev.is_uppercase() && chars.get(i + 1).is_some_and(|n| n.is_lowercase()); + if hump || acronym_end { + words.push(std::mem::take(&mut word)); + } + } + word.push(c); + } + if !word.is_empty() { + words.push(word); + } + words +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cases() { + assert_eq!(words("parseHTTPServer_v2-x"), ["parse", "HTTP", "Server", "v2", "x"]); + let all: Vec = WordCase::NAMES.iter().map(|(_, c)| c.apply("fooBar baz")).collect(); + assert_eq!( + all, + [ + "foo_bar_baz", + "fooBarBaz", + "FooBarBaz", + "foo-bar-baz", + "FOO_BAR_BAZ", + "Foo Bar Baz", + "foo bar baz", + "FOO BAR BAZ" + ] + ); + assert_eq!(WordCase::parse("k"), Some(WordCase::Kebab)); + assert_eq!(WordCase::parse(""), None); + } +} diff --git a/src/editor/tools/color.rs b/src/editor/tools/color.rs new file mode 100644 index 0000000..1973e9a --- /dev/null +++ b/src/editor/tools/color.rs @@ -0,0 +1,104 @@ +//! Colors (`xh`): a color as hex, `rgb()` and `hsl()`. + +/// `#f80`, `#ff8800`, `ff8800`, `rgb(255, 136, 0)`, `255 136 0`, `hsl(32, 100%, 50%)`: the color in all three forms. +pub fn color(input: &str) -> Result { + let text: String = input.trim().to_ascii_lowercase(); + let numbers = |inner: &str| -> Option> { + inner + .split([',', ' ', '/']) + .filter(|p| !p.is_empty()) + .map(|p| p.trim_end_matches(['%', '°']).parse().ok()) + .collect() + }; + let rgb: [u8; 3] = if let Some(inner) = text.strip_prefix("hsl(").and_then(|t| t.strip_suffix(')')) { + let [h, s, l]: [f64; 3] = numbers(inner).and_then(|n| n.try_into().ok()).ok_or("hsl(h, s%, l%)")?; + hsl_to_rgb(h, s / 100.0, l / 100.0) + } else if let Some(hex) = + text.strip_prefix('#').or_else(|| text.chars().all(|c| c.is_ascii_hexdigit()).then_some(&text[..])) + { + let full: String = match hex.len() { + 3 => hex.chars().flat_map(|c| [c, c]).collect(), + 6 => hex.to_owned(), + _ => return Err("#rgb or #rrggbb".into()), + }; + let value: u32 = u32::from_str_radix(&full, 16).map_err(|_| "#rgb or #rrggbb")?; + let [_, r, g, b]: [u8; 4] = value.to_be_bytes(); + [r, g, b] + } else { + let inner: &str = text.strip_prefix("rgb(").and_then(|t| t.strip_suffix(')')).unwrap_or(&text); + let parts: Vec = numbers(inner).ok_or("rgb(r, g, b) with 0-255")?; + let [r, g, b]: [f64; 3] = parts.try_into().map_err(|_| "rgb(r, g, b) with 0-255")?; + if [r, g, b].iter().any(|c| !(0.0..=255.0).contains(c)) { + return Err("rgb(r, g, b) with 0-255".into()); + } + [channel(r / 255.0), channel(g / 255.0), channel(b / 255.0)] + }; + let [r, g, b]: [u8; 3] = rgb; + let (h, s, l): (f64, f64, f64) = rgb_to_hsl(rgb); + Ok(format!("#{r:02x}{g:02x}{b:02x} · rgb({r}, {g}, {b}) · hsl({:.0}, {:.0}%, {:.0}%)", h, s * 100.0, l * 100.0)) +} + +/// A 0..=1 fraction as a 0..=255 channel. +#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "clamped to 0..=255 first")] +fn channel(x: f64) -> u8 { + (x.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +fn hsl_to_rgb(h: f64, s: f64, l: f64) -> [u8; 3] { + let (s, l): (f64, f64) = (s.clamp(0.0, 1.0), l.clamp(0.0, 1.0)); + let c: f64 = (1.0 - (2.0 * l - 1.0).abs()) * s; + let h: f64 = h.rem_euclid(360.0) / 60.0; + let x: f64 = c * (1.0 - (h % 2.0 - 1.0).abs()); + let (r, g, b): (f64, f64, f64) = match h { + h if h < 1.0 => (c, x, 0.0), + h if h < 2.0 => (x, c, 0.0), + h if h < 3.0 => (0.0, c, x), + h if h < 4.0 => (0.0, x, c), + h if h < 5.0 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m: f64 = l - c / 2.0; + [channel(r + m), channel(g + m), channel(b + m)] +} + +fn rgb_to_hsl([r, g, b]: [u8; 3]) -> (f64, f64, f64) { + let (r, g, b): (f64, f64, f64) = (f64::from(r) / 255.0, f64::from(g) / 255.0, f64::from(b) / 255.0); + let (max, min): (f64, f64) = (r.max(g).max(b), r.min(g).min(b)); + let l: f64 = f64::midpoint(max, min); + let d: f64 = max - min; + if d == 0.0 { + return (0.0, 0.0, l); + } + let s: f64 = d / (1.0 - (2.0 * l - 1.0).abs()); + // `max` is one of the three channels exactly, so comparing floats for equality is right here. + let h: f64 = if max.to_bits() == r.to_bits() { + 60.0 * ((g - b) / d).rem_euclid(6.0) + } else if max.to_bits() == g.to_bits() { + 60.0 * ((b - r) / d + 2.0) + } else { + 60.0 * ((r - g) / d + 4.0) + }; + (h, s, l) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn colors() { + assert_eq!(color("#f80").unwrap(), "#ff8800 · rgb(255, 136, 0) · hsl(32, 100%, 50%)"); + // Every sixth of the hue circle, and grey, both ways. + for hue in [0, 90, 150, 210, 270, 330] { + let shown: String = color(&format!("hsl({hue}, 100%, 50%)")).unwrap(); + assert!(shown.ends_with(&format!("hsl({hue}, 100%, 50%)")), "{shown}"); + } + assert_eq!(color("#808080").unwrap(), "#808080 · rgb(128, 128, 128) · hsl(0, 0%, 50%)"); + assert!(color("hsl(1, 2)").is_err()); + assert!(color("1 2").is_err()); + assert_eq!(color("rgb(255, 136, 0)").unwrap(), color("ff8800").unwrap()); + assert_eq!(color("hsl(32, 100%, 50%)").unwrap().get(..7), Some("#ff8800")); + assert!(color("#12").is_err()); + assert!(color("300 0 0").is_err()); + } +} diff --git a/src/editor/tools/generated.rs b/src/editor/tools/generated.rs new file mode 100644 index 0000000..56feca4 --- /dev/null +++ b/src/editor/tools/generated.rs @@ -0,0 +1,95 @@ +//! Text the `x` keys insert: dates, UUIDs, passwords, lorem ipsum. + +use std::collections::hash_map::RandomState; +use std::fmt::Write; +use std::hash::BuildHasher; + +use super::time::{datetime, now}; +use crate::term; + +/// Text the `x` keys insert at the cursor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Generated { + Date, + DateTime, + Uuid, + Password, + Lorem, +} + +/// Random bytes from the system's source (`/dev/urandom`, `RtlGenRandom`); if that fails, `SipHash` over a counter +/// keyed by std's per-process random keys. +fn random_bytes(n: usize) -> Vec { + random_bytes_from(n, term::random) +} + +/// `random_bytes` with the system source given, so the fallback can be tested. +fn random_bytes_from(n: usize, system: fn(&mut [u8]) -> bool) -> Vec { + let mut bytes: Vec = vec![0; n]; + if !system(&mut bytes) { + let keys: RandomState = RandomState::new(); + bytes = (0..).map(|i: u64| keys.hash_one(i)).flat_map(u64::to_le_bytes).take(n).collect(); + } + bytes +} + +fn uuid() -> String { + let mut b: Vec = random_bytes(16); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + let hex: String = b.iter().fold(String::new(), |mut s, x| { + let _: std::fmt::Result = write!(s, "{x:02x}"); + s + }); + format!("{}-{}-{}-{}-{}", &hex[..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..]) +} + +/// 20 chars: letters, digits and symbols, picked without bias (bytes past the last whole set are redrawn). +fn password() -> String { + const SET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*-_=+?"; + let limit: usize = 256 - 256 % SET.len(); + let mut out: String = String::new(); + while out.len() < 20 { + for b in random_bytes(32) { + let b: usize = usize::from(b); + if b < limit && out.len() < 20 { + out.push(char::from(SET[b % SET.len()])); + } + } + } + out +} + +const LOREM: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et \ +dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo \ +consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur."; + +impl Generated { + pub fn text(self) -> String { + let now: i64 = now(); + let local: String = datetime(now, term::utc_offset(now)); + match self { + Self::Date => local[..10].to_owned(), + Self::DateTime => local, + Self::Uuid => uuid(), + Self::Password => password(), + Self::Lorem => LOREM.to_owned(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated() { + assert_eq!(random_bytes_from(20, |_| false).len(), 20, "falls back when the system source fails"); + assert_ne!(random_bytes_from(8, |_| false), random_bytes_from(8, |_| false)); + let id: String = uuid(); + assert_eq!((id.len(), &id[14..15]), (36, "4")); + assert_ne!(uuid(), id); + assert_eq!(password().len(), 20); + assert_eq!(Generated::Date.text().len(), 10); + } +} diff --git a/src/editor/tools/json.rs b/src/editor/tools/json.rs new file mode 100644 index 0000000..1d83c1f --- /dev/null +++ b/src/editor/tools/json.rs @@ -0,0 +1,179 @@ +//! `:json` / `xj`: JSON (and JSONC) laid out or minified. + +use crate::config::json; + +/// `text` re-laid out (two-space indent, or `minify`d), token by token, so numbers and strings stay exactly as +/// written. Invalid JSON is refused. `//` and `/* */` comments (JSONC, as config files) are kept: one after a +/// token stays on its line, one on its own line keeps it; minifying drops them, a `//` would swallow the rest. +pub fn json(text: &str, minify: bool) -> Result { + json::parse(text).map_err(|e| format!("invalid JSON: {e}"))?; + let chars: Vec = text.chars().collect(); + let mut layout: Layout = Layout::new(minify, text.len()); + let mut i: usize = 0; + while let Some(&c) = chars.get(i) { + i += 1; + if layout.in_string { + layout.string_char(c); + } else if c == '/' && matches!(chars.get(i), Some('/' | '*')) { + i = layout.comment(&chars, i - 1); + } else { + layout.token(c, &chars[i..]); + } + } + Ok(layout.out) +} + +/// The text being laid out, and where the layout stands in it. +#[expect(clippy::struct_excessive_bools, reason = "independent states of the scan, each read on its own")] +struct Layout { + out: String, + minify: bool, + depth: usize, + in_string: bool, + /// The last char in a string was a backslash. + escaped: bool, + /// The bracket just opened closes right away: `{}` and `[]` stay on one line. + empty: bool, + /// A line break waits for the next token, so a comment after a token can still join its line. + pending: bool, + /// The source broke the line since the last token. + broke: bool, +} + +impl Layout { + fn new(minify: bool, capacity: usize) -> Self { + let out: String = String::with_capacity(capacity); + Self { out, minify, depth: 0, in_string: false, escaped: false, empty: false, pending: false, broke: false } + } + + /// Writes the waiting line break, indented to the current depth. + fn newline(&mut self) { + if std::mem::take(&mut self.pending) && !self.minify { + self.out.push('\n'); + self.out.push_str(&" ".repeat(self.depth)); + } + } + + /// A char inside a string, copied as it is. + fn string_char(&mut self, c: char) { + self.out.push(c); + if self.escaped { + self.escaped = false; + } else if c == '\\' { + self.escaped = true; + } else if c == '"' { + self.in_string = false; + } + } + + /// The comment starting at `start` (`//` or `/*`); returns where the text goes on after it. + fn comment(&mut self, chars: &[char], start: usize) -> usize { + let line: bool = chars.get(start + 1) == Some(&'/'); + // The parser accepted the text, so a block comment is closed. + let end: usize = if line { + chars[start..].iter().position(|&c| c == '\n').map_or(chars.len(), |p| start + p) + } else { + chars[start + 2..].windows(2).position(|w| w == ['*', '/']).map_or(chars.len(), |p| start + 2 + p + 2) + }; + if !self.minify { + let comment: String = chars[start..end].iter().collect::().trim_end().to_owned(); + self.place_comment(&comment, line); + } + end + } + + /// On its own line in the source, a comment keeps one; otherwise it joins the token before it. + fn place_comment(&mut self, comment: &str, line: bool) { + let own_line: bool = self.broke || self.out.is_empty(); + if own_line { + self.pending |= !self.out.is_empty(); + self.newline(); + } else if !self.out.ends_with(' ') { + self.out.push(' '); + } + self.out.push_str(comment); + if line || own_line { + self.pending = true; + } else if !self.pending { + // Inside a line (`"a": /* x */ 1`): stays there. + self.out.push(' '); + } + } + + /// A char outside strings and comments; `rest` is the text after it. + fn token(&mut self, c: char, rest: &[char]) { + match c { + '\n' => self.broke = true, + c if c.is_whitespace() => {} + '{' | '[' => self.open(c, rest), + '}' | ']' => self.close(c), + ',' => { + self.newline(); + self.out.push(c); + self.pending = true; + } + ':' => { + self.newline(); + self.out.push_str(if self.minify { ":" } else { ": " }); + } + c => { + self.newline(); + self.in_string = c == '"'; + self.out.push(c); + } + } + if !c.is_whitespace() { + self.broke = false; + } + } + + fn open(&mut self, bracket: char, rest: &[char]) { + self.newline(); + self.out.push(bracket); + self.depth += 1; + self.empty = rest.iter().find(|c| !c.is_whitespace()).is_some_and(|n| matches!(n, '}' | ']')); + self.pending = !self.empty; + } + + fn close(&mut self, bracket: char) { + self.depth = self.depth.saturating_sub(1); + self.pending |= !std::mem::take(&mut self.empty); + self.newline(); + self.out.push(bracket); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn layout() { + let text: &str = r#"{"a": [1, 2.50, {}], "b": {"c": "x,{y}"}, "d": []}"#; + assert_eq!(json(text, true).unwrap(), r#"{"a":[1,2.50,{}],"b":{"c":"x,{y}"},"d":[]}"#); + assert_eq!( + json(text, false).unwrap(), + "{\n \"a\": [\n 1,\n 2.50,\n {}\n ],\n \"b\": {\n \"c\": \"x,{y}\"\n },\n \"d\": []\n}" + ); + assert!(json("{\"a\": }", false).is_err()); + // JSONC: comments keep their place, trailing or on their own line; minifying drops them. + let commented: &str = + "// top\n{\"a\": 1, // one\n /* own line */\n\"b\": /* inline */ [2,\n3], \"c\": 4 // last\n}"; + assert_eq!( + json(commented, false).unwrap(), + "// top\n{\n \"a\": 1, // one\n /* own line */\n \"b\": /* inline */ [\n 2,\n 3\n ],\n \"c\": 4 // last\n}" + ); + assert_eq!(json(commented, true).unwrap(), r#"{"a":1,"b":[2,3],"c":4}"#); + assert_eq!( + json("{\"url\": \"http://x/*y*/\"}", true).unwrap(), + "{\"url\":\"http://x/*y*/\"}", + "not in strings" + ); + let formatted: String = json(commented, false).unwrap(); + assert_eq!(json(&formatted, false).unwrap(), formatted, "formatting twice changes nothing"); + let config: String = json(crate::config::EXAMPLE, false).unwrap(); + assert_eq!(config.matches("//").count(), crate::config::EXAMPLE.matches("//").count(), "{config}"); + assert_eq!(json(&config, false).unwrap(), config); + assert_eq!(json(r#"["a\"b", "c\\"]"#, true).unwrap(), r#"["a\"b","c\\"]"#, "escapes inside strings"); + } +} diff --git a/src/editor/tools/mod.rs b/src/editor/tools/mod.rs new file mode 100644 index 0000000..10b1078 --- /dev/null +++ b/src/editor/tools/mod.rs @@ -0,0 +1,203 @@ +//! `x` tools: a calculator, a unit converter, number bases, timestamps, colors, word case, JSON layout and +//! generated text (dates, UUIDs, passwords, lorem ipsum). Each tool is a pure function of the typed text, in its own +//! module; this one shows them in a popup, writes each answer under its question and yanks it into `"`. + +mod bases; +mod calc; +mod case; +mod color; +mod generated; +mod json; +mod time; +mod units; + +pub use calc::{calc, evaluate, format_number}; +pub use case::WordCase; +pub use generated::Generated; +pub use json::json; + +use bases::bases; +use color::color; +use time::{now, time}; +use units::convert; + +use super::Editor; +use super::prompt::PromptAction; +use super::registers::Register; +use crate::buffer::{Buffer, Pos}; + +/// The tools that answer a line of input, shown in a popup over a notebook buffer that keeps every answer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tool { + Calc, + Convert, + Base, + Time, + Color, +} + +impl Tool { + pub const ALL: [Self; 5] = [Self::Calc, Self::Convert, Self::Base, Self::Time, Self::Color]; + + /// The popup's buffer name. + pub fn title(self) -> &'static str { + match self { + Self::Calc => "[Calculator]", + Self::Convert => "[Convert]", + Self::Base => "[Bases]", + Self::Time => "[Time]", + Self::Color => "[Color]", + } + } + + /// What to type, shown in the popup's bottom border. + fn hint(self) -> &'static str { + match self { + Self::Calc => "2+3*4, sqrt(2), 2pi, ans*2", + Self::Convert => "5 ft to cm, 70 kg en lb, 100 f c, 2 m", + Self::Base => "255, 0xff, 0b1010, 2^40", + Self::Time => "now (or an empty line), 1700000000, 2026-10-18 14:30, two dates", + Self::Color => "#f80, rgb(255, 136, 0), hsl(32, 100%, 50%)", + } + } + + /// The answer to `input`; `ans` is the calculator's previous result. + pub fn run(self, input: &str, ans: Option) -> Result { + match self { + Self::Calc => evaluate(input, ans).map(format_number), + Self::Convert => convert(input), + Self::Base => bases(input, ans), + Self::Time => time(input, now()), + Self::Color => color(input), + } + } +} + +/// A tool window's answer line: `= 42` or `! why`. +fn is_answer(line: &str) -> bool { + line.starts_with("= ") || line.starts_with("! ") +} + +impl Editor { + /// `xc`…: the tool's popup over the windows (one popup, shared by the tools), focused, in insert mode + /// on its last line. + pub(super) fn open_tool(&mut self, tool: Tool) { + let existing: Option = + self.buffers.iter().position(|b| b.throwaway && b.title.as_deref() == Some(tool.title())); + let index: usize = existing.unwrap_or_else(|| { + let mut buf: Buffer = Buffer::scratch(); + buf.title = Some(tool.title().to_owned()); + buf.throwaway = true; + self.buffers.push(buf); + self.buffers.len() - 1 + }); + if let Some(window) = self.windows.iter().position(|w| w.buffer == index) { + self.focus(window); + } else if let Some(popup) = self.windows.iter().position(|w| w.floating) { + self.focus(popup); + self.switch_to(index); + } else { + let mut popup: super::Window = + super::Window::new(self.next_window_id, index, super::View::of(&self.buffers[index])); + popup.floating = true; + self.next_window_id += 1; + self.windows.push(popup); + self.focus(self.windows.len() - 1); + } + self.dashboard = false; + let buf: &mut Buffer = self.buf_mut(); + let last: usize = buf.last_line(); + buf.cursor = Pos::new(last, buf.line_len(last)); + self.mode = super::Mode::Insert; + } + + /// The help a tool's popup shows in its bottom border, when `buffer` is a tool's. + pub fn popup_hint(&self, buffer: usize) -> Option { + let buf: &Buffer = self.buffers.get(buffer)?; + let tool: Tool = Tool::ALL.into_iter().find(|t| buf.throwaway && buf.title.as_deref() == Some(t.title()))?; + Some(format!("{} · answers", tool.hint())) + } + + /// The tool whose window holds the current buffer. + fn current_tool(&self) -> Option { + let buf: &Buffer = self.buf(); + Tool::ALL.into_iter().find(|t| buf.throwaway && buf.title.as_deref() == Some(t.title())) + } + + /// `` in a tool window: answers the line under the cursor on the line below it (`= …`, or `! …` for an + /// error, replacing an older answer there), yanks the answer into `"`, and goes on at an empty last line. + /// False outside tool windows. On an answer line it answers the question above. + pub(super) fn tool_enter(&mut self) -> bool { + let Some(tool): Option = self.current_tool() else { return false }; + let mut line: usize = self.buf().cursor.line; + while line > 0 && is_answer(self.buf().line(line)) { + line -= 1; + } + let input: String = self.buf().line(line).trim().to_owned(); + if is_answer(&input) || (input.is_empty() && tool != Tool::Time) { + return true; + } + let shown: String = match tool.run(&input, self.calc_ans) { + Ok(answer) => { + if tool == Tool::Calc { + self.calc_ans = calc(&input, self.calc_ans); + } + self.store_register(Register { text: answer.clone(), linewise: false, block: false }, true); + format!("= {answer}") + } + Err(why) => format!("! {why}"), + }; + let buf: &mut Buffer = self.buf_mut(); + buf.seal(); + let next: usize = line + 1; + if next <= buf.last_line() && is_answer(buf.line(next)) { + buf.set_line(next, shown); + } else { + let end: usize = buf.line_len(line); + buf.insert(Pos::new(line, end), &format!("\n{shown}")); + } + let last: usize = buf.last_line(); + if !buf.line(last).is_empty() { + let end: usize = buf.line_len(last); + buf.insert(Pos::new(last, end), "\n"); + } + buf.cursor = Pos::new(buf.last_line(), 0); + true + } + + pub(super) fn open_case_prompt(&mut self) { + self.open_prompt("Case: snake camel pascal kebab constant title lower upper", "", PromptAction::Case); + } + + /// `xk`: the WORD under the cursor (blank-separated) in the typed case. + pub(super) fn case_word(&mut self, input: &str) -> Result { + let case: WordCase = WordCase::parse(input).ok_or_else(|| format!("unknown case: {input}"))?; + let buf: &mut Buffer = self.buf_mut(); + let Pos { line, col }: Pos = buf.cursor; + let chars: Vec = buf.line(line).chars().collect(); + if chars.get(col).is_none_or(|c| c.is_whitespace()) { + return Err("no word under the cursor".into()); + } + let start: usize = chars[..col].iter().rposition(|c| c.is_whitespace()).map_or(0, |i| i + 1); + let end: usize = chars[col..].iter().position(|c| c.is_whitespace()).map_or(chars.len(), |i| col + i); + let word: String = chars[start..end].iter().collect(); + let new: String = case.apply(&word); + if new != word && !new.is_empty() { + buf.seal(); + buf.remove(Pos::new(line, start), Pos::new(line, end)); + buf.insert(Pos::new(line, start), &new); + buf.cursor = Pos::new(line, start); + } + Ok(new) + } + + /// `xd`, `xi`…: the text goes in at the cursor, as one undo step. + pub(super) fn insert_generated(&mut self, what: Generated) { + let text: String = what.text(); + let buf: &mut Buffer = self.buf_mut(); + let at: Pos = buf.cursor; + buf.seal(); + buf.insert(at, &text); + buf.cursor = Pos::new(at.line, at.col + text.chars().count().saturating_sub(1)); + } +} diff --git a/src/editor/tools/time.rs b/src/editor/tools/time.rs new file mode 100644 index 0000000..2f3aedb --- /dev/null +++ b/src/editor/tools/time.rs @@ -0,0 +1,147 @@ +//! Timestamps and dates (`xt`): a Unix time or a date in every form, or the time between two. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::term; + +pub(super) fn now() -> i64 { + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(0)) +} + +/// Days since 1970-01-01 of a civil date (proleptic Gregorian), after Howard Hinnant's `days_from_civil`. +fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { + let y: i64 = if month <= 2 { year - 1 } else { year }; + let era: i64 = y.div_euclid(400); + let yoe: i64 = y - era * 400; + let doy: i64 = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1; + let doe: i64 = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +/// The civil date of a day count since 1970-01-01: `(year, month, day)`. +fn civil_from_days(days: i64) -> (i64, i64, i64) { + let z: i64 = days + 719_468; + let era: i64 = z.div_euclid(146_097); + let doe: i64 = z - era * 146_097; + let yoe: i64 = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy: i64 = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp: i64 = (5 * doy + 2) / 153; + let day: i64 = doy - (153 * mp + 2) / 5 + 1; + let month: i64 = if mp < 10 { mp + 3 } else { mp - 9 }; + (yoe + era * 400 + i64::from(month <= 2), month, day) +} + +const WEEKDAYS: [&str; 7] = ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"]; + +/// `YYYY-MM-DD HH:MM:SS` of unix time `ts` shifted by `offset` seconds. +pub(super) fn datetime(ts: i64, offset: i64) -> String { + let local: i64 = ts + offset; + let (days, secs): (i64, i64) = (local.div_euclid(86_400), local.rem_euclid(86_400)); + let (y, m, d): (i64, i64, i64) = civil_from_days(days); + format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}", secs / 3600, secs / 60 % 60, secs % 60) +} + +fn zone(offset: i64) -> String { + let sign: char = if offset < 0 { '-' } else { '+' }; + format!("UTC{sign}{:02}:{:02}", offset.abs() / 3600, offset.abs() / 60 % 60) +} + +/// A unix time described: local and UTC dates, weekday. +fn describe(ts: i64) -> String { + let offset: i64 = term::utc_offset(ts); + let weekday: &str = WEEKDAYS[usize::try_from(ts.div_euclid(86_400).rem_euclid(7)).unwrap_or(0)]; + format!("{ts} · {} {} · {} UTC · {weekday}", datetime(ts, offset), zone(offset), datetime(ts, 0)) +} + +/// `2026-10-18`, `2026-10-18 14:30`, `2026-10-18T14:30:05Z` (Z: UTC, else local time) as a unix time; the number +/// of words it used. +fn parse_date(words: &[&str]) -> Option<(i64, usize)> { + let first: &str = words.first()?; + let (date, mut clock): (&str, Option<&str>) = match first.split_once('T') { + Some((date, clock)) => (date, Some(clock)), + None => (first, None), + }; + let mut used: usize = 1; + if clock.is_none() + && let Some(next) = words.get(1).copied().filter(|w| w.contains(':') && !w.contains('-')) + { + (clock, used) = (Some(next), 2); + } + let parts: Vec = + date.trim_end_matches('Z').split('-').map(str::parse).collect::, _>>().ok()?; + let [y, m, d]: [i64; 3] = parts.try_into().ok()?; + if !(1..=12).contains(&m) || !(1..=31).contains(&d) { + return None; + } + let utc: bool = clock.is_some_and(|c| c.ends_with('Z')) || date.ends_with('Z'); + let clock: Vec = match clock.map(|c| c.trim_end_matches('Z')) { + Some(c) => c.split(':').map(str::parse).collect::, _>>().ok()?, + None => Vec::new(), + }; + if clock.len() > 3 || clock.iter().any(|n| !(0..60).contains(n)) || clock.first().is_some_and(|h| *h > 23) { + return None; + } + let secs: i64 = clock.iter().chain([&0, &0, &0]).take(3).fold(0, |acc, n| acc * 60 + n); + let local: i64 = days_from_civil(y, m, d) * 86_400 + secs; + // The offset in effect at that local time: taken at the UTC guess, which is right outside the hour of a switch. + Some((if utc { local } else { local - term::utc_offset(local) }, used)) +} + +/// `now`, a unix time (seconds, or milliseconds when it is that large), a date, or two dates (their difference). +pub fn time(input: &str, now: i64) -> Result { + let text: &str = input.trim(); + if text.is_empty() || text.eq_ignore_ascii_case("now") { + return Ok(describe(now)); + } + if let Ok(n) = text.parse::() { + return Ok(if n.abs() > 100_000_000_000 { + format!("{} ms · {}", n, describe(n.div_euclid(1000))) + } else { + describe(n) + }); + } + let words: Vec<&str> = text + .split_whitespace() + .filter(|w| !matches!(w.to_ascii_lowercase().as_str(), "to" | "-" | "à" | "->")) + .collect(); + let (start, used): (i64, usize) = parse_date(&words).ok_or("a date is YYYY-MM-DD [HH:MM[:SS]]")?; + if used == words.len() { + return Ok(describe(start)); + } + let (end, more): (i64, usize) = parse_date(&words[used..]).ok_or("a date is YYYY-MM-DD [HH:MM[:SS]]")?; + if used + more != words.len() { + return Err("at most two dates".into()); + } + let secs: i64 = (end - start).abs(); + let sign: &str = if end < start { "-" } else { "" }; + Ok(format!("{sign}{} days {} h {} min · {sign}{secs} s", secs / 86_400, secs / 3600 % 24, secs / 60 % 60)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dates() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(days_from_civil(2000, 2, 29), 11_016); + for days in [-800_000, -1, 0, 59, 11_016, 20_000, 3_000_000] { + let (y, m, d): (i64, i64, i64) = civil_from_days(days); + assert_eq!(days_from_civil(y, m, d), days); + } + assert_eq!(datetime(1_700_000_000, 0), "2023-11-14 22:13:20"); + assert!(time("1700000000", 0).unwrap().contains("2023-11-14 22:13:20 UTC · Tuesday")); + assert!(time("1700000000000", 0).unwrap().starts_with("1700000000000 ms · 1700000000")); + assert!(time("2023-11-14T22:13:20Z", 0).unwrap().starts_with("1700000000 ·")); + assert_eq!(time("2026-01-01Z to 2026-01-08T12:00Z", 0).unwrap(), "7 days 12 h 0 min · 648000 s"); + assert!(time("2026-13-01", 0).is_err()); + assert_eq!(time("2026-01-01Z 2026-01-02Z 2026-01-03Z", 0).unwrap_err(), "at most two dates"); + assert!(time("2026-01-01 25:00", 0).is_err()); + assert!(time("2026-01-01 1:2:3:4", 0).is_err()); + assert!(time("2026-01-01 x:00", 0).is_err()); + assert!(time("2026-01-02Z 2026-01-01Z", 0).unwrap().starts_with("-1 days")); + assert!(time("now", 1_700_000_000).unwrap().starts_with("1700000000 ·")); + assert!(time("", 1_700_000_000).unwrap().starts_with("1700000000 ·")); + assert!(time("soon", 0).is_err()); + } +} diff --git a/src/editor/tools/units.rs b/src/editor/tools/units.rs new file mode 100644 index 0000000..8a733a5 --- /dev/null +++ b/src/editor/tools/units.rs @@ -0,0 +1,269 @@ +//! The unit converter (`xu`): `5 ft to cm`, `2 m` (every unit of its kind). + +use super::calc::{calc, format_number}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Dim { + Length, + Area, + Volume, + Mass, + Time, + Speed, + Temperature, + Data, + Energy, + Power, + Pressure, + Angle, +} + +impl Dim { + fn name(self) -> &'static str { + match self { + Self::Length => "length", + Self::Area => "area", + Self::Volume => "volume", + Self::Mass => "mass", + Self::Time => "time", + Self::Speed => "speed", + Self::Temperature => "temperature", + Self::Data => "data", + Self::Energy => "energy", + Self::Power => "power", + Self::Pressure => "pressure", + Self::Angle => "angle", + } + } +} + +/// A unit: its names (the first is shown), what it measures, and `base = value * factor + offset` in the +/// dimension's base unit (m, m², L, kg, s, m/s, K, byte, J, W, Pa, rad). +struct Unit { + names: &'static [&'static str], + dim: Dim, + factor: f64, + offset: f64, +} + +macro_rules! u { + ($names:expr, $dim:expr, $factor:expr) => { + Unit { names: $names, dim: $dim, factor: $factor, offset: 0.0 } + }; +} + +const MILE: f64 = 1609.344; +const FOOT: f64 = 0.3048; +const INCH: f64 = 0.0254; +const POUND: f64 = 0.453_592_37; +const GALLON: f64 = 3.785_411_784; +const DAY: f64 = 86_400.0; + +/// Names match exactly first (`Mb` megabit, `MB` megabyte), then ignoring case. +const UNITS: &[Unit] = &[ + u!(&["m", "meter", "meters", "metre", "metres", "mètre", "mètres"], Dim::Length, 1.0), + u!(&["km", "kilometer", "kilometers", "kilometre", "kilomètre", "kilomètres"], Dim::Length, 1000.0), + u!(&["cm", "centimeter", "centimeters", "centimètre", "centimètres"], Dim::Length, 0.01), + u!(&["mm", "millimeter", "millimeters", "millimètre", "millimètres"], Dim::Length, 0.001), + u!(&["µm", "um", "micron", "microns"], Dim::Length, 1e-6), + u!(&["nm", "nanometer", "nanometers"], Dim::Length, 1e-9), + u!(&["in", "inch", "inches", "pouce", "pouces", "\""], Dim::Length, INCH), + u!(&["ft", "foot", "feet", "pied", "pieds", "'"], Dim::Length, FOOT), + u!(&["yd", "yard", "yards"], Dim::Length, 0.9144), + u!(&["mi", "mile", "miles"], Dim::Length, MILE), + u!(&["nmi"], Dim::Length, 1852.0), + u!(&["au"], Dim::Length, 149_597_870_700.0), + u!(&["ly", "lightyear", "lightyears", "al"], Dim::Length, 9_460_730_472_580_800.0), + u!(&["m2", "m²", "sqm"], Dim::Area, 1.0), + u!(&["km2", "km²"], Dim::Area, 1e6), + u!(&["cm2", "cm²"], Dim::Area, 1e-4), + u!(&["mm2", "mm²"], Dim::Area, 1e-6), + u!(&["ha", "hectare", "hectares"], Dim::Area, 1e4), + u!(&["acre", "acres"], Dim::Area, 4_046.856_422_4), + u!(&["ft2", "ft²", "sqft"], Dim::Area, FOOT * FOOT), + u!(&["in2", "in²", "sqin"], Dim::Area, INCH * INCH), + u!(&["mi2", "mi²", "sqmi"], Dim::Area, MILE * MILE), + u!(&["l", "L", "liter", "liters", "litre", "litres"], Dim::Volume, 1.0), + u!(&["ml", "mL", "milliliter", "millilitre"], Dim::Volume, 0.001), + u!(&["cl", "cL", "centilitre"], Dim::Volume, 0.01), + u!(&["dl", "dL", "decilitre"], Dim::Volume, 0.1), + u!(&["m3", "m³"], Dim::Volume, 1000.0), + u!(&["cm3", "cm³", "cc"], Dim::Volume, 0.001), + u!(&["gal", "gallon", "gallons"], Dim::Volume, GALLON), + u!(&["ukgal"], Dim::Volume, 4.546_09), + u!(&["qt", "quart", "quarts"], Dim::Volume, GALLON / 4.0), + u!(&["pt", "pint", "pints"], Dim::Volume, GALLON / 8.0), + u!(&["cup", "cups", "tasse", "tasses"], Dim::Volume, GALLON / 16.0), + u!(&["floz"], Dim::Volume, GALLON / 128.0), + u!(&["tbsp", "tablespoon", "càs"], Dim::Volume, GALLON / 256.0), + u!(&["tsp", "teaspoon", "càc"], Dim::Volume, GALLON / 768.0), + u!(&["kg", "kilogram", "kilograms", "kilo", "kilos", "kilogramme", "kilogrammes"], Dim::Mass, 1.0), + u!(&["g", "gram", "grams", "gramme", "grammes"], Dim::Mass, 0.001), + u!(&["mg", "milligram", "milligrams", "milligramme"], Dim::Mass, 1e-6), + u!(&["t", "tonne", "tonnes", "ton"], Dim::Mass, 1000.0), + u!(&["lb", "lbs", "pound", "pounds", "livre", "livres"], Dim::Mass, POUND), + u!(&["oz", "ounce", "ounces", "once", "onces"], Dim::Mass, POUND / 16.0), + u!(&["st", "stone", "stones"], Dim::Mass, POUND * 14.0), + u!(&["ct", "carat", "carats"], Dim::Mass, 0.0002), + u!(&["s", "sec", "second", "seconds", "seconde", "secondes"], Dim::Time, 1.0), + u!(&["ms", "millisecond", "milliseconds", "milliseconde"], Dim::Time, 0.001), + u!(&["µs", "us", "microsecond", "microseconds"], Dim::Time, 1e-6), + u!(&["ns", "nanosecond", "nanoseconds"], Dim::Time, 1e-9), + u!(&["min", "minute", "minutes"], Dim::Time, 60.0), + u!(&["h", "hr", "hour", "hours", "heure", "heures"], Dim::Time, 3600.0), + u!(&["d", "day", "days", "jour", "jours"], Dim::Time, DAY), + u!(&["wk", "week", "weeks", "semaine", "semaines"], Dim::Time, 7.0 * DAY), + u!(&["mo", "month", "months", "mois"], Dim::Time, 30.436_875 * DAY), + u!(&["yr", "year", "years", "an", "ans", "année", "années"], Dim::Time, 365.2425 * DAY), + u!(&["m/s", "mps"], Dim::Speed, 1.0), + u!(&["km/h", "kmh", "kph"], Dim::Speed, 1.0 / 3.6), + u!(&["mph", "mi/h"], Dim::Speed, MILE / 3600.0), + u!(&["kn", "knot", "knots", "noeud", "noeuds", "nœud", "nœuds"], Dim::Speed, 1852.0 / 3600.0), + u!(&["ft/s", "fps"], Dim::Speed, FOOT), + Unit { names: &["c", "°c", "celsius"], dim: Dim::Temperature, factor: 1.0, offset: 273.15 }, + Unit { + names: &["f", "°f", "fahrenheit"], + dim: Dim::Temperature, + factor: 5.0 / 9.0, + offset: 273.15 - 32.0 * 5.0 / 9.0, + }, + u!(&["k", "kelvin"], Dim::Temperature, 1.0), + u!(&["B", "byte", "bytes", "octet", "octets", "o"], Dim::Data, 1.0), + u!(&["bit", "bits", "b"], Dim::Data, 0.125), + u!(&["KB", "kB", "kilobyte", "Ko"], Dim::Data, 1e3), + u!(&["MB", "megabyte", "Mo"], Dim::Data, 1e6), + u!(&["GB", "gigabyte", "Go"], Dim::Data, 1e9), + u!(&["TB", "terabyte", "To"], Dim::Data, 1e12), + u!(&["PB", "petabyte"], Dim::Data, 1e15), + u!(&["KiB", "kibibyte", "Kio"], Dim::Data, 1024.0), + u!(&["MiB", "mebibyte", "Mio"], Dim::Data, 1_048_576.0), + u!(&["GiB", "gibibyte", "Gio"], Dim::Data, 1_073_741_824.0), + u!(&["TiB", "tebibyte", "Tio"], Dim::Data, 1_099_511_627_776.0), + u!(&["Kb", "kbit", "kilobit"], Dim::Data, 125.0), + u!(&["Mb", "mbit", "megabit"], Dim::Data, 125_000.0), + u!(&["Gb", "gbit", "gigabit"], Dim::Data, 125_000_000.0), + u!(&["J", "joule", "joules"], Dim::Energy, 1.0), + u!(&["kJ", "kilojoule", "kilojoules"], Dim::Energy, 1000.0), + u!(&["cal", "calorie", "calories"], Dim::Energy, 4.184), + u!(&["kcal", "kilocalorie", "kilocalories", "Cal"], Dim::Energy, 4184.0), + u!(&["Wh", "watt-hour"], Dim::Energy, 3600.0), + u!(&["kWh", "kilowatt-hour"], Dim::Energy, 3.6e6), + u!(&["eV", "electronvolt"], Dim::Energy, 1.602_176_634e-19), + u!(&["BTU", "btu"], Dim::Energy, 1055.06), + u!(&["W", "watt", "watts"], Dim::Power, 1.0), + u!(&["kW", "kilowatt", "kilowatts"], Dim::Power, 1000.0), + u!(&["MW", "megawatt", "megawatts"], Dim::Power, 1e6), + u!(&["hp", "horsepower"], Dim::Power, 745.699_872), + u!(&["ch", "cv", "cheval", "chevaux"], Dim::Power, 735.498_75), + u!(&["Pa", "pascal", "pascals"], Dim::Pressure, 1.0), + u!(&["hPa", "hectopascal"], Dim::Pressure, 100.0), + u!(&["kPa", "kilopascal"], Dim::Pressure, 1000.0), + u!(&["bar", "bars"], Dim::Pressure, 1e5), + u!(&["mbar", "millibar"], Dim::Pressure, 100.0), + u!(&["atm", "atmosphere", "atmosphère"], Dim::Pressure, 101_325.0), + u!(&["psi"], Dim::Pressure, 6_894.757_293_168), + u!(&["mmHg", "torr"], Dim::Pressure, 133.322_387_415), + u!(&["rad", "radian", "radians"], Dim::Angle, 1.0), + u!(&["deg", "°", "degree", "degrees", "degré", "degrés"], Dim::Angle, std::f64::consts::PI / 180.0), + u!(&["grad", "gon"], Dim::Angle, std::f64::consts::PI / 200.0), + u!(&["turn", "turns", "tour", "tours"], Dim::Angle, std::f64::consts::TAU), +]; + +fn unit(name: &str) -> Option<&'static Unit> { + UNITS + .iter() + .find(|u| u.names.contains(&name)) + .or_else(|| UNITS.iter().find(|u| u.names.iter().any(|n| n.eq_ignore_ascii_case(name)))) +} + +/// Words that join the value and the target unit: `5 ft to cm`, `70 kg en lb`. +const JOINS: [&str; 7] = ["to", "in", "en", "as", "into", "->", "="]; + +/// `5 ft to cm`, `70kg lb`, `100 f in c`, `1.5 GiB MB`; without a target, the value in every unit of its kind. +pub fn convert(input: &str) -> Result { + let text: &str = input.trim(); + // The value is the leading arithmetic; the unit starts at the first other char (a letter, `°`, `µ`, `'`). + let mut end: usize = 0; + let chars: Vec<(usize, char)> = text.char_indices().collect(); + for (k, &(i, c)) in chars.iter().enumerate() { + let exponent: bool = matches!(c, 'e' | 'E') + && k > 0 + && chars[k - 1].1.is_ascii_digit() + && chars.get(k + 1).is_some_and(|&(_, n)| n.is_ascii_digit() || n == '-' || n == '+'); + if !(c.is_ascii_digit() || "+-*/^.() ,_".contains(c) || exponent) { + break; + } + end = i + c.len_utf8(); + } + let (number, rest): (&str, &str) = text.split_at(end); + let value: f64 = calc(&number.replace(',', "."), None).ok_or("start with a number: 5 ft to cm")?; + // A joining word only between two units, so `in` stays inches in `1 in cm` and `1 in in cm`. + let words: Vec<&str> = rest.split_whitespace().collect(); + let (from, to): (&str, Option<&str>) = match words[..] { + [from] => (from, None), + [from, to] => (from, Some(to)), + [from, join, to] if JOINS.contains(&join.to_ascii_lowercase().as_str()) => (from, Some(to)), + [] => return Err("which unit? 5 ft to cm".into()), + _ => return Err("one unit to convert from, one to convert to: 5 ft to cm".into()), + }; + let from_unit: &Unit = unit(from).ok_or_else(|| format!("unknown unit: {from}"))?; + let base: f64 = value * from_unit.factor + from_unit.offset; + let show = |u: &Unit| format!("{} {}", format_number((base - u.offset) / u.factor), u.names[0]); + let shown: String = match to { + Some(to) => { + let to_unit: &Unit = unit(to).ok_or_else(|| format!("unknown unit: {to}"))?; + if to_unit.dim != from_unit.dim { + return Err(format!("{from} is a {}, {to} a {}", from_unit.dim.name(), to_unit.dim.name())); + } + show(to_unit) + } + None => UNITS + .iter() + .filter(|u| u.dim == from_unit.dim && !std::ptr::eq(*u, from_unit)) + .map(show) + .collect::>() + .join(" · "), + }; + Ok(shown) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn units() { + assert_eq!(convert("5 ft to cm").unwrap(), "152.4 cm"); + assert_eq!(convert("70kg en lb").unwrap(), "154.3235835294 lb"); + assert_eq!(convert("100 f in c").unwrap(), "37.7777777778 c"); + assert_eq!(convert("-40 c f").unwrap(), "-40 f"); + assert_eq!(convert("1 GiB MB").unwrap(), "1073.741824 MB"); + assert_eq!(convert("8 Mb to MB").unwrap(), "1 MB"); + assert_eq!(convert("90 km/h mph").unwrap(), "55.9234073014 mph"); + assert_eq!(convert("1,5 h min").unwrap(), "90 min"); + assert!(convert("2 m").unwrap().contains("200 cm"), "every unit of the kind"); + assert_eq!(convert("1 m to kg").unwrap_err(), "m is a length, kg a mass"); + // Names are unique (case aside, which tells `Mb` from `MB`), so none hides another. + let names: Vec<&str> = UNITS.iter().flat_map(|u| u.names.iter().copied()).collect(); + for (i, name) in names.iter().enumerate() { + assert!(!names[i + 1..].contains(name), "{name} names two units"); + } + // Every unit converts to the first of its kind, and names its kind when they differ. + for unit in UNITS { + let first: &Unit = UNITS.iter().find(|u| u.dim == unit.dim).unwrap(); + for name in unit.names { + assert!(convert(&format!("2 {name} to {}", first.names[0])).is_ok(), "{name}"); + } + let other: &Unit = UNITS.iter().find(|u| u.dim != unit.dim).unwrap(); + let message: String = convert(&format!("1 {} {}", unit.names[0], other.names[0])).unwrap_err(); + assert!(message.contains(unit.dim.name()), "{message}"); + } + assert!(convert("5").is_err()); + assert!(convert("5 ft cm in").is_err()); + assert_eq!(convert("1 in cm").unwrap(), "2.54 cm", "in is inches"); + assert_eq!(convert("1 in in cm").unwrap(), "2.54 cm"); + assert_eq!(convert("1 ft to in").unwrap(), "12 in"); + assert!(convert("1 parsec cm").is_err()); + assert!(convert("ft to cm").is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index f1e221e..7dd0317 100644 --- a/src/main.rs +++ b/src/main.rs @@ -87,55 +87,23 @@ fn main() -> ExitCode { } } +/// The event loop: draw, wait for a key or a timer, handle it, then do what the editor asked of the terminal. fn run(editor: &mut Editor) -> io::Result<()> { let terminal: Terminal = Terminal::enter()?; - // A bug must never kill the session: record it quietly, recover, keep editing. - panic::set_hook(Box::new(|info| { - let what: String = info - .payload() - .downcast_ref::<&str>() - .map(|s| (*s).to_owned()) - .or_else(|| info.payload().downcast_ref::().cloned()) - .unwrap_or_default(); - let place: String = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default(); - if let Ok(mut last) = LAST_PANIC.lock() { - *last = format!("{what}{place}"); - } - })); + record_panics(); let mut input: Input = Input::default(); let mut screen: Screen = Screen::new(term::color_depth()); let mut out: io::StdoutLock<'_> = io::stdout().lock(); while !editor.quit { // Skip frames while keys are still queued (pastes, key repeat). if !input.has_pending() { - let frame: String = - if let Ok(frame) = panic::catch_unwind(AssertUnwindSafe(|| screen.draw(editor, term::size()))) { - frame - } else { - recover(editor, &mut screen); - // Drawing itself failed: show the error plainly so `:w`/`:q` stay usable. - format!("\x1b[0m\x1b[2J\x1b[H{}", editor.message_text().replace(char::is_control, "?")) - }; - out.write_all(frame.as_bytes())?; + out.write_all(draw(editor, &mut screen).as_bytes())?; out.flush()?; } let event: Event = input.read(editor.deadline())?; - let handled: Result<(), Box> = panic::catch_unwind(AssertUnwindSafe(|| match event { - Event::Key(key) => editor.handle(key), - Event::Timeout => editor.tick(), - Event::Resize => {} - })); - if handled.is_err() { - recover(editor, &mut screen); - } + handle(editor, &mut screen, event); if let Some(command) = editor.shell_requested.take() { - if let Err(e) = terminal.run(&command) { - editor.set_message(editor::Message::Error(format!( - "{}: {e}", - command.first().map_or("shell", String::as_str) - ))); - } - editor.reload_changed_buffers(); + run_shell(editor, &terminal, &command); screen.invalidate(); } if let Some(sequence) = editor.osc52.take() { @@ -146,6 +114,53 @@ fn run(editor: &mut Editor) -> io::Result<()> { Ok(()) } +/// A bug must never kill the session: the panic hook records the message quietly, for `recover` to show. +fn record_panics() { + panic::set_hook(Box::new(|info| { + let what: String = info + .payload() + .downcast_ref::<&str>() + .map(|s| (*s).to_owned()) + .or_else(|| info.payload().downcast_ref::().cloned()) + .unwrap_or_default(); + let place: String = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default(); + if let Ok(mut last) = LAST_PANIC.lock() { + *last = format!("{what}{place}"); + } + })); +} + +/// The next frame. If drawing itself panics, the editor recovers and the error shows plainly, so `:w` and `:q` +/// stay usable. +fn draw(editor: &mut Editor, screen: &mut Screen) -> String { + if let Ok(frame) = panic::catch_unwind(AssertUnwindSafe(|| screen.draw(editor, term::size()))) { + return frame; + } + recover(editor, screen); + format!("\x1b[0m\x1b[2J\x1b[H{}", editor.message_text().replace(char::is_control, "?")) +} + +/// A key or a timer tick; a panic while handling it is recovered from, and editing goes on. +fn handle(editor: &mut Editor, screen: &mut Screen, event: Event) { + let handled: Result<(), Box> = panic::catch_unwind(AssertUnwindSafe(|| match event { + Event::Key(key) => editor.handle(key), + Event::Timeout => editor.tick(), + Event::Resize => {} + })); + if handled.is_err() { + recover(editor, screen); + } +} + +/// `` / `t…`: suspends the editor to run `command`, then reloads the files it may have changed. +fn run_shell(editor: &mut Editor, terminal: &Terminal, command: &[String]) { + if let Err(e) = terminal.run(command) { + let program: &str = command.first().map_or("shell", String::as_str); + editor.set_message(editor::Message::Error(format!("{program}: {e}"))); + } + editor.reload_changed_buffers(); +} + fn recover(editor: &mut Editor, screen: &mut Screen) { let detail: String = LAST_PANIC.lock().map(|m| m.clone()).unwrap_or_default(); editor.recover(&detail); diff --git a/src/render/bars.rs b/src/render/bars.rs index abf8db6..9db0bd4 100644 --- a/src/render/bars.rs +++ b/src/render/bars.rs @@ -49,53 +49,19 @@ const BUF_WIDTH: usize = 21; pub fn tabline(ed: &Editor, grid: &mut Grid, y: usize, offset: usize) -> Vec { let t: Arc = theme(); let set: &Icons = icons(&ed.options); - let mut segments: Vec<(Style, String)> = Vec::with_capacity(ed.buffers.len() * 5); - let mut hits: Vec = Vec::with_capacity(ed.buffers.len() * 2 + 2); - let mut x: usize = offset; + let mut tabs: Tabs = Tabs { + segments: Vec::with_capacity(ed.buffers.len() * 5), + hits: Vec::with_capacity(ed.buffers.len() * 2 + 2), + x: offset, + }; // While picking (`bb`), heirline swaps each icon for a red jump label. let labels: Option> = matches!(ed.mode, Mode::BufferPick(_)).then(|| ed.pick_labels()); for (i, buf) in ed.buffers.iter().enumerate() { - let active: bool = i == ed.current; - let name: Style = t.style(if active { Group::TabLineSel } else { Group::TabLine }); - let (icon, icon_fg): (String, Option) = set.for_file(buf.lang); - let (icon, icon_style): (String, Style) = match labels.as_ref().and_then(|l| l.get(i)) { - Some(label) => (format!("{label} "), t.on(Group::BufferPicker, name.bg)), - None if !icon.is_empty() => (format!("{icon} "), Style { fg: icon_fg.map_or(name.fg, Rgb), ..name }), - None => (String::new(), name), - }; - let (mark, mark_style): (String, Style) = if buf.is_dirty() { - // Green on the current buffer, red on the others (TbBufOnModified / TbBufOffModified). - let group: Group = if active { Group::TabLineModified } else { Group::ErrorMsg }; - (format!("{} ", set.modified), t.on(group, name.bg)) - } else if active { - // NvChad's red close button on the current buffer. - (format!("{} ", set.close), t.on(Group::ErrorMsg, name.bg)) - } else { - (format!("{} ", set.close), name) - }; - let label: String = buf.name(); - let used: usize = text::str_width(&icon) + text::str_width(&label) + text::str_width(&mark); - let pad: usize = BUF_WIDTH.saturating_sub(used).max(2); - let mark_x: usize = x + pad + used - text::str_width(&mark); - let end: usize = x + pad + used; - hits.extend([ - TabHit { from: x, to: mark_x, click: TabClick::Buffer(i) }, - TabHit { from: mark_x, to: end, click: TabClick::Close(i) }, - ]); - x = end; - segments.extend([ - (name, " ".repeat(pad / 2)), - (icon_style, icon), - (name, label), - (name, " ".repeat(pad - pad / 2)), - (mark_style, mark), - ]); + let label: Option = labels.as_ref().and_then(|l| l.get(i)).copied(); + tabs.push(&t, set, buf, (i, i == ed.current), label); } - // NvChad's buttons on the right: light/dark theme, close all buffers. - let buttons: [(Style, String); 2] = [ - (t.style(Group::TabLineButton), format!(" {} ", set.theme)), - (t.style(Group::TabLineCloseAll), format!(" {} ", set.close)), - ]; + let Tabs { segments, mut hits, .. }: Tabs = tabs; + let buttons: [Segment; 2] = buttons(&t, set); let right_width: usize = buttons.iter().map(|(_, s)| text::str_width(s)).sum(); let right: &[Segment] = if offset + right_width <= grid.width { &buttons } else { &[] }; // Tabs cut off by the buttons cannot be clicked. @@ -114,71 +80,165 @@ pub fn tabline(ed: &Editor, grid: &mut Grid, y: usize, offset: usize) -> Vec, + hits: Vec, + x: usize, +} + +impl Tabs { + /// Buffer `i`'s tab, centred in `BUF_WIDTH`: its icon (or jump `label`), name and close or modified mark. + fn push(&mut self, t: &Theme, set: &Icons, buf: &Buffer, (i, active): (usize, bool), label: Option) { + let name: Style = t.style(if active { Group::TabLineSel } else { Group::TabLine }); + let (icon_style, icon): Segment = tab_icon(t, set, buf, label, name); + let (mark_style, mark): Segment = tab_mark(t, set, buf.is_dirty(), active, name); + let title: String = buf.name(); + let used: usize = text::str_width(&icon) + text::str_width(&title) + text::str_width(&mark); + let pad: usize = BUF_WIDTH.saturating_sub(used).max(2); + let end: usize = self.x + pad + used; + let mark_x: usize = end - text::str_width(&mark); + self.hits.extend([ + TabHit { from: self.x, to: mark_x, click: TabClick::Buffer(i) }, + TabHit { from: mark_x, to: end, click: TabClick::Close(i) }, + ]); + self.segments.extend([ + (name, " ".repeat(pad / 2)), + (icon_style, icon), + (name, title), + (name, " ".repeat(pad - pad / 2)), + (mark_style, mark), + ]); + self.x = end; + } +} + +/// A tab's file icon in its color, or the red jump label while picking a buffer. +fn tab_icon(t: &Theme, set: &Icons, buf: &Buffer, label: Option, name: Style) -> Segment { + let (icon, icon_fg): (String, Option) = set.for_file(buf.lang); + match label { + Some(label) => (t.on(Group::BufferPicker, name.bg), format!("{label} ")), + None if !icon.is_empty() => (Style { fg: icon_fg.map_or(name.fg, Rgb), ..name }, format!("{icon} ")), + None => (name, String::new()), + } +} + +/// A tab's right end: the modified mark (green on the current buffer, red on the others, as `NvChad`'s +/// `TbBufOnModified` / `TbBufOffModified`), else the close button (red on the current buffer). +fn tab_mark(t: &Theme, set: &Icons, dirty: bool, active: bool, name: Style) -> Segment { + match (dirty, active) { + (true, true) => (t.on(Group::TabLineModified, name.bg), format!("{} ", set.modified)), + (true, false) => (t.on(Group::ErrorMsg, name.bg), format!("{} ", set.modified)), + (false, true) => (t.on(Group::ErrorMsg, name.bg), format!("{} ", set.close)), + (false, false) => (name, format!("{} ", set.close)), + } +} + +/// `NvChad`'s buttons on the right of the tabline: light/dark theme, close all buffers. +fn buttons(t: &Theme, set: &Icons) -> [Segment; 2] { + [ + (t.style(Group::TabLineButton), format!(" {} ", set.theme)), + (t.style(Group::TabLineCloseAll), format!(" {} ", set.close)), + ] +} + +/// The statusline's colors, shared by its blocks. +struct StatusColors { + fill: Style, + section: Style, + /// The grey step between a colored block and the next one. + step: Rgb, +} + +/// `NvChad`'s default statusline: mode, file and git on the left; background work, cwd and position on the right. pub fn statusline(ed: &Editor, ctx: &Context, grid: &mut Grid, y: usize) { let t: Arc = theme(); let set: &Icons = icons(&ed.options); - let buf: &Buffer = ed.buf(); - let fill: Style = t.style(Group::StatusLine); - let section: Style = t.style(Group::StatusSection); - let mode: Style = t.style(mode_group(ed.mode)); - let (icon, icon_fg): (String, Option) = set.for_file(buf.lang); - // Powerline shapes need a Nerd Font: sets without caps stay flat whatever the style. - let (left_cap, right_cap): (&str, &str) = match ed.options.statusline_style { + let colors: StatusColors = StatusColors { + fill: t.style(Group::StatusLine), + section: t.style(Group::StatusSection), + step: t.bg(Group::StatusModeSep), + }; + let (left_cap, right_cap): (&str, &str) = caps(ed.options.statusline_style, set); + let mut left: Vec = mode_and_file(ed, &t, set, &colors, right_cap); + left.extend(git(ed, ctx, &t, set)); + let mut right: Vec = activity(ed, &t, set); + right.extend(cwd_and_position(ed.buf().cursor, ctx, &t, set, &colors, left_cap)); + bar(grid, y, &left, &right, colors.fill); +} + +/// The ends of the statusline's blocks. Powerline shapes need a Nerd Font: sets without caps stay flat. +fn caps(style: StatusStyle, set: &Icons) -> (&'static str, &'static str) { + match style { _ if set.left_cap.is_empty() => ("", ""), StatusStyle::Rounded => (set.left_cap, set.right_cap), StatusStyle::Slanted => ("\u{e0ba}", "\u{e0bc}"), StatusStyle::Arrow => ("\u{e0b2}", "\u{e0b0}"), StatusStyle::Flat => ("", ""), - }; - // NvChad steps from the mode down to the file through a grey cap. - let step: Rgb = t.bg(Group::StatusModeSep); - let mut left: Vec<(Style, String)> = vec![ + } +} + +/// The mode block, stepping down through the grey cap to the file block. +fn mode_and_file(ed: &Editor, t: &Theme, set: &Icons, c: &StatusColors, cap: &str) -> Vec { + let buf: &Buffer = ed.buf(); + let mode: Style = t.style(mode_group(ed.mode)); + let mut segments: Vec = vec![ (mode, format!(" {}{} ", Icons::spaced(set.mode), ed.mode.label())), - (Style::new(mode.bg, step), right_cap.into()), - (Style::new(step, section.bg), right_cap.into()), + (Style::new(mode.bg, c.step), cap.into()), + (Style::new(c.step, c.section.bg), cap.into()), ]; + let (icon, icon_fg): (String, Option) = set.for_file(buf.lang); if !icon.is_empty() { - left.push((Style { fg: icon_fg.map_or(section.fg, Rgb), ..section }, format!(" {icon}"))); + segments.push((Style { fg: icon_fg.map_or(c.section.fg, Rgb), ..c.section }, format!(" {icon}"))); } - left.push((section, format!(" {}{} ", buf.name(), if buf.is_dirty() { " [+]" } else { "" }))); - left.push((Style::new(section.bg, fill.bg), right_cap.into())); - if let Some(branch) = &ctx.branch { - let git: Style = t.style(Group::StatusGit); - left.push((git, format!(" {}{branch}", Icons::spaced(set.branch)))); - let Counts { added, changed, removed }: Counts = - if ed.options.gitsigns { buf.git_counts() } else { Counts::default() }; - for (count, icon) in [(added, set.git_added), (changed, set.git_modified), (removed, set.git_deleted)] { - if count > 0 { - left.push((git, format!(" {}{count}", Icons::spaced(icon)))); - } + segments.push((c.section, format!(" {}{} ", buf.name(), if buf.is_dirty() { " [+]" } else { "" }))); + segments.push((Style::new(c.section.bg, c.fill.bg), cap.into())); + segments +} + +/// The branch, then gitsigns' added, changed and deleted counts. +fn git(ed: &Editor, ctx: &Context, t: &Theme, set: &Icons) -> Vec { + let Some(branch): Option<&String> = ctx.branch.as_ref() else { return Vec::new() }; + let style: Style = t.style(Group::StatusGit); + let mut segments: Vec = vec![(style, format!(" {}{branch}", Icons::spaced(set.branch)))]; + let Counts { added, changed, removed }: Counts = + if ed.options.gitsigns { ed.buf().git_counts() } else { Counts::default() }; + for (count, icon) in [(added, set.git_added), (changed, set.git_modified), (removed, set.git_deleted)] { + if count > 0 { + segments.push((style, format!(" {}{count}", Icons::spaced(icon)))); } } - let mut right: Vec<(Style, String)> = Vec::new(); - // Background work, where NvChad shows LSP progress. + segments +} + +/// Background commands (the hydra spinner, where `NvChad` shows LSP progress) and macro recording. +fn activity(ed: &Editor, t: &Theme, set: &Icons) -> Vec { + let mut segments: Vec = Vec::new(); if let Some((names, elapsed)) = ed.running_jobs() { - let frames: usize = SPINNER.len(); - let frame: &str = SPINNER[usize::try_from(elapsed.as_millis() / SPINNER_FRAME_MS).unwrap_or(0) % frames]; - right.push((t.style(Group::StatusJob), format!("{frame} {names} "))); + let tick: usize = usize::try_from(elapsed.as_millis() / SPINNER_FRAME_MS).unwrap_or(0); + segments.push((t.style(Group::StatusJob), format!("{} {names} ", SPINNER[tick % SPINNER.len()]))); } if let Some(reg) = ed.recording() { - right.push((t.style(Group::StatusRecording), format!("{}@{reg} ", Icons::spaced(set.recording)))); + segments.push((t.style(Group::StatusRecording), format!("{}@{reg} ", Icons::spaced(set.recording)))); } - let Pos { line, col }: Pos = buf.cursor; + segments +} + +/// The cwd and position blocks, each rising from the grey step as the mode block does, mirrored. +fn cwd_and_position(at: Pos, ctx: &Context, t: &Theme, set: &Icons, c: &StatusColors, cap: &str) -> [Segment; 8] { let cwd: Style = t.style(Group::StatusCwd); let position: Style = t.style(Group::StatusPosition); - // The mode's grey step, mirrored: each colored block on the right rises from its own. - right.extend([ - (Style::new(step, fill.bg), left_cap.into()), - (Style::new(cwd.bg, step), left_cap.into()), - (cwd, format!("{} ", if set.cwd.is_empty() { "" } else { set.cwd })), - (section, format!(" {} ", ctx.cwd)), - (Style::new(step, section.bg), left_cap.into()), - (Style::new(position.bg, step), left_cap.into()), - (position, format!("{} ", if set.position.is_empty() { "" } else { set.position })), + [ + (Style::new(c.step, c.fill.bg), cap.into()), + (Style::new(cwd.bg, c.step), cap.into()), + (cwd, format!("{} ", set.cwd)), + (c.section, format!(" {} ", ctx.cwd)), + (Style::new(c.step, c.section.bg), cap.into()), + (Style::new(position.bg, c.step), cap.into()), + (position, format!("{} ", set.position)), // NvChad's `%l/%v`, in the position's color. - (Style { fg: position.bg, ..section }, format!(" {}/{} ", line + 1, col + 1)), - ]); - bar(grid, y, &left, &right, fill); + (Style { fg: position.bg, ..c.section }, format!(" {}/{} ", at.line + 1, at.col + 1)), + ] } /// The `:` / `/` prompt, or (with `cmdheight=1`) the last message. diff --git a/src/render/finder.rs b/src/render/finder.rs index 3c453f9..6c21e73 100644 --- a/src/render/finder.rs +++ b/src/render/finder.rs @@ -5,9 +5,9 @@ use std::path::Path; use std::sync::Arc; use super::Theme; -use super::row::{Grid, Row}; +use super::row::{Cell, Grid, Row}; use super::theme::{Group, Rgb, Style, theme}; -use crate::buffer::text; +use crate::buffer::{Buffer, text}; use crate::editor::{Editor, Item, Picker, Rect, Target}; use crate::render::icons::Icons; use crate::syntax::{self, State}; @@ -47,125 +47,186 @@ pub fn picker(ed: &Editor, grid: &mut Grid) -> Option<(usize, usize)> { return None; } let (x, y): (usize, usize) = ((sw - w) / 2, (sh - h) / 2); + // The list takes the whole width on a narrow screen, else leaves the right part to the preview. let list_w: usize = if w >= 80 { w * 45 / 100 } else { w }; let fill: Style = t.style(Group::NormalFloat); let border: Style = t.style(Group::FloatBorder); + let cursor_col: usize = input(&t, set, picker, grid, Rect { x, y, w: list_w, h: 3 }, border); + let list: Rect = Rect { x, y: y + 3, w: list_w, h: h - 3 }; + grid.float(list, border, fill, ""); + results(&t, set, picker, grid, list, fill); + if list_w < w { + preview(ed, grid, Rect { x: x + list_w, y, w: w - list_w, h }, fill, border); + } + Some((y + 1, cursor_col)) +} - // Input, on its own shade like NvChad's telescope prompt. +/// The query line, on its own shade like `NvChad`'s telescope prompt, with the match count on the right; returns +/// the cursor's column. +fn input(t: &Theme, set: &Icons, picker: &Picker, grid: &mut Grid, area: Rect, border: Style) -> usize { let prompt: Style = t.style(Group::PickerInput); - grid.float(Rect { x, y, w: list_w, h: 3 }, border, prompt, picker.kind.title()); - let mut input: Row = Row::new(list_w - 2); + grid.float(area, border, prompt, picker.kind.title()); + let mut input: Row = Row::new(area.w - 2); input.text(Style { fg: t.bg(Group::FloatTitle), bold: true, ..prompt }, &format!(" {} ", set.search)); input.text(prompt, &picker.query); - let cursor_col: usize = x + 1 + input.used(); + let cursor_col: usize = area.x + 1 + input.used(); let count: String = format!("{}/{} ", picker.matches.len(), picker.items.len()); - input.pad_to(prompt, (list_w - 2).saturating_sub(text::str_width(&count))); + input.pad_to(prompt, (area.w - 2).saturating_sub(text::str_width(&count))); input.text(t.on(Group::LineNr, prompt.bg), &count); - grid.put(x + 1, y + 1, &input.finish(prompt)); + grid.put(area.x + 1, area.y + 1, &input.finish(prompt)); + cursor_col +} - // Results. - let list: Rect = Rect { x, y: y + 3, w: list_w, h: h - 3 }; - grid.float(list, border, fill, ""); +/// The matches that fit in `list`, scrolled to keep the selected one in view. +fn results(t: &Theme, set: &Icons, picker: &Picker, grid: &mut Grid, list: Rect, fill: Style) { let rows: usize = list.h.saturating_sub(2); let first: usize = picker.selected.saturating_sub(rows.saturating_sub(1)); for (r, m) in picker.matches.iter().enumerate().skip(first).take(rows) { let Some(item): Option<&Item> = picker.items.get(m.item) else { continue }; - let selected: bool = r == picker.selected; - let bg: Rgb = if selected { t.bg(Group::PmenuSel) } else { fill.bg }; - let base: Style = Style { bg, bold: selected, ..fill }; - let mut row: Row = Row::new(list.w - 2); - row.text(Style { fg: t.fg(Group::BufferPicker), ..base }, if selected { "\u{258c}" } else { " " }); - if let Target::File(path, _) = &item.target { - let (icon, color): (String, Option) = set.for_file(syntax::lang_for(path)); - if !icon.is_empty() { - row.text(Style { fg: color.map_or(base.fg, Rgb), ..base }, &format!("{icon} ")); - } + let row: Vec = result_row(t, set, item, &m.positions, r == picker.selected, fill, list.w - 2); + grid.put(list.x + 1, list.y + 1 + r - first, &row); + } +} + +/// One match: the selection bar, a file's icon, then the label with its folder dimmed and the matched chars lit. +fn result_row( + t: &Theme, + set: &Icons, + item: &Item, + positions: &[usize], + selected: bool, + fill: Style, + width: usize, +) -> Vec { + let bg: Rgb = if selected { t.bg(Group::PmenuSel) } else { fill.bg }; + let base: Style = Style { bg, bold: selected, ..fill }; + let mut row: Row = Row::new(width); + row.text(Style { fg: t.fg(Group::BufferPicker), ..base }, if selected { "\u{258c}" } else { " " }); + let mut dir_end: usize = 0; + if let Target::File(path, _) = &item.target { + let (icon, color): (String, Option) = set.for_file(syntax::lang_for(path)); + if !icon.is_empty() { + row.text(Style { fg: color.map_or(base.fg, Rgb), ..base }, &format!("{icon} ")); } - let dir_end: usize = match &item.target { - Target::File(..) => item.label.rfind('/').map_or(0, |i| text::char_len(&item.label[..=i])), - _ => 0, + dir_end = item.label.rfind('/').map_or(0, |i| text::char_len(&item.label[..=i])); + } + let matched: Style = Style { bg, ..t.on(Group::PickerMatch, bg) }; + let dir: Style = Style { fg: t.fg(Group::PickerDir), ..base }; + for (i, c) in item.label.chars().enumerate() { + let style: Style = if positions.contains(&i) { + matched + } else if i < dir_end { + dir + } else { + base }; - for (i, c) in item.label.chars().enumerate() { - let style: Style = if m.positions.contains(&i) { - Style { bg, ..t.on(Group::PickerMatch, bg) } - } else if i < dir_end { - Style { fg: t.fg(Group::PickerDir), ..base } - } else { - base - }; - row.cell(style, c); - } - grid.put(list.x + 1, list.y + 1 + r - first, &row.finish(base)); + row.cell(style, c); } + row.finish(base) +} - // Preview. - if list_w < w { - let area: Rect = Rect { x: x + list_w, y, w: w - list_w, h }; - preview(ed, grid, area, fill, border); - } - Some((y + 1, cursor_col)) +/// What the preview shows: a title, the lines, the line to bring into view and the path that picks the +/// highlighter. +struct Shown<'a> { + title: String, + lines: Vec, + focus: usize, + path: Option<&'a Path>, } +/// The selected item's preview: its file or buffer around its line, the theme sample, or the source's own text. fn preview(ed: &Editor, grid: &mut Grid, area: Rect, fill: Style, border: Style) { - let t: Arc = theme(); - let Some(item): Option<&Item> = ed.picker.as_ref().and_then(|p| p.selected_item()) else { + let Some(shown): Option> = preview_source(ed) else { return grid.float(area, border, fill, ""); }; - let (title, lines, focus, path): (String, Vec, usize, Option<&Path>) = match &item.target { - Target::File(path, pos) => { - let content: Option> = fs::read(path).ok().filter(|b| !b.iter().take(8000).any(|&c| c == 0)); - let content: Option = content.map(|mut b| { - b.truncate(MAX_PREVIEW_BYTES); - String::from_utf8_lossy(&b).into_owned() - }); - let lines: Vec = content.unwrap_or_default().lines().map(str::to_owned).collect(); - (path.display().to_string(), lines, pos.map_or(0, |p| p.line), Some(path)) - } - Target::Buffer(i) => match ed.buffers.get(*i) { - Some(buf) => (buf.name(), buf.lines().to_vec(), buf.cursor.line, buf.path.as_deref()), - None => return, + grid.float(area, border, fill, &shown.title); + let rows: usize = area.h.saturating_sub(2); + let first: usize = shown.focus.saturating_sub(rows / 3); + let lang: Option<&syntax::Lang> = shown.path.and_then(syntax::lang_for); + let number_width: usize = (first + rows).to_string().len(); + for (r, line) in shown.lines.iter().enumerate().skip(first).take(rows) { + // The item's own line stands out, unless it is the first (a file opened at its top). + let focused: bool = r == shown.focus && shown.focus > 0; + let row: Vec = code_row(line, r + 1, number_width, lang, focused, fill, area.w - 2); + grid.put(area.x + 1, area.y + 1 + r - first, &row); + } +} + +fn preview_source(ed: &Editor) -> Option> { + let item: &Item = ed.picker.as_ref().and_then(|p| p.selected_item())?; + Some(match &item.target { + Target::File(path, pos) => Shown { + title: path.display().to_string(), + lines: read_preview(path), + focus: pos.map_or(0, |p| p.line), + path: Some(path), }, - Target::Line(y) => (ed.buf().name(), ed.buf().lines().to_vec(), *y, ed.buf().path.as_deref()), - // The screen is already redrawn in the theme under the cursor; the sample shows each of its colors. - Target::Theme(name) => { - (name.clone(), THEME_SAMPLE.lines().map(str::to_owned).collect(), 0, Some(Path::new("sample.rs"))) + Target::Buffer(i) => { + let buf: &Buffer = ed.buffers.get(*i)?; + of_buffer(buf.name(), buf, buf.cursor.line) } + Target::Line(y) => of_buffer(ed.buf().name(), ed.buf(), *y), + // The screen is already redrawn in the theme under the cursor; the sample shows each of its colors. + Target::Theme(name) => Shown { + title: name.clone(), + lines: THEME_SAMPLE.lines().map(str::to_owned).collect(), + focus: 0, + path: Some(Path::new("sample.rs")), + }, // Icon sets preview on the current buffer, already redrawn in them. - Target::IconSet(name) => { - (name.clone(), ed.buf().lines().to_vec(), ed.buf().cursor.line, ed.buf().path.as_deref()) + Target::IconSet(name) => of_buffer(name.clone(), ed.buf(), ed.buf().cursor.line), + _ => { + let preview = ed.picker.as_ref().and_then(|p| p.preview.clone())?; + Shown { title: preview.title, lines: preview.lines, focus: 0, path: None } } - _ => match &ed.picker.as_ref().and_then(|p| p.preview.clone()) { - Some(preview) => (preview.title.clone(), preview.lines.clone(), 0, None), - None => return grid.float(area, border, fill, ""), - }, + }) +} + +/// A buffer's text, brought into view around line `focus`. +fn of_buffer(title: String, buf: &Buffer, focus: usize) -> Shown<'_> { + Shown { title, lines: buf.lines().to_vec(), focus, path: buf.path.as_deref() } +} + +/// The start of a text file, at most `MAX_PREVIEW_BYTES`; nothing for a binary one. +fn read_preview(path: &Path) -> Vec { + let Some(mut bytes): Option> = fs::read(path).ok().filter(|b| !b.iter().take(8000).any(|&c| c == 0)) else { + return Vec::new(); }; - grid.float(area, border, fill, &title); - let rows: usize = area.h.saturating_sub(2); - let first: usize = focus.saturating_sub(rows / 3); - let lang: Option<&syntax::Lang> = path.and_then(syntax::lang_for); - let number_width: usize = (first + rows).to_string().len(); - for (r, line) in lines.iter().enumerate().skip(first).take(rows) { - let bg: Rgb = if r == focus && focus > 0 { t.bg(Group::CursorLine) } else { fill.bg }; - let mut row: Row = Row::new(area.w - 2); - row.text(t.on(Group::LineNr, bg), &format!("{:>number_width$} ", r + 1)); - let chars: Vec = line.chars().collect(); - let groups: Option> = lang.map(|l| syntax::highlight(&chars, l, State::default()).0); - let mut col: usize = 0; - for (i, &c) in chars.iter().enumerate() { - let style: Style = - groups.as_ref().and_then(|g| g.get(i)).map_or(Style { bg, ..fill }, |&g| t.syntax(g, bg)); - if c == '\t' { - for _ in 0..text::tab_cells(col) { - row.cell(style, ' '); - } - col += text::tab_cells(col); - } else { - row.cell(style, c); - col += text::char_width(c); + bytes.truncate(MAX_PREVIEW_BYTES); + String::from_utf8_lossy(&bytes).lines().map(str::to_owned).collect() +} + +/// A line of code with its number, highlighted for `lang`, tabs expanded. +fn code_row( + line: &str, + number: usize, + number_width: usize, + lang: Option<&syntax::Lang>, + focused: bool, + fill: Style, + width: usize, +) -> Vec { + let t: Arc = theme(); + let bg: Rgb = if focused { t.bg(Group::CursorLine) } else { fill.bg }; + let plain: Style = Style { bg, ..fill }; + let mut row: Row = Row::new(width); + row.text(t.on(Group::LineNr, bg), &format!("{number:>number_width$} ")); + let chars: Vec = line.chars().collect(); + let groups: Option> = lang.map(|l| syntax::highlight(&chars, l, State::default()).0); + let mut col: usize = 0; + for (i, &c) in chars.iter().enumerate() { + let style: Style = groups.as_ref().and_then(|g| g.get(i)).map_or(plain, |&g| t.syntax(g, bg)); + if c == '\t' { + for _ in 0..text::tab_cells(col) { + row.cell(style, ' '); } + col += text::tab_cells(col); + } else { + row.cell(style, c); + col += text::char_width(c); } - grid.put(area.x + 1, area.y + 1 + r - first, &row.finish(Style { bg, ..fill })); } + row.finish(plain) } #[cfg(test)] diff --git a/src/render/gallery.rs b/src/render/gallery.rs index 2c62e2a..684d338 100644 --- a/src/render/gallery.rs +++ b/src/render/gallery.rs @@ -1,9 +1,12 @@ -//! The gallery of `docs/themes.md`: real frames drawn by the renderer, written as SVG to `docs/gallery`. -//! Regenerate with `cargo test gallery -- --ignored` after changing a theme, an icon set or the bars. +//! The gallery of `docs/themes.md` and the README's screenshots: real frames drawn by the renderer, written as SVG. +//! `cargo test gallery -- --ignored` rewrites `docs/gallery` after a change to a theme, an icon set or the bars; +//! `cargo test screenshots -- --ignored` writes the README's scenes to `target/screenshots`, to rasterise +//! (docs/development.md). use std::fmt::{self, Write}; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, PoisonError}; use super::icons::SETS; use super::palettes::{DEFAULT, THEMES}; @@ -134,9 +137,13 @@ fn svg(grid: &Grid) -> String { out } +/// The gallery reads the working directory (the file tree), which the screenshots change: one at a time. +static WORKING_DIR: Mutex<()> = Mutex::new(()); + #[test] #[ignore = "writes docs/gallery; run with `cargo test gallery -- --ignored`"] fn gallery() { + let _cwd: MutexGuard<'_, ()> = WORKING_DIR.lock().unwrap_or_else(PoisonError::into_inner); let dir: PathBuf = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs").join("gallery"); fs::create_dir_all(&dir).unwrap(); // Unicode icons for the themes, so the pictures need no Nerd Font to read. @@ -197,3 +204,105 @@ fn which_key_has_the_editor_background() { ); } } + +/// The demo project the README's screenshots are taken in. +const DEMO: &[(&str, &str)] = &[ + ("Cargo.toml", "[package]\nname = \"inventory\"\nversion = \"0.1.0\"\nedition = \"2024\"\n"), + ("README.md", "# inventory\n\nA tiny stock service.\n"), + ("notes.txt", "restock on monday\n"), + ("src/stock.rs", "pub struct Stock;\n"), + ("src/main.rs", DEMO_MAIN), +]; + +const DEMO_MAIN: &str = r##"//! A tiny inventory service. Docs: https://example.com/inventory +use std::collections::HashMap; + +const ACCENT: &str = "#61afef"; + +/* Items are kept in memory; + swap in a database later. */ +#[derive(Debug, Clone, PartialEq)] +struct Item { + name: String, + quantity: u32, +} + +fn restock(stock: &mut HashMap, name: &str, amount: u32) { + match stock.get_mut(name) { + Some(item) => { + item.quantity = item.quantity.saturating_add(amount); + println!("restocked {} to {}", item.name, item.quantity); + } + None => { + // TODO: log unknown items + let item = Item { name: name.to_owned(), quantity: amount }; + stock.insert(name.to_owned(), item); + } + } +} + +fn main() { + let mut stock: HashMap = HashMap::new(); + restock(&mut stock, "apples", 12); + restock(&mut stock, "pears", 4); + println!("{} items, accent {ACCENT}", stock.len()); +} +"##; + +/// The README's screenshots: each scene played with real keys in the demo project, its frame written as SVG to +/// `target/screenshots`, then rasterised to `docs/screenshots/*.png` (see docs/development.md). +#[test] +#[ignore = "writes target/screenshots; run with `cargo test screenshots -- --ignored`"] +fn screenshots() { + let _cwd: MutexGuard<'_, ()> = WORKING_DIR.lock().unwrap_or_else(PoisonError::into_inner); + let out: PathBuf = Path::new(env!("CARGO_MANIFEST_DIR")).join("target").join("screenshots"); + fs::create_dir_all(&out).unwrap(); + let demo: PathBuf = std::env::temp_dir().join(format!("lim-demo-{}", std::process::id())).join("inventory"); + for (name, text) in DEMO { + fs::create_dir_all(demo.join(name).parent().unwrap()).unwrap(); + fs::write(demo.join(name), text).unwrap(); + } + let back: PathBuf = std::env::current_dir().unwrap(); + std::env::set_current_dir(&demo).unwrap(); + for (name, size, keys) in scenes() { + // The theme picker installs the theme it previews: each scene starts from the default one. + apply_colorscheme(DEFAULT, &Config::default()); + // As `lim src/main.rs`; the start screen is `lim` alone. + let files: Vec = if name == "dashboard" { Vec::new() } else { vec!["src/main.rs".into()] }; + let mut ed: Editor = Editor::new(&files); + ed.options.iconset = "nerd".into(); + for key in keys { + ed.handle(key); + } + let (grid, _): (Grid, Option<(usize, usize)>) = + Screen::new(ColorDepth::TrueColor).compose(&mut ed, size.0, size.1); + fs::write(out.join(format!("{name}.svg")), svg(&grid)).unwrap(); + } + std::env::set_current_dir(back).unwrap(); + fs::remove_dir_all(demo.parent().unwrap()).unwrap(); + apply_colorscheme(DEFAULT, &Config::default()); +} + +/// Each screenshot: its file name, its size in cells and the keys that set it up. +fn scenes() -> Vec<(&'static str, (usize, usize), Vec)> { + let keys = |typed: &str| crate::fuzz::phrase_keys(typed); + let with = |mut first: Vec, more: &[Key]| { + first.extend_from_slice(more); + first + }; + vec![ + // The tree opens on `src`, unfolded, then the focus goes back to the code. + ("editor", (120, 34), with(keys(" el"), &[Key::Ctrl('l'), Key::Char('2'), Key::Char('1'), Key::Char('G')])), + ("dashboard", (120, 40), Vec::new()), + ("picker", (120, 34), keys(" ffma")), + ("grep", (120, 34), keys(" fwrestock")), + ("whichkey", (120, 34), keys("17G ")), + ("cheatsheet", (120, 34), vec![Key::F(1)]), + ("themes", (120, 34), keys(" fttokyo")), + ("completion", (120, 34), keys("30Goresto")), + ("cmdline", (120, 34), keys(":s")), + ("visual", (120, 34), keys("15GV4j")), + ("tools", (120, 34), keys(" xc2+3*4\nsqrt(2)\n200*15%\n")), + ("settings", (120, 34), keys(":settings\n")), + ] +} diff --git a/src/render/markdown.rs b/src/render/markdown.rs index c164358..f31a8aa 100644 --- a/src/render/markdown.rs +++ b/src/render/markdown.rs @@ -56,84 +56,110 @@ pub fn is_markdown(buf: &Buffer) -> bool { /// `**bold**`, `*italic*` / `_italic_`, `` `code` ``, `[text](url)`, `![alt](src)`, `~~gone~~`, `\*` escapes. fn inline(source: &str, base: Look) -> Vec { let chars: Vec = source.chars().collect(); - let mut spans: Vec = Vec::new(); - let mut current: String = String::new(); - let (mut bold, mut italic, mut dim): (bool, bool, bool) = (false, false, false); - let look = |bold: bool, italic: bool, dim: bool| match (dim, bold, italic) { - (true, _, _) => Look::Dim, - (_, true, _) => Look::Bold, - (_, _, true) => Look::Italic, - _ => base, - }; - let flush = |current: &mut String, spans: &mut Vec, look: Look| { - if !current.is_empty() { - spans.push(span(std::mem::take(current), look)); - } - }; + let mut text: Inline = + Inline { base, spans: Vec::new(), current: String::new(), bold: false, italic: false, dim: false }; let mut i: usize = 0; - while i < chars.len() { - let c: char = chars[i]; + while let Some(&c) = chars.get(i) { let next: Option = chars.get(i + 1).copied(); - let here: Look = look(bold, italic, dim); - match c { + // A code span or a link takes several chars; an unclosed one is plain text. + let span: Option = match c { + '`' => code_span(&chars, i, &mut text), + '!' | '[' => link(&chars, i, &mut text), + _ => None, + }; + if let Some(len) = span { + i += len; + continue; + } + i += match c { '\\' if next.is_some_and(|n| n.is_ascii_punctuation()) => { - current.push(next.unwrap_or(c)); - i += 2; - continue; - } - '`' => { - if let Some(len) = chars[i + 1..].iter().position(|&x| x == '`') { - flush(&mut current, &mut spans, here); - spans.push(span(chars[i + 1..i + 1 + len].iter().collect::(), Look::Code)); - i += len + 2; - continue; - } - } - '*' | '_' if next == Some(c) => { - flush(&mut current, &mut spans, here); - bold = !bold; - i += 2; - continue; - } - '~' if next == Some('~') => { - flush(&mut current, &mut spans, here); - dim = !dim; - i += 2; - continue; + text.current.push(next.unwrap_or(c)); + 2 } + '*' | '_' if next == Some(c) => text.toggle(|t| &mut t.bold, 2), + '~' if next == Some('~') => text.toggle(|t| &mut t.dim, 2), // `_` inside a word (snake_case) is not emphasis. - '*' | '_' if c == '*' || i == 0 || !chars[i - 1].is_alphanumeric() || italic => { - flush(&mut current, &mut spans, here); - italic = !italic; - i += 1; - continue; + '*' | '_' if c == '*' || i == 0 || !chars[i - 1].is_alphanumeric() || text.italic => { + text.toggle(|t| &mut t.italic, 1) } - '!' | '[' => { - let image: bool = c == '!'; - let open: usize = if image { i + 1 } else { i }; - if chars.get(open) == Some(&'[') - && let Some(close) = chars[open..].iter().position(|&x| x == ']').map(|p| open + p) - && chars.get(close + 1) == Some(&'(') - && let Some(end) = chars[close..].iter().position(|&x| x == ')').map(|p| close + p) - { - flush(&mut current, &mut spans, here); - let label: String = chars[open + 1..close].iter().collect(); - if image { - spans.push(span(format!("▣ {label}"), Look::Dim)); - } else { - spans.extend(inline(&label, Look::Link)); - } - i = end + 1; - continue; - } + _ => { + text.current.push(c); + 1 } - _ => {} + }; + } + text.flush(); + text.spans +} + +/// The spans read so far, the text not yet in one, and the emphasis it is in. +struct Inline { + base: Look, + spans: Vec, + current: String, + bold: bool, + italic: bool, + dim: bool, +} + +impl Inline { + /// What the current text looks like: struck-through wins over bold, bold over italic. + fn look(&self) -> Look { + match (self.dim, self.bold, self.italic) { + (true, _, _) => Look::Dim, + (_, true, _) => Look::Bold, + (_, _, true) => Look::Italic, + _ => self.base, } - current.push(c); - i += 1; } - flush(&mut current, &mut spans, look(bold, italic, dim)); - spans + + /// Ends the text in the current emphasis as a span. + fn flush(&mut self) { + if !self.current.is_empty() { + let look: Look = self.look(); + self.spans.push(span(std::mem::take(&mut self.current), look)); + } + } + + /// A `**`, `~~` or `*` marker: the text so far keeps its emphasis, the emphasis flips. Returns `len`, the + /// marker's length. + fn toggle(&mut self, emphasis: fn(&mut Self) -> &mut bool, len: usize) -> usize { + self.flush(); + let flag: &mut bool = emphasis(self); + *flag = !*flag; + len + } +} + +/// `` `code` `` at `i`: a code span, and the chars it took; None when the backtick is not closed. +fn code_span(chars: &[char], i: usize, text: &mut Inline) -> Option { + let len: usize = chars[i + 1..].iter().position(|&x| x == '`')?; + text.flush(); + text.spans.push(span(chars[i + 1..i + 1 + len].iter().collect::(), Look::Code)); + Some(len + 2) +} + +/// `[text](url)` (the text, styled as a link) or `![alt](src)` (a dimmed placeholder) at `i`, and the chars it +/// took; None when the brackets do not make one. +fn link(chars: &[char], i: usize, text: &mut Inline) -> Option { + let image: bool = chars[i] == '!'; + let open: usize = if image { i + 1 } else { i }; + if chars.get(open) != Some(&'[') { + return None; + } + let close: usize = open + chars[open..].iter().position(|&x| x == ']')?; + if chars.get(close + 1) != Some(&'(') { + return None; + } + let end: usize = close + chars[close..].iter().position(|&x| x == ')')?; + text.flush(); + let label: String = chars[open + 1..close].iter().collect(); + if image { + text.spans.push(span(format!("▣ {label}"), Look::Dim)); + } else { + text.spans.extend(inline(&label, Look::Link)); + } + Some(end + 1 - i) } /// Word-wraps `spans` into lines of `width` cells: the first starts with `first`, the rest with `rest`. diff --git a/src/render/mod.rs b/src/render/mod.rs index 948e54e..e48ccc0 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -165,101 +165,56 @@ impl Screen { out } - /// The frame as cells, and the cursor's screen cell if it shows. + /// The frame as cells, and the cursor's screen cell if it shows. Drawn back to front: the tabline, the side + /// panels, the windows, the bars at the bottom, then the floats over everything. fn compose(&mut self, ed: &mut Editor, width: usize, height: usize) -> (Grid, Option<(usize, usize)>) { ed.refresh_completion(); ed.panes.clear(); ed.tab_hits.clear(); - let t: Arc = theme(); let options: Options = ed.options.clone(); - let mut grid: Grid = Grid::new(width, height, t.style(Group::Normal)); - let roomy: bool = height > 3; - let top: usize = usize::from(options.showtabline && roomy); - let status: usize = usize::from(options.laststatus && roomy); - let prompt: bool = matches!(ed.mode, Mode::Command | Mode::Search); - // With cmdheight=0, typing `:` or `/` borrows a line as Neovim does, pushing the statusline up. - let cmd: usize = (options.cmdheight.min(1) | usize::from(prompt)) * usize::from(roomy); - let text_height: usize = height.saturating_sub(top + status + cmd).max(1); - + let mut grid: Grid = Grid::new(width, height, theme().style(Group::Normal)); + let rows: Rows = Rows::of(&options, ed.mode, height); // Columns taken by the file tree and its border, shared by the tabline and the windows. let tree_width: usize = if ed.explorer.is_some() && width >= 20 { EXPLORER_WIDTH.min(width / 2) } else { 0 }; - if top == 1 { + if rows.tabline { ed.tab_hits = bars::tabline(ed, &mut grid, 0, if tree_width > 0 { tree_width + 1 } else { 0 }); } - let mut area: Rect = Rect { x: 0, y: top, w: width, h: text_height }; - let mut cursor: Option<(usize, usize)> = None; - if tree_width > 0 { - cursor = tree::explorer(ed, &mut grid, Rect { w: tree_width, ..area }); - // NvChad hides this separator: the tree's color alone sets it apart. - let tree_bg: Rgb = t.bg(Group::ExplorerNormal); - grid.vline(tree_width, area.y..area.y + area.h, Style::new(tree_bg, tree_bg)); - area = Rect { x: tree_width + 1, w: width - tree_width - 1, ..area }; - } - // The Markdown preview takes the right half, past a separator, while a Markdown buffer is shown. - if ed.markdown_preview && !ed.zen && area.w >= 40 && markdown::is_markdown(ed.buf()) { - let panel_width: usize = area.w / 2; - area.w -= panel_width + 1; - let separator: usize = area.x + area.w; - grid.vline(separator, area.y..area.y + area.h, theme().style(Group::WinSeparator)); - markdown::preview(ed, &mut grid, Rect { x: separator + 1, w: panel_width, ..area }); - } - let windows_cursor: Option<(usize, usize)> = if ed.zen { - // snacks.zen: the focused window alone, centred, at most 120 columns wide. - let w: usize = 120.min(area.w * 3 / 4).max(area.w.min(20)); - let zen: Rect = Rect { x: area.x + (area.w - w) / 2, w, ..area }; - ed.area = zen; - ed.view_height = zen.h; - view::window(ed, ed.active, zen, &mut grid) - } else { - Self::windows(ed, area, &mut grid) - }; - if ed.dashboard { - grid.fill(area, theme().style(Group::Normal)); - home::dashboard(&mut grid, area, &options); - } else if ed.mode != Mode::Explorer { - cursor = windows_cursor; - } + let screen: Rect = Rect { x: 0, y: rows.top(), w: width, h: rows.text }; + let (area, tree_cursor): (Rect, Option<(usize, usize)>) = side_panels(ed, &mut grid, screen, tree_width); + let mut cursor: Option<(usize, usize)> = text_area(ed, &mut grid, area, &options, tree_cursor); popup::completion(ed, &mut grid, cursor); popup::which_key(ed, &mut grid, area); - if status == 1 { + self.bottom_bars(ed, &mut grid, &rows, &options); + if rows.prompt { + cursor = Some((height - 1, 1 + text::str_width(&ed.cmdline))); + } + let cursor: Option<(usize, usize)> = overlays(ed, &mut grid, cursor); + (grid, cursor) + } + + /// The statusline, then the command line (or the last message, or a notification). + fn bottom_bars(&mut self, ed: &mut Editor, grid: &mut Grid, rows: &Rows, options: &Options) { + if rows.status { // The branch can change under us (a shell, another terminal): re-read HEAD, a tiny file. self.ctx.branch = env::current_dir().ok().and_then(git_branch); // The counts come with the signs, cached per buffer version; the gutter may not have asked for them. if options.gitsigns { ed.buf_mut().git_signs(); } - bars::statusline(ed, &self.ctx, &mut grid, top + text_height); + bars::statusline(ed, &self.ctx, grid, rows.top() + rows.text); } - if cmd == 1 || prompt { - bars::cmdline(ed, &mut grid, height - 1); + if rows.cmd || rows.prompt { + bars::cmdline(ed, grid, rows.height - 1); } else if options.notifications { - bars::notification(ed, &mut grid, top); - } - if prompt { - cursor = Some((height - 1, 1 + text::str_width(&ed.cmdline))); - } - if let Some(input) = finder::picker(ed, &mut grid) { - cursor = Some(input); - } - popup::cmd_completion(ed, &mut grid); - popup::cheatsheet(ed, &mut grid); - popup::setup(ed, &mut grid); - if matches!(ed.mode, Mode::Cheatsheet | Mode::Setup) { - cursor = None; - } - if let Some(input) = popup::prompt(ed, &mut grid) { - cursor = Some(input); + bars::notification(ed, grid, rows.top()); } - (grid, cursor) } - /// Every window and split border inside `area`; returns the focused cursor's screen cell. + /// Every window and split border inside `area`, then the popups over them; returns the focused cursor's cell. fn windows(ed: &mut Editor, area: Rect, grid: &mut Grid) -> Option<(usize, usize)> { ed.area = area; let mut rects: Vec<(WindowId, Rect)> = Vec::new(); ed.layout.rects(area, &mut rects); - let mut separators: Vec = Vec::new(); - ed.layout.separators(area, &mut separators); let mut cursor: Option<(usize, usize)> = None; for (id, rect) in rects { let Some(index): Option = ed.windows.iter().position(|w| w.id == id) else { continue }; @@ -268,44 +223,153 @@ impl Screen { } cursor = cursor.or(view::window(ed, index, rect, grid)); } - let border: Style = theme().style(Group::WinSeparator); - for sep in separators { - if sep.vertical { - grid.vline(sep.x, sep.y..sep.y + sep.len, border); - } else { - grid.text(sep.x, sep.y, sep.len, border, &"\u{2500}".repeat(sep.len)); - } - } - // Popups (the tools) over the windows: small, centred, a float with the tool's name as title. + separators(ed, area, grid); let popups: Vec = (0..ed.windows.len()).filter(|&i| ed.windows[i].floating).collect(); for index in popups { - let (w, h): (usize, usize) = (POPUP_WIDTH.min(area.w.saturating_sub(4)), POPUP_HEIGHT.min(area.h)); - if w < 10 || h < 3 { - continue; - } - let frame: Rect = Rect { x: area.x + (area.w - w) / 2, y: area.y + (area.h - h) / 2, w, h }; - let title: String = ed.buffers.get(ed.windows[index].buffer).map(Buffer::name).unwrap_or_default(); - let t: Arc = theme(); - // Drawn as the notifications are: the editor's background inside a colored rounded border. - let fill: Style = t.style(Group::Normal); - let border: Style = Style::new(t.bg(Group::FloatTitle), fill.bg); - grid.float(frame, border, fill, title.trim_matches(['[', ']'])); - if let Some(hint) = ed.popup_hint(ed.windows[index].buffer) { - let hint: String = format!(" {hint} "); - let room: usize = text::str_width(&hint).min(w - 4); - grid.text(frame.x + 2, frame.y + h - 1, room, t.on(Group::LineNr, fill.bg), &hint); - } - // A column of air on each side, inside the border. - let inside: Rect = Rect { x: frame.x + 2, y: frame.y + 1, w: w - 4, h: h - 2 }; - if index == ed.active { - ed.view_height = inside.h; - } - cursor = view::window(ed, index, inside, grid).or(cursor); + cursor = popup_window(ed, index, area, grid).or(cursor); } cursor } } +/// How the screen's rows are shared: tabline, text, statusline and command line. +#[expect(clippy::struct_excessive_bools, reason = "one per bar the screen may show")] +struct Rows { + height: usize, + tabline: bool, + status: bool, + /// A command line row: `cmdheight` asks for it, or a `:` / `/` prompt borrows it (as Neovim does with + /// `cmdheight=0`), pushing the statusline up. + cmd: bool, + prompt: bool, + /// The rows left for the windows. + text: usize, +} + +impl Rows { + fn of(options: &Options, mode: Mode, height: usize) -> Self { + // Below four rows only the text shows. + let roomy: bool = height > 3; + let prompt: bool = matches!(mode, Mode::Command | Mode::Search); + let tabline: bool = options.showtabline && roomy; + let status: bool = options.laststatus && roomy; + let cmd: bool = (options.cmdheight > 0 || prompt) && roomy; + let bars: usize = usize::from(tabline) + usize::from(status) + usize::from(cmd); + Self { height, tabline, status, cmd, prompt, text: height.saturating_sub(bars).max(1) } + } + + /// The first text row. + fn top(&self) -> usize { + usize::from(self.tabline) + } +} + +/// The file tree on the left and the Markdown preview on the right, around the windows; returns the area left for +/// the windows, and the cursor in the tree when it has focus. +fn side_panels(ed: &mut Editor, grid: &mut Grid, mut area: Rect, tree_width: usize) -> (Rect, Option<(usize, usize)>) { + let mut cursor: Option<(usize, usize)> = None; + if tree_width > 0 { + cursor = tree::explorer(ed, grid, Rect { w: tree_width, ..area }); + // NvChad hides this separator: the tree's color alone sets it apart. + let tree_bg: Rgb = theme().bg(Group::ExplorerNormal); + grid.vline(tree_width, area.y..area.y + area.h, Style::new(tree_bg, tree_bg)); + area = Rect { x: tree_width + 1, w: area.w - tree_width - 1, ..area }; + } + // The Markdown preview takes the right half, past a separator, while a Markdown buffer is shown. + if ed.markdown_preview && !ed.zen && area.w >= 40 && markdown::is_markdown(ed.buf()) { + let panel_width: usize = area.w / 2; + area.w -= panel_width + 1; + let separator: usize = area.x + area.w; + grid.vline(separator, area.y..area.y + area.h, theme().style(Group::WinSeparator)); + markdown::preview(ed, grid, Rect { x: separator + 1, w: panel_width, ..area }); + } + (area, cursor) +} + +/// The windows (or the zen window, or the start screen) in `area`; returns where the cursor shows, the tree's +/// while the tree has focus. +fn text_area( + ed: &mut Editor, + grid: &mut Grid, + area: Rect, + options: &Options, + tree_cursor: Option<(usize, usize)>, +) -> Option<(usize, usize)> { + let windows_cursor: Option<(usize, usize)> = if ed.zen { + // snacks.zen: the focused window alone, centred, at most 120 columns wide. + let w: usize = 120.min(area.w * 3 / 4).max(area.w.min(20)); + let zen: Rect = Rect { x: area.x + (area.w - w) / 2, w, ..area }; + ed.area = zen; + ed.view_height = zen.h; + view::window(ed, ed.active, zen, grid) + } else { + Screen::windows(ed, area, grid) + }; + if ed.dashboard { + grid.fill(area, theme().style(Group::Normal)); + home::dashboard(grid, area, options); + tree_cursor + } else if ed.mode == Mode::Explorer { + tree_cursor + } else { + windows_cursor + } +} + +/// The floats over everything: pickers, the command-line menu, the cheat sheet, the settings and prompts. +fn overlays(ed: &mut Editor, grid: &mut Grid, mut cursor: Option<(usize, usize)>) -> Option<(usize, usize)> { + if let Some(input) = finder::picker(ed, grid) { + cursor = Some(input); + } + popup::cmd_completion(ed, grid); + popup::cheatsheet(ed, grid); + popup::setup(ed, grid); + if matches!(ed.mode, Mode::Cheatsheet | Mode::Setup) { + cursor = None; + } + popup::prompt(ed, grid).or(cursor) +} + +/// The borders between split windows. +fn separators(ed: &Editor, area: Rect, grid: &mut Grid) { + let mut separators: Vec = Vec::new(); + ed.layout.separators(area, &mut separators); + let border: Style = theme().style(Group::WinSeparator); + for sep in separators { + if sep.vertical { + grid.vline(sep.x, sep.y..sep.y + sep.len, border); + } else { + grid.text(sep.x, sep.y, sep.len, border, &"\u{2500}".repeat(sep.len)); + } + } +} + +/// A tool's popup, small and centred over the windows: drawn as the notifications are, the editor's background in +/// a colored rounded border, the tool's name on top and what to type at the bottom. +fn popup_window(ed: &mut Editor, index: usize, area: Rect, grid: &mut Grid) -> Option<(usize, usize)> { + let (w, h): (usize, usize) = (POPUP_WIDTH.min(area.w.saturating_sub(4)), POPUP_HEIGHT.min(area.h)); + if w < 10 || h < 3 { + return None; + } + let frame: Rect = Rect { x: area.x + (area.w - w) / 2, y: area.y + (area.h - h) / 2, w, h }; + let buffer: usize = ed.windows[index].buffer; + let title: String = ed.buffers.get(buffer).map(Buffer::name).unwrap_or_default(); + let t: Arc = theme(); + let fill: Style = t.style(Group::Normal); + grid.float(frame, Style::new(t.bg(Group::FloatTitle), fill.bg), fill, title.trim_matches(['[', ']'])); + if let Some(hint) = ed.popup_hint(buffer) { + let hint: String = format!(" {hint} "); + let room: usize = text::str_width(&hint).min(w - 4); + grid.text(frame.x + 2, frame.y + h - 1, room, t.on(Group::LineNr, fill.bg), &hint); + } + // A column of air on each side, inside the border. + let inside: Rect = Rect { x: frame.x + 2, y: frame.y + 1, w: w - 4, h: h - 2 }; + if index == ed.active { + ed.view_height = inside.h; + } + view::window(ed, index, inside, grid) +} + #[cfg(test)] mod tests { use std::str::Chars; diff --git a/src/render/popup.rs b/src/render/popup.rs index 7eb0446..08f65b8 100644 --- a/src/render/popup.rs +++ b/src/render/popup.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use super::Theme; use super::icons::Icons; -use super::row::{Grid, Row}; +use super::row::{Cell, Grid, Row}; use super::theme::{Group, Rgb, Style, theme}; use crate::buffer::text; use crate::editor::{Card, Cheatsheet, CmdCompletion, Completion, Editor, MenuEntry, Prompt, Rect, SETTINGS, Setup}; @@ -120,63 +120,79 @@ pub fn setup(ed: &Editor, grid: &mut Grid) { let Some(setup): &Option = &ed.setup else { return }; let t: Arc = theme(); let fill: Style = t.style(Group::NormalFloat); - let border: Style = t.style(Group::FloatBorder); let (w, h): (usize, usize) = (grid.width, grid.height); if w < 30 || h < 7 { return; } let area: Rect = Rect { x: 0, y: 0, w, h }; - grid.float(area, border, fill, "Settings"); + grid.float(area, t.style(Group::FloatBorder), fill, "Settings"); let hint: Style = Style { fg: t.fg(Group::Comment), ..fill }; - let search: String = if setup.typing { + let field: Style = if setup.typing || !setup.query.is_empty() { fill } else { hint }; + grid.text(area.x + 2, area.y + 1, w - 4, field, &search_field(setup)); + let inner: Rect = Rect { x: area.x + 2, y: area.y + 3, w: w - 4, h: h.saturating_sub(5) }; + let shown: Vec = setup.shown(); + let current: Option = shown.get(setup.selected).copied(); + let rows: Vec<(Option, &str)> = setting_rows(&shown); + if rows.is_empty() { + grid.text(inner.x, inner.y, inner.w, hint, " No setting matches"); + } + // Scrolled to keep the selected setting in view. + let at: usize = rows.iter().position(|(i, _)| *i == current).unwrap_or(0); + let scroll: usize = (at + 1).saturating_sub(inner.h); + for (y, &(index, text)) in rows.iter().skip(scroll).take(inner.h).enumerate() { + let row: Vec = setting_row(ed, &t, (index, text), index.is_some() && index == current, inner.w); + grid.put(inner.x, inner.y + y, &row); + } + let keys: &str = " / search · j/k move · h/l change · w save to config · Esc close"; + grid.text(inner.x, area.y + h - 2, inner.w, hint, keys); +} + +/// The search line: what is typed (with a cursor while typing), or a placeholder. +fn search_field(setup: &Setup) -> String { + if setup.typing { format!(" / {}█", setup.query) } else if setup.query.is_empty() { " / Search settings".into() } else { format!(" / {}", setup.query) - }; - let field: Style = if setup.typing || !setup.query.is_empty() { fill } else { hint }; - grid.text(area.x + 2, area.y + 1, w - 4, field, &search); - let inner: Rect = Rect { x: area.x + 2, y: area.y + 3, w: w - 4, h: h.saturating_sub(5) }; - // A section title row before each new section, then one row per setting. - let shown: Vec = setup.shown(); - let current: Option = shown.get(setup.selected).copied(); - let mut rows: Vec<(Option, &str)> = Vec::new(); + } +} + +/// The rows of the list: a section title before each new section (no index), then one row per setting. +fn setting_rows(shown: &[usize]) -> Vec<(Option, &'static str)> { + let mut rows: Vec<(Option, &'static str)> = Vec::new(); for (n, &i) in shown.iter().enumerate() { if n == 0 || SETTINGS[shown[n - 1]].section != SETTINGS[i].section { rows.push((None, SETTINGS[i].section)); } rows.push((Some(i), SETTINGS[i].name)); } - if rows.is_empty() { - grid.text(inner.x, inner.y, inner.w, hint, " No setting matches"); - } - let at: usize = rows.iter().position(|(i, _)| *i == current).unwrap_or(0); - let scroll: usize = (at + 1).saturating_sub(inner.h); - let selected: Style = t.on(Group::PmenuSel, fill.bg); - let selected: Style = Style { bg: t.bg(Group::PmenuSel), ..selected }; - for (y, (index, text)) in rows.iter().skip(scroll).take(inner.h).enumerate() { - let mut row: Row = Row::new(inner.w); - let base: Style = if index.is_some() && *index == current { selected } else { fill }; - match index { - None => row.text(t.style(Group::StatusModeNormal), &format!(" {text} ")), - Some(i) => { - row.text(base, &format!(" {text}")); - row.pad_to(base, 20); - row.text(Style { fg: t.fg(Group::CheatsheetKey), ..base }, &format!(" {}", ed.setup_value(text))); - row.pad_to(base, 40); - row.text(Style { fg: t.fg(Group::Comment), italic: false, ..base }, &format!(" {}", SETTINGS[*i].desc)); - } + rows +} + +/// A section title as a pill, or a setting: its name, its current value and what it does, in columns. +fn setting_row( + ed: &Editor, + t: &Theme, + (index, text): (Option, &str), + selected: bool, + width: usize, +) -> Vec { + let fill: Style = t.style(Group::NormalFloat); + let base: Style = + if selected { Style { bg: t.bg(Group::PmenuSel), ..t.on(Group::PmenuSel, fill.bg) } } else { fill }; + let mut row: Row = Row::new(width); + match index { + None => row.text(t.style(Group::StatusModeNormal), &format!(" {text} ")), + Some(i) => { + row.text(base, &format!(" {text}")); + row.pad_to(base, 20); + row.text(Style { fg: t.fg(Group::CheatsheetKey), ..base }, &format!(" {}", ed.setup_value(text))); + row.pad_to(base, 40); + row.text(Style { fg: t.fg(Group::Comment), italic: false, ..base }, &format!(" {}", SETTINGS[i].desc)); } - grid.put(inner.x, inner.y + y, &row.finish(base)); } - grid.text( - inner.x, - area.y + h - 2, - inner.w, - hint, - " / search · j/k move · h/l change · w save to config · Esc close", - ); + row.finish(base) } /// blink.cmp's menu under (or above) the cursor: kind icon, label with the typed prefix, source. diff --git a/src/render/tree.rs b/src/render/tree.rs index a0398a5..89d3c51 100644 --- a/src/render/tree.rs +++ b/src/render/tree.rs @@ -3,9 +3,9 @@ use std::path::PathBuf; use std::sync::Arc; -use super::row::{Grid, Row}; +use super::row::{Cell, Grid, Row}; use super::theme::{Group, Rgb, Style, theme}; -use crate::editor::{Editor, Explorer, GitStatus, Rect}; +use crate::editor::{Editor, Entry, Explorer, GitStatus, Rect}; use crate::render::Theme; use crate::render::icons::Icons; use crate::syntax; @@ -20,60 +20,81 @@ pub fn explorer(ed: &mut Editor, grid: &mut Grid, area: Rect) -> Option<(usize, let base: Style = t.style(Group::ExplorerNormal); grid.fill(area, base); // No root line, as NvChad's `root_folder_label = false`: the statusline shows the folder. - let rows: usize = area.h; - if explorer.cursor < explorer.scroll { - explorer.scroll = explorer.cursor; - } else if rows > 0 && explorer.cursor >= explorer.scroll + rows { - explorer.scroll = explorer.cursor + 1 - rows; - } + scroll_into_view(explorer, area.h); let mut cursor: Option<(usize, usize)> = None; - for (r, (i, entry)) in explorer.entries.iter().enumerate().skip(explorer.scroll).take(rows).enumerate() { + for (r, (i, entry)) in explorer.entries.iter().enumerate().skip(explorer.scroll).take(area.h).enumerate() { let selected: bool = i == explorer.cursor; // NvimTreeCursorLine: the editor's background, no bold. let bg: Rgb = if selected && explorer.focused { t.bg(Group::Normal) } else { base.bg }; - let line: Style = Style { bg, ..base }; - let mut row: Row = Row::new(area.w); - row.text(line, " "); - for depth in 0..entry.depth { - let marker: &str = if depth + 1 == entry.depth && entry.last { "\u{2514} " } else { "\u{2502} " }; - row.text(t.on(Group::ExplorerIndent, bg), marker); - } - let (icon, color): (String, Rgb) = if entry.dir { - ( - (if entry.expanded { set.folder_open } else { set.folder_closed }).to_owned(), - t.fg(Group::ExplorerDirectory), - ) - } else { - let (icon, color): (String, Option) = set.for_file(syntax::lang_for(&entry.path)); - (icon, color.map_or(line.fg, Rgb)) - }; - if !icon.is_empty() { - row.text(Style { fg: color, ..line }, &format!("{icon} ")); - } + let (row, name_col): (Vec, usize) = entry_row(&t, set, entry, &open, Style { bg, ..base }, area.w); if selected { - cursor = Some((area.y + r, area.x + row.used())); - } - let name_group: Group = match entry.git { - Some(GitStatus::Modified) => Group::ExplorerGitModified, - Some(GitStatus::Added) => Group::ExplorerGitAdded, - Some(GitStatus::Untracked) => Group::ExplorerGitUntracked, - Some(GitStatus::Deleted) => Group::ExplorerGitDeleted, - None if entry.dir => Group::Directory, - None if open.contains(&entry.path) => Group::ExplorerOpened, - None => Group::ExplorerFile, - }; - row.text(Style { fg: t.fg(name_group), ..line }, &entry.name); - if let Some(status) = entry.git.filter(|_| !entry.dir) { - let symbol: &str = match status { - GitStatus::Modified => set.git_modified, - GitStatus::Added => set.git_added, - GitStatus::Untracked => set.git_untracked, - GitStatus::Deleted => set.git_deleted, - }; - row.pad_to(line, area.w.saturating_sub(2)); - row.text(Style { fg: t.fg(name_group), ..line }, symbol); + cursor = Some((area.y + r, area.x + name_col)); } - grid.put(area.x, area.y + r, &row.finish(line)); + grid.put(area.x, area.y + r, &row); } explorer.focused.then_some(cursor).flatten() } + +/// Scrolls the tree just enough to show its cursor in `rows` rows. +fn scroll_into_view(explorer: &mut Explorer, rows: usize) { + if explorer.cursor < explorer.scroll { + explorer.scroll = explorer.cursor; + } else if rows > 0 && explorer.cursor >= explorer.scroll + rows { + explorer.scroll = explorer.cursor + 1 - rows; + } +} + +/// One entry: indent markers, icon, name in its git or open color, and its git symbol at the right edge. +/// Returns the cells and the column where the name starts (the cursor's place). +fn entry_row(t: &Theme, set: &Icons, entry: &Entry, open: &[PathBuf], line: Style, width: usize) -> (Vec, usize) { + let mut row: Row = Row::new(width); + row.text(line, " "); + for depth in 0..entry.depth { + let marker: &str = if depth + 1 == entry.depth && entry.last { "\u{2514} " } else { "\u{2502} " }; + row.text(t.on(Group::ExplorerIndent, line.bg), marker); + } + let (icon, color): (String, Rgb) = entry_icon(t, set, entry, line.fg); + if !icon.is_empty() { + row.text(Style { fg: color, ..line }, &format!("{icon} ")); + } + let name_col: usize = row.used(); + let name: Style = Style { fg: t.fg(name_group(entry, open)), ..line }; + row.text(name, &entry.name); + if let Some(status) = entry.git.filter(|_| !entry.dir) { + row.pad_to(line, width.saturating_sub(2)); + row.text(name, git_symbol(set, status)); + } + (row.finish(line), name_col) +} + +/// A folder's icon (open or closed) in the directory color, or a file's devicon in its language's color. +fn entry_icon(t: &Theme, set: &Icons, entry: &Entry, text_fg: Rgb) -> (String, Rgb) { + if entry.dir { + let icon: &str = if entry.expanded { set.folder_open } else { set.folder_closed }; + return (icon.to_owned(), t.fg(Group::ExplorerDirectory)); + } + let (icon, color): (String, Option) = set.for_file(syntax::lang_for(&entry.path)); + (icon, color.map_or(text_fg, Rgb)) +} + +/// The name's color: its git status first, then folder, open file or plain file. +fn name_group(entry: &Entry, open: &[PathBuf]) -> Group { + match entry.git { + Some(GitStatus::Modified) => Group::ExplorerGitModified, + Some(GitStatus::Added) => Group::ExplorerGitAdded, + Some(GitStatus::Untracked) => Group::ExplorerGitUntracked, + Some(GitStatus::Deleted) => Group::ExplorerGitDeleted, + None if entry.dir => Group::Directory, + None if open.contains(&entry.path) => Group::ExplorerOpened, + None => Group::ExplorerFile, + } +} + +fn git_symbol(set: &Icons, status: GitStatus) -> &'static str { + match status { + GitStatus::Modified => set.git_modified, + GitStatus::Added => set.git_added, + GitStatus::Untracked => set.git_untracked, + GitStatus::Deleted => set.git_deleted, + } +} diff --git a/src/render/view.rs b/src/render/view.rs index 4118463..d5d66f6 100644 --- a/src/render/view.rs +++ b/src/render/view.rs @@ -98,16 +98,11 @@ fn winbar_cells(buf: &Buffer, width: usize, active: bool, ctx: &Context) -> Vec< /// Text rows inside `area`; returns the cursor's (row, col) relative to it and the gutter's width. fn text_rows(buf: &mut Buffer, area: Rect, ctx: &Context, grid: &mut Grid) -> (usize, usize, usize) { - let t: Arc = theme(); let o: &Options = &ctx.options; - let numbers: bool = o.number || o.relativenumber; - let number_width: usize = if numbers { buf.line_count().to_string().len().max(3) } else { 0 }; - let signs: usize = if o.signcolumn { 2 } else { usize::from(numbers) }; - let fold: usize = usize::from(o.foldcolumn); - let gutter: usize = fold + number_width + signs; + let gutter: Gutter = Gutter::of(buf, o); let cursor: Pos = buf.cursor; let cursor_x: usize = text::display_col(buf.line(cursor.line), cursor.col); - scroll(buf, cursor_x, area.h.max(1), area.w.saturating_sub(gutter).max(1), o); + scroll(buf, cursor_x, area.h.max(1), area.w.saturating_sub(gutter.width).max(1), o); let bottom: usize = (buf.top + area.h).min(buf.line_count()); let git: Vec> = if o.gitsigns && o.signcolumn { let top: usize = buf.top; @@ -115,51 +110,93 @@ fn text_rows(buf: &mut Buffer, area: Rect, ctx: &Context, grid: &mut Grid) -> (u } else { Vec::new() }; + // What the whole window highlights: the bracket pair and the word under the cursor. let parens: [Option; 2] = if o.matchparen && ctx.active { matching_pair(buf, area.h) } else { [None, None] }; let word: Option = if o.wordhighlight && ctx.active && ctx.normal_mode { word_under_cursor(buf) } else { None }; + let t: Arc = theme(); let blank: Style = Style::new(t.fg(Group::Normal), ctx.base); for (r, y) in (buf.top..buf.top + area.h).enumerate() { let mut row: Row = Row::new(area.w); if y < buf.line_count() { - let current: bool = y == cursor.line; - row.pad_to(blank, fold); - if numbers { - let number: usize = match (o.number, o.relativenumber, current) { - (true, _, true) | (true, false, false) => y + 1, - _ => y.abs_diff(cursor.line), - }; - let group: Group = if current { Group::CursorLineNr } else { Group::LineNr }; - row.text(t.on(group, ctx.base), &format!("{number:>number_width$}")); - } - if let Some(Some(sign)) = git.get(r) { - let group: Group = match sign { - Sign::Add => Group::GitSignsAdd, - Sign::Change => Group::GitSignsChange, - Sign::Delete => Group::GitSignsDelete, - }; - row.text(t.on(group, ctx.base), "\u{258e}"); - } - row.pad_to(blank, gutter); - // Only the shown part of a line (and a little past it, for tokens cut by the right edge) is scanned. - let shown_chars: usize = text::col_at_display(buf.line(y), buf.left + area.w) + LOOKAHEAD; - let shown: usize = text::byte_index(buf.line(y), shown_chars); - let decor: Decor = Decor { - groups: if o.syntax { buf.highlight_line(y, shown_chars) } else { None }, - shown, - guide_to: if o.indentguides { guide_indent(buf, y) } else { 0 }, - indent_width: o.indent_width(), - urls: if o.highlighturl { text::find_urls(&buf.line(y)[..shown]) } else { Vec::new() }, - words: word.as_deref().map(|w| text::find_word(&buf.line(y)[..shown], w)).unwrap_or_default(), - parens: parens.map(|p| p.filter(|p| p.line == y).map(|p| p.col)), - colors: if o.colorhighlight { text::find_hex_colors(&buf.line(y)[..shown]) } else { Vec::new() }, - todo: if o.todocomments { syntax::todo(&buf.line(y)[..shown]) } else { None }, - }; + gutter.draw(&t, &mut row, (y, cursor.line), git.get(r).copied().flatten(), ctx); + let decor: Decor = decor_for(buf, y, buf.left + area.w, o, word.as_deref(), parens); draw_line(&mut row, buf, y, &decor, ctx); } grid.put(area.x, area.y + r, &row.finish(blank)); } - (cursor.line.saturating_sub(buf.top), (gutter + cursor_x).saturating_sub(buf.left), gutter) + (cursor.line.saturating_sub(buf.top), (gutter.width + cursor_x).saturating_sub(buf.left), gutter.width) +} + +/// `AstroNvim`'s statuscolumn: fold space, line numbers, then the sign column. +struct Gutter { + fold: usize, + /// 0 without numbers. + number_width: usize, + width: usize, +} + +impl Gutter { + fn of(buf: &Buffer, o: &Options) -> Self { + let numbers: bool = o.number || o.relativenumber; + let number_width: usize = if numbers { buf.line_count().to_string().len().max(3) } else { 0 }; + let signs: usize = if o.signcolumn { 2 } else { usize::from(numbers) }; + let fold: usize = usize::from(o.foldcolumn); + Self { fold, number_width, width: fold + number_width + signs } + } + + /// Line `y`'s number (absolute, or relative to the cursor's) and its gitsigns mark. + fn draw(&self, t: &Theme, row: &mut Row, (y, cursor): (usize, usize), sign: Option, ctx: &Context) { + let o: &Options = &ctx.options; + let blank: Style = Style::new(t.fg(Group::Normal), ctx.base); + row.pad_to(blank, self.fold); + if self.number_width > 0 { + let current: bool = y == cursor; + let number: usize = match (o.number, o.relativenumber, current) { + (true, _, true) | (true, false, false) => y + 1, + _ => y.abs_diff(cursor), + }; + let group: Group = if current { Group::CursorLineNr } else { Group::LineNr }; + let width: usize = self.number_width; + row.text(t.on(group, ctx.base), &format!("{number:>width$}")); + } + if let Some(sign) = sign { + let group: Group = match sign { + Sign::Add => Group::GitSignsAdd, + Sign::Change => Group::GitSignsChange, + Sign::Delete => Group::GitSignsDelete, + }; + row.text(t.on(group, ctx.base), "\u{258e}"); + } + row.pad_to(blank, self.width); + } +} + +/// Line `y`'s highlights over the syntax colors, found in the part shown left of screen column `right_edge` (and a +/// little past it, for tokens cut by the edge): a long line is not scanned to its end. +fn decor_for( + buf: &mut Buffer, + y: usize, + right_edge: usize, + o: &Options, + word: Option<&str>, + parens: [Option; 2], +) -> Decor { + let shown_chars: usize = text::col_at_display(buf.line(y), right_edge) + LOOKAHEAD; + let shown: usize = text::byte_index(buf.line(y), shown_chars); + let groups: Option> = if o.syntax { buf.highlight_line(y, shown_chars) } else { None }; + let line: &str = &buf.line(y)[..shown]; + Decor { + groups, + shown, + guide_to: if o.indentguides { guide_indent(buf, y) } else { 0 }, + indent_width: o.indent_width(), + urls: if o.highlighturl { text::find_urls(line) } else { Vec::new() }, + words: word.map(|w| text::find_word(line, w)).unwrap_or_default(), + parens: parens.map(|p| p.filter(|p| p.line == y).map(|p| p.col)), + colors: if o.colorhighlight { text::find_hex_colors(line) } else { Vec::new() }, + todo: if o.todocomments { syntax::todo(line) } else { None }, + } } /// Per-line extras drawn over the syntax colors. @@ -251,100 +288,180 @@ fn scroll(buf: &mut Buffer, cursor_x: usize, height: usize, width: usize, o: &Op /// Chars scanned past the right edge, so a word, URL, color or match cut by it is still recognized. const LOOKAHEAD: usize = 256; +/// The indent guide's glyph. +const GUIDE: char = '\u{258f}'; + fn draw_line(row: &mut Row, buf: &Buffer, y: usize, decor: &Decor, ctx: &Context) { - let t: Arc = theme(); let line: &str = &buf.line(y)[..decor.shown]; - let matches: Vec> = text::find_all(line, &ctx.pattern); - let cursor: Pos = buf.cursor; - let bg: Rgb = match t.get(Group::CursorLine).bg { - Some(cursorline) if y == cursor.line && ctx.options.cursorline => cursorline, - _ => ctx.base, - }; - let guide: Style = t.on(Group::IndentGuide, bg); - let is_guide = - |col: usize| decor.indent_width > 0 && col < decor.guide_to && col.is_multiple_of(decor.indent_width); + let mut paint: LinePaint<'_> = LinePaint::new(buf, y, decor, ctx, text::find_all(line, &ctx.pattern)); let leading: usize = text::char_len(text::indent(line)); - let mut next_match: usize = 0; let mut screen_x: usize = 0; for (x, c) in line.chars().enumerate() { let cells: usize = if c == '\t' { text::tab_cells(screen_x) } else { text::char_width(c) }; let end: usize = screen_x + cells; + // Scrolled off to the left. if end <= buf.left && cells > 0 { screen_x = end; continue; } - while matches.get(next_match).is_some_and(|m| m.end <= x) { - next_match += 1; + let style: Style = paint.style_at(x, screen_x); + if c == '\t' || c == ' ' || screen_x < buf.left { + paint.blanks(row, style, screen_x.max(buf.left)..end, x < leading && c.is_whitespace()); + } else { + row.cell(style, c); } - let hit: Option<&Range> = matches.get(next_match).filter(|m| m.contains(&x)); - let group: Hl = decor.groups.as_ref().and_then(|g| g.get(x)).copied().unwrap_or(Hl::Text); - let mut style: Style = t.syntax(group, bg); - if decor.urls.iter().any(|u| u.contains(&x)) { - style.underline |= t.get(Group::Url).underline; + screen_x = end; + if row.is_full() { + break; } - if decor.words.iter().any(|w| w.contains(&x)) - && let Some(word_bg) = t.get(Group::WordHighlight).bg + } + paint.guides_past_text(row, screen_x); + row.pad_to(Style::new(paint.t.fg(Group::Normal), paint.bg), usize::MAX); +} + +/// What drawing one line needs, worked out once for the line. +struct LinePaint<'a> { + t: Arc, + buf: &'a Buffer, + y: usize, + decor: &'a Decor, + ctx: &'a Context, + /// The line's background: the cursorline's, or the window's. + bg: Rgb, + guide: Style, + /// Search matches, and the first one not yet passed. + matches: Vec>, + next_match: usize, +} + +impl<'a> LinePaint<'a> { + fn new(buf: &'a Buffer, y: usize, decor: &'a Decor, ctx: &'a Context, matches: Vec>) -> Self { + let t: Arc = theme(); + let bg: Rgb = match t.get(Group::CursorLine).bg { + Some(cursorline) if y == buf.cursor.line && ctx.options.cursorline => cursorline, + _ => ctx.base, + }; + let guide: Style = t.on(Group::IndentGuide, bg); + Self { t, buf, y, decor, ctx, bg, guide, matches, next_match: 0 } + } + + /// The style of char `x` (at screen column `screen_x`): its syntax color, then each highlight drawn over it. + #[inline] + fn style_at(&mut self, x: usize, screen_x: usize) -> Style { + let group: Hl = group_at(self.decor, x).unwrap_or(Hl::Text); + let mut style: Style = self.t.syntax(group, self.bg); + self.url(&mut style, x); + self.word(&mut style, x); + self.todo(&mut style, x); + self.hex_color(&mut style, x); + self.paren(&mut style, x); + self.selection_or_search(&mut style, x, screen_x); + style + } + + /// `highlighturl`: underlined. + #[inline] + fn url(&self, style: &mut Style, x: usize) { + if self.decor.urls.iter().any(|u| u.contains(&x)) { + style.underline |= self.t.get(Group::Url).underline; + } + } + + /// The other places of the word under the cursor, as LSP document highlights. + #[inline] + fn word(&self, style: &mut Style, x: usize) { + if self.decor.words.iter().any(|w| w.contains(&x)) + && let Some(word_bg) = self.t.get(Group::WordHighlight).bg { style.bg = word_bg; } - // todo-comments only inside comments (or anywhere when there is no highlighter). - if let Some((col, len, kind)) = decor.todo - && x >= col.saturating_sub(1) - && group_at(decor, col).is_none_or(|g| g == Hl::Comment) - { - let color: Rgb = t.fg(todo_group(kind)); - if x < col + len + 1 { - style = Style { fg: ctx.base, bg: color, bold: true, ..style }; - } else { - style.fg = color; - } + } + + /// todo-comments: the keyword on its color, the rest of the comment in it. Only inside comments, or anywhere + /// when there is no highlighter. + #[inline] + fn todo(&self, style: &mut Style, x: usize) { + let Some((col, len, kind)): Option<(usize, usize, Todo)> = self.decor.todo else { return }; + if x + 1 < col || group_at(self.decor, col).is_some_and(|g| g != Hl::Comment) { + return; + } + let color: Rgb = self.t.fg(todo_group(kind)); + if x < col + len + 1 { + *style = Style { fg: self.ctx.base, bg: color, bold: true, ..*style }; + } else { + style.fg = color; } - if let Some((_, value)) = decor.colors.iter().find(|(r, _)| r.contains(&x)) { - style = Style { fg: contrast(*value), bg: Rgb(*value), ..style }; + } + + /// `#RRGGBB` drawn on its own color. + #[inline] + fn hex_color(&self, style: &mut Style, x: usize) { + if let Some((_, value)) = self.decor.colors.iter().find(|(r, _)| r.contains(&x)) { + *style = Style { fg: contrast(*value), bg: Rgb(*value), ..*style }; } - if decor.parens.contains(&Some(x)) { - let paren: Highlight = t.get(Group::MatchParen); + } + + /// `matchparen`: the bracket under the cursor and its partner. + #[inline] + fn paren(&self, style: &mut Style, x: usize) { + if self.decor.parens.contains(&Some(x)) { + let paren: Highlight = self.t.get(Group::MatchParen); style.fg = paren.fg.unwrap_or(style.fg); style.bold |= paren.bold; } - if let Some(selection) = ctx.selection - && selection.contains(Pos::new(y, x), screen_x) + } + + /// The visual selection, else a search match; the match under the cursor stands out from the others. + #[inline] + fn selection_or_search(&mut self, style: &mut Style, x: usize, screen_x: usize) { + while self.matches.get(self.next_match).is_some_and(|m| m.end <= x) { + self.next_match += 1; + } + let hit: Option<&Range> = self.matches.get(self.next_match).filter(|m| m.contains(&x)); + if let Some(selection) = self.ctx.selection + && selection.contains(Pos::new(self.y, x), screen_x) { - style.bg = t.bg(Group::Visual); + style.bg = self.t.bg(Group::Visual); } else if hit.is_some() { - style.bg = t.bg(Group::Search); + style.bg = self.t.bg(Group::Search); } - if hit.is_some_and(|m| y == cursor.line && m.contains(&cursor.col)) { - style = t.style(Group::CurSearch); + let cursor: Pos = self.buf.cursor; + if hit.is_some_and(|m| self.y == cursor.line && m.contains(&cursor.col)) { + *style = self.t.style(Group::CurSearch); } - if c == '\t' || c == ' ' || screen_x < buf.left { - // Blanks (with indent guides), and wide chars cut by the left edge, cell by cell. - for col in screen_x.max(buf.left)..end { - let guided: bool = x < leading && is_guide(col) && c.is_whitespace(); - row.cell( - if guided { Style { bg: style.bg, ..guide } } else { style }, - if guided { '\u{258f}' } else { ' ' }, - ); + } + + #[inline] + fn is_guide(&self, col: usize) -> bool { + let width: usize = self.decor.indent_width; + width > 0 && col < self.decor.guide_to && col.is_multiple_of(width) + } + + /// Blank cells (a tab, a space, a wide char cut by the left edge), with indent guides in the indentation. + #[inline] + fn blanks(&self, row: &mut Row, style: Style, cols: Range, indentation: bool) { + for col in cols { + if indentation && self.is_guide(col) { + row.cell(Style { bg: style.bg, ..self.guide }, GUIDE); + } else { + row.cell(style, ' '); } - } else { - row.cell(style, c); - } - screen_x = end; - if row.is_full() { - break; } } - // Blank lines inside a block still show the block's guides. - while screen_x < decor.guide_to && !row.is_full() { - if screen_x >= buf.left { - row.cell( - if is_guide(screen_x) { guide } else { Style::new(guide.fg, bg) }, - if is_guide(screen_x) { '\u{258f}' } else { ' ' }, - ); + + /// A blank line inside a block still shows the block's guides, up to its depth. + fn guides_past_text(&self, row: &mut Row, mut screen_x: usize) { + while screen_x < self.decor.guide_to && !row.is_full() { + if screen_x >= self.buf.left { + if self.is_guide(screen_x) { + row.cell(self.guide, GUIDE); + } else { + row.cell(Style::new(self.guide.fg, self.bg), ' '); + } + } + screen_x += 1; } - screen_x += 1; } - row.pad_to(Style::new(t.fg(Group::Normal), bg), usize::MAX); } fn group_at(decor: &Decor, col: usize) -> Option {