From 525080be890f7af110f76c73fc94e95ecf18222b Mon Sep 17 00:00:00 2001 From: Danielkonge Date: Fri, 22 Sep 2023 15:42:43 +0200 Subject: [PATCH] Additions to the InputSelector (resolving #4226) (#4227) * First step towards implementing #4226. * Added InputSelector config options. * Format + fix to prepare for PR to resolve #4226. * Accept suggestion about pop(). * Fix typo. * Tried fixing stuff from the feedback. * Fixed typo. * Fixed small mistake in docs. * Small fix for label computation. * Allow uppercase alphabet + add fuzzy_description. * Minor cleanup. * Use more standard alphabet (without j/k). * Fixed docs after previous commit. * Added key assignments to docs. * Apply suggestions from code review (2 remaining) * Updated arcording to feedback (added tests). * Update docs (1 thing left to do) * Added version details to the key table. --------- Co-authored-by: Wez Furlong --- config/src/keyassignment.rs | 21 ++++ .../config/lua/keyassignment/InputSelector.md | 112 +++++++++++++++++- wezterm-gui/src/overlay/quickselect.rs | 54 ++++++++- wezterm-gui/src/overlay/selector.rs | 78 +++++++----- 4 files changed, 232 insertions(+), 33 deletions(-) diff --git a/config/src/keyassignment.rs b/config/src/keyassignment.rs index 3d656479559..034920e571a 100644 --- a/config/src/keyassignment.rs +++ b/config/src/keyassignment.rs @@ -477,6 +477,27 @@ pub struct InputSelector { #[dynamic(default)] pub fuzzy: bool, + + #[dynamic(default = "default_num_alphabet")] + pub alphabet: String, + + #[dynamic(default = "default_description")] + pub description: String, + + #[dynamic(default = "default_fuzzy_description")] + pub fuzzy_description: String, +} + +fn default_num_alphabet() -> String { + "1234567890abcdefghilmnopqrstuvwxyz".to_string() +} + +fn default_description() -> String { + "Select an item and press Enter = accept, Esc = cancel, / = filter".to_string() +} + +fn default_fuzzy_description() -> String { + "Fuzzy matching: ".to_string() } #[derive(Debug, Clone, PartialEq, FromDynamic, ToDynamic)] diff --git a/docs/config/lua/keyassignment/InputSelector.md b/docs/config/lua/keyassignment/InputSelector.md index 3576b04cff5..c5a77782be6 100644 --- a/docs/config/lua/keyassignment/InputSelector.md +++ b/docs/config/lua/keyassignment/InputSelector.md @@ -8,7 +8,7 @@ to select from. When the user accepts a line, emits an event that allows you to act upon the input. -`InputSelector` accepts three fields: +`InputSelector` accepts the following fields: * `title` - the title that will be set for the overlay pane * `choices` - a lua table consisting of the potential choices. Each entry @@ -22,6 +22,51 @@ upon the input. objects from the current pane and window, and `id` and `label` hold the corresponding fields from the selected choice. Both will be `nil` if the overlay is cancelled without selecting anything. +* `fuzzy` - a boolean that defaults to `false`. If `true`, InputSelector will start + in its fuzzy finding mode (this is equivalent to starting the InputSelector and + pressing / in the default mode). + +{{since('nightly')}} + +These additional fields are also available: + +* `alphabet` - a string of unique characters. The characters in the string are used + to calculate one or two click shortcuts that can be used to quickly choose from + the InputSelector when in the default mode. Defaults to: + `"1234567890abcdefghilmnopqrstuvwxyz"`. (Without j/k so they can be used for movement + up and down.) +* `description` - a string to display when in the default mode. Defaults to: + `"Select an item and press Enter = accept, Esc = cancel, / = filter"`. +* `fuzzy_description` - a string to display when in fuzzy finding mode. Defaults to: + `"Fuzzy matching: "`. + + +### Key Assignments + +The default key assignments in the InputSelector are as follows: + +| Action | Key Assignment | +|---------|-------------------| +| Add to selection string until a match is found (if in the default mode) | Any key in `alphabet` {{since('nightly', inline=True)}} | +| Select matching number (if in the default mode) | 1 to 9 {{since('20230408-112425-69ae8472', inline=True)}} | +| Start fuzzy search (if in the default mode) | / | +| Add to filtering string (if in fuzzy finding mode) | Any key not listed below | +| Remove from selection or filtering string | Backspace | +| Pick currently highlighted line | Enter | +| | LeftClick (with mouse) | +| Move Down | DownArrow | +| | Ctrl + N | +| | Ctrl + J {{since('nightly', inline=True)}} | +| | j (if not in `alphabet`) | +| Move Up | UpArrow | +| | Ctrl + P | +| | Ctrl + K {{since('nightly', inline=True)}} | +| | k (if not in `alphabet`) | +| Quit | Ctrl + G | +| | Ctrl + C {{since('nightly', inline=True)}} | +| | Escape | + +Note: If the InputSelector is started with `fuzzy` set to `false`, then Backspace can go from fuzzy finding mode back to the default mode when pressed while the filtering string is empty. ## Example of choosing some canned text to enter into the terminal @@ -95,7 +140,7 @@ config.keys = { -- could read or compute data from other sources local choices = {} - for n = 1, 10 do + for n = 1, 20 do table.insert(choices, { label = tostring(n) }) end @@ -113,6 +158,8 @@ config.keys = { end), title = 'I am title', choices = choices, + alphabet = '123456789', + description = 'Write the number you want to choose or press / to search.', }, pane ) @@ -123,5 +170,66 @@ config.keys = { return config ``` +## Example of switching between a list of workspaces with the InputSelector + +```lua +local wezterm = require 'wezterm' +local act = wezterm.action +local config = wezterm.config_builder() + +config.keys = { + { + key = 'S', + mods = 'CTRL|SHIFT', + action = wezterm.action_callback(function(window, pane) + -- Here you can dynamically construct a longer list if needed + + local home = wezterm.home_dir + local workspaces = { + { id = home, label = 'Home' }, + { id = home .. '/work', label = 'Work' }, + { id = home .. '/personal', label = 'Personal' }, + { id = home .. '/.config', label = 'Config' }, + } + + window:perform_action( + act.InputSelector { + action = wezterm.action_callback( + function(inner_window, inner_pane, id, label) + if not id and not label then + wezterm.log_info 'cancelled' + else + wezterm.log_info('id = ' .. id) + wezterm.log_info('label = ' .. label) + inner_window:perform_action( + act.SwitchToWorkspace { + name = label, + spawn = { + label = 'Workspace: ' .. label, + cwd = id, + }, + }, + inner_pane + ) + end + end + ), + title = 'Choose Workspace', + choices = workspaces, + fuzzy = true, + fuzzy_description = 'Fuzzy find and/or make a workspace', + }, + pane + ) + end), + }, +} + +return config +``` + + + + See also [PromptInputLine](PromptInputLine.md). diff --git a/wezterm-gui/src/overlay/quickselect.rs b/wezterm-gui/src/overlay/quickselect.rs index 208fd9b1195..fc3d16b48aa 100644 --- a/wezterm-gui/src/overlay/quickselect.rs +++ b/wezterm-gui/src/overlay/quickselect.rs @@ -57,10 +57,32 @@ const PATTERNS: [&str; 14] = [ /// It is derived from https://github.com/fcsonline/tmux-thumbs/blob/master/src/alphabets.rs /// which is Copyright (c) 2019 Ferran Basora and provided under the MIT license pub fn compute_labels_for_alphabet(alphabet: &str, num_matches: usize) -> Vec { - let alphabet = alphabet - .chars() - .map(|c| c.to_lowercase().to_string()) - .collect::>(); + compute_labels_for_alphabet_impl(alphabet, num_matches, true) +} + +pub fn compute_labels_for_alphabet_with_preserved_case( + alphabet: &str, + num_matches: usize, +) -> Vec { + compute_labels_for_alphabet_impl(alphabet, num_matches, false) +} + +fn compute_labels_for_alphabet_impl( + alphabet: &str, + num_matches: usize, + make_lowercase: bool, +) -> Vec { + let alphabet = if make_lowercase { + alphabet + .chars() + .map(|c| c.to_lowercase().to_string()) + .collect::>() + } else { + alphabet + .chars() + .map(|c| c.to_string()) + .collect::>() + }; // Prefer to use single character matches to represent everything let mut primary = alphabet.clone(); let mut secondary = vec![]; @@ -144,6 +166,30 @@ mod alphabet_test { vec!["aa", "ab", "ba", "bb"] ); } + + #[test] + fn composed_capital() { + assert_eq!( + compute_labels_for_alphabet_with_preserved_case("AB", 4), + vec!["AA", "AB", "BA", "BB"] + ); + } + + #[test] + fn composed_mixed() { + assert_eq!( + compute_labels_for_alphabet_with_preserved_case("aA", 4), + vec!["aa", "aA", "Aa", "AA"] + ); + } + + #[test] + fn lowercase_alphabet_equal() { + assert_eq!( + compute_labels_for_alphabet_with_preserved_case("abc123", 12), + compute_labels_for_alphabet("abc123", 12) + ); + } } pub struct QuickSelectOverlay { diff --git a/wezterm-gui/src/overlay/selector.rs b/wezterm-gui/src/overlay/selector.rs index 51903a410f5..d2bb0f46c18 100644 --- a/wezterm-gui/src/overlay/selector.rs +++ b/wezterm-gui/src/overlay/selector.rs @@ -1,3 +1,4 @@ +use super::quickselect; use crate::scripting::guiwin::GuiWin; use config::keyassignment::{InputSelector, InputSelectorEntry, KeyAssignment}; use fuzzy_matcher::skim::SkimMatcherV2; @@ -26,6 +27,8 @@ struct SelectorState { always_fuzzy: bool, args: InputSelector, event_name: String, + selection: String, + labels: Vec, } impl SelectorState { @@ -69,6 +72,14 @@ impl SelectorState { fn render(&mut self, term: &mut TermWizTerminal) -> termwiz::Result<()> { let size = term.get_screen_size()?; let max_width = size.cols.saturating_sub(6); + let max_items = size.rows.saturating_sub(ROW_OVERHEAD); + if max_items != self.max_items { + self.labels = quickselect::compute_labels_for_alphabet_with_preserved_case( + &self.args.alphabet, + self.filtered_entries.len().min(max_items + 1), + ); + self.max_items = max_items; + } let mut changes = vec![ Change::ClearScreen(ColorAttribute::Default), @@ -78,16 +89,14 @@ impl SelectorState { }, Change::Text(format!( "{}\r\n", - truncate_right( - "Select an item and press Enter=accept \ - Esc=cancel /=filter", - max_width - ) + truncate_right(&self.args.description, max_width) )), Change::AllAttributes(CellAttributes::default()), ]; - let max_items = self.max_items; + let labels = &self.labels; + let max_label_len = labels.iter().map(|s| s.len()).max().unwrap_or(0); + let mut labels_iter = labels.into_iter(); for (row_num, (entry_idx, entry)) in self .filtered_entries @@ -107,8 +116,15 @@ impl SelectorState { attr.set_reverse(true); } - if row_num < 9 && !self.filtering { - changes.push(Change::Text(format!(" {}. ", row_num + 1))); + // from above we know that row_num <= max_items + // show labels as long as we have more labels left + // and we are not filtering + if !self.filtering { + if let Some(label) = labels_iter.next() { + changes.push(Change::Text(format!(" {label:>max_label_len$}. "))); + } else { + changes.push(Change::Text(" ".repeat(max_label_len + 3))); + } } else { changes.push(Change::Text(" ".to_string())); } @@ -133,7 +149,7 @@ impl SelectorState { }, Change::ClearToEndOfLine(ColorAttribute::Default), Change::Text(truncate_right( - &format!("Fuzzy matching: {}", self.filter_term), + &format!("{}{}", self.args.fuzzy_description, self.filter_term), max_width, )), ]); @@ -182,32 +198,39 @@ impl SelectorState { match event { InputEvent::Key(KeyEvent { key: KeyCode::Char(c), - .. - }) if !self.filtering && c >= '1' && c <= '9' => { - if self.launch(self.top_row + (c as u32 - '1' as u32) as usize) { - break; + modifiers: Modifiers::NONE, + }) if !self.filtering && self.args.alphabet.contains(c) => { + self.selection.push(c); + if let Some(pos) = self.labels.iter().position(|x| *x == self.selection) { + // since the number of labels is always <= self.max_items + // by construction, we have pos as usize <= self.max_items + // for free + self.active_idx = self.top_row + pos as usize; + if self.launch(self.active_idx) { + break; + } } } InputEvent::Key(KeyEvent { key: KeyCode::Char('j'), .. - }) if !self.filtering => { + }) if !self.filtering && !self.args.alphabet.contains("j") => { self.move_down(); } InputEvent::Key(KeyEvent { key: KeyCode::Char('k'), .. - }) if !self.filtering => { + }) if !self.filtering && !self.args.alphabet.contains("k") => { self.move_up(); } InputEvent::Key(KeyEvent { - key: KeyCode::Char('P'), + key: KeyCode::Char('P' | 'K'), modifiers: Modifiers::CTRL, }) => { self.move_up(); } InputEvent::Key(KeyEvent { - key: KeyCode::Char('N'), + key: KeyCode::Char('N' | 'J'), modifiers: Modifiers::CTRL, }) => { self.move_down(); @@ -222,13 +245,17 @@ impl SelectorState { key: KeyCode::Backspace, .. }) => { - if self.filter_term.pop().is_none() && !self.always_fuzzy { - self.filtering = false; + if !self.filtering { + self.selection.pop(); + } else { + if self.filter_term.pop().is_none() && !self.always_fuzzy { + self.filtering = false; + } + self.update_filter(); } - self.update_filter(); } InputEvent::Key(KeyEvent { - key: KeyCode::Char('G'), + key: KeyCode::Char('G' | 'C'), modifiers: Modifiers::CTRL, }) | InputEvent::Key(KeyEvent { @@ -301,9 +328,6 @@ impl SelectorState { break; } } - InputEvent::Resized { rows, .. } => { - self.max_items = rows.saturating_sub(ROW_OVERHEAD); - } _ => {} } self.render(term)?; @@ -354,11 +378,9 @@ pub fn selector( anyhow::bail!("InputSelector requires action to be defined by wezterm.action_callback") } }; - let size = term.get_screen_size()?; - let max_items = size.rows.saturating_sub(ROW_OVERHEAD); let mut state = SelectorState { active_idx: 0, - max_items, + max_items: 0, pane, top_row: 0, filter_term: String::new(), @@ -368,6 +390,8 @@ pub fn selector( always_fuzzy: args.fuzzy, args, event_name, + selection: String::new(), + labels: vec![], }; term.set_raw_mode()?;