From 826e24eaf4a10921bdd087079c4d7330896b79c2 Mon Sep 17 00:00:00 2001 From: DLFW Date: Tue, 30 Aug 2022 19:46:22 +0200 Subject: Add "Visual-Mode" (#192) * Add "Visual-Mode" This adds a "visual-mode" file selection feature where a range-selection follows the file-cursor. Description of usage is added to the docs. Also, the "normal" and the new "visual-mode-selection" are now preserved when a `DirList` is reloaded. Wrap-up of changes: * Add command `toggle_visual`, mapped to `V` * Add command `escape`, mapped to `ESCAPE` * Add style `[visual_mode_selection]` for file entries which are "temporarily" selected by visual-mode * For `JoshutoDirEntry`, the attribute `selected` has been renamed to `permanent_selected`, and a second selection-attribute `visual_mode_selected` has been added. "Setters" and "getters" have been adapted/added accordingly. The former "getter" for the `selecetd` attribute still exists and returns `True` for an entry which is "permanant selected" _or_ "visual-mode selected". So any higher logic which acts on selected files does not need to care about "how" and entry is selected. * Each `JoshutoDirList` has an optional index of the file where visual-mode has been entered and re-calculates the "visual-mode selecetd" status for each entry any time the cursor-index changes. * The footer has been extended so it shows a "VIS" marker when the user is in visual-mode. This implementation of visual-mode is a bit different from the ranger one, where the visual-selection is turned into a "normal selection" when a command (like `copy-files`) is issued. This implementation keeps both selections separate until the user toggles back to "normal mode". Only then the visual-selection is taken over to the "normal selection". The user also can withdraw the visual-selection with `escape`. The `escape` command may be used also for other "reset"-actions in the future. * fix syntax for Rust stable * cargo clippy --- src/commands/escape.rs | 9 ++++++ src/commands/flat.rs | 9 +++++- src/commands/mod.rs | 2 ++ src/commands/quit.rs | 2 +- src/commands/selection.rs | 18 +++++------ src/commands/uimodes.rs | 9 ++++++ src/config/option/sort_type.rs | 2 +- src/config/theme/app_theme.rs | 5 +++ src/context/app_context.rs | 2 +- src/fs/dirlist.rs | 62 ++++++++++++++++++++++++++++++++++++++ src/fs/entry.rs | 26 +++++++++++++--- src/fs/metadata.rs | 2 +- src/history.rs | 30 ++++++++++++++++++ src/key_command/command.rs | 2 ++ src/key_command/constants.rs | 2 ++ src/key_command/impl_appcommand.rs | 3 ++ src/key_command/impl_appexecute.rs | 5 ++- src/key_command/impl_comment.rs | 3 ++ src/key_command/impl_display.rs | 2 +- src/key_command/impl_from_str.rs | 4 +++ src/ui/widgets/tui_footer.rs | 14 +++++++++ src/util/style.rs | 7 ++++- 22 files changed, 198 insertions(+), 22 deletions(-) create mode 100644 src/commands/escape.rs create mode 100644 src/commands/uimodes.rs (limited to 'src') diff --git a/src/commands/escape.rs b/src/commands/escape.rs new file mode 100644 index 0000000..1a507df --- /dev/null +++ b/src/commands/escape.rs @@ -0,0 +1,9 @@ +use crate::context::AppContext; +use crate::error::JoshutoResult; + +pub fn escape(context: &mut AppContext) -> JoshutoResult { + if let Some(curr_dir_list) = context.tab_context_mut().curr_tab_mut().curr_list_mut() { + curr_dir_list.visual_mode_cancel(); + }; + Ok(()) +} diff --git a/src/commands/flat.rs b/src/commands/flat.rs index c0e9dfb..b9d0a64 100644 --- a/src/commands/flat.rs +++ b/src/commands/flat.rs @@ -79,7 +79,14 @@ pub fn flatten(depth: usize, context: &mut AppContext) -> JoshutoResult { contents.sort_by(|f1, f2| sort_options.compare(f1, f2)); let metadata = JoshutoMetadata::from(path.as_path())?; - let dirlist = JoshutoDirList::new(path.clone(), contents, index, viewport_index, metadata); + let dirlist = JoshutoDirList::new( + path.clone(), + contents, + index, + viewport_index, + None, + metadata, + ); history.insert(path, dirlist); Ok(()) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f4e0ee5..c7420e2 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod change_directory; pub mod command_line; pub mod cursor_move; pub mod delete_files; +pub mod escape; pub mod file_ops; pub mod flat; pub mod line_nums; @@ -28,4 +29,5 @@ pub mod sub_process; pub mod subdir_fzf; pub mod tab_ops; pub mod touch_file; +pub mod uimodes; pub mod zoxide; diff --git a/src/commands/quit.rs b/src/commands/quit.rs index ccc9a8b..165f289 100644 --- a/src/commands/quit.rs +++ b/src/commands/quit.rs @@ -3,7 +3,7 @@ use std::io; use crate::context::AppContext; use crate::error::{JoshutoError, JoshutoErrorKind, JoshutoResult}; -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum QuitAction { DoNot, Noop, diff --git a/src/commands/selection.rs b/src/commands/selection.rs index a4d26c4..3bfa227 100644 --- a/src/commands/selection.rs +++ b/src/commands/selection.rs @@ -23,11 +23,11 @@ fn select_without_pattern(context: &mut AppContext, options: &SelectOption) -> J if let Some(curr_list) = context.tab_context_mut().curr_tab_mut().curr_list_mut() { curr_list.iter_mut().for_each(|e| { if options.reverse { - e.set_selected(false); + e.set_permanent_selected(false); } else if options.toggle { - e.set_selected(!e.is_selected()); + e.set_permanent_selected(!e.is_selected()); } else { - e.set_selected(true); + e.set_permanent_selected(true); } }); } @@ -38,11 +38,11 @@ fn select_without_pattern(context: &mut AppContext, options: &SelectOption) -> J .and_then(|s| s.curr_entry_mut()) { if options.reverse { - entry.set_selected(false); + entry.set_permanent_selected(false); } else if options.toggle { - entry.set_selected(!entry.is_selected()); + entry.set_permanent_selected(!entry.is_selected()); } else { - entry.set_selected(true); + entry.set_permanent_selected(true); } cursor_move::down(context, 1)?; } @@ -64,11 +64,11 @@ fn select_with_pattern( .for_each(|e| { found += 1; if options.reverse { - e.set_selected(false); + e.set_permanent_selected(false); } else if options.toggle { - e.set_selected(!e.is_selected()); + e.set_permanent_selected(!e.is_selected()); } else { - e.set_selected(true); + e.set_permanent_selected(true); } }); context diff --git a/src/commands/uimodes.rs b/src/commands/uimodes.rs new file mode 100644 index 0000000..480cc75 --- /dev/null +++ b/src/commands/uimodes.rs @@ -0,0 +1,9 @@ +use crate::context::AppContext; +use crate::error::JoshutoResult; + +pub fn toggle_visual_mode(context: &mut AppContext) -> JoshutoResult { + if let Some(curr_dir_list) = context.tab_context_mut().curr_tab_mut().curr_list_mut() { + curr_dir_list.toggle_visual_mode() + }; + Ok(()) +} diff --git a/src/config/option/sort_type.rs b/src/config/option/sort_type.rs index 442eb45..a00ad29 100644 --- a/src/config/option/sort_type.rs +++ b/src/config/option/sort_type.rs @@ -8,7 +8,7 @@ use serde_derive::Deserialize; use crate::config::option::SortOption; use crate::fs::JoshutoDirEntry; -#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] pub enum SortType { Lexical, Mtime, diff --git a/src/config/theme/app_theme.rs b/src/config/theme/app_theme.rs index 651b2fd..6e3dfec 100644 --- a/src/config/theme/app_theme.rs +++ b/src/config/theme/app_theme.rs @@ -13,6 +13,8 @@ pub struct AppThemeRaw { #[serde(default)] pub selection: AppStyleRaw, #[serde(default)] + pub visual_mode_selection: AppStyleRaw, + #[serde(default)] pub directory: AppStyleRaw, #[serde(default)] pub executable: AppStyleRaw, @@ -29,6 +31,7 @@ pub struct AppThemeRaw { impl From for AppTheme { fn from(crude: AppThemeRaw) -> Self { let selection = crude.selection.to_style_theme(); + let visual_mode_selection = crude.visual_mode_selection.to_style_theme(); let executable = crude.executable.to_style_theme(); let regular = crude.regular.to_style_theme(); let directory = crude.directory.to_style_theme(); @@ -46,6 +49,7 @@ impl From for AppTheme { Self { selection, + visual_mode_selection, executable, regular, directory, @@ -61,6 +65,7 @@ impl From for AppTheme { pub struct AppTheme { pub regular: AppStyle, pub selection: AppStyle, + pub visual_mode_selection: AppStyle, pub directory: AppStyle, pub executable: AppStyle, pub link: AppStyle, diff --git a/src/context/app_context.rs b/src/context/app_context.rs index 364b5b1..66ffbc6 100644 --- a/src/context/app_context.rs +++ b/src/context/app_context.rs @@ -17,7 +17,7 @@ use crate::Args; use notify::{RecursiveMode, Watcher}; use std::path; -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct UiContext { pub layout: Vec, } diff --git a/src/fs/dirlist.rs b/src/fs/dirlist.rs index 096c9a0..567b8eb 100644 --- a/src/fs/dirlist.rs +++ b/src/fs/dirlist.rs @@ -15,6 +15,8 @@ pub struct JoshutoDirList { index: Option, /// The index in this dir list to start with when rendering the list viewport_index: usize, + /// The index in this dir list where visual mode has started or None if not in visual mode + visual_mode_anchor_index: Option, _need_update: bool, } @@ -24,6 +26,7 @@ impl JoshutoDirList { contents: Vec, index: Option, viewport_index: usize, + visual_mode_anchor_index: Option, metadata: JoshutoMetadata, ) -> Self { Self { @@ -32,6 +35,7 @@ impl JoshutoDirList { metadata, index, viewport_index, + visual_mode_anchor_index, _need_update: false, } } @@ -55,6 +59,7 @@ impl JoshutoDirList { _need_update: false, index, viewport_index: if let Some(ix) = index { ix } else { 0 }, + visual_mode_anchor_index: None, }) } @@ -62,6 +67,62 @@ impl JoshutoDirList { self.index } + pub fn get_visual_mode_anchor_index(&self) -> Option { + self.visual_mode_anchor_index + } + + fn update_visual_mode_selection(&mut self) { + //! To be invoked any time the cursor index, the visual mode anchor index, + //! or the shown sub-set of entries changes. + if let Some(vmix) = self.visual_mode_anchor_index { + if let Some(cix) = self.index { + self.iter_mut().enumerate().for_each(|(i, entry)| { + entry.set_visual_mode_selected( + (if vmix > cix { + cix..vmix + 1 + } else { + vmix..cix + 1 + }) + .contains(&i), + ) + }) + } + } else { + self.iter_mut() + .for_each(|entry| entry.set_visual_mode_selected(false)) + } + } + + fn visual_mode_enable(&mut self) { + if let Some(ix) = self.index { + self.visual_mode_anchor_index = Some(ix) + }; + self.update_visual_mode_selection(); + } + + fn visual_mode_disable(&mut self) { + self.visual_mode_anchor_index = None; + self.iter_mut().for_each(|entry| { + if entry.is_visual_mode_selected() { + entry.set_permanent_selected(true) + } + }); + self.update_visual_mode_selection(); + } + + pub fn visual_mode_cancel(&mut self) { + self.visual_mode_anchor_index = None; + self.update_visual_mode_selection(); + } + + pub fn toggle_visual_mode(&mut self) { + if self.get_visual_mode_anchor_index().is_none() { + self.visual_mode_enable() + } else { + self.visual_mode_disable() + } + } + fn update_viewport(&mut self, ui_context: &UiContext, options: &DisplayOption) { if let Some(ix) = self.index { let height = ui_context.layout[0].height as usize; @@ -106,6 +167,7 @@ impl JoshutoDirList { if !ui_context.layout.is_empty() { self.update_viewport(ui_context, options); } + self.update_visual_mode_selection(); } pub fn iter(&self) -> Iter { diff --git a/src/fs/entry.rs b/src/fs/entry.rs index 8337bc4..f7dae32 100644 --- a/src/fs/entry.rs +++ b/src/fs/entry.rs @@ -12,7 +12,10 @@ pub struct JoshutoDirEntry { label: String, path: path::PathBuf, pub metadata: JoshutoMetadata, - selected: bool, + /// Directly selected by the user, _not_ by a current visual mode selection + permanent_selected: bool, + /// Temporarily selected by the visual mode range + visual_mode_selected: bool, _marked: bool, } @@ -63,11 +66,23 @@ impl JoshutoDirEntry { } pub fn is_selected(&self) -> bool { - self.selected + self.permanent_selected || self.visual_mode_selected } - pub fn set_selected(&mut self, selected: bool) { - self.selected = selected; + pub fn is_permanent_selected(&self) -> bool { + self.permanent_selected + } + + pub fn is_visual_mode_selected(&self) -> bool { + self.visual_mode_selected + } + + pub fn set_permanent_selected(&mut self, selected: bool) { + self.permanent_selected = selected; + } + + pub fn set_visual_mode_selected(&mut self, visual_mode_selected: bool) { + self.visual_mode_selected = visual_mode_selected; } pub fn get_ext(&self) -> &str { @@ -102,7 +117,8 @@ impl JoshutoDirEntry { label, path, metadata, - selected: false, + permanent_selected: false, + visual_mode_selected: false, _marked: false, }) } diff --git a/src/fs/metadata.rs b/src/fs/metadata.rs index fd192cc..616e09c 100644 --- a/src/fs/metadata.rs +++ b/src/fs/metadata.rs @@ -1,6 +1,6 @@ use std::{fs, io, path, time}; -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum FileType { Directory, File, diff --git a/src/history.rs b/src/history.rs index 42c5b0a..162c3bd 100644 --- a/src/history.rs +++ b/src/history.rs @@ -156,6 +156,8 @@ pub fn create_dirlist_with_history( ) -> io::Result { let filter_func = options.filter_func(); let mut contents = read_directory(path, filter_func, options)?; + + // re-use directory size information on reload for entry in contents.iter_mut() { if entry.metadata.is_dir() { if let Some(lst) = history.get(entry.file_path()) { @@ -164,6 +166,19 @@ pub fn create_dirlist_with_history( } } + // preserve selection status of entries on reload + if let Some(former_dir_list) = history.get(path) { + let former_entries_by_file_name = HashMap::<&str, &JoshutoDirEntry>::from_iter( + former_dir_list.contents.iter().map(|e| (e.file_name(), e)), + ); + for entry in contents.iter_mut() { + if let Some(former_entry) = former_entries_by_file_name.get(entry.file_name()) { + entry.set_permanent_selected(former_entry.is_permanent_selected()); + entry.set_visual_mode_selected(former_entry.is_visual_mode_selected()); + } + } + } + let sort_options = tab_options.sort_options_ref(); contents.sort_by(|f1, f2| sort_options.compare(f1, f2)); @@ -199,6 +214,20 @@ pub fn create_dirlist_with_history( None => 0, } }; + let visual_mode_anchor_index = match history.get(path) { + None => None, + Some(dirlist) => { + dirlist + .get_visual_mode_anchor_index() + .map(|old_visual_mode_anchor_index| { + if old_visual_mode_anchor_index < contents_len { + old_visual_mode_anchor_index + } else { + contents_len - 1 + } + }) + } + }; let metadata = JoshutoMetadata::from(path)?; let dirlist = JoshutoDirList::new( @@ -206,6 +235,7 @@ pub fn create_dirlist_with_history( contents, index, viewport_index, + visual_mode_anchor_index, metadata, ); diff --git a/src/key_command/command.rs b/src/key_command/command.rs index 9ffbe5c..6c698ea 100644 --- a/src/key_command/command.rs +++ b/src/key_command/command.rs @@ -7,6 +7,8 @@ use crate::io::FileOperationOptions; #[derive(Clone, Debug)] #[allow(clippy::enum_variant_names)] pub enum Command { + Escape, + ToggleVisualMode, BulkRename, ChangeDirectory(path::PathBuf), diff --git a/src/key_command/constants.rs b/src/key_command/constants.rs index 8dd60ed..68b99d2 100644 --- a/src/key_command/constants.rs +++ b/src/key_command/constants.rs @@ -69,6 +69,7 @@ cmd_constants![ (CMD_TAB_SWITCH, "tab_switch"), (CMD_TAB_SWITCH_INDEX, "tab_switch_index"), (CMD_TOGGLE_HIDDEN, "toggle_hidden"), + (CMD_TOGGLE_VISUAL, "toggle_visual"), (CMD_SWITCH_LINE_NUMBERS, "line_nums"), (CMD_TOUCH_FILE, "touch"), (CMD_HELP, "help"), @@ -77,6 +78,7 @@ cmd_constants![ (CMD_ZOXIDE, "z"), (CMD_ZOXIDE_INTERACTIVE, "zi"), (CMD_FLAT, "flat"), + (CMD_ESCAPE, "escape"), ]; pub fn complete_command(partial_command: &str) -> Vec { diff --git a/src/key_command/impl_appcommand.rs b/src/key_command/impl_appcommand.rs index fe7dbc2..f942d82 100644 --- a/src/key_command/impl_appcommand.rs +++ b/src/key_command/impl_appcommand.rs @@ -8,6 +8,9 @@ impl AppCommand for Command { Self::Quit(_) => CMD_QUIT, + Self::ToggleVisualMode => CMD_TOGGLE_VISUAL, + Self::Escape => CMD_ESCAPE, + Self::BulkRename => CMD_BULK_RENAME, Self::ChangeDirectory(_) => CMD_CHANGE_DIRECTORY, diff --git a/src/key_command/impl_appexecute.rs b/src/key_command/impl_appexecute.rs index cd7aacc..65ef504 100644 --- a/src/key_command/impl_appexecute.rs +++ b/src/key_command/impl_appexecute.rs @@ -13,7 +13,10 @@ impl AppExecute for Command { backend: &mut AppBackend, keymap_t: &AppKeyMapping, ) -> JoshutoResult { - match &*self { + match self { + Self::Escape => escape::escape(context), + Self::ToggleVisualMode => uimodes::toggle_visual_mode(context), + Self::BulkRename => bulk_rename::bulk_rename(context, backend), Self::ChangeDirectory(p) => { diff --git a/src/key_command/impl_comment.rs b/src/key_command/impl_comment.rs index 36697c0..6f56c78 100644 --- a/src/key_command/impl_comment.rs +++ b/src/key_command/impl_comment.rs @@ -7,8 +7,11 @@ impl CommandComment for Command { // These comments are displayed at the help page fn comment(&self) -> &'static str { match self { + Self::Escape => "Escape from visual mode (cancel)", Self::BulkRename => "Bulk rename", + Self::ToggleVisualMode => "Toggle visual mode", + Self::ChangeDirectory(_) => "Change directory", Self::ParentDirectory => "CD to parent directory", Self::PreviousDirectory => "CD to the last dir in history", diff --git a/src/key_command/impl_display.rs b/src/key_command/impl_display.rs index 9c9a7b4..c414486 100644 --- a/src/key_command/impl_display.rs +++ b/src/key_command/impl_display.rs @@ -2,7 +2,7 @@ use super::{AppCommand, Command}; impl std::fmt::Display for Command { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match &*self { + match self { Self::ChangeDirectory(p) => write!(f, "{} {:?}", self.command(), p), Self::CommandLine(s, p) => write!(f, "{} {} {}", self.command(), s, p), Self::CursorMoveUp(i) => write!(f, "{} {}", self.command(), i), diff --git a/src/key_command/impl_from_str.rs b/src/key_command/impl_from_str.rs index 2c9fc96..f358e3e 100644 --- a/src/key_command/impl_from_str.rs +++ b/src/key_command/impl_from_str.rs @@ -34,6 +34,10 @@ impl std::str::FromStr for Command { None => (s, ""), }; + simple_command_conversion_case!(command, CMD_ESCAPE, Self::Escape); + + simple_command_conversion_case!(command, CMD_TOGGLE_VISUAL, Self::ToggleVisualMode); + simple_command_conversion_case!(command, CMD_NEW_TAB, Self::NewTab); simple_command_conversion_case!(command, CMD_CLOSE_TAB, Self::CloseTab); diff --git a/src/ui/widgets/tui_footer.rs b/src/ui/widgets/tui_footer.rs index 1cc0995..d4fbef7 100644 --- a/src/ui/widgets/tui_footer.rs +++ b/src/ui/widgets/tui_footer.rs @@ -26,6 +26,7 @@ impl<'a> Widget for TuiFooter<'a> { Some(i) if i < self.dirlist.len() => { let entry = &self.dirlist.contents[i]; + let visual_mode_style = Style::default().fg(Color::Black).bg(Color::LightRed); let mode_style = Style::default().fg(Color::Cyan); let mode_str = unix::mode_to_string(entry.metadata.permissions_ref().mode()); @@ -33,6 +34,19 @@ impl<'a> Widget for TuiFooter<'a> { let size_str = format::file_size_to_string(entry.metadata.len()); let mut text = vec![ + Span::styled( + if self.dirlist.get_visual_mode_anchor_index().is_none() { + "" + } else { + "VIS" + }, + visual_mode_style, + ), + Span::raw(if self.dirlist.get_visual_mode_anchor_index().is_none() { + "" + } else { + " " + }), Span::styled(mode_str, mode_style), Span::raw(" "), Span::raw(format!("{}/{}", i + 1, self.dirlist.len())), diff --git a/src/util/style.rs b/src/util/style.rs index 4312425..6715f50 100644 --- a/src/util/style.rs +++ b/src/util/style.rs @@ -10,7 +10,12 @@ pub fn entry_style(entry: &JoshutoDirEntry) -> Style { let filetype = &metadata.file_type(); let linktype = &metadata.link_type(); - if entry.is_selected() { + if entry.is_visual_mode_selected() { + Style::default() + .fg(THEME_T.visual_mode_selection.fg) + .bg(THEME_T.visual_mode_selection.bg) + .add_modifier(THEME_T.visual_mode_selection.modifier) + } else if entry.is_permanent_selected() { Style::default() .fg(THEME_T.selection.fg) .bg(THEME_T.selection.bg) -- cgit v1.2.3