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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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, "")
|
||||
};
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user