tuiの単語単位Backspace

This commit is contained in:
Keisuke Hirata 2026-04-29 21:31:19 +09:00
parent 0ad3923932
commit 862c38d7f7
No known key found for this signature in database
5 changed files with 102 additions and 6 deletions

View File

@ -12,7 +12,7 @@
- [ ] フルスクリーン化によるオーバーホール → [tickets/tui-fullscreen-overhaul.md](tickets/tui-fullscreen-overhaul.md)
- [ ] Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md)
- [ ] ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md)
- [ ] 入力欄の単語単位カーソル移動 → [tickets/tui-input-word-motion.md](tickets/tui-input-word-motion.md)
- [ ] 入力欄の単語単位カーソル移動・削除 → [tickets/tui-input-word-motion.md](tickets/tui-input-word-motion.md)
- [ ] サブミット入力
- [ ] TUI 補完 + 型付き atom 化 → [tickets/submit-tui-completion.md](tickets/submit-tui-completion.md)
- [ ] セッションログの Segment 保持 → [tickets/session-log-segments.md](tickets/session-log-segments.md)

View File

@ -398,6 +398,9 @@ impl App {
pub fn delete_char_after(&mut self) {
self.input.delete_after();
}
pub fn delete_word_before(&mut self) {
self.input.delete_word_before();
}
pub fn move_cursor_left(&mut self) {
self.input.move_left();
}

View File

@ -148,6 +148,15 @@ impl InputBuffer {
}
}
/// Delete one word backward — the same span [`move_word_left`] would
/// jump over.
pub fn delete_word_before(&mut self) {
let end = self.cursor;
self.move_word_left();
let start = self.cursor;
self.atoms.drain(start..end);
}
pub fn move_left(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
@ -724,6 +733,84 @@ mod word_motion_tests {
assert_eq!(cursor(&buf), 0);
}
/// Render atoms as a string for assertions; pastes become `<P>`.
fn as_text(buf: &InputBuffer) -> String {
let mut out = String::new();
for a in &buf.atoms {
match a {
Atom::Char(c) => out.push(*c),
Atom::Paste(_) => out.push_str("<P>"),
}
}
out
}
#[test]
fn delete_word_removes_trailing_word_at_end() {
let mut buf = buf_from("foo bar");
buf.delete_word_before();
assert_eq!(as_text(&buf), "foo ");
assert_eq!(cursor(&buf), 4);
}
#[test]
fn delete_word_removes_word_at_cursor() {
let mut buf = buf_from("foo bar");
buf.cursor = 3; // right after "foo"
buf.delete_word_before();
assert_eq!(as_text(&buf), " bar");
assert_eq!(cursor(&buf), 0);
}
#[test]
fn delete_word_swallows_trailing_separators() {
let mut buf = buf_from("foo ");
buf.delete_word_before();
assert_eq!(as_text(&buf), "");
assert_eq!(cursor(&buf), 0);
}
#[test]
fn delete_word_at_start_is_noop() {
let mut buf = buf_from("foo");
buf.cursor = 0;
buf.delete_word_before();
assert_eq!(as_text(&buf), "foo");
assert_eq!(cursor(&buf), 0);
}
#[test]
fn delete_word_respects_script_boundary() {
// 「日本語の」末尾から1回削除すると、ひらがな部分「の」だけ消える
let mut buf = buf_from("日本語の");
buf.delete_word_before();
assert_eq!(as_text(&buf), "日本語");
assert_eq!(cursor(&buf), 3);
buf.delete_word_before();
assert_eq!(as_text(&buf), "");
assert_eq!(cursor(&buf), 0);
}
#[test]
fn delete_word_treats_paste_as_one_unit() {
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 (cursor at end = 9)
buf.delete_word_before();
assert_eq!(as_text(&buf), "foo <P> ");
assert_eq!(cursor(&buf), 6);
// Next deletion: trailing space then the paste atom (kind=Paste)
buf.delete_word_before();
assert_eq!(as_text(&buf), "foo ");
assert_eq!(cursor(&buf), 4);
}
#[test]
fn japanese_punctuation_is_a_separator() {
// 「、」 (U+3001) and 「。」 (U+3002) are not word chars.

View File

@ -407,6 +407,10 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
None
}
KeyCode::Enter => app.submit_input(),
KeyCode::Backspace if ctrl => {
app.delete_word_before();
None
}
KeyCode::Backspace => {
app.delete_char_before();
None

View File

@ -1,10 +1,10 @@
# TUI: 入力欄の単語単位カーソル移動
# TUI: 入力欄の単語単位カーソル移動・削除
## 背景
TUI の入力欄では現在、`Left/Right` で1文字単位の移動、`Home/End` で行端への移動ができるが、単語単位で飛ぶ手段がない。長めの行を編集するときに左右キーを押し続けることになりテンポが悪い。
TUI の入力欄では現在、`Left/Right` で1文字単位の移動、`Home/End` で行端への移動ができるが、単語単位で飛ぶ手段がない。`Backspace` も1文字ずつしか消せない。長めの行を編集するときに左右キーや Backspace を押し続けることになりテンポが悪い。
シェルやエディタで広く使われている `Ctrl+Left` / `Ctrl+Right` での単語単位移動を提供したい。
シェルやエディタで広く使われている `Ctrl+Left` / `Ctrl+Right` での単語単位移動と、`Ctrl+Backspace` での単語単位削除を提供したい。
## 要件
@ -20,16 +20,18 @@ TUI の入力欄では現在、`Left/Right` で1文字単位の移動、`Home/En
- 同じ種別の連続は1単語、種別が切り替わる位置で境界となる。形態素解析は使わない送り仮名の途中で切れることは許容、VSCode/emacs と同等の挙動)。
- ペースト atom (`Atom::Paste`) は不可分な1単語として扱うカーソルが内部に入らない既存の不変条件を維持する
- 既存の `Ctrl+Home/End` (履歴のスクロール)や `Ctrl+[` / `Ctrl+]` (ターンジャンプ)と衝突しないこと。
- 既存の `Left/Right`1文字移動の挙動は変えない。
- 既存の `Left/Right`1文字移動`Backspace`1文字削除の挙動は変えない。
- `Ctrl+Backspace` でカーソルから1単語ぶん手前を削除する。境界判定は `Ctrl+Left` と同じ(同じロジックを共有)。
## 完了条件
- `crates/tui``Ctrl+Left` / `Ctrl+Right` が単語単位移動として動作する。
- `Ctrl+Backspace` で単語単位削除が動作する。
- 単語境界の判定にユニットテストが付いている(空・連続スペース・`\n` をまたぐ・Paste をまたぐ・ひらがな/カタカナ/漢字/ASCII の混在)。
- 既存テストが通る。
## 範囲外
- `Ctrl+Backspace` / `Alt+Backspace` などによる単語単位削除(別チケット候補)。
- `Ctrl+Delete` / `Alt+d` などによる単語単位の前方削除(別チケット候補)。
- `Alt+Left/Right` など他の単語移動キーバインドの追加。
- 形態素解析による日本語の単語分割(辞書サイズ・起動コストの観点で TUI には過剰)。送り仮名や複合語の途中で切れる挙動は許容する。