Command::NAMES as single source of truth for names/aliases; use in dispatch and completion

- Move Command enum to commands/mod.rs with NAMES, from_name, takes_path_arg
- Replace manual match in CommandAPI::execute with Command::from_name
- Derive canonical names for completion from Command::NAMES (first per variant)
- Simplify complete() to use Command::takes_path_arg instead of complete_path_arg
- Fix bug: 'h' was incorrectly listed as alias for both help and finder
This commit is contained in:
Greg Shuflin
2026-03-03 05:02:50 -08:00
parent 28191314b0
commit 55f316f028
6 changed files with 184 additions and 69 deletions
+4
View File
@@ -18,6 +18,10 @@
- [ ] `PanePosition::Main { tab }` field is unused — implement tab ordering for the main editing area
- [ ] `OverlayLifetime::Persistent` is unused — use it for long-lived status indicators (e.g. unsaved-changes marker, read-only indicator)
## Help
- [ ] Make help text more sophisticated — dynamically generated from keybindings and command definitions rather than a static string; could include sections for current mode, available commands with their aliases, and key bindings
## Styling
- [ ] `Theme` is never constructed — wire `Theme::default_theme()` into the renderer so `StyleName` variants resolve to actual `TextAttrs` instead of being looked up at each call site
+99 -23
View File
@@ -31,7 +31,11 @@ fn char_class(ch: char) -> CharClass {
/// A `\n` at `(x, y)` is consumed as a jump to `(0, y+1)` rather than staying on the newline.
fn next_pos(x: usize, y: usize, buffer: &dyn BufferQuery) -> Option<(usize, usize)> {
if buffer.char_at(x, y) == Some('\n') {
if y + 1 < buffer.line_count() { Some((0, y + 1)) } else { None }
if y + 1 < buffer.line_count() {
Some((0, y + 1))
} else {
None
}
} else {
let line_len = buffer.line_len(y);
if x + 1 < line_len {
@@ -190,7 +194,9 @@ impl Configuration {
},
Input::Newline => match (mode, properties.command) {
(Mode::Insert, true) => EditorAction::Command(CommandAction::Execute),
(Mode::Insert, false) => EditorAction::Edit(EditAction::InsertText("\n".to_string())),
(Mode::Insert, false) => {
EditorAction::Edit(EditAction::InsertText("\n".to_string()))
}
_ => return vec![],
},
Input::Backspace => match mode {
@@ -202,14 +208,18 @@ impl Configuration {
EditorAction::Focus(FocusAction::Previous)
} else {
match mode {
Mode::Insert => EditorAction::Edit(EditAction::TransitionToMode(Mode::Normal)),
Mode::Insert => {
EditorAction::Edit(EditAction::TransitionToMode(Mode::Normal))
}
Mode::Normal => return vec![],
}
}
}
Input::Tab => match (mode, properties.command) {
(Mode::Insert, true) => EditorAction::Command(CommandAction::Complete),
(Mode::Insert, false) => EditorAction::Edit(EditAction::InsertText("\t".to_string())),
(Mode::Insert, false) => {
EditorAction::Edit(EditAction::InsertText("\t".to_string()))
}
_ => return vec![],
},
input => {
@@ -266,7 +276,10 @@ impl Configuration {
let new_line_len = buffer.line_len(new_y);
// Clamp x to the length of the new line
let new_x = x.min(new_line_len);
Some(EditorAction::Edit(EditAction::MoveCursor { x: new_x, y: new_y }))
Some(EditorAction::Edit(EditAction::MoveCursor {
x: new_x,
y: new_y,
}))
} else {
None // Already at first line
}
@@ -280,7 +293,10 @@ impl Configuration {
let new_line_len = buffer.line_len(new_y);
// Clamp x to the length of the new line
let new_x = x.min(new_line_len);
Some(EditorAction::Edit(EditAction::MoveCursor { x: new_x, y: new_y }))
Some(EditorAction::Edit(EditAction::MoveCursor {
x: new_x,
y: new_y,
}))
} else {
None // Already at last line
}
@@ -307,7 +323,10 @@ impl Configuration {
fn move_to_last_line(&self, buffer: &dyn BufferQuery) -> Option<EditorAction> {
let last_y = buffer.line_count().saturating_sub(1);
Some(EditorAction::Edit(EditAction::MoveCursor { x: 0, y: last_y }))
Some(EditorAction::Edit(EditAction::MoveCursor {
x: 0,
y: last_y,
}))
}
/// `w`: move to the start of the next word.
@@ -335,9 +354,16 @@ impl Configuration {
}
// Skip whitespace to land at the start of the next word
while buffer.char_at(x, y).map(|c| c.is_whitespace()).unwrap_or(false) {
while buffer
.char_at(x, y)
.map(|c| c.is_whitespace())
.unwrap_or(false)
{
match next_pos(x, y, buffer) {
Some((nx, ny)) => { x = nx; y = ny; }
Some((nx, ny)) => {
x = nx;
y = ny;
}
None => break,
}
}
@@ -350,7 +376,10 @@ impl Configuration {
/// Same as `word_forward` but treats any non-whitespace run as a single word.
fn bigword_forward(&self, buffer: &dyn BufferQuery) -> Option<EditorAction> {
let (mut x, mut y) = buffer.cursor();
let on_whitespace = buffer.char_at(x, y).map(|c| c.is_whitespace()).unwrap_or(true);
let on_whitespace = buffer
.char_at(x, y)
.map(|c| c.is_whitespace())
.unwrap_or(true);
// Skip past the current non-whitespace run (if not already on whitespace)
if !on_whitespace {
@@ -359,7 +388,11 @@ impl Configuration {
Some((nx, ny)) => {
x = nx;
y = ny;
if buffer.char_at(x, y).map(|c| c.is_whitespace()).unwrap_or(true) {
if buffer
.char_at(x, y)
.map(|c| c.is_whitespace())
.unwrap_or(true)
{
break;
}
}
@@ -369,9 +402,16 @@ impl Configuration {
}
// Skip whitespace to land at the start of the next WORD
while buffer.char_at(x, y).map(|c| c.is_whitespace()).unwrap_or(false) {
while buffer
.char_at(x, y)
.map(|c| c.is_whitespace())
.unwrap_or(false)
{
match next_pos(x, y, buffer) {
Some((nx, ny)) => { x = nx; y = ny; }
Some((nx, ny)) => {
x = nx;
y = ny;
}
None => break,
}
}
@@ -388,9 +428,16 @@ impl Configuration {
let (mut x, mut y) = prev_pos(x, y, buffer)?;
// Skip whitespace going backward (including newlines at line ends)
while buffer.char_at(x, y).map(|c| c.is_whitespace()).unwrap_or(false) {
while buffer
.char_at(x, y)
.map(|c| c.is_whitespace())
.unwrap_or(false)
{
match prev_pos(x, y, buffer) {
Some((nx, ny)) => { x = nx; y = ny; }
Some((nx, ny)) => {
x = nx;
y = ny;
}
None => return Some(EditorAction::Edit(EditAction::MoveCursor { x: 0, y: 0 })),
}
}
@@ -422,9 +469,16 @@ impl Configuration {
let (mut x, mut y) = prev_pos(x, y, buffer)?;
// Skip whitespace going backward
while buffer.char_at(x, y).map(|c| c.is_whitespace()).unwrap_or(false) {
while buffer
.char_at(x, y)
.map(|c| c.is_whitespace())
.unwrap_or(false)
{
match prev_pos(x, y, buffer) {
Some((nx, ny)) => { x = nx; y = ny; }
Some((nx, ny)) => {
x = nx;
y = ny;
}
None => return Some(EditorAction::Edit(EditAction::MoveCursor { x: 0, y: 0 })),
}
}
@@ -433,7 +487,11 @@ impl Configuration {
loop {
match prev_pos(x, y, buffer) {
Some((nx, ny)) => {
if buffer.char_at(nx, ny).map(|c| !c.is_whitespace()).unwrap_or(false) {
if buffer
.char_at(nx, ny)
.map(|c| !c.is_whitespace())
.unwrap_or(false)
{
x = nx;
y = ny;
} else {
@@ -456,9 +514,16 @@ impl Configuration {
let (mut x, mut y) = next_pos(x, y, buffer)?;
// Skip whitespace
while buffer.char_at(x, y).map(|c| c.is_whitespace()).unwrap_or(false) {
while buffer
.char_at(x, y)
.map(|c| c.is_whitespace())
.unwrap_or(false)
{
match next_pos(x, y, buffer) {
Some((nx, ny)) => { x = nx; y = ny; }
Some((nx, ny)) => {
x = nx;
y = ny;
}
None => return Some(EditorAction::Edit(EditAction::MoveCursor { x, y })),
}
}
@@ -491,9 +556,16 @@ impl Configuration {
let (mut x, mut y) = next_pos(x, y, buffer)?;
// Skip whitespace
while buffer.char_at(x, y).map(|c| c.is_whitespace()).unwrap_or(false) {
while buffer
.char_at(x, y)
.map(|c| c.is_whitespace())
.unwrap_or(false)
{
match next_pos(x, y, buffer) {
Some((nx, ny)) => { x = nx; y = ny; }
Some((nx, ny)) => {
x = nx;
y = ny;
}
None => return Some(EditorAction::Edit(EditAction::MoveCursor { x, y })),
}
}
@@ -502,7 +574,11 @@ impl Configuration {
loop {
match next_pos(x, y, buffer) {
Some((nx, ny)) => {
if buffer.char_at(nx, ny).map(|c| !c.is_whitespace()).unwrap_or(false) {
if buffer
.char_at(nx, ny)
.map(|c| !c.is_whitespace())
.unwrap_or(false)
{
x = nx;
y = ny;
} else {
+6 -17
View File
@@ -11,6 +11,8 @@ use crate::core::{
EditorAction,
};
use super::Command;
// Well-known buffer IDs for standard panes
// TODO: these should probably be managed more dynamically
const HELP_BUFFER_ID: BufferId = BufferId(100);
@@ -43,13 +45,10 @@ impl CommandAPI {
pub(crate) fn execute(&mut self, command_str: String) -> Vec<EditorAction> {
let mut commands = command_str.split_whitespace();
let operation = match commands.next() {
Some(s) if s == "open" => Command::Open,
Some(s) if s == "quit" || s == "q" => Command::Quit,
Some(s) if s == "write" || s == "w" => Command::Write,
Some(s) if s == "help" || s == "h" => Command::Help,
Some(s) if s == "finder" || s == "h" => Command::Finder,
Some(s) if s == "list" || s == "ls" => Command::List,
Some(other) => return vec![EditorAction::Error(format!("Invalid command: {other}"))],
Some(s) => match Command::from_name(s) {
Some(cmd) => cmd,
None => return vec![EditorAction::Error(format!("Invalid command: {s}"))],
},
None => return vec![],
};
@@ -135,13 +134,3 @@ impl CommandAPI {
}
}
}
#[derive(Debug)]
enum Command {
Open,
Quit,
Write,
Help,
Finder,
List,
}
+32 -27
View File
@@ -1,8 +1,4 @@
/// Completion logic for command-mode input.
/// Canonical command names used for name completion. Short aliases (`q`, `w`,
/// `h`, `ls`) are intentionally excluded — they're already minimal.
const COMMAND_NAMES: &[&str] = &["finder", "help", "list", "open", "quit", "write"];
//! Completion logic for command-mode input.
/// Complete a partial command string, returning the completed version or `None`
/// if no completion is possible.
@@ -11,15 +7,20 @@ const COMMAND_NAMES: &[&str] = &["finder", "help", "list", "open", "quit", "writ
/// - No space yet → complete the command name.
/// - Space present → complete the path argument (for commands that take one).
pub(crate) fn complete(command_text: &str) -> Option<String> {
if command_text.contains(' ') {
complete_path_arg(command_text, &["open"])
.or_else(|| complete_path_arg(command_text, &["w", "write"]))
if let Some((cmd_word, partial_path)) = command_text.split_once(' ') {
let cmd = super::Command::from_name(cmd_word)?;
if !cmd.takes_path_arg() {
return None;
}
let completed = complete_path(partial_path)?;
Some(format!("{cmd_word} {completed}"))
} else {
complete_command_name(command_text)
}
}
/// Complete a partial command name against `COMMAND_NAMES`.
/// Complete a partial command name against the canonical command names derived
/// from [`super::Command::NAMES`].
///
/// A single match is returned with a trailing space (ready to type an
/// argument). Multiple matches are reduced to their longest common prefix.
@@ -28,9 +29,24 @@ fn complete_command_name(partial: &str) -> Option<String> {
if partial.is_empty() {
return None;
}
let candidates: Vec<&str> = COMMAND_NAMES
// Collect canonical names: the first occurrence of each Command variant in
// NAMES (canonical names are listed before aliases).
let mut seen: Vec<super::Command> = Vec::new();
let canonical: Vec<&str> = super::Command::NAMES
.iter()
.copied()
.filter_map(|(name, cmd)| {
if seen.contains(cmd) {
None
} else {
seen.push(*cmd);
Some(*name)
}
})
.collect();
let candidates: Vec<&str> = canonical
.into_iter()
.filter(|name| name.starts_with(partial))
.collect();
@@ -54,21 +70,6 @@ fn complete_command_name(partial: &str) -> Option<String> {
}
}
/// Complete the path argument of any command that takes a single path.
///
/// Tries each name in `command_names` as a prefix; on a match, delegates to
/// `complete_path` and reassembles the result as `"<cmd> <completed-path>"`.
fn complete_path_arg(command_text: &str, command_names: &[&str]) -> Option<String> {
let (cmd_name, partial_path) = command_names.iter().find_map(|&name| {
command_text
.strip_prefix(&format!("{name} "))
.map(|rest| (name, rest))
})?;
let completed = complete_path(partial_path)?;
Some(format!("{cmd_name} {completed}"))
}
/// Complete a partial filesystem path.
///
/// Splits on the last `/` to determine the directory to list and the partial
@@ -79,7 +80,11 @@ fn complete_path_arg(command_text: &str, command_names: &[&str]) -> Option<Strin
/// Returns `None` if no candidates match.
fn complete_path(partial_path: &str) -> Option<String> {
let (search_dir, file_prefix, path_prefix) = if let Some(slash) = partial_path.rfind('/') {
(&partial_path[..=slash], &partial_path[slash + 1..], &partial_path[..=slash])
(
&partial_path[..=slash],
&partial_path[slash + 1..],
&partial_path[..=slash],
)
} else {
(".", partial_path, "")
};
+39 -1
View File
@@ -10,6 +10,45 @@ use self::api::CommandAPI;
use super::EditorAction;
/// All recognized command names and their short aliases, mapping to the
/// [`Command`] variant they invoke. Canonical (display) names are listed
/// first for each variant; aliases follow.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Command {
Finder,
Help,
List,
Open,
Quit,
Write,
}
impl Command {
pub(super) const NAMES: &'static [(&'static str, Command)] = &[
("finder", Command::Finder),
("help", Command::Help),
("h", Command::Help),
("list", Command::List),
("ls", Command::List),
("open", Command::Open),
("quit", Command::Quit),
("q", Command::Quit),
("write", Command::Write),
("w", Command::Write),
];
pub(super) fn from_name(name: &str) -> Option<Self> {
Self::NAMES
.iter()
.find(|(n, _)| *n == name)
.map(|(_, c)| *c)
}
pub(super) fn takes_path_arg(self) -> bool {
matches!(self, Command::Open | Command::Write)
}
}
#[derive(Debug)]
pub(crate) struct Commands {
pub(crate) command_api: CommandAPI,
@@ -45,5 +84,4 @@ impl Commands {
}
}
}
}
+4 -1
View File
@@ -272,7 +272,10 @@ impl EditorCore {
#[tracing::instrument(skip(self))]
pub(crate) fn handle_action(&mut self, action: EditorAction) {
// Clear UntilNextAction overlays on any action except AddOverlay
if !matches!(action, EditorAction::Buffer(BufferAction::AddOverlay { .. })) {
if !matches!(
action,
EditorAction::Buffer(BufferAction::AddOverlay { .. })
) {
for buf in self.buffers.values_mut() {
buf.clear_until_next_action_overlays();
}