Add regex terminal hints

This adds support for hints, which allow opening parts of the visual
buffer with external programs if they match a certain regex.

This is done using a visual overlay triggered on a specified key
binding, which then instructs the user which keys they need to press to
pass the text to the application.

In the future it should be possible to supply some built-in actions for
Copy/Pasting the action and using this to launch text when clicking on
it with the mouse. But the current implementation should already be
useful as-is.

Fixes #2792.
Fixes #2536.
This commit is contained in:
Christian Duerr 2021-03-01 19:50:39 +00:00 committed by GitHub
parent 772afc6a8a
commit a954e076ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 749 additions and 98 deletions

View File

@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- IME composition preview not appearing on Windows
- Synchronized terminal updates using `DCS = 1 s ST`/`DCS = 2 s ST`
- Regex terminal hints ([see features.md](./docs/features.md#hints))
### Fixed

View File

@ -195,7 +195,7 @@
#
# Colors which should be used to draw the terminal cursor.
#
# Allowed values are CellForeground and CellBackground, which reference the
# Allowed values are CellForeground/CellBackground, which reference the
# affected cell, or hexadecimal colors like #ff00ff.
#cursor:
# text: CellBackground
@ -205,7 +205,7 @@
#
# Colors for the cursor when the vi mode is active.
#
# Allowed values are CellForeground and CellBackground, which reference the
# Allowed values are CellForeground/CellBackground, which reference the
# affected cell, or hexadecimal colors like #ff00ff.
#vi_mode_cursor:
# text: CellBackground
@ -215,7 +215,7 @@
#
# Colors used for the search bar and match highlighting.
#search:
# Allowed values are CellForeground and CellBackground, which reference the
# Allowed values are CellForeground/CellBackground, which reference the
# affected cell, or hexadecimal colors like #ff00ff.
#matches:
# foreground: '#000000'
@ -228,6 +228,24 @@
# background: '#c5c8c6'
# foreground: '#1d1f21'
# Keyboard hints
#hints:
# Fist character in the hint label
#
# Allowed values are CellForeground/CellBackground, which reference the
# affected cell, or hexadecimal colors like #ff00ff.
#start:
# foreground: '#1d1f21'
# background: '#e9ff5e'
# All characters after the first one in the hint label
#
# Allowed values are CellForeground/CellBackground, which reference the
# affected cell, or hexadecimal colors like #ff00ff.
#end:
# foreground: '#e9ff5e'
# background: '#1d1f21'
# Line indicator
#
# Color used for the indicator displaying the position in history during
@ -242,7 +260,7 @@
#
# Colors which should be used to draw the selection area.
#
# Allowed values are CellForeground and CellBackground, which reference the
# Allowed values are CellForeground/CellBackground, which reference the
# affected cell, or hexadecimal colors like #ff00ff.
#selection:
# text: CellBackground
@ -450,6 +468,29 @@
# binding section.
#modifiers: None
# Regex hints
#
# Terminal hints can be used to find text in the visible part of the terminal
# and pipe it to other applications.
#hints:
# Keys used for the hint labels.
#alphabet: "jfkdls;ahgurieowpq"
# List with all available hints
#
# The fields `command`, `binding.key` and `binding.mods` accept the same
# values as they do in the `key_bindings` section.
#
# Example
#
# enabled:
# - regex: "alacritty/alacritty#\\d*"
# command: firefox
# binding:
# key: G
# mods: Control|Shift
#enabled: []
# Mouse bindings
#
# Mouse bindings are specified as a list of objects, much like the key

View File

@ -16,6 +16,8 @@ use alacritty_terminal::config::Program;
use alacritty_terminal::term::TermMode;
use alacritty_terminal::vi_mode::ViMotion;
use crate::config::ui_config::Hint;
/// Describes a state and action to take in that state.
///
/// This is the shared component of `MouseBinding` and `KeyBinding`.
@ -91,6 +93,10 @@ pub enum Action {
#[config(skip)]
Command(Program),
/// Regex keyboard hints.
#[config(skip)]
Hint(Hint),
/// Move vi mode cursor.
#[config(skip)]
ViMotion(ViMotion),
@ -1132,7 +1138,7 @@ impl<'a> Deserialize<'a> for KeyBinding {
/// Our deserialize impl wouldn't be covered by a derive(Deserialize); see the
/// impl below.
#[derive(Debug, Copy, Clone, Hash, Default, Eq, PartialEq)]
pub struct ModsWrapper(ModifiersState);
pub struct ModsWrapper(pub ModifiersState);
impl ModsWrapper {
pub fn into_inner(self) -> ModifiersState {

View File

@ -16,6 +16,7 @@ pub struct Colors {
pub indexed_colors: Vec<IndexedColor>,
pub search: SearchColors,
pub line_indicator: LineIndicatorColors,
pub hints: HintColors,
}
impl Colors {
@ -34,6 +35,42 @@ pub struct LineIndicatorColors {
pub background: Option<Rgb>,
}
#[derive(ConfigDeserialize, Default, Copy, Clone, Debug, PartialEq, Eq)]
pub struct HintColors {
pub start: HintStartColors,
pub end: HintEndColors,
}
#[derive(ConfigDeserialize, Copy, Clone, Debug, PartialEq, Eq)]
pub struct HintStartColors {
pub foreground: CellRgb,
pub background: CellRgb,
}
impl Default for HintStartColors {
fn default() -> Self {
Self {
foreground: CellRgb::Rgb(Rgb { r: 0x1d, g: 0x1f, b: 0x21 }),
background: CellRgb::Rgb(Rgb { r: 0xe9, g: 0xff, b: 0x5e }),
}
}
}
#[derive(ConfigDeserialize, Copy, Clone, Debug, PartialEq, Eq)]
pub struct HintEndColors {
pub foreground: CellRgb,
pub background: CellRgb,
}
impl Default for HintEndColors {
fn default() -> Self {
Self {
foreground: CellRgb::Rgb(Rgb { r: 0xe9, g: 0xff, b: 0x5e }),
background: CellRgb::Rgb(Rgb { r: 0x1d, g: 0x1f, b: 0x21 }),
}
}
}
#[derive(Deserialize, Copy, Clone, Default, Debug, PartialEq, Eq)]
pub struct IndexedColor {
pub color: Rgb,

View File

@ -159,6 +159,9 @@ fn read_config(path: &Path, cli_config: Value) -> Result<Config> {
let mut config = Config::deserialize(config_value)?;
config.ui_config.config_paths = config_paths;
// Create key bindings for regex hints.
config.ui_config.generate_hint_bindings();
Ok(config)
}

View File

@ -1,13 +1,20 @@
use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use log::error;
use serde::de::Error as SerdeError;
use serde::{Deserialize, Deserializer};
use unicode_width::UnicodeWidthChar;
use alacritty_config_derive::ConfigDeserialize;
use alacritty_terminal::config::{Percentage, LOG_TARGET_CONFIG};
use alacritty_terminal::config::{Percentage, Program, LOG_TARGET_CONFIG};
use alacritty_terminal::term::search::RegexSearch;
use crate::config::bell::BellConfig;
use crate::config::bindings::{self, Binding, KeyBinding, MouseBinding};
use crate::config::bindings::{
self, Action, Binding, BindingMode, Key, KeyBinding, ModsWrapper, MouseBinding,
};
use crate::config::color::Colors;
use crate::config::debug::Debug;
use crate::config::font::Font;
@ -46,6 +53,9 @@ pub struct UiConfig {
#[config(skip)]
pub config_paths: Vec<PathBuf>,
/// Regex hints for interacting with terminal content.
pub hints: Hints,
/// Keybindings.
key_bindings: KeyBindings,
@ -72,11 +82,27 @@ impl Default for UiConfig {
bell: Default::default(),
colors: Default::default(),
draw_bold_text_with_bright_colors: Default::default(),
hints: Default::default(),
}
}
}
impl UiConfig {
/// Generate key bindings for all keyboard hints.
pub fn generate_hint_bindings(&mut self) {
for hint in self.hints.enabled.drain(..) {
let binding = KeyBinding {
trigger: hint.binding.key,
mods: hint.binding.mods.0,
mode: BindingMode::empty(),
notmode: BindingMode::empty(),
action: Action::Hint(hint),
};
self.key_bindings.0.push(binding);
}
}
#[inline]
pub fn background_opacity(&self) -> f32 {
self.background_opacity.as_f32()
@ -169,3 +195,141 @@ pub struct Delta<T: Default> {
/// Vertical change.
pub y: T,
}
/// Regex terminal hints.
#[derive(ConfigDeserialize, Default, Debug, PartialEq, Eq)]
pub struct Hints {
/// Characters for the hint labels.
alphabet: HintsAlphabet,
/// All configured terminal hints.
enabled: Vec<Hint>,
}
impl Hints {
/// Characters for the hint labels.
pub fn alphabet(&self) -> &str {
&self.alphabet.0
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct HintsAlphabet(String);
impl Default for HintsAlphabet {
fn default() -> Self {
Self(String::from("jfkdls;ahgurieowpq"))
}
}
impl<'de> Deserialize<'de> for HintsAlphabet {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
let mut character_count = 0;
for character in value.chars() {
if character.width() != Some(1) {
return Err(D::Error::custom("characters must be of width 1"));
}
character_count += 1;
}
if character_count < 2 {
return Err(D::Error::custom("must include at last 2 characters"));
}
Ok(Self(value))
}
}
/// Hint configuration.
#[derive(Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct Hint {
/// Command the text will be piped to.
pub command: Program,
/// Regex for finding matches.
pub regex: LazyRegex,
/// Binding required to search for this hint.
binding: HintBinding,
}
/// Binding for triggering a keyboard hint.
#[derive(Deserialize, Copy, Clone, Debug, PartialEq, Eq)]
pub struct HintBinding {
pub key: Key,
pub mods: ModsWrapper,
}
/// Lazy regex with interior mutability.
#[derive(Clone, Debug)]
pub struct LazyRegex(Rc<RefCell<LazyRegexVariant>>);
impl LazyRegex {
/// Execute a function with the compiled regex DFAs as parameter.
pub fn with_compiled<T, F>(&self, f: F) -> T
where
F: Fn(&RegexSearch) -> T,
{
f(self.0.borrow_mut().compiled())
}
}
impl<'de> Deserialize<'de> for LazyRegex {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let regex = LazyRegexVariant::Pattern(String::deserialize(deserializer)?);
Ok(Self(Rc::new(RefCell::new(regex))))
}
}
/// Implement placeholder to allow derive upstream, since we never need it for this struct itself.
impl PartialEq for LazyRegex {
fn eq(&self, _other: &Self) -> bool {
false
}
}
impl Eq for LazyRegex {}
/// Regex which is compiled on demand, to avoid expensive computations at startup.
#[derive(Clone, Debug)]
pub enum LazyRegexVariant {
Compiled(Box<RegexSearch>),
Pattern(String),
}
impl LazyRegexVariant {
/// Get a reference to the compiled regex.
///
/// If the regex is not already compiled, this will compile the DFAs and store them for future
/// access.
fn compiled(&mut self) -> &RegexSearch {
// Check if the regex has already been compiled.
let regex = match self {
Self::Compiled(regex_search) => return regex_search,
Self::Pattern(regex) => regex,
};
// Compile the regex.
let regex_search = match RegexSearch::new(&regex) {
Ok(regex_search) => regex_search,
Err(error) => {
error!("hint regex is invalid: {}", error);
RegexSearch::new("").unwrap()
},
};
*self = Self::Compiled(Box::new(regex_search));
// Return a reference to the compiled DFAs.
match self {
Self::Compiled(dfas) => dfas,
Self::Pattern(_) => unreachable!(),
}
}
}

View File

@ -1,6 +1,7 @@
use std::borrow::Cow;
use std::cmp::max;
use std::mem;
use std::ops::RangeInclusive;
use std::ops::{Deref, DerefMut, RangeInclusive};
use alacritty_terminal::ansi::{Color, CursorShape, NamedColor};
use alacritty_terminal::config::Config;
@ -16,6 +17,8 @@ use alacritty_terminal::term::{
use crate::config::ui_config::UiConfig;
use crate::display::color::{List, DIM_FACTOR};
use crate::display::hint::HintState;
use crate::display::Display;
/// Minimum contrast between a fixed cursor color and the cell's background.
pub const MIN_CURSOR_CONTRAST: f64 = 1.5;
@ -30,31 +33,38 @@ pub struct RenderableContent<'a> {
terminal_content: TerminalContent<'a>,
terminal_cursor: TerminalCursor,
cursor: Option<RenderableCursor>,
search: RenderableSearch,
search: Regex<'a>,
hint: Hint<'a>,
config: &'a Config<UiConfig>,
colors: &'a List,
}
impl<'a> RenderableContent<'a> {
pub fn new<T: EventListener>(
term: &'a Term<T>,
dfas: Option<&RegexSearch>,
config: &'a Config<UiConfig>,
colors: &'a List,
show_cursor: bool,
display: &'a mut Display,
term: &'a Term<T>,
search_dfas: Option<&RegexSearch>,
) -> Self {
let search = dfas.map(|dfas| RenderableSearch::new(&term, dfas)).unwrap_or_default();
let search = search_dfas.map(|dfas| Regex::new(&term, dfas)).unwrap_or_default();
let terminal_content = term.renderable_content();
// Copy the cursor and override its shape if necessary.
let mut terminal_cursor = terminal_content.cursor;
if !show_cursor || terminal_cursor.shape == CursorShape::Hidden {
if terminal_cursor.shape == CursorShape::Hidden
|| display.cursor_hidden
|| search_dfas.is_some()
{
terminal_cursor.shape = CursorShape::Hidden;
} else if !term.is_focused && config.cursor.unfocused_hollow {
terminal_cursor.shape = CursorShape::HollowBlock;
}
Self { cursor: None, terminal_content, terminal_cursor, search, config, colors }
display.hint_state.update_matches(term);
let hint = Hint::from(&display.hint_state);
let colors = &display.colors;
Self { cursor: None, terminal_content, terminal_cursor, search, config, colors, hint }
}
/// Viewport offset.
@ -193,37 +203,40 @@ impl RenderableCell {
.map_or(false, |selection| selection.contains_cell(&cell, content.terminal_cursor));
let mut is_match = false;
let mut character = cell.c;
let colors = &content.config.ui_config.colors;
if is_selected {
if let Some((c, is_first)) = content.hint.advance(cell.point) {
let (config_fg, config_bg) = if is_first {
(colors.hints.start.foreground, colors.hints.start.background)
} else {
(colors.hints.end.foreground, colors.hints.end.background)
};
Self::compute_cell_rgb(&mut fg_rgb, &mut bg_rgb, &mut bg_alpha, config_fg, config_bg);
character = c;
} else if is_selected {
let config_fg = colors.selection.foreground;
let config_bg = colors.selection.background;
let selected_fg = colors.selection.foreground.color(fg_rgb, bg_rgb);
bg_rgb = config_bg.color(fg_rgb, bg_rgb);
fg_rgb = selected_fg;
Self::compute_cell_rgb(&mut fg_rgb, &mut bg_rgb, &mut bg_alpha, config_fg, config_bg);
if fg_rgb == bg_rgb && !cell.flags.contains(Flags::HIDDEN) {
// Reveal inversed text when fg/bg is the same.
fg_rgb = content.color(NamedColor::Background as usize);
bg_rgb = content.color(NamedColor::Foreground as usize);
bg_alpha = 1.0;
} else if config_bg != CellRgb::CellBackground {
bg_alpha = 1.0;
}
} else if content.search.advance(cell.point) {
// Highlight the cell if it is part of a search match.
let config_fg = colors.search.matches.foreground;
let config_bg = colors.search.matches.background;
let matched_fg = colors.search.matches.foreground.color(fg_rgb, bg_rgb);
bg_rgb = config_bg.color(fg_rgb, bg_rgb);
fg_rgb = matched_fg;
if config_bg != CellRgb::CellBackground {
bg_alpha = 1.0;
}
Self::compute_cell_rgb(&mut fg_rgb, &mut bg_rgb, &mut bg_alpha, config_fg, config_bg);
is_match = true;
}
RenderableCell {
character: cell.c,
character,
zerowidth: cell.zerowidth().map(|zerowidth| zerowidth.to_vec()),
point: cell.point,
fg: fg_rgb,
@ -242,6 +255,22 @@ impl RenderableCell {
&& self.zerowidth.is_none()
}
/// Apply [`CellRgb`] colors to the cell's colors.
fn compute_cell_rgb(
cell_fg: &mut Rgb,
cell_bg: &mut Rgb,
bg_alpha: &mut f32,
fg: CellRgb,
bg: CellRgb,
) {
let old_fg = mem::replace(cell_fg, fg.color(*cell_fg, *cell_bg));
*cell_bg = bg.color(old_fg, *cell_bg);
if bg != CellRgb::CellBackground {
*bg_alpha = 1.0;
}
}
/// Get the RGB color from a cell's foreground color.
fn compute_fg_rgb(content: &mut RenderableContent<'_>, fg: Color, flags: Flags) -> Rgb {
let ui_config = &content.config.ui_config;
@ -339,18 +368,58 @@ impl RenderableCursor {
}
}
/// Regex search highlight tracking.
#[derive(Default)]
pub struct RenderableSearch {
/// All visible search matches.
matches: Vec<RangeInclusive<Point>>,
/// Regex hints for keyboard shortcuts.
struct Hint<'a> {
/// Hint matches and position.
regex: Regex<'a>,
/// Index of the last match checked.
index: usize,
/// Last match checked against current cell position.
labels: &'a Vec<Vec<char>>,
}
impl RenderableSearch {
/// Create a new renderable search iterator.
impl<'a> Hint<'a> {
/// Advance the hint iterator.
///
/// If the point is within a hint, the keyboard shortcut character that should be displayed at
/// this position will be returned.
///
/// The tuple's [`bool`] will be `true` when the character is the first for this hint.
fn advance(&mut self, point: Point) -> Option<(char, bool)> {
// Check if we're within a match at all.
if !self.regex.advance(point) {
return None;
}
// Match starting position on this line; linebreaks interrupt the hint labels.
let start = self
.regex
.matches
.get(self.regex.index)
.map(|regex_match| regex_match.start())
.filter(|start| start.line == point.line)?;
// Position within the hint label.
let label_position = point.column.0 - start.column.0;
let is_first = label_position == 0;
// Hint label character.
self.labels[self.regex.index].get(label_position).copied().map(|c| (c, is_first))
}
}
impl<'a> From<&'a HintState> for Hint<'a> {
fn from(hint_state: &'a HintState) -> Self {
let regex = Regex { matches: Cow::Borrowed(hint_state.matches()), index: 0 };
Self { labels: hint_state.labels(), regex }
}
}
/// Wrapper for finding visible regex matches.
#[derive(Default, Clone)]
pub struct RegexMatches(Vec<RangeInclusive<Point>>);
impl RegexMatches {
/// Find all visible matches.
pub fn new<T>(term: &Term<T>, dfas: &RegexSearch) -> Self {
let viewport_end = term.grid().display_offset();
let viewport_start = viewport_end + term.screen_lines().0 - 1;
@ -383,12 +452,44 @@ impl RenderableSearch {
viewport_start..=viewport_end
});
Self { matches: iter.collect(), index: 0 }
Self(iter.collect())
}
}
impl Deref for RegexMatches {
type Target = Vec<RangeInclusive<Point>>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for RegexMatches {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
/// Visible regex match tracking.
#[derive(Default)]
struct Regex<'a> {
/// All visible matches.
matches: Cow<'a, RegexMatches>,
/// Index of the last match checked.
index: usize,
}
impl<'a> Regex<'a> {
/// Create a new renderable regex iterator.
fn new<T>(term: &Term<T>, dfas: &RegexSearch) -> Self {
let matches = Cow::Owned(RegexMatches::new(term, dfas));
Self { index: 0, matches }
}
/// Advance the search tracker to the next point.
/// Advance the regex tracker to the next point.
///
/// This will return `true` if the point passed is part of a search match.
/// This will return `true` if the point passed is part of a regex match.
fn advance(&mut self, point: Point) -> bool {
while let Some(regex_match) = self.matches.get(self.index) {
if regex_match.start() > &point {

View File

@ -0,0 +1,264 @@
use alacritty_terminal::term::Term;
use crate::config::ui_config::Hint;
use crate::daemon::start_daemon;
use crate::display::content::RegexMatches;
/// Percentage of characters in the hints alphabet used for the last character.
const HINT_SPLIT_PERCENTAGE: f32 = 0.5;
/// Keyboard regex hint state.
pub struct HintState {
/// Hint currently in use.
hint: Option<Hint>,
/// Alphabet for hint labels.
alphabet: String,
/// Visible matches.
matches: RegexMatches,
/// Key label for each visible match.
labels: Vec<Vec<char>>,
/// Keys pressed for hint selection.
keys: Vec<char>,
}
impl HintState {
/// Initialize an inactive hint state.
pub fn new<S: Into<String>>(alphabet: S) -> Self {
Self {
alphabet: alphabet.into(),
hint: Default::default(),
matches: Default::default(),
labels: Default::default(),
keys: Default::default(),
}
}
/// Check if a hint selection is in progress.
pub fn active(&self) -> bool {
self.hint.is_some()
}
/// Start the hint selection process.
pub fn start(&mut self, hint: Hint) {
self.hint = Some(hint);
}
/// Cancel the hint highlighting process.
fn stop(&mut self) {
self.matches.clear();
self.labels.clear();
self.keys.clear();
self.hint = None;
}
/// Update the visible hint matches and key labels.
pub fn update_matches<T>(&mut self, term: &Term<T>) {
let hint = match self.hint.as_mut() {
Some(hint) => hint,
None => return,
};
// Find visible matches.
self.matches = hint.regex.with_compiled(|regex| RegexMatches::new(term, regex));
// Cancel highlight with no visible matches.
if self.matches.is_empty() {
self.stop();
return;
}
let mut generator = HintLabels::new(&self.alphabet, HINT_SPLIT_PERCENTAGE);
let match_count = self.matches.len();
let keys_len = self.keys.len();
// Get the label for each match.
self.labels.resize(match_count, Vec::new());
for i in (0..match_count).rev() {
let mut label = generator.next();
if label.len() >= keys_len && label[..keys_len] == self.keys[..] {
self.labels[i] = label.split_off(keys_len);
} else {
self.labels[i] = Vec::new();
}
}
}
/// Handle keyboard input during hint selection.
pub fn keyboard_input<T>(&mut self, term: &Term<T>, c: char) {
match c {
// Use backspace to remove the last character pressed.
'\x08' | '\x1f' => {
self.keys.pop();
},
// Cancel hint highlighting on ESC.
'\x1b' => self.stop(),
_ => (),
}
// Update the visible matches.
self.update_matches(term);
let hint = match self.hint.as_ref() {
Some(hint) => hint,
None => return,
};
// Find the last label starting with the input character.
let mut labels = self.labels.iter().enumerate().rev();
let (index, label) = match labels.find(|(_, label)| !label.is_empty() && label[0] == c) {
Some(last) => last,
None => return,
};
// Check if the selected label is fully matched.
if label.len() == 1 {
// Get text for the hint's regex match.
let hint_match = &self.matches[index];
let start = term.visible_to_buffer(*hint_match.start());
let end = term.visible_to_buffer(*hint_match.end());
let text = term.bounds_to_string(start, 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();
} else {
// Store character to preserve the selection.
self.keys.push(c);
}
}
/// Hint key labels.
pub fn labels(&self) -> &Vec<Vec<char>> {
&self.labels
}
/// Visible hint regex matches.
pub fn matches(&self) -> &RegexMatches {
&self.matches
}
/// Update the alphabet used for hint labels.
pub fn update_alphabet(&mut self, alphabet: &str) {
if self.alphabet != alphabet {
self.alphabet = alphabet.to_owned();
self.keys.clear();
}
}
}
/// Generator for creating new hint labels.
struct HintLabels {
/// Full character set available.
alphabet: Vec<char>,
/// Alphabet indices for the next label.
indices: Vec<usize>,
/// Point separating the alphabet's head and tail characters.
///
/// To make identification of the tail character easy, part of the alphabet cannot be used for
/// any other position.
///
/// All characters in the alphabet before this index will be used for the last character, while
/// the rest will be used for everything else.
split_point: usize,
}
impl HintLabels {
/// Create a new label generator.
///
/// The `split_ratio` should be a number between 0.0 and 1.0 representing the percentage of
/// elements in the alphabet which are reserved for the tail of the hint label.
fn new(alphabet: impl Into<String>, split_ratio: f32) -> Self {
let alphabet: Vec<char> = alphabet.into().chars().collect();
let split_point = ((alphabet.len() - 1) as f32 * split_ratio.min(1.)) as usize;
Self { indices: vec![0], split_point, alphabet }
}
/// Get the characters for the next label.
fn next(&mut self) -> Vec<char> {
let characters = self.indices.iter().rev().map(|index| self.alphabet[*index]).collect();
self.increment();
characters
}
/// Increment the character sequence.
fn increment(&mut self) {
// Increment the last character; if it's not at the split point we're done.
let tail = &mut self.indices[0];
if *tail < self.split_point {
*tail += 1;
return;
}
*tail = 0;
// Increment all other characters in reverse order.
let alphabet_len = self.alphabet.len();
for index in self.indices.iter_mut().skip(1) {
if *index + 1 == alphabet_len {
// Reset character and move to the next if it's already at the limit.
*index = self.split_point + 1;
} else {
// If the character can be incremented, we're done.
*index += 1;
return;
}
}
// Extend the sequence with another character when nothing could be incremented.
self.indices.push(self.split_point + 1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hint_label_generation() {
let mut generator = HintLabels::new("0123", 0.5);
assert_eq!(generator.next(), vec!['0']);
assert_eq!(generator.next(), vec!['1']);
assert_eq!(generator.next(), vec!['2', '0']);
assert_eq!(generator.next(), vec!['2', '1']);
assert_eq!(generator.next(), vec!['3', '0']);
assert_eq!(generator.next(), vec!['3', '1']);
assert_eq!(generator.next(), vec!['2', '2', '0']);
assert_eq!(generator.next(), vec!['2', '2', '1']);
assert_eq!(generator.next(), vec!['2', '3', '0']);
assert_eq!(generator.next(), vec!['2', '3', '1']);
assert_eq!(generator.next(), vec!['3', '2', '0']);
assert_eq!(generator.next(), vec!['3', '2', '1']);
assert_eq!(generator.next(), vec!['3', '3', '0']);
assert_eq!(generator.next(), vec!['3', '3', '1']);
assert_eq!(generator.next(), vec!['2', '2', '2', '0']);
assert_eq!(generator.next(), vec!['2', '2', '2', '1']);
assert_eq!(generator.next(), vec!['2', '2', '3', '0']);
assert_eq!(generator.next(), vec!['2', '2', '3', '1']);
assert_eq!(generator.next(), vec!['2', '3', '2', '0']);
assert_eq!(generator.next(), vec!['2', '3', '2', '1']);
assert_eq!(generator.next(), vec!['2', '3', '3', '0']);
assert_eq!(generator.next(), vec!['2', '3', '3', '1']);
assert_eq!(generator.next(), vec!['3', '2', '2', '0']);
assert_eq!(generator.next(), vec!['3', '2', '2', '1']);
assert_eq!(generator.next(), vec!['3', '2', '3', '0']);
assert_eq!(generator.next(), vec!['3', '2', '3', '1']);
assert_eq!(generator.next(), vec!['3', '3', '2', '0']);
assert_eq!(generator.next(), vec!['3', '3', '2', '1']);
assert_eq!(generator.next(), vec!['3', '3', '3', '0']);
assert_eq!(generator.next(), vec!['3', '3', '3', '1']);
}
}

View File

@ -38,6 +38,7 @@ use crate::display::bell::VisualBell;
use crate::display::color::List;
use crate::display::content::RenderableContent;
use crate::display::cursor::IntoRects;
use crate::display::hint::HintState;
use crate::display::meter::Meter;
use crate::display::window::Window;
use crate::event::{Mouse, SearchState};
@ -48,6 +49,7 @@ use crate::url::{Url, Urls};
pub mod content;
pub mod cursor;
pub mod hint;
pub mod window;
mod bell;
@ -181,6 +183,9 @@ pub struct Display {
/// Mapped RGB values for each terminal color.
pub colors: List,
/// State of the keyboard hints.
pub hint_state: HintState,
renderer: QuadRenderer,
glyph_cache: GlyphCache,
meter: Meter,
@ -317,10 +322,13 @@ impl Display {
_ => (),
}
let hint_state = HintState::new(config.ui_config.hints.alphabet());
Ok(Self {
window,
renderer,
glyph_cache,
hint_state,
meter: Meter::new(),
size_info,
urls: Urls::new(),
@ -474,12 +482,10 @@ impl Display {
let viewport_match = search_state
.focused_match()
.and_then(|focused_match| terminal.grid().clamp_buffer_range_to_visible(focused_match));
let cursor_hidden = self.cursor_hidden || search_state.regex().is_some();
// Collect renderable content before the terminal is dropped.
let dfas = search_state.dfas();
let colors = &self.colors;
let mut content = RenderableContent::new(&terminal, dfas, config, colors, !cursor_hidden);
let search_dfas = search_state.dfas();
let mut content = RenderableContent::new(config, self, &terminal, search_dfas);
let mut grid_cells = Vec::new();
while let Some(cell) = content.next() {
grid_cells.push(cell);

View File

@ -42,9 +42,9 @@ use alacritty_terminal::tty;
use crate::cli::Options as CLIOptions;
use crate::clipboard::Clipboard;
use crate::config;
use crate::config::Config;
use crate::config::{self, Config};
use crate::daemon::start_daemon;
use crate::display::hint::HintState;
use crate::display::window::Window;
use crate::display::{Display, DisplayUpdate};
use crate::input::{self, ActionContext as _, FONT_SIZE_STEP};
@ -61,7 +61,7 @@ pub const TYPING_SEARCH_DELAY: Duration = Duration::from_millis(500);
const MAX_SEARCH_WHILE_TYPING: Option<usize> = Some(1000);
/// Maximum number of search terms stored in the history.
const MAX_HISTORY_SIZE: usize = 255;
const MAX_SEARCH_HISTORY_SIZE: usize = 255;
/// Events dispatched through the UI event loop.
#[derive(Debug, Clone)]
@ -117,10 +117,6 @@ pub struct SearchState {
}
impl SearchState {
fn new() -> Self {
Self::default()
}
/// Search regex text if a search is active.
pub fn regex(&self) -> Option<&String> {
self.history_index.and_then(|index| self.history.get(index))
@ -440,7 +436,7 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> for ActionCon
// Only create new history entry if the previous regex wasn't empty.
if self.search_state.history.get(0).map_or(true, |regex| !regex.is_empty()) {
self.search_state.history.push_front(String::new());
self.search_state.history.truncate(MAX_HISTORY_SIZE);
self.search_state.history.truncate(MAX_SEARCH_HISTORY_SIZE);
}
self.search_state.history_index = Some(0);
@ -660,6 +656,16 @@ impl<'a, N: Notify + 'a, T: EventListener> input::ActionContext<T> for ActionCon
}
}
fn hint_state(&mut self) -> &mut HintState {
&mut self.display.hint_state
}
/// Process a new character for keyboard hints.
fn hint_input(&mut self, c: char) {
self.display.hint_state.keyboard_input(self.terminal, c);
*self.dirty = true;
}
/// Toggle the vi mode status.
#[inline]
fn toggle_vi_mode(&mut self) {
@ -951,19 +957,19 @@ impl<N: Notify + OnResize> Processor<N> {
cli_options: CLIOptions,
) -> Processor<N> {
Processor {
notifier,
mouse: Default::default(),
received_count: 0,
suppress_chars: false,
modifiers: Default::default(),
font_size: config.ui_config.font.size(),
config,
message_buffer,
display,
event_queue: Vec::new(),
search_state: SearchState::new(),
cli_options,
dirty: false,
notifier,
display,
config,
received_count: Default::default(),
suppress_chars: Default::default(),
search_state: Default::default(),
event_queue: Default::default(),
modifiers: Default::default(),
mouse: Default::default(),
dirty: Default::default(),
}
}
@ -1381,6 +1387,9 @@ impl<N: Notify + OnResize> Processor<N> {
#[cfg(target_os = "macos")]
processor.ctx.window_mut().set_has_shadow(config.ui_config.background_opacity() >= 1.0);
// Update hint keys.
processor.ctx.display.hint_state.update_alphabet(config.ui_config.hints.alphabet());
*processor.ctx.config = config;
// Update cursor blinking.

View File

@ -10,8 +10,6 @@ use std::cmp::{max, min, Ordering};
use std::marker::PhantomData;
use std::time::{Duration, Instant};
use log::trace;
use glutin::dpi::PhysicalPosition;
use glutin::event::{
ElementState, KeyboardInput, ModifiersState, MouseButton, MouseScrollDelta, TouchPhase,
@ -31,8 +29,9 @@ use alacritty_terminal::term::{ClipboardType, SizeInfo, Term, TermMode};
use alacritty_terminal::vi_mode::ViMotion;
use crate::clipboard::Clipboard;
use crate::config::{Action, Binding, BindingMode, Config, Key, SearchAction, ViAction};
use crate::config::{Action, BindingMode, Config, Key, SearchAction, ViAction};
use crate::daemon::start_daemon;
use crate::display::hint::HintState;
use crate::display::window::Window;
use crate::event::{ClickState, Event, Mouse, TYPING_SEARCH_DELAY};
use crate::message_bar::{self, Message};
@ -112,18 +111,8 @@ pub trait ActionContext<T: EventListener> {
fn search_active(&self) -> bool;
fn on_typing_start(&mut self) {}
fn toggle_vi_mode(&mut self) {}
}
trait Execute<T: EventListener> {
fn execute<A: ActionContext<T>>(&self, ctx: &mut A);
}
impl<T, U: EventListener> Execute<U> for Binding<T> {
/// Execute the action associate with this binding.
#[inline]
fn execute<A: ActionContext<U>>(&self, ctx: &mut A) {
self.action.execute(ctx)
}
fn hint_state(&mut self) -> &mut HintState;
fn hint_input(&mut self, _character: char) {}
}
impl Action {
@ -142,41 +131,43 @@ impl Action {
}
}
trait Execute<T: EventListener> {
fn execute<A: ActionContext<T>>(&self, ctx: &mut A);
}
impl<T: EventListener> Execute<T> for Action {
#[inline]
fn execute<A: ActionContext<T>>(&self, ctx: &mut A) {
match *self {
Action::Esc(ref s) => {
match self {
Action::Esc(s) => {
ctx.on_typing_start();
ctx.clear_selection();
ctx.scroll(Scroll::Bottom);
ctx.write_to_pty(s.clone().into_bytes())
},
Action::Command(ref program) => {
let args = program.args();
let program = program.program();
trace!("Running command {} with args {:?}", program, args);
start_daemon(program, args);
Action::Command(program) => start_daemon(program.program(), program.args()),
Action::Hint(hint) => {
ctx.hint_state().start(hint.clone());
ctx.mark_dirty();
},
Action::ToggleViMode => ctx.toggle_vi_mode(),
Action::ViMotion(motion) => {
ctx.on_typing_start();
ctx.terminal_mut().vi_motion(motion);
ctx.terminal_mut().vi_motion(*motion);
ctx.mark_dirty();
},
Action::ViAction(ViAction::ToggleNormalSelection) => {
Self::toggle_selection(ctx, SelectionType::Simple)
Self::toggle_selection(ctx, SelectionType::Simple);
},
Action::ViAction(ViAction::ToggleLineSelection) => {
Self::toggle_selection(ctx, SelectionType::Lines)
Self::toggle_selection(ctx, SelectionType::Lines);
},
Action::ViAction(ViAction::ToggleBlockSelection) => {
Self::toggle_selection(ctx, SelectionType::Block)
Self::toggle_selection(ctx, SelectionType::Block);
},
Action::ViAction(ViAction::ToggleSemanticSelection) => {
Self::toggle_selection(ctx, SelectionType::Semantic)
Self::toggle_selection(ctx, SelectionType::Semantic);
},
Action::ViAction(ViAction::Open) => {
ctx.mouse_mut().block_url_launcher = false;
@ -840,6 +831,12 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
/// Process key input.
pub fn key_input(&mut self, input: KeyboardInput) {
// All key bindings are disabled while a hint is being selected.
if self.ctx.hint_state().active() {
*self.ctx.suppress_chars() = false;
return;
}
// Reset search delay when the user is still typing.
if self.ctx.search_active() {
if let Some(timer) = self.ctx.scheduler_mut().get_mut(TimerId::DelayedSearch) {
@ -876,8 +873,16 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
/// Process a received character.
pub fn received_char(&mut self, c: char) {
let suppress_chars = *self.ctx.suppress_chars();
// Handle hint selection over anything else.
if self.ctx.hint_state().active() && !suppress_chars {
self.ctx.hint_input(c);
return;
}
// Pass keys to search and ignore them during `suppress_chars`.
let search_active = self.ctx.search_active();
if suppress_chars || self.ctx.terminal().mode().contains(TermMode::VI) || search_active {
if suppress_chars || search_active || self.ctx.terminal().mode().contains(TermMode::VI) {
if search_active && !suppress_chars {
self.ctx.search_input(c);
}
@ -929,12 +934,11 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
};
if binding.is_triggered_by(mode, mods, &key) {
// Binding was triggered; run the action.
let binding = binding.clone();
binding.execute(&mut self.ctx);
// Pass through the key if any of the bindings has the `ReceiveChar` action.
*suppress_chars.get_or_insert(true) &= binding.action != Action::ReceiveChar;
// Binding was triggered; run the action.
binding.action.clone().execute(&mut self.ctx);
}
}
@ -960,7 +964,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
}
if binding.is_triggered_by(mode, mods, &button) {
binding.execute(&mut self.ctx);
binding.action.execute(&mut self.ctx);
}
}
}
@ -1092,6 +1096,7 @@ mod tests {
use alacritty_terminal::event::Event as TerminalEvent;
use alacritty_terminal::selection::Selection;
use crate::config::Binding;
use crate::message_bar::MessageBuffer;
const KEY: VirtualKeyCode = VirtualKeyCode::Key0;
@ -1226,6 +1231,10 @@ mod tests {
fn scheduler_mut(&mut self) -> &mut Scheduler {
unimplemented!();
}
fn hint_state(&mut self) -> &mut HintState {
unimplemented!();
}
}
macro_rules! test_clickstate {

View File

@ -15,6 +15,7 @@ const BRACKET_PAIRS: [(char, char); 4] = [('(', ')'), ('[', ']'), ('{', '}'), ('
pub type Match = RangeInclusive<Point<usize>>;
/// Terminal regex search state.
#[derive(Clone, Debug)]
pub struct RegexSearch {
/// Locate end of match searching right.
right_fdfa: DenseDFA<Vec<usize>, usize>,

View File

@ -54,6 +54,15 @@ you can still jump between matches using <kbd>Enter</kbd> and <kbd>Shift</kbd>
<kbd>Enter</kbd>. After leaving search with <kbd>Escape</kbd> your active match
stays selected, allowing you to easily copy it.
## Hints
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
feeds them to an external application.
Hints can be configured in the `hints` and `colors.hints` sections in the
Alacritty configuration file.
## Selection expansion
After making a selection, you can use the right mouse button to expand it.