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()?;