Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions src/app/input/lease.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::collections::HashMap;

use crate::app::{InputSourceId, TerminalInputContext, TerminalInputTarget};
use crate::app::{InputContext, InputSourceId, TerminalInputTarget};
use crate::input::{KeyIdentity, TerminalKey};

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
Expand All @@ -26,7 +26,7 @@ pub(crate) struct ForwardedInputLease {

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ConsumedInputLease {
ReprocessRepeats(TerminalInputContext),
ReprocessRepeats(InputContext),
SuppressRepeats,
}

Expand All @@ -39,7 +39,7 @@ pub(crate) enum InputLease {
pub(crate) enum RepeatPlan {
Forwarded(TerminalInputTarget),
Reprocess {
context: TerminalInputContext,
context: InputContext,
repetitions: u16,
tracked: bool,
},
Expand Down Expand Up @@ -76,8 +76,8 @@ impl InputLeaseTable {
&mut self,
lease_key: InputLeaseKey,
key: &TerminalKey,
initial_context: Option<&TerminalInputContext>,
resulting_context: Option<&TerminalInputContext>,
initial_context: Option<&InputContext>,
resulting_context: Option<&InputContext>,
target: Option<TerminalInputTarget>,
) -> RepeatPlan {
if key.generated_text.is_some() && !key.has_physical_identity() {
Expand Down Expand Up @@ -114,7 +114,7 @@ impl InputLeaseTable {
&mut self,
lease_key: InputLeaseKey,
key: &TerminalKey,
current_context: Option<&TerminalInputContext>,
current_context: Option<&InputContext>,
) -> RepeatPlan {
match self.leases.get(&lease_key) {
Some(InputLease::Forwarded(lease)) => {
Expand Down Expand Up @@ -151,8 +151,8 @@ impl InputLeaseTable {
pub(crate) fn reprocess_allowed(
&mut self,
lease_key: InputLeaseKey,
expected_context: &TerminalInputContext,
current_context: Option<&TerminalInputContext>,
expected_context: &InputContext,
current_context: Option<&InputContext>,
tracked: bool,
) -> bool {
let allowed = current_context == Some(expected_context);
Expand Down Expand Up @@ -353,7 +353,7 @@ mod tests {
fn physical_generated_text_keeps_native_repeat_lifecycle() {
let key = physical_generated_slash(3);
let lease_key = InputLeaseKey::new(7, &key);
let context = TerminalInputContext::Pane;
let context = InputContext::Pane;
let forwarded_target = target();
let mut leases = InputLeaseTable::default();

Expand Down Expand Up @@ -387,13 +387,13 @@ mod tests {
fn consumed_grouped_physical_generated_text_reprocesses_repeats() {
let key = physical_generated_slash(3);
let lease_key = InputLeaseKey::new(7, &key);
let context = TerminalInputContext::Pane;
let context = InputContext::Pane;
let mut leases = InputLeaseTable::default();

assert!(matches!(
leases.complete_press(lease_key, &key, Some(&context), Some(&context), None),
RepeatPlan::Reprocess {
context: TerminalInputContext::Pane,
context: InputContext::Pane,
repetitions: 2,
tracked: true,
}
Expand All @@ -406,7 +406,7 @@ mod tests {
.with_generated_text(Some("/".to_owned()))
.with_repeat_count(3);
let lease_key = InputLeaseKey::new(7, &key);
let context = TerminalInputContext::Pane;
let context = InputContext::Pane;
let mut leases = InputLeaseTable::default();

assert!(matches!(
Expand All @@ -426,7 +426,7 @@ mod tests {
fn new_semantic_press_recomputes_consumed_repeat_disposition() {
let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()).with_repeat_count(3);
let lease_key = InputLeaseKey::new(7, &key);
let context = TerminalInputContext::Pane;
let context = InputContext::Pane;
let mut leases = InputLeaseTable::default();
leases.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats);

Expand All @@ -436,7 +436,7 @@ mod tests {
assert!(matches!(
plan,
RepeatPlan::Reprocess {
context: TerminalInputContext::Pane,
context: InputContext::Pane,
repetitions: 2,
tracked: true,
}
Expand Down
212 changes: 203 additions & 9 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,18 @@ pub(crate) struct TerminalInputTarget {
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum TerminalInputContext {
pub(crate) enum InputContext {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed TerminalInputContext to InputContext: the enum now has a NonTerminal variant, so the name "terminal input" no longer matches what it holds. TerminalInputTarget keeps its name because it is truly terminal-only. The rename is a mechanical replace, split into the first commit.

Pane,
Popup(crate::terminal::TerminalId),
NonTerminal(NonTerminalInputContext),
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum NonTerminalInputContext {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This enum lists only the modes where we want key repeat. Modes not listed here get None from input_context(), so their repeats stay suppressed as before. I added only the four modes needed so far, but listing all modes is also possible if that is preferred.

Copy,
Navigator,
Navigate,
KeybindHelp,
}

pub(crate) type InputSourceId = u64;
Expand Down Expand Up @@ -1666,11 +1675,23 @@ impl App {
// ---------------------------------------------------------------------------

impl App {
pub(crate) fn terminal_input_context(&self) -> Option<TerminalInputContext> {
pub(crate) fn input_context(&self) -> Option<InputContext> {
if let Some(popup) = &self.state.popup_pane {
Some(TerminalInputContext::Popup(popup.terminal_id.clone()))
Some(InputContext::Popup(popup.terminal_id.clone()))
} else if self.state.mode == Mode::Terminal {
Some(TerminalInputContext::Pane)
Some(InputContext::Pane)
} else if self.state.mode == Mode::Copy {
Some(InputContext::NonTerminal(NonTerminalInputContext::Copy))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is the direct cause of the broken repeat in copy mode. Whether repeats are allowed is decided right after a press is handled, by comparing the context before the press with the context after it (complete_press). If both are the same Some, later repeats are reprocessed; if either is None, they are all dropped. Copy mode always had None here, so its repeats were always dropped. Returning Some makes this comparison succeed.

Keys that leave the mode (Enter/y/q/Esc, ...) change the context at press time, so they still do not repeat. Every repeat also re-checks that the context is still the same, so if the mode changes while the key is held, the repeat stops there.

} else if self.state.mode == Mode::Navigator {
Some(InputContext::NonTerminal(
NonTerminalInputContext::Navigator,
))
} else if self.state.mode == Mode::Navigate {
Some(InputContext::NonTerminal(NonTerminalInputContext::Navigate))
} else if self.state.mode == Mode::KeybindHelp {
Some(InputContext::NonTerminal(
NonTerminalInputContext::KeybindHelp,
))
} else {
None
}
Expand Down Expand Up @@ -1706,7 +1727,7 @@ impl App {
}
continue;
}
let current_context = self.terminal_input_context();
let current_context = self.input_context();
if !self.input_leases.reprocess_allowed(
lease_key,
&context,
Expand All @@ -1715,6 +1736,10 @@ impl App {
) {
break;
}
if matches!(context, InputContext::NonTerminal(_)) {
self.handle_non_terminal_key_headless(key.clone());
continue;
}
if let Some(target) =
self.handle_terminal_key_headless_from(source_id, key.clone())
{
Expand Down Expand Up @@ -1793,14 +1818,17 @@ impl App {
let key = self.input_leases.normalize_press(&lease_key, key);
match key.kind {
crossterm::event::KeyEventKind::Press => {
let initial_context = self.terminal_input_context();
let target = if initial_context.is_some() {
let initial_context = self.input_context();
let target = if matches!(
initial_context,
Some(InputContext::Pane | InputContext::Popup(_))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This used to be is_some(), which meant "if there is a context, forward the key to the terminal". That is no longer true once NonTerminal contexts exist, so the terminal variants are matched explicitly now. The reprocess loop had the same pattern; there it is handled by the added NonTerminal branch (a hunk above in this file).

) {
self.handle_terminal_key_headless_from(source_id, key.clone())
} else {
self.handle_non_terminal_key_headless(key.clone());
None
};
let resulting_context = self.terminal_input_context();
let resulting_context = self.input_context();
let plan = self.input_leases.complete_press(
lease_key,
&key,
Expand All @@ -1811,7 +1839,7 @@ impl App {
self.execute_repeat_plan_headless(source_id, lease_key, key, plan);
}
crossterm::event::KeyEventKind::Repeat => {
let current_context = self.terminal_input_context();
let current_context = self.input_context();
let plan = self.input_leases.plan_repeat(
lease_key,
&key,
Expand Down Expand Up @@ -2093,6 +2121,89 @@ mod tests {
assert!(app.input_leases.is_empty());
}

#[tokio::test]
async fn copy_mode_press_moves_cursor_and_forwards_nothing() {
let (mut app, _pane_id, mut rx) = app_with_copy_mode();

app.route_client_events(
vec![raw_key(
KeyCode::Down,
KeyModifiers::empty(),
KeyEventKind::Press,
)],
false,
);

assert_eq!(app.state.mode, Mode::Copy);
assert_eq!(
app.state.copy_mode.as_ref().expect("copy mode").cursor_row,
1
);
assert!(rx.try_recv().is_err());
}

#[tokio::test]
async fn copy_mode_repeat_after_press_moves_cursor_again() {
let (mut app, _pane_id, mut rx) = app_with_copy_mode();

app.route_client_events(
vec![
raw_key(KeyCode::Down, KeyModifiers::empty(), KeyEventKind::Press),
raw_key(KeyCode::Down, KeyModifiers::empty(), KeyEventKind::Repeat),
],
false,
);

assert_eq!(app.state.mode, Mode::Copy);
assert_eq!(
app.state.copy_mode.as_ref().expect("copy mode").cursor_row,
2,
"repeat should move the copy-mode cursor a second time"
);
assert!(rx.try_recv().is_err());
}

#[tokio::test]
async fn copy_mode_repeat_with_repeat_count_moves_cursor_by_count() {
let (mut app, _pane_id, mut rx) = app_with_copy_mode();

app.route_client_events(
vec![
raw_key(KeyCode::Down, KeyModifiers::empty(), KeyEventKind::Press),
crate::raw_input::RawInputEvent::Key(
crate::input::TerminalKey::new(KeyCode::Down, KeyModifiers::empty())
.with_kind(KeyEventKind::Repeat)
.with_repeat_count(2),
),
],
false,
);

assert_eq!(
app.state.copy_mode.as_ref().expect("copy mode").cursor_row,
3,
"a single repeat_count=2 event should move the cursor two more times"
);
assert!(rx.try_recv().is_err());
}

#[tokio::test]
async fn copy_mode_esc_repeat_does_not_leak_after_exit() {
let (mut app, _pane_id, mut rx) = app_with_copy_mode();

app.route_client_events(
vec![
raw_key(KeyCode::Esc, KeyModifiers::empty(), KeyEventKind::Press),
raw_key(KeyCode::Esc, KeyModifiers::empty(), KeyEventKind::Repeat),
],
false,
);

assert_eq!(app.state.mode, Mode::Terminal);
assert!(app.state.copy_mode.is_none());
assert!(rx.try_recv().is_err());
}

fn release_notes_state() -> state::ReleaseNotesState {
state::ReleaseNotesState {
version: "0.1.0".into(),
Expand All @@ -2113,6 +2224,41 @@ mod tests {
)
}

/// Puts a fresh app into copy mode with a channel-backed pane runtime
/// showing several lines of text, and pins the copy-mode cursor to the
/// top row so vertical cursor movement is unambiguous.
fn app_with_copy_mode() -> (
App,
crate::layout::PaneId,
tokio::sync::mpsc::Receiver<bytes::Bytes>,
) {
let mut app = test_app();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 20, 5));
let info = pane_infos[0].clone();
let (runtime, rx) = TerminalRuntime::test_with_channel_and_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
0,
b"line-0\r\nline-1\r\nline-2\r\nline-3\r\nline-4\r\n",
8,
);
ws.tabs[0].runtimes.insert(pane_id, runtime);
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.view.pane_infos = pane_infos;
app.state.enter_copy_mode(&app.terminal_runtimes);
app.state
.copy_mode
.as_mut()
.expect("copy mode entered")
.cursor_row = 0;
(app, pane_id, rx)
}

fn unique_temp_path(name: &str) -> std::path::PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
Expand Down Expand Up @@ -4051,6 +4197,54 @@ mod tests {
assert!(next_press_handled);
}

#[tokio::test]
async fn monolithic_copy_mode_repeat_after_press_moves_cursor_again() {
let (mut app, _pane_id, mut rx) = app_with_copy_mode();

app.handle_raw_input_event(raw_key(
KeyCode::Down,
KeyModifiers::empty(),
KeyEventKind::Press,
))
.await;
app.handle_raw_input_event(raw_key(
KeyCode::Down,
KeyModifiers::empty(),
KeyEventKind::Repeat,
))
.await;

assert_eq!(app.state.mode, Mode::Copy);
assert_eq!(
app.state.copy_mode.as_ref().expect("copy mode").cursor_row,
2,
"repeat should move the copy-mode cursor a second time"
);
assert!(rx.try_recv().is_err());
}

#[tokio::test]
async fn monolithic_copy_mode_esc_repeat_does_not_leak_after_exit() {
let (mut app, _pane_id, mut rx) = app_with_copy_mode();

app.handle_raw_input_event(raw_key(
KeyCode::Esc,
KeyModifiers::empty(),
KeyEventKind::Press,
))
.await;
app.handle_raw_input_event(raw_key(
KeyCode::Esc,
KeyModifiers::empty(),
KeyEventKind::Repeat,
))
.await;

assert_eq!(app.state.mode, Mode::Terminal);
assert!(app.state.copy_mode.is_none());
assert!(rx.try_recv().is_err());
}

#[test]
fn read_only_api_requests_do_not_force_rerender() {
let read_only = crate::api::schema::Request {
Expand Down
Loading
Loading