diff --git a/Cargo.lock b/Cargo.lock index 473ae8c88..2cd202f34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -317,10 +317,12 @@ dependencies = [ "etcetera", "helix-syntax", "once_cell", + "quickcheck", "regex", "ropey", "rust-embed", "serde", + "similar", "smallvec", "tendril", "toml", @@ -394,7 +396,6 @@ dependencies = [ "helix-view", "serde", "unicode-segmentation", - "unicode-width", ] [[package]] @@ -692,6 +693,15 @@ dependencies = [ "unicase", ] +[[package]] +name = "quickcheck" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" +dependencies = [ + "rand", +] + [[package]] name = "quote" version = "1.0.9" @@ -701,6 +711,24 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +dependencies = [ + "getrandom", +] + [[package]] name = "redox_syscall" version = "0.2.9" @@ -872,6 +900,12 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad1d488a557b235fc46dae55512ffbfc429d2482b08b4d9435ab07384ca8aec" + [[package]] name = "slab" version = "0.4.3" diff --git a/book/src/keymap.md b/book/src/keymap.md index 0265fe6dc..c0c455d32 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -73,6 +73,7 @@ | `Alt-;` | Flip selection cursor and anchor | | `%` | Select entire file | | `x` | Select current line, if already selected, extend to next line | +| `X` | Extend selection to line bounds (line-wise selection) | | | Expand selection to parent syntax node TODO: pick a key | | `J` | join lines inside selection | | `K` | keep selections matching the regex TODO: overlapped by hover help | @@ -150,7 +151,8 @@ Jumps to various locations. ## Match mode Enter this mode using `m` from normal mode. See the relavant section -in [Usage](./usage.md#surround) for an explanation about surround usage. +in [Usage](./usage.md) for an explanation about [surround](./usage.md#surround) +and [textobject](./usage.md#textobject) usage. | Key | Description | | ----- | ----------- | @@ -158,6 +160,8 @@ in [Usage](./usage.md#surround) for an explanation about surround usage. | `s` `` | Surround current selection with `` | | `r` `` | Replace surround character `` with `` | | `d` `` | Delete surround character `` | +| `a` `` | Select around textobject | +| `i` `` | Select inside textobject | ## Object mode diff --git a/book/src/themes.md b/book/src/themes.md index d6ed78ba4..e5c461fd2 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -81,6 +81,7 @@ Possible keys: | `ui.cursor.match` | Matching bracket etc. | | `ui.cursor.primary` | Cursor with primary selection | | `ui.linenr` | | +| `ui.linenr.selected` | | | `ui.statusline` | | | `ui.statusline.inactive` | | | `ui.popup` | | diff --git a/book/src/usage.md b/book/src/usage.md index e6bd60e2c..0458071a5 100644 --- a/book/src/usage.md +++ b/book/src/usage.md @@ -24,3 +24,19 @@ It can also act on multiple seletions (yay!). For example, to change every occur - `mr([` to replace the parens with square brackets Multiple characters are currently not supported, but planned. + +## Textobjects + +Currently supported: `word`, `surround`. + +![textobject-demo](https://user-images.githubusercontent.com/23398472/124231131-81a4bb00-db2d-11eb-9d10-8e577ca7b177.gif) + +- `ma` - Select around the object (`va` in vim, `` in kakoune) +- `mi` - Select inside the object (`vi` in vim, `` in kakoune) + +| Key after `mi` or `ma` | Textobject selected | +| --- | --- | +| `w` | Word | +| `(`, `[`, `'`, etc | Specified surround pairs | + +Textobjects based on treesitter, like `function`, `class`, etc are planned. diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index 726e90ccc..80d559a95 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -31,5 +31,10 @@ regex = "1" serde = { version = "1.0", features = ["derive"] } toml = "0.5" +similar = "1.3" + etcetera = "0.3" rust-embed = { version = "5.9.0", optional = true } + +[dev-dependencies] +quickcheck = { version = "1", default-features = false } diff --git a/helix-core/src/diff.rs b/helix-core/src/diff.rs new file mode 100644 index 000000000..9c1fc999b --- /dev/null +++ b/helix-core/src/diff.rs @@ -0,0 +1,70 @@ +use ropey::Rope; + +use crate::{Change, Transaction}; + +/// Compares `old` and `new` to generate a [`Transaction`] describing +/// the steps required to get from `old` to `new`. +pub fn compare_ropes(old: &Rope, new: &Rope) -> Transaction { + // `similar` only works on contiguous data, so a `Rope` has + // to be temporarily converted into a `String`. + let old_converted = old.to_string(); + let new_converted = new.to_string(); + + // A timeout is set so after 1 seconds, the algorithm will start + // approximating. This is especially important for big `Rope`s or + // `Rope`s that are extremely dissimilar to each other. + // + // Note: Ignore the clippy warning, as the trait bounds of + // `Transaction::change()` require an iterator implementing + // `ExactIterator`. + let mut config = similar::TextDiff::configure(); + config.timeout(std::time::Duration::from_secs(1)); + + let diff = config.diff_chars(&old_converted, &new_converted); + + // The current position of the change needs to be tracked to + // construct the `Change`s. + let mut pos = 0; + let changes: Vec = diff + .ops() + .iter() + .map(|op| op.as_tag_tuple()) + .filter_map(|(tag, old_range, new_range)| { + // `old_pos..pos` is equivalent to `start..end` for where + // the change should be applied. + let old_pos = pos; + pos += old_range.end - old_range.start; + + match tag { + // Semantically, inserts and replacements are the same thing. + similar::DiffTag::Insert | similar::DiffTag::Replace => { + // This is the text from the `new` rope that should be + // inserted into `old`. + let text: &str = { + let start = new.char_to_byte(new_range.start); + let end = new.char_to_byte(new_range.end); + &new_converted[start..end] + }; + Some((old_pos, pos, Some(text.into()))) + } + similar::DiffTag::Delete => Some((old_pos, pos, None)), + similar::DiffTag::Equal => None, + } + }) + .collect(); + Transaction::change(old, changes.into_iter()) +} + +#[cfg(test)] +mod tests { + use super::*; + + quickcheck::quickcheck! { + fn test_compare_ropes(a: String, b: String) -> bool { + let mut old = Rope::from(a); + let new = Rope::from(b); + compare_ropes(&old, &new).apply(&mut old); + old.to_string() == new.to_string() + } + } +} diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index dfbbd7489..3684a93ee 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod auto_pairs; pub mod chars; pub mod comment; pub mod diagnostic; +pub mod diff; pub mod graphemes; pub mod history; pub mod indent; @@ -17,6 +18,7 @@ pub mod selection; mod state; pub mod surround; pub mod syntax; +pub mod textobject; mod transaction; pub mod unicode { diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs index 62311ee4d..bc56f9a4e 100644 --- a/helix-core/src/movement.rs +++ b/helix-core/src/movement.rs @@ -176,6 +176,10 @@ pub fn move_prev_long_word_start(slice: RopeSlice, range: Range, count: usize) - word_move(slice, range, count, WordMotionTarget::PrevLongWordStart) } +pub fn move_prev_word_end(slice: RopeSlice, range: Range, count: usize) -> Range { + word_move(slice, range, count, WordMotionTarget::PrevWordEnd) +} + fn word_move(slice: RopeSlice, range: Range, count: usize, target: WordMotionTarget) -> Range { (0..count).fold(range, |range, _| { slice.chars_at(range.head).range_to_target(target, range) @@ -222,6 +226,7 @@ pub enum WordMotionTarget { NextWordStart, NextWordEnd, PrevWordStart, + PrevWordEnd, // A "Long word" (also known as a WORD in vim/kakoune) is strictly // delimited by whitespace, and can consist of punctuation as well // as alphanumerics. @@ -244,7 +249,9 @@ impl CharHelpers for Chars<'_> { fn range_to_target(&mut self, target: WordMotionTarget, origin: Range) -> Range { // Characters are iterated forward or backwards depending on the motion direction. let characters: Box> = match target { - WordMotionTarget::PrevWordStart | WordMotionTarget::PrevLongWordStart => { + WordMotionTarget::PrevWordStart + | WordMotionTarget::PrevLongWordStart + | WordMotionTarget::PrevWordEnd => { self.next(); Box::new(from_fn(|| self.prev())) } @@ -253,9 +260,9 @@ impl CharHelpers for Chars<'_> { // Index advancement also depends on the direction. let advance: &dyn Fn(&mut usize) = match target { - WordMotionTarget::PrevWordStart | WordMotionTarget::PrevLongWordStart => { - &|u| *u = u.saturating_sub(1) - } + WordMotionTarget::PrevWordStart + | WordMotionTarget::PrevLongWordStart + | WordMotionTarget::PrevWordEnd => &|u| *u = u.saturating_sub(1), _ => &|u| *u += 1, }; @@ -328,7 +335,7 @@ fn reached_target(target: WordMotionTarget, peek: char, next_peek: Option<&char> }; match target { - WordMotionTarget::NextWordStart => { + WordMotionTarget::NextWordStart | WordMotionTarget::PrevWordEnd => { is_word_boundary(peek, *next_peek) && (char_is_line_ending(*next_peek) || !next_peek.is_whitespace()) } @@ -978,6 +985,88 @@ mod test { } } + #[test] + fn test_behaviour_when_moving_to_end_of_previous_words() { + let tests = array::IntoIter::new([ + ("Basic backward motion from the middle of a word", + vec![(1, Range::new(9, 9), Range::new(9, 5))]), + ("Starting from after boundary retreats the anchor", + vec![(1, Range::new(0, 13), Range::new(12, 8))]), + ("Jump to end of a word succeeded by whitespace", + vec![(1, Range::new(10, 10), Range::new(10, 4))]), + (" Jump to start of line from end of word preceded by whitespace", + vec![(1, Range::new(7, 7), Range::new(7, 0))]), + ("Previous anchor is irrelevant for backward motions", + vec![(1, Range::new(26, 12), Range::new(12, 8))]), + (" Starting from whitespace moves to first space in sequence", + vec![(1, Range::new(0, 3), Range::new(3, 0))]), + ("Test identifiers_with_underscores are considered a single word", + vec![(1, Range::new(0, 25), Range::new(25, 4))]), + ("Jumping\n \nback through a newline selects whitespace", + vec![(1, Range::new(0, 13), Range::new(11, 8))]), + ("Jumping to start of word from the end selects the whole word", + vec![(1, Range::new(15, 15), Range::new(15, 10))]), + ("alphanumeric.!,and.?=punctuation are considered 'words' for the purposes of word motion", + vec![ + (1, Range::new(30, 30), Range::new(30, 21)), + (1, Range::new(30, 21), Range::new(20, 18)), + (1, Range::new(20, 18), Range::new(17, 15)) + ]), + + ("... ... punctuation and spaces behave as expected", + vec![ + (1, Range::new(0, 10), Range::new(9, 9)), + (1, Range::new(9, 6), Range::new(5, 3)), + ]), + (".._.._ punctuation is not joined by underscores into a single block", + vec![(1, Range::new(0, 5), Range::new(4, 3))]), + ("Newlines\n\nare bridged seamlessly.", + vec![ + (1, Range::new(0, 10), Range::new(7, 0)), + ]), + ("Jumping \n\n\n\n\nback from within a newline group selects previous block", + vec![ + (1, Range::new(0, 13), Range::new(10, 7)), + ]), + ("Failed motions do not modify the range", + vec![ + (0, Range::new(3, 0), Range::new(3, 0)), + ]), + ("Multiple motions at once resolve correctly", + vec![ + (3, Range::new(23, 23), Range::new(15, 8)), + ]), + ("Excessive motions are performed partially", + vec![ + (999, Range::new(40, 40), Range::new(8, 0)), + ]), + ("", // Edge case of moving backwards in empty string + vec![ + (1, Range::new(0, 0), Range::new(0, 0)), + ]), + ("\n\n\n\n\n", // Edge case of moving backwards in all newlines + vec![ + (1, Range::new(0, 0), Range::new(0, 0)), + ]), + (" \n \nJumping back through alternated space blocks and newlines selects the space blocks", + vec![ + (1, Range::new(0, 7), Range::new(6, 4)), + (1, Range::new(6, 4), Range::new(2, 0)), + ]), + ("Test ヒーリクス multibyte characters behave as normal characters", + vec![ + (1, Range::new(0, 9), Range::new(9, 4)), + ]), + ]); + + for (sample, scenario) in tests { + for (count, begin, expected_end) in scenario.into_iter() { + let range = move_prev_word_end(Rope::from(sample).slice(..), begin, count); + assert_eq!(range, expected_end, "Case failed: [{}]", sample); + } + } + } + #[test] fn test_behaviour_when_moving_to_end_of_next_long_words() { let tests = array::IntoIter::new([ diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs index 6ca798a61..64ff51d8d 100644 --- a/helix-core/src/selection.rs +++ b/helix-core/src/selection.rs @@ -216,6 +216,16 @@ impl Range { } } +impl From<(usize, usize)> for Range { + fn from(tuple: (usize, usize)) -> Self { + Self { + anchor: tuple.0, + head: tuple.1, + horiz: None, + } + } +} + /// A selection consists of one or more selection ranges. /// invariant: A selection can never be empty (always contains at least primary range). #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/helix-core/src/surround.rs b/helix-core/src/surround.rs index 61981d6e8..52f60cabd 100644 --- a/helix-core/src/surround.rs +++ b/helix-core/src/surround.rs @@ -41,11 +41,14 @@ pub fn find_nth_pairs_pos( let (open, close) = get_pair(ch); let (open_pos, close_pos) = if open == close { - // find_nth* do not consider current character; +1/-1 to include them - ( - search::find_nth_prev(text, open, pos + 1, n, true)?, - search::find_nth_next(text, close, pos - 1, n, true)?, - ) + let prev = search::find_nth_prev(text, open, pos, n, true); + let next = search::find_nth_next(text, close, pos, n, true); + if text.char(pos) == open { + // cursor is *on* a pair + next.map(|n| (pos, n)).or_else(|| prev.map(|p| (p, pos)))? + } else { + (prev?, next?) + } } else { ( find_nth_open_pair(text, open, close, pos, n)?, @@ -198,6 +201,11 @@ mod test { assert_eq!(find_nth_pairs_pos(slice, '\'', 13, 1), Some((10, 15))); assert_eq!(find_nth_pairs_pos(slice, '\'', 13, 2), Some((4, 21))); assert_eq!(find_nth_pairs_pos(slice, '\'', 13, 3), Some((0, 27))); + // cursor on the quotes + assert_eq!(find_nth_pairs_pos(slice, '\'', 10, 1), Some((10, 15))); + // this is the best we can do since opening and closing pairs are same + assert_eq!(find_nth_pairs_pos(slice, '\'', 0, 1), Some((0, 4))); + assert_eq!(find_nth_pairs_pos(slice, '\'', 27, 1), Some((21, 27))); } #[test] diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index d4379a8e4..84a5f9bdd 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -94,6 +94,7 @@ fn load_runtime_file(language: &str, filename: &str) -> Result Result> { use std::fmt; + use std::path::PathBuf; #[derive(rust_embed::RustEmbed)] #[folder = "../runtime/"] diff --git a/helix-core/src/textobject.rs b/helix-core/src/textobject.rs new file mode 100644 index 000000000..fbf66256d --- /dev/null +++ b/helix-core/src/textobject.rs @@ -0,0 +1,318 @@ +use ropey::RopeSlice; + +use crate::chars::{categorize_char, char_is_line_ending, char_is_whitespace, CharCategory}; +use crate::movement::{self, Direction}; +use crate::surround; +use crate::Range; + +fn this_word_end_pos(slice: RopeSlice, pos: usize) -> usize { + this_word_bound_pos(slice, pos, Direction::Forward) +} + +fn this_word_start_pos(slice: RopeSlice, pos: usize) -> usize { + this_word_bound_pos(slice, pos, Direction::Backward) +} + +fn this_word_bound_pos(slice: RopeSlice, mut pos: usize, direction: Direction) -> usize { + let iter = match direction { + Direction::Forward => slice.chars_at(pos + 1), + Direction::Backward => { + let mut iter = slice.chars_at(pos); + iter.reverse(); + iter + } + }; + + match categorize_char(slice.char(pos)) { + CharCategory::Eol | CharCategory::Whitespace => pos, + category => { + for peek in iter { + let curr_category = categorize_char(peek); + if curr_category != category + || curr_category == CharCategory::Eol + || curr_category == CharCategory::Whitespace + { + return pos; + } + pos = match direction { + Direction::Forward => pos + 1, + Direction::Backward => pos.saturating_sub(1), + } + } + pos + } + } +} + +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum TextObject { + Around, + Inside, +} + +// count doesn't do anything yet +pub fn textobject_word( + slice: RopeSlice, + range: Range, + textobject: TextObject, + count: usize, +) -> Range { + let this_word_start = this_word_start_pos(slice, range.head); + let this_word_end = this_word_end_pos(slice, range.head); + + let (anchor, head); + match textobject { + TextObject::Inside => { + anchor = this_word_start; + head = this_word_end; + } + TextObject::Around => { + if slice + .get_char(this_word_end + 1) + .map_or(true, char_is_line_ending) + { + head = this_word_end; + if slice + .get_char(this_word_start.saturating_sub(1)) + .map_or(true, char_is_line_ending) + { + // single word on a line + anchor = this_word_start; + } else { + // last word on a line, select the whitespace before it too + anchor = movement::move_prev_word_end(slice, range, count).head; + } + } else if char_is_whitespace(slice.char(range.head)) { + // select whole whitespace and next word + head = movement::move_next_word_end(slice, range, count).head; + anchor = movement::backwards_skip_while(slice, range.head, |c| c.is_whitespace()) + .map(|p| p + 1) // p is first *non* whitespace char, so +1 to get whitespace pos + .unwrap_or(0); + } else { + head = movement::move_next_word_start(slice, range, count).head; + anchor = this_word_start; + } + } + }; + Range::new(anchor, head) +} + +pub fn textobject_surround( + slice: RopeSlice, + range: Range, + textobject: TextObject, + ch: char, + count: usize, +) -> Range { + surround::find_nth_pairs_pos(slice, ch, range.head, count) + .map(|(anchor, head)| match textobject { + TextObject::Inside => Range::new(anchor + 1, head.saturating_sub(1)), + TextObject::Around => Range::new(anchor, head), + }) + .unwrap_or(range) +} + +#[cfg(test)] +mod test { + use super::TextObject::*; + use super::*; + + use crate::Range; + use ropey::Rope; + + #[test] + fn test_textobject_word() { + // (text, [(cursor position, textobject, final range), ...]) + let tests = &[ + ( + "cursor at beginning of doc", + vec![(0, Inside, (0, 5)), (0, Around, (0, 6))], + ), + ( + "cursor at middle of word", + vec![ + (13, Inside, (10, 15)), + (10, Inside, (10, 15)), + (15, Inside, (10, 15)), + (13, Around, (10, 16)), + (10, Around, (10, 16)), + (15, Around, (10, 16)), + ], + ), + ( + "cursor between word whitespace", + vec![(6, Inside, (6, 6)), (6, Around, (6, 13))], + ), + ( + "cursor on word before newline\n", + vec![ + (22, Inside, (22, 28)), + (28, Inside, (22, 28)), + (25, Inside, (22, 28)), + (22, Around, (21, 28)), + (28, Around, (21, 28)), + (25, Around, (21, 28)), + ], + ), + ( + "cursor on newline\nnext line", + vec![(17, Inside, (17, 17)), (17, Around, (17, 22))], + ), + ( + "cursor on word after newline\nnext line", + vec![ + (29, Inside, (29, 32)), + (30, Inside, (29, 32)), + (32, Inside, (29, 32)), + (29, Around, (29, 33)), + (30, Around, (29, 33)), + (32, Around, (29, 33)), + ], + ), + ( + "cursor on #$%:;* punctuation", + vec![ + (13, Inside, (10, 15)), + (10, Inside, (10, 15)), + (15, Inside, (10, 15)), + (13, Around, (10, 16)), + (10, Around, (10, 16)), + (15, Around, (10, 16)), + ], + ), + ( + "cursor on punc%^#$:;.tuation", + vec![ + (14, Inside, (14, 20)), + (20, Inside, (14, 20)), + (17, Inside, (14, 20)), + (14, Around, (14, 20)), + // FIXME: edge case + // (20, Around, (14, 20)), + (17, Around, (14, 20)), + ], + ), + ( + "cursor in extra whitespace", + vec![ + (9, Inside, (9, 9)), + (10, Inside, (10, 10)), + (11, Inside, (11, 11)), + (9, Around, (9, 16)), + (10, Around, (9, 16)), + (11, Around, (9, 16)), + ], + ), + ( + "cursor at end of doc", + vec![(19, Inside, (17, 19)), (19, Around, (16, 19))], + ), + ]; + + for (sample, scenario) in tests { + let doc = Rope::from(*sample); + let slice = doc.slice(..); + for &case in scenario { + let (pos, objtype, expected_range) = case; + let result = textobject_word(slice, Range::point(pos), objtype, 1); + assert_eq!( + result, + expected_range.into(), + "\nCase failed: {:?} - {:?}", + sample, + case + ); + } + } + } + + #[test] + fn test_textobject_surround() { + // (text, [(cursor position, textobject, final range, count), ...]) + let tests = &[ + ( + "simple (single) surround pairs", + vec![ + (3, Inside, (3, 3), '(', 1), + (7, Inside, (8, 13), ')', 1), + (10, Inside, (8, 13), '(', 1), + (14, Inside, (8, 13), ')', 1), + (3, Around, (3, 3), '(', 1), + (7, Around, (7, 14), ')', 1), + (10, Around, (7, 14), '(', 1), + (14, Around, (7, 14), ')', 1), + ], + ), + ( + "samexx 'single' surround pairs", + vec![ + (3, Inside, (3, 3), '\'', 1), + (7, Inside, (8, 13), '\'', 1), + (10, Inside, (8, 13), '\'', 1), + (14, Inside, (8, 13), '\'', 1), + (3, Around, (3, 3), '\'', 1), + (7, Around, (7, 14), '\'', 1), + (10, Around, (7, 14), '\'', 1), + (14, Around, (7, 14), '\'', 1), + ], + ), + ( + "(nested (surround (pairs)) 3 levels)", + vec![ + (0, Inside, (1, 34), '(', 1), + (6, Inside, (1, 34), ')', 1), + (8, Inside, (9, 24), '(', 1), + (8, Inside, (9, 34), ')', 2), + (20, Inside, (9, 24), '(', 2), + (20, Inside, (1, 34), ')', 3), + (0, Around, (0, 35), '(', 1), + (6, Around, (0, 35), ')', 1), + (8, Around, (8, 25), '(', 1), + (8, Around, (8, 35), ')', 2), + (20, Around, (8, 25), '(', 2), + (20, Around, (0, 35), ')', 3), + ], + ), + ( + "(mixed {surround [pair] same} line)", + vec![ + (2, Inside, (1, 33), '(', 1), + (9, Inside, (8, 27), '{', 1), + (18, Inside, (18, 21), '[', 1), + (2, Around, (0, 34), '(', 1), + (9, Around, (7, 28), '{', 1), + (18, Around, (17, 22), '[', 1), + ], + ), + ( + "(stepped (surround) pairs (should) skip)", + vec![(22, Inside, (1, 38), '(', 1), (22, Around, (0, 39), '(', 1)], + ), + ( + "[surround pairs{\non different]\nlines}", + vec![ + (7, Inside, (1, 28), '[', 1), + (15, Inside, (16, 35), '{', 1), + (7, Around, (0, 29), '[', 1), + (15, Around, (15, 36), '{', 1), + ], + ), + ]; + + for (sample, scenario) in tests { + let doc = Rope::from(*sample); + let slice = doc.slice(..); + for &case in scenario { + let (pos, objtype, expected_range, ch, count) = case; + let result = textobject_surround(slice, Range::point(pos), objtype, ch, count); + assert_eq!( + result, + expected_range.into(), + "\nCase failed: {:?} - {:?}", + sample, + case + ); + } + } + } +} diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 9622ad912..17ba26521 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -160,7 +160,11 @@ impl Application { } self.render(); } - Some(callback) = self.jobs.next_job() => { + Some(callback) = self.jobs.futures.next() => { + self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); + self.render(); + } + Some(callback) = self.jobs.wait_futures.next() => { self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); self.render(); } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 5ab0926ac..fbeae5ff4 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -16,6 +16,7 @@ use helix_core::{ use helix_view::{ document::{IndentStyle, Mode}, editor::Action, + info::Info, input::KeyEvent, keyboard::KeyCode, view::{View, PADDING}, @@ -38,6 +39,7 @@ use crate::{ use crate::job::{self, Job, Jobs}; use futures_util::{FutureExt, TryFutureExt}; +use std::collections::HashMap; use std::{fmt, future::Future}; use std::{ @@ -45,7 +47,7 @@ use std::{ path::{Path, PathBuf}, }; -use once_cell::sync::Lazy; +use once_cell::sync::{Lazy, OnceCell}; use serde::de::{self, Deserialize, Deserializer}; pub struct Context<'a> { @@ -74,6 +76,16 @@ impl<'a> Context<'a> { self.on_next_key_callback = Some(Box::new(on_next_key_callback)); } + #[inline] + pub fn on_next_key_mode(&mut self, map: HashMap) { + self.on_next_key(move |cx, event| { + cx.editor.autoinfo = None; + if let Some(func) = map.get(&event) { + func(cx); + } + }); + } + #[inline] pub fn callback( &mut self, @@ -153,17 +165,12 @@ impl Command { move_char_right, move_line_up, move_line_down, - move_line_end, - move_line_start, - move_first_nonwhitespace, move_next_word_start, move_prev_word_start, move_next_word_end, move_next_long_word_start, move_prev_long_word_start, move_next_long_word_end, - move_file_start, - move_file_end, extend_next_word_start, extend_prev_word_start, extend_next_word_end, @@ -175,7 +182,6 @@ impl Command { find_prev_char, extend_till_prev_char, extend_prev_char, - extend_first_nonwhitespace, replace, page_up, page_down, @@ -185,8 +191,6 @@ impl Command { extend_char_right, extend_line_up, extend_line_down, - extend_line_end, - extend_line_start, select_all, select_regex, split_selection, @@ -196,6 +200,7 @@ impl Command { extend_search_next, search_selection, extend_line, + extend_to_line_bounds, delete_selection, change_selection, collapse_selection, @@ -217,11 +222,17 @@ impl Command { goto_definition, goto_type_definition, goto_implementation, + goto_file_start, + goto_file_end, goto_reference, goto_first_diag, goto_last_diag, goto_next_diag, goto_prev_diag, + goto_line_start, + goto_line_end, + goto_line_end_newline, + goto_first_nonwhitespace, signature_help, insert_tab, insert_newline, @@ -376,7 +387,7 @@ fn move_line_down(cx: &mut Context) { ); } -fn move_line_end(cx: &mut Context) { +fn goto_line_end(cx: &mut Context) { let (view, doc) = current!(cx.editor); doc.set_selection( view.id, @@ -388,12 +399,33 @@ fn move_line_end(cx: &mut Context) { let pos = graphemes::nth_prev_grapheme_boundary(text.slice(..), pos, 1); let pos = range.head.max(pos).max(text.line_to_char(line)); + Range::new( + match doc.mode { + Mode::Normal | Mode::Insert => pos, + Mode::Select => range.anchor, + }, + pos, + ) + }), + ); +} + +fn goto_line_end_newline(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + + doc.set_selection( + view.id, + doc.selection(view.id).clone().transform(|range| { + let text = doc.text(); + let line = text.char_to_line(range.head); + + let pos = line_end_char_index(&text.slice(..), line); Range::new(pos, pos) }), ); } -fn move_line_start(cx: &mut Context) { +fn goto_line_start(cx: &mut Context) { let (view, doc) = current!(cx.editor); doc.set_selection( view.id, @@ -403,12 +435,18 @@ fn move_line_start(cx: &mut Context) { // adjust to start of the line let pos = text.line_to_char(line); - Range::new(pos, pos) + Range::new( + match doc.mode { + Mode::Normal | Mode::Insert => pos, + Mode::Select => range.anchor, + }, + pos, + ) }), ); } -fn move_first_nonwhitespace(cx: &mut Context) { +fn goto_first_nonwhitespace(cx: &mut Context) { let (view, doc) = current!(cx.editor); doc.set_selection( view.id, @@ -418,7 +456,13 @@ fn move_first_nonwhitespace(cx: &mut Context) { if let Some(pos) = find_first_non_whitespace_char(text.line(line_idx)) { let pos = pos + text.line_to_char(line_idx); - Range::new(pos, pos) + Range::new( + match doc.mode { + Mode::Normal | Mode::Insert => pos, + Mode::Select => range.anchor, + }, + pos, + ) } else { range } @@ -426,6 +470,37 @@ fn move_first_nonwhitespace(cx: &mut Context) { ); } +fn goto_window(cx: &mut Context, align: Align) { + let (view, doc) = current!(cx.editor); + + let scrolloff = PADDING.min(view.area.height as usize / 2); // TODO: user pref + + let last_line = view.last_line(doc); + + let line = match align { + Align::Top => (view.first_line + scrolloff), + Align::Center => (view.first_line + (view.area.height as usize / 2)), + Align::Bottom => last_line.saturating_sub(scrolloff), + } + .min(last_line.saturating_sub(scrolloff)); + + let pos = doc.text().line_to_char(line); + + doc.set_selection(view.id, Selection::point(pos)); +} + +fn goto_window_top(cx: &mut Context) { + goto_window(cx, Align::Top) +} + +fn goto_window_middle(cx: &mut Context) { + goto_window(cx, Align::Center) +} + +fn goto_window_bottom(cx: &mut Context) { + goto_window(cx, Align::Bottom) +} + // TODO: move vs extend could take an extra type Extend/Move that would // Range::new(if Move { pos } if Extend { range.anchor }, pos) // since these all really do the same thing @@ -497,13 +572,13 @@ fn move_next_long_word_end(cx: &mut Context) { ); } -fn move_file_start(cx: &mut Context) { +fn goto_file_start(cx: &mut Context) { push_jump(cx.editor); let (view, doc) = current!(cx.editor); doc.set_selection(view.id, Selection::point(0)); } -fn move_file_end(cx: &mut Context) { +fn goto_file_end(cx: &mut Context) { push_jump(cx.editor); let (view, doc) = current!(cx.editor); let text = doc.text(); @@ -683,24 +758,6 @@ fn extend_prev_char(cx: &mut Context) { ) } -fn extend_first_nonwhitespace(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - doc.set_selection( - view.id, - doc.selection(view.id).clone().transform(|range| { - let text = doc.text(); - let line_idx = text.char_to_line(range.head); - - if let Some(pos) = find_first_non_whitespace_char(text.line(line_idx)) { - let pos = pos + text.line_to_char(line_idx); - Range::new(range.anchor, pos) - } else { - range - } - }), - ); -} - fn replace(cx: &mut Context) { let mut buf = [0u8; 4]; // To hold utf8 encoded char. @@ -880,38 +937,6 @@ fn extend_line_down(cx: &mut Context) { ); } -fn extend_line_end(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - doc.set_selection( - view.id, - doc.selection(view.id).clone().transform(|range| { - let text = doc.text().slice(..); - let line = text.char_to_line(range.head); - - let pos = line_end_char_index(&text, line); - let pos = graphemes::nth_prev_grapheme_boundary(text, pos, 1); - let pos = range.head.max(pos).max(text.line_to_char(line)); - - Range::new(range.anchor, pos) - }), - ); -} - -fn extend_line_start(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - doc.set_selection( - view.id, - doc.selection(view.id).clone().transform(|range| { - let text = doc.text(); - let line = text.char_to_line(range.head); - - // adjust to start of the line - let pos = text.line_to_char(line); - Range::new(range.anchor, pos) - }), - ); -} - fn select_all(cx: &mut Context) { let (view, doc) = current!(cx.editor); @@ -1055,6 +1080,27 @@ fn extend_line(cx: &mut Context) { doc.set_selection(view.id, Selection::single(start, end)); } +fn extend_to_line_bounds(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + + doc.set_selection( + view.id, + doc.selection(view.id).clone().transform(|range| { + let text = doc.text(); + let start = text.line_to_char(text.char_to_line(range.from())); + let end = text + .line_to_char(text.char_to_line(range.to()) + 1) + .saturating_sub(1); + + if range.anchor < range.head { + Range::new(start, end) + } else { + Range::new(end, start) + } + }), + ); +} + fn delete_selection_impl(reg: &mut Register, doc: &mut Document, view_id: ViewId) { let text = doc.text().slice(..); let selection = doc.selection(view_id).clone().min_width_1(text); @@ -1580,6 +1626,24 @@ mod cmd { } } + /// Sets the [`Document`]'s encoding.. + fn set_encoding(cx: &mut compositor::Context, args: &[&str], _: PromptEvent) { + let (_, doc) = current!(cx.editor); + if let Some(label) = args.first() { + doc.set_encoding(label) + .unwrap_or_else(|e| cx.editor.set_error(e.to_string())); + } else { + let encoding = doc.encoding().name().to_string(); + cx.editor.set_status(encoding) + } + } + + /// Reload the [`Document`] from its source file. + fn reload(cx: &mut compositor::Context, _args: &[&str], _: PromptEvent) { + let (view, doc) = current!(cx.editor); + doc.reload(view.id).unwrap(); + } + pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "quit", @@ -1763,6 +1827,20 @@ mod cmd { fun: show_current_directory, completer: None, }, + TypableCommand { + name: "encoding", + alias: None, + doc: "Set encoding based on `https://encoding.spec.whatwg.org`", + fun: set_encoding, + completer: None, + }, + TypableCommand { + name: "reload", + alias: None, + doc: "Discard changes and reload from the source file.", + fun: reload, + completer: None, + } ]; pub static COMMANDS: Lazy> = Lazy::new(|| { @@ -1955,7 +2033,7 @@ fn symbol_picker(cx: &mut Context) { // I inserts at the first nonwhitespace character of each line with a selection fn prepend_to_line(cx: &mut Context) { - move_first_nonwhitespace(cx); + goto_first_nonwhitespace(cx); let doc = doc_mut!(cx.editor); enter_insert_mode(doc); } @@ -2124,7 +2202,7 @@ fn push_jump(editor: &mut Editor) { view.jumps.push(jump); } -fn switch_to_last_accessed_file(cx: &mut Context) { +fn goto_last_accessed_file(cx: &mut Context) { let alternate_file = view!(cx.editor).last_accessed_doc; if let Some(alt) = alternate_file { cx.editor.switch(alt, Action::Replace); @@ -2133,65 +2211,6 @@ fn switch_to_last_accessed_file(cx: &mut Context) { } } -fn goto_mode(cx: &mut Context) { - if let Some(count) = cx.count { - push_jump(cx.editor); - - let (view, doc) = current!(cx.editor); - let line_idx = std::cmp::min(count.get() - 1, doc.text().len_lines().saturating_sub(2)); - let pos = doc.text().line_to_char(line_idx); - doc.set_selection(view.id, Selection::point(pos)); - return; - } - - cx.on_next_key(move |cx, event| { - if let KeyEvent { - code: KeyCode::Char(ch), - .. - } = event - { - // TODO: temporarily show GOTO in the mode list - let doc = doc_mut!(cx.editor); - match (doc.mode, ch) { - (_, 'g') => move_file_start(cx), - (_, 'e') => move_file_end(cx), - (_, 'a') => switch_to_last_accessed_file(cx), - (Mode::Normal, 'h') => move_line_start(cx), - (Mode::Normal, 'l') => move_line_end(cx), - (Mode::Select, 'h') => extend_line_start(cx), - (Mode::Select, 'l') => extend_line_end(cx), - (_, 'd') => goto_definition(cx), - (_, 'y') => goto_type_definition(cx), - (_, 'r') => goto_reference(cx), - (_, 'i') => goto_implementation(cx), - (Mode::Normal, 's') => move_first_nonwhitespace(cx), - (Mode::Select, 's') => extend_first_nonwhitespace(cx), - - (_, 't') | (_, 'm') | (_, 'b') => { - let (view, doc) = current!(cx.editor); - - let scrolloff = PADDING.min(view.area.height as usize / 2); // TODO: user pref - - let last_line = view.last_line(doc); - - let line = match ch { - 't' => (view.first_line + scrolloff), - 'm' => (view.first_line + (view.area.height as usize / 2)), - 'b' => last_line.saturating_sub(scrolloff), - _ => unreachable!(), - } - .min(last_line.saturating_sub(scrolloff)); - - let pos = doc.text().line_to_char(line); - - doc.set_selection(view.id, Selection::point(pos)); - } - _ => (), - } - } - }) -} - fn select_mode(cx: &mut Context) { let (view, doc) = current!(cx.editor); @@ -2211,13 +2230,27 @@ fn select_mode(cx: &mut Context) { }), ); - doc.mode = Mode::Select; + doc_mut!(cx.editor).mode = Mode::Select; } fn exit_select_mode(cx: &mut Context) { doc_mut!(cx.editor).mode = Mode::Normal; } +fn goto_prehook(cx: &mut Context) -> bool { + if let Some(count) = cx.count { + push_jump(cx.editor); + + let (view, doc) = current!(cx.editor); + let line_idx = std::cmp::min(count.get() - 1, doc.text().len_lines().saturating_sub(1)); + let pos = doc.text().line_to_char(line_idx); + doc.set_selection(view.id, Selection::point(pos)); + true + } else { + false + } +} + fn goto_impl( editor: &mut Editor, compositor: &mut Compositor, @@ -3457,33 +3490,6 @@ fn select_register(cx: &mut Context) { }) } -fn space_mode(cx: &mut Context) { - cx.on_next_key(move |cx, event| { - if let KeyEvent { - code: KeyCode::Char(ch), - .. - } = event - { - // TODO: temporarily show SPC in the mode list - match ch { - 'f' => file_picker(cx), - 'b' => buffer_picker(cx), - 's' => symbol_picker(cx), - 'w' => window_mode(cx), - 'y' => yank_joined_to_clipboard(cx), - 'Y' => yank_main_selection_to_clipboard(cx), - 'p' => paste_clipboard_after(cx), - 'P' => paste_clipboard_before(cx), - 'R' => replace_selections_with_clipboard(cx), - // ' ' => toggle_alternate_buffer(cx), - // TODO: temporary since space mode took its old key - ' ' => keep_primary_selection(cx), - _ => (), - } - } - }) -} - fn view_mode(cx: &mut Context) { cx.on_next_key(move |cx, event| { if let KeyEvent { @@ -3559,6 +3565,9 @@ fn right_bracket_mode(cx: &mut Context) { }) } +use helix_core::surround; +use helix_core::textobject; + fn match_mode(cx: &mut Context) { let count = cx.count; cx.on_next_key(move |cx, event| { @@ -3574,13 +3583,41 @@ fn match_mode(cx: &mut Context) { 's' => surround_add(cx), 'r' => surround_replace(cx), 'd' => surround_delete(cx), + 'a' => select_textobject(cx, textobject::TextObject::Around), + 'i' => select_textobject(cx, textobject::TextObject::Inside), _ => (), } } }) } -use helix_core::surround; +fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { + let count = cx.count(); + cx.on_next_key(move |cx, event| { + if let KeyEvent { + code: KeyCode::Char(ch), + .. + } = event + { + let (view, doc) = current!(cx.editor); + + doc.set_selection( + view.id, + doc.selection(view.id).clone().transform(|range| { + let text = doc.text().slice(..); + match ch { + 'w' => textobject::textobject_word(text, range, objtype, count), + // TODO: cancel new ranges if inconsistent surround matches across lines + ch if !ch.is_ascii_alphanumeric() => { + textobject::textobject_surround(text, range, objtype, ch, count) + } + _ => range, + } + }), + ); + } + }) +} fn surround_add(cx: &mut Context) { cx.on_next_key(move |cx, event| { @@ -3671,3 +3708,132 @@ fn surround_delete(cx: &mut Context) { } }) } + +/// Do nothing, just for modeinfo. +fn noop(_cx: &mut Context) -> bool { + false +} + +/// Generate modeinfo. +/// +/// If prehook returns true then it will stop the rest. +macro_rules! mode_info { + // TODO: reuse $mode for $stat + (@join $first:expr $(,$rest:expr)*) => { + concat!($first, $(", ", $rest),*) + }; + (@name #[doc = $name:literal] $(#[$rest:meta])*) => { + $name + }; + { + #[doc = $name:literal] $(#[$doc:meta])* $mode:ident, $stat:ident, + $(#[doc = $desc:literal] $($key:tt)|+ => $func:expr),+, + } => { + mode_info! { + #[doc = $name] + $(#[$doc])* + $mode, $stat, noop, + $( + #[doc = $desc] + $($key)|+ => $func + ),+, + } + }; + { + #[doc = $name:literal] $(#[$doc:meta])* $mode:ident, $stat:ident, $prehook:expr, + $(#[doc = $desc:literal] $($key:tt)|+ => $func:expr),+, + } => { + #[doc = $name] + $(#[$doc])* + #[doc = ""] + #[doc = ""] + $( + #[doc = ""] + )+ + #[doc = "
keydesc
"] + // TODO switch to this once we use rust 1.54 + // right now it will produce multiple rows + // #[doc = mode_info!(@join $($key),+)] + $( + #[doc = $key] + )+ + // <- + #[doc = ""] + #[doc = $desc] + #[doc = "
"] + pub fn $mode(cx: &mut Context) { + if $prehook(cx) { + return; + } + static $stat: OnceCell = OnceCell::new(); + cx.editor.autoinfo = Some($stat.get_or_init(|| Info::key( + $name.trim(), + vec![$((&[$($key.parse().unwrap()),+], $desc)),+], + ))); + use helix_core::hashmap; + // TODO: try and convert this to match later + let map = hashmap! { + $($($key.parse::().unwrap() => $func as for<'r, 's> fn(&'r mut Context<'s>)),+),* + }; + cx.on_next_key_mode(map); + } + }; +} + +mode_info! { + /// space mode + space_mode, SPACE_MODE, + /// file picker + "f" => file_picker, + /// buffer picker + "b" => buffer_picker, + /// symbol picker + "s" => symbol_picker, + /// window mode + "w" => window_mode, + /// yank joined to clipboard + "y" => yank_joined_to_clipboard, + /// yank main selection to clipboard + "Y" => yank_main_selection_to_clipboard, + /// paste system clipboard after selections + "p" => paste_clipboard_after, + /// paste system clipboard before selections + "P" => paste_clipboard_before, + /// replace selections with clipboard + "R" => replace_selections_with_clipboard, + /// keep primary selection + "space" => keep_primary_selection, +} + +mode_info! { + /// goto mode + /// + /// When specified with a count, it will go to that line without entering the mode. + goto_mode, GOTO_MODE, goto_prehook, + /// file start + "g" => goto_file_start, + /// file end + "e" => goto_file_end, + /// line start + "h" => goto_line_start, + /// line end + "l" => goto_line_end, + /// line first non blank + "s" => goto_first_nonwhitespace, + /// definition + "d" => goto_definition, + /// type references + "y" => goto_type_definition, + /// references + "r" => goto_reference, + /// implementation + "i" => goto_implementation, + /// window top + "t" => goto_window_top, + /// window middle + "m" => goto_window_middle, + /// window bottom + "b" => goto_window_bottom, + /// last accessed file + "a" => goto_last_accessed_file, +} diff --git a/helix-term/src/job.rs b/helix-term/src/job.rs index c28735138..2ac419265 100644 --- a/helix-term/src/job.rs +++ b/helix-term/src/job.rs @@ -16,9 +16,9 @@ pub struct Job { #[derive(Default)] pub struct Jobs { - futures: FuturesUnordered, + pub futures: FuturesUnordered, /// These are the ones that need to complete before we exit. - wait_futures: FuturesUnordered, + pub wait_futures: FuturesUnordered, } impl Job { @@ -77,11 +77,11 @@ impl Jobs { } } - pub fn next_job( - &mut self, - ) -> impl Future>>> + '_ { - future::select(self.futures.next(), self.wait_futures.next()) - .map(|either| either.factor_first().0) + pub async fn next_job(&mut self) -> Option>> { + tokio::select! { + event = self.futures.next() => { event } + event = self.wait_futures.next() => { event } + } } pub fn add(&mut self, j: Job) { diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index 53588a2ba..d815e0064 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -1,118 +1,25 @@ pub use crate::commands::Command; use crate::config::Config; use helix_core::hashmap; -use helix_view::{ - document::Mode, - input::KeyEvent, - keyboard::{KeyCode, KeyModifiers}, -}; +use helix_view::{document::Mode, input::KeyEvent}; use serde::Deserialize; use std::{ collections::HashMap, ops::{Deref, DerefMut}, }; -// Kakoune-inspired: -// mode = { -// normal = { -// q = record_macro -// w = (next) word -// W = next WORD -// e = end of word -// E = end of WORD -// r = replace -// R = replace with yanked -// t = 'till char -// y = yank -// u = undo -// U = redo -// i = insert -// I = INSERT (start of line) -// o = open below (insert on new line below) -// O = open above (insert on new line above) -// p = paste (before cursor) -// P = PASTE (after cursor) -// ` = -// [ = select to text object start (alt = select whole object) -// ] = select to text object end -// { = extend to inner object start -// } = extend to inner object end -// a = append -// A = APPEND (end of line) -// s = split -// S = select -// d = delete() -// f = find_char() -// g = goto (gg, G, gc, gd, etc) -// -// h = move_char_left(n) || arrow-left = move_char_left(n) -// j = move_line_down(n) || arrow-down = move_line_down(n) -// k = move_line_up(n) || arrow_up = move_line_up(n) -// l = move_char_right(n) || arrow-right = move_char_right(n) -// : = command line -// ; = collapse selection to cursor -// " = use register -// ` = convert case? (to lower) (alt = swap case) -// ~ = convert to upper case -// . = repeat last command -// \ = disable hook? -// / = search -// > = indent -// < = deindent -// % = select whole buffer (in vim = jump to matching bracket) -// * = search pattern in selection -// ( = rotate main selection backward -// ) = rotate main selection forward -// - = trim selections? (alt = merge contiguous sel together) -// @ = convert tabs to spaces -// & = align cursor -// ? = extend to next given regex match (alt = to prev) -// -// in kakoune these are alt-h alt-l / gh gl -// select from curs to begin end / move curs to begin end -// 0 = start of line -// ^ = start of line(first non blank char) || Home = start of line(first non blank char) -// $ = end of line || End = end of line -// -// z = save selections -// Z = restore selections -// x = select line -// X = extend line -// c = change selected text -// C = copy selection? -// v = view menu (viewport manipulation) -// b = select to previous word start -// B = select to previous WORD start -// -// -// -// -// -// -// = = align? -// + = -// } -// -// gd = goto definition -// gr = goto reference -// [d = previous diagnostic -// d] = next diagnostic -// [D = first diagnostic -// D] = last diagnostic -// } - #[macro_export] macro_rules! key { ($key:ident) => { KeyEvent { - code: KeyCode::$key, - modifiers: KeyModifiers::NONE, + code: ::helix_view::keyboard::KeyCode::$key, + modifiers: ::helix_view::keyboard::KeyModifiers::NONE, } }; ($($ch:tt)*) => { KeyEvent { - code: KeyCode::Char($($ch)*), - modifiers: KeyModifiers::NONE, + code: ::helix_view::keyboard::KeyCode::Char($($ch)*), + modifiers: ::helix_view::keyboard::KeyModifiers::NONE, } }; } @@ -120,8 +27,8 @@ macro_rules! key { macro_rules! ctrl { ($($ch:tt)*) => { KeyEvent { - code: KeyCode::Char($($ch)*), - modifiers: KeyModifiers::CONTROL, + code: ::helix_view::keyboard::KeyCode::Char($($ch)*), + modifiers: ::helix_view::keyboard::KeyModifiers::CONTROL, } }; } @@ -129,8 +36,8 @@ macro_rules! ctrl { macro_rules! alt { ($($ch:tt)*) => { KeyEvent { - code: KeyCode::Char($($ch)*), - modifiers: KeyModifiers::ALT, + code: ::helix_view::keyboard::KeyCode::Char($($ch)*), + modifiers: ::helix_view::keyboard::KeyModifiers::ALT, } }; } @@ -175,8 +82,8 @@ impl Default for Keymaps { key!('r') => Command::replace, key!('R') => Command::replace_with_yanked, - key!(Home) => Command::move_line_start, - key!(End) => Command::move_line_end, + key!(Home) => Command::goto_line_start, + key!(End) => Command::goto_line_end, key!('w') => Command::move_next_word_start, key!('b') => Command::move_prev_word_start, @@ -213,7 +120,9 @@ impl Default for Keymaps { alt!(';') => Command::flip_selections, key!('%') => Command::select_all, key!('x') => Command::extend_line, - // extend_to_whole_line, crop_to_whole_line + key!('x') => Command::extend_line, + key!('X') => Command::extend_to_line_bounds, + // crop_to_whole_line key!('m') => Command::match_mode, @@ -307,8 +216,8 @@ impl Default for Keymaps { key!('T') => Command::extend_till_prev_char, key!('F') => Command::extend_prev_char, - key!(Home) => Command::extend_line_start, - key!(End) => Command::extend_line_end, + key!(Home) => Command::goto_line_start, + key!(End) => Command::goto_line_end, key!(Esc) => Command::exit_select_mode, ) .into_iter(), @@ -331,8 +240,8 @@ impl Default for Keymaps { key!(Right) => Command::move_char_right, key!(PageUp) => Command::page_up, key!(PageDown) => Command::page_down, - key!(Home) => Command::move_line_start, - key!(End) => Command::move_line_end, + key!(Home) => Command::goto_line_start, + key!(End) => Command::goto_line_end_newline, ctrl!('x') => Command::completion, ctrl!('w') => Command::delete_word_backward, ), @@ -352,6 +261,7 @@ pub fn merge_keys(mut config: Config) -> Config { #[test] fn merge_partial_keys() { + use helix_view::keyboard::{KeyCode, KeyModifiers}; let config = Config { keys: Keymaps(hashmap! { Mode::Normal => hashmap! { diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index dab654adb..d374d9b63 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -738,6 +738,11 @@ impl Component for EditorView { self.render_view(doc, view, area, surface, &cx.editor.theme, is_focused); } + if let Some(info) = std::mem::take(&mut cx.editor.autoinfo) { + info.render(area, surface, cx); + cx.editor.autoinfo = Some(info); + } + // render status msg if let Some((status_msg, severity)) = &cx.editor.status_msg { use helix_view::editor::Severity; @@ -756,8 +761,7 @@ impl Component for EditorView { } if let Some(completion) = &self.completion { - completion.render(area, surface, cx) - // render completion here + completion.render(area, surface, cx); } } diff --git a/helix-term/src/ui/info.rs b/helix-term/src/ui/info.rs new file mode 100644 index 000000000..e5f20562f --- /dev/null +++ b/helix-term/src/ui/info.rs @@ -0,0 +1,30 @@ +use crate::compositor::{Component, Context}; +use helix_view::graphics::Rect; +use helix_view::info::Info; +use tui::buffer::Buffer as Surface; +use tui::widgets::{Block, Borders, Widget}; + +impl Component for Info { + fn render(&self, viewport: Rect, surface: &mut Surface, cx: &mut Context) { + let style = cx.editor.theme.get("ui.popup"); + let block = Block::default() + .title(self.title) + .borders(Borders::ALL) + .border_style(style); + let Info { width, height, .. } = self; + let (w, h) = (*width + 2, *height + 2); + // -2 to subtract command line + statusline. a bit of a hack, because of splits. + let area = viewport.intersection(Rect::new( + viewport.width.saturating_sub(w), + viewport.height.saturating_sub(h + 2), + w, + h, + )); + surface.clear_with(area, style); + let Rect { x, y, .. } = block.inner(area); + for (y, line) in (y..).zip(self.text.lines()) { + surface.set_string(x, y, line, style); + } + block.render(area, surface); + } +} diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 7111c9684..288d3d2ec 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -1,5 +1,6 @@ mod completion; mod editor; +mod info; mod markdown; mod menu; mod picker; diff --git a/helix-tui/Cargo.toml b/helix-tui/Cargo.toml index dde2eafe3..7f98144c2 100644 --- a/helix-tui/Cargo.toml +++ b/helix-tui/Cargo.toml @@ -19,7 +19,6 @@ default = ["crossterm"] bitflags = "1.0" cassowary = "0.3" unicode-segmentation = "1.2" -unicode-width = "0.1" crossterm = { version = "0.20", optional = true } serde = { version = "1", "optional" = true, features = ["derive"]} helix-view = { version = "0.3", path = "../helix-view", features = ["term"] } diff --git a/helix-tui/src/backend/test.rs b/helix-tui/src/backend/test.rs index a03bcd8e2..3f56b49c8 100644 --- a/helix-tui/src/backend/test.rs +++ b/helix-tui/src/backend/test.rs @@ -2,9 +2,9 @@ use crate::{ backend::Backend, buffer::{Buffer, Cell}, }; +use helix_core::unicode::width::UnicodeWidthStr; use helix_view::graphics::{CursorKind, Rect}; use std::{fmt::Write, io}; -use unicode_width::UnicodeWidthStr; /// A backend used for the integration tests. #[derive(Debug)] diff --git a/helix-tui/src/buffer.rs b/helix-tui/src/buffer.rs index 3a7ad144f..377e3e395 100644 --- a/helix-tui/src/buffer.rs +++ b/helix-tui/src/buffer.rs @@ -1,7 +1,7 @@ use crate::text::{Span, Spans}; +use helix_core::unicode::width::UnicodeWidthStr; use std::cmp::min; use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; use helix_view::graphics::{Color, Modifier, Rect, Style}; diff --git a/helix-tui/src/text.rs b/helix-tui/src/text.rs index 4af6b09de..b8e52479f 100644 --- a/helix-tui/src/text.rs +++ b/helix-tui/src/text.rs @@ -47,10 +47,10 @@ //! ]); //! ``` use helix_core::line_ending::str_is_line_ending; +use helix_core::unicode::width::UnicodeWidthStr; use helix_view::graphics::Style; use std::borrow::Cow; use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; /// A grapheme associated to a style. #[derive(Debug, Clone, PartialEq)] diff --git a/helix-tui/src/widgets/paragraph.rs b/helix-tui/src/widgets/paragraph.rs index bdfb5b9ab..fee35d250 100644 --- a/helix-tui/src/widgets/paragraph.rs +++ b/helix-tui/src/widgets/paragraph.rs @@ -7,9 +7,9 @@ use crate::{ Block, Widget, }, }; +use helix_core::unicode::width::UnicodeWidthStr; use helix_view::graphics::{Rect, Style}; use std::iter; -use unicode_width::UnicodeWidthStr; fn get_line_offset(line_width: u16, text_area_width: u16, alignment: Alignment) -> u16 { match alignment { diff --git a/helix-tui/src/widgets/reflow.rs b/helix-tui/src/widgets/reflow.rs index ae561a4f9..21847783b 100644 --- a/helix-tui/src/widgets/reflow.rs +++ b/helix-tui/src/widgets/reflow.rs @@ -1,7 +1,7 @@ use crate::text::StyledGrapheme; use helix_core::line_ending::str_is_line_ending; +use helix_core::unicode::width::UnicodeWidthStr; use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; const NBSP: &str = "\u{00a0}"; diff --git a/helix-tui/src/widgets/table.rs b/helix-tui/src/widgets/table.rs index ee5147b78..1ee4286a8 100644 --- a/helix-tui/src/widgets/table.rs +++ b/helix-tui/src/widgets/table.rs @@ -9,9 +9,9 @@ use cassowary::{ WeightedRelation::*, {Expression, Solver}, }; +use helix_core::unicode::width::UnicodeWidthStr; use helix_view::graphics::{Rect, Style}; use std::collections::HashMap; -use unicode_width::UnicodeWidthStr; /// A [`Cell`] contains the [`Text`] to be displayed in a [`Row`] of a [`Table`]. /// diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 86f3dfb83..b917b9026 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -70,7 +70,6 @@ pub enum IndentStyle { } pub struct Document { - // rope + selection pub(crate) id: DocumentId, text: Rope, pub(crate) selections: HashMap, @@ -307,6 +306,19 @@ pub async fn to_writer<'a, W: tokio::io::AsyncWriteExt + Unpin + ?Sized>( Ok(()) } +/// Inserts the final line ending into `rope` if it's missing. [Why?](https://stackoverflow.com/questions/729692/why-should-text-files-end-with-a-newline) +pub fn with_line_ending(rope: &mut Rope) -> LineEnding { + // search for line endings + let line_ending = auto_detect_line_ending(rope).unwrap_or(DEFAULT_LINE_ENDING); + + // add missing newline at the end of file + if rope.len_bytes() == 0 || !char_is_line_ending(rope.char(rope.len_chars() - 1)) { + rope.insert(rope.len_chars(), line_ending.as_str()); + } + + line_ending +} + /// Like std::mem::replace() except it allows the replacement value to be mapped from the /// original value. fn take_with(mut_ref: &mut T, closure: F) @@ -395,12 +407,13 @@ pub fn normalize_path(path: &Path) -> PathBuf { /// This function is used instead of `std::fs::canonicalize` because we don't want to verify /// here if the path exists, just normalize it's components. pub fn canonicalize_path(path: &Path) -> std::io::Result { - let normalized = normalize_path(path); - if normalized.is_absolute() { - Ok(normalized) + let path = if path.is_relative() { + std::env::current_dir().map(|current_dir| current_dir.join(path))? } else { - std::env::current_dir().map(|current_dir| current_dir.join(normalized)) - } + path.to_path_buf() + }; + + Ok(normalize_path(&path)) } use helix_lsp::lsp; @@ -448,7 +461,8 @@ impl Document { } let mut file = std::fs::File::open(&path).context(format!("unable to open {:?}", path))?; - let (rope, encoding) = from_reader(&mut file, encoding)?; + let (mut rope, encoding) = from_reader(&mut file, encoding)?; + let line_ending = with_line_ending(&mut rope); let mut doc = Self::from(rope, Some(encoding)); @@ -458,9 +472,9 @@ impl Document { doc.detect_language(theme, loader); } - // Detect indentation style and line ending. + // Detect indentation style and set line ending. doc.detect_indent_style(); - doc.line_ending = auto_detect_line_ending(&doc.text).unwrap_or(DEFAULT_LINE_ENDING); + doc.line_ending = line_ending; Ok(doc) } @@ -578,6 +592,45 @@ impl Document { } } + /// Reload the document from its path. + pub fn reload(&mut self, view_id: ViewId) -> Result<(), Error> { + let encoding = &self.encoding; + let path = self.path().filter(|path| path.exists()); + + // If there is no path or the path no longer exists. + if path.is_none() { + return Err(anyhow!("can't find file to reload from")); + } + + let mut file = std::fs::File::open(path.unwrap())?; + let (mut rope, ..) = from_reader(&mut file, Some(encoding))?; + let line_ending = with_line_ending(&mut rope); + + let transaction = helix_core::diff::compare_ropes(self.text(), &rope); + self.apply(&transaction, view_id); + self.append_changes_to_history(view_id); + + // Detect indentation style and set line ending. + self.detect_indent_style(); + self.line_ending = line_ending; + + Ok(()) + } + + /// Sets the [`Document`]'s encoding with the encoding correspondent to `label`. + pub fn set_encoding(&mut self, label: &str) -> Result<(), Error> { + match encoding_rs::Encoding::for_label(label.as_bytes()) { + Some(encoding) => self.encoding = encoding, + None => return Err(anyhow::anyhow!("unknown encoding")), + } + Ok(()) + } + + /// Returns the [`Document`]'s current encoding. + pub fn encoding(&self) -> &'static encoding_rs::Encoding { + self.encoding + } + fn detect_indent_style(&mut self) { // Build a histogram of the indentation *increases* between // subsequent lines, ignoring lines that are all whitespace. @@ -996,14 +1049,11 @@ impl Document { let cwdir = std::env::current_dir().expect("couldn't determine current directory"); self.path.as_ref().map(|path| { - let path = fold_home_dir(path); - if path.is_relative() { - path - } else { - path.strip_prefix(cwdir) - .map(|p| p.to_path_buf()) - .unwrap_or(path) - } + let mut path = path.as_path(); + if path.is_absolute() { + path = path.strip_prefix(cwdir).unwrap_or(path) + }; + fold_home_dir(path) }) } diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index a16cc50f8..4f01cce40 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1,6 +1,7 @@ use crate::{ clipboard::{get_clipboard_provider, ClipboardProvider}, graphics::{CursorKind, Rect}, + info::Info, theme::{self, Theme}, tree::Tree, Document, DocumentId, RegisterSelection, View, ViewId, @@ -32,6 +33,7 @@ pub struct Editor { pub syn_loader: Arc, pub theme_loader: Arc, + pub autoinfo: Option<&'static Info>, pub status_msg: Option<(String, Severity)>, } @@ -64,6 +66,7 @@ impl Editor { theme_loader: themes, registers: Registers::default(), clipboard_provider: get_clipboard_provider(), + autoinfo: None, status_msg: None, } } diff --git a/helix-view/src/info.rs b/helix-view/src/info.rs new file mode 100644 index 000000000..f3df50fe3 --- /dev/null +++ b/helix-view/src/info.rs @@ -0,0 +1,57 @@ +use crate::input::KeyEvent; +use helix_core::unicode::width::UnicodeWidthStr; +use std::fmt::Write; + +#[derive(Debug)] +/// Info box used in editor. Rendering logic will be in other crate. +pub struct Info { + /// Title kept as static str for now. + pub title: &'static str, + /// Text body, should contains newline. + pub text: String, + /// Body width. + pub width: u16, + /// Body height. + pub height: u16, +} + +impl Info { + pub fn key(title: &'static str, body: Vec<(&[KeyEvent], &'static str)>) -> Info { + let (lpad, mpad, rpad) = (1, 2, 1); + let keymaps_width: u16 = body + .iter() + .map(|r| r.0.iter().map(|e| e.width() as u16 + 2).sum::() - 2) + .max() + .unwrap(); + let mut text = String::new(); + let mut width = 0; + let height = body.len() as u16; + for (keyevents, desc) in body { + let keyevent = keyevents[0]; + let mut left = keymaps_width - keyevent.width() as u16; + for _ in 0..lpad { + text.push(' '); + } + write!(text, "{}", keyevent).ok(); + for keyevent in &keyevents[1..] { + write!(text, ", {}", keyevent).ok(); + left -= 2 + keyevent.width() as u16; + } + for _ in 0..left + mpad { + text.push(' '); + } + let desc = desc.trim(); + let w = lpad + keymaps_width + mpad + (desc.width() as u16) + rpad; + if w > width { + width = w; + } + writeln!(text, "{}", desc).ok(); + } + Info { + title, + text, + width, + height, + } + } +} diff --git a/helix-view/src/input.rs b/helix-view/src/input.rs index 5f61ce14f..2847bb696 100644 --- a/helix-view/src/input.rs +++ b/helix-view/src/input.rs @@ -1,5 +1,6 @@ //! Input event handling, currently backed by crossterm. use anyhow::{anyhow, Error}; +use helix_core::unicode::width::UnicodeWidthStr; use serde::de::{self, Deserialize, Deserializer}; use std::fmt; @@ -13,6 +14,32 @@ pub struct KeyEvent { pub modifiers: KeyModifiers, } +pub(crate) mod keys { + pub(crate) const BACKSPACE: &str = "backspace"; + pub(crate) const ENTER: &str = "ret"; + pub(crate) const LEFT: &str = "left"; + pub(crate) const RIGHT: &str = "right"; + pub(crate) const UP: &str = "up"; + pub(crate) const DOWN: &str = "down"; + pub(crate) const HOME: &str = "home"; + pub(crate) const END: &str = "end"; + pub(crate) const PAGEUP: &str = "pageup"; + pub(crate) const PAGEDOWN: &str = "pagedown"; + pub(crate) const TAB: &str = "tab"; + pub(crate) const BACKTAB: &str = "backtab"; + pub(crate) const DELETE: &str = "del"; + pub(crate) const INSERT: &str = "ins"; + pub(crate) const NULL: &str = "null"; + pub(crate) const ESC: &str = "esc"; + pub(crate) const SPACE: &str = "space"; + pub(crate) const LESS_THAN: &str = "lt"; + pub(crate) const GREATER_THAN: &str = "gt"; + pub(crate) const PLUS: &str = "plus"; + pub(crate) const MINUS: &str = "minus"; + pub(crate) const SEMICOLON: &str = "semicolon"; + pub(crate) const PERCENT: &str = "percent"; +} + impl fmt::Display for KeyEvent { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result { f.write_fmt(format_args!( @@ -34,28 +61,29 @@ impl fmt::Display for KeyEvent { }, ))?; match self.code { - KeyCode::Backspace => f.write_str("backspace")?, - KeyCode::Enter => f.write_str("ret")?, - KeyCode::Left => f.write_str("left")?, - KeyCode::Right => f.write_str("right")?, - KeyCode::Up => f.write_str("up")?, - KeyCode::Down => f.write_str("down")?, - KeyCode::Home => f.write_str("home")?, - KeyCode::End => f.write_str("end")?, - KeyCode::PageUp => f.write_str("pageup")?, - KeyCode::PageDown => f.write_str("pagedown")?, - KeyCode::Tab => f.write_str("tab")?, - KeyCode::BackTab => f.write_str("backtab")?, - KeyCode::Delete => f.write_str("del")?, - KeyCode::Insert => f.write_str("ins")?, - KeyCode::Null => f.write_str("null")?, - KeyCode::Esc => f.write_str("esc")?, - KeyCode::Char('<') => f.write_str("lt")?, - KeyCode::Char('>') => f.write_str("gt")?, - KeyCode::Char('+') => f.write_str("plus")?, - KeyCode::Char('-') => f.write_str("minus")?, - KeyCode::Char(';') => f.write_str("semicolon")?, - KeyCode::Char('%') => f.write_str("percent")?, + KeyCode::Backspace => f.write_str(keys::BACKSPACE)?, + KeyCode::Enter => f.write_str(keys::ENTER)?, + KeyCode::Left => f.write_str(keys::LEFT)?, + KeyCode::Right => f.write_str(keys::RIGHT)?, + KeyCode::Up => f.write_str(keys::UP)?, + KeyCode::Down => f.write_str(keys::DOWN)?, + KeyCode::Home => f.write_str(keys::HOME)?, + KeyCode::End => f.write_str(keys::END)?, + KeyCode::PageUp => f.write_str(keys::PAGEUP)?, + KeyCode::PageDown => f.write_str(keys::PAGEDOWN)?, + KeyCode::Tab => f.write_str(keys::TAB)?, + KeyCode::BackTab => f.write_str(keys::BACKTAB)?, + KeyCode::Delete => f.write_str(keys::DELETE)?, + KeyCode::Insert => f.write_str(keys::INSERT)?, + KeyCode::Null => f.write_str(keys::NULL)?, + KeyCode::Esc => f.write_str(keys::ESC)?, + KeyCode::Char(' ') => f.write_str(keys::SPACE)?, + KeyCode::Char('<') => f.write_str(keys::LESS_THAN)?, + KeyCode::Char('>') => f.write_str(keys::GREATER_THAN)?, + KeyCode::Char('+') => f.write_str(keys::PLUS)?, + KeyCode::Char('-') => f.write_str(keys::MINUS)?, + KeyCode::Char(';') => f.write_str(keys::SEMICOLON)?, + KeyCode::Char('%') => f.write_str(keys::PERCENT)?, KeyCode::F(i) => f.write_fmt(format_args!("F{}", i))?, KeyCode::Char(c) => f.write_fmt(format_args!("{}", c))?, }; @@ -63,34 +91,83 @@ impl fmt::Display for KeyEvent { } } +impl UnicodeWidthStr for KeyEvent { + fn width(&self) -> usize { + use helix_core::unicode::width::UnicodeWidthChar; + let mut width = match self.code { + KeyCode::Backspace => keys::BACKSPACE.len(), + KeyCode::Enter => keys::ENTER.len(), + KeyCode::Left => keys::LEFT.len(), + KeyCode::Right => keys::RIGHT.len(), + KeyCode::Up => keys::UP.len(), + KeyCode::Down => keys::DOWN.len(), + KeyCode::Home => keys::HOME.len(), + KeyCode::End => keys::END.len(), + KeyCode::PageUp => keys::PAGEUP.len(), + KeyCode::PageDown => keys::PAGEDOWN.len(), + KeyCode::Tab => keys::TAB.len(), + KeyCode::BackTab => keys::BACKTAB.len(), + KeyCode::Delete => keys::DELETE.len(), + KeyCode::Insert => keys::INSERT.len(), + KeyCode::Null => keys::NULL.len(), + KeyCode::Esc => keys::ESC.len(), + KeyCode::Char(' ') => keys::SPACE.len(), + KeyCode::Char('<') => keys::LESS_THAN.len(), + KeyCode::Char('>') => keys::GREATER_THAN.len(), + KeyCode::Char('+') => keys::PLUS.len(), + KeyCode::Char('-') => keys::MINUS.len(), + KeyCode::Char(';') => keys::SEMICOLON.len(), + KeyCode::Char('%') => keys::PERCENT.len(), + KeyCode::F(1..=9) => 2, + KeyCode::F(_) => 3, + KeyCode::Char(c) => c.width().unwrap_or(0), + }; + if self.modifiers.contains(KeyModifiers::SHIFT) { + width += 2; + } + if self.modifiers.contains(KeyModifiers::ALT) { + width += 2; + } + if self.modifiers.contains(KeyModifiers::CONTROL) { + width += 2; + } + width + } + + fn width_cjk(&self) -> usize { + self.width() + } +} + impl std::str::FromStr for KeyEvent { type Err = Error; fn from_str(s: &str) -> Result { let mut tokens: Vec<_> = s.split('-').collect(); let code = match tokens.pop().ok_or_else(|| anyhow!("Missing key code"))? { - "backspace" => KeyCode::Backspace, - "space" => KeyCode::Char(' '), - "ret" => KeyCode::Enter, - "lt" => KeyCode::Char('<'), - "gt" => KeyCode::Char('>'), - "plus" => KeyCode::Char('+'), - "minus" => KeyCode::Char('-'), - "semicolon" => KeyCode::Char(';'), - "percent" => KeyCode::Char('%'), - "left" => KeyCode::Left, - "right" => KeyCode::Right, - "up" => KeyCode::Down, - "home" => KeyCode::Home, - "end" => KeyCode::End, - "pageup" => KeyCode::PageUp, - "pagedown" => KeyCode::PageDown, - "tab" => KeyCode::Tab, - "backtab" => KeyCode::BackTab, - "del" => KeyCode::Delete, - "ins" => KeyCode::Insert, - "null" => KeyCode::Null, - "esc" => KeyCode::Esc, + keys::BACKSPACE => KeyCode::Backspace, + keys::ENTER => KeyCode::Enter, + keys::LEFT => KeyCode::Left, + keys::RIGHT => KeyCode::Right, + keys::UP => KeyCode::Up, + keys::DOWN => KeyCode::Down, + keys::HOME => KeyCode::Home, + keys::END => KeyCode::End, + keys::PAGEUP => KeyCode::PageUp, + keys::PAGEDOWN => KeyCode::PageDown, + keys::TAB => KeyCode::Tab, + keys::BACKTAB => KeyCode::BackTab, + keys::DELETE => KeyCode::Delete, + keys::INSERT => KeyCode::Insert, + keys::NULL => KeyCode::Null, + keys::ESC => KeyCode::Esc, + keys::SPACE => KeyCode::Char(' '), + keys::LESS_THAN => KeyCode::Char('<'), + keys::GREATER_THAN => KeyCode::Char('>'), + keys::PLUS => KeyCode::Char('+'), + keys::MINUS => KeyCode::Char('-'), + keys::SEMICOLON => KeyCode::Char(';'), + keys::PERCENT => KeyCode::Char('%'), single if single.len() == 1 => KeyCode::Char(single.chars().next().unwrap()), function if function.len() > 1 && function.starts_with('F') => { let function: String = function.chars().skip(1).collect(); diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index caed29523..9bcc0b7d5 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -5,6 +5,7 @@ pub mod clipboard; pub mod document; pub mod editor; pub mod graphics; +pub mod info; pub mod input; pub mod keyboard; pub mod register_selection; diff --git a/languages.toml b/languages.toml index f7564c885..204a59878 100644 --- a/languages.toml +++ b/languages.toml @@ -165,6 +165,15 @@ roots = [] indent = { tab-width = 4, unit = "\t" } +[[language]] +name = "julia" +scope = "source.julia" +injection-regex = "julia" +file-types = ["jl"] +roots = [] +language-server = { command = "julia", args = [ "--startup-file=no", "--history-file=no", "-e", "using LanguageServer;using Pkg;import StaticLint;import SymbolServer;env_path = dirname(Pkg.Types.Context().env.project_file);server = LanguageServer.LanguageServerInstance(stdin, stdout, env_path, \"\");server.runlinter = true;run(server);" ] } +indent = { tab-width = 2, unit = " " } + # [[language]] # name = "haskell" # scope = "source.haskell" diff --git a/runtime/queries/julia/folds.scm b/runtime/queries/julia/folds.scm new file mode 100644 index 000000000..91eede5f6 --- /dev/null +++ b/runtime/queries/julia/folds.scm @@ -0,0 +1,11 @@ +[ + (module_definition) + (struct_definition) + (macro_definition) + (function_definition) + (compound_expression) ; begin blocks + (let_statement) + (if_statement) + (for_statement) + (while_statement) +] @fold diff --git a/runtime/queries/julia/highlights.scm b/runtime/queries/julia/highlights.scm new file mode 100644 index 000000000..a53dabe53 --- /dev/null +++ b/runtime/queries/julia/highlights.scm @@ -0,0 +1,180 @@ +(identifier) @variable +;; In case you want type highlighting based on Julia naming conventions (this might collide with mathematical notation) +;((identifier) @type ; exception: mark `A_foo` sort of identifiers as variables + ;(match? @type "^[A-Z][^_]")) +((identifier) @constant + (match? @constant "^[A-Z][A-Z_]{2}[A-Z_]*$")) + +[ + (triple_string) + (string) +] @string + +(string + prefix: (identifier) @constant.builtin) + +(macro_identifier) @function.macro +(macro_identifier (identifier) @function.macro) ; for any one using the variable highlight +(macro_definition + name: (identifier) @function.macro + ["macro" "end" @keyword]) + +(field_expression + (identifier) + (identifier) @field .) + +(function_definition + name: (identifier) @function) +(call_expression + (identifier) @function) +(call_expression + (field_expression (identifier) @method .)) +(broadcast_call_expression + (identifier) @function) +(broadcast_call_expression + (field_expression (identifier) @method .)) +(parameter_list + (identifier) @parameter) +(parameter_list + (optional_parameter . + (identifier) @parameter)) +(typed_parameter + (identifier) @parameter + (identifier) @type) +(type_parameter_list + (identifier) @type) +(typed_parameter + (identifier) @parameter + (parameterized_identifier) @type) +(function_expression + . (identifier) @parameter) +(spread_parameter) @parameter +(spread_parameter + (identifier) @parameter) +(named_argument + . (identifier) @parameter) +(argument_list + (typed_expression + (identifier) @parameter + (identifier) @type)) +(argument_list + (typed_expression + (identifier) @parameter + (parameterized_identifier) @type)) + +;; Symbol expressions (:my-wanna-be-lisp-keyword) +(quote_expression + (identifier)) @symbol + +;; Parsing error! foo (::Type) get's parsed as two quote expressions +(argument_list + (quote_expression + (quote_expression + (identifier) @type))) + +(type_argument_list + (identifier) @type) +(parameterized_identifier (_)) @type +(argument_list + (typed_expression . (identifier) @parameter)) + +(typed_expression + (identifier) @type .) +(typed_expression + (parameterized_identifier) @type .) + +(struct_definition + name: (identifier) @type) + +(number) @number +(range_expression + (identifier) @number + (eq? @number "end")) +(range_expression + (_ + (identifier) @number + (eq? @number "end"))) +(coefficient_expression + (number) + (identifier) @constant.builtin) + +;; TODO: operators. +;; Those are a bit difficult to implement since the respective nodes are hidden right now (_power_operator) +;; and heavily use Unicode chars (support for those are bad in vim/lua regexes) +;[; + ;(power_operator); + ;(times_operator); + ;(plus_operator); + ;(arrow_operator); + ;(comparison_operator); + ;(assign_operator); +;] @operator ; + +"end" @keyword + +(if_statement + ["if" "end"] @conditional) +(elseif_clause + ["elseif"] @conditional) +(else_clause + ["else"] @conditional) +(ternary_expression + ["?" ":"] @conditional) + +(function_definition ["function" "end"] @keyword.function) + +(comment) @comment + +[ + "const" + "return" + "macro" + "struct" + "primitive" + "type" +] @keyword + +((identifier) @keyword (#any-of? @keyword "global" "local")) + +(compound_expression + ["begin" "end"] @keyword) +(try_statement + ["try" "end" ] @exception) +(finally_clause + "finally" @exception) +(catch_clause + "catch" @exception) +(quote_statement + ["quote" "end"] @keyword) +(let_statement + ["let" "end"] @keyword) +(for_statement + ["for" "end"] @repeat) +(while_statement + ["while" "end"] @repeat) +(break_statement) @repeat +(continue_statement) @repeat +(for_binding + "in" @repeat) +(for_clause + "for" @repeat) +(do_clause + ["do" "end"] @keyword) + +(export_statement + ["export"] @include) + +[ + "using" + "module" + "import" +] @include + +((identifier) @include (#eq? @include "baremodule")) + +(((identifier) @constant.builtin) (match? @constant.builtin "^(nothing|Inf|NaN)$")) +(((identifier) @boolean) (eq? @boolean "true")) +(((identifier) @boolean) (eq? @boolean "false")) + +["::" ":" "." "," "..." "!"] @punctuation.delimiter +["[" "]" "(" ")" "{" "}"] @punctuation.bracket diff --git a/runtime/queries/julia/injections.scm b/runtime/queries/julia/injections.scm new file mode 100644 index 000000000..be2412c06 --- /dev/null +++ b/runtime/queries/julia/injections.scm @@ -0,0 +1,5 @@ +; TODO: re-add when markdown is added. +; ((triple_string) @markdown +; (#offset! @markdown 0 3 0 -3)) + +(comment) @comment diff --git a/runtime/queries/julia/locals.scm b/runtime/queries/julia/locals.scm new file mode 100644 index 000000000..f8b34f71d --- /dev/null +++ b/runtime/queries/julia/locals.scm @@ -0,0 +1,59 @@ + +(import_statement + (identifier) @definition.import) +(variable_declaration + (identifier) @definition.var) +(variable_declaration + (tuple_expression + (identifier) @definition.var)) +(for_binding + (identifier) @definition.var) +(for_binding + (tuple_expression + (identifier) @definition.var)) + +(assignment_expression + (tuple_expression + (identifier) @definition.var)) +(assignment_expression + (bare_tuple_expression + (identifier) @definition.var)) +(assignment_expression + (identifier) @definition.var) + +(type_parameter_list + (identifier) @definition.type) +(type_argument_list + (identifier) @definition.type) +(struct_definition + name: (identifier) @definition.type) + +(parameter_list + (identifier) @definition.parameter) +(typed_parameter + (identifier) @definition.parameter + (identifier)) +(function_expression + . (identifier) @definition.parameter) +(argument_list + (typed_expression + (identifier) @definition.parameter + (identifier))) +(spread_parameter + (identifier) @definition.parameter) + +(function_definition + name: (identifier) @definition.function) @scope +(macro_definition + name: (identifier) @definition.macro) @scope + +(identifier) @reference + +[ + (try_statement) + (finally_clause) + (quote_statement) + (let_statement) + (compound_expression) + (for_statement) +] @scope diff --git a/runtime/themes/onedark.toml b/runtime/themes/onedark.toml index f478b05f8..508109dd3 100644 --- a/runtime/themes/onedark.toml +++ b/runtime/themes/onedark.toml @@ -29,16 +29,26 @@ "warning" = { fg = "#e5c07b", modifiers = ['bold'] } "error" = { fg = "#e06c75", modifiers = ['bold'] } -"ui.menu.selected" = { fg = "#282C34", bg = "#61AFEF" } "ui.background" = { fg = "#ABB2BF", bg = "#282C34" } -"ui.help" = { bg = "#3E4452" } + +"ui.cursor" = { fg = "#ABB2BF", modifiers = ["reversed"] } +"ui.cursor.primary" = { fg = "#ABB2BF", modifiers = ["reversed"] } +"ui.cursor.match" = { fg = "#61AFEF", modifiers = ['underlined']} + +"ui.selection" = { bg = "#5C6370" } +"ui.selection.primary" = { bg = "#3E4452" } + "ui.linenr" = { fg = "#4B5263", modifiers = ['dim'] } "ui.linenr.selected" = { fg = "#ABB2BF" } -"ui.popup" = { bg = "#3E4452" } + "ui.statusline" = { fg = "#ABB2BF", bg = "#2C323C" } -"ui.statusline.inactive" = { fg = "#ABB2Bf", bg = "#2C323C" } -"ui.selection" = { bg = "#3E4452" } +"ui.statusline.inactive" = { fg = "#5C6370", bg = "#2C323C" } + "ui.text" = { fg = "#ABB2BF", bg = "#282C34" } "ui.text.focus" = { fg = "#ABB2BF", bg = "#2C323C", modifiers = ['bold'] } + +"ui.help" = { bg = "#3E4452" } +"ui.popup" = { bg = "#3E4452" } "ui.window" = { bg = "#3E4452" } -# "ui.cursor.match" # TODO might want to override this because dimmed is not widely supported +"ui.menu.selected" = { fg = "#282C34", bg = "#61AFEF" } +