tuiの単語境界カーソル移動実装
This commit is contained in:
parent
588c25a570
commit
0ad3923932
|
|
@ -404,6 +404,12 @@ impl App {
|
|||
pub fn move_cursor_right(&mut self) {
|
||||
self.input.move_right();
|
||||
}
|
||||
pub fn move_cursor_word_left(&mut self) {
|
||||
self.input.move_word_left();
|
||||
}
|
||||
pub fn move_cursor_word_right(&mut self) {
|
||||
self.input.move_word_right();
|
||||
}
|
||||
pub fn move_cursor_home(&mut self) {
|
||||
self.input.move_home();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,48 @@ pub enum Atom {
|
|||
Paste(PasteRef),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum AtomClass {
|
||||
Word(WordKind),
|
||||
Sep,
|
||||
Paste,
|
||||
}
|
||||
|
||||
/// Sub-classification of word atoms. A run of equal `WordKind` is one word;
|
||||
/// a kind switch is a word boundary. Lets `Ctrl+Left/Right` step over
|
||||
/// runs of hiragana/katakana/han/ASCII independently when they sit adjacent.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WordKind {
|
||||
Ascii,
|
||||
Hiragana,
|
||||
Katakana,
|
||||
Han,
|
||||
Other,
|
||||
}
|
||||
|
||||
fn atom_class(atom: &Atom) -> AtomClass {
|
||||
match atom {
|
||||
Atom::Paste(_) => AtomClass::Paste,
|
||||
Atom::Char(c) => char_class(*c),
|
||||
}
|
||||
}
|
||||
|
||||
fn char_class(c: char) -> AtomClass {
|
||||
if c.is_ascii_alphanumeric() || c == '_' {
|
||||
return AtomClass::Word(WordKind::Ascii);
|
||||
}
|
||||
let cp = c as u32;
|
||||
match cp {
|
||||
0x3040..=0x309F => AtomClass::Word(WordKind::Hiragana),
|
||||
0x30A0..=0x30FF | 0x31F0..=0x31FF => AtomClass::Word(WordKind::Katakana),
|
||||
0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x2FFFF => {
|
||||
AtomClass::Word(WordKind::Han)
|
||||
}
|
||||
_ if c.is_alphanumeric() => AtomClass::Word(WordKind::Other),
|
||||
_ => AtomClass::Sep,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InputBuffer {
|
||||
atoms: Vec<Atom>,
|
||||
/// Insertion point in `0..=atoms.len()`.
|
||||
|
|
@ -114,6 +156,38 @@ impl InputBuffer {
|
|||
self.cursor = (self.cursor + 1).min(self.atoms.len());
|
||||
}
|
||||
|
||||
/// Move backward by one word. Skips a run of separators, then a run of
|
||||
/// atoms sharing the same [`AtomClass`] — so `Word(Hiragana)` next to
|
||||
/// `Word(Han)` are separate blocks, and a `Paste` atom is its own block.
|
||||
pub fn move_word_left(&mut self) {
|
||||
while self.cursor > 0 && atom_class(&self.atoms[self.cursor - 1]) == AtomClass::Sep {
|
||||
self.cursor -= 1;
|
||||
}
|
||||
if self.cursor == 0 {
|
||||
return;
|
||||
}
|
||||
let kind = atom_class(&self.atoms[self.cursor - 1]);
|
||||
while self.cursor > 0 && atom_class(&self.atoms[self.cursor - 1]) == kind {
|
||||
self.cursor -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move forward by one word. Mirror of [`move_word_left`].
|
||||
pub fn move_word_right(&mut self) {
|
||||
while self.cursor < self.atoms.len()
|
||||
&& atom_class(&self.atoms[self.cursor]) == AtomClass::Sep
|
||||
{
|
||||
self.cursor += 1;
|
||||
}
|
||||
if self.cursor == self.atoms.len() {
|
||||
return;
|
||||
}
|
||||
let kind = atom_class(&self.atoms[self.cursor]);
|
||||
while self.cursor < self.atoms.len() && atom_class(&self.atoms[self.cursor]) == kind {
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_home(&mut self) {
|
||||
while self.cursor > 0 {
|
||||
if matches!(self.atoms[self.cursor - 1], Atom::Char('\n')) {
|
||||
|
|
@ -489,3 +563,180 @@ mod submit_segments_tests {
|
|||
assert!(matches!(segs[0], Segment::Paste { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod word_motion_tests {
|
||||
use super::*;
|
||||
|
||||
fn buf_from(text: &str) -> InputBuffer {
|
||||
let mut buf = InputBuffer::new();
|
||||
for c in text.chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
fn cursor(buf: &InputBuffer) -> usize {
|
||||
buf.cursor
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_buffer_is_noop() {
|
||||
let mut buf = InputBuffer::new();
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 0);
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_from_start_lands_after_first_word() {
|
||||
let mut buf = buf_from("foo bar baz");
|
||||
buf.cursor = 0;
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 3); // after "foo"
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 7); // after "foo bar"
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 11); // after "foo bar baz"
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 11); // end stays put
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backward_from_end_lands_at_last_word_start() {
|
||||
let mut buf = buf_from("foo bar baz");
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 8); // start of "baz"
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 4); // start of "bar"
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 0); // start of "foo"
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_runs_of_separators() {
|
||||
let mut buf = buf_from("a , b");
|
||||
buf.cursor = 1; // just after "a"
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 7); // after "b"
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 6); // start of "b"
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 0); // start of "a"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newline_is_a_separator() {
|
||||
let mut buf = buf_from("foo\nbar");
|
||||
buf.cursor = 0;
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 3);
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 7);
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 4);
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_counts_as_one_word() {
|
||||
let mut buf = InputBuffer::new();
|
||||
for c in "foo ".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
buf.insert_paste("anything".into());
|
||||
for c in " bar".chars() {
|
||||
buf.insert_char(c);
|
||||
}
|
||||
// atoms: f o o ' ' [P] ' ' b a r → 9 atoms, paste at index 4
|
||||
let end = 9;
|
||||
buf.cursor = end;
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 6); // start of "bar"
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 4); // before paste
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 0); // start of "foo"
|
||||
|
||||
buf.cursor = 0;
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 3); // after "foo"
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 5); // after paste
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 9); // after "bar"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underscore_is_a_word_char() {
|
||||
let mut buf = buf_from("foo_bar baz");
|
||||
buf.cursor = 0;
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 7); // "foo_bar" is one word
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiragana_run_is_one_word() {
|
||||
// "こんにちは" — 5 hiragana atoms, no separators.
|
||||
let mut buf = buf_from("こんにちは");
|
||||
buf.cursor = 0;
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 5);
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_switch_is_a_word_boundary() {
|
||||
// 漢字 | ひらがな | ASCII
|
||||
let mut buf = buf_from("日本語のtest");
|
||||
buf.cursor = 0;
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 3); // after "日本語"
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 4); // after "の"
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 8); // after "test"
|
||||
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 4); // start of "test"
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 3); // start of "の"
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 0); // start of "日本語"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn katakana_separates_from_ascii() {
|
||||
let mut buf = buf_from("カタカナsecret");
|
||||
buf.cursor = 0;
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 4); // after "カタカナ"
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 10); // after "secret"
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 4);
|
||||
buf.move_word_left();
|
||||
assert_eq!(cursor(&buf), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_punctuation_is_a_separator() {
|
||||
// 「、」 (U+3001) and 「。」 (U+3002) are not word chars.
|
||||
let mut buf = buf_from("読んだ、走った。");
|
||||
buf.cursor = 0;
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 1); // after "読" (han run of 1)
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 3); // after "んだ" (hiragana run)
|
||||
// "、" is sep — skipped, then han "走"
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 5); // after "走"
|
||||
buf.move_word_right();
|
||||
assert_eq!(cursor(&buf), 7); // after "った"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -415,10 +415,18 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
app.delete_char_after();
|
||||
None
|
||||
}
|
||||
KeyCode::Left if ctrl => {
|
||||
app.move_cursor_word_left();
|
||||
None
|
||||
}
|
||||
KeyCode::Left => {
|
||||
app.move_cursor_left();
|
||||
None
|
||||
}
|
||||
KeyCode::Right if ctrl => {
|
||||
app.move_cursor_word_right();
|
||||
None
|
||||
}
|
||||
KeyCode::Right => {
|
||||
app.move_cursor_right();
|
||||
None
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user