Titles are typed right in their boxes on the canvas: the box grows with the text, the terminal's cursor sits in it (a bar while typing, a block on the letter with vim keys), and the border turns the insert color. `i`, `A` and Enter edit at the end, `I` at the start, `cc` and `S` type the title anew. The document follows the text as it's typed, so the layout stays current, and the whole edit is one undo step. Esc gives vim's normal mode inside the text (TextEditor, in ui/): h l w b e 0 ^ $, f t F T with ; and ,, the d c y operators with any of those motions or the iw aw i" a" i( a( text objects, x X s S D C Y, r ~, p P, counts, u and Ctrl-r for the edit's own changes, and `.` for the last one, typing included. j k gg G and J work on lines, for bodies. It follows vim where it's quirky too: cw acts like ce, dw stops at the end of the line, Esc steps back onto the last letter. Keys have two new tables, `text` and `insert`, and in text an operator takes motions as targets (`dw`, `c$`, `2d3fx`), which which-key lists. Rapid entry: after `o`, `O` or `a`, the new idea's title is typed at once; Enter starts the next idea, Tab puts the one being typed under the idea above, Shift-Tab takes it out a level, and Enter on an empty idea stops. An idea left empty goes away without a trace in the undo history (a group whose edits cancel out leaves no step); a title left empty comes back as it was. Inside a title, `o` and `O` start a new sibling. Registers are shared by the whole app, so text yanked in one title pastes into another; `"a`-`"z` and `"_` work, and `"+` stays inside cumulus until the app core connects the clipboard. The mouse while editing: a click in the text moves the text cursor, a click anywhere else keeps the text and finishes the edit, and a double-click on a title starts editing at that letter. `>>` and `<<` now move nodes: under the node above, as its last child, or out a level, right after the old parent, with counts taking in the next siblings. The command line's input became TextInput, line-aware so the editor can use it for bodies too; bodies themselves get edited in the Note view. The example's tests type every key above into the app and check the text and the cursor after each one against what vim does. Co-Authored-By: Claude Opus 5.5 <[email protected]>
153 lines
6.0 KiB
Rust
153 lines
6.0 KiB
Rust
//! Text objects: the word, the quoted text or the bracketed text the cursor
|
|
//! is in, for `diw`, `ca"` or `yi(`.
|
|
|
|
use std::ops::Range;
|
|
|
|
use super::motion::{Class, class_at, line_end, line_start, next, previous};
|
|
use crate::input::command::TextObject;
|
|
|
|
/// What a text object covers at `at` (with its edges when `around`), or
|
|
/// `None` when the cursor isn't in one.
|
|
pub fn range(text: &str, at: usize, object: TextObject, around: bool) -> Option<Range<usize>> {
|
|
match object {
|
|
TextObject::Word => word(text, at, around),
|
|
TextObject::Quotes => quotes(text, at, around),
|
|
TextObject::Parens => parens(text, at, around),
|
|
}
|
|
}
|
|
|
|
/// `iw`: the word (or the run of spaces) the cursor is on. `aw`: the word
|
|
/// and the spaces after it, or else the ones before it.
|
|
fn word(text: &str, at: usize, around: bool) -> Option<Range<usize>> {
|
|
let (start, end) = (line_start(text, at), line_end(text, at));
|
|
let class = class_at(text, at).filter(|_| at < end)?;
|
|
let run = |from: usize, class: Class| {
|
|
let mut to = from;
|
|
while to < end && class_at(text, to) == Some(class) {
|
|
to = next(text, to);
|
|
}
|
|
to
|
|
};
|
|
let mut first = at;
|
|
while first > start && class_at(text, previous(text, first)) == Some(class) {
|
|
first = previous(text, first);
|
|
}
|
|
let last = run(at, class);
|
|
if !around {
|
|
return Some(first..last);
|
|
}
|
|
if class == Class::Blank {
|
|
// On spaces, `aw` takes them and the word after.
|
|
return Some(first..class_at(text, last).map_or(last, |after| run(last, after)));
|
|
}
|
|
let spaces_after = run(last, Class::Blank);
|
|
if spaces_after > last {
|
|
return Some(first..spaces_after);
|
|
}
|
|
let mut spaces_before = first;
|
|
while spaces_before > start && class_at(text, previous(text, spaces_before)) == Some(Class::Blank) {
|
|
spaces_before = previous(text, spaces_before);
|
|
}
|
|
Some(spaces_before..last)
|
|
}
|
|
|
|
/// `i"`: the text between the quotes the cursor is in or on, on its line;
|
|
/// the first quoted text after the cursor when it's in none. `a"`: the
|
|
/// quotes too, and the spaces after them (or else before), as in vim.
|
|
fn quotes(text: &str, at: usize, around: bool) -> Option<Range<usize>> {
|
|
let (start, end) = (line_start(text, at), line_end(text, at));
|
|
let quotes: Vec<usize> = text[start..end].match_indices('"').map(|(found, _)| start + found).collect();
|
|
let (open, close) = match quotes.iter().position(|"e| quote == at) {
|
|
Some(index) if index % 2 == 0 => (index, index + 1),
|
|
Some(index) => (index - 1, index),
|
|
None => {
|
|
let before = quotes.iter().filter(|&"e| quote < at).count();
|
|
if before % 2 == 1 { (before - 1, before) } else { (before, before + 1) }
|
|
}
|
|
};
|
|
let (open, close) = (*quotes.get(open)?, *quotes.get(close)?);
|
|
if !around {
|
|
return Some(open + 1..close);
|
|
}
|
|
// Quotes and spaces are one byte each.
|
|
let after = close + 1 + text[close + 1..end].len() - text[close + 1..end].trim_start_matches(' ').len();
|
|
let before = if after > close + 1 { open } else { start + text[start..open].trim_end_matches(' ').len() };
|
|
Some(before..after)
|
|
}
|
|
|
|
/// `i(`: what's between the brackets around the cursor (they can be on
|
|
/// other lines). `a(`: the brackets too.
|
|
fn parens(text: &str, at: usize, around: bool) -> Option<Range<usize>> {
|
|
let open = if text[at..].starts_with('(') {
|
|
at
|
|
} else {
|
|
let mut depth = 0;
|
|
let mut found = None;
|
|
for (index, c) in text[..at].char_indices().rev() {
|
|
match c {
|
|
')' => depth += 1,
|
|
'(' if depth == 0 => {
|
|
found = Some(index);
|
|
break;
|
|
}
|
|
'(' => depth -= 1,
|
|
_ => {}
|
|
}
|
|
}
|
|
found?
|
|
};
|
|
let mut depth = 0;
|
|
let mut close = None;
|
|
for (index, c) in text[open + 1..].char_indices() {
|
|
match c {
|
|
'(' => depth += 1,
|
|
')' if depth == 0 => {
|
|
close = Some(open + 1 + index);
|
|
break;
|
|
}
|
|
')' => depth -= 1,
|
|
_ => {}
|
|
}
|
|
}
|
|
let close = close?;
|
|
Some(if around { open..close + 1 } else { open + 1..close })
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The text an object covers, the cursor at '|' in `marked`.
|
|
fn covered(marked: &str, object: TextObject, around: bool) -> Option<String> {
|
|
let at = marked.find('|').unwrap();
|
|
let text = marked.replacen('|', "", 1);
|
|
range(&text, at, object, around).map(|range| text[range].to_string())
|
|
}
|
|
|
|
#[test]
|
|
fn words() {
|
|
assert_eq!(covered("raised b|eds here", TextObject::Word, false).unwrap(), "beds");
|
|
assert_eq!(covered("raised b|eds here", TextObject::Word, true).unwrap(), "beds ");
|
|
assert_eq!(covered("raised b|eds", TextObject::Word, true).unwrap(), " beds", "no space after");
|
|
assert_eq!(covered("raised | beds", TextObject::Word, true).unwrap(), " beds", "on spaces");
|
|
assert_eq!(covered("|", TextObject::Word, false), None, "nothing on an empty line");
|
|
}
|
|
|
|
#[test]
|
|
fn quotes_and_brackets() {
|
|
let quoted = "say \"hi there\" now";
|
|
assert_eq!(covered("say \"hi th|ere\" now", TextObject::Quotes, false).unwrap(), "hi there");
|
|
assert_eq!(covered("say \"hi th|ere\" now", TextObject::Quotes, true).unwrap(), "\"hi there\" ");
|
|
assert_eq!(
|
|
covered("s|ay \"hi there\" now", TextObject::Quotes, false).unwrap(),
|
|
"hi there",
|
|
"the next pair"
|
|
);
|
|
assert_eq!(covered(&format!("{quoted}|"), TextObject::Quotes, false), None);
|
|
assert_eq!(covered("f(a, (b|), c)", TextObject::Parens, false).unwrap(), "b");
|
|
assert_eq!(covered("f(a, (b), |c)", TextObject::Parens, true).unwrap(), "(a, (b), c)");
|
|
assert_eq!(covered("f(a,\n|b)", TextObject::Parens, false).unwrap(), "a,\nb", "across lines");
|
|
assert_eq!(covered("no |brackets", TextObject::Parens, false), None);
|
|
}
|
|
}
|