use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::time::Duration;
use rich::cells::cell_len;
use rich::{Segment, Style};
use crate::chrome::{Breadcrumbs, StatusBar, key_hint};
use crate::component::{Component, Context, Flow, View};
use crate::compose::{ComponentExt, Layer, LayerHandle, Layers, Rect};
use crate::event::{Event, Key, KeyCode, MouseKind};
use crate::item::{Action, ActionTarget, Actions, TargetKind};
use crate::keymap::{Binding, Keymap, keys};
use crate::kit::{self, ActionMenu, FilterState, ListState, MenuReply, ScrollState, Theme};
use crate::policy::{LineIo, NotInteractive};
pub const PALETTE: &str = "palette";
pub const HELP: &str = "help";
pub const SHORTCUTS: &str = "shortcuts";
pub const ACTIONS: &str = "actions";
fn style(definition: &str) -> Style {
Style::parse(definition).expect("a built-in style")
}
fn typed(key: Key) -> Option<char> {
match key.code {
KeyCode::Char(c) if !key.modifiers.ctrl && !key.modifiers.alt => Some(c),
_ => None,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct Command {
pub id: String,
pub label: String,
pub category: String,
pub keys: Vec<Key>,
pub context: Option<String>,
pub sequence: bool,
pub unavailable: Option<String>,
}
impl Command {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Command {
Command {
id: id.into(),
label: label.into(),
category: String::new(),
keys: Vec::new(),
context: None,
sequence: false,
unavailable: None,
}
}
pub fn category(mut self, category: impl Into<String>) -> Self {
self.category = category.into();
self
}
pub fn keys(mut self, keys: impl IntoIterator<Item = Key>) -> Self {
self.keys = keys.into_iter().collect();
self
}
pub fn context(mut self, context: impl Into<String>) -> Self {
self.context = Some(context.into());
self
}
pub fn unavailable(mut self, reason: impl Into<String>) -> Self {
self.unavailable = Some(reason.into());
self
}
pub fn from_binding(binding: &Binding) -> Command {
Command {
id: binding.id(),
label: binding.description.clone(),
category: binding.context.clone(),
keys: binding.keys.clone(),
context: Some(binding.context.clone()),
sequence: binding.sequence,
unavailable: binding.unavailable.clone(),
}
}
pub fn keys_label(&self) -> String {
crate::chrome::keys_hint_of(&self.keys, self.sequence)
}
fn search_text(&self) -> String {
format!("{} {}", self.category, self.label)
}
}
pub fn palette_keymap() -> Keymap {
Keymap::new("palette")
.bind("up", keys("up ctrl+p"), "move up")
.bind("down", keys("down ctrl+n"), "move down")
.bind("page-up", keys("pageup"), "page up")
.bind("page-down", keys("pagedown"), "page down")
.bind("run", keys("enter"), "run the command")
.bind("close", keys("escape"), "close")
.bind("delete", keys("backspace"), "delete a character")
.bind("clear", keys("ctrl+u"), "clear the search")
}
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct Palette {
commands: Vec<Command>,
icons: Vec<(String, Vec<Segment>)>,
listed: Vec<usize>,
active: Option<Vec<String>>,
filter: FilterState,
list: ListState,
prompt: String,
theme: Theme,
keymap: Keymap,
page: Cell<usize>,
}
impl Palette {
pub fn new(commands: impl IntoIterator<Item = Command>) -> Palette {
let mut palette = Palette {
commands: commands.into_iter().collect(),
icons: Vec::new(),
listed: Vec::new(),
active: None,
filter: FilterState::default(),
list: ListState::new(),
prompt: "Command".to_string(),
theme: Theme::default(),
keymap: palette_keymap(),
page: Cell::new(10),
};
palette.relist();
palette
}
pub fn from_keymap(keymap: &Keymap) -> Palette {
Palette::new(
keymap
.bindings()
.iter()
.filter(|binding| !binding.keys.is_empty())
.map(Command::from_binding),
)
}
pub fn category_icon(mut self, category: impl Into<String>, icon: &rich::Text) -> Self {
let category = category.into();
self.icons.retain(|(known, _)| *known != category);
self.icons.push((category, kit::icon(icon)));
self
}
fn icon(&self, category: &str) -> Option<&[Segment]> {
self.icons
.iter()
.find(|(known, _)| known == category)
.map(|(_, icon)| icon.as_slice())
}
fn category_cells(&self, category: &str) -> usize {
cell_len(category) + self.icon(category).map_or(0, |icon| kit::width(icon) + 1)
}
pub fn command(mut self, command: Command) -> Self {
self.commands.push(command);
self.relist();
self
}
pub fn commands(mut self, commands: impl IntoIterator<Item = Command>) -> Self {
self.commands.extend(commands);
self.relist();
self
}
pub fn contexts<I, S>(mut self, contexts: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.active = Some(contexts.into_iter().map(Into::into).collect());
self.relist();
self
}
pub fn hints(mut self, keymap: &Keymap) -> Self {
let bindings = keymap.bindings();
for command in &mut self.commands {
if let Some(binding) = bindings.iter().find(|b| b.id() == command.id) {
command.keys = binding.keys.clone();
}
}
self
}
pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
self.prompt = prompt.into();
self
}
pub fn theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
pub fn query(mut self, query: impl Into<String>) -> Self {
self.filter.set_query(query);
self.reset();
self
}
pub fn rebind(mut self, action: &str, keys: impl IntoIterator<Item = Key>) -> Self {
self.keymap.rebind(action, keys);
self
}
pub fn set_commands(&mut self, commands: impl IntoIterator<Item = Command>) {
let focused = self.focused().map(|command| command.id.clone());
let at = self.list.cursor();
let offset = self.list.offset();
self.commands = commands.into_iter().collect();
self.relist();
let found = focused.and_then(|id| self.position_of(&id));
let len = self.filter.len();
if len > 0 {
let to = found.unwrap_or(at.min(len - 1));
self.list.set_offset(offset.min(to));
self.list.select_row(to, self.page.get());
}
}
pub fn focus_id(&mut self, id: &str) -> bool {
match self.position_of(id) {
Some(at) => {
self.list.select_row(at, self.page.get());
true
}
None => false,
}
}
fn position_of(&self, id: &str) -> Option<usize> {
self.filter
.matches()
.iter()
.position(|(index, _)| self.commands[self.listed[*index]].id == id)
}
pub fn matches(&self) -> Vec<&Command> {
self.filter
.matches()
.iter()
.map(|(index, _)| &self.commands[self.listed[*index]])
.collect()
}
pub fn focused(&self) -> Option<&Command> {
self.filter
.index(self.list.cursor())
.map(|index| &self.commands[self.listed[index]])
}
pub fn filter(&self) -> &FilterState {
&self.filter
}
fn relist(&mut self) {
let active = self.active.as_ref();
self.listed = (0..self.commands.len())
.filter(|&index| match (&self.commands[index].context, active) {
(Some(context), Some(active)) => active.iter().any(|a| a == context),
_ => true,
})
.collect();
let candidates: Vec<String> = self
.listed
.iter()
.map(|&index| self.commands[index].search_text())
.collect();
self.filter.set_candidates(candidates);
self.reset();
}
fn reset(&mut self) {
self.list.set_len(self.filter.len());
self.list.reset(0, self.page.get());
}
fn row(&self, position: usize, category_width: usize, width: usize) -> Vec<Segment> {
let theme = &self.theme;
let (index, positions) = &self.filter.matches()[position];
let command = &self.commands[self.listed[*index]];
let focused = position == self.list.cursor();
let mut line = if focused {
vec![kit::text(
format!("{} ", theme.pointer),
&theme.pointer_style,
)]
} else {
vec![kit::plain(" ")]
};
let split = command.category.chars().count() + 1;
let (in_category, in_label): (Vec<usize>, Vec<usize>) =
positions.iter().partition(|&&p| p < split);
let in_label: Vec<usize> = in_label.iter().map(|p| p - split).collect();
let mut category = match self.icon(&command.category) {
Some(icon) => {
let mut cells = icon.to_vec();
cells.push(kit::plain(" "));
cells
}
None => Vec::new(),
};
category.extend(kit::highlight(
&command.category,
&in_category,
Some(&theme.hint),
&theme.matched,
));
category.push(kit::plain(" "));
line.extend(kit::pad(category, category_width + 1));
let base = match (&command.unavailable, focused) {
(Some(_), true) => Some(theme.focused.combine(&theme.hint)),
(Some(_), false) => Some(theme.hint.clone()),
(None, true) => Some(theme.focused.clone()),
(None, false) => None,
};
line.extend(kit::highlight(
&command.label,
&in_label,
base.as_ref(),
&theme.matched,
));
if let Some(reason) = command.unavailable.as_ref().filter(|r| !r.is_empty()) {
line.push(kit::text(format!(" ({reason})"), &theme.hint));
}
let shortcut = command.keys_label();
if !shortcut.is_empty() {
let used = kit::width(&line);
let wanted = cell_len(&shortcut) + 1;
if used + wanted < width {
line = kit::pad(line, width - wanted);
line.push(kit::text(format!("{shortcut} "), &theme.hint));
} else if width > wanted + 8 {
let room = width - wanted - 1;
line = kit::pad(line, room - 1);
line.push(kit::plain("… "));
line.push(kit::text(format!("{shortcut} "), &theme.hint));
}
}
kit::fit(line, width)
}
fn move_by(&mut self, delta: isize) {
self.list.step(delta, self.page.get());
}
}
impl Component for Palette {
type Output = Command;
fn handle(&mut self, event: &Event, _: &Context<'_>) -> Flow<Command> {
let page = self.page.get();
match event {
Event::Paste(text) => {
self.filter.push_str(&kit::pasted(text, " "));
self.reset();
return Flow::Continue;
}
Event::Mouse(mouse) => {
match mouse.kind {
MouseKind::ScrollUp => self.move_by(-3),
MouseKind::ScrollDown => self.move_by(3),
_ if mouse.is_click() => {
let row = mouse.row as usize;
let position = self.list.offset() + row.wrapping_sub(1);
if row >= 1 && row <= page && position < self.filter.len() {
self.list.select_row(position, page);
if let Some(command) =
self.focused().filter(|c| c.unavailable.is_none())
{
return Flow::Done(command.clone());
}
}
}
_ => {}
}
return Flow::Continue;
}
_ => {}
}
let Some(key) = event.key() else {
return Flow::Ignored;
};
match self.keymap.action(key) {
Some("up") => self.move_by(-1),
Some("down") => self.move_by(1),
Some("page-up") => self.move_by(-(page as isize)),
Some("page-down") => self.move_by(page as isize),
Some("run") => {
return match self.focused().filter(|c| c.unavailable.is_none()) {
Some(command) => Flow::Done(command.clone()),
None => Flow::Continue,
};
}
Some("close") => return Flow::Cancel,
Some("delete") => {
if self.filter.pop() {
self.reset();
}
}
Some("clear") => {
self.filter.clear();
self.reset();
}
_ => match typed(key) {
Some(c) => {
self.filter.push(c);
self.reset();
}
None => return Flow::Ignored,
},
}
Flow::Continue
}
fn render(&self, context: &Context<'_>) -> View {
let width = context.width;
let theme = &self.theme;
let page = context.height.saturating_sub(2).max(1);
self.page.set(page);
let mut header = kit::question(theme, &self.prompt);
let column = kit::width(&header) + cell_len(self.filter.query());
header.push(kit::plain(self.filter.query().to_string()));
let mut lines = vec![kit::fit(header, width)];
let category_width = self
.listed
.iter()
.map(|&index| self.category_cells(&self.commands[index].category))
.max()
.unwrap_or(0)
.min(width / 3);
for position in self.list.visible(page) {
lines.push(self.row(position, category_width, width));
}
if self.filter.is_empty() {
lines.push(vec![kit::text(" no matching commands", &theme.hint)]);
}
lines.resize_with(page + 1, Vec::new);
let hint = format!(
" {}/{} · ↑↓ move · enter run · esc close",
self.filter.len(),
self.listed.len()
);
lines.push(kit::fit(vec![kit::text(hint, &theme.hint)], width));
View::new(lines).with_cursor(0, column.min(width.saturating_sub(1)))
}
fn mouse(&self) -> bool {
true
}
fn keymap(&self) -> Keymap {
self.keymap.clone()
}
}
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct Help {
bindings: Vec<Binding>,
filter: FilterState,
scroll: ScrollState,
prompt: String,
theme: Theme,
page: Cell<usize>,
wrap: bool,
flat: bool,
format: Option<KeyFormat>,
filter_key: Option<Key>,
searching: bool,
width: Cell<usize>,
}
type KeyFormat = Rc<dyn Fn(&Binding) -> String>;
impl Help {
pub fn new(keymap: &Keymap) -> Help {
Help::from_bindings(keymap.bindings())
}
pub fn from_bindings(bindings: impl IntoIterator<Item = Binding>) -> Help {
let bindings: Vec<Binding> = bindings
.into_iter()
.filter(|binding| !binding.keys.is_empty())
.collect();
let mut help = Help {
bindings,
filter: FilterState::new(Vec::<String>::new()),
scroll: ScrollState::new(0),
prompt: "Search keys".to_string(),
theme: Theme::default(),
page: Cell::new(10),
wrap: false,
flat: false,
format: None,
filter_key: None,
searching: true,
width: Cell::new(0),
};
help.search_keys();
help
}
pub fn filter_key(mut self, key: Option<Key>) -> Self {
self.filter_key = key;
self.searching = key.is_none();
self
}
pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
self.prompt = prompt.into();
self
}
pub fn theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
pub fn wrap(mut self, on: bool) -> Self {
self.wrap = on;
self.refilter();
self
}
pub fn flat(mut self, on: bool) -> Self {
self.flat = on;
self.refilter();
self
}
pub fn key_format(mut self, format: impl Fn(&Binding) -> String + 'static) -> Self {
self.format = Some(Rc::new(format));
self.search_keys();
self
}
pub fn query(mut self, query: impl Into<String>) -> Self {
self.filter.set_query(query);
self.refilter();
self
}
pub fn set_bindings(&mut self, bindings: impl IntoIterator<Item = Binding>) {
let offset = self.scroll.offset();
self.bindings = bindings
.into_iter()
.filter(|binding| !binding.keys.is_empty())
.collect();
self.search_keys();
self.scroll.scroll_to(offset, self.page.get());
}
pub fn matches(&self) -> Vec<&Binding> {
let mut indices: Vec<usize> = self.filter.matches().iter().map(|(i, _)| *i).collect();
indices.sort_unstable();
indices.iter().map(|&index| &self.bindings[index]).collect()
}
fn hint_of(&self, binding: &Binding) -> String {
match &self.format {
Some(format) => format(binding),
None => binding.hint(),
}
}
fn search_keys(&mut self) {
let candidates: Vec<String> = self
.bindings
.iter()
.map(|b| {
let hint = self.hint_of(b);
format!("{hint} {} {} {}", b.keys_label(), b.description, b.context)
})
.collect();
self.filter.set_candidates(candidates);
self.refilter();
}
fn refilter(&mut self) {
self.scroll = ScrollState::new(self.rows(false, self.width.get()).len());
}
fn rows(&self, styled: bool, width: usize) -> Vec<Vec<Segment>> {
let theme = &self.theme;
let matches = self.matches();
let hints: Vec<String> = matches.iter().map(|b| self.hint_of(b)).collect();
let mut key_width = hints
.iter()
.map(|hint| cell_len(hint))
.max()
.unwrap_or(0)
.min(24);
if self.wrap && width > 0 {
key_width = key_width.min((width.saturating_sub(4) / 2).max(4));
}
let indent = key_width + 4;
let room = width.saturating_sub(indent);
let wrapping = self.wrap && width > 0 && room >= 4;
let mut rows = Vec::new();
let mut contexts: Vec<&str> = Vec::new();
for binding in &matches {
if !contexts.contains(&binding.context.as_str()) {
contexts.push(&binding.context);
}
}
let groups: Vec<Option<&str>> = if self.flat {
vec![None]
} else {
contexts.into_iter().map(Some).collect()
};
for group in groups {
if let Some(context) = group {
rows.push(vec![kit::text(context.to_string(), &style("bold"))]);
}
let shown = matches
.iter()
.zip(&hints)
.filter(|(b, _)| group.is_none_or(|context| b.context == context));
for (binding, hint) in shown {
let description = match &binding.unavailable {
Some(reason) if !reason.is_empty() => {
format!("{} ({reason})", binding.description)
}
_ => binding.description.clone(),
};
let (keys, lines) = if wrapping {
(wrap_keys(hint, key_width), wrap_words(&description, room))
} else {
(vec![hint.clone()], vec![description])
};
let (key_style, text_style) = match binding.unavailable {
Some(_) => (theme.hint.clone(), Some(theme.hint.clone())),
None => (theme.prompt.clone(), None),
};
let count = keys.len().max(lines.len());
if !styled {
rows.extend((0..count).map(|_| Vec::new()));
continue;
}
for n in 0..count {
let mut line = vec![kit::plain(" ")];
let text = lines.get(n);
match keys.get(n) {
Some(keys) if text.is_none() => {
line.push(kit::text(keys.clone(), &key_style));
}
Some(keys) => line.extend(kit::pad(
vec![kit::text(keys.clone(), &key_style)],
key_width,
)),
None => line.push(kit::plain(" ".repeat(key_width))),
}
if let Some(text) = text {
line.push(kit::plain(" "));
line.push(match &text_style {
Some(style) => kit::text(text.clone(), style),
None => kit::plain(text.clone()),
});
}
rows.push(line);
}
}
}
rows
}
}
fn wrap_keys(keys: &str, width: usize) -> Vec<String> {
if cell_len(keys) <= width || width == 0 {
return vec![keys.to_string()];
}
let mut pieces: Vec<String> = Vec::new();
let mut piece = String::new();
let mut chars = keys.chars().peekable();
while let Some(c) = chars.next() {
piece.push(c);
let ends = match c {
'/' | ' ' => chars.peek().is_some_and(|next| *next != ' '),
',' => {
chars.peek() == Some(&' ') && {
piece.push(chars.next().unwrap_or(' '));
true
}
}
_ => false,
};
if ends {
pieces.push(std::mem::take(&mut piece));
}
}
if !piece.is_empty() {
pieces.push(piece);
}
let mut lines: Vec<String> = Vec::new();
let mut line = String::new();
for piece in pieces {
if !line.is_empty() && cell_len(&line) + cell_len(piece.trim_end()) > width {
lines.push(line.trim_end().to_string());
line.clear();
}
if line.is_empty() && cell_len(piece.trim_end()) > width {
let mut broken = wrap_words(piece.trim_end(), width);
line = broken.pop().unwrap_or_default();
lines.extend(broken);
if piece.ends_with(' ') {
line.push(' ');
}
continue;
}
line.push_str(&piece);
}
if !line.trim_end().is_empty() {
lines.push(line.trim_end().to_string());
}
lines
}
fn wrap_words(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split(' ') {
let mut word = word.to_string();
loop {
let needed = if line.is_empty() {
cell_len(&word)
} else {
cell_len(&line) + 1 + cell_len(&word)
};
if needed <= width {
if !line.is_empty() {
line.push(' ');
}
line.push_str(&word);
break;
}
if !line.is_empty() {
lines.push(std::mem::take(&mut line));
continue;
}
let mut head = String::new();
let mut rest = word.chars();
for c in rest.by_ref() {
if cell_len(&head) + cell_len(&c.to_string()) > width {
lines.push(std::mem::take(&mut head));
head.push(c);
break;
}
head.push(c);
}
word = head + rest.as_str();
if cell_len(&word) <= width {
line = word;
break;
}
}
}
if !line.is_empty() || lines.is_empty() {
lines.push(line);
}
lines
}
impl Component for Help {
type Output = ();
fn handle(&mut self, event: &Event, _: &Context<'_>) -> Flow<()> {
let page = self.page.get();
let rows = self.rows(false, self.width.get()).len();
self.scroll.set_len(rows);
match event {
Event::Paste(text) => {
self.filter.push_str(&kit::pasted(text, " "));
self.refilter();
return Flow::Continue;
}
Event::Mouse(_) => {
self.scroll.handle(event, page);
return Flow::Continue;
}
_ => {}
}
let Some(key) = event.key() else {
return Flow::Ignored;
};
if !self.searching {
if Some(key) == self.filter_key {
self.searching = true;
return Flow::Continue;
}
let plain = !key.modifiers.ctrl && !key.modifiers.alt;
let moved = match key.code {
KeyCode::Char('j') if plain => self.scroll.scroll(1, page),
KeyCode::Char('k') if plain => self.scroll.scroll(-1, page),
KeyCode::Char('g') if plain => self.scroll.scroll_to(0, page),
KeyCode::Char('G') if plain => self.scroll.scroll_to(usize::MAX, page),
_ => typed(key).is_some(),
};
if moved || typed(key).is_some() {
return Flow::Continue;
}
}
match key.code {
KeyCode::Escape if self.filter_key.is_some() && self.searching => {
self.searching = false;
self.filter.clear();
self.refilter();
}
KeyCode::Escape if self.filter.is_filtering() => {
self.filter.clear();
self.refilter();
}
KeyCode::Escape => return Flow::Cancel,
KeyCode::Enter => return Flow::Done(()),
KeyCode::Backspace => {
if self.filter.pop() {
self.refilter();
}
}
KeyCode::Char('u') if key.modifiers.ctrl => {
self.filter.clear();
self.refilter();
}
KeyCode::Up => {
self.scroll.scroll(-1, page);
}
KeyCode::Down => {
self.scroll.scroll(1, page);
}
KeyCode::PageUp => {
self.scroll.scroll(-(page as isize), page);
}
KeyCode::PageDown => {
self.scroll.scroll(page as isize, page);
}
KeyCode::Home => {
self.scroll.scroll_to(0, page);
}
KeyCode::End => {
self.scroll.scroll_to(usize::MAX, page);
}
_ => match typed(key) {
Some(c) => {
self.filter.push(c);
self.refilter();
}
None => return Flow::Ignored,
},
}
Flow::Continue
}
fn render(&self, context: &Context<'_>) -> View {
let width = context.width;
let theme = &self.theme;
let top = if self.flat { 2 } else { 1 };
let page = context.height.saturating_sub(top + 1).max(1);
self.page.set(page);
self.width.set(width);
let mut header = kit::question(theme, &self.prompt);
let column = kit::width(&header) + cell_len(self.filter.query());
header.push(kit::plain(self.filter.query().to_string()));
let mut lines = vec![kit::fit(header, width)];
if self.flat {
lines.push(vec![kit::text("─".repeat(width), &theme.hint)]);
}
let rows = self.rows(true, width);
let mut scroll = ScrollState::new(rows.len());
scroll.scroll_to(self.scroll.offset(), page);
let shown = scroll.visible(page);
let more = shown.end < rows.len();
lines.extend(rows[shown].iter().map(|row| kit::fit(row.clone(), width)));
if rows.is_empty() {
lines.push(vec![kit::text(" no matching keys", &theme.hint)]);
}
lines.resize_with(page + top, Vec::new);
let count = self.matches().len();
let search = match (self.filter_key, self.searching) {
(Some(key), false) => format!("{} search · j/k move", key_hint(key)),
_ => "type to search".to_string(),
};
let hint = format!(
" {count} {}{} · {search} · ↑↓ scroll · esc close",
if count == 1 { "key" } else { "keys" },
if more { " · more below" } else { "" }
);
lines.push(kit::fit(vec![kit::text(hint, &theme.hint)], width));
let view = View::new(lines);
if self.searching {
view.with_cursor(0, column.min(width.saturating_sub(1)))
} else {
view
}
}
fn mouse(&self) -> bool {
true
}
fn keymap(&self) -> Keymap {
Keymap::new("help")
.bind("scroll-up", keys("up"), "scroll up")
.bind("scroll-down", keys("down"), "scroll down")
.bind("page-up", keys("pageup"), "page up")
.bind("page-down", keys("pagedown"), "page down")
.bind("close", keys("escape enter"), "close")
.bind("clear", keys("ctrl+u"), "clear the search")
}
}
fn shortcut_hint(binding: &Binding) -> String {
if binding.sequence {
binding.hint()
} else {
key_hint(binding.keys[0])
}
}
pub struct Shortcuts {
bindings: Vec<Binding>,
theme: Theme,
}
impl Shortcuts {
pub fn new(keymap: &Keymap) -> Shortcuts {
Shortcuts::from_bindings(keymap.bindings())
}
pub fn from_bindings(bindings: impl IntoIterator<Item = Binding>) -> Shortcuts {
Shortcuts {
bindings: bindings
.into_iter()
.filter(|binding| !binding.keys.is_empty())
.collect(),
theme: Theme::default(),
}
}
#[must_use]
pub fn theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
fn widths(&self) -> (usize, usize) {
let key = self
.bindings
.iter()
.map(|b| cell_len(&shortcut_hint(b)))
.max()
.unwrap_or(0);
let entry = self
.bindings
.iter()
.map(|b| cell_len(&b.description))
.max()
.unwrap_or(0);
(key, (key + 2 + entry).min(36))
}
fn grid(&self, width: usize) -> (usize, usize) {
let (_, entry) = self.widths();
let columns = ((width + 3) / (entry + 3)).min(self.bindings.len()).max(1);
(columns, self.bindings.len().div_ceil(columns))
}
pub fn size(&self, width: usize, height: usize) -> (usize, usize) {
let inner = width.saturating_sub(4);
let (columns, rows) = self.grid(inner);
let (_, entry) = self.widths();
let used = columns * (entry + 3) - 3;
((used + 4).min(width), (rows + 4).min(height))
}
}
impl Component for Shortcuts {
type Output = ();
fn handle(&mut self, event: &Event, _: &Context<'_>) -> Flow<()> {
match event {
Event::Key(_) => Flow::Done(()),
Event::Mouse(mouse) if mouse.is_click() => Flow::Done(()),
_ => Flow::Ignored,
}
}
fn render(&self, context: &Context<'_>) -> View {
let theme = &self.theme;
let width = context.width.saturating_sub(2);
let (key_width, entry) = self.widths();
let (columns, rows) = self.grid(width);
let mut lines = Vec::with_capacity(rows + 2);
for row in 0..rows {
let mut line = vec![kit::plain(" ")];
for column in 0..columns {
let Some(binding) = self.bindings.get(column * rows + row) else {
break;
};
if column > 0 {
line.push(kit::plain(" "));
}
let mut cell = kit::pad(
vec![kit::text(shortcut_hint(binding), &theme.prompt)],
key_width,
);
cell.push(kit::plain(" "));
cell.push(kit::text(binding.description.clone(), &Style::default()));
line.extend(kit::pad(cell, entry));
}
lines.push(kit::fit(line, context.width));
}
lines.push(Vec::new());
lines.push(kit::fit(
vec![kit::text(" any key closes", &theme.hint)],
context.width,
));
View::new(lines)
}
fn mouse(&self) -> bool {
true
}
fn keymap(&self) -> Keymap {
Keymap::new("shortcuts").bind("close", keys("escape"), "close (any key)")
}
}
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct Menu {
menu: ActionMenu,
target: ActionTarget,
theme: Theme,
}
impl Menu {
pub fn new(actions: Vec<Action>, target: ActionTarget) -> Menu {
Menu {
menu: ActionMenu::new(actions, Key::ctrl('k')),
target,
theme: Theme::default(),
}
}
pub fn key(mut self, key: Key) -> Self {
self.menu.key = key;
self
}
pub fn theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
pub fn target(&self) -> &ActionTarget {
&self.target
}
pub fn menu(&self) -> &ActionMenu {
&self.menu
}
pub fn size(&self) -> (usize, usize) {
let (width, rows) = self.menu.size();
(width + 2, rows + 2)
}
}
impl Component for Menu {
type Output = Action;
fn handle(&mut self, event: &Event, _: &Context<'_>) -> Flow<Action> {
if !matches!(event, Event::Key(_) | Event::Mouse(_)) {
return Flow::Ignored;
}
match self.menu.handle(event, 0) {
MenuReply::Stay => Flow::Continue,
MenuReply::Close => Flow::Cancel,
MenuReply::Run(action) => Flow::Done(action),
}
}
fn render(&self, context: &Context<'_>) -> View {
View::new(self.menu.render(&self.theme, context.width))
}
fn mouse(&self) -> bool {
true
}
fn keymap(&self) -> Keymap {
Keymap::new("menu")
.bind("up", keys("up"), "move up")
.bind("down", keys("down tab"), "move down")
.bind("run", keys("enter"), "run")
.bind("close", keys("escape"), "close")
}
}
pub fn overlays_keymap() -> Keymap {
Keymap::new("overlays")
.bind(PALETTE, keys("ctrl+o"), "commands")
.bind(HELP, keys("f1"), "help")
.bind(SHORTCUTS, keys("? f2"), "shortcuts")
}
enum Picked {
Command(Command),
Action(Action),
}
type Handler<'a, M> = Box<dyn FnMut() -> Flow<M> + 'a>;
type ActionHandler<'a, M> = Box<dyn FnMut(&Action, &ActionTarget) -> Flow<M> + 'a>;
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct Overlays<'a, M> {
layers: Layers<'a, M>,
keymap: Keymap,
commands: Vec<Command>,
category_icons: Vec<(String, rich::Text)>,
handlers: Vec<(String, Handler<'a, M>)>,
picked: Rc<RefCell<Option<Picked>>>,
region: Option<ActionTarget>,
actions: Actions,
on_action: Option<ActionHandler<'a, M>>,
ran: Option<(String, ActionTarget)>,
status: Option<StatusBar<M>>,
crumbs: Option<Breadcrumbs<'a, M>>,
theme: Theme,
}
impl<'a, M: 'a> Overlays<'a, M> {
pub fn new(component: impl Component<Output = M> + 'a) -> Overlays<'a, M> {
Overlays {
layers: Layers::new(component),
keymap: overlays_keymap(),
commands: Vec::new(),
category_icons: Vec::new(),
handlers: Vec::new(),
picked: Rc::new(RefCell::new(None)),
region: None,
actions: Actions::new(),
on_action: None,
ran: None,
status: None,
crumbs: None,
theme: Theme::default(),
}
}
pub fn rebind(mut self, action: &str, keys: impl IntoIterator<Item = Key>) -> Self {
self.keymap.rebind(action, keys);
self
}
pub fn category_icon(mut self, category: impl Into<String>, icon: rich::Text) -> Self {
let category = category.into();
self.category_icons.retain(|(known, _)| *known != category);
self.category_icons.push((category, icon));
self
}
pub fn command(mut self, command: Command, handler: impl FnMut() -> Flow<M> + 'a) -> Self {
self.handlers.retain(|(id, _)| *id != command.id);
self.handlers.push((command.id.clone(), Box::new(handler)));
self.commands.retain(|known| known.id != command.id);
self.commands.push(command);
self
}
pub fn region(mut self, label: impl Into<String>, value: impl Into<String>) -> Self {
self.region = Some(ActionTarget {
kind: TargetKind::Region,
index: 0,
label: label.into(),
value: value.into(),
});
self
}
pub fn actions(mut self, actions: Actions) -> Self {
self.actions = actions;
if !self.actions.is_empty() {
self.keymap = std::mem::take(&mut self.keymap).bind(
ACTIONS,
keys("ctrl+k"),
"actions for this region",
);
}
self
}
pub fn on_action(
mut self,
handler: impl FnMut(&Action, &ActionTarget) -> Flow<M> + 'a,
) -> Self {
self.on_action = Some(Box::new(handler));
self
}
pub fn status_bar(mut self, bar: StatusBar<M>) -> Self {
self.status = Some(bar);
self
}
pub fn breadcrumbs(mut self, crumbs: Breadcrumbs<'a, M>) -> Self {
self.crumbs = Some(crumbs);
self
}
pub fn theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
pub fn own_keymap(&self) -> &Keymap {
&self.keymap
}
pub fn layers(&self) -> &Layers<'a, M> {
&self.layers
}
pub fn layers_mut(&mut self) -> &mut Layers<'a, M> {
&mut self.layers
}
pub fn layer_handle(&self) -> LayerHandle<'a, M> {
self.layers.handle()
}
pub fn component(&self) -> &(dyn Component<Output = M> + 'a) {
self.layers.base()
}
pub fn status(&self) -> Option<&StatusBar<M>> {
self.status.as_ref()
}
pub fn action(&self) -> Option<&str> {
self.ran.as_ref().map(|(id, _)| id.as_str())
}
pub fn target(&self) -> ActionTarget {
self.region.clone().unwrap_or(ActionTarget {
kind: TargetKind::Region,
index: 0,
label: "region".to_string(),
value: "region".to_string(),
})
}
pub fn host_keymap(&self) -> Keymap {
let mut keymap = self.layers.base().keymap();
keymap.extend(self.keymap.clone());
keymap
}
pub fn palette(&self) -> Palette {
let keymap = self.host_keymap();
let mut active: Vec<String> = Vec::new();
for binding in keymap.bindings() {
if !active.contains(&binding.context) {
active.push(binding.context);
}
}
let bindings = keymap
.bindings()
.into_iter()
.filter(|b| !b.keys.is_empty() && b.id() != format!("overlays.{PALETTE}"));
let mut palette = Palette::new(self.commands.iter().cloned())
.commands(bindings.map(|b| Command::from_binding(&b)))
.contexts(active)
.hints(&keymap)
.theme(self.theme.clone());
for (category, icon) in &self.category_icons {
palette = palette.category_icon(category.clone(), icon);
}
palette
}
fn body(&self, context: &Context<'_>) -> Rect {
let top = usize::from(self.crumbs.is_some());
let chrome = top + usize::from(self.status.is_some());
Rect::new(
0,
top,
context.width,
context.height.saturating_sub(chrome).max(1),
)
}
fn open(&mut self, action: &str, body: &Context<'_>) -> bool {
let (width, height) = (body.width, body.height);
let slot = Rc::clone(&self.picked);
let layer = match action {
PALETTE => {
let palette = self.palette().map(move |command| {
*slot.borrow_mut() = Some(Picked::Command(command));
Flow::Continue
});
Layer::modal(palette)
.title("Commands")
.size(width.min(72), height.min(18))
}
HELP => {
let help = Help::new(&self.host_keymap())
.theme(self.theme.clone())
.map(|()| Flow::Continue);
Layer::modal(help)
.title("Keys")
.size(width.min(72), height.min(20))
}
SHORTCUTS => {
let sheet = Shortcuts::new(&self.host_keymap()).theme(self.theme.clone());
let (w, h) = sheet.size(width, height);
Layer::modal(sheet.map(|()| Flow::Continue))
.title("Shortcuts")
.size(w, h)
}
ACTIONS => {
let target = self.target();
let actions = self.actions.for_target(&target, &[]);
if actions.is_empty() {
return false;
}
let menu = Menu::new(actions, target.clone())
.key(self.keymap.key(ACTIONS).unwrap_or(Key::ctrl('k')))
.theme(self.theme.clone());
let (w, h) = menu.size();
let menu = menu.map(move |action| {
*slot.borrow_mut() = Some(Picked::Action(action));
Flow::Continue
});
Layer::modal(menu)
.title(format!("Actions · {}", target.label))
.size(w.min(width), h.min(height))
}
_ => return false,
};
self.layers.open(layer);
true
}
fn run(&mut self, picked: Picked, context: &Context<'_>) -> Flow<M> {
match picked {
Picked::Command(command) => {
if let Some((_, handler)) =
self.handlers.iter_mut().find(|(id, _)| *id == command.id)
{
return handler();
}
match command.keys.first() {
Some(key) => match Component::handle(self, &Event::Key(*key), context) {
Flow::Ignored => Flow::Continue,
flow => flow,
},
None => Flow::Continue,
}
}
Picked::Action(action) => {
let target = self.target();
self.ran = Some((action.id.clone(), target.clone()));
match &mut self.on_action {
Some(handler) => handler(&action, &target),
None => Flow::Continue,
}
}
}
}
fn overlay_for(&self, key: Key) -> Option<String> {
let action = self.keymap.action(key).or_else(|| match key.code {
KeyCode::Char(c) if key.modifiers.shift && !c.is_alphabetic() => {
let mut plain = key;
plain.modifiers.shift = false;
self.keymap.action(plain)
}
_ => None,
})?;
Some(action.to_string())
}
fn status_keymap(&self) -> Keymap {
self.keymap()
}
}
impl<'a, M: 'a> Component for Overlays<'a, M> {
type Output = M;
fn start(&mut self, context: &Context<'_>) -> Flow<M> {
let body = self.body(context);
self.layers.start(&body.context(context))
}
fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<M> {
let body = self.body(context);
let inner = body.context(context);
let flow = match event {
Event::Mouse(mouse) => {
let row = mouse.row as usize;
if let Some(crumbs) = self.crumbs.as_mut().filter(|_| row == 0) {
if !crumbs.mouse() {
return Flow::Ignored;
}
let line = Rect::new(0, 0, context.width, 1);
return crumbs.handle(event, &line.context(context));
}
if row < body.y || row >= body.y + body.height {
return Flow::Ignored;
}
Component::handle(&mut self.layers, &Event::Mouse(body.local(*mouse)), &inner)
}
Event::Tick => {
let flow = if self.layers.tick().is_some() {
Component::handle(&mut self.layers, event, &inner)
} else {
Flow::Ignored
};
if self.status.as_ref().is_some_and(|s| s.tick().is_some()) {
return match flow {
Flow::Ignored => Flow::Continue,
flow => flow,
};
}
flow
}
_ => Component::handle(&mut self.layers, event, &inner),
};
let picked = self.picked.borrow_mut().take();
if let Some(picked) = picked {
return match flow {
Flow::Continue | Flow::Ignored => self.run(picked, context),
flow => flow,
};
}
if !matches!(flow, Flow::Ignored) || self.layers.depth() > 0 {
return flow;
}
let Some(action) = event.key().and_then(|key| self.overlay_for(key)) else {
return flow;
};
if self.open(&action, &inner) {
Flow::Continue
} else {
flow
}
}
fn render(&self, context: &Context<'_>) -> View {
let body = self.body(context);
let mut lines = Vec::new();
if let Some(crumbs) = &self.crumbs {
let line = Rect::new(0, 0, context.width, 1);
lines.extend(
crumbs
.render(&line.context(context))
.lines
.into_iter()
.take(1),
);
lines.resize_with(1, Vec::new);
}
let view = self.layers.render(&body.context(context));
let cursor = view.cursor.map(|(row, column)| (row + body.y, column));
lines.extend(view.lines.into_iter().take(body.height));
if let Some(status) = &self.status {
let line = Rect::new(0, 0, context.width, 1);
lines.push(status.line(&line.context(context), Some(&self.status_keymap())));
}
View { lines, cursor }
}
fn tick(&self) -> Option<Duration> {
let status = self.status.as_ref().and_then(|status| status.tick());
self.layers.tick().into_iter().chain(status).min()
}
fn mouse(&self) -> bool {
self.layers.mouse() || self.crumbs.as_ref().is_some_and(|crumbs| crumbs.mouse())
}
fn keymap(&self) -> Keymap {
let mut keymap = self.layers.keymap();
if self.layers.depth() == 0 {
keymap.extend(self.keymap.clone());
}
keymap
}
fn focusable(&self) -> bool {
self.layers.focusable()
}
fn focus_step(&mut self, forward: bool) -> bool {
self.layers.focus_step(forward)
}
fn focus_enter(&mut self, forward: bool) -> bool {
self.layers.focus_enter(forward)
}
fn default_value(&self) -> Option<M> {
self.layers.default_value()
}
fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<M>, NotInteractive> {
self.layers.prompt(io)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn keymap() -> Keymap {
Keymap::new("demo")
.bind("save", keys("ctrl+s"), "save the file")
.bind("quit", keys("q"), "quit")
}
#[test]
fn a_palette_filters_by_context_and_reads_hints_from_the_keymap() {
let keymap = keymap().rebound("save", keys("ctrl+w"));
let palette = Palette::from_keymap(&keymap)
.command(Command::new("demo.save", "save").context("demo"))
.command(Command::new("other", "elsewhere").context("elsewhere"))
.command(Command::new("always", "always there"))
.contexts(["demo"])
.hints(&keymap);
let labels: Vec<&str> = palette.matches().iter().map(|c| c.label.as_str()).collect();
assert_eq!(labels, ["save the file", "quit", "save", "always there"]);
assert_eq!(palette.matches()[2].keys_label(), "ctrl+w");
let palette = palette.query("qt");
assert_eq!(palette.focused().unwrap().id, "demo.quit");
}
#[test]
fn help_groups_and_searches_bindings() {
let mut keymap = keymap();
keymap.extend(Keymap::new("tabs").bind("next", keys("alt+right"), "next tab"));
let help = Help::new(&keymap);
assert_eq!(help.matches().len(), 3);
let help = help.query("tab");
let found: Vec<String> = help.matches().iter().map(|b| b.id()).collect();
assert_eq!(found, ["tabs.next"]);
}
fn help_rows(help: &Help, width: usize) -> Vec<String> {
help.rows(true, width)
.iter()
.map(|row| row.iter().map(|s| s.text.as_str()).collect::<String>())
.collect()
}
#[test]
fn help_wraps_a_long_row_under_its_description() {
let keymap = Keymap::new("scope").bind(
"up",
keys("pageup"),
"threshold up (shift ×10, ctrl ×5, alt ×0.2)",
);
let cut = help_rows(&Help::new(&keymap), 30);
assert_eq!(cut.len(), 2, "{cut:?}");
let rows = help_rows(&Help::new(&keymap).wrap(true), 30);
assert_eq!(
rows,
[
"scope",
" pgup threshold up (shift",
" ×10, ctrl ×5, alt",
" ×0.2)",
]
);
assert_eq!(wrap_words("abcdefghij", 4), ["abcd", "efgh", "ij"]);
assert_eq!(wrap_words("", 4), [""]);
}
#[test]
fn help_wraps_a_long_key_list_between_keys() {
let keymap = Keymap::new("scope").bind(
"more",
keys("pageup shift+pageup ctrl+pageup alt+pageup"),
"average more",
);
let rows = help_rows(&Help::new(&keymap).wrap(true), 40);
assert_eq!(
rows,
[
"scope",
" pgup/shift+pgup/ average more",
" ctrl+pgup/alt+pgup",
]
);
assert_eq!(wrap_keys("<Up>, k, <C-p>", 8), ["<Up>, k,", "<C-p>"]);
assert_eq!(wrap_keys("abcdefghij", 4), ["abcd", "efgh", "ij"]);
assert_eq!(wrap_keys("a/b", 8), ["a/b"]);
}
#[test]
fn a_filter_key_makes_letters_move_until_it_starts_the_search() {
let console = rich::Console::new();
let context = Context {
console: &console,
width: 40,
height: 4,
};
let keymap = Keymap::new("demo")
.bind("a", keys("a"), "first")
.bind("b", keys("b"), "second")
.bind("c", keys("c"), "third")
.bind("d", keys("d"), "fourth");
let mut help = Help::new(&keymap)
.flat(true)
.filter_key(Key::parse("slash"));
help.render(&context);
let press = |help: &mut Help, name: &str| {
let key = Key::parse(name).unwrap();
help.handle(&Event::Key(key), &context)
};
assert!(matches!(press(&mut help, "u"), Flow::Continue));
assert_eq!(help.matches().len(), 4);
press(&mut help, "j");
assert_eq!(help.scroll.offset(), 1);
press(&mut help, "slash");
press(&mut help, "u");
assert_eq!(help.matches().len(), 1);
press(&mut help, "escape");
assert_eq!(help.matches().len(), 4);
assert!(matches!(press(&mut help, "escape"), Flow::Cancel));
}
#[test]
fn help_says_keys_its_own_way_and_lists_them_flat() {
let mut keymap = Keymap::new("a").bind("x", keys("ctrl+u"), "clear");
keymap.extend(Keymap::new("b").bind("y", keys("g"), "go"));
let help = Help::new(&keymap)
.key_format(|b| format!("<{}>", b.keys_label()))
.flat(true);
assert_eq!(
help_rows(&help, 30),
[" <ctrl+u> clear", " <g> go"]
);
assert_eq!(help.query("<g>").matches().len(), 1);
}
#[test]
fn palette_categories_show_their_icons() {
let palette = Palette::new([
Command::new("a", "deploy").category("ship"),
Command::new("b", "open").category("file"),
])
.category_icon("ship", &rich::Text::new("^^"));
let console = rich::Console::new();
let context = Context {
console: &console,
width: 40,
height: 6,
};
let view = palette.render(&context);
let rows: Vec<String> = view
.lines
.iter()
.map(|line| line.iter().map(|s| s.text.as_str()).collect::<String>())
.collect();
assert!(
rows.iter().any(|r| r.contains("^^ ship deploy")),
"{rows:?}"
);
assert!(rows.iter().any(|r| r.contains("file open")), "{rows:?}");
}
#[test]
fn a_shortcut_sheet_packs_columns() {
let sheet = Shortcuts::new(&keymap());
assert_eq!(sheet.grid(80), (2, 1));
assert_eq!(sheet.grid(10), (1, 2));
}
}