refactor: split input processor by responsibility
This commit is contained in:
1 parent
d9daa6058b
commit
870490cf3a
6 files changed
+3016
-2945
No files matched your search
@@ -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(),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-2945
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
}
|
||||
@@ -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 => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user