Merge pull request #2471 from Hmbown/codex/composer-input-fix
fix(tui): keep composer scroll and alt motion in draft
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(_) => {}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user