Add copy/paste/select hint actions

This adds some built-in actions for handling hint selections without
having to spawn external applications.

The new actions are `Copy`, `Select` and `Paste`.
This commit is contained in:
Christian Duerr 2021-04-03 23:52:44 +00:00 committed by GitHub
parent 531e494cf9
commit cbcc129440
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 135 additions and 65 deletions

View File

@ -478,9 +478,19 @@
# List with all available hints # List with all available hints
# #
# Each hint takes a `regex`, `binding` and either a `command` or an `action`.
#
# The fields `command`, `binding.key` and `binding.mods` accept the same # The fields `command`, `binding.key` and `binding.mods` accept the same
# values as they do in the `key_bindings` section. # values as they do in the `key_bindings` section.
# #
# Values for `action`:
# - Copy
# Copy the hint's text to the clipboard.
# - Paste
# Paste the hint's text to the terminal or search.
# - Select
# Select the hint's text.
#
# Example # Example
# #
# enabled: # enabled:

View File

@ -4,7 +4,7 @@ use std::rc::Rc;
use log::error; use log::error;
use serde::de::Error as SerdeError; use serde::de::Error as SerdeError;
use serde::{Deserialize, Deserializer}; use serde::{self, Deserialize, Deserializer};
use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthChar;
use alacritty_config_derive::ConfigDeserialize; use alacritty_config_derive::ConfigDeserialize;
@ -245,11 +245,35 @@ impl<'de> Deserialize<'de> for HintsAlphabet {
} }
} }
/// Built-in actions for hint mode.
#[derive(ConfigDeserialize, Clone, Debug, PartialEq, Eq)]
pub enum HintInternalAction {
/// Copy the text to the clipboard.
Copy,
/// Write the text to the PTY/search.
Paste,
/// Select the text matching the hint.
Select,
}
/// Actions for hint bindings.
#[derive(Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum HintAction {
/// Built-in hint action.
#[serde(rename = "action")]
Action(HintInternalAction),
/// Command the text will be piped to.
#[serde(rename = "command")]
Command(Program),
}
/// Hint configuration. /// Hint configuration.
#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] #[derive(Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct Hint { pub struct Hint {
/// Command the text will be piped to. /// Action executed when this hint is triggered.
pub command: Program, #[serde(flatten)]
pub action: HintAction,
/// Regex for finding matches. /// Regex for finding matches.
pub regex: LazyRegex, pub regex: LazyRegex,

View File

