use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::picker::{
value_spans, Line, LineContext, Picker, PickerItem, PickerStatus, TextComponent,
};
use super::settings::{Action, Keymap};
use super::{Editor, EditorAction, EditorEvent, OpenTarget, ViewKind};
pub enum SubmittedCommand {
Quit,
Restart,
}
pub fn submitted_command(input: &str) -> Option<SubmittedCommand> {
if input.contains('\n') {
return None;
}
match input.trim().trim_end_matches(';').trim_end() {
"exit" | "quit" => Some(SubmittedCommand::Quit),
"restart" => Some(SubmittedCommand::Restart),
_ => None,
}
}
type CommandCallback = fn(&mut Editor, OpenTarget) -> EditorAction;
#[derive(Clone, Copy)]
pub struct Command {
label: &'static str,
shortcut: Option<Action>,
splittable: bool,
callback: CommandCallback,
}
impl Command {
const fn new(
label: &'static str,
shortcut: Option<Action>,
splittable: bool,
callback: CommandCallback,
) -> Self {
Self {
label,
shortcut,
splittable,
callback,
}
}
fn matches(self, query: &str) -> bool {
self.label.starts_with(query)
|| self
.label
.split_whitespace()
.any(|word| word.starts_with(query))
}
fn item(self, keymap: &Keymap, split: bool) -> Box<dyn PickerItem> {
Box::new(CommandItem {
command: self,
hint: if self.splittable && !split {
"ctrl+enter open in split view".to_string()
} else {
self.shortcut
.and_then(|action| keymap.key_for(action))
.unwrap_or_default()
},
can_split: self.splittable && !split,
})
}
pub fn run(self, editor: &mut Editor, target: OpenTarget) -> EditorAction {
(self.callback)(editor, target)
}
pub const fn label(self) -> &'static str {
self.label
}
}
struct CommandItem {
command: Command,
hint: String,
can_split: bool,
}
impl TextComponent for CommandItem {
fn spans(&self, context: &LineContext<'_>) -> Vec<ratatui::text::Span<'static>> {
value_spans(self.command.label, &self.hint, true, context)
}
}
impl Line for CommandItem {}
impl PickerItem for CommandItem {
fn handle_key(&mut self, key: KeyEvent) -> Option<EditorEvent> {
if key.code != KeyCode::Enter {
return None;
}
let target = if key.modifiers.contains(KeyModifiers::CONTROL) && self.can_split {
OpenTarget::Split
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
return None;
} else {
OpenTarget::Current
};
Some(EditorEvent::RunCommand(self.command, target))
}
}
const COMMANDS: [Command; 11] = [
Command::new("settings", Some(Action::OpenSettings), false, open_settings),
Command::new("style", None, false, style),
Command::new("keymaps", None, false, open_keymaps),
Command::new("clear", None, false, clear),
Command::new("code", None, true, code),
Command::new("new", None, true, new),
Command::new("output", None, true, output),
Command::new("syntax tree", None, true, syntax_tree),
Command::new("restart", None, false, restart),
Command::new("quit", Some(Action::Quit), false, quit),
Command::new("debug", Some(Action::ToggleHighlightDebug), false, debug),
];
const CLOSE: Command = Command::new("close focused window", None, false, close);
pub fn matching(query: &str, split: bool) -> Vec<Command> {
let query = query.to_lowercase();
COMMANDS
.iter()
.chain(split.then_some(&CLOSE))
.copied()
.filter(|command| command.matches(&query))
.collect()
}
pub fn exact(input: &str, split: bool) -> Option<Command> {
let label = input.strip_prefix('/')?;
(!label.contains('\n'))
.then(|| matching(label.trim(), split))?
.into_iter()
.find(|command| command.label == label.trim())
}
fn picker_with_status(
commands: &[Command],
keymap: &Keymap,
split: bool,
status: PickerStatus,
) -> Picker {
Picker::new(
"commands",
commands
.iter()
.copied()
.map(|command| command.item(keymap, split))
.collect(),
status,
)
}
pub fn filtered_picker(commands: &[Command], keymap: &Keymap, split: bool) -> Picker {
picker_with_status(
commands,
keymap,
split,
PickerStatus::new("/ ", "type to filter · ↑↓ select · enter run · esc close"),
)
}
pub fn picker(commands: &[Command], keymap: &Keymap, split: bool) -> Picker {
picker_with_status(
commands,
keymap,
split,
PickerStatus::new("commands ", "↑↓ select · enter run · esc close"),
)
}
fn open_settings(editor: &mut Editor, _: OpenTarget) -> EditorAction {
editor.open_settings_from_commands();
EditorAction::Continue
}
fn style(editor: &mut Editor, _: OpenTarget) -> EditorAction {
editor.open_style_from_commands();
EditorAction::Continue
}
fn open_keymaps(editor: &mut Editor, _: OpenTarget) -> EditorAction {
editor.open_keymaps_from_commands();
EditorAction::Continue
}
fn clear(editor: &mut Editor, _: OpenTarget) -> EditorAction {
editor.clear_feed();
EditorAction::Continue
}
fn debug(editor: &mut Editor, _: OpenTarget) -> EditorAction {
editor.finish_command();
editor.toggle_highlight_debug();
EditorAction::Continue
}
fn open(editor: &mut Editor, target: OpenTarget, view: ViewKind) -> EditorAction {
editor.finish_command();
EditorAction::Open(view, target)
}
fn new(editor: &mut Editor, target: OpenTarget) -> EditorAction {
editor.finish_command();
EditorAction::New(target)
}
fn syntax_tree(editor: &mut Editor, target: OpenTarget) -> EditorAction {
open(editor, target, ViewKind::SyntaxTree)
}
fn code(editor: &mut Editor, target: OpenTarget) -> EditorAction {
open(editor, target, ViewKind::Code)
}
fn output(editor: &mut Editor, target: OpenTarget) -> EditorAction {
open(editor, target, ViewKind::Output)
}
fn close(editor: &mut Editor, _: OpenTarget) -> EditorAction {
editor.finish_command();
EditorAction::ClosePane
}
fn restart(editor: &mut Editor, _: OpenTarget) -> EditorAction {
editor.finish_command();
EditorAction::Restart
}
fn quit(editor: &mut Editor, _: OpenTarget) -> EditorAction {
editor.finish_command();
EditorAction::Quit
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_process_commands_are_intercepted() {
assert!(matches!(
submitted_command("exit"),
Some(SubmittedCommand::Quit)
));
assert!(matches!(
submitted_command(" quit ;"),
Some(SubmittedCommand::Quit)
));
assert!(matches!(
submitted_command("restart"),
Some(SubmittedCommand::Restart)
));
}
#[test]
fn macro_delimiters_and_m2_code_are_not_commands() {
assert!(submitted_command("$code").is_none());
assert!(submitted_command("$help").is_none());
assert!(submitted_command("exit 1").is_none());
assert!(submitted_command("f = () -> exit").is_none());
assert!(submitted_command("restart\n1 + 1").is_none());
}
#[test]
fn picker_entries_filter_by_word_prefix_in_the_visible_name() {
assert_eq!(matching("settings", false).len(), 1);
assert_eq!(matching("co", false).len(), 1);
assert_eq!(matching("syntax t", false).len(), 1);
assert_eq!(matching("ode", false).len(), 0);
assert!(matching("does-not-exist", false).is_empty());
assert!(matching("close", false).is_empty());
assert_eq!(matching("close", true).len(), 1);
assert_eq!(matching("style", false).len(), 1);
assert_eq!(exact("/style", false).unwrap().label(), "style");
assert!(exact("/sty", false).is_none());
}
#[test]
fn ctrl_enter_requests_a_split_only_for_splittable_unsplit_items() {
let keymap = Keymap::default();
let code = matching("code", false)[0];
let mut item = code.item(&keymap, false);
assert!(matches!(
item.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL)),
Some(EditorEvent::RunCommand(_, OpenTarget::Split))
));
let mut item = code.item(&keymap, true);
assert!(item
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL))
.is_none());
let settings = matching("settings", false)[0];
let mut item = settings.item(&keymap, false);
assert!(item
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL))
.is_none());
}
}