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