mod keys;
mod pickers;
mod table;
mod tools;
use std::path::PathBuf;
use std::sync::LazyLock;
use bevy_app::{App, PostStartup, Startup, Update};
use bevy_ecs::prelude::{
Commands, Entity, IntoScheduleConfigs, On, Res, ResMut, Resource, Single, With, World,
};
use bevy_ecs::system::{SystemId, SystemParam};
use bevy_input::ButtonState;
use bevy_input::keyboard::{Key, KeyboardInput};
use bevy_input_focus::FocusedInput;
use bevy_window::PrimaryWindow;
use plurimus::term::KeyModifiers;
use plurimus::term::bevy_compat::HeldModifiers;
use plurimus::ui::KeyBinding;
pub use table::COMMANDS;
use super::documents;
use super::journal;
use super::nav::{Page, PageSystems, ShownSurface};
use super::picker;
use super::scope::{KeyScope, Scoped};
use super::session::NO_DOCUMENT;
pub struct CommandSpec {
pub name: &'static str,
pub doc: &'static str,
pub keys: Vec<KeyBinding>,
pub hint: Option<&'static str>,
pub register: Register,
pub scope: Scope,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Scope {
Shell,
Anywhere,
Lists,
On(Page),
}
impl Scope {
pub fn covers(self, shown: Option<Page>) -> bool {
match self {
Self::Shell => true,
Self::Anywhere => shown.is_some(),
Self::Lists => shown
.filter(|page| page.is_domain())
.is_some_and(super::edit::lists_items),
Self::On(page) => shown == Some(page),
}
}
}
pub type Register = Box<dyn Fn(&mut World) -> SystemId<(), Outcome> + Send + Sync>;
#[derive(Clone, Debug, PartialEq, Eq)]
#[must_use = "a dropped refusal is one nobody hears"]
pub enum Outcome {
Done,
Refused(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CommandId(usize);
static KEY_LABELS: LazyLock<Vec<String>> = LazyLock::new(|| {
COMMANDS
.iter()
.map(|spec| spec.keys.first().map(keys::label).unwrap_or_default())
.collect()
});
impl CommandId {
pub fn spec(self) -> &'static CommandSpec {
&COMMANDS[self.0]
}
pub fn key_label(self) -> &'static str {
&KEY_LABELS[self.0]
}
}
pub fn all() -> impl Iterator<Item = CommandId> {
(0..COMMANDS.len()).map(CommandId)
}
pub fn named(name: &str) -> Option<CommandId> {
all().find(|command| command.spec().name == name)
}
pub fn defer_named(commands: &mut Commands, name: &'static str) {
commands.queue(move |world: &mut World| {
if let Some(command) = named(name) {
world.resource_mut::<Pending>().defer(command);
}
});
}
pub fn arrows_bound_on(page: Page) -> impl Iterator<Item = &'static Key> {
let is_plain = |binding: &KeyBinding| {
let held = binding.modifiers;
!(held.ctrl || held.alt || held.shift)
};
table::BINDINGS
.iter()
.filter(move |(binding, command)| {
command.spec().scope == Scope::On(page) && is_plain(binding)
})
.map(|(binding, _)| &binding.key)
.filter(|key| {
matches!(
key,
Key::ArrowUp | Key::ArrowDown | Key::ArrowLeft | Key::ArrowRight
)
})
}
pub fn page_hints(shown: Option<Page>) -> impl Iterator<Item = (&'static str, &'static str)> {
let live = move |command: &CommandId| command.spec().scope.covers(shown);
let is_particular =
|command: &CommandId| matches!(command.spec().scope, Scope::Lists | Scope::On(_));
let particular = all().filter(is_particular);
let general = all().filter(move |command| !is_particular(command));
particular
.chain(general)
.filter(live)
.filter_map(|command| Some((command.key_label(), command.spec().hint?)))
}
#[derive(Resource, Default)]
pub struct Pending(Option<Deferred>);
enum Deferred {
Command(CommandId),
Open(PathBuf),
}
impl Pending {
pub fn defer(&mut self, command: CommandId) {
self.0 = Some(Deferred::Command(command));
}
pub fn defer_open(&mut self, path: PathBuf) {
self.0 = Some(Deferred::Open(path));
}
}
fn run_pending(mut pending: ResMut<Pending>, registry: Res<Registry>, mut commands: Commands) {
match pending.0.take() {
Some(Deferred::Command(command)) => registry.run(&mut commands, command),
Some(Deferred::Open(path)) => {
commands.run_system_cached_with(documents::open, path.into());
}
None => {}
}
}
#[derive(Resource)]
pub struct Registry(Vec<SystemId<(), Outcome>>);
impl Registry {
pub fn run(&self, commands: &mut Commands, command: CommandId) {
let system = self.0[command.0];
commands.queue(move |world: &mut World| {
let spec = command.spec();
let shown = super::nav::shown_in(world);
if shown.is_none() && !spec.scope.covers(shown) {
journal::warn(NO_DOCUMENT);
return;
}
if let Ok(Outcome::Refused(reason)) = world.run_system(system) {
journal::warn(reason);
}
});
}
}
pub fn plugin(app: &mut App) {
app.init_resource::<Pending>();
app.add_systems(Startup, register);
app.add_systems(
Update,
run_pending.after(picker::Synced).in_set(PageSystems::Turn),
);
app.add_systems(PostStartup, watch_keys);
}
fn watch_keys(window: Single<Entity, With<PrimaryWindow>>, mut commands: Commands) {
commands.entity(*window).observe(handle_shell_key);
}
fn yields(scope: Option<KeyScope>, held: KeyModifiers) -> bool {
match scope {
Some(KeyScope::All) => false,
Some(KeyScope::Plain) => held.ctrl || held.alt,
None => true,
}
}
#[derive(SystemParam)]
struct Dispatch<'w> {
shown: ShownSurface<'w>,
registry: Res<'w, Registry>,
}
fn handle_shell_key(
mut input: On<FocusedInput<KeyboardInput>>,
held: HeldModifiers,
scoped: Scoped,
dispatch: Dispatch,
mut commands: Commands,
) {
let held = held.get();
if !yields(scoped.of(input.original_event_target()), held) {
return;
}
let Some(command) = bound_on(dispatch.shown.surface(), &input.input, held) else {
return;
};
input.propagate(false);
dispatch.registry.run(&mut commands, command);
}
fn bound_on(shown: Option<Page>, input: &KeyboardInput, held: KeyModifiers) -> Option<CommandId> {
if input.state != ButtonState::Pressed {
return None;
}
let bound = || {
table::BINDINGS
.iter()
.filter(|(binding, _)| binding.matches(input, held))
.map(|(_, command)| *command)
};
let is_own = |command: &CommandId| matches!(command.spec().scope, Scope::On(_));
bound()
.find(|command| is_own(command) && command.spec().scope.covers(shown))
.or_else(|| bound().find(|command| !is_own(command)))
}
fn register(world: &mut World) {
let systems = COMMANDS.iter().map(|spec| (spec.register)(world)).collect();
world.insert_resource(Registry(systems));
pickers::register(world);
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::*;
#[test]
fn a_field_gives_up_chords_and_an_overlay_gives_up_nothing() {
let plain = KeyModifiers::default();
let chord = KeyModifiers::default().with_ctrl(true);
assert!(yields(None, plain));
assert!(!yields(Some(KeyScope::Plain), plain));
assert!(yields(Some(KeyScope::Plain), chord));
assert!(!yields(Some(KeyScope::All), chord));
}
#[test]
fn names_are_unique_and_keys_are_unique_on_any_one_page() {
let names: BTreeSet<&str> = COMMANDS.iter().map(|spec| spec.name).collect();
assert_eq!(names.len(), COMMANDS.len());
let keys: Vec<(&KeyBinding, Scope)> = COMMANDS
.iter()
.flat_map(|spec| spec.keys.iter().map(|key| (key, spec.scope)))
.collect();
let are_apart = |a: Scope, b: Scope| match (a, b) {
(Scope::On(x), Scope::On(y)) => x != y,
(Scope::On(_), _) | (_, Scope::On(_)) => true,
_ => false,
};
for (index, (key, scope)) in keys.iter().enumerate() {
let twice = keys[..index]
.iter()
.any(|(earlier, held)| earlier == key && !are_apart(*scope, *held));
assert!(!twice, "{} is bound twice", keys::label(key));
}
}
#[test]
fn a_pages_row_takes_a_key_the_shell_binds_while_the_page_is_shown() {
use bevy_input::keyboard::{Key, KeyCode};
let esc = KeyboardInput {
key_code: KeyCode::Escape,
logical_key: Key::Escape,
state: ButtonState::Pressed,
text: None,
repeat: false,
window: Entity::PLACEHOLDER,
};
let on = |page: Page| {
let command = bound_on(Some(page), &esc, KeyModifiers::default());
command.map(|command| command.spec().name)
};
assert_eq!(on(Page::Ledger), Some("ledger-plan"));
assert_eq!(on(Page::Accounts), Some("domains"));
}
#[test]
fn a_key_two_pages_bind_runs_the_command_of_the_page_on_show() {
use bevy_input::keyboard::{Key, KeyCode};
let w = KeyboardInput {
key_code: KeyCode::KeyW,
logical_key: Key::Character("w".into()),
state: ButtonState::Pressed,
text: None,
repeat: false,
window: Entity::PLACEHOLDER,
};
let on = |page: Page| {
let command = bound_on(Some(page), &w, KeyModifiers::default());
command.map(|command| command.spec().name)
};
assert_eq!(on(Page::RothConversions), Some("write-ladder"));
assert_eq!(on(Page::SsaBenefits), Some("write-claims"));
assert_eq!(on(Page::Overview), None, "a page's key is its own");
}
#[test]
fn only_the_shell_scope_acts_without_a_document() {
assert!(Scope::Shell.covers(None));
assert!(!Scope::Anywhere.covers(None));
assert!(!Scope::Lists.covers(None));
assert!(Scope::Anywhere.covers(Some(Page::Overview)));
let scope_of = |name| {
COMMANDS
.iter()
.find(|spec| spec.name == name)
.unwrap()
.scope
};
for name in [
"open",
"quit",
"help",
"palette",
"focus-next",
"focus-previous",
] {
assert_eq!(scope_of(name), Scope::Shell, "{name}");
}
for name in ["save", "new", "tab-next", "go-to", "overview", "plan"] {
assert_eq!(scope_of(name), Scope::Anywhere, "{name}");
}
}
#[test]
fn every_command_documents_itself() {
for spec in COMMANDS.iter() {
assert!(!spec.doc.is_empty(), "{} has no doc", spec.name);
}
}
#[test]
fn a_page_is_hinted_the_commands_that_act_on_it() {
let words =
|page: Page| -> Vec<&str> { page_hints(Some(page)).map(|(_, word)| word).collect() };
let viewing = words(Page::Overview);
assert!(viewing.contains(&"save"), "{viewing:?}");
let composing: Vec<&str> = page_hints(None).map(|(_, word)| word).collect();
assert_eq!(composing, ["quit"], "nothing to save without a document");
assert!(!viewing.contains(&"add"), "nothing to add to: {viewing:?}");
assert!(words(Page::Accounts).contains(&"add"));
assert!(
!words(Page::Settings).contains(&"add"),
"a form, not a list"
);
let keyed = page_hints(Some(Page::Accounts)).all(|(key, _)| !key.is_empty());
assert!(keyed, "a hinted command names its key");
}
}