From b79747bd0c22ab3fc4a25d63fd8a937c51bd9be4 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 29 Apr 2026 21:31:19 +0900 Subject: [PATCH] =?UTF-8?q?tui=E3=81=AE=E5=8D=98=E8=AA=9E=E5=8D=98?= =?UTF-8?q?=E4=BD=8DBackspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 2 +- crates/tui/src/app.rs | 3 ++ crates/tui/src/input.rs | 87 ++++++++++++++++++++++++++++++++ crates/tui/src/main.rs | 4 ++ tickets/tui-input-word-motion.md | 12 +++-- 5 files changed, 102 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index 45d4512d..c97fe6fb 100644 --- a/TODO.md +++ b/TODO.md @@ -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) diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 53abe3ac..906cef38 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -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(); } diff --git a/crates/tui/src/input.rs b/crates/tui/src/input.rs index d71622a2..7eec869b 100644 --- a/crates/tui/src/input.rs +++ b/crates/tui/src/input.rs @@ -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 `

`. + 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("

"), + } + } + 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

"); + 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. diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 5a01572a..4b5ba8a4 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -407,6 +407,10 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { None } KeyCode::Enter => app.submit_input(), + KeyCode::Backspace if ctrl => { + app.delete_word_before(); + None + } KeyCode::Backspace => { app.delete_char_before(); None diff --git a/tickets/tui-input-word-motion.md b/tickets/tui-input-word-motion.md index 53e3ab59..f9d13ce1 100644 --- a/tickets/tui-input-word-motion.md +++ b/tickets/tui-input-word-motion.md @@ -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 には過剰)。送り仮名や複合語の途中で切れる挙動は許容する。