diff --git a/app/src/context.rs b/app/src/context.rs index 652f255b..83e8dcd5 100644 --- a/app/src/context.rs +++ b/app/src/context.rs @@ -1,4 +1,4 @@ -use core::{input::Input, manager::Manager, select::Select, tasks::Tasks, which::Which, Position}; +use core::{help::Help, input::Input, manager::Manager, select::Select, tasks::Tasks, which::Which, Position}; use config::keymap::KeymapLayer; use crossterm::terminal::WindowSize; @@ -8,8 +8,9 @@ use shared::Term; pub struct Ctx { pub manager: Manager, pub which: Which, - pub select: Select, + pub help: Help, pub input: Input, + pub select: Select, pub tasks: Tasks, } @@ -18,8 +19,9 @@ impl Ctx { Self { manager: Manager::make(), which: Default::default(), - select: Default::default(), + help: Default::default(), input: Default::default(), + select: Default::default(), tasks: Tasks::start(), } } @@ -60,6 +62,9 @@ impl Ctx { let Rect { x, y, .. } = self.area(&self.input.position); return Some((x + 1 + self.input.cursor(), y + 1)); } + if let Some((x, y)) = self.help.cursor() { + return Some((x, y)); + } None } @@ -67,6 +72,8 @@ impl Ctx { pub(super) fn layer(&self) -> KeymapLayer { if self.which.visible { KeymapLayer::Which + } else if self.help.visible() { + KeymapLayer::Help } else if self.input.visible { KeymapLayer::Input } else if self.select.visible { @@ -80,6 +87,6 @@ impl Ctx { #[inline] pub(super) fn image_layer(&self) -> bool { - !matches!(self.layer(), KeymapLayer::Which | KeymapLayer::Tasks) + !matches!(self.layer(), KeymapLayer::Which | KeymapLayer::Help | KeymapLayer::Tasks) } } diff --git a/app/src/executor.rs b/app/src/executor.rs index 6028691a..f1b0f3b1 100644 --- a/app/src/executor.rs +++ b/app/src/executor.rs @@ -15,13 +15,15 @@ impl Executor { return cx.which.press(key); } - if layer == KeymapLayer::Input && cx.input.mode() == InputMode::Insert { - if let Some(c) = key.plain() { - return cx.input.type_(c); - } + if layer == KeymapLayer::Input && cx.input.type_(&key) { + return true; } - for Control { on, exec } in KEYMAP.get(layer) { + if layer == KeymapLayer::Help && cx.help.type_(&key) { + return true; + } + + for Control { on, exec, .. } in KEYMAP.get(layer) { if on.is_empty() || on[0] != key { continue; } @@ -44,6 +46,7 @@ impl Executor { KeymapLayer::Tasks => Self::tasks(cx, e), KeymapLayer::Select => Self::select(cx, e), KeymapLayer::Input => Self::input(cx, e), + KeymapLayer::Help => Self::help(cx, e), KeymapLayer::Which => unreachable!(), }; } @@ -173,6 +176,9 @@ impl Executor { // Tasks "tasks_show" => cx.tasks.toggle(), + // Help + "help" => cx.help.toggle(cx.layer()), + _ => false, } } @@ -187,8 +193,9 @@ impl Executor { } "inspect" => cx.tasks.inspect(), - "cancel" => cx.tasks.cancel(), + + "help" => cx.help.toggle(cx.layer()), _ => false, } } @@ -202,6 +209,7 @@ impl Executor { if step > 0 { cx.select.next(step as usize) } else { cx.select.prev(step.unsigned_abs()) } } + "help" => cx.help.toggle(cx.layer()), _ => false, } } @@ -235,12 +243,27 @@ impl Executor { "undo" => cx.input.undo(), "redo" => cx.input.redo(), + + "help" => cx.help.toggle(cx.layer()), _ => false, }, - InputMode::Insert => match exec.cmd.as_str() { - "backspace" => cx.input.backspace(), - _ => false, - }, + InputMode::Insert => false, + } + } + + fn help(cx: &mut Ctx, exec: &Exec) -> bool { + match exec.cmd.as_str() { + "close" => cx.help.toggle(cx.layer()), + "escape" => cx.help.escape(), + + "arrow" => { + let step = exec.args.get(0).and_then(|s| s.parse().ok()).unwrap_or(0); + cx.help.arrow(step) + } + + "filter" => cx.help.filter(), + + _ => false, } } } diff --git a/app/src/help/bindings.rs b/app/src/help/bindings.rs new file mode 100644 index 00000000..f3fb2733 --- /dev/null +++ b/app/src/help/bindings.rs @@ -0,0 +1,50 @@ +use ratatui::{layout::{self, Constraint}, prelude::{Buffer, Direction, Rect}, style::{Color, Style, Stylize}, widgets::{List, ListItem, Widget}}; + +use crate::context::Ctx; + +pub(super) struct Bindings<'a> { + cx: &'a Ctx, +} + +impl<'a> Bindings<'a> { + pub(super) fn new(cx: &'a Ctx) -> Self { Self { cx } } +} + +impl Widget for Bindings<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let bindings = &self.cx.help.window(); + if bindings.is_empty() { + return; + } + + let col1 = bindings + .iter() + .map(|c| ListItem::new(c.on()).style(Style::new().fg(Color::Yellow))) + .collect::>(); + + let col2 = bindings + .iter() + .map(|c| ListItem::new(c.exec()).style(Style::new().fg(Color::Cyan))) + .collect::>(); + + let col3 = bindings + .iter() + .map(|c| ListItem::new(if let Some(ref desc) = c.desc { desc } else { "-" })) + .collect::>(); + + let chunks = layout::Layout::new() + .direction(Direction::Horizontal) + .constraints([Constraint::Ratio(2, 10), Constraint::Ratio(3, 10), Constraint::Ratio(5, 10)]) + .split(area); + + let cursor = self.cx.help.rel_cursor() as u16; + buf.set_style( + Rect { x: area.x, y: area.y + cursor, width: area.width, height: 1 }, + Style::new().bg(Color::Black).bold(), + ); + + List::new(col1).render(chunks[0], buf); + List::new(col2).render(chunks[1], buf); + List::new(col3).render(chunks[2], buf); + } +} diff --git a/app/src/help/layout.rs b/app/src/help/layout.rs new file mode 100644 index 00000000..140ff44a --- /dev/null +++ b/app/src/help/layout.rs @@ -0,0 +1,30 @@ +use ratatui::{buffer::Buffer, layout::{self, Rect}, prelude::{Constraint, Direction}, style::{Color, Style}, widgets::{Clear, Paragraph, Widget}}; + +use super::Bindings; +use crate::Ctx; + +pub(crate) struct Layout<'a> { + cx: &'a Ctx, +} + +impl<'a> Layout<'a> { + pub fn new(cx: &'a Ctx) -> Self { Self { cx } } +} + +impl<'a> Widget for Layout<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + let chunks = layout::Layout::new() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1)].as_ref()) + .split(area); + + Clear.render(area, buf); + + let help = &self.cx.help; + Paragraph::new(help.keyword().unwrap_or_else(|| format!("{}.help", help.layer()))) + .style(Style::new().fg(Color::Black).bg(Color::White)) + .render(chunks[1], buf); + + Bindings::new(self.cx).render(chunks[0], buf); + } +} diff --git a/app/src/help/mod.rs b/app/src/help/mod.rs new file mode 100644 index 00000000..cf35bffb --- /dev/null +++ b/app/src/help/mod.rs @@ -0,0 +1,5 @@ +mod bindings; +mod layout; + +use bindings::*; +pub(super) use layout::*; diff --git a/app/src/main.rs b/app/src/main.rs index 89ff02c1..825752a5 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -4,6 +4,7 @@ mod app; mod context; mod executor; mod header; +mod help; mod input; mod logs; mod manager; diff --git a/app/src/root.rs b/app/src/root.rs index 0460be6a..cb612eb1 100644 --- a/app/src/root.rs +++ b/app/src/root.rs @@ -1,6 +1,7 @@ use ratatui::{buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, widgets::Widget}; use super::{header, input, manager, select, status, tasks, which, Ctx}; +use crate::help; pub(super) struct Root<'a> { cx: &'a Ctx, @@ -33,6 +34,10 @@ impl<'a> Widget for Root<'a> { input::Input::new(self.cx).render(area, buf); } + if self.cx.help.visible() { + help::Layout::new(self.cx).render(area, buf); + } + if self.cx.which.visible { which::Which::new(self.cx).render(area, buf); } diff --git a/app/src/which/side.rs b/app/src/which/side.rs index bbdd95e8..a0c10a69 100644 --- a/app/src/which/side.rs +++ b/app/src/which/side.rs @@ -33,8 +33,7 @@ impl Widget for Side<'_> { spans.push(Span::styled("  ".to_string(), Style::new().fg(Color::DarkGray))); // Exec - let exec = c.exec.iter().map(ToString::to_string).collect::>().join("; "); - spans.push(Span::styled(exec, Style::new().fg(Color::Magenta))); + spans.push(Span::styled(c.desc_or_exec(), Style::new().fg(Color::Magenta))); ListItem::new(Line::from(spans)) }) diff --git a/config/docs/keymap.md b/config/docs/keymap.md index 2bc77893..fff92991 100644 --- a/config/docs/keymap.md +++ b/config/docs/keymap.md @@ -16,6 +16,10 @@ - enter: Enter the child directory. - back: Go back to the previous directory. - forward: Go forward to the next directory. +- peek + + - `n`: Peek up or down at file contents in the preview. Use negative values to peek up and positive values to peek down. + - cd: Change the current directory. - `path`: the path to change to. @@ -54,15 +58,15 @@ - `--force`: Overwrite the destination file if it exists. - `--follow`: Copy the file pointed to by a symbolic link, rather than the link itself. Only valid during copying. -- remove: Move the file to the trash/recycle bin. +- remove: Move the files to the trash/recycle bin. - - `--permanently`: Permanently delete the file. + - `--permanently`: Permanently delete the files. -- create: Create a file or directory (ends with `/` for directory). +- create: Create a file or directory (ends with `/` for directories). - rename: Rename a file or directory. - copy: Copy the path of files or directories that are selected or hovered on. - - `path`: Copy the full absolute path. + - `path`: Copy the absolute path. - `dirname`: Copy the path of the parent directory. - `filename`: Copy the name of the file. - `name_without_ext`: Copy the name of the file without the extension. @@ -90,12 +94,23 @@ - `fzf`: Jump to a directory, or reveal a file using fzf. - `zoxide`: Jump to a directory using zoxide. +- sort + + - `by` + - `"alphabetical"`: Sort alphabetically, e.g. `1.md` < `10.md` < `2.md` + - `"created"`: Sort by creation time. + - `"modified"`: Sort by last modified time. + - `"natural"`: Sort naturally, e.g. `1.md` < `2.md` < `10.md` + - `"size"`: Sort by file size. + - `--reverse`: Display files in reverse order. + - `--dir_first`: Display directories first. + ### Tabs - tab_create - `path`: Create a new tab using the specified path. - - `--current`: Create a new tab based on the current directory. + - `--current`: Create a new tab using the current path. - tab_close @@ -112,7 +127,11 @@ ### Tasks -- tasks_show: Display the task manager. +- tasks_show: Show the task manager. + +### Help + +- help: Open the help menu. ## tasks @@ -120,7 +139,9 @@ - arrow: - `-1`: Move the cursor up 1 line. - `1`: Move the cursor down 1 line. +- inspect: Inspect the task. - cancel: Cancel the task. +- help: Open the help menu. ## select @@ -128,6 +149,7 @@ - `--submit`: Submit the selection. - arrow - `n`: Move the cursor up or down n lines. Negative value for up, positive value for down. +- help: Open the help menu. ## input @@ -135,7 +157,7 @@ - `--submit`: Submit the input. -- escape: Cancel visual mode and enter normal mode. +- escape: Go back the normal mode, or cancel input. - move: Move the cursor left or right. - `n`: Move the cursor n characters left or right. Negative value for left, positive value for right. @@ -155,7 +177,7 @@ - delete: Delete the selected characters. - - `--cut`: Cut the selected characters into the clipboard, instead of only deleting them. + - `--cut`: Cut the selected characters into clipboard, instead of only deleting them. - `--insert`: Delete and enter insert mode. - yank: Copy the selected characters. @@ -166,6 +188,8 @@ - undo: Undo the last operation. - redo: Redo the last operation. +- help: Open the help menu. + ### Insert mode - close: Cancel input. @@ -173,4 +197,11 @@ - `--submit`: Submit the input. - escape: Cancel insert mode and enter normal mode. -- backspace: Delete the character before the cursor. + +## Help + +- close: Hide the help menu. +- escape: Clear the filter, or hide the help menu. +- arrow + - `n`: Move the cursor up or down n lines. Negative value for up, positive value for down. +- filter: Apply a filter for the help items. diff --git a/config/preset/keymap.toml b/config/preset/keymap.toml index 960c26e0..399fca0b 100644 --- a/config/preset/keymap.toml +++ b/config/preset/keymap.toml @@ -1,185 +1,214 @@ [manager] keymap = [ - { on = [ "" ], exec = "escape" }, - { on = [ "q" ], exec = "quit" }, - # close current tab and exit if it is last tab - { on = [ "" ], exec = "close" }, + { on = [ "" ], exec = "escape", desc = "Exit visual mode, clear selected, or cancel search" }, + { on = [ "q" ], exec = "quit", desc = "Exit the process" }, + { on = [ "" ], exec = "close", desc = "Close the current tab, or quit if it is last tab" }, # Navigation - { on = [ "k" ], exec = "arrow -1" }, - { on = [ "j" ], exec = "arrow 1" }, + { on = [ "k" ], exec = "arrow -1", desc = "Move cursor up" }, + { on = [ "j" ], exec = "arrow 1", desc = "Move cursor down" }, - { on = [ "K" ], exec = "arrow -5" }, - { on = [ "J" ], exec = "arrow 5" }, + { on = [ "K" ], exec = "arrow -5", desc = "Move cursor up 5 lines" }, + { on = [ "J" ], exec = "arrow 5", desc = "Move cursor down 5 lines" }, - { on = [ "h" ], exec = "leave" }, - { on = [ "l" ], exec = "enter" }, + { on = [ "h" ], exec = "leave", desc = "Go back to the parent directory" }, + { on = [ "l" ], exec = "enter", desc = "Enter the child directory" }, - { on = [ "H" ], exec = "back" }, - { on = [ "L" ], exec = "forward" }, + { on = [ "H" ], exec = "back", desc = "Go back to the previous directory" }, + { on = [ "L" ], exec = "forward", desc = "Go forward to the next directory" }, - { on = [ "" ], exec = "peek -5" }, - { on = [ "" ], exec = "peek 5" }, + { on = [ "" ], exec = "peek -5", desc = "Peek up 5 units in the preview" }, + { on = [ "" ], exec = "peek 5", desc = "Peek down 5 units in the preview" }, - { on = [ "" ], exec = "arrow -1" }, - { on = [ "" ], exec = "arrow 1" }, - { on = [ "" ], exec = "leave" }, - { on = [ "" ], exec = "enter" }, + { on = [ "" ], exec = "arrow -1", desc = "Move cursor up" }, + { on = [ "" ], exec = "arrow 1", desc = "Move cursor down" }, + { on = [ "" ], exec = "leave", desc = "Go back to the parent directory" }, + { on = [ "" ], exec = "enter", desc = "Enter the child directory" }, # Selection - { on = [ "" ], exec = [ "select --state=none", "arrow 1" ] }, - { on = [ "v" ], exec = "visual_mode" }, - { on = [ "V" ], exec = "visual_mode --unset" }, - { on = [ "" ], exec = "select_all --state=true" }, - { on = [ "" ], exec = "select_all --state=none" }, + { on = [ "" ], exec = [ "select --state=none", "arrow 1" ], desc = "Toggle the current selection state" }, + { on = [ "v" ], exec = "visual_mode", desc = "Enter visual mode (selection mode)" }, + { on = [ "V" ], exec = "visual_mode --unset", desc = "Enter visual mode (unset mode)" }, + { on = [ "" ], exec = "select_all --state=true", desc = "Select all files" }, + { on = [ "" ], exec = "select_all --state=none", desc = "Inverse selection of all files" }, # Operation - { on = [ "o" ], exec = "open" }, - { on = [ "O" ], exec = "open --interactive" }, - { on = [ "" ], exec = "open" }, - { on = [ "" ], exec = "open --interactive" }, # It's cool if you're using a terminal that supports CSI u - { on = [ "y" ], exec = "yank" }, - { on = [ "x" ], exec = "yank --cut" }, - { on = [ "p" ], exec = "paste" }, - { on = [ "P" ], exec = "paste --force" }, - { on = [ "k" ], exec = "paste --follow" }, - { on = [ "K" ], exec = "paste --follow --force" }, - { on = [ "d" ], exec = "remove" }, - { on = [ "D" ], exec = "remove --permanently" }, - { on = [ "a" ], exec = "create" }, - { on = [ "r" ], exec = "rename" }, - { on = [ ";" ], exec = "shell" }, - { on = [ ":" ], exec = "shell --block" }, - { on = [ "." ], exec = "hidden toggle" }, - { on = [ "s" ], exec = "search fd" }, - { on = [ "S" ], exec = "search rg" }, - { on = [ "" ], exec = "search none" }, - { on = [ "z" ], exec = "jump zoxide" }, - { on = [ "Z" ], exec = "jump fzf" }, + { on = [ "o" ], exec = "open", desc = "Open the selected files" }, + { on = [ "O" ], exec = "open --interactive", desc = "Open the selected files interactively" }, + { on = [ "" ], exec = "open", desc = "Open the selected files" }, + { on = [ "" ], exec = "open --interactive", desc = "Open the selected files interactively" }, # It's cool if you're using a terminal that supports CSI u + { on = [ "y" ], exec = "yank", desc = "Copy the selected files" }, + { on = [ "x" ], exec = "yank --cut", desc = "Cut the selected files" }, + { on = [ "p" ], exec = "paste", desc = "Paste the files" }, + { on = [ "P" ], exec = "paste --force", desc = "Paste the files (overwrite if the destination exists)" }, + { on = [ "k" ], exec = "paste --follow", desc = "Paste the files (follow the symlinks)" }, + { on = [ "K" ], exec = "paste --follow --force", desc = "Paste the files (overwrite + follow)" }, + { on = [ "d" ], exec = "remove", desc = "Move the files to the trash" }, + { on = [ "D" ], exec = "remove --permanently", desc = "Permanently delete the files" }, + { on = [ "a" ], exec = "create", desc = "Create a file or directory (ends with / for directories)" }, + { on = [ "r" ], exec = "rename", desc = "Rename a file or directory" }, + { on = [ ";" ], exec = "shell", desc = "Run a shell command" }, + { on = [ ":" ], exec = "shell --block", desc = "Run a shell command (block the UI until the command finishes)" }, + { on = [ "." ], exec = "hidden toggle", desc = "Toggle the visibility of hidden files" }, + { on = [ "s" ], exec = "search fd", desc = "Search files by content using ripgrep" }, + { on = [ "S" ], exec = "search rg", desc = "Search files by name using fd" }, + { on = [ "" ], exec = "search none", desc = "Cancel the ongoing search" }, + { on = [ "z" ], exec = "jump zoxide", desc = "Jump to a directory using zoxide" }, + { on = [ "Z" ], exec = "jump fzf", desc = "Jump to a directory, or reveal a file using fzf" }, # Copy - { on = [ "c", "c" ], exec = "copy path" }, - { on = [ "c", "d" ], exec = "copy dirname" }, - { on = [ "c", "f" ], exec = "copy filename" }, - { on = [ "c", "n" ], exec = "copy name_without_ext" }, + { on = [ "c", "c" ], exec = "copy path", desc = "Copy the absolute path" }, + { on = [ "c", "d" ], exec = "copy dirname", desc = "Copy the path of the parent directory" }, + { on = [ "c", "f" ], exec = "copy filename", desc = "Copy the name of the file" }, + { on = [ "c", "n" ], exec = "copy name_without_ext", desc = "Copy the name of the file without the extension" }, # Sorting - { on = [ ",", "a" ], exec = "sort alphabetical --dir_first" }, - { on = [ ",", "A" ], exec = "sort alphabetical --reverse --dir_first" }, - { on = [ ",", "c" ], exec = "sort created --dir_first" }, - { on = [ ",", "C" ], exec = "sort created --reverse --dir_first" }, - { on = [ ",", "m" ], exec = "sort modified --dir_first" }, - { on = [ ",", "M" ], exec = "sort modified --reverse --dir_first" }, - { on = [ ",", "n" ], exec = "sort natural --dir_first" }, - { on = [ ",", "N" ], exec = "sort natural --reverse --dir_first" }, - { on = [ ",", "s" ], exec = "sort size --dir_first" }, - { on = [ ",", "S" ], exec = "sort size --reverse --dir_first" }, + { on = [ ",", "a" ], exec = "sort alphabetical --dir_first", desc = "Sort alphabetically, directories first" }, + { on = [ ",", "A" ], exec = "sort alphabetical --reverse --dir_first", desc = "Sort alphabetically, directories first (reverse)" }, + { on = [ ",", "c" ], exec = "sort created --dir_first", desc = "Sort by creation time, directories first" }, + { on = [ ",", "C" ], exec = "sort created --reverse --dir_first", desc = "Sort by creation time, directories first (reverse)" }, + { on = [ ",", "m" ], exec = "sort modified --dir_first", desc = "Sort by modified time, directories first" }, + { on = [ ",", "M" ], exec = "sort modified --reverse --dir_first", desc = "Sort by modified time, directories first (reverse)" }, + { on = [ ",", "n" ], exec = "sort natural --dir_first", desc = "Sort naturally, directories first" }, + { on = [ ",", "N" ], exec = "sort natural --reverse --dir_first", desc = "Sort naturally, directories first (reverse)" }, + { on = [ ",", "s" ], exec = "sort size --dir_first", desc = "Sort by size, directories first" }, + { on = [ ",", "S" ], exec = "sort size --reverse --dir_first", desc = "Sort by size, directories first (reverse)" }, # Tabs - { on = [ "t" ], exec = "tab_create --current" }, + { on = [ "t" ], exec = "tab_create --current", desc = "Create a new tab using the current path" }, - { on = [ "1" ], exec = "tab_switch 0" }, - { on = [ "2" ], exec = "tab_switch 1" }, - { on = [ "3" ], exec = "tab_switch 2" }, - { on = [ "4" ], exec = "tab_switch 3" }, - { on = [ "5" ], exec = "tab_switch 4" }, - { on = [ "6" ], exec = "tab_switch 5" }, - { on = [ "7" ], exec = "tab_switch 6" }, - { on = [ "8" ], exec = "tab_switch 7" }, - { on = [ "9" ], exec = "tab_switch 8" }, + { on = [ "1" ], exec = "tab_switch 0", desc = "Switch to the first tab" }, + { on = [ "2" ], exec = "tab_switch 1", desc = "Switch to the second tab" }, + { on = [ "3" ], exec = "tab_switch 2", desc = "Switch to the third tab" }, + { on = [ "4" ], exec = "tab_switch 3", desc = "Switch to the fourth tab" }, + { on = [ "5" ], exec = "tab_switch 4", desc = "Switch to the fifth tab" }, + { on = [ "6" ], exec = "tab_switch 5", desc = "Switch to the sixth tab" }, + { on = [ "7" ], exec = "tab_switch 6", desc = "Switch to the seventh tab" }, + { on = [ "8" ], exec = "tab_switch 7", desc = "Switch to the eighth tab" }, + { on = [ "9" ], exec = "tab_switch 8", desc = "Switch to the ninth tab" }, - { on = [ "[" ], exec = "tab_switch -1 --relative" }, - { on = [ "]" ], exec = "tab_switch 1 --relative" }, + { on = [ "[" ], exec = "tab_switch -1 --relative", desc = "Switch to the previous tab" }, + { on = [ "]" ], exec = "tab_switch 1 --relative", desc = "Switch to the next tab" }, - { on = [ "{" ], exec = "tab_swap -1" }, - { on = [ "}" ], exec = "tab_swap 1" }, + { on = [ "{" ], exec = "tab_swap -1", desc = "Swap the current tab with the previous tab" }, + { on = [ "}" ], exec = "tab_swap 1", desc = "Swap the current tab with the next tab" }, # Tasks - { on = [ "w" ], exec = "tasks_show" }, + { on = [ "w" ], exec = "tasks_show", desc = "Show the tasks manager" }, # Goto - { on = [ "g", "h" ], exec = "cd ~" }, - { on = [ "g", "c" ], exec = "cd ~/.config" }, - { on = [ "g", "d" ], exec = "cd ~/Downloads" }, - { on = [ "g", "t" ], exec = "cd /tmp" }, - { on = [ "g", "" ], exec = "cd --interactive" }, + { on = [ "g", "h" ], exec = "cd ~", desc = "Go to the home directory" }, + { on = [ "g", "c" ], exec = "cd ~/.config", desc = "Go to the config directory" }, + { on = [ "g", "d" ], exec = "cd ~/Downloads", desc = "Go to the downloads directory" }, + { on = [ "g", "t" ], exec = "cd /tmp", desc = "Go to the temporary directory" }, + { on = [ "g", "" ], exec = "cd --interactive", desc = "Go to a directory interactively" }, + + # Help + { on = [ "?" ], exec = "help", desc = "Open help" }, ] [tasks] keymap = [ - { on = [ "" ], exec = "close" }, - { on = [ "" ], exec = "close" }, - { on = [ "w" ], exec = "close" }, + { on = [ "" ], exec = "close", desc = "Hide the task manager" }, + { on = [ "" ], exec = "close", desc = "Hide the task manager" }, + { on = [ "w" ], exec = "close", desc = "Hide the task manager" }, - { on = [ "k" ], exec = "arrow -1" }, - { on = [ "j" ], exec = "arrow 1" }, + { on = [ "k" ], exec = "arrow -1", desc = "Move cursor up" }, + { on = [ "j" ], exec = "arrow 1", desc = "Move cursor down" }, - { on = [ "" ], exec = "arrow -1" }, - { on = [ "" ], exec = "arrow 1" }, + { on = [ "" ], exec = "arrow -1", desc = "Move cursor up" }, + { on = [ "" ], exec = "arrow 1", desc = "Move cursor down" }, - { on = [ "" ], exec = "inspect" }, - { on = [ "x" ], exec = "cancel" }, + { on = [ "" ], exec = "inspect", desc = "Inspect the task" }, + { on = [ "x" ], exec = "cancel", desc = "Cancel the task" }, + + { on = [ "?" ], exec = "help", desc = "Open help" } ] [select] keymap = [ - { on = [ "" ], exec = "close" }, - { on = [ "" ], exec = "close" }, - { on = [ "" ], exec = "close --submit" }, + { on = [ "" ], exec = "close", desc = "Cancel selection" }, + { on = [ "" ], exec = "close", desc = "Cancel selection" }, + { on = [ "" ], exec = "close --submit", desc = "Submit the selection" }, - { on = [ "k" ], exec = "arrow -1" }, - { on = [ "j" ], exec = "arrow 1" }, + { on = [ "k" ], exec = "arrow -1", desc = "Move cursor up" }, + { on = [ "j" ], exec = "arrow 1", desc = "Move cursor down" }, - { on = [ "K" ], exec = "arrow -5" }, - { on = [ "J" ], exec = "arrow 5" }, + { on = [ "K" ], exec = "arrow -5", desc = "Move cursor up 5 lines" }, + { on = [ "J" ], exec = "arrow 5", desc = "Move cursor down 5 lines" }, - { on = [ "" ], exec = "arrow -1" }, - { on = [ "" ], exec = "arrow 1" }, + { on = [ "" ], exec = "arrow -1", desc = "Move cursor up" }, + { on = [ "" ], exec = "arrow 1", desc = "Move cursor down" }, + + { on = [ "?" ], exec = "help", desc = "Open help" } ] [input] keymap = [ - { on = [ "" ], exec = "close" }, - { on = [ "" ], exec = "close --submit" }, - { on = [ "" ], exec = "escape" }, - { on = [ "" ], exec = "backspace" }, + { on = [ "" ], exec = "close", desc = "Cancel input" }, + { on = [ "" ], exec = "close --submit", desc = "Submit the input" }, + { on = [ "" ], exec = "escape", desc = "Go back the normal mode, or cancel input" }, # Mode - { on = [ "i" ], exec = "insert" }, - { on = [ "a" ], exec = "insert --append" }, - { on = [ "v" ], exec = "visual" }, + { on = [ "i" ], exec = "insert", desc = "Enter insert mode" }, + { on = [ "a" ], exec = "insert --append", desc = "Enter append mode" }, + { on = [ "v" ], exec = "visual", desc = "Enter visual mode" }, # Navigation - { on = [ "h" ], exec = "move -1" }, - { on = [ "l" ], exec = "move 1" }, + { on = [ "h" ], exec = "move -1", desc = "Move cursor left" }, + { on = [ "l" ], exec = "move 1", desc = "Move cursor right" }, - { on = [ "0" ], exec = "move -999" }, - { on = [ "$" ], exec = "move 999" }, - { on = [ "I" ], exec = [ "move -999", "insert" ] }, - { on = [ "A" ], exec = [ "move 999", "insert --append" ] }, + { on = [ "0" ], exec = "move -999", desc = "Move to the BOL" }, + { on = [ "$" ], exec = "move 999", desc = "Move to the EOL" }, + { on = [ "I" ], exec = [ "move -999", "insert" ], desc = "Move to the BOL, and enter insert mode" }, + { on = [ "A" ], exec = [ "move 999", "insert --append" ], desc = "Move to the EOL, and enter append mode" }, - { on = [ "" ], exec = "move -1" }, - { on = [ "" ], exec = "move 1" }, + { on = [ "" ], exec = "move -1", desc = "Move cursor left" }, + { on = [ "" ], exec = "move 1", desc = "Move cursor right" }, - { on = [ "b" ], exec = "backward" }, - { on = [ "w" ], exec = "forward" }, - { on = [ "e" ], exec = "forward --end-of-word" }, + { on = [ "b" ], exec = "backward", desc = "Move to the beginning of the previous word" }, + { on = [ "w" ], exec = "forward", desc = "Move to the beginning of the next word" }, + { on = [ "e" ], exec = "forward --end-of-word", desc = "Move to the end of the next word" }, # Deletion - { on = [ "d" ], exec = "delete --cut" }, - { on = [ "c" ], exec = "delete --cut --insert" }, - { on = [ "x" ], exec = [ "delete --cut", "move 1 --in-operating" ] }, + { on = [ "d" ], exec = "delete --cut", desc = "Cut the selected characters" }, + { on = [ "c" ], exec = "delete --cut --insert", desc = "Cut the selected characters, and enter insert mode" }, + { on = [ "x" ], exec = [ "delete --cut", "move 1 --in-operating" ], desc = "Cut the current character" }, # Yank/Paste - { on = [ "y" ], exec = "yank" }, - { on = [ "p" ], exec = "paste" }, - { on = [ "P" ], exec = "paste --before" }, + { on = [ "y" ], exec = "yank", desc = "Copy the selected characters" }, + { on = [ "p" ], exec = "paste", desc = "Paste the copied characters after the cursor" }, + { on = [ "P" ], exec = "paste --before", desc = "Paste the copied characters before the cursor" }, # Undo/Redo - { on = [ "u" ], exec = "undo" }, - { on = [ "" ], exec = "redo" }, + { on = [ "u" ], exec = "undo", desc = "Undo the last operation" }, + { on = [ "" ], exec = "redo", desc = "Redo the last operation" }, + + # Help + { on = [ "?" ], exec = "help", desc = "Open help" } +] + +[help] + +keymap = [ + { on = [ "" ], exec = "escape", desc = "Clear the filter, or hide the help" }, + { on = [ "q" ], exec = "close", desc = "Exit the process" }, + { on = [ "" ], exec = "close", desc = "Hide the help" }, + + # Navigation + { on = [ "k" ], exec = "arrow -1", desc = "Move cursor up" }, + { on = [ "j" ], exec = "arrow 1", desc = "Move cursor down" }, + + { on = [ "K" ], exec = "arrow -5", desc = "Move cursor up 5 lines" }, + { on = [ "J" ], exec = "arrow 5", desc = "Move cursor down 5 lines" }, + + { on = [ "" ], exec = "arrow -1", desc = "Move cursor up" }, + { on = [ "" ], exec = "arrow 1", desc = "Move cursor down" }, + + # Filtering + { on = [ "/" ], exec = "filter", desc = "Apply a filter for the help items" }, ] diff --git a/config/src/keymap/control.rs b/config/src/keymap/control.rs new file mode 100644 index 00000000..afe50ca5 --- /dev/null +++ b/config/src/keymap/control.rs @@ -0,0 +1,35 @@ +use std::borrow::Cow; + +use serde::Deserialize; + +use super::{Exec, Key}; + +#[derive(Clone, Debug, Deserialize)] +pub struct Control { + pub on: Vec, + #[serde(deserialize_with = "Exec::deserialize")] + pub exec: Vec, + pub desc: Option, +} + +impl Control { + #[inline] + pub fn on(&self) -> String { self.on.iter().map(ToString::to_string).collect() } + + #[inline] + pub fn exec(&self) -> String { + self.exec.iter().map(|e| e.to_string()).collect::>().join("; ") + } + + #[inline] + pub fn desc_or_exec(&self) -> Cow { + if let Some(ref s) = self.desc { Cow::Borrowed(s) } else { self.exec().into() } + } + + #[inline] + pub fn contains(&self, s: &str) -> bool { + self.desc.as_ref().map(|d| d.contains(s)).unwrap_or(false) + || self.exec().contains(s) + || self.on().contains(s) + } +} diff --git a/config/src/keymap/exec.rs b/config/src/keymap/exec.rs index cb1ebf72..3180567f 100644 --- a/config/src/keymap/exec.rs +++ b/config/src/keymap/exec.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, fmt::{self, Debug}}; +use std::{collections::BTreeMap, fmt::{self, Debug, Display}}; use anyhow::bail; use serde::{de::{self, Visitor}, Deserializer}; @@ -34,16 +34,19 @@ impl TryFrom<&str> for Exec { } } -impl ToString for Exec { - fn to_string(&self) -> String { - let mut s = Vec::with_capacity(self.args.len() + self.named.len() + 1); - s.push(self.cmd.clone()); - s.extend(self.args.iter().cloned()); - for (key, val) in self.named.iter() { - s.push(format!("--{}={}", key, val)); +impl Display for Exec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.cmd)?; + if !self.args.is_empty() { + write!(f, " {}", self.args.join(" "))?; } - - shell_words::join(s) + for (k, v) in &self.named { + write!(f, " --{k}")?; + if !v.is_empty() { + write!(f, "={v}")?; + } + } + Ok(()) } } diff --git a/config/src/keymap/key.rs b/config/src/keymap/key.rs index 1c5c8ec8..aabbf485 100644 --- a/config/src/keymap/key.rs +++ b/config/src/keymap/key.rs @@ -19,6 +19,11 @@ impl Key { _ => None, } } + + #[inline] + pub fn is_enter(&self) -> bool { + matches!(self, Key { code: KeyCode::Enter, shift: false, ctrl: false, alt: false }) + } } impl Default for Key { diff --git a/config/src/keymap/keymap.rs b/config/src/keymap/keymap.rs index 3c864368..d4ef0795 100644 --- a/config/src/keymap/keymap.rs +++ b/config/src/keymap/keymap.rs @@ -1,30 +1,17 @@ +use std::fmt::{self, Display}; + use serde::{Deserialize, Deserializer}; -use super::{Exec, Key}; +use super::Control; use crate::MERGED_KEYMAP; -#[derive(Clone, Debug, Deserialize)] -pub struct Control { - pub on: Vec, - #[serde(deserialize_with = "Exec::deserialize")] - pub exec: Vec, -} - #[derive(Debug)] pub struct Keymap { pub manager: Vec, pub tasks: Vec, pub select: Vec, pub input: Vec, -} - -#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)] -pub enum KeymapLayer { - Manager, - Tasks, - Select, - Input, - Which, + pub help: Vec, } impl<'de> Deserialize<'de> for Keymap { @@ -38,6 +25,7 @@ impl<'de> Deserialize<'de> for Keymap { tasks: Inner, select: Inner, input: Inner, + help: Inner, } #[derive(Deserialize)] struct Inner { @@ -50,6 +38,7 @@ impl<'de> Deserialize<'de> for Keymap { tasks: shadow.tasks.keymap, select: shadow.select.keymap, input: shadow.input.keymap, + help: shadow.help.keymap, }) } } @@ -66,7 +55,32 @@ impl Keymap { KeymapLayer::Tasks => &self.tasks, KeymapLayer::Select => &self.select, KeymapLayer::Input => &self.input, + KeymapLayer::Help => &self.help, KeymapLayer::Which => unreachable!(), } } } + +#[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Copy)] +pub enum KeymapLayer { + #[default] + Manager, + Tasks, + Select, + Input, + Help, + Which, +} + +impl Display for KeymapLayer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + KeymapLayer::Manager => write!(f, "manager"), + KeymapLayer::Tasks => write!(f, "tasks"), + KeymapLayer::Select => write!(f, "select"), + KeymapLayer::Input => write!(f, "input"), + KeymapLayer::Help => write!(f, "help"), + KeymapLayer::Which => write!(f, "which"), + } + } +} diff --git a/config/src/keymap/mod.rs b/config/src/keymap/mod.rs index 12bd2809..4ca907b5 100644 --- a/config/src/keymap/mod.rs +++ b/config/src/keymap/mod.rs @@ -1,7 +1,9 @@ +mod control; mod exec; mod key; mod keymap; +pub use control::*; pub use exec::*; pub use key::*; pub use keymap::*; diff --git a/core/src/help/help.rs b/core/src/help/help.rs new file mode 100644 index 00000000..80ad1a29 --- /dev/null +++ b/core/src/help/help.rs @@ -0,0 +1,170 @@ +use config::{keymap::{Control, Key, KeymapLayer}, KEYMAP}; +use shared::Term; +use unicode_width::UnicodeWidthStr; + +use super::HELP_MARGIN; +use crate::{emit, input::Input}; + +#[derive(Default)] +pub struct Help { + visible: bool, + layer: KeymapLayer, + bindings: Vec, + + // Filter + keyword: Option, + in_filter: Option, + + offset: usize, + cursor: usize, +} + +impl Help { + #[inline] + pub fn limit() -> usize { Term::size().rows.saturating_sub(HELP_MARGIN) as usize } + + pub fn toggle(&mut self, layer: KeymapLayer) -> bool { + self.visible = !self.visible; + self.layer = layer; + + self.keyword = Some(String::new()); + self.in_filter = None; + self.filter_apply(); + + self.offset = 0; + self.cursor = 0; + + emit!(Peek); // Show/hide preview for images + true + } + + pub fn escape(&mut self) -> bool { + if self.in_filter.is_some() { + self.in_filter = None; + self.filter_apply(); + true + } else { + self.toggle(self.layer) + } + } + + #[inline] + pub fn arrow(&mut self, step: isize) -> bool { + let max = self.bindings.len().saturating_sub(1); + self.offset = self.offset.min(max); + self.cursor = self.cursor.min(max); + + if step > 0 { self.next(step as usize) } else { self.prev(step.unsigned_abs()) } + } + + pub fn next(&mut self, step: usize) -> bool { + let len = self.bindings.len(); + if len == 0 { + return false; + } + + let old = self.cursor; + self.cursor = (self.cursor + step).min(len - 1); + + let limit = Self::limit(); + if self.cursor >= (self.offset + limit).min(len).saturating_sub(5) { + self.offset = len.saturating_sub(limit).min(self.offset + self.cursor - old); + } + + old != self.cursor + } + + pub fn prev(&mut self, step: usize) -> bool { + let old = self.cursor; + self.cursor = self.cursor.saturating_sub(step); + + if self.cursor < self.offset + 5 { + self.offset = self.offset.saturating_sub(old - self.cursor); + } + + old != self.cursor + } + + pub fn filter(&mut self) -> bool { + self.in_filter = Some(Default::default()); + self.filter_apply(); + true + } + + fn filter_apply(&mut self) -> bool { + let kw = self.in_filter.as_ref().map(|i| i.value()).filter(|v| !v.is_empty()); + if self.keyword.as_deref() == kw { + return false; + } + + if let Some(kw) = kw { + self.bindings = KEYMAP.get(self.layer).iter().filter(|&c| c.contains(kw)).cloned().collect(); + } else { + self.bindings = KEYMAP.get(self.layer).clone(); + } + + self.keyword = kw.map(|s| s.to_owned()); + self.arrow(0); + true + } + + pub fn type_(&mut self, key: &Key) -> bool { + let Some(input) = &mut self.in_filter else { + return false; + }; + + if key.is_enter() { + self.in_filter = None; + return true; + } + + if input.type_(key) { + return self.filter_apply(); + } + + false + } +} + +impl Help { + // --- Visible + #[inline] + pub fn visible(&self) -> bool { self.visible } + + // --- Layer + #[inline] + pub fn layer(&self) -> KeymapLayer { self.layer } + + // --- Keyword + #[inline] + pub fn keyword(&self) -> Option { + self + .in_filter + .as_ref() + .map(|i| i.value()) + .or(self.keyword.as_deref()) + .map(|s| format!("/{}", s)) + } + + // --- Bindings + #[inline] + pub fn window(&self) -> &[Control] { + let end = (self.offset + Self::limit()).min(self.bindings.len()); + &self.bindings[self.offset..end] + } + + // --- Cursor + #[inline] + pub fn cursor(&self) -> Option<(u16, u16)> { + if !self.visible || self.in_filter.is_none() { + return None; + } + if let Some(kw) = self.keyword() { + return Some((kw.width() as u16, Term::size().rows)); + } + None + } + + #[inline] + pub fn rel_cursor(&self) -> usize { self.cursor - self.offset } +} diff --git a/core/src/help/mod.rs b/core/src/help/mod.rs new file mode 100644 index 00000000..6525211a --- /dev/null +++ b/core/src/help/mod.rs @@ -0,0 +1,5 @@ +mod help; + +pub use help::*; + +pub const HELP_MARGIN: u16 = 1; diff --git a/core/src/input/input.rs b/core/src/input/input.rs index 2f009947..31752fb7 100644 --- a/core/src/input/input.rs +++ b/core/src/input/input.rs @@ -1,6 +1,8 @@ use std::ops::Range; use anyhow::{anyhow, Result}; +use config::keymap::Key; +use crossterm::event::KeyCode; use shared::CharKind; use tokio::sync::oneshot::Sender; use unicode_width::UnicodeWidthStr; @@ -177,8 +179,23 @@ impl Input { self.move_(snap.len() as isize) } + pub fn type_(&mut self, key: &Key) -> bool { + if self.mode() != InputMode::Insert { + return false; + } + + if let Some(c) = key.plain() { + return self.type_char(c); + } + + match key { + Key { code: KeyCode::Backspace, shift: false, ctrl: false, alt: false } => self.backspace(), + _ => false, + } + } + #[inline] - pub fn type_(&mut self, c: char) -> bool { + pub fn type_char(&mut self, c: char) -> bool { let mut bits = [0; 4]; self.type_str(c.encode_utf8(&mut bits)) } @@ -253,7 +270,7 @@ impl Input { self.insert(!before); for c in s.to_string_lossy().chars() { - self.type_(c); + self.type_char(c); } self.escape(); true diff --git a/core/src/lib.rs b/core/src/lib.rs index 0b819f3d..3696401f 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -10,6 +10,7 @@ mod blocker; mod event; pub mod external; pub mod files; +pub mod help; mod highlighter; pub mod input; pub mod manager; diff --git a/core/src/manager/folder.rs b/core/src/manager/folder.rs index 9cb3ff1e..d94d6b38 100644 --- a/core/src/manager/folder.rs +++ b/core/src/manager/folder.rs @@ -35,9 +35,9 @@ impl Folder { return false; } - let len = self.files.len(); - self.offset = self.offset.min(len); - self.cursor = self.cursor.min(len.saturating_sub(1)); + let max = self.files.len().saturating_sub(1); + self.offset = self.offset.min(max); + self.cursor = self.cursor.min(max); self.set_page(true); self.hover_repos(); diff --git a/cspell.json b/cspell.json index ed49cb39..2003b2e3 100644 --- a/cspell.json +++ b/cspell.json @@ -1 +1 @@ -{"flagWords":[],"words":["Punct","KEYMAP","splitn","crossterm","YAZI","unar","peekable","ratatui","syntect","pbpaste","pbcopy","ffmpegthumbnailer","oneshot","Posix","Lsar","XADDOS","zoxide","cands","Deque","precache","imageops","IFBLK","IFCHR","IFDIR","IFIFO","IFLNK","IFMT","IFSOCK","IRGRP","IROTH","IRUSR","ISGID","ISUID","ISVTX","IWGRP","IWOTH","IWUSR","IXGRP","IXOTH","IXUSR","libc","winsize","TIOCGWINSZ","xpixel","ypixel","ioerr","appender","Catppuccin","macchiato","gitmodules","Dotfiles","bashprofile","vimrc","flac","webp","exiftool","mediainfo","ripgrep","nvim","indexmap","indexmap","unwatch","canonicalize","serde","fsevent","Ueberzug","iterm","wezterm","sixel","chafa","ueberzugpp","️ Überzug","️ Überzug","Konsole","Alacritty","Überzug","pkgs","paru","unarchiver","pdftoppm","poppler","prebuild","singlefile","jpegopt","EXIF","rustfmt","mktemp","nanos","xclip","xsel","natord"],"language":"en","version":"0.2"} +{"language":"en","flagWords":[],"words":["Punct","KEYMAP","splitn","crossterm","YAZI","unar","peekable","ratatui","syntect","pbpaste","pbcopy","ffmpegthumbnailer","oneshot","Posix","Lsar","XADDOS","zoxide","cands","Deque","precache","imageops","IFBLK","IFCHR","IFDIR","IFIFO","IFLNK","IFMT","IFSOCK","IRGRP","IROTH","IRUSR","ISGID","ISUID","ISVTX","IWGRP","IWOTH","IWUSR","IXGRP","IXOTH","IXUSR","libc","winsize","TIOCGWINSZ","xpixel","ypixel","ioerr","appender","Catppuccin","macchiato","gitmodules","Dotfiles","bashprofile","vimrc","flac","webp","exiftool","mediainfo","ripgrep","nvim","indexmap","indexmap","unwatch","canonicalize","serde","fsevent","Ueberzug","iterm","wezterm","sixel","chafa","ueberzugpp","️ Überzug","️ Überzug","Konsole","Alacritty","Überzug","pkgs","paru","unarchiver","pdftoppm","poppler","prebuild","singlefile","jpegopt","EXIF","rustfmt","mktemp","nanos","xclip","xsel","natord"],"version":"0.2"}