//! Handle input from winit. //! //! Certain key combinations should send some escape sequence back to the PTY. //! In order to figure that out, state about which modifier keys are pressed //! needs to be tracked. Additionally, we need a bit of a state machine to //! determine what to do when a non-modifier key is pressed. use std::borrow::Cow; use std::cmp::{max, min, Ordering}; use std::collections::HashSet; use std::ffi::OsStr; use std::fmt::Debug; use std::marker::PhantomData; use std::mem; use std::time::{Duration, Instant}; use log::debug; use winit::dpi::PhysicalPosition; use winit::event::{ ElementState, Modifiers, MouseButton, MouseScrollDelta, Touch as TouchEvent, TouchPhase, }; #[cfg(target_os = "macos")] use winit::event_loop::EventLoopWindowTarget; use winit::keyboard::ModifiersState; #[cfg(target_os = "macos")] use winit::platform::macos::EventLoopWindowTargetExtMacOS; use winit::window::CursorIcon; use alacritty_terminal::event::EventListener; use alacritty_terminal::grid::{Dimensions, Scroll}; use alacritty_terminal::index::{Boundary, Column, Direction, Point, Side}; use alacritty_terminal::selection::SelectionType; use alacritty_terminal::term::search::Match; use alacritty_terminal::term::{ClipboardType, Term, TermMode}; use alacritty_terminal::vi_mode::ViMotion; use alacritty_terminal::vte::ansi::{ClearMode, Handler}; use crate::clipboard::Clipboard; #[cfg(target_os = "macos")] use crate::config::window::Decorations; use crate::config::{Action, BindingMode, MouseAction, SearchAction, UiConfig, ViAction}; use crate::display::hint::HintMatch; use crate::display::window::Window; use crate::display::{Display, SizeInfo}; use crate::event::{ ClickState, Event, EventType, InlineSearchState, Mouse, TouchPurpose, TouchZoom, }; use crate::message_bar::{self, Message}; use crate::scheduler::{Scheduler, TimerId, Topic}; pub mod keyboard; /// Font size change interval in px. pub const FONT_SIZE_STEP: f32 = 1.; /// Interval for mouse scrolling during selection outside of the boundaries. const SELECTION_SCROLLING_INTERVAL: Duration = Duration::from_millis(15); /// Minimum number of pixels at the bottom/top where selection scrolling is performed. const MIN_SELECTION_SCROLLING_HEIGHT: f64 = 5.; /// Number of pixels for increasing the selection scrolling speed factor by one. const SELECTION_SCROLLING_STEP: f64 = 20.; /// Distance before a touch input is considered a drag. const MAX_TAP_DISTANCE: f64 = 20.; /// Threshold used for double_click/triple_click. const CLICK_THRESHOLD: Duration = Duration::from_millis(400); /// Processes input from winit. /// /// An escape sequence may be emitted in case specific keys or key combinations /// are activated. pub struct Processor> { pub ctx: A, _phantom: PhantomData, } pub trait ActionContext { fn write_to_pty>>(&self, _data: B) {} fn mark_dirty(&mut self) {} fn size_info(&self) -> SizeInfo; fn copy_selection(&mut self, _ty: ClipboardType) {} fn start_selection(&mut self, _ty: SelectionType, _point: Point, _side: Side) {} fn toggle_selection(&mut self, _ty: SelectionType, _point: Point, _side: Side) {} fn update_selection(&mut self, _point: Point, _side: Side) {} fn clear_selection(&mut self) {} fn selection_is_empty(&self) -> bool; fn mouse_mut(&mut self) -> &mut Mouse; fn mouse(&self) -> &Mouse; fn touch_purpose(&mut self) -> &mut TouchPurpose; fn modifiers(&mut self) -> &mut Modifiers; fn scroll(&mut self, _scroll: Scroll) {} fn window(&mut self) -> &mut Window; fn display(&mut self) -> &mut Display; fn terminal(&self) -> &Term; fn terminal_mut(&mut self) -> &mut Term; fn spawn_new_instance(&mut self) {} #[cfg(target_os = "macos")] fn create_new_window(&mut self, _tabbing_id: Option) {} #[cfg(not(target_os = "macos"))] fn create_new_window(&mut self) {} fn change_font_size(&mut self, _delta: f32) {} fn reset_font_size(&mut self) {} fn pop_message(&mut self) {} fn message(&self) -> Option<&Message>; fn config(&self) -> &UiConfig; #[cfg(target_os = "macos")] fn event_loop(&self) -> &EventLoopWindowTarget; fn mouse_mode(&self) -> bool; fn clipboard_mut(&mut self) -> &mut Clipboard; fn scheduler_mut(&mut self) -> &mut Scheduler; fn start_search(&mut self, _direction: Direction) {} fn confirm_search(&mut self) {} fn cancel_search(&mut self) {} fn search_input(&mut self, _c: char) {} fn search_pop_word(&mut self) {} fn search_history_previous(&mut self) {} fn search_history_next(&mut self) {} fn search_next(&mut self, origin: Point, direction: Direction, side: Side) -> Option; fn advance_search_origin(&mut self, _direction: Direction) {} fn search_direction(&self) -> Direction; fn search_active(&self) -> bool; fn on_typing_start(&mut self) {} fn toggle_vi_mode(&mut self) {} fn inline_search_state(&mut self) -> &mut InlineSearchState; fn start_inline_search(&mut self, _direction: Direction, _stop_short: bool) {} fn inline_search_next(&mut self) {} fn inline_search_previous(&mut self) {} fn hint_input(&mut self, _character: char) {} fn trigger_hint(&mut self, _hint: &HintMatch) {} fn expand_selection(&mut self) {} fn on_terminal_input_start(&mut self) {} fn paste(&mut self, _text: &str, _bracketed: bool) {} fn spawn_daemon(&self, _program: &str, _args: I) where I: IntoIterator + Debug + Copy, S: AsRef, { } } impl Action { fn toggle_selection(ctx: &mut A, ty: SelectionType) where A: ActionContext, 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(); } } } trait Execute { fn execute>(&self, ctx: &mut A); } impl Execute for Action { #[inline] fn execute>(&self, ctx: &mut A) { match self { Action::Esc(s) => ctx.paste(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 @ 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.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); } }, #[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(), _ => (), } } } impl> Processor { pub fn new(ctx: A) -> Self { Self { ctx, _phantom: Default::default() } } #[inline] pub fn mouse_moved(&mut self, position: PhysicalPosition) { let size_info = self.ctx.size_info(); let (x, y) = position.into(); let lmb_pressed = self.ctx.mouse().left_button_state == ElementState::Pressed; let rmb_pressed = self.ctx.mouse().right_button_state == ElementState::Pressed; if !self.ctx.selection_is_empty() && (lmb_pressed || rmb_pressed) { self.update_selection_scrolling(y); } let display_offset = self.ctx.terminal().grid().display_offset(); let old_point = self.ctx.mouse().point(&size_info, display_offset); let x = x.clamp(0, size_info.width() as i32 - 1) as usize; let y = y.clamp(0, size_info.height() as i32 - 1) as usize; self.ctx.mouse_mut().x = x; self.ctx.mouse_mut().y = y; let inside_text_area = size_info.contains_point(x, y); let cell_side = self.cell_side(x); let point = self.ctx.mouse().point(&size_info, display_offset); let cell_changed = old_point != point; // If the mouse hasn't changed cells, do nothing. if !cell_changed && self.ctx.mouse().cell_side == cell_side && self.ctx.mouse().inside_text_area == inside_text_area { return; } self.ctx.mouse_mut().inside_text_area = inside_text_area; self.ctx.mouse_mut().cell_side = cell_side; // Update mouse state and check for URL change. let mouse_state = self.cursor_state(); self.ctx.window().set_mouse_cursor(mouse_state); // Prompt hint highlight update. self.ctx.mouse_mut().hint_highlight_dirty = true; // Don't launch URLs if mouse has moved. self.ctx.mouse_mut().block_hint_launcher = true; if (lmb_pressed || rmb_pressed) && (self.ctx.modifiers().state().shift_key() || !self.ctx.mouse_mode()) { self.ctx.update_selection(point, cell_side); } else if cell_changed && self.ctx.terminal().mode().intersects(TermMode::MOUSE_MOTION | TermMode::MOUSE_DRAG) { if lmb_pressed { self.mouse_report(32, ElementState::Pressed); } else if self.ctx.mouse().middle_button_state == ElementState::Pressed { self.mouse_report(33, ElementState::Pressed); } else if self.ctx.mouse().right_button_state == ElementState::Pressed { self.mouse_report(34, ElementState::Pressed); } else if self.ctx.terminal().mode().contains(TermMode::MOUSE_MOTION) { self.mouse_report(35, ElementState::Pressed); } } } /// Check which side of a cell an X coordinate lies on. fn cell_side(&self, x: usize) -> Side { let size_info = self.ctx.size_info(); let cell_x = x.saturating_sub(size_info.padding_x() as usize) % size_info.cell_width() as usize; let half_cell_width = (size_info.cell_width() / 2.0) as usize; let additional_padding = (size_info.width() - size_info.padding_x() * 2.) % size_info.cell_width(); let end_of_grid = size_info.width() - size_info.padding_x() - additional_padding; if cell_x > half_cell_width // Edge case when mouse leaves the window. || x as f32 >= end_of_grid { Side::Right } else { Side::Left } } fn mouse_report(&mut self, button: u8, state: ElementState) { let display_offset = self.ctx.terminal().grid().display_offset(); let point = self.ctx.mouse().point(&self.ctx.size_info(), display_offset); // Assure the mouse point is not in the scrollback. if point.line < 0 { return; } // Calculate modifiers value. let mut mods = 0; let modifiers = self.ctx.modifiers().state(); if modifiers.shift_key() { mods += 4; } if modifiers.alt_key() { mods += 8; } if modifiers.control_key() { mods += 16; } // Report mouse events. if self.ctx.terminal().mode().contains(TermMode::SGR_MOUSE) { self.sgr_mouse_report(point, button + mods, state); } else if let ElementState::Released = state { self.normal_mouse_report(point, 3 + mods); } else { self.normal_mouse_report(point, button + mods); } } fn normal_mouse_report(&mut self, point: Point, button: u8) { let Point { line, column } = point; let utf8 = self.ctx.terminal().mode().contains(TermMode::UTF8_MOUSE); let max_point = if utf8 { 2015 } else { 223 }; if line >= max_point || column >= max_point { return; } let mut msg = vec![b'\x1b', b'[', b'M', 32 + button]; let mouse_pos_encode = |pos: usize| -> Vec { let pos = 32 + 1 + pos; let first = 0xC0 + pos / 64; let second = 0x80 + (pos & 63); vec![first as u8, second as u8] }; if utf8 && column >= Column(95) { msg.append(&mut mouse_pos_encode(column.0)); } else { msg.push(32 + 1 + column.0 as u8); } if utf8 && line >= 95 { msg.append(&mut mouse_pos_encode(line.0 as usize)); } else { msg.push(32 + 1 + line.0 as u8); } self.ctx.write_to_pty(msg); } fn sgr_mouse_report(&mut self, point: Point, button: u8, state: ElementState) { let c = match state { ElementState::Pressed => 'M', ElementState::Released => 'm', }; let msg = format!("\x1b[<{};{};{}{}", button, point.column + 1, point.line + 1, c); self.ctx.write_to_pty(msg.into_bytes()); } fn on_mouse_press(&mut self, button: MouseButton) { // 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 { // Calculate time since the last click to handle double/triple clicks. let now = Instant::now(); let elapsed = now - self.ctx.mouse().last_click_timestamp; self.ctx.mouse_mut().last_click_timestamp = now; // Update multi-click state. self.ctx.mouse_mut().click_state = match self.ctx.mouse().click_state { // Reset click state if button has changed. _ if button != self.ctx.mouse().last_click_button => { self.ctx.mouse_mut().last_click_button = button; ClickState::Click }, ClickState::Click if elapsed < CLICK_THRESHOLD => ClickState::DoubleClick, ClickState::DoubleClick if elapsed < CLICK_THRESHOLD => ClickState::TripleClick, _ => ClickState::Click, }; // 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) } } } /// Handle left click selection and vi mode cursor movement. fn on_left_click(&mut self, point: Point) { let side = self.ctx.mouse().cell_side; match self.ctx.mouse().click_state { ClickState::Click => { // Don't launch URLs if this click cleared the selection. self.ctx.mouse_mut().block_hint_launcher = !self.ctx.selection_is_empty(); self.ctx.clear_selection(); // Start new empty selection. if self.ctx.modifiers().state().control_key() { self.ctx.start_selection(SelectionType::Block, point, side); } else { self.ctx.start_selection(SelectionType::Simple, point, side); } }, ClickState::DoubleClick => { self.ctx.mouse_mut().block_hint_launcher = true; self.ctx.start_selection(SelectionType::Semantic, point, side); }, ClickState::TripleClick => { self.ctx.mouse_mut().block_hint_launcher = true; self.ctx.start_selection(SelectionType::Lines, point, side); }, ClickState::None => (), }; // Move vi mode cursor to mouse click position. if self.ctx.terminal().mode().contains(TermMode::VI) && !self.ctx.search_active() { self.ctx.terminal_mut().vi_mode_cursor.point = point; self.ctx.mark_dirty(); } } fn on_mouse_release(&mut self, button: MouseButton) { if !self.ctx.modifiers().state().shift_key() && self.ctx.mouse_mode() { 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::Released); return; } // Trigger hints highlighted by the mouse. let hint = self.ctx.display().highlighted_hint.take(); if let Some(hint) = hint.as_ref().filter(|_| button == MouseButton::Left) { self.ctx.trigger_hint(hint); } self.ctx.display().highlighted_hint = hint; let timer_id = TimerId::new(Topic::SelectionScrolling, self.ctx.window().id()); self.ctx.scheduler_mut().unschedule(timer_id); if let MouseButton::Left | MouseButton::Right = button { // Copy selection on release, to prevent flooding the display server. self.ctx.copy_selection(ClipboardType::Selection); } } pub fn mouse_wheel_input(&mut self, delta: MouseScrollDelta, phase: TouchPhase) { let multiplier = self.ctx.config().scrolling.multiplier; match delta { MouseScrollDelta::LineDelta(columns, lines) => { let new_scroll_px_x = columns * self.ctx.size_info().cell_width(); let new_scroll_px_y = lines * self.ctx.size_info().cell_height(); self.scroll_terminal( new_scroll_px_x as f64, new_scroll_px_y as f64, multiplier as f64, ); }, MouseScrollDelta::PixelDelta(mut lpos) => { match phase { TouchPhase::Started => { // Reset offset to zero. self.ctx.mouse_mut().accumulated_scroll = Default::default(); }, TouchPhase::Moved => { // When the angle between (x, 0) and (x, y) is lower than ~25 degrees // (cosine is larger that 0.9) we consider this scrolling as horizontal. if lpos.x.abs() / lpos.x.hypot(lpos.y) > 0.9 { lpos.y = 0.; } else { lpos.x = 0.; } self.scroll_terminal(lpos.x, lpos.y, multiplier as f64); }, _ => (), } }, } } fn scroll_terminal(&mut self, new_scroll_x_px: f64, new_scroll_y_px: f64, multiplier: f64) { const MOUSE_WHEEL_UP: u8 = 64; const MOUSE_WHEEL_DOWN: u8 = 65; const MOUSE_WHEEL_LEFT: u8 = 66; const MOUSE_WHEEL_RIGHT: u8 = 67; let width = f64::from(self.ctx.size_info().cell_width()); let height = f64::from(self.ctx.size_info().cell_height()); if self.ctx.mouse_mode() { self.ctx.mouse_mut().accumulated_scroll.x += new_scroll_x_px; self.ctx.mouse_mut().accumulated_scroll.y += new_scroll_y_px; let code = if new_scroll_y_px > 0. { MOUSE_WHEEL_UP } else { MOUSE_WHEEL_DOWN }; let lines = (self.ctx.mouse().accumulated_scroll.y / height).abs() as i32; for _ in 0..lines { self.mouse_report(code, ElementState::Pressed); } let code = if new_scroll_x_px > 0. { MOUSE_WHEEL_LEFT } else { MOUSE_WHEEL_RIGHT }; let columns = (self.ctx.mouse().accumulated_scroll.x / width).abs() as i32; for _ in 0..columns { self.mouse_report(code, ElementState::Pressed); } } else if self .ctx .terminal() .mode() .contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) && !self.ctx.modifiers().state().shift_key() { self.ctx.mouse_mut().accumulated_scroll.x += new_scroll_x_px * multiplier; self.ctx.mouse_mut().accumulated_scroll.y += new_scroll_y_px * multiplier; // The chars here are the same as for the respective arrow keys. let line_cmd = if new_scroll_y_px > 0. { b'A' } else { b'B' }; let column_cmd = if new_scroll_x_px > 0. { b'D' } else { b'C' }; let lines = (self.ctx.mouse().accumulated_scroll.y / height).abs() as usize; let columns = (self.ctx.mouse().accumulated_scroll.x / width).abs() as usize; let mut content = Vec::with_capacity(3 * (lines + columns)); for _ in 0..lines { content.push(0x1b); content.push(b'O'); content.push(line_cmd); } for _ in 0..columns { content.push(0x1b); content.push(b'O'); content.push(column_cmd); } self.ctx.write_to_pty(content); } else { self.ctx.mouse_mut().accumulated_scroll.y += new_scroll_y_px * multiplier; let lines = (self.ctx.mouse().accumulated_scroll.y / height) as i32; if lines != 0 { self.ctx.scroll(Scroll::Delta(lines)); } } self.ctx.mouse_mut().accumulated_scroll.x %= width; self.ctx.mouse_mut().accumulated_scroll.y %= height; } pub fn on_focus_change(&mut self, is_focused: bool) { 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) { 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::Zoom(zoom) => TouchPurpose::Invalid(zoom.slots()), 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::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); }, // Invalidate zoom once a finger was released. TouchPurpose::Zoom(zoom) => { let mut slots = zoom.slots(); slots.remove(&touch.id); *touch_purpose = TouchPurpose::Invalid(slots); }, // 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 => (), } } /// Reset mouse cursor based on modifier and terminal state. #[inline] pub fn reset_mouse_cursor(&mut self) { let mouse_state = self.cursor_state(); self.ctx.window().set_mouse_cursor(mouse_state); } /// Modifier state change. pub fn modifiers_input(&mut self, modifiers: Modifiers) { *self.ctx.modifiers() = modifiers; // Prompt hint highlight update. self.ctx.mouse_mut().hint_highlight_dirty = true; // Update mouse state and check for URL change. let mouse_state = self.cursor_state(); self.ctx.window().set_mouse_cursor(mouse_state); } pub fn mouse_input(&mut self, state: ElementState, button: MouseButton) { match button { MouseButton::Left => self.ctx.mouse_mut().left_button_state = state, MouseButton::Middle => self.ctx.mouse_mut().middle_button_state = state, MouseButton::Right => self.ctx.mouse_mut().right_button_state = state, _ => (), } // Skip normal mouse events if the message bar has been clicked. if self.message_bar_cursor_state() == Some(CursorIcon::Pointer) && state == ElementState::Pressed { let size = self.ctx.size_info(); let current_lines = self.ctx.message().map_or(0, |m| m.text(&size).len()); self.ctx.clear_selection(); self.ctx.pop_message(); // Reset cursor when message bar height changed or all messages are gone. let new_lines = self.ctx.message().map_or(0, |m| m.text(&size).len()); let new_icon = match current_lines.cmp(&new_lines) { Ordering::Less => CursorIcon::Default, Ordering::Equal => CursorIcon::Pointer, Ordering::Greater => { if self.ctx.mouse_mode() { CursorIcon::Default } else { CursorIcon::Text } }, }; self.ctx.window().set_mouse_cursor(new_icon); } else { match state { ElementState::Pressed => { // Process mouse press before bindings to update the `click_state`. self.on_mouse_press(button); self.process_mouse_bindings(button); }, ElementState::Released => self.on_mouse_release(button), } } } /// Attempt to find a binding and execute its action. /// /// The provided mode, mods, and key must match what is allowed by a binding /// for its action to be executed. fn process_mouse_bindings(&mut self, button: MouseButton) { let mode = BindingMode::new(self.ctx.terminal().mode(), self.ctx.search_active()); let mouse_mode = self.ctx.mouse_mode(); let mods = self.ctx.modifiers().state(); let mouse_bindings = self.ctx.config().mouse_bindings().to_owned(); // If mouse mode is active, also look for bindings without shift. let mut check_fallback = mouse_mode && mods.contains(ModifiersState::SHIFT); for binding in &mouse_bindings { // Don't trigger normal bindings in mouse mode unless Shift is pressed. if binding.is_triggered_by(mode, mods, &button) && (check_fallback || !mouse_mode) { binding.action.execute(&mut self.ctx); check_fallback = false; } } if check_fallback { let fallback_mods = mods & !ModifiersState::SHIFT; for binding in &mouse_bindings { if binding.is_triggered_by(mode, fallback_mods, &button) { binding.action.execute(&mut self.ctx); } } } } /// Check mouse icon state in relation to the message bar. fn message_bar_cursor_state(&self) -> Option { // Since search is above the message bar, the button is offset by search's height. let search_height = usize::from(self.ctx.search_active()); // Calculate Y position of the end of the last terminal line. let size = self.ctx.size_info(); let terminal_end = size.padding_y() as usize + size.cell_height() as usize * (size.screen_lines() + search_height); let mouse = self.ctx.mouse(); let display_offset = self.ctx.terminal().grid().display_offset(); let point = self.ctx.mouse().point(&self.ctx.size_info(), display_offset); if self.ctx.message().is_none() || (mouse.y <= terminal_end) { None } else if mouse.y <= terminal_end + size.cell_height() as usize && point.column + message_bar::CLOSE_BUTTON_TEXT.len() >= size.columns() { Some(CursorIcon::Pointer) } else { Some(CursorIcon::Default) } } /// Icon state of the cursor. fn cursor_state(&mut self) -> CursorIcon { let display_offset = self.ctx.terminal().grid().display_offset(); let point = self.ctx.mouse().point(&self.ctx.size_info(), display_offset); let hyperlink = self.ctx.terminal().grid()[point].hyperlink(); // Function to check if mouse is on top of a hint. let hint_highlighted = |hint: &HintMatch| hint.should_highlight(point, hyperlink.as_ref()); if let Some(mouse_state) = self.message_bar_cursor_state() { mouse_state } else if self.ctx.display().highlighted_hint.as_ref().map_or(false, hint_highlighted) { CursorIcon::Pointer } else if !self.ctx.modifiers().state().shift_key() && self.ctx.mouse_mode() { CursorIcon::Default } else { CursorIcon::Text } } /// Handle automatic scrolling when selecting above/below the window. fn update_selection_scrolling(&mut self, mouse_y: i32) { let scale_factor = self.ctx.window().scale_factor; let size = self.ctx.size_info(); let window_id = self.ctx.window().id(); let scheduler = self.ctx.scheduler_mut(); // Scale constants by DPI. let min_height = (MIN_SELECTION_SCROLLING_HEIGHT * scale_factor) as i32; let step = (SELECTION_SCROLLING_STEP * scale_factor) as i32; // Compute the height of the scrolling areas. let end_top = max(min_height, size.padding_y() as i32); let text_area_bottom = size.padding_y() + size.screen_lines() as f32 * size.cell_height(); let start_bottom = min(size.height() as i32 - min_height, text_area_bottom as i32); // Get distance from closest window boundary. let delta = if mouse_y < end_top { end_top - mouse_y + step } else if mouse_y >= start_bottom { start_bottom - mouse_y - step } else { scheduler.unschedule(TimerId::new(Topic::SelectionScrolling, window_id)); return; }; // Scale number of lines scrolled based on distance to boundary. let event = Event::new(EventType::Scroll(Scroll::Delta(delta / step)), Some(window_id)); // Schedule event. let timer_id = TimerId::new(Topic::SelectionScrolling, window_id); scheduler.unschedule(timer_id); scheduler.schedule(event, SELECTION_SCROLLING_INTERVAL, true, timer_id); } } #[cfg(test)] mod tests { use super::*; use winit::event::{DeviceId, Event as WinitEvent, WindowEvent}; use winit::keyboard::Key; use winit::window::WindowId; use alacritty_terminal::event::Event as TerminalEvent; use crate::config::Binding; use crate::message_bar::MessageBuffer; const KEY: Key<&'static str> = Key::Character("0"); struct MockEventProxy; impl EventListener for MockEventProxy {} struct ActionContext<'a, T> { pub terminal: &'a mut Term, 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<'a, T: EventListener> super::ActionContext for ActionContext<'a, T> { fn search_next( &mut self, _origin: Point, _direction: Direction, _side: Side, ) -> Option { 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 { self.terminal } fn terminal_mut(&mut self) -> &mut Term { 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 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) -> &EventLoopWindowTarget { unimplemented!(); } fn scheduler_mut(&mut self) -> &mut Scheduler { 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:: = $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: unsafe { DeviceId::dummy() }, }, window_id: unsafe { 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: unsafe { DeviceId::dummy() }, }, window_id: unsafe { 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: unsafe { DeviceId::dummy() }, }, window_id: unsafe { 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: unsafe { DeviceId::dummy() }, }, window_id: unsafe { 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: unsafe { DeviceId::dummy() }, }, window_id: unsafe { WindowId::dummy() }, }, end_state: ClickState::Click, input_delay: CLICK_THRESHOLD, } 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: unsafe { DeviceId::dummy() }, }, window_id: unsafe { 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: unsafe { DeviceId::dummy() }, }, window_id: unsafe { WindowId::dummy() }, }, end_state: ClickState::Click, input_delay: CLICK_THRESHOLD, } 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: unsafe { DeviceId::dummy() }, }, window_id: unsafe { 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, } }