mod editor_terminal;
use std::rc::Rc;
use std::sync::PoisonError;
use std::time::Instant;
use crossterm::event::Event;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use crossterm::event::MouseButton;
use crossterm::event::MouseEventKind;
pub(super) use editor_terminal::handle_framework_overlay_editor_key;
pub(super) use editor_terminal::open_finder;
pub(super) use editor_terminal::open_in_editor;
pub(super) use editor_terminal::open_paths_in_editor;
pub(super) use editor_terminal::open_terminal;
use ratatui::layout::Position;
use tui_pane::Action;
use tui_pane::AppContext;
use tui_pane::FocusedPane;
use tui_pane::FrameworkFocusId;
use tui_pane::FrameworkOverlayId;
use tui_pane::Globals;
use tui_pane::KeyBind;
use tui_pane::KeyOutcome;
use tui_pane::KeySequence;
use tui_pane::Mode;
use tui_pane::Navigation;
use tui_pane::Pane;
use tui_pane::Viewport;
#[cfg(test)]
pub(super) use tui_pane::set_last_mouse_pos_for_test;
use super::app::App;
use super::app::ConfirmAction;
use super::app::PendingClean;
use super::finder;
use super::integration::AppGlobalAction;
use super::integration::AppNavigation;
use super::integration::AppPaneId;
use super::integration::FinderPane;
use super::integration::NavAction;
use super::integration::OutputPane;
use super::interaction;
use super::interaction::ClickMode;
use super::keymap;
use super::keymap::OutputAction;
use super::keymap::ProjectListAction;
use super::keymap_ui;
use super::panes;
use super::panes::PaneBehavior;
use super::panes::PaneId;
use super::sccache;
use super::settings;
use super::terminal;
pub(super) fn handle_event(app: &mut App, event: &Event) {
let started = Instant::now();
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => handle_key_event(app, key),
Event::Mouse(mouse) => {
tui_pane::record_mouse_pos(mouse.column, mouse.row);
app.mouse_pos = Some(Position::new(mouse.column, mouse.row));
handle_mouse_event(app, mouse.kind, mouse.column, mouse.row);
},
Event::FocusGained => {
let _ = terminal::rearm_input_modes();
if let Some((column, row)) = tui_pane::last_mouse_pos() {
app.mouse_pos = Some(Position::new(column, row));
handle_mouse_click(app, column, row, ClickMode::FocusOnly);
}
},
_ => {},
}
app.sync_selected_project();
let elapsed = started.elapsed();
if elapsed.as_millis() >= tui_pane::SLOW_INPUT_EVENT_MS {
tracing::trace!(
target: tui_pane::PERF_LOG_TARGET,
elapsed_ms = tui_pane::perf_log_ms(elapsed.as_millis()),
kind = %tui_pane::event_label(event),
focus = pane_label(app.focused_pane_id()),
scan_complete = app.scan.is_complete(),
selected = %app.project_list.selected_project_path()
.map_or_else(|| "-".to_string(), |path| path.display().to_string()),
"input_event"
);
}
}
#[derive(Clone, Copy)]
enum KeyDispatchLayer {
FrameworkOverlay,
AppSurface(AppSurfaceKey),
}
#[derive(Clone, Copy)]
struct AppSurfaceKey {
focused: FocusedPane<AppPaneId>,
}
impl KeyDispatchLayer {
const fn current(app: &App) -> Self {
if app.framework.overlay().is_some() {
Self::FrameworkOverlay
} else {
Self::AppSurface(AppSurfaceKey {
focused: *app.framework.focused(),
})
}
}
}
#[derive(Clone, Copy)]
enum OutputCancelPreflight {
ExitVisualSelection,
StopRunningExample,
CloseVisibleOutput,
Pass,
}
fn handle_key_event(app: &mut App, raw: &KeyEvent) {
app.mouse_pos = None;
app.reconcile_bottom_row_focus();
let normalized = normalize_nav(app, raw);
let bind = key_bind_from_event(raw);
match KeyDispatchLayer::current(app) {
KeyDispatchLayer::FrameworkOverlay => {
dispatch_framework_overlay_key(app, &bind, &normalized);
app.pending_nav_chord.clear();
},
KeyDispatchLayer::AppSurface(surface) => {
handle_app_surface_key(app, surface, raw, &bind);
},
}
}
fn handle_app_surface_key(app: &mut App, surface: AppSurfaceKey, raw: &KeyEvent, bind: &KeyBind) {
let code = raw.code;
let output_preflight = classify_output_cancel_preflight(app, code, bind);
if dispatch_output_cancel_preflight(app, output_preflight) {
app.pending_nav_chord.clear();
return;
}
if handle_confirm_key(app, code) {
app.pending_nav_chord.clear();
return;
}
if dispatch_finder_overlay(app, bind) {
app.pending_nav_chord.clear();
return;
}
if sccache::dispatch_sccache_overlay(app, bind) {
app.pending_nav_chord.clear();
return;
}
let focused = surface.focused;
let focused_on_toasts = matches!(focused, FocusedPane::Framework(FrameworkFocusId::Toasts));
if focused_on_toasts && tui_pane::dispatch_focused_toasts(app, bind) {
app.pending_nav_chord.clear();
return;
}
if dispatch_framework_global(app, bind) {
app.pending_nav_chord.clear();
return;
}
if dispatch_app_global(app, bind) {
app.pending_nav_chord.clear();
return;
}
if let FocusedPane::App(id) = focused
&& dispatch_focused_app_pane(app, id, bind)
{
app.pending_nav_chord.clear();
return;
}
if app.focus_is(PaneId::Output)
&& !focused_text_input_mode(app)
&& dispatch_output_selection_gesture(app, raw)
{
app.pending_nav_chord.clear();
return;
}
let _ = dispatch_navigation(app, focused, bind);
}
fn classify_output_cancel_preflight(
app: &App,
code: KeyCode,
bind: &KeyBind,
) -> OutputCancelPreflight {
let is_output_cancel = !focused_text_input_mode(app)
&& app.framework_keymap.is_key_bound_to_toml_key(
OutputPane::APP_PANE_ID,
OutputAction::Cancel.toml_key(),
bind,
);
let output_visual = is_output_cancel
&& app.focus_is(PaneId::Output)
&& app.panes.output.selection().is_visual();
let running_example = code == KeyCode::Esc && app.inflight.example_running().is_some();
let visible_output = is_output_cancel && !app.inflight.example_output().is_empty();
match (output_visual, running_example, visible_output) {
(true, _, _) => OutputCancelPreflight::ExitVisualSelection,
(false, true, _) => OutputCancelPreflight::StopRunningExample,
(false, false, true) => OutputCancelPreflight::CloseVisibleOutput,
(false, false, false) => OutputCancelPreflight::Pass,
}
}
fn dispatch_output_cancel_preflight(app: &mut App, preflight: OutputCancelPreflight) -> bool {
match preflight {
OutputCancelPreflight::ExitVisualSelection => {
app.panes.output.exit_visual();
true
},
OutputCancelPreflight::StopRunningExample => {
let pid_holder = app.inflight.example_child();
let pid = *pid_holder.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(pid) = pid {
terminal::stop_example_process(pid);
}
app.inflight.mark_run_killed();
true
},
OutputCancelPreflight::CloseVisibleOutput => {
let was_on_output = app.focus_is(PaneId::Output);
app.inflight.clear_example_output();
if was_on_output {
app.set_focus(FocusedPane::App(AppPaneId::Targets));
}
true
},
OutputCancelPreflight::Pass => false,
}
}
fn dispatch_framework_overlay_key(app: &mut App, bind: &KeyBind, normalized: &KeyEvent) {
if !dispatch_framework_overlay(app, bind, normalized) {
let _ = dispatch_framework_global(app, bind);
}
}
fn dispatch_output_selection_gesture(app: &mut App, raw: &KeyEvent) -> bool {
let code = raw.code;
if app.config.navigation_keys().uses_vim()
&& code == KeyCode::Char('V')
&& !raw.modifiers.contains(KeyModifiers::CONTROL)
&& !raw.modifiers.contains(KeyModifiers::ALT)
{
let live = app.inflight.example_output().to_vec();
app.panes.output.toggle_visual(&live);
return true;
}
let ctrl_shift = KeyModifiers::CONTROL | KeyModifiers::SHIFT;
let to_edge = raw.modifiers == ctrl_shift;
let one_row = raw.modifiers == KeyModifiers::SHIFT;
if (one_row || to_edge) && matches!(code, KeyCode::Up | KeyCode::Down) {
let live = app.inflight.example_output().to_vec();
let output = &mut app.panes.output;
match (to_edge, code) {
(false, KeyCode::Up) => output.select_extend_up(&live),
(false, KeyCode::Down) => output.select_extend_down(&live),
(true, KeyCode::Up) => output.select_extend_to_top(&live),
(true, _) => output.select_extend_to_bottom(&live),
(false, _) => {},
}
return true;
}
false
}
fn key_bind_from_event(event: &KeyEvent) -> KeyBind {
let bind = KeyBind::from_key_event(*event);
let (code, mods) = keymap::canonical_event_code_and_mods(bind.code, bind.mods);
KeyBind { code, mods }
}
fn dispatch_framework_global(app: &mut App, bind: &KeyBind) -> bool {
let keymap = Rc::clone(&app.framework_keymap);
let Some(action) = keymap.framework_globals().action_for(bind) else {
return false;
};
let overlay_before = app.framework.overlay();
keymap.dispatch_framework_global(action, app);
if app.framework.overlay().is_none()
&& let Some(overlay) = overlay_before
{
clear_legacy_framework_overlay_state(app, overlay);
}
true
}
fn clear_legacy_framework_overlay_state(app: &mut App, overlay: FrameworkOverlayId) {
match overlay {
FrameworkOverlayId::Settings => {
app.overlays.close_settings();
app.framework.settings_pane.enter_browse();
},
FrameworkOverlayId::Keymap => {
app.overlays.clear_inline_error();
app.framework.keymap_pane.enter_browse();
},
FrameworkOverlayId::GlobalShortcuts => {},
}
}
fn dispatch_app_global(app: &mut App, bind: &KeyBind) -> bool {
let keymap = Rc::clone(&app.framework_keymap);
let Some(scope) = keymap.globals::<AppGlobalAction>() else {
return false;
};
let Some(action) = scope.action_for(bind) else {
return false;
};
(AppGlobalAction::dispatcher())(action, app);
true
}
fn dispatch_focused_app_pane(app: &mut App, app_pane_id: AppPaneId, bind: &KeyBind) -> bool {
let keymap = Rc::clone(&app.framework_keymap);
matches!(
keymap.dispatch_app_pane(app_pane_id, bind, app),
KeyOutcome::Consumed
)
}
fn dispatch_framework_overlay(app: &mut App, bind: &KeyBind, normalized: &KeyEvent) -> bool {
let Some(overlay) = app.framework.overlay() else {
return false;
};
if let Some(action) = app.framework_keymap.framework_globals().action_for(bind)
&& tui_pane::matches_open_overlay_toggle(action, overlay)
&& !tui_pane::overlay_is_in_text_mode(&app.framework, overlay)
{
return false;
}
if overlay == FrameworkOverlayId::Settings && app.framework.settings_pane.is_editing() {
let command = app.framework.settings_pane.handle_text_input(*bind);
settings::handle_settings_text_command(app, command);
return true;
}
if overlay == FrameworkOverlayId::Keymap && app.framework.keymap_pane.is_capturing() {
let command = app.framework.keymap_pane.handle_capture_key(*bind);
keymap_ui::handle_keymap_capture_command(app, command);
return true;
}
if let Some(Mode::TextInput(handler)) = app.framework.focused_pane_mode(app) {
handler(*bind, app);
return true;
}
if handle_framework_overlay_editor_key(app, bind, overlay) {
return true;
}
match overlay {
FrameworkOverlayId::Settings => dispatch_settings_overlay(app, bind),
FrameworkOverlayId::Keymap => dispatch_keymap_overlay(app, bind, normalized),
FrameworkOverlayId::GlobalShortcuts => dispatch_global_shortcuts_overlay(app, bind),
}
true
}
fn dispatch_settings_overlay(app: &mut App, bind: &KeyBind) {
if let Some(action) = app.framework_keymap.overlay().action_for(bind) {
settings::dispatch_settings_action(action, app);
return;
}
settings::handle_settings_navigation_key(app, bind.code);
}
fn dispatch_keymap_overlay(app: &mut App, bind: &KeyBind, normalized: &KeyEvent) {
if let Some(action) = app.framework_keymap.overlay().action_for(bind) {
keymap_ui::dispatch_keymap_action(action, app);
return;
}
keymap_ui::handle_keymap_navigation_key(app, normalized);
}
fn dispatch_global_shortcuts_overlay(app: &mut App, bind: &KeyBind) {
if let Some(action) = app.framework_keymap.overlay().action_for(bind)
&& matches!(action, tui_pane::OverlayAction::Cancel)
{
app.close_framework_overlay_if_open();
return;
}
app.framework
.global_shortcuts_pane
.handle_navigation_key(bind.code);
}
fn dispatch_finder_overlay(app: &mut App, bind: &KeyBind) -> bool {
if !app.overlays.is_finder_open() {
return false;
}
match (FinderPane::mode())(app) {
Mode::TextInput(handler) => handler(*bind, app),
Mode::Static | Mode::Navigable => finder::handle_finder_text_key(app, bind.code),
}
true
}
fn dispatch_navigation(app: &mut App, focused: FocusedPane<AppPaneId>, bind: &KeyBind) -> bool {
let keymap = Rc::clone(&app.framework_keymap);
let Some(nav_scope) = keymap.navigation() else {
return false;
};
app.pending_nav_chord.push(*bind);
let pending = KeySequence::new(app.pending_nav_chord.clone());
if let Some(action) = nav_scope.action_for_sequence(&pending)
&& !nav_scope.has_prefix(pending.keys())
{
app.pending_nav_chord.clear();
(AppNavigation::dispatcher())(action, focused, app);
return true;
}
if nav_scope.has_prefix(pending.keys()) {
return true;
}
app.pending_nav_chord.clear();
let single = KeySequence::from(*bind);
if let Some(action) = nav_scope.action_for_sequence(&single)
&& !nav_scope.has_prefix(single.keys())
{
(AppNavigation::dispatcher())(action, focused, app);
return true;
}
if nav_scope.has_prefix(single.keys()) {
app.pending_nav_chord.push(*bind);
return true;
}
false
}
fn focused_text_input_mode(app: &App) -> bool {
if app.framework.overlay() == Some(FrameworkOverlayId::Keymap)
&& app.framework.keymap_pane.is_capturing()
{
return true;
}
matches!(
app.framework.focused_pane_mode(app),
Some(Mode::TextInput(_))
)
}
fn normalize_nav(app: &App, raw: &KeyEvent) -> KeyEvent {
if focused_text_input_mode(app) {
return *raw;
}
let code = if raw.modifiers == KeyModifiers::NONE && app.config.navigation_keys().uses_vim() {
match panes::behavior(app.focused_pane_id()) {
PaneBehavior::DetailFields
| PaneBehavior::DetailTargets
| PaneBehavior::Cpu
| PaneBehavior::CiRuns
| PaneBehavior::Toasts => match raw.code {
KeyCode::Char('h' | 'k') => KeyCode::Up,
KeyCode::Char('j' | 'l') => KeyCode::Down,
_ => raw.code,
},
_ => match raw.code {
KeyCode::Char('h') => KeyCode::Left,
KeyCode::Char('j') => KeyCode::Down,
KeyCode::Char('k') => KeyCode::Up,
KeyCode::Char('l') => KeyCode::Right,
_ => raw.code,
},
}
} else {
raw.code
};
let code = if raw.modifiers == KeyModifiers::NONE {
match panes::behavior(app.focused_pane_id()) {
PaneBehavior::DetailFields
| PaneBehavior::DetailTargets
| PaneBehavior::Cpu
| PaneBehavior::CiRuns
| PaneBehavior::Toasts => match code {
KeyCode::Left => KeyCode::Up,
KeyCode::Right => KeyCode::Down,
_ => code,
},
_ => code,
}
} else {
code
};
KeyEvent::new(code, raw.modifiers)
}
fn handle_confirm_key(app: &mut App, key: KeyCode) -> bool {
if key == KeyCode::Char('y') && app.scan.confirm_verifying().is_some() {
return true;
}
let Some(action) = app.take_confirm() else {
return false;
};
if key == KeyCode::Char('y') {
match action {
ConfirmAction::Clean(abs_path) => {
if app.start_clean(&abs_path) {
app.inflight
.pending_cleans_mut()
.push_back(PendingClean { abs_path });
}
},
ConfirmAction::CleanGroup { primary, linked } => {
for path in std::iter::once(primary).chain(linked) {
if app.start_clean(&path) {
app.inflight
.pending_cleans_mut()
.push_back(PendingClean { abs_path: path });
}
}
},
ConfirmAction::KillTarget {
pid, create_time, ..
} => {
panes::execute_target_kill(app, pid, create_time);
},
}
}
true
}
fn handle_mouse_event(app: &mut App, kind: MouseEventKind, column: u16, row: u16) {
if app.confirm().is_some() {
return;
}
match kind {
MouseEventKind::ScrollUp => scroll_pane_at(app, column, row, true),
MouseEventKind::ScrollDown => scroll_pane_at(app, column, row, false),
MouseEventKind::Down(MouseButton::Left) => {
handle_mouse_click(app, column, row, ClickMode::Dispatch);
},
MouseEventKind::Drag(MouseButton::Left) => handle_output_drag(app, column, row),
_ => {},
}
}
fn handle_output_drag(app: &mut App, column: u16, row: u16) {
if !app.focus_is(PaneId::Output) {
return;
}
let pos = Position::new(column, row);
let Some(row) = app.panes.output.viewport.pos_to_local_row(pos) else {
return;
};
let live = app.inflight.example_output().to_vec();
app.panes.output.select_drag_to(&live, row);
}
fn scroll_pane_at(app: &mut App, column: u16, row: u16, scroll_up: bool) {
let up = scroll_up ^ app.config.invert_scroll().is_inverted();
let pos = Position::new(column, row);
if scroll_modal_overlay_at(app, pos, up) {
return;
}
if app.panes.project_list.body_rect.contains(pos) {
if up {
app.project_list.move_up();
} else {
app.project_list.move_down();
}
return;
}
let pane_regions = app
.panes
.tiled_layout
.panes
.iter()
.map(|resolved| (resolved.pane, resolved.area))
.collect::<Vec<_>>();
for (pane_id, pane_rect) in pane_regions {
if pane_id == PaneId::ProjectList || !pane_rect.contains(pos) {
continue;
}
if pane_id == PaneId::Package {
let action = if up { NavAction::Up } else { NavAction::Down };
panes::navigate_package_detail(app, action);
return;
}
if let Some(pane) = interaction::viewport_mut_for(app, pane_id) {
if up {
pane.up();
} else {
pane.down();
}
if pane_id == PaneId::Targets {
panes::sync_running_targets_cursor(app);
}
}
return;
}
}
const fn scroll_modal_overlay_at(app: &mut App, pos: Position, up: bool) -> bool {
if app.overlays.is_finder_open() {
scroll_viewport_if_contains(&mut app.overlays.finder_pane.viewport, pos, up);
return true;
}
if app.overlays.is_sccache_open() {
scroll_viewport_if_contains(app.overlays.sccache_pane.viewport_mut(), pos, up);
return true;
}
match app.framework.overlay() {
Some(FrameworkOverlayId::Settings) => {
scroll_viewport_if_contains(app.framework.settings_pane.viewport_mut(), pos, up);
true
},
Some(FrameworkOverlayId::Keymap) => {
scroll_viewport_if_contains(app.framework.keymap_pane.viewport_mut(), pos, up);
true
},
Some(FrameworkOverlayId::GlobalShortcuts) => {
scroll_viewport_if_contains(
app.framework.global_shortcuts_pane.viewport_mut(),
pos,
up,
);
true
},
None => false,
}
}
const fn scroll_viewport_if_contains(viewport: &mut Viewport, pos: Position, up: bool) {
if !viewport.content_area().contains(pos) {
return;
}
if up {
viewport.up();
} else {
viewport.down();
}
}
const fn pane_label(pane: PaneId) -> &'static str {
match pane {
PaneId::ProjectList => "project_list",
PaneId::Package => "package",
PaneId::Lang => "lang",
PaneId::Cpu => "cpu",
PaneId::Git => "git",
PaneId::Targets => "targets",
PaneId::Lints => "lints",
PaneId::CiRuns => "ci_runs",
PaneId::Output => "output",
PaneId::Toasts => "toasts",
PaneId::Settings => "settings",
PaneId::Finder => "finder",
PaneId::Keymap => "keymap",
PaneId::Sccache => "sccache",
}
}
fn handle_mouse_click(app: &mut App, column: u16, row: u16, mode: ClickMode) {
let pos = Position::new(column, row);
if app.confirm().is_some() {
return;
}
if matches!(mode, interaction::ClickMode::Dispatch)
&& let Some(hovered) = interaction::hovered_pane_row_at(app, pos)
&& hovered.pane == PaneId::Output
{
app.set_focus(FocusedPane::App(AppPaneId::Output));
let live = app.inflight.example_output().to_vec();
app.panes.output.click_select_row(&live, hovered.row);
return;
}
if interaction::handle_click(app, pos, mode) {
return;
}
if app.framework.overlay().is_some()
|| app.overlays.is_finder_open()
|| app.overlays.is_sccache_open()
{
return;
}
let project_list = app.panes.project_list.body_rect;
let pane_regions = app
.panes
.tiled_layout
.panes
.iter()
.map(|resolved| (resolved.pane, resolved.area))
.collect::<Vec<_>>();
if project_list.contains(pos) {
app.set_focus(FocusedPane::App(AppPaneId::ProjectList));
return;
}
for (pane_id, pane_rect) in pane_regions {
if pane_id != PaneId::ProjectList && pane_rect.contains(pos) {
if let Some(id) = AppPaneId::from_legacy(pane_id) {
app.set_focus(FocusedPane::App(id));
}
return;
}
}
}
pub(super) fn dispatch_project_list_action(action: ProjectListAction, app: &mut App) {
let include_non_rust = app.config.include_non_rust().includes_non_rust();
match action {
ProjectListAction::ExpandAll => app.project_list.expand_all(include_non_rust),
ProjectListAction::CollapseAll => app.project_list.collapse_all(include_non_rust),
ProjectListAction::ExpandRow => {
if !app.expand() {
app.project_list.move_down();
}
},
ProjectListAction::CollapseRow => {
if !app.project_list.collapse(include_non_rust) {
app.project_list.move_up();
}
},
}
}
pub(super) fn dispatch_output_action(action: OutputAction, app: &mut App) {
match action {
OutputAction::SelectAll => {
let live = app.inflight.example_output().to_vec();
app.panes.output.select_all(&live);
},
OutputAction::Cancel => {
if !app.inflight.example_output().is_empty() {
app.inflight.clear_example_output();
app.set_focus(FocusedPane::App(AppPaneId::Targets));
}
},
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::editor_terminal::framework_overlay_editor_target_path;
use super::editor_terminal::terminal_shell_command;
use super::*;
use crate::project::AbsolutePath;
#[test]
fn terminal_shell_command_leaves_command_without_path_placeholder_unchanged() {
assert_eq!(
terminal_shell_command("open -a Terminal .", Path::new("/tmp/my project")),
"open -a Terminal ."
);
}
#[test]
fn terminal_shell_command_substitutes_shell_escaped_path() {
assert_eq!(
terminal_shell_command("cd {path} && exec zsh", Path::new("/tmp/my project")),
"cd '/tmp/my project' && exec zsh"
);
}
#[test]
fn terminal_shell_command_escapes_single_quotes() {
assert_eq!(
terminal_shell_command("cd {path}", Path::new("/tmp/bob's project")),
"cd '/tmp/bob'\\''s project'"
);
}
#[test]
fn framework_overlay_editor_target_path_uses_settings_config_path() {
let config_path = Path::new("/tmp/config.toml");
assert_eq!(
framework_overlay_editor_target_path(
FrameworkOverlayId::Settings,
Some(config_path),
None
),
Some(AbsolutePath::from(config_path))
);
}
#[test]
fn framework_overlay_editor_target_path_uses_keymap_path() {
let keymap_path = Path::new("/tmp/keymap.toml");
assert_eq!(
framework_overlay_editor_target_path(
FrameworkOverlayId::Keymap,
None,
Some(keymap_path)
),
Some(AbsolutePath::from(keymap_path))
);
}
}