Repository: wezterm/wezterm (language: rust)

Issue:
Additions to the InputSelector
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is.

I am trying to use the InputSelector more for choosing from longer lists and would like a few more options.


**Describe the solution you'd like**
A clear and concise description of what you want to happen.

My primary suggestion is to add a longer alphabet to choose from with one click when opening the InputSelector. 

Right now it only shows numbers [1-9] for the first 9 elements of the list, but it would be nice to show [1-9] for the first 9 elements and then [a-z] for the next 26 elements and possibly [A-Z] beyond that for anyone using small enough fonts to see that many elements. 

Alternatively, one could choose the exact labels themselves, but this would probably require slightly more work, and I assume my first suggestion would be easier to implement.

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

I have also thought about jumping straight to the filter part of the InputSelector to avoid picking out elements like above. It would be nice if there was a flag "start_with_filter" (or something like that) for the InputSelector that is "false" if you want to start the InputSelector as it does now and "true" if it essentially does what would be equivalent to starting the InputSelector and then pressing "/" now. 

Finally it would be nice if we were able to edit the text that currently says:
"Select an item and press Enter=accept Esc=cancel /=filter"

**Additional context**
Add any other context or screenshots about the feature request here.

Here is a picture where what I would like to add is "a. " in front of the line with .pylint.d, "b. " in front of the line with .ipython, and so on. 

<img width="481" alt="Screenshot 2023-08-30 at 17 22 09" src="https://github.com/wez/wezterm/assets/7075380/5c7dccee-15e2-4959-ba7d-7d0707c3e86c">

If I am pointed to the code that would need changing to implement this, I can try to take a look at it myself, but I don't really have any working experience with rust. Though I assume the above would hopefully be relatively simple?


Discussion / hints:
These sound like reasonable requests.

If you feel like taking a crack at coding some of this, then I'd suggest starting with the alphabet, as that doesn't require changing any of the other config related structures to get it going.

It should be possible to re-use the logic from quick select, which is encapsulated in this function:
https://github.com/wez/wezterm/blob/99c9613966a73d5388344fd0df68d5977b493c7c/wezterm-gui/src/overlay/quickselect.rs#L59

You pass in the alphabet (eg: "1234567890abcdefghijklmnopqrstuvwxyz") and the number of entries in the list (or rather, the number of lines that can seen on the screen) and it will return a list of corresponding strings that represent each entry in the list.

Then the logic over here in the selector that matches a label by number:
https://github.com/wez/wezterm/blob/99c9613966a73d5388344fd0df68d5977b493c7c/wezterm-gui/src/overlay/selector.rs#L186-L190

would need to be modified in a way that is similar to the quickselect logic for matching against the labels and resolving the index in the list:
https://github.com/wez/wezterm/blob/99c9613966a73d5388344fd0df68d5977b493c7c/wezterm-gui/src/overlay/quickselect.rs#L367-L377

this particular code is accumulating the keypress into a string, then looking up that string in the list of labels to see if there is a match.

I'd be happy to give more feedback on a PR if you want to iterate that way!

Why use `compute_labels_for_alphabet` instead of expanding and hard coding the existing logic? The reason is that it allows the user to specify their preferred alphabet. They may want to use alpha symbols from the home row in their preferred keyboard layout, for example.

Fix patch:
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) | <kbd>1</kbd> to <kbd>9</kbd> {{since('20230408-112425-69ae8472', inline=True)}} |
+| Start fuzzy search (if in the default mode) | <kbd>/</kbd> |
+| Add to filtering string (if in fuzzy finding mode) | Any key not listed below |
+| Remove from selection or filtering string | <kbd>Backspace</kbd> |
+| Pick currently highlighted line | <kbd>Enter</kbd> |
+|                                 | <kbd>LeftClick</kbd> (with mouse) |
+| Move Down      | <kbd>DownArrow</kbd> |
+|                | <kbd>Ctrl</kbd> + <kbd>N</kbd> |
+|                | <kbd>Ctrl</kbd> + <kbd>J</kbd> {{since('nightly', inline=True)}} |
+|                | <kbd>j</kbd> (if not in `alphabet`) |
+| Move Up        | <kbd>UpArrow</kbd>  |
+|                | <kbd>Ctrl</kbd> + <kbd>P</kbd> |
+|                | <kbd>Ctrl</kbd> + <kbd>K</kbd> {{since('nightly', inline=True)}} |
+|                | <kbd>k</kbd>  (if not in `alphabet`)   |
+| Quit     | <kbd>Ctrl</kbd> + <kbd>G</kbd> |
+|          | <kbd>Ctrl</kbd> + <kbd>C</kbd> {{since('nightly', inline=True)}} |
+|          | <kbd>Escape</kbd> |
+
+Note: If the InputSelector is started with `fuzzy` set to `false`, then <kbd>Backspace</kbd> 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<String> {
-    let alphabet = alphabet
-        .chars()
-        .map(|c| c.to_lowercase().to_string())
-        .collect::<Vec<String>>();
+    compute_labels_for_alphabet_impl(alphabet, num_matches, true)
+}
+
+pub fn compute_labels_for_alphabet_with_preserved_case(
+    alphabet: &str,
+    num_matches: usize,
+) -> Vec<String> {
+    compute_labels_for_alphabet_impl(alphabet, num_matches, false)
+}
+
+fn compute_labels_for_alphabet_impl(
+    alphabet: &str,
+    num_matches: usize,
+    make_lowercase: bool,
+) -> Vec<String> {
+    let alphabet = if make_lowercase {
+        alphabet
+            .chars()
+            .map(|c| c.to_lowercase().to_string())
+            .collect::<Vec<String>>()
+    } else {
+        alphabet
+            .chars()
+            .map(|c| c.to_string())
+            .collect::<Vec<String>>()
+    };
     // 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<String>,
 }
 
 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()?;


Test patch: