diff --git a/Cargo.lock b/Cargo.lock index 185f900..a0b57af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -659,6 +659,7 @@ dependencies = [ "tokio", "unicode-segmentation", "unicode-width", + "wcgw-kit", ] [[package]] @@ -1481,6 +1482,58 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" +[[package]] +name = "jiff" +version = "0.2.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26699905cc795a23e3949585cc7ba3ee8ade090872a27e4316304a777297894a" +dependencies = [ + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe6854081f5ac0a3046a9b223729ccd7a20b1e0e8277e44dc0660eedf67dfe2" +dependencies = [ + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4ff49b2887190c760b86207b55cdcaabbdecaf7a0660d68aa2ed6f652abf0a" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -1760,6 +1813,16 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -2175,6 +2238,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -2442,6 +2514,18 @@ dependencies = [ "termwiz", ] +[[package]] +name = "ratatui-textarea" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf7263fbb7f37caa4bcfd00fc604ff1409f70456a833fbdff99282e88fdfe92" +dependencies = [ + "ratatui-core", + "ratatui-widgets", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "ratatui-widgets" version = "0.3.2" @@ -3611,6 +3695,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wcgw-kit" +version = "0.1.0" +source = "git+https://github.com/wcgw/wcgw-kit?tag=v0.1.0#88762d4dc388ffb287bda3a94ae5476804c5ea58" +dependencies = [ + "crossterm", + "futures-util", + "jiff", + "nucleo-matcher", + "ratatui", + "ratatui-textarea", + "regex", + "tokio", + "unicode-segmentation", + "unicode-width", + "wcgw-kit-gfx", +] + +[[package]] +name = "wcgw-kit-gfx" +version = "0.1.0" +source = "git+https://github.com/wcgw/wcgw-kit?tag=v0.1.0#88762d4dc388ffb287bda3a94ae5476804c5ea58" +dependencies = [ + "base64 0.22.1", + "image", +] + [[package]] name = "web-sys" version = "0.3.107" diff --git a/crates/devnull/Cargo.toml b/crates/devnull/Cargo.toml index 1628d3c..48567e2 100644 --- a/crates/devnull/Cargo.toml +++ b/crates/devnull/Cargo.toml @@ -24,8 +24,11 @@ regex = "1" tokio = { version = "1", features = ["rt", "macros", "time", "sync", "fs"] } unicode-segmentation = "1" unicode-width = { version = "0.2", default-features = false } +# The shared Neovim look & feel, at its release tag (its D18). +wcgw-kit = { git = "https://github.com/wcgw/wcgw-kit", tag = "v0.1.0" } [dev-dependencies] +wcgw-kit = { git = "https://github.com/wcgw/wcgw-kit", tag = "v0.1.0", features = ["testing"] } insta = "1" serde_json = "1" tempfile = "3" diff --git a/crates/devnull/src/app/actions.rs b/crates/devnull/src/app/actions.rs index 882ffa7..fcdf5e4 100644 --- a/crates/devnull/src/app/actions.rs +++ b/crates/devnull/src/app/actions.rs @@ -3,55 +3,12 @@ use devnull_api::model::Commit; -use devnull_api::Draft; - -use super::composer::Composing; use super::feed::Source; use super::threads::short_hash; -use super::{App, AppMode, Effect, FetchError, Kind, NetEvent, Request}; -use crate::doc::{Document, Target}; -use crate::key::{Code, Key, Mods}; +use super::{Dn, Effects, FetchError, Kit, NetEvent, Request, fetch}; +use crate::doc::Target; use crate::render::{commit_block, deleted_block, focused_block}; -/// A question in the command line, answered by the next key: `y` does it, anything else does not (but see -/// [`Pending::Publish`]). -#[derive(Debug)] -pub(super) struct Confirm { - question: String, - then: Pending, - /// What the question is about, shown above it. - shown: Option, -} - -/// What a question is about. -#[derive(Debug)] -pub(super) enum Pending { - /// Delete the account's commit of this id. - Delete(String), - /// Publish a draft; `e` edits it again. - Publish { draft: Draft, composing: Composing }, -} - -impl Confirm { - pub(super) fn new(question: String, then: Pending, shown: Option) -> Self { - Self { - question, - then, - shown, - } - } - - /// The question, with its answers. - pub(super) fn question(&self) -> &str { - &self.question - } - - /// What the question is about, to show above it. - pub(super) fn shown(&self) -> Option<&Document> { - self.shown.as_ref() - } -} - /// The commit under the cursor, as `:browse` finds it: a quote's, else the one holding the cursor. pub(super) struct UnderCursor { pub(super) id: String, @@ -59,55 +16,55 @@ pub(super) struct UnderCursor { starred: bool, } -impl App { - pub(super) fn commit_under_cursor(&self) -> Option { - let window = self.windows.current(); - let layout = window.layout(&self.buffers, self.window_area()); - [ - window.cursor.target(&layout), - window.cursor.block_target(&layout), - ] - .into_iter() - .flatten() - .find_map(|target| match target { - Target::Commit { - id, - author, - starred, - .. - } => Some(UnderCursor { - id: id.clone(), - author: author.as_ref().map(|author| author.handle.clone()), - starred: *starred, - }), - _ => None, - }) +impl Dn { + pub(super) fn commit_under_cursor(kit: &Kit) -> Option { + kit.targets_under_cursor() + .into_iter() + .flatten() + .find_map(|target| match target { + Target::Commit { + id, + author, + starred, + .. + } => Some(UnderCursor { + id: id.clone(), + author: author.as_ref().map(|author| author.handle.clone()), + starred: *starred, + }), + _ => None, + }) } /// `:star` (`s`): stars the commit under the cursor, or takes its star back. - pub(super) fn star(&mut self) -> Result, String> { - let commit = self - .commit_under_cursor() + pub(super) fn star(&mut self, kit: &mut Kit) -> Result { + let commit = Self::commit_under_cursor(kit) .ok_or_else(|| "No commit under the cursor".to_owned())?; - Ok(vec![Effect::Fetch(Request::Star { + Ok(vec![fetch(Request::Star { id: commit.id, on: !commit.starred, })]) } /// `:delete` (`dd`): asks to delete the account's own commit under the cursor. - pub(super) fn delete(&mut self) -> Result, String> { - let commit = self - .commit_under_cursor() + pub(super) fn delete(&mut self, kit: &mut Kit) -> Result { + let commit = Self::commit_under_cursor(kit) .ok_or_else(|| "No commit under the cursor".to_owned())?; if commit.author.is_none() || commit.author.as_deref() != self.connection.handle() { return Err("You can only delete your own commits".to_owned()); } - self.mode = AppMode::Confirm(Confirm::new( - format!("Delete commit {}? (y/n)", short_hash(&commit.id)), - Pending::Delete(commit.id), + let id = commit.id; + kit.confirm( + format!("Delete commit {}? (y/n)", short_hash(&id)), None, - )); + move |_, _, answer| { + if answer == 'y' { + vec![fetch(Request::DeleteCommit { id })] + } else { + Vec::new() + } + }, + ); Ok(Vec::new()) } @@ -115,12 +72,13 @@ impl App { /// of the commit under it. pub(super) fn follow( &mut self, + kit: &mut Kit, handle: Option, on: bool, - ) -> Result, String> { + ) -> Result { let (handle, kind) = match handle { Some(handle) => (handle, None), - None => match self.target_under_cursor() { + None => match kit.target_under_cursor() { Some(Target::Account(account)) => (account.handle, account.kind), Some(Target::Commit { author: Some(author), @@ -129,55 +87,36 @@ impl App { _ => return Err("No account under the cursor".to_owned()), }, }; - Ok(vec![Effect::Fetch(Request::Follow { handle, kind, on })]) - } - - /// The answer to a question in the command line. - pub(super) fn confirm_key(&mut self, key: Key) -> Vec { - let AppMode::Confirm(confirm) = std::mem::replace(&mut self.mode, AppMode::Normal) else { - return Vec::new(); - }; - let answer = match key.code { - Code::Char(answer) if key.mods == Mods::NONE => answer, - _ => '\0', - }; - match confirm.then { - Pending::Delete(id) if answer == 'y' => { - vec![Effect::Fetch(Request::DeleteCommit { id })] - } - Pending::Delete(_) => Vec::new(), - Pending::Publish { draft, composing } => self.publish(answer, draft, composing), - } + Ok(vec![fetch(Request::Follow { handle, kind, on })]) } /// A star given or taken back, or why not: the commit shows its new count wherever it is shown. pub(super) fn starred( &mut self, + kit: &mut Kit, id: &str, on: bool, result: Result, FetchError>, now: u64, - ) -> Vec { + ) -> Effects { match result { Ok(commit) => { - for buffer in &mut self.buffers { - let Kind::Feed(feed) = &buffer.kind else { - continue; - }; + let cell = kit.cell_size(); + for (&buffer, feed) in &self.feeds { // A thread shows its commit whole. let content = if feed.source == Source::Thread(id.to_owned()) { - focused_block(&commit, now, self.cell_size) + focused_block(&commit, now, cell) } else { commit_block(&commit, now) }; - let _ = buffer.doc.replace(id, content); + let _ = kit.buffer_mut(buffer).doc_mut().replace(id, content); } Vec::new() } - Err(FetchError::NotApproved) => self.net(NetEvent::NotApproved), + Err(FetchError::NotApproved) => self.net(kit, NetEvent::NotApproved), Err(FetchError::Failed(reason)) => { let verb = if on { "star" } else { "unstar" }; - self.error(format!("Cannot {verb} {}: {reason}", short_hash(id))); + kit.error(format!("Cannot {verb} {}: {reason}", short_hash(id))); Vec::new() } } @@ -186,13 +125,14 @@ impl App { /// An account followed or no longer, or why not; a profile shown of it is asked for again. pub(super) fn followed( &mut self, + kit: &mut Kit, handle: &str, on: bool, result: Result<(), FetchError>, - ) -> Vec { + ) -> Effects { match result { Ok(()) => { - self.info(if on { + kit.info(if on { format!("Following @{handle}") } else { format!("No longer following @{handle}") @@ -202,33 +142,39 @@ impl App { kind: None, }; self.feed_buffer(&profile) - .and_then(|index| self.refresh(index)) + .and_then(|id| self.refresh(kit, id)) .unwrap_or_default() } - Err(FetchError::NotApproved) => self.net(NetEvent::NotApproved), + Err(FetchError::NotApproved) => self.net(kit, NetEvent::NotApproved), Err(FetchError::Failed(reason)) => { let verb = if on { "follow" } else { "unfollow" }; - self.error(format!("Cannot {verb} @{handle}: {reason}")); + kit.error(format!("Cannot {verb} @{handle}: {reason}")); Vec::new() } } } /// A commit deleted, or why not: wherever it was, it says it was deleted. - pub(super) fn deleted(&mut self, id: &str, result: Result<(), FetchError>) -> Vec { + pub(super) fn deleted( + &mut self, + kit: &mut Kit, + id: &str, + result: Result<(), FetchError>, + ) -> Effects { match result { Ok(()) => { - for buffer in &mut self.buffers { - if matches!(buffer.kind, Kind::Feed(_)) { - let _ = buffer.doc.replace(id, deleted_block()); - } + for &buffer in self.feeds.keys() { + let _ = kit + .buffer_mut(buffer) + .doc_mut() + .replace(id, deleted_block()); } - self.info(format!("Deleted {}", short_hash(id))); + kit.info(format!("Deleted {}", short_hash(id))); Vec::new() } - Err(FetchError::NotApproved) => self.net(NetEvent::NotApproved), + Err(FetchError::NotApproved) => self.net(kit, NetEvent::NotApproved), Err(FetchError::Failed(reason)) => { - self.error(format!("Cannot delete {}: {reason}", short_hash(id))); + kit.error(format!("Cannot delete {}: {reason}", short_hash(id))); Vec::new() } } diff --git a/crates/devnull/src/app/cmdline.rs b/crates/devnull/src/app/cmdline.rs deleted file mode 100644 index 597c380..0000000 --- a/crates/devnull/src/app/cmdline.rs +++ /dev/null @@ -1,308 +0,0 @@ -//! The command line being typed after `:`, `/` or `?`. - -use unicode_width::UnicodeWidthStr; - -use super::complete::Completions; -use crate::doc::Direction; -use crate::key::{Code, Key, Mods}; -use crate::keymap::CmdlineKind; - -/// What a command line is for. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum Purpose { - /// `:`. - Ex, - /// `/` and `?`. - Search(Direction), - /// The device key's passphrase: shown masked, never remembered. - Passphrase, -} - -impl From for Purpose { - fn from(kind: CmdlineKind) -> Self { - match kind { - CmdlineKind::Ex => Self::Ex, - CmdlineKind::Search(direction) => Self::Search(direction), - } - } -} - -/// A command line being edited. -#[derive(Debug)] -pub(super) struct Cmdline { - pub(super) purpose: Purpose, - /// The count typed before `/` or `?`, for the search. - pub(super) count: Option, - text: Vec, - /// Insertion point, in chars. - cursor: usize, - /// While browsing history: the entry shown, counted back from the newest (0 is the newest), and what was typed - /// before browsing started. - browsing: Option<(usize, Vec)>, - /// While `` cycles through completions. - completing: Option, -} - -/// Completions `` and `` cycle through, as Vim's 'wildmenu' does. -#[derive(Debug)] -struct Completing { - /// Where the completed word starts, in chars. - start: usize, - /// The word as typed, which cycling comes back to. - typed: Vec, - /// More than one. - candidates: Vec, - /// The one in place, if any; else what was typed is. - chosen: Option, -} - -/// The completions shown while `` cycles through them. -#[derive(Debug)] -pub(super) struct Shown<'c> { - /// Where the completed word starts, in cells after the prompt. - pub(super) cells: usize, - pub(super) candidates: &'c [String], - /// The one in place, if any. - pub(super) chosen: Option, -} - -/// What a key did to the command line. -#[derive(Debug, PartialEq, Eq)] -pub(super) enum Edit { - /// Still editing. - Editing, - /// ``: run this text. - Submit(String), - /// ``, ``, or `` on an empty line: leave without running anything. - Cancel, -} - -impl Cmdline { - pub(super) fn new(purpose: Purpose, count: Option) -> Self { - Self { - purpose, - count, - text: Vec::new(), - cursor: 0, - browsing: None, - completing: None, - } - } - - /// The prompt: `:`, `/`, `?` or `Passphrase: `. - pub(super) fn prompt(&self) -> &'static str { - match self.purpose { - Purpose::Ex => ":", - Purpose::Search(Direction::Forward) => "/", - Purpose::Search(Direction::Backward) => "?", - Purpose::Passphrase => "Passphrase: ", - } - } - - /// The text as shown: masked for a passphrase. - pub(super) fn shown(&self) -> String { - match self.purpose { - Purpose::Passphrase => "*".repeat(self.text.len()), - _ => self.text(), - } - } - - pub(super) fn text(&self) -> String { - self.text.iter().collect() - } - - /// Where the cursor is drawn, in cells after the prompt. - pub(super) fn cursor_cells(&self) -> usize { - if self.purpose == Purpose::Passphrase { - return self.cursor; - } - self.text[..self.cursor].iter().collect::().width() - } - - /// `` (`forward`) and ``: puts the next or previous of the completions `complete` gives for the text - /// before the cursor in place of the word they complete; past either end, the word as typed. A lone completion - /// is put in place at once, with nothing to cycle through. - pub(super) fn complete(&mut self, forward: bool, complete: impl FnOnce(&str) -> Completions) { - if self.completing.is_none() { - let before: String = self.text[..self.cursor].iter().collect(); - let Completions { start, candidates } = complete(&before); - let start = start.min(self.cursor); - match &candidates[..] { - [] => return, - [lone] => { - let lone: Vec = lone.chars().collect(); - self.replace(start, &lone); - return; - } - _ => {} - } - self.completing = Some(Completing { - start, - typed: self.text[start..self.cursor].to_vec(), - candidates, - chosen: None, - }); - } - let Some(completing) = &mut self.completing else { - return; - }; - let last = completing.candidates.len() - 1; - completing.chosen = match (completing.chosen, forward) { - (None, true) => Some(0), - (None, false) => Some(last), - (Some(at), true) if at < last => Some(at + 1), - (Some(at), false) if at > 0 => Some(at - 1), - _ => None, - }; - let replacement: Vec = match completing.chosen { - Some(at) => completing.candidates[at].chars().collect(), - None => completing.typed.clone(), - }; - let start = completing.start; - self.replace(start, &replacement); - } - - /// The completions `` cycles through, while it does. - pub(super) fn completions(&self) -> Option> { - let completing = self.completing.as_ref()?; - Some(Shown { - cells: self.text[..completing.start] - .iter() - .collect::() - .width(), - candidates: &completing.candidates, - chosen: completing.chosen, - }) - } - - /// Puts `chars` in place of the text from `start` to the cursor, the cursor after them. - fn replace(&mut self, start: usize, chars: &[char]) { - self.text.splice(start..self.cursor, chars.iter().copied()); - self.cursor = start + chars.len(); - } - - /// Applies one key, with `history` (oldest first) for `` and ``. Any key keeps the completion in - /// place. - pub(super) fn key(&mut self, key: Key, history: &[String]) -> Edit { - self.completing = None; - let ctrl = |ch| Key { - code: Code::Char(ch), - mods: Mods::CTRL, - }; - match key { - Key { - code: Code::Enter, .. - } => return Edit::Submit(self.text()), - Key { - code: Code::Esc, .. - } => return Edit::Cancel, - k if k == ctrl('c') => return Edit::Cancel, - Key { - code: Code::Backspace, - .. - } => { - if self.text.is_empty() { - return Edit::Cancel; - } - if self.cursor > 0 { - self.cursor -= 1; - self.text.remove(self.cursor); - } - } - Key { - code: Code::Delete, .. - } => { - if self.cursor < self.text.len() { - self.text.remove(self.cursor); - } - } - Key { - code: Code::Left, .. - } => self.cursor = self.cursor.saturating_sub(1), - Key { - code: Code::Right, .. - } => self.cursor = (self.cursor + 1).min(self.text.len()), - Key { - code: Code::Home, .. - } => self.cursor = 0, - k if k == ctrl('b') => self.cursor = 0, - Key { - code: Code::End, .. - } => self.cursor = self.text.len(), - k if k == ctrl('e') => self.cursor = self.text.len(), - k if k == ctrl('u') => { - self.text.drain(..self.cursor); - self.cursor = 0; - } - k if k == ctrl('w') => { - let end = self.cursor; - let mut start = end; - while start > 0 && self.text[start - 1].is_whitespace() { - start -= 1; - } - let word = |c: char| c.is_alphanumeric() || c == '_'; - let in_word = start > 0 && word(self.text[start - 1]); - while start > 0 - && !self.text[start - 1].is_whitespace() - && word(self.text[start - 1]) == in_word - { - start -= 1; - } - self.text.drain(start..end); - self.cursor = start; - } - Key { code: Code::Up, .. } => self.browse(history, true), - Key { - code: Code::Down, .. - } => self.browse(history, false), - Key { - code: Code::Char(ch), - mods, - } if !mods.contains(Mods::CTRL) && !mods.contains(Mods::ALT) => { - self.insert(&[ch]); - } - _ => {} - } - Edit::Editing - } - - /// Inserts pasted text at the cursor, up to its first line break: a command line is one line. - pub(super) fn paste(&mut self, text: &str) { - self.completing = None; - let line: Vec = text - .chars() - .take_while(|&c| c != '\n' && c != '\r') - .collect(); - self.insert(&line); - } - - fn insert(&mut self, chars: &[char]) { - self.text - .splice(self.cursor..self.cursor, chars.iter().copied()); - self.cursor += chars.len(); - } - - /// `` (`older`) and ``: steps through `history`, back to what was typed past the newest entry. - fn browse(&mut self, history: &[String], older: bool) { - let typed = || self.text.clone(); - let next = match (&self.browsing, older) { - (None, true) if !history.is_empty() => Some(0), - (None, _) => return, - (Some((back, _)), true) => Some((back + 1).min(history.len() - 1)), - (Some((0, _)), false) => None, - (Some((back, _)), false) => Some(back - 1), - }; - let original = self - .browsing - .take() - .map_or_else(typed, |(_, original)| original); - self.text = match next { - Some(back) => { - self.browsing = Some((back, original)); - history[history.len() - 1 - back].chars().collect() - } - None => original, - }; - self.cursor = self.text.len(); - } -} diff --git a/crates/devnull/src/app/commands.rs b/crates/devnull/src/app/commands.rs new file mode 100644 index 0000000..3a16784 --- /dev/null +++ b/crates/devnull/src/app/commands.rs @@ -0,0 +1,86 @@ +//! `dn`'s ex commands (D5): every action on what dev-null.sh shows has one, so `:map` can reach anything a key +//! does. + +use devnull_api::Answering; +use wcgw_kit::shell::Command; + +use super::{Dn, Kit}; + +/// Whom `:commit` and its kin post as: the one handle in `args`, `@` or not; `None` without one. +fn author(args: &str) -> Result, String> { + match args.split_whitespace().collect::>()[..] { + [] => Ok(None), + [handle] => Ok(Some(handle.trim_start_matches('@').to_owned())), + _ => Err(format!("E475: Invalid argument: {args}")), + } +} + +/// `args`, if not empty. +fn optional(args: &str) -> Option { + (!args.is_empty()).then(|| args.to_owned()) +} + +/// Adds `dn`'s commands to `kit`, with the shortest abbreviation Vim would accept for each. +pub(super) fn add(kit: &mut Kit) { + let commands = [ + Command::::new("profile", 4, |dn, kit, line| { + dn.profile(kit, optional(line.args)) + }) + .jumps() + .help("a profile; alone, of the account under the cursor"), + Command::::new("browse", 3, |dn, kit, line| match line.args { + "" => dn.browse(kit, false), + "link" => dn.browse(kit, true), + args => Err(format!("E475: Invalid argument: {args}")), + }) + .help("the commit or account on the web; with link, the link under the cursor, or one of the commit's") + .complete(|_, _, before, _| { + if before.is_empty() { + vec!["link".to_owned()] + } else { + Vec::new() + } + }), + Command::::new("yanklink", 5, |_, kit, _| Dn::yank_link(kit)) + .help("yank the link under the cursor, else the one :browse opens"), + Command::::new("details", 3, |dn, kit, _| dn.hover(kit)) + .help("a card about what is under the cursor; K again goes into it"), + Command::::new("star", 4, |dn, kit, _| dn.star(kit)) + .help("star the commit, or take the star back"), + Command::::new("commit", 4, |dn, kit, line| { + dn.compose(kit, Answering::Nothing, author(line.args)?) + }) + .help("write a commit, as an organization when named"), + Command::::new("reply", 3, |dn, kit, line| { + dn.compose(kit, Answering::Reply(String::new()), author(line.args)?) + }) + .help("reply to the commit"), + Command::::new("quote", 3, |dn, kit, line| { + dn.compose(kit, Answering::Quote(String::new()), author(line.args)?) + }) + .help("quote the commit"), + Command::::new("delete", 3, |dn, kit, _| dn.delete(kit)).help("delete your own commit"), + Command::::new("follow", 3, |dn, kit, line| { + dn.follow(kit, handle(line.args), true) + }) + .help("follow the account named or under the cursor"), + Command::::new("unfollow", 3, |dn, kit, line| { + dn.follow(kit, handle(line.args), false) + }) + .help("stop following it"), + Command::::new("connect", 4, |dn, kit, line| { + dn.connect(kit, optional(line.args)) + }) + .help("connect this device; with a link, finish connecting"), + Command::::new("agree", 5, |dn, _, _| dn.agree()).help("accept dev-null.sh's terms"), + ]; + for command in commands { + kit.add_command(command); + } + kit.add_picker("users", |_, _| super::picker::users()); +} + +/// The handle `:follow` names, `@` or not; `None` for what is under the cursor. +fn handle(args: &str) -> Option { + (!args.is_empty()).then(|| args.trim_start_matches('@').to_owned()) +} diff --git a/crates/devnull/src/app/complete.rs b/crates/devnull/src/app/complete.rs deleted file mode 100644 index a85da1b..0000000 --- a/crates/devnull/src/app/complete.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! `` on the command line: what the word before the cursor may be completed to, by what the command takes. - -use super::feed::Source; -use super::{App, AppMode, options}; -use crate::ex; -use crate::hl::{self, Group}; -use crate::keymap::Mode; - -/// What `` may put in place of the end of a command line: from char `start` on, one of `candidates`, sorted. -#[derive(Debug, Default, PartialEq, Eq)] -pub(super) struct Completions { - /// Where the word they replace starts, in chars. - pub(super) start: usize, - pub(super) candidates: Vec, -} - -impl App { - /// `` (`forward`) and `` on the ex command line: the next or previous completion. - pub(super) fn complete_command_line(&mut self, forward: bool) { - let AppMode::Cmdline(mut cmdline) = std::mem::replace(&mut self.mode, AppMode::Normal) - else { - return; - }; - cmdline.complete(forward, |line| self.ex_completions(line)); - self.mode = AppMode::Cmdline(cmdline); - } - - /// What the ex command line `line`, up to the cursor, may be completed to: a command's name while it is the - /// first word, else an argument of the command, as the command takes them. - pub(super) fn ex_completions(&self, line: &str) -> Completions { - let command = line.trim_start_matches(|c: char| c == ':' || c.is_whitespace()); - let skipped = line.len() - command.len(); - let name_len = command - .find(|c: char| !c.is_ascii_alphabetic()) - .unwrap_or(command.len()); - if name_len == command.len() { - let names = ex::command_names().map(str::to_owned); - return completions(line[..skipped].chars().count(), command, names); - } - let Some(name) = ex::full_name(&command[..name_len]) else { - return Completions::default(); - }; - // `:e! name` as `:e name`. - let rest = &command[name_len..]; - let rest = rest.strip_prefix('!').unwrap_or(rest); - if !rest.starts_with(char::is_whitespace) { - return Completions::default(); - } - let word_at = line - .char_indices() - .rev() - .find(|(_, c)| c.is_whitespace()) - .map_or(0, |(at, c)| at + c.len_utf8()); - let word = &line[word_at..]; - let start = line[..word_at].chars().count(); - let before: Vec<&str> = rest.split_whitespace().collect(); - let before = &before[..before.len() - usize::from(!word.is_empty())]; - let names = |names: &[&str]| { - names - .iter() - .map(|name| (*name).to_owned()) - .collect::>() - }; - let candidates: Vec = match (name, before) { - ("set", _) => { - let (offset, values) = options::completions(word); - let value = word.chars().skip(offset).collect::(); - return completions(start + offset, &value, values); - } - ("colorscheme", []) => hl::colorscheme_names().map(str::to_owned).collect(), - ("edit", []) => self - .buffers - .iter() - .map(|buffer| buffer.name.clone()) - .chain(Source::names()) - .chain(["profile".to_owned()]) - .collect(), - ("buffer", []) => self - .listed() - .into_iter() - .map(|index| self.buffers[index].name.clone()) - .collect(), - ("highlight", []) => groups().chain(names(&["clear", "link"])).collect(), - ("highlight", ["link"] | ["link", _]) => groups().collect(), - ("help", []) => [Mode::Normal, Mode::Visual] - .into_iter() - .flat_map(|mode| self.keymap.listing(mode)) - .map(|listed| listed.keys) - .collect(), - ("pick", []) => names(&["buffers", "users"]), - ("browse", []) => names(&["link"]), - _ => Vec::new(), - }; - completions(start, word, candidates) - } -} - -/// The names of the highlight groups, as `:hi` takes them. -fn groups() -> impl Iterator { - Group::ALL.iter().map(|group| group.name().to_owned()) -} - -/// Those of `candidates` that complete `word`, from char `start` on: sorted, each once. -fn completions( - start: usize, - word: &str, - candidates: impl IntoIterator, -) -> Completions { - let mut candidates: Vec = candidates - .into_iter() - .filter(|candidate| candidate.starts_with(word)) - .collect(); - candidates.sort_unstable(); - candidates.dedup(); - Completions { start, candidates } -} diff --git a/crates/devnull/src/app/composer.rs b/crates/devnull/src/app/composer.rs index 3644822..9573315 100644 --- a/crates/devnull/src/app/composer.rs +++ b/crates/devnull/src/app/composer.rs @@ -7,10 +7,9 @@ use devnull_api::body::{self, MAX_LENGTH}; use devnull_api::model::{Commit, Preview}; use devnull_api::{Answering, Draft}; -use super::actions::{Confirm, Pending}; use super::feed::Source; use super::threads::short_hash; -use super::{App, AppMode, Effect, FetchError, NetEvent, Request}; +use super::{Dn, Effect, Effects, FetchError, Kit, NetEvent, Request, fetch}; use crate::doc::Document; use crate::render::{commit_block, preview_block}; @@ -61,15 +60,16 @@ fn parse(saved: &str) -> &str { saved[..end].trim_end() } -impl App { +impl Dn { /// `:commit` (`c`), `:reply` (`r`) and `:quote` (`R`): opens the editor on the draft kept for that, or an /// empty one; a reply or quote is of the commit under the cursor. It is posted as the organization `author`, if /// any (the server refuses one the account is not a member of). pub(super) fn compose( &mut self, + kit: &mut Kit, answering: Answering, author: Option, - ) -> Result, String> { + ) -> Result { let me = self .connection .handle() @@ -83,12 +83,12 @@ impl App { author, }, _ => { - let commit = self - .commit_under_cursor() + let commit = Self::commit_under_cursor(kit) .ok_or_else(|| "No commit under the cursor".to_owned())?; let to = commit.author.unwrap_or_default(); - let excerpt = self.buffers[self.windows.current().buffer] - .doc + let excerpt = kit + .buffer(kit.current_buffer()) + .doc() .line_text(commit.id.as_str(), 1) .map(|line| line.chars().take(EXCERPT).collect::()); let verb = if matches!(answering, Answering::Reply(_)) { @@ -119,12 +119,12 @@ impl App { .unwrap_or_default(); let text = template(&body, &composing); self.composing = Some(composing); - Ok(vec![Effect::Edit { text }]) + Ok(vec![Effect::Edit(wcgw_kit::Edit::new(text))]) } /// The editor closed, with what it saved or why there is nothing: an empty draft aborts, others are kept, counted /// and sent for a preview. - pub(super) fn edited(&mut self, saved: Result) -> Vec { + pub(super) fn saved(&mut self, kit: &mut Kit, saved: Result) -> Effects { let Some(composing) = &self.composing else { return Vec::new(); }; @@ -133,7 +133,7 @@ impl App { Ok(saved) => saved, Err(reason) => { self.composing = None; - self.error(format!("Not committed: {reason}")); + kit.error(format!("Not committed: {reason}")); return Vec::new(); } }; @@ -141,32 +141,33 @@ impl App { if body::normalize(&draft).is_empty() { self.composing = None; self.drafts.remove(&answering); - self.error("Aborting commit due to empty commit message."); + kit.error("Aborting commit due to empty commit message."); return Vec::new(); } self.drafts.insert(answering, draft.clone()); if let Err(refused) = body::check(&draft) { self.composing = None; - self.error(format!("Not committed: {refused}")); + kit.error(format!("Not committed: {refused}")); return Vec::new(); } - vec![Effect::Fetch(Request::Preview { body: draft })] + vec![fetch(Request::Preview { body: draft })] } /// The server's preview of `draft`, or why it refuses it: asks to publish it, showing it. pub(super) fn previewed( &mut self, + kit: &mut Kit, draft: String, result: Result, - ) -> Vec { + ) -> Effects { let Some(composing) = self.composing.take() else { return Vec::new(); }; let preview = match result { Ok(preview) => preview, - Err(FetchError::NotApproved) => return self.net(NetEvent::NotApproved), + Err(FetchError::NotApproved) => return self.net(kit, NetEvent::NotApproved), Err(FetchError::Failed(reason)) => { - self.error(format!("Not committed: {reason}")); + kit.error(format!("Not committed: {reason}")); return Vec::new(); } }; @@ -185,28 +186,33 @@ impl App { shown.push_back("preview", preview_block(&preview)); let mut draft = Draft::new(composing.answering.clone(), draft); draft.author.clone_from(&composing.author); - self.mode = AppMode::Confirm(Confirm::new( - format!( - "{kind} {count}/{MAX_LENGTH}{to}{}? [y]es/[e]dit/[n]o", - posted_as(composing.author.as_deref()) - ), - Pending::Publish { draft, composing }, - Some(shown), - )); + let question = format!( + "{kind} {count}/{MAX_LENGTH}{to}{}? [y]es/[e]dit/[n]o", + posted_as(composing.author.as_deref()) + ); + kit.confirm(question, Some(shown), move |dn: &mut Dn, kit, answer| { + dn.publish(kit, answer, draft, composing) + }); Vec::new() } /// The answer to "publish?": `y` publishes, `e` edits again, anything else keeps the draft. - pub(super) fn publish(&mut self, key: char, draft: Draft, composing: Composing) -> Vec { + pub(super) fn publish( + &mut self, + kit: &mut Kit, + key: char, + draft: Draft, + composing: Composing, + ) -> Effects { match key { - 'y' => vec![Effect::Fetch(Request::Publish { draft })], + 'y' => vec![fetch(Request::Publish { draft })], 'e' => { let text = template(&draft.body, &composing); self.composing = Some(composing); - vec![Effect::Edit { text }] + vec![Effect::Edit(wcgw_kit::Edit::new(text))] } _ => { - self.info("Kept as a draft"); + kit.info("Kept as a draft"); Vec::new() } } @@ -215,23 +221,24 @@ impl App { /// `draft` published, or why not: the draft goes, and a reply joins its thread if it is open. pub(super) fn published( &mut self, + kit: &mut Kit, draft: &Draft, result: Result, FetchError>, now: u64, - ) -> Vec { + ) -> Effects { let commit = match result { Ok(commit) => commit, - Err(FetchError::NotApproved) => return self.net(NetEvent::NotApproved), + Err(FetchError::NotApproved) => return self.net(kit, NetEvent::NotApproved), Err(FetchError::Failed(reason)) => { - self.error(format!("Not published: {reason}")); + kit.error(format!("Not published: {reason}")); return Vec::new(); } }; self.drafts.remove(&draft.answering); if let Answering::Reply(to) = &draft.answering - && let Some(index) = self.feed_buffer(&Source::Thread(to.clone())) + && let Some(id) = self.feed_buffer(&Source::Thread(to.clone())) { - let doc = &mut self.buffers[index].doc; + let doc = kit.buffer_mut(id).doc_mut(); if !doc.contains(commit.id.as_str()) { doc.push_back(commit.id.as_str(), commit_block(&commit, now)); } @@ -246,9 +253,9 @@ impl App { let posted_as = commit.author.as_ref().map(|author| author.handle.as_str()); match (draft.author.as_deref(), posted_as) { (Some(asked), Some(posted)) if !asked.eq_ignore_ascii_case(posted) => { - self.error(format!("{published} as @{posted}, not @{asked}{held}")); + kit.error(format!("{published} as @{posted}, not @{asked}{held}")); } - _ => self.info(format!("{published}{held}")), + _ => kit.info(format!("{published}{held}")), } Vec::new() } diff --git a/crates/devnull/src/app/connection.rs b/crates/devnull/src/app/connection.rs index bb51269..3fba32c 100644 --- a/crates/devnull/src/app/connection.rs +++ b/crates/devnull/src/app/connection.rs @@ -3,7 +3,6 @@ use devnull_api::model::Me; use crate::doc::{Document, Line, Span, Target}; -use crate::hl::Group; /// What the network reports about the connection, sent back by the runtime. #[derive(Debug, Clone, PartialEq, Eq)] @@ -96,11 +95,11 @@ impl Connection { plain("Approve this device in your browser:"), plain(""), Line::Text(vec![ - Span::new(url.clone(), Group::Link).with_target(Target::Url(url.clone())), + Span::new(url.clone(), crate::hl::LINK).with_target(Target::Url(url.clone())), ]), plain(""), plain("Check that the page shows this fingerprint:"), - Line::Text(vec![Span::new(fingerprint.clone(), Group::Hash)]), + Line::Text(vec![Span::new(fingerprint.clone(), crate::hl::HASH)]), plain(""), plain("Waiting for the browser to come back…"), plain("Over SSH, paste the link the browser ends on with :connect ."), diff --git a/crates/devnull/src/app/feed.rs b/crates/devnull/src/app/feed.rs index cf12220..abcf7c3 100644 --- a/crates/devnull/src/app/feed.rs +++ b/crates/devnull/src/app/feed.rs @@ -338,7 +338,10 @@ impl Feed { self.list.more()?; doc.push_back( MORE, - vec![Line::Text(vec![Span::new("Loading more…", Group::Time)])], + vec![Line::Text(vec![Span::new( + "Loading more…", + crate::hl::TIME, + )])], ); Some(request) } @@ -434,7 +437,7 @@ impl Feed { } } if doc.is_empty() { - doc.push_back(NOTICE, notice_lines("Nothing here yet.", Group::Time)); + doc.push_back(NOTICE, notice_lines("Nothing here yet.", crate::hl::TIME)); } self.list.loaded = true; arrived diff --git a/crates/devnull/src/app/fetch.rs b/crates/devnull/src/app/fetch.rs index 06c3b7f..edaeee6 100644 --- a/crates/devnull/src/app/fetch.rs +++ b/crates/devnull/src/app/fetch.rs @@ -5,8 +5,10 @@ use devnull_api::model::{ }; use devnull_api::{Category, Draft, Log, OrganizationTab, People}; +use wcgw_kit::BufferId; + use super::feed::{Feed, NOTICE, Source, notice, notice_lines}; -use super::{App, Buffer, Effect, Kind, NetEvent, threads}; +use super::{Dn, Effects, Kit, NetEvent, fetch, threads}; use crate::doc::{Content, Cursor as DocCursor, Document}; use crate::hl::Group; use crate::render::{account_block, commit_block, notification_block, parse_time}; @@ -287,88 +289,71 @@ fn inbox_page(inbox: &Inbox, now: u64) -> Page<(String, Content)> { Page::new(items, inbox.page.next.clone()) } -impl App { - /// The buffer showing `source`, if there is one. - pub(super) fn feed_buffer(&self, source: &Source) -> Option { - self.buffers - .iter() - .position(|buffer| matches!(&buffer.kind, Kind::Feed(feed) if feed.source == *source)) - } - +impl Dn { /// The buffer showing `source`, created empty if there is none yet. - pub(super) fn feed_buffer_or_new(&mut self, source: Source) -> usize { + pub(super) fn feed_buffer_or_new(&mut self, kit: &mut Kit, source: Source) -> BufferId { self.feed_buffer(&source).unwrap_or_else(|| { - self.buffers.push(Buffer { - name: source.name(), - doc: Document::new(), - kind: Kind::Feed(Feed::new(source)), - }); - self.buffers.len() - 1 + let id = kit.add_buffer(source.name(), Document::new(), true); + // A feed is prose: laid out up to the kit's 'textwidth' (120), as every app's. + kit.set_prose(id, true); + self.feeds.insert(id, Feed::new(source)); + id }) } /// Shows `source` in the current window, loading it if needed; the cursor starts at `start` when the window /// never showed it. - pub(super) fn open_feed(&mut self, source: Source, start: DocCursor) -> Vec { - let index = self.feed_buffer_or_new(source); - self.show_buffer_at(index, start) + pub(super) fn open_feed(&mut self, kit: &mut Kit, source: Source, start: DocCursor) -> Effects { + let id = self.feed_buffer_or_new(kit, source); + kit.show_buffer_at(id, start); + Vec::new() } - /// Asks for what buffer `index` shows, unless it has it or is getting it. - pub(super) fn load(&mut self, index: usize) -> Vec { - let requests = match &mut self.buffers[index].kind { - Kind::Feed(feed) => feed.start(), - Kind::Status | Kind::Fixed | Kind::Help => Vec::new(), + /// Asks for what buffer `id` shows, unless it has it or is getting it. + pub(super) fn load(&mut self, kit: &mut Kit, id: BufferId) -> Effects { + let requests = match self.feeds.get_mut(&id) { + Some(feed) => feed.start(), + None => Vec::new(), }; - self.ask(index, requests) + self.ask(kit, id, requests) } - /// `:e!`: asks for what buffer `index` shows again, keeping what is shown until it arrives. `None` when the + /// `:e!`: asks for what buffer `id` shows again, keeping what is shown until it arrives. `None` when the /// buffer shows nothing from the server. - pub(super) fn refresh(&mut self, index: usize) -> Option> { - let requests = match &mut self.buffers[index].kind { - Kind::Feed(feed) => feed.refresh(), - Kind::Status | Kind::Fixed | Kind::Help => return None, - }; - Some(self.ask(index, requests)) + pub(super) fn refresh(&mut self, kit: &mut Kit, id: BufferId) -> Option { + let requests = self.feeds.get_mut(&id)?.refresh(); + Some(self.ask(kit, id, requests)) } - /// Sends `requests` for buffer `index`, saying it is loading while it has nothing else to show. - fn ask(&mut self, index: usize, requests: Vec) -> Vec { - let buffer = &mut self.buffers[index]; - if let Kind::Feed(feed) = &buffer.kind + /// Sends `requests` for buffer `id`, saying it is loading while it has nothing else to show. + fn ask(&mut self, kit: &mut Kit, id: BufferId, requests: Vec) -> Effects { + if let Some(feed) = self.feeds.get(&id) && !feed.has_content() { - buffer.doc = notice(&format!("Loading {}…", buffer.name), Group::Time); + let buffer = kit.buffer_mut(id); + let doc = notice(&format!("Loading {}…", buffer.name()), crate::hl::TIME); + *buffer.doc_mut() = doc; } - requests.into_iter().map(Effect::Fetch).collect() + requests.into_iter().map(fetch).collect() } /// Asks for the next page of the current window's list when its cursor is within a window's height of the end. - pub(super) fn load_more(&mut self) -> Vec { - let area = self.window_area(); - let window = self.windows.current(); - let index = window.buffer; - let buffer = &self.buffers[index]; - if !matches!(&buffer.kind, Kind::Feed(feed) if feed.has_more()) { - return Vec::new(); - } - let layout = window.layout(&self.buffers, area); - if window.cursor.screen(&layout).row + usize::from(area.height) < layout.row_count() { + pub(super) fn load_more(&mut self, kit: &mut Kit) -> Effects { + let id = kit.current_buffer(); + if !self.feeds.get(&id).is_some_and(Feed::has_more) || !kit.cursor_near_end() { return Vec::new(); } - let buffer = &mut self.buffers[index]; - let Kind::Feed(feed) = &mut buffer.kind else { + let Some(feed) = self.feeds.get_mut(&id) else { return Vec::new(); }; - feed.more(&mut buffer.doc) - .map(Effect::Fetch) + feed.more(kit.buffer_mut(id).doc_mut()) + .map(fetch) .into_iter() .collect() } /// An answer from the server; `now` (Unix seconds) is when it arrived, for ages. - pub(super) fn fetched(&mut self, fetched: Fetched, now: u64) -> Vec { + pub(super) fn fetched(&mut self, kit: &mut Kit, fetched: Fetched, now: u64) -> Effects { let commits = |page: Page| { let items = page .items @@ -379,23 +364,30 @@ impl App { }; match fetched { Fetched::Log { log, after, result } => { - self.page_arrived(&Source::Log(log), after.is_some(), result.map(commits)) + self.page_arrived(kit, &Source::Log(log), after.is_some(), result.map(commits)) } - Fetched::Replies { id, after, result } => { - self.page_arrived(&Source::Thread(id), after.is_some(), result.map(commits)) - } - Fetched::Thread { id, result } => self.head_arrived( + Fetched::Replies { id, after, result } => self.page_arrived( + kit, &Source::Thread(id), - result.map(|thread| threads::head(&thread, now, self.cell_size)), + after.is_some(), + result.map(commits), ), - Fetched::Profile { handle, result } => self.profile_arrived(handle, result, now), + Fetched::Thread { id, result } => { + let cell = kit.cell_size(); + self.head_arrived( + kit, + &Source::Thread(id), + result.map(|thread| threads::head(&thread, now, cell)), + ) + } + Fetched::Profile { handle, result } => self.profile_arrived(kit, handle, result, now), Fetched::Commits { handle, after, result, } => { let source = Source::Profile { handle, kind: None }; - self.page_arrived(&source, after.is_some(), result.map(commits)) + self.page_arrived(kit, &source, after.is_some(), result.map(commits)) } Fetched::OrganizationCommits { handle, @@ -407,7 +399,7 @@ impl App { OrganizationTab::Mentions => Source::Mentions(handle), _ => Source::Profile { handle, kind: None }, }; - self.page_arrived(&source, after.is_some(), result.map(commits)) + self.page_arrived(kit, &source, after.is_some(), result.map(commits)) } Fetched::People { handle, @@ -424,9 +416,9 @@ impl App { Page::new(items, page.next) }; let source = Source::People(handle, which); - self.page_arrived(&source, after.is_some(), result.map(accounts)) + self.page_arrived(kit, &source, after.is_some(), result.map(accounts)) } - Fetched::SearchProfiles { term, result } => self.found(&term, result), + Fetched::SearchProfiles { term, result } => self.found(kit, &term, result), Fetched::Notifications { only, after, @@ -438,25 +430,26 @@ impl App { } let source = Source::Notifications(only); self.page_arrived( + kit, &source, after.is_some(), result.map(|inbox| inbox_page(&inbox, now)), ) } - Fetched::Star { id, on, result } => self.starred(&id, on, result, now), - Fetched::Follow { handle, on, result } => self.followed(&handle, on, result), - Fetched::DeleteCommit { id, result } => self.deleted(&id, result), - Fetched::Preview { body, result } => self.previewed(body, result), - Fetched::Published { draft, result } => self.published(&draft, result, now), + Fetched::Star { id, on, result } => self.starred(kit, &id, on, result, now), + Fetched::Follow { handle, on, result } => self.followed(kit, &handle, on, result), + Fetched::DeleteCommit { id, result } => self.deleted(kit, &id, result), + Fetched::Preview { body, result } => self.previewed(kit, body, result), + Fetched::Published { draft, result } => self.published(kit, &draft, result, now), Fetched::Starred { after, result } => { - self.page_arrived(&Source::Starred, after.is_some(), result.map(commits)) + self.page_arrived(kit, &Source::Starred, after.is_some(), result.map(commits)) } Fetched::UnreadCount { result } => match result { Ok(count) => { self.unread = count; Vec::new() } - Err(FetchError::NotApproved) => self.net(NetEvent::NotApproved), + Err(FetchError::NotApproved) => self.net(kit, NetEvent::NotApproved), // Asked for every poll: the next one tries again, without an error each time. Err(FetchError::Failed(_)) => Vec::new(), }, @@ -466,23 +459,23 @@ impl App { /// The head of the feed of `source` arrived as blocks, or failed to. pub(super) fn head_arrived( &mut self, + kit: &mut Kit, source: &Source, result: Result, FetchError>, - ) -> Vec { - let Some(index) = self.feed_buffer(source) else { + ) -> Effects { + let Some(id) = self.feed_buffer(source) else { return Vec::new(); }; - let buffer = &mut self.buffers[index]; - let Kind::Feed(feed) = &mut buffer.kind else { + let Some(feed) = self.feeds.get_mut(&id) else { return Vec::new(); }; feed.head_answered(); match result { Ok(blocks) => { - feed.head_arrived(&mut buffer.doc, blocks); + feed.head_arrived(kit.buffer_mut(id).doc_mut(), blocks); Vec::new() } - Err(error) => self.failed(index, error), + Err(error) => self.failed(kit, id, error), } } @@ -490,27 +483,27 @@ impl App { /// else the first. fn page_arrived( &mut self, + kit: &mut Kit, source: &Source, next_page: bool, result: Result, FetchError>, - ) -> Vec { - let Some(index) = self.feed_buffer(source) else { + ) -> Effects { + let Some(id) = self.feed_buffer(source) else { return Vec::new(); }; - let top_before = self.top_row(index); - let buffer = &mut self.buffers[index]; - let Kind::Feed(feed) = &mut buffer.kind else { + let top_before = kit.top_row(id); + let Some(feed) = self.feeds.get_mut(&id) else { return Vec::new(); }; - feed.page_answered(&mut buffer.doc, next_page); + feed.page_answered(kit.buffer_mut(id).doc_mut(), next_page); let page = match result { Ok(page) => page, - Err(error) => return self.failed(index, error), + Err(error) => return self.failed(kit, id, error), }; - let arrived = feed.page_arrived(&mut buffer.doc, next_page, page); + let arrived = feed.page_arrived(kit.buffer_mut(id).doc_mut(), next_page, page); let of_commits = !matches!(source, Source::People(..)); if of_commits && arrived > 0 && top_before.is_some_and(|top| top > 0) { - self.info(format!( + kit.info(format!( "{arrived} new commit{}", if arrived == 1 { "" } else { "s" } )); @@ -518,33 +511,25 @@ impl App { Vec::new() } - /// A request for buffer `index` failed: says why, also in the buffer while it has nothing else to show. - fn failed(&mut self, index: usize, error: FetchError) -> Vec { + /// A request for buffer `id` failed: says why, also in the buffer while it has nothing else to show. + fn failed(&mut self, kit: &mut Kit, id: BufferId, error: FetchError) -> Effects { match error { - FetchError::NotApproved => self.net(NetEvent::NotApproved), + FetchError::NotApproved => self.net(kit, NetEvent::NotApproved), FetchError::Failed(reason) => { - let buffer = &mut self.buffers[index]; - let text = format!("Cannot load {}: {reason}", buffer.name); - if matches!(&buffer.kind, Kind::Feed(feed) if !feed.has_content()) { - buffer.doc = notice(&text, Group::ErrorMsg); - } else if buffer.doc.contains(NOTICE) { + let has_content = self.feeds.get(&id).is_none_or(Feed::has_content); + let buffer = kit.buffer_mut(id); + let text = format!("Cannot load {}: {reason}", buffer.name()); + if !has_content { + *buffer.doc_mut() = notice(&text, Group::ERROR_MSG); + } else if buffer.doc().contains(NOTICE) { // Such as "Loading…" above replies that arrived when their commit did not. let _ = buffer - .doc - .replace(NOTICE, notice_lines(&text, Group::ErrorMsg)); + .doc_mut() + .replace(NOTICE, notice_lines(&text, Group::ERROR_MSG)); } - self.error(text); + kit.error(text); Vec::new() } } } - - /// The first row shown by the current window when it shows buffer `index`. - fn top_row(&self, index: usize) -> Option { - let window = self.windows.current(); - (window.buffer == index).then(|| { - let layout = window.layout(&self.buffers, self.window_area()); - window.top(&layout, self.viewport()) - }) - } } diff --git a/crates/devnull/src/app/help.rs b/crates/devnull/src/app/help.rs deleted file mode 100644 index 6381fa3..0000000 --- a/crates/devnull/src/app/help.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! `:help`: the key bindings as the keymap has them, user maps too, in a window above the current one, as Vim shows -//! its help. - -use super::windows::Axis; -use super::{App, Buffer, Kind}; -use crate::doc::{Cursor, Document, Line, Span}; -use crate::hl::Group; -use crate::keymap::{Keymap, Listed, Mode, Topic}; - -/// The help buffer's name. -const NAME: &str = "help"; - -impl App { - /// `:help` and `:help {keys}`: the key bindings in the help window, opened above the current one when there is - /// none, with the cursor on the binding of `keys` when given. - /// - /// # Errors - /// - /// Vim's E149 when nothing is bound to `keys`. - pub(super) fn help(&mut self, keys: Option<&str>) -> Result<(), String> { - let doc = document(&self.keymap); - let start = match keys { - None => None, - Some(keys) => { - let block = [Mode::Normal, Mode::Visual] - .into_iter() - .flat_map(|mode| [builtin_block(mode, keys), map_block(mode, keys)]) - .find(|block| doc.contains(block.as_str())) - .ok_or_else(|| format!("E149: Sorry, no help for {keys}"))?; - Some(Cursor::on_block(block.as_str())) - } - }; - let shown = self - .buffers - .iter() - .position(|buffer| matches!(buffer.kind, Kind::Help)); - let index = if let Some(index) = shown { - self.buffers[index].doc = doc; - index - } else { - self.buffers.push(Buffer { - name: NAME.to_owned(), - doc, - kind: Kind::Help, - }); - self.buffers.len() - 1 - }; - if !self.windows.go_to_buffer(index) { - self.windows.split(Axis::Rows); - let start = start.clone().unwrap_or_default(); - self.windows.current_mut().switch_to(index, start); - } - if let Some(start) = start { - self.windows.current_mut().cursor = start; - } - self.follow_cursor(); - Ok(()) - } -} - -/// The block of the built-in binding of `keys` in `mode`. -fn builtin_block(mode: Mode, keys: &str) -> String { - format!("key/{}/{keys}", mode_letter(mode)) -} - -/// The block of the user map of `keys` in `mode`. -fn map_block(mode: Mode, keys: &str) -> String { - format!("map/{}/{keys}", mode_letter(mode)) -} - -/// `mode` as `:map` shows it. -fn mode_letter(mode: Mode) -> &'static str { - match mode { - Mode::Normal => "n", - Mode::Visual => "v", - } -} - -/// The help: a title, the Normal-mode built-ins by topic, those only Visual mode has, then the user maps. -fn document(keymap: &Keymap) -> Document { - let normal = keymap.listing(Mode::Normal); - let visual = keymap.listing(Mode::Visual); - let builtin = |listed: &&Listed| listed.topic.is_some(); - let visual_only: Vec<&Listed> = visual - .iter() - .filter(builtin) - .filter(|listed| { - !normal - .iter() - .any(|other| other.keys == listed.keys && other.description == listed.description) - }) - .collect(); - let maps: Vec<(Mode, &Listed)> = [(Mode::Normal, &normal), (Mode::Visual, &visual)] - .into_iter() - .flat_map(|(mode, listing)| { - listing - .iter() - .filter(|listed| listed.topic.is_none()) - .map(move |listed| (mode, listed)) - }) - .collect(); - let all = || normal.iter().chain(&visual); - let keys_width = all().map(|listed| width(&listed.keys)).max().unwrap_or(0) + 2; - let description_width = all() - .filter(builtin) - .map(|listed| width(&listed.description)) - .max() - .unwrap_or(0) - + 2; - let row = |listed: &Listed, mode: Option| { - let mut spans = vec![Span::new(" ", Group::Normal)]; - if let Some(mode) = mode { - spans.push(Span::new( - format!("{} ", mode_letter(mode)), - Group::WhichKeySeparator, - )); - } - spans.push(Span::new(pad(&listed.keys, keys_width), Group::WhichKey)); - if let Some(command) = &listed.command { - let description = pad(&listed.description, description_width); - spans.push(Span::new(description, Group::WhichKeyDesc)); - spans.push(Span::new(format!(":{command}"), Group::Code)); - } else { - spans.push(Span::new(listed.description.clone(), Group::WhichKeyDesc)); - } - vec![Line::Text(spans)] - }; - let heading = |title: &str| { - vec![ - Line::text(""), - Line::Text(vec![Span::new(title.to_owned(), Group::WhichKeyGroup)]), - ] - }; - - let mut doc = Document::new(); - doc.push_back( - "title", - vec![ - Line::Text(vec![Span::new("Key bindings", Group::WhichKeyGroup)]), - Line::text(format!( - " is {}. Keys that run a command show it, to type after : or use in :map.", - keymap.leader() - )), - ], - ); - let mut topic = None; - for listed in normal.iter().filter(builtin) { - if listed.topic != topic { - topic = listed.topic; - let title = topic.map_or("", Topic::title); - doc.push_back(format!("topic/{title}").as_str(), heading(title)); - } - let block = builtin_block(Mode::Normal, &listed.keys); - doc.push_back(block.as_str(), row(listed, None)); - } - if !visual_only.is_empty() { - doc.push_back("visual", heading("Visual mode")); - for listed in visual_only { - let block = builtin_block(Mode::Visual, &listed.keys); - doc.push_back(block.as_str(), row(listed, None)); - } - } - if !maps.is_empty() { - doc.push_back("maps", heading("Your maps")); - for (mode, listed) in maps { - let block = map_block(mode, &listed.keys); - doc.push_back(block.as_str(), row(listed, Some(mode))); - } - } - doc -} - -/// The cells `text` takes. -fn width(text: &str) -> usize { - unicode_width::UnicodeWidthStr::width(text) -} - -/// `text` followed by blanks up to `cells`. -fn pad(text: &str, cells: usize) -> String { - format!("{text}{}", " ".repeat(cells.saturating_sub(width(text)))) -} diff --git a/crates/devnull/src/app/hover.rs b/crates/devnull/src/app/hover.rs index b138bf2..0ea55f0 100644 --- a/crates/devnull/src/app/hover.rs +++ b/crates/devnull/src/app/hover.rs @@ -1,41 +1,17 @@ //! `K`: a card about what is under the cursor, floating next to it until the next key. use devnull_api::model::AccountPage; -use ratatui::Frame; -use ratatui::layout::{Position, Rect}; -use ratatui::text::{Line as TextLine, Span as TextSpan}; -use ratatui::widgets::{Block, Clear, Paragraph}; -use unicode_width::UnicodeWidthStr; use super::feed::{Source, notice}; -use super::{App, Effect, FetchError, NetEvent, Request}; -use crate::doc::{Document, Layout, Line, LinkCard, Span, Target}; -use crate::hl::Group; +use super::{Dn, Effects, FetchError, Kit, NetEvent, Request, fetch}; +use crate::doc::{Document, Line, LinkCard, Span, Target}; use crate::render::{organization_header, profile_header}; -/// The widest a card's text is, in cells, before it wraps. -const MAX_WIDTH: u16 = 60; - -/// A card shown by `K`. -#[derive(Debug)] -pub(super) struct Hover { - /// What the card says. - doc: Document, - /// The handle whose profile the card waits for, if any. - waiting: Option, -} - -impl Hover { - /// What the card says. - pub(super) fn doc(&self) -> &Document { - &self.doc - } - - fn new(lines: Vec) -> Self { - let mut doc = Document::new(); - doc.push_back("card", lines); - Self { doc, waiting: None } - } +/// A card of `lines`. +fn card(lines: Vec) -> Document { + let mut doc = Document::new(); + doc.push_back("card", lines); + doc } /// A link card: title, site, what the page is about, and the link. @@ -44,11 +20,14 @@ fn link_card(card: &LinkCard) -> Vec { if !card.title.is_empty() { lines.push(Line::Text(vec![Span::new( card.title.clone(), - Group::Author, + crate::hl::AUTHOR, )])); } if !card.site.is_empty() { - lines.push(Line::Text(vec![Span::new(card.site.clone(), Group::Time)])); + lines.push(Line::Text(vec![Span::new( + card.site.clone(), + crate::hl::TIME, + )])); } lines.extend( card.description @@ -62,7 +41,7 @@ fn link_card(card: &LinkCard) -> Vec { fn link(url: &str) -> Line { Line::Text(vec![ - Span::new(url, Group::Link).with_target(Target::Url(url.to_owned())), + Span::new(url, crate::hl::LINK).with_target(Target::Url(url.to_owned())), ]) } @@ -77,17 +56,18 @@ fn account_card(page: &AccountPage) -> Option> { Some(content.lines) } -impl App { - /// `:hover`: the card of the link under the cursor, else of the account, else of the author of the commit; an +impl Dn { + /// `:details` (`K`), as muttrs' and slackers': the card of the link under the cursor, else of the account, else of the author of the commit; an /// account's card waits for its profile to arrive. - pub(super) fn hover(&mut self) -> Result, String> { - let account = match self.target_under_cursor() { + pub(super) fn hover(&mut self, kit: &mut Kit) -> Result { + self.hover_waiting = None; + let account = match kit.target_under_cursor() { Some(Target::Url(url)) => { - self.hover = Some(Hover::new(vec![link(&url)])); + kit.show_hover(card(vec![link(&url)])); return Ok(Vec::new()); } - Some(Target::LinkCard(card)) => { - self.hover = Some(Hover::new(link_card(&card))); + Some(Target::LinkCard(link_card_of)) => { + kit.show_hover(card(link_card(&link_card_of))); return Ok(Vec::new()); } Some(Target::Account(account)) => account, @@ -97,11 +77,12 @@ impl App { }) => author, _ => return Err("E349: No identifier under cursor".to_owned()), }; - self.hover = Some(Hover { - doc: notice(&format!("Loading @{}…", account.handle), Group::Time), - waiting: Some(account.handle.clone()), - }); - Ok(vec![Effect::Fetch(Request::Profile { + kit.show_hover(notice( + &format!("Loading @{}…", account.handle), + crate::hl::TIME, + )); + self.hover_waiting = Some(account.handle.clone()); + Ok(vec![fetch(Request::Profile { handle: account.handle, kind: account.kind, })]) @@ -111,23 +92,24 @@ impl App { /// profile buffer will. pub(super) fn hover_arrived( &mut self, + kit: &mut Kit, handle: &str, result: &Result, - ) -> Vec { - let Some(hover) = self - .hover - .as_mut() - .filter(|hover| hover.waiting.as_deref() == Some(handle)) - else { + ) -> Effects { + if self.hover_waiting.as_deref() != Some(handle) { + return Vec::new(); + } + self.hover_waiting = None; + let Some(hover) = kit.hover_mut() else { return Vec::new(); }; if let Ok(page) = result && let Some(lines) = account_card(page) { - *hover = Hover::new(lines); + *hover = card(lines); return Vec::new(); } - self.hover = None; + kit.close_hover(); let source = Source::Profile { handle: handle.to_owned(), kind: None, @@ -136,80 +118,12 @@ impl App { return Vec::new(); } match result { - Err(FetchError::NotApproved) => self.net(NetEvent::NotApproved), + Err(FetchError::NotApproved) => self.net(kit, NetEvent::NotApproved), Err(FetchError::Failed(reason)) => { - self.error(format!("Cannot load @{handle}: {reason}")); + kit.error(format!("Cannot load @{handle}: {reason}")); Vec::new() } Ok(_) => Vec::new(), } } - - /// Draws the card, if any, just below the cursor at `cursor`, or above it when there is more room there. - pub(super) fn render_hover(&self, frame: &mut Frame<'_>, cursor: Position) { - let Some(hover) = &self.hover else { return }; - let (width, height) = self.size; - let max_inner = MAX_WIDTH.min(width.saturating_sub(2)); - // Rows between the bufferline and the command line. - let (first, last) = (1, height.saturating_sub(2)); - if max_inner < 4 || last < first + 2 { - return; - } - let natural = Layout::new(&hover.doc, max_inner); - let inner_width = (0..natural.row_count()) - .map(|row| { - natural - .segments(row) - .map(|(text, _)| text.width()) - .sum::() - }) - .max() - .unwrap_or(1) - .clamp(1, usize::from(max_inner)); - let inner_width = u16::try_from(inner_width).unwrap_or(max_inner); - let layout = Layout::new(&hover.doc, inner_width); - let needed = u16::try_from(layout.row_count()) - .unwrap_or(u16::MAX) - .saturating_add(2); - let below = (last + 1).saturating_sub(cursor.y + 1); - let above = cursor.y.saturating_sub(first); - let (y, card_height) = if needed <= below || below >= above { - (cursor.y + 1, needed.min(below)) - } else { - let card_height = needed.min(above); - (cursor.y - card_height, card_height) - }; - if card_height < 3 { - return; - } - let card_width = inner_width + 2; - let x = cursor.x.min(width.saturating_sub(card_width)); - let area = Rect::new(x, y, card_width, card_height); - let block = Block::bordered() - .border_style(self.theme.style(Group::FloatBorder)) - .style(self.theme.style(Group::NormalFloat)); - let inner = block.inner(area); - frame.render_widget(Clear, area); - frame.render_widget(block, area); - let rows: Vec> = (0..layout.row_count().min(usize::from(inner.height))) - .map(|row| { - TextLine::from( - layout - .segments(row) - .map(|(text, group)| { - // Plain text takes the card's colours, not the window's. - let group = if group == Group::Normal { - Group::NormalFloat - } else { - group - }; - TextSpan::styled(text, self.theme.style(group)) - }) - .collect::>(), - ) - }) - .collect(); - frame.render_widget(Paragraph::new(rows), inner); - self.draw_pictures(frame.buffer_mut(), &layout, 0, inner); - } } diff --git a/crates/devnull/src/app/logs.rs b/crates/devnull/src/app/logs.rs index 26f8010..078132e 100644 --- a/crates/devnull/src/app/logs.rs +++ b/crates/devnull/src/app/logs.rs @@ -4,7 +4,7 @@ use std::time::Duration; use devnull_api::Log; -use super::{App, Effect, Kind, Request, Wake, WakePurpose}; +use super::{Dn, Effect, Effects, Kit, Msg, Request, fetch}; /// How often chronological logs are checked for new commits while the terminal has focus, and without it. const POLL_FOCUSED: Duration = Duration::from_secs(15); @@ -28,45 +28,46 @@ pub(super) fn names() -> impl Iterator { LOGS.into_iter().map(buffer_name) } -impl App { +impl Dn { /// Starts polling chronological logs for new commits, once. - pub(super) fn start_polling(&mut self) -> Vec { + pub(super) fn start_polling(&mut self, kit: &Kit) -> Effects { if self.polling { return Vec::new(); } self.polling = true; - vec![self.next_poll()] + vec![self.next_poll(kit)] } /// The wake-up for the next poll, sooner while the terminal has focus. - fn next_poll(&self) -> Effect { - Effect::Wake { - after: if self.focused { + fn next_poll(&self, kit: &Kit) -> Effect { + Effect::After { + after: if kit.is_focused() { POLL_FOCUSED } else { POLL_UNFOCUSED }, - token: Wake { - purpose: WakePurpose::Poll, + msg: Msg::Poll { generation: self.poll_generation, }, } } /// Time to poll: refresh every loaded chronological log, and plan the next poll. - pub(super) fn poll(&mut self) -> Vec { - let polled: Vec = (0..self.buffers.len()) - .filter( - |&index| matches!(&self.buffers[index].kind, Kind::Feed(feed) if feed.is_polled()), - ) + pub(super) fn poll(&mut self, kit: &mut Kit) -> Effects { + let mut polled: Vec<_> = self + .feeds + .iter() + .filter(|(_, feed)| feed.is_polled()) + .map(|(&id, _)| id) .collect(); - let mut effects: Vec = polled + polled.sort(); + let mut effects: Effects = polled .into_iter() - .filter_map(|index| self.refresh(index)) + .filter_map(|id| self.refresh(kit, id)) .flatten() .collect(); - effects.push(Effect::Fetch(Request::UnreadCount)); - effects.push(self.next_poll()); + effects.push(fetch(Request::UnreadCount)); + effects.push(self.next_poll(kit)); effects } } diff --git a/crates/devnull/src/app/mod.rs b/crates/devnull/src/app/mod.rs index cfbbe1a..cd1396d 100644 --- a/crates/devnull/src/app/mod.rs +++ b/crates/devnull/src/app/mod.rs @@ -1,195 +1,63 @@ -//! The application core (D10): state and a pure `update`; the runtime does the I/O. +//! `dn` in the kit (D10): what dev-null.sh shows and what `dn` does with it. The editor around it (buffers, +//! windows, keys, the command line, highlight groups, pictures) is wcgw-kit's; `Dn` is the application it runs. mod actions; -mod cmdline; -mod complete; +mod commands; mod composer; mod connection; mod feed; mod fetch; -mod help; mod hover; mod logs; -mod options; mod picker; -mod pictures; mod profiles; mod threads; -mod view; -mod window; -mod windows; #[cfg(test)] mod tests; -use std::time::Duration; +use std::collections::HashMap; -use devnull_api::Answering; -use ratatui::layout::Rect; +use wcgw_kit::BufferId; -use crate::doc::{ - Cursor, Direction, Document, LinkCard, Motion, Pattern, SearchOutcome, Selection, - SelectionKind, Target, -}; -use crate::ex::{self, Command}; -use crate::hl::Theme; -use crate::key::{Code, Key, Mods}; -use crate::keymap::{Action, Invocation, Keymap, Mode}; +use crate::doc::{Cursor, Document, LinkCard, Target}; -use actions::Confirm; -use cmdline::{Cmdline, Edit, Purpose}; use connection::Connection; pub use connection::NetEvent; -use feed::{Feed, Source}; +use feed::Source; pub use fetch::{FetchError, Fetched, Request}; -use hover::Hover; -use options::Options; -use picker::Picker; -pub use pictures::Prepared; -use window::{Viewport, Window}; -use windows::{Axis, Toward, Windows}; -/// Everything `dn` knows, and the only place that decides what happens next. -#[derive(Debug)] -pub struct App { - /// The terminal's size in cells, as (width, height). - size: (u16, u16), - focused: bool, - keymap: Keymap, - options: Options, - theme: Theme, - buffers: Vec, - windows: Windows, - mode: AppMode, - /// The message shown in the command line until the next key, if any. - message: Option, - /// Every message shown, oldest first, for `:messages`. - messages: Vec, - /// Command lines run, oldest first. - ex_history: Vec, - /// Patterns searched for, oldest first. - search_history: Vec, - last_search: Option, - connection: Connection, - /// Counts key presses, so a [`Wake`] asked for before the latest key is recognised as stale. - generation: u64, - /// Whether the which-key popup is showing. - which_key: bool, - /// The card `K` shows, until the next key. - hover: Option, - /// Whether the logs are being polled. - polling: bool, - /// The generation of poll wake-ups, apart from keys so that typing does not stop polling. - poll_generation: u64, - /// The generation of search wake-ups: the user picker's query changed this many times. - search_generation: u64, - /// Buffers in the order they were last shown, the latest first. - recent: Vec, - /// Notifications unread, as the server last counted them. - unread: u64, - /// The draft being written, from opening the editor until it is published or kept. - composing: Option, - /// Drafts not published, to open again. - drafts: composer::Drafts, - /// The pictures asked for. - pictures: pictures::Pictures, - /// Whether the last search's matches are shown, with `'hlsearch'`: `:noh` hides them until the next search. - highlighting: bool, - /// A cell's size in pixels, across and down: what a picture at its own size takes cells by. - cell_size: (u16, u16), -} - -/// A cell's size in pixels until the terminal says otherwise: Ghostty's, as measured in M0.1. -const DEFAULT_CELL_SIZE: (u16, u16) = (10, 20); - -/// How long pending keys wait before the which-key popup shows what can follow them. -const WHICH_KEY_DELAY: Duration = Duration::from_millis(200); +/// The kit `dn` runs in. +pub type Kit = wcgw_kit::Kit; -/// A token the runtime hands back as [`Msg::Wake`] once the delay of an [`Effect::Wake`] has passed. It is only -/// meaningful to the app that asked for it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Wake { - purpose: WakePurpose, - /// The key press it belongs to; any later key makes it stale. - generation: u64, -} +/// What `dn`'s code returns for the runtime to do. +pub type Effects = wcgw_kit::Effects; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum WakePurpose { - /// `'timeoutlen'` passed on an ambiguous key sequence. - KeyTimeout, - /// Time to show the which-key popup. - WhichKey, - /// Time to check the logs for new commits. - Poll, - /// Typing paused in the user picker: time to search. - Search, - /// Time for animations drawn with half blocks to show their next frames. - Frame, -} +/// One thing for the runtime to do. +pub type Effect = wcgw_kit::Effect; -/// Something that happened, for [`App::update`] to react to. +/// What `dn`'s own work tells it. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Msg { - /// A key was pressed. - Key(Key), - /// Text was pasted (bracketed paste). It never runs commands; on the command line it is inserted. - Paste(String), - /// The editor opened by [`Effect::Edit`] closed: what it saved, or why there is nothing (it failed, or quit - /// with an error as Vim's `:cq` does). - Edited(Result), - /// A picture asked for by [`Effect::Picture`], prepared as asked, or why it could not be. - Picture { - /// Where it is. - source: String, - /// Its width in cells. - cols: u16, - /// Its height in cells. - rows: u16, - /// The picture, or why there is none. - result: Result, - }, - /// The terminal gained focus. - FocusGained, - /// The terminal lost focus. - FocusLost, - /// The delay of an [`Effect::Wake`] has passed. - Wake(Wake), /// News about the connection. Net(NetEvent), - /// The answer to an [`Effect::Fetch`], and when it arrived (Unix seconds), for ages. + /// The answer to an [`Io::Fetch`], and when it arrived (Unix seconds), for ages. Fetched { /// The answer. fetched: Fetched, /// When it arrived. now: u64, }, - /// A file to run as ex commands, such as `init.vim` at startup or what `:source` asked for, or why it could not - /// be read. - Sourced { - /// The file, as named to the user. - path: String, - /// Its text, or the reason it could not be read. - contents: Result, - }, - /// The terminal has this size, in cells; sent at startup and on every resize. - Resize { - /// Columns. - width: u16, - /// Rows. - height: u16, + /// Time to check the logs for new commits, unless polling restarted since this was asked for. + Poll { + /// The polling it belongs to. + generation: u64, }, } -/// Something [`App::update`] asks the runtime to do. +/// What `dn` asks its network side to do. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum Effect { - /// Restore the terminal and exit. - Quit, - /// Put this text on the system clipboard (OSC 52). - Copy(String), - /// Read this file (`~` is the home directory) and send its contents back as [`Msg::Sourced`]. - Source(String), +pub enum Io { /// Connect with the device key, reporting back as [`Msg::Net`]. Connect, /// Decrypt the device key with this passphrase, then connect. @@ -200,919 +68,185 @@ pub enum Effect { EnrollPasted(String), /// Accept the terms of this version. AcceptTerms(String), - /// Download the picture at `source` and prepare it for `cols` × `rows` cells, then send [`Msg::Picture`]: for the - /// kitty protocol as image `kitty` when given, as half blocks otherwise. - Picture { - /// Where it is. - source: String, - /// Its width in cells. - cols: u16, - /// Its height in cells. - rows: u16, - /// Its id in the terminal, for the kitty protocol. - kitty: Option, - }, - /// Write these graphics protocol escapes to the terminal, as they are. - Graphics(String), - /// Hand the terminal to the user's editor on `text`, then send [`Msg::Edited`]. - Edit { - /// What the editor opens. - text: String, - }, - /// Open this link in the browser. - OpenUrl(String), /// Ask the server, sending the answer back as [`Msg::Fetched`]. Fetch(Request), - /// Send [`Msg::Wake`] with `token` once `after` has passed. - Wake { - /// How long to wait. - after: Duration, - /// What to send back. - token: Wake, - }, -} - -/// A named document, such as a log or a thread. -#[derive(Debug)] -struct Buffer { - name: String, - doc: Document, - kind: Kind, -} - -/// What a buffer shows, and so where its document comes from. -#[derive(Debug)] -enum Kind { - /// A page saying where something stands, such as `connect`; unlike the others, `:ls`, `:b` and the bufferline - /// leave it out. - Status, - /// A document given whole, as to [`App::with_buffer`]. - Fixed, - /// `:help`'s; unlisted too, as Vim's help buffers are. - Help, - /// What dev-null.sh sends, such as a log or a thread. - Feed(Feed), } -impl Buffer { - /// Whether `:ls`, `:b` and the bufferline show it. - fn is_listed(&self) -> bool { - !matches!(self.kind, Kind::Status | Kind::Help) - } +/// `request`, as an effect. +fn fetch(request: Request) -> Effect { + Effect::App(Io::Fetch(request)) } -/// What keys are for right now. +/// What `dn` knows that the editor around it does not. #[derive(Debug)] -enum AppMode { - Normal, - /// Selecting, from the selection's fixed end to the cursor. - Visual(Selection), - /// Typing after `:`, `/` or `?`. - Cmdline(Cmdline), - /// Lines shown over the screen (messages, or a listing such as `:ls`) until a key continues. - MorePrompt { - lines: Vec, - }, - /// A picker over the windows, taking keys until something is picked or it is left. - Picker(Picker), - /// A question in the command line, answered by the next key. - Confirm(Confirm), -} - -/// A line for the command line area. -#[derive(Debug, Clone, PartialEq, Eq)] -struct Message { - text: String, - kind: MessageKind, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MessageKind { - Info, - Warning, - Error, -} - -/// The last search, for `n` and `N`. -#[derive(Debug, Clone)] -struct LastSearch { - pattern: Pattern, - direction: Direction, +pub struct Dn { + connection: Connection, + /// The buffer saying where connecting stands, while there is one. + status: Option, + /// What each buffer of dev-null.sh's shows, and how far it loaded. + feeds: HashMap, + /// Whether the logs are being polled. + polling: bool, + /// The generation of polls: those of an older one are stale. + poll_generation: u64, + /// Notifications unread, as the server last counted them. + unread: u64, + /// The draft being written, from opening the editor until it is published or kept. + composing: Option, + /// Drafts not published, to open again. + drafts: composer::Drafts, + /// The handle whose profile the hover card waits for, if any. + hover_waiting: Option, } -impl Default for App { +impl Default for Dn { fn default() -> Self { Self::new() } } -impl App { - /// A fresh session, connecting: [`App::start`] says how. +impl Dn { + /// A fresh session, before connecting. #[must_use] pub fn new() -> Self { - let doc = Connection::Checking.document().unwrap_or_default(); - let mut app = Self::with_buffer("connect", doc); - app.buffers[0].kind = Kind::Status; - app - } - - /// A fresh session showing `doc` as a buffer called `name`. - #[must_use] - pub fn with_buffer(name: impl Into, doc: Document) -> Self { Self { - size: (80, 24), - focused: true, - keymap: Keymap::new(), - options: Options::default(), - theme: Theme::default(), - buffers: vec![Buffer { - name: name.into(), - doc, - kind: Kind::Fixed, - }], - windows: Windows::new(Window::new(0)), - mode: AppMode::Normal, - message: None, - messages: Vec::new(), - ex_history: Vec::new(), - search_history: Vec::new(), - last_search: None, connection: Connection::Checking, - generation: 0, - which_key: false, - hover: None, + status: None, + feeds: HashMap::new(), polling: false, poll_generation: 0, - search_generation: 0, - recent: Vec::new(), unread: 0, composing: None, drafts: composer::Drafts::new(), - pictures: pictures::Pictures::default(), - highlighting: false, - cell_size: DEFAULT_CELL_SIZE, + hover_waiting: None, } } - /// The terminal's size in cells, as (width, height), as last told by [`Msg::Resize`]. + /// `dn` and the editor around it, on the `connect` buffer saying where connecting stands. #[must_use] - pub fn size(&self) -> (u16, u16) { - self.size - } - - /// Takes a cell to be `width` × `height` pixels, as the terminal says, for pictures shown at their own size from - /// now on; a size of 0 is ignored. - pub fn set_cell_size(&mut self, width: u16, height: u16) { - if width > 0 && height > 0 { - self.cell_size = (width, height); - } + pub fn session() -> (Self, Kit) { + let doc = Connection::Checking.document().unwrap_or_default(); + let mut dn = Self::new(); + let kit = Self::kit("connect", doc, false); + dn.status = kit.find_buffer("connect"); + (dn, kit) } - /// Whether the terminal has focus, which slows polling down when it doesn't. + /// The editor `dn` runs in, on one buffer called `name` showing `doc`, listed or not: with `dn`'s groups, + /// keys, commands and statusline, and its bufferline always shown. #[must_use] - pub fn is_focused(&self) -> bool { - self.focused - } - - /// Reacts to `msg`, returning what the runtime must do. - pub fn update(&mut self, msg: Msg) -> Vec { - let mut effects = self.react(msg); - if !effects.contains(&Effect::Quit) { - effects.extend(self.pictures_in_view()); - } - effects - } - - fn react(&mut self, msg: Msg) -> Vec { - match msg { - Msg::Key(key) => { - self.generation += 1; - self.which_key = false; - let mut effects = self.key(key); - effects.extend(self.wake_ups()); - effects.extend(self.load_more()); - effects - } - Msg::Wake(token) if token.generation != self.generation_of(token.purpose) => Vec::new(), - Msg::Wake(Wake { purpose, .. }) => match purpose { - WakePurpose::KeyTimeout => { - self.which_key = false; - let ran = self.keymap.timeout(self.keymap_mode()); - self.run_all(ran) - } - WakePurpose::WhichKey => { - self.which_key = !self.keymap.pending_keys().is_empty(); - Vec::new() - } - WakePurpose::Poll => self.poll(), - WakePurpose::Search => self.search_people(), - WakePurpose::Frame => { - self.next_frames(); - Vec::new() - } - }, - Msg::Paste(text) => match &mut self.mode { - AppMode::Cmdline(cmdline) => { - cmdline.paste(&text); - Vec::new() - } - AppMode::Picker(_) => self.picker_paste(&text), - _ => Vec::new(), - }, - Msg::Sourced { path, contents } => self.source(&path, contents), - Msg::Edited(saved) => self.edited(saved), - Msg::Picture { - source, - cols, - rows, - result, - } => self.picture_arrived(source, cols, rows, result), - Msg::Net(event) => self.net(event), - Msg::Fetched { fetched, now } => self.fetched(fetched, now), - Msg::FocusGained => { - self.focused = true; - Vec::new() - } - Msg::FocusLost => { - self.focused = false; - Vec::new() - } - Msg::Resize { width, height } => { - self.size = (width, height); - Vec::new() - } - } - } - - fn key(&mut self, key: Key) -> Vec { - if let AppMode::Cmdline(cmdline) = &self.mode - && cmdline.purpose == Purpose::Ex - && key.code == Code::Tab - { - self.complete_command_line(!key.mods.contains(Mods::SHIFT)); - return Vec::new(); - } - match &mut self.mode { - AppMode::Normal | AppMode::Visual(_) => { - self.message = None; - // As Neovim's hover window, the card goes with the next key, which also does its usual job. - self.hover = None; - let ran = self.keymap.feed(self.keymap_mode(), key); - self.run_all(ran) - } - AppMode::MorePrompt { .. } => { - self.mode = AppMode::Normal; - // As in Vim, , and only dismiss; any other key also does its usual job. - let dismiss_only = [ - Key::new(crate::key::Code::Enter), - Key::char(' '), - Key::new(crate::key::Code::Esc), - ]; - if dismiss_only.contains(&key) { - Vec::new() - } else { - self.key(key) - } - } - AppMode::Picker(_) => self.picker_key(key), - AppMode::Confirm(_) => self.confirm_key(key), - AppMode::Cmdline(cmdline) => { - let history: &[String] = match cmdline.purpose { - Purpose::Ex => &self.ex_history, - Purpose::Search(_) => &self.search_history, - Purpose::Passphrase => &[], - }; - match cmdline.key(key, history) { - Edit::Editing => Vec::new(), - Edit::Cancel => { - self.mode = AppMode::Normal; - Vec::new() - } - Edit::Submit(text) => { - let AppMode::Cmdline(cmdline) = - std::mem::replace(&mut self.mode, AppMode::Normal) - else { - unreachable!("the mode was just matched as the command line"); - }; - match cmdline.purpose { - Purpose::Ex => { - remember(&mut self.ex_history, &text); - self.ex(&text) - } - Purpose::Search(direction) => { - remember(&mut self.search_history, &text); - self.search(&text, direction, cmdline.count.unwrap_or(1)); - Vec::new() - } - Purpose::Passphrase => vec![Effect::Unlock(text)], - } - } - } - } - } - } - - fn run_all(&mut self, ran: Vec) -> Vec { - ran.into_iter() - .flat_map(|invocation| self.run(invocation)) - .collect() - } - - /// The wake-ups pending keys need: the which-key popup, and the timeout of an ambiguous sequence. - fn wake_ups(&self) -> Vec { - let in_keymap_mode = matches!(self.mode, AppMode::Normal | AppMode::Visual(_)); - if !in_keymap_mode || self.keymap.pending_keys().is_empty() { - return Vec::new(); - } - let wake = |purpose| Wake { - purpose, - generation: self.generation, - }; - let mut wakes = vec![Effect::Wake { - after: WHICH_KEY_DELAY, - token: wake(WakePurpose::WhichKey), - }]; - if self.keymap.is_ambiguous() { - wakes.push(Effect::Wake { - after: Duration::from_millis(self.options.timeoutlen), - token: wake(WakePurpose::KeyTimeout), - }); - } - wakes - } - - /// The current generation of wake-ups for `purpose`: tokens of an older one are stale. - fn generation_of(&self, purpose: WakePurpose) -> u64 { - match purpose { - WakePurpose::KeyTimeout | WakePurpose::WhichKey => self.generation, - WakePurpose::Poll => self.poll_generation, - WakePurpose::Search => self.search_generation, - WakePurpose::Frame => self.pictures.frame_generation(), - } - } - - /// Shows buffer `index` in the current window, where the window last left it, and loads what it shows unless - /// it has it or is getting it. - fn show_buffer(&mut self, index: usize) -> Vec { - self.show_buffer_at(index, Cursor::new()) - } - - /// As [`App::show_buffer`], starting at `start` when the window never showed the buffer. Showing another buffer - /// is a jump, as starting to edit another file is in Vim, unless it is from a status page such as `connect`. - fn show_buffer_at(&mut self, index: usize, start: Cursor) -> Vec { - let area = self.window_area(); - let window = self.windows.current_mut(); - let shown = &self.buffers[window.buffer]; - if index != window.buffer && shown.is_listed() { - let layout = window.layout(&self.buffers, area); - window.record_jump(&layout, window.cursor.clone()); - } - window.switch_to(index, start); - self.visited(index); - self.load(index) - } - - /// Notes buffer `index` as the last shown. - fn visited(&mut self, index: usize) { - self.recent.retain(|&recent| recent != index); - self.recent.insert(0, index); - } - - /// After the current window jumped from buffer `before`: keeps its cursor in view, and loads the buffer it - /// jumped to, if it is another, unless it has what it shows or is getting it. - fn jumped(&mut self, before: usize) -> Vec { - self.follow_cursor(); - let now = self.windows.current().buffer; - if now == before { - Vec::new() - } else { - self.visited(now); - self.load(now) - } - } - - /// `:pop` (``): back to where the window was when `:open` or `:profile` last left for another buffer. - fn pop_tag(&mut self) -> Result, String> { - let area = self.window_area(); - let window = self.windows.current_mut(); - let before = window.buffer; - let layout = window.layout(&self.buffers, area); - if !window.pop_tag(&layout) { - return Err("E73: Tag stack empty".to_owned()); - } - Ok(self.jumped(before)) - } - - /// The listed buffers' indices, in order: what `:ls`, `:b` and the bufferline count. - fn listed(&self) -> Vec { - (0..self.buffers.len()) - .filter(|&index| self.buffers[index].is_listed()) - .collect() - } - - /// `:jumps`: the jumplist as Vim lists it: how far each entry is from where `` and `` last went, its - /// row and column, and the text there, or the name of its buffer when it is another; `>` marks where one is. - fn list_jumps(&mut self) { - let area = self.window_area(); - let window = self.windows.current(); - let (jumps, at) = window.jumplist(); - let mut lines = vec![" jump line col file/text".to_owned()]; - for (i, jump) in jumps.iter().enumerate() { - let layout = window.layout_of(&self.buffers, jump.buffer, area); - let shown = jump.cursor.screen(&layout); - let text = if jump.buffer == window.buffer { - layout.segments(shown.row).map(|(text, _)| text).collect() - } else { - self.buffers[jump.buffer].name.clone() - }; - lines.push(format!( - "{} {:2} {:5} {:4} {text}", - if i == at { '>' } else { ' ' }, - i.abs_diff(at), - shown.row + 1, - shown.col, - )); - } - if at == jumps.len() { - lines.push(">".to_owned()); - } - let lines = lines - .into_iter() - .map(|text| Message { - text, - kind: MessageKind::Info, - }) - .collect(); - self.mode = AppMode::MorePrompt { lines }; - } - - /// The search being typed after `/` or `?`, with `'incsearch'`: its pattern, direction and count. `None` while - /// it is empty or not a valid pattern yet. - fn typed_search(&self) -> Option<(Pattern, Direction, usize)> { - let AppMode::Cmdline(cmdline) = &self.mode else { - return None; - }; - let Purpose::Search(direction) = cmdline.purpose else { - return None; - }; - let text = cmdline.text(); - if !self.options.incsearch || text.is_empty() { - return None; - } - let pattern = Pattern::new(&text, self.options.case_mode()).ok()?; - Some((pattern, direction, cmdline.count.unwrap_or(1))) - } - - /// The last search's pattern, while its matches are shown: with `'hlsearch'`, and not since `:noh`. - fn searched(&self) -> Option<&Pattern> { - self.last_search - .as_ref() - .filter(|_| self.highlighting) - .map(|search| &search.pattern) - } - - /// The alternate buffer, as Vim calls it: the listed buffer shown last before the current one. - fn alternate(&self) -> Option { - let current = self.windows.current().buffer; - self.recent - .iter() - .copied() - .find(|&index| index != current && self.buffers[index].is_listed()) - } - - /// `:ls`: the listed buffers, the current one marked `%a` and the alternate one `#`. - fn list_buffers(&mut self) { - let current = self.windows.current().buffer; - let alternate = self.alternate(); - let lines = self - .listed() - .into_iter() - .enumerate() - .map(|(number, index)| Message { - text: format!( - "{:>3} {:2} \"{}\"", - number + 1, - if index == current { - "%a" - } else if Some(index) == alternate { - "#" - } else { - "" - }, - self.buffers[index].name - ), - kind: MessageKind::Info, - }) - .collect(); - self.mode = AppMode::MorePrompt { lines }; - } - - /// `:b {arg}`: the alternate buffer for `#`, else the listed buffer numbered `arg`, else named exactly `arg`, else the only one whose name contains - /// it. - fn buffer_named(&self, arg: &str) -> Result { - if arg == "#" { - return self - .alternate() - .ok_or_else(|| "E23: No alternate file".to_owned()); - } - let listed = self.listed(); - if let Ok(number) = arg.parse::() { - return listed - .get(number.wrapping_sub(1)) - .copied() - .ok_or_else(|| format!("E86: Buffer {number} does not exist")); - } - if let Some(&index) = listed - .iter() - .find(|&&index| self.buffers[index].name == arg) - { - return Ok(index); - } - let matching: Vec = listed - .into_iter() - .filter(|&index| self.buffers[index].name.contains(arg)) - .collect(); - match matching.as_slice() { - [index] => Ok(*index), - [] => Err(format!("E94: No matching buffer for {arg}")), - _ => Err(format!("E93: More than one match for {arg}")), - } - } - - /// `:bn` and `:bp`: the next or previous listed buffer, wrapping around. - fn cycle_buffer(&mut self, forward: bool) -> Vec { - let listed = self.listed(); - if listed.is_empty() { - return Vec::new(); - } - let current = self.windows.current().buffer; - let at = listed + pub fn kit(name: &str, doc: Document, listed: bool) -> Kit { + let mut kit = Kit::new(name, doc, listed); + *kit.theme_mut() = crate::hl::theme(); + *kit.keymap_mut() = crate::keymap::keymap(); + commands::add(&mut kit); + kit.add_segment(wcgw_kit::shell::Segment::new( + wcgw_kit::shell::Side::Right, + 0, + |dn: &Dn, _| dn.account_segment(), + )); + let mut dn = Self::new(); + // dn has always left the mouse to the terminal (its D1); its buffers at the top are the kit's default now. + let _ = kit.run_ex(&mut dn, "set mouse="); + // A feed is prose: laid out up to the kit's 'textwidth' (120), as every app's. + let first = kit.current_buffer(); + kit.set_prose(first, true); + kit + } + + /// The buffer showing `source`, if there is one. + fn feed_buffer(&self, source: &Source) -> Option { + self.feeds .iter() - .position(|&index| index == current) - .unwrap_or(0); - let next = if forward { - (at + 1) % listed.len() - } else { - (at + listed.len() - 1) % listed.len() - }; - self.show_buffer(listed[next]) + .find(|(_, feed)| feed.source == *source) + .map(|(&id, _)| id) } - /// The keymap table for the current mode. - fn keymap_mode(&self) -> Mode { - match self.mode { - AppMode::Visual(_) => Mode::Visual, - _ => Mode::Normal, - } - } - - fn run(&mut self, invocation: Invocation) -> Vec { - match invocation.action { - Action::Visual(kind) => { - self.visual(kind); - return Vec::new(); - } - Action::Yank => return self.yank(), - Action::Escape => { - self.mode = AppMode::Normal; - return Vec::new(); + fn net(&mut self, kit: &mut Kit, event: NetEvent) -> Effects { + let mut effects = Vec::new(); + self.connection = match event { + NetEvent::Error(text) => { + kit.error(text); + return effects; } - Action::Fold(op) => { - let window = self.windows.current_mut(); - let doc = &self.buffers[window.buffer].doc; - window.folds.fold(doc, &window.cursor, op); + NetEvent::Connected(me) if !me.terms_accepted => Connection::Terms(me), + NetEvent::Connected(me) => { + self.unread = me.unread_notifications; + Connection::Connected(me) } - Action::FoldAll { open } => { - let window = self.windows.current_mut(); - window - .folds - .fold_all(&self.buffers[window.buffer].doc, open); + NetEvent::NoKey => Connection::NoKey, + NetEvent::NotApproved => Connection::NotApproved, + NetEvent::Failed(reason) => Connection::Failed(reason), + NetEvent::NeedsPassphrase { wrong } => { + kit.prompt("Passphrase: ", true, |_, _, passphrase| { + vec![Effect::App(Io::Unlock(passphrase))] + }); + Connection::NeedsPassphrase { wrong } } - _ => return self.run_in_window(invocation), - } - self.follow_cursor(); - Vec::new() - } - - /// `v` and `V`: start Visual mode, switch its kind, or leave it when it already is that kind. - fn visual(&mut self, kind: SelectionKind) { - self.mode = match std::mem::replace(&mut self.mode, AppMode::Normal) { - AppMode::Visual(selection) if selection.kind() == kind => AppMode::Normal, - AppMode::Visual(selection) => AppMode::Visual(selection.with_kind(kind)), - _ => { - let area = self.window_area(); - let layout = self.windows.current().layout(&self.buffers, area); - AppMode::Visual(Selection::start( - &self.windows.current().cursor, - &layout, - kind, - )) + NetEvent::Enrolling { url, fingerprint } => { + effects.push(Effect::OpenUrl(url.clone())); + Connection::Enrolling { url, fingerprint } } }; + effects.extend(self.show_connection(kit)); + effects } - /// `y` in Visual mode and `yy` in Normal mode: copies the text and, as Vim does, leaves the cursor at its start. - fn yank(&mut self) -> Vec { - let area = self.window_area(); - let layout = self.windows.current().layout(&self.buffers, area); - let cursor = &mut self.windows.current_mut().cursor; - let selection = match std::mem::replace(&mut self.mode, AppMode::Normal) { - AppMode::Visual(selection) => selection, - _ => Selection::start(cursor, &layout, SelectionKind::Rowwise), - }; - let text = selection.text(cursor, &layout); - *cursor = selection.start_cursor(cursor, &layout); - let lines = text.trim_end_matches('\n').matches('\n').count() + 1; - // Vim reports yanks of more lines than 'report', which is 2 by default. - if lines > 2 { - self.info(format!("{lines} lines yanked")); - } - self.follow_cursor(); - vec![Effect::Copy(text)] - } - - /// Keeps the cursor of the current window in view. - fn follow_cursor(&mut self) { - let viewport = self.viewport(); - let area = self.window_area(); - let layout = self.windows.current().layout(&self.buffers, area); - self.windows.current_mut().follow_cursor(&layout, viewport); - } - - /// Opens the folds hiding the cursor, as Vim's `'foldopen'` does after a search. - fn open_fold_at_cursor(&mut self) { - let window = self.windows.current_mut(); - let doc = &self.buffers[window.buffer].doc; - window.folds.reveal(doc, &window.cursor); - self.follow_cursor(); - } - - fn run_in_window(&mut self, invocation: Invocation) -> Vec { - let count = invocation.count.unwrap_or(1); - let viewport = self.viewport(); - let area = self.window_area(); - let window = self.windows.current_mut(); - let layout = window.layout(&self.buffers, area); - // Searches and these motions are jumps: where they start goes into the jumplist, as in Vim. - let from = window.cursor.clone(); - match invocation.action { - Action::Motion(motion) => { - window.cursor.apply(&layout, motion, count); - if is_jump(motion) { - window.record_jump(&layout, from); - } - window.follow_cursor(&layout, viewport); - } - Action::JumpOlder => { - let before = window.buffer; - window.jump_older(&layout, count); - return self.jumped(before); - } - Action::JumpNewer => { - let before = window.buffer; - window.jump_newer(count); - return self.jumped(before); - } - Action::Scroll(scroll) => window.scroll(&layout, viewport, scroll, count), - Action::Place(place) => window.place(&layout, viewport, place), - Action::ViewRow(place) => { - window.view_row(&layout, viewport, place, count); - window.record_jump(&layout, from); - } - Action::SearchWord(direction) => { - let case = self.options.case_mode(); - if let Some((pattern, outcome)) = - window.cursor.search_word(&layout, direction, count, case) - { - window.record_jump(&layout, from); - self.last_search = Some(LastSearch { pattern, direction }); - self.highlighting = true; - self.open_fold_at_cursor(); - self.report_search(outcome, direction, ""); + /// Shows the `connect` buffer while not connected, and the following log once connected, asking for it if it + /// was never loaded. + fn show_connection(&mut self, kit: &mut Kit) -> Effects { + let (id, effects) = match self.connection.document() { + Some(doc) => { + let id = if let Some(id) = self.status { + id } else { - self.error("E348: No string under cursor"); - } - } - Action::SearchNext { reverse } => match self.last_search.clone() { - Some(LastSearch { pattern, direction }) => { - let direction = if reverse { - opposite(direction) - } else { - direction - }; - let outcome = window.cursor.search(&layout, &pattern, direction, count); - if outcome != SearchOutcome::NotFound { - window.record_jump(&layout, from); - } - // A search again: its matches show again, after `:noh`. - self.highlighting = true; - self.open_fold_at_cursor(); - self.report_search(outcome, direction, ""); - } - None => self.error("E35: No previous regular expression"), - }, - Action::Cmdline(kind) => { - self.mode = AppMode::Cmdline(Cmdline::new(kind.into(), invocation.count)) - } - Action::Keys { keys, remap } => { - return keys - .into_iter() - .flat_map(|key| self.replay(key, remap)) - .collect(); - } - Action::Ex(line) => return self.ex(&line), - Action::Error(text) => self.error(text), - _ => {} - } - Vec::new() - } - - /// Feeds one key of an [`Action::Keys`] to whatever mode is current now. - fn replay(&mut self, key: Key, remap: bool) -> Vec { - match self.mode { - AppMode::Normal | AppMode::Visual(_) => { - let ran = self.keymap.replay(self.keymap_mode(), &[key], remap); - self.run_all(ran) - } - AppMode::Cmdline(_) - | AppMode::MorePrompt { .. } - | AppMode::Picker(_) - | AppMode::Confirm(_) => self.key(key), - } - } - - /// Runs an ex command line, reporting its error in the message area. - fn ex(&mut self, line: &str) -> Vec { - match self.run_line(line) { - Ok(effects) => effects, - Err(error) => { - self.error(error); - Vec::new() + let id = kit.add_buffer("connect", Document::new(), false); + self.status = Some(id); + id + }; + *kit.buffer_mut(id).doc_mut() = doc; + (id, Vec::new()) } - } - } - - /// Parses and runs one command line; blank lines and comments do nothing. - fn run_line(&mut self, line: &str) -> Result, String> { - match ex::parse(line) { - Ok(Some(command)) => self.execute(command), - Ok(None) => Ok(Vec::new()), - Err(error) => Err(error.to_string()), - } + None => ( + self.feed_buffer_or_new(kit, Source::Log(devnull_api::Log::Following)), + self.start_polling(kit), + ), + }; + kit.show_buffer(id); + effects } - fn execute(&mut self, command: Command) -> Result, String> { - match command { - Command::Quit { all: false, .. } if self.windows.count() > 1 => { - self.windows.close(); - } - Command::Quit { .. } => { - let mut effects = self.free_pictures(); - effects.push(Effect::Quit); - return Ok(effects); - } - Command::Split { vertical } => { - self.windows - .split(if vertical { Axis::Columns } else { Axis::Rows }) - } - Command::Close => self.close_window()?, - Command::Only => self.windows.only(), - Command::Wincmd(ch) => return self.wincmd(ch), - Command::Set(args) => { - let graphics = self.options.graphics; - for arg in &args { - if let Some(shown) = self.options.set(arg, &mut self.keymap)? { - self.info(shown); - } - } - if self.options.graphics != graphics { - // Drawn otherwise now: what was sent goes, and pictures are asked for again. - return Ok(self.pictures.clear()); - } - } - Command::Map { - modes, - lhs, - rhs, - remap, - } => { - for mode in modes { - self.keymap.map(mode, &lhs, &rhs, remap); - } - } - Command::Unmap { modes, lhs } => { - let mut removed = false; - for mode in modes { - removed |= self.keymap.unmap(mode, &lhs); - } - if !removed { - return Err("E31: No such mapping".to_owned()); - } - } - // Like Vim, nothing to show means no prompt either. - Command::Help(keys) => self.help(keys.as_deref())?, - Command::Messages if self.messages.is_empty() => {} - Command::Messages => { - self.mode = AppMode::MorePrompt { - lines: self.messages.clone(), - }; - } - Command::Highlight { group, attrs } => { - if let Some(shown) = self.theme.highlight(&group, &attrs)? { - self.info(shown); - } - } - Command::HighlightClear => self.theme.clear(), - Command::HighlightLink { from, to } => self.theme.link(&from, to.as_deref())?, - Command::Colorscheme(None) => self.info(self.theme.name().to_owned()), - Command::Colorscheme(Some(name)) => self.theme = Theme::named(&name)?, - Command::Source(path) => return Ok(vec![Effect::Source(path)]), - Command::Connect(link) => return self.connect(link), - Command::Edit(None) => { - return self - .refresh(self.windows.current().buffer) - .ok_or_else(|| "Nothing to reload here".to_owned()); - } - Command::Buffers => self.list_buffers(), - Command::Buffer(arg) => { - let index = self.buffer_named(&arg)?; - return Ok(self.show_buffer(index)); - } - Command::NextBuffer { forward } => return Ok(self.cycle_buffer(forward)), - Command::Edit(Some(name)) => return self.edit(&name), - Command::Open => return self.following(Self::open), - Command::Profile(handle) => return self.following(|app| app.profile(handle)), - Command::Pop => return self.pop_tag(), - Command::Jumps => self.list_jumps(), - Command::Nohlsearch => self.highlighting = false, - Command::Pick { list, query } => return Ok(self.pick(list, &query)), - Command::Star => return self.star(), - Command::Commit { author } => return self.compose(Answering::Nothing, author), - Command::Reply { author } => { - return self.compose(Answering::Reply(String::new()), author); + /// `:connect`, whose meaning depends on where connecting stands. + fn connect(&mut self, kit: &mut Kit, link: Option) -> Result { + let io = |io| Ok(vec![Effect::App(io)]); + match (&self.connection, link) { + (Connection::Enrolling { .. }, Some(link)) => io(Io::EnrollPasted(link)), + (_, Some(_)) => Err("No approval in progress: :connect first".to_owned()), + (Connection::NoKey | Connection::NotApproved | Connection::Enrolling { .. }, None) => { + io(Io::Enroll) } - Command::Quote { author } => { - return self.compose(Answering::Quote(String::new()), author); + (Connection::Connected(me), None) => { + kit.info(format!("Connected as @{}", me.handle)); + Ok(Vec::new()) } - Command::Delete => return self.delete(), - Command::Follow { handle, on } => return self.follow(handle, on), - Command::Browse { link } => return self.browse(link), - Command::YankLink => return self.yank_link(), - Command::Hover => return self.hover(), - Command::Agree => match &self.connection { - Connection::Terms(me) => { - return Ok(vec![Effect::AcceptTerms( - me.terms_version.clone().unwrap_or_default(), - )]); - } - _ => return Err("No terms to agree to".to_owned()), - }, + (_, None) => io(Io::Connect), } - Ok(Vec::new()) - } - - /// What the text under the current window's cursor refers to, if anything. - fn target_under_cursor(&self) -> Option { - let window = self.windows.current(); - let layout = window.layout(&self.buffers, self.window_area()); - window.cursor.target(&layout).cloned() } - /// Runs `follow` (`:open`, `:profile`), noting where the window was on the tag stack if it shows another buffer - /// after. - fn following( - &mut self, - follow: impl FnOnce(&mut Self) -> Result, String>, - ) -> Result, String> { - let from = self.windows.current().here(); - let outcome = follow(self); - self.windows.current_mut().push_tag(from); - outcome - } - - /// `:open`: what the text under the cursor refers to: a link opens in the browser, a commit's thread, a profile - /// or another buffer in the window. - fn open(&mut self) -> Result, String> { - match self.target_under_cursor() { - Some(Target::Url(url) | Target::LinkCard(LinkCard { url, .. })) => { - Ok(vec![Effect::OpenUrl(url)]) - } - Some(Target::Commit { id, .. }) => Ok(self.open_thread(&id)), - Some(Target::Account(account)) => Ok(self.open_profile(account)), - Some(Target::Buffer(name)) => self.edit(&name), - None => Ok(Vec::new()), + /// `:agree`: accepts dev-null.sh's current terms. + fn agree(&self) -> Result { + match &self.connection { + Connection::Terms(me) => Ok(vec![Effect::App(Io::AcceptTerms( + me.terms_version.clone().unwrap_or_default(), + ))]), + _ => Err("No terms to agree to".to_owned()), } } /// The web page of what the cursor is on: of the commit (a quote's, else the one holding the cursor), else of /// the account. - fn web_page(&self) -> Option { - let window = self.windows.current(); - let layout = window.layout(&self.buffers, self.window_area()); - let targets = [ - window.cursor.target(&layout), - window.cursor.block_target(&layout), - ]; + fn web_page(kit: &Kit) -> Option { + let targets = kit.targets_under_cursor(); let commit = targets.iter().flatten().find_map(|target| match target { Target::Commit { url: Some(url), .. } => Some(url.clone()), _ => None, @@ -1127,312 +261,131 @@ impl App { }) } - /// `:browse`: the commit or account under the cursor on the web; `:browse link`, the link under it. - fn browse(&mut self, link: bool) -> Result, String> { + /// The link under the cursor, a link card's too: what `:browse link` opens. + fn link_under_cursor(kit: &Kit) -> Option { + match kit.target_under_cursor() { + Some(Target::Url(url) | Target::LinkCard(LinkCard { url, .. })) => Some(url), + _ => None, + } + } + + /// `:browse`: the commit or account under the cursor on the web; `:browse link`, the link under it, else one of + /// what holds it: the only one, or as picked (`` yanks one), as muttrs' and slackers' do. + fn browse(&mut self, kit: &mut Kit, link: bool) -> Result { let url = if link { - match self.target_under_cursor() { - Some(Target::Url(url) | Target::LinkCard(LinkCard { url, .. })) => url, - _ => return Err("No link under the cursor".to_owned()), + match Self::link_under_cursor(kit) { + Some(url) => url, + None => { + let mut urls: Vec = Vec::new(); + for target in kit.targets_in_block_under_cursor() { + if let Target::Url(url) | Target::LinkCard(LinkCard { url, .. }) = target + && !urls.contains(url) + { + urls.push(url.clone()); + } + } + match <[String; 1]>::try_from(urls) { + Ok([only]) => only, + Err(urls) if urls.is_empty() => { + return Err("No link under the cursor, nor in what holds it".to_owned()); + } + Err(urls) => return Ok(kit.open_picker(self, picker::links(urls))), + } + } } } else { - self.web_page() - .ok_or_else(|| "No commit or account under the cursor".to_owned())? + Self::web_page(kit).ok_or_else(|| "No commit or account under the cursor".to_owned())? }; Ok(vec![Effect::OpenUrl(url)]) } - /// `:yanklink`: the link `:browse` opens, to the clipboard. - fn yank_link(&mut self) -> Result, String> { - let url = self - .web_page() - .ok_or_else(|| "No commit or account under the cursor".to_owned())?; - self.info(format!("Yanked {url}")); + /// `:yanklink`: the link under the cursor, else the one `:browse` opens, to the clipboard, as in every app. + fn yank_link(kit: &mut Kit) -> Result { + let url = Self::link_under_cursor(kit) + .or_else(|| Self::web_page(kit)) + .ok_or_else(|| "No link, commit or account under the cursor".to_owned())?; + kit.info(format!("Yanked {url}")); Ok(vec![Effect::Copy(url)]) } - /// `:e {name}`: shows the buffer of that name, or else the feed the name names (see [`Source::named`]), or - /// with `profile`, the connected account's profile. - fn edit(&mut self, name: &str) -> Result, String> { - if let Some(index) = self.buffers.iter().position(|buffer| buffer.name == name) { - return Ok(self.show_buffer(index)); - } - let source = if name == "profile" { - self.own_profile() - } else { - Source::named(name) - }; - match source { - Some(source) => Ok(self.open_feed(source, Cursor::new())), - None => Err(format!("E94: No matching buffer for {name}")), - } - } - - /// Runs the lines of a sourced file, carrying on after errors and then listing them as Vim does. - fn source(&mut self, path: &str, contents: Result) -> Vec { - let Ok(contents) = contents else { - self.error(format!("E484: Can't open file {path}")); - return Vec::new(); - }; - let mut effects = Vec::new(); - let mut errors = Vec::new(); - for (number, line) in (1..).zip(contents.lines()) { - match self.run_line(line) { - Ok(more) => effects.extend(more), - Err(error) => errors.push((number, error)), - } - } - if !errors.is_empty() { - let from = self.messages.len(); - self.error(format!("Error detected while processing {path}:")); - for (number, error) in errors { - self.info(format!("line {number}:")); - self.error(error); - } - self.mode = AppMode::MorePrompt { - lines: self.messages[from..].to_vec(), - }; - } - effects + /// Notifications unread, as the server last counted them, and the account connected, for the statusline. + fn account_segment(&self) -> Option { + let unread = (self.unread > 0).then(|| format!("󰂚 {}", self.unread)); + let handle = self.connection.handle().map(|handle| format!("@{handle}")); + let parts: Vec = unread.into_iter().chain(handle).collect(); + (!parts.is_empty()).then(|| parts.join(" ")) } +} - fn close_window(&mut self) -> Result<(), String> { - if self.windows.close() { - Ok(()) - } else { - Err("E444: Cannot close last window".to_owned()) - } - } +impl wcgw_kit::App for Dn { + type Target = Target; + type Msg = Msg; + type Effect = Io; - /// `:wincmd {ch}`, which `{ch}` runs. - fn wincmd(&mut self, ch: char) -> Result, String> { - let area = self.windows_area(); - match ch { - 'h' => self.windows.focus(area, Toward::Left), - 'j' => self.windows.focus(area, Toward::Down), - 'k' => self.windows.focus(area, Toward::Up), - 'l' => self.windows.focus(area, Toward::Right), - 'w' => self.windows.cycle(true), - 'W' => self.windows.cycle(false), - 's' => self.windows.split(Axis::Rows), - 'v' => self.windows.split(Axis::Columns), - 'c' => self.close_window()?, - 'o' => self.windows.only(), - 'q' => return self.run_line("quit"), - _ => {} + fn update(&mut self, kit: &mut Kit, msg: Msg) -> Effects { + match msg { + Msg::Net(event) => self.net(kit, event), + Msg::Fetched { fetched, now } => self.fetched(kit, fetched, now), + Msg::Poll { generation } if generation == self.poll_generation => self.poll(kit), + Msg::Poll { .. } => Vec::new(), } - Ok(Vec::new()) } - /// `/` and `?` submitted with `text`; an empty pattern repeats the last one. - fn search(&mut self, text: &str, direction: Direction, count: usize) { - let pattern = if text.is_empty() { - match &self.last_search { - Some(last) => last.pattern.clone(), - None => return self.error("E35: No previous regular expression"), - } - } else { - match Pattern::new(text, self.options.case_mode()) { - Ok(pattern) => pattern, - Err(error) => return self.error(error.to_string()), - } - }; - let area = self.window_area(); - let window = self.windows.current_mut(); - let layout = window.layout(&self.buffers, area); - let from = window.cursor.clone(); - let outcome = window.cursor.search(&layout, &pattern, direction, count); - if outcome != SearchOutcome::NotFound { - window.record_jump(&layout, from); - } - self.last_search = Some(LastSearch { pattern, direction }); - self.highlighting = true; - self.open_fold_at_cursor(); - self.report_search(outcome, direction, text); + fn start(&mut self, _kit: &mut Kit) -> Effects { + vec![Effect::App(Io::Connect)] } - fn report_search(&mut self, outcome: SearchOutcome, direction: Direction, text: &str) { - match (outcome, direction) { - (SearchOutcome::Found, _) => {} - (SearchOutcome::Wrapped, Direction::Forward) => { - self.warn("search hit BOTTOM, continuing at TOP") - } - (SearchOutcome::Wrapped, Direction::Backward) => { - self.warn("search hit TOP, continuing at BOTTOM") + /// What the text under the cursor refers to: a link opens in the browser, a commit's thread, a profile or + /// another buffer in the window. + fn open(&mut self, kit: &mut Kit, target: Target) -> Result { + match target { + Target::Url(url) | Target::LinkCard(LinkCard { url, .. }) => { + Ok(vec![Effect::OpenUrl(url)]) } - (SearchOutcome::NotFound, _) => self.error(format!("E486: Pattern not found: {text}")), + Target::Commit { id, .. } => Ok(self.open_thread(kit, &id)), + Target::Account(account) => Ok(self.open_profile(kit, account)), + Target::Buffer(name) => self.edit(kit, &name), } } - fn info(&mut self, text: impl Into) { - self.say(text.into(), MessageKind::Info); - } - - fn warn(&mut self, text: impl Into) { - self.say(text.into(), MessageKind::Warning); - } - - fn error(&mut self, text: impl Into) { - self.say(text.into(), MessageKind::Error); - } - - fn say(&mut self, text: String, kind: MessageKind) { - let message = Message { text, kind }; - self.messages.push(message.clone()); - self.message = Some(message); - } - - /// Where windows go: between the bufferline and the command line. - fn windows_area(&self) -> Rect { - let (width, height) = self.size; - Rect::new(0, 1, width, height.saturating_sub(2)) - } - - /// Where the current window's text goes: its share of the windows area, above its statusline. - fn window_area(&self) -> Rect { - let current = self.windows.current_id(); - let arrangement = self.windows.arrange(self.windows_area()); - let rect = arrangement - .windows - .into_iter() - .find(|(id, _)| *id == current) - .map(|(_, rect)| rect); - text_area(rect.unwrap_or_default()) - } - - fn viewport(&self) -> Viewport { - Viewport { - height: usize::from(self.window_area().height), - scrolloff: self.options.scrolloff, + /// `:e {name}`: the feed the name names (see [`Source::named`]), or with `profile`, the connected account's + /// profile. + fn edit(&mut self, kit: &mut Kit, name: &str) -> Result { + if let Some(id) = kit.find_buffer(name) { + kit.show_buffer(id); + return Ok(Vec::new()); } - } - - /// The effects to start with, once the runtime is ready: connecting. - pub fn start(&mut self) -> Vec { - vec![Effect::Connect] - } - - fn net(&mut self, event: NetEvent) -> Vec { - let mut effects = Vec::new(); - self.connection = match event { - NetEvent::Error(text) => { - self.error(text); - return effects; - } - NetEvent::Connected(me) if !me.terms_accepted => Connection::Terms(me), - NetEvent::Connected(me) => { - self.unread = me.unread_notifications; - Connection::Connected(me) - } - NetEvent::NoKey => Connection::NoKey, - NetEvent::NotApproved => Connection::NotApproved, - NetEvent::Failed(reason) => Connection::Failed(reason), - NetEvent::NeedsPassphrase { wrong } => { - self.mode = AppMode::Cmdline(Cmdline::new(Purpose::Passphrase, None)); - Connection::NeedsPassphrase { wrong } - } - NetEvent::Enrolling { url, fingerprint } => { - effects.push(Effect::OpenUrl(url.clone())); - Connection::Enrolling { url, fingerprint } - } - }; - effects.extend(self.show_connection()); - effects - } - - /// Shows the `connect` buffer while not connected, and the following log once connected, asking for it if it - /// was never loaded. - fn show_connection(&mut self) -> Vec { - let (index, mut effects) = match self.connection.document() { - Some(doc) => { - let index = match self - .buffers - .iter() - .position(|buffer| buffer.name == "connect") - { - Some(index) => index, - None => { - self.buffers.push(Buffer { - name: "connect".to_owned(), - doc: Document::new(), - kind: Kind::Status, - }); - self.buffers.len() - 1 - } - }; - self.buffers[index].doc = doc; - (index, Vec::new()) - } - None => ( - self.feed_buffer_or_new(Source::Log(devnull_api::Log::Following)), - self.start_polling(), - ), + let source = if name == "profile" { + self.own_profile() + } else { + Source::named(name) }; - effects.extend(self.show_buffer(index)); - effects - } - - /// `:connect`, whose meaning depends on where connecting stands. - fn connect(&mut self, link: Option) -> Result, String> { - Ok(match (&self.connection, link) { - (Connection::Enrolling { .. }, Some(link)) => vec![Effect::EnrollPasted(link)], - (_, Some(_)) => return Err("No approval in progress: :connect first".to_owned()), - (Connection::NoKey | Connection::NotApproved | Connection::Enrolling { .. }, None) => { - vec![Effect::Enroll] - } - (Connection::Connected(me), None) => { - self.info(format!("Connected as @{}", me.handle)); - Vec::new() - } - (_, None) => vec![Effect::Connect], - }) + match source { + Some(source) => Ok(self.open_feed(kit, source, Cursor::new())), + None => Err(format!("E94: No matching buffer for {name}")), + } } - /// How `group` is drawn now. - #[cfg(test)] - pub(crate) fn style(&self, group: crate::hl::Group) -> ratatui::style::Style { - self.theme.style(group) + fn edit_names(&self, _kit: &Kit) -> Vec { + Source::names() + .into_iter() + .chain(["profile".to_owned()]) + .collect() } - /// The document of the current window, for tests to change as the network would. - #[cfg(test)] - pub(crate) fn doc_mut(&mut self) -> &mut Document { - &mut self.buffers[self.windows.current().buffer].doc + fn reload(&mut self, kit: &mut Kit, buffer: BufferId) -> Option { + self.refresh(kit, buffer) } -} - -/// Whether `motion` is a jump, remembered in the jumplist. -fn is_jump(motion: Motion) -> bool { - matches!( - motion, - Motion::GotoRow(_) - | Motion::LastRow - | Motion::ParagraphForward - | Motion::ParagraphBackward - | Motion::NextBlock - | Motion::PrevBlock - ) -} -/// A window's text area: all of `rect` but its last row, the statusline. -fn text_area(rect: Rect) -> Rect { - Rect { - height: rect.height.saturating_sub(1), - ..rect + fn shown(&mut self, kit: &mut Kit, buffer: BufferId) -> Effects { + self.load(kit, buffer) } -} -fn opposite(direction: Direction) -> Direction { - match direction { - Direction::Forward => Direction::Backward, - Direction::Backward => Direction::Forward, + fn keyed(&mut self, kit: &mut Kit) -> Effects { + self.load_more(kit) } -} -/// Adds `entry` to `history` as its newest, unless empty; an older identical entry moves up instead of repeating. -fn remember(history: &mut Vec, entry: &str) { - if entry.is_empty() { - return; + fn edited(&mut self, kit: &mut Kit, saved: Result) -> Effects { + self.saved(kit, saved) } - history.retain(|old| old != entry); - history.push(entry.to_owned()); } diff --git a/crates/devnull/src/app/options.rs b/crates/devnull/src/app/options.rs deleted file mode 100644 index 4d7f3e4..0000000 --- a/crates/devnull/src/app/options.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! The options `:set` changes. - -use crate::doc::CaseMode; -use crate::ex::{SetArg, SetOp}; -use crate::graphics::Graphics; -use crate::key::{Key, parse_keys}; -use crate::keymap::Keymap; - -/// The options `dn` has, with Vim's defaults. -#[derive(Debug, Clone)] -pub(super) struct Options { - pub(super) ignorecase: bool, - pub(super) smartcase: bool, - /// Rows of context kept above and below the cursor. - pub(super) scrolloff: usize, - /// How long, in milliseconds, an ambiguous key sequence waits for more. - pub(super) timeoutlen: u64, - /// How pictures are drawn. - pub(super) graphics: Graphics, - /// Whether the last search's matches are shown, as Neovim does by default. - pub(super) hlsearch: bool, - /// Whether a search being typed shows where it would go, as Neovim does by default. - pub(super) incsearch: bool, -} - -impl Default for Options { - fn default() -> Self { - Self { - ignorecase: false, - smartcase: false, - scrolloff: 0, - timeoutlen: 1000, - graphics: Graphics::HalfBlocks, - hlsearch: true, - incsearch: true, - } - } -} - -/// What a `no` or `inv` prefix does to a flag. -#[derive(Clone, Copy)] -enum Flip { - Off, - Toggle, -} - -impl Flip { - /// The equivalent operation. Turning off is written as assigning nothing, which only flags accept here. - fn op(self) -> SetOp { - match self { - Self::Off => SetOp::Assign(String::new()), - Self::Toggle => SetOp::Toggle, - } - } -} - -/// An option's name, its short name, and what it holds. -#[derive(Clone, Copy)] -enum Opt { - Flag(fn(&mut Options) -> &mut bool), - Number(fn(&mut Options) -> &mut u64), - Count(fn(&mut Options) -> &mut usize), - /// `leader`: the key `` stands for, kept by the keymap. - Leader, - /// `graphics`: how pictures are drawn. - Graphics, -} - -const OPTIONS: &[(&str, &str, Opt)] = &[ - ("ignorecase", "ic", Opt::Flag(|o| &mut o.ignorecase)), - ("smartcase", "scs", Opt::Flag(|o| &mut o.smartcase)), - ("hlsearch", "hls", Opt::Flag(|o| &mut o.hlsearch)), - ("incsearch", "is", Opt::Flag(|o| &mut o.incsearch)), - ("scrolloff", "so", Opt::Count(|o| &mut o.scrolloff)), - ("timeoutlen", "tm", Opt::Number(|o| &mut o.timeoutlen)), - ("leader", "", Opt::Leader), - ("graphics", "", Opt::Graphics), -]; - -impl Options { - /// Vim's E474 for `arg`. - fn invalid(&self, arg: &SetArg) -> String { - let value = match &arg.op { - SetOp::Assign(value) => format!("={value}"), - _ => String::new(), - }; - format!("E474: Invalid argument: {}{value}", arg.name) - } - - /// How searches treat case, from `'ignorecase'` and `'smartcase'`. - pub(super) fn case_mode(&self) -> CaseMode { - match (self.ignorecase, self.smartcase) { - (false, _) => CaseMode::Sensitive, - (true, false) => CaseMode::Insensitive, - (true, true) => CaseMode::Smart, - } - } - - /// Applies one `:set` argument. Returns what to show (for `name?`, or `name` on a non-flag), or Vim's error. - pub(super) fn set( - &mut self, - arg: &SetArg, - keymap: &mut Keymap, - ) -> Result, String> { - let find = |typed: &str| { - OPTIONS - .iter() - .copied() - .find(|(name, short, _)| typed == *name || (!short.is_empty() && typed == *short)) - }; - let unknown = || format!("E518: Unknown option: {}", arg.name); - // `noname` and `invname` turn a flag off or flip it, unless an option has that name itself. - let (found, op) = match (find(&arg.name), &arg.op) { - (Some(found), op) => (found, op.clone()), - (None, SetOp::On) => { - let off = arg - .name - .strip_prefix("no") - .and_then(find) - .map(|found| (found, Flip::Off)); - let inv = arg - .name - .strip_prefix("inv") - .and_then(find) - .map(|found| (found, Flip::Toggle)); - match off.or(inv) { - Some((found @ (_, _, Opt::Flag(_)), flip)) => (found, flip.op()), - Some(_) => return Err(self.invalid(arg)), - None => return Err(unknown()), - } - } - (None, _) => return Err(unknown()), - }; - let (name, _, opt) = found; - let invalid = || self.invalid(arg); - match (opt, &op) { - (Opt::Flag(field), SetOp::Query) => { - let on = *field(self); - Ok(Some(format!("{}{name}", if on { "" } else { "no" }))) - } - (Opt::Flag(field), SetOp::On) => { - *field(self) = true; - Ok(None) - } - (Opt::Flag(field), SetOp::Assign(off)) if off.is_empty() => { - *field(self) = false; - Ok(None) - } - (Opt::Flag(field), SetOp::Toggle) => { - let flag = field(self); - *flag = !*flag; - Ok(None) - } - (Opt::Number(field), SetOp::Assign(value)) => { - *field(self) = number(&arg.name, value)?; - Ok(None) - } - (Opt::Count(field), SetOp::Assign(value)) => { - *field(self) = usize::try_from(number(&arg.name, value)?).map_err(|_| invalid())?; - Ok(None) - } - (Opt::Number(field), SetOp::Query | SetOp::On) => { - Ok(Some(format!("{name}={}", field(self)))) - } - (Opt::Count(field), SetOp::Query | SetOp::On) => { - Ok(Some(format!("{name}={}", field(self)))) - } - (Opt::Leader, SetOp::Assign(value)) => { - match parse_keys(value, Key::char(' ')).as_slice() { - [key] => { - keymap.set_leader(*key); - Ok(None) - } - _ => Err(invalid()), - } - } - (Opt::Leader, SetOp::Query | SetOp::On) => { - Ok(Some(format!("{name}={}", keymap.leader()))) - } - (Opt::Graphics, SetOp::Assign(value)) => { - self.graphics = Graphics::named(value).ok_or_else(invalid)?; - Ok(None) - } - (Opt::Graphics, SetOp::Query | SetOp::On) => { - Ok(Some(format!("{name}={}", self.graphics.name()))) - } - _ => Err(invalid()), - } - } -} - -/// What `` may complete `word`, an argument of `:set`, to: from that many chars into `word` on, one of the -/// candidates. An option's name, or for a flag `no` and its name; after `graphics=`, how pictures may be drawn. -pub(super) fn completions(word: &str) -> (usize, Vec) { - if let Some((name, value)) = word.split_once('=') { - let graphics = OPTIONS.iter().any(|&(full, short, opt)| { - matches!(opt, Opt::Graphics) && (name == full || (!short.is_empty() && name == short)) - }); - let values = if graphics { - Graphics::ALL - .iter() - .map(|graphics| graphics.name()) - .filter(|candidate| candidate.starts_with(value)) - .map(str::to_owned) - .collect() - } else { - Vec::new() - }; - return (name.chars().count() + 1, values); - } - let names = OPTIONS.iter().map(|&(full, _, _)| full.to_owned()); - let negated = OPTIONS - .iter() - .filter(|(_, _, opt)| matches!(opt, Opt::Flag(_))) - .map(|&(full, _, _)| format!("no{full}")); - let candidates = names - .chain(negated) - .filter(|candidate| candidate.starts_with(word)) - .collect(); - (0, candidates) -} - -fn number(name: &str, value: &str) -> Result { - value - .parse() - .map_err(|_| format!("E521: Number required after =: {name}={value}")) -} diff --git a/crates/devnull/src/app/picker.rs b/crates/devnull/src/app/picker.rs index edafe75..d44d932 100644 --- a/crates/devnull/src/app/picker.rs +++ b/crates/devnull/src/app/picker.rs @@ -1,400 +1,87 @@ -//! Pickers: a floating list narrowed by what is typed, ``/`` to choose, `` to open, `` to leave. -//! The buffer picker narrows its list fuzzily; the user picker asks dev-null.sh's search once typing pauses. - -#[cfg(test)] -mod tests; - -use std::time::Duration; +//! The user picker (`ff`, `:pick users`, as every app finds its own with `ff`): people and +//! organizations, as dev-null.sh's search finds them once typing pauses; and a commit's links, for `:browse link` off +//! any of them. use devnull_api::model::{AccountKind, ProfileSearch}; -use ratatui::Frame; -use ratatui::layout::{Position, Rect}; -use ratatui::text::{Line as TextLine, Span as TextSpan}; -use ratatui::widgets::{Block, Clear, Paragraph}; -use unicode_width::UnicodeWidthStr; +use wcgw_kit::shell::{Choice, Pick, Picker}; -use super::cmdline::{Cmdline, Edit, Purpose}; -use super::{App, AppMode, Effect, FetchError, Request, Wake, WakePurpose}; -use crate::doc::AccountRef; -use crate::ex::PickList; +use super::{Dn, Effect, Effects, FetchError, Kit, NetEvent, Request, fetch}; +use crate::doc::{AccountRef, Target}; use crate::hl::Group; -use crate::key::{Code, Key, Mods}; - -/// How long typing must pause before the user picker searches, as the Go client waits. -const SEARCH_DELAY: Duration = Duration::from_millis(120); - -/// The widest a picker is, in cells. -const MAX_WIDTH: u16 = 72; - -/// The most rows a picker lists. -const MAX_ROWS: u16 = 12; - -/// What the query follows. -const PROMPT: &str = "> "; - -/// A picker open over the windows. -#[derive(Debug)] -pub(super) struct Picker { - list: PickList, - /// The query, edited as a command line is. - query: Cmdline, - /// What can be picked, in their own order: the last shown buffer first, or as the search found them. - choices: Vec, - /// The choices matching the query, best first, as indices into `choices`, each with the characters of its text - /// that matched. - shown: Vec<(usize, Vec)>, - /// The index into `shown` of the chosen one. - selected: usize, - /// What to say while nothing is shown. - hint: &'static str, -} - -/// Something a picker can open. -#[derive(Debug, Clone)] -struct Choice { - /// How it is listed, as text and the group it is drawn in. - label: Vec<(String, Group)>, - /// What the query is matched against. - text: String, - pick: Pick, -} - -/// What picking a choice opens. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) enum Pick { - /// A buffer, by index. - Buffer(usize), - /// A person's or an organization's profile. - Account(AccountRef), -} - -/// What a key did to a picker. -enum Outcome { - /// Nothing the app needs to know. - Stay, - /// The query changed. - Changed, - /// ``: open the chosen one, if any. - Picked(Option), - /// ``, ``. - Leave, -} -/// How well `query` matches `text`, as fzf does it, and which characters of `text` matched, by index: its characters in order, ignoring case unless the query has -/// capitals. Matches at the start of words and runs of consecutive characters score higher, gaps lower. `None` when -/// `text` does not hold the query's characters in order. -fn best(query: &str, text: &str) -> Option<(i64, Vec)> { - let smart = query.chars().any(char::is_uppercase); - let fold = |c: char| { - if smart { - c - } else { - c.to_lowercase().next().unwrap_or(c) - } - }; - let query: Vec = query - .chars() - .filter(|c| !c.is_whitespace()) - .map(fold) - .collect(); - let text: Vec = text.chars().map(fold).collect(); - let Some(&first) = query.first() else { - return Some((0, Vec::new())); - }; - let word_start = |i: usize| i == 0 || matches!(text[i - 1], '/' | '@' | '-' | '_' | '.' | ' '); - // Each place the first character matches starts a greedy run; the best run counts. - (0..text.len()) - .filter(|&i| text[i] == first) - .filter_map(|start| { - let mut total = 0; - let mut at = start; - let mut previous: Option = None; - let mut positions = Vec::with_capacity(query.len()); - for &wanted in &query { - let found = (at..text.len()).find(|&i| text[i] == wanted)?; - total += 16; - if word_start(found) { - total += 8; - } - match previous { - Some(previous) if found == previous + 1 => total += 8, - Some(previous) => { - total -= i64::try_from(found - previous - 1).unwrap_or(i64::MAX) - } - None => total -= i64::try_from(found).unwrap_or(i64::MAX) / 4, - } - previous = Some(found); - positions.push(found); - at = found + 1; - } - Some((total, positions)) - }) - // The first of the best, as `max` would pick the last. - .fold(None, |best: Option<(i64, Vec)>, run| match best { - Some(best) if best.0 >= run.0 => Some(best), - _ => Some(run), +/// What the user picker says while there is nothing to search for. +const IDLE: &str = "Type to find people and organizations"; + +/// `urls` to pick one of to open, as `:browse link` lists a commit's; `` yanks one instead, as muttrs' and +/// slackers' do. +pub(super) fn links(urls: Vec) -> Picker { + let choices = urls + .into_iter() + .map(|url| Choice { + label: vec![(url.clone(), Group::NORMAL_FLOAT)], + text: url.clone(), + pick: Pick::Target(Target::Url(url)), }) -} - -impl Picker { - fn new(list: PickList, choices: Vec, hint: &'static str) -> Self { - let mut picker = Self { - list, - query: Cmdline::new(Purpose::Ex, None), - choices, - shown: Vec::new(), - selected: 0, - hint, - }; - picker.narrow(); - picker - } - - /// The choices matching the query, best first; ties keep their order. - fn narrow(&mut self) { - let query = self.query.text(); - let mut scored: Vec<(usize, i64, Vec)> = self - .choices - .iter() - .enumerate() - .filter_map(|(i, choice)| { - best(&query, &choice.text).map(|(score, matched)| (i, score, matched)) - }) - .collect(); - scored.sort_by_key(|&(_, score, _)| std::cmp::Reverse(score)); - self.shown = scored - .into_iter() - .map(|(i, _, matched)| (i, matched)) - .collect(); - self.selected = 0; - } - - fn key(&mut self, key: Key) -> Outcome { - let ctrl = |ch| Key { - code: Code::Char(ch), - mods: Mods::CTRL, - }; - let count = self.shown.len(); - match key { - k if k == ctrl('n') || k.code == Code::Down => { - if count > 0 { - self.selected = (self.selected + 1) % count; - } - Outcome::Stay - } - k if k == ctrl('p') || k.code == Code::Up => { - if count > 0 { - self.selected = (self.selected + count - 1) % count; - } - Outcome::Stay - } - // Unlike the command line, an empty picker stays open on . - k if k.code == Code::Backspace && self.query.text().is_empty() => Outcome::Stay, - k => { - let before = self.query.text(); - match self.query.key(k, &[]) { - Edit::Submit(_) => Outcome::Picked(self.chosen()), - Edit::Cancel => Outcome::Leave, - Edit::Editing if self.query.text() == before => Outcome::Stay, - Edit::Editing => { - if self.list == PickList::Buffers { - self.narrow(); - } - Outcome::Changed - } - } - } - } - } - - /// Pasted text, up to its first line break, goes into the query. - fn paste(&mut self, text: &str) -> Outcome { - self.query.paste(text); - if self.list == PickList::Buffers { - self.narrow(); - } - Outcome::Changed - } - - fn chosen(&self) -> Option { - let &(index, _) = self.shown.get(self.selected)?; - Some(self.choices[index].pick.clone()) - } - - /// The search term the query stands for; empty searches nothing. - fn term(&self) -> String { - self.query.text().trim().to_owned() - } -} - -impl App { - /// `:pick {list} [query]`. - pub(super) fn pick(&mut self, list: PickList, query: &str) -> Vec { - let mut picker = match list { - PickList::Buffers => self.buffer_picker(), - PickList::Users => { - Picker::new(list, Vec::new(), "Type to find people and organizations") - } - }; - if !query.is_empty() { - picker.query.paste(query); - picker.narrow(); - } - let term = picker.term(); - self.mode = AppMode::Picker(picker); - if list == PickList::Users && !term.is_empty() { - return vec![Effect::Fetch(Request::SearchProfiles { term })]; - } - Vec::new() - } - - /// The listed buffers, the one shown first, then the others from the last shown; it starts on the second. - fn buffer_picker(&self) -> Picker { - let current = self.windows.current().buffer; - let mut order: Vec = std::iter::once(current) - .chain(self.recent.iter().copied()) - .chain(self.listed()) - .filter(|&index| self.buffers[index].is_listed()) - .collect(); - let mut seen = std::collections::HashSet::new(); - order.retain(|index| seen.insert(*index)); - let choices = order - .into_iter() - .map(|index| { - let name = self.buffers[index].name.clone(); - let group = if index == current { - Group::Time - } else { - Group::NormalFloat - }; - Choice { - label: vec![(name.clone(), group)], - text: name, - pick: Pick::Buffer(index), - } - }) - .collect(); - let mut picker = Picker::new(PickList::Buffers, choices, "No matching buffer"); - if picker.shown.len() > 1 { - picker.selected = 1; - } - picker - } - - /// A key while a picker is open. - pub(super) fn picker_key(&mut self, key: Key) -> Vec { - let AppMode::Picker(picker) = &mut self.mode else { - return Vec::new(); - }; - let outcome = picker.key(key); - self.picked(outcome) - } - - /// Text pasted while a picker is open. - pub(super) fn picker_paste(&mut self, text: &str) -> Vec { - let AppMode::Picker(picker) = &mut self.mode else { - return Vec::new(); - }; - let outcome = picker.paste(text); - self.picked(outcome) - } - - /// What the open picker does about what a key or a paste did to it. - fn picked(&mut self, outcome: Outcome) -> Vec { - let AppMode::Picker(picker) = &mut self.mode else { + .collect(); + Picker::list("Links", choices, "No links").verb("", "yank", |_, kit, pick| { + let Pick::Target(Target::Url(url)) = pick else { return Vec::new(); }; - match outcome { - Outcome::Stay => Vec::new(), - Outcome::Changed if picker.list == PickList::Users => { - self.search_generation += 1; - if picker.term().is_empty() { - picker.choices.clear(); - picker.narrow(); - picker.hint = "Type to find people and organizations"; - return Vec::new(); - } - picker.hint = "Searching…"; - vec![Effect::Wake { - after: SEARCH_DELAY, - token: Wake { - purpose: WakePurpose::Search, - generation: self.search_generation, - }, - }] - } - Outcome::Changed => Vec::new(), - Outcome::Leave => { - self.mode = AppMode::Normal; - Vec::new() - } - Outcome::Picked(pick) => { - self.mode = AppMode::Normal; - match pick { - Some(Pick::Buffer(index)) => self.show_buffer(index), - Some(Pick::Account(account)) => self.open_profile(account), - None => Vec::new(), - } - } - } - } + kit.close_picker(); + kit.info(format!("Yanked {url}")); + vec![Effect::Copy(url)] + }) +} - /// Typing paused in the user picker: search for what it says. - pub(super) fn search_people(&mut self) -> Vec { - match &self.mode { - AppMode::Picker(picker) - if picker.list == PickList::Users && !picker.term().is_empty() => - { - vec![Effect::Fetch(Request::SearchProfiles { - term: picker.term(), - })] - } - _ => Vec::new(), - } - } +/// The user picker, its title saying what it searches. +pub(super) fn users() -> Picker { + Picker::search("People and organizations", IDLE, |_, _, term| { + vec![fetch(Request::SearchProfiles { term })] + }) +} +impl Dn { /// What a search for `term` found, for the user picker if it still asks for that. pub(super) fn found( &mut self, + kit: &mut Kit, term: &str, result: Result, - ) -> Vec { - let AppMode::Picker(picker) = &mut self.mode else { + ) -> Effects { + let Some(picker) = kit.picker_mut() else { return Vec::new(); }; - if picker.list != PickList::Users || picker.term() != term { + if !picker.is_searching() || picker.term() != term { return Vec::new(); } let found = match result { Ok(found) => found, Err(FetchError::NotApproved) => { - self.mode = AppMode::Normal; - return self.net(super::NetEvent::NotApproved); + kit.close_picker(); + return self.net(kit, NetEvent::NotApproved); } Err(FetchError::Failed(_)) => { - picker.choices.clear(); - picker.narrow(); - picker.hint = "Cannot search now"; + picker.found(term, Vec::new(), "Cannot search now"); return Vec::new(); } }; let account = |name: &str, handle: &str, kind: AccountKind| { let mut label = Vec::new(); if !name.is_empty() { - label.push((name.to_owned(), Group::Author)); + label.push((name.to_owned(), crate::hl::AUTHOR)); if kind == AccountKind::Organization { - label.push((" (org)".to_owned(), Group::Time)); + label.push((" (org)".to_owned(), crate::hl::TIME)); } - label.push((" ".to_owned(), Group::NormalFloat)); + label.push((" ".to_owned(), Group::NORMAL_FLOAT)); } - label.push((format!("@{handle}"), Group::Handle)); + label.push((format!("@{handle}"), crate::hl::HANDLE)); Choice { label, text: String::new(), - pick: Pick::Account(AccountRef::new(handle, Some(kind))), + pick: Pick::Target(Target::Account(AccountRef::new(handle, Some(kind)))), } }; - picker.choices = found + let choices = found .people .iter() .map(|person| account(&person.name, &person.handle, AccountKind::User)) @@ -406,93 +93,7 @@ impl App { ) .collect(); // The server ranks them: every one is shown, in its order. - picker.shown = (0..picker.choices.len()).map(|i| (i, Vec::new())).collect(); - picker.selected = 0; - picker.hint = "Nobody found"; + picker.found(term, choices, "Nobody found"); Vec::new() } - - /// Draws `picker` in the middle of the screen, and returns where the cursor goes: in its query. - pub(super) fn render_picker(&self, frame: &mut Frame<'_>, picker: &Picker) -> Position { - let (width, height) = self.size; - let card_width = MAX_WIDTH.min(width.saturating_sub(4)).max(10).min(width); - let rows = u16::try_from(picker.shown.len()) - .unwrap_or(u16::MAX) - .clamp(1, MAX_ROWS); - let card_height = (rows + 3).min(height.saturating_sub(2)).max(3).min(height); - let area = Rect::new( - (width - card_width) / 2, - height.saturating_sub(card_height) / 3, - card_width, - card_height, - ); - let title = match picker.list { - PickList::Buffers => " Buffers ", - PickList::Users => " People and organizations ", - }; - let block = Block::bordered() - .title(title) - .border_style(self.theme.style(Group::FloatBorder)) - .style(self.theme.style(Group::NormalFloat)); - let inner = block.inner(area); - frame.render_widget(Clear, area); - frame.render_widget(block, area); - let prompt = format!("{PROMPT}{}", picker.query.text()); - frame.render_widget( - Paragraph::new(prompt), - Rect::new(inner.x, inner.y, inner.width, 1.min(inner.height)), - ); - let list = Rect::new( - inner.x, - inner.y + 1, - inner.width, - inner.height.saturating_sub(1), - ); - let visible = usize::from(list.height); - // Scrolled so the chosen one is in view. - let first = picker.selected.saturating_sub(visible.saturating_sub(1)); - let lines: Vec> = if picker.shown.is_empty() { - vec![TextLine::from(vec![ - TextSpan::raw(" "), - TextSpan::styled(picker.hint, self.theme.style(Group::Time)), - ])] - } else { - picker - .shown - .iter() - .skip(first) - .take(visible) - .map(|(index, matched)| { - let mut spans = vec![TextSpan::raw(" ")]; - // The label's characters, those that matched in `PmenuMatch`. - let mut at = 0; - for (text, group) in &picker.choices[*index].label { - for ch in text.chars() { - let group = if matched.contains(&at) { - Group::PmenuMatch - } else { - *group - }; - spans.push(TextSpan::styled(ch.to_string(), self.theme.style(group))); - at += 1; - } - } - TextLine::from(spans) - }) - .collect() - }; - frame.render_widget(Paragraph::new(lines), list); - if !picker.shown.is_empty() { - // Over the labels' own colours, as Visual mode's selection. - let row = u16::try_from(picker.selected - first).unwrap_or(0); - if row < list.height { - frame.buffer_mut().set_style( - Rect::new(list.x, list.y + row, list.width, 1), - self.theme.style(Group::Visual), - ); - } - } - let x = inner.x + u16::try_from(PROMPT.width() + picker.query.cursor_cells()).unwrap_or(0); - Position::new(x.min(inner.right().saturating_sub(1)), inner.y) - } } diff --git a/crates/devnull/src/app/picker/tests.rs b/crates/devnull/src/app/picker/tests.rs deleted file mode 100644 index 324d525..0000000 --- a/crates/devnull/src/app/picker/tests.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! The fuzzy matching behind the buffer picker. - -use super::best; - -/// How well `query` matches `text`. -fn score(query: &str, text: &str) -> Option { - best(query, text).map(|(score, _)| score) -} - -/// Which characters of `text` matched `query`, by index. -fn matched(query: &str, text: &str) -> Option> { - best(query, text).map(|(_, positions)| positions) -} - -#[test] -fn the_query_s_characters_must_appear_in_order() { - assert!(score("lgfl", "log/following").is_some()); - assert_eq!(score("lgfl", "log/latest"), None); - // `g`, then `l`, but no `/` after them. - assert_eq!(score("gl/", "log/following"), None); -} - -#[test] -fn an_empty_query_matches_everything_alike() { - assert_eq!(score("", "log/latest"), score("", "thread/a82e820")); -} - -#[test] -fn case_is_ignored_unless_the_query_has_capitals() { - assert!(score("ada", "@Ada").is_some()); - assert_eq!(score("Ada", "@ada"), None); -} - -#[test] -fn word_starts_and_runs_beat_scattered_letters() { - let starts = score("lf", "log/following").expect("match"); - let scattered = score("lf", "self-fulfilling").expect("match"); - assert!(starts > scattered); - let run = score("foll", "log/following").expect("match"); - let spread = score("foll", "f/o/l/l").expect("match"); - assert!(run > spread); -} - -#[test] -fn a_later_better_place_wins_over_the_first_one() { - // The first `t` starts a scattered match; the `t` of `trending` starts a run. - let best = score("tren", "log/trending"); - let only_scattered = score("tren", "log/t-r-e-n"); - assert!(best > only_scattered); -} - -#[test] -fn a_match_says_which_characters_matched() { - assert_eq!(matched("lgfl", "log/following"), Some(vec![0, 2, 4, 6])); - assert_eq!( - matched("tren", "log/trending"), - Some(vec![4, 5, 6, 7]), - "the best place, not the first" - ); - assert_eq!(matched("x", "log"), None); - assert_eq!( - matched("a", "a/a"), - Some(vec![0]), - "of equal places, the first" - ); -} diff --git a/crates/devnull/src/app/pictures.rs b/crates/devnull/src/app/pictures.rs deleted file mode 100644 index e457dcb..0000000 --- a/crates/devnull/src/app/pictures.rs +++ /dev/null @@ -1,371 +0,0 @@ -//! Pictures in buffers (D7): asked for once they come into view, then drawn in the space their lines take, by the -//! terminal through placeholders (kitty) or as half blocks. The terminal runs animations it is sent; those drawn with -//! half blocks move on frame by frame while in view, as wake-ups say. - -use std::collections::HashMap; -use std::time::Duration; - -use ratatui::buffer::Buffer as Cells; -use ratatui::layout::Rect; -use ratatui::style::{Color, Style}; - -use super::{App, Effect, Wake, WakePurpose}; -use crate::doc::{Layout, PictureRow}; -use crate::graphics::{self, Graphics, HalfBlock, HalfBlockFrame}; -use crate::hl::Group; - -/// The narrowest picture that says, while it is not drawn, that it is loading or unavailable. -const STATUS_COLS: u16 = 12; - -/// A picture ready to draw, as the runtime prepares it. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum Prepared { - /// The escapes that send it to the terminal, to write as they are; then placeholders show it. - Kitty(String), - /// Its frames: one for a still picture. Those of an animation show for some time each. - HalfBlocks(Vec), -} - -/// A picture at one size. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct Key { - source: String, - cols: u16, - rows: u16, -} - -/// Where a picture stands. -#[derive(Debug)] -enum State { - /// Asked for; with the id it will have in the terminal, for the kitty protocol. - Loading(Option), - /// Sent to the terminal as image `id`. - Kitty(u32), - /// Drawn with half blocks. - HalfBlocks(Playing), - /// It could not be had. - Failed, -} - -/// A picture drawn with half blocks: its frames, and where it is in them. -#[derive(Debug)] -struct Playing { - frames: Vec, - /// The frame shown. - shown: usize, - /// How much longer it shows, for an animation. - left: Duration, -} - -impl Playing { - fn new(frames: Vec) -> Self { - let left = frames.first().map_or(Duration::ZERO, |frame| frame.delay); - Self { - frames, - shown: 0, - left, - } - } - - /// Whether it moves: it has frames to move on to, each showing for some time. - fn is_animated(&self) -> bool { - self.frames.len() > 1 && !self.left.is_zero() - } - - /// The cells of the frame shown, by rows. - fn cells(&self) -> &[Vec] { - self.frames - .get(self.shown) - .map_or(&[], |frame| &frame.cells[..]) - } - - /// Moves on by `elapsed`, to the frame showing then. - fn advance(&mut self, mut elapsed: Duration) { - while self.is_animated() && elapsed >= self.left { - elapsed -= self.left; - self.shown = (self.shown + 1) % self.frames.len(); - self.left = self.frames[self.shown].delay; - } - self.left = self.left.saturating_sub(elapsed); - } -} - -/// The pictures asked for. -#[derive(Debug)] -pub(super) struct Pictures { - states: HashMap, - /// The id the next picture sent to the terminal gets. - next_id: u32, - /// How long after it was asked for the wake-up for the next frames comes, while one is to come. - frame_wake: Option, - /// That wake-up's generation: those of another are stale, and none comes while there is no `frame_wake`. - frame_generation: u64, -} - -impl Default for Pictures { - fn default() -> Self { - Self { - states: HashMap::new(), - next_id: 1, - frame_wake: None, - frame_generation: 0, - } - } -} - -impl Pictures { - /// The generation of the wake-up for the next frames. - pub(super) fn frame_generation(&self) -> u64 { - self.frame_generation - } - - /// Frees what was sent to the terminal and forgets every picture: the escapes to write. - pub(super) fn clear(&mut self) -> Vec { - self.frame_wake = None; - self.states - .drain() - .filter_map(|(_, state)| match state { - State::Kitty(id) => Some(Effect::Graphics(graphics::kitty_delete(id))), - _ => None, - }) - .collect() - } - - /// Draws row `picture` of a picture in `area`, a row of the screen, if it is ready; otherwise says how it - /// stands in its first row. - pub(super) fn draw( - &self, - cells: &mut Cells, - area: Rect, - picture: PictureRow<'_>, - theme: &crate::hl::Theme, - ) { - let key = Key { - source: picture.source.to_owned(), - cols: picture.cols, - rows: picture.rows, - }; - let width = picture.cols.min(area.width); - match self.states.get(&key) { - Some(State::Kitty(id)) => { - let (r, g, b) = graphics::image_colour(*id); - for col in 0..width { - if let Some(cell) = cells.cell_mut((area.x + col, area.y)) { - cell.set_symbol(&graphics::placeholder(picture.row, col)) - .set_fg(Color::Rgb(r, g, b)); - } - } - } - Some(State::HalfBlocks(playing)) => { - let Some(row) = playing.cells().get(usize::from(picture.row)) else { - return; - }; - for (col, half) in (0..width).zip(row) { - let Some(cell) = cells.cell_mut((area.x + col, area.y)) else { - continue; - }; - let rgb = |(r, g, b)| Color::Rgb(r, g, b); - let mut style = Style::new(); - if let Some(upper) = half.upper { - style = style.fg(rgb(upper)); - } - if let Some(lower) = half.lower { - style = style.bg(rgb(lower)); - } - let symbol = if half.upper.is_some() { "▀" } else { " " }; - cell.set_symbol(symbol).set_style(style); - } - } - // Saying how it stands needs room: a picture in a line of text, such as an avatar, says nothing. - state if picture.row == 0 && picture.cols >= STATUS_COLS => { - let text = match state { - Some(State::Failed) => "unavailable", - _ => "loading…", - }; - cells.set_stringn( - area.x, - area.y, - text, - usize::from(area.width), - theme.style(Group::Time), - ); - } - _ => {} - } - } -} - -impl App { - /// Draws pictures as `graphics` says, from now on (as `:set graphics=` does): what was drawn otherwise is freed - /// (the effects say how) and asked for again. - pub fn set_graphics(&mut self, graphics: Graphics) -> Vec { - if graphics == self.options.graphics { - return Vec::new(); - } - self.options.graphics = graphics; - self.pictures.clear() - } - - /// Numbers the pictures sent to the terminal from `id` on, so they do not take the ids of another program's. - pub fn number_pictures_from(&mut self, id: u32) { - self.pictures.next_id = id.clamp(1, 0x00ff_ffff); - } - - /// What the pictures in view in any window need: those never asked for, asked for now; a wake-up for the next - /// frame of an animation drawn with half blocks, unless one is to come. - pub(super) fn pictures_in_view(&mut self) -> Vec { - let in_view = self.in_view(); - let mut effects = Vec::new(); - for key in &in_view { - if self.pictures.states.contains_key(key) { - continue; - } - let kitty = (self.options.graphics == Graphics::Kitty).then(|| { - let id = self.pictures.next_id; - // Ids are 24 bits, the colour of the placeholder cells; past the last, they start over. - self.pictures.next_id = if id >= 0x00ff_ffff { 1 } else { id + 1 }; - id - }); - effects.push(Effect::Picture { - source: key.source.clone(), - cols: key.cols, - rows: key.rows, - kitty, - }); - self.pictures - .states - .insert(key.clone(), State::Loading(kitty)); - } - let next = in_view - .iter() - .filter_map(|key| match self.pictures.states.get(key) { - Some(State::HalfBlocks(playing)) if playing.is_animated() => Some(playing.left), - _ => None, - }) - .min(); - if let (Some(after), None) = (next, self.pictures.frame_wake) { - self.pictures.frame_wake = Some(after); - self.pictures.frame_generation += 1; - effects.push(Effect::Wake { - after, - token: Wake { - purpose: WakePurpose::Frame, - generation: self.pictures.frame_generation, - }, - }); - } - effects - } - - /// The wake-up for the next frames came: the animations in view move on by the time it waited. - pub(super) fn next_frames(&mut self) { - let Some(elapsed) = self.pictures.frame_wake.take() else { - return; - }; - for key in self.in_view() { - if let Some(State::HalfBlocks(playing)) = self.pictures.states.get_mut(&key) { - playing.advance(elapsed); - } - } - } - - /// Draws the pictures of `layout`'s rows from `top` on, a row of `area` each. - pub(super) fn draw_pictures( - &self, - cells: &mut Cells, - layout: &Layout<'_>, - top: usize, - area: Rect, - ) { - for (y, row) in (area.y..area.bottom()).zip(top..layout.row_count()) { - if let Some(picture) = layout.picture(row) { - let line = Rect::new(area.x, y, area.width, 1); - self.pictures.draw(cells, line, picture, &self.theme); - } - for (col, picture) in layout.inline_pictures(row) { - let col = u16::try_from(col).unwrap_or(u16::MAX); - if col < area.width { - let rest = Rect::new(area.x + col, y, area.width - col, 1); - self.pictures.draw(cells, rest, picture, &self.theme); - } - } - } - } - - /// The pictures in view, each once: in any window, and on the hover card. - fn in_view(&self) -> Vec { - let arrangement = self.windows.arrange(self.windows_area()); - let mut wanted: Vec = Vec::new(); - for &(id, rect) in &arrangement.windows { - let window = self.windows.get(id); - let area = super::text_area(rect); - let layout = window.layout(&self.buffers, area); - let viewport = super::Viewport { - height: usize::from(area.height), - scrolloff: self.options.scrolloff, - }; - let top = window.top(&layout, viewport); - let bottom = (top + usize::from(area.height)).min(layout.row_count()); - add_pictures(&layout, top..bottom, &mut wanted); - } - if let Some(hover) = &self.hover { - // Which pictures it has does not depend on the width it is laid out to. - let layout = Layout::new(hover.doc(), u16::MAX); - add_pictures(&layout, 0..layout.row_count(), &mut wanted); - } - wanted - } - - /// A picture prepared, or why it could not be; ignored when no longer asked for as such. - pub(super) fn picture_arrived( - &mut self, - source: String, - cols: u16, - rows: u16, - result: Result, - ) -> Vec { - let key = Key { source, cols, rows }; - let Some(State::Loading(kitty)) = self.pictures.states.get(&key) else { - return Vec::new(); - }; - let kitty = *kitty; - let (state, effects) = match (result, kitty) { - (Ok(Prepared::Kitty(sent)), Some(id)) => { - (State::Kitty(id), vec![Effect::Graphics(sent)]) - } - (Ok(Prepared::HalfBlocks(frames)), None) => { - (State::HalfBlocks(Playing::new(frames)), Vec::new()) - } - // Prepared for other graphics than asked for, or not at all. - _ => (State::Failed, Vec::new()), - }; - self.pictures.states.insert(key, state); - effects - } - - /// Frees the pictures sent to the terminal, before quitting. - pub(super) fn free_pictures(&mut self) -> Vec { - self.pictures.clear() - } -} - -/// Adds to `wanted` the pictures on `rows` of `layout` it does not have yet. -fn add_pictures(layout: &Layout<'_>, rows: std::ops::Range, wanted: &mut Vec) { - for row in rows { - let inline = layout - .inline_pictures(row) - .into_iter() - .map(|(_, picture)| picture); - for picture in layout.picture(row).into_iter().chain(inline) { - let key = Key { - source: picture.source.to_owned(), - cols: picture.cols, - rows: picture.rows, - }; - if !wanted.contains(&key) { - wanted.push(key); - } - } - } -} diff --git a/crates/devnull/src/app/profiles.rs b/crates/devnull/src/app/profiles.rs index 9c84c49..d6ee900 100644 --- a/crates/devnull/src/app/profiles.rs +++ b/crates/devnull/src/app/profiles.rs @@ -4,7 +4,7 @@ use devnull_api::model::{AccountKind, AccountPage}; use super::feed::Source; -use super::{App, Effect, FetchError, Kind}; +use super::{Dn, Effects, FetchError, Kit}; use crate::doc::{AccountRef, Content, Cursor, Target}; use crate::render::{organization_header, pinned_block, profile_header}; @@ -24,22 +24,26 @@ fn head(page: &AccountPage, now: u64) -> Vec<(String, Content)> { } } -impl App { +impl Dn { /// Shows the profile of `account` in the current window, loading it if needed. - pub(super) fn open_profile(&mut self, account: AccountRef) -> Vec { + pub(super) fn open_profile(&mut self, kit: &mut Kit, account: AccountRef) -> Effects { let source = Source::Profile { handle: account.handle, kind: account.kind, }; - self.open_feed(source, Cursor::new()) + self.open_feed(kit, source, Cursor::new()) } /// `:profile`: the profile of `handle` (`@` or not), or without one, of the account under the cursor or of the /// author of the commit under it. - pub(super) fn profile(&mut self, handle: Option) -> Result, String> { + pub(super) fn profile( + &mut self, + kit: &mut Kit, + handle: Option, + ) -> Result { let account = match handle { Some(handle) => AccountRef::new(handle.trim_start_matches('@'), None), - None => match self.target_under_cursor() { + None => match kit.target_under_cursor() { Some(Target::Account(account)) => account, Some(Target::Commit { author: Some(author), @@ -48,7 +52,7 @@ impl App { _ => return Err("No account under the cursor".to_owned()), }, }; - Ok(self.open_profile(account)) + Ok(self.open_profile(kit, account)) } /// The connected account's own profile, as `:e profile` names it. @@ -63,28 +67,29 @@ impl App { /// asked for if they were waiting for that. pub(super) fn profile_arrived( &mut self, + kit: &mut Kit, handle: String, result: Result, now: u64, - ) -> Vec { + ) -> Effects { let kind = match &result { Ok(AccountPage::Person(_)) => Some(AccountKind::User), Ok(AccountPage::Organization(_)) => Some(AccountKind::Organization), _ => None, }; - let mut effects = self.hover_arrived(&handle, &result); + let mut effects = self.hover_arrived(kit, &handle, &result); let source = Source::Profile { handle, kind: None }; - effects.extend(self.head_arrived(&source, result.map(|page| head(&page, now)))); + effects.extend(self.head_arrived(kit, &source, result.map(|page| head(&page, now)))); // Only once it arrived: after a failure, loading would ask for the profile again straight away. if let Some(kind) = kind - && let Some(index) = self.feed_buffer(&source) + && let Some(id) = self.feed_buffer(&source) { - if let Kind::Feed(feed) = &mut self.buffers[index].kind + if let Some(feed) = self.feeds.get_mut(&id) && let Source::Profile { kind: known, .. } = &mut feed.source { *known = Some(kind); } - effects.extend(self.load(index)); + effects.extend(self.load(kit, id)); } effects } diff --git a/crates/devnull/src/app/snapshots/devnull__app__tests__dn_starts_on_the_following_log.snap b/crates/devnull/src/app/snapshots/devnull__app__tests__dn_starts_on_the_following_log.snap index 311d10a..1337441 100644 --- a/crates/devnull/src/app/snapshots/devnull__app__tests__dn_starts_on_the_following_log.snap +++ b/crates/devnull/src/app/snapshots/devnull__app__tests__dn_starts_on_the_following_log.snap @@ -1,6 +1,5 @@ --- source: crates/devnull/src/app/tests.rs -assertion_line: 930 expression: "screen(&app).rows.join(\"\\n\")" --- log/following @@ -25,4 +24,4 @@ Pinned the timezone in the fixture and deleted the weekday logic. Tom Okafor @tokafor · 1h · 5e11f02 Does anyone else use jj for everything now? Asking for @adaq. - NORMAL log/following @ada 1,1 Top + NORMAL  log/following @ada Top  1:1 diff --git a/crates/devnull/src/app/tests.rs b/crates/devnull/src/app/tests.rs index ef45263..2cc18bd 100644 --- a/crates/devnull/src/app/tests.rs +++ b/crates/devnull/src/app/tests.rs @@ -1,18 +1,276 @@ //! The app's behaviour, observed the way a user does: keys in, screen out. +//! +//! The tests drive `dn` in the kit through a facade with the shape `dn`'s app had before the kit: one message and +//! one effect type for both, so they read as they did. use ratatui::Terminal; use ratatui::backend::TestBackend; use ratatui::style::Color; +use std::time::Duration; +use wcgw_kit::app::{Wake as KitWake, WakePurpose as KitPurpose}; +use wcgw_kit::shell::{Completions, Prepared}; +use wcgw_kit::testing::Harness; use super::*; use devnull_api::model::AccountKind; use devnull_api::model::{Commit, Cursor, Page}; -use devnull_api::{Category, Log, OrganizationTab, People}; +use devnull_api::{Answering, Category, Log, OrganizationTab, People}; use crate::doc::{Content, Document, Fold, Line}; use crate::hl::Group; use crate::key::{Key, parse_keys}; +/// What a wake-up is for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WakePurpose { + KeyTimeout, + WhichKey, + Poll, + Search, + Frame, +} + +/// A wake-up: the kit's, or `dn`'s poll. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Wake { + purpose: WakePurpose, + token: Token, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Token { + Kit(KitWake), + Poll(u64), +} + +/// The kit's messages and `dn`'s, as one. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Msg { + Key(Key), + Paste(String), + Edited(Result), + Picture { + source: String, + cols: u16, + rows: u16, + result: Result, + }, + FocusGained, + FocusLost, + Wake(Wake), + Net(NetEvent), + Fetched { + fetched: Fetched, + now: u64, + }, + Sourced { + path: String, + contents: Result, + }, + Resize { + width: u16, + height: u16, + }, +} + +/// The kit's effects and `dn`'s, as one. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Effect { + Quit, + Copy(String), + Source(String), + Connect, + Unlock(String), + Enroll, + EnrollPasted(String), + AcceptTerms(String), + Picture { + source: String, + cols: u16, + rows: u16, + kitty: Option, + }, + Graphics(String), + Edit { + text: String, + }, + OpenUrl(String), + Fetch(Request), + Wake { + after: std::time::Duration, + token: Wake, + }, +} + +impl From for wcgw_kit::Msg { + fn from(msg: Msg) -> Self { + use wcgw_kit::Msg as K; + match msg { + Msg::Key(key) => K::Key(key), + Msg::Paste(text) => K::Paste(text), + Msg::Edited(saved) => K::Edited(saved), + Msg::Picture { + source, + cols, + rows, + result, + } => K::Picture { + source, + cols, + rows, + result, + }, + Msg::FocusGained => K::FocusGained, + Msg::FocusLost => K::FocusLost, + Msg::Wake(Wake { + token: Token::Kit(token), + .. + }) => K::Wake(token), + Msg::Wake(Wake { + token: Token::Poll(generation), + .. + }) => K::App(super::Msg::Poll { generation }), + Msg::Net(event) => K::App(super::Msg::Net(event)), + Msg::Fetched { fetched, now } => K::App(super::Msg::Fetched { fetched, now }), + Msg::Sourced { path, contents } => K::Sourced { path, contents }, + Msg::Resize { width, height } => K::Resize { width, height }, + } + } +} + +impl From for Effect { + fn from(effect: super::Effect) -> Self { + use wcgw_kit::Effect as K; + match effect { + K::Quit => Self::Quit, + K::Copy(text) => Self::Copy(text), + K::Source(path) => Self::Source(path), + K::Picture { + source, + cols, + rows, + kitty, + } => Self::Picture { + source, + cols, + rows, + kitty, + }, + K::Graphics(sent) => Self::Graphics(sent), + K::Edit(edit) => Self::Edit { text: edit.text }, + K::OpenUrl(url) => Self::OpenUrl(url), + K::Wake { after, token } => Self::Wake { + after, + token: Wake { + purpose: match token.purpose() { + KitPurpose::KeyTimeout => WakePurpose::KeyTimeout, + KitPurpose::WhichKey => WakePurpose::WhichKey, + KitPurpose::Search => WakePurpose::Search, + _ => WakePurpose::Frame, + }, + token: Token::Kit(token), + }, + }, + K::After { + after, + msg: super::Msg::Poll { generation }, + } => Self::Wake { + after, + token: Wake { + purpose: WakePurpose::Poll, + token: Token::Poll(generation), + }, + }, + K::After { msg, .. } => panic!("dn sends nothing else later: {msg:?}"), + K::CaptureMouse(on) => panic!("dn leaves the mouse alone, yet asked for {on}"), + K::Shell(shell) => panic!("dn runs no shell commands, yet asked for {shell:?}"), + K::Notify { title, .. } => panic!("dn notifies nothing, yet asked to say {title}"), + K::App(Io::Connect) => Self::Connect, + K::App(Io::Unlock(passphrase)) => Self::Unlock(passphrase), + K::App(Io::Enroll) => Self::Enroll, + K::App(Io::EnrollPasted(link)) => Self::EnrollPasted(link), + K::App(Io::AcceptTerms(version)) => Self::AcceptTerms(version), + K::App(Io::Fetch(request)) => Self::Fetch(request), + } + } +} + +/// A picture prepared for the kitty protocol as `escapes`, sent in band. +fn kitty_sent(escapes: impl Into) -> Prepared { + Prepared::Kitty { + escapes: escapes.into(), + staged: None, + bytes: 0, + } +} + +/// `dn` in the kit, driven as the app was. +#[derive(Debug)] +struct App(Harness); + +impl App { + /// A fresh session, connecting. + fn new() -> Self { + let (dn, kit) = Dn::session(); + Self(Harness::new(dn, kit, 80, 24)) + } + + /// A fresh session showing `doc` as a buffer called `name`. + fn with_buffer(name: &str, doc: Document) -> Self { + Self(Harness::new(Dn::new(), Dn::kit(name, doc, true), 80, 24)) + } + + fn update(&mut self, msg: Msg) -> Vec { + self.0 + .update(msg.into()) + .into_iter() + .map(Effect::from) + .collect() + } + + fn start(&mut self) -> Vec { + let Harness { app, kit, .. } = &mut self.0; + kit.start(app).into_iter().map(Effect::from).collect() + } + + fn render(&self, frame: &mut ratatui::Frame<'_>) { + self.0.kit.render(&self.0.app, frame); + } + + fn size(&self) -> (u16, u16) { + self.0.kit.size() + } + + fn is_focused(&self) -> bool { + self.0.kit.is_focused() + } + + fn style(&self, group: Group) -> ratatui::style::Style { + self.0.kit.style(group) + } + + fn doc_mut(&mut self) -> &mut Document { + self.0.kit.current_doc_mut() + } + + fn set_graphics(&mut self, graphics: wcgw_kit::gfx::Graphics) -> Vec { + self.0 + .kit + .set_graphics(graphics) + .into_iter() + .map(Effect::from) + .collect() + } + + fn set_cell_size(&mut self, width: u16, height: u16) { + self.0.kit.set_cell_size(width, height); + } + + fn ex_completions(&self, line: &str) -> Completions { + self.0.kit.ex_completions(&self.0.app, line) + } +} + /// An app showing one buffer of `lines`, in a `width` × `height` terminal. fn app_with(lines: &[&str], width: u16, height: u16) -> App { let mut doc = Document::new(); @@ -235,7 +493,7 @@ fn the_screen_has_a_bufferline_a_statusline_and_a_command_line() { one two three ~ - NORMAL test 2,5 All + NORMAL  test All  2:5 "); } @@ -402,17 +660,19 @@ fn ignorecase_and_smartcase_apply_to_searches() { } #[test] -fn pending_keys_show_at_the_right_of_the_command_line() { +fn pending_keys_show_at_the_right_of_the_command_line_with_showcmd() { let mut app = app_with(&["one"], 30, 6); let _ = press(&mut app, "3g"); + assert_eq!(cmdline(&app), "", "noshowcmd by default, as the kit's"); + let _ = press(&mut app, ":set showcmd3g"); assert!(cmdline(&app).ends_with("3g"), "{:?}", cmdline(&app)); } /// The background a cell of plain text gets: the theme's, else the terminal's own. fn plain_bg(app: &App) -> Option { - app.style(crate::hl::Group::Normal) + app.style(crate::hl::Group::NORMAL) .bg .or(Some(Color::Reset)) } @@ -446,7 +706,7 @@ fn the_selection_is_highlighted() { // A cell is highlighted when patching it with the Visual style changes nothing. let highlighted = |x| { let style = style_at(&app, x, 1); - style.patch(app.style(Group::Visual)) == style + style.patch(app.style(Group::VISUAL)) == style }; assert!(highlighted(0)); assert!(highlighted(1)); @@ -727,6 +987,8 @@ fn which_key_marks_groups_of_further_keys() { #[test] fn split_stacks_two_windows_on_the_same_buffer_with_their_own_cursors() { let mut app = app_with(&["one", "two", "three"], 30, 12); + // A statusline each, as Vim's default has it; see the next test for the kit's. + let _ = press(&mut app, ":set laststatus=2"); let _ = press(&mut app, ":sp"); let rows = screen(&app).rows; @@ -748,6 +1010,29 @@ fn split_stacks_two_windows_on_the_same_buffer_with_their_own_cursors() { assert_eq!(screen(&app).cursor, (0, 3)); } +#[test] +fn with_one_statusline_for_all_a_line_separates_stacked_windows() { + let mut app = app_with(&["one", "two", "three"], 30, 12); + + let _ = press(&mut app, ":sp"); + + let rows = screen(&app).rows; + assert_eq!(rows[1..4], ["one", "two", "three"]); + assert_eq!(rows[5], "─".repeat(30), "{rows:#?}"); + assert_eq!(rows[6..9], ["one", "two", "three"]); + assert!( + rows[10].contains("NORMAL") && rows[10].contains("test"), + "{rows:#?}" + ); + assert_eq!(statuslines(&app), 1); + let _ = press(&mut app, "jjj"); + assert_eq!(screen(&app).cursor, (0, 6)); + assert!( + screen(&app).rows[10].contains("1:1"), + "the current window's" + ); +} + #[test] fn vsplit_puts_windows_side_by_side_with_a_separator() { let mut app = app_with(&["one", "two"], 21, 8); @@ -765,13 +1050,15 @@ fn vsplit_puts_windows_side_by_side_with_a_separator() { #[test] fn side_by_side_statuslines_meet_under_the_separator_as_in_vim() { let mut app = app_with(&["one", "two"], 21, 8); + let _ = press(&mut app, ":set laststatus=2"); let status = |app: &App, x: u16| (style_at(app, x, 6).fg, style_at(app, x, 6).bg); let _ = press(&mut app, "v"); assert_eq!(screen(&app).rows[6].chars().nth(10), Some(' ')); + let line = app.style(Group::STATUS_LINE); assert_eq!( status(&app, 10), - status(&app, 9), + (line.fg, line.bg), "the current window's statusline, on the left, goes on" ); let _ = press(&mut app, "l"); @@ -802,6 +1089,7 @@ fn cursor_line(app: &App) -> String { #[test] fn help_lists_the_key_bindings_in_a_window_above() { let mut app = app_with(&["one"], 80, 30); + let _ = press(&mut app, ":set laststatus=2"); let _ = press(&mut app, ":help"); @@ -888,6 +1176,7 @@ fn close_and_only_remove_windows_but_never_the_last() { #[test] fn q_closes_a_window_and_quits_only_with_the_last() { let mut app = app_with(&["one"], 60, 12); + let _ = press(&mut app, ":set laststatus=2"); let _ = press(&mut app, ":vs"); assert_eq!(statuslines(&app), 2); @@ -901,7 +1190,7 @@ fn statuslines(app: &App) -> usize { screen(app) .rows .iter() - .map(|row| row.matches("1,1").count()) + .map(|row| row.matches("1:1").count()) .sum() } @@ -990,10 +1279,19 @@ fn tab_completes_the_arguments_each_command_takes() { assert_eq!(completed(&app, "set gr"), ["graphics"]); assert_eq!( completed(&app, "set no"), - ["nohlsearch", "noignorecase", "noincsearch", "nosmartcase"], + [ + "nohlsearch", + "noignorecase", + "noincsearch", + "noshowcmd", + "nosmartcase" + ], "the flags that turn off" ); - assert_eq!(completed(&app, "se hls graphics="), ["halfblocks", "kitty"]); + assert_eq!( + completed(&app, "se hls graphics="), + ["halfblocks", "kitty", "off"] + ); assert_eq!(app.ex_completions("set graphics=k").start, 13); assert_eq!( completed(&app, "colo tokyonight-"), @@ -1089,8 +1387,8 @@ fn the_matches_show_in_a_menu_above_the_command_line_the_chosen_one_marked() { ) .bg }; - assert_eq!(cell(moon), app.style(Group::PmenuSel).bg); - assert_eq!(cell(day), app.style(Group::Pmenu).bg); + assert_eq!(cell(moon), app.style(Group::PMENU_SEL).bg); + assert_eq!(cell(day), app.style(Group::PMENU).bg); let _ = press(&mut app, "x"); assert!( !screen(&app) @@ -1106,10 +1404,10 @@ fn colorscheme_shows_and_switches_the_colorscheme() { let mut app = app_with(&["one"], 40, 6); let _ = press(&mut app, ":colorscheme"); - assert_eq!(cmdline(&app), "tokyonight-night"); - let night = style_at(&app, 0, 1).fg; + assert_eq!(cmdline(&app), "tokyonight-moon"); + let moon = style_at(&app, 0, 1).fg; let _ = press(&mut app, ":colo tokyonight-day"); - assert_ne!(style_at(&app, 0, 1).fg, night); + assert_ne!(style_at(&app, 0, 1).fg, moon); let _ = press(&mut app, ":colo nope"); assert_eq!(cmdline(&app), "E185: Cannot find color scheme 'nope'"); } @@ -1123,7 +1421,7 @@ fn hi_restyles_a_group_on_screen_and_hi_clear_restores_it() { assert_eq!(style_at(&app, 10, 1).bg, Some(Color::Rgb(0, 0, 0))); let _ = press(&mut app, ":hi Normal"); assert!( - cmdline(&app).starts_with("Normal xxx guifg=#c0caf5 guibg=#000000"), + cmdline(&app).starts_with("Normal xxx guifg=#c8d3f5 guibg=#000000"), "{:?}", cmdline(&app) ); @@ -1139,8 +1437,8 @@ fn the_statusline_mode_has_its_own_colours() { let _ = press(&mut app, "v"); let visual = style_at(&app, 1, 4); - assert_eq!(normal.bg, app.style(Group::StatusLineModeNormal).bg); - assert_eq!(visual.bg, app.style(Group::StatusLineModeVisual).bg); + assert_eq!(normal.bg, app.style(Group::STATUS_LINE_MODE_NORMAL).bg); + assert_eq!(visual.bg, app.style(Group::STATUS_LINE_MODE_VISUAL).bg); } #[test] @@ -2541,7 +2839,31 @@ fn one_commit(text: &str, extra: serde_json::Value) -> Page { } #[test] -fn gx_opens_the_link_under_the_cursor_and_nothing_else() { +fn a_commit_is_laid_out_no_wider_than_textwidth_however_wide_the_window() { + let mut app = with_log( + Log::Following, + one_commit(&"words ".repeat(60), serde_json::json!({})), + ); + let _ = app.update(Msg::Resize { + width: 200, + height: 20, + }); + + let widest = screen(&app) + .rows + .iter() + .filter(|row| row.contains("words")) + .map(|row| unicode_width::UnicodeWidthStr::width(row.trim_end())) + .max() + .unwrap_or(0); + assert!( + (101..=120).contains(&widest), + "as wide as 'textwidth', 120, in a window of 200: {widest}" + ); +} + +#[test] +fn gx_opens_the_link_under_the_cursor_else_the_commit_s_only_one() { let mut app = with_log( Log::Following, one_commit("see https://example.org/x", serde_json::json!({})), @@ -2551,8 +2873,39 @@ fn gx_opens_the_link_under_the_cursor_and_nothing_else() { without_wakes(press(&mut app, "j$gx")), [Effect::OpenUrl("https://example.org/x".into())] ); - assert_eq!(without_wakes(press(&mut app, "0gx")), []); - assert_eq!(cmdline(&app), "No link under the cursor"); + assert_eq!( + without_wakes(press(&mut app, "0gx")), + [Effect::OpenUrl("https://example.org/x".into())], + "off it, the commit's only link, as in muttrs and slackers" + ); + + let mut app = with_log(Log::Following, one_commit("no link", serde_json::json!({}))); + assert_eq!(without_wakes(press(&mut app, "jgx")), []); + assert_eq!( + cmdline(&app), + "No link under the cursor, nor in what holds it" + ); +} + +#[test] +fn gx_off_a_link_lists_the_commit_s_to_open_or_yank() { + let mut app = with_log( + Log::Following, + one_commit( + "see https://example.org/x and https://example.org/y", + serde_json::json!({}), + ), + ); + + assert_eq!(without_wakes(press(&mut app, "j0gx")), []); + for text in ["Links", "https://example.org/x", "https://example.org/y"] { + assert!(shows(&app, text), "{text}: {:#?}", screen(&app).rows); + } + assert_eq!( + without_wakes(press(&mut app, "")), + [Effect::Copy("https://example.org/x".into())] + ); + assert!(!shows(&app, "Links"), "yanked, the list goes"); } #[test] @@ -2654,7 +3007,7 @@ fn k_on_a_mention_shows_the_account_s_card_until_the_next_key() { #[test] fn k_shows_the_account_s_avatar_in_its_card() { let mut app = with_log(Log::Following, mentioning_lin()); - let _ = app.set_graphics(crate::graphics::Graphics::HalfBlocks); + let _ = app.set_graphics(wcgw_kit::gfx::Graphics::HalfBlocks); let _ = press(&mut app, "j$K"); let profile = serde_json::from_value(serde_json::json!({ "data": { "id": "u2", "handle": "lin", "name": "Lin", "avatar_url": "https://example.org/lin.png", @@ -2676,7 +3029,7 @@ fn k_shows_the_account_s_avatar_in_its_card() { else { panic!("the avatar asked for: {asked:?}"); }; - let red = crate::graphics::HalfBlock { + let red = wcgw_kit::gfx::HalfBlock { upper: Some((255, 0, 0)), lower: Some((255, 0, 0)), }; @@ -2737,6 +3090,11 @@ fn k_on_a_link_card_shows_it_whole() { [Effect::OpenUrl("https://example.org/post".into())], "a link card is a link" ); + assert_eq!( + without_wakes(press(&mut app, "gy")), + [Effect::Copy("https://example.org/post".into())], + "gy yanks what gx opens" + ); assert_eq!( press(&mut app, ""), [Effect::OpenUrl("https://example.org/post".into())], @@ -3034,7 +3392,7 @@ fn the_picker_moves_with_ctrl_n_and_ctrl_p_and_escape_leaves_it() { #[test] fn the_user_picker_searches_once_typing_pauses_and_opens_who_is_picked() { let mut app = with_log(Log::Following, page_of(1, 1, None)); - let _ = press(&mut app, "/"); + let _ = press(&mut app, "ff"); assert!(shows(&app, "Type to find people and organizations")); let first = press(&mut app, "a"); @@ -3076,7 +3434,7 @@ fn the_user_picker_searches_once_typing_pauses_and_opens_who_is_picked() { #[test] fn answers_to_an_older_search_are_ignored() { let mut app = with_log(Log::Following, page_of(1, 1, None)); - let _ = press(&mut app, "/ad"); + let _ = press(&mut app, "ffad"); let found = serde_json::from_value( serde_json::json!({ "users": [{ "id": "u1", "handle": "a", "name": "Old" }] }), @@ -3486,6 +3844,16 @@ fn following_someone_whose_profile_is_open_refreshes_it() { })); } +#[test] +fn delete_takes_three_letters_as_in_muttrs() { + let mut app = with_log(Log::Following, page_of(1, 2, None)); + + let _ = press(&mut app, ":d"); + assert_eq!(cmdline(&app), "E492: Not an editor command: d"); + let _ = press(&mut app, ":del"); + assert_eq!(cmdline(&app), "Delete commit c1? (y/n)"); +} + #[test] fn dd_deletes_your_own_commit_once_confirmed() { let mut app = with_log(Log::Following, page_of(1, 2, None)); @@ -3857,7 +4225,7 @@ fn a_commit_published_as_someone_else_than_asked_says_so() { let (_, height) = app.size(); assert_eq!( style_at(&app, 0, height - 1).fg, - app.style(Group::ErrorMsg).fg, + app.style(Group::ERROR_MSG).fg, "said as an error" ); } @@ -3991,7 +4359,7 @@ fn with_picture() -> Page { /// A connected app on following, drawing pictures as `graphics` does, and the effects of `page` arriving: tall enough /// that the window has room for `with_picture`'s 48 × 12. -fn pictured(graphics: crate::graphics::Graphics, page: Page) -> (App, Vec) { +fn pictured(graphics: wcgw_kit::gfx::Graphics, page: Page) -> (App, Vec) { let mut app = with_log(Log::Following, page_of(1, 0, None)); let _ = app.update(Msg::Resize { width: 60, @@ -4026,8 +4394,8 @@ fn pictures(effects: &[Effect]) -> Vec<(String, u16, u16, Option)> { } /// A still picture of `cells`, drawn with half blocks. -fn still(cells: Vec>) -> Prepared { - Prepared::HalfBlocks(vec![crate::graphics::HalfBlockFrame { +fn still(cells: Vec>) -> Prepared { + Prepared::HalfBlocks(vec![wcgw_kit::gfx::HalfBlockFrame { cells, delay: Duration::ZERO, }]) @@ -4035,7 +4403,7 @@ fn still(cells: Vec>) -> Prepared { #[test] fn a_picture_in_view_is_asked_for_once_and_drawn_with_half_blocks() { - let (mut app, effects) = pictured(crate::graphics::Graphics::HalfBlocks, with_picture()); + let (mut app, effects) = pictured(wcgw_kit::gfx::Graphics::HalfBlocks, with_picture()); assert_eq!( pictures(&effects), @@ -4049,7 +4417,7 @@ fn a_picture_in_view_is_asked_for_once_and_drawn_with_half_blocks() { assert_eq!(loading, 1, "said once, in its first row"); assert_eq!(pictures(&press(&mut app, "j")), [], "asked for once"); - let red = crate::graphics::HalfBlock { + let red = wcgw_kit::gfx::HalfBlock { upper: Some((255, 0, 0)), lower: Some((0, 0, 255)), }; @@ -4077,10 +4445,10 @@ fn animated(frames: &[((u8, u8, u8), u64)]) -> Prepared { Prepared::HalfBlocks( frames .iter() - .map(|&(colour, delay)| crate::graphics::HalfBlockFrame { + .map(|&(colour, delay)| wcgw_kit::gfx::HalfBlockFrame { cells: vec![ vec![ - crate::graphics::HalfBlock { + wcgw_kit::gfx::HalfBlock { upper: Some(colour), lower: Some(colour), }; @@ -4124,7 +4492,7 @@ fn picture_arrives(app: &mut App, prepared: Prepared) -> Vec { #[test] fn an_animation_drawn_with_half_blocks_moves_on_after_each_frame_s_delay() { - let (mut app, _) = pictured(crate::graphics::Graphics::HalfBlocks, with_picture()); + let (mut app, _) = pictured(wcgw_kit::gfx::Graphics::HalfBlocks, with_picture()); let red = Some(Color::Rgb(255, 0, 0)); let blue = Some(Color::Rgb(0, 0, 255)); @@ -4157,7 +4525,7 @@ fn animations_in_view_move_on_each_after_its_own_delay() { "author": { "id": "u", "handle": "ada", "name": "Ada", "avatar_url": "https://example.org/ada.png" }, }), ); - let (mut app, effects) = pictured(crate::graphics::Graphics::HalfBlocks, page); + let (mut app, effects) = pictured(wcgw_kit::gfx::Graphics::HalfBlocks, page); let asked = pictures(&effects); let Some((avatar, cols, rows, _)) = asked .iter() @@ -4165,10 +4533,10 @@ fn animations_in_view_move_on_each_after_its_own_delay() { else { panic!("an avatar: {asked:?}"); }; - let frames = |colour, delay| crate::graphics::HalfBlockFrame { + let frames = |colour, delay| wcgw_kit::gfx::HalfBlockFrame { cells: vec![ vec![ - crate::graphics::HalfBlock { + wcgw_kit::gfx::HalfBlock { upper: Some(colour), lower: None }; @@ -4208,7 +4576,7 @@ fn animations_in_view_move_on_each_after_its_own_delay() { #[test] fn an_animation_out_of_view_waits_until_it_is_back() { - let (mut app, _) = pictured(crate::graphics::Graphics::HalfBlocks, with_picture()); + let (mut app, _) = pictured(wcgw_kit::gfx::Graphics::HalfBlocks, with_picture()); let effects = picture_arrives(&mut app, animated(&[((255, 0, 0), 50), ((0, 0, 255), 120)])); let [(_, token)] = frame_wakes(&effects)[..] else { panic!("one wake-up: {effects:?}"); @@ -4231,10 +4599,10 @@ fn an_animation_out_of_view_waits_until_it_is_back() { #[test] fn still_pictures_and_animations_the_terminal_runs_need_no_wake_ups() { - let (mut app, _) = pictured(crate::graphics::Graphics::HalfBlocks, with_picture()); + let (mut app, _) = pictured(wcgw_kit::gfx::Graphics::HalfBlocks, with_picture()); let effects = picture_arrives(&mut app, animated(&[((255, 0, 0), 50)])); assert_eq!(frame_wakes(&effects), [], "one frame"); - let (mut app, _) = pictured(crate::graphics::Graphics::HalfBlocks, with_picture()); + let (mut app, _) = pictured(wcgw_kit::gfx::Graphics::HalfBlocks, with_picture()); let still = animated(&[((255, 0, 0), 0), ((0, 0, 255), 0)]); assert_eq!( frame_wakes(&picture_arrives(&mut app, still)), @@ -4242,7 +4610,7 @@ fn still_pictures_and_animations_the_terminal_runs_need_no_wake_ups() { "no time to move" ); - let (mut app, effects) = pictured(crate::graphics::Graphics::Kitty, with_picture()); + let (mut app, effects) = pictured(wcgw_kit::gfx::Graphics::Kitty, with_picture()); let [(_, cols, rows, Some(id))] = pictures(&effects)[..] else { panic!("one picture with an id: {effects:?}"); }; @@ -4250,14 +4618,14 @@ fn still_pictures_and_animations_the_terminal_runs_need_no_wake_ups() { source: "https://example.org/p.png".into(), cols, rows, - result: Ok(Prepared::Kitty(format!("frames of {id}"))), + result: Ok(kitty_sent(format!("frames of {id}"))), }); assert_eq!(frame_wakes(&effects), []); } #[test] fn a_wake_up_for_pictures_since_forgotten_is_ignored() { - let (mut app, _) = pictured(crate::graphics::Graphics::HalfBlocks, with_picture()); + let (mut app, _) = pictured(wcgw_kit::gfx::Graphics::HalfBlocks, with_picture()); let effects = picture_arrives(&mut app, animated(&[((255, 0, 0), 50), ((0, 0, 255), 120)])); let [(_, stale)] = frame_wakes(&effects)[..] else { panic!("one wake-up: {effects:?}"); @@ -4278,7 +4646,7 @@ fn a_wake_up_for_pictures_since_forgotten_is_ignored() { fn a_picture_taller_than_the_window_shrinks_to_fit_it() { // 60 × 12: nine rows of text area, so the 48 × 12 picture shrinks, keeping its shape. let mut app = with_log(Log::Following, page_of(1, 0, None)); - app.set_graphics(crate::graphics::Graphics::HalfBlocks); + app.set_graphics(wcgw_kit::gfx::Graphics::HalfBlocks); let effects = press(&mut app, ":e!"); let effects = [ @@ -4326,7 +4694,7 @@ fn thread_with_picture( #[test] fn a_commit_s_own_buffer_shows_its_picture_at_its_own_size_or_as_large_as_the_window_allows() { let mut app = with_log(Log::Following, page_of(1, 1, None)); - app.set_graphics(crate::graphics::Graphics::HalfBlocks); + app.set_graphics(wcgw_kit::gfx::Graphics::HalfBlocks); let _ = app.update(Msg::Resize { width: 100, height: 30, @@ -4338,7 +4706,7 @@ fn a_commit_s_own_buffer_shows_its_picture_at_its_own_size_or_as_large_as_the_wi ); let mut app = with_log(Log::Following, page_of(1, 1, None)); - app.set_graphics(crate::graphics::Graphics::HalfBlocks); + app.set_graphics(wcgw_kit::gfx::Graphics::HalfBlocks); let _ = app.update(Msg::Resize { width: 60, height: 24, @@ -4350,7 +4718,7 @@ fn a_commit_s_own_buffer_shows_its_picture_at_its_own_size_or_as_large_as_the_wi ); let mut app = with_log(Log::Following, page_of(1, 1, None)); - app.set_graphics(crate::graphics::Graphics::HalfBlocks); + app.set_graphics(wcgw_kit::gfx::Graphics::HalfBlocks); app.set_cell_size(20, 40); let _ = app.update(Msg::Resize { width: 100, @@ -4365,7 +4733,7 @@ fn a_commit_s_own_buffer_shows_its_picture_at_its_own_size_or_as_large_as_the_wi #[test] fn with_kitty_graphics_a_picture_is_sent_once_and_shown_by_placeholders() { - let (mut app, effects) = pictured(crate::graphics::Graphics::Kitty, with_picture()); + let (mut app, effects) = pictured(wcgw_kit::gfx::Graphics::Kitty, with_picture()); let [(_, cols, rows, Some(id))] = pictures(&effects)[..] else { panic!("one picture with an id: {effects:?}"); }; @@ -4374,13 +4742,13 @@ fn with_kitty_graphics_a_picture_is_sent_once_and_shown_by_placeholders() { source: "https://example.org/p.png".into(), cols, rows, - result: Ok(Prepared::Kitty("".into())), + result: Ok(kitty_sent("")), }); assert_eq!(effects, [Effect::Graphics("".into())]); let rows = screen(&app).rows; assert!(rows[3].starts_with('\u{10EEEE}'), "{rows:#?}"); - let (r, g, b) = crate::graphics::image_colour(id); + let (r, g, b) = wcgw_kit::gfx::image_colour(id); assert_eq!(style_at(&app, 0, 3).fg, Some(Color::Rgb(r, g, b))); } @@ -4391,7 +4759,7 @@ fn a_picture_out_of_view_is_asked_for_once_scrolled_to() { // Not c1: that one is in the page already. last.id = "c9".into(); page.items.push(last); - let (mut app, effects) = pictured(crate::graphics::Graphics::HalfBlocks, page); + let (mut app, effects) = pictured(wcgw_kit::gfx::Graphics::HalfBlocks, page); assert_eq!(pictures(&effects), []); let effects = press(&mut app, "G"); @@ -4401,7 +4769,7 @@ fn a_picture_out_of_view_is_asked_for_once_scrolled_to() { #[test] fn a_picture_that_cannot_be_had_says_so_in_its_place() { - let (mut app, _) = pictured(crate::graphics::Graphics::HalfBlocks, with_picture()); + let (mut app, _) = pictured(wcgw_kit::gfx::Graphics::HalfBlocks, with_picture()); let _ = app.update(Msg::Picture { source: "https://example.org/p.png".into(), @@ -4415,7 +4783,7 @@ fn a_picture_that_cannot_be_had_says_so_in_its_place() { #[test] fn switching_graphics_frees_what_was_sent_and_draws_pictures_again() { - let (mut app, effects) = pictured(crate::graphics::Graphics::Kitty, with_picture()); + let (mut app, effects) = pictured(wcgw_kit::gfx::Graphics::Kitty, with_picture()); let [(_, cols, rows, Some(id))] = pictures(&effects)[..] else { panic!("one picture: {effects:?}"); }; @@ -4423,12 +4791,12 @@ fn switching_graphics_frees_what_was_sent_and_draws_pictures_again() { source: "https://example.org/p.png".into(), cols, rows, - result: Ok(Prepared::Kitty(String::new())), + result: Ok(kitty_sent(String::new())), }); let effects = press(&mut app, ":set graphics=halfblocks"); - assert!(effects.contains(&Effect::Graphics(crate::graphics::kitty_delete(id)))); + assert!(effects.contains(&Effect::Graphics(wcgw_kit::gfx::kitty_delete(id)))); assert_eq!( pictures(&effects), [("https://example.org/p.png".to_owned(), 48, 12, None)] @@ -4439,7 +4807,7 @@ fn switching_graphics_frees_what_was_sent_and_draws_pictures_again() { #[test] fn quitting_frees_the_pictures_sent() { - let (mut app, effects) = pictured(crate::graphics::Graphics::Kitty, with_picture()); + let (mut app, effects) = pictured(wcgw_kit::gfx::Graphics::Kitty, with_picture()); let [(_, cols, rows, Some(id))] = pictures(&effects)[..] else { panic!("one picture: {effects:?}"); }; @@ -4447,7 +4815,7 @@ fn quitting_frees_the_pictures_sent() { source: "https://example.org/p.png".into(), cols, rows, - result: Ok(Prepared::Kitty(String::new())), + result: Ok(kitty_sent(String::new())), }); let effects = press(&mut app, ":qa"); @@ -4455,7 +4823,7 @@ fn quitting_frees_the_pictures_sent() { assert_eq!( effects, [ - Effect::Graphics(crate::graphics::kitty_delete(id)), + Effect::Graphics(wcgw_kit::gfx::kitty_delete(id)), Effect::Quit ] ); @@ -4470,7 +4838,7 @@ fn an_avatar_beside_a_name_is_asked_for_and_drawn_in_its_cells() { None, ); - let (mut app, effects) = pictured(crate::graphics::Graphics::HalfBlocks, page); + let (mut app, effects) = pictured(wcgw_kit::gfx::Graphics::HalfBlocks, page); assert_eq!( pictures(&effects), @@ -4480,7 +4848,7 @@ fn an_avatar_beside_a_name_is_asked_for_and_drawn_in_its_cells() { screen(&app).rows[1].contains(" Ada @ada"), "the name shows while the avatar loads" ); - let face = crate::graphics::HalfBlock { + let face = wcgw_kit::gfx::HalfBlock { upper: Some((1, 2, 3)), lower: Some((4, 5, 6)), }; @@ -4505,7 +4873,7 @@ fn an_avatar_further_along_a_row_is_drawn_where_it_is() { .expect("notification"); let (mut app, _) = starting(); let _ = net(&mut app, NetEvent::Connected(me("ada", true))); - app.set_graphics(crate::graphics::Graphics::HalfBlocks); + app.set_graphics(wcgw_kit::gfx::Graphics::HalfBlocks); let _ = press(&mut app, "n"); let inbox = devnull_api::model::Inbox::new(Page::new(vec![follow], None), "2026-11-12T15:13:40Z"); @@ -4518,7 +4886,7 @@ fn an_avatar_further_along_a_row_is_drawn_where_it_is() { }, ); - let face = crate::graphics::HalfBlock { + let face = wcgw_kit::gfx::HalfBlock { upper: Some((1, 2, 3)), lower: Some((4, 5, 6)), }; @@ -4608,7 +4976,7 @@ fn jumps_into_another_buffer_count_rows_with_the_folds_left_there() { let ruler = statusline(&app); let row = ruler .split_whitespace() - .find_map(|word| word.split_once(',').map(|(row, _)| row.to_owned())) + .find_map(|word| word.split_once(':').map(|(row, _)| row.to_owned())) .expect("a ruler"); let _ = press(&mut app, ":e log/latest:ju"); @@ -4635,8 +5003,11 @@ fn a_narrow_statusline_keeps_the_ruler_and_shortens_the_name_from_the_left() { height: 12, }); - // " NORMAL log/following @ada 1,1 All " is 39 cells: the handle goes, then the name's start. - assert_eq!(statusline(&app), " NORMAL "); - assert_eq!(app.style(Group::Handle), app.style(Group::ErrorMsg)); + assert_eq!(app.style(crate::hl::HANDLE), app.style(Group::ERROR_MSG)); let _ = press(&mut app, ":hi DevnullHandle"); assert_eq!(cmdline(&app), "DevnullHandle xxx links to ErrorMsg"); let _ = press(&mut app, ":hi link DevnullHandle NONE"); - assert_eq!(app.style(Group::Handle), own); + assert_eq!(app.style(crate::hl::HANDLE), own); let _ = press( &mut app, ":hi link DevnullHandle ErrorMsg:hi DevnullHandle gui=bold", ); assert_ne!( - app.style(Group::Handle), - app.style(Group::ErrorMsg), + app.style(crate::hl::HANDLE), + app.style(Group::ERROR_MSG), "restyling a linked group unlinks it" ); let _ = press(&mut app, ":hi link DevnullHash ErrorMsg:hi clear"); - assert_ne!(app.style(Group::Hash), app.style(Group::ErrorMsg)); + assert_ne!(app.style(crate::hl::HASH), app.style(Group::ERROR_MSG)); let _ = press(&mut app, ":hi link Nope ErrorMsg"); assert_eq!(cmdline(&app), "E411: Highlight group not found: Nope"); } #[test] fn groups_linked_in_a_loop_still_draw() { - use crate::hl::Group; let mut app = app_with(&["one"], 40, 6); let _ = press( @@ -4688,7 +5058,7 @@ fn groups_linked_in_a_loop_still_draw() { ":hi link DevnullHash DevnullHandle:hi link DevnullHandle DevnullHash", ); - let _ = app.style(Group::Hash); + let _ = app.style(crate::hl::HASH); let _ = screen(&app); } @@ -4701,17 +5071,17 @@ fn hlsearch_shows_the_last_search_s_matches_and_noh_hides_them_until_the_next() assert_eq!( style_at(&app, 4, 1).bg, - app.style(Group::CurSearch).bg, + app.style(Group::CUR_SEARCH).bg, "under the cursor" ); - assert_eq!(style_at(&app, 4, 2).bg, app.style(Group::Search).bg); + assert_eq!(style_at(&app, 4, 2).bg, app.style(Group::SEARCH).bg); assert_eq!(style_at(&app, 0, 2).bg, plain_bg(&app)); let _ = press(&mut app, ":noh"); assert_eq!(style_at(&app, 4, 2).bg, plain_bg(&app)); let _ = press(&mut app, "n"); assert_eq!( style_at(&app, 4, 1).bg, - app.style(Group::Search).bg, + app.style(Group::SEARCH).bg, "back, the cursor on the other match" ); let _ = press(&mut app, ":set nohls"); @@ -4735,7 +5105,7 @@ fn incsearch_shows_where_a_search_being_typed_goes_and_escape_goes_back() { .expect("scrolled to the match"); assert_eq!( style_at(&app, 2, at as u16).bg, - app.style(Group::IncSearch).bg + app.style(Group::INC_SEARCH).bg ); let _ = press(&mut app, ""); assert!(shows(&app, "line 1"), "back where it was"); @@ -4753,7 +5123,7 @@ fn a_match_is_highlighted_whole_and_on_the_rows_it_wraps_to_only() { assert_eq!(style_at(&app, 5, 1).bg, plain_bg(&app)); assert_eq!( style_at(&app, 3, 2).bg, - app.style(Group::CurSearch).bg, + app.style(Group::CUR_SEARCH).bg, "the whole match" ); } @@ -4770,7 +5140,7 @@ fn star_shows_the_matches_again_after_noh_and_noincsearch_keeps_the_view() { assert!(shows(&app, "line 1") && !shows(&app, "a needle here")); let _ = press(&mut app, "/line:noh*"); - assert_eq!(style_at(&app, 0, 1).bg, app.style(Group::Search).bg); + assert_eq!(style_at(&app, 0, 1).bg, app.style(Group::SEARCH).bg); } #[test] @@ -4793,7 +5163,7 @@ fn the_buffer_picker_shows_which_letters_matched() { u16::try_from(y).expect("y"), ) }; - let matched = app.style(Group::PmenuMatch).fg; + let matched = app.style(Group::PMENU_MATCH).fg; // l, g, the f of "following", then its l. assert_eq!([0, 2, 4, 6].map(|dx| style(dx).fg), [matched; 4]); assert_ne!(style(1).fg, matched); diff --git a/crates/devnull/src/app/threads.rs b/crates/devnull/src/app/threads.rs index e6c5659..163a3a0 100644 --- a/crates/devnull/src/app/threads.rs +++ b/crates/devnull/src/app/threads.rs @@ -3,7 +3,7 @@ use devnull_api::model::Thread; use super::feed::Source; -use super::{App, Effect}; +use super::{Dn, Effects, Kit}; use crate::doc::{Content, Cursor}; use crate::render::{commit_block, focused_block}; @@ -39,9 +39,9 @@ pub(super) fn head(thread: &Thread, now: u64, cell: (u16, u16)) -> Vec<(String, .collect() } -impl App { +impl Dn { /// Shows the thread about commit `id` in the current window, at the commit, loading it if needed. - pub(super) fn open_thread(&mut self, id: &str) -> Vec { - self.open_feed(Source::Thread(id.to_owned()), Cursor::on_block(id)) + pub(super) fn open_thread(&mut self, kit: &mut Kit, id: &str) -> Effects { + self.open_feed(kit, Source::Thread(id.to_owned()), Cursor::on_block(id)) } } diff --git a/crates/devnull/src/app/view.rs b/crates/devnull/src/app/view.rs deleted file mode 100644 index 17e065a..0000000 --- a/crates/devnull/src/app/view.rs +++ /dev/null @@ -1,593 +0,0 @@ -//! Drawing the app: bufferline, windows with their statuslines, and the command line. - -use ratatui::Frame; -use ratatui::layout::{Position, Rect}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Clear, Paragraph}; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; - -use super::cmdline::Shown; -use super::window::{Viewport, Window}; -use super::windows::Separator; -use super::{App, AppMode, Kind, Message, MessageKind, text_area}; -use crate::doc::{Layout, SelectionKind}; -use crate::hl::Group; - -/// The width of the area showing pending keys, at the right of the command line, as Vim's `'showcmd'`. -const SHOWCMD_WIDTH: u16 = 10; - -impl App { - /// Draws the whole screen. - pub fn render(&self, frame: &mut Frame<'_>) { - let (width, height) = self.size; - let screen = frame.area(); - frame - .buffer_mut() - .set_style(screen, self.theme.style(Group::Normal)); - let current = self.windows.current().buffer; - // The listed buffers, or the current one alone while it is a status page such as `connect`. - let shown = if matches!(self.buffers[current].kind, Kind::Status) { - vec![current] - } else { - self.listed() - }; - let tabs: Vec> = shown - .into_iter() - .map(|index| { - let group = if index == current { - Group::TabLineSel - } else { - Group::TabLine - }; - Span::styled( - format!(" {} ", self.buffers[index].name), - self.theme.style(group), - ) - }) - .collect(); - let bufferline = Line::from(tabs).style(self.theme.style(Group::TabLineFill)); - frame.render_widget(Paragraph::new(bufferline), Rect::new(0, 0, width, 1)); - - let arrangement = self.windows.arrange(self.windows_area()); - for &Separator { area, left } in &arrangement.separators { - let rows = area.height.saturating_sub(1); - let bar: Vec> = (0..rows).map(|_| Line::from("│")).collect(); - frame.render_widget( - Paragraph::new(bar).style(self.theme.style(Group::WinSeparator)), - Rect { - height: rows, - ..area - }, - ); - let joint = Rect { - y: area.y + rows, - height: area.height.min(1), - ..area - }; - let group = statusline_group(left == self.windows.current_id()); - frame.buffer_mut().set_style(joint, self.theme.style(group)); - } - let mut cursor = None; - for &(id, rect) in &arrangement.windows { - let is_current = id == self.windows.current_id(); - let shown = self.render_window(frame, self.windows.get(id), rect, is_current); - if is_current { - cursor = Some(shown); - } - } - - if self.which_key { - self.render_which_key(frame); - } - if let Some(cursor) = cursor { - self.render_hover(frame, cursor); - } - - let last = height.saturating_sub(1); - let cmdline_area = Rect::new(0, last, width, 1); - match &self.mode { - AppMode::Cmdline(cmdline) => { - let text = format!("{}{}", cmdline.prompt(), cmdline.shown()); - frame.render_widget(Paragraph::new(text), cmdline_area); - if let Some(shown) = cmdline.completions() { - let column = cmdline.prompt().width() + shown.cells; - self.render_completions(frame, &shown, column, last); - } - let x = cmdline.prompt().width() + cmdline.cursor_cells(); - frame.set_cursor_position(Position::new( - to_u16(x).min(width.saturating_sub(1)), - last, - )); - return; - } - AppMode::MorePrompt { lines } => { - self.render_messages(frame, lines); - return; - } - AppMode::Picker(picker) => { - let at = self.render_picker(frame, picker); - frame.set_cursor_position(at); - return; - } - AppMode::Confirm(confirm) => { - if let Some(shown) = confirm.shown() { - self.render_shown(frame, shown); - } - let question = confirm.question(); - frame.render_widget(Paragraph::new(question), cmdline_area); - frame.set_cursor_position(Position::new( - to_u16(question.width()).min(width.saturating_sub(1)), - last, - )); - return; - } - AppMode::Normal | AppMode::Visual(_) => {} - } - if let Some(message) = &self.message { - let group = match message.kind { - MessageKind::Info => Group::Normal, - MessageKind::Warning => Group::WarningMsg, - MessageKind::Error => Group::ErrorMsg, - }; - frame.render_widget( - Paragraph::new(Span::styled(message.text.as_str(), self.theme.style(group))), - cmdline_area, - ); - } - self.render_showcmd(frame, cmdline_area); - if let Some(cursor) = cursor { - frame.set_cursor_position(cursor); - } - } - - /// Draws `window` in `rect`, its statusline on the last row, and returns where its cursor is. - fn render_window( - &self, - frame: &mut Frame<'_>, - window: &Window, - rect: Rect, - is_current: bool, - ) -> Position { - let area = text_area(rect); - let viewport = Viewport { - height: usize::from(area.height), - scrolloff: self.options.scrolloff, - }; - let buffer = &self.buffers[window.buffer]; - let layout = window.layout(&self.buffers, area); - // While a search is typed, the current window shows where it would go, its cursor left where it is. - let typed = is_current.then(|| self.typed_search()).flatten(); - let moved; - let window = match &typed { - Some((pattern, direction, count)) => { - let mut shown = window.clone(); - // Where the view goes follows from the cursor: nothing else needs moving. - let _ = shown.cursor.search(&layout, pattern, *direction, *count); - moved = shown; - &moved - } - None => window, - }; - let top = window.top(&layout, viewport); - let rows: Vec> = (top..top + usize::from(area.height)) - .map(|row| { - if row < layout.row_count() { - Line::from( - layout - .segments(row) - .map(|(text, group)| Span::styled(text, self.theme.style(group))) - .collect::>(), - ) - } else { - Line::from(Span::styled("~", self.theme.style(Group::EndOfBuffer))) - } - }) - .collect(); - frame.render_widget(Paragraph::new(rows), area); - self.draw_pictures(frame.buffer_mut(), &layout, top, area); - let highlighted = match &typed { - Some((pattern, ..)) => Some((pattern, Group::IncSearch)), - None => self.searched().map(|pattern| (pattern, Group::CurSearch)), - }; - if let Some((pattern, current)) = highlighted { - for (y, row) in (area.y..area.bottom()).zip(top..layout.row_count()) { - for (cells, holds_cursor) in layout.matches(row, pattern, &window.cursor) { - let group = if holds_cursor { - current - } else if self.options.hlsearch { - Group::Search - } else { - continue; - }; - let x = area.x.saturating_add(to_u16(cells.start)); - let width = to_u16(cells.len()).min(area.right().saturating_sub(x)); - frame - .buffer_mut() - .set_style(Rect::new(x, y, width, 1), self.theme.style(group)); - } - } - } - for (y, row) in (area.y..area.bottom()).zip(top..layout.row_count()) { - if layout.is_folded(row) { - frame.buffer_mut().set_style( - Rect::new(area.x, y, area.width, 1), - self.theme.style(Group::Folded), - ); - } - } - if let (true, AppMode::Visual(selection)) = (is_current, &self.mode) { - for (y, row) in (area.y..area.bottom()).zip(top..layout.row_count()) { - if let Some(cells) = selection.cells(&window.cursor, &layout, row) { - let x = area.x.saturating_add(to_u16(cells.start)); - let width = to_u16(cells.len()).min(area.right().saturating_sub(x)); - frame - .buffer_mut() - .set_style(Rect::new(x, y, width, 1), self.theme.style(Group::Visual)); - } - } - } - - let cursor = window.cursor.screen(&layout); - let (mode, mode_group) = self.mode_label(); - let status = statusline( - is_current.then_some(mode), - &buffer.name, - Account { - handle: self.connection.handle(), - unread: self.unread, - }, - &format!("{},{}", cursor.row + 1, cursor.col + 1), - &position(top, viewport.height, layout.row_count()), - rect.width, - ); - let group = statusline_group(is_current); - let status_area = Rect::new(rect.x, area.bottom(), rect.width, 1); - frame.render_widget( - Paragraph::new(status).style(self.theme.style(group)), - status_area, - ); - if is_current { - // " NORMAL ": the mode and the space on either side of it. - let label = Rect { - width: to_u16(mode.width() + 2).min(rect.width), - ..status_area - }; - frame - .buffer_mut() - .set_style(label, self.theme.style(mode_group)); - } - - let x = area.x.saturating_add(to_u16(cursor.col)); - let y = area - .y - .saturating_add(to_u16(cursor.row.saturating_sub(top))); - Position::new(x.min(area.right().saturating_sub(1)), y) - } - - /// The mode as the statusline names and colours it. - fn mode_label(&self) -> (&'static str, Group) { - match &self.mode { - AppMode::Normal | AppMode::MorePrompt { .. } | AppMode::Confirm(_) => { - ("NORMAL", Group::StatusLineModeNormal) - } - AppMode::Visual(selection) => match selection.kind() { - SelectionKind::Charwise => ("VISUAL", Group::StatusLineModeVisual), - SelectionKind::Rowwise => ("V-LINE", Group::StatusLineModeVisual), - }, - AppMode::Cmdline(_) => ("COMMAND", Group::StatusLineModeCommand), - // As Telescope's prompt, typed into. - AppMode::Picker(_) => ("INSERT", Group::StatusLineModeCommand), - } - } - - /// Keys typed toward a binding, with their count, as Vim's `'showcmd'` shows them. - fn render_showcmd(&self, frame: &mut Frame<'_>, area: Rect) { - let mut pending: String = self - .keymap - .pending_count() - .map(|count| count.to_string()) - .unwrap_or_default(); - pending.extend(self.keymap.pending_keys().iter().map(ToString::to_string)); - if pending.is_empty() || area.width <= SHOWCMD_WIDTH + 1 { - return; - } - let shown: String = pending - .chars() - .rev() - .take(usize::from(SHOWCMD_WIDTH)) - .collect::>() - .into_iter() - .rev() - .collect(); - let x = area.right() - SHOWCMD_WIDTH - 1; - frame.render_widget( - Paragraph::new(shown), - Rect::new(x, area.y, SHOWCMD_WIDTH, 1), - ); - } - - /// `shown` in a bordered box just above the command line, as tall as it needs, up to half the screen. - fn render_shown(&self, frame: &mut Frame<'_>, shown: &crate::doc::Document) { - let (width, height) = self.size; - if width < 4 || height < 6 { - return; - } - let inner_width = width - 2; - let layout = Layout::new(shown, inner_width); - let rows = to_u16(layout.row_count()).clamp(1, height / 2); - let area = Rect::new(0, height - 1 - (rows + 2), width, rows + 2); - let block = Block::bordered() - .title(" Preview ") - .border_style(self.theme.style(Group::FloatBorder)) - .style(self.theme.style(Group::NormalFloat)); - let inner = block.inner(area); - frame.render_widget(Clear, area); - frame.render_widget(block, area); - let lines: Vec> = (0..layout.row_count().min(usize::from(rows))) - .map(|row| { - Line::from( - layout - .segments(row) - .map(|(text, group)| Span::styled(text, self.theme.style(group))) - .collect::>(), - ) - }) - .collect(); - frame.render_widget(Paragraph::new(lines), inner); - } - - /// The which-key popup: what may follow the pending keys, in columns, just above the command line. - fn render_which_key(&self, frame: &mut Frame<'_>) { - let (width, height) = self.size; - let entries: Vec<(String, String)> = self - .keymap - .continuations(self.keymap_mode()) - .into_iter() - .map(|c| (c.keys, c.description)) - .collect(); - if entries.is_empty() || width < 8 || height < 5 { - return; - } - let entry_width = entries - .iter() - .map(|(k, d)| k.width() + d.width() + 3) - .max() - .unwrap_or(1) - + 3; - let inner_width = usize::from(width - 2); - let columns = (inner_width / entry_width).max(1); - let rows = entries.len().div_ceil(columns).min(usize::from(height) / 2); - let popup_height = to_u16(rows) + 2; - let area = Rect::new(0, height - 1 - popup_height, width, popup_height); - let title: String = self - .keymap - .pending_keys() - .iter() - .map(ToString::to_string) - .collect(); - let block = Block::bordered() - .title(format!(" {title} ")) - .border_style(self.theme.style(Group::FloatBorder)) - .style(self.theme.style(Group::NormalFloat)); - let inner = block.inner(area); - frame.render_widget(Clear, area); - frame.render_widget(block, area); - for (i, (key, description)) in entries.iter().take(rows * columns).enumerate() { - let (row, column) = (i / columns, i % columns); - let group = if description == "+prefix" { - Group::WhichKeyGroup - } else { - Group::WhichKeyDesc - }; - let line = Line::from(vec![ - Span::styled(key.as_str(), self.theme.style(Group::WhichKey)), - Span::styled(" → ", self.theme.style(Group::WhichKeySeparator)), - Span::styled(description.as_str(), self.theme.style(group)), - ]); - let x = inner.x + to_u16(column * entry_width); - let cell = Rect::new( - x, - inner.y + to_u16(row), - to_u16(entry_width).min(inner.right() - x), - 1, - ); - frame.render_widget(Paragraph::new(line), cell); - } - } - - /// Messages from index `from` of the history over the bottom of the screen, as many as fit, then Vim's prompt - /// to continue. - fn render_messages(&self, frame: &mut Frame<'_>, lines: &[Message]) { - let (width, height) = self.size; - // Long messages wrap onto further rows, as in Vim; the newest that fit are shown. - let mut rows: Vec> = lines - .iter() - .flat_map(|message| { - let group = if message.kind == MessageKind::Error { - Group::ErrorMsg - } else { - Group::Normal - }; - let style = self.theme.style(group); - wrapped(&message.text, usize::from(width.max(1))) - .into_iter() - .map(move |row| Line::from(Span::styled(row, style))) - }) - .collect(); - let fit = usize::from(height.saturating_sub(1)); - rows.drain(..rows.len().saturating_sub(fit)); - let top = height.saturating_sub(1).saturating_sub(to_u16(rows.len())); - let area = Rect::new(0, top, width, height - top); - frame.render_widget(Clear, area); - let prompt = "Press ENTER or type command to continue"; - rows.push(Line::from(Span::styled( - prompt, - self.theme.style(Group::MoreMsg), - ))); - frame.render_widget(Paragraph::new(rows), area); - let x = to_u16(prompt.width()).min(width.saturating_sub(1)); - frame.set_cursor_position(Position::new(x, height.saturating_sub(1))); - } -} - -/// `text` cut into rows of at most `width` cells. -fn wrapped(text: &str, width: usize) -> Vec { - let mut rows = vec![String::new()]; - let mut used = 0; - for ch in text.chars() { - let cells = ch.to_string().width(); - if used + cells > width && used > 0 { - rows.push(String::new()); - used = 0; - } - rows.last_mut().expect("rows starts non-empty").push(ch); - used += cells; - } - rows -} - -/// The most completions the menu shows at once; it scrolls to the chosen one. -const MENU_ROWS: usize = 10; - -impl App { - /// The completions `` cycles through, in a menu just above the command line (row `cmdline`), each lined up - /// with the word it completes at `column`, as Neovim's popup menu: the chosen one in `PmenuSel`. - fn render_completions( - &self, - frame: &mut Frame<'_>, - shown: &Shown<'_>, - column: usize, - cmdline: u16, - ) { - let (width, _) = self.size; - let rows = shown - .candidates - .len() - .min(MENU_ROWS) - .min(usize::from(cmdline.saturating_sub(1))); - if rows == 0 { - return; - } - let first = shown - .chosen - .map_or(0, |chosen| chosen.saturating_sub(rows - 1)); - let inner = shown - .candidates - .iter() - .map(|candidate| candidate.width()) - .max() - .unwrap_or(0); - // A cell of room on either side, the left one before the word's column. - let menu_width = to_u16(inner + 2).min(width); - let x = to_u16(column.saturating_sub(1)).min(width.saturating_sub(menu_width)); - let top = cmdline - to_u16(rows); - for (row, at) in (first..first + rows).enumerate() { - let group = if shown.chosen == Some(at) { - Group::PmenuSel - } else { - Group::Pmenu - }; - let style = self - .theme - .style(Group::Pmenu) - .patch(self.theme.style(group)); - let text = format!(" {: Group { - if is_current { - Group::StatusLine - } else { - Group::StatusLineNC - } -} - -/// A statusline: the mode (on the current window only) and the buffer's name on the left, the ruler on the right. -/// The statusline: mode (in the current window), buffer name, then the unread notifications, the account, the -/// ruler and where the view is. Short of room, the account goes first, then the start of the name, as Vim shortens -/// a file name: `<` and its end. -fn statusline( - mode: Option<&str>, - name: &str, - account: Account<'_>, - ruler: &str, - position: &str, - width: u16, -) -> Line<'static> { - let width = usize::from(width); - let prefix = match mode { - Some(mode) => format!(" {mode} "), - None => " ".to_owned(), - }; - let unread = if account.unread > 0 { - format!("󰂚 {} ", account.unread) - } else { - String::new() - }; - let handle = account - .handle - .map(|handle| format!("@{handle} ")) - .unwrap_or_default(); - let ruler = format!("{ruler} {position} "); - let full = format!("{unread}{handle}{ruler}"); - let right = if prefix.width() + name.width() + 1 + full.width() <= width { - full - } else { - ruler - }; - let room = width.saturating_sub(prefix.width() + 1 + right.width()); - let left = format!("{prefix}{}", shortened(name, room)); - let gap = width.saturating_sub(left.width() + right.width()).max(1); - Line::from(format!("{left}{}{right}", " ".repeat(gap))) -} - -/// `name` in at most `room` cells: whole, or `<` and as much of its end as fits. -fn shortened(name: &str, room: usize) -> String { - if name.width() <= room { - return name.to_owned(); - } - if room == 0 { - return String::new(); - } - let mut kept = Vec::new(); - let mut used = 1; - for ch in name.chars().rev() { - let cells = ch.width().unwrap_or(0); - if used + cells > room { - break; - } - used += cells; - kept.push(ch); - } - std::iter::once('<').chain(kept.into_iter().rev()).collect() -} - -/// What the statusline says about the account. -#[derive(Debug, Clone, Copy)] -struct Account<'a> { - /// Its handle, once connected. - handle: Option<&'a str>, - /// Its unread notifications. - unread: u64, -} - -/// Where the view is in its buffer, as Vim's ruler says it: `All`, `Top`, `Bot`, or the share of rows above it. -fn position(top: usize, height: usize, rows: usize) -> String { - let below = rows.saturating_sub(top + height); - match (top, below) { - (0, 0) => "All".to_owned(), - (0, _) => "Top".to_owned(), - (_, 0) => "Bot".to_owned(), - (above, below) => format!("{}%", above * 100 / (above + below)), - } -} - -fn to_u16(n: usize) -> u16 { - u16::try_from(n).unwrap_or(u16::MAX) -} diff --git a/crates/devnull/src/app/window.rs b/crates/devnull/src/app/window.rs deleted file mode 100644 index 0262402..0000000 --- a/crates/devnull/src/app/window.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! A window: a view of a buffer with its own cursor and scroll position, and its own folds. - -use std::collections::HashMap; - -use ratatui::layout::Rect; - -use super::Buffer; -use crate::doc::{Cursor, Folds, Layout, Motion}; -use crate::keymap::{Place, Scroll}; - -/// A view of a buffer. Both the cursor and the top of the view are pinned to text, so neither moves when blocks -/// arrive above them; a view that has never scrolled stays at the top, showing new blocks as they come. -#[derive(Debug, Clone)] -pub(super) struct Window { - pub(super) buffer: usize, - pub(super) cursor: Cursor, - /// Which folds are open here, in whichever buffer: each block's folds are its own. - pub(super) folds: Folds, - /// The text at the first row shown. - top: Cursor, - /// Where jumps started from, oldest first, in whichever buffer (Vim's jumplist). - jumps: Vec, - /// The entry `` and `` last went to; `jumps.len()` when they haven't since the last jump. - jump: usize, - /// Where `:open` and `:profile` left for another buffer from, newest last (Vim's tag stack). - tags: Vec, - /// Where this window left the buffers it showed before, to return there. - left: HashMap, -} - -/// A place in a buffer to go back to. -#[derive(Debug, Clone)] -pub(super) struct Jump { - pub(super) buffer: usize, - pub(super) cursor: Cursor, -} - -/// Where a window was in a buffer it left: cursor and scroll position. -#[derive(Debug, Clone)] -struct View { - cursor: Cursor, - top: Cursor, -} - -/// How many positions a jumplist keeps, as in Vim. -const MAX_JUMPS: usize = 100; - -/// How many entries the tag stack keeps, as in Vim. -const MAX_TAGS: usize = 20; - -/// How a window is shown: its height in rows, and the context (`'scrolloff'`) kept around the cursor. -#[derive(Debug, Clone, Copy)] -pub(super) struct Viewport { - pub(super) height: usize, - pub(super) scrolloff: usize, -} - -impl Viewport { - fn height(self) -> usize { - self.height.max(1) - } - - /// `'scrolloff'`, limited so it can always be honoured, as in Vim. - fn scrolloff(self) -> usize { - self.scrolloff.min((self.height() - 1) / 2) - } -} - -impl Window { - pub(super) fn new(buffer: usize) -> Self { - Self { - buffer, - cursor: Cursor::new(), - folds: Folds::default(), - top: Cursor::new(), - jumps: Vec::new(), - jump: 0, - tags: Vec::new(), - left: HashMap::new(), - } - } - - /// Shows buffer `buffer` instead, where this window last left it, or with the cursor at `start` when it never - /// showed it. Not a jump by itself: see [`Window::record_jump`]. - pub(super) fn switch_to(&mut self, buffer: usize, start: Cursor) { - if buffer == self.buffer { - return; - } - let view = self.left.remove(&buffer).unwrap_or(View { - cursor: start, - top: Cursor::new(), - }); - let leaving = View { - cursor: std::mem::replace(&mut self.cursor, view.cursor), - top: std::mem::replace(&mut self.top, view.top), - }; - self.left.insert(self.buffer, leaving); - self.buffer = buffer; - } - - /// The buffer it shows, laid out to fit `area`, its text area, with its folds there. - pub(super) fn layout<'d>(&self, buffers: &'d [Buffer], area: Rect) -> Layout<'d> { - self.layout_of(buffers, self.buffer, area) - } - - /// Buffer `buffer`, laid out to fit `area` as this window shows it, or would: with its folds there. - pub(super) fn layout_of<'d>( - &self, - buffers: &'d [Buffer], - buffer: usize, - area: Rect, - ) -> Layout<'d> { - let folds = self.folds.clone(); - Layout::within(&buffers[buffer].doc, folds, area.width, area.height) - } - - /// The jumplist, oldest first, and the entry `` and `` last went to: past the end when they have not - /// since the last jump. - pub(super) fn jumplist(&self) -> (&[Jump], usize) { - (&self.jumps, self.jump) - } - - /// Where the cursor is, to come back to. - pub(super) fn here(&self) -> Jump { - Jump { - buffer: self.buffer, - cursor: self.cursor.clone(), - } - } - - /// Remembers `from`, where a jump started in the buffer shown (laid out as `layout`), as the newest jumplist - /// entry; an older entry on the same row of that buffer goes. - pub(super) fn record_jump(&mut self, layout: &Layout<'_>, from: Cursor) { - let row = from.screen(layout).row; - let buffer = self.buffer; - self.jumps - .retain(|jump| jump.buffer != buffer || jump.cursor.screen(layout).row != row); - self.jumps.push(Jump { - buffer, - cursor: from, - }); - if self.jumps.len() > MAX_JUMPS { - self.jumps.remove(0); - } - self.jump = self.jumps.len(); - } - - /// ``: back `count` entries, in whichever buffer. Going back from the newest position first remembers it, - /// so `` can return. - pub(super) fn jump_older(&mut self, layout: &Layout<'_>, count: usize) { - if self.jump == self.jumps.len() { - self.record_jump(layout, self.cursor.clone()); - self.jump = self.jumps.len() - 1; - } - let target = self.jump.saturating_sub(count); - if target != self.jump { - self.jump = target; - self.go(self.jumps[target].clone()); - } - } - - /// ``: forward `count` entries, after `` went back. - pub(super) fn jump_newer(&mut self, count: usize) { - let target = self.jump + count; - if target < self.jumps.len() { - self.jump = target; - self.go(self.jumps[target].clone()); - } - } - - /// Notes `from` as where the window left for another buffer to follow something, unless it is still there. - pub(super) fn push_tag(&mut self, from: Jump) { - if from.buffer == self.buffer { - return; - } - self.tags.push(from); - if self.tags.len() > MAX_TAGS { - self.tags.remove(0); - } - } - - /// ``: back to where the newest entry of the tag stack left from, as a jump (the buffer shown laid out as - /// `layout`). `false` when the stack is empty. - pub(super) fn pop_tag(&mut self, layout: &Layout<'_>) -> bool { - let Some(tag) = self.tags.pop() else { - return false; - }; - self.record_jump(layout, self.cursor.clone()); - self.go(tag); - true - } - - /// Puts the cursor at `jump`, showing its buffer. - fn go(&mut self, jump: Jump) { - self.switch_to(jump.buffer, jump.cursor.clone()); - self.cursor = jump.cursor; - } - - /// The first row to show: where the view was, moved as little as possible to keep the cursor in view with - /// `'scrolloff'` rows around it, without scrolling past the end for context alone. - pub(super) fn top(&self, layout: &Layout<'_>, viewport: Viewport) -> usize { - let (height, scrolloff) = (viewport.height(), viewport.scrolloff()); - let top = self.top.screen(layout).row; - let cursor = self.cursor.screen(layout).row; - if cursor < top + scrolloff { - cursor.saturating_sub(scrolloff) - } else if cursor + scrolloff >= top + height { - let with_context = cursor + scrolloff + 1 - height; - let last_page = layout.row_count().saturating_sub(height); - with_context - .min(last_page) - .max((cursor + 1).saturating_sub(height)) - } else { - top - } - } - - /// Keeps the cursor in view after it moved, pinning the view to new text only when it had to scroll. - pub(super) fn follow_cursor(&mut self, layout: &Layout<'_>, viewport: Viewport) { - let top = self.top(layout, viewport); - self.set_top(layout, top); - } - - fn set_top(&mut self, layout: &Layout<'_>, row: usize) { - if row != self.top.screen(layout).row { - self.top = Cursor::at_row(layout, row); - } - } - - /// `` and friends: moves the view, then the cursor only as far as it must to stay in view. - pub(super) fn scroll( - &mut self, - layout: &Layout<'_>, - viewport: Viewport, - scroll: Scroll, - count: usize, - ) { - let height = viewport.height(); - let top = self.top(layout, viewport); - let last = layout.row_count().saturating_sub(1); - let last_page = layout.row_count().saturating_sub(height); - let half = (height / 2).max(1); - let page = height.saturating_sub(2).max(1); - let new_top = match scroll { - Scroll::LineDown => (top + count).min(last), - Scroll::LineUp => top.saturating_sub(count), - Scroll::HalfDown => (top + half).min(last_page.max(top)), - Scroll::HalfUp => top.saturating_sub(half), - Scroll::PageDown => (top + page * count).min(last), - Scroll::PageUp => top.saturating_sub(page * count), - }; - self.set_top(layout, new_top); - match scroll { - Scroll::HalfDown => self.cursor.apply(layout, Motion::Down, half), - Scroll::HalfUp => self.cursor.apply(layout, Motion::Up, half), - Scroll::PageDown => self.cursor.apply(layout, Motion::GotoRow(new_top + 1), 1), - Scroll::PageUp => { - let bottom = (new_top + height - 1).min(last); - self.cursor.apply(layout, Motion::GotoRow(bottom + 1), 1); - } - Scroll::LineDown | Scroll::LineUp => {} - } - let scrolloff = viewport.scrolloff(); - let (row, top) = (self.cursor.screen(layout).row, self.top.screen(layout).row); - let (first, last_shown) = ( - top + if top > 0 { scrolloff } else { 0 }, - top + height - 1 - scrolloff, - ); - if row < first { - self.cursor.apply(layout, Motion::Down, first - row); - } else if row > last_shown { - self.cursor.apply(layout, Motion::Up, row - last_shown); - } - } - - /// `zt`, `zz`, `zb`. - pub(super) fn place(&mut self, layout: &Layout<'_>, viewport: Viewport, place: Place) { - let (height, scrolloff) = (viewport.height(), viewport.scrolloff()); - let row = self.cursor.screen(layout).row; - let top = match place { - Place::Top => row.saturating_sub(scrolloff), - Place::Middle => row.saturating_sub((height - 1) / 2), - Place::Bottom => (row + 1 + scrolloff).saturating_sub(height), - }; - self.set_top(layout, top); - } - - /// `H`, `M`, `L`: `count` rows in from the top or bottom (1 is the edge itself), and at least `'scrolloff'` - /// rows in unless that edge is the buffer's. - pub(super) fn view_row( - &mut self, - layout: &Layout<'_>, - viewport: Viewport, - place: Place, - count: usize, - ) { - let top = self.top(layout, viewport); - let shown = viewport - .height() - .min(layout.row_count().saturating_sub(top)) - .max(1); - let scrolloff = viewport.scrolloff(); - let offset = count.max(1) - 1; - let row = match place { - Place::Top => { - top + offset - .max(if top > 0 { scrolloff } else { 0 }) - .min(shown - 1) - } - Place::Middle => top + (shown - 1) / 2, - Place::Bottom => { - let at_end = top + shown >= layout.row_count(); - top + (shown - 1).saturating_sub(offset.max(if at_end { 0 } else { scrolloff })) - } - }; - self.cursor.apply(layout, Motion::GotoRow(row + 1), 1); - self.follow_cursor(layout, viewport); - } -} diff --git a/crates/devnull/src/app/windows.rs b/crates/devnull/src/app/windows.rs deleted file mode 100644 index bbc2e51..0000000 --- a/crates/devnull/src/app/windows.rs +++ /dev/null @@ -1,295 +0,0 @@ -//! The window layout: a tree of horizontal and vertical splits, and which window has the cursor. - -use std::collections::HashMap; - -use ratatui::layout::Rect; - -use super::window::Window; - -/// Identifies a window for as long as it is open. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(super) struct WindowId(u64); - -/// Which way a split divides its space. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum Axis { - /// `:split`: windows stacked, one above the other. - Rows, - /// `:vsplit`: windows side by side, with a separator column between them. - Columns, -} - -/// A direction to move between windows in, as `h`, `j`, `k` and `l`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum Toward { - Left, - Down, - Up, - Right, -} - -#[derive(Debug, Clone)] -enum Split { - Leaf(WindowId), - Node(Axis, Vec), -} - -/// Where everything goes on screen. -#[derive(Debug, Default)] -pub(super) struct Arrangement { - /// Each window with its area, statusline row included, in tree order. - pub(super) windows: Vec<(WindowId, Rect)>, - /// The columns separating side-by-side windows. - pub(super) separators: Vec, -} - -/// A column between side-by-side windows. Its last row is a row of statuslines, where it is not a bar but the -/// statusline of the window on its left goes on, as in Vim. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct Separator { - /// Its column, from top to bottom. - pub(super) area: Rect, - /// The window on its left at its last row. - pub(super) left: WindowId, -} - -/// All open windows and their layout. There is always at least one, and one of them is current. -#[derive(Debug)] -pub(super) struct Windows { - tree: Split, - open: HashMap, - current: WindowId, - next_id: u64, -} - -impl Windows { - /// A layout of `window` alone. - pub(super) fn new(window: Window) -> Self { - let id = WindowId(0); - Self { - tree: Split::Leaf(id), - open: HashMap::from([(id, window)]), - current: id, - next_id: 1, - } - } - - pub(super) fn current_id(&self) -> WindowId { - self.current - } - - pub(super) fn current(&self) -> &Window { - &self.open[&self.current] - } - - pub(super) fn current_mut(&mut self) -> &mut Window { - self.open - .get_mut(&self.current) - .expect("the current window is open") - } - - pub(super) fn get(&self, id: WindowId) -> &Window { - &self.open[&id] - } - - pub(super) fn count(&self) -> usize { - self.open.len() - } - - /// `:split` and `:vsplit`: a copy of the current window's view goes above or to the left of it and becomes - /// current. - pub(super) fn split(&mut self, axis: Axis) { - let id = WindowId(self.next_id); - self.next_id += 1; - // Cloned so the new window starts where the current one is, then moves on its own. - self.open.insert(id, self.current().clone()); - self.tree.split(self.current, id, axis); - self.current = id; - } - - /// Makes current the first window, in tree order, that shows buffer `buffer`; whether there is one. - pub(super) fn go_to_buffer(&mut self, buffer: usize) -> bool { - let showing = self - .order() - .into_iter() - .find(|id| self.open[id].buffer == buffer); - if let Some(id) = showing { - self.current = id; - } - showing.is_some() - } - - /// `:close`: closes the current window, giving the cursor to the one before it (or after it, for the first). - /// Returns `false`, closing nothing, when it is the last window. - pub(super) fn close(&mut self) -> bool { - if self.open.len() == 1 { - return false; - } - let order = self.order(); - let at = order.iter().position(|&id| id == self.current).unwrap_or(0); - let next = if at > 0 { order[at - 1] } else { order[at + 1] }; - self.tree.remove(self.current); - self.open.remove(&self.current); - self.current = next; - true - } - - /// `:only`: closes every window but the current one. - pub(super) fn only(&mut self) { - self.tree = Split::Leaf(self.current); - self.open.retain(|&id, _| id == self.current); - } - - /// `w` (`forward`) and `W`: the next or previous window, wrapping around. - pub(super) fn cycle(&mut self, forward: bool) { - let order = self.order(); - let at = order.iter().position(|&id| id == self.current).unwrap_or(0); - let next = if forward { - (at + 1) % order.len() - } else { - (at + order.len() - 1) % order.len() - }; - self.current = order[next]; - } - - /// `h` and friends: the nearest window on that side of the current one, laid out in `area`, if any. - pub(super) fn focus(&mut self, area: Rect, toward: Toward) { - let arrangement = self.arrange(area); - let Some(&(_, here)) = arrangement - .windows - .iter() - .find(|(id, _)| *id == self.current) - else { - return; - }; - let rows_overlap = |r: &Rect| r.top() < here.bottom() && here.top() < r.bottom(); - let columns_overlap = |r: &Rect| r.left() < here.right() && here.left() < r.right(); - let distance = |r: &Rect| match toward { - Toward::Left if rows_overlap(r) && r.right() <= here.left() => { - Some(here.left() - r.right()) - } - Toward::Right if rows_overlap(r) && r.left() >= here.right() => { - Some(r.left() - here.right()) - } - Toward::Up if columns_overlap(r) && r.bottom() <= here.top() => { - Some(here.top() - r.bottom()) - } - Toward::Down if columns_overlap(r) && r.top() >= here.bottom() => { - Some(r.top() - here.bottom()) - } - _ => None, - }; - if let Some((id, _)) = arrangement - .windows - .iter() - .filter_map(|(id, rect)| distance(rect).map(|d| (*id, d))) - .min_by_key(|&(_, d)| d) - { - self.current = id; - } - } - - /// Lays the windows out in `area`, sharing space evenly; side-by-side windows lose a column to a separator. - pub(super) fn arrange(&self, area: Rect) -> Arrangement { - let mut arrangement = Arrangement::default(); - arrange(&self.tree, area, &mut arrangement); - arrangement - } - - /// Window ids in tree order: top to bottom, left to right. - fn order(&self) -> Vec { - let mut order = Vec::with_capacity(self.open.len()); - self.tree.leaves(&mut order); - order - } -} - -impl Split { - /// Splits the leaf `target`, putting `new` before it. - fn split(&mut self, target: WindowId, new: WindowId, axis: Axis) -> bool { - match self { - Self::Leaf(id) if *id == target => { - *self = Self::Node(axis, vec![Self::Leaf(new), Self::Leaf(target)]); - true - } - Self::Leaf(_) => false, - Self::Node(node_axis, children) => { - let at = children - .iter() - .position(|child| matches!(child, Self::Leaf(id) if *id == target)); - if let Some(at) = at - && *node_axis == axis - { - children.insert(at, Self::Leaf(new)); - return true; - } - children - .iter_mut() - .any(|child| child.split(target, new, axis)) - } - } - } - - /// Removes the leaf `target`, collapsing splits left with one child. - fn remove(&mut self, target: WindowId) -> bool { - let Self::Node(_, children) = self else { - return false; - }; - let at = children - .iter() - .position(|child| matches!(child, Self::Leaf(id) if *id == target)); - match at { - Some(at) => { - children.remove(at); - } - None if children.iter_mut().any(|child| child.remove(target)) => {} - None => return false, - } - if children.len() == 1 { - *self = children.pop().expect("one child left"); - } - true - } - - fn leaves(&self, out: &mut Vec) { - match self { - Self::Leaf(id) => out.push(*id), - Self::Node(_, children) => children.iter().for_each(|child| child.leaves(out)), - } - } -} - -fn arrange(split: &Split, area: Rect, out: &mut Arrangement) { - let (axis, children) = match split { - Split::Leaf(id) => { - out.windows.push((*id, area)); - return; - } - Split::Node(axis, children) => (*axis, children), - }; - let count = u16::try_from(children.len()).unwrap_or(u16::MAX).max(1); - let total = match axis { - Axis::Rows => area.height, - Axis::Columns => area.width.saturating_sub(count - 1), - }; - let (base, extra) = (total / count, total % count); - let mut offset = 0; - for (i, child) in (0..count).zip(children) { - let size = base + u16::from(i < extra); - let child_area = match axis { - Axis::Rows => Rect::new(area.x, area.y + offset, area.width, size), - Axis::Columns => Rect::new(area.x + offset, area.y, size, area.height), - }; - arrange(child, child_area, out); - offset += size; - if axis == Axis::Columns && i + 1 < count { - // Windows are laid out top to bottom, left to right: the last one is the child's bottom right. - let (left, _) = *out.windows.last().expect("a split's child holds a window"); - out.separators.push(Separator { - area: Rect::new(area.x + offset, area.y, 1, area.height), - left, - }); - offset += 1; - } - } -} diff --git a/crates/devnull/src/doc.rs b/crates/devnull/src/doc.rs new file mode 100644 index 0000000..1567dd2 --- /dev/null +++ b/crates/devnull/src/doc.rs @@ -0,0 +1,77 @@ +//! The text model behind every buffer (D4): the kit's document model (its D3), with what `dn`'s text refers to. + +pub use wcgw_kit::doc::{ + BlockKey, CaseMode, Cursor, Direction, FoldOp, Folds, Motion, Pattern, PatternError, + PictureRow, ScreenPos, SearchOutcome, Selection, SelectionKind, +}; + +use devnull_api::model::AccountKind; + +/// A read-only document of keyed blocks of lines, whose text refers to [`Target`]s. +pub type Document = wcgw_kit::doc::Document; +/// One line of a block before wrapping. +pub type Line = wcgw_kit::doc::Line; +/// A run of text sharing one highlight group and one target. +pub type Span = wcgw_kit::doc::Span; +/// What a block holds: its lines, and the folds over them. +pub type Content = wcgw_kit::doc::Content; +/// A foldable range of a block's lines. +pub type Fold = wcgw_kit::doc::Fold; +/// A document wrapped into screen rows for a width. +pub type Layout<'d> = wcgw_kit::doc::Layout<'d, Target>; + +/// Something the text refers to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Target { + /// A web link, opened in the browser. + Url(String), + /// A person or an organization. + Account(AccountRef), + /// A commit. + Commit { + /// Its id. + id: String, + /// Who wrote it; `None` for a deleted commit. + author: Option, + /// Its page on the web; `None` for a deleted commit. + url: Option, + /// Whether the connected account starred it. + starred: bool, + }, + /// The card the server made for a link, opened in the browser like the link. + LinkCard(LinkCard), + /// A buffer, by name, such as a profile's list of followers. + Buffer(String), +} + +/// What the server says about a link. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkCard { + /// The link. + pub url: String, + /// The page's title; may be empty. + pub title: String, + /// The site's name; may be empty. + pub site: String, + /// What the page says it is about; may be empty. + pub description: String, +} + +/// A person or an organization, as text refers to it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountRef { + /// The handle, without the `@`. + pub handle: String, + /// Whether it is a person or an organization; `None` when the text does not say. + pub kind: Option, +} + +impl AccountRef { + /// `handle`, of `kind`; a kind this version does not know counts as not saying. + pub fn new(handle: impl Into, kind: Option) -> Self { + Self { + handle: handle.into(), + kind: kind.filter(|kind| *kind != AccountKind::Other), + } + } +} diff --git a/crates/devnull/src/doc/cursor.rs b/crates/devnull/src/doc/cursor.rs deleted file mode 100644 index 31435a7..0000000 --- a/crates/devnull/src/doc/cursor.rs +++ /dev/null @@ -1,398 +0,0 @@ -use super::search::{self, CaseMode, Direction, Pattern, SearchOutcome}; -use super::word::{self, Kind}; -use super::{Anchor, BlockKey, Document, Layout, Pos, ScreenPos, Target}; - -/// The Normal-mode cursor of one window over a [`super::Document`]. -/// -/// Once moved, it stays on the same text as the document changes around it. Until then it stays at the top, so -/// blocks arriving above an untouched view appear under it, the newest first. -#[derive(Debug, Clone, Default)] -pub struct Cursor { - /// Where the cursor is; `None` until it first moves, meaning the start of the document. - pub(super) anchor: Option, - /// Where vertical moves aim for (Vim's `curswant`), kept across rows too short to reach it. `None` means the - /// cursor's current column. - want: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Want { - /// A display column. - Column(usize), - /// The last character of each row, after `$`. - RowEnd, -} - -/// A Vim motion, in screen-row terms (D4). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum Motion { - /// `j`: down one screen row. - Down, - /// `k`: up one screen row. - Up, - /// `l`: right one character, onto the next row of a wrapped line but not past the line's end. - Right, - /// `h`: left one character, not past the line's start. - Left, - /// `0`: the first character of the screen row. - RowStart, - /// `^`: the first non-blank character of the screen row. - FirstNonBlank, - /// `$`: the last character of the screen row (`count - 1` rows down); later vertical moves stay at row ends. - RowEnd, - /// `w`: the start of the next word; words are runs of letters, digits and `_`, or of other symbols. - WordForward, - /// `b`: the start of this word, or of the previous one. - WordBackward, - /// `e`: the end of this word, or of the next one. - WordEnd, - /// `W`: the start of the next run of non-blanks. - BigWordForward, - /// `B`: the start of this run of non-blanks, or of the previous one. - BigWordBackward, - /// `E`: the end of this run of non-blanks, or of the next one. - BigWordEnd, - /// `f{char}`: the `count`th next occurrence of the character in the line (the whole line, across its rows). - FindForward(char), - /// `t{char}`: just before the `count`th next occurrence of the character in the line. - TillForward(char), - /// `F{char}`: the `count`th previous occurrence of the character in the line. - FindBackward(char), - /// `T{char}`: just after the `count`th previous occurrence of the character in the line. - TillBackward(char), - /// `]]`: the start of the next block (commit); stays put on the last one. - NextBlock, - /// `[[`: the start of this block, or of the previous one when already there. - PrevBlock, - /// `}`: the next empty line after a paragraph, or the end of the document. - ParagraphForward, - /// `{`: the previous empty line before a paragraph, or the start of the document. - ParagraphBackward, - /// `{count}G`, `{count}gg`: the first non-blank of screen row `count` (from 1), clamped to the last row. - GotoRow(usize), - /// `G` without a count: the first non-blank of the last screen row. - LastRow, -} - -impl Motion { - fn word_kind(self) -> Kind { - match self { - Self::BigWordForward | Self::BigWordBackward | Self::BigWordEnd => Kind::Big, - _ => Kind::Small, - } - } -} - -impl Cursor { - /// A cursor at the start of the document. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// A cursor at the start of screen row `row` of `layout`, pinned to the text there. Rows past the end clamp to - /// the last one; an empty layout gives a fresh cursor. - #[must_use] - pub fn at_row(layout: &Layout<'_>, row: usize) -> Self { - let mut cursor = Self::new(); - if layout.row_count() > 0 { - let row = layout.row(row.min(layout.row_count() - 1)); - cursor.set( - layout.doc(), - Pos { - line: row.line, - col: row.start, - }, - ); - } - cursor - } - - /// A cursor at the start of block `key`, which may arrive later: until it does, the cursor is at the start of the - /// document. - #[must_use] - pub fn on_block(key: impl Into) -> Self { - Self { - anchor: Some(Anchor { - block: key.into(), - line: 0, - col: 0, - fallback: 0, - }), - want: None, - } - } - - /// Where the cursor is drawn. - #[must_use] - pub fn screen(&self, layout: &Layout<'_>) -> ScreenPos { - if layout.row_count() == 0 { - return ScreenPos { row: 0, col: 0 }; - } - let pos = self.pos(layout.doc()); - let row = layout.row_of(pos); - ScreenPos { - row, - col: layout.cell_of(row, pos.col), - } - } - - /// What the text under the cursor refers to, if anything. - #[must_use] - pub fn target<'d>(&self, layout: &Layout<'d>) -> Option<&'d Target> { - if layout.row_count() == 0 { - return None; - } - let doc = layout.doc(); - doc.target_at(self.pos(doc)) - } - - /// What the block under the cursor refers to as a whole, such as the commit holding a link the cursor is on. - #[must_use] - pub fn block_target<'d>(&self, layout: &Layout<'d>) -> Option<&'d Target> { - if layout.row_count() == 0 { - return None; - } - let doc = layout.doc(); - doc.block_target_at(self.pos(doc)) - } - - /// Moves `count` times by `motion` (a count of 0 counts as 1, as in Vim). A motion that cannot move, such as - /// `f` for a character that isn't there, leaves the cursor where it is. - pub fn apply(&mut self, layout: &Layout<'_>, motion: Motion, count: usize) { - if layout.row_count() == 0 { - return; - } - let count = count.max(1); - let doc = layout.doc(); - let here = self.screen(layout); - let want = match motion { - Motion::Down | Motion::Up => self.want.unwrap_or(Want::Column(here.col)), - Motion::RowEnd => Want::RowEnd, - _ => { - if let Some(pos) = self.destination(layout, motion, count) { - self.set(doc, layout.outside_closed_fold(pos)); - } - return; - } - }; - let row = match motion { - Motion::Down => here.row + count, - Motion::Up => here.row.saturating_sub(count), - _ => here.row + count - 1, - } - .min(layout.row_count() - 1); - let pos = match want { - Want::Column(col) => layout.pos_at(row, col), - Want::RowEnd => layout.row_end(row), - }; - self.set(doc, pos); - self.want = Some(want); - } - - /// Where a motion that forgets the wanted column goes, or `None` if it cannot move. - fn destination(&self, layout: &Layout<'_>, motion: Motion, count: usize) -> Option { - let doc = layout.doc(); - let pos = self.pos(doc); - // Text motions step over a closed fold as one stop: they leave it from its far side and stop at its start. - let repeat = |forward: bool, step: &dyn Fn(Pos) -> Pos| { - (0..count).fold(pos, |pos, _| { - layout.outside_closed_fold(step(layout.leaving_closed_fold(pos, forward))) - }) - }; - let block_steps = |steps: usize| isize::try_from(steps).unwrap_or(isize::MAX); - Some(match motion { - Motion::Down | Motion::Up | Motion::RowEnd => { - unreachable!("vertical motions keep the wanted column") - } - Motion::Right | Motion::Left if doc.line(pos.line).is_image() => return None, - Motion::Right => Pos { - col: (pos.col + count).min(doc.line(pos.line).last_col()), - ..pos - }, - Motion::Left => Pos { - col: pos.col.saturating_sub(count), - ..pos - }, - Motion::RowStart => { - let row = layout.row(layout.row_of(pos)); - Pos { - line: row.line, - col: row.start, - } - } - Motion::FirstNonBlank => layout.first_non_blank(layout.row_of(pos)), - Motion::GotoRow(row) => { - layout.first_non_blank(row.saturating_sub(1).min(layout.row_count() - 1)) - } - Motion::LastRow => layout.first_non_blank(layout.row_count() - 1), - Motion::WordForward | Motion::BigWordForward => { - repeat(true, &|p| word::forward(doc, p, motion.word_kind())) - } - Motion::WordBackward | Motion::BigWordBackward => { - repeat(false, &|p| word::backward(doc, p, motion.word_kind())) - } - Motion::WordEnd | Motion::BigWordEnd => { - repeat(true, &|p| word::end(doc, p, motion.word_kind())) - } - Motion::FindForward(ch) => Pos { - col: find(doc, pos, ch, count, true)?, - ..pos - }, - Motion::TillForward(ch) => Pos { - col: find(doc, pos, ch, count, true)? - 1, - ..pos - }, - Motion::FindBackward(ch) => Pos { - col: find(doc, pos, ch, count, false)?, - ..pos - }, - Motion::TillBackward(ch) => Pos { - col: find(doc, pos, ch, count, false)? + 1, - ..pos - }, - Motion::NextBlock => { - let line = doc.block_start(pos.line, block_steps(count)); - (line != doc.block_start(pos.line, 0)).then_some(Pos { line, col: 0 })? - } - Motion::PrevBlock => { - let at_start = pos.col == 0 && doc.block_start(pos.line, 0) == pos.line; - let steps = if at_start { count } else { count - 1 }; - Pos { - line: doc.block_start(pos.line, -block_steps(steps)), - col: 0, - } - } - Motion::ParagraphForward => repeat(true, &|p| paragraph(doc, p, true)), - Motion::ParagraphBackward => repeat(false, &|p| paragraph(doc, p, false)), - }) - } - - /// `/`, `?`, `n`, `N`: moves to the start of the `count`th match of `pattern` going `direction`, wrapping - /// around the ends of the document. Leaves the cursor in place when there is no match. - pub fn search( - &mut self, - layout: &Layout<'_>, - pattern: &Pattern, - direction: Direction, - count: usize, - ) -> SearchOutcome { - if layout.row_count() == 0 { - return SearchOutcome::NotFound; - } - let doc = layout.doc(); - let mut pos = self.pos(doc); - let mut wrapped = false; - for _ in 0..count.max(1) { - let Some((next, wrap)) = search::next_match(doc, pattern, pos, direction) else { - return SearchOutcome::NotFound; - }; - pos = next; - wrapped |= wrap; - } - self.set(doc, pos); - if wrapped { - SearchOutcome::Wrapped - } else { - SearchOutcome::Found - } - } - - /// `*` and `#`: searches for the keyword under the cursor, or the first one after it on the line, as a whole - /// word; without one, for the non-blank word under the cursor or after it, as it is (Vim's order). The cursor - /// first moves to the word's start, so the search finds another occurrence. Returns the pattern, for `n` and `N`, - /// and the outcome; `None`, without moving, when the line has nothing but blanks from the cursor on. - pub fn search_word( - &mut self, - layout: &Layout<'_>, - direction: Direction, - count: usize, - case: CaseMode, - ) -> Option<(Pattern, SearchOutcome)> { - if layout.row_count() == 0 { - return None; - } - let doc = layout.doc(); - let pos = self.pos(doc); - let line = doc.line(pos.line); - let text = |word: &std::ops::Range| { - &line.text[line.byte_offset(word.start)..line.byte_offset(word.end)] - }; - let (word, pattern) = match word::keyword(doc, pos) { - Some(keyword) => { - let pattern = Pattern::whole_word(text(&keyword), case); - (keyword, pattern) - } - None => { - let word = word::non_blank_word(doc, pos)?; - let pattern = Pattern::literal(text(&word), case); - (word, pattern) - } - }; - self.set( - doc, - Pos { - col: word.start, - ..pos - }, - ); - let outcome = self.search(layout, &pattern, direction, count); - Some((pattern, outcome)) - } - - pub(super) fn pos(&self, doc: &Document) -> Pos { - self.anchor - .as_ref() - .map_or_else(Pos::default, |anchor| doc.resolve(anchor)) - } - - /// Moves to `pos`, forgetting the column vertical moves aimed for. - pub(super) fn set(&mut self, doc: &Document, pos: Pos) { - self.anchor = Some(doc.anchor(pos)); - self.want = None; - } -} - -/// The column of the `count`th occurrence of `ch` after (`forward`) or before `pos` in its line. -fn find(doc: &Document, pos: Pos, ch: char, count: usize, forward: bool) -> Option { - let line = doc.line(pos.line); - let is_ch = |&i: &usize| is_char(line.grapheme(i), ch); - if forward { - (pos.col + 1..line.len()).filter(is_ch).nth(count - 1) - } else { - (0..pos.col).rev().filter(is_ch).nth(count - 1) - } -} - -/// Whether `grapheme` is exactly the character `ch`. -fn is_char(grapheme: &str, ch: char) -> bool { - let mut chars = grapheme.chars(); - chars.next() == Some(ch) && chars.next().is_none() -} - -/// One `}` (`forward`) or `{` from `pos`, as Vim's `findpar`: skip any empty lines, then the paragraph, and stop on -/// the empty line after it. Without one, `}` ends on the last character and `{` on the first. -fn paragraph(doc: &Document, pos: Pos, forward: bool) -> Pos { - let mut line = pos.line; - let mut in_paragraph = false; - loop { - let next = if forward { - line + 1 - } else { - line.wrapping_sub(1) - }; - if next >= doc.line_count() { - return if forward { - doc.last_pos() - } else { - Pos::default() - }; - } - in_paragraph |= doc.line(line).len() > 0; - line = next; - if in_paragraph && doc.line(line).len() == 0 { - return Pos { line, col: 0 }; - } - } -} diff --git a/crates/devnull/src/doc/layout.rs b/crates/devnull/src/doc/layout.rs deleted file mode 100644 index 63039d4..0000000 --- a/crates/devnull/src/doc/layout.rs +++ /dev/null @@ -1,426 +0,0 @@ -use super::{Cursor, Document, FoldAt, Folds, Pattern, Pos, Shaped}; -use unicode_width::UnicodeWidthStr; - -use crate::hl::Group; - -/// A [`Document`] wrapped into screen rows for one width, its closed folds a row each. -/// -/// It borrows the document, so it can never describe a stale version of it: rebuild it after an edit, a fold -/// command or a resize. -#[derive(Debug)] -pub struct Layout<'d> { - doc: &'d Document, - folds: Folds, - width: usize, - /// The most rows a picture takes: a taller one shrinks to fit. - height: usize, - rows: Vec, - /// The first row of each logical line. - first_row: Vec, -} - -/// A screen row: a grapheme range of one logical line. -#[derive(Debug, Clone, Copy)] -pub(super) struct Row { - pub(super) line: usize, - /// First grapheme, inclusive. - pub(super) start: usize, - /// Last grapheme, exclusive. Equal to `start` only for an empty line. - pub(super) end: usize, - /// Whether this row is the summary of a closed fold starting at `line` (with an empty range at 0). - pub(super) folded: bool, -} - -/// A screen row of a picture: which picture, and which of its rows. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PictureRow<'d> { - /// Where the picture is. - pub source: &'d str, - /// Its width in cells. - pub cols: u16, - /// Its height in rows. - pub rows: u16, - /// Which of its rows this is, from 0. - pub row: u16, -} - -/// A cell on screen, relative to the top-left of the whole laid-out document. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ScreenPos { - /// Row, from 0. - pub row: usize, - /// Display column in terminal cells, from 0. - pub col: usize, -} - -impl<'d> Layout<'d> { - /// Wraps `doc` to `width` cells, its folds as the document gives them. A width of 0 is treated as 1. - #[must_use] - pub fn new(doc: &'d Document, width: u16) -> Self { - Self::with_folds(doc, Folds::default(), width) - } - - /// Wraps `doc` to `width` cells, its folds open or closed as `folds` says. A width of 0 is treated as 1. - #[must_use] - pub fn with_folds(doc: &'d Document, folds: Folds, width: u16) -> Self { - Self::within(doc, folds, width, u16::MAX) - } - - /// Wraps `doc` to `width` cells, its folds open or closed as `folds` says, in a window `height` rows tall: a - /// picture larger than `width` × `height` cells shrinks to fit, keeping its shape. A width or height of 0 is - /// treated as 1. - #[must_use] - pub fn within(doc: &'d Document, folds: Folds, width: u16, height: u16) -> Self { - let width = usize::from(width.max(1)); - let height = usize::from(height.max(1)); - let mut rows = Vec::new(); - let mut first_row = Vec::with_capacity(doc.line_count()); - let mut line = 0; - while line < doc.line_count() { - if let Some(fold) = outermost_closed(doc, &folds, line) { - first_row.extend(fold.lines.clone().map(|_| rows.len())); - rows.push(Row { - line, - start: 0, - end: 0, - folded: true, - }); - line = fold.lines.end; - continue; - } - first_row.push(rows.len()); - wrap(doc.line(line), width, height, |start, end| { - rows.push(Row { - line, - start, - end, - folded: false, - }); - }); - line += 1; - } - Self { - doc, - folds, - width, - height, - rows, - first_row, - } - } - - pub(super) fn doc(&self) -> &'d Document { - self.doc - } - - /// The closed fold hiding `line`, if any: the outermost. - fn closed_fold_at(&self, line: usize) -> Option> { - outermost_closed(self.doc, &self.folds, line) - } - - /// `pos`, or the start of the closed fold hiding it: where a motion that lands in a closed fold stops. - pub(super) fn outside_closed_fold(&self, pos: Pos) -> Pos { - self.closed_fold_at(pos.line).map_or(pos, |fold| Pos { - line: fold.lines.start, - col: 0, - }) - } - - /// Where a motion leaving a closed fold that holds `pos` starts from: just past its last character going - /// forward, its first line going backward. `pos` itself when no closed fold holds it. - pub(super) fn leaving_closed_fold(&self, pos: Pos, forward: bool) -> Pos { - match self.closed_fold_at(pos.line) { - Some(fold) if forward => { - let line = fold.lines.end - 1; - Pos { - line, - col: self.doc.line(line).len(), - } - } - Some(fold) => Pos { - line: fold.lines.start, - col: 0, - }, - None => pos, - } - } - - /// Whether `row` is the summary of a closed fold. - #[must_use] - pub fn is_folded(&self, row: usize) -> bool { - self.rows[row].folded - } - - /// The picture row `row` shows, if it shows one (not when it is the summary of a fold over it). - #[must_use] - pub fn picture(&self, row: usize) -> Option> { - let Row { - line, - start, - folded, - .. - } = *self.rows.get(row)?; - if folded { - return None; - } - let image = self.doc.line(line).image.as_ref()?; - let (cols, rows) = fitted(image.cols, image.rows, self.width, self.height); - Some(PictureRow { - source: &image.source, - cols, - rows, - row: u16::try_from(start).unwrap_or(u16::MAX), - }) - } - - /// The pictures in line with the text of `row`, such as avatars, each with the cell it starts at in the row. - #[must_use] - pub fn inline_pictures(&self, row: usize) -> Vec<(usize, PictureRow<'d>)> { - let Some(&Row { - line, - start, - end, - folded: false, - }) = self.rows.get(row) - else { - return Vec::new(); - }; - let shaped = self.doc.line(line); - let (from, to) = (shaped.byte_offset(start), shaped.byte_offset(end)); - let mut run_start = 0; - let mut pictures = Vec::new(); - for run in &shaped.runs { - let span_start = run_start; - run_start = run.end; - let Some(source) = &run.picture else { continue }; - if span_start < from || span_start >= to { - continue; - } - let cols = shaped.text[span_start..run.end.min(to)].width(); - pictures.push(( - shaped.text[from..span_start].width(), - PictureRow { - source, - cols: u16::try_from(cols).unwrap_or(u16::MAX), - rows: 1, - row: 0, - }, - )); - } - pictures - } - - /// The cells of `row` that matches of `pattern` cover, in order, each with whether it holds `cursor`. - #[must_use] - pub fn matches( - &self, - row: usize, - pattern: &Pattern, - cursor: &Cursor, - ) -> Vec<(std::ops::Range, bool)> { - let Some(&Row { - line, - start, - end, - folded: false, - }) = self.rows.get(row) - else { - return Vec::new(); - }; - let at = cursor.pos(self.doc); - pattern - .ranges(self.doc.line(line)) - .into_iter() - .filter_map(|matched| { - let (lo, hi) = (matched.start.max(start), matched.end.min(end)); - (lo < hi).then(|| { - let holds = at.line == line && matched.contains(&at.col); - (self.cell_of(row, lo)..self.cell_of(row, hi), holds) - }) - }) - .collect() - } - - /// The number of screen rows. - #[must_use] - pub fn row_count(&self) -> usize { - self.rows.len() - } - - pub(super) fn row(&self, row: usize) -> Row { - self.rows[row] - } - - /// The row showing `pos`. - pub(super) fn row_of(&self, pos: Pos) -> usize { - let first = self.first_row[pos.line]; - let mut row = first; - while row + 1 < self.rows.len() - && self.rows[row + 1].line == pos.line - && self.rows[row + 1].start <= pos.col - { - row += 1; - } - row - } - - /// The grapheme of `row` covering display column `cell`, or its last one when the row is narrower. - pub(super) fn pos_at(&self, row: usize, cell: usize) -> Pos { - let Row { - line, start, end, .. - } = self.rows[row]; - // Empty for an empty line and for every row of an image, whose column is the row itself. - let graphemes = self - .doc - .line(line) - .graphemes - .get(start..end) - .unwrap_or_default(); - let mut left = 0; - for (i, g) in graphemes.iter().enumerate() { - if cell < left + g.width.max(1) { - return Pos { - line, - col: start + i, - }; - } - left += g.width.max(1); - } - self.row_end(row) - } - - /// The text shown on `row`, left to right, as runs of one highlight group. Spaces hanging off the row's edge - /// are included; an empty row yields nothing. - pub fn segments(&self, row: usize) -> impl Iterator + use<'d> { - let Row { - line, - start, - end, - folded, - } = self.rows[row]; - let (shaped, from, to) = if let Some(fold) = self.closed_fold_at(line).filter(|_| folded) { - let summary = fold.summary; - (summary, 0, summary.byte_offset(summary.fitting(self.width))) - } else { - let shaped = self.doc.line(line); - (shaped, shaped.byte_offset(start), shaped.byte_offset(end)) - }; - let mut run_start = 0; - shaped.runs.iter().filter_map(move |run| { - let (lo, hi) = (run_start.max(from), run.end.min(to)); - run_start = run.end; - (lo < hi).then(|| (&shaped.text[lo..hi], run.group)) - }) - } - - /// The text shown on `row`, including spaces hanging off its edge. - #[cfg(test)] - pub(crate) fn row_text(&self, row: usize) -> &str { - let Row { - line, start, end, .. - } = self.rows[row]; - let shaped = self.doc.line(line); - &shaped.text[shaped.byte_offset(start)..shaped.byte_offset(end)] - } - - /// The first non-blank grapheme of `row`, or its last one when it is all blank. - pub(super) fn first_non_blank(&self, row: usize) -> Pos { - let Row { - line, start, end, .. - } = self.rows[row]; - let shaped = self.doc.line(line); - match (start..end).find(|&i| !shaped.is_blank(i)) { - Some(col) => Pos { line, col }, - None => self.row_end(row), - } - } - - /// The last grapheme of `row`, or its start when the row is empty. - pub(super) fn row_end(&self, row: usize) -> Pos { - let Row { - line, start, end, .. - } = self.rows[row]; - Pos { - line, - col: end.saturating_sub(1).max(start), - } - } - - /// The display column where grapheme `col` of `row`'s line starts. - pub(super) fn cell_of(&self, row: usize, col: usize) -> usize { - let Row { - line, - start, - folded, - .. - } = self.rows[row]; - if folded { - return 0; - } - let graphemes = self - .doc - .line(line) - .graphemes - .get(start..col) - .unwrap_or_default(); - graphemes.iter().map(|g| g.width).sum() - } -} - -/// Splits a line into rows of at most `width` cells, emitting each row's grapheme range `[start, end)`. -/// -/// Rows break after the last whitespace that fits, or mid-word when a word alone is wider than the row. Whitespace -/// that reaches the edge stays on its row, hanging past it, rather than starting the next one. An empty line is one -/// empty row; an image is as many empty rows as it is tall. -fn wrap(line: &Shaped, width: usize, height: usize, mut emit: impl FnMut(usize, usize)) { - if let Some(image) = &line.image { - // Row `i` is the empty range at `i`, so that column `i` lands on it (see `Shaped::image`). - let (_, rows) = fitted(image.cols, image.rows, width, height); - (0..usize::from(rows)).for_each(|i| emit(i, i)); - return; - } - let mut start = 0; - let mut used = 0; - // Where a row may end: just after the most recent whitespace of the current row. - let mut breakable = None; - for (i, g) in line.graphemes.iter().enumerate() { - let blank = line.is_blank(i); - if used + g.width > width && i > start && !blank { - let end = breakable.filter(|&b| b > start).unwrap_or(i); - emit(start, end); - used = line.graphemes[end..i].iter().map(|g| g.width).sum(); - start = end; - breakable = None; - } - used += g.width; - if blank { - breakable = Some(i + 1); - } - } - emit(start, line.len()); -} - -/// A picture of `cols` × `rows` cells shrunk to fit `width` × `height` cells when larger, keeping its shape; never -/// less than a cell each way. -fn fitted(cols: u16, rows: u16, width: usize, height: usize) -> (u16, u16) { - let (cols64, rows64) = (u64::from(cols.max(1)), u64::from(rows.max(1))); - let (width, height) = (width as u64, height as u64); - if cols64 <= width && rows64 <= height { - return (cols, rows.max(1)); - } - let cells = |value: u64| u16::try_from(value.max(1)).unwrap_or(u16::MAX); - // The side that has less room sets the scale: compare cols / width with rows / height. - if cols64 * height >= rows64 * width { - (cells(width), cells(rows64 * width / cols64)) - } else { - (cells(cols64 * height / rows64), cells(height)) - } -} - -/// The outermost of the folds of `doc` holding `line` that `folds` says are closed, if any: the largest, or of two as -/// large, the first. -fn outermost_closed<'d>(doc: &'d Document, folds: &Folds, line: usize) -> Option> { - doc.folds_holding(line) - .filter(|fold| folds.is_closed(fold)) - .max_by_key(|fold| (fold.lines.len(), std::cmp::Reverse(fold.id.1))) -} diff --git a/crates/devnull/src/doc/mod.rs b/crates/devnull/src/doc/mod.rs deleted file mode 100644 index 05c6e56..0000000 --- a/crates/devnull/src/doc/mod.rs +++ /dev/null @@ -1,764 +0,0 @@ -//! The text model behind every buffer (D4). -//! -//! A [`Document`] is a read-only sequence of keyed blocks (one per commit, header, …), each a list of [`Line`]s. -//! A [`Layout`] wraps it into screen rows for a width, and a [`Cursor`] moves over those rows with Vim motions -//! while staying on the same text when blocks are inserted or replaced and when the width changes. - -mod cursor; -mod layout; -mod search; -#[cfg(test)] -mod tests; -mod visual; -mod word; - -use std::cmp::Reverse; -use std::collections::HashMap; -use std::ops::Range; -use std::rc::Rc; -use std::sync::atomic::{AtomicU64, Ordering}; - -use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; - -pub use cursor::{Cursor, Motion}; -pub use layout::{Layout, PictureRow, ScreenPos}; -pub use search::{CaseMode, Direction, Pattern, PatternError, SearchOutcome}; -pub use visual::{Selection, SelectionKind}; - -use devnull_api::model::AccountKind; - -use crate::hl::Group; - -/// Identifies a block across document edits, such as a commit id or `"header"`. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct BlockKey(String); - -impl BlockKey { - /// A key from any string; keys are compared exactly. - pub fn new(key: impl Into) -> Self { - Self(key.into()) - } -} - -impl From<&str> for BlockKey { - fn from(key: &str) -> Self { - Self::new(key) - } -} - -/// One line of a block before wrapping, such as a commit's header or one paragraph of its body. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Line { - /// Styled text, wrapped to the layout width. - Text(Vec), - /// Space for a picture: `rows` empty screen rows whatever the width, `cols` cells wide. The cursor moves through - /// it like empty lines; the window draws the picture over it (D7). - Image { - /// Height in screen rows; 0 counts as 1. - rows: u16, - /// Width in cells. - cols: u16, - /// Where the picture is, such as its URL. - source: String, - }, -} - -impl Line { - /// A line of plain text in [`Group::Normal`], with no target. - pub fn text(text: impl Into) -> Self { - Self::Text(vec![Span::new(text, Group::Normal)]) - } -} - -/// What a block holds: its lines, and the folds over them. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct Content { - /// The lines, top to bottom. - pub lines: Vec, - /// Ranges of lines that `zc` can collapse into a summary row, such as the rest of a long commit. Folds do not - /// nest: a line belongs to the first fold covering it. Folds with an empty range or reaching past the lines are - /// ignored. - pub folds: Vec, - /// What the block as a whole refers to, such as the commit it shows: the target of its text that has none of its - /// own, empty lines included. - pub target: Option, -} - -impl From> for Content { - fn from(lines: Vec) -> Self { - Self { - lines, - ..Self::default() - } - } -} - -/// A foldable range of a block's lines. The folds of a block nest, as in Vim: two are apart or one holds the other -/// (of two over the same lines, the first holds the second); one that overlaps another otherwise is dropped. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Fold { - /// The folded lines, as indices into the block's lines. - pub lines: Range, - /// The one row shown in their place while closed; it is cut, not wrapped, at the window's width. - pub summary: Line, - /// Whether the fold starts closed; see [`Folds`] for how fold commands change that. - pub closed: bool, -} - -/// What a fold command does to the folds holding the cursor's line, as in Vim, from the outermost in. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FoldOp { - /// `zo`: opens the first closed fold. - Open, - /// `zO`: opens the first closed fold and every fold inside it. - OpenRecursively, - /// `zc`: closes the last open fold before the first closed one. - Close, - /// `zC`: closes them all. - CloseRecursively, - /// `za`: `zo` when one is closed, else `zc`. - Toggle, - /// `zA`: `zO` when one is closed, else `zC`. - ToggleRecursively, -} - -/// A run of text sharing one highlight group and one target. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Span { - /// The text; it should not contain line breaks, which start a new [`Line`] instead. - pub text: String, - /// How the text is drawn. - pub group: Group, - /// What ``, `gd` and `gx` act on when the cursor is on this text, if anything. - pub target: Option, - /// Where the picture this span makes space for is, if it does (see [`Span::picture`]). - pub picture: Option, -} - -impl Span { - /// Text in `group`, with no target. - pub fn new(text: impl Into, group: Group) -> Self { - Self { - text: text.into(), - group, - target: None, - picture: None, - } - } - - /// Space for a picture a row tall and `cols` cells wide in a line of text, such as an avatar: blank cells, - /// over which the window draws the picture at `source`. They are braille blanks, not spaces, so that wrapping - /// moves them as a word rather than hanging them past the edge as it does spaces. - pub fn picture(source: impl Into, cols: u16) -> Self { - Self { - picture: Some(source.into()), - ..Self::new("\u{2800}".repeat(usize::from(cols)), Group::Normal) - } - } - - /// The same span, referring to `target`. - #[must_use] - pub fn with_target(self, target: Target) -> Self { - Self { - target: Some(target), - ..self - } - } -} - -/// Something the text refers to. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Target { - /// A web link, opened in the browser. - Url(String), - /// A person or an organization. - Account(AccountRef), - /// A commit. - Commit { - /// Its id. - id: String, - /// Who wrote it; `None` for a deleted commit. - author: Option, - /// Its page on the web; `None` for a deleted commit. - url: Option, - /// Whether the connected account starred it. - starred: bool, - }, - /// The card the server made for a link, opened in the browser like the link. - LinkCard(LinkCard), - /// A buffer, by name, such as a profile's list of followers. - Buffer(String), -} - -/// What the server says about a link. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LinkCard { - /// The link. - pub url: String, - /// The page's title; may be empty. - pub title: String, - /// The site's name; may be empty. - pub site: String, - /// What the page says it is about; may be empty. - pub description: String, -} - -/// A person or an organization, as text refers to it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AccountRef { - /// The handle, without the `@`. - pub handle: String, - /// Whether it is a person or an organization; `None` when the text does not say. - pub kind: Option, -} - -impl AccountRef { - /// `handle`, of `kind`; a kind this version does not know counts as not saying. - pub fn new(handle: impl Into, kind: Option) -> Self { - Self { - handle: handle.into(), - kind: kind.filter(|kind| *kind != AccountKind::Other), - } - } -} - -/// A read-only document of keyed blocks of lines. -/// -/// Positions inside it are logical (line, grapheme) pairs, independent of any width; see [`Layout`] for screen rows. -#[derive(Debug, Default)] -pub struct Document { - blocks: Vec, - /// Block index by key. - index: HashMap, - /// The first logical line of each block, plus the total line count at the end. - starts: Vec, -} - -/// Which folds are open, as one window shows them (folds are per window, as in Vim), in whichever [`Document`]s it -/// shows: a block's folds are its own. Those a fold command opened or closed stay so, also when their block is -/// replaced; the others are as [`Fold::closed`] says, as are the folds of a block dropped and added again. -/// -/// Clones are cheap: they share the state until one of them changes. -#[derive(Debug, Clone, Default)] -pub struct Folds { - /// Whether a fold is open, by [`FoldAt::id`]. - open: Rc>, -} - -impl Folds { - /// Opens or closes folds holding the cursor's line in `doc` as `op` says. The cursor keeps its place in the - /// text; while a fold hides it, it is drawn on the fold's summary row. - pub fn fold(&mut self, doc: &Document, cursor: &Cursor, op: FoldOp) { - let holding = self.holding(doc, cursor); - let first_closed = holding.iter().position(|fold| self.is_closed(fold)); - let opening = match op { - FoldOp::Open | FoldOp::OpenRecursively => true, - FoldOp::Close | FoldOp::CloseRecursively => false, - FoldOp::Toggle | FoldOp::ToggleRecursively => first_closed.is_some(), - }; - let recursively = matches!( - op, - FoldOp::OpenRecursively | FoldOp::CloseRecursively | FoldOp::ToggleRecursively - ); - if opening { - let Some(at) = first_closed else { - return; - }; - let inside = recursively - .then(|| doc.folds_inside(&holding[at])) - .into_iter() - .flatten(); - self.set( - std::iter::once(holding[at].id).chain(inside.map(|fold| fold.id)), - true, - ); - } else { - let open = &holding[..first_closed.unwrap_or(holding.len())]; - let closing = if recursively { - &holding[..] - } else { - open.last().map(std::slice::from_ref).unwrap_or_default() - }; - self.set(closing.iter().map(|fold| fold.id), false); - } - } - - /// Opens every fold hiding the cursor's line in `doc`, as a search landing there does. - pub fn reveal(&mut self, doc: &Document, cursor: &Cursor) { - let holding = self.holding(doc, cursor); - self.set(holding.iter().map(|fold| fold.id), true); - } - - /// The folds holding the cursor's line in `doc`, the outermost first. - fn holding<'d>(&self, doc: &'d Document, cursor: &Cursor) -> Vec> { - if doc.line_count() == 0 { - return Vec::new(); - } - let mut holding: Vec> = doc.folds_holding(cursor.pos(doc).line).collect(); - holding.sort_by_key(|fold| (Reverse(fold.lines.len()), fold.id.1)); - holding - } - - /// Leaves the folds `ids` open, or closed. - fn set(&mut self, ids: impl Iterator, open: bool) { - let states = Rc::make_mut(&mut self.open); - states.extend(ids.map(|id| (id, open))); - } - - /// `zR` (`open`) and `zM`: opens or closes every fold of `doc`. - pub fn fold_all(&mut self, doc: &Document, open: bool) { - let states = Rc::make_mut(&mut self.open); - for block in &doc.blocks { - states.extend((0..block.folds.len()).map(|index| ((block.id, index), open))); - } - } - - fn is_closed(&self, fold: &FoldAt<'_>) -> bool { - self.open.get(&fold.id).map_or(fold.closed, |open| !open) - } -} - -/// The id of the next block made, in any document. -static NEXT_BLOCK_ID: AtomicU64 = AtomicU64::new(0); - -#[derive(Debug)] -struct Block { - /// Unique among the blocks of every document, and kept when the block is replaced: what [`Folds`] knows a block - /// by, so a block dropped and added again under its key, or one of another document, is another. - id: u64, - key: BlockKey, - lines: Vec, - folds: Vec, - target: Option, -} - -#[derive(Debug)] -struct ShapedFold { - lines: Range, - summary: Shaped, - closed: bool, -} - -impl Block { - /// A block with a new id. - fn new(key: BlockKey, content: Content) -> Self { - // Only uniqueness matters: no other memory is ordered by the counter. - let id = NEXT_BLOCK_ID.fetch_add(1, Ordering::Relaxed); - Self::with_id(id, key, content) - } - - fn with_id(id: u64, key: BlockKey, content: Content) -> Self { - let lines: Vec = content.lines.into_iter().map(Shaped::new).collect(); - let mut folds: Vec = Vec::new(); - for fold in content.folds { - let nests = |kept: &ShapedFold| { - let (a, b) = (&kept.lines, &fold.lines); - a.end <= b.start - || b.end <= a.start - || (a.start <= b.start && b.end <= a.end) - || (b.start <= a.start && a.end <= b.end) - }; - if !fold.lines.is_empty() && fold.lines.end <= lines.len() && folds.iter().all(nests) { - folds.push(ShapedFold { - lines: fold.lines, - summary: Shaped::new(fold.summary), - closed: fold.closed, - }); - } - } - Self { - id, - key, - lines, - folds, - target: content.target, - } - } -} - -/// A fold located in the document. -struct FoldAt<'d> { - /// Its block's id and its index there. - id: (u64, usize), - /// The folded logical lines, document-wide. - lines: Range, - summary: &'d Shaped, - /// Whether it is closed until a fold command says otherwise. - closed: bool, -} - -/// A line with its graphemes pre-segmented, so layout and motions don't segment the same text repeatedly. -#[derive(Debug)] -struct Shaped { - /// The text of all spans, concatenated. - text: String, - /// Byte offset and display width of each grapheme, in order. - graphemes: Vec, - /// The spans' styling and targets, by the byte range of `text` they cover, in order. - runs: Vec, - /// For a picture, the space it takes; text lines wrap instead. On a picture's line a position's column is not a - /// grapheme (there are none) but which of its rows the cursor is on. - image: Option, -} - -/// The space a picture takes. -#[derive(Debug)] -struct Space { - source: String, - cols: u16, - /// At least 1. - rows: u16, -} - -/// A span without its text: what styles and targets `text[previous run's end..end]`. -#[derive(Debug)] -struct Run { - /// Byte offset just past the span's text. - end: usize, - group: Group, - target: Option, - /// Where the picture drawn over the span's text is, for a span made by [`Span::picture`]. - picture: Option, -} - -#[derive(Debug, Clone, Copy)] -struct Grapheme { - start: usize, - width: usize, -} - -impl Shaped { - fn new(line: Line) -> Self { - let spans = match line { - Line::Text(spans) => spans, - Line::Image { rows, cols, source } => { - return Self { - text: String::new(), - graphemes: Vec::new(), - runs: Vec::new(), - image: Some(Space { - source, - cols, - rows: rows.max(1), - }), - }; - } - }; - let mut text = String::new(); - let mut runs = Vec::with_capacity(spans.len()); - for span in spans { - text.push_str(&span.text); - runs.push(Run { - end: text.len(), - group: span.group, - target: span.target, - picture: span.picture, - }); - } - let graphemes = text - .grapheme_indices(true) - .map(|(start, g)| Grapheme { - start, - width: g.width(), - }) - .collect(); - Self { - text, - graphemes, - runs, - image: None, - } - } - - /// The target of the span holding grapheme `col`. - fn target_at(&self, col: usize) -> Option<&Target> { - let byte = self.graphemes.get(col)?.start; - self.runs.iter().find(|run| run.end > byte)?.target.as_ref() - } - - fn len(&self) -> usize { - self.graphemes.len() - } - - /// How many leading graphemes fit in `width` cells. - fn fitting(&self, width: usize) -> usize { - let mut used = 0; - self.graphemes - .iter() - .take_while(|g| { - used += g.width; - used <= width - }) - .count() - } - - /// The highest column a position on this line can have: its last grapheme, or an image's last row. - fn last_col(&self) -> usize { - self.image - .as_ref() - .map_or(self.len(), |image| usize::from(image.rows)) - .saturating_sub(1) - } - - fn is_image(&self) -> bool { - self.image.is_some() - } - - /// The byte offset of grapheme `i`, or the text's length for `i == len()`. - fn byte_offset(&self, i: usize) -> usize { - self.graphemes.get(i).map_or(self.text.len(), |g| g.start) - } - - /// The text of grapheme `i`. - fn grapheme(&self, i: usize) -> &str { - &self.text[self.byte_offset(i)..self.byte_offset(i + 1)] - } - - /// Whether grapheme `i` is whitespace. - fn is_blank(&self, i: usize) -> bool { - self.grapheme(i).chars().all(char::is_whitespace) - } -} - -/// A position expressed relative to its block, so it survives edits elsewhere in the document. -#[derive(Debug, Clone, PartialEq, Eq)] -struct Anchor { - block: BlockKey, - /// Logical line within the block. - line: usize, - /// Grapheme within the line. - col: usize, - /// The document-wide logical line when anchored, used once the block is gone. - fallback: usize, -} - -/// A logical position: a line of the whole document and a grapheme index within it. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)] -struct Pos { - line: usize, - col: usize, -} - -impl Document { - /// An empty document. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Appends a block at the end. - /// - /// # Panics - /// - /// If a block with the same key is already present. - pub fn push_back(&mut self, key: impl Into, content: impl Into) { - self.insert(self.blocks.len(), key.into(), content.into()); - } - - /// Inserts a block at the start, as when newer commits arrive. Cursors stay on their text. - /// - /// # Panics - /// - /// If a block with the same key is already present. - pub fn push_front(&mut self, key: impl Into, content: impl Into) { - self.insert(0, key.into(), content.into()); - } - - /// Inserts a block just before the block `before`, or at the end when there is no such block. Cursors stay on - /// their text. - /// - /// # Panics - /// - /// If a block with the same key is already present. - pub fn insert_before( - &mut self, - before: impl Into, - key: impl Into, - content: impl Into, - ) { - let at = self - .index - .get(&before.into()) - .copied() - .unwrap_or(self.blocks.len()); - self.insert(at, key.into(), content.into()); - } - - fn insert(&mut self, at: usize, key: BlockKey, content: Content) { - assert!( - !self.index.contains_key(&key), - "duplicate block key {key:?}" - ); - self.blocks.insert(at, Block::new(key, content)); - self.reindex(); - } - - /// Replaces the content of the block `key`, keeping its place. Cursors in it stay on the same line and - /// character, clamped to the new text, and its folds keep the state fold commands gave them. Returns whether the - /// block was there; nothing changes when it wasn't. - pub fn replace(&mut self, key: impl Into, content: impl Into) -> bool { - let key = key.into(); - let Some(&block) = self.index.get(&key) else { - return false; - }; - self.blocks[block] = Block::with_id(self.blocks[block].id, key, content.into()); - self.reindex(); - true - } - - /// Removes the block `key`, if present. Cursors in it move to whatever takes its place. - pub fn remove(&mut self, key: impl Into) { - let key = key.into(); - if let Some(block) = self.index.get(&key).copied() { - self.blocks.remove(block); - self.reindex(); - } - } - - /// Removes every block whose key `keep` says not to keep. Cursors in them move to whatever takes their place. - pub fn retain(&mut self, mut keep: impl FnMut(&str) -> bool) { - self.blocks.retain(|block| keep(&block.key.0)); - self.reindex(); - } - - /// The folds holding `line`, in no particular order. - fn folds_holding(&self, line: usize) -> impl Iterator> { - self.folds_of(self.block_of(line)) - .filter(move |fold| fold.lines.contains(&line)) - } - - /// The folds inside `outer`, but for itself. - fn folds_inside<'d>(&'d self, outer: &FoldAt<'_>) -> impl Iterator> { - let (id, lines) = (outer.id, outer.lines.clone()); - self.folds_of(self.block_of(lines.start)) - .filter(move |fold| { - fold.id != id && lines.start <= fold.lines.start && fold.lines.end <= lines.end - }) - } - - /// The folds of block `block`. - fn folds_of(&self, block: usize) -> impl Iterator> { - let start = self.starts[block]; - let id = self.blocks[block].id; - self.blocks[block] - .folds - .iter() - .enumerate() - .map(move |(index, fold)| FoldAt { - id: (id, index), - lines: start + fold.lines.start..start + fold.lines.end, - summary: &fold.summary, - closed: fold.closed, - }) - } - - /// The text of line `line` (from 0) of block `key`, if both are there. - #[must_use] - pub fn line_text(&self, key: impl Into, line: usize) -> Option<&str> { - let block = &self.blocks[*self.index.get(&key.into())?]; - block.lines.get(line).map(|shaped| shaped.text.as_str()) - } - - /// Whether a block with this key is in the document. - #[must_use] - pub fn contains(&self, key: impl Into) -> bool { - self.index.contains_key(&key.into()) - } - - /// Whether the document has no blocks. - #[must_use] - pub fn is_empty(&self) -> bool { - self.blocks.is_empty() - } - - /// The anchor for `pos`, which must be a position in this document. - fn anchor(&self, pos: Pos) -> Anchor { - let block = self.block_of(pos.line); - Anchor { - block: self.blocks[block].key.clone(), - line: pos.line - self.starts[block], - col: pos.col, - fallback: pos.line, - } - } - - /// Where `anchor` is now: the same text when its block still exists, clamped to the block's current lines; - /// otherwise whatever took the block's place. An empty document resolves to the origin. - fn resolve(&self, anchor: &Anchor) -> Pos { - let line = match self.index.get(&anchor.block) { - Some(&block) => { - let len = self.blocks[block].lines.len(); - self.starts[block] + anchor.line.min(len.saturating_sub(1)) - } - None => anchor.fallback.min(self.line_count().saturating_sub(1)), - }; - if self.line_count() == 0 { - return Pos::default(); - } - Pos { - line, - col: anchor.col.min(self.line(line).last_col()), - } - } - - /// The last character of the document, or the origin when it is empty. - fn last_pos(&self) -> Pos { - let Some(line) = self.line_count().checked_sub(1) else { - return Pos::default(); - }; - Pos { - line, - col: self.line(line).len().saturating_sub(1), - } - } - - /// The first line of the block `offset` blocks away from the one holding `line` (negative is backward), clamped - /// to the first and last blocks. - fn block_start(&self, line: usize, offset: isize) -> usize { - let block = self.block_of(line).saturating_add_signed(offset); - self.starts[block.min(self.blocks.len() - 1)] - } - - fn block_of(&self, line: usize) -> usize { - self.starts.partition_point(|&start| start <= line) - 1 - } - - fn reindex(&mut self) { - self.index = self - .blocks - .iter() - .enumerate() - .map(|(i, block)| (block.key.clone(), i)) - .collect(); - self.starts.clear(); - let mut line = 0; - for block in &self.blocks { - self.starts.push(line); - line += block.lines.len(); - } - self.starts.push(line); - } - - /// The number of logical lines. - fn line_count(&self) -> usize { - self.starts.last().copied().unwrap_or(0) - } - - fn line(&self, line: usize) -> &Shaped { - let block = self.block_of(line); - &self.blocks[block].lines[line - self.starts[block]] - } - - /// What the text at `pos` refers to: its span's target, else its block's. - fn target_at(&self, pos: Pos) -> Option<&Target> { - self.line(pos.line) - .target_at(pos.col) - .or_else(|| self.block_target_at(pos)) - } - - /// What the block holding `pos` refers to as a whole. - fn block_target_at(&self, pos: Pos) -> Option<&Target> { - self.blocks[self.block_of(pos.line)].target.as_ref() - } -} diff --git a/crates/devnull/src/doc/search.rs b/crates/devnull/src/doc/search.rs deleted file mode 100644 index 0854f18..0000000 --- a/crates/devnull/src/doc/search.rs +++ /dev/null @@ -1,193 +0,0 @@ -//! `/` and `?`: regex search over the logical text, so a match can span a wrap point but not a line break. - -use std::fmt; - -use regex::{Regex, RegexBuilder}; - -use super::{Document, Pos, Shaped}; - -/// How a pattern treats letter case, as Vim's `'ignorecase'` and `'smartcase'`. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum CaseMode { - /// Case matters (Vim's default). - #[default] - Sensitive, - /// Case never matters (`'ignorecase'`). - Insensitive, - /// Case matters only when the pattern has an uppercase letter (`'ignorecase'` and `'smartcase'`). - Smart, -} - -/// A compiled search pattern, in Rust [`regex`](https://docs.rs/regex) syntax. -#[derive(Debug, Clone)] -pub struct Pattern { - regex: Regex, -} - -/// Why a pattern does not compile. -#[derive(Debug)] -pub struct PatternError(regex::Error); - -impl fmt::Display for PatternError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "invalid pattern: {}", self.0) - } -} - -impl std::error::Error for PatternError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - Some(&self.0) - } -} - -impl Pattern { - /// Compiles `pattern`, matching case as `case` says. - /// - /// # Errors - /// - /// If `pattern` is not a valid regex. - pub fn new(pattern: &str, case: CaseMode) -> Result { - let insensitive = match case { - CaseMode::Sensitive => false, - CaseMode::Insensitive => true, - CaseMode::Smart => !pattern.chars().any(char::is_uppercase), - }; - let regex = RegexBuilder::new(pattern) - .case_insensitive(insensitive) - .build() - .map_err(PatternError)?; - Ok(Self { regex }) - } - - /// Matches `word` literally and only as a whole word, as `*` does. Like Vim, it ignores `'smartcase'`, so - /// [`CaseMode::Smart`] matches case-insensitively. - /// - /// # Panics - /// - /// Never for any `word`: it is escaped, so the pattern is always valid. A panic here is a bug. - #[must_use] - pub fn whole_word(word: &str, case: CaseMode) -> Self { - let case = if case == CaseMode::Sensitive { - CaseMode::Sensitive - } else { - CaseMode::Insensitive - }; - let pattern = format!(r"\b{}\b", regex::escape(word)); - Self::new(&pattern, case).expect("an escaped word between word boundaries is a valid regex") - } - - /// Matches `text` literally, anywhere, as `*` does for a word that is not a keyword; like - /// [`Pattern::whole_word`], it ignores `'smartcase'`. - /// - /// # Panics - /// - /// Never for any `text`: it is escaped, so the pattern is always valid. A panic here is a bug. - #[must_use] - pub fn literal(text: &str, case: CaseMode) -> Self { - let case = if case == CaseMode::Sensitive { - CaseMode::Sensitive - } else { - CaseMode::Insensitive - }; - Self::new(®ex::escape(text), case).expect("escaped text is a valid regex") - } - - /// The grapheme ranges matches cover in `line`, in order; empty matches are left out. - pub(super) fn ranges(&self, line: &Shaped) -> Vec> { - self.regex - .find_iter(&line.text) - .filter(|m| !m.is_empty()) - .map(|m| { - let start = line - .graphemes - .partition_point(|g| g.start <= m.start()) - .saturating_sub(1); - let end = line.graphemes.partition_point(|g| g.start < m.end()); - start..end - }) - .collect() - } - - /// The grapheme columns where matches start in `line`, in order. - fn starts(&self, line: &Shaped) -> Vec { - self.regex - .find_iter(&line.text) - .map(|m| { - line.graphemes - .partition_point(|g| g.start <= m.start()) - .saturating_sub(1) - }) - .collect() - } -} - -/// Which way a search goes. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Direction { - /// `/` and `n` after `/`: toward the end. - Forward, - /// `?` and `n` after `?`: toward the start. - Backward, -} - -/// What a search did, for the message Vim would show. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[must_use] -pub enum SearchOutcome { - /// Moved to a match. - Found, - /// Moved to a match after wrapping around an end of the document ("search hit BOTTOM, continuing at TOP"). - Wrapped, - /// No match anywhere; the cursor did not move ("Pattern not found"). - NotFound, -} - -/// The next match from `pos` going `direction`, and whether reaching it wrapped around. -pub(super) fn next_match( - doc: &Document, - pattern: &Pattern, - pos: Pos, - direction: Direction, -) -> Option<(Pos, bool)> { - let first = |line| { - Some(Pos { - line, - col: *pattern.starts(doc.line(line)).first()?, - }) - }; - let last = |line| { - Some(Pos { - line, - col: *pattern.starts(doc.line(line)).last()?, - }) - }; - let in_line = |col| Pos { - line: pos.line, - col, - }; - let lines = doc.line_count(); - let (unwrapped, wrapped) = match direction { - Direction::Forward => ( - pattern - .starts(doc.line(pos.line)) - .into_iter() - .find(|&c| c > pos.col) - .map(in_line) - .or_else(|| (pos.line + 1..lines).find_map(first)), - (0..=pos.line).find_map(first), - ), - Direction::Backward => ( - pattern - .starts(doc.line(pos.line)) - .into_iter() - .rev() - .find(|&c| c < pos.col) - .map(in_line) - .or_else(|| (0..pos.line).rev().find_map(last)), - (pos.line..lines).rev().find_map(last), - ), - }; - unwrapped - .map(|p| (p, false)) - .or_else(|| wrapped.map(|p| (p, true))) -} diff --git a/crates/devnull/src/doc/tests.rs b/crates/devnull/src/doc/tests.rs deleted file mode 100644 index 49ac52a..0000000 --- a/crates/devnull/src/doc/tests.rs +++ /dev/null @@ -1,1454 +0,0 @@ -//! Behaviour of the document model through its public interface: `Document`, `Layout` and `Cursor`. - -use super::*; - -/// A document of `(key, lines)` blocks of plain text. -fn doc(blocks: &[(&str, &[&str])]) -> Document { - let mut doc = Document::new(); - for (key, lines) in blocks { - doc.push_back( - *key, - lines.iter().map(|l| Line::text(*l)).collect::>(), - ); - } - doc -} - -fn at(row: usize, col: usize) -> ScreenPos { - ScreenPos { row, col } -} - -#[test] -fn j_moves_down_one_row() { - let doc = doc(&[("a", &["first", "second"])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - - cursor.apply(&layout, Motion::Down, 1); - - assert_eq!(cursor.screen(&layout), at(1, 0)); -} - -#[test] -fn cursor_stays_on_its_line_when_blocks_arrive_above() { - let mut doc = doc(&[("b", &["bravo one", "bravo two"])]); - let mut cursor = Cursor::new(); - cursor.apply(&Layout::new(&doc, 80), Motion::Down, 1); - - doc.push_front("a", vec![Line::text("alpha one"), Line::text("alpha two")]); - - assert_eq!(cursor.screen(&Layout::new(&doc, 80)), at(3, 0)); -} - -#[test] -fn long_lines_wrap_at_word_boundaries() { - let doc = doc(&[("a", &["one two three four", ""])]); - let layout = Layout::new(&doc, 9); - - let rows: Vec<&str> = (0..layout.row_count()) - .map(|row| layout.row_text(row)) - .collect(); - - assert_eq!(rows, ["one two ", "three ", "four", ""]); -} - -#[test] -fn a_word_longer_than_the_width_is_cut() { - let doc = doc(&[("a", &["abcdefghij xy"])]); - let layout = Layout::new(&doc, 4); - - let rows: Vec<&str> = (0..layout.row_count()) - .map(|row| layout.row_text(row)) - .collect(); - - assert_eq!(rows, ["abcd", "efgh", "ij ", "xy"]); -} - -#[test] -fn spaces_that_reach_the_edge_hang_off_it_instead_of_starting_a_row() { - let doc = doc(&[("a", &["one two three"])]); - let layout = Layout::new(&doc, 7); - - let rows: Vec<&str> = (0..layout.row_count()) - .map(|row| layout.row_text(row)) - .collect(); - - assert_eq!(rows, ["one two ", "three"]); -} - -#[test] -fn j_moves_through_the_rows_of_a_wrapped_line() { - let doc = doc(&[("a", &["one two three four"])]); - let layout = Layout::new(&doc, 9); - let mut cursor = Cursor::new(); - - cursor.apply(&layout, Motion::Down, 2); - - assert_eq!(cursor.screen(&layout), at(2, 0)); -} - -#[test] -fn l_follows_the_text_onto_the_next_row_but_stops_at_the_end_of_the_line() { - let doc = doc(&[("a", &["one two", "next"])]); - let layout = Layout::new(&doc, 4); - let mut cursor = Cursor::new(); - - cursor.apply(&layout, Motion::Right, 5); - assert_eq!(cursor.screen(&layout), at(1, 1)); - - cursor.apply(&layout, Motion::Right, 10); - assert_eq!(cursor.screen(&layout), at(1, 2)); -} - -#[test] -fn h_stops_at_the_start_of_the_line() { - let doc = doc(&[("a", &["one", "two three"])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - cursor.apply(&layout, Motion::Down, 1); - cursor.apply(&layout, Motion::Right, 4); - - cursor.apply(&layout, Motion::Left, 3); - assert_eq!(cursor.screen(&layout), at(1, 1)); - - cursor.apply(&layout, Motion::Left, 3); - assert_eq!(cursor.screen(&layout), at(1, 0)); -} - -#[test] -fn the_cursor_stays_on_its_character_when_the_width_changes() { - let doc = doc(&[("a", &["one two three four"])]); - let mut cursor = Cursor::new(); - cursor.apply(&Layout::new(&doc, 9), Motion::Right, 15); - assert_eq!(cursor.screen(&Layout::new(&doc, 9)), at(2, 1)); - - assert_eq!(cursor.screen(&Layout::new(&doc, 80)), at(0, 15)); - assert_eq!(cursor.screen(&Layout::new(&doc, 5)), at(3, 1)); -} - -#[test] -fn k_moves_up_and_stops_at_the_top() { - let doc = doc(&[("a", &["one", "two", "three"])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - cursor.apply(&layout, Motion::Down, 2); - - cursor.apply(&layout, Motion::Up, 1); - assert_eq!(cursor.screen(&layout), at(1, 0)); - - cursor.apply(&layout, Motion::Up, 5); - assert_eq!(cursor.screen(&layout), at(0, 0)); -} - -#[test] -fn vertical_moves_keep_aiming_for_the_same_column_across_short_rows() { - let doc = doc(&[("a", &["hello world", "hi", "", "hello again"])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - cursor.apply(&layout, Motion::Right, 8); - - cursor.apply(&layout, Motion::Down, 1); - assert_eq!(cursor.screen(&layout), at(1, 1)); - cursor.apply(&layout, Motion::Down, 1); - assert_eq!(cursor.screen(&layout), at(2, 0)); - cursor.apply(&layout, Motion::Down, 1); - assert_eq!(cursor.screen(&layout), at(3, 8)); - - cursor.apply(&layout, Motion::Left, 1); - cursor.apply(&layout, Motion::Up, 3); - assert_eq!(cursor.screen(&layout), at(0, 7)); -} - -#[test] -fn wide_characters_take_two_cells_and_the_cursor_lands_on_the_one_covering_its_column() { - let doc = doc(&[("a", &["abcdef", "日本語です", "x🦀y"])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - cursor.apply(&layout, Motion::Right, 3); - - cursor.apply(&layout, Motion::Down, 1); - assert_eq!(cursor.screen(&layout), at(1, 2)); - cursor.apply(&layout, Motion::Right, 1); - assert_eq!(cursor.screen(&layout), at(1, 4)); - cursor.apply(&layout, Motion::Down, 1); - assert_eq!(cursor.screen(&layout), at(2, 3)); -} - -#[test] -fn zero_caret_and_dollar_work_on_the_screen_row() { - let doc = doc(&[("a", &[" indented text wraps here"])]); - let layout = Layout::new(&doc, 16); - assert_eq!( - (0..layout.row_count()) - .map(|r| layout.row_text(r)) - .collect::>(), - [" indented text ", "wraps here"] - ); - let mut cursor = Cursor::new(); - - cursor.apply(&layout, Motion::FirstNonBlank, 1); - assert_eq!(cursor.screen(&layout), at(0, 2)); - cursor.apply(&layout, Motion::RowEnd, 1); - assert_eq!(cursor.screen(&layout), at(0, 15)); - cursor.apply(&layout, Motion::Down, 1); - cursor.apply(&layout, Motion::Left, 2); - cursor.apply(&layout, Motion::RowStart, 1); - assert_eq!(cursor.screen(&layout), at(1, 0)); -} - -#[test] -fn after_dollar_vertical_moves_stick_to_the_end_of_each_row() { - let doc = doc(&[("a", &["short", "a longer line", "mid", ""])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - cursor.apply(&layout, Motion::RowEnd, 1); - - cursor.apply(&layout, Motion::Down, 1); - assert_eq!(cursor.screen(&layout), at(1, 12)); - cursor.apply(&layout, Motion::Down, 1); - assert_eq!(cursor.screen(&layout), at(2, 2)); - cursor.apply(&layout, Motion::Down, 1); - assert_eq!(cursor.screen(&layout), at(3, 0)); - cursor.apply(&layout, Motion::Up, 2); - assert_eq!(cursor.screen(&layout), at(1, 12)); -} - -/// Applies `motion` once per step and records where the cursor lands each time. -fn walk(layout: &Layout<'_>, cursor: &mut Cursor, motion: Motion, steps: usize) -> Vec { - (0..steps) - .map(|_| { - cursor.apply(layout, motion, 1); - cursor.screen(layout) - }) - .collect() -} - -#[test] -fn w_stops_at_each_word_and_punctuation_run() { - let doc = doc(&[("a", &["foo.bar baz"])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - - let stops = walk(&layout, &mut cursor, Motion::WordForward, 4); - - assert_eq!(stops, [at(0, 3), at(0, 4), at(0, 8), at(0, 10)]); -} - -#[test] -fn w_crosses_lines_and_stops_on_empty_ones() { - let doc = doc(&[("a", &["one", ""]), ("b", &[" two"])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - - let stops = walk(&layout, &mut cursor, Motion::WordForward, 2); - - assert_eq!(stops, [at(1, 0), at(2, 2)]); -} - -#[test] -fn big_w_only_stops_after_blanks() { - let doc = doc(&[("a", &["foo.bar baz"])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - - cursor.apply(&layout, Motion::BigWordForward, 1); - - assert_eq!(cursor.screen(&layout), at(0, 8)); -} - -#[test] -fn e_goes_to_the_end_of_each_word() { - let doc = doc(&[("a", &["foo.bar", "", "baz"])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - - let stops = walk(&layout, &mut cursor, Motion::WordEnd, 4); - - assert_eq!(stops, [at(0, 2), at(0, 3), at(0, 6), at(2, 2)]); -} - -#[test] -fn b_goes_back_to_the_start_of_each_word() { - let doc = doc(&[("a", &["foo.bar", "", "baz qux"])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - cursor.apply(&layout, Motion::Down, 2); - cursor.apply(&layout, Motion::RowEnd, 1); - - let stops = walk(&layout, &mut cursor, Motion::WordBackward, 6); - - assert_eq!( - stops, - [at(2, 4), at(2, 0), at(1, 0), at(0, 4), at(0, 3), at(0, 0)] - ); -} - -#[test] -fn big_e_and_big_b_treat_punctuation_as_part_of_the_word() { - let doc = doc(&[("a", &["foo.bar baz"])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - - cursor.apply(&layout, Motion::BigWordEnd, 1); - assert_eq!(cursor.screen(&layout), at(0, 6)); - cursor.apply(&layout, Motion::BigWordForward, 1); - cursor.apply(&layout, Motion::BigWordBackward, 1); - assert_eq!(cursor.screen(&layout), at(0, 0)); -} - -#[test] -fn word_motions_stop_at_the_edges_of_the_document() { - let doc = doc(&[("a", &["one two"])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - - cursor.apply(&layout, Motion::WordForward, 5); - assert_eq!(cursor.screen(&layout), at(0, 6)); - cursor.apply(&layout, Motion::WordBackward, 5); - assert_eq!(cursor.screen(&layout), at(0, 0)); -} - -#[test] -fn f_and_t_find_a_character_forward_in_the_line_and_stay_put_when_it_is_missing() { - let doc = doc(&[("a", &["a.b.c.d", "x.y"])]); - let layout = Layout::new(&doc, 4); - let mut cursor = Cursor::new(); - - cursor.apply(&layout, Motion::FindForward('.'), 2); - assert_eq!(cursor.screen(&layout), at(0, 3)); - cursor.apply(&layout, Motion::TillForward('.'), 1); - assert_eq!(cursor.screen(&layout), at(1, 0)); - cursor.apply(&layout, Motion::FindForward('y'), 1); - assert_eq!(cursor.screen(&layout), at(1, 0)); -} - -#[test] -fn big_f_and_big_t_find_backward() { - let doc = doc(&[("a", &["a.b.c.d"])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - cursor.apply(&layout, Motion::RowEnd, 1); - - cursor.apply(&layout, Motion::FindBackward('.'), 2); - assert_eq!(cursor.screen(&layout), at(0, 3)); - cursor.apply(&layout, Motion::TillBackward('a'), 1); - assert_eq!(cursor.screen(&layout), at(0, 1)); -} - -#[test] -fn double_brackets_jump_between_blocks() { - let doc = doc(&[ - ("a", &["alpha", "more alpha"]), - ("b", &[" bravo"]), - ("c", &["charlie"]), - ]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - - assert_eq!( - walk(&layout, &mut cursor, Motion::NextBlock, 3), - [at(2, 0), at(3, 0), at(3, 0)] - ); - - cursor.apply(&layout, Motion::Up, 1); - cursor.apply(&layout, Motion::RowEnd, 1); - cursor.apply(&layout, Motion::PrevBlock, 1); - assert_eq!(cursor.screen(&layout), at(2, 0)); - cursor.apply(&layout, Motion::PrevBlock, 5); - assert_eq!(cursor.screen(&layout), at(0, 0)); -} - -#[test] -fn braces_jump_to_the_empty_lines_between_paragraphs() { - let doc = doc(&[ - ("a", &["one", "two", "", "", "three", ""]), - ("b", &["four"]), - ]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - - assert_eq!( - walk(&layout, &mut cursor, Motion::ParagraphForward, 3), - [at(2, 0), at(5, 0), at(6, 3)] - ); - assert_eq!( - walk(&layout, &mut cursor, Motion::ParagraphBackward, 3), - [at(5, 0), at(3, 0), at(0, 0)] - ); -} - -#[test] -fn gg_and_big_g_go_to_a_screen_row_by_number_or_to_the_last_one() { - let doc = doc(&[("a", &["first", " wrapped second row", "last"])]); - let layout = Layout::new(&doc, 10); - let mut cursor = Cursor::new(); - - cursor.apply(&layout, Motion::LastRow, 1); - assert_eq!(cursor.screen(&layout), at(layout.row_count() - 1, 0)); - cursor.apply(&layout, Motion::GotoRow(2), 1); - assert_eq!(cursor.screen(&layout), at(1, 2)); - cursor.apply(&layout, Motion::GotoRow(99), 1); - assert_eq!(cursor.screen(&layout), at(layout.row_count() - 1, 0)); -} - -#[test] -fn a_count_past_the_last_block_goes_as_far_as_it_can() { - let doc = doc(&[("a", &["alpha"]), ("b", &["bravo"]), ("c", &["charlie"])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - - cursor.apply(&layout, Motion::NextBlock, 9); - - assert_eq!(cursor.screen(&layout), at(2, 0)); -} - -#[test] -fn the_target_under_the_cursor_is_the_one_of_the_span_it_sits_in() { - let mut doc = Document::new(); - doc.push_back( - "a", - vec![Line::Text(vec![ - Span::new("hi ", Group::Normal), - Span::new("@ada", Group::Normal).with_target(Target::Buffer("@ada".into())), - Span::new(" 🦀 ", Group::Normal), - Span::new("dev-null.sh", Group::Normal) - .with_target(Target::Url("https://dev-null.sh".into())), - ])], - ); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - let mut target_after = |motion, count| { - cursor.apply(&layout, motion, count); - cursor.target(&layout).cloned() - }; - - assert_eq!(target_after(Motion::Left, 1), None); - assert_eq!( - target_after(Motion::Right, 3), - Some(Target::Buffer("@ada".into())) - ); - assert_eq!( - target_after(Motion::Right, 3), - Some(Target::Buffer("@ada".into())) - ); - assert_eq!(target_after(Motion::Right, 2), None); - assert_eq!( - target_after(Motion::Right, 2), - Some(Target::Url("https://dev-null.sh".into())) - ); -} - -#[test] -fn text_without_a_target_of_its_own_refers_to_its_block_s() { - let mut doc = Document::new(); - let lines = vec![ - Line::Text(vec![ - Span::new("by ", Group::Normal), - Span::new("@ada", Group::Normal).with_target(Target::Buffer("@ada".into())), - ]), - Line::text(""), - ]; - doc.push_back( - "a", - Content { - lines, - target: Some(Target::Buffer("c1".into())), - ..Content::default() - }, - ); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - let mut target_after = |motion, count| { - cursor.apply(&layout, motion, count); - cursor.target(&layout).cloned() - }; - - assert_eq!( - target_after(Motion::Left, 1), - Some(Target::Buffer("c1".into())) - ); - assert_eq!( - target_after(Motion::Right, 3), - Some(Target::Buffer("@ada".into())) - ); - assert_eq!( - target_after(Motion::Down, 1), - Some(Target::Buffer("c1".into())), - "an empty line too" - ); - let mut cursor = Cursor::new(); - cursor.apply(&layout, Motion::Right, 3); - assert_eq!(cursor.target(&layout), Some(&Target::Buffer("@ada".into()))); - assert_eq!( - cursor.block_target(&layout), - Some(&Target::Buffer("c1".into())), - "the block's own" - ); -} - -#[test] -fn a_cursor_can_wait_at_a_block_that_has_not_arrived_yet() { - let mut doc = doc(&[("a", &["one", "two"])]); - let cursor = Cursor::on_block("b"); - assert_eq!(cursor.screen(&Layout::new(&doc, 80)), at(0, 0)); - - doc.push_back("b", vec![Line::text("three")]); - - assert_eq!(cursor.screen(&Layout::new(&doc, 80)), at(2, 0)); -} - -#[test] -fn an_empty_document_has_no_target_and_ignores_motions() { - let doc = Document::new(); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - - cursor.apply(&layout, Motion::WordForward, 3); - - assert_eq!(cursor.screen(&layout), at(0, 0)); - assert_eq!(cursor.target(&layout), None); -} - -#[test] -fn replacing_the_cursor_block_keeps_the_cursor_on_the_same_line_clamped_to_the_new_text() { - let mut doc = doc(&[("a", &["header", "★ 12 stars"]), ("b", &["next"])]); - let mut cursor = Cursor::new(); - cursor.apply(&Layout::new(&doc, 80), Motion::Down, 1); - cursor.apply(&Layout::new(&doc, 80), Motion::RowEnd, 1); - - assert!(doc.replace("a", vec![Line::text("header"), Line::text("★ 3")])); - - assert_eq!(cursor.screen(&Layout::new(&doc, 80)), at(1, 2)); - assert!(!doc.replace("missing", vec![])); -} - -#[test] -fn removing_the_cursor_block_leaves_the_cursor_on_what_took_its_place() { - let mut doc = doc(&[("a", &["alpha"]), ("b", &["bravo"]), ("c", &["charlie"])]); - let mut cursor = Cursor::new(); - cursor.apply(&Layout::new(&doc, 80), Motion::Down, 1); - - doc.remove("b"); - - assert_eq!(cursor.screen(&Layout::new(&doc, 80)), at(1, 0)); - assert_eq!(Layout::new(&doc, 80).row_text(1), "charlie"); -} - -fn row_texts(doc: &Document) -> Vec { - let layout = Layout::new(doc, 80); - (0..layout.row_count()) - .map(|row| layout.row_text(row).to_owned()) - .collect() -} - -#[test] -fn a_block_can_go_just_before_another_or_at_the_end_without_it() { - let mut doc = doc(&[("a", &["alpha"]), ("c", &["charlie"])]); - let mut cursor = Cursor::new(); - cursor.apply(&Layout::new(&doc, 80), Motion::Down, 1); - - doc.insert_before("c", "b", vec![Line::text("bravo")]); - doc.insert_before("nothing", "d", vec![Line::text("delta")]); - - assert_eq!(row_texts(&doc), ["alpha", "bravo", "charlie", "delta"]); - assert_eq!( - cursor.screen(&Layout::new(&doc, 80)), - at(2, 0), - "still on charlie" - ); -} - -#[test] -fn retain_keeps_only_the_blocks_asked_for() { - let mut doc = doc(&[("a", &["alpha"]), ("b", &["bravo"]), ("c", &["charlie"])]); - let mut cursor = Cursor::new(); - cursor.apply(&Layout::new(&doc, 80), Motion::Down, 2); - - doc.retain(|key| key != "b"); - - assert_eq!(row_texts(&doc), ["alpha", "charlie"]); - assert_eq!( - cursor.screen(&Layout::new(&doc, 80)), - at(1, 0), - "still on charlie" - ); -} - -#[test] -fn rows_come_out_as_styled_segments_split_at_span_and_row_boundaries() { - let mut doc = Document::new(); - doc.push_back( - "a", - vec![Line::Text(vec![ - Span::new("plain ", Group::Normal), - Span::new("@handle", Group::Handle), - ])], - ); - let layout = Layout::new(&doc, 9); - - let rows: Vec> = (0..layout.row_count()) - .map(|row| layout.segments(row).collect()) - .collect(); - - assert_eq!( - rows, - [ - vec![("plain ", Group::Normal)], - vec![("@handle", Group::Handle)] - ] - ); - - let mut doc = Document::new(); - doc.push_back( - "a", - vec![Line::Text(vec![ - Span::new("ab", Group::Normal), - Span::new("cdefgh", Group::Handle), - ])], - ); - let layout = Layout::new(&doc, 4); - let rows: Vec> = (0..layout.row_count()) - .map(|row| layout.segments(row).collect()) - .collect(); - assert_eq!( - rows, - [ - vec![("ab", Group::Normal), ("cd", Group::Handle)], - vec![("efgh", Group::Handle)] - ] - ); -} - -#[test] -fn an_image_line_takes_its_rows_at_any_width_it_fits_and_the_cursor_walks_through_them() { - let mut doc = Document::new(); - doc.push_back( - "a", - vec![ - Line::text("above"), - Line::Image { - rows: 3, - cols: 16, - source: "p.png".into(), - }, - Line::text("below"), - ], - ); - - for width in [16, 80] { - let layout = Layout::new(&doc, width); - assert_eq!(layout.row_count(), 5); - let mut cursor = Cursor::new(); - cursor.apply(&layout, Motion::Right, 2); - assert_eq!( - walk(&layout, &mut cursor, Motion::Down, 4), - [at(1, 0), at(2, 0), at(3, 0), at(4, 2)] - ); - assert_eq!(layout.segments(2).count(), 0); - } -} - -#[test] -fn on_an_image_h_and_l_stay_put_and_the_cursor_keeps_its_image_row_across_resizes() { - let mut doc = Document::new(); - doc.push_back( - "a", - vec![ - Line::text("above the picture, long"), - Line::Image { - rows: 3, - cols: 16, - source: "p.png".into(), - }, - ], - ); - let mut cursor = Cursor::new(); - cursor.apply(&Layout::new(&doc, 80), Motion::Down, 2); - - cursor.apply(&Layout::new(&doc, 80), Motion::Right, 1); - cursor.apply(&Layout::new(&doc, 80), Motion::Left, 1); - assert_eq!(cursor.screen(&Layout::new(&doc, 80)), at(2, 0)); - assert_eq!( - cursor.screen(&Layout::new(&doc, 16)), - at(3, 0), - "below the text, now two rows" - ); -} - -#[test] -fn slash_search_moves_to_the_next_match_wrapping_around_the_end() { - use SearchOutcome::{Found, Wrapped}; - - let doc = doc(&[ - ("a", &["one fish two fish"]), - ("b", &["red fish blue fish"]), - ]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - let fish = Pattern::new("fi.h", CaseMode::Sensitive).expect("valid pattern"); - - let mut hits = Vec::new(); - for _ in 0..5 { - let outcome = cursor.search(&layout, &fish, Direction::Forward, 1); - hits.push((outcome, cursor.screen(&layout))); - } - - assert_eq!( - hits, - [ - (Found, at(0, 4)), - (Found, at(0, 13)), - (Found, at(1, 4)), - (Found, at(1, 14)), - (Wrapped, at(0, 4)) - ] - ); -} - -#[test] -fn backward_search_and_counts() { - let doc = doc(&[ - ("a", &["one fish two fish"]), - ("b", &["red fish blue fish"]), - ]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - let fish = Pattern::new("fish", CaseMode::Sensitive).expect("valid pattern"); - - assert_eq!( - cursor.search(&layout, &fish, Direction::Backward, 1), - SearchOutcome::Wrapped - ); - assert_eq!(cursor.screen(&layout), at(1, 14)); - assert_eq!( - cursor.search(&layout, &fish, Direction::Backward, 2), - SearchOutcome::Found - ); - assert_eq!(cursor.screen(&layout), at(0, 13)); -} - -#[test] -fn search_matches_across_wrap_points_and_reports_a_missing_pattern() { - let doc = doc(&[("a", &["the quick brown fox"])]); - let layout = Layout::new(&doc, 10); - let mut cursor = Cursor::new(); - let phrase = Pattern::new("quick brown", CaseMode::Sensitive).expect("valid pattern"); - let missing = Pattern::new("QUICK", CaseMode::Sensitive).expect("valid pattern"); - - assert_eq!( - cursor.search(&layout, &phrase, Direction::Forward, 1), - SearchOutcome::Found - ); - assert_eq!(cursor.screen(&layout), at(0, 4)); - assert_eq!( - cursor.search(&layout, &missing, Direction::Forward, 1), - SearchOutcome::NotFound - ); - assert_eq!(cursor.screen(&layout), at(0, 4)); -} - -#[test] -fn case_modes_follow_ignorecase_and_smartcase() { - let doc = doc(&[("a", &["Rust rust"])]); - let layout = Layout::new(&doc, 80); - let first_match = |pattern: &str, case| { - let mut cursor = Cursor::new(); - cursor.apply(&layout, Motion::RowEnd, 1); - let pattern = Pattern::new(pattern, case).expect("valid pattern"); - let _ = cursor.search(&layout, &pattern, Direction::Forward, 1); - cursor.screen(&layout).col - }; - - assert_eq!(first_match("rust", CaseMode::Sensitive), 5); - assert_eq!(first_match("rust", CaseMode::Insensitive), 0); - assert_eq!(first_match("rust", CaseMode::Smart), 0); - assert_eq!(first_match("Rust", CaseMode::Smart), 0); - assert!(Pattern::new("(", CaseMode::Sensitive).is_err()); -} - -#[test] -fn star_searches_the_word_under_or_after_the_cursor_as_a_whole_word() { - let doc = doc(&[("a", &[" foo foobar", "foo. bar"])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - - let (pattern, outcome) = cursor - .search_word(&layout, Direction::Forward, 1, CaseMode::Smart) - .expect("a word after the cursor"); - assert_eq!( - (outcome, cursor.screen(&layout)), - (SearchOutcome::Found, at(1, 0)) - ); - assert_eq!( - cursor.search(&layout, &pattern, Direction::Forward, 1), - SearchOutcome::Wrapped - ); - assert_eq!(cursor.screen(&layout), at(0, 2)); - - cursor.apply(&layout, Motion::Down, 1); - cursor.apply(&layout, Motion::FindForward(' '), 1); - let (_, outcome) = cursor - .search_word(&layout, Direction::Backward, 1, CaseMode::Smart) - .expect("bar"); - assert_eq!( - (outcome, cursor.screen(&layout)), - (SearchOutcome::Wrapped, at(1, 5)) - ); -} - -#[test] -fn star_with_only_blanks_on_the_rest_of_the_line_does_nothing() { - let doc = doc(&[("a", &["word ", "next"])]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - cursor.apply(&layout, Motion::RowEnd, 1); - - assert!( - cursor - .search_word(&layout, Direction::Forward, 1, CaseMode::Smart) - .is_none() - ); - assert_eq!(cursor.screen(&layout), at(0, 6)); -} - -#[test] -fn v_yanks_characters_inclusively_joining_wrapped_rows_and_breaking_lines() { - let doc = doc(&[("a", &["one two", "three"])]); - let layout = Layout::new(&doc, 4); - let mut cursor = Cursor::new(); - let selection = Selection::start(&cursor, &layout, SelectionKind::Charwise); - cursor.apply(&layout, Motion::WordEnd, 1); - assert_eq!(selection.text(&cursor, &layout), "one"); - - cursor.apply(&layout, Motion::WordForward, 1); - let selection = Selection::start(&cursor, &layout, SelectionKind::Charwise); - cursor.apply(&layout, Motion::Down, 1); - cursor.apply(&layout, Motion::Right, 1); - assert_eq!(selection.text(&cursor, &layout), "two\nth"); - - cursor.apply(&layout, Motion::GotoRow(1), 1); - assert_eq!(selection.text(&cursor, &layout), "one t"); -} - -#[test] -fn big_v_yanks_whole_screen_rows_as_lines() { - let doc = doc(&[("a", &["one two three", "four"])]); - let layout = Layout::new(&doc, 8); - let mut cursor = Cursor::new(); - cursor.apply(&layout, Motion::Down, 1); - let selection = Selection::start(&cursor, &layout, SelectionKind::Rowwise); - - assert_eq!(selection.text(&cursor, &layout), "three\n"); - cursor.apply(&layout, Motion::Down, 1); - assert_eq!(selection.text(&cursor, &layout), "three\nfour\n"); - cursor.apply(&layout, Motion::Up, 2); - assert_eq!(selection.text(&cursor, &layout), "one two three\n"); -} - -#[test] -fn a_selection_survives_blocks_arriving_above() { - let mut doc = doc(&[("b", &["bravo"])]); - let mut cursor = Cursor::new(); - let selection = Selection::start(&cursor, &Layout::new(&doc, 80), SelectionKind::Charwise); - cursor.apply(&Layout::new(&doc, 80), Motion::RowEnd, 1); - - doc.push_front("a", vec![Line::text("alpha")]); - - assert_eq!(selection.text(&cursor, &Layout::new(&doc, 80)), "bravo"); -} - -#[test] -fn the_selection_highlights_cells_row_by_row() { - let doc = doc(&[("a", &["one two", "日本"])]); - let layout = Layout::new(&doc, 4); - let mut cursor = Cursor::new(); - cursor.apply(&layout, Motion::Right, 2); - let selection = Selection::start(&cursor, &layout, SelectionKind::Charwise); - cursor.apply(&layout, Motion::Down, 2); - - let cells: Vec<_> = (0..layout.row_count()) - .map(|row| selection.cells(&cursor, &layout, row)) - .collect(); - - assert_eq!(cells, [Some(2..4), Some(0..3), Some(0..4)]); - let rows = Selection::start(&cursor, &layout, SelectionKind::Rowwise); - assert_eq!(rows.cells(&cursor, &layout, 1), None); - assert_eq!(rows.cells(&cursor, &layout, 2), Some(0..4)); -} - -#[test] -fn a_cursor_that_never_moved_stays_at_the_top_as_blocks_arrive() { - let mut doc = doc(&[("b", &["bravo"])]); - let cursor = Cursor::new(); - - doc.push_front("a", vec![Line::text("alpha")]); - - assert_eq!(cursor.screen(&Layout::new(&doc, 80)), at(0, 0)); -} - -/// A block "a" of `head`, three folded lines and `tail`, then a block "b". -fn folded(closed: bool) -> Document { - let mut doc = Document::new(); - let lines = ["head", "one", "two three", "four", "tail"] - .map(Line::text) - .to_vec(); - let fold = Fold { - lines: 1..4, - summary: Line::text("+ 3 more lines"), - closed, - }; - doc.push_back( - "a", - Content { - lines, - folds: vec![fold], - ..Content::default() - }, - ); - doc.push_back("b", vec![Line::text("next")]); - doc -} - -fn texts(layout: &Layout<'_>) -> Vec { - (0..layout.row_count()) - .map(|row| layout.segments(row).map(|(text, _)| text).collect()) - .collect() -} - -#[test] -fn a_closed_fold_is_one_summary_row_that_j_and_k_step_over() { - let doc = folded(true); - let layout = Layout::new(&doc, 80); - assert_eq!(texts(&layout), ["head", "+ 3 more lines", "tail", "next"]); - let mut cursor = Cursor::new(); - cursor.apply(&layout, Motion::Right, 3); - - assert_eq!( - walk(&layout, &mut cursor, Motion::Down, 2), - [at(1, 0), at(2, 3)] - ); - assert_eq!(walk(&layout, &mut cursor, Motion::Up, 1), [at(1, 0)]); -} - -#[test] -fn zo_zc_and_za_open_close_and_toggle_the_fold_under_the_cursor() { - let doc = folded(true); - let mut folds = Folds::default(); - let laid_out = |folds: &Folds| Layout::with_folds(&doc, folds.clone(), 80); - let mut cursor = Cursor::new(); - cursor.apply(&laid_out(&folds), Motion::Down, 1); - - folds.fold(&doc, &cursor, FoldOp::Open); - assert_eq!( - texts(&laid_out(&folds)), - ["head", "one", "two three", "four", "tail", "next"] - ); - assert_eq!(cursor.screen(&laid_out(&folds)), at(1, 0)); - cursor.apply(&laid_out(&folds), Motion::Down, 1); - cursor.apply(&laid_out(&folds), Motion::RowEnd, 1); - - folds.fold(&doc, &cursor, FoldOp::Close); - assert_eq!(cursor.screen(&laid_out(&folds)), at(1, 0)); - folds.fold(&doc, &cursor, FoldOp::Toggle); - assert_eq!(cursor.screen(&laid_out(&folds)), at(2, 8)); - folds.fold(&doc, &cursor, FoldOp::Toggle); - assert_eq!(laid_out(&folds).row_count(), 4); - assert_eq!( - Layout::new(&doc, 80).row_count(), - 4, - "the document's folds are as given" - ); -} - -#[test] -fn zm_closes_and_zr_opens_every_fold() { - let doc = folded(false); - let mut folds = Folds::default(); - assert_eq!(Layout::with_folds(&doc, folds.clone(), 80).row_count(), 6); - - folds.fold_all(&doc, false); - assert_eq!(Layout::with_folds(&doc, folds.clone(), 80).row_count(), 4); - folds.fold_all(&doc, true); - assert_eq!(Layout::with_folds(&doc, folds.clone(), 80).row_count(), 6); -} - -#[test] -fn a_clone_of_folds_keeps_its_own_state() { - let doc = folded(true); - let mut cursor = Cursor::new(); - cursor.apply(&Layout::new(&doc, 80), Motion::Down, 1); - let mut folds = Folds::default(); - folds.fold(&doc, &cursor, FoldOp::Open); - - let mut other = folds.clone(); - other.fold(&doc, &cursor, FoldOp::Close); - - assert_eq!(Layout::with_folds(&doc, folds, 80).row_count(), 6); - assert_eq!(Layout::with_folds(&doc, other, 80).row_count(), 4); -} - -#[test] -fn a_fold_keeps_its_state_when_its_block_is_replaced() { - let mut doc = folded(true); - let mut cursor = Cursor::new(); - cursor.apply(&Layout::new(&doc, 80), Motion::Down, 1); - let mut folds = Folds::default(); - folds.fold(&doc, &cursor, FoldOp::Open); - - let lines = ["head", "one", "two", "four", "tail ★ 4"] - .map(Line::text) - .to_vec(); - let fold = Fold { - lines: 1..4, - summary: Line::text("+ 3 more lines"), - closed: true, - }; - doc.replace( - "a", - Content { - lines, - folds: vec![fold], - ..Content::default() - }, - ); - - assert_eq!(Layout::with_folds(&doc, folds, 80).row_count(), 6); -} - -#[test] -fn a_block_dropped_and_added_again_starts_with_its_folds_as_given() { - let mut doc = folded(true); - let mut cursor = Cursor::new(); - cursor.apply(&Layout::new(&doc, 80), Motion::Down, 1); - let mut folds = Folds::default(); - folds.fold(&doc, &cursor, FoldOp::Open); - - doc.retain(|key| key != "a"); - let lines = ["head", "one", "two", "four", "tail"] - .map(Line::text) - .to_vec(); - let fold = Fold { - lines: 1..4, - summary: Line::text("+ 3 more lines"), - closed: true, - }; - doc.push_back( - "a", - Content { - lines, - folds: vec![fold], - ..Content::default() - }, - ); - - // "next", then the block's head, summary and tail. - assert_eq!(Layout::with_folds(&doc, folds, 80).row_count(), 4); -} - -#[test] -fn word_motions_treat_a_closed_fold_as_one_stop() { - let doc = folded(true); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - - assert_eq!( - walk(&layout, &mut cursor, Motion::WordForward, 2), - [at(1, 0), at(2, 0)] - ); - assert_eq!( - walk(&layout, &mut cursor, Motion::WordBackward, 2), - [at(1, 0), at(0, 0)] - ); -} - -#[test] -fn word_motions_go_through_an_open_fold_word_by_word() { - let doc = folded(false); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - - assert_eq!( - walk(&layout, &mut cursor, Motion::WordForward, 3), - [at(1, 0), at(2, 0), at(2, 4)] - ); -} - -/// A block of `head`, `a`, `b`, `c`, `d`, `tail` with `folds` as (lines, summary, closed), then a block `next`. -fn nested(folds: &[(Range, &str, bool)]) -> Document { - let mut doc = Document::new(); - let lines = ["head", "a", "b", "c", "d", "tail"] - .map(Line::text) - .to_vec(); - let folds = folds - .iter() - .map(|(lines, summary, closed)| Fold { - lines: lines.clone(), - summary: Line::text(*summary), - closed: *closed, - }) - .collect(); - doc.push_back( - "a", - Content { - lines, - folds, - ..Content::default() - }, - ); - doc.push_back("b", vec![Line::text("next")]); - doc -} - -/// What `doc` shows with `folds`, row by row. -fn shown(doc: &Document, folds: &Folds) -> Vec { - texts(&Layout::with_folds(doc, folds.clone(), 80)) -} - -/// A cursor on line `line` of the first block of `doc`, all folds open. -fn on_line(doc: &Document, line: usize) -> Cursor { - let mut open = Folds::default(); - open.fold_all(doc, true); - Cursor::at_row(&Layout::with_folds(doc, open, 80), line) -} - -const OUTER: (Range, &str, bool) = (1..5, "+ outer", true); -const INNER: (Range, &str, bool) = (2..4, "+ inner", true); - -#[test] -fn a_closed_fold_inside_an_open_one_shows_its_own_summary() { - let doc = nested(&[(1..5, "+ outer", false), INNER]); - assert_eq!( - texts(&Layout::new(&doc, 80)), - ["head", "a", "+ inner", "d", "tail", "next"] - ); - - let doc = nested(&[OUTER, (2..4, "+ inner", false)]); - assert_eq!( - texts(&Layout::new(&doc, 80)), - ["head", "+ outer", "tail", "next"] - ); -} - -#[test] -fn zo_opens_the_outermost_closed_fold_and_zc_the_innermost_open_one_outside_it() { - let doc = nested(&[OUTER, INNER]); - let mut folds = Folds::default(); - let b = on_line(&doc, 2); - - folds.fold(&doc, &b, FoldOp::Open); - assert_eq!( - shown(&doc, &folds), - ["head", "a", "+ inner", "d", "tail", "next"] - ); - folds.fold(&doc, &b, FoldOp::Open); - assert_eq!( - shown(&doc, &folds), - ["head", "a", "b", "c", "d", "tail", "next"] - ); - folds.fold(&doc, &b, FoldOp::Close); - assert_eq!( - shown(&doc, &folds), - ["head", "a", "+ inner", "d", "tail", "next"] - ); - folds.fold(&doc, &b, FoldOp::Close); - assert_eq!(shown(&doc, &folds), ["head", "+ outer", "tail", "next"]); - folds.fold(&doc, &b, FoldOp::Close); - folds.fold(&doc, &b, FoldOp::Open); - assert_eq!( - shown(&doc, &folds), - ["head", "a", "+ inner", "d", "tail", "next"], - "nothing open to close outside the closed fold" - ); -} - -#[test] -fn zo_and_zc_in_capitals_go_all_the_way() { - let doc = nested(&[OUTER, INNER, (4..5, "+ d", true)]); - let mut folds = Folds::default(); - let b = on_line(&doc, 2); - - folds.fold(&doc, &b, FoldOp::OpenRecursively); - assert_eq!( - shown(&doc, &folds), - ["head", "a", "b", "c", "d", "tail", "next"], - "and the folds inside, holding the cursor or not" - ); - folds.fold(&doc, &b, FoldOp::CloseRecursively); - assert_eq!(shown(&doc, &folds), ["head", "+ outer", "tail", "next"]); - folds.fold(&doc, &b, FoldOp::Open); - assert_eq!( - shown(&doc, &folds), - ["head", "a", "+ inner", "d", "tail", "next"], - "only the folds holding the cursor were closed" - ); -} - -#[test] -fn za_opens_a_closed_fold_and_closes_an_open_one_and_za_in_capitals_all_the_way() { - let doc = nested(&[OUTER, INNER]); - let mut folds = Folds::default(); - let b = on_line(&doc, 2); - - folds.fold(&doc, &b, FoldOp::Toggle); - assert_eq!( - shown(&doc, &folds), - ["head", "a", "+ inner", "d", "tail", "next"] - ); - folds.fold(&doc, &b, FoldOp::ToggleRecursively); - assert_eq!( - shown(&doc, &folds), - ["head", "a", "b", "c", "d", "tail", "next"] - ); - folds.fold(&doc, &b, FoldOp::Toggle); - assert_eq!( - shown(&doc, &folds), - ["head", "a", "+ inner", "d", "tail", "next"] - ); - folds.fold(&doc, &b, FoldOp::ToggleRecursively); - assert_eq!( - shown(&doc, &folds), - ["head", "a", "b", "c", "d", "tail", "next"] - ); - folds.fold(&doc, &b, FoldOp::ToggleRecursively); - assert_eq!(shown(&doc, &folds), ["head", "+ outer", "tail", "next"]); -} - -#[test] -fn revealing_a_line_opens_every_fold_hiding_it_and_no_other() { - let doc = nested(&[OUTER, INNER, (4..5, "+ d", true)]); - let mut folds = Folds::default(); - - folds.reveal(&doc, &on_line(&doc, 3)); - - assert_eq!( - shown(&doc, &folds), - ["head", "a", "b", "c", "+ d", "tail", "next"] - ); -} - -#[test] -fn folds_nest_whatever_order_they_come_in() { - let doc = nested(&[INNER, OUTER]); - let mut folds = Folds::default(); - assert_eq!( - texts(&Layout::new(&doc, 80)), - ["head", "+ outer", "tail", "next"] - ); - - folds.fold(&doc, &on_line(&doc, 2), FoldOp::Open); - - assert_eq!( - shown(&doc, &folds), - ["head", "a", "+ inner", "d", "tail", "next"] - ); - let doc = nested(&[(4..5, "+ d", true), (1..3, "+ ab", true)]); - assert_eq!( - texts(&Layout::new(&doc, 80)), - ["head", "+ ab", "c", "+ d", "tail", "next"] - ); -} - -#[test] -fn zo_in_capitals_leaves_the_folds_around_alone() { - let doc = nested(&[OUTER, INNER, (0..1, "+ head", true), (5..6, "+ tail", true)]); - let mut folds = Folds::default(); - - folds.fold(&doc, &on_line(&doc, 2), FoldOp::OpenRecursively); - - assert_eq!( - shown(&doc, &folds), - ["+ head", "a", "b", "c", "d", "+ tail", "next"] - ); -} - -#[test] -fn a_fold_overlapping_another_without_holding_it_is_dropped() { - let doc = nested(&[(1..4, "+ first", true), (2..5, "+ second", true)]); - - assert_eq!( - texts(&Layout::new(&doc, 80)), - ["head", "+ first", "d", "tail", "next"] - ); -} - -#[test] -fn a_fold_over_the_same_lines_as_another_is_inside_it() { - let doc = nested(&[(1..3, "+ outer", false), (1..3, "+ inner", true)]); - - assert_eq!( - texts(&Layout::new(&doc, 80)), - ["head", "+ inner", "c", "d", "tail", "next"] - ); -} - -#[test] -fn word_motions_step_over_a_closed_fold_inside_an_open_one() { - let doc = nested(&[(1..5, "+ outer", false), INNER]); - let layout = Layout::new(&doc, 80); - let mut cursor = Cursor::new(); - - assert_eq!( - walk(&layout, &mut cursor, Motion::WordForward, 3), - [at(1, 0), at(2, 0), at(3, 0)] - ); -} - -/// A block of a `cols` × `rows` picture between two lines of text. -fn with_picture(cols: u16, rows: u16) -> Document { - let mut doc = Document::new(); - doc.push_back( - "a", - vec![ - Line::text("above"), - Line::Image { - rows, - cols, - source: "p".into(), - }, - Line::text("below"), - ], - ); - doc -} - -#[test] -fn a_picture_larger_than_the_room_shrinks_to_fit_keeping_its_shape() { - let doc = with_picture(40, 10); - let size = |layout: &Layout<'_>| { - layout - .picture(1) - .map(|picture| (picture.cols, picture.rows)) - }; - - assert_eq!( - size(&Layout::new(&doc, 80)), - Some((40, 10)), - "whole when it fits" - ); - let narrow = Layout::new(&doc, 20); - assert_eq!(size(&narrow), Some((20, 5))); - assert_eq!(narrow.row_count(), 1 + 5 + 1); - let low = Layout::within(&doc, Folds::default(), 80, 3); - assert_eq!(size(&low), Some((12, 3))); - assert_eq!(low.row_count(), 1 + 3 + 1); - assert_eq!(texts(&low)[4], "below"); -} - -#[test] -fn a_block_s_lines_can_be_read_back() { - let doc = doc(&[("a", &["header", "body"])]); - - assert_eq!(doc.line_text("a", 1), Some("body")); - assert_eq!(doc.line_text("a", 2), None); - assert_eq!(doc.line_text("b", 0), None); -} - -#[test] -fn a_picture_row_says_which_picture_and_which_of_its_rows() { - let mut doc = Document::new(); - doc.push_back( - "a", - vec![ - Line::text("above"), - Line::Image { - rows: 2, - cols: 16, - source: "p.png".into(), - }, - ], - ); - let layout = Layout::new(&doc, 80); - - assert_eq!(layout.picture(0), None); - assert_eq!( - layout.picture(2), - Some(PictureRow { - source: "p.png", - cols: 16, - rows: 2, - row: 1 - }) - ); -} - -#[test] -fn a_picture_folded_away_shows_no_picture_rows() { - let mut doc = Document::new(); - let picture = Line::Image { - rows: 2, - cols: 16, - source: "p.png".into(), - }; - let fold = Fold { - lines: 1..2, - summary: Line::text("+ a picture"), - closed: true, - }; - doc.push_back( - "a", - Content { - lines: vec![Line::text("above"), picture], - folds: vec![fold], - ..Content::default() - }, - ); - let layout = Layout::new(&doc, 80); - - assert_eq!(layout.row_count(), 2); - assert_eq!(layout.picture(1), None, "the fold's summary row"); -} - -#[test] -fn a_picture_in_a_line_of_text_takes_its_cells_and_says_where_it_starts() { - let mut doc = Document::new(); - doc.push_back( - "a", - vec![Line::Text(vec![ - Span::new("ab", Group::Normal), - Span::picture("face.png", 2), - Span::new("cd", Group::Normal), - ])], - ); - let layout = Layout::new(&doc, 80); - - assert_eq!(layout.row_text(0), "ab\u{2800}\u{2800}cd"); - assert_eq!( - layout.inline_pictures(0), - [( - 2, - PictureRow { - source: "face.png", - cols: 2, - rows: 1, - row: 0 - } - )] - ); -} - -#[test] -fn a_picture_wrapped_onto_the_next_row_belongs_to_that_row() { - let mut doc = Document::new(); - doc.push_back( - "a", - vec![Line::Text(vec![ - Span::new("abcd efgh ", Group::Normal), - Span::picture("face.png", 2), - ])], - ); - let layout = Layout::new(&doc, 10); - - assert_eq!(layout.inline_pictures(0), []); - assert_eq!( - layout.inline_pictures(1), - [( - 0, - PictureRow { - source: "face.png", - cols: 2, - rows: 1, - row: 0 - } - )] - ); -} diff --git a/crates/devnull/src/doc/visual.rs b/crates/devnull/src/doc/visual.rs deleted file mode 100644 index d3b2174..0000000 --- a/crates/devnull/src/doc/visual.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! Visual mode: a selection from where `v` or `V` was pressed to the cursor, and the text `y` yanks from it. - -use std::ops::Range; - -use super::{Anchor, Cursor, Document, Layout, Pos}; - -/// One end of a visual selection, fixed where `v` or `V` was pressed; the cursor is the other end. -/// -/// The fixed end is anchored like the cursor, so the selection keeps covering the same text when blocks are inserted -/// or replaced around it. -#[derive(Debug, Clone)] -pub struct Selection { - /// The fixed end; `None` only in an empty document. - anchor: Option, - kind: SelectionKind, -} - -/// What a selection covers. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SelectionKind { - /// `v`: from one character to another, both included. - Charwise, - /// `V`: whole screen rows. - Rowwise, -} - -/// The selected text as `[start, end)` logical positions. -struct Span { - start: Pos, - end: Pos, -} - -impl Selection { - /// Starts a selection at `cursor`, pinned to the text there even if the cursor has never moved. - #[must_use] - pub fn start(cursor: &Cursor, layout: &Layout<'_>, kind: SelectionKind) -> Self { - let doc = layout.doc(); - let anchor = (layout.row_count() > 0).then(|| doc.anchor(cursor.pos(doc))); - Self { anchor, kind } - } - - /// What the selection covers. - #[must_use] - pub fn kind(&self) -> SelectionKind { - self.kind - } - - /// The same selection covering `kind` instead, as `V` in charwise Visual mode does. - #[must_use] - pub fn with_kind(self, kind: SelectionKind) -> Self { - Self { kind, ..self } - } - - /// A cursor at the first selected position, where `y` leaves the cursor in Vim. - #[must_use] - pub fn start_cursor(&self, cursor: &Cursor, layout: &Layout<'_>) -> Cursor { - let mut start = Cursor::new(); - if layout.row_count() > 0 { - let (first, _) = self.ends(cursor, layout.doc()); - start.set(layout.doc(), first); - } - start - } - - /// The text `y` yanks: rows of one wrapped line join without a break, lines are separated by `\n`, and a - /// rowwise selection ends with `\n`, as a linewise register does in Vim. - #[must_use] - pub fn text(&self, cursor: &Cursor, layout: &Layout<'_>) -> String { - if layout.row_count() == 0 { - return String::new(); - } - let doc = layout.doc(); - let Span { start, end } = self.span(cursor, layout); - let mut text = String::new(); - for line in start.line..=end.line { - let shaped = doc.line(line); - let from = if line == start.line { start.col } else { 0 }.min(shaped.len()); - let to = if line == end.line { - end.col - } else { - shaped.len() - } - .min(shaped.len()); - if line > start.line { - text.push('\n'); - } - text.push_str(&shaped.text[shaped.byte_offset(from)..shaped.byte_offset(to.max(from))]); - } - if self.kind == SelectionKind::Rowwise { - text.push('\n'); - } - text - } - - /// The display cells to highlight on `row`, or `None` when the selection does not reach it. A selected empty - /// row highlights one cell, as in Vim. - #[must_use] - pub fn cells(&self, cursor: &Cursor, layout: &Layout<'_>, row: usize) -> Option> { - if layout.row_count() == 0 { - return None; - } - let doc = layout.doc(); - let (first, last) = self.ends(cursor, doc); - let (first_row, last_row) = (layout.row_of(first), layout.row_of(last)); - if !(first_row..=last_row).contains(&row) { - return None; - } - let whole = self.kind == SelectionKind::Rowwise; - let left = if row == first_row && !whole { - layout.cell_of(row, first.col) - } else { - 0 - }; - let right = if row == last_row && !whole { - let width = doc - .line(last.line) - .graphemes - .get(last.col) - .map_or(1, |g| g.width.max(1)); - layout.cell_of(row, last.col) + width - } else { - layout.cell_of(row, layout.row(row).end) - }; - Some(left..right.max(left + 1)) - } - - /// The selected positions in document order: the first and last character for a charwise selection, any - /// position on the first and last row for a rowwise one. - fn ends(&self, cursor: &Cursor, doc: &Document) -> (Pos, Pos) { - let fixed = self - .anchor - .as_ref() - .map_or_else(Pos::default, |anchor| doc.resolve(anchor)); - let moving = cursor.pos(doc); - (fixed.min(moving), fixed.max(moving)) - } - - fn span(&self, cursor: &Cursor, layout: &Layout<'_>) -> Span { - let (first, last) = self.ends(cursor, layout.doc()); - match self.kind { - SelectionKind::Charwise => Span { - start: first, - end: Pos { - col: last.col + 1, - ..last - }, - }, - SelectionKind::Rowwise => { - let (top, bottom) = ( - layout.row(layout.row_of(first)), - layout.row(layout.row_of(last)), - ); - Span { - start: Pos { - line: top.line, - col: top.start, - }, - end: Pos { - line: bottom.line, - col: bottom.end, - }, - } - } - } - } -} diff --git a/crates/devnull/src/doc/word.rs b/crates/devnull/src/doc/word.rs deleted file mode 100644 index 1b4fc5d..0000000 --- a/crates/devnull/src/doc/word.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! Vim's word motions (`w b e` and `W B E`), ported from its `fwd_word`, `bck_word` and `end_word`. -//! -//! They walk the logical text, not screen rows: wrapping never splits a word. As in Vim, the end of each line is a -//! position of its own that counts as a blank, so a line break separates words, and an empty line is a word that -//! `w` and `b` stop on. - -use super::{Document, Pos}; - -/// Character classes, as Vim's `cls()`: words are made of one class, blanks separate them. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Class { - /// Whitespace and line ends. - Blank, - /// Punctuation and other symbols. - Punct, - /// Letters, digits and `_`. - Word, - /// Wide symbols such as emoji, a class of their own as in Vim. - Wide, -} - -/// Which word definition a motion uses. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum Kind { - /// `w b e`: runs of one class. - Small, - /// `W B E`: runs of non-blanks. - Big, -} - -/// A walk over the logical text, one grapheme or line end at a time. -struct Walk<'d> { - doc: &'d Document, - kind: Kind, - pos: Pos, -} - -impl Walk<'_> { - fn class(&self) -> Class { - let line = self.doc.line(self.pos.line); - if self.pos.col >= line.len() || line.is_blank(self.pos.col) { - return Class::Blank; - } - if self.kind == Kind::Big { - return Class::Word; - } - let first = line.grapheme(self.pos.col).chars().next().unwrap_or(' '); - if first.is_alphanumeric() || first == '_' { - Class::Word - } else if line.graphemes[self.pos.col].width > 1 { - Class::Wide - } else { - Class::Punct - } - } - - /// Whether the walk is on an empty line. - fn on_empty_line(&self) -> bool { - self.doc.line(self.pos.line).len() == 0 - } - - /// Steps forward onto the next grapheme or line end; `false` at the end of the document. - fn inc(&mut self) -> bool { - if self.pos.col < self.doc.line(self.pos.line).len() { - self.pos.col += 1; - } else if self.pos.line + 1 < self.doc.line_count() { - self.pos = Pos { - line: self.pos.line + 1, - col: 0, - }; - } else { - return false; - } - true - } - - /// Steps backward onto the previous grapheme or line end; `false` at the start of the document. - fn dec(&mut self) -> bool { - if self.pos.col > 0 { - self.pos.col -= 1; - } else if self.pos.line > 0 { - self.pos.line -= 1; - self.pos.col = self.doc.line(self.pos.line).len(); - } else { - return false; - } - true - } - - /// Steps while on `class`; `false` if the document ended first. - fn skip(&mut self, class: Class, forward: bool) -> bool { - while self.class() == class { - if !(if forward { self.inc() } else { self.dec() }) { - return false; - } - } - true - } -} - -/// `w`/`W`: the start of the next word, or the last character of the document when there is none. -pub(super) fn forward(doc: &Document, pos: Pos, kind: Kind) -> Pos { - let mut walk = Walk { doc, kind, pos }; - let class = walk.class(); - let reached_next = walk.inc() - && (class == Class::Blank || walk.skip(class, true)) - && loop { - if walk.class() != Class::Blank || (walk.on_empty_line() && walk.pos.col == 0) { - break true; - } - if !walk.inc() { - break false; - } - }; - if reached_next { - walk.pos - } else { - doc.last_pos() - } -} - -/// `e`/`E`: the end of this word, or of the next one when already at an end. Stays put when there is none. -pub(super) fn end(doc: &Document, pos: Pos, kind: Kind) -> Pos { - let mut walk = Walk { doc, kind, pos }; - let class = walk.class(); - if !walk.inc() { - return pos; - } - let found = if walk.class() == class && class != Class::Blank { - walk.skip(class, true) - } else { - walk.skip(Class::Blank, true) && { - let next = walk.class(); - walk.skip(next, true) - } - }; - if !found { - return pos; - } - walk.dec(); - walk.pos -} - -/// `b`/`B`: the start of this word, or of the previous one when already at a start. An empty line counts as a word. -pub(super) fn backward(doc: &Document, pos: Pos, kind: Kind) -> Pos { - let mut walk = Walk { doc, kind, pos }; - if !walk.dec() { - return pos; - } - while walk.class() == Class::Blank { - if walk.on_empty_line() { - return walk.pos; - } - if !walk.dec() { - return walk.pos; - } - } - let class = walk.class(); - if walk.skip(class, false) { - walk.inc(); - } - walk.pos -} - -/// The grapheme range of the keyword (a run of letters, digits and `_`) under the cursor, or else the first one after -/// it on the same line, as Vim's `*` picks it. `None` when the rest of the line has none. -pub(super) fn keyword(doc: &Document, pos: Pos) -> Option> { - word_from(doc, pos, Kind::Small) -} - -/// The non-blank word (a WORD, in Vim's terms) under `pos`, or the first one after it on its line, as a grapheme -/// range: what `*` falls back to without a keyword. -pub(super) fn non_blank_word(doc: &Document, pos: Pos) -> Option> { - word_from(doc, pos, Kind::Big) -} - -fn word_from(doc: &Document, pos: Pos, kind: Kind) -> Option> { - let len = doc.line(pos.line).len(); - let is_word = |col| { - let walk = Walk { - doc, - kind, - pos: Pos { - line: pos.line, - col, - }, - }; - walk.class() == Class::Word - }; - let first = (pos.col..len).find(|&c| is_word(c))?; - let start = (0..first) - .rev() - .take_while(|&c| is_word(c)) - .last() - .unwrap_or(first); - let end = (first..len).find(|&c| !is_word(c)).unwrap_or(len); - Some(start..end) -} diff --git a/crates/devnull/src/ex.rs b/crates/devnull/src/ex.rs deleted file mode 100644 index df8c146..0000000 --- a/crates/devnull/src/ex.rs +++ /dev/null @@ -1,827 +0,0 @@ -//! Ex command lines (D5, D6): what `:` runs and what `init.vim` holds. -//! -//! Only whole commands, one per line: no ranges and no `|` chaining. Application commands (`:star`, `:reply`, …) -//! join the table as they are implemented; until then they are "Not an editor command". - -use std::fmt; - -use crate::keymap::Mode; - -/// A parsed command line. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum Command { - /// `:q`, `:qa`, with `!` to force. - Quit { - /// `:qa`: every window rather than the current one. - all: bool, - /// `!`: even when something would be lost. - force: bool, - }, - /// `:map` and its mode and `noremap` variants. - Map { - /// The modes the map applies to. - modes: Vec, - /// The keys typed, in Vim notation. - lhs: String, - /// The keys they stand for, in Vim notation. - rhs: String, - /// Whether `rhs` goes through user maps too (`:map`) rather than only built-ins (`:noremap`). - remap: bool, - }, - /// `:unmap` and its mode variants. - Unmap { - /// The modes to remove the map from. - modes: Vec, - /// The keys of the map, in Vim notation. - lhs: String, - }, - /// `:set` with its arguments, in order. - Set(Vec), - /// `:hi {group} {key}={value} ...`. - Highlight { - /// The highlight group's name, as typed. - group: String, - /// Attribute keys and values, in order, such as `guifg` and `#c0caf5`. - attrs: Vec<(String, String)>, - }, - /// `:hi clear`: every group back to the colorscheme's defaults. - HighlightClear, - /// `:hi link {from} {to}`: draws `from` as `to`; `:hi link {from} NONE` (`to` is `None`) undoes it. - HighlightLink { - /// The group drawn as another. - from: String, - /// The group it is drawn as; `None` to draw it as itself again. - to: Option, - }, - /// `:colorscheme {name}`, or without a name to show the current one. - Colorscheme(Option), - /// `:source {file}`. - Source(String), - /// `:messages`. - Messages, - /// `:help [keys]`: the key bindings, at those of `keys` when given. - Help(Option), - /// `:split` and `:vsplit` (`vertical`). - Split { - /// Side by side rather than stacked. - vertical: bool, - }, - /// `:close`: the current window, unless it is the last. - Close, - /// `:only`: every window but the current one. - Only, - /// `:wincmd {char}`: what `{char}` does. - Wincmd(char), - /// `:connect`: connect this device, or with a link, finish connecting with the callback link the browser ended - /// on. - Connect(Option), - /// `:agree`: accept dev-null.sh's current terms. - Agree, - /// `:open`: opens what the text under the cursor refers to: a link in the browser, a commit's thread. - Open, - /// `:browse` opens the web page of the commit under the cursor (or of the account, where there is no commit); - /// `:browse link`, the link under the cursor. - Browse { - /// Whether to open the link under the cursor rather than a web page. - link: bool, - }, - /// `:yanklink`: yanks the web link `:browse` opens. - YankLink, - /// `:hover`: a card about what is under the cursor: an account, a link, a commit's author. - Hover, - /// `:pop`: back to where `:open` or `:profile` last left for another buffer from (Vim's tag stack). - Pop, - /// `:jumps`: lists the current window's jumplist. - Jumps, - /// `:nohlsearch`: stops showing the last search's matches until the next search. - Nohlsearch, - /// `:star`: stars the commit under the cursor, or takes its star back. - Star, - /// `:commit [@organization]`: writes a new commit in `$EDITOR`, posted as the account or the organization - /// named. - Commit { - /// The organization's handle, without `@`; `None` for the account itself. - author: Option, - }, - /// `:reply [@organization]`: writes a reply to the commit under the cursor, as `:commit` does. - Reply { - /// As for [`Command::Commit`]. - author: Option, - }, - /// `:quote [@organization]`: writes a quote of the commit under the cursor, as `:commit` does. - Quote { - /// As for [`Command::Commit`]. - author: Option, - }, - /// `:delete`: deletes the account's own commit under the cursor, once confirmed. - Delete, - /// `:follow [handle]` and `:unfollow [handle]`: the account named, or under the cursor (or the author of the - /// commit under it). - Follow { - /// The handle, `@` or not; `None` for what is under the cursor. - handle: Option, - /// Whether to follow rather than stop following. - on: bool, - }, - /// `:pick {list} [query]`: a fuzzy picker over buffers, or a search for people and organizations, starting - /// with `query`. - Pick { - /// What to pick from. - list: PickList, - /// What the query starts as; may be empty. - query: String, - }, - /// `:profile {handle}` opens a profile; `:profile` alone, the profile of the account under the cursor, or of the - /// author of the commit under it. - Profile(Option), - /// `:edit {name}` opens a buffer by name; `:edit` alone (or `:e!`) reloads the current one. - Edit(Option), - /// `:ls`, `:buffers`: lists the buffers. - Buffers, - /// `:buffer {N|name}`: shows a buffer, by number or (part of) its name. - Buffer(String), - /// `:bnext` (`forward`) and `:bprevious`. - NextBuffer { - /// Whether to go to the next buffer rather than the previous one. - forward: bool, - }, -} - -/// What `:pick` picks from. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PickList { - /// `buffers`: the listed buffers, the last shown first. - Buffers, - /// `users`: people and organizations, as dev-null.sh's search finds them. - Users, -} - -/// One `:set` argument. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SetArg { - /// The option's name as typed, including any `no` or `inv` prefix: only the options know whether `nowrap` is - /// `wrap` turned off or an option of its own. - pub name: String, - /// What to do with it. - pub op: SetOp, -} - -/// What a `:set` argument does to its option. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SetOp { - /// `name`: turn a flag on (or off, or flip it, through a `no` or `inv` prefix), or show another option. - On, - /// `name!`: flip a flag. - Toggle, - /// `name?`: show the value. - Query, - /// `name=value` or `name:value`. - Assign(String), -} - -/// Why a command line cannot run, worded as Vim words it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ExError { - /// E492: the command name is unknown. - NotACommand(String), - /// E477: the command takes no `!`. - NoBang, - /// E471: an argument is missing. - ArgumentRequired, - /// E475: an argument is malformed. - InvalidArgument(String), -} - -impl fmt::Display for ExError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::NotACommand(line) => write!(f, "E492: Not an editor command: {line}"), - Self::NoBang => f.write_str("E477: No ! allowed"), - Self::ArgumentRequired => f.write_str("E471: Argument required"), - Self::InvalidArgument(arg) => write!(f, "E475: Invalid argument: {arg}"), - } - } -} - -impl std::error::Error for ExError {} - -/// The commands `dn` knows, with the shortest abbreviation Vim accepts for each. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Name { - Quit, - QuitAll, - Map(&'static [Mode], bool), - Unmap(&'static [Mode]), - Set, - Highlight, - Colorscheme, - Source, - Messages, - Help, - Split(bool), - Close, - Only, - Wincmd, - Connect, - Agree, - Open, - Profile, - Browse, - YankLink, - Hover, - Pop, - Jumps, - Nohlsearch, - Pick, - Star, - Commit, - Reply, - Quote, - Delete, - Follow(bool), - Edit, - Buffer, - Buffers, - NextBuffer(bool), -} - -const NORMAL: &[Mode] = &[Mode::Normal]; -const VISUAL: &[Mode] = &[Mode::Visual]; -const NORMAL_VISUAL: &[Mode] = &[Mode::Normal, Mode::Visual]; - -/// Full name, shortest abbreviation length, and command, in the order Vim resolves ambiguous abbreviations. -const COMMANDS: &[(&str, usize, Name)] = &[ - ("edit", 1, Name::Edit), - ("buffer", 1, Name::Buffer), - ("buffers", 7, Name::Buffers), - ("bnext", 2, Name::NextBuffer(true)), - ("bprevious", 2, Name::NextBuffer(false)), - ("ls", 2, Name::Buffers), - ("quit", 1, Name::Quit), - ("qall", 2, Name::QuitAll), - ("quitall", 5, Name::QuitAll), - ("map", 3, Name::Map(NORMAL_VISUAL, true)), - ("nmap", 2, Name::Map(NORMAL, true)), - ("vmap", 2, Name::Map(VISUAL, true)), - ("xmap", 2, Name::Map(VISUAL, true)), - ("noremap", 2, Name::Map(NORMAL_VISUAL, false)), - ("nnoremap", 2, Name::Map(NORMAL, false)), - ("vnoremap", 2, Name::Map(VISUAL, false)), - ("xnoremap", 2, Name::Map(VISUAL, false)), - ("unmap", 3, Name::Unmap(NORMAL_VISUAL)), - ("nunmap", 3, Name::Unmap(NORMAL)), - ("vunmap", 2, Name::Unmap(VISUAL)), - ("xunmap", 2, Name::Unmap(VISUAL)), - ("set", 2, Name::Set), - ("help", 1, Name::Help), - ("highlight", 2, Name::Highlight), - ("colorscheme", 4, Name::Colorscheme), - ("source", 2, Name::Source), - ("messages", 3, Name::Messages), - ("split", 2, Name::Split(false)), - ("vsplit", 2, Name::Split(true)), - ("close", 3, Name::Close), - ("open", 1, Name::Open), - ("profile", 4, Name::Profile), - ("browse", 3, Name::Browse), - ("yanklink", 5, Name::YankLink), - ("hover", 3, Name::Hover), - ("pop", 2, Name::Pop), - ("jumps", 2, Name::Jumps), - ("nohlsearch", 3, Name::Nohlsearch), - ("pick", 2, Name::Pick), - ("star", 4, Name::Star), - ("commit", 4, Name::Commit), - ("reply", 3, Name::Reply), - ("quote", 3, Name::Quote), - ("delete", 1, Name::Delete), - ("follow", 3, Name::Follow(true)), - ("unfollow", 3, Name::Follow(false)), - ("only", 2, Name::Only), - ("wincmd", 4, Name::Wincmd), - ("connect", 4, Name::Connect), - ("agree", 5, Name::Agree), -]; - -/// The command `typed` names, in full or abbreviated as Vim allows, with its full name. -fn lookup(typed: &str) -> Option<(&'static str, Name)> { - COMMANDS - .iter() - .find(|(full, min, _)| typed.len() >= *min && full.starts_with(typed)) - .map(|&(full, _, name)| (full, name)) -} - -/// The full name of the command `typed` names, as [`parse`] reads it: `colorscheme` for `colo`. -#[must_use] -pub fn full_name(typed: &str) -> Option<&'static str> { - lookup(typed).map(|(full, _)| full) -} - -/// The full names of the commands `dn` knows. -pub fn command_names() -> impl Iterator { - COMMANDS.iter().map(|&(full, _, _)| full) -} - -/// Parses one command line, as typed after `:` or written in `init.vim`. Blank lines and `"` comments are -/// `Ok(None)`; leading `:` and whitespace are ignored. -/// -/// # Errors -/// -/// When the command is unknown or its arguments don't fit it, with Vim's message. -pub fn parse(line: &str) -> Result, ExError> { - let trimmed = line.trim_start_matches(|c: char| c == ':' || c.is_whitespace()); - if trimmed.is_empty() || trimmed.starts_with('"') { - return Ok(None); - } - let name_len = trimmed - .find(|c: char| !c.is_ascii_alphabetic()) - .unwrap_or(trimmed.len()); - let (typed, rest) = trimmed.split_at(name_len); - let not_a_command = || ExError::NotACommand(trimmed.trim_end().to_owned()); - let (_, name) = lookup(typed).ok_or_else(not_a_command)?; - let (bang, args) = match rest.strip_prefix('!') { - Some(args) => (true, args.trim()), - None => (false, rest.trim()), - }; - if bang && !matches!(name, Name::Quit | Name::QuitAll | Name::Edit) { - return Err(ExError::NoBang); - } - let command = match name { - Name::Quit | Name::QuitAll => Command::Quit { - all: name == Name::QuitAll, - force: bang, - }, - Name::Map(modes, remap) => { - let (lhs, rhs) = map_args(args).ok_or(ExError::ArgumentRequired)?; - Command::Map { - modes: modes.to_vec(), - lhs: lhs.to_owned(), - rhs: rhs.to_owned(), - remap, - } - } - Name::Unmap(modes) => Command::Unmap { - modes: modes.to_vec(), - lhs: required(args)?.to_owned(), - }, - Name::Set => Command::Set(args.split_whitespace().map(set_arg).collect()), - Name::Highlight => highlight(args)?, - Name::Colorscheme => Command::Colorscheme((!args.is_empty()).then(|| args.to_owned())), - Name::Source => Command::Source(required(args)?.to_owned()), - Name::Messages => Command::Messages, - Name::Help => Command::Help((!args.is_empty()).then(|| args.to_owned())), - Name::Split(vertical) => Command::Split { vertical }, - Name::Close => Command::Close, - Name::Only => Command::Only, - Name::Connect => Command::Connect((!args.is_empty()).then(|| args.to_owned())), - Name::Agree => Command::Agree, - Name::Open => Command::Open, - Name::Profile => Command::Profile((!args.is_empty()).then(|| args.to_owned())), - Name::Browse => match args { - "" => Command::Browse { link: false }, - "link" => Command::Browse { link: true }, - _ => return Err(ExError::InvalidArgument(args.to_owned())), - }, - Name::YankLink => Command::YankLink, - Name::Hover => Command::Hover, - Name::Pop => Command::Pop, - Name::Jumps => Command::Jumps, - Name::Nohlsearch => Command::Nohlsearch, - Name::Star => Command::Star, - Name::Commit => Command::Commit { - author: author(args)?, - }, - Name::Reply => Command::Reply { - author: author(args)?, - }, - Name::Quote => Command::Quote { - author: author(args)?, - }, - Name::Delete => Command::Delete, - Name::Follow(on) => Command::Follow { - handle: (!args.is_empty()).then(|| args.trim_start_matches('@').to_owned()), - on, - }, - Name::Pick => { - let (list, query) = required(args)? - .split_once(' ') - .map_or((args, ""), |(list, query)| (list, query.trim_start())); - let list = match list { - "buffers" => PickList::Buffers, - "users" => PickList::Users, - _ => return Err(ExError::InvalidArgument(list.to_owned())), - }; - Command::Pick { - list, - query: query.to_owned(), - } - } - Name::Edit => Command::Edit((!args.is_empty()).then(|| args.to_owned())), - Name::Buffer => Command::Buffer(required(args)?.to_owned()), - Name::Buffers => Command::Buffers, - Name::NextBuffer(forward) => Command::NextBuffer { forward }, - Name::Wincmd => { - let mut chars = required(args)?.chars(); - match (chars.next(), chars.next()) { - (Some(ch), None) => Command::Wincmd(ch), - _ => return Err(ExError::InvalidArgument(args.to_owned())), - } - } - }; - Ok(Some(command)) -} - -/// Whom `:commit` and its kin post as: the one handle in `args`, `@` or not; `None` without one. -fn author(args: &str) -> Result, ExError> { - match args.split_whitespace().collect::>()[..] { - [] => Ok(None), - [handle] => Ok(Some(handle.trim_start_matches('@').to_owned())), - _ => Err(ExError::InvalidArgument(args.to_owned())), - } -} - -fn required(args: &str) -> Result<&str, ExError> { - if args.is_empty() { - Err(ExError::ArgumentRequired) - } else { - Ok(args) - } -} - -/// The left side and the rest of the line of a `:map`, after map modifiers like ``, which `dn` accepts but -/// has no use for. `None` when either side is missing. -fn map_args(args: &str) -> Option<(&str, &str)> { - let mut rest = args; - while let Some(after) = ["", "", ""] - .iter() - .find_map(|m| rest.strip_prefix(m)) - { - rest = after.trim_start(); - } - let (lhs, rhs) = rest.split_once(char::is_whitespace)?; - let rhs = rhs.trim_start(); - (!rhs.is_empty()).then_some((lhs, rhs)) -} - -fn set_arg(arg: &str) -> SetArg { - let named = |name: &str, op| SetArg { - name: name.to_owned(), - op, - }; - if let Some((name, value)) = arg.split_once(['=', ':']) { - return named(name, SetOp::Assign(value.to_owned())); - } - if let Some(name) = arg.strip_suffix('?') { - return named(name, SetOp::Query); - } - if let Some(name) = arg.strip_suffix('!') { - return named(name, SetOp::Toggle); - } - named(arg, SetOp::On) -} - -fn highlight(args: &str) -> Result { - let mut words = args.split_whitespace(); - let group = words.next().ok_or(ExError::ArgumentRequired)?; - if group == "clear" { - return Ok(Command::HighlightClear); - } - if group == "link" { - let from = words.next().ok_or(ExError::ArgumentRequired)?; - let to = words.next().ok_or(ExError::ArgumentRequired)?; - return Ok(Command::HighlightLink { - from: from.to_owned(), - to: (to != "NONE").then(|| to.to_owned()), - }); - } - let attrs = words - .map(|word| { - word.split_once('=') - .map(|(key, value)| (key.to_owned(), value.to_owned())) - .ok_or_else(|| ExError::InvalidArgument(word.to_owned())) - }) - .collect::>()?; - Ok(Command::Highlight { - group: group.to_owned(), - attrs, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::keymap::Mode; - - fn parsed(line: &str) -> Command { - parse(line).expect("valid command").expect("not blank") - } - - #[test] - fn help_is_h_and_takes_keys() { - assert_eq!(parsed("h"), Command::Help(None)); - assert_eq!(parsed("help zo"), Command::Help(Some("zo".into()))); - assert_eq!(parsed("hi clear"), Command::HighlightClear); - } - - #[test] - fn commit_reply_and_quote_take_whom_to_post_as() { - assert_eq!(parsed("commit"), Command::Commit { author: None }); - assert_eq!( - parsed("comm @acme"), - Command::Commit { - author: Some("acme".into()) - } - ); - assert_eq!( - parsed("rep acme "), - Command::Reply { - author: Some("acme".into()) - } - ); - assert_eq!(parsed("quo"), Command::Quote { author: None }); - assert_eq!( - parse("commit @a @b"), - Err(ExError::InvalidArgument("@a @b".into())) - ); - } - - #[test] - fn quit_and_its_abbreviations_and_bang() { - assert_eq!( - parsed("q"), - Command::Quit { - all: false, - force: false - } - ); - assert_eq!( - parsed("quit!"), - Command::Quit { - all: false, - force: true - } - ); - assert_eq!( - parsed("qa"), - Command::Quit { - all: true, - force: false - } - ); - assert_eq!( - parsed(" quita!"), - Command::Quit { - all: true, - force: true - } - ); - } - - #[test] - fn unknown_commands_and_misplaced_bangs_are_vim_errors() { - assert_eq!( - parse("tselect").unwrap_err().to_string(), - "E492: Not an editor command: tselect" - ); - assert_eq!( - parse("frobnicate x").unwrap_err().to_string(), - "E492: Not an editor command: frobnicate x" - ); - assert_eq!( - parse("messages!").unwrap_err().to_string(), - "E477: No ! allowed" - ); - assert_eq!( - parse("colo").map(Option::unwrap), - Ok(Command::Colorscheme(None)) - ); - } - - #[test] - fn blank_lines_and_comments_are_nothing() { - assert_eq!(parse(" "), Ok(None)); - assert_eq!(parse("\" a comment"), Ok(None)); - assert_eq!(parse(":"), Ok(None)); - } - - #[test] - fn maps_take_a_left_side_and_the_rest_of_the_line() { - assert_eq!( - parsed("nnoremap x :star"), - Command::Map { - modes: vec![Mode::Normal], - lhs: "x".into(), - rhs: ":star".into(), - remap: false - } - ); - assert_eq!( - parsed("map Q gq ip"), - Command::Map { - modes: vec![Mode::Normal, Mode::Visual], - lhs: "Q".into(), - rhs: "gq ip".into(), - remap: true - } - ); - assert!( - matches!(parsed("xn x y"), Command::Map { remap: false, ref modes, .. } if *modes == [Mode::Visual]) - ); - assert!( - matches!(parsed("vmap x y"), Command::Map { remap: true, ref modes, .. } if *modes == [Mode::Visual]) - ); - assert_eq!( - parse("nnoremap x").unwrap_err().to_string(), - "E471: Argument required" - ); - assert_eq!( - parsed("nun x"), - Command::Unmap { - modes: vec![Mode::Normal], - lhs: "x".into() - } - ); - } - - #[test] - fn set_takes_any_number_of_options() { - assert_eq!( - parsed("se ignorecase nosmartcase invwrap timeoutlen=500 leader=, scrolloff:3 wrap?"), - Command::Set(vec![ - SetArg { - name: "ignorecase".into(), - op: SetOp::On - }, - SetArg { - name: "nosmartcase".into(), - op: SetOp::On - }, - SetArg { - name: "invwrap".into(), - op: SetOp::On - }, - SetArg { - name: "timeoutlen".into(), - op: SetOp::Assign("500".into()) - }, - SetArg { - name: "leader".into(), - op: SetOp::Assign(",".into()) - }, - SetArg { - name: "scrolloff".into(), - op: SetOp::Assign("3".into()) - }, - SetArg { - name: "wrap".into(), - op: SetOp::Query - }, - ]) - ); - assert_eq!( - parsed("set nonumber!"), - Command::Set(vec![SetArg { - name: "nonumber".into(), - op: SetOp::Toggle - }]) - ); - } - - #[test] - fn highlight_sets_attributes_by_key() { - assert_eq!( - parsed("hi StatusLine guifg=#c0caf5 bg=#1f2335 gui=bold,italic"), - Command::Highlight { - group: "StatusLine".into(), - attrs: vec![ - ("guifg".into(), "#c0caf5".into()), - ("bg".into(), "#1f2335".into()), - ("gui".into(), "bold,italic".into()), - ], - } - ); - assert_eq!(parsed("highlight clear"), Command::HighlightClear); - assert_eq!( - parse("hi Normal guifg").unwrap_err().to_string(), - "E475: Invalid argument: guifg" - ); - } - - #[test] - fn colorscheme_source_and_messages() { - assert_eq!( - parsed("colorscheme tokyonight-storm"), - Command::Colorscheme(Some("tokyonight-storm".into())) - ); - assert_eq!( - parsed("so ~/.config/devnull/init.vim"), - Command::Source("~/.config/devnull/init.vim".into()) - ); - assert_eq!( - parse("source").unwrap_err().to_string(), - "E471: Argument required" - ); - assert_eq!(parsed("mes"), Command::Messages); - } - - #[test] - fn edit_reloads_or_opens_a_buffer() { - assert_eq!(parsed("e!"), Command::Edit(None)); - assert_eq!(parsed("edit"), Command::Edit(None)); - assert_eq!( - parsed("e log/latest"), - Command::Edit(Some("log/latest".into())) - ); - } - - #[test] - fn buffer_commands() { - assert_eq!(parsed("ls"), Command::Buffers); - assert_eq!(parsed("buffers"), Command::Buffers); - assert_eq!(parsed("b 2"), Command::Buffer("2".into())); - assert_eq!(parsed("buffer lat"), Command::Buffer("lat".into())); - assert_eq!(parsed("bn"), Command::NextBuffer { forward: true }); - assert_eq!(parsed("bprevious"), Command::NextBuffer { forward: false }); - assert_eq!( - parse("b").unwrap_err().to_string(), - "E471: Argument required" - ); - } - - #[test] - fn profile_takes_a_handle_or_none() { - assert_eq!(parsed("prof"), Command::Profile(None)); - assert_eq!( - parsed("profile @ada"), - Command::Profile(Some("@ada".into())) - ); - } - - #[test] - fn browse_takes_link_or_nothing() { - assert_eq!(parsed("bro"), Command::Browse { link: false }); - assert_eq!(parsed("browse link"), Command::Browse { link: true }); - assert_eq!( - parse("browse that").unwrap_err().to_string(), - "E475: Invalid argument: that" - ); - assert_eq!(parsed("yankl"), Command::YankLink); - assert_eq!(parsed("hov"), Command::Hover); - } - - #[test] - fn pick_takes_a_list_and_maybe_a_query() { - assert_eq!( - parsed("pick buffers"), - Command::Pick { - list: PickList::Buffers, - query: String::new() - } - ); - assert_eq!( - parsed("pi users ada lo"), - Command::Pick { - list: PickList::Users, - query: "ada lo".into() - } - ); - assert_eq!( - parse("pick files").unwrap_err().to_string(), - "E475: Invalid argument: files" - ); - assert_eq!( - parse("pick").unwrap_err().to_string(), - "E471: Argument required" - ); - } - - #[test] - fn hi_link_names_two_groups_or_none() { - assert_eq!( - parsed("hi link DevnullHandle ErrorMsg"), - Command::HighlightLink { - from: "DevnullHandle".into(), - to: Some("ErrorMsg".into()) - } - ); - assert_eq!( - parsed("highlight link DevnullHandle NONE"), - Command::HighlightLink { - from: "DevnullHandle".into(), - to: None - } - ); - assert_eq!( - parse("hi link DevnullHandle").unwrap_err().to_string(), - "E471: Argument required" - ); - } - - #[test] - fn o_is_open_and_on_is_only() { - assert_eq!(parsed("o"), Command::Open); - assert_eq!(parsed("open"), Command::Open); - assert_eq!(parsed("on"), Command::Only); - } -} diff --git a/crates/devnull/src/graphics/mod.rs b/crates/devnull/src/graphics/mod.rs deleted file mode 100644 index 22071a0..0000000 --- a/crates/devnull/src/graphics/mod.rs +++ /dev/null @@ -1,439 +0,0 @@ -//! Pictures as the terminal shows them (D7): fitted to cells, then sent with the kitty graphics protocol and shown -//! through Unicode placeholder cells, or drawn with half blocks where the protocol is missing. Both are text, so a -//! picture scrolls, clips and folds with the rest of a buffer. - -#[cfg(test)] -mod tests; - -use std::io::Cursor; -use std::time::Duration; - -use base64::Engine; -use base64::engine::general_purpose::STANDARD; -use image::codecs::gif::GifDecoder; -use image::codecs::png::PngDecoder; -use image::codecs::webp::WebPDecoder; -use image::imageops::FilterType; -use image::{AnimationDecoder, DynamicImage, Frames, ImageFormat, ImageReader, RgbaImage}; - -/// The most cells a picture takes across: no more than the placeholder diacritics below can number. -pub const MAX_COLS: u16 = 96; - -/// The most cells a picture takes down. -pub const MAX_ROWS: u16 = 48; - -/// The most pixels a picture may have to be decoded, as the Go client allows. -const MAX_PIXELS: u64 = 8_000_000; - -/// Pixels sent per cell, across and down, for a sharp picture on dense screens without sending more than needed. -const CELL_PIXELS: (u32, u32) = (16, 32); - -/// Base64 bytes per chunk of a transmission, as the kitty protocol requires at most. -const CHUNK: usize = 4096; - -/// The character of a placeholder cell. -const PLACEHOLDER: char = '\u{10EEEE}'; - -/// The first entries of kitty's `rowcolumn-diacritics.txt`, the protocol's table: entry `n` stands for row or column -/// `n`. Enough for [`MAX_COLS`] and [`MAX_ROWS`]. -const DIACRITICS: [char; 100] = [ - '\u{305}', '\u{30D}', '\u{30E}', '\u{310}', '\u{312}', '\u{33D}', '\u{33E}', '\u{33F}', - '\u{346}', '\u{34A}', '\u{34B}', '\u{34C}', '\u{350}', '\u{351}', '\u{352}', '\u{357}', - '\u{35B}', '\u{363}', '\u{364}', '\u{365}', '\u{366}', '\u{367}', '\u{368}', '\u{369}', - '\u{36A}', '\u{36B}', '\u{36C}', '\u{36D}', '\u{36E}', '\u{36F}', '\u{483}', '\u{484}', - '\u{485}', '\u{486}', '\u{487}', '\u{592}', '\u{593}', '\u{594}', '\u{595}', '\u{597}', - '\u{598}', '\u{599}', '\u{59C}', '\u{59D}', '\u{59E}', '\u{59F}', '\u{5A0}', '\u{5A1}', - '\u{5A8}', '\u{5A9}', '\u{5AB}', '\u{5AC}', '\u{5AF}', '\u{5C4}', '\u{610}', '\u{611}', - '\u{612}', '\u{613}', '\u{614}', '\u{615}', '\u{616}', '\u{617}', '\u{657}', '\u{658}', - '\u{659}', '\u{65A}', '\u{65B}', '\u{65D}', '\u{65E}', '\u{6D6}', '\u{6D7}', '\u{6D8}', - '\u{6D9}', '\u{6DA}', '\u{6DB}', '\u{6DC}', '\u{6DF}', '\u{6E0}', '\u{6E1}', '\u{6E2}', - '\u{6E4}', '\u{6E7}', '\u{6E8}', '\u{6EB}', '\u{6EC}', '\u{730}', '\u{732}', '\u{733}', - '\u{735}', '\u{736}', '\u{73A}', '\u{73D}', '\u{73F}', '\u{740}', '\u{741}', '\u{743}', - '\u{745}', '\u{747}', '\u{749}', '\u{74A}', -]; - -/// How pictures are drawn. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Graphics { - /// Sent with the kitty graphics protocol, shown through placeholder cells. - Kitty, - /// Drawn with half blocks in true colour. - HalfBlocks, -} - -impl Graphics { - /// Every way pictures are drawn. - pub const ALL: [Self; 2] = [Self::Kitty, Self::HalfBlocks]; - - /// What the terminal likely speaks, from `$TERM` and `$TERM_PROGRAM`: the kitty protocol in kitty and Ghostty, - /// but not inside tmux (`tmux`), where placeholders would need passing through; half blocks elsewhere. - #[must_use] - pub fn guess(term: Option<&str>, program: Option<&str>, tmux: bool) -> Self { - let named = |name: &str| { - [term, program] - .into_iter() - .flatten() - .any(|value| value.to_lowercase().contains(name)) - }; - if !tmux && (named("kitty") || named("ghostty")) { - Self::Kitty - } else { - Self::HalfBlocks - } - } - - /// Its name, as `:set graphics=` takes it. - #[must_use] - pub fn name(self) -> &'static str { - match self { - Self::Kitty => "kitty", - Self::HalfBlocks => "halfblocks", - } - } - - /// The graphics of that name. - #[must_use] - pub fn named(name: &str) -> Option { - [Self::Kitty, Self::HalfBlocks] - .into_iter() - .find(|graphics| graphics.name() == name) - } -} - -/// The cells a `width` × `height` pixel picture takes when as wide as `max_cols` and no taller than `max_rows` -/// (both within the protocol's limits), as (columns, rows); cells are twice as tall as they are wide. `(0, 0)` for -/// an empty picture. -#[must_use] -pub fn fit(width: u32, height: u32, max_cols: u16, max_rows: u16) -> (u16, u16) { - if width == 0 || height == 0 || max_cols == 0 || max_rows == 0 { - return (0, 0); - } - let (width, height) = (f64::from(width), f64::from(height)); - let max_rows = max_rows.min(MAX_ROWS); - let mut cols = max_cols.min(MAX_COLS); - let mut rows = rounded(f64::from(cols) * height / width / 2.0).max(1); - if rows > max_rows { - rows = max_rows; - cols = cols - .min(rounded(f64::from(rows) * 2.0 * width / height)) - .max(1); - } - (cols, rows) -} - -/// The cells a `width` × `height` pixel picture takes at its own size, a cell being `cell` pixels (across, down): -/// those its pixels cover, keeping its shape within the protocol's limits, as (columns, rows). `(0, 0)` for an empty -/// picture. -#[must_use] -pub fn natural(width: u32, height: u32, cell: (u16, u16)) -> (u16, u16) { - let covered = |pixels: u32, per_cell: u16| { - u16::try_from(pixels.div_ceil(u32::from(per_cell.max(1)))).unwrap_or(u16::MAX) - }; - fit( - width, - height, - covered(width, cell.0), - covered(height, cell.1), - ) -} - -/// `value`, which is positive, rounded to cells. -fn rounded(value: f64) -> u16 { - // Cells are counted in the thousands at most: the cast neither truncates nor loses a sign. - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let cells = value.round().min(f64::from(u16::MAX)) as u16; - cells -} - -/// The most frames an animation keeps; a longer one stays on its first. -const MAX_FRAMES: usize = 200; - -/// A frame said to show this briefly or less shows for [`BRIEF_FRAME`] instead, as browsers do. -const BRIEFEST_FRAME: Duration = Duration::from_millis(10); - -/// How long a frame said to show too briefly shows. -const BRIEF_FRAME: Duration = Duration::from_millis(100); - -/// A decoded picture: a still one, or an animation. -#[derive(Debug, Clone)] -pub struct Picture { - /// Never empty: a still picture is one frame. - frames: Vec, -} - -/// A frame of a picture, all of it. -#[derive(Debug, Clone)] -struct Frame { - pixels: RgbaImage, - /// How long it shows before the next, in an animation. - delay: Duration, -} - -/// A frame of a picture drawn with half blocks. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HalfBlockFrame { - /// Its cells, by rows. - pub cells: Vec>, - /// How long it shows before the next, in an animation. - pub delay: Duration, -} - -/// One cell of a picture drawn with half blocks: `▀` in the upper pixel's colour over the lower's. `None` is a -/// transparent pixel, which shows the background. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct HalfBlock { - /// The upper pixel, as RGB. - pub upper: Option<(u8, u8, u8)>, - /// The lower pixel, as RGB. - pub lower: Option<(u8, u8, u8)>, -} - -impl Picture { - /// The picture `bytes` hold: PNG, JPEG, WebP or GIF, of at most 8 megapixels. An animated GIF, WebP or PNG - /// keeps its frames, up to [`MAX_FRAMES`] and 8 megapixels in all; a longer or larger one only its first. `None` - /// for anything else. - #[must_use] - pub fn decode(bytes: &[u8]) -> Option { - let reader = ImageReader::new(Cursor::new(bytes)) - .with_guessed_format() - .ok()?; - let format = reader.format()?; - if !matches!( - format, - ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::WebP | ImageFormat::Gif - ) { - return None; - } - let (width, height) = reader.into_dimensions().ok()?; - let canvas = u64::from(width) * u64::from(height); - if canvas == 0 || canvas > MAX_PIXELS { - return None; - } - if let Some(frames) = animation(bytes, format, canvas) { - return Some(Self { frames }); - } - let picture = image::load_from_memory_with_format(bytes, format).ok()?; - Some(Self { - frames: vec![Frame { - pixels: picture.to_rgba8(), - delay: Duration::ZERO, - }], - }) - } - - /// Its size in pixels, as (width, height). - #[must_use] - pub fn size(&self) -> (u32, u32) { - self.frames[0].pixels.dimensions() - } - - /// The kitty protocol escapes that send it as image `id`, shown in `cols` × `rows` cells by placeholders (a - /// virtual placement), quietly: a PNG of at most [`CELL_PIXELS`] per cell, base64 in chunks. An animation sends - /// its other frames too, then has the terminal run it, looping (D7). - #[must_use] - pub fn kitty(&self, id: u32, cols: u16, rows: u16) -> String { - let (width, height) = ( - u32::from(cols) * CELL_PIXELS.0, - u32::from(rows) * CELL_PIXELS.1, - ); - let mut sent = String::new(); - for (i, frame) in self.frames.iter().enumerate() { - let Some(png) = png(&boxed(&frame.pixels, width, height, Fill::Shape)) else { - return String::new(); - }; - if i == 0 { - let keys = format!("a=T,f=100,t=d,i={id},c={cols},r={rows},U=1,q=2"); - transmit(&mut sent, &keys, "", &png); - } else { - let keys = format!("a=f,f=100,i={id},z={},q=2", frame.delay.as_millis()); - transmit(&mut sent, &keys, "a=f,", &png); - } - } - if let [first, _, ..] = &self.frames[..] { - // The root frame's gap can only be set once it is there. - let gap = first.delay.as_millis(); - sent.push_str(&format!("\x1b_Ga=a,i={id},r=1,z={gap},q=2\x1b\\")); - sent.push_str(&format!("\x1b_Ga=a,i={id},s=3,v=1,q=2\x1b\\")); - } - sent - } - - /// The picture as `cols` × `rows` cells of half blocks, frame by frame. - #[must_use] - pub fn half_blocks(&self, cols: u16, rows: u16) -> Vec { - self.frames - .iter() - .map(|frame| HalfBlockFrame { - cells: half_blocks(&frame.pixels, cols, rows), - delay: frame.delay, - }) - .collect() - } -} - -/// The frames of the GIF, or animated WebP or PNG, `bytes` hold as `format`, each of `canvas` pixels, how long each -/// shows made sensible; `None` for anything else, or when they are more than [`MAX_FRAMES`] or [`MAX_PIXELS`] in all. -fn animation(bytes: &[u8], format: ImageFormat, canvas: u64) -> Option> { - // A still WebP or PNG is not decoded as frames, then again as a picture. - let frames: Frames<'_> = match format { - ImageFormat::Gif => GifDecoder::new(Cursor::new(bytes)).ok()?.into_frames(), - ImageFormat::WebP => { - let decoder = WebPDecoder::new(Cursor::new(bytes)).ok()?; - if !decoder.has_animation() { - return None; - } - decoder.into_frames() - } - ImageFormat::Png => { - let decoder = PngDecoder::new(Cursor::new(bytes)).ok()?; - if !decoder.is_apng().ok()? { - return None; - } - decoder.apng().ok()?.into_frames() - } - _ => return None, - }; - let most = usize::try_from(MAX_PIXELS / canvas) - .unwrap_or(usize::MAX) - .min(MAX_FRAMES); - let mut kept = Vec::new(); - for frame in frames { - if kept.len() == most { - return None; - } - let frame = frame.ok()?; - let delay = Duration::from(frame.delay()); - kept.push(Frame { - delay: if delay <= BRIEFEST_FRAME { - BRIEF_FRAME - } else { - delay - }, - pixels: frame.into_buffer(), - }); - } - (!kept.is_empty()).then_some(kept) -} - -/// `pixels` as a PNG, if it can be encoded. -fn png(pixels: &RgbaImage) -> Option> { - let mut png = Cursor::new(Vec::new()); - DynamicImage::ImageRgba8(pixels.clone()) - .write_to(&mut png, ImageFormat::Png) - .ok()?; - Some(png.into_inner()) -} - -/// Appends to `sent` the escapes that send `data` in base64 chunks: the first with `keys`, the others with -/// `continued` (`a=f,` for a frame, which every chunk must say) before their own. -fn transmit(sent: &mut String, keys: &str, continued: &str, data: &[u8]) { - let encoded = STANDARD.encode(data); - let chunks: Vec<&[u8]> = encoded.as_bytes().chunks(CHUNK).collect(); - sent.reserve(encoded.len() + chunks.len() * 64); - for (i, chunk) in chunks.iter().enumerate() { - let more = u8::from(i + 1 < chunks.len()); - // Chunks are base64, so ASCII. - let chunk = std::str::from_utf8(chunk).unwrap_or_default(); - if i == 0 { - sent.push_str(&format!("\x1b_G{keys},m={more};{chunk}\x1b\\")); - } else { - sent.push_str(&format!("\x1b_G{continued}m={more},q=2;{chunk}\x1b\\")); - } - } -} - -/// `pixels` as `cols` × `rows` cells of half blocks, by rows. -fn half_blocks(pixels: &RgbaImage, cols: u16, rows: u16) -> Vec> { - if cols == 0 || rows == 0 { - return Vec::new(); - } - let scaled = boxed(pixels, u32::from(cols), u32::from(rows) * 2, Fill::Exactly); - let colour = |x: u32, y: u32| { - let [r, g, b, a] = scaled.get_pixel(x, y).0; - (a >= 128).then_some((r, g, b)) - }; - (0..u32::from(rows)) - .map(|row| { - (0..u32::from(cols)) - .map(|col| HalfBlock { - upper: colour(col, row * 2), - lower: colour(col, row * 2 + 1), - }) - .collect() - }) - .collect() -} - -/// How [`Picture::boxed`] fills its box. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Fill { - /// Exactly the box's pixels: the picture scaled up or down to fit. - Exactly, - /// The box's shape, at most its size: the picture scaled down to fit, never up, for a terminal that stretches - /// it over the cells. - Shape, -} - -/// `source` centred in a transparent canvas filling a `width` × `height` pixel box as `fill` says, keeping its -/// own shape. -fn boxed(source: &RgbaImage, width: u32, height: u32, fill: Fill) -> RgbaImage { - let (w, h) = source.dimensions(); - let (box_w, box_h) = (f64::from(width.max(1)), f64::from(height.max(1))); - let mut scale = (box_w / f64::from(w)).min(box_h / f64::from(h)); - if fill == Fill::Shape { - scale = scale.min(1.0); - } - let scaled_w = pixels(f64::from(w) * scale); - let scaled_h = pixels(f64::from(h) * scale); - let scaled = if (scaled_w, scaled_h) == (w, h) { - source.clone() - } else { - image::imageops::resize(source, scaled_w, scaled_h, FilterType::CatmullRom) - }; - let (canvas_w, canvas_h) = match fill { - Fill::Exactly => (width.max(1), height.max(1)), - Fill::Shape => ( - scaled_w.max(pixels(f64::from(scaled_h) * box_w / box_h)), - scaled_h.max(pixels(f64::from(scaled_w) * box_h / box_w)), - ), - }; - let mut canvas = RgbaImage::new(canvas_w, canvas_h); - image::imageops::overlay( - &mut canvas, - &scaled, - i64::from((canvas_w - scaled_w.min(canvas_w)) / 2), - i64::from((canvas_h - scaled_h.min(canvas_h)) / 2), - ); - canvas -} - -/// `value`, a positive size in pixels, rounded to at least one. -fn pixels(value: f64) -> u32 { - // Pictures are 8 megapixels at most, and boxes a few thousand pixels: neither truncates nor loses a sign. - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let pixels = value.round().clamp(1.0, f64::from(u32::MAX)) as u32; - pixels -} - -/// The text of the placeholder cell at `row` and `col` of a picture shown through placeholders; its foreground -/// colour names the picture (see [`image_colour`]). -#[must_use] -pub fn placeholder(row: u16, col: u16) -> String { - let diacritic = |n: u16| DIACRITICS[usize::from(n).min(DIACRITICS.len() - 1)]; - [PLACEHOLDER, diacritic(row), diacritic(col)] - .into_iter() - .collect() -} - -/// The foreground colour, as RGB, of the placeholder cells of image `id` (24 bits). -#[must_use] -pub fn image_colour(id: u32) -> (u8, u8, u8) { - let [_, r, g, b] = id.to_be_bytes(); - (r, g, b) -} - -/// The kitty protocol escape that frees image `id`, quietly. -#[must_use] -pub fn kitty_delete(id: u32) -> String { - format!("\x1b_Ga=d,d=I,i={id},q=2\x1b\\") -} diff --git a/crates/devnull/src/graphics/tests.rs b/crates/devnull/src/graphics/tests.rs deleted file mode 100644 index 39c33a2..0000000 --- a/crates/devnull/src/graphics/tests.rs +++ /dev/null @@ -1,372 +0,0 @@ -//! Pictures as the terminal shows them. - -use std::time::Duration; - -use image::codecs::gif::{GifEncoder, Repeat}; -use image::{Delay, Frame, ImageFormat, Rgba, RgbaImage}; - -use super::*; - -/// `width` × `height` pixels, red above half its height and blue below, as PNG. -fn png(width: u32, height: u32) -> Vec { - let picture = RgbaImage::from_fn(width, height, |_, y| { - if y < height / 2 { - Rgba([255, 0, 0, 255]) - } else { - Rgba([0, 0, 255, 255]) - } - }); - let mut bytes = std::io::Cursor::new(Vec::new()); - picture.write_to(&mut bytes, ImageFormat::Png).expect("png"); - bytes.into_inner() -} - -const RED: [u8; 4] = [255, 0, 0, 255]; -const BLUE: [u8; 4] = [0, 0, 255, 255]; - -/// An animated GIF of `width` × `height` pixels: each of `frames` one colour, shown for its delay in milliseconds. -fn gif(width: u32, height: u32, frames: &[([u8; 4], u32)]) -> Vec { - let mut bytes = Vec::new(); - { - let mut encoder = GifEncoder::new_with_speed(&mut bytes, 30); - encoder.set_repeat(Repeat::Infinite).expect("repeat"); - for &(colour, delay) in frames { - let pixels = RgbaImage::from_pixel(width, height, Rgba(colour)); - let delay = Delay::from_numer_denom_ms(delay, 1); - encoder - .encode_frame(Frame::from_parts(pixels, 0, 0, delay)) - .expect("frame"); - } - } - bytes -} - -/// The bytes of `name` in `tests/fixtures`. -fn fixture(name: &str) -> Vec { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures") - .join(name); - std::fs::read(path).expect("fixture") -} - -/// Whether each frame is red or blue (in its first cell's upper pixel), and how long it shows. -fn frames_of(picture: &Picture) -> Vec<(&'static str, Duration)> { - picture - .half_blocks(1, 1) - .iter() - .map(|frame| { - let colour = match frame.cells[0][0].upper { - Some((r, _, b)) if r > 200 && b < 60 => "red", - Some((r, _, b)) if b > 200 && r < 60 => "blue", - other => panic!("neither red nor blue: {other:?}"), - }; - (colour, frame.delay) - }) - .collect() -} - -fn ms(millis: u64) -> Duration { - Duration::from_millis(millis) -} - -/// `width` × `height` pixels of noise, as PNG: too much to send in one chunk. -fn noisy_png(width: u32, height: u32) -> Vec { - let picture = RgbaImage::from_fn(width, height, |x, y| { - let n = x - .wrapping_mul(2_654_435_761) - .wrapping_add(y.wrapping_mul(40_503)) - .to_le_bytes(); - Rgba([n[0], n[1], n[2], 255]) - }); - let mut bytes = std::io::Cursor::new(Vec::new()); - picture.write_to(&mut bytes, ImageFormat::Png).expect("png"); - bytes.into_inner() -} - -#[test] -fn a_picture_fits_the_cells_as_wide_as_allowed_keeping_its_shape() { - // Cells are twice as tall as they are wide. - assert_eq!(fit(400, 200, 40, 30), (40, 10)); - assert_eq!( - fit(200, 400, 40, 10), - (10, 10), - "too tall: as tall as allowed" - ); - assert_eq!(fit(4000, 10, 40, 30), (40, 1), "at least a row"); - assert_eq!( - fit(400, 200, 500, 500), - (MAX_COLS, MAX_COLS / 4), - "never more than the protocol's cells" - ); - assert_eq!(fit(0, 10, 40, 30), (0, 0)); -} - -#[test] -fn at_its_own_size_a_picture_takes_the_cells_its_pixels_cover() { - assert_eq!(natural(400, 200, (10, 20)), (40, 10)); - assert_eq!( - natural(405, 201, (10, 20)), - (41, 10), - "a cell started counts, its shape kept" - ); - assert_eq!( - natural(4000, 2000, (10, 20)), - (96, 24), - "within the protocol's limits" - ); - assert_eq!(natural(0, 200, (10, 20)), (0, 0)); -} - -#[test] -fn pictures_decode_from_png_and_anything_else_is_refused() { - let picture = Picture::decode(&png(8, 4)).expect("picture"); - - assert_eq!(picture.size(), (8, 4)); - assert!(Picture::decode(b"not a picture").is_none()); -} - -#[test] -fn pictures_too_large_to_decode_are_refused() { - assert!( - Picture::decode(&png(4000, 2001)).is_none(), - "over 8 megapixels" - ); -} - -#[test] -fn half_blocks_put_the_upper_pixel_in_front_and_the_lower_behind() { - let picture = Picture::decode(&png(4, 4)).expect("picture"); - - let frames = picture.half_blocks(2, 1); - - assert_eq!(frames.len(), 1, "a still picture is one frame"); - let cells = &frames[0].cells; - assert_eq!(cells.len(), 1); - assert_eq!(cells[0].len(), 2); - let HalfBlock { - upper: Some(upper), - lower: Some(lower), - } = cells[0][0] - else { - panic!("opaque: {:?}", cells[0][0]); - }; - assert!(upper.0 > 200 && upper.2 < 60, "red above: {upper:?}"); - assert!(lower.2 > 200 && lower.0 < 60, "blue below: {lower:?}"); -} - -#[test] -fn the_kitty_transmission_sends_a_png_in_chunks_as_a_virtual_placement() { - let picture = Picture::decode(&noisy_png(400, 200)).expect("picture"); - - let sent = picture.kitty(0x0a_0b_0c, 40, 10); - - let chunks: Vec<&str> = sent - .split("\x1b\\") - .filter(|chunk| !chunk.is_empty()) - .collect(); - assert!( - chunks[0].starts_with("\x1b_Ga=T,f=100,t=d,i=658188,c=40,r=10,U=1,q=2,m="), - "{}", - &chunks[0][..60] - ); - assert!(chunks.iter().all(|chunk| chunk.starts_with("\x1b_G"))); - assert!(chunks.last().expect("a chunk").starts_with("\x1b_Gm=0")); - assert!(chunks.iter().all(|chunk| chunk.len() <= CHUNK + 64)); -} - -#[test] -fn a_placeholder_cell_names_its_row_and_column_and_its_colour_the_image() { - assert_eq!(placeholder(0, 0), "\u{10EEEE}\u{305}\u{305}"); - assert_eq!(placeholder(2, 1), "\u{10EEEE}\u{30E}\u{30D}"); - assert_eq!(image_colour(0x0a_0b_0c), (10, 11, 12)); -} - -#[test] -fn deleting_an_image_frees_it_quietly() { - assert_eq!(kitty_delete(7), "\x1b_Ga=d,d=I,i=7,q=2\x1b\\"); -} - -#[test] -fn kitty_graphics_are_guessed_from_the_terminal_and_never_through_tmux() { - let guess = - |term: &str, program: &str, tmux: bool| Graphics::guess(Some(term), Some(program), tmux); - - assert_eq!(guess("xterm-ghostty", "", false), Graphics::Kitty); - assert_eq!(guess("xterm-kitty", "", false), Graphics::Kitty); - assert_eq!(guess("xterm-256color", "ghostty", false), Graphics::Kitty); - assert_eq!( - guess("xterm-256color", "Apple_Terminal", false), - Graphics::HalfBlocks - ); - assert_eq!( - guess("xterm-ghostty", "", true), - Graphics::HalfBlocks, - "placeholders need passthrough there" - ); - assert_eq!(Graphics::guess(None, None, false), Graphics::HalfBlocks); -} - -#[test] -fn graphics_have_the_names_set_takes() { - for graphics in [Graphics::Kitty, Graphics::HalfBlocks] { - assert_eq!(Graphics::named(graphics.name()), Some(graphics)); - } - assert_eq!(Graphics::named("sixel"), None); -} - -#[test] -fn transparent_pixels_show_the_background() { - let clear = RgbaImage::from_pixel(2, 2, Rgba([255, 255, 255, 0])); - let mut bytes = std::io::Cursor::new(Vec::new()); - clear.write_to(&mut bytes, ImageFormat::Png).expect("png"); - let picture = Picture::decode(&bytes.into_inner()).expect("picture"); - - assert_eq!( - picture.half_blocks(1, 1)[0].cells, - [[HalfBlock { - upper: None, - lower: None - }]] - ); -} - -#[test] -fn a_picture_keeps_its_shape_in_a_box_of_another_centred_on_nothing() { - let square = RgbaImage::from_pixel(8, 8, Rgba([255, 0, 0, 255])); - let mut bytes = std::io::Cursor::new(Vec::new()); - square.write_to(&mut bytes, ImageFormat::Png).expect("png"); - let picture = Picture::decode(&bytes.into_inner()).expect("picture"); - - // Four cells across, one down: a box twice as wide as tall, so the square takes the middle half. - let cells = &picture.half_blocks(4, 1)[0].cells; - - let filled: Vec = cells[0].iter().map(|cell| cell.upper.is_some()).collect(); - assert_eq!(filled, [false, true, true, false]); -} - -/// The picture a kitty transmission sends, decoded from its chunks. -fn sent_picture(sent: &str) -> image::DynamicImage { - let payload: String = sent - .split("\x1b\\") - .filter_map(|chunk| chunk.split_once(';').map(|(_, data)| data)) - .collect(); - let png = STANDARD.decode(payload).expect("base64"); - image::load_from_memory(&png).expect("png") -} - -#[test] -fn a_small_picture_is_sent_at_its_size_for_the_terminal_to_stretch() { - let picture = Picture::decode(&png(4, 2)).expect("picture"); - - let sent = sent_picture(&picture.kitty(1, 48, 12)); - - // The box's shape (48 × 16 by 12 × 32 pixels: 2:1) around the picture's own 4 × 2. - assert_eq!((sent.width(), sent.height()), (4, 2)); -} - -#[test] -fn an_animated_gif_has_every_frame_with_its_delay() { - let picture = Picture::decode(&gif(4, 2, &[(RED, 50), (BLUE, 120)])).expect("picture"); - - assert_eq!(frames_of(&picture), [("red", ms(50)), ("blue", ms(120))]); -} - -#[test] -fn animated_webp_and_png_move_too() { - // Made with Pillow, which writes both; `image` writes neither. - for name in ["two-frames.webp", "two-frames.png"] { - let picture = Picture::decode(&fixture(name)).expect(name); - - assert_eq!( - frames_of(&picture), - [("red", ms(60)), ("blue", ms(120))], - "{name}" - ); - } -} - -#[test] -fn a_frame_too_brief_shows_for_a_tenth_of_a_second_as_in_browsers() { - let picture = Picture::decode(&gif(1, 1, &[(RED, 0), (BLUE, 10), (RED, 20)])).expect("picture"); - - let delays: Vec = frames_of(&picture).into_iter().map(|(_, d)| d).collect(); - assert_eq!(delays, [ms(100), ms(100), ms(20)]); -} - -#[test] -fn an_animation_too_long_or_too_large_stays_on_its_first_frame() { - let long: Vec<([u8; 4], u32)> = (0..=MAX_FRAMES) - .map(|n| (if n == 0 { RED } else { BLUE }, 50)) - .collect(); - let picture = Picture::decode(&gif(1, 1, &long)).expect("picture"); - assert!(matches!(frames_of(&picture)[..], [("red", _)])); - - // Three frames of 3 megapixels, over 8 in all (made with Pillow: encoding it here takes seconds). - let large = fixture("large-animation.gif"); - let picture = Picture::decode(&large).expect("picture"); - assert!(matches!(frames_of(&picture)[..], [("red", _)])); -} - -#[test] -fn the_kitty_transmission_of_an_animation_sends_its_frames_then_runs_it() { - let picture = Picture::decode(&gif(4, 2, &[(RED, 50), (BLUE, 120)])).expect("picture"); - - let sent = picture.kitty(7, 4, 1); - - let escapes: Vec<&str> = sent - .split("\x1b\\") - .filter(|escape| !escape.is_empty()) - .collect(); - assert!(escapes[0].starts_with("\x1b_Ga=T,f=100,t=d,i=7,c=4,r=1,U=1,q=2,m=0;")); - assert!(escapes[1].starts_with("\x1b_Ga=f,f=100,i=7,z=120,q=2,m=0;")); - assert_eq!( - escapes[2..], - ["\x1b_Ga=a,i=7,r=1,z=50,q=2", "\x1b_Ga=a,i=7,s=3,v=1,q=2"] - ); - let blue = sent_picture(&format!("{}\x1b\\", escapes[1])); - assert_eq!(blue.to_rgba8().get_pixel(0, 0).0, BLUE); -} - -#[test] -fn every_chunk_of_a_frame_says_it_is_one() { - let noise = |seed: u32| { - RgbaImage::from_fn(200, 100, |x, y| { - let n = (x ^ seed) - .wrapping_mul(2_654_435_761) - .wrapping_add(y.wrapping_mul(40_503)) - .to_le_bytes(); - Rgba([n[0], n[1], n[2], 255]) - }) - }; - let mut bytes = Vec::new(); - { - let mut encoder = GifEncoder::new_with_speed(&mut bytes, 30); - for seed in [1, 2] { - let frame = Frame::from_parts(noise(seed), 0, 0, Delay::from_numer_denom_ms(50, 1)); - encoder.encode_frame(frame).expect("frame"); - } - } - let picture = Picture::decode(&bytes).expect("picture"); - - let sent = picture.kitty(7, 20, 5); - - let start = sent.find("\x1b_Ga=f,").expect("a frame"); - let end = sent.find("\x1b_Ga=a,").expect("then the animation"); - let frame: Vec<&str> = sent[start..end] - .split("\x1b\\") - .filter(|escape| !escape.is_empty()) - .collect(); - assert!(frame.len() > 1, "in chunks"); - assert!( - frame.iter().all(|escape| escape.starts_with("\x1b_Ga=f,")), - "{:?}", - frame.iter().map(|escape| &escape[..12]).collect::>() - ); -} - -#[test] -fn a_still_picture_is_sent_without_frames() { - let sent = Picture::decode(&png(4, 2)).expect("picture").kitty(7, 4, 1); - - assert!(!sent.contains("a=f") && !sent.contains("a=a"), "{sent:?}"); -} diff --git a/crates/devnull/src/hl.rs b/crates/devnull/src/hl.rs new file mode 100644 index 0000000..bfcbca0 --- /dev/null +++ b/crates/devnull/src/hl.rs @@ -0,0 +1,121 @@ +//! `dn`'s highlight groups (D6): the kit's (its D7), and those of what dev-null.sh shows, each linked to a standard +//! group so any colorscheme colours it, with `dn`'s own colours for the built-in colorschemes. + +pub use wcgw_kit::hl::*; + +/// A user or organization handle. +pub const HANDLE: Group = Group::new("DevnullHandle"); +/// A commit author's name. +pub const AUTHOR: Group = Group::new("DevnullAuthor"); +/// When a commit was made. +pub const TIME: Group = Group::new("DevnullTime"); +/// A commit's short hash. +pub const HASH: Group = Group::new("DevnullHash"); +/// A commit's counts: stars, replies. +pub const STATS: Group = Group::new("DevnullStats"); +/// A link in a commit. +pub const LINK: Group = Group::new("DevnullLink"); +/// Inline code in a commit. +pub const CODE: Group = Group::new("DevnullCode"); + +/// `dn`'s groups and the standard ones they are drawn as when a colorscheme says nothing of them. +const GROUPS: [(Group, Group); 7] = [ + (HANDLE, Group::IDENTIFIER), + (AUTHOR, Group::BOLD), + (TIME, Group::COMMENT), + (HASH, Group::COMMENT), + (STATS, Group::NORMAL), + (LINK, Group::UNDERLINED), + (CODE, Group::STRING), +]; + +/// `dn`'s colours for the built-in colorschemes, as tokyonight.nvim's palettes give them. +const OVERRIDES: [(&str, &str); 6] = [ + ( + "tokyonight-night", + "\ + hi DevnullHandle guifg=#7dcfff\n\ + hi DevnullAuthor gui=bold guifg=#c0caf5\n\ + hi DevnullTime guifg=#565f89\n\ + hi DevnullHash guifg=#737aa2\n\ + hi DevnullStats guifg=#a9b1d6\n\ + hi DevnullLink gui=underline guifg=#7aa2f7\n\ + hi DevnullCode guifg=#9ece6a\n\ +", + ), + ( + "tokyonight", + "\ + hi DevnullHandle guifg=#7dcfff\n\ + hi DevnullAuthor gui=bold guifg=#c0caf5\n\ + hi DevnullTime guifg=#565f89\n\ + hi DevnullHash guifg=#737aa2\n\ + hi DevnullStats guifg=#a9b1d6\n\ + hi DevnullLink gui=underline guifg=#7aa2f7\n\ + hi DevnullCode guifg=#9ece6a\n\ +", + ), + ( + "tokyonight-storm", + "\ + hi DevnullHandle guifg=#7dcfff\n\ + hi DevnullAuthor gui=bold guifg=#c0caf5\n\ + hi DevnullTime guifg=#565f89\n\ + hi DevnullHash guifg=#737aa2\n\ + hi DevnullStats guifg=#a9b1d6\n\ + hi DevnullLink gui=underline guifg=#7aa2f7\n\ + hi DevnullCode guifg=#9ece6a\n\ +", + ), + ( + "tokyonight-moon", + "\ + hi DevnullHandle guifg=#86e1fc\n\ + hi DevnullAuthor gui=bold guifg=#c8d3f5\n\ + hi DevnullTime guifg=#636da6\n\ + hi DevnullHash guifg=#737aa2\n\ + hi DevnullStats guifg=#828bb8\n\ + hi DevnullLink gui=underline guifg=#82aaff\n\ + hi DevnullCode guifg=#c3e88d\n\ +", + ), + ( + "tokyonight-day", + "\ + hi DevnullHandle guifg=#007197\n\ + hi DevnullAuthor gui=bold guifg=#3760bf\n\ + hi DevnullTime guifg=#848cb5\n\ + hi DevnullHash guifg=#68709a\n\ + hi DevnullStats guifg=#6172b0\n\ + hi DevnullLink gui=underline guifg=#2e7de9\n\ + hi DevnullCode guifg=#587539\n\ +", + ), + ( + "default", + "\ + hi DevnullHandle guifg=cyan\n\ + hi DevnullAuthor gui=bold\n\ + hi DevnullTime guifg=darkgray\n\ + hi DevnullHash guifg=darkgray\n\ + hi DevnullStats NONE\n\ + hi DevnullLink gui=underline guifg=blue\n\ + hi DevnullCode guifg=green\n\ +", + ), +]; + +/// The kit's theme with `dn`'s groups and colours. +#[must_use] +pub fn theme() -> Theme { + let mut theme = Theme::new(); + for (group, link) in GROUPS { + theme.define(group, Some(link)); + } + for (scheme, text) in OVERRIDES { + theme + .add_overrides(scheme, text) + .expect("dn's colours name dn's groups"); + } + theme +} diff --git a/crates/devnull/src/hl/mod.rs b/crates/devnull/src/hl/mod.rs deleted file mode 100644 index d857e7a..0000000 --- a/crates/devnull/src/hl/mod.rs +++ /dev/null @@ -1,503 +0,0 @@ -//! Highlight groups (D6): the names text is styled by, the colorschemes that colour them, and `:hi`. - -mod tokyonight; - -#[cfg(test)] -mod tests; - -use std::collections::HashMap; -use std::fmt::Write as _; -use std::ops::BitOr; - -use ratatui::style::{Color, Modifier, Style}; - -/// Declares [`Group`] with each variant's `:hi` name, keeping the two in one place. -macro_rules! groups { - ($($(#[$doc:meta])* $variant:ident => $name:literal,)*) => { - /// A named highlight group. The colorscheme gives each its default; `:hi` changes it by name. - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] - #[non_exhaustive] - pub enum Group { - $($(#[$doc])* $variant,)* - } - - impl Group { - /// Every group, in declaration order. - pub const ALL: &[Group] = &[$(Group::$variant,)*]; - - /// The name `:hi` knows the group by. - #[must_use] - pub fn name(self) -> &'static str { - match self { - $(Group::$variant => $name,)* - } - } - } - }; -} - -groups! { - /// Plain text; also the background of the whole screen. - #[default] - Normal => "Normal", - /// Text that is not part of the content, such as wrap markers. - NonText => "NonText", - /// The `~` filling rows past the end of a buffer. - EndOfBuffer => "EndOfBuffer", - /// The statusline of the current window. - StatusLine => "StatusLine", - /// The statuslines of the other windows. - StatusLineNC => "StatusLineNC", - /// The mode in the statusline, in Normal mode. - StatusLineModeNormal => "StatusLineModeNormal", - /// The mode in the statusline, in Visual mode. - StatusLineModeVisual => "StatusLineModeVisual", - /// The mode in the statusline, on the command line. - StatusLineModeCommand => "StatusLineModeCommand", - /// The current buffer in the bufferline. - TabLineSel => "TabLineSel", - /// The other buffers in the bufferline. - TabLine => "TabLine", - /// The rest of the bufferline. - TabLineFill => "TabLineFill", - /// The column between side-by-side windows. - WinSeparator => "WinSeparator", - /// Error messages. - ErrorMsg => "ErrorMsg", - /// Warnings, such as a search wrapping around. - WarningMsg => "WarningMsg", - /// "Press ENTER" prompts. - MoreMsg => "MoreMsg", - /// The Visual mode selection. - Visual => "Visual", - /// The summary row of a closed fold. - Folded => "Folded", - /// The background of popups. - NormalFloat => "NormalFloat", - /// The border of popups. - FloatBorder => "FloatBorder", - /// A key in the which-key popup. - WhichKey => "WhichKey", - /// The arrow between a key and what it does. - WhichKeySeparator => "WhichKeySeparator", - /// What a key does, in the which-key popup. - WhichKeyDesc => "WhichKeyDesc", - /// A key that leads to more keys, in the which-key popup. - WhichKeyGroup => "WhichKeyGroup", - /// Matches of the last search, while `'hlsearch'` shows them. - Search => "Search", - /// The match a search being typed would go to, with `'incsearch'`. - IncSearch => "IncSearch", - /// The match of the last search under the cursor. - CurSearch => "CurSearch", - /// A popup menu, such as the completions of the command line. - Pmenu => "Pmenu", - /// The chosen item of a popup menu. - PmenuSel => "PmenuSel", - /// What matched the query in a picker's list. - PmenuMatch => "PmenuMatch", - /// A user or organization handle. - Handle => "DevnullHandle", - /// A commit author's name. - Author => "DevnullAuthor", - /// When a commit was made. - Time => "DevnullTime", - /// A commit's short hash. - Hash => "DevnullHash", - /// A commit's counts: stars, replies. - Stats => "DevnullStats", - /// A link in a commit. - Link => "DevnullLink", - /// Inline code in a commit. - Code => "DevnullCode", -} - -impl Group { - /// The group `:hi` calls `name`, ignoring case as Vim does. - #[must_use] - pub fn named(name: &str) -> Option { - Self::ALL - .iter() - .copied() - .find(|group| group.name().eq_ignore_ascii_case(name)) - } -} - -/// Text attributes, as `:hi … gui=` lists them. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct Attrs(u8); - -impl Attrs { - /// None. - pub const NONE: Self = Self(0); - /// `bold`. - pub const BOLD: Self = Self(1); - /// `italic`. - pub const ITALIC: Self = Self(1 << 1); - /// `underline`. - pub const UNDERLINE: Self = Self(1 << 2); - /// `undercurl`, drawn as an underline. - pub const UNDERCURL: Self = Self(1 << 3); - /// `strikethrough`. - pub const STRIKETHROUGH: Self = Self(1 << 4); - /// `reverse` (also `inverse`). - pub const REVERSE: Self = Self(1 << 5); - - const NAMED: [(&str, Self); 6] = [ - ("bold", Self::BOLD), - ("italic", Self::ITALIC), - ("underline", Self::UNDERLINE), - ("undercurl", Self::UNDERCURL), - ("strikethrough", Self::STRIKETHROUGH), - ("reverse", Self::REVERSE), - ]; - - fn contains(self, other: Self) -> bool { - self.0 & other.0 == other.0 - } -} - -impl BitOr for Attrs { - type Output = Self; - - fn bitor(self, other: Self) -> Self { - Self(self.0 | other.0) - } -} - -/// How a group looks, as `:hi` sets it: `None` leaves a colour to the terminal (`NONE`). -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct Spec { - /// `guifg`. - pub fg: Option, - /// `guibg`. - pub bg: Option, - /// `guisp`: the underline colour. - pub sp: Option, - /// `gui`. - pub attrs: Attrs, -} - -impl Spec { - fn style(self) -> Style { - let mut style = Style::new(); - style.fg = self.fg; - style.bg = self.bg; - style.underline_color = self.sp; - let modifiers = [ - (Attrs::BOLD, Modifier::BOLD), - (Attrs::ITALIC, Modifier::ITALIC), - (Attrs::UNDERLINE, Modifier::UNDERLINED), - (Attrs::UNDERCURL, Modifier::UNDERLINED), - (Attrs::STRIKETHROUGH, Modifier::CROSSED_OUT), - (Attrs::REVERSE, Modifier::REVERSED), - ]; - for (attr, modifier) in modifiers { - if self.attrs.contains(attr) { - style = style.add_modifier(modifier); - } - } - style - } - - /// `:hi`'s description of it, in Vim's order: `gui=… guifg=… guibg=… guisp=…`. - fn describe(self) -> String { - let mut text = String::new(); - let attrs: Vec<&str> = Attrs::NAMED - .iter() - .filter(|(_, attr)| self.attrs.contains(*attr)) - .map(|(name, _)| *name) - .collect(); - if !attrs.is_empty() { - let _ = write!(text, " gui={}", attrs.join(",")); - } - for (key, color) in [("guifg", self.fg), ("guibg", self.bg), ("guisp", self.sp)] { - if let Some(color) = color { - let _ = write!(text, " {key}={}", color_name(color)); - } - } - if text.is_empty() { - " cleared".to_owned() - } else { - text - } - } -} - -/// The names of the colorschemes `:colorscheme` knows. -pub fn colorscheme_names() -> impl Iterator { - COLORSCHEMES.iter().map(|&(name, _)| name) -} - -/// The colorschemes `:colorscheme` knows, by name. -const COLORSCHEMES: &[(&str, &[(Group, Spec)])] = &[ - ("tokyonight-night", tokyonight::NIGHT), - ("tokyonight", tokyonight::NIGHT), - ("tokyonight-storm", tokyonight::STORM), - ("tokyonight-moon", tokyonight::MOON), - ("tokyonight-day", tokyonight::DAY), - ("default", DEFAULT), -]; - -/// `default`: terminal colours only, for those who want `dn` in their terminal's own theme. -const DEFAULT: &[(Group, Spec)] = &[ - (Group::NonText, fg(Color::Blue)), - (Group::EndOfBuffer, fg(Color::Blue)), - (Group::StatusLine, attrs(Attrs::REVERSE)), - ( - Group::StatusLineNC, - Spec { - fg: Some(Color::DarkGray), - attrs: Attrs::REVERSE, - ..EMPTY - }, - ), - (Group::StatusLineModeNormal, attrs(Attrs::BOLD)), - (Group::StatusLineModeVisual, attrs(Attrs::BOLD)), - (Group::StatusLineModeCommand, attrs(Attrs::BOLD)), - (Group::TabLineSel, attrs(Attrs::BOLD)), - (Group::TabLineFill, fg(Color::DarkGray)), - (Group::TabLine, fg(Color::DarkGray)), - (Group::WinSeparator, fg(Color::DarkGray)), - (Group::ErrorMsg, fg(Color::Red)), - (Group::WarningMsg, fg(Color::Yellow)), - (Group::MoreMsg, fg(Color::Green)), - (Group::Visual, attrs(Attrs::REVERSE)), - (Group::Search, attrs(Attrs::REVERSE)), - (Group::IncSearch, attrs(Attrs::REVERSE)), - (Group::CurSearch, attrs(Attrs::REVERSE)), - (Group::Pmenu, bg(Color::DarkGray)), - (Group::PmenuSel, attrs(Attrs::REVERSE)), - (Group::PmenuMatch, attrs(Attrs::BOLD)), - (Group::Folded, fg(Color::Cyan)), - (Group::FloatBorder, fg(Color::DarkGray)), - (Group::WhichKey, fg(Color::Cyan)), - (Group::WhichKeySeparator, fg(Color::DarkGray)), - (Group::WhichKeyGroup, fg(Color::Magenta)), - (Group::Handle, fg(Color::Cyan)), - (Group::Author, attrs(Attrs::BOLD)), - (Group::Time, fg(Color::DarkGray)), - (Group::Hash, fg(Color::DarkGray)), - ( - Group::Link, - Spec { - fg: Some(Color::Blue), - attrs: Attrs::UNDERLINE, - ..EMPTY - }, - ), - (Group::Code, fg(Color::Green)), -]; - -const EMPTY: Spec = Spec { - fg: None, - bg: None, - sp: None, - attrs: Attrs::NONE, -}; - -const fn fg(color: Color) -> Spec { - Spec { - fg: Some(color), - ..EMPTY - } -} - -const fn bg(color: Color) -> Spec { - Spec { - bg: Some(color), - ..EMPTY - } -} - -const fn attrs(attrs: Attrs) -> Spec { - Spec { attrs, ..EMPTY } -} - -/// The active colorscheme, with whatever `:hi` changed since it was loaded. -#[derive(Debug, Clone)] -pub struct Theme { - name: &'static str, - specs: HashMap, - /// Groups drawn as another (`:hi link`). - links: HashMap, -} - -impl Default for Theme { - /// tokyonight-night (D6). - fn default() -> Self { - Self::named("tokyonight-night").expect("tokyonight-night is built in") - } -} - -impl Theme { - /// `:colorscheme {name}`. - /// - /// # Errors - /// - /// Vim's E185 when there is no colorscheme by that name. - pub fn named(name: &str) -> Result { - let &(name, specs) = COLORSCHEMES - .iter() - .find(|(known, _)| *known == name) - .ok_or_else(|| format!("E185: Cannot find color scheme '{name}'"))?; - Ok(Self { - name, - specs: specs.iter().copied().collect(), - links: HashMap::new(), - }) - } - - /// The colorscheme's name, as `:colorscheme` without arguments shows it. - #[must_use] - pub fn name(&self) -> &str { - self.name - } - - /// How `group` is drawn. - #[must_use] - pub fn style(&self, group: Group) -> Style { - self.specs - .get(&self.linked(group)) - .copied() - .unwrap_or_default() - .style() - } - - /// The group `group` is drawn as, following links; a loop of links ends where it started to repeat. - fn linked(&self, group: Group) -> Group { - let mut seen = vec![group]; - let mut current = group; - while let Some(&next) = self.links.get(¤t) { - if seen.contains(&next) { - break; - } - seen.push(next); - current = next; - } - current - } - - /// `:hi link {from} {to}`: draws `from` as `to`, or as itself again for `None`. - /// - /// # Errors - /// - /// Vim's E411 for an unknown group. - pub fn link(&mut self, from: &str, to: Option<&str>) -> Result<(), String> { - let named = |name: &str| { - Group::named(name).ok_or_else(|| format!("E411: Highlight group not found: {name}")) - }; - let from = named(from)?; - match to.map(named).transpose()? { - Some(to) => self.links.insert(from, to), - None => self.links.remove(&from), - }; - Ok(()) - } - - /// `:hi clear`: every group back to the colorscheme's definition. - pub fn clear(&mut self) { - if let Ok(theme) = Self::named(self.name) { - *self = theme; - } - } - - /// `:hi {group} {key}={value} …`: changes only the attributes given; with none, describes the group. - /// - /// # Errors - /// - /// Vim's error for an unknown group (E411), key (E423), colour (E254) or attribute (E418). Nothing changes - /// when any argument is wrong. - pub fn highlight( - &mut self, - group: &str, - attrs: &[(String, String)], - ) -> Result, String> { - let group = Group::named(group) - .ok_or_else(|| format!("E411: Highlight group not found: {group}"))?; - let mut spec = self.specs.get(&group).copied().unwrap_or_default(); - if attrs.is_empty() { - let shown = match self.links.get(&group) { - Some(to) => format!(" links to {}", to.name()), - None => spec.describe(), - }; - return Ok(Some(format!("{:<15}xxx{shown}", group.name()))); - } - for (key, value) in attrs { - match key.to_ascii_lowercase().as_str() { - "guifg" | "fg" => spec.fg = color(value)?, - "guibg" | "bg" => spec.bg = color(value)?, - "guisp" | "sp" => spec.sp = color(value)?, - "gui" => spec.attrs = gui(value)?, - // Terminal-only keys from vimrcs written for 256 colours: `dn` draws in true colour. - "term" | "cterm" | "ctermfg" | "ctermbg" | "ctermul" | "start" | "stop" => {} - _ => return Err(format!("E423: Illegal argument: {key}={value}")), - } - } - self.specs.insert(group, spec); - // As in Vim, highlighting a linked group unlinks it. - self.links.remove(&group); - Ok(None) - } -} - -/// A `:hi` colour: `#rrggbb`, one of the terminal's named colours, or `NONE`. -fn color(value: &str) -> Result, String> { - let named = [ - ("black", Color::Black), - ("red", Color::Red), - ("green", Color::Green), - ("yellow", Color::Yellow), - ("blue", Color::Blue), - ("magenta", Color::Magenta), - ("cyan", Color::Cyan), - ("gray", Color::Gray), - ("grey", Color::Gray), - ("darkgray", Color::DarkGray), - ("darkgrey", Color::DarkGray), - ("white", Color::White), - ]; - if value.eq_ignore_ascii_case("none") { - return Ok(None); - } - if let Some(&(_, color)) = named - .iter() - .find(|(name, _)| value.eq_ignore_ascii_case(name)) - { - return Ok(Some(color)); - } - let hex = value.strip_prefix('#').filter(|hex| hex.len() == 6); - let rgb = hex.and_then(|hex| u32::from_str_radix(hex, 16).ok()); - match rgb { - Some(rgb) => Ok(Some(Color::from_u32(rgb))), - None => Err(format!("E254: Cannot allocate color {value}")), - } -} - -/// A `gui=` value: attribute names separated by commas, or `NONE`. -fn gui(value: &str) -> Result { - value.split(',').try_fold(Attrs::NONE, |attrs, name| { - let name = name.to_ascii_lowercase(); - if name == "none" { - return Ok(attrs); - } - let name = if name == "inverse" { - "reverse".to_owned() - } else { - name - }; - Attrs::NAMED - .iter() - .find(|(known, _)| *known == name) - .map(|(_, attr)| attrs | *attr) - .ok_or_else(|| format!("E418: Illegal value: {name}")) - }) -} - -/// How `:hi` writes a colour back. -fn color_name(color: Color) -> String { - match color { - Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"), - other => format!("{other:?}"), - } -} diff --git a/crates/devnull/src/hl/tests.rs b/crates/devnull/src/hl/tests.rs deleted file mode 100644 index 2ee7cac..0000000 --- a/crates/devnull/src/hl/tests.rs +++ /dev/null @@ -1,150 +0,0 @@ -//! Highlight groups, colorschemes and `:hi`, through the public `Theme` interface. - -use ratatui::style::{Color, Modifier}; - -use super::*; - -fn hi(theme: &mut Theme, line: &str) -> Result, String> { - let mut words = line.split_whitespace(); - let group = words.next().unwrap_or_default(); - let attrs: Vec<(String, String)> = words - .map(|word| { - let (key, value) = word.split_once('=').unwrap_or((word, "")); - (key.to_owned(), value.to_owned()) - }) - .collect(); - theme.highlight(group, &attrs) -} - -#[test] -fn the_default_theme_is_tokyonight_night() { - let theme = Theme::default(); - - assert_eq!(theme.name(), "tokyonight-night"); - let normal = theme.style(Group::Normal); - assert_eq!(normal.fg, Some(Color::Rgb(0xc0, 0xca, 0xf5))); - assert_eq!(normal.bg, None, "the terminal's own"); -} - -#[test] -fn tokyonight_leaves_the_background_to_the_terminal_as_its_transparent_option_does() { - for name in [ - "tokyonight-night", - "tokyonight-storm", - "tokyonight-moon", - "tokyonight-day", - ] { - let theme = Theme::named(name).expect(name); - - assert_eq!(theme.style(Group::Normal).bg, None, "{name}"); - assert_eq!(theme.style(Group::TabLineFill).bg, None, "{name}"); - assert!( - theme.style(Group::NormalFloat).bg.is_some(), - "{name}: floats keep theirs" - ); - } -} - -#[test] -fn colorschemes_are_found_by_name_and_tokyonight_means_night() { - let day = Theme::named("tokyonight-day").expect("day exists"); - assert_ne!( - day.style(Group::Normal).fg, - Theme::default().style(Group::Normal).fg - ); - assert_eq!( - Theme::named("tokyonight").map(|t| t.style(Group::Normal)), - Ok(Theme::default().style(Group::Normal)) - ); - assert!(Theme::named("default").is_ok()); - assert_eq!( - Theme::named("solarized").err(), - Some("E185: Cannot find color scheme 'solarized'".to_owned()) - ); -} - -#[test] -fn hi_sets_colors_and_attributes_keeping_the_rest() { - let mut theme = Theme::default(); - let before = theme.style(Group::StatusLine); - - assert_eq!( - hi(&mut theme, "statusline guifg=#ff0000 gui=bold,italic"), - Ok(None) - ); - - let after = theme.style(Group::StatusLine); - assert_eq!(after.fg, Some(Color::Rgb(0xff, 0, 0))); - assert_eq!(after.bg, before.bg); - assert!( - after - .add_modifier - .contains(Modifier::BOLD | Modifier::ITALIC) - ); - assert_eq!(hi(&mut theme, "StatusLine bg=NONE ctermbg=0"), Ok(None)); - assert_eq!(theme.style(Group::StatusLine).bg, None); -} - -#[test] -fn hi_with_only_a_group_shows_it() { - let mut theme = Theme::default(); - let _ = hi(&mut theme, "Visual gui=reverse"); - - assert_eq!( - hi(&mut theme, "Visual"), - Ok(Some( - "Visual xxx gui=reverse guibg=#283457".to_owned() - )) - ); - assert_eq!( - hi(&mut theme, "DevnullHandle"), - Ok(Some("DevnullHandle xxx guifg=#7dcfff".to_owned())) - ); -} - -#[test] -fn hi_reports_vim_errors() { - let mut theme = Theme::default(); - - assert_eq!( - hi(&mut theme, "Nope guifg=#fff000"), - Err("E411: Highlight group not found: Nope".to_owned()) - ); - assert_eq!( - hi(&mut theme, "Normal guifg=#zzz"), - Err("E254: Cannot allocate color #zzz".to_owned()) - ); - assert_eq!( - hi(&mut theme, "Normal frob=1"), - Err("E423: Illegal argument: frob=1".to_owned()) - ); - assert_eq!( - hi(&mut theme, "Normal gui=wobbly"), - Err("E418: Illegal value: wobbly".to_owned()) - ); -} - -#[test] -fn hi_clear_goes_back_to_the_colorscheme() { - let mut theme = Theme::named("tokyonight-storm").expect("storm exists"); - let original = theme.style(Group::Visual); - let _ = hi(&mut theme, "Visual guibg=#000000"); - - theme.clear(); - - assert_eq!(theme.style(Group::Visual), original); - assert_eq!(theme.name(), "tokyonight-storm"); -} - -#[test] -fn named_terminal_colors_are_accepted() { - let mut theme = Theme::named("default").expect("default exists"); - - assert_eq!( - hi(&mut theme, "ErrorMsg guifg=Red guibg=darkgray"), - Ok(None) - ); - - assert_eq!(theme.style(Group::ErrorMsg).fg, Some(Color::Red)); - assert_eq!(theme.style(Group::ErrorMsg).bg, Some(Color::DarkGray)); -} diff --git a/crates/devnull/src/hl/tokyonight.rs b/crates/devnull/src/hl/tokyonight.rs deleted file mode 100644 index 1fd0c47..0000000 --- a/crates/devnull/src/hl/tokyonight.rs +++ /dev/null @@ -1,1320 +0,0 @@ -//! The tokyonight colorschemes, generated from [folke/tokyonight.nvim](https://github.com/folke/tokyonight.nvim) -//! (Apache-2.0, Copyright Folke Lemaitre) `extras/lua/tokyonight_*.lua` at commit `cdc07ac78467a233fd62c493de29a17e0cf2b2b6`, as with its -//! `transparent = true`: the background is the terminal's own. -//! Regenerate rather than edit by hand. - -use ratatui::style::Color; - -use super::{Attrs, Group, Spec}; - -/// tokyonight-night. -pub(super) const NIGHT: &[(Group, Spec)] = &[ - ( - Group::Normal, - Spec { - fg: Some(Color::Rgb(0xc0, 0xca, 0xf5)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::NonText, - Spec { - fg: Some(Color::Rgb(0x54, 0x5c, 0x7e)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::EndOfBuffer, - Spec { - fg: Some(Color::Rgb(0x1a, 0x1b, 0x26)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::StatusLine, - Spec { - fg: Some(Color::Rgb(0xa9, 0xb1, 0xd6)), - bg: Some(Color::Rgb(0x16, 0x16, 0x1e)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::StatusLineNC, - Spec { - fg: Some(Color::Rgb(0x3b, 0x42, 0x61)), - bg: Some(Color::Rgb(0x16, 0x16, 0x1e)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::TabLineSel, - Spec { - fg: Some(Color::Rgb(0x15, 0x16, 0x1e)), - bg: Some(Color::Rgb(0x7a, 0xa2, 0xf7)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::TabLineFill, - Spec { - fg: None, - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::TabLine, - Spec { - fg: Some(Color::Rgb(0x3b, 0x42, 0x61)), - bg: Some(Color::Rgb(0x16, 0x16, 0x1e)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WinSeparator, - Spec { - fg: Some(Color::Rgb(0x15, 0x16, 0x1e)), - bg: None, - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::ErrorMsg, - Spec { - fg: Some(Color::Rgb(0xdb, 0x4b, 0x4b)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WarningMsg, - Spec { - fg: Some(Color::Rgb(0xe0, 0xaf, 0x68)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::MoreMsg, - Spec { - fg: Some(Color::Rgb(0x7a, 0xa2, 0xf7)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Visual, - Spec { - fg: None, - bg: Some(Color::Rgb(0x28, 0x34, 0x57)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Folded, - Spec { - fg: Some(Color::Rgb(0x7a, 0xa2, 0xf7)), - bg: Some(Color::Rgb(0x3b, 0x42, 0x61)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::NormalFloat, - Spec { - fg: Some(Color::Rgb(0xc0, 0xca, 0xf5)), - bg: Some(Color::Rgb(0x16, 0x16, 0x1e)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::FloatBorder, - Spec { - fg: Some(Color::Rgb(0x27, 0xa1, 0xb9)), - bg: Some(Color::Rgb(0x16, 0x16, 0x1e)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WhichKey, - Spec { - fg: Some(Color::Rgb(0x7d, 0xcf, 0xff)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WhichKeySeparator, - Spec { - fg: Some(Color::Rgb(0x56, 0x5f, 0x89)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WhichKeyDesc, - Spec { - fg: Some(Color::Rgb(0xbb, 0x9a, 0xf7)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WhichKeyGroup, - Spec { - fg: Some(Color::Rgb(0x7a, 0xa2, 0xf7)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Search, - Spec { - fg: Some(Color::Rgb(0xc0, 0xca, 0xf5)), - bg: Some(Color::Rgb(0x3d, 0x59, 0xa1)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::IncSearch, - Spec { - fg: Some(Color::Rgb(0x15, 0x16, 0x1e)), - bg: Some(Color::Rgb(0xff, 0x9e, 0x64)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::CurSearch, - Spec { - fg: Some(Color::Rgb(0x15, 0x16, 0x1e)), - bg: Some(Color::Rgb(0xff, 0x9e, 0x64)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Pmenu, - Spec { - fg: Some(Color::Rgb(0xc0, 0xca, 0xf5)), - bg: Some(Color::Rgb(0x16, 0x16, 0x1e)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::PmenuSel, - Spec { - fg: None, - bg: Some(Color::Rgb(0x34, 0x3a, 0x55)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::PmenuMatch, - Spec { - fg: Some(Color::Rgb(0x2a, 0xc3, 0xde)), - bg: Some(Color::Rgb(0x16, 0x16, 0x1e)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::StatusLineModeNormal, - Spec { - fg: Some(Color::Rgb(0x15, 0x16, 0x1e)), - bg: Some(Color::Rgb(0x7a, 0xa2, 0xf7)), - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::StatusLineModeVisual, - Spec { - fg: Some(Color::Rgb(0x15, 0x16, 0x1e)), - bg: Some(Color::Rgb(0xbb, 0x9a, 0xf7)), - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::StatusLineModeCommand, - Spec { - fg: Some(Color::Rgb(0x15, 0x16, 0x1e)), - bg: Some(Color::Rgb(0xe0, 0xaf, 0x68)), - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::Handle, - Spec { - fg: Some(Color::Rgb(0x7d, 0xcf, 0xff)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Author, - Spec { - fg: Some(Color::Rgb(0xc0, 0xca, 0xf5)), - bg: None, - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::Time, - Spec { - fg: Some(Color::Rgb(0x56, 0x5f, 0x89)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Hash, - Spec { - fg: Some(Color::Rgb(0x73, 0x7a, 0xa2)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Stats, - Spec { - fg: Some(Color::Rgb(0xa9, 0xb1, 0xd6)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Link, - Spec { - fg: Some(Color::Rgb(0x7a, 0xa2, 0xf7)), - bg: None, - sp: None, - attrs: Attrs::UNDERLINE, - }, - ), - ( - Group::Code, - Spec { - fg: Some(Color::Rgb(0x9e, 0xce, 0x6a)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), -]; - -/// tokyonight-storm. -pub(super) const STORM: &[(Group, Spec)] = &[ - ( - Group::Normal, - Spec { - fg: Some(Color::Rgb(0xc0, 0xca, 0xf5)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::NonText, - Spec { - fg: Some(Color::Rgb(0x54, 0x5c, 0x7e)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::EndOfBuffer, - Spec { - fg: Some(Color::Rgb(0x24, 0x28, 0x3b)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::StatusLine, - Spec { - fg: Some(Color::Rgb(0xa9, 0xb1, 0xd6)), - bg: Some(Color::Rgb(0x1f, 0x23, 0x35)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::StatusLineNC, - Spec { - fg: Some(Color::Rgb(0x3b, 0x42, 0x61)), - bg: Some(Color::Rgb(0x1f, 0x23, 0x35)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::TabLineSel, - Spec { - fg: Some(Color::Rgb(0x1d, 0x20, 0x2f)), - bg: Some(Color::Rgb(0x7a, 0xa2, 0xf7)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::TabLineFill, - Spec { - fg: None, - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::TabLine, - Spec { - fg: Some(Color::Rgb(0x3b, 0x42, 0x61)), - bg: Some(Color::Rgb(0x1f, 0x23, 0x35)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WinSeparator, - Spec { - fg: Some(Color::Rgb(0x1d, 0x20, 0x2f)), - bg: None, - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::ErrorMsg, - Spec { - fg: Some(Color::Rgb(0xdb, 0x4b, 0x4b)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WarningMsg, - Spec { - fg: Some(Color::Rgb(0xe0, 0xaf, 0x68)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::MoreMsg, - Spec { - fg: Some(Color::Rgb(0x7a, 0xa2, 0xf7)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Visual, - Spec { - fg: None, - bg: Some(Color::Rgb(0x2e, 0x3c, 0x64)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Folded, - Spec { - fg: Some(Color::Rgb(0x7a, 0xa2, 0xf7)), - bg: Some(Color::Rgb(0x3b, 0x42, 0x61)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::NormalFloat, - Spec { - fg: Some(Color::Rgb(0xc0, 0xca, 0xf5)), - bg: Some(Color::Rgb(0x1f, 0x23, 0x35)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::FloatBorder, - Spec { - fg: Some(Color::Rgb(0x29, 0xa4, 0xbd)), - bg: Some(Color::Rgb(0x1f, 0x23, 0x35)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WhichKey, - Spec { - fg: Some(Color::Rgb(0x7d, 0xcf, 0xff)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WhichKeySeparator, - Spec { - fg: Some(Color::Rgb(0x56, 0x5f, 0x89)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WhichKeyDesc, - Spec { - fg: Some(Color::Rgb(0xbb, 0x9a, 0xf7)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WhichKeyGroup, - Spec { - fg: Some(Color::Rgb(0x7a, 0xa2, 0xf7)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Search, - Spec { - fg: Some(Color::Rgb(0xc0, 0xca, 0xf5)), - bg: Some(Color::Rgb(0x3d, 0x59, 0xa1)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::IncSearch, - Spec { - fg: Some(Color::Rgb(0x1d, 0x20, 0x2f)), - bg: Some(Color::Rgb(0xff, 0x9e, 0x64)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::CurSearch, - Spec { - fg: Some(Color::Rgb(0x1d, 0x20, 0x2f)), - bg: Some(Color::Rgb(0xff, 0x9e, 0x64)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Pmenu, - Spec { - fg: Some(Color::Rgb(0xc0, 0xca, 0xf5)), - bg: Some(Color::Rgb(0x1f, 0x23, 0x35)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::PmenuSel, - Spec { - fg: None, - bg: Some(Color::Rgb(0x36, 0x3d, 0x59)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::PmenuMatch, - Spec { - fg: Some(Color::Rgb(0x2a, 0xc3, 0xde)), - bg: Some(Color::Rgb(0x1f, 0x23, 0x35)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::StatusLineModeNormal, - Spec { - fg: Some(Color::Rgb(0x1d, 0x20, 0x2f)), - bg: Some(Color::Rgb(0x7a, 0xa2, 0xf7)), - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::StatusLineModeVisual, - Spec { - fg: Some(Color::Rgb(0x1d, 0x20, 0x2f)), - bg: Some(Color::Rgb(0xbb, 0x9a, 0xf7)), - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::StatusLineModeCommand, - Spec { - fg: Some(Color::Rgb(0x1d, 0x20, 0x2f)), - bg: Some(Color::Rgb(0xe0, 0xaf, 0x68)), - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::Handle, - Spec { - fg: Some(Color::Rgb(0x7d, 0xcf, 0xff)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Author, - Spec { - fg: Some(Color::Rgb(0xc0, 0xca, 0xf5)), - bg: None, - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::Time, - Spec { - fg: Some(Color::Rgb(0x56, 0x5f, 0x89)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Hash, - Spec { - fg: Some(Color::Rgb(0x73, 0x7a, 0xa2)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Stats, - Spec { - fg: Some(Color::Rgb(0xa9, 0xb1, 0xd6)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Link, - Spec { - fg: Some(Color::Rgb(0x7a, 0xa2, 0xf7)), - bg: None, - sp: None, - attrs: Attrs::UNDERLINE, - }, - ), - ( - Group::Code, - Spec { - fg: Some(Color::Rgb(0x9e, 0xce, 0x6a)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), -]; - -/// tokyonight-moon. -pub(super) const MOON: &[(Group, Spec)] = &[ - ( - Group::Normal, - Spec { - fg: Some(Color::Rgb(0xc8, 0xd3, 0xf5)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::NonText, - Spec { - fg: Some(Color::Rgb(0x54, 0x5c, 0x7e)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::EndOfBuffer, - Spec { - fg: Some(Color::Rgb(0x22, 0x24, 0x36)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::StatusLine, - Spec { - fg: Some(Color::Rgb(0x82, 0x8b, 0xb8)), - bg: Some(Color::Rgb(0x1e, 0x20, 0x30)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::StatusLineNC, - Spec { - fg: Some(Color::Rgb(0x3b, 0x42, 0x61)), - bg: Some(Color::Rgb(0x1e, 0x20, 0x30)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::TabLineSel, - Spec { - fg: Some(Color::Rgb(0x1b, 0x1d, 0x2b)), - bg: Some(Color::Rgb(0x82, 0xaa, 0xff)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::TabLineFill, - Spec { - fg: None, - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::TabLine, - Spec { - fg: Some(Color::Rgb(0x3b, 0x42, 0x61)), - bg: Some(Color::Rgb(0x1e, 0x20, 0x30)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WinSeparator, - Spec { - fg: Some(Color::Rgb(0x1b, 0x1d, 0x2b)), - bg: None, - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::ErrorMsg, - Spec { - fg: Some(Color::Rgb(0xc5, 0x3b, 0x53)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WarningMsg, - Spec { - fg: Some(Color::Rgb(0xff, 0xc7, 0x77)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::MoreMsg, - Spec { - fg: Some(Color::Rgb(0x82, 0xaa, 0xff)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Visual, - Spec { - fg: None, - bg: Some(Color::Rgb(0x2d, 0x3f, 0x76)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Folded, - Spec { - fg: Some(Color::Rgb(0x82, 0xaa, 0xff)), - bg: Some(Color::Rgb(0x3b, 0x42, 0x61)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::NormalFloat, - Spec { - fg: Some(Color::Rgb(0xc8, 0xd3, 0xf5)), - bg: Some(Color::Rgb(0x1e, 0x20, 0x30)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::FloatBorder, - Spec { - fg: Some(Color::Rgb(0x58, 0x9e, 0xd7)), - bg: Some(Color::Rgb(0x1e, 0x20, 0x30)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WhichKey, - Spec { - fg: Some(Color::Rgb(0x86, 0xe1, 0xfc)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WhichKeySeparator, - Spec { - fg: Some(Color::Rgb(0x63, 0x6d, 0xa6)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WhichKeyDesc, - Spec { - fg: Some(Color::Rgb(0xc0, 0x99, 0xff)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WhichKeyGroup, - Spec { - fg: Some(Color::Rgb(0x82, 0xaa, 0xff)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Search, - Spec { - fg: Some(Color::Rgb(0xc8, 0xd3, 0xf5)), - bg: Some(Color::Rgb(0x3e, 0x68, 0xd7)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::IncSearch, - Spec { - fg: Some(Color::Rgb(0x1b, 0x1d, 0x2b)), - bg: Some(Color::Rgb(0xff, 0x96, 0x6c)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::CurSearch, - Spec { - fg: Some(Color::Rgb(0x1b, 0x1d, 0x2b)), - bg: Some(Color::Rgb(0xff, 0x96, 0x6c)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Pmenu, - Spec { - fg: Some(Color::Rgb(0xc8, 0xd3, 0xf5)), - bg: Some(Color::Rgb(0x1e, 0x20, 0x30)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::PmenuSel, - Spec { - fg: None, - bg: Some(Color::Rgb(0x36, 0x3c, 0x58)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::PmenuMatch, - Spec { - fg: Some(Color::Rgb(0x65, 0xbc, 0xff)), - bg: Some(Color::Rgb(0x1e, 0x20, 0x30)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::StatusLineModeNormal, - Spec { - fg: Some(Color::Rgb(0x1b, 0x1d, 0x2b)), - bg: Some(Color::Rgb(0x82, 0xaa, 0xff)), - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::StatusLineModeVisual, - Spec { - fg: Some(Color::Rgb(0x1b, 0x1d, 0x2b)), - bg: Some(Color::Rgb(0xc0, 0x99, 0xff)), - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::StatusLineModeCommand, - Spec { - fg: Some(Color::Rgb(0x1b, 0x1d, 0x2b)), - bg: Some(Color::Rgb(0xff, 0xc7, 0x77)), - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::Handle, - Spec { - fg: Some(Color::Rgb(0x86, 0xe1, 0xfc)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Author, - Spec { - fg: Some(Color::Rgb(0xc8, 0xd3, 0xf5)), - bg: None, - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::Time, - Spec { - fg: Some(Color::Rgb(0x63, 0x6d, 0xa6)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Hash, - Spec { - fg: Some(Color::Rgb(0x73, 0x7a, 0xa2)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Stats, - Spec { - fg: Some(Color::Rgb(0x82, 0x8b, 0xb8)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Link, - Spec { - fg: Some(Color::Rgb(0x82, 0xaa, 0xff)), - bg: None, - sp: None, - attrs: Attrs::UNDERLINE, - }, - ), - ( - Group::Code, - Spec { - fg: Some(Color::Rgb(0xc3, 0xe8, 0x8d)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), -]; - -/// tokyonight-day. -pub(super) const DAY: &[(Group, Spec)] = &[ - ( - Group::Normal, - Spec { - fg: Some(Color::Rgb(0x37, 0x60, 0xbf)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::NonText, - Spec { - fg: Some(Color::Rgb(0x89, 0x90, 0xb3)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::EndOfBuffer, - Spec { - fg: Some(Color::Rgb(0xe1, 0xe2, 0xe7)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::StatusLine, - Spec { - fg: Some(Color::Rgb(0x61, 0x72, 0xb0)), - bg: Some(Color::Rgb(0xd0, 0xd5, 0xe3)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::StatusLineNC, - Spec { - fg: Some(Color::Rgb(0xa8, 0xae, 0xcb)), - bg: Some(Color::Rgb(0xd0, 0xd5, 0xe3)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::TabLineSel, - Spec { - fg: Some(Color::Rgb(0xb4, 0xb5, 0xb9)), - bg: Some(Color::Rgb(0x2e, 0x7d, 0xe9)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::TabLineFill, - Spec { - fg: None, - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::TabLine, - Spec { - fg: Some(Color::Rgb(0xa8, 0xae, 0xcb)), - bg: Some(Color::Rgb(0xd0, 0xd5, 0xe3)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WinSeparator, - Spec { - fg: Some(Color::Rgb(0xb4, 0xb5, 0xb9)), - bg: None, - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::ErrorMsg, - Spec { - fg: Some(Color::Rgb(0xc6, 0x43, 0x43)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WarningMsg, - Spec { - fg: Some(Color::Rgb(0x8c, 0x6c, 0x3e)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::MoreMsg, - Spec { - fg: Some(Color::Rgb(0x2e, 0x7d, 0xe9)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Visual, - Spec { - fg: None, - bg: Some(Color::Rgb(0xb7, 0xc1, 0xe3)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Folded, - Spec { - fg: Some(Color::Rgb(0x2e, 0x7d, 0xe9)), - bg: Some(Color::Rgb(0xa8, 0xae, 0xcb)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::NormalFloat, - Spec { - fg: Some(Color::Rgb(0x37, 0x60, 0xbf)), - bg: Some(Color::Rgb(0xd0, 0xd5, 0xe3)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::FloatBorder, - Spec { - fg: Some(Color::Rgb(0x40, 0x94, 0xa3)), - bg: Some(Color::Rgb(0xd0, 0xd5, 0xe3)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WhichKey, - Spec { - fg: Some(Color::Rgb(0x00, 0x71, 0x97)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WhichKeySeparator, - Spec { - fg: Some(Color::Rgb(0x84, 0x8c, 0xb5)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WhichKeyDesc, - Spec { - fg: Some(Color::Rgb(0x98, 0x54, 0xf1)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::WhichKeyGroup, - Spec { - fg: Some(Color::Rgb(0x2e, 0x7d, 0xe9)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Search, - Spec { - fg: Some(Color::Rgb(0x37, 0x60, 0xbf)), - bg: Some(Color::Rgb(0x78, 0x90, 0xdd)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::IncSearch, - Spec { - fg: Some(Color::Rgb(0xb4, 0xb5, 0xb9)), - bg: Some(Color::Rgb(0xb1, 0x5c, 0x00)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::CurSearch, - Spec { - fg: Some(Color::Rgb(0xb4, 0xb5, 0xb9)), - bg: Some(Color::Rgb(0xb1, 0x5c, 0x00)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Pmenu, - Spec { - fg: Some(Color::Rgb(0x37, 0x60, 0xbf)), - bg: Some(Color::Rgb(0xd0, 0xd5, 0xe3)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::PmenuSel, - Spec { - fg: None, - bg: Some(Color::Rgb(0xb3, 0xb8, 0xd1)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::PmenuMatch, - Spec { - fg: Some(Color::Rgb(0x18, 0x80, 0x92)), - bg: Some(Color::Rgb(0xd0, 0xd5, 0xe3)), - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::StatusLineModeNormal, - Spec { - fg: Some(Color::Rgb(0xb4, 0xb5, 0xb9)), - bg: Some(Color::Rgb(0x2e, 0x7d, 0xe9)), - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::StatusLineModeVisual, - Spec { - fg: Some(Color::Rgb(0xb4, 0xb5, 0xb9)), - bg: Some(Color::Rgb(0x98, 0x54, 0xf1)), - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::StatusLineModeCommand, - Spec { - fg: Some(Color::Rgb(0xb4, 0xb5, 0xb9)), - bg: Some(Color::Rgb(0x8c, 0x6c, 0x3e)), - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::Handle, - Spec { - fg: Some(Color::Rgb(0x00, 0x71, 0x97)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Author, - Spec { - fg: Some(Color::Rgb(0x37, 0x60, 0xbf)), - bg: None, - sp: None, - attrs: Attrs::BOLD, - }, - ), - ( - Group::Time, - Spec { - fg: Some(Color::Rgb(0x84, 0x8c, 0xb5)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Hash, - Spec { - fg: Some(Color::Rgb(0x68, 0x70, 0x9a)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Stats, - Spec { - fg: Some(Color::Rgb(0x61, 0x72, 0xb0)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), - ( - Group::Link, - Spec { - fg: Some(Color::Rgb(0x2e, 0x7d, 0xe9)), - bg: None, - sp: None, - attrs: Attrs::UNDERLINE, - }, - ), - ( - Group::Code, - Spec { - fg: Some(Color::Rgb(0x58, 0x75, 0x39)), - bg: None, - sp: None, - attrs: Attrs::NONE, - }, - ), -]; diff --git a/crates/devnull/src/key.rs b/crates/devnull/src/key.rs deleted file mode 100644 index 35a4dba..0000000 --- a/crates/devnull/src/key.rs +++ /dev/null @@ -1,461 +0,0 @@ -//! Keys as Vim names them: `q`, ``, ``, ``. - -use std::fmt; -use std::ops::BitOr; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - -/// One key press, with the modifiers Vim distinguishes. -/// -/// Shift is folded into printable characters (`Q`, `?`), and control letters are lowercase (`` is ``), -/// so each key has one value. The kitty keyboard protocol, when the terminal speaks it, keeps `` apart from -/// ``. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct Key { - /// Which key. - pub code: Code, - /// The modifiers held with it. - pub mods: Mods, -} - -/// The key itself, without modifiers. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Code { - /// A printable character. - Char(char), - /// ``. - Enter, - /// ``. - Esc, - /// ``; `` with [`Mods::SHIFT`]. - Tab, - /// ``. - Backspace, - /// ``. - Delete, - /// ``. - Insert, - /// ``. - Up, - /// ``. - Down, - /// ``. - Left, - /// ``. - Right, - /// ``. - Home, - /// ``. - End, - /// ``. - PageUp, - /// ``. - PageDown, - /// `` to `` and beyond. - F(u8), -} - -/// A set of modifier keys. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] -pub struct Mods(u8); - -impl Mods { - /// No modifier. - pub const NONE: Self = Self(0); - /// Shift, kept only on keys that are not printable characters. - pub const SHIFT: Self = Self(1); - /// Control. - pub const CTRL: Self = Self(2); - /// Alt, which Vim calls Meta (``). - pub const ALT: Self = Self(4); - - /// Whether every modifier in `other` is held. - #[must_use] - pub fn contains(self, other: Self) -> bool { - self.0 & other.0 == other.0 - } - - #[must_use] - fn without(self, other: Self) -> Self { - Self(self.0 & !other.0) - } -} - -impl BitOr for Mods { - type Output = Self; - - fn bitor(self, other: Self) -> Self { - Self(self.0 | other.0) - } -} - -impl Key { - /// `code` with no modifier. - #[must_use] - pub fn new(code: Code) -> Self { - Self { - code, - mods: Mods::NONE, - } - } - - /// A printable character with no modifier. - #[must_use] - pub fn char(ch: char) -> Self { - Self::new(Code::Char(ch)) - } - - /// ``. - #[must_use] - pub fn ctrl(ch: char) -> Self { - Self { - code: Code::Char(ch.to_ascii_lowercase()), - mods: Mods::CTRL, - } - } -} - -impl fmt::Display for Key { - /// Vim notation: a plain printable character as itself, anything else in angle brackets (``, ``, - /// ``). - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = match self.code { - Code::Char(' ') => "Space".to_owned(), - Code::Char('<') => "lt".to_owned(), - Code::Char('|') => "Bar".to_owned(), - Code::Char('\\') => "Bslash".to_owned(), - Code::Char(ch) if self.mods == Mods::NONE => return write!(f, "{ch}"), - Code::Char(ch) => ch.to_string(), - Code::Enter => "CR".to_owned(), - Code::Esc => "Esc".to_owned(), - Code::Tab => "Tab".to_owned(), - Code::Backspace => "BS".to_owned(), - Code::Delete => "Del".to_owned(), - Code::Insert => "Insert".to_owned(), - Code::Up => "Up".to_owned(), - Code::Down => "Down".to_owned(), - Code::Left => "Left".to_owned(), - Code::Right => "Right".to_owned(), - Code::Home => "Home".to_owned(), - Code::End => "End".to_owned(), - Code::PageUp => "PageUp".to_owned(), - Code::PageDown => "PageDown".to_owned(), - Code::F(n) => format!("F{n}"), - }; - f.write_str("<")?; - for (modifier, prefix) in [(Mods::CTRL, "C-"), (Mods::ALT, "M-"), (Mods::SHIFT, "S-")] { - if self.mods.contains(modifier) { - f.write_str(prefix)?; - } - } - write!(f, "{name}>") - } -} - -/// Parses Vim key notation (`:h key-notation`) such as `gg`, `j` or `n`, with `` standing for -/// `leader`. Names are case-insensitive; anything that is not a key name, like `` or an unclosed ` Vec { - let mut keys = Vec::new(); - let mut rest = notation; - while let Some(ch) = rest.chars().next() { - if ch == '<' - && let Some(end) = rest.find('>') - && let Some(key) = named_key(&rest[1..end], leader) - { - keys.push(key); - rest = &rest[end + 1..]; - continue; - } - keys.push(Key::char(ch)); - rest = &rest[ch.len_utf8()..]; - } - keys -} - -/// The key a `<...>` name stands for, such as `C-w`, `CR` or `leader`. -fn named_key(name: &str, leader: Key) -> Option { - let mut mods = Mods::NONE; - let mut base = name; - while let [prefix, b'-', _, ..] = base.as_bytes() { - mods = mods - | match prefix.to_ascii_lowercase() { - b'c' => Mods::CTRL, - b'm' | b'a' => Mods::ALT, - b's' => Mods::SHIFT, - _ => break, - }; - base = &base[2..]; - } - let code = match base.to_ascii_lowercase().as_str() { - "leader" if mods == Mods::NONE => return Some(leader), - "cr" | "enter" | "return" => Code::Enter, - "esc" => Code::Esc, - "tab" => Code::Tab, - "bs" | "backspace" => Code::Backspace, - "del" | "delete" => Code::Delete, - "insert" | "ins" => Code::Insert, - "up" => Code::Up, - "down" => Code::Down, - "left" => Code::Left, - "right" => Code::Right, - "home" => Code::Home, - "end" => Code::End, - "pageup" => Code::PageUp, - "pagedown" => Code::PageDown, - "space" => Code::Char(' '), - "lt" => Code::Char('<'), - "bar" => Code::Char('|'), - "bslash" => Code::Char('\\'), - lower => match lower.strip_prefix('f').and_then(|n| n.parse::().ok()) { - Some(n) if (1..=37).contains(&n) => Code::F(n), - _ => { - let mut chars = base.chars(); - match (chars.next(), chars.next()) { - (Some(ch), None) if mods != Mods::NONE => Code::Char(ch), - _ => return None, - } - } - }, - }; - Some(normalized(code, mods)) -} - -/// `code` with `mods`, in the one form [`Key`] keeps: shift folded into printable characters, control letters -/// lowercase. -fn normalized(code: Code, mods: Mods) -> Key { - match code { - Code::Char(ch) if mods.contains(Mods::CTRL) => Key { - code: Code::Char(ch.to_ascii_lowercase()), - mods: mods.without(Mods::SHIFT), - }, - Code::Char(ch) if mods.contains(Mods::SHIFT) => Key { - code: Code::Char(ch.to_ascii_uppercase()), - mods: mods.without(Mods::SHIFT), - }, - code => Key { code, mods }, - } -} - -/// A key press that Vim has no name for, such as a media key or a bare modifier. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UnnamedKey(KeyCode); - -impl fmt::Display for UnnamedKey { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "no Vim name for key {:?}", self.0) - } -} - -impl std::error::Error for UnnamedKey {} - -impl TryFrom for Key { - type Error = UnnamedKey; - - fn try_from(event: KeyEvent) -> Result { - let mut mods = Mods::NONE; - for (held, modifier) in [ - (KeyModifiers::SHIFT, Mods::SHIFT), - (KeyModifiers::CONTROL, Mods::CTRL), - (KeyModifiers::ALT, Mods::ALT), - ] { - if event.modifiers.contains(held) { - mods = mods | modifier; - } - } - let code = match event.code { - // The terminal already applied shift to the character (`?`, `Q`); only control needs normalizing. - KeyCode::Char(ch) => return Ok(normalized(Code::Char(ch), mods.without(Mods::SHIFT))), - KeyCode::BackTab => { - mods = mods | Mods::SHIFT; - Code::Tab - } - KeyCode::Tab => Code::Tab, - KeyCode::Enter => Code::Enter, - KeyCode::Esc => Code::Esc, - KeyCode::Backspace => Code::Backspace, - KeyCode::Delete => Code::Delete, - KeyCode::Insert => Code::Insert, - KeyCode::Up => Code::Up, - KeyCode::Down => Code::Down, - KeyCode::Left => Code::Left, - KeyCode::Right => Code::Right, - KeyCode::Home => Code::Home, - KeyCode::End => Code::End, - KeyCode::PageUp => Code::PageUp, - KeyCode::PageDown => Code::PageDown, - KeyCode::F(n) => Code::F(n), - other => return Err(UnnamedKey(other)), - }; - Ok(Self { code, mods }) - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - use super::*; - - fn press(code: KeyCode, modifiers: KeyModifiers) -> Key { - Key::try_from(KeyEvent::new(code, modifiers)).expect("a key Vim has a name for") - } - - #[test] - fn printable_characters_keep_their_case_and_drop_shift() { - assert_eq!( - press(KeyCode::Char('q'), KeyModifiers::NONE), - Key::char('q') - ); - assert_eq!( - press(KeyCode::Char('Q'), KeyModifiers::SHIFT), - Key::char('Q') - ); - assert_eq!( - press(KeyCode::Char('?'), KeyModifiers::SHIFT), - Key::char('?') - ); - } - - #[test] - fn control_and_alt_are_kept_and_letters_with_control_are_lowercase() { - assert_eq!( - press(KeyCode::Char('w'), KeyModifiers::CONTROL), - Key::ctrl('w') - ); - assert_eq!( - press( - KeyCode::Char('W'), - KeyModifiers::CONTROL | KeyModifiers::SHIFT - ), - Key::ctrl('w') - ); - assert_eq!( - press(KeyCode::Char('x'), KeyModifiers::ALT), - Key { - code: Code::Char('x'), - mods: Mods::ALT - } - ); - } - - #[test] - fn special_keys_map_by_name_and_back_tab_is_shift_tab() { - assert_eq!( - press(KeyCode::Enter, KeyModifiers::NONE), - Key::new(Code::Enter) - ); - assert_eq!(press(KeyCode::Esc, KeyModifiers::NONE), Key::new(Code::Esc)); - assert_eq!( - press(KeyCode::BackTab, KeyModifiers::SHIFT), - Key { - code: Code::Tab, - mods: Mods::SHIFT - } - ); - assert_eq!(press(KeyCode::Tab, KeyModifiers::CONTROL).code, Code::Tab); - } - - #[test] - fn keys_vim_has_no_name_for_are_rejected() { - assert!(Key::try_from(KeyEvent::new(KeyCode::CapsLock, KeyModifiers::NONE)).is_err()); - } - - fn keys(notation: &str) -> Vec { - parse_keys(notation, Key::char(' ')) - } - - #[test] - fn plain_characters_are_keys_of_their_own() { - assert_eq!(keys("gg"), [Key::char('g'), Key::char('g')]); - } - - #[test] - fn special_key_names_are_case_insensitive() { - assert_eq!( - keys(""), - [ - Key::new(Code::Enter), - Key::new(Code::Esc), - Key::new(Code::Tab), - Key::new(Code::Backspace), - Key::new(Code::Enter), - Key::new(Code::Up), - Key::new(Code::F(2)), - ] - ); - } - - #[test] - fn modifiers_combine_and_normalize() { - assert_eq!(keys("j"), [Key::ctrl('w'), Key::char('j')]); - assert_eq!(keys(""), keys("")); - let alt_x = Key { - code: Code::Char('x'), - mods: Mods::ALT, - }; - assert_eq!(keys(""), [alt_x, alt_x]); - assert_eq!( - keys(""), - [Key { - code: Code::Tab, - mods: Mods::SHIFT - }] - ); - assert_eq!( - keys(""), - [Key { - code: Code::Tab, - mods: Mods::CTRL | Mods::SHIFT - }] - ); - assert_eq!(keys(""), [Key::char('A')]); - } - - #[test] - fn named_characters_and_the_leader() { - assert_eq!( - keys(""), - [ - Key::char(' '), - Key::char('<'), - Key::char('|'), - Key::char('\\') - ] - ); - assert_eq!( - parse_keys("n", Key::char(',')), - [Key::char(','), Key::char('n')] - ); - assert_eq!(keys(""), [Key::char(' '), Key::char(' ')]); - } - - #[test] - fn anything_that_is_not_a_key_name_is_taken_literally() { - assert_eq!( - keys(""), - "".chars().map(Key::char).collect::>() - ); - assert_eq!( - keys(">() - ); - assert_eq!(keys("a<"), [Key::char('a'), Key::char('<')]); - } - - #[test] - fn keys_print_back_in_vim_notation() { - let printed: Vec = keys("jQ") - .iter() - .map(ToString::to_string) - .collect(); - - assert_eq!( - printed, - [ - "", "j", "", "", "", "", "", "Q", "" - ] - ); - } -} diff --git a/crates/devnull/src/keymap.rs b/crates/devnull/src/keymap.rs index 9339e85..80833f7 100644 --- a/crates/devnull/src/keymap.rs +++ b/crates/devnull/src/keymap.rs @@ -1,1273 +1,52 @@ -//! Normal- and Visual-mode key bindings (D5): built-ins, user maps, counts and pending prefixes. -//! -//! Application commands are bound by name, as ex commands (`s` runs `:star`), so every key has an ex form and -//! `:map` can reach anything a key does. - -use std::borrow::Cow; -use std::collections::{HashMap, HashSet}; - -use crate::doc::{Direction, FoldOp, Motion, SelectionKind}; -use crate::key::{Code, Key, Mods, parse_keys}; - -/// Which key table applies. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Mode { - /// Normal mode. - Normal, - /// Visual mode, charwise or rowwise. - Visual, -} - -/// What a key sequence does. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum Action { - /// Moves the cursor. - Motion(Motion), - /// Opens the command line: `:` for ex commands, `/` and `?` for search. - Cmdline(CmdlineKind), - /// `n`, or `N` (`reverse`): the last search again. - SearchNext { - /// Whether to go the other way than the last search. - reverse: bool, - }, - /// `*` and `#`: searches for the word under the cursor. - SearchWord(Direction), - /// `v` and `V`: starts Visual mode, or switches or leaves it. - Visual(SelectionKind), - /// `y` in Visual mode, `yy` in Normal mode (the cursor's row). - Yank, - /// `` in Visual mode. - Escape, - /// `zo`, `zO`, `zc`, `zC`, `za`, `zA`. - Fold(FoldOp), - /// `zR` (`open`) and `zM`. - FoldAll { - /// Whether every fold opens rather than closes. - open: bool, - }, - /// Scrolls the window, moving the cursor only as far as it must to stay in view. - Scroll(Scroll), - /// `zt`, `zz`, `zb`: scrolls so the cursor's row is at the top, middle or bottom of the window. - Place(Place), - /// `H`, `M`, `L`: the cursor to the top, middle or bottom row of the window (`count` rows in for `H` and `L`). - ViewRow(Place), - /// ``: back in the jumplist. - JumpOlder, - /// ``: forward in the jumplist. - JumpNewer, - /// An ex command line, as typed after `:`, such as `star`. - Ex(String), - /// The rest of a map's keys after one that changed mode (`:`, `v`, ``, …), to be fed to the new mode - /// with [`Keymap::replay`]. - Keys { - /// The keys, in order. - keys: Vec, - /// Whether user maps apply to them. - remap: bool, - }, - /// A key sequence that cannot run, such as a recursive mapping; the message says why. - Error(String), -} - -/// How far a scroll goes, and which way. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Scroll { - /// ``: one row down the buffer. - LineDown, - /// ``: one row up. - LineUp, - /// ``: half a window down, cursor along. - HalfDown, - /// ``: half a window up, cursor along. - HalfUp, - /// ``: a window down, keeping two rows. - PageDown, - /// ``: a window up, keeping two rows. - PageUp, -} - -/// A place in the window: its top, middle or bottom row. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Place { - /// The top row. - Top, - /// The middle row. - Middle, - /// The bottom row. - Bottom, -} - -/// What the command line is for. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CmdlineKind { - /// `:`. - Ex, - /// `/` and `?`. - Search(Direction), -} - -/// One action to run, with the count typed before it, if any. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Invocation { - /// What to do. - pub action: Action, - /// The count typed before the keys; `None` when none was. - pub count: Option, -} - -/// `f t F T`: builds the motion once the character is typed. -type CharMotion = fn(char) -> Motion; - -/// What a bound sequence leads to. -#[derive(Debug, Clone)] -enum Target { - Action(Action), - /// `f t F T`: a motion completed by the next key. - Char(CharMotion), - /// `gg` and `G` (`last`): the row given by the count, or the first or last one without. - Row { - last: bool, - }, - /// `;` and `,` (`reverse`): the last `f t F T` again. - RepeatFind { - reverse: bool, - }, - /// A user map: keys fed again, through user maps only when `remap`. - Keys { - rhs: Vec, - remap: bool, - }, -} - -#[derive(Debug, Clone)] -struct Binding { - target: Target, - /// What which-key and `:help` show for it. - description: Cow<'static, str>, - /// Its keys as written, `` and all. - written: Cow<'static, str>, - /// What a built-in is about; `None` for a user map. - topic: Option, - /// Where it was bound among its table's bindings, which [`Table::bind`] says. - order: usize, -} - -/// What a built-in binding is about, as `:help` groups them, in its order. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[non_exhaustive] -pub enum Topic { - /// Moving the cursor. - Moving, - /// Scrolling the window. - Scrolling, - /// Searching, and the command line. - Searching, - /// Selecting and yanking. - Yanking, - /// Folds. - Folding, - /// The jumplist. - Jumping, - /// Opening what is under the cursor, and coming back. - Opening, - /// Writing commits and acting on them. - Committing, - /// Buffers. - Buffers, - /// Windows. - Windows, -} - -impl Topic { - /// Its heading. - #[must_use] - pub fn title(self) -> &'static str { - match self { - Self::Moving => "Moving", - Self::Scrolling => "Scrolling", - Self::Searching => "Searching and commands", - Self::Yanking => "Selecting and yanking", - Self::Folding => "Folds", - Self::Jumping => "Jumps", - Self::Opening => "Opening", - Self::Committing => "Commits", - Self::Buffers => "Buffers", - Self::Windows => "Windows", - } - } -} - -/// A binding as `:help` lists it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Listed { - /// Its keys as written, `` and all. - pub keys: String, - /// What it does; for a user map, the keys it stands for. - pub description: String, - /// What a built-in is about; `None` for a user map. - pub topic: Option, - /// The ex command it runs, without the `:`, if it runs one. - pub command: Option, -} - -/// One mode's bindings, with every proper prefix of their key sequences, so a prefix is known to be worth waiting -/// on. -#[derive(Debug, Default)] -struct Table { - bindings: HashMap, Binding>, - prefixes: HashSet>, - /// How many bindings were bound. - bound: usize, -} - -impl Table { - /// Binds `keys` as `binding` says, numbering it after those bound before. - fn bind(&mut self, keys: Vec, binding: Binding) { - for end in 1..keys.len() { - self.prefixes.insert(keys[..end].to_vec()); - } - self.bindings.insert( - keys, - Binding { - order: self.bound, - ..binding - }, - ); - self.bound += 1; - } - - /// Its bindings as `:help` lists them, in the order they were bound. - fn listed(&self) -> Vec { - let mut bindings: Vec<&Binding> = self.bindings.values().collect(); - bindings.sort_by_key(|binding| binding.order); - bindings - .into_iter() - .map(|binding| Listed { - keys: binding.written.clone().into_owned(), - description: binding.description.clone().into_owned(), - topic: binding.topic, - command: match &binding.target { - Target::Action(Action::Ex(command)) => Some(command.clone()), - _ => None, - }, - }) - .collect() - } -} - -/// Keys typed so far that have not run yet. -#[derive(Debug, Default)] -struct Typed { - keys: Vec, - count: Option, - /// A find motion waiting for its character. - awaiting_char: Option, - /// Whether `keys` are a complete binding that is also the prefix of a longer one, so a timeout runs them. - ambiguous: bool, -} - -impl Typed { - fn is_empty(&self) -> bool { - self.keys.is_empty() && self.count.is_none() && self.awaiting_char.is_none() - } -} - -/// A key that may follow the pending ones, for which-key. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Continuation { - /// The next key, in Vim notation. - pub keys: String, - /// What it does, or `+prefix` when it only leads to more bindings. - pub description: String, -} - -/// How deep user maps may expand into each other before Vim's "E223: recursive mapping". -const MAX_MAP_DEPTH: usize = 100; - -/// The key bindings of Normal and Visual mode, and the keys typed toward one. -#[derive(Debug)] -pub struct Keymap { - leader: Key, - builtin: HashMap, - user: HashMap, - typed: Typed, - last_find: Option, -} - -impl Default for Keymap { - fn default() -> Self { - Self::new() - } -} - -impl Keymap { - /// The default bindings, with `` as ``. - #[must_use] - pub fn new() -> Self { - let leader = Key::char(' '); - Self { - leader, - builtin: builtins(leader), - user: HashMap::new(), - typed: Typed::default(), - last_find: None, - } - } - - /// Feeds one typed key in `mode`, returning what is ready to run: nothing while a sequence is still pending or - /// when the keys are bound to nothing, several actions when a user map expands to them. - pub fn feed(&mut self, mode: Mode, key: Key) -> Vec { - let mut typed = std::mem::take(&mut self.typed); - let ran = self.step(&mut typed, mode, key, true, 0); - self.typed = typed; - ran - } - - /// Feeds the keys of an [`Action::Keys`] in `mode`, through user maps only when `remap`. - pub fn replay(&mut self, mode: Mode, keys: &[Key], remap: bool) -> Vec { - let mut typed = Typed::default(); - self.expand(&mut typed, mode, keys, remap, 0) - } - - /// Whether keys typed so far wait for more. - #[must_use] - pub fn is_pending(&self) -> bool { - !self.typed.is_empty() - } - - /// The keys typed toward a binding so far, without the count. - #[must_use] - pub fn pending_keys(&self) -> &[Key] { - &self.typed.keys - } - - /// Whether the pending keys are a complete binding that is also the start of a longer one: `timeoutlen` after - /// the last key, [`Keymap::timeout`] runs them. - #[must_use] - pub fn is_ambiguous(&self) -> bool { - self.typed.ambiguous - } - - /// Runs the pending keys after `timeoutlen` when they are [ambiguous](Keymap::is_ambiguous). Anything else keeps - /// waiting, as Vim does for its own multi-key commands. - pub fn timeout(&mut self, mode: Mode) -> Vec { - if !self.typed.ambiguous { - return Vec::new(); - } - let mut typed = std::mem::take(&mut self.typed); - let target = self - .lookup(mode, &typed.keys, true) - .map(|binding| binding.target.clone()); - let ran = target.map_or_else(Vec::new, |target| self.run(&mut typed, mode, target, 0)); - self.typed = typed; - ran - } - - /// Forgets keys typed so far, as when the mode changes under them. - pub fn reset(&mut self) { - self.typed = Typed::default(); - } - - /// `:map` and friends: binds `lhs` to the keys `rhs` (both in Vim notation, `` read now) in `mode`. - /// With `remap`, `rhs` goes through user maps too (`:nmap`), otherwise only through built-ins (`:nnoremap`). - pub fn map(&mut self, mode: Mode, lhs: &str, rhs: &str, remap: bool) { - let target = Target::Keys { - rhs: parse_keys(rhs, self.leader), - remap, - }; - let binding = Binding { - target, - description: rhs.to_owned().into(), - written: lhs.to_owned().into(), - topic: None, - order: 0, - }; - self.user - .entry(mode) - .or_default() - .bind(parse_keys(lhs, self.leader), binding); - } - - /// The bindings of `mode`, as `:help` lists them: the built-ins by topic, each in the order they were bound, - /// then the user maps in the order they were made. - #[must_use] - pub fn listing(&self, mode: Mode) -> Vec { - let mut builtins = self - .builtin - .get(&mode) - .map(Table::listed) - .unwrap_or_default(); - // Stable: within a topic, they stay as bound. - builtins.sort_by_key(|listed| listed.topic); - let users = self.user.get(&mode).map(Table::listed).unwrap_or_default(); - builtins.into_iter().chain(users).collect() - } - - /// Removes the user map of exactly `lhs` (Vim notation) from `mode`, returning whether there was one. - pub fn unmap(&mut self, mode: Mode, lhs: &str) -> bool { - let lhs = parse_keys(lhs, self.leader); - let Some(table) = self.user.get_mut(&mode) else { - return false; - }; - if table.bindings.remove(&lhs).is_none() { - return false; - } - table.prefixes = table - .bindings - .keys() - .flat_map(|keys| (1..keys.len()).map(|end| keys[..end].to_vec())) - .collect(); - true - } - - /// The key `` stands for. - #[must_use] - pub fn leader(&self) -> Key { - self.leader - } - - /// The count typed before the pending keys, if any. - #[must_use] - pub fn pending_count(&self) -> Option { - self.typed.count - } - - /// Makes `key` the `` of built-in bindings and of maps defined from now on, as setting `mapleader` - /// before them does in Vim. Existing user maps keep the leader they were defined with. - pub fn set_leader(&mut self, key: Key) { - self.leader = key; - self.builtin = builtins(key); - } - - /// What may follow the pending keys in `mode`, sorted by key: each binding's description, or `+prefix` for keys - /// that only lead to more bindings. User maps take precedence over built-ins. - #[must_use] - pub fn continuations(&self, mode: Mode) -> Vec { - let typed = &self.typed.keys; - let mut next: HashMap> = HashMap::new(); - // Built-ins first, so user maps overwrite them. - for table in [self.builtin.get(&mode), self.user.get(&mode)] - .into_iter() - .flatten() - { - for (keys, binding) in &table.bindings { - if keys.len() <= typed.len() || !keys.starts_with(typed) { - continue; - } - let key = keys[typed.len()]; - if keys.len() == typed.len() + 1 { - next.insert(key, binding.description.clone()); - } else { - next.entry(key).or_insert(Cow::Borrowed("+prefix")); - } - } - } - let mut next: Vec = next - .into_iter() - .map(|(key, description)| Continuation { - keys: key.to_string(), - description: description.into_owned(), - }) - .collect(); - next.sort_by(|a, b| a.keys.cmp(&b.keys)); - next - } - - /// The binding of exactly `keys` in `mode`, a user map before a built-in. - fn lookup(&self, mode: Mode, keys: &[Key], allow_user: bool) -> Option<&Binding> { - let user = self.user.get(&mode).filter(|_| allow_user); - user.and_then(|table| table.bindings.get(keys)) - .or_else(|| self.builtin[&mode].bindings.get(keys)) - } - - /// Whether `keys` start a longer binding in `mode`. - fn is_prefix(&self, mode: Mode, keys: &[Key], allow_user: bool) -> bool { - let user = self.user.get(&mode).filter(|_| allow_user); - user.is_some_and(|table| table.prefixes.contains(keys)) - || self.builtin[&mode].prefixes.contains(keys) - } - - fn step( - &mut self, - typed: &mut Typed, - mode: Mode, - key: Key, - allow_user: bool, - depth: usize, - ) -> Vec { - if let Some(make) = typed.awaiting_char.take() { - let count = typed.count.take(); - typed.keys.clear(); - let Key { - code: Code::Char(ch), - mods: Mods::NONE, - } = key - else { - return Vec::new(); - }; - let motion = make(ch); - self.last_find = Some(motion); - return vec![Invocation { - action: Action::Motion(motion), - count, - }]; - } - if key == Key::new(Code::Esc) && !typed.is_empty() { - *typed = Typed::default(); - return Vec::new(); - } - if typed.keys.is_empty() - && key.mods == Mods::NONE - && let Code::Char(digit @ '0'..='9') = key.code - && (digit != '0' || typed.count.is_some()) - { - let digit = digit.to_digit(10).map_or(0, |d| d as usize); - typed.count = Some( - typed - .count - .unwrap_or(0) - .saturating_mul(10) - .saturating_add(digit), - ); - return Vec::new(); - } - typed.keys.push(key); - let exact = self - .lookup(mode, &typed.keys, allow_user) - .map(|binding| binding.target.clone()); - let is_prefix = self.is_prefix(mode, &typed.keys, allow_user); - typed.ambiguous = exact.is_some() && is_prefix; - match (exact, is_prefix) { - (Some(target), false) => self.run(typed, mode, target, depth), - (_, true) => Vec::new(), - (None, false) => { - *typed = Typed::default(); - Vec::new() - } - } - } - - fn run( - &mut self, - typed: &mut Typed, - mode: Mode, - target: Target, - depth: usize, - ) -> Vec { - let count = typed.count.take(); - typed.keys.clear(); - typed.ambiguous = false; - let one = |action| vec![Invocation { action, count }]; - match target { - Target::Action(action) => one(action), - Target::Char(make) => { - typed.count = count; - typed.awaiting_char = Some(make); - Vec::new() - } - Target::Row { last } => { - let motion = match count { - Some(row) => Motion::GotoRow(row), - None if last => Motion::LastRow, - None => Motion::GotoRow(1), - }; - vec![Invocation { - action: Action::Motion(motion), - count: None, - }] - } - Target::RepeatFind { reverse } => match self.last_find { - Some(motion) => one(Action::Motion(if reverse { - reversed(motion) - } else { - motion - })), - None => Vec::new(), - }, - Target::Keys { rhs, remap } => { - if depth >= MAX_MAP_DEPTH { - return one(Action::Error("E223: recursive mapping".to_owned())); - } - let mut ran = self.expand(&mut Typed::default(), mode, &rhs, remap, depth + 1); - // As in Vim, the count typed before a map applies to the first thing it runs. - if let Some(first) = ran.first_mut() { - first.count = first.count.or(count); - } - ran - } - } - } - - /// Runs `keys` in `mode` until one of them changes mode; the rest go back as [`Action::Keys`] for the new mode. - fn expand( - &mut self, - typed: &mut Typed, - mode: Mode, - keys: &[Key], - remap: bool, - depth: usize, - ) -> Vec { - let mut ran = Vec::new(); - for (i, &key) in keys.iter().enumerate() { - ran.extend(self.step(typed, mode, key, remap, depth)); - if ran - .last() - .is_some_and(|last| changes_mode(&last.action, mode)) - { - let rest = keys[i + 1..].to_vec(); - if !rest.is_empty() { - ran.push(Invocation { - action: Action::Keys { keys: rest, remap }, - count: None, - }); - } - break; - } - } - ran - } -} - -/// Whether `action`, run in `mode`, leaves it, so keys after it belong to another mode. -fn changes_mode(action: &Action, mode: Mode) -> bool { - match action { - Action::Cmdline(_) | Action::Visual(_) => true, - Action::Escape | Action::Yank => mode == Mode::Visual, - _ => false, - } -} - -/// The `f t F T` motion going the other way, for `,`. -fn reversed(motion: Motion) -> Motion { - match motion { - Motion::FindForward(ch) => Motion::FindBackward(ch), - Motion::FindBackward(ch) => Motion::FindForward(ch), - Motion::TillForward(ch) => Motion::TillBackward(ch), - Motion::TillBackward(ch) => Motion::TillForward(ch), - other => other, - } -} - -/// The default bindings of each mode (D5), with `leader` for ``. -fn builtins(leader: Key) -> HashMap { - let bind = - |table: &mut Table, notation: &'static str, target, description: &'static str, topic| { - let binding = Binding { - target, - description: description.into(), - written: notation.into(), - topic: Some(topic), - order: 0, - }; - table.bind(parse_keys(notation, leader), binding); - }; - let mut normal = Table::default(); - let mut visual = Table::default(); - let motions: [(&str, Motion, &str); 22] = [ - ("h", Motion::Left, "left"), - ("", Motion::Left, "left"), - ("j", Motion::Down, "down"), - ("", Motion::Down, "down"), - ("k", Motion::Up, "up"), - ("", Motion::Up, "up"), - ("l", Motion::Right, "right"), - ("", Motion::Right, "right"), - ("w", Motion::WordForward, "next word"), - ("b", Motion::WordBackward, "previous word"), - ("e", Motion::WordEnd, "end of word"), - ("W", Motion::BigWordForward, "next WORD"), - ("B", Motion::BigWordBackward, "previous WORD"), - ("E", Motion::BigWordEnd, "end of WORD"), - ("0", Motion::RowStart, "start of row"), - ("^", Motion::FirstNonBlank, "first non-blank"), - ("$", Motion::RowEnd, "end of row"), - ("}", Motion::ParagraphForward, "next paragraph"), - ("{", Motion::ParagraphBackward, "previous paragraph"), - ("]]", Motion::NextBlock, "next commit"), - ("[[", Motion::PrevBlock, "previous commit"), - ("", Motion::RowStart, "start of row"), - ]; - let char_motions: [(&str, CharMotion, &str); 4] = [ - ("f", Motion::FindForward, "find character"), - ("t", Motion::TillForward, "till character"), - ("F", Motion::FindBackward, "find character backward"), - ("T", Motion::TillBackward, "till character backward"), - ]; - for table in [&mut normal, &mut visual] { - for (notation, motion, description) in motions { - let target = Target::Action(Action::Motion(motion)); - bind(table, notation, target, description, Topic::Moving); - } - let end = Target::Action(Action::Motion(Motion::RowEnd)); - bind(table, "", end, "end of row", Topic::Moving); - for (notation, make, description) in char_motions { - bind( - table, - notation, - Target::Char(make), - description, - Topic::Moving, - ); - } - let repeat = |reverse| Target::RepeatFind { reverse }; - bind(table, ";", repeat(false), "repeat find", Topic::Moving); - bind( - table, - ",", - repeat(true), - "repeat find backward", - Topic::Moving, - ); - let row = |last| Target::Row { last }; - bind( - table, - "gg", - row(false), - "first row, or row [count]", - Topic::Moving, - ); - bind( - table, - "G", - row(true), - "last row, or row [count]", - Topic::Moving, - ); - let view: [(&str, Action, &str); 12] = [ - ("", Action::Scroll(Scroll::LineDown), "scroll down"), - ("", Action::Scroll(Scroll::LineUp), "scroll up"), - ("", Action::Scroll(Scroll::HalfDown), "half page down"), - ("", Action::Scroll(Scroll::HalfUp), "half page up"), - ("", Action::Scroll(Scroll::PageDown), "page down"), - ("", Action::Scroll(Scroll::PageUp), "page up"), - ("zt", Action::Place(Place::Top), "row to top"), - ("zz", Action::Place(Place::Middle), "row to middle"), - ("zb", Action::Place(Place::Bottom), "row to bottom"), - ("H", Action::ViewRow(Place::Top), "top of window"), - ("M", Action::ViewRow(Place::Middle), "middle of window"), - ("L", Action::ViewRow(Place::Bottom), "bottom of window"), - ]; - for (notation, action, description) in view { - bind( - table, - notation, - Target::Action(action), - description, - Topic::Scrolling, - ); - } - let visual = |kind| Target::Action(Action::Visual(kind)); - bind( - table, - "v", - visual(SelectionKind::Charwise), - "visual", - Topic::Yanking, - ); - bind( - table, - "V", - visual(SelectionKind::Rowwise), - "visual rows", - Topic::Yanking, - ); - } - bind( - &mut visual, - "y", - Target::Action(Action::Yank), - "yank", - Topic::Yanking, - ); - let escape = Target::Action(Action::Escape); - bind(&mut visual, "", escape, "leave visual", Topic::Yanking); - - let search = |direction| Action::Cmdline(CmdlineKind::Search(direction)); - let actions: [(&str, Action, &str, Topic); 17] = [ - ( - ":", - Action::Cmdline(CmdlineKind::Ex), - "command line", - Topic::Searching, - ), - ( - "/", - search(Direction::Forward), - "search forward", - Topic::Searching, - ), - ( - "?", - search(Direction::Backward), - "search backward", - Topic::Searching, - ), - ( - "n", - Action::SearchNext { reverse: false }, - "next match", - Topic::Searching, - ), - ( - "N", - Action::SearchNext { reverse: true }, - "previous match", - Topic::Searching, - ), - ( - "*", - Action::SearchWord(Direction::Forward), - "search word forward", - Topic::Searching, - ), - ( - "#", - Action::SearchWord(Direction::Backward), - "search word backward", - Topic::Searching, - ), - ("yy", Action::Yank, "yank row", Topic::Yanking), - ( - "zo", - Action::Fold(FoldOp::Open), - "open fold", - Topic::Folding, - ), - ( - "zO", - Action::Fold(FoldOp::OpenRecursively), - "open folds recursively", - Topic::Folding, - ), - ( - "zc", - Action::Fold(FoldOp::Close), - "close fold", - Topic::Folding, - ), - ( - "zC", - Action::Fold(FoldOp::CloseRecursively), - "close folds recursively", - Topic::Folding, - ), - ( - "za", - Action::Fold(FoldOp::Toggle), - "toggle fold", - Topic::Folding, - ), - ( - "zA", - Action::Fold(FoldOp::ToggleRecursively), - "toggle folds recursively", - Topic::Folding, - ), - ( - "zR", - Action::FoldAll { open: true }, - "open all folds", - Topic::Folding, - ), - ( - "zM", - Action::FoldAll { open: false }, - "close all folds", - Topic::Folding, - ), - ("", Action::JumpOlder, "jump back", Topic::Jumping), - ]; - for (notation, action, description, topic) in actions { - bind( - &mut normal, - notation, - Target::Action(action), - description, - topic, - ); - } - // Without the kitty keyboard protocol, terminals send as ; as in Vim, both go forward. - for notation in ["", ""] { - let newer = Target::Action(Action::JumpNewer); - bind(&mut normal, notation, newer, "jump forward", Topic::Jumping); - } - let commands: [(&str, &str, &str, Topic); 34] = [ - ("", "open", "open under cursor", Topic::Opening), - ("", "open", "open under cursor", Topic::Opening), - ("", "pop", "back from what was opened", Topic::Opening), - ("gd", "profile", "author's profile", Topic::Opening), - ("K", "hover", "hover card", Topic::Opening), - ("gx", "browse link", "open link in browser", Topic::Opening), - ("gX", "browse", "open commit on the web", Topic::Opening), - ("/", "pick users", "find user", Topic::Opening), - ("gy", "yanklink", "yank commit link", Topic::Yanking), - ("c", "commit", "new commit", Topic::Committing), - ("r", "reply", "reply", Topic::Committing), - ("R", "quote", "quote", Topic::Committing), - ("s", "star", "toggle star", Topic::Committing), - ("dd", "delete", "delete own commit", Topic::Committing), - // Terminals send as unless the kitty keyboard protocol tells them apart. - ("", "buffer #", "alternate buffer", Topic::Buffers), - ("", "buffer #", "alternate buffer", Topic::Buffers), - ("", "edit!", "refresh", Topic::Buffers), - ( - "1", - "edit log/following", - "following log", - Topic::Buffers, - ), - ("2", "edit log/latest", "latest log", Topic::Buffers), - ( - "3", - "edit log/trending", - "trending log", - Topic::Buffers, - ), - ( - "n", - "edit notifications", - "notifications", - Topic::Buffers, - ), - ("s", "edit starred", "starred", Topic::Buffers), - ("p", "edit profile", "your profile", Topic::Buffers), - ( - "", - "pick buffers", - "find buffer", - Topic::Buffers, - ), - ("q", "quit", "close window", Topic::Windows), - ("h", "wincmd h", "window left", Topic::Windows), - ("j", "wincmd j", "window below", Topic::Windows), - ("k", "wincmd k", "window above", Topic::Windows), - ("l", "wincmd l", "window right", Topic::Windows), - ("w", "wincmd w", "next window", Topic::Windows), - ("s", "split", "split", Topic::Windows), - ("v", "vsplit", "split vertically", Topic::Windows), - ("c", "close", "close window", Topic::Windows), - ("o", "only", "only this window", Topic::Windows), - ]; - for (notation, command, description, topic) in commands { - let target = Target::Action(Action::Ex(command.to_owned())); - bind(&mut normal, notation, target, description, topic); - } - HashMap::from([(Mode::Normal, normal), (Mode::Visual, visual)]) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::doc::Motion; - use crate::key::{Key, parse_keys}; - - /// Feeds `notation` in `mode` and collects every invocation it produced. - fn feed(keymap: &mut Keymap, mode: Mode, notation: &str) -> Vec { - parse_keys(notation, Key::char(' ')) - .into_iter() - .flat_map(|key| keymap.feed(mode, key)) - .collect() - } - - fn run(action: Action, count: Option) -> Invocation { - Invocation { action, count } - } - - fn motion(motion: Motion) -> Invocation { - run(Action::Motion(motion), None) - } - - #[test] - fn a_single_key_runs_its_action() { - let mut keymap = Keymap::new(); - - assert_eq!(feed(&mut keymap, Mode::Normal, "j"), [motion(Motion::Down)]); - } - - #[test] - fn digits_before_a_command_are_its_count_and_a_lone_zero_is_a_motion() { - let mut keymap = Keymap::new(); - - assert_eq!( - feed(&mut keymap, Mode::Normal, "3j"), - [run(Action::Motion(Motion::Down), Some(3))] - ); - assert_eq!( - feed(&mut keymap, Mode::Normal, "10k"), - [run(Action::Motion(Motion::Up), Some(10))] - ); - assert_eq!( - feed(&mut keymap, Mode::Normal, "0"), - [motion(Motion::RowStart)] - ); - } - - #[test] - fn a_prefix_waits_for_the_rest_of_the_sequence() { - let mut keymap = Keymap::new(); - - assert_eq!(feed(&mut keymap, Mode::Normal, "g"), []); - assert!(keymap.is_pending()); - assert_eq!(keymap.pending_keys(), [Key::char('g')]); - assert_eq!( - feed(&mut keymap, Mode::Normal, "g"), - [motion(Motion::GotoRow(1))] - ); - assert!(!keymap.is_pending()); - } - - #[test] - fn gg_and_big_g_turn_a_count_into_a_row_number() { - let mut keymap = Keymap::new(); - - assert_eq!( - feed(&mut keymap, Mode::Normal, "5gg"), - [motion(Motion::GotoRow(5))] - ); - assert_eq!( - feed(&mut keymap, Mode::Normal, "G"), - [motion(Motion::LastRow)] - ); - assert_eq!( - feed(&mut keymap, Mode::Normal, "7G"), - [motion(Motion::GotoRow(7))] - ); - } - - #[test] - fn find_motions_take_the_next_key_and_semicolon_and_comma_repeat_them() { - let mut keymap = Keymap::new(); - - assert_eq!(feed(&mut keymap, Mode::Normal, "f"), []); - assert!(keymap.is_pending()); - assert_eq!( - feed(&mut keymap, Mode::Normal, "x"), - [motion(Motion::FindForward('x'))] - ); - assert_eq!( - feed(&mut keymap, Mode::Normal, "2T."), - [run(Action::Motion(Motion::TillBackward('.')), Some(2))] - ); - assert_eq!( - feed(&mut keymap, Mode::Normal, ";"), - [motion(Motion::TillBackward('.'))] - ); - assert_eq!( - feed(&mut keymap, Mode::Normal, ","), - [motion(Motion::TillForward('.'))] - ); - } - - #[test] - fn escape_cancels_a_pending_sequence_and_its_count() { - let mut keymap = Keymap::new(); - - assert_eq!( - feed(&mut keymap, Mode::Normal, "3gj"), - [motion(Motion::Down)] - ); - assert_eq!( - feed(&mut keymap, Mode::Normal, "fj"), - [motion(Motion::Down)] - ); - } - - #[test] - fn unbound_keys_do_nothing() { - let mut keymap = Keymap::new(); - - assert_eq!(feed(&mut keymap, Mode::Normal, ""), []); - assert!(!keymap.is_pending()); - } - - fn ex(command: &str) -> Invocation { - run(Action::Ex(command.to_owned()), None) - } - - #[test] - fn a_noremap_expands_to_built_ins_only_and_its_count_goes_to_the_first_action() { - let mut keymap = Keymap::new(); - keymap.map(Mode::Normal, "x", "jw", false); - keymap.map(Mode::Normal, "w", "k", false); - - assert_eq!( - feed(&mut keymap, Mode::Normal, "x"), - [motion(Motion::Down), motion(Motion::WordForward)] - ); - assert_eq!( - feed(&mut keymap, Mode::Normal, "3x"), - [ - run(Action::Motion(Motion::Down), Some(3)), - motion(Motion::WordForward) - ] - ); - } - - #[test] - fn a_remap_expands_through_other_user_maps_and_recursion_is_an_error() { - let mut keymap = Keymap::new(); - keymap.map(Mode::Normal, "a", "b", true); - keymap.map(Mode::Normal, "b", "j", true); - keymap.map(Mode::Normal, "o", "o", true); - - assert_eq!(feed(&mut keymap, Mode::Normal, "a"), [motion(Motion::Down)]); - assert_eq!( - feed(&mut keymap, Mode::Normal, "o"), - [run(Action::Error("E223: recursive mapping".into()), None)] - ); - } - - #[test] - fn user_maps_shadow_built_ins_and_can_run_ex_commands() { - let mut keymap = Keymap::new(); - keymap.map(Mode::Normal, "j", "k", false); - keymap.map(Mode::Normal, "x", ":star", false); - - assert_eq!(feed(&mut keymap, Mode::Normal, "j"), [motion(Motion::Up)]); - assert_eq!( - feed(&mut keymap, Mode::Normal, "x"), - [ - run(Action::Cmdline(CmdlineKind::Ex), None), - run( - Action::Keys { - keys: parse_keys("star", Key::char(' ')), - remap: false - }, - None - ) - ] - ); - } - - #[test] - fn a_map_that_is_also_a_prefix_waits_for_more_or_runs_on_timeout() { - let mut keymap = Keymap::new(); - keymap.map(Mode::Normal, "d", "j", false); - - assert_eq!(feed(&mut keymap, Mode::Normal, "d"), []); - assert_eq!(feed(&mut keymap, Mode::Normal, "d"), [ex("delete")]); - assert_eq!(feed(&mut keymap, Mode::Normal, "2d"), []); - assert!(keymap.is_ambiguous()); - assert_eq!( - keymap.timeout(Mode::Normal), - [run(Action::Motion(Motion::Down), Some(2))] - ); - assert_eq!(keymap.timeout(Mode::Normal), []); - } - - #[test] - fn help_lists_built_ins_by_topic_as_bound_then_user_maps() { - let mut keymap = Keymap::new(); - keymap.map(Mode::Normal, "c", ":commit @acme", false); - - let listing = keymap.listing(Mode::Normal); - - let at = |keys: &str| { - listing - .iter() - .position(|listed| listed.keys == keys) - .expect(keys) - }; - assert_eq!(listing[0].keys, "h", "the first motion first"); - assert!(at("zo") < at("zO") && at("zO") < at("zc"), "as bound"); - let topics: Vec = listing.iter().filter_map(|listed| listed.topic).collect(); - assert!(topics.windows(2).all(|pair| pair[0] <= pair[1]), "by topic"); - let star = &listing[at("s")]; - assert_eq!( - ( - star.description.as_str(), - star.topic, - star.command.as_deref() - ), - ("toggle star", Some(Topic::Committing), Some("star")) - ); - assert_eq!(listing[at("1")].topic, Some(Topic::Buffers)); - let mine = listing.last().expect("a binding"); - assert_eq!( - (mine.keys.as_str(), mine.description.as_str(), mine.topic), - ("c", ":commit @acme", None) - ); - } - - #[test] - fn built_in_prefixes_wait_without_a_timeout() { - let mut keymap = Keymap::new(); - - assert_eq!(feed(&mut keymap, Mode::Normal, "z"), []); - assert!(!keymap.is_ambiguous()); - assert_eq!(keymap.timeout(Mode::Normal), []); - assert_eq!( - feed(&mut keymap, Mode::Normal, "a"), - [run(Action::Fold(FoldOp::Toggle), None)] - ); - } - - #[test] - fn which_key_lists_what_can_follow_the_pending_keys() { - let mut keymap = Keymap::new(); - keymap.map(Mode::Normal, "zz", ":center", false); - let _ = feed(&mut keymap, Mode::Normal, "z"); - - let next: Vec<(String, String)> = keymap - .continuations(Mode::Normal) - .into_iter() - .map(|c| (c.keys, c.description)) - .collect(); - - assert_eq!( - next, - [ - ("A", "toggle folds recursively"), - ("C", "close folds recursively"), - ("M", "close all folds"), - ("O", "open folds recursively"), - ("R", "open all folds"), - ("a", "toggle fold"), - ("b", "row to bottom"), - ("c", "close fold"), - ("o", "open fold"), - ("t", "row to top"), - ("z", ":center"), - ] - .map(|(k, d)| (k.to_owned(), d.to_owned())) - ); - } - - #[test] - fn the_leader_can_change_and_built_ins_follow_it() { - let mut keymap = Keymap::new(); - assert_eq!( - feed(&mut keymap, Mode::Normal, "1"), - [ex("edit log/following")] - ); - - keymap.set_leader(Key::char(',')); - - assert_eq!( - feed(&mut keymap, Mode::Normal, ",1"), - [ex("edit log/following")] - ); - assert_eq!(feed(&mut keymap, Mode::Normal, "1"), []); - } - - #[test] - fn visual_mode_has_its_own_table() { - let mut keymap = Keymap::new(); - keymap.map(Mode::Visual, "x", "y", false); - - assert_eq!( - feed(&mut keymap, Mode::Visual, "wy"), - [motion(Motion::WordForward), run(Action::Yank, None)] - ); - assert_eq!( - feed(&mut keymap, Mode::Visual, "x"), - [run(Action::Yank, None)] - ); - assert_eq!(feed(&mut keymap, Mode::Normal, "x"), []); - assert_eq!( - feed(&mut keymap, Mode::Visual, ""), - [run(Action::Escape, None)] - ); - } +//! Key bindings (D5): the kit's (its D12), and `dn`'s defaults, bound by the ex commands they run. + +pub use wcgw_kit::keymap::*; + +/// What `dn`'s own commands for commits are about, in `:help`: between opening and buffers. +pub const COMMITTING: Topic = Topic::new(75, "Commits"); + +/// `dn`'s default bindings: keys, the ex command each runs, what which-key says of it, and its `:help` topic. +const DEFAULTS: [(&str, &str, &str, Topic); 17] = [ + ("gd", "profile", "author's profile", Topic::OPENING), + ("K", "details", "details card", Topic::OPENING), + ("gx", "browse link", "open link in browser", Topic::OPENING), + ("gX", "browse", "open commit on the web", Topic::OPENING), + ("ff", "pick users", "find people", Topic::OPENING), + ("gy", "yanklink", "yank commit link", Topic::YANKING), + ("c", "commit", "new commit", COMMITTING), + ("r", "reply", "reply", COMMITTING), + ("R", "quote", "quote", COMMITTING), + ("s", "star", "toggle star", COMMITTING), + ("dd", "delete", "delete own commit", COMMITTING), + ( + "1", + "edit log/following", + "following log", + Topic::BUFFERS, + ), + ("2", "edit log/latest", "latest log", Topic::BUFFERS), + ( + "3", + "edit log/trending", + "trending log", + Topic::BUFFERS, + ), + ( + "n", + "edit notifications", + "notifications", + Topic::BUFFERS, + ), + ("s", "edit starred", "starred", Topic::BUFFERS), + ("p", "edit profile", "your profile", Topic::BUFFERS), +]; + +/// The kit's bindings and `dn`'s. +#[must_use] +pub fn keymap() -> Keymap { + let mut keymap = Keymap::new(); + for (notation, command, description, topic) in DEFAULTS { + keymap.bind(&[Mode::Normal], notation, command, description, topic); + } + keymap } diff --git a/crates/devnull/src/lib.rs b/crates/devnull/src/lib.rs index d111490..59e3e9b 100644 --- a/crates/devnull/src/lib.rs +++ b/crates/devnull/src/lib.rs @@ -4,10 +4,8 @@ pub mod app; pub mod doc; -pub mod ex; -pub mod graphics; pub mod hl; -pub mod key; +pub use wcgw_kit::key; pub mod keymap; mod net; pub mod render; diff --git a/crates/devnull/src/net.rs b/crates/devnull/src/net.rs index 2471554..c09dc1c 100644 --- a/crates/devnull/src/net.rs +++ b/crates/devnull/src/net.rs @@ -1,5 +1,5 @@ -//! The runtime's network side: the device key, the API client and device approval, each reporting back to the app -//! as [`Msg::Net`]. +//! `dn`'s network side: the device key, the API client and device approval, each reporting back to the app as +//! [`Msg::Net`]; pictures for the kit to show. use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -10,12 +10,11 @@ use devnull_api::signing::{KeyError, Signer}; use devnull_api::{ApiError, Client, ORIGIN}; use tokio::sync::mpsc; use tokio::task::JoinHandle; +use wcgw_kit::runtime::{Bytes, Inbox, Perform}; use devnull_api::media::Media; -use tokio::sync::Semaphore; -use crate::app::{FetchError, Fetched, Msg, NetEvent, Prepared, Request}; -use crate::graphics::Picture; +use crate::app::{Dn, FetchError, Fetched, Io, Msg, NetEvent, Request}; /// What the device is called on dev-null.sh's device list. const DEVICE_NAME: &str = "dn"; @@ -32,19 +31,13 @@ pub(crate) struct Net { /// While approving: where pasted callback links go, and the task waiting for the browser. approval: Option<(mpsc::UnboundedSender, JoinHandle<()>)>, /// Where results go. - later: mpsc::UnboundedSender, + later: Inbox, /// Downloads pictures. media: Option, - /// Pictures downloading at once: two, as the Go client. - picture_slots: Arc, } impl Net { - pub(crate) fn new( - key: Option, - agent: Option, - later: mpsc::UnboundedSender, - ) -> Self { + pub(crate) fn new(key: Option, agent: Option, later: Inbox) -> Self { Self { key, agent, @@ -52,7 +45,6 @@ impl Net { approval: None, later, media: Media::new(ORIGIN).ok(), - picture_slots: Arc::new(Semaphore::new(2)), } } @@ -311,39 +303,6 @@ impl Net { }); } - /// Downloads the picture at `source` and prepares it for `cols` × `rows` cells (for the kitty protocol as image - /// `kitty`, else as half blocks), answering with [`Msg::Picture`]. - pub(crate) fn picture(&self, source: String, cols: u16, rows: u16, kitty: Option) { - let (media, slots, later) = ( - self.media.clone(), - Arc::clone(&self.picture_slots), - self.later.clone(), - ); - tokio::spawn(async move { - // Never closed: a permit always comes. - let _slot = slots.acquire_owned().await; - let result = match media { - None => Err("pictures cannot be downloaded".to_owned()), - Some(media) => match media.get(&source).await { - Ok(bytes) => { - // Decoding and scaling take a while: off the runtime's thread. - tokio::task::spawn_blocking(move || prepare(&bytes, cols, rows, kitty)) - .await - .unwrap_or_else(|_| Err("cannot prepare it".to_owned())) - } - Err(error) => Err(error.to_string()), - }, - }; - // Fails only once the runtime's loop has ended, when nobody needs the picture. - let _ = later.send(Msg::Picture { - source, - cols, - rows, - result, - }); - }); - } - fn client(&self) -> Option { // Cloned out so the lock is never held across `.await`; a poisoned lock still holds a usable client. self.client @@ -354,7 +313,7 @@ impl Net { } /// Connects with `signer`: the account answers, or the reason it did not. -async fn finish(signer: Signer, slot: &Mutex>, later: &mpsc::UnboundedSender) { +async fn finish(signer: Signer, slot: &Mutex>, later: &Inbox) { let client = match Client::new(ORIGIN, signer) { Ok(client) => client, Err(error) => return report(later, NetEvent::Failed(error.to_string())), @@ -375,7 +334,7 @@ async fn approve( key: PathBuf, agent: Option, slot: Arc>>, - later: mpsc::UnboundedSender, + later: Inbox, mut pasted: mpsc::UnboundedReceiver, ) { let signer = match Signer::open(&key, agent.as_deref()) { @@ -451,16 +410,6 @@ async fn follow( } } -/// The picture in `bytes`, prepared for `cols` × `rows` cells: for the kitty protocol as image `kitty`, else as -/// half blocks. -fn prepare(bytes: &[u8], cols: u16, rows: u16, kitty: Option) -> Result { - let picture = Picture::decode(bytes).ok_or_else(|| "not a picture dn shows".to_owned())?; - Ok(match kitty { - Some(id) => Prepared::Kitty(picture.kitty(id, cols, rows)), - None => Prepared::HalfBlocks(picture.half_blocks(cols, rows)), - }) -} - fn fetch_error(error: &ApiError) -> FetchError { match error { ApiError::NotApproved => FetchError::NotApproved, @@ -475,25 +424,31 @@ fn failure(error: &ApiError) -> NetEvent { } } -fn report(later: &mpsc::UnboundedSender, event: NetEvent) { +fn report(later: &Inbox, event: NetEvent) { // Fails only once the runtime's loop has ended, when nobody needs the news. let _ = later.send(Msg::Net(event)); } -/// Opens `url` in the browser, without waiting for it. -pub(crate) fn open_url(url: &str) -> std::io::Result<()> { - let opener = if cfg!(target_os = "macos") { - "open" - } else { - "xdg-open" - }; - let mut child = std::process::Command::new(opener) - .arg(url) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn()?; - // Reaped on a thread of its own so it does not linger as a zombie. - std::thread::spawn(move || child.wait()); - Ok(()) +impl Perform for Net { + fn perform(&mut self, effect: Io) { + match effect { + Io::Connect => self.connect(), + Io::Unlock(passphrase) => self.unlock(passphrase), + Io::Enroll => self.enroll(), + Io::EnrollPasted(link) => self.paste(link), + Io::AcceptTerms(version) => self.accept_terms(version), + Io::Fetch(request) => self.fetch(request), + } + } + + /// Downloads the picture at `source`, through the media proxy. + fn picture(&mut self, source: &str) -> Bytes { + let (media, source) = (self.media.clone(), source.to_owned()); + Box::pin(async move { + match media { + None => Err("pictures cannot be downloaded".to_owned()), + Some(media) => media.get(&source).await.map_err(|error| error.to_string()), + } + }) + } } diff --git a/crates/devnull/src/render/accounts.rs b/crates/devnull/src/render/accounts.rs index 6f440fb..72e61f6 100644 --- a/crates/devnull/src/render/accounts.rs +++ b/crates/devnull/src/render/accounts.rs @@ -47,7 +47,7 @@ pub fn profile_header(profile: &Profile) -> Content { .into_iter() .flatten() .collect(); - lines.extend(joined(counts, Group::Stats)); + lines.extend(joined(counts, crate::hl::STATS)); let relationship: Vec<&str> = [ profile.follows_you.then_some("Follows you"), profile.is_following.then_some("Following"), @@ -58,7 +58,7 @@ pub fn profile_header(profile: &Profile) -> Content { if !relationship.is_empty() { lines.push(Line::Text(vec![Span::new( relationship.join(" · "), - Group::Time, + crate::hl::TIME, )])); } if let Some(streaks) = profile.streaks.filter(|streaks| streaks.longest > 0) { @@ -68,7 +68,7 @@ pub fn profile_header(profile: &Profile) -> Content { "Streak {} {days} · longest {}", streaks.current, streaks.longest ), - Group::Time, + crate::hl::TIME, )])); } lines.push(Line::text("")); @@ -105,17 +105,17 @@ pub fn organization_header(page: &OrganizationPage) -> Content { Some(format!("@{}/mentions", organization.handle)), )]) .collect(); - lines.extend(joined(counts, Group::Stats)); + lines.extend(joined(counts, crate::hl::STATS)); if !organization.admins.is_empty() { - let mut spans = vec![Span::new("Admins:", Group::Time)]; + let mut spans = vec![Span::new("Admins:", crate::hl::TIME)]; for admin in &organization.admins { - spans.push(Span::new(" ", Group::Normal)); + spans.push(Span::new(" ", Group::NORMAL)); spans.push(mention(&admin.handle, Some(AccountKind::User))); } lines.push(Line::Text(spans)); } if page.is_following { - lines.push(Line::Text(vec![Span::new("Following", Group::Time)])); + lines.push(Line::Text(vec![Span::new("Following", crate::hl::TIME)])); } lines.push(Line::text("")); Content { @@ -134,7 +134,7 @@ pub fn pinned_block(commit: &Commit, now: u64) -> Content { let mut content = commit_block(commit, now); content .lines - .insert(0, Line::Text(vec![Span::new("📌 Pinned", Group::Time)])); + .insert(0, Line::Text(vec![Span::new("📌 Pinned", crate::hl::TIME)])); for fold in &mut content.folds { fold.lines = fold.lines.start + 1..fold.lines.end + 1; } @@ -165,7 +165,7 @@ pub fn account_block(account: &Account) -> Content { if !relationship.is_empty() { spans.push(Span::new( format!(" · {}", relationship.join(" · ")), - Group::Time, + crate::hl::TIME, )); } let mut lines = vec![Line::Text(spans)]; @@ -217,11 +217,11 @@ fn details(plain: &[&str], website: &str) -> Option { let mut spans = Vec::new(); for (i, (text, target)) in parts.into_iter().enumerate() { if i > 0 { - spans.push(Span::new(" · ", Group::Time)); + spans.push(Span::new(" · ", crate::hl::TIME)); } spans.push(match target { - Some(target) => Span::new(text, Group::Link).with_target(target), - None => Span::new(text, Group::Time), + Some(target) => Span::new(text, crate::hl::LINK).with_target(target), + None => Span::new(text, crate::hl::TIME), }); } Some(Line::Text(spans)) diff --git a/crates/devnull/src/render/mod.rs b/crates/devnull/src/render/mod.rs index bcd870f..94ba17c 100644 --- a/crates/devnull/src/render/mod.rs +++ b/crates/devnull/src/render/mod.rs @@ -12,8 +12,9 @@ pub use accounts::{account_block, organization_header, pinned_block, profile_hea pub use notifications::notification_block; use crate::doc::{AccountRef, Content, Fold, Line, LinkCard, Span, Target}; -use crate::graphics::{fit, natural}; use crate::hl::Group; +use wcgw_kit::gfx::{fit, natural}; +use wcgw_kit::jiff::Timestamp; /// Lines of text shown before the rest of a long commit folds away. const SHOWN_LINES: usize = 6; @@ -53,7 +54,7 @@ pub fn focused_block(commit: &Commit, now: u64, cell: (u16, u16)) -> Content { #[must_use] pub fn deleted_block() -> Content { vec![ - Line::Text(vec![Span::new("Commit deleted", Group::Time)]), + Line::Text(vec![Span::new("Commit deleted", crate::hl::TIME)]), Line::text(""), ] .into() @@ -107,7 +108,7 @@ fn block(commit: &Commit, now: u64, shown: Shown) -> Content { description: card.description.clone(), }); lines.push(Line::Text(vec![ - Span::new(text, Group::Link).with_target(target), + Span::new(text, crate::hl::LINK).with_target(target), ])); if let Some(picture) = card.image_url.as_deref().filter(|url| !url.is_empty()) { // Its size is not known before it arrives: a box of the usual card shape. @@ -135,7 +136,7 @@ fn block(commit: &Commit, now: u64, shown: Shown) -> Content { text.push_str(&format!(": {description}")); } lines.push(Line::Text(vec![ - Span::new(text, Group::Stats).with_target(Target::Url(image.url.clone())), + Span::new(text, crate::hl::STATS).with_target(Target::Url(image.url.clone())), ])); } lines.push(counts(commit)); @@ -161,7 +162,7 @@ fn block(commit: &Commit, now: u64, shown: Shown) -> Content { "… {hidden} more {}", if hidden == 1 { "line" } else { "lines" } ), - Group::Folded, + Group::FOLDED, )]), closed: true, }); @@ -177,7 +178,7 @@ fn block(commit: &Commit, now: u64, shown: Shown) -> Content { fn header(commit: &Commit, now: u64, prefix: Option<&str>) -> Line { let mut spans = Vec::new(); if let Some(prefix) = prefix { - spans.push(Span::new(prefix, Group::Time)); + spans.push(Span::new(prefix, crate::hl::TIME)); } if let Some(author) = &commit.author { spans.extend(author_spans(author)); @@ -185,8 +186,8 @@ fn header(commit: &Commit, now: u64, prefix: Option<&str>) -> Line { let age = parse_time(&commit.created_at) .map(|then| age(then, now)) .unwrap_or_default(); - spans.push(Span::new(format!(" · {age} · "), Group::Time)); - spans.push(Span::new(commit.short_hash.clone(), Group::Hash)); + spans.push(Span::new(format!(" · {age} · "), crate::hl::TIME)); + spans.push(Span::new(commit.short_hash.clone(), crate::hl::HASH)); Line::Text(spans) } @@ -209,14 +210,14 @@ fn who(name: &str, handle: &str, kind: AccountKind, avatar: &str) -> Vec { let mut spans = Vec::new(); if !avatar.is_empty() { spans.push(Span::picture(sized(avatar, AVATAR_PIXELS), AVATAR_COLS)); - spans.push(Span::new(" ", Group::Normal)); + spans.push(Span::new(" ", Group::NORMAL)); } if !name.is_empty() { - spans.push(Span::new(name, Group::Author)); + spans.push(Span::new(name, crate::hl::AUTHOR)); if kind == AccountKind::Organization { - spans.push(Span::new(" (org)", Group::Time)); + spans.push(Span::new(" (org)", crate::hl::TIME)); } - spans.push(Span::new(" ", Group::Normal)); + spans.push(Span::new(" ", Group::NORMAL)); } spans.push(mention(handle, Some(kind))); spans @@ -250,7 +251,7 @@ fn sized(url: &str, pixels: u32) -> String { /// `@handle`, referring to the account. fn mention(handle: &str, kind: Option) -> Span { - Span::new(format!("@{handle}"), Group::Handle) + Span::new(format!("@{handle}"), crate::hl::HANDLE) .with_target(Target::Account(AccountRef::new(handle, kind))) } @@ -260,14 +261,14 @@ fn quote(quoted: &Commit, now: u64) -> Vec { let mut lines = if quoted.is_deleted() { vec![Line::Text(vec![Span::new( format!("{BAR}Commit deleted"), - Group::Time, + crate::hl::TIME, )])] } else { let mut lines = vec![header(quoted, now, Some(BAR))]; lines.extend(body_lines(quoted).into_iter().map(|line| { let Line::Text(spans) = line else { return line }; Line::Text( - std::iter::once(Span::new(BAR, Group::Time)) + std::iter::once(Span::new(BAR, crate::hl::TIME)) .chain(spans) .collect(), ) @@ -291,12 +292,12 @@ fn counts(commit: &Commit) -> Line { if !commit.status.is_empty() && commit.status != "approved" { text.push_str(&format!(" · {}", commit.status)); } - Line::Text(vec![Span::new(text, Group::Stats)]) + Line::Text(vec![Span::new(text, crate::hl::STATS)]) } /// ` ↳ Name @handle: first line`. fn reply_line(reply: &Commit) -> Line { - let mut spans = vec![Span::new(" ↳ ", Group::Time)]; + let mut spans = vec![Span::new(" ↳ ", crate::hl::TIME)]; if let Some(author) = &reply.author { spans.extend(author_spans(author)); } @@ -307,7 +308,7 @@ fn reply_line(reply: &Commit) -> Line { Line::Text(spans) => spans.into_iter().map(|span| span.text).collect(), Line::Image { .. } => String::new(), }); - spans.push(Span::new(format!(": {first}"), Group::Normal)); + spans.push(Span::new(format!(": {first}"), Group::NORMAL)); Line::Text(spans) } @@ -341,7 +342,7 @@ fn segment_lines(segments: &[Segment], mentions: &[Mention]) -> Vec { current.extend(text_spans(piece, &mentioned)); } } - Segment::Code { code } => current.push(Span::new(code.clone(), Group::Code)), + Segment::Code { code } => current.push(Span::new(code.clone(), crate::hl::CODE)), Segment::Block { language, filename, @@ -355,11 +356,11 @@ fn segment_lines(segments: &[Segment], mentions: &[Mention]) -> Vec { (Some(name), None) | (None, Some(name)) => format!("── {name} ──"), (None, None) => "──".to_owned(), }; - lines.push(Line::Text(vec![Span::new(title, Group::Time)])); + lines.push(Line::Text(vec![Span::new(title, crate::hl::TIME)])); let code = plain_text(html); lines.extend( code.lines() - .map(|line| Line::Text(vec![Span::new(line, Group::Code)])), + .map(|line| Line::Text(vec![Span::new(line, crate::hl::CODE)])), ); } // A kind of segment this version does not know: leave it out rather than show it wrong. @@ -379,7 +380,7 @@ fn text_spans(text: &str, mentioned: &[(String, AccountKind)]) -> Vec { let mut rest = text; let flush = |plain: &mut String, spans: &mut Vec| { if !plain.is_empty() { - spans.push(Span::new(std::mem::take(plain), Group::Normal)); + spans.push(Span::new(std::mem::take(plain), Group::NORMAL)); } }; while let Some(ch) = rest.chars().next() { @@ -402,7 +403,7 @@ fn text_spans(text: &str, mentioned: &[(String, AccountKind)]) -> Vec { let url = rest[..end].trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']', '\'', '"']); flush(&mut plain, &mut spans); - spans.push(Span::new(url, Group::Link).with_target(Target::Url(url.to_owned()))); + spans.push(Span::new(url, crate::hl::LINK).with_target(Target::Url(url.to_owned()))); rest = &rest[url.len()..]; continue; } @@ -458,30 +459,17 @@ fn plain_text(html: &str) -> String { text } -/// How long ago `then` was at `now` (both Unix seconds), as the web says it: `now`, `5m`, `3h`, `2d`, then the date -/// (`Oct 1`, with the year when it is not this year's). +/// How long ago `then` was at `now` (both Unix seconds), as every wcgw app says it: `now`, `5m`, `3h`, `2d`, `6w`, +/// `1y` (the kit's `when::ago`). #[must_use] pub fn age(then: u64, now: u64) -> String { - let seconds = now.saturating_sub(then); - match seconds { - 0..60 => "now".to_owned(), - 60..3600 => format!("{}m", seconds / 60), - 3600..86_400 => format!("{}h", seconds / 3600), - 86_400..604_800 => format!("{}d", seconds / 86_400), - _ => { - const MONTHS: [&str; 12] = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", - ]; - let (year, month, day) = civil(then / 86_400); - let (this_year, ..) = civil(now / 86_400); - let date = format!("{} {day}", MONTHS[usize::from(month - 1)]); - if year == this_year { - date - } else { - format!("{date}, {year}") - } - } - } + let at = |seconds: u64| { + i64::try_from(seconds) + .ok() + .and_then(|seconds| Timestamp::from_second(seconds).ok()) + .unwrap_or_default() + }; + wcgw_kit::when::ago(at(then), at(now)) } /// Unix seconds for an RFC 3339 UTC time such as `2026-11-12T15:13:40.000000Z`; `None` when it is not one. @@ -515,23 +503,3 @@ fn days_from_civil(year: u64, month: u64, day: u64) -> Option { let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; (era * 146_097 + day_of_era).checked_sub(719_468) } - -/// The date of day `days` since 1970-01-01, as (year, month, day). -fn civil(days: u64) -> (u64, u8, u8) { - let days = days + 719_468; - let era = days / 146_097; - let day_of_era = days - era * 146_097; - let year_of_era = - (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; - let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); - let month_index = (5 * day_of_year + 2) / 153; - let day = u8::try_from(day_of_year - (153 * month_index + 2) / 5 + 1).unwrap_or(1); - let month = u8::try_from(if month_index < 10 { - month_index + 3 - } else { - month_index - 9 - }) - .unwrap_or(1); - let year = year_of_era + era * 400 + u64::from(month <= 2); - (year, month, day) -} diff --git a/crates/devnull/src/render/notifications.rs b/crates/devnull/src/render/notifications.rs index 017757e..6c6f3b5 100644 --- a/crates/devnull/src/render/notifications.rs +++ b/crates/devnull/src/render/notifications.rs @@ -29,15 +29,15 @@ pub fn notification_block(notification: &Notification, new: bool, now: u64) -> C _ => ("•", "did something"), }; let mut first = vec![ - Span::new(if new { "● " } else { " " }, Group::Handle), - Span::new(format!("{icon} "), Group::Stats), + Span::new(if new { "● " } else { " " }, crate::hl::HANDLE), + Span::new(format!("{icon} "), crate::hl::STATS), ]; let moderation = matches!( kind, NotificationKind::Approved | NotificationKind::Rejected ); if moderation { - first.push(Span::new(verb, Group::Normal)); + first.push(Span::new(verb, Group::NORMAL)); } else { if let Some(actor) = ¬ification.actor { first.extend(who( @@ -50,14 +50,14 @@ pub fn notification_block(notification: &Notification, new: bool, now: u64) -> C let others = notification.actors_count.saturating_sub(1); if others > 0 { let s = if others == 1 { "" } else { "s" }; - first.push(Span::new(format!(" and {others} other{s}"), Group::Normal)); + first.push(Span::new(format!(" and {others} other{s}"), Group::NORMAL)); } - first.push(Span::new(format!(" {verb}"), Group::Normal)); + first.push(Span::new(format!(" {verb}"), Group::NORMAL)); } let age = parse_time(¬ification.created_at) .map(|then| age(then, now)) .unwrap_or_default(); - first.push(Span::new(format!(" · {age}"), Group::Time)); + first.push(Span::new(format!(" · {age}"), crate::hl::TIME)); let mut lines = vec![Line::Text(first)]; if let Some(commit) = ¬ification.commit { // The account's own commit is quoted; a reply, quote or mention is shown as written. @@ -67,14 +67,14 @@ pub fn notification_block(notification: &Notification, new: bool, now: u64) -> C if !notification.reason.is_empty() { lines.push(Line::Text(vec![Span::new( format!("{INDENT}reason: {}", notification.reason), - Group::Time, + crate::hl::TIME, )])); } let mut folds = Vec::new(); if notification.actors.len() > 1 { let start = lines.len(); for actor in ¬ification.actors { - let mut spans = vec![Span::new(INDENT, Group::Normal)]; + let mut spans = vec![Span::new(INDENT, Group::NORMAL)]; spans.extend(who( &actor.name, &actor.handle, @@ -92,7 +92,7 @@ pub fn notification_block(notification: &Notification, new: bool, now: u64) -> C lines: start..lines.len(), summary: Line::Text(vec![Span::new( format!("{INDENT}▸ {} {noun}", notification.event_count), - Group::Folded, + Group::FOLDED, )]), closed: true, }); @@ -122,13 +122,13 @@ fn excerpt(commit: &Commit, own: bool) -> Vec { }; let indent = |spans: Vec| { Line::Text( - std::iter::once(Span::new(lead.clone(), Group::Time)) + std::iter::once(Span::new(lead.clone(), crate::hl::TIME)) .chain(spans) .collect(), ) }; if commit.is_deleted() { - return vec![indent(vec![Span::new("Commit deleted", Group::Time)])]; + return vec![indent(vec![Span::new("Commit deleted", crate::hl::TIME)])]; } let body = body_lines(commit); let more = body.len() > EXCERPT_LINES; @@ -141,7 +141,7 @@ fn excerpt(commit: &Commit, own: bool) -> Vec { }) .collect(); if more { - lines.push(indent(vec![Span::new("…", Group::Time)])); + lines.push(indent(vec![Span::new("…", crate::hl::TIME)])); } lines } diff --git a/crates/devnull/src/render/tests.rs b/crates/devnull/src/render/tests.rs index dc8321a..dbe57a5 100644 --- a/crates/devnull/src/render/tests.rs +++ b/crates/devnull/src/render/tests.rs @@ -315,7 +315,7 @@ fn a_long_commit_folds_after_its_first_lines() { } #[test] -fn ages_read_like_the_web() { +fn ages_read_as_every_app_says_them() { let at = |seconds_ago: u64| age(NOW - seconds_ago, NOW); assert_eq!( @@ -324,14 +324,18 @@ fn ages_read_like_the_web() { ); assert_eq!( age(parse_time("2026-10-01T08:00:00Z").expect("time"), NOW), - "Oct 1" + "6w" ); assert_eq!( age( parse_time("2025-12-24T08:00:00.123456Z").expect("time"), NOW ), - "Dec 24, 2025" + "46w" + ); + assert_eq!( + age(parse_time("2025-10-01T08:00:00Z").expect("time"), NOW), + "1y" ); assert_eq!(parse_time("not a time"), None); } diff --git a/crates/devnull/src/runtime.rs b/crates/devnull/src/runtime.rs index 07cb728..f4675a0 100644 --- a/crates/devnull/src/runtime.rs +++ b/crates/devnull/src/runtime.rs @@ -1,28 +1,13 @@ -//! The I/O shell around [`App`]: owns the terminal, turns terminal events into [`Msg`]s and carries out -//! [`Effect`]s. +//! `dn` in the kit's runtime (its D2): the terminal, the event loop and the effects are the kit's; `dn` brings its +//! network side. -use std::io::{self, Stdout, stdout}; +use std::io; +use std::path::PathBuf; -use std::ops::{Deref, DerefMut}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use wcgw_kit::runtime; -use crossterm::clipboard::CopyToClipboard; -use crossterm::event::{ - DisableBracketedPaste, DisableFocusChange, EnableBracketedPaste, EnableFocusChange, Event, - EventStream, KeyEventKind, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, - PushKeyboardEnhancementFlags, -}; -use crossterm::execute; -use crossterm::terminal::supports_keyboard_enhancement; -use futures_util::StreamExt; -use ratatui::DefaultTerminal; -use tokio::sync::mpsc; - -use crate::app::{App, Effect, Msg, NetEvent}; -use crate::graphics::Graphics; -use crate::key::Key; -use crate::net::{Net, open_url}; +use crate::app::Dn; +use crate::net::Net; /// Runs `dn` until it quits, restoring the terminal however it ends. /// @@ -30,515 +15,12 @@ use crate::net::{Net, open_url}; /// /// If the terminal cannot be set up, read from or drawn to. pub async fn run() -> io::Result<()> { - let mut terminal = Session::start()?; - let mut app = App::new(); - let var = |name| std::env::var(name).ok(); - let graphics = Graphics::guess( - var("TERM").as_deref(), - var("TERM_PROGRAM").as_deref(), - std::env::var_os("TMUX").is_some(), - ); - // Nothing was drawn yet, so nothing to free. - let _ = app.set_graphics(graphics); - app.number_pictures_from(first_picture_id()); - measure_cells(&mut app); - let size = terminal.size()?; - let _ = app.update(Msg::Resize { - width: size.width, - height: size.height, - }); - let mut events = EventStream::new(); - // Messages that effects send back later, such as wake-ups and files read. - let (later, mut arrived) = mpsc::unbounded_channel(); - let env = |name| std::env::var_os(name).map(PathBuf::from); - let config = config_dir(env("XDG_CONFIG_HOME"), env("HOME")); - let mut net = Net::new( - config.as_ref().map(|dir| dir.join("device_ed25519")), - env("SSH_AUTH_SOCK"), - later.clone(), + let (inbox, mailbox) = runtime::channel(); + let net = Net::new( + runtime::config_dir("devnull").map(|dir| dir.join("device_ed25519")), + std::env::var_os("SSH_AUTH_SOCK").map(PathBuf::from), + inbox.clone(), ); - if let Some(init) = config.map(|dir| dir.join("init.vim")) { - match tokio::fs::read_to_string(&init).await { - Ok(contents) => { - let path = shown_path(&init, env("HOME")); - let _ = later.send(Msg::Sourced { - path, - contents: Ok(contents), - }); - } - // No init.vim is the usual case, not an error. - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => { - let path = shown_path(&init, env("HOME")); - let _ = later.send(Msg::Sourced { - path, - contents: Err(error.to_string()), - }); - } - } - } - for effect in app.start() { - if let Step::Quit = perform(effect, &later, &mut net)? { - return Ok(()); - } - } - loop { - terminal.draw(|frame| app.render(frame))?; - // Both branches are cancel-safe: an event or message not taken now stays queued. - let msg = tokio::select! { - event = events.next() => { - let Some(event) = event else { - return Ok(()); - }; - match to_msg(event?) { - Some(msg) => msg, - None => continue, - } - } - Some(msg) = arrived.recv() => msg, - }; - if let Msg::Resize { .. } = msg { - // The font may have changed size with the window. - measure_cells(&mut app); - } - for effect in app.update(msg) { - match perform(effect, &later, &mut net)? { - Step::Go => {} - Step::Quit => return Ok(()), - Step::Edit(text) => { - // The editor reads the keys until it exits: stop reading them meanwhile. - drop(events); - let saved = terminal.edit(&text)?; - events = EventStream::new(); - // Fails only once the loop has ended. - let _ = later.send(Msg::Edited(saved)); - } - } - } - } -} - -/// Tells `app` a cell's size in pixels, when the terminal reports its own in pixels (Ghostty and kitty do; some -/// multiplexers do not, and `app` keeps what it had). -fn measure_cells(app: &mut App) { - let Ok(size) = crossterm::terminal::window_size() else { - return; - }; - if size.columns > 0 && size.rows > 0 { - app.set_cell_size(size.width / size.columns, size.height / size.rows); - } -} - -/// The id of the first picture sent to the terminal: from the process and the time, so that two runs, or another -/// program, are unlikely to reuse it while the terminal still holds the first's. -fn first_picture_id() -> u32 { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |since| since.subsec_nanos()); - (std::process::id().wrapping_mul(2_654_435_761) ^ nanos) & 0x007f_ffff | 0x0001_0000 -} - -/// What the loop does after an effect. -enum Step { - Go, - Quit, - /// Hand the terminal to the editor on this text. - Edit(String), -} - -/// Carries out one effect, but for the editor, which needs the terminal. Effects that finish later send their result -/// through `later`. -fn perform(effect: Effect, later: &mpsc::UnboundedSender, net: &mut Net) -> io::Result { - match effect { - Effect::Quit => return Ok(Step::Quit), - Effect::Edit { text } => return Ok(Step::Edit(text)), - Effect::Copy(text) => execute!(stdout(), CopyToClipboard::to_clipboard_from(text))?, - // Sending below only fails once the loop has ended, when nobody needs the result any more. - Effect::Wake { after, token } => { - let later = later.clone(); - tokio::spawn(async move { - tokio::time::sleep(after).await; - let _ = later.send(Msg::Wake(token)); - }); - } - Effect::Connect => net.connect(), - Effect::Unlock(passphrase) => net.unlock(passphrase), - Effect::Enroll => net.enroll(), - Effect::EnrollPasted(link) => net.paste(link), - Effect::AcceptTerms(version) => net.accept_terms(version), - Effect::Fetch(request) => net.fetch(request), - Effect::Picture { - source, - cols, - rows, - kitty, - } => net.picture(source, cols, rows, kitty), - Effect::Graphics(escapes) => { - let mut out = stdout(); - io::Write::write_all(&mut out, escapes.as_bytes())?; - io::Write::flush(&mut out)?; - } - Effect::OpenUrl(url) => { - if let Err(error) = open_url(&url) { - let _ = later.send(Msg::Net(NetEvent::Error(format!( - "Cannot open the browser: {error}" - )))); - } - } - Effect::Source(path) => { - let later = later.clone(); - let file = expand_home(&path, std::env::var_os("HOME").map(PathBuf::from)); - tokio::spawn(async move { - let contents = tokio::fs::read_to_string(file) - .await - .map_err(|error| error.to_string()); - let _ = later.send(Msg::Sourced { path, contents }); - }); - } - } - Ok(Step::Go) -} - -/// Counts drafts opened, so each gets a file of its own. -static EDITS: AtomicUsize = AtomicUsize::new(0); - -/// The user's editor (D3): `$EDITOR`, else `$VISUAL`, else `nvim` when installed, else `vi`. -fn editor() -> String { - let set = |name| { - std::env::var(name) - .ok() - .filter(|value| !value.trim().is_empty()) - }; - set("EDITOR").or_else(|| set("VISUAL")).unwrap_or_else(|| { - let installed = std::env::var_os("PATH") - .is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join("nvim").is_file())); - if installed { "nvim" } else { "vi" }.to_owned() - }) -} - -/// Where drafts are written (D3): `$XDG_RUNTIME_DIR` (memory only, the user's alone), else the temporary directory. -fn drafts_dir() -> PathBuf { - std::env::var_os("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .filter(|dir| dir.is_absolute() && dir.is_dir()) - .unwrap_or_else(std::env::temp_dir) -} - -/// Runs `editor` (a shell command, as `$EDITOR` may hold arguments) on a file of `dir` holding `text`, readable by -/// the user alone and removed after; what it saved, or why there is nothing: it could not run, or exited with an -/// error (`:cq`). -fn run_editor(editor: &str, dir: &Path, text: &str) -> Result { - let path = dir.join(format!( - "dn-draft-{}-{}.md", - std::process::id(), - EDITS.fetch_add(1, Ordering::SeqCst) - )); - let mut options = std::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); - let written = options - .open(&path) - .and_then(|mut file| io::Write::write_all(&mut file, text.as_bytes())); - if let Err(error) = written { - let _ = std::fs::remove_file(&path); - return Err(format!("cannot write the draft: {error}")); - } - let status = std::process::Command::new("sh") - .arg("-c") - .arg(format!("{editor} \"$1\"")) - .arg("dn") - .arg(&path) - .status(); - let saved = std::fs::read_to_string(&path); - // The draft stays in the app's memory, not on disk. - let _ = std::fs::remove_file(&path); - match status { - Err(error) => Err(format!("cannot run {editor}: {error}")), - Ok(status) if !status.success() => Err(match status.code() { - Some(code) => format!("the editor exited with {code}"), - None => "the editor was stopped".to_owned(), - }), - Ok(_) => saved.map_err(|error| format!("cannot read the draft: {error}")), - } -} - -/// `path` with a leading `~` standing for `home`, when there is one. -fn expand_home(path: &str, home: Option) -> PathBuf { - match (path.strip_prefix('~'), home) { - (Some(rest), Some(home)) if rest.is_empty() || rest.starts_with('/') => { - home.join(rest.trim_start_matches('/')) - } - _ => PathBuf::from(path), - } -} - -/// `path` as messages show it: under `home`, as `~/…`. -fn shown_path(path: &Path, home: Option) -> String { - match home.and_then(|home| path.strip_prefix(home).ok().map(Path::to_path_buf)) { - Some(rest) => format!("~/{}", rest.display()), - None => path.display().to_string(), - } -} - -/// `dn`'s configuration directory (D6), holding `init.vim` and the device key: `devnull` under -/// `$XDG_CONFIG_HOME`, else under `~/.config`. -fn config_dir(config_home: Option, home: Option) -> Option { - let config = config_home - .filter(|dir| dir.is_absolute()) - .or_else(|| home.map(|home| home.join(".config")))?; - Some(config.join("devnull")) -} - -/// The message for a terminal event, if `App` cares. -fn to_msg(event: Event) -> Option { - match event { - Event::Key(key) if key.kind != KeyEventKind::Release => { - Key::try_from(key).ok().map(Msg::Key) - } - Event::Paste(text) => Some(Msg::Paste(text)), - Event::FocusGained => Some(Msg::FocusGained), - Event::FocusLost => Some(Msg::FocusLost), - Event::Resize(width, height) => Some(Msg::Resize { width, height }), - Event::Key(_) | Event::Mouse(_) => None, - } -} - -/// Whether the kitty keyboard protocol was turned on, so that it is turned off again exactly once, also from the -/// panic hook, which cannot reach the [`Session`]. -static KEYBOARD_ENHANCED: AtomicBool = AtomicBool::new(false); - -/// The terminal in `dn`'s modes: raw, alternate screen, focus reporting, bracketed paste and, where supported, the -/// kitty keyboard protocol (so `` and `` differ). Dropping it, or panicking, restores the terminal. -struct Session { - terminal: DefaultTerminal, - /// Whether the terminal speaks the kitty keyboard protocol, asked once: asking takes a round trip. - keyboard: bool, -} - -impl Session { - fn start() -> io::Result { - // Raw mode, the alternate screen, and a panic hook that leaves both. - let terminal = ratatui::try_init()?; - let session = Self { - terminal, - keyboard: supports_keyboard_enhancement().unwrap_or(false), - }; - enter_modes(session.keyboard)?; - // Runs before ratatui's hook, which this one wraps. - let ratatui_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - leave_modes(); - ratatui_hook(info); - })); - Ok(session) - } -} - -impl Session { - /// Hands the terminal to the user's editor on `text` until it exits, then takes it back; what it saved, or why - /// there is nothing. - fn edit(&mut self, text: &str) -> io::Result> { - leave_modes(); - ratatui::restore(); - let saved = run_editor(&editor(), &drafts_dir(), text); - crossterm::terminal::enable_raw_mode()?; - execute!( - stdout(), - crossterm::terminal::EnterAlternateScreen, - crossterm::terminal::Clear(crossterm::terminal::ClearType::All) - )?; - enter_modes(self.keyboard)?; - // A blank screen, and a terminal that knows it, so the next draw paints it all. Not `Terminal::clear`: it - // asks the terminal where the cursor is, and gives up with an error when the answer is slow. - self.terminal = ratatui::Terminal::new(ratatui::backend::CrosstermBackend::new(stdout()))?; - Ok(saved) - } -} - -/// Turns on the modes `dn` adds on top of ratatui's: focus reporting, bracketed paste and, when the terminal speaks -/// it (`keyboard`), the kitty keyboard protocol. -fn enter_modes(keyboard: bool) -> io::Result<()> { - execute!(stdout(), EnableFocusChange, EnableBracketedPaste)?; - if keyboard { - execute!( - stdout(), - PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) - )?; - KEYBOARD_ENHANCED.store(true, Ordering::SeqCst); - } - Ok(()) -} - -/// Turns off the modes `Session::start` added on top of ratatui's. Errors are ignored: this runs while exiting or -/// panicking, when there is nothing better to do. -fn leave_modes() { - let mut out = stdout(); - if KEYBOARD_ENHANCED.swap(false, Ordering::SeqCst) { - let _ = execute!(out, PopKeyboardEnhancementFlags); - } - let _ = execute!(out, DisableBracketedPaste, DisableFocusChange); -} - -impl Drop for Session { - fn drop(&mut self) { - leave_modes(); - ratatui::restore(); - } -} - -impl Deref for Session { - type Target = ratatui::Terminal>; - - fn deref(&self) -> &Self::Target { - &self.terminal - } -} - -impl DerefMut for Session { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.terminal - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; - - use super::*; - use crate::key::Key; - - fn key(kind: KeyEventKind) -> Event { - Event::Key(KeyEvent { - code: KeyCode::Char('j'), - modifiers: KeyModifiers::NONE, - kind, - state: KeyEventState::NONE, - }) - } - - #[test] - fn a_leading_tilde_is_the_home_directory() { - let home = Some(PathBuf::from("/home/u")); - - assert_eq!( - expand_home("~/x.vim", home.clone()), - PathBuf::from("/home/u/x.vim") - ); - assert_eq!(expand_home("~", home.clone()), PathBuf::from("/home/u")); - assert_eq!( - expand_home("~bob/x.vim", home.clone()), - PathBuf::from("~bob/x.vim") - ); - assert_eq!(expand_home("x.vim", home), PathBuf::from("x.vim")); - assert_eq!(expand_home("~/x.vim", None), PathBuf::from("~/x.vim")); - } - - #[test] - fn paths_under_home_are_shown_with_a_tilde() { - let home = Some(PathBuf::from("/home/u")); - - assert_eq!( - shown_path(Path::new("/home/u/.config/devnull/init.vim"), home.clone()), - "~/.config/devnull/init.vim" - ); - assert_eq!( - shown_path(Path::new("/etc/init.vim"), home), - "/etc/init.vim" - ); - } - - #[test] - fn the_config_dir_is_under_the_xdg_config_home_or_dot_config() { - let home = Some(PathBuf::from("/home/u")); - - assert_eq!( - config_dir(Some("/xdg".into()), home.clone()), - Some(PathBuf::from("/xdg/devnull")) - ); - assert_eq!( - config_dir(None, home.clone()), - Some(PathBuf::from("/home/u/.config/devnull")) - ); - assert_eq!( - config_dir(Some("relative".into()), home), - Some(PathBuf::from("/home/u/.config/devnull")) - ); - assert_eq!(config_dir(None, None), None); - } - - #[test] - fn presses_and_repeats_are_keys_but_releases_are_dropped() { - assert_eq!( - to_msg(key(KeyEventKind::Press)), - Some(Msg::Key(Key::char('j'))) - ); - assert_eq!( - to_msg(key(KeyEventKind::Repeat)), - Some(Msg::Key(Key::char('j'))) - ); - assert_eq!(to_msg(key(KeyEventKind::Release)), None); - } - - #[test] - fn focus_paste_and_resize_become_messages() { - assert_eq!(to_msg(Event::FocusLost), Some(Msg::FocusLost)); - assert_eq!(to_msg(Event::FocusGained), Some(Msg::FocusGained)); - assert_eq!( - to_msg(Event::Paste("hi".into())), - Some(Msg::Paste("hi".into())) - ); - assert_eq!( - to_msg(Event::Resize(80, 24)), - Some(Msg::Resize { - width: 80, - height: 24 - }) - ); - } -} - -#[cfg(test)] -mod editor_tests { - use super::*; - - fn files(dir: &Path) -> usize { - std::fs::read_dir(dir).expect("dir").count() - } - - #[test] - fn what_the_editor_saved_comes_back_and_the_draft_file_goes() { - let dir = tempfile::tempdir().expect("dir"); - - let saved = run_editor("printf 'saved' >", dir.path(), "draft"); - - assert_eq!(saved, Ok("saved".to_owned())); - assert_eq!(files(dir.path()), 0); - } - - #[test] - fn an_editor_quitting_with_an_error_gives_nothing() { - let dir = tempfile::tempdir().expect("dir"); - - let saved = run_editor("false", dir.path(), "draft"); - - assert_eq!(saved, Err("the editor exited with 1".to_owned())); - assert_eq!(files(dir.path()), 0); - } - - #[cfg(target_os = "linux")] - #[test] - fn the_draft_is_the_user_s_alone_and_editors_may_take_arguments() { - let dir = tempfile::tempdir().expect("dir"); - - let mode = run_editor( - "f() { stat -c %a \"$1\" > \"$1\"; }; f", - dir.path(), - "draft", - ); - let edited = run_editor("sed -i s/draft/done/", dir.path(), "a draft"); - - assert_eq!(mode, Ok("600\n".to_owned())); - assert_eq!(edited, Ok("a done".to_owned())); - } + let (dn, kit) = Dn::session(); + runtime::run("devnull", dn, kit, net, (inbox, mailbox)).await } diff --git a/docs/plan.md b/docs/plan.md index 68903c1..3a406af 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -77,8 +77,8 @@ Defaults: | ``, `` (``) | Jumplist back/forward, across buffers | | | `` | Back to where ``/`gd` left for another buffer (tag stack) | `:pop` | | `gd` | Profile of the commit's author | `:profile` | -| `K` | Hover card (author, link card, poll) | `:hover` | -| `gx` / `gX` | Open URL under cursor / the commit on the web | `:browse` | +| `K` | Details card (author, link card, poll) | `:details` | +| `gx` / `gX` | Open URL under cursor, else one of the commit's / the commit on the web | `:browse link` / `:browse` | | `]]`, `[[` | Next/previous commit | | | `zo zO zc zC za zA zR zM` | Folds: long commits, reply groups, notification groups; nested as in Vim | | | `c` | New commit | `:commit [@org]` | @@ -86,14 +86,14 @@ Defaults: | `s` | Toggle star | `:star` | | `dd` | Delete own commit (confirms) | `:delete` | | `yy`, `v…y` | Yank text (Vim semantics) | | -| `gy` | Yank commit URL to the clipboard (OSC 52) | `:yanklink` | +| `gy` | Yank the link under the cursor, else the commit's URL, to the clipboard (OSC 52) | `:yanklink` | | `:e!`, `` | Refresh buffer | `:e!` | | `q`, `:q` | Close window; the last one quits | | | — | Follow/unfollow (no plain key; `f` stays a motion) | `:follow`, `:unfollow` | | `1/2/3` | Following, Latest, Trending | `:e log/following` … | | `n` `s` `p` | Notifications, Starred, own profile | `:e notifications` … | | `` | Buffer picker | `:pick buffers` | -| `/` | User picker | `:pick users` | +| `ff` | User picker (`/` until the apps found their own with `ff`) | `:pick users` | | — | Every key binding, user maps too, by topic, in a window above (as Vim's help); `:h zo` goes to one | `:help [keys]` | Older pages load automatically as the cursor nears the end of a buffer; there is no paging key. @@ -449,9 +449,9 @@ from M1 even though graphics land in M6. - [x] **M3.4** `gx` (link under the cursor), `gX` (commit on the web), `gy` (yank its link); `K` hover card. `:browse link` (`gx`) opens only a link; `:browse` (`gX`) and `:yanklink` (`gy`) take the commit under the cursor (a quote's, else the one holding it), else the account (`https://dev-null.sh/@handle`, as every captured `html_url`). - `:hover` (`K`) floats a card next to the cursor until the next key: a link card whole, an account's profile head - (fetched, its avatar too: asked for and drawn on the card since a fix after M6), or elsewhere in a commit, its - author's. Polls get theirs when polls are drawn. Checked live: `K` on a + `:details` (`K`; `:hover` until it was named as muttrs' and slackers') floats a card next to the cursor until the + next key: a link card whole, an account's profile head (fetched, its avatar too: asked for and drawn on the card + since a fix after M6), or elsewhere in a commit, its author's. Polls get theirs when polls are drawn. Checked live: `K` on a commit and `gy` (OSC 52); `gx`/`gX` only in tests, as they open the browser. - [x] **M3.5** The jumplist spans buffers: `` from a thread returns to where `` was pressed. Entries carry their buffer, and showing another buffer is a jump (as starting to edit another file is in Vim), except from a