use std::collections::BTreeMap;
use std::fmt;
use std::path::Path;
use std::str::FromStr;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::Deserialize;
use serde::de::{self, Deserializer, SeqAccess, Visitor};
use crate::frame::Binding;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Key {
code: KeyCode,
modifiers: KeyModifiers,
}
impl Key {
pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
let mut modifiers =
modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT);
let code = match code {
KeyCode::Tab if modifiers.contains(KeyModifiers::SHIFT) => {
modifiers.remove(KeyModifiers::SHIFT);
KeyCode::BackTab
}
KeyCode::BackTab => {
modifiers.remove(KeyModifiers::SHIFT);
KeyCode::BackTab
}
KeyCode::Char(c) => {
let shifted = modifiers.contains(KeyModifiers::SHIFT);
modifiers.remove(KeyModifiers::SHIFT);
if shifted && c.is_lowercase() {
KeyCode::Char(c.to_uppercase().next().unwrap_or(c))
} else {
KeyCode::Char(c)
}
}
other => other,
};
Self { code, modifiers }
}
fn is_ctrl_c(self) -> bool {
self.modifiers == KeyModifiers::CONTROL && self.code == KeyCode::Char('c')
}
fn modifier_prefix(modifiers: KeyModifiers) -> String {
let mut prefix = String::new();
for (flag, name) in [
(KeyModifiers::CONTROL, "Ctrl+"),
(KeyModifiers::ALT, "Alt+"),
(KeyModifiers::SHIFT, "Shift+"),
] {
if modifiers.contains(flag) {
prefix.push_str(name);
}
}
prefix
}
fn code_name(code: KeyCode) -> String {
match code {
KeyCode::Char(' ') => "space".into(),
KeyCode::Char(c) => c.to_string(),
KeyCode::Tab => "Tab".into(),
KeyCode::BackTab => "Shift+Tab".into(),
KeyCode::Enter => "↵".into(),
KeyCode::Esc => "Esc".into(),
KeyCode::Backspace => "Backspace".into(),
KeyCode::Delete => "Del".into(),
KeyCode::Insert => "Ins".into(),
KeyCode::Home => "Home".into(),
KeyCode::End => "End".into(),
KeyCode::PageUp => "PgUp".into(),
KeyCode::PageDown => "PgDn".into(),
KeyCode::Left => "←".into(),
KeyCode::Right => "→".into(),
KeyCode::Up => "↑".into(),
KeyCode::Down => "↓".into(),
KeyCode::F(n) => format!("F{n}"),
other => format!("{other:?}"),
}
}
}
impl From<KeyEvent> for Key {
fn from(event: KeyEvent) -> Self {
Self::new(event.code, event.modifiers)
}
}
impl fmt::Display for Key {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}{}",
Self::modifier_prefix(self.modifiers),
Self::code_name(self.code)
)
}
}
impl FromStr for Key {
type Err = String;
fn from_str(text: &str) -> Result<Self, Self::Err> {
let text = text.trim();
if text.is_empty() {
return Err("an empty key name; to unbind an action, write `[]`".into());
}
let (modifier_part, key_part) = if text == "+" {
("", "+")
} else if let Some(modifiers) = text.strip_suffix("++") {
(modifiers, "+")
} else {
text.rsplit_once('+').unwrap_or(("", text))
};
let mut modifiers = KeyModifiers::NONE;
if !modifier_part.is_empty() {
for word in modifier_part.split('+') {
modifiers |= match word.to_ascii_lowercase().as_str() {
"ctrl" | "control" => KeyModifiers::CONTROL,
"alt" | "option" | "opt" => KeyModifiers::ALT,
"shift" => KeyModifiers::SHIFT,
"cmd" | "command" | "super" | "win" | "meta" => {
return Err(format!(
"`{text}`: a terminal does not pass `{word}` through to \
the programs running in it; use ctrl or alt"
));
}
_ => return Err(format!("`{text}`: `{word}` is not a modifier")),
};
}
}
let mut chars = key_part.chars();
let code = match (chars.next(), chars.next()) {
(Some(c), None) => match c {
'←' => KeyCode::Left,
'→' => KeyCode::Right,
'↑' => KeyCode::Up,
'↓' => KeyCode::Down,
'↵' => KeyCode::Enter,
c if c.is_control() => {
return Err(format!("`{text}` is a control character, not a key"));
}
c => KeyCode::Char(c),
},
_ => named_key(key_part).ok_or_else(|| format!("`{key_part}` is not a key name"))?,
};
if modifiers.contains(KeyModifiers::SHIFT)
&& let KeyCode::Char(c) = code
&& !c.is_alphabetic()
{
return Err(format!(
"`{text}`: write the character Shift makes rather than `shift+{c}`"
));
}
Ok(Self::new(code, modifiers))
}
}
fn named_key(name: &str) -> Option<KeyCode> {
let lower = name.to_ascii_lowercase();
Some(match lower.as_str() {
"tab" => KeyCode::Tab,
"enter" | "return" => KeyCode::Enter,
"esc" | "escape" => KeyCode::Esc,
"space" => KeyCode::Char(' '),
"backspace" => KeyCode::Backspace,
"delete" | "del" => KeyCode::Delete,
"insert" | "ins" => KeyCode::Insert,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" | "pgup" => KeyCode::PageUp,
"pagedown" | "pgdn" => KeyCode::PageDown,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
_ => {
let n: u8 = lower.strip_prefix('f')?.parse().ok()?;
if !(1..=12).contains(&n) {
return None;
}
KeyCode::F(n)
}
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
FocusNext,
FocusPrevious,
Help,
Quit,
Panels,
Arrange,
Theme,
ResizeWider,
ResizeNarrower,
ResizeTaller,
ResizeShorter,
}
#[derive(Debug)]
pub struct Meta<A: 'static> {
pub action: A,
pub name: &'static str,
pub defaults: &'static [(KeyCode, KeyModifiers)],
pub label: &'static str,
pub primary: bool,
pub joins: bool,
pub about: &'static str,
}
impl<A> Meta<A> {
pub fn default_keys(&self) -> Vec<Key> {
self.defaults
.iter()
.map(|&(code, modifiers)| Key::new(code, modifiers))
.collect()
}
}
const ACTIONS: &[Meta<Action>] = &[
Meta {
action: Action::FocusNext,
name: "focus_next",
defaults: &[(KeyCode::Tab, KeyModifiers::NONE)],
label: "focus",
primary: true,
joins: false,
about: "move focus to the next panel",
},
Meta {
action: Action::Help,
name: "help",
defaults: &[(KeyCode::Char('?'), KeyModifiers::NONE)],
label: "keys",
primary: true,
joins: false,
about: "show the keys, then the key map",
},
Meta {
action: Action::Quit,
name: "quit",
defaults: &[(KeyCode::Char('q'), KeyModifiers::NONE)],
label: "quit",
primary: true,
joins: false,
about: "quit mirador",
},
Meta {
action: Action::Panels,
name: "panels",
defaults: &[(KeyCode::Char('w'), KeyModifiers::NONE)],
label: "panels",
primary: true,
joins: false,
about: "choose which panels are shown",
},
Meta {
action: Action::Arrange,
name: "arrange",
defaults: &[(KeyCode::Char('m'), KeyModifiers::NONE)],
label: "arrange",
primary: true,
joins: false,
about: "rearrange the panels",
},
Meta {
action: Action::Theme,
name: "theme",
defaults: &[(KeyCode::Char('t'), KeyModifiers::NONE)],
label: "theme",
primary: true,
joins: false,
about: "choose a theme",
},
Meta {
action: Action::ResizeWider,
name: "resize_wider",
defaults: &[(KeyCode::Right, KeyModifiers::CONTROL)],
label: "wider",
primary: true,
joins: false,
about: "widen the focused panel",
},
Meta {
action: Action::ResizeNarrower,
name: "resize_narrower",
defaults: &[(KeyCode::Left, KeyModifiers::CONTROL)],
label: "narrower",
primary: true,
joins: false,
about: "narrow the focused panel",
},
Meta {
action: Action::ResizeTaller,
name: "resize_taller",
defaults: &[(KeyCode::Down, KeyModifiers::CONTROL)],
label: "taller",
primary: true,
joins: false,
about: "make the focused panel taller",
},
Meta {
action: Action::ResizeShorter,
name: "resize_shorter",
defaults: &[(KeyCode::Up, KeyModifiers::CONTROL)],
label: "shorter",
primary: true,
joins: false,
about: "make the focused panel shorter",
},
Meta {
action: Action::FocusPrevious,
name: "focus_previous",
defaults: &[(KeyCode::BackTab, KeyModifiers::NONE)],
label: "focus back",
primary: false,
joins: false,
about: "move focus to the previous panel",
},
];
impl Action {
pub fn is_resize(self) -> bool {
matches!(
self,
Self::ResizeWider | Self::ResizeNarrower | Self::ResizeTaller | Self::ResizeShorter
)
}
fn meta(self) -> &'static Meta<Action> {
ACTIONS
.iter()
.find(|meta| meta.action == self)
.expect("every action has an entry in ACTIONS")
}
pub fn name(self) -> &'static str {
self.meta().name
}
pub fn about(self) -> &'static str {
self.meta().about
}
pub fn defaults(self) -> Vec<Key> {
self.meta().default_keys()
}
pub const LISTED: [Self; 11] = [
Self::FocusNext,
Self::FocusPrevious,
Self::Help,
Self::Quit,
Self::Panels,
Self::Arrange,
Self::Theme,
Self::ResizeWider,
Self::ResizeNarrower,
Self::ResizeTaller,
Self::ResizeShorter,
];
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct KeyList(pub Vec<Key>);
impl<'de> Deserialize<'de> for KeyList {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct KeyListVisitor;
impl<'de> Visitor<'de> for KeyListVisitor {
type Value = KeyList;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a key such as \"ctrl+left\", or a list of them")
}
fn visit_str<E: de::Error>(self, text: &str) -> Result<KeyList, E> {
text.parse()
.map(|key| KeyList(vec![key]))
.map_err(|e| E::custom(format!("in a key table, {e}")))
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<KeyList, A::Error> {
let mut keys = Vec::new();
while let Some(text) = seq.next_element::<String>()? {
keys.push(
text.parse()
.map_err(|e| de::Error::custom(format!("in a key table, {e}")))?,
);
}
Ok(KeyList(keys))
}
}
deserializer.deserialize_any(KeyListVisitor)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(transparent)]
pub struct KeysConfig(pub BTreeMap<String, KeyList>);
#[derive(Debug, Clone)]
pub struct Keymap {
keys: Vec<(Action, Vec<Key>)>,
bindings: Vec<Binding>,
}
impl Keymap {
pub fn new(config: &KeysConfig) -> Result<Self, String> {
for name in config.0.keys() {
if !ACTIONS.iter().any(|meta| meta.name == name) {
let names: Vec<&str> = ACTIONS.iter().map(|meta| meta.name).collect();
return Err(format!(
"`[keys]` has no action `{name}`. The actions are: {}.",
names.join(", ")
));
}
}
let mut keys: Vec<(Action, Vec<Key>)> = Vec::with_capacity(ACTIONS.len());
for meta in ACTIONS {
let list = written_or_default(meta, config);
for key in &list {
check_key("keys", meta.name, *key)?;
check_resize_key(meta, *key)?;
}
for (other, taken) in &keys {
if let Some(key) = list.iter().find(|key| taken.contains(key)) {
return Err(format!(
"`{key}` is bound to both `{}` and `{}` in `[keys]`. Give one \
of them another key, or unbind it with `{} = []`.",
other.name(),
meta.name,
if config.0.contains_key(meta.name) {
other.name()
} else {
meta.name
},
));
}
}
keys.push((meta.action, list));
}
let mut keymap = Self {
keys,
bindings: Vec::new(),
};
keymap.bindings = keymap.derive_bindings();
Ok(keymap)
}
pub fn action(&self, key: impl Into<Key>) -> Option<Action> {
let key = key.into();
self.keys
.iter()
.find(|(_, keys)| keys.contains(&key))
.map(|(action, _)| *action)
}
pub fn keys(&self, action: Action) -> &[Key] {
self.keys
.iter()
.find(|(candidate, _)| *candidate == action)
.map_or(&[], |(_, keys)| keys.as_slice())
}
pub fn is_default(&self, action: Action) -> bool {
self.keys(action) == action.defaults().as_slice()
}
pub fn bindings(&self) -> &[Binding] {
&self.bindings
}
pub fn resize_bindings(&self) -> Vec<Binding> {
let first = |action| self.keys(action).first().copied();
if let Some(text) = arrows_as_one([
first(Action::ResizeNarrower),
first(Action::ResizeWider),
first(Action::ResizeShorter),
first(Action::ResizeTaller),
]) {
return vec![Binding::owned(text, "resize", true)];
}
[
Action::ResizeWider,
Action::ResizeNarrower,
Action::ResizeTaller,
Action::ResizeShorter,
]
.into_iter()
.filter_map(|action| {
let meta = action.meta();
first(action).map(|key| Binding::owned(key.to_string(), meta.label, meta.primary))
})
.collect()
}
fn derive_bindings(&self) -> Vec<Binding> {
let mut bindings = Vec::new();
let mut aliases = Vec::new();
let mut resize_done = false;
for (action, keys) in &self.keys {
let meta = action.meta();
if action.is_resize() {
if !resize_done {
bindings.extend(self.resize_bindings());
resize_done = true;
}
aliases.extend(
keys.iter()
.skip(1)
.map(|key| Binding::owned(key.to_string(), meta.label, false)),
);
continue;
}
let mut keys = keys.iter();
if let Some(key) = keys.next() {
bindings.push(Binding::owned(key.to_string(), meta.label, meta.primary));
}
aliases.extend(keys.map(|key| Binding::owned(key.to_string(), meta.label, false)));
}
bindings.extend(aliases);
bindings.push(Binding::extra("1-9", "jump to panel"));
bindings.push(Binding::extra("Ctrl+C", "quit"));
bindings
}
}
impl Default for Keymap {
fn default() -> Self {
Self::new(&KeysConfig::default()).expect("the default keymap is valid")
}
}
pub fn reset_text(text: &str, note: &str) -> Result<Option<String>, String> {
let ending = crate::store::line_ending(text);
let mut lines: Vec<String> = Vec::new();
let mut in_keys = false;
let mut depth = 0i32;
let mut marked = false;
for line in text.lines() {
let trimmed = line.trim_start();
if depth == 0 && trimmed.starts_with('[') {
in_keys = is_keys_header(trimmed);
lines.push(line.to_string());
continue;
}
let live = depth > 0 || !(trimmed.is_empty() || trimmed.starts_with('#'));
if in_keys && live {
if !marked {
lines.push(format!("# {note}"));
marked = true;
}
depth += bracket_depth_change(line);
lines.push(format!("# {line}"));
} else {
lines.push(line.to_string());
}
}
let mut edited = lines.join(ending);
if text.ends_with('\n') {
edited.push_str(ending);
}
let refused = "the keys in this config are not all written under a `[keys]` \
or `[<panel>.keys]` heading, so they cannot be reset \
automatically; comment out the lines that set them by hand";
let parse = |text: &str| text.parse::<toml::Table>().map_err(|e| e.to_string());
let mut before = parse(text)?;
let mut after = parse(&edited).map_err(|_| refused.to_string())?;
take_key_tables(&mut before);
let left = take_key_tables(&mut after);
if before != after
|| left
.iter()
.any(|keys| keys.as_table().is_none_or(|t| !t.is_empty()))
{
return Err(refused.into());
}
Ok(marked.then_some(edited))
}
pub fn reset_file(path: &Path) -> Result<bool, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("could not read {}: {e}", path.display()))?;
let note = format!(
"Keys reset to the defaults by mirador on {}. What was set:",
jiff::Zoned::now().date()
);
match reset_text(&text, ¬e)? {
Some(edited) => crate::store::write_atomic(path, &edited)
.map(|()| true)
.map_err(|e| format!("{e:#}")),
None => Ok(false),
}
}
fn key_sections() -> impl Iterator<Item = &'static str> {
scopes().map(|scope| scope.widget)
}
fn take_key_tables(table: &mut toml::Table) -> Vec<toml::Value> {
let mut taken: Vec<toml::Value> = table.remove("keys").into_iter().collect();
for name in key_sections() {
if let Some(toml::Value::Table(section)) = table.get_mut(name) {
taken.extend(section.remove("keys"));
}
}
taken
}
fn is_keys_header(trimmed: &str) -> bool {
trimmed
.strip_prefix('[')
.filter(|rest| !rest.starts_with('['))
.and_then(|rest| rest.split_once(']'))
.is_some_and(|(name, after)| {
let name = name.trim();
let named = name == "keys"
|| name.split_once('.').is_some_and(|(widget, keys)| {
keys.trim() == "keys" && key_sections().any(|name| name == widget.trim())
});
named && {
let after = after.trim_start();
after.is_empty() || after.starts_with('#')
}
})
}
fn bracket_depth_change(line: &str) -> i32 {
let mut change = 0;
let mut quote: Option<char> = None;
let mut escaped = false;
for c in line.chars() {
match quote {
Some(q) => {
if escaped {
escaped = false;
} else if c == '\\' && q == '"' {
escaped = true;
} else if c == q {
quote = None;
}
}
None => match c {
'"' | '\'' => quote = Some(c),
'#' => break,
'[' => change += 1,
']' => change -= 1,
_ => {}
},
}
}
change
}
fn written_or_default<A>(meta: &Meta<A>, config: &KeysConfig) -> Vec<Key> {
let mut list: Vec<Key> = match config.0.get(meta.name) {
Some(KeyList(written)) => written.clone(),
None => meta.default_keys(),
};
let mut seen = Vec::new();
list.retain(|key| {
let first = !seen.contains(key);
seen.push(*key);
first
});
list
}
fn check_key(scope: &str, name: &str, key: Key) -> Result<(), String> {
if key.is_ctrl_c() {
return Err(format!(
"`{name}` cannot be Ctrl+C in `[{scope}]`: Ctrl+C always quits, whatever \
else is bound, so that there is always a way out."
));
}
if key.code == KeyCode::Esc {
return Err(format!(
"`{name}` cannot use Esc in `[{scope}]`: Esc always backs out of whatever \
is open, and is not in the keymap."
));
}
if key.modifiers.is_empty() && matches!(key.code, KeyCode::Char('1'..='9')) {
return Err(format!(
"`{name}` cannot be `{key}` in `[{scope}]`: 1-9 jump straight to a panel."
));
}
Ok(())
}
fn check_resize_key(meta: &Meta<Action>, key: Key) -> Result<(), String> {
if meta.action.is_resize()
&& !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
let name = meta.name;
return Err(format!(
"`{name}` is `{key}` in `[keys]`, and a resize key needs Ctrl or Alt \
held, as in \"alt+right\": resizing is read before the focused panel sees the \
key, so without one it would take `{key}` from every panel."
));
}
Ok(())
}
fn hints<A: 'static>(entries: &[(&Meta<A>, &[Key])]) -> (Vec<Binding>, Vec<Binding>) {
let mut bindings = Vec::new();
let mut aliases = Vec::new();
let mut index = 0;
while index < entries.len() {
let (meta, keys) = entries[index];
let joined = entries.get(index + 1).filter(|(next, _)| next.joins);
let (next, next_keys): (Option<&Meta<A>>, &[Key]) =
joined.map_or((None, &[]), |(next, keys)| (Some(*next), *keys));
let label = match next {
Some(next) if next.label != meta.label => format!("{} / {}", meta.label, next.label),
_ => meta.label.to_string(),
};
for position in 0..keys.len().max(next_keys.len()) {
let binding = match (keys.get(position), next_keys.get(position), next) {
(Some(key), Some(other), _) => Binding::owned(
joined_keys(*key, *other),
label.clone(),
meta.primary && position == 0,
),
(Some(key), None, _) => {
Binding::owned(key.to_string(), meta.label, meta.primary && position == 0)
}
(None, Some(other), Some(next)) => {
Binding::owned(other.to_string(), next.label, next.primary && position == 0)
}
(None, _, _) => continue,
};
if position == 0 {
bindings.push(binding);
} else {
aliases.push(binding);
}
}
index += if next.is_some() { 2 } else { 1 };
}
(bindings, aliases)
}
fn joined_keys(key: Key, other: Key) -> String {
arrow_pair(key, other, BareArrows::Apart).unwrap_or_else(|| format!("{key} / {other}"))
}
const ARROWS: [KeyCode; 4] = [KeyCode::Left, KeyCode::Right, KeyCode::Up, KeyCode::Down];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BareArrows {
Apart,
Joined,
}
fn arrow_pair(a: Key, b: Key, bare: BareArrows) -> Option<String> {
let arrows = ARROWS.contains(&a.code) && ARROWS.contains(&b.code);
let held = !a.modifiers.is_empty() || bare == BareArrows::Joined;
(arrows && held && a.modifiers == b.modifiers).then(|| {
format!(
"{}{}{}",
Key::modifier_prefix(a.modifiers),
Key::code_name(a.code),
Key::code_name(b.code)
)
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Listed {
pub name: &'static str,
pub keys: Vec<Key>,
pub defaults: Vec<Key>,
pub about: &'static str,
}
impl Listed {
pub fn is_default(&self) -> bool {
self.keys == self.defaults
}
}
pub type PanelListing = Vec<(&'static str, Vec<Listed>)>;
#[derive(Debug, Clone)]
pub struct PanelKeymap<A: 'static> {
actions: &'static [Meta<A>],
keys: Vec<Vec<Key>>,
bindings: Vec<Binding>,
}
impl<A: Copy + PartialEq> PanelKeymap<A> {
pub fn new(
widget: &str,
actions: &'static [Meta<A>],
config: &KeysConfig,
) -> Result<Self, String> {
let scope = format!("{widget}.keys");
for name in config.0.keys() {
if !actions.iter().any(|meta| meta.name == name) {
let names: Vec<&str> = actions.iter().map(|meta| meta.name).collect();
return Err(format!(
"`[{scope}]` has no action `{name}`. The actions are: {}.",
names.join(", ")
));
}
}
let mut keys: Vec<Vec<Key>> = Vec::with_capacity(actions.len());
for meta in actions {
let list = written_or_default(meta, config);
for key in &list {
check_key(&scope, meta.name, *key)?;
}
for (other, taken) in actions.iter().zip(&keys) {
if let Some(key) = list.iter().find(|key| taken.contains(key)) {
return Err(format!(
"`{key}` is bound to both `{}` and `{}` in `[{scope}]`. Give one \
of them another key, or unbind it with `{} = []`.",
other.name,
meta.name,
if config.0.contains_key(meta.name) {
other.name
} else {
meta.name
},
));
}
}
keys.push(list);
}
let entries: Vec<(&Meta<A>, &[Key])> =
actions.iter().zip(keys.iter().map(Vec::as_slice)).collect();
let (mut bindings, aliases) = hints(&entries);
bindings.extend(aliases);
Ok(Self {
actions,
keys,
bindings,
})
}
pub fn defaults(widget: &str, actions: &'static [Meta<A>]) -> Self {
Self::new(widget, actions, &KeysConfig::default())
.expect("a panel's default keys are valid")
}
pub fn or_defaults(widget: &str, actions: &'static [Meta<A>], config: &KeysConfig) -> Self {
Self::new(widget, actions, config).unwrap_or_else(|_| Self::defaults(widget, actions))
}
pub fn action(&self, key: impl Into<Key>) -> Option<A> {
let key = key.into();
self.actions
.iter()
.zip(&self.keys)
.find(|(_, keys)| keys.contains(&key))
.map(|(meta, _)| meta.action)
}
pub fn keys(&self, action: A) -> &[Key] {
self.actions
.iter()
.zip(&self.keys)
.find(|(meta, _)| meta.action == action)
.map_or(&[], |(_, keys)| keys.as_slice())
}
#[must_use]
pub fn with_fixed(mut self, fixed: &[Binding]) -> Self {
self.bindings.extend_from_slice(fixed);
self
}
pub fn bindings(&self) -> &[Binding] {
&self.bindings
}
pub fn listing(&self) -> Vec<Listed> {
self.actions
.iter()
.zip(&self.keys)
.map(|(meta, keys)| Listed {
name: meta.name,
keys: keys.clone(),
defaults: meta.default_keys(),
about: meta.about,
})
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArrangeAction {
MoveLeft,
MoveRight,
MoveUp,
MoveDown,
RowUp,
RowDown,
Keep,
FocusNext,
FocusPrevious,
}
const NONE: KeyModifiers = KeyModifiers::NONE;
pub const ARRANGE_ACTIONS: &[Meta<ArrangeAction>] = &[
Meta {
action: ArrangeAction::MoveLeft,
name: "move_left",
defaults: &[(KeyCode::Left, NONE), (KeyCode::Char('h'), NONE)],
label: "move",
primary: true,
joins: false,
about: "swap the panel with its left neighbour",
},
Meta {
action: ArrangeAction::MoveRight,
name: "move_right",
defaults: &[(KeyCode::Right, NONE), (KeyCode::Char('l'), NONE)],
label: "move",
primary: true,
joins: true,
about: "swap the panel with its right neighbour",
},
Meta {
action: ArrangeAction::MoveUp,
name: "move_up",
defaults: &[(KeyCode::Up, NONE), (KeyCode::Char('k'), NONE)],
label: "move",
primary: true,
joins: false,
about: "move up a row; past the top, a new row",
},
Meta {
action: ArrangeAction::MoveDown,
name: "move_down",
defaults: &[(KeyCode::Down, NONE), (KeyCode::Char('j'), NONE)],
label: "move",
primary: true,
joins: true,
about: "move down a row; past the bottom, a new row",
},
Meta {
action: ArrangeAction::RowUp,
name: "row_up",
defaults: &[
(KeyCode::Up, KeyModifiers::SHIFT),
(KeyCode::Char('K'), NONE),
],
label: "move row",
primary: true,
joins: false,
about: "move the panel's whole row up",
},
Meta {
action: ArrangeAction::RowDown,
name: "row_down",
defaults: &[
(KeyCode::Down, KeyModifiers::SHIFT),
(KeyCode::Char('J'), NONE),
],
label: "move row",
primary: true,
joins: true,
about: "move the panel's whole row down",
},
Meta {
action: ArrangeAction::Keep,
name: "keep",
defaults: &[
(KeyCode::Enter, NONE),
(KeyCode::Char('m'), NONE),
(KeyCode::Char('q'), NONE),
],
label: "keep",
primary: true,
joins: false,
about: "keep the arrangement and leave the mode",
},
Meta {
action: ArrangeAction::FocusNext,
name: "focus_next",
defaults: &[(KeyCode::Tab, NONE)],
label: "pick panel",
primary: false,
joins: false,
about: "pick the next panel, staying in the mode",
},
Meta {
action: ArrangeAction::FocusPrevious,
name: "focus_previous",
defaults: &[(KeyCode::BackTab, NONE)],
label: "pick panel",
primary: false,
joins: true,
about: "pick the previous panel, staying in the mode",
},
];
pub fn arrange_keymap(keys: &KeysConfig) -> Result<PanelKeymap<ArrangeAction>, String> {
PanelKeymap::new("arrange", ARRANGE_ACTIONS, keys)
}
fn arrows_as_one(keys: [Option<Key>; 4]) -> Option<String> {
let lead = keys[0]?;
keys.iter()
.zip(ARROWS)
.all(|(key, arrow)| {
key.is_some_and(|key| key.modifiers == lead.modifiers && key.code == arrow)
})
.then(|| {
let glyphs: String = ARROWS.into_iter().map(Key::code_name).collect();
format!("{}{glyphs}", Key::modifier_prefix(lead.modifiers))
})
}
fn pair(first: Option<Key>, second: Option<Key>) -> Option<String> {
match (first, second) {
(Some(a), Some(b)) => {
Some(arrow_pair(a, b, BareArrows::Joined).unwrap_or_else(|| format!("{a} / {b}")))
}
(Some(one), None) | (None, Some(one)) => Some(one.to_string()),
(None, None) => None,
}
}
pub fn arrange_legend(map: &PanelKeymap<ArrangeAction>, resize: Vec<Binding>) -> Vec<Binding> {
use ArrangeAction as A;
let first = |action| map.keys(action).first().copied();
let mut legend = Vec::new();
let moves = [A::MoveLeft, A::MoveRight, A::MoveUp, A::MoveDown];
if let Some(text) = arrows_as_one(moves.map(first)) {
legend.push(Binding::owned(text, "move", true));
} else {
for (action, way) in moves.into_iter().zip(["left", "right", "up", "down"]) {
if let Some(key) = first(action) {
legend.push(Binding::owned(key.to_string(), format!("move {way}"), true));
}
}
}
if let Some(key) = first(A::Keep) {
legend.push(Binding::owned(key.to_string(), "keep", true));
}
legend.push(Binding::primary("Esc", "cancel"));
if let Some(keys) = pair(first(A::RowUp), first(A::RowDown)) {
legend.push(Binding::owned(keys, "move row", true));
}
legend.extend(resize);
if let Some(keys) = pair(first(A::MoveUp), first(A::MoveDown)) {
legend.push(Binding::owned(format!("{keys} at edge"), "new row", true));
}
legend
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HelpAction {
Up,
Down,
PageUp,
PageDown,
First,
Last,
}
pub const HELP_ACTIONS: &[Meta<HelpAction>] = &[
Meta {
action: HelpAction::Up,
name: "up",
defaults: &[(KeyCode::Up, NONE), (KeyCode::Char('k'), NONE)],
label: "scroll",
primary: false,
joins: false,
about: "scroll the help up a line",
},
Meta {
action: HelpAction::Down,
name: "down",
defaults: &[(KeyCode::Down, NONE), (KeyCode::Char('j'), NONE)],
label: "scroll",
primary: false,
joins: true,
about: "scroll the help down a line",
},
Meta {
action: HelpAction::PageUp,
name: "page_up",
defaults: &[(KeyCode::PageUp, NONE)],
label: "page",
primary: false,
joins: false,
about: "scroll the help up a page",
},
Meta {
action: HelpAction::PageDown,
name: "page_down",
defaults: &[(KeyCode::PageDown, NONE)],
label: "page",
primary: false,
joins: true,
about: "scroll the help down a page",
},
Meta {
action: HelpAction::First,
name: "first",
defaults: &[(KeyCode::Home, NONE)],
label: "top",
primary: false,
joins: false,
about: "scroll the help to the top",
},
Meta {
action: HelpAction::Last,
name: "last",
defaults: &[(KeyCode::End, NONE)],
label: "bottom",
primary: false,
joins: true,
about: "scroll the help to the bottom",
},
];
pub fn help_keymap(keys: &KeysConfig) -> Result<PanelKeymap<HelpAction>, String> {
PanelKeymap::new("help_overlay", HELP_ACTIONS, keys)
}
pub fn help_scroll_hint(
map: &PanelKeymap<HelpAction>,
more_above: bool,
more_below: bool,
) -> Option<String> {
let up = map.keys(HelpAction::Up).first().copied();
let down = map.keys(HelpAction::Down).first().copied();
match (more_above, more_below) {
(true, true) => pair(up, down),
(true, false) => up.map(|key| key.to_string()),
_ => down.map(|key| key.to_string()),
}
}
pub const MODE_SCOPES: &[crate::widgets::KeyScope] = &[
crate::widgets::KeyScope {
widget: "arrange",
keys: |config| &config.arrange.keys,
keys_mut: |config| &mut config.arrange.keys,
listing: |keys| arrange_keymap(keys).map(|map| map.listing()),
},
crate::widgets::KeyScope {
widget: "panel_picker",
keys: |config| &config.panel_picker.keys,
keys_mut: |config| &mut config.panel_picker.keys,
listing: |keys| crate::picker::keymap(keys).map(|map| map.listing()),
},
crate::widgets::KeyScope {
widget: "theme_picker",
keys: |config| &config.theme_picker.keys,
keys_mut: |config| &mut config.theme_picker.keys,
listing: |keys| crate::theme_picker::keymap(keys).map(|map| map.listing()),
},
crate::widgets::KeyScope {
widget: "help_overlay",
keys: |config| &config.help_overlay.keys,
keys_mut: |config| &mut config.help_overlay.keys,
listing: |keys| help_keymap(keys).map(|map| map.listing()),
},
];
fn read_first(table: &str) -> &'static [Action] {
match table {
"panel_picker" | "theme_picker" => &[],
"help_overlay" => &[Action::Help],
_ => &[
Action::ResizeWider,
Action::ResizeNarrower,
Action::ResizeTaller,
Action::ResizeShorter,
],
}
}
fn scopes() -> impl Iterator<Item = &'static crate::widgets::KeyScope> {
MODE_SCOPES.iter().chain(crate::widgets::KEY_SCOPES)
}
#[derive(Debug, Clone, Default)]
pub struct KeyTables {
pub shell: KeysConfig,
pub scopes: BTreeMap<&'static str, KeysConfig>,
}
impl KeyTables {
pub fn from_config(config: &crate::config::Config) -> Self {
Self {
shell: config.keys.clone(),
scopes: scopes()
.map(|scope| (scope.widget, (scope.keys)(config).clone()))
.collect(),
}
}
pub fn check(&self) -> Result<(Keymap, PanelListing), String> {
let shell = Keymap::new(&self.shell)?;
let clash = |table: &str, listing: &[Listed]| -> Result<(), String> {
for &action in read_first(table) {
for key in shell.keys(action) {
let Some(entry) = listing.iter().find(|entry| entry.keys.contains(key)) else {
continue;
};
let why = if action.is_resize() {
"Resizing is read before anything else sees a key"
} else {
"The help key is read first there, to open the key map"
};
return Err(format!(
"`{}` is `{key}` in `[{table}.keys]`, which is `{}` in `[keys]`. \
{why}, so `{}` would never get it; give one of them another key.",
entry.name,
action.name(),
entry.name,
));
}
}
Ok(())
};
let empty = KeysConfig::default();
let mut listings = Vec::new();
for scope in scopes() {
let listing = (scope.listing)(self.scopes.get(scope.widget).unwrap_or(&empty))?;
clash(scope.widget, &listing)?;
listings.push((scope.widget, listing));
}
Ok((shell, listings))
}
pub fn apply(self, config: &mut crate::config::Config) {
let Self { shell, mut scopes } = self;
config.keys = shell;
for scope in self::scopes() {
*(scope.keys_mut)(config) = scopes.remove(scope.widget).unwrap_or_default();
}
}
}
pub fn read_keys(path: &Path) -> Result<(KeyTables, Keymap), String> {
#[derive(Deserialize)]
struct KeysOnly {
#[serde(default)]
keys: KeysConfig,
}
let text = std::fs::read_to_string(path)
.map_err(|e| format!("could not read {}: {e}", path.display()))?;
let mut table: toml::Table = text.parse().map_err(|e: toml::de::Error| e.to_string())?;
let shell = table
.remove("keys")
.map(toml::Value::try_into::<KeysConfig>)
.transpose()
.map_err(|e| format!("in `[keys]`: {e}"))?
.unwrap_or_default();
let read_section = |table: &mut toml::Table, name: &str| -> Result<KeysConfig, String> {
table
.remove(name)
.map(toml::Value::try_into::<KeysOnly>)
.transpose()
.map(|only| only.map(|only| only.keys).unwrap_or_default())
.map_err(|e| format!("in `[{name}.keys]`: {e}"))
};
let mut tables = KeyTables {
shell,
scopes: BTreeMap::new(),
};
for scope in scopes() {
tables
.scopes
.insert(scope.widget, read_section(&mut table, scope.widget)?);
}
let (keymap, _) = tables.check()?;
Ok((tables, keymap))
}
#[cfg(test)]
fn advertises(bindings: &[Binding], key: Key) -> bool {
let text = key.to_string();
let prefix = Key::modifier_prefix(key.modifiers);
let glyph = Key::code_name(key.code);
bindings.iter().any(|binding| {
binding.key == text
|| binding.key.split(" / ").any(|half| half == text)
|| (!prefix.is_empty()
&& binding.key.starts_with(&prefix)
&& binding.key[prefix.len()..].contains(&glyph))
})
}
#[cfg(test)]
pub(crate) fn keys_config(toml_text: &str) -> KeysConfig {
let mut tables: BTreeMap<String, KeysConfig> =
toml::from_str(toml_text).unwrap_or_else(|e| panic!("{e}"));
tables.remove("keys").expect("a [keys] table")
}
#[cfg(test)]
pub(crate) fn assert_every_key_works<A: Copy + PartialEq>(
map: &PanelKeymap<A>,
mut press: impl FnMut(KeyEvent) -> crate::panel::KeyOutcome,
) {
for (meta, keys) in map.actions.iter().zip(&map.keys) {
assert!(!keys.is_empty(), "`{}` has no default key", meta.name);
for key in keys {
assert!(
advertises(map.bindings(), *key),
"`{key}` ({}) is handled but not advertised: {:?}",
meta.name,
map.bindings()
);
assert_eq!(
press(KeyEvent::new(key.code, key.modifiers)),
crate::panel::KeyOutcome::Consumed,
"`{key}` ({}) is in the map but the panel ignores it",
meta.name
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::testing::TempDir;
fn key(text: &str) -> Key {
text.parse().unwrap_or_else(|e| panic!("{text:?}: {e}"))
}
fn event(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
#[test]
fn key_names_read_the_way_a_config_writes_them() {
assert_eq!(key("q"), Key::new(KeyCode::Char('q'), KeyModifiers::NONE));
assert_eq!(
key("ctrl+left"),
Key::new(KeyCode::Left, KeyModifiers::CONTROL)
);
assert_eq!(key("Ctrl+Left"), key("ctrl+left"));
assert_eq!(key("option+right"), key("alt+right"));
assert_eq!(
key("shift+tab"),
Key::new(KeyCode::BackTab, KeyModifiers::NONE)
);
assert_eq!(
key("space"),
Key::new(KeyCode::Char(' '), KeyModifiers::NONE)
);
assert_eq!(key("+"), Key::new(KeyCode::Char('+'), KeyModifiers::NONE));
assert_eq!(
key("ctrl++"),
Key::new(KeyCode::Char('+'), KeyModifiers::CONTROL)
);
assert_eq!(key("f5"), Key::new(KeyCode::F(5), KeyModifiers::NONE));
assert_eq!(key("shift+k"), key("K"));
assert_ne!(key("K"), key("k"), "a letter's case is the key");
}
#[test]
fn keys_that_cannot_arrive_are_refused_with_a_reason() {
for (text, says) in [
("", "empty"),
("cmd+left", "does not pass"),
("hyper+x", "not a modifier"),
("ctrl+", "not a key name"),
("banana", "not a key name"),
("f13", "not a key name"),
("shift+1", "the character Shift makes"),
] {
let error = text.parse::<Key>().expect_err(text);
assert!(error.contains(says), "{text:?} said {error:?}");
}
}
#[test]
fn a_printed_key_reads_back_as_itself() {
let codes = [
KeyCode::Char('q'),
KeyCode::Char('Q'),
KeyCode::Char('?'),
KeyCode::Char('+'),
KeyCode::Char(' '),
KeyCode::Char('é'),
KeyCode::Tab,
KeyCode::BackTab,
KeyCode::Enter,
KeyCode::Backspace,
KeyCode::Delete,
KeyCode::Insert,
KeyCode::Home,
KeyCode::End,
KeyCode::PageUp,
KeyCode::PageDown,
KeyCode::Left,
KeyCode::Right,
KeyCode::Up,
KeyCode::Down,
KeyCode::F(1),
KeyCode::F(12),
];
let modifiers = [
KeyModifiers::NONE,
KeyModifiers::CONTROL,
KeyModifiers::ALT,
KeyModifiers::SHIFT,
KeyModifiers::CONTROL | KeyModifiers::ALT,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
];
for code in codes {
for held in modifiers {
let original = Key::new(code, held);
let printed = original.to_string();
assert_eq!(
printed.parse::<Key>(),
Ok(original),
"{printed:?} did not read back"
);
}
}
}
#[test]
fn a_shifted_character_matches_however_the_terminal_reports_it() {
let map = Keymap::default();
for held in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
assert_eq!(
map.action(event(KeyCode::Char('?'), held)),
Some(Action::Help)
);
}
assert_eq!(
map.action(event(KeyCode::BackTab, KeyModifiers::SHIFT)),
Some(Action::FocusPrevious)
);
}
#[test]
fn the_default_map_is_the_keys_mirador_always_had() {
let map = Keymap::default();
let expected = [
(KeyCode::Tab, KeyModifiers::NONE, Action::FocusNext),
(KeyCode::BackTab, KeyModifiers::NONE, Action::FocusPrevious),
(KeyCode::Char('?'), KeyModifiers::NONE, Action::Help),
(KeyCode::Char('q'), KeyModifiers::NONE, Action::Quit),
(KeyCode::Char('w'), KeyModifiers::NONE, Action::Panels),
(KeyCode::Char('m'), KeyModifiers::NONE, Action::Arrange),
(KeyCode::Char('t'), KeyModifiers::NONE, Action::Theme),
(KeyCode::Right, KeyModifiers::CONTROL, Action::ResizeWider),
(KeyCode::Left, KeyModifiers::CONTROL, Action::ResizeNarrower),
(KeyCode::Down, KeyModifiers::CONTROL, Action::ResizeTaller),
(KeyCode::Up, KeyModifiers::CONTROL, Action::ResizeShorter),
];
for (code, held, action) in expected {
assert_eq!(map.action(event(code, held)), Some(action), "{code:?}");
}
assert_eq!(
ACTIONS.len(),
expected.len(),
"an action was added without a default in this table"
);
for unbound in [
event(KeyCode::Esc, KeyModifiers::NONE),
event(KeyCode::Char('c'), KeyModifiers::CONTROL),
event(KeyCode::Char('1'), KeyModifiers::NONE),
event(KeyCode::Right, KeyModifiers::NONE),
] {
assert_eq!(map.action(unbound), None, "{unbound:?}");
}
}
#[test]
fn the_default_hints_are_the_ones_the_shell_always_drew() {
let drawn: Vec<(String, String, bool)> = Keymap::default()
.bindings()
.iter()
.map(|b| (b.key.to_string(), b.action.to_string(), b.primary))
.collect();
let expected = [
("Tab", "focus", true),
("?", "keys", true),
("q", "quit", true),
("w", "panels", true),
("m", "arrange", true),
("t", "theme", true),
("Ctrl+←→↑↓", "resize", true),
("Shift+Tab", "focus back", false),
("1-9", "jump to panel", false),
("Ctrl+C", "quit", false),
]
.map(|(k, a, p)| (k.to_string(), a.to_string(), p));
assert_eq!(drawn, expected);
}
#[test]
fn resize_moves_off_ctrl_and_its_hint_follows() {
let map = Keymap::new(&keys_config(
r#"
[keys]
resize_wider = "alt+right"
resize_narrower = "alt+left"
resize_taller = "alt+down"
resize_shorter = "alt+up"
"#,
))
.expect("valid");
assert_eq!(
map.action(event(KeyCode::Right, KeyModifiers::ALT)),
Some(Action::ResizeWider)
);
assert_eq!(
map.action(event(KeyCode::Right, KeyModifiers::CONTROL)),
None,
"the old key is released, not kept as a second binding"
);
let resize = map.resize_bindings();
assert_eq!(resize.len(), 1);
assert_eq!(resize[0].key, "Alt+←→↑↓");
assert!(map.bindings().iter().any(|b| b.key == "Alt+←→↑↓"));
assert!(!map.bindings().iter().any(|b| b.key.contains("Ctrl+←")));
}
#[test]
fn resize_keys_of_mixed_shapes_are_hinted_one_by_one() {
let map = Keymap::new(&keys_config(
r#"
[keys]
resize_wider = "alt+l"
resize_narrower = "alt+h"
"#,
))
.expect("valid");
let hints: Vec<String> = map
.resize_bindings()
.iter()
.map(|b| format!("{} {}", b.key, b.action))
.collect();
assert_eq!(
hints,
[
"Alt+l wider",
"Alt+h narrower",
"Ctrl+↓ taller",
"Ctrl+↑ shorter"
]
);
}
#[test]
fn one_key_changed_leaves_the_rest_as_they_were() {
let map = Keymap::new(&keys_config("[keys]\ntheme = \"T\"")).expect("valid");
assert_eq!(
map.action(event(KeyCode::Char('T'), KeyModifiers::SHIFT)),
Some(Action::Theme)
);
assert_eq!(
map.action(event(KeyCode::Char('t'), KeyModifiers::NONE)),
None
);
for (action, keys) in &Keymap::default().keys {
if *action != Action::Theme {
assert_eq!(map.keys(*action), keys.as_slice(), "{action:?} moved");
}
}
}
#[test]
fn an_action_can_have_several_keys_or_none() {
let map =
Keymap::new(&keys_config("[keys]\nquit = [\"q\", \"x\"]\ntheme = []")).expect("valid");
for c in ['q', 'x'] {
assert_eq!(
map.action(event(KeyCode::Char(c), KeyModifiers::NONE)),
Some(Action::Quit)
);
}
let keys = map.keys(Action::Theme);
assert!(keys.is_empty(), "{keys:?}");
let hints = map.bindings();
assert!(hints.iter().any(|b| b.key == "q" && b.primary));
assert!(hints.iter().any(|b| b.key == "x" && !b.primary));
assert!(
!hints.iter().any(|b| b.action == "theme"),
"an unbound action is not advertised"
);
}
#[test]
fn the_way_out_cannot_be_bound() {
for (line, says) in [
("quit = \"ctrl+c\"", "Ctrl+C always quits"),
("help = \"esc\"", "Esc always backs out"),
("panels = \"3\"", "1-9 jump"),
] {
let error = Keymap::new(&keys_config(&format!("[keys]\n{line}"))).expect_err(line);
assert!(error.contains(says), "{line}: {error}");
}
}
#[test]
fn a_resize_key_must_hold_ctrl_or_alt() {
for line in ["resize_wider = \"l\"", "resize_taller = \"shift+down\""] {
let error = Keymap::new(&keys_config(&format!("[keys]\n{line}"))).expect_err(line);
assert!(error.contains("needs Ctrl or Alt"), "{line}: {error}");
}
Keymap::new(&keys_config("[keys]\nresize_wider = \"ctrl+shift+right\""))
.expect("Ctrl with Shift is fine");
}
#[test]
fn one_key_cannot_do_two_things() {
let error = Keymap::new(&keys_config("[keys]\ntheme = \"q\"")).expect_err("clash");
assert!(
error.contains("`q` is bound to both `quit` and `theme`")
&& error.contains("`quit = []`"),
"{error}"
);
Keymap::new(&keys_config("[keys]\ntheme = \"q\"\nquit = \"x\""))
.expect("moving the other one out of the way resolves it");
}
#[test]
fn a_misspelled_action_is_answered_with_the_real_ones() {
let error =
Keymap::new(&keys_config("[keys]\nresize_widder = \"alt+right\"")).expect_err("typo");
assert!(
error.contains("no action `resize_widder`") && error.contains("resize_wider"),
"{error}"
);
}
#[test]
fn the_shipped_config_documents_every_default_exactly() {
let shipped = crate::config::DEFAULT_CONFIG.replace("\r\n", "\n");
let block = shipped
.split_once("\n[keys]\n")
.expect("the shipped config has a [keys] section")
.1;
let uncommented = block
.lines()
.take_while(|line| !line.starts_with('['))
.filter_map(|line| line.strip_prefix("# "))
.filter(|line| !line.starts_with(' ') && line.contains('='))
.collect::<Vec<_>>()
.join("\n");
let written = keys_config(&format!("[keys]\n{uncommented}"));
assert_eq!(
written.0.len(),
ACTIONS.len(),
"every action is listed once: {:?}",
written.0.keys().collect::<Vec<_>>()
);
let documented = Keymap::new(&written).expect("the documented defaults are valid");
for (action, keys) in &Keymap::default().keys {
assert_eq!(documented.keys(*action), keys.as_slice(), "{action:?}");
}
}
const NOTE: &str = "Keys reset to the defaults by mirador on 2026-09-24. What was set:";
#[test]
fn a_reset_comments_out_the_keys_and_touches_nothing_else() {
let text = "[general]\nmouse = true # keep this\n\n[keys]\n# quit = \"q\"\n\
quit = [\n \"x\",\n \"[\", # a key that is a bracket\n]\n\
theme = \"T\"\n\n[network]\ninterfaces = []\n";
let edited = reset_text(text, NOTE)
.expect("resets")
.expect("something to do");
assert_eq!(
edited,
"[general]\nmouse = true # keep this\n\n[keys]\n# quit = \"q\"\n\
# Keys reset to the defaults by mirador on 2026-09-24. What was set:\n\
# quit = [\n# \"x\",\n# \"[\", # a key that is a bracket\n# ]\n\
# theme = \"T\"\n\n[network]\ninterfaces = []\n"
);
}
#[test]
fn a_literal_key_ending_in_a_backslash_closes_its_array() {
let text = "[keys]\nquit = ['\\', \"q\"]\n\n[clocks]\ntwelve_hour = true\n";
let edited = reset_text(text, NOTE).expect("resets").expect("changed");
assert_eq!(
edited,
format!("[keys]\n# {NOTE}\n# quit = ['\\', \"q\"]\n\n[clocks]\ntwelve_hour = true\n")
);
}
#[test]
fn a_reset_keeps_the_files_own_line_endings() {
let text = "[keys]\r\nquit = \"x\"\r\n";
let edited = reset_text(text, NOTE).expect("resets").expect("changed");
assert!(!edited.replace("\r\n", "").contains('\n'), "{edited:?}");
}
#[test]
fn a_config_that_sets_no_keys_has_nothing_to_reset() {
for text in ["[general]\nmouse = true\n", crate::config::DEFAULT_CONFIG] {
assert_eq!(reset_text(text, NOTE), Ok(None));
}
}
#[test]
fn keys_the_reset_cannot_find_are_refused_rather_than_half_reset() {
for text in [
"keys = { quit = \"x\" }\n",
"keys.quit = \"x\"\n[general]\nmouse = true\n",
"[[keys]]\nquit = \"x\"\n",
] {
let error = reset_text(text, NOTE).expect_err(text);
assert!(error.contains("by hand"), "{text}: {error}");
}
}
#[test]
fn a_reset_file_reads_back_as_the_default_keymap() {
let dir = TempDir::new("reset-keys");
let path = dir.join("config.toml");
std::fs::write(&path, "[keys]\nquit = \"x\"\n").expect("write");
let (_, before) = read_keys(&path).expect("reads");
assert!(!before.is_default(Action::Quit));
assert_eq!(reset_file(&path), Ok(true));
let (keys, after) = read_keys(&path).expect("reads");
assert!(keys.shell.0.is_empty(), "{keys:?}");
assert!(Action::LISTED.iter().all(|a| after.is_default(*a)));
assert_eq!(
reset_file(&path),
Ok(false),
"a second reset has nothing to do"
);
}
#[test]
fn a_reload_reports_a_bad_keymap_instead_of_applying_it() {
let dir = TempDir::new("read-keys");
let path = dir.join("config.toml");
std::fs::write(&path, "[keys]\ntheme = \"q\"\n").expect("write");
let error = read_keys(&path).expect_err("clash");
assert!(error.contains("bound to both"), "{error}");
}
#[test]
fn every_action_is_listed_once_in_the_dialog_order() {
for meta in ACTIONS {
assert_eq!(
Action::LISTED.iter().filter(|a| **a == meta.action).count(),
1,
"{}",
meta.name
);
}
}
#[test]
fn a_bad_key_name_is_reported_where_it_was_written() {
let error = toml::from_str::<BTreeMap<String, KeysConfig>>("[keys]\n\nquit = \"cmd+q\"\n")
.expect_err("cmd is not deliverable")
.to_string();
assert!(
error.contains("line 3") && error.contains("does not pass"),
"{error}"
);
assert!(error.contains("in a key table"), "{error}");
let listed = toml::from_str::<BTreeMap<String, KeysConfig>>("[keys]\nquit = [\"cmd+q\"]\n")
.expect_err("in a list too")
.to_string();
assert!(listed.contains("in a key table"), "{listed}");
}
fn cpu(toml_text: &str) -> Result<PanelKeymap<crate::widgets::cpu::CpuAction>, String> {
crate::widgets::cpu::keymap(&toml::from_str(toml_text).expect("a table"))
}
#[test]
fn a_panel_key_moves_and_its_hint_follows() {
use crate::widgets::cpu::CpuAction;
let map = cpu("per_core = \"p\"").expect("valid");
assert_eq!(map.action(key("p")), Some(CpuAction::PerCore));
assert_eq!(map.action(key("c")), None);
assert_eq!(map.bindings(), &[Binding::owned("p", "per-core", true)]);
let both = cpu("per_core = [\"p\", \"c\"]").expect("valid");
assert_eq!(both.action(key("c")), Some(CpuAction::PerCore));
assert_eq!(
both.bindings(),
&[
Binding::owned("p", "per-core", true),
Binding::owned("c", "per-core", false)
],
"the first key is the hint and the second an alias"
);
let none = cpu("per_core = []").expect("valid");
assert_eq!(none.action(key("c")), None);
assert!(
none.bindings().is_empty(),
"an unbound key is not advertised"
);
}
#[test]
fn a_panel_table_is_held_to_the_shells_rules_and_named_in_the_refusal() {
let unknown = cpu("percore = \"p\"").expect_err("misspelled");
assert!(
unknown.contains("[cpu.keys]") && unknown.contains("per_core"),
"{unknown}"
);
for (written, why) in [
("\"ctrl+c\"", "Ctrl+C"),
("\"esc\"", "Esc"),
("\"3\"", "1-9"),
] {
let error = cpu(&format!("per_core = {written}")).expect_err(written);
assert!(
error.contains(why) && error.contains("[cpu.keys]"),
"{written}: {error}"
);
}
}
#[test]
fn a_panel_key_may_share_a_shell_key_but_not_a_resize_key() {
let tables = |shell: &str, panel: &str| KeyTables {
shell: keys_config(&format!("[keys]\n{shell}")),
scopes: [("cpu", toml::from_str(panel).expect("a table"))].into(),
};
assert!(tables("", "per_core = \"t\"").check().is_ok());
let error = tables("", "per_core = \"ctrl+right\"")
.check()
.expect_err("resize reads it first");
assert!(
error.contains("[cpu.keys]") && error.contains("resize_wider"),
"{error}"
);
assert!(
tables("resize_wider = \"alt+right\"", "per_core = \"ctrl+right\"")
.check()
.is_ok(),
"once resize has moved, the key is free"
);
}
#[test]
fn every_panel_scope_is_a_widget_listed_once_with_valid_defaults() {
for scope in crate::widgets::KEY_SCOPES {
assert!(
crate::widgets::is_known_widget(scope.widget),
"{}",
scope.widget
);
assert_eq!(
crate::widgets::KEY_SCOPES
.iter()
.filter(|other| other.widget == scope.widget)
.count(),
1,
"{}",
scope.widget
);
let listing = (scope.listing)(&KeysConfig::default()).expect("defaults are valid");
assert!(!listing.is_empty(), "{} has no keys to move", scope.widget);
assert!(listing.iter().all(Listed::is_default));
}
let (_, panels) = KeyTables::default()
.check()
.expect("the defaults check out");
let tables: Vec<&str> = panels.iter().map(|(name, _)| *name).collect();
let expected: Vec<&str> = ["arrange", "panel_picker", "theme_picker", "help_overlay"]
.into_iter()
.chain(crate::widgets::KEY_SCOPES.iter().map(|scope| scope.widget))
.collect();
assert_eq!(
tables, expected,
"the shell's modes first, then each panel's"
);
}
fn arrange(toml_text: &str) -> Result<PanelKeymap<ArrangeAction>, String> {
arrange_keymap(&toml::from_str(toml_text).expect("a table"))
}
#[test]
fn the_default_arrange_map_is_the_keys_the_mode_always_had() {
use ArrangeAction as A;
let map = arrange("").expect("valid");
let shift = KeyModifiers::SHIFT;
for (code, held, action) in [
(KeyCode::Left, NONE, A::MoveLeft),
(KeyCode::Char('h'), NONE, A::MoveLeft),
(KeyCode::Right, NONE, A::MoveRight),
(KeyCode::Char('l'), NONE, A::MoveRight),
(KeyCode::Up, NONE, A::MoveUp),
(KeyCode::Char('k'), NONE, A::MoveUp),
(KeyCode::Down, NONE, A::MoveDown),
(KeyCode::Char('j'), NONE, A::MoveDown),
(KeyCode::Up, shift, A::RowUp),
(KeyCode::Char('K'), shift, A::RowUp),
(KeyCode::Down, shift, A::RowDown),
(KeyCode::Char('J'), NONE, A::RowDown),
(KeyCode::Enter, NONE, A::Keep),
(KeyCode::Char('m'), NONE, A::Keep),
(KeyCode::Char('q'), NONE, A::Keep),
(KeyCode::Tab, NONE, A::FocusNext),
(KeyCode::BackTab, shift, A::FocusPrevious),
] {
assert_eq!(
map.action(event(code, held)),
Some(action),
"{code:?} {held:?}"
);
}
assert_eq!(
map.action(event(KeyCode::Esc, NONE)),
None,
"Esc is not in the map"
);
}
#[test]
fn the_default_arrange_legend_is_the_one_the_mode_always_drew() {
let legend = arrange_legend(
&arrange("").expect("valid"),
Keymap::default().resize_bindings(),
);
let drawn: Vec<String> = legend
.iter()
.map(|b| format!("{} {}", b.key, b.action))
.collect();
assert_eq!(
drawn,
[
"←→↑↓ move",
"↵ keep",
"Esc cancel",
"Shift+↑↓ move row",
"Ctrl+←→↑↓ resize",
"↑↓ at edge new row",
]
);
}
#[test]
fn a_moved_arrange_key_is_shown_where_it_went() {
let map = arrange(
r#"
move_left = "a"
move_right = "d"
move_up = "w"
move_down = "s"
row_up = "W"
row_down = "S"
keep = "space"
"#,
)
.expect("valid");
let legend = arrange_legend(&map, Keymap::default().resize_bindings());
let drawn: Vec<String> = legend
.iter()
.map(|b| format!("{} {}", b.key, b.action))
.collect();
assert_eq!(
drawn,
[
"a move left",
"d move right",
"w move up",
"s move down",
"space keep",
"Esc cancel",
"W / S move row",
"Ctrl+←→↑↓ resize",
"w / s at edge new row",
]
);
assert_eq!(map.action(key("d")), Some(ArrangeAction::MoveRight));
assert_eq!(
map.action(event(KeyCode::Right, NONE)),
None,
"the old key is free"
);
}
#[test]
fn an_arrange_table_is_held_to_the_rules_and_kept_off_the_resize_keys() {
for (written, why) in [
("\"esc\"", "Esc"),
("\"ctrl+c\"", "Ctrl+C"),
("\"4\"", "1-9"),
] {
let error = arrange(&format!("keep = {written}")).expect_err(written);
assert!(
error.contains(why) && error.contains("[arrange.keys]"),
"{error}"
);
}
let misspelled = arrange("moveleft = \"a\"").expect_err("unknown");
assert!(misspelled.contains("move_left"), "{misspelled}");
let tables = |shell: &str, mode: &str| KeyTables {
shell: keys_config(&format!("[keys]\n{shell}")),
scopes: [("arrange", toml::from_str(mode).expect("a table"))].into(),
};
let error = tables("", "move_right = \"ctrl+right\"")
.check()
.expect_err("resize reads it first");
assert!(
error.contains("[arrange.keys]") && error.contains("resize_wider"),
"{error}"
);
assert!(
tables(
"resize_wider = \"alt+right\"",
"move_right = \"ctrl+right\""
)
.check()
.is_ok(),
"once resize has moved, the key is free"
);
}
#[test]
fn the_default_help_map_is_the_keys_the_overlay_always_scrolled_with() {
let map = help_keymap(&KeysConfig::default()).expect("valid");
for (code, action) in [
(KeyCode::Up, HelpAction::Up),
(KeyCode::Char('k'), HelpAction::Up),
(KeyCode::Down, HelpAction::Down),
(KeyCode::Char('j'), HelpAction::Down),
(KeyCode::PageUp, HelpAction::PageUp),
(KeyCode::PageDown, HelpAction::PageDown),
(KeyCode::Home, HelpAction::First),
(KeyCode::End, HelpAction::Last),
] {
assert_eq!(map.action(event(code, NONE)), Some(action), "{code:?}");
}
}
#[test]
fn a_help_overlay_key_may_not_be_the_help_key() {
let tables = |shell: &str, overlay: &str| KeyTables {
shell: keys_config(&format!("[keys]\n{shell}")),
scopes: [("help_overlay", toml::from_str(overlay).expect("a table"))].into(),
};
let error = tables("", "down = \"?\"").check().expect_err("read first");
assert!(
error.contains("[help_overlay.keys]")
&& error.contains("`help`")
&& error.contains("key map"),
"{error}"
);
assert!(tables("help = \"f1\"", "down = \"?\"").check().is_ok());
assert!(
tables("", "down = \"ctrl+down\"").check().is_ok(),
"resizing is not read ahead of the overlay"
);
}
#[test]
fn the_help_scroll_hint_follows_the_keys_and_the_position() {
let defaults = help_keymap(&KeysConfig::default()).expect("valid");
assert_eq!(
help_scroll_hint(&defaults, true, true).as_deref(),
Some("↑↓")
);
assert_eq!(
help_scroll_hint(&defaults, true, false).as_deref(),
Some("↑")
);
assert_eq!(
help_scroll_hint(&defaults, false, true).as_deref(),
Some("↓")
);
let moved = help_keymap(&toml::from_str("up = \"w\"\ndown = \"s\"").expect("a table"))
.expect("valid");
assert_eq!(
help_scroll_hint(&moved, true, true).as_deref(),
Some("w / s")
);
assert_eq!(help_scroll_hint(&moved, false, true).as_deref(), Some("s"));
let unbound = help_keymap(&toml::from_str("down = []").expect("a table")).expect("valid");
assert_eq!(help_scroll_hint(&unbound, false, true), None);
}
#[test]
fn the_shipped_config_documents_every_mode_and_panel_default_exactly() {
let shipped = crate::config::DEFAULT_CONFIG.replace("\r\n", "\n");
for scope in scopes() {
let heading = format!("\n[{}.keys]\n", scope.widget);
let block = shipped
.split_once(&heading)
.unwrap_or_else(|| panic!("the shipped config has no {}", heading.trim()))
.1;
let uncommented = block
.lines()
.map_while(|line| line.strip_prefix("# "))
.filter(|line| !line.starts_with(' ') && line.contains('='))
.collect::<Vec<_>>()
.join("\n");
let written: KeysConfig = toml::from_str(&uncommented).expect("valid TOML");
let documented = (scope.listing)(&written).expect("the documented keys are valid");
let defaults = (scope.listing)(&KeysConfig::default()).expect("valid");
assert_eq!(
written.0.len(),
defaults.len(),
"[{}.keys] lists every action once: {:?}",
scope.widget,
written.0.keys().collect::<Vec<_>>()
);
assert_eq!(documented, defaults, "{}", scope.widget);
}
}
#[test]
fn the_arrange_table_reloads_and_resets_with_the_others() {
let dir = TempDir::new("arrange-keys");
let path = dir.join("config.toml");
std::fs::write(
&path,
"[keys]\nquit = \"x\"\n\n[arrange.keys]\nkeep = \"space\"\n",
)
.expect("write");
let (tables, _) = read_keys(&path).expect("reads");
assert_eq!(
arrange_keymap(&tables.scopes["arrange"])
.expect("valid")
.action(key("space")),
Some(ArrangeAction::Keep)
);
assert_eq!(reset_file(&path), Ok(true));
let text = std::fs::read_to_string(&path).expect("read");
assert!(
text.contains("[arrange.keys]\n# keep = \"space\""),
"{text}"
);
let (tables, _) = read_keys(&path).expect("reads");
assert!(
tables.scopes["arrange"].0.is_empty() && tables.shell.0.is_empty(),
"{tables:?}"
);
}
#[test]
fn a_reset_comments_out_a_panels_keys_and_keeps_its_settings() {
let text = "[cpu]\nhistory = 5 # mine\n\n[cpu.keys]\nper_core = \"p\"\n\n\
[memory]\nshow_swap = false\n";
let edited = reset_text(text, NOTE).expect("resets").expect("changed");
assert_eq!(
edited,
format!(
"[cpu]\nhistory = 5 # mine\n\n[cpu.keys]\n# {NOTE}\n# per_core = \"p\"\n\n\
[memory]\nshow_swap = false\n"
)
);
let inline = "[cpu]\nhistory = 5\nkeys = { per_core = \"p\" }\n";
assert!(
reset_text(inline, NOTE)
.expect_err(inline)
.contains("by hand")
);
let other = "[general.keys]\nx = 1\n";
assert_eq!(reset_text(other, NOTE), Ok(None));
}
#[test]
fn a_reload_reads_panel_tables_and_reports_them_by_name() {
let dir = TempDir::new("panel-keys");
let path = dir.join("config.toml");
std::fs::write(
&path,
"[cpu]\nhistory = 5\n[cpu.keys]\nper_core = \"p\"\n[disk]\nshow_io = false\n",
)
.expect("write");
let (tables, _) = read_keys(&path).expect("reads");
assert_eq!(tables.scopes["cpu"].0.len(), 1);
assert!(tables.scopes["disk"].0.is_empty(), "no table, no keys");
std::fs::write(&path, "[cpu.keys]\nper_core = \"esc\"\n").expect("write");
let error = read_keys(&path).expect_err("Esc is never a key");
assert!(error.contains("[cpu.keys]"), "{error}");
}
}