@ -1,7 +1,7 @@
use alacritty_terminal::term::search::Match;
use alacritty_terminal::term::Term; use alacritty_terminal::term::Term;
use crate::config::ui_config::Hint; use crate::config::ui_config::{Hint, HintAction};
use crate::daemon::start_daemon;
use crate::display::content::RegexMatches; use crate::display::content::RegexMatches;
/// Percentage of characters in the hints alphabet used for the last character. /// Percentage of characters in the hints alphabet used for the last character.
@ -88,7 +88,7 @@ impl HintState {
} }
/// Handle keyboard input during hint selection. /// Handle keyboard input during hint selection.
pub fn keyboard_input<T>(&mut self, term: &Term<T>, c: char) { pub fn keyboard_input<T>(&mut self, term: &Term<T>, c: char) -> Option<HintMatch> {
match c { match c {
// Use backspace to remove the last character pressed. // Use backspace to remove the last character pressed.
'\x08' | '\x1f' => { '\x08' | '\x1f' => {
@ -102,34 +102,25 @@ impl HintState {
// Update the visible matches. // Update the visible matches.
self.update_matches(term); self.update_matches(term);
let hint = match self.hint.as_ref() { let hint = self.hint.as_ref()?;
Some(hint) => hint,
None => return,
};
// Find the last label starting with the input character. // Find the last label starting with the input character.
let mut labels = self.labels.iter().enumerate().rev(); let mut labels = self.labels.iter().enumerate().rev();
let (index, label) = match labels.find(|(_, label)| !label.is_empty() && label[0] == c) { let (index, label) = labels.find(|(_, label)| !label.is_empty() && label[0] == c)?;
Some(last) => last,
None => return,
};
// Check if the selected label is fully matched. // Check if the selected label is fully matched.
if label.len() == 1 { if label.len() == 1 {
// Get text for the hint's regex match. let bounds = self.matches[index].clone();
let hint_match = &self.matches[index]; let action = hint.action.clone();
let text = term.bounds_to_string(*hint_match.start(), *hint_match.end());
// Append text as last argument and launch command.
let program = hint.command.program();
let mut args = hint.command.args().to_vec();
args.push(text);
start_daemon(program, &args);
self.stop(); self.stop();
Some(HintMatch { action, bounds })
} else { } else {
// Store character to preserve the selection. // Store character to preserve the selection.
self.keys.push(c); self.keys.push(c);
None
} }
} }
@ -152,6 +143,15 @@ impl HintState {
} }
} }
/// Hint match which was selected by the user.
pub struct HintMatch {
/// Action for handling the text.
pub action: HintAction,
/// Terminal range matching the hint.
pub bounds: Match,
}
/// Generator for creating new hint labels. /// Generator for creating new hint labels.
struct HintLabels { struct HintLabels {
/// Full character set available. /// Full character set available.

View File

@ -41,9 +41,10 @@ use alacritty_terminal::tty;
use crate::cli::Options as CLIOptions; use crate::cli::Options as CLIOptions;
use crate::clipboard::Clipboard; use crate::clipboard::Clipboard;
use crate::config::ui_config::{HintAction, HintInternalAction};
use crate::config::{self, Config}; use crate::config::{self, Config};
use crate::daemon::start_daemon; use crate::daemon::start_daemon;
use crate::display::hint::HintState; use crate::display::hint::{HintMatch, HintState};
use crate::display::window::Window; use crate::display::window::Window;
use crate::display::{Display, DisplayUpdate}; use crate::display::{Display, DisplayUpdate};
use crate::input::{self, ActionContext as _, FONT_SIZE_STEP}; use crate::input::{self, ActionContext as _, FONT_SIZE_STEP};
@ -178,7 +179,7 @@ pub struct ActionContext<'a, N, T> {
impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> for ActionContext<'a, N, T> { impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> for ActionContext<'a, N, T> {
#[inline] #[inline]
fn write_to_pty<B: Into<Cow<'static, [u8]>>>(&mut self, val: B) { fn write_to_pty<B: Into<Cow<'static, [u8]>>>(&self, val: B) {
self.notifier.notify(val); self.notifier.notify(val);
} }
@ -221,12 +222,17 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> for ActionCon
*self.dirty = true; *self.dirty = true;
} }
// Copy text selection.
fn copy_selection(&mut self, ty: ClipboardType) { fn copy_selection(&mut self, ty: ClipboardType) {
if let Some(selected) = self.terminal.selection_to_string() { let text = match self.terminal.selection_to_string().filter(|s| !s.is_empty()) {
if !selected.is_empty() { Some(text) => text,
self.clipboard.store(ty, selected); None => return,
} };
if ty == ClipboardType::Selection && self.config.selection.save_to_clipboard {
self.clipboard.store(ClipboardType::Clipboard, text.clone());
} }
self.clipboard.store(ty, text);
} }
fn selection_is_empty(&self) -> bool { fn selection_is_empty(&self) -> bool {
@ -258,11 +264,15 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> for ActionCon
self.terminal.selection = Some(selection); self.terminal.selection = Some(selection);
*self.dirty = true; *self.dirty = true;
self.copy_selection(ClipboardType::Selection);
} }
fn start_selection(&mut self, ty: SelectionType, point: Point, side: Side) { fn start_selection(&mut self, ty: SelectionType, point: Point, side: Side) {
self.terminal.selection = Some(Selection::new(ty, point, side)); self.terminal.selection = Some(Selection::new(ty, point, side));
*self.dirty = true; *self.dirty = true;
self.copy_selection(ClipboardType::Selection);
} }
fn toggle_selection(&mut self, ty: SelectionType, point: Point, side: Side) { fn toggle_selection(&mut self, ty: SelectionType, point: Point, side: Side) {
@ -273,6 +283,8 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> for ActionCon
Some(selection) if !selection.is_empty() => { Some(selection) if !selection.is_empty() => {
selection.ty = ty; selection.ty = ty;
*self.dirty = true; *self.dirty = true;
self.copy_selection(ClipboardType::Selection);
}, },
_ => self.start_selection(ty, point, side), _ => self.start_selection(ty, point, side),
} }
@ -639,8 +651,59 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> for ActionCon
/// Process a new character for keyboard hints. /// Process a new character for keyboard hints.
fn hint_input(&mut self, c: char) { fn hint_input(&mut self, c: char) {
self.display.hint_state.keyboard_input(self.terminal, c); let action = self.display.hint_state.keyboard_input(self.terminal, c);
*self.dirty = true; *self.dirty = true;
let HintMatch { action, bounds } = match action {
Some(action) => action,
None => return,
};
match action {
// Launch an external program.
HintAction::Command(command) => {
let text = self.terminal.bounds_to_string(*bounds.start(), *bounds.end());
let mut args = command.args().to_vec();
args.push(text);
start_daemon(command.program(), &args);
},
// Copy the text to the clipboard.
HintAction::Action(HintInternalAction::Copy) => {
let text = self.terminal.bounds_to_string(*bounds.start(), *bounds.end());
self.clipboard.store(ClipboardType::Clipboard, text);
},
// Write the text to the PTY/search.
HintAction::Action(HintInternalAction::Paste) => {
let text = self.terminal.bounds_to_string(*bounds.start(), *bounds.end());
self.paste(&text);
},
// Select the text.
HintAction::Action(HintInternalAction::Select) => {
self.start_selection(SelectionType::Simple, *bounds.start(), Side::Left);
self.update_selection(*bounds.end(), Side::Right);
},
}
}
/// Paste a text into the terminal.
fn paste(&mut self, text: &str) {
if self.search_active() {
for c in text.chars() {
self.search_input(c);
}
} else if self.terminal().mode().contains(TermMode::BRACKETED_PASTE) {
self.write_to_pty(&b"\x1b[200~"[..]);
self.write_to_pty(text.replace("\x1b", "").into_bytes());
self.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 single carriage return (\r, which is what the Enter key produces).
self.write_to_pty(text.replace("\r\n", "\r").replace("\n", "\r").into_bytes());
}
} }
/// Toggle the vi mode status. /// Toggle the vi mode status.

View File

@ -60,7 +60,7 @@ pub struct Processor<T: EventListener, A: ActionContext<T>> {
} }
pub trait ActionContext<T: EventListener> { pub trait ActionContext<T: EventListener> {
fn write_to_pty<B: Into<Cow<'static, [u8]>>>(&mut self, _data: B) {} fn write_to_pty<B: Into<Cow<'static, [u8]>>>(&self, _data: B) {}
fn mark_dirty(&mut self) {} fn mark_dirty(&mut self) {}
fn size_info(&self) -> SizeInfo; fn size_info(&self) -> SizeInfo;
fn copy_selection(&mut self, _ty: ClipboardType) {} fn copy_selection(&mut self, _ty: ClipboardType) {}
@ -107,6 +107,7 @@ pub trait ActionContext<T: EventListener> {
fn toggle_vi_mode(&mut self) {} fn toggle_vi_mode(&mut self) {}
fn hint_state(&mut self) -> &mut HintState; fn hint_state(&mut self) -> &mut HintState;
fn hint_input(&mut self, _character: char) {} fn hint_input(&mut self, _character: char) {}
fn paste(&mut self, _text: &str) {}
} }
impl Action { impl Action {
@ -243,11 +244,11 @@ impl<T: EventListener> Execute<T> for Action {
Action::ClearSelection => ctx.clear_selection(), Action::ClearSelection => ctx.clear_selection(),
Action::Paste => { Action::Paste => {
let text = ctx.clipboard_mut().load(ClipboardType::Clipboard); let text = ctx.clipboard_mut().load(ClipboardType::Clipboard);
paste(ctx, &text); ctx.paste(&text);
}, },
Action::PasteSelection => { Action::PasteSelection => {
let text = ctx.clipboard_mut().load(ClipboardType::Selection); let text = ctx.clipboard_mut().load(ClipboardType::Selection);
paste(ctx, &text); ctx.paste(&text);
}, },
Action::ToggleFullscreen => ctx.window_mut().toggle_fullscreen(), Action::ToggleFullscreen => ctx.window_mut().toggle_fullscreen(),
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@ -324,26 +325,6 @@ impl<T: EventListener> Execute<T> for Action {
} }
} }
fn paste<T: EventListener, A: ActionContext<T>>(ctx: &mut A, contents: &str) {
if ctx.search_active() {
for c in contents.chars() {
ctx.search_input(c);
}
} else 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 single carriage return (\r, which is what the Enter key produces).
ctx.write_to_pty(contents.replace("\r\n", "\r").replace("\n", "\r").into_bytes());
}
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum MouseState { pub enum MouseState {
Url(Url), Url(Url),
@ -680,7 +661,6 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
} }
self.ctx.scheduler_mut().unschedule(TimerId::SelectionScrolling); self.ctx.scheduler_mut().unschedule(TimerId::SelectionScrolling);
self.copy_selection();
} }
pub fn mouse_wheel_input(&mut self, delta: MouseScrollDelta, phase: TouchPhase) { pub fn mouse_wheel_input(&mut self, delta: MouseScrollDelta, phase: TouchPhase) {
@ -965,14 +945,6 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
} }
} }
/// Copy text selection.
fn copy_selection(&mut self) {
if self.ctx.config().selection.save_to_clipboard {
self.ctx.copy_selection(ClipboardType::Clipboard);
}
self.ctx.copy_selection(ClipboardType::Selection);
}
/// Trigger redraw when URL highlight changed. /// Trigger redraw when URL highlight changed.
#[inline] #[inline]
fn update_url_state(&mut self, mouse_state: &MouseState) { fn update_url_state(&mut self, mouse_state: &MouseState) {

View File

@ -70,7 +70,7 @@ pub trait Notify {
/// Notify that an escape sequence should be written to the PTY. /// Notify that an escape sequence should be written to the PTY.
/// ///
/// TODO this needs to be able to error somehow. /// TODO this needs to be able to error somehow.
fn notify<B: Into<Cow<'static, [u8]>>>(&mut self, _: B); fn notify<B: Into<Cow<'static, [u8]>>>(&self, _: B);
} }
/// Types that are interested in when the display is resized. /// Types that are interested in when the display is resized.

View File

@ -62,7 +62,7 @@ struct Writing {
pub struct Notifier(pub Sender<Msg>); pub struct Notifier(pub Sender<Msg>);
impl event::Notify for Notifier { impl event::Notify for Notifier {
fn notify<B>(&mut self, bytes: B) fn notify<B>(&self, bytes: B)
where where
B: Into<Cow<'static, [u8]>>, B: Into<Cow<'static, [u8]>>,
{ {

View File

@ -58,7 +58,8 @@ stays selected, allowing you to easily copy it.
Terminal hints allow easily interacting with visible text without having to Terminal hints allow easily interacting with visible text without having to
start vi mode. They consist of a regex that detects these text elements and then start vi mode. They consist of a regex that detects these text elements and then
feeds them to an external application. either feeds them to an external application or triggers one of Alacritty's
built-in actions.
Hints can be configured in the `hints` and `colors.hints` sections in the Hints can be configured in the `hints` and `colors.hints` sections in the
Alacritty configuration file. Alacritty configuration file.