diff --git a/crates/tui/src/tui/composer_ui.rs b/crates/tui/src/tui/composer_ui.rs index db55168a..75275873 100644 --- a/crates/tui/src/tui/composer_ui.rs +++ b/crates/tui/src/tui/composer_ui.rs @@ -86,6 +86,26 @@ pub(crate) fn is_word_cursor_modifier(modifiers: KeyModifiers) -> bool { modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) } +pub(crate) fn handle_composer_alt_word_motion_key(app: &mut App, key: KeyEvent) -> bool { + if !key.modifiers.contains(KeyModifiers::ALT) || key.modifiers.contains(KeyModifiers::CONTROL) { + return false; + } + + match key.code { + KeyCode::Char('f') | KeyCode::Char('F') => { + app.clear_selection(); + app.move_cursor_word_forward(); + true + } + KeyCode::Char('b') | KeyCode::Char('B') => { + app.clear_selection(); + app.move_cursor_word_backward(); + true + } + _ => false, + } +} + pub(crate) fn is_composer_newline_key(key: KeyEvent) -> bool { match key.code { KeyCode::Char('j') => key.modifiers.contains(KeyModifiers::CONTROL), diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 8d742b87..d4a9b235 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -25,6 +25,8 @@ use crate::tui::ui::{ open_details_pager_for_cell, open_pager_for_selection, }; +const COMPOSER_MOUSE_SCROLL_LINES: usize = 3; + pub(crate) fn should_drop_loading_mouse_motion(app: &App, mouse: MouseEvent) -> bool { if !app.is_loading { return false; @@ -79,6 +81,68 @@ fn mouse_pos_to_char_index(app: &App, col: u16, row: u16, inner: Rect) -> Option Some(line_start + char_offset) } +fn composer_wrapped_cursor_row_col( + input: &str, + cursor: usize, + wrapped: &[(usize, String)], +) -> (usize, usize) { + let total = input.chars().count(); + let cursor = cursor.min(total); + + for (idx, (line_start, line_text)) in wrapped.iter().enumerate() { + let next_start = wrapped + .get(idx + 1) + .map(|(start, _)| *start) + .unwrap_or_else(|| total.saturating_add(1)); + + if cursor >= *line_start && cursor < next_start { + let line_len = line_text.chars().count(); + return (idx, cursor.saturating_sub(*line_start).min(line_len)); + } + } + + let row = wrapped.len().saturating_sub(1); + let col = wrapped + .get(row) + .map(|(_, line_text)| line_text.chars().count()) + .unwrap_or(0); + (row, col) +} + +fn move_composer_cursor_by_wrapped_rows(app: &mut App, inner: Rect, rows: isize) { + if app.input.is_empty() || rows == 0 { + return; + } + + let width = inner.width.max(1) as usize; + let wrapped = crate::tui::widgets::wrap_input_lines_for_mouse(&app.input, width); + if wrapped.len() <= 1 { + return; + } + + let (current_row, current_col) = + composer_wrapped_cursor_row_col(&app.input, app.cursor_position, &wrapped); + let max_row = wrapped.len().saturating_sub(1); + let target_row = if rows.is_negative() { + current_row.saturating_sub(rows.unsigned_abs()) + } else { + current_row.saturating_add(rows as usize).min(max_row) + }; + + if target_row == current_row { + return; + } + + let (target_start, target_text) = &wrapped[target_row]; + let target_len = target_text.chars().count(); + let total = app.input.chars().count(); + app.clear_selection(); + app.cursor_position = target_start + .saturating_add(current_col.min(target_len)) + .min(total); + app.needs_redraw = true; +} + /// Handle mouse events within the composer area. /// Returns true if the event was consumed. pub(crate) fn handle_composer_mouse(app: &mut App, mouse: MouseEvent) -> bool { @@ -97,6 +161,18 @@ pub(crate) fn handle_composer_mouse(app: &mut App, mouse: MouseEvent) -> bool { let inner = app.viewport.last_composer_content.unwrap_or(area); match mouse.kind { + MouseEventKind::ScrollUp => { + move_composer_cursor_by_wrapped_rows( + app, + inner, + -(COMPOSER_MOUSE_SCROLL_LINES as isize), + ); + true + } + MouseEventKind::ScrollDown => { + move_composer_cursor_by_wrapped_rows(app, inner, COMPOSER_MOUSE_SCROLL_LINES as isize); + true + } MouseEventKind::Down(MouseButton::Left) => { if let Some(pos) = mouse_pos_to_char_index(app, mouse.column, mouse.row, inner) { app.cursor_position = pos; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 14fff6da..171f168c 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3773,6 +3773,7 @@ async fn run_event_loop( app.clear_selection(); app.move_cursor_end(); } + _ if handle_composer_alt_word_motion_key(app, key) => {} KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => { // Ctrl+O: spawn $EDITOR on the composer contents (#91). // Only fires when no modal is active (the !view_stack @@ -3945,9 +3946,10 @@ async fn run_event_loop( { // absorb — Visual mode not yet fully implemented } - KeyCode::Char(c) => { + KeyCode::Char(c) if is_plain_char => { app.insert_char(c); } + KeyCode::Char(_) => {} _ => {} } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index a2e8ddae..51240f01 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -295,6 +295,45 @@ fn word_cursor_modifier_accepts_control_and_alt() { assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT)); } +#[test] +fn alt_f_and_alt_b_move_by_word_without_inserting_text() { + let mut app = create_test_app(); + app.input = "alpha beta gamma".to_string(); + app.cursor_position = 0; + + assert!(handle_composer_alt_word_motion_key( + &mut app, + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT), + )); + assert_eq!(app.input, "alpha beta gamma"); + assert_eq!(app.cursor_position, "alpha ".chars().count()); + + app.selection_anchor = Some(0); + assert!(handle_composer_alt_word_motion_key( + &mut app, + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT), + )); + assert_eq!(app.input, "alpha beta gamma"); + assert_eq!(app.cursor_position, 0); + assert!(app.selection_anchor.is_none()); +} + +#[test] +fn alt_word_motion_helper_ignores_altgr_style_control_alt() { + let mut app = create_test_app(); + app.input = "alpha beta".to_string(); + app.cursor_position = 0; + + assert!(!handle_composer_alt_word_motion_key( + &mut app, + KeyEvent::new( + KeyCode::Char('f'), + KeyModifiers::CONTROL | KeyModifiers::ALT + ), + )); + assert_eq!(app.cursor_position, 0); +} + fn select_full_transcript(app: &mut App) { app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint { line_index: 0, @@ -1141,6 +1180,70 @@ fn mouse_events_do_not_mutate_transcript_behind_modal() { assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help)); } +#[test] +fn composer_mouse_wheel_scrolls_wrapped_draft_not_transcript() { + let mut app = create_test_app(); + app.input = "alpha beta gamma delta epsilon".to_string(); + app.cursor_position = 0; + app.viewport.last_composer_area = Some(Rect { + x: 0, + y: 10, + width: 12, + height: 5, + }); + app.viewport.last_composer_content = Some(Rect { + x: 1, + y: 11, + width: 5, + height: 3, + }); + + let events = handle_mouse_event( + &mut app, + MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 2, + row: 12, + modifiers: KeyModifiers::NONE, + }, + ); + + assert!(events.is_empty()); + assert_eq!(app.viewport.pending_scroll_delta, 0); + assert!(app.cursor_position > 0); +} + +#[test] +fn composer_mouse_wheel_up_moves_within_wrapped_draft() { + let mut app = create_test_app(); + app.input = "alpha beta gamma delta epsilon".to_string(); + app.cursor_position = app.input.chars().count(); + app.viewport.last_composer_area = Some(Rect { + x: 0, + y: 10, + width: 12, + height: 5, + }); + app.viewport.last_composer_content = Some(Rect { + x: 1, + y: 11, + width: 5, + height: 3, + }); + + assert!(handle_composer_mouse( + &mut app, + MouseEvent { + kind: MouseEventKind::ScrollUp, + column: 2, + row: 12, + modifiers: KeyModifiers::NONE, + }, + )); + + assert!(app.cursor_position < app.input.chars().count()); +} + #[test] fn copy_shortcut_accepts_cmd_and_ctrl_shift_only() { assert!(crate::tui::key_shortcuts::is_copy_shortcut(&KeyEvent::new(