diff options
Diffstat (limited to 'alacritty_terminal/src/input.rs')
-rw-r--r-- | alacritty_terminal/src/input.rs | 1300 |
1 files changed, 1300 insertions, 0 deletions
diff --git a/alacritty_terminal/src/input.rs b/alacritty_terminal/src/input.rs new file mode 100644 index 00000000..fc79b398 --- /dev/null +++ b/alacritty_terminal/src/input.rs @@ -0,0 +1,1300 @@ +// Copyright 2016 Joe Wilm, The Alacritty Project Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//! Handle input from glutin +//! +//! 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::mem; +use std::ops::RangeInclusive; +use std::time::Instant; + +use copypasta::{Buffer as ClipboardBuffer, Clipboard, Load}; +use glutin::{ + ElementState, KeyboardInput, ModifiersState, MouseButton, MouseCursor, MouseScrollDelta, + TouchPhase, +}; +use unicode_width::UnicodeWidthStr; + +use crate::ansi::{ClearMode, Handler}; +use crate::config::{self, Key}; +use crate::event::{ClickState, Mouse}; +use crate::grid::Scroll; +use crate::index::{Column, Line, Linear, Point, Side}; +use crate::message_bar::{self, Message}; +use crate::term::mode::TermMode; +use crate::term::{Search, SizeInfo, Term}; +use crate::url::Url; +use crate::util::fmt::Red; +use crate::util::start_daemon; + +pub const FONT_SIZE_STEP: f32 = 0.5; + +/// Processes input from glutin. +/// +/// An escape sequence may be emitted in case specific keys or key combinations +/// are activated. +/// +/// TODO also need terminal state when processing input +pub struct Processor<'a, A: 'a> { + pub key_bindings: &'a [KeyBinding], + pub mouse_bindings: &'a [MouseBinding], + pub mouse_config: &'a config::Mouse, + pub scrolling_config: &'a config::Scrolling, + pub ctx: A, + pub save_to_clipboard: bool, + pub alt_send_esc: bool, +} + +pub trait ActionContext { + fn write_to_pty<B: Into<Cow<'static, [u8]>>>(&mut self, _: B); + fn size_info(&self) -> SizeInfo; + fn copy_selection(&self, _: ClipboardBuffer); + fn clear_selection(&mut self); + fn update_selection(&mut self, point: Point, side: Side); + fn simple_selection(&mut self, point: Point, side: Side); + fn semantic_selection(&mut self, point: Point); + fn line_selection(&mut self, point: Point); + fn selection_is_empty(&self) -> bool; + fn mouse_mut(&mut self) -> &mut Mouse; + fn mouse(&self) -> &Mouse; + fn mouse_coords(&self) -> Option<Point>; + fn received_count(&mut self) -> &mut usize; + fn suppress_chars(&mut self) -> &mut bool; + fn last_modifiers(&mut self) -> &mut ModifiersState; + fn scroll(&mut self, scroll: Scroll); + fn hide_window(&mut self); + fn terminal(&self) -> &Term; + fn terminal_mut(&mut self) -> &mut Term; + fn spawn_new_instance(&mut self); + fn toggle_fullscreen(&mut self); + #[cfg(target_os = "macos")] + fn toggle_simple_fullscreen(&mut self); +} + +/// Describes a state and action to take in that state +/// +/// This is the shared component of `MouseBinding` and `KeyBinding` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Binding<T> { + /// Modifier keys required to activate binding + pub mods: ModifiersState, + + /// String to send to pty if mods and mode match + pub action: Action, + + /// Terminal mode required to activate binding + pub mode: TermMode, + + /// excluded terminal modes where the binding won't be activated + pub notmode: TermMode, + + /// This property is used as part of the trigger detection code. + /// + /// For example, this might be a key like "G", or a mouse button. + pub trigger: T, +} + +/// Bindings that are triggered by a keyboard key +pub type KeyBinding = Binding<Key>; + +/// Bindings that are triggered by a mouse button +pub type MouseBinding = Binding<MouseButton>; + +impl Default for KeyBinding { + fn default() -> KeyBinding { + KeyBinding { + mods: Default::default(), + action: Action::Esc(String::new()), + mode: TermMode::NONE, + notmode: TermMode::NONE, + trigger: Key::A, + } + } +} + +impl Default for MouseBinding { + fn default() -> MouseBinding { + MouseBinding { + mods: Default::default(), + action: Action::Esc(String::new()), + mode: TermMode::NONE, + notmode: TermMode::NONE, + trigger: MouseButton::Left, + } + } +} + +impl<T: Eq> Binding<T> { + #[inline] + fn is_triggered_by( + &self, + mode: TermMode, + mods: ModifiersState, + input: &T, + relaxed: bool, + ) -> bool { + // Check input first since bindings are stored in one big list. This is + // the most likely item to fail so prioritizing it here allows more + // checks to be short circuited. + self.trigger == *input + && self.mode_matches(mode) + && self.not_mode_matches(mode) + && self.mods_match(mods, relaxed) + } + + #[inline] + pub fn triggers_match(&self, binding: &Binding<T>) -> bool { + self.trigger == binding.trigger + && self.mode == binding.mode + && self.notmode == binding.notmode + && self.mods == binding.mods + } +} + +impl<T> Binding<T> { + /// Execute the action associate with this binding + #[inline] + fn execute<A: ActionContext>(&self, ctx: &mut A, mouse_mode: bool) { + self.action.execute(ctx, mouse_mode) + } + + #[inline] + fn mode_matches(&self, mode: TermMode) -> bool { + self.mode.is_empty() || mode.intersects(self.mode) + } + + #[inline] + fn not_mode_matches(&self, mode: TermMode) -> bool { + self.notmode.is_empty() || !mode.intersects(self.notmode) + } + + /// Check that two mods descriptions for equivalence + #[inline] + fn mods_match(&self, mods: ModifiersState, relaxed: bool) -> bool { + if relaxed { + self.mods.relaxed_eq(mods) + } else { + self.mods == mods + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Write an escape sequence + Esc(String), + + /// Paste contents of system clipboard + Paste, + + // Store current selection into clipboard + Copy, + + /// Paste contents of selection buffer + PasteSelection, + + /// Increase font size + IncreaseFontSize, + + /// Decrease font size + DecreaseFontSize, + + /// Reset font size to the config value + ResetFontSize, + + /// Scroll exactly one page up + ScrollPageUp, + + /// Scroll exactly one page down + ScrollPageDown, + + /// Scroll one line up + ScrollLineUp, + + /// Scroll one line down + ScrollLineDown, + + /// Scroll all the way to the top + ScrollToTop, + + /// Scroll all the way to the bottom + ScrollToBottom, + + /// Clear the display buffer(s) to remove history + ClearHistory, + + /// Run given command + Command(String, Vec<String>), + + /// Hides the Alacritty window + Hide, + + /// Quits Alacritty. + Quit, + + /// Clears warning and error notices. + ClearLogNotice, + + /// Spawn a new instance of Alacritty. + SpawnNewInstance, + + /// Toggle fullscreen. + ToggleFullscreen, + + /// Toggle simple fullscreen on macos. + #[cfg(target_os = "macos")] + ToggleSimpleFullscreen, + + /// No action. + None, +} + +impl Default for Action { + fn default() -> Action { + Action::None + } +} + +impl Action { + #[inline] + fn execute<A: ActionContext>(&self, ctx: &mut A, mouse_mode: bool) { + match *self { + Action::Esc(ref s) => { + ctx.scroll(Scroll::Bottom); + ctx.write_to_pty(s.clone().into_bytes()) + }, + Action::Copy => { + ctx.copy_selection(ClipboardBuffer::Primary); + }, + Action::Paste => { + Clipboard::new() + .and_then(|clipboard| clipboard.load_primary()) + .map(|contents| self.paste(ctx, &contents)) + .unwrap_or_else(|err| { + error!("Error loading data from clipboard: {}", Red(err)); + }); + }, + Action::PasteSelection => { + // Only paste if mouse events are not captured by an application + if !mouse_mode { + Clipboard::new() + .and_then(|clipboard| clipboard.load_selection()) + .map(|contents| self.paste(ctx, &contents)) + .unwrap_or_else(|err| { + error!("Error loading data from clipboard: {}", Red(err)); + }); + } + }, + Action::Command(ref program, ref args) => { + trace!("Running command {} with args {:?}", program, args); + + match start_daemon(program, args) { + Ok(_) => { + debug!("Spawned new proc"); + }, + Err(err) => { + warn!("Couldn't run command {}", err); + }, + } + }, + Action::ToggleFullscreen => { + ctx.toggle_fullscreen(); + }, + #[cfg(target_os = "macos")] + Action::ToggleSimpleFullscreen => { + ctx.toggle_simple_fullscreen(); + }, + Action::Hide => { + ctx.hide_window(); + }, + Action::Quit => { + ctx.terminal_mut().exit(); + }, + Action::IncreaseFontSize => { + ctx.terminal_mut().change_font_size(FONT_SIZE_STEP); + }, + Action::DecreaseFontSize => { + ctx.terminal_mut().change_font_size(-FONT_SIZE_STEP); + }, + Action::ResetFontSize => { + ctx.terminal_mut().reset_font_size(); + }, + Action::ScrollPageUp => { + ctx.scroll(Scroll::PageUp); + }, + Action::ScrollPageDown => { + ctx.scroll(Scroll::PageDown); + }, + Action::ScrollLineUp => { + ctx.scroll(Scroll::Lines(1)); + }, + Action::ScrollLineDown => { + ctx.scroll(Scroll::Lines(-1)); + }, + Action::ScrollToTop => { + ctx.scroll(Scroll::Top); + }, + Action::ScrollToBottom => { + ctx.scroll(Scroll::Bottom); + }, + Action::ClearHistory => { + ctx.terminal_mut().clear_screen(ClearMode::Saved); + }, + Action::ClearLogNotice => { + ctx.terminal_mut().message_buffer_mut().pop(); + }, + Action::SpawnNewInstance => { + ctx.spawn_new_instance(); + }, + Action::None => (), + } + } + + fn paste<A: ActionContext>(&self, ctx: &mut A, contents: &str) { + if ctx.terminal().mode().contains(TermMode::BRACKETED_PASTE) { + ctx.write_to_pty(&b"\x1b[200~"[..]); + ctx.write_to_pty(contents.replace("\x1b", "").into_bytes()); + ctx.write_to_pty(&b"\x1b[201~"[..]); + } else { + // In non-bracketed (ie: normal) mode, terminal applications cannot distinguish + // pasted data from keystrokes. + // In theory, we should construct the keystrokes needed to produce the data we are + // pasting... since that's neither practical nor sensible (and probably an impossible + // task to solve in a general way), we'll just replace line breaks (windows and unix + // style) with a singe carriage return (\r, which is what the Enter key produces). + ctx.write_to_pty(contents.replace("\r\n", "\r").replace("\n", "\r").into_bytes()); + } + } +} + +trait RelaxedEq<T: ?Sized = Self> { + fn relaxed_eq(&self, other: T) -> bool; +} + +impl RelaxedEq for ModifiersState { + // Make sure that modifiers in the config are always present, + // but ignore surplus modifiers. + fn relaxed_eq(&self, other: Self) -> bool { + (!self.logo || other.logo) + && (!self.alt || other.alt) + && (!self.ctrl || other.ctrl) + && (!self.shift || other.shift) + } +} + +impl From<&'static str> for Action { + fn from(s: &'static str) -> Action { + Action::Esc(s.into()) + } +} + +impl<'a, A: ActionContext + 'a> Processor<'a, A> { + #[inline] + pub fn mouse_moved(&mut self, x: usize, y: usize, modifiers: ModifiersState) { + self.ctx.mouse_mut().x = x; + self.ctx.mouse_mut().y = y; + + let size_info = self.ctx.size_info(); + let point = size_info.pixels_to_coords(x, y); + + let cell_side = self.get_mouse_side(); + let prev_side = mem::replace(&mut self.ctx.mouse_mut().cell_side, cell_side); + let prev_line = mem::replace(&mut self.ctx.mouse_mut().line, point.line); + let prev_col = mem::replace(&mut self.ctx.mouse_mut().column, point.col); + + let motion_mode = TermMode::MOUSE_MOTION | TermMode::MOUSE_DRAG; + let report_mode = TermMode::MOUSE_REPORT_CLICK | motion_mode; + + let mouse_moved = prev_line != self.ctx.mouse().line + || prev_col != self.ctx.mouse().column + || prev_side != cell_side; + + // Don't launch URLs if mouse has moved + if mouse_moved { + self.ctx.mouse_mut().block_url_launcher = true; + } + + // Only report motions when cell changed and mouse is not over the message bar + if self.message_at_point(Some(point)).is_some() || !mouse_moved { + return; + } + + // Underline URLs and change cursor on hover + self.update_url_highlight(point, modifiers); + + if self.ctx.mouse().left_button_state == ElementState::Pressed + && (modifiers.shift || !self.ctx.terminal().mode().intersects(report_mode)) + { + self.ctx.update_selection(Point { line: point.line, col: point.col }, cell_side); + } else if self.ctx.terminal().mode().intersects(motion_mode) + && size_info.contains_point(x, y, false) + { + if self.ctx.mouse().left_button_state == ElementState::Pressed { + self.mouse_report(32, ElementState::Pressed, modifiers); + } else if self.ctx.mouse().middle_button_state == ElementState::Pressed { + self.mouse_report(33, ElementState::Pressed, modifiers); + } else if self.ctx.mouse().right_button_state == ElementState::Pressed { + self.mouse_report(34, ElementState::Pressed, modifiers); + } else if self.ctx.terminal().mode().contains(TermMode::MOUSE_MOTION) { + self.mouse_report(35, ElementState::Pressed, modifiers); + } + } + } + + /// Underline URLs and change the mouse cursor when URL hover state changes. + fn update_url_highlight(&mut self, point: Point, modifiers: ModifiersState) { + let mouse_mode = + TermMode::MOUSE_MOTION | TermMode::MOUSE_DRAG | TermMode::MOUSE_REPORT_CLICK; + + // Only show URLs as launchable when all required modifiers are pressed + let url = if self.mouse_config.url.modifiers.relaxed_eq(modifiers) + && (!self.ctx.terminal().mode().intersects(mouse_mode) || modifiers.shift) + && self.mouse_config.url.launcher.is_some() + { + self.ctx.terminal().url_search(point.into()) + } else { + None + }; + + if let Some(Url { origin, text }) = url { + let cols = self.ctx.size_info().cols().0; + + // Calculate the URL's start position + let lines_before = (origin + cols - point.col.0 - 1) / cols; + let (start_col, start_line) = if lines_before > point.line.0 { + (0, 0) + } else { + let start_col = (cols + point.col.0 - origin % cols) % cols; + let start_line = point.line.0 - lines_before; + (start_col, start_line) + }; + let start = Point::new(start_line, Column(start_col)); + + // Calculate the URL's end position + let len = text.width(); + let end_col = (point.col.0 + len - origin) % cols - 1; + let end_line = point.line.0 + (point.col.0 + len - origin) / cols; + let end = Point::new(end_line, Column(end_col)); + + let start = Linear::from_point(Column(cols), start); + let end = Linear::from_point(Column(cols), end); + + self.ctx.terminal_mut().set_url_highlight(RangeInclusive::new(start, end)); + self.ctx.terminal_mut().set_mouse_cursor(MouseCursor::Hand); + self.ctx.terminal_mut().dirty = true; + } else { + self.ctx.terminal_mut().reset_url_highlight(); + } + } + + fn get_mouse_side(&self) -> Side { + let size_info = self.ctx.size_info(); + let x = self.ctx.mouse().x; + + 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 + } + } + + pub fn normal_mouse_report(&mut self, button: u8) { + let (line, column) = (self.ctx.mouse().line, self.ctx.mouse().column); + + if line < Line(223) && column < Column(223) { + let msg = vec![ + b'\x1b', + b'[', + b'M', + 32 + button, + 32 + 1 + column.0 as u8, + 32 + 1 + line.0 as u8, + ]; + + self.ctx.write_to_pty(msg); + } + } + + pub fn sgr_mouse_report(&mut self, button: u8, state: ElementState) { + let (line, column) = (self.ctx.mouse().line, self.ctx.mouse().column); + let c = match state { + ElementState::Pressed => 'M', + ElementState::Released => 'm', + }; + + let msg = format!("\x1b[<{};{};{}{}", button, column + 1, line + 1, c); + self.ctx.write_to_pty(msg.into_bytes()); + } + + pub fn mouse_report(&mut self, button: u8, state: ElementState, modifiers: ModifiersState) { + // Calculate modifiers value + let mut mods = 0; + if modifiers.shift { + mods += 4; + } + if modifiers.alt { + mods += 8; + } + if modifiers.ctrl { + mods += 16; + } + + // Report mouse events + if self.ctx.terminal().mode().contains(TermMode::SGR_MOUSE) { + self.sgr_mouse_report(button + mods, state); + } else if let ElementState::Released = state { + self.normal_mouse_report(3 + mods); + } else { + self.normal_mouse_report(button + mods); + } + } + + pub fn on_mouse_double_click(&mut self, button: MouseButton, point: Option<Point>) { + if let (Some(point), true) = (point, button == MouseButton::Left) { + self.ctx.semantic_selection(point); + } + } + + pub fn on_mouse_triple_click(&mut self, button: MouseButton, point: Option<Point>) { + if let (Some(point), true) = (point, button == MouseButton::Left) { + self.ctx.line_selection(point); + } + } + + pub fn on_mouse_press( + &mut self, + button: MouseButton, + modifiers: ModifiersState, + point: Option<Point>, + ) { + let now = Instant::now(); + let elapsed = self.ctx.mouse().last_click_timestamp.elapsed(); + self.ctx.mouse_mut().last_click_timestamp = now; + + let button_changed = self.ctx.mouse().last_button != button; + + self.ctx.mouse_mut().click_state = match self.ctx.mouse().click_state { + ClickState::Click + if !button_changed && elapsed < self.mouse_config.double_click.threshold => + { + self.ctx.mouse_mut().block_url_launcher = true; + self.on_mouse_double_click(button, point); + ClickState::DoubleClick + } + ClickState::DoubleClick + if !button_changed && elapsed < self.mouse_config.triple_click.threshold => + { + self.ctx.mouse_mut().block_url_launcher = true; + self.on_mouse_triple_click(button, point); + ClickState::TripleClick + } + _ => { + // Don't launch URLs if this click cleared the selection + self.ctx.mouse_mut().block_url_launcher = !self.ctx.selection_is_empty(); + + self.ctx.clear_selection(); + + // Start new empty selection + let side = self.ctx.mouse().cell_side; + if let Some(point) = point { + self.ctx.simple_selection(point, side); + } + + let report_modes = + TermMode::MOUSE_REPORT_CLICK | TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION; + if !modifiers.shift && self.ctx.terminal().mode().intersects(report_modes) { + let code = match button { + MouseButton::Left => 0, + MouseButton::Middle => 1, + MouseButton::Right => 2, + // Can't properly report more than three buttons. + MouseButton::Other(_) => return, + }; + self.mouse_report(code, ElementState::Pressed, modifiers); + return; + } + + ClickState::Click + }, + }; + } + + pub fn on_mouse_release( + &mut self, + button: MouseButton, + modifiers: ModifiersState, + point: Option<Point>, + ) { + let report_modes = + TermMode::MOUSE_REPORT_CLICK | TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION; + if !modifiers.shift && self.ctx.terminal().mode().intersects(report_modes) { + let code = match button { + MouseButton::Left => 0, + MouseButton::Middle => 1, + MouseButton::Right => 2, + // Can't properly report more than three buttons. + MouseButton::Other(_) => return, + }; + self.mouse_report(code, ElementState::Released, modifiers); + return; + } else if let (Some(point), true) = (point, button == MouseButton::Left) { + self.launch_url(modifiers, point); + } + + self.copy_selection(); + } + + // Spawn URL launcher when clicking on URLs + fn launch_url(&self, modifiers: ModifiersState, point: Point) -> Option<()> { + if !self.mouse_config.url.modifiers.relaxed_eq(modifiers) + || self.ctx.mouse().block_url_launcher + { + return None; + } + + let text = self.ctx.terminal().url_search(point.into())?.text; + + let launcher = self.mouse_config.url.launcher.as_ref()?; + let mut args = launcher.args().to_vec(); + args.push(text); + + match start_daemon(launcher.program(), &args) { + Ok(_) => debug!("Launched {} with args {:?}", launcher.program(), args), + Err(_) => warn!("Unable to launch {} with args {:?}", launcher.program(), args), + } + + Some(()) + } + + pub fn on_mouse_wheel( + &mut self, + delta: MouseScrollDelta, + phase: TouchPhase, + modifiers: ModifiersState, + ) { + match delta { + MouseScrollDelta::LineDelta(_columns, lines) => { + let new_scroll_px = lines * self.ctx.size_info().cell_height; + self.scroll_terminal(modifiers, new_scroll_px as i32); + }, + MouseScrollDelta::PixelDelta(lpos) => { + match phase { + TouchPhase::Started => { + // Reset offset to zero + self.ctx.mouse_mut().scroll_px = 0; + }, + TouchPhase::Moved => { + self.scroll_terminal(modifiers, lpos.y as i32); + }, + _ => (), + } + }, + } + } + + fn scroll_terminal(&mut self, modifiers: ModifiersState, new_scroll_px: i32) { + let mouse_modes = + TermMode::MOUSE_REPORT_CLICK | TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION; + let height = self.ctx.size_info().cell_height as i32; + + // Make sure the new and deprecated setting are both allowed + let faux_multiplier = self + .mouse_config + .faux_scrollback_lines + .unwrap_or(self.scrolling_config.faux_multiplier as usize); + + if self.ctx.terminal().mode().intersects(mouse_modes) { + self.ctx.mouse_mut().scroll_px += new_scroll_px; + + let code = if new_scroll_px > 0 { 64 } else { 65 }; + let lines = (self.ctx.mouse().scroll_px / height).abs(); + + for _ in 0..lines { + self.mouse_report(code, ElementState::Pressed, modifiers); + } + } else if self.ctx.terminal().mode().contains(TermMode::ALT_SCREEN) + && faux_multiplier > 0 + && !modifiers.shift + { + self.ctx.mouse_mut().scroll_px += new_scroll_px * faux_multiplier as i32; + + let cmd = if new_scroll_px > 0 { b'A' } else { b'B' }; + let lines = (self.ctx.mouse().scroll_px / height).abs(); + + let mut content = Vec::with_capacity(lines as usize * 3); + for _ in 0..lines { + content.push(0x1b); + content.push(b'O'); + content.push(cmd); + } + self.ctx.write_to_pty(content); + } else { + let multiplier = i32::from(self.scrolling_config.multiplier); + self.ctx.mouse_mut().scroll_px += new_scroll_px * multiplier; + + let lines = self.ctx.mouse().scroll_px / height; + + self.ctx.scroll(Scroll::Lines(lines as isize)); + } + + self.ctx.mouse_mut().scroll_px %= 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()); + } + } + + pub fn mouse_input( + &mut self, + state: ElementState, + button: MouseButton, + modifiers: ModifiersState, + ) { + 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, + _ => (), + } + + let point = self.ctx.mouse_coords(); + + // Skip normal mouse events if the message bar has been clicked + if let Some(message) = self.message_at_point(point) { + // Message should never be `Some` if point is `None` + debug_assert!(point.is_some()); + self.on_message_bar_click(state, point.unwrap(), message); + } else { + match state { + ElementState::Pressed => { + self.process_mouse_bindings(modifiers, button); + self.on_mouse_press(button, modifiers, point); + }, + ElementState::Released => self.on_mouse_release(button, modifiers, point), + } + } + + self.ctx.mouse_mut().last_button = button; + } + + /// Process key input + pub fn process_key(&mut self, input: KeyboardInput) { + match input.state { + ElementState::Pressed => { + *self.ctx.last_modifiers() = input.modifiers; + *self.ctx.received_count() = 0; + *self.ctx.suppress_chars() = false; + + if self.process_key_bindings(input) { + *self.ctx.suppress_chars() = true; + } + }, + ElementState::Released => *self.ctx.suppress_chars() = false, + } + } + + /// Process a received character + pub fn received_char(&mut self, c: char) { + if *self.ctx.suppress_chars() { + return; + } + + self.ctx.scroll(Scroll::Bottom); + self.ctx.clear_selection(); + + let utf8_len = c.len_utf8(); + let mut bytes = Vec::with_capacity(utf8_len); + unsafe { + bytes.set_len(utf8_len); + c.encode_utf8(&mut bytes[..]); + } + + if self.alt_send_esc + && *self.ctx.received_count() == 0 + && self.ctx.last_modifiers().alt + && utf8_len == 1 + { + bytes.insert(0, b'\x1b'); + } + + self.ctx.write_to_pty(bytes); + + *self.ctx.received_count() += 1; + } + + /// Attempts 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. + /// + /// Returns true if an action is executed. + fn process_key_bindings(&mut self, input: KeyboardInput) -> bool { + let mut has_binding = false; + for binding in self.key_bindings { + let is_triggered = match binding.trigger { + Key::Scancode(_) => binding.is_triggered_by( + *self.ctx.terminal().mode(), + input.modifiers, + &Key::Scancode(input.scancode), + false, + ), + _ => { + if let Some(key) = input.virtual_keycode { + let key = Key::from_glutin_input(key); + binding.is_triggered_by( + *self.ctx.terminal().mode(), + input.modifiers, + &key, + false, + ) + } else { + false + } + }, + }; + + if is_triggered { + // binding was triggered; run the action + binding.execute(&mut self.ctx, false); + has_binding = true; + } + } + + has_binding + } + + /// Attempts 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. + /// + /// Returns true if an action is executed. + fn process_mouse_bindings(&mut self, mods: ModifiersState, button: MouseButton) -> bool { + let mut has_binding = false; + for binding in self.mouse_bindings { + if binding.is_triggered_by(*self.ctx.terminal().mode(), mods, &button, true) { + // binding was triggered; run the action + let mouse_mode = !mods.shift + && self.ctx.terminal().mode().intersects( + TermMode::MOUSE_REPORT_CLICK + | TermMode::MOUSE_DRAG + | TermMode::MOUSE_MOTION, + ); + binding.execute(&mut self.ctx, mouse_mode); + has_binding = true; + } + } + + has_binding + } + + /// Return the message bar's message if there is some at the specified point + fn message_at_point(&mut self, point: Option<Point>) -> Option<Message> { + if let (Some(point), Some(message)) = + (point, self.ctx.terminal_mut().message_buffer_mut().message()) + { + let size = self.ctx.size_info(); + if point.line.0 >= size.lines().saturating_sub(message.text(&size).len()) { + return Some(message); + } + } + + None + } + + /// Handle clicks on the message bar. + fn on_message_bar_click(&mut self, button_state: ElementState, point: Point, message: Message) { + match button_state { + ElementState::Released => self.copy_selection(), + ElementState::Pressed => { + let size = self.ctx.size_info(); + if point.col + message_bar::CLOSE_BUTTON_TEXT.len() >= size.cols() + && point.line == size.lines() - message.text(&size).len() + { + self.ctx.terminal_mut().message_buffer_mut().pop(); + } + + self.ctx.clear_selection(); + }, + } + } + + /// Copy text selection. + fn copy_selection(&mut self) { + if self.save_to_clipboard { + self.ctx.copy_selection(ClipboardBuffer::Primary); + } + self.ctx.copy_selection(ClipboardBuffer::Selection); + } +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + use std::time::Duration; + + use glutin::{ElementState, Event, ModifiersState, MouseButton, VirtualKeyCode, WindowEvent}; + + use crate::config::{self, ClickHandler, Config}; + use crate::event::{ClickState, Mouse, WindowChanges}; + use crate::grid::Scroll; + use crate::index::{Point, Side}; + use crate::message_bar::MessageBuffer; + use crate::selection::Selection; + use crate::term::{SizeInfo, Term, TermMode}; + + use super::{Action, Binding, Processor}; + use copypasta::Buffer as ClipboardBuffer; + + const KEY: VirtualKeyCode = VirtualKeyCode::Key0; + + #[derive(PartialEq)] + enum MultiClick { + DoubleClick, + TripleClick, + None, + } + + struct ActionContext<'a> { + pub terminal: &'a mut Term, + pub selection: &'a mut Option<Selection>, + pub size_info: &'a SizeInfo, + pub mouse: &'a mut Mouse, + pub last_action: MultiClick, + pub received_count: usize, + pub suppress_chars: bool, + pub last_modifiers: ModifiersState, + pub window_changes: &'a mut WindowChanges, + } + + impl<'a> super::ActionContext for ActionContext<'a> { + fn write_to_pty<B: Into<Cow<'static, [u8]>>>(&mut self, _val: B) {} + + fn update_selection(&mut self, _point: Point, _side: Side) {} + + fn simple_selection(&mut self, _point: Point, _side: Side) {} + + fn copy_selection(&self, _buffer: ClipboardBuffer) {} + + fn clear_selection(&mut self) {} + + fn hide_window(&mut self) {} + + fn spawn_new_instance(&mut self) {} + + fn toggle_fullscreen(&mut self) {} + + #[cfg(target_os = "macos")] + fn toggle_simple_fullscreen(&mut self) {} + + fn terminal(&self) -> &Term { + &self.terminal + } + + fn terminal_mut(&mut self) -> &mut Term { + &mut self.terminal + } + + fn size_info(&self) -> SizeInfo { + *self.size_info + } + + fn semantic_selection(&mut self, _point: Point) { + // set something that we can check for here + self.last_action = MultiClick::DoubleClick; + } + + fn line_selection(&mut self, _point: Point) { + self.last_action = MultiClick::TripleClick; + } + + fn selection_is_empty(&self) -> bool { + true + } + + fn scroll(&mut self, scroll: Scroll) { + self.terminal.scroll_display(scroll); + } + + fn mouse_coords(&self) -> Option<Point> { + self.terminal.pixels_to_coords(self.mouse.x as usize, self.mouse.y as usize) + } + + #[inline] + fn mouse_mut(&mut self) -> &mut Mouse { + self.mouse + } + + #[inline] + fn mouse(&self) -> &Mouse { + self.mouse + } + + fn received_count(&mut self) -> &mut usize { + &mut self.received_count + } + + fn suppress_chars(&mut self) -> &mut bool { + &mut self.suppress_chars + } + + fn last_modifiers(&mut self) -> &mut ModifiersState { + &mut self.last_modifiers + } + } + + macro_rules! test_clickstate { + { + name: $name:ident, + initial_state: $initial_state:expr, + initial_button: $initial_button:expr, + input: $input:expr, + end_state: $end_state:pat, + last_action: $last_action:expr + } => { + #[test] + fn $name() { + let config = Config::default(); + let size = SizeInfo { + width: 21.0, + height: 51.0, + cell_width: 3.0, + cell_height: 3.0, + padding_x: 0.0, + padding_y: 0.0, + dpr: 1.0, + }; + + let mut terminal = Term::new(&config, size, MessageBuffer::new()); + + let mut mouse = Mouse::default(); + mouse.click_state = $initial_state; + mouse.last_button = $initial_button; + + let mut selection = None; + + let context = ActionContext { + terminal: &mut terminal, + selection: &mut selection, + mouse: &mut mouse, + size_info: &size, + last_action: MultiClick::None, + received_count: 0, + suppress_chars: false, + last_modifiers: ModifiersState::default(), + window_changes: &mut WindowChanges::default(), + }; + + let mut processor = Processor { + ctx: context, + mouse_config: &config::Mouse { + double_click: ClickHandler { + threshold: Duration::from_millis(1000), + }, + triple_click: ClickHandler { + threshold: Duration::from_millis(1000), + }, + hide_when_typing: false, + faux_scrollback_lines: None, + url: Default::default(), + }, + scrolling_config: &config::Scrolling::default(), + key_bindings: &config.key_bindings()[..], + mouse_bindings: &config.mouse_bindings()[..], + save_to_clipboard: config.selection().save_to_clipboard, + alt_send_esc: config.alt_send_esc(), + }; + + if let Event::WindowEvent { event: WindowEvent::MouseInput { state, button, modifiers, .. }, .. } = $input { + processor.mouse_input(state, button, modifiers); + }; + + assert!(match processor.ctx.mouse.click_state { + $end_state => processor.ctx.last_action == $last_action, + _ => false + }); + } + } + } + + 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, false)); + } else { + assert!(!$binding.is_triggered_by($mode, $mods, &KEY, false)); + } + } + } + } + + test_clickstate! { + name: single_click, + initial_state: ClickState::None, + initial_button: MouseButton::Other(0), + input: Event::WindowEvent { + event: WindowEvent::MouseInput { + state: ElementState::Pressed, + button: MouseButton::Left, + device_id: unsafe { ::std::mem::transmute_copy(&0) }, + modifiers: ModifiersState::default(), + }, + window_id: unsafe { ::std::mem::transmute_copy(&0) }, + }, + end_state: ClickState::Click, + last_action: MultiClick::None + } + + test_clickstate! { + name: double_click, + initial_state: ClickState::Click, + initial_button: MouseButton::Left, + input: Event::WindowEvent { + event: WindowEvent::MouseInput { + state: ElementState::Pressed, + button: MouseButton::Left, + device_id: unsafe { ::std::mem::transmute_copy(&0) }, + modifiers: ModifiersState::default(), + }, + window_id: unsafe { ::std::mem::transmute_copy(&0) }, + }, + end_state: ClickState::DoubleClick, + last_action: MultiClick::DoubleClick + } + + test_clickstate! { + name: triple_click, + initial_state: ClickState::DoubleClick, + initial_button: MouseButton::Left, + input: Event::WindowEvent { + event: WindowEvent::MouseInput { + state: ElementState::Pressed, + button: MouseButton::Left, + device_id: unsafe { ::std::mem::transmute_copy(&0) }, + modifiers: ModifiersState::default(), + }, + window_id: unsafe { ::std::mem::transmute_copy(&0) }, + }, + end_state: ClickState::TripleClick, + last_action: MultiClick::TripleClick + } + + test_clickstate! { + name: multi_click_separate_buttons, + initial_state: ClickState::DoubleClick, + initial_button: MouseButton::Left, + input: Event::WindowEvent { + event: WindowEvent::MouseInput { + state: ElementState::Pressed, + button: MouseButton::Right, + device_id: unsafe { ::std::mem::transmute_copy(&0) }, + modifiers: ModifiersState::default(), + }, + window_id: unsafe { ::std::mem::transmute_copy(&0) }, + }, + end_state: ClickState::Click, + last_action: MultiClick::None + } + + test_process_binding! { + name: process_binding_nomode_shiftmod_require_shift, + binding: Binding { trigger: KEY, mods: ModifiersState { shift: true, ctrl: false, alt: false, logo: false }, action: Action::from("\x1b[1;2D"), mode: TermMode::NONE, notmode: TermMode::NONE }, + triggers: true, + mode: TermMode::NONE, + mods: ModifiersState { shift: true, ctrl: false, alt: false, logo: false } + } + + test_process_binding! { + name: process_binding_nomode_nomod_require_shift, + binding: Binding { trigger: KEY, mods: ModifiersState { shift: true, ctrl: false, alt: false, logo: false }, action: Action::from("\x1b[1;2D"), mode: TermMode::NONE, notmode: TermMode::NONE }, + triggers: false, + mode: TermMode::NONE, + mods: ModifiersState { shift: false, ctrl: false, alt: false, logo: false } + } + + test_process_binding! { + name: process_binding_nomode_controlmod, + binding: Binding { trigger: KEY, mods: ModifiersState { ctrl: true, shift: false, alt: false, logo: false }, action: Action::from("\x1b[1;5D"), mode: TermMode::NONE, notmode: TermMode::NONE }, + triggers: true, + mode: TermMode::NONE, + mods: ModifiersState { ctrl: true, shift: false, alt: false, logo: false } + } + + test_process_binding! { + name: process_binding_nomode_nomod_require_not_appcursor, + binding: Binding { trigger: KEY, mods: ModifiersState { shift: false, ctrl: false, alt: false, logo: false }, action: Action::from("\x1b[D"), mode: TermMode::NONE, notmode: TermMode::APP_CURSOR }, + triggers: true, + mode: TermMode::NONE, + mods: ModifiersState { shift: false, ctrl: false, alt: false, logo: false } + } + + test_process_binding! { + name: process_binding_appcursormode_nomod_require_appcursor, + binding: Binding { trigger: KEY, mods: ModifiersState { shift: false, ctrl: false, alt: false, logo: false }, action: Action::from("\x1bOD"), mode: TermMode::APP_CURSOR, notmode: TermMode::NONE }, + triggers: true, + mode: TermMode::APP_CURSOR, + mods: ModifiersState { shift: false, ctrl: false, alt: false, logo: false } + } + + test_process_binding! { + name: process_binding_nomode_nomod_require_appcursor, + binding: Binding { trigger: KEY, mods: ModifiersState { shift: false, ctrl: false, alt: false, logo: false }, action: Action::from("\x1bOD"), mode: TermMode::APP_CURSOR, notmode: TermMode::NONE }, + triggers: false, + mode: TermMode::NONE, + mods: ModifiersState { shift: false, ctrl: false, alt: false, logo: false } + } + + test_process_binding! { + name: process_binding_appcursormode_appkeypadmode_nomod_require_appcursor, + binding: Binding { trigger: KEY, mods: ModifiersState { shift: false, ctrl: false, alt: false, logo: false }, action: Action::from("\x1bOD"), mode: TermMode::APP_CURSOR, notmode: TermMode::NONE }, + triggers: true, + mode: TermMode::APP_CURSOR | TermMode::APP_KEYPAD, + mods: ModifiersState { shift: false, ctrl: false, alt: false, logo: false } + } + + test_process_binding! { + name: process_binding_fail_with_extra_mods, + binding: Binding { trigger: KEY, mods: ModifiersState { shift: false, ctrl: false, alt: false, logo: true }, action: Action::from("arst"), mode: TermMode::NONE, notmode: TermMode::NONE }, + triggers: false, + mode: TermMode::NONE, + mods: ModifiersState { shift: false, ctrl: false, alt: true, logo: true } + } +} |