refactor: split input processor by responsibility

This commit is contained in:
kuddev committed 2026-07-16 18:57:20 +08:00
1 parent d9daa6058b
commit 870490cf3a
6 files changed
+3016 -2945

No files matched your search

+354
View File
@@ -0,0 +1,354 @@
//! Dispatch configured input actions to the active input context.
use log::debug;
#[cfg(target_os = "macos")]
use winit::platform::macos::ActiveEventLoopExtMacOS;
use nebula_terminal::event::EventListener;
use nebula_terminal::grid::{Dimensions, Scroll};
use nebula_terminal::index::{Boundary, Direction, Side};
use nebula_terminal::selection::SelectionType;
use nebula_terminal::term::{ClipboardType, TermMode};
use nebula_terminal::vi_mode::ViMotion;
use nebula_terminal::vte::ansi::{ClearMode, Handler};
#[cfg(target_os = "macos")]
use crate::config::window::Decorations;
use crate::config::{Action, MouseAction, SearchAction, ViAction};
use super::{ActionContext, FONT_SIZE_STEP};
impl Action {
fn toggle_selection<T, A>(ctx: &mut A, ty: SelectionType)
where
A: ActionContext<T>,
T: EventListener,
{
ctx.toggle_selection(ty, ctx.terminal().vi_mode_cursor.point, Side::Left);
// Make sure initial selection is not empty.
if let Some(selection) = &mut ctx.terminal_mut().selection {
selection.include_all();
}
}
}
pub(super) trait Execute<T: EventListener> {
fn execute<A: ActionContext<T>>(&self, ctx: &mut A);
}
impl<T: EventListener> Execute<T> for Action {
#[inline]
fn execute<A: ActionContext<T>>(&self, ctx: &mut A) {
match self {
// Esc/chars bindings are keystroke sequences (e.g. Shift+Enter → ESC+CR for
// Claude Code multiline), not clipboard pastes. paste() gates on \r/\n and
// would pop the multi-line confirm modal, so write through paste_now.
Action::Esc(s) => ctx.paste_now(s, false),
Action::Command(program) => ctx.spawn_daemon(program.program(), program.args()),
Action::Hint(hint) => {
ctx.display().hint_state.start(hint.clone());
ctx.mark_dirty();
},
Action::ToggleViMode => {
ctx.on_typing_start();
ctx.toggle_vi_mode()
},
action @ (Action::ViMotion(_) | Action::Vi(_))
if !ctx.terminal().mode().contains(TermMode::VI) =>
{
debug!("Ignoring {action:?}: Vi mode inactive");
},
Action::ViMotion(motion) => {
ctx.on_typing_start();
ctx.terminal_mut().vi_motion(*motion);
ctx.mark_dirty();
},
Action::Vi(ViAction::ToggleNormalSelection) => {
Self::toggle_selection(ctx, SelectionType::Simple);
},
Action::Vi(ViAction::ToggleLineSelection) => {
Self::toggle_selection(ctx, SelectionType::Lines);
},
Action::Vi(ViAction::ToggleBlockSelection) => {
Self::toggle_selection(ctx, SelectionType::Block);
},
Action::Vi(ViAction::ToggleSemanticSelection) => {
Self::toggle_selection(ctx, SelectionType::Semantic);
},
Action::Vi(ViAction::Open) => {
let hint = ctx.display().vi_highlighted_hint.take();
if let Some(hint) = &hint {
ctx.mouse_mut().block_hint_launcher = false;
ctx.trigger_hint(hint);
}
ctx.display().vi_highlighted_hint = hint;
},
Action::Vi(ViAction::SearchNext) => {
ctx.on_typing_start();
let terminal = ctx.terminal();
let direction = ctx.search_direction();
let vi_point = terminal.vi_mode_cursor.point;
let origin = match direction {
Direction::Right => vi_point.add(terminal, Boundary::None, 1),
Direction::Left => vi_point.sub(terminal, Boundary::None, 1),
};
if let Some(regex_match) = ctx.search_next(origin, direction, Side::Left) {
ctx.terminal_mut().vi_goto_point(*regex_match.start());
ctx.mark_dirty();
}
},
Action::Vi(ViAction::SearchPrevious) => {
ctx.on_typing_start();
let terminal = ctx.terminal();
let direction = ctx.search_direction().opposite();
let vi_point = terminal.vi_mode_cursor.point;
let origin = match direction {
Direction::Right => vi_point.add(terminal, Boundary::None, 1),
Direction::Left => vi_point.sub(terminal, Boundary::None, 1),
};
if let Some(regex_match) = ctx.search_next(origin, direction, Side::Left) {
ctx.terminal_mut().vi_goto_point(*regex_match.start());
ctx.mark_dirty();
}
},
Action::Vi(ViAction::SearchStart) => {
let terminal = ctx.terminal();
let origin = terminal.vi_mode_cursor.point.sub(terminal, Boundary::None, 1);
if let Some(regex_match) = ctx.search_next(origin, Direction::Left, Side::Left) {
ctx.terminal_mut().vi_goto_point(*regex_match.start());
ctx.mark_dirty();
}
},
Action::Vi(ViAction::SearchEnd) => {
let terminal = ctx.terminal();
let origin = terminal.vi_mode_cursor.point.add(terminal, Boundary::None, 1);
if let Some(regex_match) = ctx.search_next(origin, Direction::Right, Side::Right) {
ctx.terminal_mut().vi_goto_point(*regex_match.end());
ctx.mark_dirty();
}
},
Action::Vi(ViAction::CenterAroundViCursor) => {
let term = ctx.terminal();
let display_offset = term.grid().display_offset() as i32;
let target = -display_offset + term.screen_lines() as i32 / 2 - 1;
let line = term.vi_mode_cursor.point.line;
let scroll_lines = target - line.0;
ctx.scroll(Scroll::Delta(scroll_lines));
},
Action::Vi(ViAction::InlineSearchForward) => {
ctx.start_inline_search(Direction::Right, false)
},
Action::Vi(ViAction::InlineSearchBackward) => {
ctx.start_inline_search(Direction::Left, false)
},
Action::Vi(ViAction::InlineSearchForwardShort) => {
ctx.start_inline_search(Direction::Right, true)
},
Action::Vi(ViAction::InlineSearchBackwardShort) => {
ctx.start_inline_search(Direction::Left, true)
},
Action::Vi(ViAction::InlineSearchNext) => ctx.inline_search_next(),
Action::Vi(ViAction::InlineSearchPrevious) => ctx.inline_search_previous(),
Action::Vi(ViAction::SemanticSearchForward | ViAction::SemanticSearchBackward) => {
let seed_text = match ctx.terminal().selection_to_string() {
Some(selection) if !selection.is_empty() => selection,
// Get semantic word at the vi cursor position.
_ => ctx.semantic_word(ctx.terminal().vi_mode_cursor.point),
};
if !seed_text.is_empty() {
let direction = match self {
Action::Vi(ViAction::SemanticSearchForward) => Direction::Right,
_ => Direction::Left,
};
ctx.start_seeded_search(direction, seed_text);
}
},
action @ Action::Search(_) if !ctx.search_active() => {
debug!("Ignoring {action:?}: Search mode inactive");
},
Action::Search(SearchAction::SearchFocusNext) => {
ctx.advance_search_origin(ctx.search_direction());
},
Action::Search(SearchAction::SearchFocusPrevious) => {
let direction = ctx.search_direction().opposite();
ctx.advance_search_origin(direction);
},
Action::Search(SearchAction::SearchConfirm) => ctx.confirm_search(),
Action::Search(SearchAction::SearchCancel) => ctx.cancel_search(),
Action::Search(SearchAction::SearchClear) => {
let direction = ctx.search_direction();
ctx.cancel_search();
ctx.start_search(direction);
},
Action::Search(SearchAction::SearchDeleteWord) => ctx.search_pop_word(),
Action::Search(SearchAction::SearchHistoryPrevious) => ctx.search_history_previous(),
Action::Search(SearchAction::SearchHistoryNext) => ctx.search_history_next(),
Action::Mouse(MouseAction::ExpandSelection) => ctx.expand_selection(),
Action::SearchForward => ctx.start_search(Direction::Right),
Action::SearchBackward => ctx.start_search(Direction::Left),
Action::Copy => ctx.copy_selection(ClipboardType::Clipboard),
#[cfg(not(any(target_os = "macos", windows)))]
Action::CopySelection => ctx.copy_selection(ClipboardType::Selection),
Action::ClearSelection => ctx.clear_selection(),
Action::Paste => {
let text = ctx.clipboard_mut().load(ClipboardType::Clipboard);
ctx.paste(&text, true);
},
Action::PasteSelection => {
let text = ctx.clipboard_mut().load(ClipboardType::Selection);
ctx.paste(&text, true);
},
Action::ToggleFullscreen => ctx.window().toggle_fullscreen(),
Action::ToggleMaximized => ctx.window().toggle_maximized(),
#[cfg(target_os = "macos")]
Action::ToggleSimpleFullscreen => ctx.window().toggle_simple_fullscreen(),
#[cfg(target_os = "macos")]
Action::Hide => ctx.event_loop().hide_application(),
#[cfg(target_os = "macos")]
Action::HideOtherApplications => ctx.event_loop().hide_other_applications(),
#[cfg(not(target_os = "macos"))]
Action::Hide => ctx.window().set_visible(false),
Action::Minimize => ctx.window().set_minimized(true),
Action::Quit => {
ctx.window().hold = false;
ctx.terminal_mut().exit();
},
Action::IncreaseFontSize => ctx.change_font_size(FONT_SIZE_STEP),
Action::DecreaseFontSize => ctx.change_font_size(-FONT_SIZE_STEP),
Action::ResetFontSize => ctx.reset_font_size(),
Action::ScrollPageUp
| Action::ScrollPageDown
| Action::ScrollHalfPageUp
| Action::ScrollHalfPageDown => {
// Move vi mode cursor.
let term = ctx.terminal_mut();
let (scroll, amount) = match self {
Action::ScrollPageUp => (Scroll::PageUp, term.screen_lines() as i32),
Action::ScrollPageDown => (Scroll::PageDown, -(term.screen_lines() as i32)),
Action::ScrollHalfPageUp => {
let amount = term.screen_lines() as i32 / 2;
(Scroll::Delta(amount), amount)
},
Action::ScrollHalfPageDown => {
let amount = -(term.screen_lines() as i32 / 2);
(Scroll::Delta(amount), amount)
},
_ => unreachable!(),
};
let old_vi_cursor = term.vi_mode_cursor;
term.vi_mode_cursor = term.vi_mode_cursor.scroll(term, amount);
if old_vi_cursor != term.vi_mode_cursor {
ctx.mark_dirty();
}
ctx.scroll(scroll);
},
Action::ScrollLineUp => ctx.scroll(Scroll::Delta(1)),
Action::ScrollLineDown => ctx.scroll(Scroll::Delta(-1)),
Action::ScrollToTop => {
ctx.scroll(Scroll::Top);
// Move vi mode cursor.
let topmost_line = ctx.terminal().topmost_line();
ctx.terminal_mut().vi_mode_cursor.point.line = topmost_line;
ctx.terminal_mut().vi_motion(ViMotion::FirstOccupied);
ctx.mark_dirty();
},
Action::ScrollToBottom => {
ctx.scroll(Scroll::Bottom);
// Move vi mode cursor.
let term = ctx.terminal_mut();
term.vi_mode_cursor.point.line = term.bottommost_line();
// Move to beginning twice, to always jump across linewraps.
term.vi_motion(ViMotion::FirstOccupied);
term.vi_motion(ViMotion::FirstOccupied);
ctx.mark_dirty();
},
Action::ClearHistory => ctx.terminal_mut().clear_screen(ClearMode::Saved),
Action::ClearLogNotice => ctx.pop_message(),
#[cfg(not(target_os = "macos"))]
Action::CreateNewWindow => ctx.create_new_window(),
Action::SpawnNewInstance => ctx.spawn_new_instance(),
#[cfg(target_os = "macos")]
Action::CreateNewWindow => ctx.create_new_window(None),
#[cfg(target_os = "macos")]
Action::CreateNewTab => {
// Tabs on macOS are not possible without decorations.
if ctx.config().window.decorations != Decorations::None {
let tabbing_id = Some(ctx.window().tabbing_id());
ctx.create_new_window(tabbing_id);
}
},
// Elsewhere the tab actions drive Nebula's own tab bar, so config
// `[[keyboard.bindings]]` can remap them freely (设置→按键映射).
#[cfg(not(target_os = "macos"))]
Action::CreateNewTab => ctx.nebula_tab(crate::event::TabRequest::New),
#[cfg(not(target_os = "macos"))]
Action::SelectNextTab => ctx.nebula_tab(crate::event::TabRequest::SelectNext),
#[cfg(not(target_os = "macos"))]
Action::SelectPreviousTab => ctx.nebula_tab(crate::event::TabRequest::SelectPrev),
#[cfg(not(target_os = "macos"))]
Action::SelectTab1 => ctx.nebula_tab(crate::event::TabRequest::Select(0)),
#[cfg(not(target_os = "macos"))]
Action::SelectTab2 => ctx.nebula_tab(crate::event::TabRequest::Select(1)),
#[cfg(not(target_os = "macos"))]
Action::SelectTab3 => ctx.nebula_tab(crate::event::TabRequest::Select(2)),
#[cfg(not(target_os = "macos"))]
Action::SelectTab4 => ctx.nebula_tab(crate::event::TabRequest::Select(3)),
#[cfg(not(target_os = "macos"))]
Action::SelectTab5 => ctx.nebula_tab(crate::event::TabRequest::Select(4)),
#[cfg(not(target_os = "macos"))]
Action::SelectTab6 => ctx.nebula_tab(crate::event::TabRequest::Select(5)),
#[cfg(not(target_os = "macos"))]
Action::SelectTab7 => ctx.nebula_tab(crate::event::TabRequest::Select(6)),
#[cfg(not(target_os = "macos"))]
Action::SelectTab8 => ctx.nebula_tab(crate::event::TabRequest::Select(7)),
#[cfg(not(target_os = "macos"))]
Action::SelectTab9 => ctx.nebula_tab(crate::event::TabRequest::Select(8)),
#[cfg(not(target_os = "macos"))]
Action::SelectLastTab => {
// The window context clamps out-of-range indices; usize::MAX
// is "last" only via SelectTab-specific handling, so send a
// large index the clamp folds to the final tab.
ctx.nebula_tab(crate::event::TabRequest::SelectLast)
},
#[cfg(target_os = "macos")]
Action::SelectNextTab => ctx.window().select_next_tab(),
#[cfg(target_os = "macos")]
Action::SelectPreviousTab => ctx.window().select_previous_tab(),
#[cfg(target_os = "macos")]
Action::SelectTab1 => ctx.window().select_tab_at_index(0),
#[cfg(target_os = "macos")]
Action::SelectTab2 => ctx.window().select_tab_at_index(1),
#[cfg(target_os = "macos")]
Action::SelectTab3 => ctx.window().select_tab_at_index(2),
#[cfg(target_os = "macos")]
Action::SelectTab4 => ctx.window().select_tab_at_index(3),
#[cfg(target_os = "macos")]
Action::SelectTab5 => ctx.window().select_tab_at_index(4),
#[cfg(target_os = "macos")]
Action::SelectTab6 => ctx.window().select_tab_at_index(5),
#[cfg(target_os = "macos")]
Action::SelectTab7 => ctx.window().select_tab_at_index(6),
#[cfg(target_os = "macos")]
Action::SelectTab8 => ctx.window().select_tab_at_index(7),
#[cfg(target_os = "macos")]
Action::SelectTab9 => ctx.window().select_tab_at_index(8),
#[cfg(target_os = "macos")]
Action::SelectLastTab => ctx.window().select_last_tab(),
_ => (),
}
}
}
+899
View File
@@ -0,0 +1,899 @@
//! Nebula chrome, modal, tab, drawer, and context-menu pointer dispatch.
use std::time::{Duration, Instant};
use winit::event::{ElementState, MouseButton};
use nebula_terminal::event::EventListener;
use nebula_terminal::grid::{Dimensions, Scroll};
use nebula_terminal::term::ClipboardType;
use crate::event::{ClickState, Event, EventType};
use crate::scheduler::{TimerId, Topic};
use super::{ActionContext, Processor};
/// Fallback double/triple-click interval where the OS setting isn't
/// available; Windows uses the user's control-panel value instead.
#[cfg(not(windows))]
const CLICK_THRESHOLD: Duration = Duration::from_millis(400);
/// Multi-click interval: the user's system double-click time on Windows.
#[cfg(windows)]
pub(super) fn multi_click_time() -> Duration {
let ms = unsafe { windows_sys::Win32::UI::Input::KeyboardAndMouse::GetDoubleClickTime() };
Duration::from_millis(u64::from(ms))
}
#[cfg(not(windows))]
pub(super) fn multi_click_time() -> Duration {
CLICK_THRESHOLD
}
/// Half the system double-click rectangle: how far apart two presses may
/// land (per axis) and still count as one multi-click sequence.
#[cfg(windows)]
fn double_click_slop() -> (f32, f32) {
use windows_sys::Win32::UI::WindowsAndMessaging::{
GetSystemMetrics, SM_CXDOUBLECLK, SM_CYDOUBLECLK,
};
let half = |v: i32| (v.max(4) as f32) / 2.0;
unsafe { (half(GetSystemMetrics(SM_CXDOUBLECLK)), half(GetSystemMetrics(SM_CYDOUBLECLK))) }
}
#[cfg(not(windows))]
fn double_click_slop() -> (f32, f32) {
(4.0, 4.0)
}
impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
pub fn nebula_confirm_accept(&mut self, confirm: crate::display::NebulaConfirm) {
use crate::display::NebulaConfirm;
match confirm {
NebulaConfirm::ClosePane { .. } => {
self.ctx.nebula_tab(crate::event::TabRequest::Close);
},
NebulaConfirm::CloseTab { index, .. } => {
self.ctx.nebula_tab(crate::event::TabRequest::CloseIndex(index));
},
NebulaConfirm::CloseWindow { .. } => {
self.ctx.nebula_tab(crate::event::TabRequest::CloseWindow);
},
NebulaConfirm::Paste { text, bracketed, .. } => {
self.ctx.display().nebula_confirm = None;
self.ctx.paste_now(&text, bracketed);
},
NebulaConfirm::DeleteSsh { host, .. } => {
self.ctx.display().nebula_confirm = None;
if self.ctx.display().confirm_delete_ssh_host(&host) {
self.schedule_ssh_delete_undo_expiry();
}
},
NebulaConfirm::DeleteSftp { entry } => {
self.ctx.display().sftp_confirm_delete(entry);
},
NebulaConfirm::InstallRequiredFont { directory } => {
self.ctx.display().nebula_confirm = None;
self.ctx.open_path(&directory);
},
}
}
/// Dismiss a confirmation without taking its primary action.
pub fn nebula_confirm_cancel(&mut self, confirm: crate::display::NebulaConfirm) {
if confirm.can_dismiss() {
self.ctx.display().nebula_confirm = None;
}
}
/// Keep timer creation and cancellation identical across keyboard, mouse,
/// and confirm-dialog paths. The delayed event owns only window routing;
/// sensitive credential state stays inside `Display`.
fn schedule_ssh_delete_undo_expiry(&mut self) {
let window_id = self.ctx.window().id();
let timer_id = TimerId::new(Topic::SshDeleteUndo, window_id);
let event = Event::new(EventType::SshDeleteUndoExpired, window_id);
let scheduler = self.ctx.scheduler_mut();
scheduler.unschedule(timer_id);
scheduler.schedule(event, crate::display::SSH_DELETE_UNDO_DURATION, false, timer_id);
}
/// Cancel expiry first so a late event cannot immediately dispose a host
/// that the user has just restored.
pub(super) fn undo_ssh_delete(&mut self) -> bool {
let window_id = self.ctx.window().id();
self.ctx.scheduler_mut().unschedule(TimerId::new(Topic::SshDeleteUndo, window_id));
self.ctx.display().undo_delete_ssh_host()
}
/// Advance the multi-click state machine for this press — exactly ONCE
/// per press, at the top of `on_mouse_press` (WT's `_numberOfClicks`
/// model). A press upgrades Click→Double→Triple only when it is the same
/// button, within the system double-click time AND within half a cell of
/// the previous press; anything else resets to a plain Click. The
/// distance gate keeps "click somewhere, then immediately click-drag
/// elsewhere" from being misread as a word/line drag.
fn advance_click_state(&mut self, button: MouseButton) -> ClickState {
let now = Instant::now();
let mouse = self.ctx.mouse();
let elapsed = now - mouse.last_click_timestamp;
let (last_x, last_y) = mouse.last_click_pos;
let (slop_x, slop_y) = double_click_slop();
let near = (mouse.x as f32 - last_x as f32).abs() <= slop_x
&& (mouse.y as f32 - last_y as f32).abs() <= slop_y;
let state = match mouse.click_state {
_ if button != mouse.last_click_button || !near || elapsed >= multi_click_time() => {
ClickState::Click
},
ClickState::Click => ClickState::DoubleClick,
ClickState::DoubleClick => ClickState::TripleClick,
_ => ClickState::Click,
};
let pos = (self.ctx.mouse().x, self.ctx.mouse().y);
let mouse = self.ctx.mouse_mut();
mouse.last_click_timestamp = now;
mouse.last_click_button = button;
mouse.last_click_pos = pos;
mouse.click_state = state;
state
}
fn run_context_menu_action(&mut self, action: crate::display::ContextMenuAction) {
use crate::display::ContextMenuAction::*;
match action {
DuplicateTab(index) => {
self.ctx.nebula_tab(crate::event::TabRequest::Duplicate(index));
},
SplitTabRight(index) => {
self.ctx.nebula_tab(crate::event::TabRequest::SplitIndex {
index,
direction: crate::display::SplitDirection::LeftRight,
});
},
SplitTabDown(index) => {
self.ctx.nebula_tab(crate::event::TabRequest::SplitIndex {
index,
direction: crate::display::SplitDirection::TopBottom,
});
},
RenameTab(index) => {
self.ctx.nebula_tab(crate::event::TabRequest::BeginRename(index));
},
CloseTab(index) => {
self.ctx.nebula_tab(crate::event::TabRequest::CloseIndex(index));
},
SetTabColor { index, color } => {
self.ctx.nebula_tab(crate::event::TabRequest::SetColor { index, color });
},
ConnectSsh(index) => {
let host = self.ctx.display().nebula_ssh_hosts.get(index).cloned();
if let Some(host) = host {
self.ctx.nebula_tab(crate::event::TabRequest::NewSsh(host));
}
},
OpenSftp(index) => {
let host = self.ctx.display().nebula_ssh_hosts.get(index).cloned();
if let Some(host) = host {
self.ctx.nebula_open_sftp(host);
}
},
CopySshAddress(index) => {
let host = self.ctx.display().nebula_ssh_hosts.get(index).cloned();
if let Some(host) = host {
self.ctx.clipboard_mut().store(ClipboardType::Clipboard, host);
}
},
EditSsh(index) => self.ctx.display().edit_ssh_host(index),
DeleteSsh(index) => self.ctx.display().request_delete_ssh_host(index),
DownloadSftp(index) => self.ctx.display().sftp_download_row(index),
RenameSftp(index) => self.ctx.display().sftp_begin_rename_row(index),
DeleteSftp(index) => self.ctx.display().sftp_request_delete_row(index),
}
self.ctx.mark_dirty();
}
pub(super) fn on_mouse_press(&mut self, button: MouseButton) {
// Multi-click bookkeeping happens here and nowhere else. It used to
// be advanced both by the chrome block and the terminal block below;
// the second advance saw elapsed≈0 and upgraded EVERY terminal click
// to a double/triple — plain drags selected by word or whole line.
self.advance_click_state(button);
// Window chrome is deliberately optional at the input boundary. The
// terminal's click-state machine is useful and testable without a GPU
// window; real application contexts keep the default `true` value.
if self.ctx.nebula_chrome_active() {
// Non-primary presses also release lightweight menus. Right-click on
// a context menu is handled below so it can naturally retarget.
if button != MouseButton::Left {
if button != MouseButton::Right && self.ctx.display().context_menu_interactive() {
self.ctx.display().close_context_menu();
self.ctx.mark_dirty();
return;
}
if self.ctx.display().command_palette_picker_open() {
self.ctx.display().close_command_palette();
self.ctx.mark_dirty();
return;
}
if self.ctx.display().nebula_shell_picker_open {
self.ctx.display().close_shell_picker();
self.ctx.mark_dirty();
return;
}
if self.ctx.display().nebula_font_picker_open {
self.ctx.display().close_font_picker();
self.ctx.mark_dirty();
return;
}
}
if button == MouseButton::Left && self.ctx.display().context_menu_interactive() {
let x = self.ctx.mouse().x as f32;
let y = self.ctx.mouse().y as f32;
if let crate::display::ContextMenuHit::Action(action) =
self.ctx.display().context_menu_click(x, y)
{
self.run_context_menu_action(action);
}
self.ctx.mark_dirty();
return;
}
// A left press anywhere OUTSIDE the rename box ends the edit
// (canceling, like Esc) — the click itself still lands wherever it
// was aimed. Clicking inside the box is caret placement (below).
if button == MouseButton::Left {
if let Some((idx, _)) = self.ctx.display().nebula_tab_rename.clone() {
let x = self.ctx.mouse().x as f32;
let y = self.ctx.mouse().y as f32;
if self.ctx.display().chrome_hit(x, y) != crate::display::ChromeHit::Tab(idx) {
self.ctx.nebula_tab(crate::event::TabRequest::CancelRename);
}
}
}
if button == MouseButton::Left && self.ctx.display().nebula_ssh_editor.is_some() {
if self.ctx.display().ssh_editor_active() {
let x = self.ctx.mouse().x as f32;
let y = self.ctx.mouse().y as f32;
self.ctx.display().ssh_editor_click(x, y);
}
self.ctx.mark_dirty();
return;
}
// Nebula command palette: clicking a row runs it, clicking outside
// dismisses — same modal semantics as the keyboard path.
if button == MouseButton::Left && self.ctx.display().command_palette_open() {
let x = self.ctx.mouse().x as f32;
let y = self.ctx.mouse().y as f32;
let size = self.ctx.display().size_info;
let scale = self.ctx.window().scale_factor as f32;
let layout = crate::display::command_palette::palette_layout(
size.width(),
size.height(),
scale,
);
let (px, py, pw, ph) = layout.panel;
if x >= px && x < px + pw && y >= py && y < py + ph {
if y >= layout.list_y {
let row = ((y - layout.list_y) / layout.row_h) as usize;
if let Some(action) = self.ctx.display().palette_click(row, layout.max_rows)
{
self.run_palette_action(action);
}
}
} else {
self.ctx.display().close_command_palette();
}
self.ctx.mark_dirty();
return;
}
// The reversible-action bar floats above drawer/chrome content. True
// modals still retain pointer ownership and therefore block this path.
let undo_pointer = (self.ctx.mouse().x as f32, self.ctx.mouse().y as f32);
if button == MouseButton::Left
&& self.ctx.display().nebula_confirm.is_none()
&& self.ctx.display().ssh_delete_undo_hit(undo_pointer.0, undo_pointer.1)
{
self.undo_ssh_delete();
self.ctx.mark_dirty();
return;
}
// Right-side drawer (directory tree / git): header tabs switch views,
// directory rows expand/collapse. Sits under the modal layers, so
// only when no modal owns the pointer.
if button == MouseButton::Left
&& self.ctx.display().nebula_sftp_panel.is_some()
&& !self.ctx.display().settings_open()
&& self.ctx.display().nebula_confirm.is_none()
{
let x = self.ctx.mouse().x as f32;
let y = self.ctx.mouse().y as f32;
let hit = self.ctx.display().sftp_hit(x, y);
if hit != crate::display::sftp_panel::SftpHit::None {
self.ctx.display().sftp_click(hit);
self.ctx.mark_dirty();
return;
}
}
if button == MouseButton::Left
&& self.ctx.display().nebula_side_panel.open
&& self.ctx.display().nebula_sftp_panel.is_none()
&& !self.ctx.display().settings_open()
&& self.ctx.display().nebula_confirm.is_none()
{
use crate::display::side_panel::{PanelHit, PanelView, panel_interactive_hit};
let x = self.ctx.mouse().x as f32;
let y = self.ctx.mouse().y as f32;
let layout = self.ctx.display().side_panel_layout();
let view = self.ctx.display().nebula_side_panel.view;
let custom_root = self.ctx.display().nebula_side_panel.custom_root_active();
match panel_interactive_hit(&layout, view, custom_root, x, y) {
PanelHit::None => {
// Clicking anywhere outside the drawer drops search focus
// and the persistent file selection.
let panel = &mut self.ctx.display().nebula_side_panel;
if panel.search_focus || panel.selected.is_some() {
panel.search_unfocus(false);
panel.selected = None;
self.ctx.mark_dirty();
}
},
hit => {
match hit {
PanelHit::ViewFiles => {
self.ctx.display().toggle_side_panel(PanelView::Files)
},
PanelHit::ViewGit => {
self.ctx.display().toggle_side_panel(PanelView::Git)
},
PanelHit::OpenDirectory => {
self.ctx.display().choose_side_panel_directory();
},
PanelHit::FollowCurrentDirectory => {
self.ctx.display().follow_focused_directory();
},
PanelHit::Search => {
let files =
self.ctx.display().nebula_side_panel.view == PanelView::Files;
if files {
// The Files view's filter box takes focus.
self.ctx.display().nebula_side_panel.search_focus = true;
} else {
// Git view: that strip is the 暂存/提交/推送
// button row (or the commit-message input,
// which the keyboard owns — clicks are inert).
if !self.ctx.display().nebula_side_panel.commit_focus {
let (sx, _, sw, _) = layout.search;
let gap = 6.0 * self.ctx.window().scale_factor as f32;
let rects = crate::display::side_panel::git_button_rects(
sx, sw, gap,
);
let panel = &mut self.ctx.display().nebula_side_panel;
let action =
rects.iter().position(|(button_x, button_w)| {
x >= *button_x && x < *button_x + *button_w
});
match action {
Some(0) => panel.git_stage_all(),
Some(1) => panel.git_begin_commit(),
Some(2) => panel.git_pull(),
Some(3) => panel.git_push(),
_ => {},
}
}
}
},
PanelHit::Row(row) => {
self.ctx.display().nebula_side_panel.search_unfocus(false);
let info = self
.ctx
.display()
.nebula_side_panel
.visible_row(row)
.map(|r| (r.path.clone(), r.is_dir));
match info {
// Directories expand/collapse on click.
Some((_, true)) | None => {
self.ctx.display().nebula_side_panel.click_row(row);
},
// Files: double-click opens with the system
// handler; a single press arms a drag toward
// the terminal (drop pastes the path).
Some((path, false)) => {
use crate::display::side_panel::FileDrag;
let now = std::time::Instant::now();
let dbl = {
let panel = &mut self.ctx.display().nebula_side_panel;
// Click = persistent selection (until
// clicking off the panel / closing it).
panel.selected = Some(path.clone());
let dbl = panel.last_file_click.as_ref().is_some_and(
|(p, t)| {
*p == path
&& t.elapsed()
< std::time::Duration::from_millis(400)
},
);
if dbl {
panel.last_file_click = None;
panel.drag_file = None;
} else {
panel.last_file_click = Some((path.clone(), now));
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
panel.drag_file = Some(FileDrag {
path: path.clone(),
name,
origin: (x, y),
pos: (x, y),
active: false,
});
}
dbl
};
if dbl {
// Readable text files open in an
// in-app viewer tab; everything else
// goes to the system handler.
if crate::display::markdown_view::viewable_file(&path) {
self.ctx.nebula_tab(
crate::event::TabRequest::OpenDoc(path),
);
} else {
self.ctx.open_path(&path);
}
}
},
}
},
_ => {
self.ctx.display().nebula_side_panel.search_unfocus(false);
},
}
self.ctx.mark_dirty();
return;
},
}
}
// Right-clicking the sidebar "+" opens the quick-launch profile menu
// (Windows Terminal's profile dropdown); left-click keeps opening the
// default shell. Tab and SSH context menus will replace the old
// reorder/pin shortcuts; until that menu lands, SSH right-click is
// consumed without changing the saved-host order.
if button == MouseButton::Right {
let x = self.ctx.mouse().x as f32;
let y = self.ctx.mouse().y as f32;
let menu_was_open = self.ctx.display().context_menu_interactive();
if menu_was_open
&& !matches!(
self.ctx.display().context_menu_hit(x, y),
crate::display::ContextMenuHit::Outside
)
{
return;
}
if let crate::display::sftp_panel::SftpHit::Row(index) =
self.ctx.display().sftp_hit(x, y)
{
self.ctx.display().open_sftp_context_menu(index, x, y);
self.ctx.mark_dirty();
return;
}
match self.ctx.display().chrome_hit(x, y) {
crate::display::ChromeHit::NewTab => {
let profiles: Vec<String> =
self.ctx.config().profiles.iter().map(|p| p.name.clone()).collect();
// Detected shells fill the menu even with no config
// profiles, so always open it.
self.ctx.display().open_shell_menu(&profiles);
self.ctx.mark_dirty();
return;
},
crate::display::ChromeHit::Tab(index)
| crate::display::ChromeHit::TabClose(index) => {
self.ctx.display().open_tab_context_menu(index, x, y);
self.ctx.mark_dirty();
return;
},
crate::display::ChromeHit::Host(index) => {
self.ctx.display().open_ssh_context_menu(index, x, y);
self.ctx.mark_dirty();
return;
},
_ if menu_was_open => {
self.ctx.display().close_context_menu();
self.ctx.mark_dirty();
return;
},
_ => {},
}
}
// Nebula chrome: intercept clicks on the custom title bar and window
// controls before any terminal handling.
if button == MouseButton::Left {
let x = self.ctx.mouse().x as f32;
let y = self.ctx.mouse().y as f32;
// Chrome geometry is window-relative; the pane view would misplace
// every hit rect in split mode (unclickable gear, wrong tabs).
let size = self.ctx.display().size_info;
let scale = self.ctx.window().scale_factor as f32;
// Window border resize takes priority over the chrome controls.
let resize_enabled = self.ctx.window().allows_drag_resize();
if let Some(dir) = crate::display::resize_edge(&size, scale, x, y, resize_enabled) {
self.ctx.window().drag_resize(dir);
return;
}
// The confirm modal owns the pointer while it shows: its two
// buttons dispatch, any other click is swallowed (modal
// semantics — nothing may reach the UI behind the veil).
if let Some(confirm) = self.ctx.display().nebula_confirm.clone() {
if let Some((primary, cancel)) = self.ctx.display().nebula_confirm_buttons {
let hit = |(rx, ry, rw, rh): (f32, f32, f32, f32)| {
x >= rx && x < rx + rw && y >= ry && y < ry + rh
};
if hit(primary) {
self.nebula_confirm_accept(confirm);
} else if hit(cancel) {
self.nebula_confirm_cancel(confirm);
}
}
self.ctx.mark_dirty();
return;
}
let settings_open = self.ctx.display().settings_open();
let settings_section = self.ctx.display().settings_section();
let settings_scroll = self.ctx.display().settings_scroll();
let shell_picker_open = self.ctx.display().nebula_shell_picker_open;
let shell_picker_count = self.ctx.display().shell_picker_count();
let font_picker_open = self.ctx.display().nebula_font_picker_open;
let font_picker_count = self.ctx.display().font_picker_count();
let hidden_host_count = self.ctx.display().hidden_ssh_host_count();
let settings_area = self.ctx.display().terminal_card_rect();
let settings_hit = crate::display::settings_hit(
&size,
scale,
settings_area,
x,
y,
settings_open,
settings_section,
settings_scroll,
shell_picker_open,
shell_picker_count,
font_picker_open,
font_picker_count,
hidden_host_count,
);
if shell_picker_open
&& !matches!(
settings_hit,
crate::display::SettingsHit::ShellCycle
| crate::display::SettingsHit::ShellPickerRow(_)
)
{
// First outside click dismisses the picker without activating
// an unrelated control hidden behind its temporary focus scope.
self.ctx.display().close_shell_picker();
self.ctx.mark_dirty();
return;
}
if font_picker_open
&& !matches!(
settings_hit,
crate::display::SettingsHit::FontCycle
| crate::display::SettingsHit::FontPickerRow(_)
)
{
self.ctx.display().close_font_picker();
self.ctx.mark_dirty();
return;
}
match settings_hit {
crate::display::SettingsHit::Toggle => {
self.ctx.nebula_tab(crate::event::TabRequest::OpenSettings);
return;
},
crate::display::SettingsHit::Nav(section) => {
self.ctx.display().select_settings_section(section);
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::Theme(theme) => {
self.ctx.display().select_nebula_theme(theme);
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::Language(language) => {
self.ctx.display().set_ui_language(language);
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::GhostToggle => {
self.ctx.display().toggle_ghost();
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::AcceptCycle => {
self.ctx.display().cycle_accept();
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::ShellCycle => {
// Toggle inline shell picker (expand/collapse the list).
self.ctx.display().toggle_shell_picker();
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::ShellPickerRow(index) => {
self.ctx.display().set_default_shell_by_index(index);
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::FontCycle => {
self.ctx.display().toggle_font_picker();
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::FontPickerRow(index) => {
let base_font = self.ctx.config().font.clone();
self.ctx.display().set_terminal_font_by_index(index, &base_font);
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::SystemThemeToggle => {
self.ctx.display().toggle_system_theme_following();
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::RestoreHiddenSsh(index) => {
self.ctx.display().restore_hidden_ssh_host(index);
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::FetchToggle => {
self.ctx.display().toggle_fetch();
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::PowerlineToggle => {
self.ctx.display().toggle_powerline();
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::KeepSessionToggle => {
self.ctx.display().toggle_keep_session();
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::OpacityDown => {
self.ctx.display().adjust_window_opacity(-0.05);
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::OpacityUp => {
self.ctx.display().adjust_window_opacity(0.05);
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::BackgroundColor => {
self.ctx.display().cycle_background_color();
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::BackgroundImage => {
self.ctx.display().pick_background_image();
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::OpenConfigFile => {
self.ctx.display().open_user_config_file();
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::Reset => {
self.ctx.display().reset_appearance_settings();
self.ctx.mark_dirty();
return;
},
crate::display::SettingsHit::Panel => return,
crate::display::SettingsHit::None => {},
}
let chrome_hit = self.ctx.display().chrome_hit(x, y);
if self.ctx.display().nebula_special_tab_active
&& matches!(
chrome_hit,
crate::display::ChromeHit::PanelFiles | crate::display::ChromeHit::PanelGit
)
{
return;
}
// Multi-click state was advanced once at the top of this
// function; read it for the tab double-click rename below.
let state = self.ctx.mouse().click_state;
match chrome_hit {
crate::display::ChromeHit::NewTab => {
self.ctx.nebula_tab(crate::event::TabRequest::New);
return;
},
crate::display::ChromeHit::NewTabMenu => {
// The chevron opens the shell dropdown (detected shells +
// config profiles), like Windows Terminal's profile menu.
let profiles: Vec<String> =
self.ctx.config().profiles.iter().map(|p| p.name.clone()).collect();
self.ctx.display().open_shell_menu(&profiles);
self.ctx.mark_dirty();
return;
},
crate::display::ChromeHit::AddSshHost => {
self.ctx.display().open_ssh_editor();
self.ctx.mark_dirty();
return;
},
crate::display::ChromeHit::TabClose(index) => {
self.ctx.nebula_tab(crate::event::TabRequest::CloseIndex(index));
return;
},
crate::display::ChromeHit::Tab(index) => {
// Clicking inside the rename box places the caret there
// (real text-field behaviour) instead of starting a drag.
if self
.ctx
.display()
.nebula_tab_rename
.as_ref()
.is_some_and(|(i, _)| *i == index)
{
self.ctx.display().tab_rename_click(x);
self.ctx.mark_dirty();
return;
}
// Double-click a tab to start renaming (Windows Terminal style).
if state == ClickState::DoubleClick {
self.ctx.nebula_tab(crate::event::TabRequest::BeginRename(index));
return;
}
// Selection is deferred to release (a plain click becomes
// TabDropAction::Click): the terminal area must keep
// showing the ACTIVE tab while another tab is dragged over
// it toward a dock zone.
self.ctx.display().arm_tab_drag(index, x, y);
return;
},
crate::display::ChromeHit::SidebarToggle => {
self.ctx.display().toggle_sidebar();
self.ctx.mark_dirty();
return;
},
crate::display::ChromeHit::Host(index) => {
// Open a new tab connected to this ~/.ssh/config host.
let host = self.ctx.display().nebula_ssh_hosts.get(index).cloned();
if let Some(host) = host {
self.ctx.nebula_tab(crate::event::TabRequest::NewSsh(host));
}
return;
},
crate::display::ChromeHit::TabsSection => {
self.ctx.display().toggle_sidebar_section(false);
self.ctx.mark_dirty();
return;
},
crate::display::ChromeHit::HostsSection => {
self.ctx.display().toggle_sidebar_section(true);
self.ctx.mark_dirty();
return;
},
crate::display::ChromeHit::PanelFiles => {
self.ctx
.display()
.toggle_side_panel(crate::display::side_panel::PanelView::Files);
self.ctx.mark_dirty();
return;
},
crate::display::ChromeHit::PanelGit => {
self.ctx
.display()
.toggle_side_panel(crate::display::side_panel::PanelView::Git);
self.ctx.mark_dirty();
return;
},
crate::display::ChromeHit::Close => {
self.ctx.nebula_tab(crate::event::TabRequest::CloseWindow);
return;
},
crate::display::ChromeHit::Minimize => {
self.ctx.window().set_minimized(true);
return;
},
crate::display::ChromeHit::Maximize => {
self.ctx.window().toggle_maximized();
return;
},
crate::display::ChromeHit::TitleBar => {
self.ctx.window().drag_window();
return;
},
crate::display::ChromeHit::None => {},
}
// A press inside the chrome band (top bar / tab sidebar) that
// hit no control ends here. Falling through used to reach the
// terminal's selection arming, which clamps the point into the
// nearest grid cell — dragging from the sidebar then painted a
// stray selection across the pane (the "drag ghost").
let window_size = self.ctx.display().size_info;
let scale = self.ctx.window().scale_factor as f32;
if crate::display::in_chrome_bar(&window_size, scale, x, y) {
return;
}
// Nebula: grab the scrollback thumb (or jump on a track press).
// Only live while scrolled into history, since the bar auto-hides.
let view = self.ctx.size_info();
let display_offset = self.ctx.terminal().grid().display_offset();
let total_lines = self.ctx.terminal().total_lines();
if let Some(grab) =
self.ctx.display().scrollbar_grab(&view, display_offset, total_lines, x, y)
{
self.ctx.display().nebula_scrollbar_drag = Some(grab);
let target =
self.ctx.display().scrollbar_target_offset(&view, total_lines, y, grab);
let delta = target as i32 - display_offset as i32;
if delta != 0 {
self.ctx.scroll(Scroll::Delta(delta));
}
self.ctx.mark_dirty();
return;
}
}
}
// Nebula: right-click copies the selection, or pastes when there is
// none (Windows Terminal-style), unless the app is in mouse mode.
if button == MouseButton::Right
&& !self.ctx.modifiers().state().shift_key()
&& !self.ctx.mouse_mode()
{
if self.ctx.selection_is_empty() {
let text = self.ctx.clipboard_mut().load(ClipboardType::Clipboard);
self.ctx.paste(&text, true);
} else {
self.ctx.copy_selection(ClipboardType::Clipboard);
self.ctx.clear_selection();
}
return;
}
// Handle mouse mode.
if !self.ctx.modifiers().state().shift_key() && self.ctx.mouse_mode() {
self.ctx.mouse_mut().click_state = ClickState::None;
let code = match button {
MouseButton::Left => 0,
MouseButton::Middle => 1,
MouseButton::Right => 2,
// Can't properly report more than three buttons..
MouseButton::Back | MouseButton::Forward | MouseButton::Other(_) => return,
};
self.mouse_report(code, ElementState::Pressed);
} else {
// Multi-click state was advanced once at the top of this function.
// Load mouse point, treating message bar and padding as the closest cell.
let display_offset = self.ctx.terminal().grid().display_offset();
let point = self.ctx.mouse().point(&self.ctx.size_info(), display_offset);
if let MouseButton::Left = button {
self.on_left_click(point)
}
}
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+421
View File
@@ -0,0 +1,421 @@
use std::time::{Duration, Instant};
use winit::event::{
DeviceId, ElementState, Event as WinitEvent, Modifiers, MouseButton, WindowEvent,
};
#[cfg(target_os = "macos")]
use winit::event_loop::ActiveEventLoop;
use winit::keyboard::{Key, ModifiersState};
use winit::window::WindowId;
use nebula_terminal::event::{Event as TerminalEvent, EventListener};
use nebula_terminal::grid::Scroll;
use nebula_terminal::index::{Direction, Point, Side};
use nebula_terminal::term::Term;
use nebula_terminal::term::search::Match;
use crate::clipboard::Clipboard;
use crate::config::{Action, Binding, BindingMode, UiConfig};
use crate::display::window::Window;
use crate::display::{Display, SizeInfo};
use crate::event::{ClickState, InlineSearchState, Mouse, TouchPurpose};
use crate::message_bar::{Message, MessageBuffer};
use crate::scheduler::Scheduler;
use super::chrome::multi_click_time;
use super::{ActionContext as InputActionContext, Processor};
const KEY: Key<&'static str> = Key::Character("0");
struct MockEventProxy;
impl EventListener for MockEventProxy {}
struct ActionContext<'a, T> {
pub terminal: &'a mut Term<T>,
pub size_info: &'a SizeInfo,
pub mouse: &'a mut Mouse,
pub clipboard: &'a mut Clipboard,
pub message_buffer: &'a mut MessageBuffer,
pub modifiers: Modifiers,
config: &'a UiConfig,
inline_search_state: &'a mut InlineSearchState,
}
impl<T: EventListener> InputActionContext<T> for ActionContext<'_, T> {
fn search_next(&mut self, _origin: Point, _direction: Direction, _side: Side) -> Option<Match> {
None
}
fn search_direction(&self) -> Direction {
Direction::Right
}
fn inline_search_state(&mut self) -> &mut InlineSearchState {
self.inline_search_state
}
fn search_active(&self) -> bool {
false
}
fn terminal(&self) -> &Term<T> {
self.terminal
}
fn terminal_mut(&mut self) -> &mut Term<T> {
self.terminal
}
fn size_info(&self) -> SizeInfo {
*self.size_info
}
fn selection_is_empty(&self) -> bool {
true
}
fn scroll(&mut self, scroll: Scroll) {
self.terminal.scroll_display(scroll);
}
fn mouse_mode(&self) -> bool {
false
}
#[inline]
fn mouse_mut(&mut self) -> &mut Mouse {
self.mouse
}
#[inline]
fn mouse(&self) -> &Mouse {
self.mouse
}
#[inline]
fn touch_purpose(&mut self) -> &mut TouchPurpose {
unimplemented!();
}
fn modifiers(&mut self) -> &mut Modifiers {
&mut self.modifiers
}
fn window(&mut self) -> &mut Window {
unimplemented!();
}
fn display(&mut self) -> &mut Display {
unimplemented!();
}
fn nebula_chrome_active(&self) -> bool {
false
}
fn pop_message(&mut self) {
self.message_buffer.pop();
}
fn message(&self) -> Option<&Message> {
self.message_buffer.message()
}
fn config(&self) -> &UiConfig {
self.config
}
fn clipboard_mut(&mut self) -> &mut Clipboard {
self.clipboard
}
#[cfg(target_os = "macos")]
fn event_loop(&self) -> &ActiveEventLoop {
unimplemented!();
}
fn scheduler_mut(&mut self) -> &mut Scheduler {
unimplemented!();
}
fn semantic_word(&self, _point: Point) -> String {
unimplemented!();
}
}
macro_rules! test_clickstate {
{
name: $name:ident,
initial_state: $initial_state:expr,
initial_button: $initial_button:expr,
input: $input:expr,
end_state: $end_state:expr,
input_delay: $input_delay:expr,
} => {
#[test]
fn $name() {
let mut clipboard = Clipboard::new_nop();
let cfg = UiConfig::default();
let size = SizeInfo::new(
21.0,
51.0,
3.0,
3.0,
0.,
0.,
false,
);
let mut terminal = Term::new(cfg.term_options(), &size, MockEventProxy);
let mut mouse = Mouse {
click_state: $initial_state,
last_click_button: $initial_button,
last_click_timestamp: Instant::now() - $input_delay,
..Mouse::default()
};
let mut inline_search_state = InlineSearchState::default();
let mut message_buffer = MessageBuffer::default();
let context = ActionContext {
terminal: &mut terminal,
mouse: &mut mouse,
size_info: &size,
clipboard: &mut clipboard,
modifiers: Default::default(),
message_buffer: &mut message_buffer,
inline_search_state: &mut inline_search_state,
config: &cfg,
};
let mut processor = Processor::new(context);
let event: WinitEvent::<TerminalEvent> = $input;
if let WinitEvent::WindowEvent {
event: WindowEvent::MouseInput {
state,
button,
..
},
..
} = event
{
processor.mouse_input(state, button);
};
assert_eq!(processor.ctx.mouse.click_state, $end_state);
}
}
}
macro_rules! test_process_binding {
{
name: $name:ident,
binding: $binding:expr,
triggers: $triggers:expr,
mode: $mode:expr,
mods: $mods:expr,
} => {
#[test]
fn $name() {
if $triggers {
assert!($binding.is_triggered_by($mode, $mods, &KEY));
} else {
assert!(!$binding.is_triggered_by($mode, $mods, &KEY));
}
}
}
}
test_clickstate! {
name: single_click,
initial_state: ClickState::None,
initial_button: MouseButton::Other(0),
input: WinitEvent::WindowEvent {
event: WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
device_id: DeviceId::dummy(),
},
window_id: WindowId::dummy(),
},
end_state: ClickState::Click,
input_delay: Duration::ZERO,
}
test_clickstate! {
name: single_right_click,
initial_state: ClickState::None,
initial_button: MouseButton::Other(0),
input: WinitEvent::WindowEvent {
event: WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Right,
device_id: DeviceId::dummy(),
},
window_id: WindowId::dummy(),
},
end_state: ClickState::Click,
input_delay: Duration::ZERO,
}
test_clickstate! {
name: single_middle_click,
initial_state: ClickState::None,
initial_button: MouseButton::Other(0),
input: WinitEvent::WindowEvent {
event: WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Middle,
device_id: DeviceId::dummy(),
},
window_id: WindowId::dummy(),
},
end_state: ClickState::Click,
input_delay: Duration::ZERO,
}
test_clickstate! {
name: double_click,
initial_state: ClickState::Click,
initial_button: MouseButton::Left,
input: WinitEvent::WindowEvent {
event: WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
device_id: DeviceId::dummy(),
},
window_id: WindowId::dummy(),
},
end_state: ClickState::DoubleClick,
input_delay: Duration::ZERO,
}
test_clickstate! {
name: double_click_failed,
initial_state: ClickState::Click,
initial_button: MouseButton::Left,
input: WinitEvent::WindowEvent {
event: WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
device_id: DeviceId::dummy(),
},
window_id: WindowId::dummy(),
},
end_state: ClickState::Click,
input_delay: multi_click_time(),
}
test_clickstate! {
name: triple_click,
initial_state: ClickState::DoubleClick,
initial_button: MouseButton::Left,
input: WinitEvent::WindowEvent {
event: WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
device_id: DeviceId::dummy(),
},
window_id: WindowId::dummy(),
},
end_state: ClickState::TripleClick,
input_delay: Duration::ZERO,
}
test_clickstate! {
name: triple_click_failed,
initial_state: ClickState::DoubleClick,
initial_button: MouseButton::Left,
input: WinitEvent::WindowEvent {
event: WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
device_id: DeviceId::dummy(),
},
window_id: WindowId::dummy(),
},
end_state: ClickState::Click,
input_delay: multi_click_time(),
}
test_clickstate! {
name: multi_click_separate_buttons,
initial_state: ClickState::DoubleClick,
initial_button: MouseButton::Left,
input: WinitEvent::WindowEvent {
event: WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Right,
device_id: DeviceId::dummy(),
},
window_id: WindowId::dummy(),
},
end_state: ClickState::Click,
input_delay: Duration::ZERO,
}
test_process_binding! {
name: process_binding_nomode_shiftmod_require_shift,
binding: Binding { trigger: KEY, mods: ModifiersState::SHIFT, action: Action::from("\x1b[1;2D"), mode: BindingMode::empty(), notmode: BindingMode::empty() },
triggers: true,
mode: BindingMode::empty(),
mods: ModifiersState::SHIFT,
}
test_process_binding! {
name: process_binding_nomode_nomod_require_shift,
binding: Binding { trigger: KEY, mods: ModifiersState::SHIFT, action: Action::from("\x1b[1;2D"), mode: BindingMode::empty(), notmode: BindingMode::empty() },
triggers: false,
mode: BindingMode::empty(),
mods: ModifiersState::empty(),
}
test_process_binding! {
name: process_binding_nomode_controlmod,
binding: Binding { trigger: KEY, mods: ModifiersState::CONTROL, action: Action::from("\x1b[1;5D"), mode: BindingMode::empty(), notmode: BindingMode::empty() },
triggers: true,
mode: BindingMode::empty(),
mods: ModifiersState::CONTROL,
}
test_process_binding! {
name: process_binding_nomode_nomod_require_not_appcursor,
binding: Binding { trigger: KEY, mods: ModifiersState::empty(), action: Action::from("\x1b[D"), mode: BindingMode::empty(), notmode: BindingMode::APP_CURSOR },
triggers: true,
mode: BindingMode::empty(),
mods: ModifiersState::empty(),
}
test_process_binding! {
name: process_binding_appcursormode_nomod_require_appcursor,
binding: Binding { trigger: KEY, mods: ModifiersState::empty(), action: Action::from("\x1bOD"), mode: BindingMode::APP_CURSOR, notmode: BindingMode::empty() },
triggers: true,
mode: BindingMode::APP_CURSOR,
mods: ModifiersState::empty(),
}
test_process_binding! {
name: process_binding_nomode_nomod_require_appcursor,
binding: Binding { trigger: KEY, mods: ModifiersState::empty(), action: Action::from("\x1bOD"), mode: BindingMode::APP_CURSOR, notmode: BindingMode::empty() },
triggers: false,
mode: BindingMode::empty(),
mods: ModifiersState::empty(),
}
test_process_binding! {
name: process_binding_appcursormode_appkeypadmode_nomod_require_appcursor,
binding: Binding { trigger: KEY, mods: ModifiersState::empty(), action: Action::from("\x1bOD"), mode: BindingMode::APP_CURSOR, notmode: BindingMode::empty() },
triggers: true,
mode: BindingMode::APP_CURSOR | BindingMode::APP_KEYPAD,
mods: ModifiersState::empty(),
}
test_process_binding! {
name: process_binding_fail_with_extra_mods,
binding: Binding { trigger: KEY, mods: ModifiersState::SUPER, action: Action::from("arst"), mode: BindingMode::empty(), notmode: BindingMode::empty() },
triggers: false,
mode: BindingMode::empty(),
mods: ModifiersState::ALT | ModifiersState::SUPER,
}
+168
View File
@@ -0,0 +1,168 @@
//! Focus notifications and touch gesture state transitions.
use std::collections::HashSet;
use std::mem;
use winit::event::{ElementState, MouseButton, Touch as TouchEvent, TouchPhase};
use nebula_terminal::event::EventListener;
use nebula_terminal::term::TermMode;
use crate::display::window::ImeInhibitor;
use crate::event::{TouchPurpose, TouchZoom};
use super::{ActionContext, Processor};
/// Distance before a touch input is considered a drag.
const MAX_TAP_DISTANCE: f64 = 20.;
impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
pub fn on_focus_change(&mut self, is_focused: bool) {
if !is_focused {
// Lightweight pointer menus must never remain stranded over an
// inactive window. True modals intentionally survive focus loss.
self.ctx.display().close_context_menu();
self.ctx.display().close_shell_picker();
if self.ctx.display().command_palette_picker_open() {
self.ctx.display().close_command_palette();
}
self.ctx.mark_dirty();
}
if self.ctx.terminal().mode().contains(TermMode::FOCUS_IN_OUT) {
let chr = if is_focused { "I" } else { "O" };
let msg = format!("\x1b[{chr}");
self.ctx.write_to_pty(msg.into_bytes());
}
}
/// Handle touch input.
pub fn touch(&mut self, touch: TouchEvent) {
match touch.phase {
TouchPhase::Started => self.on_touch_start(touch),
TouchPhase::Moved => self.on_touch_motion(touch),
TouchPhase::Ended | TouchPhase::Cancelled => self.on_touch_end(touch),
}
}
/// Handle beginning of touch input.
pub fn on_touch_start(&mut self, touch: TouchEvent) {
// Inhibit IME on touch while not focused, forcing a touch tap while focused to enable IME.
if !self.ctx.terminal().is_focused {
self.ctx.window().set_ime_inhibitor(ImeInhibitor::TOUCH, true);
}
let touch_purpose = self.ctx.touch_purpose();
*touch_purpose = match mem::take(touch_purpose) {
TouchPurpose::None => TouchPurpose::Tap(touch),
TouchPurpose::Tap(start) => TouchPurpose::Zoom(TouchZoom::new((start, touch))),
TouchPurpose::ZoomPendingSlot(slot) => {
TouchPurpose::Zoom(TouchZoom::new((slot, touch)))
},
TouchPurpose::Zoom(zoom) => {
let slots = zoom.slots();
let mut set = HashSet::default();
set.insert(slots.0.id);
set.insert(slots.1.id);
TouchPurpose::Invalid(set)
},
TouchPurpose::Scroll(event) | TouchPurpose::Select(event) => {
let mut set = HashSet::default();
set.insert(event.id);
TouchPurpose::Invalid(set)
},
TouchPurpose::Invalid(mut slots) => {
slots.insert(touch.id);
TouchPurpose::Invalid(slots)
},
};
}
/// Handle touch input movement.
pub fn on_touch_motion(&mut self, touch: TouchEvent) {
let touch_purpose = self.ctx.touch_purpose();
match touch_purpose {
TouchPurpose::None => (),
// Handle transition from tap to scroll/select.
TouchPurpose::Tap(start) => {
let delta_x = touch.location.x - start.location.x;
let delta_y = touch.location.y - start.location.y;
if delta_x.abs() > MAX_TAP_DISTANCE {
// Update gesture state.
let start_location = start.location;
*touch_purpose = TouchPurpose::Select(*start);
// Start simulated mouse input.
self.mouse_moved(start_location);
self.mouse_input(ElementState::Pressed, MouseButton::Left);
// Apply motion since touch start.
self.on_touch_motion(touch);
} else if delta_y.abs() > MAX_TAP_DISTANCE {
// Update gesture state.
*touch_purpose = TouchPurpose::Scroll(*start);
// Apply motion since touch start.
self.on_touch_motion(touch);
}
},
TouchPurpose::Zoom(zoom) => {
let font_delta = zoom.font_delta(touch);
self.ctx.change_font_size(font_delta);
},
TouchPurpose::Scroll(last_touch) => {
// Calculate delta and update last touch position.
let delta_y = touch.location.y - last_touch.location.y;
*touch_purpose = TouchPurpose::Scroll(touch);
// Use a fixed scroll factor for touchscreens, to accurately track finger motion.
self.scroll_terminal(0., delta_y, 1.0);
},
TouchPurpose::Select(_) => self.mouse_moved(touch.location),
TouchPurpose::ZoomPendingSlot(_) | TouchPurpose::Invalid(_) => (),
}
}
/// Handle end of touch input.
pub fn on_touch_end(&mut self, touch: TouchEvent) {
// Finalize the touch motion up to the release point.
self.on_touch_motion(touch);
let touch_purpose = self.ctx.touch_purpose();
match touch_purpose {
// Simulate LMB clicks.
TouchPurpose::Tap(start) => {
let start_location = start.location;
*touch_purpose = Default::default();
self.mouse_moved(start_location);
self.mouse_input(ElementState::Pressed, MouseButton::Left);
self.mouse_input(ElementState::Released, MouseButton::Left);
self.ctx.window().set_ime_inhibitor(ImeInhibitor::TOUCH, false);
},
// Transition zoom to pending state once a finger was released.
TouchPurpose::Zoom(zoom) => {
let slots = zoom.slots();
let remaining = if slots.0.id == touch.id { slots.1 } else { slots.0 };
*touch_purpose = TouchPurpose::ZoomPendingSlot(remaining);
},
TouchPurpose::ZoomPendingSlot(_) => *touch_purpose = Default::default(),
// Reset touch state once all slots were released.
TouchPurpose::Invalid(slots) => {
slots.remove(&touch.id);
if slots.is_empty() {
*touch_purpose = Default::default();
}
},
// Release simulated LMB.
TouchPurpose::Select(_) => {
*touch_purpose = Default::default();
self.mouse_input(ElementState::Released, MouseButton::Left);
},
// Reset touch state on scroll finish.
TouchPurpose::Scroll(_) => *touch_purpose = Default::default(),
TouchPurpose::None => (),
}
}
}