1
1
mirror of https://github.com/wez/wezterm.git synced 2024-11-22 22:42:48 +03:00

Add InputSelector action

Allows prompting the user to select from a list and then
triggering some action on the selected item.

This is the guts of the launcher menu hooked up to user-supplied
arbitrary entries.
This commit is contained in:
Wez Furlong 2023-04-05 17:20:51 -07:00
parent 153497d01d
commit e3e9821c4d
No known key found for this signature in database
GPG Key ID: 7A7F66A31EC9B387
8 changed files with 564 additions and 1 deletions

View File

@ -453,6 +453,24 @@ pub struct PromptInputLine {
pub description: String,
}
#[derive(Debug, Clone, PartialEq, FromDynamic, ToDynamic)]
pub struct InputSelectorEntry {
pub label: String,
pub id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, FromDynamic, ToDynamic)]
pub struct InputSelector {
pub action: Box<KeyAssignment>,
#[dynamic(default)]
pub title: String,
pub choices: Vec<InputSelectorEntry>,
#[dynamic(default)]
pub fuzzy: bool,
}
#[derive(Debug, Clone, PartialEq, FromDynamic, ToDynamic)]
pub enum KeyAssignment {
SpawnTab(SpawnTabDomain),
@ -559,6 +577,7 @@ pub enum KeyAssignment {
ActivateWindowRelative(isize),
ActivateWindowRelativeNoWrap(isize),
PromptInputLine(PromptInputLine),
InputSelector(InputSelector),
}
impl_lua_conversion_dynamic!(KeyAssignment);

View File

@ -42,6 +42,9 @@ As features stabilize some brief notes about them will accumulate here.
* [PromptInputLine](config/lua/keyassignment/PromptInputLine.md) action for
prompting the user for a line of text and then doing something with it.
Can be used to prompt for (re)naming new or existing tabs, workspaces and so on.
* [InputSelector](config/lua/keyassignment/InputSelector.md) action for
prompting the user to select an item from a list and then doing something
with it.
* [pane:activate()](config/lua/pane/activate.md) and [tab:activate()](config/lua/MuxTab/activate.md). #3217
* [ulimit_nofile](config/lua/config/ulimit_nofile.md) and [ulimint_nproc](config/lua/config/ulimit_nproc.md) options. ?3353
* [serial_ports](config/lua/config/serial_ports.md) for more convenient access to serial ports

View File

@ -0,0 +1,127 @@
# `InputSelector`
{{since('nightly')}}
Activates an overlay to display a list of choices for the user
to select from.
When the user accepts a line, emits an event that allows you to act
upon the input.
`InputSelector` accepts three fields:
* `title` - the title that will be set for the overlay pane
* `choices` - a lua table consisting of the potential choices. Each entry
is itself a table with a `label` field and an optional `id` field.
The label will be shown in the list, while the id can be a different
string that is meaningful to your action. The label can be used together
with [wezterm.format](../wezterm/format.md) to produce styled test.
* `action` - and event callback registerd via `wezterm.action_callback`. The
callback's function signature is `(window, pane, id, label)` where `window` and
`pane` are the [Window](../window/index.md) and [Pane](../pane/index.md)
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.
## Example of choosing some canned text to enter into the terminal
```lua
local wezterm = require 'wezterm'
local act = wezterm.action
local config = wezterm.config_builder()
config.keys = {
{
key = 'E',
mods = 'CTRL|SHIFT',
action = act.InputSelector {
action = wezterm.action_callback(function(window, pane, id, label)
if not id and not label then
wezterm.log_info 'cancelled'
else
wezterm.log_info('you selected ', id, label)
pane:send_text(id)
end
end),
title = 'I am title',
choices = {
-- This is the first entry
{
-- Here we're using wezterm.format to color the text.
-- You can just use a string directly if you don't want
-- to control the colors
label = wezterm.format {
{ Foreground = { AnsiColor = 'Red' } },
{ Text = 'No' },
{ Foreground = { AnsiColor = 'Green' } },
{ Text = ' thanks' },
},
-- This is the text that we'll send to the terminal when
-- this entry is selected
id = 'Regretfully, I decline this offer.',
},
-- This is the second entry
{
label = 'WTF?',
id = 'An interesting idea, but I have some questions about it.',
},
-- This is the third entry
{
label = 'LGTM',
id = 'This sounds like the right choice',
},
},
},
},
}
return config
```
## Example of dynamically constructing a list
```lua
local wezterm = require 'wezterm'
local act = wezterm.action
local config = wezterm.config_builder()
config.keys = {
{
key = 'R',
mods = 'CTRL|SHIFT',
action = wezterm.action_callback(function(window, pane)
-- We're going to dynamically construct the list and then
-- show it. Here we're just showing some numbers but you
-- could read or compute data from other sources
local choices = {}
for n = 1, 10 do
table.insert(choices, { label = tostring(n) })
end
window:perform_action(
act.InputSelector {
action = wezterm.action_callback(function(window, pane, id, label)
if not id and not label then
wezterm.log_info 'cancelled'
else
wezterm.log_info('you selected ', id, label)
-- Since we didn't set an id in this example, we're
-- sending the label
pane:send_text(label)
end
end),
title = 'I am title',
choices = choices,
},
pane
)
end),
},
}
return config
```
See also [PromptInputLine](PromptInputLine.md).

View File

@ -5,7 +5,7 @@
Activates an overlay to display a prompt and request a line of input
from the user.
When the user enters the line, emits an event and allows you to act
When the user enters the line, emits an event that allows you to act
upon the input.
`PromptInputLine` accepts two fields:
@ -88,3 +88,5 @@ config.keys = {
return config
```
See also [InputSelector](InputSelector.md).

View File

@ -736,6 +736,14 @@ pub fn derive_command_from_key_assignment(action: &KeyAssignment) -> Option<Comm
menubar: &["Help"],
icon: Some("cod_debug"),
},
InputSelector(_) => CommandDef {
brief: "Prompt the user to choose from a list".into(),
doc: "Activates the selector overlay and wait for input".into(),
keys: vec![],
args: &[ArgType::ActiveWindow],
menubar: &[],
icon: None,
},
PromptInputLine(_) => CommandDef {
brief: "Prompt the user for a line of text".into(),
doc: "Activates the prompt overlay and wait for input".into(),

View File

@ -12,6 +12,7 @@ pub mod debug;
pub mod launcher;
pub mod prompt;
pub mod quickselect;
pub mod selector;
pub use confirm_close_pane::{
confirm_close_pane, confirm_close_tab, confirm_close_window, confirm_quit_program,

View File

@ -0,0 +1,378 @@
use crate::scripting::guiwin::GuiWin;
use config::keyassignment::{InputSelector, InputSelectorEntry, KeyAssignment};
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use mux::termwiztermtab::TermWizTerminal;
use mux_lua::MuxPane;
use std::rc::Rc;
use termwiz::cell::{AttributeChange, CellAttributes};
use termwiz::color::ColorAttribute;
use termwiz::input::{InputEvent, KeyCode, KeyEvent, Modifiers, MouseButtons, MouseEvent};
use termwiz::surface::{Change, Position};
use termwiz::terminal::Terminal;
use termwiz_funcs::truncate_right;
const ROW_OVERHEAD: usize = 3;
struct SelectorState {
active_idx: usize,
max_items: usize,
top_row: usize,
filter_term: String,
filtered_entries: Vec<InputSelectorEntry>,
pane: MuxPane,
window: GuiWin,
filtering: bool,
always_fuzzy: bool,
args: InputSelector,
event_name: String,
}
impl SelectorState {
fn update_filter(&mut self) {
if self.filter_term.is_empty() {
self.filtered_entries = self.args.choices.clone();
return;
}
self.filtered_entries.clear();
let matcher = SkimMatcherV2::default();
struct MatchResult {
row_idx: usize,
score: i64,
}
let mut scores: Vec<MatchResult> = self
.args
.choices
.iter()
.enumerate()
.filter_map(|(row_idx, entry)| {
let score = matcher.fuzzy_match(&entry.label, &self.filter_term)?;
Some(MatchResult { row_idx, score })
})
.collect();
scores.sort_by(|a, b| a.score.cmp(&b.score).reverse());
for result in scores {
self.filtered_entries
.push(self.args.choices[result.row_idx].clone());
}
self.active_idx = 0;
self.top_row = 0;
}
fn render(&mut self, term: &mut TermWizTerminal) -> termwiz::Result<()> {
let size = term.get_screen_size()?;
let max_width = size.cols.saturating_sub(6);
let mut changes = vec![
Change::ClearScreen(ColorAttribute::Default),
Change::CursorPosition {
x: Position::Absolute(0),
y: Position::Absolute(0),
},
Change::Text(format!(
"{}\r\n",
truncate_right(
"Select an item and press Enter=accept \
Esc=cancel /=filter",
max_width
)
)),
Change::AllAttributes(CellAttributes::default()),
];
let max_items = self.max_items;
for (row_num, (entry_idx, entry)) in self
.filtered_entries
.iter()
.enumerate()
.skip(self.top_row)
.enumerate()
{
if row_num > max_items {
break;
}
let mut attr = CellAttributes::blank();
if entry_idx == self.active_idx {
changes.push(AttributeChange::Reverse(true).into());
attr.set_reverse(true);
}
if row_num < 9 && !self.filtering {
changes.push(Change::Text(format!(" {}. ", row_num + 1)));
} else {
changes.push(Change::Text(" ".to_string()));
}
let mut line = crate::tabbar::parse_status_text(&entry.label, attr.clone());
if line.len() > max_width {
line.resize(max_width, termwiz::surface::SEQ_ZERO);
}
changes.append(&mut line.changes(&attr));
if entry_idx == self.active_idx {
changes.push(AttributeChange::Reverse(false).into());
}
changes.push(Change::AllAttributes(CellAttributes::default()));
changes.push(Change::Text(" \r\n".to_string()));
}
if self.filtering || !self.filter_term.is_empty() {
changes.append(&mut vec![
Change::CursorPosition {
x: Position::Absolute(0),
y: Position::Absolute(0),
},
Change::ClearToEndOfLine(ColorAttribute::Default),
Change::Text(truncate_right(
&format!("Fuzzy matching: {}", self.filter_term),
max_width,
)),
]);
}
term.render(&changes)
}
fn trigger_event(&self, entry: Option<InputSelectorEntry>) {
let name = self.event_name.clone();
let window = self.window.clone();
let pane = self.pane.clone();
promise::spawn::spawn_into_main_thread(async move {
trampoline(name, window, pane, entry);
anyhow::Result::<()>::Ok(())
})
.detach();
}
fn launch(&self, active_idx: usize) -> bool {
if let Some(entry) = self.filtered_entries.get(active_idx).cloned() {
self.trigger_event(Some(entry));
true
} else {
false
}
}
fn move_up(&mut self) {
self.active_idx = self.active_idx.saturating_sub(1);
if self.active_idx < self.top_row {
self.top_row = self.active_idx;
}
}
fn move_down(&mut self) {
self.active_idx = (self.active_idx + 1).min(self.filtered_entries.len() - 1);
if self.active_idx + self.top_row > self.max_items {
self.top_row = self.active_idx.saturating_sub(self.max_items);
}
}
fn run_loop(&mut self, term: &mut TermWizTerminal) -> anyhow::Result<()> {
while let Ok(Some(event)) = term.poll_input(None) {
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;
}
}
InputEvent::Key(KeyEvent {
key: KeyCode::Char('j'),
..
}) if !self.filtering => {
self.move_down();
}
InputEvent::Key(KeyEvent {
key: KeyCode::Char('k'),
..
}) if !self.filtering => {
self.move_up();
}
InputEvent::Key(KeyEvent {
key: KeyCode::Char('P'),
modifiers: Modifiers::CTRL,
}) => {
self.move_up();
}
InputEvent::Key(KeyEvent {
key: KeyCode::Char('N'),
modifiers: Modifiers::CTRL,
}) => {
self.move_down();
}
InputEvent::Key(KeyEvent {
key: KeyCode::Char('/'),
..
}) if !self.filtering => {
self.filtering = true;
}
InputEvent::Key(KeyEvent {
key: KeyCode::Backspace,
..
}) => {
if self.filter_term.pop().is_none() && !self.always_fuzzy {
self.filtering = false;
}
self.update_filter();
}
InputEvent::Key(KeyEvent {
key: KeyCode::Char('G'),
modifiers: Modifiers::CTRL,
})
| InputEvent::Key(KeyEvent {
key: KeyCode::Escape,
..
}) => {
self.trigger_event(None);
break;
}
InputEvent::Key(KeyEvent {
key: KeyCode::Char(c),
..
}) if self.filtering => {
self.filter_term.push(c);
self.update_filter();
}
InputEvent::Key(KeyEvent {
key: KeyCode::UpArrow,
..
}) => {
self.move_up();
}
InputEvent::Key(KeyEvent {
key: KeyCode::DownArrow,
..
}) => {
self.move_down();
}
InputEvent::Mouse(MouseEvent {
y, mouse_buttons, ..
}) if mouse_buttons.contains(MouseButtons::VERT_WHEEL) => {
if mouse_buttons.contains(MouseButtons::WHEEL_POSITIVE) {
self.top_row = self.top_row.saturating_sub(1);
} else {
self.top_row += 1;
self.top_row = self.top_row.min(
self.filtered_entries
.len()
.saturating_sub(self.max_items)
.saturating_sub(1),
);
}
if y > 0 && y as usize <= self.filtered_entries.len() {
self.active_idx = self.top_row + y as usize - 1;
}
}
InputEvent::Mouse(MouseEvent {
y, mouse_buttons, ..
}) => {
if y > 0 && y as usize <= self.filtered_entries.len() {
self.active_idx = self.top_row + y as usize - 1;
if mouse_buttons == MouseButtons::LEFT {
if self.launch(self.active_idx) {
break;
}
}
}
if mouse_buttons != MouseButtons::NONE {
// Treat any other mouse button as cancel
self.trigger_event(None);
break;
}
}
InputEvent::Key(KeyEvent {
key: KeyCode::Enter,
..
}) => {
if self.launch(self.active_idx) {
break;
}
}
InputEvent::Resized { rows, .. } => {
self.max_items = rows.saturating_sub(ROW_OVERHEAD);
}
_ => {}
}
self.render(term)?;
}
Ok(())
}
}
fn trampoline(name: String, window: GuiWin, pane: MuxPane, entry: Option<InputSelectorEntry>) {
promise::spawn::spawn(async move {
config::with_lua_config_on_main_thread(move |lua| do_event(lua, name, window, pane, entry))
.await
})
.detach();
}
async fn do_event(
lua: Option<Rc<mlua::Lua>>,
name: String,
window: GuiWin,
pane: MuxPane,
entry: Option<InputSelectorEntry>,
) -> anyhow::Result<()> {
if let Some(lua) = lua {
let id = entry.as_ref().map(|entry| entry.id.clone());
let label = entry.as_ref().map(|entry| entry.label.to_string());
let args = lua.pack_multi((window, pane, id, label))?;
if let Err(err) = config::lua::emit_event(&lua, (name.clone(), args)).await {
log::error!("while processing {} event: {:#}", name, err);
}
}
Ok(())
}
pub fn selector(
mut term: TermWizTerminal,
args: InputSelector,
window: GuiWin,
pane: MuxPane,
) -> anyhow::Result<()> {
let event_name = match *args.action {
KeyAssignment::EmitEvent(ref id) => id.to_string(),
_ => {
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,
pane,
top_row: 0,
filter_term: String::new(),
filtered_entries: vec![],
window,
filtering: args.fuzzy,
always_fuzzy: args.fuzzy,
args,
event_name,
};
term.set_raw_mode()?;
term.render(&[Change::Title(state.args.title.to_string())])?;
state.update_filter();
state.render(&mut term)?;
state.run_loop(&mut term)
}

View File

@ -2109,6 +2109,30 @@ impl TermWindow {
Ok(())
}
fn show_input_selector(&mut self, args: &config::keyassignment::InputSelector) {
let mux = Mux::get();
let tab = match mux.get_active_tab_for_window(self.mux_window_id) {
Some(tab) => tab,
None => return,
};
let pane = match self.get_active_pane_or_overlay() {
Some(pane) => pane,
None => return,
};
let args = args.clone();
let gui_win = GuiWin::new(self);
let pane = MuxPane(pane.pane_id());
let (overlay, future) = start_overlay(self, &tab, move |_tab_id, term| {
crate::overlay::selector::selector(term, args, gui_win, pane)
});
self.assign_overlay(tab.tab_id(), overlay);
promise::spawn::spawn(future).detach();
}
fn show_prompt_input_line(&mut self, args: &PromptInputLine) {
let mux = Mux::get();
let tab = match mux.get_active_tab_for_window(self.mux_window_id) {
@ -2887,6 +2911,7 @@ impl TermWindow {
self.set_modal(Rc::new(modal));
}
PromptInputLine(args) => self.show_prompt_input_line(args),
InputSelector(args) => self.show_input_selector(args),
};
Ok(PerformAssignmentResult::Handled)
}