Files
herdr/src/client/shell/input.rs
T

767 lines
28 KiB
Rust

use super::*;
use crate::protocol::ClientPaneInputEvent;
use crate::raw_input::RawInputEvent;
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
fn is_retained_selection_copy_key(key: &crate::input::TerminalKey) -> bool {
matches!(key.code, KeyCode::Char('c' | 'C'))
&& matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER)
}
impl ClientShellState {
#[cfg(any(unix, test))]
pub(crate) fn handle_input_bytes(&mut self, data: &[u8]) -> ClientShellInput {
self.handle_raw_events(crate::raw_input::parse_raw_input_bytes_sync(data))
}
#[cfg(any(unix, test))]
pub(crate) fn handle_pixel_mouse(
&mut self,
data: &[u8],
geometry: crate::input::mouse::HostGeometry,
) -> ClientShellInput {
let Some((x, y)) = crate::input::mouse::parse_report(data) else {
return ClientShellInput::default();
};
let Some((column, row)) = geometry.cell(x, y) else {
return ClientShellInput::default();
};
let Some(cell_report) = crate::input::mouse::report_at_cell(data, column, row) else {
return ClientShellInput::default();
};
let events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report);
if events.len() != 1 || !matches!(events[0], RawInputEvent::Mouse(_)) {
return ClientShellInput::default();
}
self.host_mouse_pixels = Some(crate::input::mouse::HostPixels { x, y, geometry });
let outcome = self.handle_raw_events(events);
self.host_mouse_pixels = None;
outcome
}
#[cfg(windows)]
pub(crate) fn handle_client_events(
&mut self,
events: &[crate::protocol::ClientInputEvent],
) -> ClientShellInput {
self.handle_raw_events(
events
.iter()
.map(crate::protocol::ClientInputEvent::to_raw_input_event)
.collect(),
)
}
fn prepare_committed_text(&mut self, text: &str, outcome: &mut ClientShellInput) -> bool {
if self.insert_copy_search_text(text) {
outcome.repaint = true;
return true;
}
self.pending_word_selection = None;
if self.copy_or_terminal_mode() != ClientShellMode::Copy && self.selection.take().is_some()
{
self.stop_selection_autoscroll();
self.selection_highlight_clear_deadline = None;
outcome.repaint = true;
}
false
}
pub(super) fn handle_raw_events(&mut self, events: Vec<RawInputEvent>) -> ClientShellInput {
let mut outcome = ClientShellInput::default();
if !events.is_empty() && self.endpoint_error.take().is_some() {
outcome.repaint = true;
}
for event in events {
match event {
RawInputEvent::Key(key) => self.handle_key(key, &mut outcome),
RawInputEvent::Text(text) => {
let text = text.into_string();
if matches!(
self.overlay,
Some(
ClientShellOverlay::Onboarding
| ClientShellOverlay::ProductAnnouncement(_)
| ClientShellOverlay::ReleaseNotes(_)
)
) {
continue;
}
if self.prepare_committed_text(&text, &mut outcome) {
continue;
}
if let Some(target) = self.popup_input_target() {
super::push_target_event(
target,
ClientPaneInputEvent::TextCommit(text),
&mut outcome,
);
} else if !self.popup_pending {
if self.insert_overlay_text(&text) {
outcome.repaint = true;
} else if self.overlay.is_none() && self.mode == ClientShellMode::Terminal {
self.push_focused_pane_event(
ClientPaneInputEvent::TextCommit(text),
&mut outcome,
);
}
}
}
RawInputEvent::Paste(text) => {
if matches!(
self.overlay,
Some(
ClientShellOverlay::Onboarding
| ClientShellOverlay::ProductAnnouncement(_)
| ClientShellOverlay::ReleaseNotes(_)
)
) {
continue;
}
if self.prepare_committed_text(&text, &mut outcome) {
continue;
}
if let Some(target) = self.popup_input_target() {
super::push_target_event(
target,
ClientPaneInputEvent::Paste(text),
&mut outcome,
);
} else if !self.popup_pending {
if self.insert_overlay_text(&text) {
outcome.repaint = true;
} else if self.overlay.is_none() && self.mode == ClientShellMode::Terminal {
self.push_focused_pane_event(
ClientPaneInputEvent::Paste(text),
&mut outcome,
);
}
}
}
RawInputEvent::Mouse(mouse) => self.handle_mouse(mouse, &mut outcome),
RawInputEvent::OuterFocusGained => {
self.outer_focused = Some(true);
outcome.query_host_appearance = true;
}
RawInputEvent::OuterFocusLost => self.outer_focused = Some(false),
RawInputEvent::HostColorSchemeChanged(appearance) => {
if self.config.theme_runtime.auto_switch {
self.config.palette = crate::app::client_palette_for_appearance(
&self.config.theme_runtime,
appearance,
);
outcome.repaint = true;
}
}
RawInputEvent::HostDefaultColor { .. }
| RawInputEvent::HostPaletteColors { .. }
| RawInputEvent::HostCellSizeReport { .. }
| RawInputEvent::Unsupported => {}
}
}
outcome.repaint |= self.resume_mobile_switcher_if_ready();
outcome
}
pub(super) fn handle_key(
&mut self,
key: crate::input::TerminalKey,
outcome: &mut ClientShellInput,
) {
if self.copy_operation_in_flight {
self.copy_input_queue.push_back(key);
return;
}
const LOCAL_INPUT_SOURCE: u8 = 0;
let lease_key = crate::input::InputLeaseKey::new(LOCAL_INPUT_SOURCE, &key);
let key = self.input_leases.normalize_press(&lease_key, key);
match key.kind {
KeyEventKind::Press => {
let initial_context = self.input_context();
let target = self.route_key_press(&key, outcome);
if let Some(target) = target.as_ref() {
self.push_pane_key(target.clone(), key.clone(), outcome);
}
let resulting_context = self.input_context();
let plan = self.input_leases.complete_press(
lease_key,
&key,
Some(&initial_context),
Some(&resulting_context),
target,
);
self.execute_repeat_plan(lease_key, key, plan, outcome);
}
KeyEventKind::Repeat => {
let context = self.input_context();
let plan = self
.input_leases
.plan_repeat(lease_key, &key, Some(&context));
self.execute_repeat_plan(lease_key, key, plan, outcome);
}
KeyEventKind::Release => {
if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) {
self.push_pane_key(lease.target, key, outcome);
} else {
let _ = self.input_leases.remove(&lease_key);
}
}
}
}
fn execute_repeat_plan(
&mut self,
lease_key: crate::input::InputLeaseKey<u8>,
key: crate::input::TerminalKey,
plan: crate::input::RepeatPlan<ClientInputContext, ClientInputTarget>,
outcome: &mut ClientShellInput,
) {
match plan {
crate::input::RepeatPlan::Forwarded(target) => {
let pane_blocked_by_popup = matches!(&target, ClientInputTarget::Pane(_))
&& (self.popup_pending || self.popup_terminal_id.is_some());
if !pane_blocked_by_popup {
self.push_pane_key(target, key, outcome);
}
}
crate::input::RepeatPlan::Reprocess {
context,
repetitions,
tracked,
} => {
for _ in 0..repetitions {
let current = self.input_context();
if !self.input_leases.reprocess_allowed(
lease_key,
&context,
Some(&current),
tracked,
) {
break;
}
let repeated = key
.clone()
.with_repeat_count(1)
.with_kind(KeyEventKind::Repeat);
if let Some(target) = self.route_key_press(&repeated, outcome) {
self.push_pane_key(target, repeated, outcome);
}
}
}
crate::input::RepeatPlan::Ignore => {}
}
}
fn route_key_press(
&mut self,
key: &crate::input::TerminalKey,
outcome: &mut ClientShellInput,
) -> Option<ClientInputTarget> {
if matches!(
self.overlay,
Some(
ClientShellOverlay::Onboarding
| ClientShellOverlay::ProductAnnouncement(_)
| ClientShellOverlay::ReleaseNotes(_)
)
) {
if key.kind == KeyEventKind::Press {
self.route_overlay_key(key, outcome);
}
return None;
}
if let Some(target) = self.popup_input_target() {
return Some(target);
}
if self.popup_pending {
return None;
}
if self.overlay.is_some() {
self.route_overlay_key(key, outcome);
return None;
}
if matches!(key.code, KeyCode::Modifier(_)) {
return None;
}
self.pending_word_selection = None;
if self.mode != ClientShellMode::Copy
&& self.copy_or_terminal_mode() != ClientShellMode::Copy
&& !self.config.copy_on_select
&& is_retained_selection_copy_key(key)
&& self
.selection
.as_ref()
.is_some_and(crate::selection::Selection::is_visible)
{
self.request_selection_copy(outcome);
self.selection = None;
self.stop_selection_autoscroll();
self.selection_highlight_clear_deadline = None;
outcome.repaint = true;
return None;
}
if self.mode != ClientShellMode::Copy
&& self.copy_or_terminal_mode() != ClientShellMode::Copy
&& self.selection.take().is_some()
{
self.stop_selection_autoscroll();
self.selection_highlight_clear_deadline = None;
outcome.repaint = true;
}
match self.mode {
ClientShellMode::Terminal => {
if let Some(binding) =
crate::input::resolve_direct_binding(&self.config.keybinds.keybinds, key)
{
self.record_binding(binding, outcome);
return None;
}
if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) {
self.mode = ClientShellMode::Prefix;
outcome.repaint = true;
return None;
}
self.focused_pane_id().map(ClientInputTarget::Pane)
}
ClientShellMode::Prefix => {
let return_mode = if self.copy_mode.as_ref().is_some_and(|copy_mode| {
self.focused_pane_id().as_deref() == Some(copy_mode.pane_id.as_str())
}) {
ClientShellMode::Copy
} else {
ClientShellMode::Terminal
};
if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) {
self.mode = return_mode;
outcome.repaint = true;
return self.focused_pane_id().map(ClientInputTarget::Pane);
}
if key.code == KeyCode::Esc {
self.mode = return_mode;
outcome.repaint = true;
return None;
}
if let Some(binding) =
crate::input::resolve_prefix_binding(&self.config.keybinds.keybinds, key)
{
self.mode = return_mode;
outcome.repaint = true;
self.record_binding(binding, outcome);
return None;
}
self.mode = return_mode;
outcome.repaint = true;
None
}
ClientShellMode::Navigate => {
self.route_navigate_key(key, outcome);
None
}
ClientShellMode::Resize => {
self.route_resize_key(key, outcome);
None
}
ClientShellMode::Copy => {
if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) {
self.mode = ClientShellMode::Prefix;
outcome.repaint = true;
} else {
self.route_copy_mode_key(key, outcome);
}
None
}
}
}
pub(super) fn copy_or_terminal_mode(&self) -> ClientShellMode {
if self.copy_mode.as_ref().is_some_and(|copy_mode| {
self.focused_pane_id().as_deref() == Some(copy_mode.pane_id.as_str())
}) {
ClientShellMode::Copy
} else {
ClientShellMode::Terminal
}
}
fn route_navigate_key(
&mut self,
key: &crate::input::TerminalKey,
outcome: &mut ClientShellInput,
) {
use crate::input::{KeybindAction, KeybindDispatch, KeybindMatch};
if key.code == KeyCode::Esc
|| crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix)
{
self.mode = self.copy_or_terminal_mode();
self.navigate_workspace_id = None;
outcome.repaint = true;
return;
}
if self
.config
.keybinds
.keybinds
.navigate
.workspace_up
.matches_direct_key(key)
{
self.move_navigate_workspace(-1);
outcome.repaint = true;
return;
}
if self
.config
.keybinds
.keybinds
.navigate
.workspace_down
.matches_direct_key(key)
{
self.move_navigate_workspace(1);
outcome.repaint = true;
return;
}
if let Some(index) = ('1'..='9').position(|digit| {
crate::config::terminal_key_matches_combo(
key,
(KeyCode::Char(digit), KeyModifiers::empty()),
)
}) {
let valid = self.snapshot.as_deref().is_some_and(|snapshot| {
self.navigation_workspace_entries(snapshot)
.get(index)
.is_some()
});
if valid {
self.mode = ClientShellMode::Terminal;
self.navigate_workspace_id = None;
self.record_binding(
KeybindMatch::Action(KeybindAction::SwitchWorkspace(index)),
outcome,
);
outcome.repaint = true;
}
return;
}
let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers));
if modifiers.is_empty() {
match code {
KeyCode::Enter => {
let selected = self.navigate_workspace_id.clone();
self.mode = ClientShellMode::Terminal;
self.navigate_workspace_id = None;
if let Some(workspace_id) = selected {
self.push_endpoint_method(
crate::api::schema::Method::WorkspaceFocus(
crate::api::schema::WorkspaceTarget { workspace_id },
),
outcome,
);
}
outcome.repaint = true;
return;
}
KeyCode::Tab => {
self.record_navigate_binding(
KeybindMatch::Action(KeybindAction::CyclePaneNext),
false,
outcome,
);
return;
}
KeyCode::BackTab => {
self.record_navigate_binding(
KeybindMatch::Action(KeybindAction::CyclePanePrevious),
false,
outcome,
);
return;
}
KeyCode::Left => {
self.record_navigate_binding(
KeybindMatch::Action(KeybindAction::FocusPaneLeft),
true,
outcome,
);
return;
}
KeyCode::Right => {
self.record_navigate_binding(
KeybindMatch::Action(KeybindAction::FocusPaneRight),
true,
outcome,
);
return;
}
_ => {}
}
}
let pane_action = [
(
&self.config.keybinds.keybinds.navigate.pane_left,
KeybindAction::FocusPaneLeft,
),
(
&self.config.keybinds.keybinds.navigate.pane_down,
KeybindAction::FocusPaneDown,
),
(
&self.config.keybinds.keybinds.navigate.pane_up,
KeybindAction::FocusPaneUp,
),
(
&self.config.keybinds.keybinds.navigate.pane_right,
KeybindAction::FocusPaneRight,
),
]
.into_iter()
.find_map(|(bindings, action)| bindings.matches_direct_key(key).then_some(action));
if let Some(action) = pane_action {
self.record_navigate_binding(KeybindMatch::Action(action), true, outcome);
return;
}
let binding = crate::input::resolve_non_indexed_action(
&self.config.keybinds.keybinds,
key,
KeybindDispatch::Prefix,
)
.filter(|action| {
!matches!(
action,
KeybindAction::FocusPaneLeft
| KeybindAction::FocusPaneDown
| KeybindAction::FocusPaneUp
| KeybindAction::FocusPaneRight
)
})
.map(KeybindMatch::Action)
.or_else(|| {
crate::input::resolve_custom_command(
&self.config.keybinds.keybinds,
key,
KeybindDispatch::Prefix,
)
.map(KeybindMatch::Command)
})
.or_else(|| {
crate::input::resolve_indexed_action(
&self.config.keybinds.keybinds,
key,
KeybindDispatch::Prefix,
)
.map(KeybindMatch::Action)
});
if let Some(binding) = binding {
self.record_navigate_binding(binding, false, outcome);
}
}
fn record_navigate_binding(
&mut self,
binding: crate::input::KeybindMatch,
preserve_navigate: bool,
outcome: &mut ClientShellInput,
) {
use crate::input::{KeybindAction, KeybindMatch};
let indexed_target_exists = match &binding {
KeybindMatch::Action(KeybindAction::SwitchWorkspace(index)) => {
self.snapshot.as_deref().is_some_and(|snapshot| {
self.navigation_workspace_entries(snapshot)
.get(*index)
.is_some()
})
}
KeybindMatch::Action(KeybindAction::SwitchTab(index)) => self
.snapshot
.as_deref()
.and_then(|snapshot| {
let workspace_id = snapshot.focused_workspace_id.as_deref()?;
snapshot
.tabs
.iter()
.filter(|tab| tab.workspace_id == workspace_id)
.nth(*index)
})
.is_some(),
KeybindMatch::Action(KeybindAction::FocusAgent(index)) => {
self.snapshot.as_deref().is_some_and(|snapshot| {
super::agent_sidebar::ordered_agent_pane_ids(
snapshot,
self.config.agent_panel_sort,
)
.get(*index)
.is_some()
})
}
_ => true,
};
if !indexed_target_exists {
return;
}
if let KeybindMatch::Action(KeybindAction::CyclePaneNext) = binding {
self.cycle_pane(false, outcome);
} else if let KeybindMatch::Action(KeybindAction::CyclePanePrevious) = binding {
self.cycle_pane(true, outcome);
} else {
if !preserve_navigate {
self.mode = ClientShellMode::Terminal;
}
self.record_binding(binding, outcome);
}
if !preserve_navigate {
if self.mode == ClientShellMode::Navigate {
self.mode = self.copy_or_terminal_mode();
}
self.navigate_workspace_id = None;
}
outcome.repaint = true;
}
fn move_navigate_workspace(&mut self, delta: isize) {
let Some(snapshot) = self.snapshot.as_deref() else {
return;
};
let mobile = self.mobile_layout_active();
let entries = self.navigation_workspace_entries(snapshot);
if entries.is_empty() {
return;
}
let current = self
.navigate_workspace_id
.as_deref()
.and_then(|selected| {
entries
.iter()
.position(|entry| snapshot.workspaces[entry.index].workspace_id == selected)
})
.unwrap_or(0);
let next = if mobile {
(current as isize + delta).clamp(0, entries.len().saturating_sub(1) as isize) as usize
} else {
(current as isize + delta).rem_euclid(entries.len() as isize) as usize
};
self.navigate_workspace_id = Some(
snapshot.workspaces[entries[next].index]
.workspace_id
.clone(),
);
self.reveal_mobile_workspace = mobile;
}
fn cycle_pane(&mut self, reverse: bool, outcome: &mut ClientShellInput) {
let Some(snapshot) = self.snapshot.as_deref() else {
return;
};
let Some(surface) = self.pane_surface.as_ref() else {
return;
};
if surface.panes.is_empty() {
return;
}
let current = snapshot
.focused_pane_id
.as_deref()
.and_then(|focused| {
surface
.panes
.iter()
.position(|pane| pane.pane_id == focused)
})
.unwrap_or(0);
let next = if reverse {
(current + surface.panes.len() - 1) % surface.panes.len()
} else {
(current + 1) % surface.panes.len()
};
let pane_id = surface.panes[next].pane_id.clone();
self.push_endpoint_method(
crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }),
outcome,
);
}
fn route_resize_key(
&mut self,
key: &crate::input::TerminalKey,
outcome: &mut ClientShellInput,
) {
let resize_bindings = &self.config.keybinds.keybinds.resize_mode;
if key.code == KeyCode::Esc
|| key.code == KeyCode::Enter
|| resize_bindings.matches_prefix_key(key)
|| resize_bindings.matches_direct_key(key)
{
self.mode = self.copy_or_terminal_mode();
outcome.repaint = true;
return;
}
let action = match key.code {
KeyCode::Char('h') | KeyCode::Left => Some(crate::input::KeybindAction::ResizePaneLeft),
KeyCode::Char('j') | KeyCode::Down => Some(crate::input::KeybindAction::ResizePaneDown),
KeyCode::Char('k') | KeyCode::Up => Some(crate::input::KeybindAction::ResizePaneUp),
KeyCode::Char('l') | KeyCode::Right => {
Some(crate::input::KeybindAction::ResizePaneRight)
}
_ => None,
};
if let Some(action) = action {
self.record_binding(crate::input::KeybindMatch::Action(action), outcome);
}
}
fn input_context(&self) -> ClientInputContext {
ClientInputContext {
mode: self.mode,
overlay: self.overlay.as_ref().map(ClientShellOverlay::kind),
popup_terminal_id: self.popup_input_target().and_then(|target| match target {
ClientInputTarget::Popup(terminal_id) => Some(terminal_id),
ClientInputTarget::Pane(_) => None,
}),
popup_pending: self.popup_pending,
retained_selection: self
.selection
.as_ref()
.is_some_and(crate::selection::Selection::is_visible),
}
}
pub(super) fn focused_pane_id(&self) -> Option<String> {
self.snapshot
.as_deref()
.and_then(|snapshot| snapshot.focused_pane_id.clone())
}
fn popup_input_target(&self) -> Option<ClientInputTarget> {
self.popup_terminal_id
.as_ref()
.map(|terminal_id| ClientInputTarget::Popup(terminal_id.clone()))
}
fn push_pane_key(
&self,
target: ClientInputTarget,
key: crate::input::TerminalKey,
outcome: &mut ClientShellInput,
) {
if let Some(event) = ClientPaneInputEvent::from_terminal_key(key) {
super::push_target_event(target, event, outcome);
}
}
fn push_focused_pane_event(&self, event: ClientPaneInputEvent, outcome: &mut ClientShellInput) {
if let Some(pane_id) = self.focused_pane_id() {
super::push_target_event(ClientInputTarget::Pane(pane_id), event, outcome);
}
}
}