├── tcide ├── tests └── test_dirs │ ├── file0 │ ├── file1 │ ├── file2 │ ├── file21 │ ├── file22 │ ├── file23 │ ├── file24 │ ├── file25 │ ├── file26 │ ├── file27 │ ├── dir1 │ ├── file7 │ ├── file8 │ ├── dir6 │ │ ├── file10 │ │ ├── file11 │ │ ├── file9 │ │ ├── dir10 │ │ │ └── file28 │ │ ├── dir8 │ │ │ └── file17 │ │ └── dir9 │ │ │ ├── file12 │ │ │ ├── dir11 │ │ │ └── file18 │ │ │ └── dir12 │ │ │ ├── file13 │ │ │ ├── file14 │ │ │ └── file15 │ └── dir7 │ │ └── file19 │ ├── dir2 │ └── file20 │ └── dir0 │ ├── dir3 │ ├── file4 │ └── file5 │ ├── dir4 │ └── file16 │ └── dir5 │ └── file6 ├── .travis.yml ├── dev ├── cover.sh ├── build.sh ├── test.sh ├── lint.sh ├── install.sh └── deploy.sh ├── screenshots ├── tcide.png └── twilight-commander.png ├── rustfmt.toml ├── src ├── controller │ ├── key_event_matcher │ │ ├── quit.rs │ │ ├── entry_up.rs │ │ ├── entry_down.rs │ │ ├── expand_dir.rs │ │ ├── file_action.rs │ │ ├── reload.rs │ │ └── collapse_dir.rs │ ├── resize_event_handler.rs │ ├── key_event_handler.rs │ └── key_event_matcher.rs ├── model │ ├── config │ │ ├── setup.rs │ │ ├── color.rs │ │ ├── composition.rs │ │ ├── behavior.rs │ │ ├── debug.rs │ │ └── keybinding.rs │ ├── path_node │ │ └── debug.rs │ ├── tree_index.rs │ ├── compare_functions.rs │ ├── event.rs │ ├── config.rs │ └── path_node.rs ├── main.rs ├── view.rs ├── utils.rs ├── view │ ├── update.rs │ ├── composer.rs │ ├── print.rs │ └── scroll.rs ├── controller.rs └── model.rs ├── .gitignore ├── Cargo.toml ├── LICENSE ├── twilight-commander-vim.toml ├── twilight-commander.toml ├── tcide_vim ├── tcide_neovim ├── README.md └── Cargo.lock /tcide: -------------------------------------------------------------------------------- 1 | tcide_neovim -------------------------------------------------------------------------------- /tests/test_dirs/file0: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/file1: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/file2: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/file21: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/file22: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/file23: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/file24: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/file25: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/file26: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/file27: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir1/file7: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir1/file8: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir2/file20: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir0/dir3/file4: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir0/dir3/file5: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir0/dir4/file16: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir0/dir5/file6: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir1/dir6/file10: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir1/dir6/file11: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir1/dir6/file9: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir1/dir7/file19: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir1/dir6/dir10/file28: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir1/dir6/dir8/file17: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir1/dir6/dir9/file12: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir1/dir6/dir9/dir11/file18: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir1/dir6/dir9/dir12/file13: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir1/dir6/dir9/dir12/file14: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_dirs/dir1/dir6/dir9/dir12/file15: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | cache: cargo 3 | -------------------------------------------------------------------------------- /dev/cover.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cargo tarpaulin -v 4 | -------------------------------------------------------------------------------- /dev/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cargo build --release 4 | 5 | -------------------------------------------------------------------------------- /dev/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | RUST_BACKTRACE=full cargo test "$1" -- --nocapture 4 | -------------------------------------------------------------------------------- /screenshots/tcide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golmman/twilight-commander/HEAD/screenshots/tcide.png -------------------------------------------------------------------------------- /dev/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cargo clean --package twilight-commander 4 | cargo clippy -- -W clippy::all 5 | 6 | -------------------------------------------------------------------------------- /screenshots/twilight-commander.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/golmman/twilight-commander/HEAD/screenshots/twilight-commander.png -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2018" 2 | max_width = 80 3 | newline_style = "Unix" 4 | use_field_init_shorthand = true 5 | use_small_heuristics = "Default" 6 | 7 | -------------------------------------------------------------------------------- /src/controller/key_event_matcher/quit.rs: -------------------------------------------------------------------------------- 1 | use crate::controller::EventQueue; 2 | use std::io::Write; 3 | 4 | impl EventQueue { 5 | pub fn do_quit(&mut self) -> Option<()> { 6 | None 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # These are backup files generated by rustfmt 6 | **/*.rs.bk 7 | 8 | # vim swap files 9 | **/*.swp 10 | 11 | # anything else 12 | notes.md 13 | -------------------------------------------------------------------------------- /src/controller/key_event_matcher/entry_up.rs: -------------------------------------------------------------------------------- 1 | use crate::controller::EventQueue; 2 | use std::io::Write; 3 | 4 | impl EventQueue { 5 | pub fn do_entry_up(&mut self) -> Option<()> { 6 | self.update_pager(-1); 7 | Some(()) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/controller/key_event_matcher/entry_down.rs: -------------------------------------------------------------------------------- 1 | use crate::controller::EventQueue; 2 | use std::io::Write; 3 | 4 | impl EventQueue { 5 | pub fn do_entry_down(&mut self) -> Option<()> { 6 | self.update_pager(1); 7 | Some(()) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /dev/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "$EUID" -ne 0 ] 4 | then echo "please run as root" 5 | exit 6 | fi 7 | 8 | cp target/release/twilight-commander /usr/local/bin/twilight-commander 9 | cp tcide_vim /usr/local/bin/tcide_vim 10 | cp tcide_neovim /usr/local/bin/tcide_neovim 11 | cp tcide /usr/local/bin/tcide 12 | 13 | echo "twilight-commander was installed/updated" 14 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Dirk Kretschmann "] 3 | edition = "2018" 4 | license = "MIT" 5 | name = "twilight-commander" 6 | version = "0.14.1" 7 | 8 | [dependencies] 9 | chrono = "0.4.10" 10 | fern = "0.5" 11 | log = "0.4" 12 | serde = { version = "1.0.101", features = ["derive"] } 13 | signal-hook = "0.1.10" 14 | termion = "1.5.3" 15 | toml = "0.5.3" 16 | exec = "0.3.1" -------------------------------------------------------------------------------- /src/model/config/setup.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Clone, Debug, Deserialize)] 4 | pub struct Setup { 5 | #[serde(default = "Setup::default_working_dir")] 6 | pub working_dir: String, 7 | } 8 | 9 | impl Default for Setup { 10 | fn default() -> Self { 11 | Setup { 12 | working_dir: Self::default_working_dir(), 13 | } 14 | } 15 | } 16 | 17 | impl Setup { 18 | fn default_working_dir() -> String { 19 | String::from(".") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/controller/resize_event_handler.rs: -------------------------------------------------------------------------------- 1 | use crate::model::event::Event; 2 | use std::sync::mpsc::SyncSender; 3 | use std::sync::mpsc; 4 | 5 | pub struct ResizeEventHandler {} 6 | 7 | impl ResizeEventHandler { 8 | pub fn handle(sync_sender: SyncSender, rx: mpsc::Receiver<()>) { 9 | let hook_id = unsafe { 10 | signal_hook::register(signal_hook::SIGWINCH, move || { 11 | sync_sender.send(Event::Resize).unwrap(); 12 | }) 13 | }; 14 | let _ = rx.recv(); 15 | signal_hook::unregister(hook_id.unwrap()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/controller/key_event_matcher/expand_dir.rs: -------------------------------------------------------------------------------- 1 | use crate::controller::EventQueue; 2 | use std::io::Write; 3 | 4 | impl EventQueue { 5 | pub fn do_expand_dir(&mut self) -> Option<()> { 6 | let tree_index = self 7 | .path_node_root 8 | .flat_index_to_tree_index(self.pager.cursor_row as usize); 9 | self.path_node_root 10 | .expand_dir(&tree_index, self.path_node_compare); 11 | self.text_entries = 12 | self.composer.compose_path_node(&self.path_node_root); 13 | 14 | self.update_pager(0); 15 | Some(()) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/model/path_node/debug.rs: -------------------------------------------------------------------------------- 1 | use crate::model::config::Config; 2 | use crate::model::path_node::PathNode; 3 | use crate::view::composer::Composer; 4 | use std::fmt::Debug; 5 | use std::fmt::Formatter; 6 | use std::fmt::Result; 7 | 8 | impl Debug for PathNode { 9 | fn fmt(&self, f: &mut Formatter<'_>) -> Result { 10 | let composer = Composer::from(Config::new()); 11 | 12 | let entries = composer.compose_path_node(self); 13 | 14 | for (index, entry) in entries.iter().enumerate() { 15 | writeln!(f, "{:4}|{}", index, entry)?; 16 | } 17 | 18 | Ok(()) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/model/config/color.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Clone, Debug, Deserialize)] 4 | pub struct Color { 5 | #[serde(default = "Color::default_background")] 6 | pub background: String, 7 | 8 | #[serde(default = "Color::default_foreground")] 9 | pub foreground: String, 10 | } 11 | 12 | impl Default for Color { 13 | fn default() -> Self { 14 | Color { 15 | background: Self::default_background(), 16 | foreground: Self::default_foreground(), 17 | } 18 | } 19 | } 20 | 21 | impl Color { 22 | fn default_background() -> String { 23 | String::from("000000") 24 | } 25 | 26 | fn default_foreground() -> String { 27 | String::from("FFFFFF") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/controller/key_event_handler.rs: -------------------------------------------------------------------------------- 1 | use crate::model::event::Event; 2 | use crate::model::event::Key; 3 | use std::io::stdin; 4 | use std::sync::mpsc::SyncSender; 5 | use termion::input::TermRead; 6 | use std::sync::mpsc::{self, TryRecvError}; 7 | 8 | pub struct KeyEventHandler {} 9 | 10 | impl KeyEventHandler { 11 | pub fn handle(sync_sender: SyncSender, rx: mpsc::Receiver<()>) { 12 | let stdin = stdin(); 13 | 14 | for termion_event in stdin.events() { 15 | if let Ok(termion_event) = termion_event { 16 | let _ = sync_sender.send(Event::Key(Key::from(termion_event))); 17 | } 18 | match rx.try_recv() { 19 | Ok(_) | Err(TryRecvError::Disconnected) => { 20 | // println!("Terminating."); 21 | break; 22 | } 23 | Err(TryRecvError::Empty) => {} 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/model/config/composition.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Clone, Debug, Deserialize)] 4 | pub struct Composition { 5 | #[serde(default = "Composition::default_indent")] 6 | pub indent: i32, 7 | 8 | #[serde(default = "Composition::default_show_indent")] 9 | pub show_indent: bool, 10 | 11 | #[serde(default = "Composition::default_use_utf8")] 12 | pub use_utf8: bool, 13 | } 14 | 15 | impl Default for Composition { 16 | fn default() -> Composition { 17 | Composition { 18 | indent: Self::default_indent(), 19 | show_indent: Self::default_show_indent(), 20 | use_utf8: Self::default_use_utf8(), 21 | } 22 | } 23 | } 24 | 25 | impl Composition { 26 | fn default_indent() -> i32 { 27 | 2 28 | } 29 | 30 | fn default_show_indent() -> bool { 31 | false 32 | } 33 | 34 | fn default_use_utf8() -> bool { 35 | true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /dev/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo 'DEPLOY: formatting project...' 4 | if ! cargo fmt ; then 5 | echo 'DEPLOY: An error occurred.' 6 | exit 1 7 | fi 8 | 9 | echo 'DEPLOY: checking for uncommitted changes...' 10 | if [[ -n $(git status -s) ]]; then 11 | echo 'DEPLOY: Please commit your changes.' 12 | exit 1 13 | fi 14 | 15 | echo 'DEPLOY: Checking for version change...' 16 | if -z git show --name-status | grep 'Cargo.toml'; then 17 | echo 'DEPLOY: The last commit did not contain a change to Cargo.toml. Please commit a version change.' 18 | exit 1 19 | fi 20 | 21 | echo 'DEPLOY: cleaning project...' 22 | if ! cargo clean --package twilight-commander ; then 23 | echo 'DEPLOY: An error occurred.' 24 | exit 1 25 | fi 26 | 27 | echo 'DEPLOY: linting project...' 28 | if ! cargo clippy -- -D warnings ; then 29 | exit 1 30 | fi 31 | 32 | echo 'DEPLOY: testing project...' 33 | if ! cargo test ; then 34 | exit 1 35 | fi 36 | 37 | echo 'DEPLOY: pushing to master...' 38 | git push origin master 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 golmman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | extern crate chrono; 2 | extern crate termion; 3 | extern crate toml; 4 | use controller::EventQueue; 5 | use log::info; 6 | use model::config::Config; 7 | use model::path_node::PathNode; 8 | use std::io::stdout; 9 | use termion::raw::IntoRawMode; 10 | use utils::setup_logger; 11 | use view::composer::Composer; 12 | use view::Pager; 13 | use exec::execvp; 14 | 15 | mod controller; 16 | mod model; 17 | mod utils; 18 | mod view; 19 | 20 | fn main() { 21 | 22 | let command_to_run_on_exit = { 23 | let _ = setup_logger(); 24 | 25 | let config = Config::new(); 26 | 27 | let composer = Composer::from(config.clone()); 28 | 29 | let pager = Pager::new(config.clone(), stdout().into_raw_mode().unwrap()); 30 | 31 | let path_node_root = PathNode::new_expanded(config.clone()); 32 | 33 | let mut event_queue = 34 | EventQueue::new(config, composer, pager, path_node_root); 35 | 36 | event_queue.handle_messages() 37 | }; 38 | 39 | if let Some(cmd) = command_to_run_on_exit { 40 | let _ = execvp("bash", &["bash", "-c", &cmd]); 41 | }; 42 | 43 | info!("clean exit"); 44 | } 45 | -------------------------------------------------------------------------------- /src/controller/key_event_matcher/file_action.rs: -------------------------------------------------------------------------------- 1 | use crate::controller::EventQueue; 2 | use std::io::Write; 3 | use log::info; 4 | 5 | impl EventQueue { 6 | pub fn do_file_action(&mut self) -> Option<()> { 7 | let tree_index = self 8 | .path_node_root 9 | .flat_index_to_tree_index(self.pager.cursor_row as usize); 10 | 11 | let child_node = self.path_node_root.get_child_path_node(&tree_index); 12 | 13 | if !child_node.is_dir { 14 | let file_path = &child_node.get_absolute_path(); 15 | let file_action_replaced = 16 | self.config.behavior.file_action.replace("%s", file_path); 17 | 18 | info!("executing file action:\n{}", file_action_replaced); 19 | 20 | 21 | if self.config.behavior.quit_on_action { 22 | self.command_to_run_on_exit = Some(file_action_replaced); 23 | None 24 | } else { 25 | std::process::Command::new("bash") 26 | .arg("-c") 27 | .arg(file_action_replaced) 28 | .spawn() 29 | .unwrap(); 30 | Some(()) 31 | } 32 | } 33 | else 34 | {Some(())} 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/model/config/behavior.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Clone, Debug, Deserialize)] 4 | pub struct Behavior { 5 | #[serde(default = "Behavior::default_file_action")] 6 | pub file_action: String, 7 | 8 | #[serde(default = "Behavior::default_path_node_sort")] 9 | pub path_node_sort: String, 10 | 11 | #[serde(default = "Behavior::default_scrolling")] 12 | pub scrolling: String, 13 | 14 | #[serde(default = "Behavior::default_quit_on_action")] 15 | pub quit_on_action: bool, 16 | } 17 | 18 | impl Default for Behavior { 19 | fn default() -> Behavior { 20 | Behavior { 21 | file_action: Self::default_file_action(), 22 | path_node_sort: Self::default_path_node_sort(), 23 | scrolling: Self::default_scrolling(), 24 | quit_on_action: Self::default_quit_on_action(), 25 | } 26 | } 27 | } 28 | 29 | impl Behavior { 30 | fn default_file_action() -> String { 31 | String::from("true") // do nothing! 32 | } 33 | 34 | fn default_path_node_sort() -> String { 35 | String::from("dirs_top_simple") 36 | } 37 | 38 | fn default_scrolling() -> String { 39 | String::from("center") 40 | } 41 | 42 | fn default_quit_on_action() -> bool { 43 | false 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /twilight-commander-vim.toml: -------------------------------------------------------------------------------- 1 | [behavior] 2 | # command interpreted by bash when pressing the file_action key 3 | file_action = "true" 4 | 5 | # determines the compare function used for sorting entries 6 | # enum: none, dirs_top_simple, dirs_bot_simple 7 | # TODO: rename to entry_sort 8 | path_node_sort = "dirs_top_simple" 9 | 10 | # the scrollung algorithm used 11 | # enum: center, editor 12 | scrolling = "center" 13 | 14 | # the amount of entries skipped when the skip keys are pressed 15 | skip_amount = 5 16 | 17 | 18 | [composition] 19 | # indention used for subentries 20 | indent = 2 21 | 22 | # when true shows visual markers for indention whitespaces 23 | show_indent = false 24 | 25 | # when true uses utf8 characters 26 | use_utf8 = true 27 | 28 | [debug] 29 | # enables debug mode 30 | enabled = false 31 | 32 | # the minimum distance of the highlighted entry to the spacing 33 | padding_bot = 3 34 | padding_top = 3 35 | 36 | # the number of lines not uses for entries 37 | spacing_bot = 2 38 | spacing_top = 2 39 | 40 | [keybinding] 41 | collapse_dir = "h" 42 | entry_down = "j" 43 | entry_up = "k" 44 | expand_dir = "l" 45 | file_action = "return" 46 | quit = "q" 47 | reload = "r" 48 | skip_up = "ctrl+k" 49 | skip_down = "ctrl+j" 50 | 51 | [setup] 52 | # the working directory used when starting 53 | working_dir = "." 54 | -------------------------------------------------------------------------------- /twilight-commander.toml: -------------------------------------------------------------------------------- 1 | [behavior] 2 | # command interpreted by bash when pressing the file_action key 3 | file_action = "true" 4 | 5 | # determines the compare function used for sorting entries 6 | # enum: none, dirs_top_simple, dirs_bot_simple 7 | # TODO: rename to entry_sort 8 | path_node_sort = "dirs_top_simple" 9 | 10 | # the scrollung algorithm used 11 | # enum: center, editor 12 | scrolling = "center" 13 | 14 | # the amount of entries skipped when the skip keys are pressed 15 | skip_amount = 5 16 | 17 | 18 | [composition] 19 | # indention used for subentries 20 | indent = 2 21 | 22 | # when true shows visual markers for indention whitespaces 23 | show_indent = false 24 | 25 | # when true uses utf8 characters 26 | use_utf8 = true 27 | 28 | [debug] 29 | # enables debug mode 30 | enabled = false 31 | 32 | # the minimum distance of the highlighted entry to the spacing 33 | padding_bot = 3 34 | padding_top = 3 35 | 36 | # the number of lines not uses for entries 37 | spacing_bot = 2 38 | spacing_top = 2 39 | 40 | [keybinding] 41 | collapse_dir = "left" 42 | entry_down = "down" 43 | entry_up = "up" 44 | expand_dir = "right" 45 | file_action = "return" 46 | quit = "q" 47 | reload = "r" 48 | skip_up = "ctrl+up" 49 | skip_down = "ctrl+down" 50 | 51 | [setup] 52 | # the working directory used when starting 53 | working_dir = "." 54 | -------------------------------------------------------------------------------- /src/model/config/debug.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Clone, Debug, Deserialize)] 4 | pub struct Debug { 5 | #[serde(default = "Debug::default_enabled")] 6 | pub enabled: bool, 7 | 8 | #[serde(default = "Debug::default_padding_bot")] 9 | pub padding_bot: i32, 10 | 11 | #[serde(default = "Debug::default_padding_top")] 12 | pub padding_top: i32, 13 | 14 | #[serde(default = "Debug::default_spacing_bot")] 15 | pub spacing_bot: i32, 16 | 17 | #[serde(default = "Debug::default_spacing_top")] 18 | pub spacing_top: i32, 19 | } 20 | 21 | impl Default for Debug { 22 | fn default() -> Self { 23 | Self { 24 | enabled: Self::default_enabled(), 25 | padding_bot: Self::default_padding_bot(), 26 | padding_top: Self::default_padding_top(), 27 | spacing_bot: Self::default_spacing_bot(), 28 | spacing_top: Self::default_spacing_top(), 29 | } 30 | } 31 | } 32 | 33 | impl Debug { 34 | fn default_enabled() -> bool { 35 | false 36 | } 37 | 38 | fn default_padding_bot() -> i32 { 39 | 3 40 | } 41 | 42 | fn default_padding_top() -> i32 { 43 | 3 44 | } 45 | 46 | fn default_spacing_bot() -> i32 { 47 | 2 48 | } 49 | 50 | fn default_spacing_top() -> i32 { 51 | 2 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tcide_vim: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Opens a tmux session with 3 panes: vim, twilight-commander, terminal. 4 | # Hitting the file_action key (default: return) on a file entry opens it in a new vim tab. 5 | # You need to compile vim with the clientserver option: 6 | # git clone https://github.com/vim/vim.git 7 | # cd vim/src 8 | # make distclean 9 | # ./configure +clientserver 10 | # make 11 | # sudo make install 12 | 13 | if [ -z "$1" ]; then 14 | INPUT_DIR='.' 15 | elif [ -d "$1" ]; then 16 | INPUT_DIR="$1" 17 | elif [ -f "$1" ]; then 18 | INPUT_DIR="$(dirname "$1")" 19 | else 20 | echo "tcide: opens a tmux session with vim and integrated twilight-commander" 21 | echo "usage: tcide [directory or file]" 22 | exit 1 23 | fi 24 | 25 | DIRNAME="$(readlink -f "$INPUT_DIR")" 26 | BASENAME="$(basename "$DIRNAME")" 27 | 28 | echo "opening $DIRNAME" 29 | 30 | # TODO: check for used vim servers with 'vim --serverlist'? 31 | # tmux does not allow reuse of session names, so ths seems unecessary 32 | 33 | tmux -2 new-session -x "$(tput cols)" -y "$(tput lines)" \ 34 | -s "$DIRNAME" \ 35 | -d twilight-commander \ 36 | --behavior.file_action="vim --servername $DIRNAME --remote-tab %s" \ 37 | --setup.working_dir="$DIRNAME" \ 38 | \; \ 39 | split-window -h "vim --servername $DIRNAME" \; \ 40 | split-window -v \; \ 41 | resize-pane -t 0 -x 30 \; \ 42 | resize-pane -t 2 -y 15 \; \ 43 | set-option set-titles on \; \ 44 | set-option set-titles-string "$BASENAME" \; \ 45 | attach 46 | -------------------------------------------------------------------------------- /src/view.rs: -------------------------------------------------------------------------------- 1 | use crate::model::config::Config; 2 | use crate::view::composer::Composer; 3 | use log::info; 4 | use std::io::Write; 5 | 6 | pub mod composer; 7 | mod print; 8 | mod scroll; 9 | mod update; 10 | 11 | pub struct Pager { 12 | config: Config, 13 | pub cursor_row: i32, 14 | out: W, 15 | terminal_cols: i32, 16 | terminal_rows: i32, 17 | text_row: i32, 18 | } 19 | 20 | impl Pager { 21 | pub fn new(config: Config, mut out: W) -> Self { 22 | info!("initializing pager"); 23 | 24 | write!( 25 | out, 26 | "{}{}{}", 27 | termion::cursor::Hide, 28 | termion::cursor::Goto(1, 1), 29 | termion::clear::All, 30 | ) 31 | .unwrap(); 32 | 33 | Self { 34 | config, 35 | cursor_row: 0, 36 | out, 37 | terminal_cols: 0, 38 | terminal_rows: 0, 39 | text_row: 0, 40 | } 41 | } 42 | } 43 | 44 | impl Drop for Pager { 45 | fn drop(&mut self) { 46 | write!( 47 | self, 48 | "{}{}{}", 49 | termion::clear::All, 50 | termion::cursor::Goto(1, 1), 51 | termion::cursor::Show, 52 | ) 53 | .unwrap(); 54 | } 55 | } 56 | 57 | impl Write for Pager { 58 | fn write(&mut self, buf: &[u8]) -> std::io::Result { 59 | self.out.write(buf) 60 | } 61 | 62 | fn flush(&mut self) -> std::io::Result<()> { 63 | self.out.flush() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/model/config/keybinding.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Clone, Debug, Deserialize)] 4 | pub struct Keybinding { 5 | #[serde(default = "Keybinding::default_quit")] 6 | pub quit: String, 7 | 8 | #[serde(default = "Keybinding::default_entry_up")] 9 | pub entry_up: String, 10 | 11 | #[serde(default = "Keybinding::default_entry_down")] 12 | pub entry_down: String, 13 | 14 | #[serde(default = "Keybinding::default_expand_dir")] 15 | pub expand_dir: String, 16 | 17 | #[serde(default = "Keybinding::default_collapse_dir")] 18 | pub collapse_dir: String, 19 | 20 | #[serde(default = "Keybinding::default_file_action")] 21 | pub file_action: String, 22 | 23 | #[serde(default = "Keybinding::default_reload")] 24 | pub reload: String, 25 | } 26 | 27 | impl Default for Keybinding { 28 | fn default() -> Self { 29 | Self { 30 | quit: Self::default_quit(), 31 | entry_up: Self::default_entry_up(), 32 | entry_down: Self::default_entry_down(), 33 | expand_dir: Self::default_expand_dir(), 34 | collapse_dir: Self::default_collapse_dir(), 35 | file_action: Self::default_file_action(), 36 | reload: Self::default_reload(), 37 | } 38 | } 39 | } 40 | 41 | impl Keybinding { 42 | fn default_quit() -> String { 43 | String::from("q") 44 | } 45 | 46 | fn default_entry_up() -> String { 47 | String::from("up") 48 | } 49 | 50 | fn default_entry_down() -> String { 51 | String::from("down") 52 | } 53 | 54 | fn default_expand_dir() -> String { 55 | String::from("right") 56 | } 57 | 58 | fn default_collapse_dir() -> String { 59 | String::from("left") 60 | } 61 | 62 | fn default_file_action() -> String { 63 | String::from("return") 64 | } 65 | 66 | fn default_reload() -> String { 67 | String::from("r") 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tcide_neovim: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Opens a tmux session with 3 panes: nvim, twilight-commander, terminal. 4 | # Hitting the file_action key (default: return) on a file entry opens it in a new nvim tab. 5 | 6 | if [ -z "$1" ]; then 7 | INPUT_DIR='.' 8 | elif [ -d "$1" ]; then 9 | INPUT_DIR="$1" 10 | elif [ -f "$1" ]; then 11 | INPUT_DIR="$(dirname "$1")" 12 | else 13 | echo "tcide: opens a tmux session with neovim and integrated twilight-commander" 14 | echo "usage: tcide [directory or file]" 15 | exit 1 16 | fi 17 | 18 | DIRNAME="$(readlink -f "$INPUT_DIR")" 19 | BASENAME="$(basename "$DIRNAME")" 20 | 21 | echo "opening $DIRNAME" 22 | 23 | # The following code builds a msgpack-object (https://msgpack.org/) which 24 | # conforms to the rpc specification: 25 | # https://github.com/msgpack-rpc/msgpack-rpc/blob/master/spec.md 26 | # [0, 1, "nvim_command", ["tabnew test.js"]] 27 | # 28 | # The json array above is encoded to hex and then to binary. 29 | # The command is then sent to the named socket SOCKET_NAME 30 | MSGPACK_COMMAND=' 31 | ( 32 | realpath --relative-to=. "%s" 33 | ) | ( 34 | NVIM_COMMAND_ARG="tabnew $(cat /dev/stdin)" 35 | 36 | LANG=C LC_ALL=C 37 | LENGTH=${#NVIM_COMMAND_ARG}; 38 | 39 | if [ $LENGTH -lt 32 ]; then 40 | MSGPACK_LEN_HEX=$(printf "%x" $(( 160 + $LENGTH ))); 41 | else 42 | MSGPACK_LEN_HEX=$(printf "d9%x" $(( $LENGTH ))); 43 | fi; 44 | 45 | NVIM_COMMAND_HEX="940001ac6e76696d5f636f6d6d616e6491" 46 | NVIM_COMMAND_ARG_HEX=$(printf "$NVIM_COMMAND_ARG" | xxd -p -c 10000); 47 | 48 | printf "$NVIM_COMMAND_HEX$MSGPACK_LEN_HEX$NVIM_COMMAND_ARG_HEX"; 49 | ) | ( 50 | xxd -r -p 51 | )' 52 | 53 | SOCKET_NAME="tcide_$RANDOM" 54 | NC_SOCKET="nc -q 0 -U $SOCKET_NAME" 55 | 56 | FILE_ACTION="$MSGPACK_COMMAND | $NC_SOCKET" 57 | 58 | tmux -2 new-session -x "$(tput cols)" -y "$(tput lines)" \ 59 | -s "$DIRNAME" \ 60 | -d twilight-commander \ 61 | --behavior.file_action="$FILE_ACTION" \ 62 | --setup.working_dir="$DIRNAME" \ 63 | \; \ 64 | split-window -h "nvim --listen $SOCKET_NAME" \; \ 65 | split-window -v \; \ 66 | resize-pane -t 0 -x 40 \; \ 67 | resize-pane -t 2 -y 20 \; \ 68 | set-option set-titles on \; \ 69 | set-option set-titles-string "$BASENAME" \; \ 70 | attach 71 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | use std::fs::File; 3 | use std::io::Read; 4 | use std::panic::set_hook; 5 | use std::process::exit; 6 | 7 | pub fn read_file(file_name: &str) -> std::io::Result { 8 | let mut file = File::open(file_name)?; 9 | let mut contents = String::new(); 10 | file.read_to_string(&mut contents)?; 11 | Ok(contents) 12 | } 13 | 14 | pub fn print_help() { 15 | println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); 16 | println!(r#"usage: twilight-commander [--key1=value1 --key2=value2 ...]"#); 17 | exit(0); 18 | } 19 | 20 | pub fn setup_logger() -> Result<(), fern::InitError> { 21 | let log_file_path = format!("{}/{}", get_config_dir()?, "tc.log"); 22 | 23 | fern::Dispatch::new() 24 | .format(|out, message, record| { 25 | let target = record.target(); 26 | let target_split_at = 0.max(target.len() as i32 - 20) as usize; 27 | let target_short = target.split_at(target_split_at); 28 | 29 | out.finish(format_args!( 30 | "[{}][{:05}][{:>20}] {}", 31 | chrono::Local::now() 32 | .to_rfc3339_opts(::chrono::SecondsFormat::Millis, true), 33 | record.level(), 34 | target_short.1, 35 | message 36 | )) 37 | }) 38 | .level(log::LevelFilter::Debug) 39 | .chain(fern::log_file(log_file_path)?) 40 | .apply()?; 41 | 42 | set_hook(Box::new(|panic_info| { 43 | if let Some(p) = panic_info.payload().downcast_ref::() { 44 | info!("{:?}, \npayload: {}", panic_info, p,); 45 | } else if let Some(p) = panic_info.payload().downcast_ref::<&str>() { 46 | info!("{:?}, \npayload: {}", panic_info, p,); 47 | } else { 48 | info!("{:?}", panic_info); 49 | } 50 | })); 51 | 52 | info!( 53 | r#"starting... 54 | 55 | _|_ o|o _ |__|_ _ _ ._ _ ._ _ _.._ _| _ ._ 56 | |_\/\/|||(_|| ||_ (_(_)| | || | |(_|| |(_|(/_| 57 | _| 58 | "# 59 | ); 60 | 61 | info!("logger initialized"); 62 | 63 | Ok(()) 64 | } 65 | 66 | pub fn get_config_dir() -> std::io::Result { 67 | if let Ok(xdg_config_home) = std::env::var("XDG_CONFIG_HOME") { 68 | Ok(xdg_config_home) 69 | } else if let Ok(home) = std::env::var("HOME") { 70 | Ok(format!("{}/.config/twilight-commander", home)) 71 | } else { 72 | Err(std::io::Error::new( 73 | std::io::ErrorKind::NotFound, 74 | "no HOME or XDG_CONFIG_HOME variable is defined", 75 | )) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/view/update.rs: -------------------------------------------------------------------------------- 1 | use crate::view::Pager; 2 | use std::io::Write; 3 | use termion::terminal_size; 4 | 5 | impl Pager { 6 | fn update_terminal_size(&mut self) { 7 | let (terminal_cols_raw, terminal_rows_raw) = terminal_size().unwrap(); 8 | self.terminal_cols = i32::from(terminal_cols_raw); 9 | self.terminal_rows = i32::from(terminal_rows_raw); 10 | } 11 | 12 | fn update_cursor_row( 13 | &mut self, 14 | cursor_row_delta: i32, 15 | text_entries_len: i32, 16 | ) { 17 | self.cursor_row += cursor_row_delta; 18 | if self.cursor_row < 0 { 19 | self.cursor_row = text_entries_len - 1; 20 | } 21 | if self.cursor_row >= text_entries_len { 22 | self.cursor_row = 0; 23 | } 24 | } 25 | 26 | pub fn update( 27 | &mut self, 28 | cursor_row_delta: i32, 29 | text_entries: &[String], 30 | header_text: String, 31 | ) { 32 | self.update_terminal_size(); 33 | 34 | let spacing_bot = self.config.debug.spacing_bot; 35 | let spacing_top = self.config.debug.spacing_top; 36 | 37 | let text_entries_len = text_entries.len() as i32; 38 | 39 | self.update_cursor_row(cursor_row_delta, text_entries_len); 40 | 41 | self.text_row = match self.config.behavior.scrolling.as_str() { 42 | "center" => { 43 | self.scroll_like_center(cursor_row_delta, text_entries_len) 44 | } 45 | "editor" => self.scroll_like_editor(), 46 | _ => 0, 47 | }; 48 | 49 | let displayable_rows = self.terminal_rows - (spacing_bot + spacing_top); 50 | 51 | let first_index = spacing_top - self.text_row; 52 | 53 | // clear screen 54 | self.print_clear(); 55 | 56 | // print rows 57 | for i in 0..displayable_rows { 58 | let index = first_index + i; 59 | 60 | if index >= 0 && index < text_entries.len() as i32 { 61 | let text_entry = &text_entries[index as usize]; 62 | 63 | if index == self.cursor_row { 64 | self.print_text_entry_emphasized( 65 | text_entry, 66 | 1 + spacing_top + i, 67 | ) 68 | } else { 69 | self.print_text_entry(text_entry, 1 + spacing_top + i); 70 | } 71 | } 72 | } 73 | 74 | let footer_text = 75 | format!("[{}/{}]", self.cursor_row + 1, text_entries_len); 76 | 77 | self.print_header(&header_text); 78 | self.print_footer(&footer_text); 79 | 80 | self.print_debug_info(); 81 | 82 | self.flush().unwrap(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/model/tree_index.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Debug, PartialEq, PartialOrd)] 2 | pub struct TreeIndex { 3 | pub index: Vec, 4 | } 5 | 6 | impl From> for TreeIndex { 7 | fn from(index: Vec) -> Self { 8 | Self { index } 9 | } 10 | } 11 | 12 | impl TreeIndex { 13 | pub fn new() -> Self { 14 | Self { index: vec![] } 15 | } 16 | 17 | pub fn get_parent(&self) -> Self { 18 | if self.index.is_empty() { 19 | return Self { index: vec![] }; 20 | } 21 | 22 | let mut index = self.index.clone(); 23 | index.pop().unwrap(); 24 | 25 | Self { index } 26 | } 27 | 28 | #[allow(dead_code)] // TODO: remove? 29 | pub fn to_flat_index(&self) -> usize { 30 | if self.index.is_empty() { 31 | return 0; 32 | } 33 | 34 | let mut flat_index = 0; 35 | for i in &self.index { 36 | flat_index += i + 1; 37 | } 38 | 39 | flat_index - 1 40 | } 41 | } 42 | 43 | #[cfg(test)] 44 | mod tests { 45 | use super::*; 46 | 47 | mod get_parent_tests { 48 | use super::*; 49 | 50 | #[test] 51 | fn empty() { 52 | let tree_index = TreeIndex::new(); 53 | let parent = tree_index.get_parent(); 54 | assert_eq!(TreeIndex::new(), parent); 55 | } 56 | 57 | #[test] 58 | fn minimal() { 59 | let tree_index = TreeIndex::from(vec![0]); 60 | let parent = tree_index.get_parent(); 61 | assert_eq!(TreeIndex::new(), parent); 62 | } 63 | 64 | #[test] 65 | fn zeroes() { 66 | let tree_index = TreeIndex::from(vec![0, 0, 0, 0, 0]); 67 | let parent = tree_index.get_parent(); 68 | assert_eq!(TreeIndex::from(vec![0, 0, 0, 0]), parent); 69 | } 70 | 71 | #[test] 72 | fn complex() { 73 | let tree_index = TreeIndex::from(vec![3, 4, 6, 7, 1]); 74 | let parent = tree_index.get_parent(); 75 | assert_eq!(TreeIndex::from(vec![3, 4, 6, 7]), parent); 76 | } 77 | } 78 | 79 | mod to_flat_index_tests { 80 | use super::*; 81 | 82 | #[test] 83 | fn empty() { 84 | let tree_index = TreeIndex::new(); 85 | let flat_index = tree_index.to_flat_index(); 86 | assert_eq!(0, flat_index); 87 | } 88 | 89 | #[test] 90 | fn minimal() { 91 | let tree_index = TreeIndex::from(vec![0]); 92 | let flat_index = tree_index.to_flat_index(); 93 | assert_eq!(0, flat_index); 94 | } 95 | 96 | #[test] 97 | fn zeroes() { 98 | let tree_index = TreeIndex::from(vec![0, 0, 0, 0, 0]); 99 | let flat_index = tree_index.to_flat_index(); 100 | assert_eq!(4, flat_index); 101 | } 102 | 103 | #[test] 104 | fn complex() { 105 | let tree_index = TreeIndex::from(vec![3, 4, 6, 7, 1]); 106 | let flat_index = tree_index.to_flat_index(); 107 | assert_eq!(25, flat_index); 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/controller.rs: -------------------------------------------------------------------------------- 1 | use crate::controller::key_event_handler::KeyEventHandler; 2 | use crate::controller::resize_event_handler::ResizeEventHandler; 3 | use crate::model::compare_functions::PathNodeCompare; 4 | use crate::model::config::Config; 5 | use crate::model::event::Event; 6 | use crate::model::path_node::PathNode; 7 | use crate::view::composer::Composer; 8 | use crate::view::Pager; 9 | use log::info; 10 | use std::io::Write; 11 | use std::sync::mpsc::sync_channel; 12 | use std::sync::mpsc::Receiver; 13 | use std::sync::mpsc::SyncSender; 14 | use std::thread; 15 | 16 | mod key_event_handler; 17 | mod key_event_matcher; 18 | mod resize_event_handler; 19 | 20 | pub struct EventQueue { 21 | config: Config, 22 | composer: Composer, 23 | pager: Pager, 24 | path_node_root: PathNode, 25 | path_node_compare: PathNodeCompare, 26 | queue_receiver: Receiver, 27 | queue_sender: SyncSender, 28 | 29 | // TODO: should be part of the view? 30 | text_entries: Vec, 31 | command_to_run_on_exit: Option, 32 | } 33 | 34 | impl EventQueue { 35 | pub fn new( 36 | config: Config, 37 | composer: Composer, 38 | mut pager: Pager, 39 | path_node_root: PathNode, 40 | ) -> Self { 41 | info!("initializing event queue"); 42 | 43 | let (queue_sender, queue_receiver): ( 44 | SyncSender, 45 | Receiver, 46 | ) = sync_channel(1024); 47 | 48 | let path_node_compare = PathNode::get_path_node_compare(&config); 49 | 50 | let text_entries = composer.compose_path_node(&path_node_root); 51 | pager.update(0, &text_entries, path_node_root.get_absolute_path()); 52 | let command_to_run_on_exit = None; 53 | 54 | Self { 55 | config, 56 | composer, 57 | pager, 58 | path_node_root, 59 | path_node_compare, 60 | queue_receiver, 61 | queue_sender, 62 | text_entries, 63 | command_to_run_on_exit 64 | } 65 | } 66 | 67 | pub fn handle_messages(&mut self) -> Option { 68 | let (tx1, rx1) = std::sync::mpsc::channel(); 69 | let (tx2, rx2) = std::sync::mpsc::channel(); 70 | let sender1 = self.queue_sender.clone(); 71 | let sender2 = self.queue_sender.clone(); 72 | thread::spawn(move || KeyEventHandler::handle(sender1, rx1)); 73 | thread::spawn(move || ResizeEventHandler::handle(sender2, rx2)); 74 | 75 | while self 76 | .match_event(self.queue_receiver.recv().unwrap()) 77 | .is_some() 78 | {} 79 | let _ = tx1.send(()); 80 | let _ = tx2.send(()); 81 | self.command_to_run_on_exit.clone() 82 | } 83 | 84 | fn match_event(&mut self, event: Event) -> Option<()> { 85 | match event { 86 | Event::Key(key) => self.match_key_event(key), 87 | Event::Resize => { 88 | self.pager.update( 89 | 0, 90 | &self.text_entries, 91 | self.path_node_root.get_absolute_path(), 92 | ); 93 | Some(()) 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/model.rs: -------------------------------------------------------------------------------- 1 | pub mod compare_functions; 2 | pub mod config; 3 | pub mod event; 4 | pub mod path_node; 5 | pub mod tree_index; 6 | 7 | #[cfg(test)] 8 | mod tests { 9 | use crate::model::config::Config; 10 | use crate::model::path_node::PathNode; 11 | use crate::model::tree_index::TreeIndex; 12 | use crate::view::composer::Composer; 13 | 14 | #[test] 15 | fn test_integration_with_path_node_sort_dirs_top_simple() { 16 | let mut config = Config::default(); 17 | config.setup.working_dir = String::from("./tests/test_dirs"); 18 | 19 | let composer = Composer::from(config.clone()); 20 | let mut path_node = PathNode::from(config.setup.working_dir); 21 | let path_node_compare = PathNode::compare_dirs_top_simple; 22 | assert_eq!(0, composer.compose_path_node(&path_node).len()); 23 | 24 | // expand_dir 25 | path_node.expand_dir(&TreeIndex::from(Vec::new()), path_node_compare); 26 | assert_eq!( 27 | 13, 28 | composer.compose_path_node(&path_node).len(), 29 | "expanding the root directory" 30 | ); 31 | 32 | path_node.expand_dir(&TreeIndex::from(vec![3]), path_node_compare); 33 | assert_eq!( 34 | 13, 35 | composer.compose_path_node(&path_node).len(), 36 | "expanding a file does nothing" 37 | ); 38 | 39 | path_node.expand_dir(&TreeIndex::from(vec![1]), path_node_compare); 40 | assert_eq!(17, composer.compose_path_node(&path_node).len()); 41 | 42 | path_node.expand_dir(&TreeIndex::from(vec![1, 0]), path_node_compare); 43 | assert_eq!(23, composer.compose_path_node(&path_node).len()); 44 | 45 | path_node 46 | .expand_dir(&TreeIndex::from(vec![1, 0, 2]), path_node_compare); 47 | assert_eq!(26, composer.compose_path_node(&path_node).len()); 48 | 49 | path_node 50 | .expand_dir(&TreeIndex::from(vec![1, 0, 2, 1]), path_node_compare); 51 | assert_eq!(29, composer.compose_path_node(&path_node).len()); 52 | 53 | // tree_index_to_flat_index 54 | let flat_index = TreeIndex::from(vec![7, 2, 4, 0, 0]).to_flat_index(); 55 | assert_eq!(17, flat_index); 56 | 57 | // flat_index_to_tree_index 58 | let tree_index = path_node.flat_index_to_tree_index(9); 59 | assert_eq!(vec![1, 0, 2, 1, 1], tree_index.index); 60 | 61 | let tree_index = path_node.flat_index_to_tree_index(10); 62 | assert_eq!(vec![1, 0, 2, 1, 2], tree_index.index); 63 | 64 | let tree_index = path_node.flat_index_to_tree_index(11); 65 | assert_eq!(vec![1, 0, 2, 2], tree_index.index); 66 | 67 | let tree_index = path_node.flat_index_to_tree_index(15); 68 | assert_eq!(vec![1, 1], tree_index.index); 69 | 70 | // collapse_dir 71 | path_node.collapse_dir(&TreeIndex::from(vec![1, 0, 2, 1])); 72 | assert_eq!( 73 | 26, 74 | composer.compose_path_node(&path_node).len(), 75 | "reducing the last opened dir" 76 | ); 77 | 78 | path_node.collapse_dir(&TreeIndex::from(vec![1, 0])); 79 | assert_eq!( 80 | 17, 81 | composer.compose_path_node(&path_node).len(), 82 | "reducing lots of sub dirs" 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/controller/key_event_matcher/reload.rs: -------------------------------------------------------------------------------- 1 | use crate::controller::EventQueue; 2 | use crate::model::path_node::PathNode; 3 | use crate::model::tree_index::TreeIndex; 4 | use std::io::Write; 5 | 6 | impl EventQueue { 7 | pub fn do_reload(&mut self) -> Option<()> { 8 | self.reload_openend_dirs(); 9 | 10 | self.text_entries = 11 | self.composer.compose_path_node(&self.path_node_root); 12 | 13 | self.update_pager(0); 14 | 15 | Some(()) 16 | } 17 | 18 | fn reload_openend_dirs(&mut self) { 19 | // backup the old path node structure 20 | let old_path_node_root = self.path_node_root.clone(); 21 | 22 | // reset the root path node 23 | self.path_node_root = 24 | PathNode::from(self.config.setup.working_dir.clone()); 25 | self.path_node_root 26 | .expand_dir(&TreeIndex::from(Vec::new()), self.path_node_compare); 27 | 28 | // restore the old path nodes structure for the root path node 29 | self.restore_expansions(&old_path_node_root, &mut TreeIndex::new()); 30 | } 31 | 32 | fn restore_expansions( 33 | &mut self, 34 | path_node: &PathNode, 35 | tree_index: &mut TreeIndex, 36 | ) { 37 | for (c, child) in path_node.children.iter().enumerate() { 38 | if child.is_expanded { 39 | tree_index.index.push(c); 40 | 41 | self.path_node_root 42 | .expand_dir(tree_index, self.path_node_compare); 43 | self.restore_expansions(child, tree_index); 44 | 45 | tree_index.index.pop(); 46 | } 47 | } 48 | } 49 | } 50 | 51 | #[cfg(test)] 52 | mod tests { 53 | use super::*; 54 | use crate::model::config::Config; 55 | use crate::model::path_node::PathNode; 56 | use crate::model::tree_index::TreeIndex; 57 | use crate::view::composer::Composer; 58 | use crate::view::Pager; 59 | 60 | fn get_expanded_path_node(working_dir: &str) -> PathNode { 61 | let mut path_node = PathNode::from(working_dir); 62 | 63 | path_node 64 | .expand_dir(&TreeIndex::new(), PathNode::compare_dirs_top_simple); 65 | path_node.expand_dir( 66 | &TreeIndex::from(vec![0]), 67 | PathNode::compare_dirs_top_simple, 68 | ); 69 | path_node.expand_dir( 70 | &TreeIndex::from(vec![0, 0]), 71 | PathNode::compare_dirs_top_simple, 72 | ); 73 | path_node.expand_dir( 74 | &TreeIndex::from(vec![1]), 75 | PathNode::compare_dirs_top_simple, 76 | ); 77 | path_node.expand_dir( 78 | &TreeIndex::from(vec![1, 0]), 79 | PathNode::compare_dirs_top_simple, 80 | ); 81 | path_node.expand_dir( 82 | &TreeIndex::from(vec![1, 0, 2]), 83 | PathNode::compare_dirs_top_simple, 84 | ); 85 | path_node 86 | } 87 | 88 | fn prepare_event_queue(working_dir: &str) -> EventQueue> { 89 | let mut config = Config::default(); 90 | config.setup.working_dir = String::from(working_dir.clone()); 91 | 92 | let composer = Composer::from(config.clone()); 93 | let pager = Pager::new(config.clone(), Vec::new()); 94 | let path_node = PathNode::from(config.setup.working_dir.clone()); 95 | 96 | let mut event_queue = 97 | EventQueue::new(config, composer, pager, path_node); 98 | 99 | event_queue.path_node_root = get_expanded_path_node(working_dir); 100 | 101 | event_queue 102 | } 103 | 104 | #[test] 105 | fn do_reload() { 106 | // TODO: implement proper test 107 | let mut event_queue = prepare_event_queue("./tests/test_dirs"); 108 | event_queue.do_reload(); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/view/composer.rs: -------------------------------------------------------------------------------- 1 | use crate::model::config::Config; 2 | use crate::model::path_node::PathNode; 3 | use log::info; 4 | 5 | pub struct Composer { 6 | config: Config, 7 | } 8 | 9 | impl From for Composer { 10 | fn from(config: Config) -> Self { 11 | info!("initializing composer"); 12 | Self { config } 13 | } 14 | } 15 | 16 | impl Composer { 17 | pub fn truncate_string(string: &str, desired_char_count: usize) -> String { 18 | if desired_char_count < 1 { 19 | return String::new(); 20 | } 21 | 22 | if desired_char_count >= string.chars().count() { 23 | return String::from(string); 24 | } 25 | 26 | let truncated = match string.char_indices().nth(desired_char_count - 1) 27 | { 28 | None => string, 29 | Some((idx, _)) => &string[..idx], 30 | }; 31 | 32 | format!("{}~", truncated) 33 | } 34 | 35 | pub fn compose_path_node(&self, path_node: &PathNode) -> Vec { 36 | let mut result = Vec::new(); 37 | 38 | self.compose_path_node_recursive(path_node, &mut result, 0); 39 | 40 | result 41 | } 42 | 43 | fn compose_path_node_recursive( 44 | &self, 45 | path_node: &PathNode, 46 | texts: &mut Vec, 47 | depth: usize, 48 | ) { 49 | for child in &path_node.children { 50 | let dir_prefix = self.get_dir_prefix(child); 51 | let dir_suffix = self.get_dir_suffix(child); 52 | let indent = self.get_indent(depth); 53 | 54 | let text = format!( 55 | "{}{}{}{}", 56 | indent, 57 | dir_prefix, 58 | child.display_text.clone(), 59 | dir_suffix, 60 | ); 61 | texts.push(text); 62 | self.compose_path_node_recursive(child, texts, depth + 1); 63 | } 64 | } 65 | 66 | fn get_dir_prefix(&self, path_node: &PathNode) -> String { 67 | let (err_char, expanded_char, reduced_char) = 68 | if self.config.composition.use_utf8 { 69 | ('⨯', '▼', '▶') 70 | } else { 71 | ('x', 'v', '>') 72 | }; 73 | 74 | let expanded_indicator = if path_node.is_err { 75 | err_char 76 | } else if path_node.is_expanded { 77 | expanded_char 78 | } else { 79 | reduced_char 80 | }; 81 | 82 | if path_node.is_dir { 83 | format!("{} ", expanded_indicator) 84 | } else { 85 | String::from(" ") 86 | } 87 | } 88 | 89 | fn get_dir_suffix(&self, path_node: &PathNode) -> String { 90 | if path_node.is_dir { 91 | String::from("/") 92 | } else { 93 | String::from("") 94 | } 95 | } 96 | 97 | fn get_indent(&self, depth: usize) -> String { 98 | let indent_char = if !self.config.composition.show_indent { 99 | ' ' 100 | } else if self.config.composition.use_utf8 { 101 | '·' 102 | } else { 103 | '-' 104 | }; 105 | let indent = " ".repeat(self.config.composition.indent as usize - 1); 106 | 107 | format!("{}{}", indent_char, indent).repeat(depth) 108 | } 109 | } 110 | 111 | #[cfg(test)] 112 | mod tests { 113 | use super::*; 114 | 115 | #[test] 116 | fn truncate_string_test() { 117 | let tc = Composer::truncate_string; 118 | assert_eq!(tc("hello world", 5), "hell~"); 119 | assert_eq!(tc("hello world", 1), "~"); 120 | assert_eq!(tc("hello world", 0), ""); 121 | assert_eq!(tc("aaa▶bbb▶ccc", 8), "aaa▶bbb~"); 122 | assert_eq!(tc("aaa▶bbb▶ccc", 6), "aaa▶b~"); 123 | assert_eq!(tc("aaa▶bbb▶ccc", 4), "aaa~"); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/model/compare_functions.rs: -------------------------------------------------------------------------------- 1 | use crate::model::config::Config; 2 | use crate::model::path_node::PathNode; 3 | use std::cmp::Ordering; 4 | 5 | pub type PathNodeCompare = fn(&PathNode, &PathNode) -> Ordering; 6 | 7 | impl PathNode { 8 | pub fn compare_dirs_bot_simple(a: &PathNode, b: &PathNode) -> Ordering { 9 | if a.is_dir && !b.is_dir { 10 | return std::cmp::Ordering::Greater; 11 | } else if !a.is_dir && b.is_dir { 12 | return std::cmp::Ordering::Less; 13 | } 14 | 15 | a.display_text.cmp(&b.display_text) 16 | } 17 | 18 | pub fn compare_dirs_top_simple(a: &PathNode, b: &PathNode) -> Ordering { 19 | if a.is_dir && !b.is_dir { 20 | return std::cmp::Ordering::Less; 21 | } else if !a.is_dir && b.is_dir { 22 | return std::cmp::Ordering::Greater; 23 | } 24 | 25 | a.display_text.cmp(&b.display_text) 26 | } 27 | 28 | pub fn get_path_node_compare(config: &Config) -> PathNodeCompare { 29 | let path_node_compare: fn(&PathNode, &PathNode) -> Ordering = 30 | match config.behavior.path_node_sort.as_str() { 31 | "dirs_bot_simple" => PathNode::compare_dirs_bot_simple, 32 | "dirs_top_simple" => PathNode::compare_dirs_top_simple, 33 | "none" => |_, _| Ordering::Equal, 34 | _ => |_, _| Ordering::Equal, 35 | }; 36 | 37 | path_node_compare 38 | } 39 | } 40 | 41 | #[cfg(test)] 42 | mod tests { 43 | use super::*; 44 | use std::cmp::Ordering::Greater; 45 | use std::cmp::Ordering::Less; 46 | 47 | mod compare_dirs_bot_simple_tests { 48 | use super::*; 49 | 50 | #[test] 51 | fn dir_to_dir() { 52 | let dir_a = get_dir("dir_a"); 53 | let dir_b = get_dir("dir_b"); 54 | 55 | let order = PathNode::compare_dirs_bot_simple(&dir_a, &dir_b); 56 | 57 | assert_eq!(Less, order); 58 | } 59 | 60 | #[test] 61 | fn dir_to_file() { 62 | let dir = get_dir("something"); 63 | let file = get_file("something"); 64 | 65 | let order = PathNode::compare_dirs_bot_simple(&dir, &file); 66 | 67 | assert_eq!(Greater, order); 68 | } 69 | 70 | #[test] 71 | fn file_to_file() { 72 | let file_a = get_file("file_a"); 73 | let file_b = get_file("file_b"); 74 | 75 | let order = PathNode::compare_dirs_bot_simple(&file_a, &file_b); 76 | 77 | assert_eq!(Less, order); 78 | } 79 | } 80 | 81 | mod compare_dirs_top_simple_tests { 82 | use super::*; 83 | 84 | #[test] 85 | fn dir_to_dir() { 86 | let dir_a = get_dir("dir_a"); 87 | let dir_b = get_dir("dir_b"); 88 | 89 | let order = PathNode::compare_dirs_top_simple(&dir_a, &dir_b); 90 | 91 | assert_eq!(Less, order); 92 | } 93 | 94 | #[test] 95 | fn dir_to_file() { 96 | let dir = get_dir("something"); 97 | let file = get_file("something"); 98 | 99 | let order = PathNode::compare_dirs_top_simple(&dir, &file); 100 | 101 | assert_eq!(Less, order); 102 | } 103 | 104 | #[test] 105 | fn file_to_file() { 106 | let file_a = get_file("file_a"); 107 | let file_b = get_file("file_b"); 108 | 109 | let order = PathNode::compare_dirs_top_simple(&file_a, &file_b); 110 | 111 | assert_eq!(Less, order); 112 | } 113 | } 114 | 115 | fn get_dir(name: &str) -> PathNode { 116 | let mut path_node = PathNode::from("."); 117 | path_node.is_dir = true; 118 | path_node.display_text = String::from(name); 119 | path_node 120 | } 121 | 122 | fn get_file(name: &str) -> PathNode { 123 | let mut path_node = PathNode::from("."); 124 | path_node.is_dir = false; 125 | path_node.display_text = String::from(name); 126 | path_node 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/controller/key_event_matcher/collapse_dir.rs: -------------------------------------------------------------------------------- 1 | use crate::controller::EventQueue; 2 | use crate::model::tree_index::TreeIndex; 3 | use std::io::Write; 4 | 5 | impl EventQueue { 6 | pub fn do_collapse_dir(&mut self) -> Option<()> { 7 | let tree_index = self 8 | .path_node_root 9 | .flat_index_to_tree_index(self.pager.cursor_row as usize); 10 | 11 | let cursor_delta = self.get_parent_dir_cursor_delta(&tree_index); 12 | 13 | if cursor_delta == 0 { 14 | self.path_node_root.collapse_dir(&tree_index); 15 | } 16 | 17 | self.text_entries = 18 | self.composer.compose_path_node(&self.path_node_root); 19 | 20 | self.update_pager(cursor_delta); 21 | Some(()) 22 | } 23 | 24 | fn get_parent_dir_cursor_delta(&mut self, tree_index: &TreeIndex) -> i32 { 25 | let child_path_node = 26 | self.path_node_root.get_child_path_node(tree_index); 27 | if child_path_node.is_dir && child_path_node.is_expanded { 28 | return 0; 29 | } 30 | 31 | let parent_path_node_tree_index = tree_index.get_parent(); 32 | if parent_path_node_tree_index == TreeIndex::new() { 33 | return 0; 34 | } 35 | 36 | let parent_flat_index = self 37 | .path_node_root 38 | .tree_index_to_flat_index(&parent_path_node_tree_index) 39 | as i32; 40 | 41 | parent_flat_index - self.pager.cursor_row 42 | } 43 | } 44 | 45 | #[cfg(test)] 46 | mod tests { 47 | use super::*; 48 | use crate::model::config::Config; 49 | use crate::model::path_node::PathNode; 50 | use crate::model::tree_index::TreeIndex; 51 | use crate::view::composer::Composer; 52 | use crate::view::Pager; 53 | 54 | // TODO: duplicate code, create test utils? 55 | fn get_expanded_path_node() -> PathNode { 56 | let mut path_node = PathNode::from("./tests/test_dirs"); 57 | path_node 58 | .expand_dir(&TreeIndex::new(), PathNode::compare_dirs_top_simple); 59 | path_node.expand_dir( 60 | &TreeIndex::from(vec![0]), 61 | PathNode::compare_dirs_top_simple, 62 | ); 63 | path_node.expand_dir( 64 | &TreeIndex::from(vec![0, 0]), 65 | PathNode::compare_dirs_top_simple, 66 | ); 67 | path_node.expand_dir( 68 | &TreeIndex::from(vec![1]), 69 | PathNode::compare_dirs_top_simple, 70 | ); 71 | path_node.expand_dir( 72 | &TreeIndex::from(vec![1, 0]), 73 | PathNode::compare_dirs_top_simple, 74 | ); 75 | path_node.expand_dir( 76 | &TreeIndex::from(vec![1, 0, 2]), 77 | PathNode::compare_dirs_top_simple, 78 | ); 79 | path_node 80 | } 81 | 82 | fn prepare_event_queue() -> EventQueue> { 83 | let config = Config::default(); 84 | 85 | let composer = Composer::from(config.clone()); 86 | let pager = Pager::new(config.clone(), Vec::new()); 87 | let path_node = PathNode::from(config.setup.working_dir.clone()); 88 | 89 | let mut event_queue = 90 | EventQueue::new(config, composer, pager, path_node); 91 | 92 | event_queue.path_node_root = get_expanded_path_node(); 93 | 94 | event_queue 95 | } 96 | 97 | mod get_parent_dir_cursor_delta_tests { 98 | use super::*; 99 | 100 | #[test] 101 | fn expanded() { 102 | let mut event_queue = prepare_event_queue(); 103 | 104 | let delta = event_queue 105 | .get_parent_dir_cursor_delta(&TreeIndex::from(vec![0])); 106 | 107 | assert_eq!(0, delta); 108 | } 109 | 110 | #[test] 111 | fn empty_tree_index() { 112 | let mut event_queue = prepare_event_queue(); 113 | 114 | let delta = 115 | event_queue.get_parent_dir_cursor_delta(&TreeIndex::new()); 116 | 117 | assert_eq!(0, delta); 118 | } 119 | 120 | #[test] 121 | fn jump() { 122 | let mut event_queue = prepare_event_queue(); 123 | 124 | let delta = event_queue 125 | .get_parent_dir_cursor_delta(&TreeIndex::from(vec![1, 0, 4])); 126 | 127 | assert_eq!(7, delta); 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/controller/key_event_matcher.rs: -------------------------------------------------------------------------------- 1 | use crate::controller::EventQueue; 2 | use crate::model::event::Key; 3 | use std::io::Write; 4 | 5 | mod collapse_dir; 6 | mod entry_down; 7 | mod entry_up; 8 | mod expand_dir; 9 | mod file_action; 10 | mod quit; 11 | mod reload; 12 | 13 | impl EventQueue { 14 | #[rustfmt::skip] 15 | pub fn match_key_event(&mut self, key: Key) -> Option<()> { 16 | let ck = self.config.keybinding.clone(); 17 | 18 | if key == Key::from(ck.collapse_dir) { self.do_collapse_dir() } 19 | else if key == Key::from(ck.entry_down) { self.do_entry_down() } 20 | else if key == Key::from(ck.entry_up) { self.do_entry_up() } 21 | else if key == Key::from(ck.expand_dir) { self.do_expand_dir() } 22 | else if key == Key::from(ck.file_action) { self.do_file_action() } 23 | else if key == Key::from(ck.quit) { self.do_quit() } 24 | else if key == Key::from(ck.reload) { self.do_reload() } 25 | else { Some(()) } 26 | } 27 | 28 | fn update_pager(&mut self, cursor_delta: i32) { 29 | self.pager.update( 30 | cursor_delta, 31 | &self.text_entries, 32 | self.path_node_root.get_absolute_path(), 33 | ); 34 | } 35 | } 36 | 37 | #[cfg(test)] 38 | mod tests { 39 | use super::*; 40 | use crate::model::config::Config; 41 | use crate::model::path_node::PathNode; 42 | use crate::view::composer::Composer; 43 | use crate::view::Pager; 44 | 45 | fn prepare_event_queue() -> EventQueue> { 46 | let config = Config::default(); 47 | 48 | let composer = Composer::from(config.clone()); 49 | let pager = Pager::new(config.clone(), Vec::new()); 50 | let path_node = PathNode::from(config.setup.working_dir.clone()); 51 | 52 | EventQueue::new(config, composer, pager, path_node) 53 | } 54 | 55 | #[test] 56 | fn match_key_event_default_test() { 57 | let result = { 58 | let mut event_queue = prepare_event_queue(); 59 | event_queue.match_key_event(Key::from("nonsense")) 60 | }; 61 | 62 | assert!(result.is_some()); 63 | } 64 | 65 | #[test] 66 | fn match_key_event_quit_test() { 67 | let result = { 68 | let mut event_queue = prepare_event_queue(); 69 | event_queue.match_key_event(Key::from( 70 | event_queue.config.keybinding.quit.clone(), 71 | )) 72 | }; 73 | 74 | assert!(result.is_none()); 75 | } 76 | 77 | #[test] 78 | fn match_key_event_reload_test() { 79 | let result = { 80 | let mut event_queue = prepare_event_queue(); 81 | event_queue.match_key_event(Key::from( 82 | event_queue.config.keybinding.reload.clone(), 83 | )) 84 | }; 85 | 86 | assert!(result.is_some()); 87 | } 88 | 89 | #[test] 90 | fn match_key_event_file_action_test() { 91 | let result = { 92 | let mut event_queue = prepare_event_queue(); 93 | event_queue.match_key_event(Key::from( 94 | event_queue.config.keybinding.file_action.clone(), 95 | )) 96 | }; 97 | 98 | assert!(result.is_some()); 99 | } 100 | 101 | #[test] 102 | fn match_key_event_entry_up_test() { 103 | let result = { 104 | let mut event_queue = prepare_event_queue(); 105 | event_queue.match_key_event(Key::from( 106 | event_queue.config.keybinding.entry_up.clone(), 107 | )) 108 | }; 109 | 110 | assert!(result.is_some()); 111 | } 112 | 113 | #[test] 114 | fn match_key_event_entry_down_test() { 115 | let result = { 116 | let mut event_queue = prepare_event_queue(); 117 | event_queue.match_key_event(Key::from( 118 | event_queue.config.keybinding.entry_down.clone(), 119 | )) 120 | }; 121 | 122 | assert!(result.is_some()); 123 | } 124 | 125 | #[test] 126 | fn match_key_event_collapse_dir_test() { 127 | let result = { 128 | let mut event_queue = prepare_event_queue(); 129 | event_queue.match_key_event(Key::from( 130 | event_queue.config.keybinding.collapse_dir.clone(), 131 | )) 132 | }; 133 | 134 | assert!(result.is_some()); 135 | } 136 | 137 | #[test] 138 | fn match_key_event_expand_dir_test() { 139 | let result = { 140 | let mut event_queue = prepare_event_queue(); 141 | event_queue.match_key_event(Key::from( 142 | event_queue.config.keybinding.expand_dir.clone(), 143 | )) 144 | }; 145 | 146 | assert!(result.is_some()); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # twilight-commander 2 | 3 | [![Build Status](https://travis-ci.org/golmman/twilight-commander.svg?branch=master)](https://travis-ci.org/golmman/twilight-commander) 4 | 5 | A simple console tree file explorer for linux, similiar to NERDTree but independent of vim. 6 | Developed and tested on Ubuntu 18.04 with xterm derivatives. 7 | 8 | ![Screenshot](https://github.com/golmman/twilight-commander/blob/master/screenshots/twilight-commander.png "twilight-commander") 9 | 10 | ## Build and install 11 | 12 | ### Instructions for Debian 10 or Ubuntu 18.04/20.04 13 | 14 | | |step|description| 15 | |---|---|---| 16 | |1.|install rust|https://www.rust-lang.org/tools/install| 17 | |2.|clone the repository|`git clone https://github.com/golmman/twilight-commander.git`| 18 | |3.|change to the newly created directory|`cd twilight-commander`| 19 | |4.|build the project|`cargo build --release`| 20 | |5.|run the executable|`./target/release/twilight-commander`| 21 | 22 | ## Implemented features 23 | 24 | ### Configuration 25 | The configuration is loaded as follows 26 | 1. load values from `$XDG_CONFIG_HOME/twilight-commander.toml` 27 | 2. else load values from `$HOME/.config/twilight-commander/twilight-commander.toml` 28 | 2. fill missing values with app defaults 29 | 3. overwrite values with defines from the command line options 30 | 31 | For a config file with the default values, see [twilight-commander.toml](./twilight-commander.toml). 32 | The command line options are derived from the values defined inside the twilight-commander.toml . 33 | E.g. 34 | ``` 35 | [debug] 36 | enabled = true 37 | ``` 38 | is set with the option `--debug.enabled=true`. 39 | 40 | ### Configurable key bindings 41 | 42 | The key bindings are configurable. For the set of configurable keys and key combinations consult the [event.rs](./src/model/event.rs). 43 | 44 | |default key|default configuration|action| 45 | |---|---|---| 46 | |up arrow|`--keybinding.entry_up=up`|move an entry up| 47 | |down arrow|`--keybinding.entry_down=down`|move an entry down| 48 | |left arrow|`--keybinding.collapse_dir=left`|collapse an entry directory or jump to parent if not collapsable| 49 | |right arrow|`--keybinding.expand_dir=left`|expand an entry directory| 50 | |r|`--keybinding.reload=r`|collapse all directories and reload root directory| 51 | |return|`--keybinding.file_action=return`|perform configured file action| 52 | |q|`--keybinding.quit=q`|quit| 53 | 54 | ### Directory entry management 55 | 56 | #### File Action 57 | The command line option / config value `--behavior.file_action` defines the action taken when the return key is pressed 58 | on a file. The action is interpreted by `bash` and any occurence of `%s` will be replaced by the selected filename. 59 | E.g. when enter is pressed on the file `.bashrc` in a twilight-commander process created with 60 | ``` 61 | twilight-commander "--behavior.file_action=xterm -e 'cat %s; echo opened file: %s; bash'" 62 | ``` 63 | then 64 | ``` 65 | bash -c "xterm -e 'cat /home/user/.bashrc; echo opened file: /home/user/.bashrc; bash'" 66 | ``` 67 | is executed, i.e.: 68 | * a new xterm window is opened 69 | * where the selected file (`.bashrc`) is printed to stdout 70 | * then `opened file: ~/.bashrc` is printed 71 | * `bash` prevents the window from closing. 72 | 73 | `--behavior.file_action` defaults to [true](https://en.wikipedia.org/wiki/True_and_false_(commands)), which does 74 | (almost) nothing. 75 | 76 | ### Scrolling modes 77 | Specified with the option `--behaviour.scrolling` (default = `center`) 78 | 79 | * `center`: move the cursor until it is in the center, then move the text instead 80 | * `editor`: move the cursor until it hits the top/bottom boundaries set by the `debug.paddin_top/bot` limits 81 | 82 | ### Utf-8 support 83 | In case your terminal does not support utf-8 you can disable it with `--composition.use_utf8=false`. 84 | 85 | ### Logs 86 | Logs are written to 87 | 1. `$XDG_CONFIG_HOME/tc.log` if XDG_CONFIG_HOME is defined 88 | 2. else they are placed in `$HOME/.config/twilight-commander/tc.log` 89 | 90 | ## Usage with tmux and vim 91 | 92 | ![Screenshot](https://github.com/golmman/twilight-commander/blob/master/screenshots/tcide.png "tmux + vim + twilight-commander") 93 | 94 | The `tcide_vim` and `tcide_neovim` scripts open a new tmux session with 3 panes: (neo)vim, twilight-commander and terminal. 95 | Hitting the file_action key (default: return) on a file entry opens it in a new tab. 96 | 97 | ### tcide requirements 98 | 99 | When using vim you need to build it with the clientserver option: 100 | 101 | ``` 102 | git clone https://github.com/vim/vim.git 103 | cd vim/src 104 | make distclean 105 | ./configure +clientserver 106 | make 107 | sudo make install 108 | ``` 109 | 110 | ## Ideas for improvements 111 | 112 | * **neovim support via https://neovim.io/doc/user/api.html** 113 | * **configuration of 'on close'-event scripts** 114 | * **storing sessions** 115 | * **improved reload** 116 | * ~~preserve expanded tree on reload~~ 117 | * automatic reload 118 | * **more colors, configurable** 119 | * git colors (indicating modified or new files) 120 | * **improved sorting** 121 | * sort case insensitivly 122 | * **advanced navigation** 123 | * ~~jump to parent directory~~ 124 | * skip x entries by holding a modifier key 125 | * collapse the current parent directory 126 | * **improve tcide to store vim sessions** 127 | * add a proper Makefile 128 | * https://sagiegurari.github.io/cargo-make/ 129 | * better response to terminal resize events: in some terminals response is too slow, text is wrapped 130 | * intended to work like `less -S ` 131 | * problem seems not to appear in plain xterm 132 | * https://www.xfree86.org/4.8.0/ctlseqs.html 133 | * https://invisible-island.net/ncurses/man/resizeterm.3x.html 134 | * https://linux.die.net/man/1/resize 135 | * https://stackoverflow.com/questions/4738803/resize-terminal-and-scrolling-problem-with-ncurses#4739108 136 | * directory entry stats 137 | * directory entry management 138 | * copy 139 | * create directory 140 | * create file 141 | * move 142 | * ~~open with custom command~~ 143 | * remove 144 | * rename 145 | * --help screen with info to all command line options 146 | * [clap](https://crates.io/crates/clap) 147 | * [gumdrop](https://crates.io/crates/gumdrop) 148 | * bookmark / pin entries (recursivly?) and prevent them from being collapsed 149 | * search 150 | * case insensitive wildcard 151 | * mark hits 152 | * nnn like status updates in the footer (permission denied, ...) 153 | * logging 154 | * ~~to log file~~ 155 | * limit log file length 156 | * add rust doc 157 | -------------------------------------------------------------------------------- /src/model/event.rs: -------------------------------------------------------------------------------- 1 | type TEvent = termion::event::Event; 2 | type TKey = termion::event::Key; 3 | 4 | #[derive(Clone, Debug, PartialEq)] 5 | pub struct Key { 6 | inner: termion::event::Event, 7 | } 8 | 9 | #[derive(Clone, Debug, PartialEq)] 10 | pub enum Event { 11 | Resize, 12 | Key(Key), 13 | } 14 | 15 | impl From for Key { 16 | fn from(t_event: TEvent) -> Key { 17 | Key { inner: t_event } 18 | } 19 | } 20 | 21 | impl From<&str> for Key { 22 | fn from(s: &str) -> Key { 23 | Key::from(convert_str_to_termion_event(s)) 24 | } 25 | } 26 | 27 | impl From for Key { 28 | fn from(s: String) -> Key { 29 | Key::from(convert_str_to_termion_event(&s)) 30 | } 31 | } 32 | 33 | fn convert_str_to_termion_event(s: &str) -> TEvent { 34 | if s.chars().count() == 1 { 35 | return TEvent::Key(TKey::Char(s.chars().last().unwrap())); 36 | } 37 | 38 | if s.starts_with("alt+") && s.len() == 5 { 39 | return TEvent::Key(TKey::Alt(s.chars().last().unwrap())); 40 | } 41 | 42 | if s.starts_with("ctrl+") && s.len() == 6 { 43 | return TEvent::Key(TKey::Ctrl(s.chars().last().unwrap())); 44 | } 45 | 46 | match s { 47 | // f keys 48 | "f1" => TEvent::Key(TKey::F(1)), 49 | "f2" => TEvent::Key(TKey::F(2)), 50 | "f3" => TEvent::Key(TKey::F(3)), 51 | "f4" => TEvent::Key(TKey::F(4)), 52 | "f5" => TEvent::Key(TKey::F(5)), 53 | "f6" => TEvent::Key(TKey::F(6)), 54 | "f7" => TEvent::Key(TKey::F(7)), 55 | "f8" => TEvent::Key(TKey::F(8)), 56 | "f9" => TEvent::Key(TKey::F(9)), 57 | "f10" => TEvent::Key(TKey::F(10)), 58 | "f11" => TEvent::Key(TKey::F(11)), 59 | "f12" => TEvent::Key(TKey::F(12)), 60 | 61 | // special keys 62 | "backspace" => TEvent::Key(TKey::Backspace), 63 | "left" => TEvent::Key(TKey::Left), 64 | "right" => TEvent::Key(TKey::Right), 65 | "up" => TEvent::Key(TKey::Up), 66 | "down" => TEvent::Key(TKey::Down), 67 | "home" => TEvent::Key(TKey::Home), 68 | "end" => TEvent::Key(TKey::End), 69 | "page_up" => TEvent::Key(TKey::PageUp), 70 | "page_down" => TEvent::Key(TKey::PageDown), 71 | "delete" => TEvent::Key(TKey::Delete), 72 | "insert" => TEvent::Key(TKey::Insert), 73 | "esc" => TEvent::Key(TKey::Esc), 74 | "return" => TEvent::Key(TKey::Char('\n')), 75 | "tab" => TEvent::Key(TKey::Char('\t')), 76 | 77 | // special key combinations 78 | 79 | // arrow keys 80 | "ctrl+left" => TEvent::Unsupported(vec![27, 91, 49, 59, 53, 68]), 81 | "ctrl+right" => TEvent::Unsupported(vec![27, 91, 49, 59, 53, 67]), 82 | "ctrl+up" => TEvent::Unsupported(vec![27, 91, 49, 59, 53, 65]), 83 | "ctrl+down" => TEvent::Unsupported(vec![27, 91, 49, 59, 53, 66]), 84 | "shift+left" => TEvent::Unsupported(vec![27, 91, 49, 59, 50, 68]), 85 | "shift+right" => TEvent::Unsupported(vec![27, 91, 49, 59, 50, 67]), 86 | "shift+up" => TEvent::Unsupported(vec![27, 91, 49, 59, 50, 65]), 87 | "shift+down" => TEvent::Unsupported(vec![27, 91, 49, 59, 50, 66]), 88 | "alt+shift+left" => TEvent::Unsupported(vec![27, 91, 49, 59, 52, 68]), 89 | "alt+shift+right" => TEvent::Unsupported(vec![27, 91, 49, 59, 52, 67]), 90 | "alt+shift+up" => TEvent::Unsupported(vec![27, 91, 49, 59, 52, 65]), 91 | "alt+shift+down" => TEvent::Unsupported(vec![27, 91, 49, 59, 52, 66]), 92 | "shift+alt+left" => TEvent::Unsupported(vec![27, 91, 49, 59, 52, 68]), 93 | "shift+alt+right" => TEvent::Unsupported(vec![27, 91, 49, 59, 52, 67]), 94 | "shift+alt+up" => TEvent::Unsupported(vec![27, 91, 49, 59, 52, 65]), 95 | "shift+alt+down" => TEvent::Unsupported(vec![27, 91, 49, 59, 52, 66]), 96 | 97 | // default 98 | _ => TEvent::Unsupported(Vec::new()), 99 | } 100 | } 101 | 102 | #[cfg(test)] 103 | mod tests { 104 | use super::*; 105 | 106 | #[test] 107 | fn key_from_termion_event_test() { 108 | assert_eq!( 109 | Key::from(TEvent::Unsupported(vec![27, 91, 49, 59, 52, 66])), 110 | Key { 111 | inner: TEvent::Unsupported(vec![27, 91, 49, 59, 52, 66]) 112 | } 113 | ); 114 | } 115 | 116 | #[test] 117 | fn key_from_str_test() { 118 | assert_eq!( 119 | Key::from("shift+alt+down"), 120 | Key { 121 | inner: TEvent::Unsupported(vec![27, 91, 49, 59, 52, 66]) 122 | } 123 | ); 124 | } 125 | 126 | #[test] 127 | fn key_from_string_test() { 128 | assert_eq!( 129 | Key::from(String::from("shift+alt+up")), 130 | Key { 131 | inner: TEvent::Unsupported(vec![27, 91, 49, 59, 52, 65]) 132 | } 133 | ); 134 | } 135 | 136 | mod convert_str_to_termion_event_tests { 137 | use super::super::*; 138 | #[test] 139 | fn nonsense() { 140 | assert_eq!( 141 | TEvent::Unsupported(Vec::new()), 142 | convert_str_to_termion_event("x1") 143 | ); 144 | assert_eq!( 145 | TEvent::Unsupported(Vec::new()), 146 | convert_str_to_termion_event("alt+x1") 147 | ); 148 | assert_eq!( 149 | TEvent::Unsupported(Vec::new()), 150 | convert_str_to_termion_event("ctrl+x1") 151 | ); 152 | } 153 | 154 | #[test] 155 | fn single_digit() { 156 | assert_eq!( 157 | TEvent::Key(TKey::Char('x')), 158 | convert_str_to_termion_event("x") 159 | ); 160 | } 161 | #[test] 162 | fn alt_digit() { 163 | assert_eq!( 164 | TEvent::Key(TKey::Alt('x')), 165 | convert_str_to_termion_event("alt+x") 166 | ); 167 | } 168 | #[test] 169 | fn ctrl_digit() { 170 | assert_eq!( 171 | TEvent::Key(TKey::Ctrl('x')), 172 | convert_str_to_termion_event("ctrl+x") 173 | ); 174 | } 175 | #[test] 176 | fn f_key() { 177 | assert_eq!( 178 | TEvent::Key(TKey::F(5)), 179 | convert_str_to_termion_event("f5") 180 | ); 181 | } 182 | #[test] 183 | fn special_key() { 184 | assert_eq!( 185 | TEvent::Key(TKey::PageDown), 186 | convert_str_to_termion_event("page_down") 187 | ); 188 | } 189 | #[test] 190 | fn special_key_comination() { 191 | assert_eq!( 192 | TEvent::Unsupported(vec![27, 91, 49, 59, 52, 65]), 193 | convert_str_to_termion_event("alt+shift+up") 194 | ); 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/view/print.rs: -------------------------------------------------------------------------------- 1 | use crate::view::Composer; 2 | use crate::view::Pager; 3 | use std::io::Write; 4 | use termion::{color, style}; 5 | 6 | impl Pager { 7 | pub fn print_clear(&mut self) { 8 | write!(self, "{}", termion::clear::All).unwrap(); 9 | } 10 | 11 | pub fn print_text_entry(&mut self, text_entry: &str, row: i32) { 12 | write!( 13 | self, 14 | "{}{}{}", 15 | termion::cursor::Goto(1, row as u16), 16 | Composer::truncate_string(text_entry, self.terminal_cols as usize), 17 | style::Reset 18 | ) 19 | .unwrap(); 20 | } 21 | 22 | pub fn print_text_entry_emphasized(&mut self, text_entry: &str, row: i32) { 23 | write!( 24 | self, 25 | "{}{}{}{}", 26 | termion::cursor::Goto(1, row as u16), 27 | color::Bg(color::Blue), 28 | Composer::truncate_string(text_entry, self.terminal_cols as usize), 29 | style::Reset 30 | ) 31 | .unwrap(); 32 | } 33 | 34 | pub fn print_header(&mut self, text: &str) { 35 | write!( 36 | self, 37 | "{}{}", 38 | termion::cursor::Goto(1, 1), 39 | Composer::truncate_string(text, self.terminal_cols as usize), 40 | ) 41 | .unwrap(); 42 | } 43 | 44 | pub fn print_footer(&mut self, text: &str) { 45 | write!( 46 | self, 47 | "{}{}", 48 | termion::cursor::Goto(1, 1 + self.terminal_rows as u16), 49 | Composer::truncate_string(text, self.terminal_cols as usize), 50 | ) 51 | .unwrap(); 52 | } 53 | 54 | pub fn print_debug_info(&mut self) { 55 | if !self.config.debug.enabled { 56 | return; 57 | } 58 | 59 | let padding_bot = self.config.debug.padding_bot; 60 | let padding_top = self.config.debug.padding_top; 61 | let spacing_bot = self.config.debug.spacing_bot; 62 | let spacing_top = self.config.debug.spacing_top; 63 | 64 | // line numbers 65 | for i in 0..self.terminal_rows { 66 | write!( 67 | self, 68 | "{} L{}", 69 | termion::cursor::Goto(50, 1 + i as u16), 70 | i.to_string() 71 | ) 72 | .unwrap(); 73 | } 74 | 75 | // padding_top debug 76 | for i in 0..padding_bot { 77 | write!( 78 | self, 79 | "{}~~~ padding_bot", 80 | termion::cursor::Goto( 81 | 30, 82 | (self.terminal_rows - (spacing_bot + i)) as u16 83 | ) 84 | ) 85 | .unwrap(); 86 | } 87 | 88 | for i in 0..padding_top { 89 | write!( 90 | self, 91 | "{}~~~ padding_top", 92 | termion::cursor::Goto(30, (1 + spacing_top + i) as u16) 93 | ) 94 | .unwrap(); 95 | } 96 | 97 | // spacing_top debug 98 | for i in 0..spacing_bot { 99 | write!( 100 | self, 101 | "{}--- spacing_bot", 102 | termion::cursor::Goto(30, (self.terminal_rows - i) as u16) 103 | ) 104 | .unwrap(); 105 | } 106 | for i in 0..spacing_top { 107 | write!( 108 | self, 109 | "{}--- spacing_top", 110 | termion::cursor::Goto(30, 1 + i as u16) 111 | ) 112 | .unwrap(); 113 | } 114 | 115 | // debug info 116 | let terminal_rows = self.terminal_rows; 117 | let terminal_cols = self.terminal_cols; 118 | let cursor_row = self.cursor_row; 119 | let text_row = self.text_row; 120 | 121 | write!( 122 | self, 123 | "{}rows: {}, cols: {}", 124 | termion::cursor::Goto(1, (self.terminal_rows - 1) as u16), 125 | terminal_rows, 126 | terminal_cols 127 | ) 128 | .unwrap(); 129 | write!( 130 | self, 131 | "{}cursor_row: {}, text_row: {}", 132 | termion::cursor::Goto(1, self.terminal_rows as u16), 133 | cursor_row, 134 | text_row 135 | ) 136 | .unwrap(); 137 | } 138 | } 139 | 140 | #[cfg(test)] 141 | mod tests { 142 | use super::*; 143 | use crate::model::config::Config; 144 | 145 | fn prepare_pager() -> Pager> { 146 | let mut config = Config::default(); 147 | config.debug.enabled = true; 148 | config.debug.padding_bot = 1; 149 | config.debug.padding_top = 1; 150 | config.debug.spacing_bot = 1; 151 | config.debug.spacing_top = 1; 152 | 153 | let out: Vec = Vec::new(); 154 | let mut pager = Pager::new(config, out); 155 | 156 | pager.terminal_cols = 100; 157 | pager.terminal_rows = 10; 158 | 159 | pager 160 | } 161 | 162 | fn get_result(pager: Pager>) -> Option { 163 | let pager_out = pager.out.clone(); 164 | Some(String::from(std::str::from_utf8(&pager_out).unwrap())) 165 | } 166 | 167 | #[test] 168 | fn print_clear_test() { 169 | let result = { 170 | let mut pager = prepare_pager(); 171 | pager.print_clear(); 172 | get_result(pager) 173 | }; 174 | 175 | assert_eq!("\u{1b}[?25l\u{1b}[1;1H\u{1b}[2J\u{1b}[2J", result.unwrap()); 176 | } 177 | 178 | #[test] 179 | fn print_text_entry_test() { 180 | let result = { 181 | let mut pager = prepare_pager(); 182 | pager.print_text_entry("--- test 123 ---", 42); 183 | get_result(pager) 184 | }; 185 | 186 | assert_eq!( 187 | "\u{1b}[?25l\u{1b}[1;1H\u{1b}[2J\u{1b}[42;1H--- test 123 ---\u{1b}[m", 188 | result.unwrap(), 189 | ); 190 | } 191 | 192 | #[test] 193 | fn print_text_entry_emphasized_test() { 194 | let result = { 195 | let mut pager = prepare_pager(); 196 | pager.print_text_entry_emphasized("--- test 123 ---", 42); 197 | get_result(pager) 198 | }; 199 | 200 | assert_eq!( 201 | "\u{1b}[?25l\u{1b}[1;1H\u{1b}[2J\u{1b}[42;1H\u{1b}[48;5;4m--- test 123 ---\u{1b}[m", 202 | result.unwrap(), 203 | ); 204 | } 205 | 206 | #[test] 207 | fn print_header_test() { 208 | let result = { 209 | let mut pager = prepare_pager(); 210 | pager.print_header("--- test 123 ---"); 211 | get_result(pager) 212 | }; 213 | 214 | assert_eq!( 215 | "\u{1b}[?25l\u{1b}[1;1H\u{1b}[2J\u{1b}[1;1H--- test 123 ---", 216 | result.unwrap(), 217 | ); 218 | } 219 | 220 | #[test] 221 | fn print_footer_test() { 222 | let result = { 223 | let mut pager = prepare_pager(); 224 | pager.print_footer("--- test 123 ---"); 225 | get_result(pager) 226 | }; 227 | 228 | assert_eq!( 229 | "\u{1b}[?25l\u{1b}[1;1H\u{1b}[2J\u{1b}[11;1H--- test 123 ---", 230 | result.unwrap(), 231 | ); 232 | } 233 | 234 | #[test] 235 | fn print_debug_info_test() { 236 | let result = { 237 | let mut pager = prepare_pager(); 238 | pager.print_debug_info(); 239 | get_result(pager) 240 | } 241 | .unwrap(); 242 | 243 | assert!(result.contains("~~~ padding_bot")); 244 | assert!(result.contains("~~~ padding_top")); 245 | assert!(result.contains("--- spacing_bot")); 246 | assert!(result.contains("--- spacing_top")); 247 | assert!(result.contains("cols: 100")); 248 | assert!(result.contains("rows: 10")); 249 | assert!(result.contains("cursor_row: 0")); 250 | assert!(result.contains("text_row: 0")); 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "arc-swap" 7 | version = "0.4.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "f1a1eca3195b729bbd64e292ef2f5fff6b1c28504fed762ce2b1013dde4d8e92" 10 | 11 | [[package]] 12 | name = "autocfg" 13 | version = "1.0.0" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d" 16 | 17 | [[package]] 18 | name = "cc" 19 | version = "1.0.74" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "581f5dba903aac52ea3feb5ec4810848460ee833876f1f9b0fdeab1f19091574" 22 | 23 | [[package]] 24 | name = "cfg-if" 25 | version = "0.1.10" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 28 | 29 | [[package]] 30 | name = "chrono" 31 | version = "0.4.10" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "31850b4a4d6bae316f7a09e691c944c28299298837edc0a03f755618c23cbc01" 34 | dependencies = [ 35 | "num-integer", 36 | "num-traits", 37 | "time", 38 | ] 39 | 40 | [[package]] 41 | name = "errno" 42 | version = "0.2.8" 43 | source = "registry+https://github.com/rust-lang/crates.io-index" 44 | checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" 45 | dependencies = [ 46 | "errno-dragonfly", 47 | "libc", 48 | "winapi", 49 | ] 50 | 51 | [[package]] 52 | name = "errno-dragonfly" 53 | version = "0.1.2" 54 | source = "registry+https://github.com/rust-lang/crates.io-index" 55 | checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" 56 | dependencies = [ 57 | "cc", 58 | "libc", 59 | ] 60 | 61 | [[package]] 62 | name = "exec" 63 | version = "0.3.1" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "886b70328cba8871bfc025858e1de4be16b1d5088f2ba50b57816f4210672615" 66 | dependencies = [ 67 | "errno", 68 | "libc", 69 | ] 70 | 71 | [[package]] 72 | name = "fern" 73 | version = "0.5.9" 74 | source = "registry+https://github.com/rust-lang/crates.io-index" 75 | checksum = "e69ab0d5aca163e388c3a49d284fed6c3d0810700e77c5ae2756a50ec1a4daaa" 76 | dependencies = [ 77 | "chrono", 78 | "log", 79 | ] 80 | 81 | [[package]] 82 | name = "libc" 83 | version = "0.2.62" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "34fcd2c08d2f832f376f4173a231990fa5aef4e99fb569867318a227ef4c06ba" 86 | 87 | [[package]] 88 | name = "log" 89 | version = "0.4.8" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "14b6052be84e6b71ab17edffc2eeabf5c2c3ae1fdb464aae35ac50c67a44e1f7" 92 | dependencies = [ 93 | "cfg-if", 94 | ] 95 | 96 | [[package]] 97 | name = "num-integer" 98 | version = "0.1.42" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "3f6ea62e9d81a77cd3ee9a2a5b9b609447857f3d358704331e4ef39eb247fcba" 101 | dependencies = [ 102 | "autocfg", 103 | "num-traits", 104 | ] 105 | 106 | [[package]] 107 | name = "num-traits" 108 | version = "0.2.11" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "c62be47e61d1842b9170f0fdeec8eba98e60e90e5446449a0545e5152acd7096" 111 | dependencies = [ 112 | "autocfg", 113 | ] 114 | 115 | [[package]] 116 | name = "numtoa" 117 | version = "0.1.0" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" 120 | 121 | [[package]] 122 | name = "proc-macro2" 123 | version = "1.0.5" 124 | source = "registry+https://github.com/rust-lang/crates.io-index" 125 | checksum = "90cf5f418035b98e655e9cdb225047638296b862b42411c4e45bb88d700f7fc0" 126 | dependencies = [ 127 | "unicode-xid", 128 | ] 129 | 130 | [[package]] 131 | name = "quote" 132 | version = "1.0.2" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "053a8c8bcc71fcce321828dc897a98ab9760bef03a4fc36693c231e5b3216cfe" 135 | dependencies = [ 136 | "proc-macro2", 137 | ] 138 | 139 | [[package]] 140 | name = "redox_syscall" 141 | version = "0.1.56" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "2439c63f3f6139d1b57529d16bc3b8bb855230c8efcc5d3a896c8bea7c3b1e84" 144 | 145 | [[package]] 146 | name = "redox_termios" 147 | version = "0.1.1" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "7e891cfe48e9100a70a3b6eb652fef28920c117d366339687bd5576160db0f76" 150 | dependencies = [ 151 | "redox_syscall", 152 | ] 153 | 154 | [[package]] 155 | name = "serde" 156 | version = "1.0.101" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "9796c9b7ba2ffe7a9ce53c2287dfc48080f4b2b362fcc245a259b3a7201119dd" 159 | dependencies = [ 160 | "serde_derive", 161 | ] 162 | 163 | [[package]] 164 | name = "serde_derive" 165 | version = "1.0.101" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "4b133a43a1ecd55d4086bd5b4dc6c1751c68b1bfbeba7a5040442022c7e7c02e" 168 | dependencies = [ 169 | "proc-macro2", 170 | "quote", 171 | "syn", 172 | ] 173 | 174 | [[package]] 175 | name = "signal-hook" 176 | version = "0.1.10" 177 | source = "registry+https://github.com/rust-lang/crates.io-index" 178 | checksum = "4f61c4d59f3aaa9f61bba6450a9b80ba48362fd7d651689e7a10c453b1f6dc68" 179 | dependencies = [ 180 | "libc", 181 | "signal-hook-registry", 182 | ] 183 | 184 | [[package]] 185 | name = "signal-hook-registry" 186 | version = "1.1.1" 187 | source = "registry+https://github.com/rust-lang/crates.io-index" 188 | checksum = "1797d48f38f91643908bb14e35e79928f9f4b3cefb2420a564dde0991b4358dc" 189 | dependencies = [ 190 | "arc-swap", 191 | "libc", 192 | ] 193 | 194 | [[package]] 195 | name = "syn" 196 | version = "1.0.5" 197 | source = "registry+https://github.com/rust-lang/crates.io-index" 198 | checksum = "66850e97125af79138385e9b88339cbcd037e3f28ceab8c5ad98e64f0f1f80bf" 199 | dependencies = [ 200 | "proc-macro2", 201 | "quote", 202 | "unicode-xid", 203 | ] 204 | 205 | [[package]] 206 | name = "termion" 207 | version = "1.5.3" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "6a8fb22f7cde82c8220e5aeacb3258ed7ce996142c77cba193f203515e26c330" 210 | dependencies = [ 211 | "libc", 212 | "numtoa", 213 | "redox_syscall", 214 | "redox_termios", 215 | ] 216 | 217 | [[package]] 218 | name = "time" 219 | version = "0.1.42" 220 | source = "registry+https://github.com/rust-lang/crates.io-index" 221 | checksum = "db8dcfca086c1143c9270ac42a2bbd8a7ee477b78ac8e45b19abfb0cbede4b6f" 222 | dependencies = [ 223 | "libc", 224 | "redox_syscall", 225 | "winapi", 226 | ] 227 | 228 | [[package]] 229 | name = "toml" 230 | version = "0.5.3" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "c7aabe75941d914b72bf3e5d3932ed92ce0664d49d8432305a8b547c37227724" 233 | dependencies = [ 234 | "serde", 235 | ] 236 | 237 | [[package]] 238 | name = "twilight-commander" 239 | version = "0.14.1" 240 | dependencies = [ 241 | "chrono", 242 | "exec", 243 | "fern", 244 | "log", 245 | "serde", 246 | "signal-hook", 247 | "termion", 248 | "toml", 249 | ] 250 | 251 | [[package]] 252 | name = "unicode-xid" 253 | version = "0.2.0" 254 | source = "registry+https://github.com/rust-lang/crates.io-index" 255 | checksum = "826e7639553986605ec5979c7dd957c7895e93eabed50ab2ffa7f6128a75097c" 256 | 257 | [[package]] 258 | name = "winapi" 259 | version = "0.3.8" 260 | source = "registry+https://github.com/rust-lang/crates.io-index" 261 | checksum = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6" 262 | dependencies = [ 263 | "winapi-i686-pc-windows-gnu", 264 | "winapi-x86_64-pc-windows-gnu", 265 | ] 266 | 267 | [[package]] 268 | name = "winapi-i686-pc-windows-gnu" 269 | version = "0.4.0" 270 | source = "registry+https://github.com/rust-lang/crates.io-index" 271 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 272 | 273 | [[package]] 274 | name = "winapi-x86_64-pc-windows-gnu" 275 | version = "0.4.0" 276 | source = "registry+https://github.com/rust-lang/crates.io-index" 277 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 278 | -------------------------------------------------------------------------------- /src/model/config.rs: -------------------------------------------------------------------------------- 1 | use crate::model::config::behavior::Behavior; 2 | use crate::model::config::color::Color; 3 | use crate::model::config::composition::Composition; 4 | use crate::model::config::debug::Debug; 5 | use crate::model::config::keybinding::Keybinding; 6 | use crate::model::config::setup::Setup; 7 | use crate::utils::get_config_dir; 8 | use crate::utils::print_help; 9 | use crate::utils::read_file; 10 | use log::{info, warn}; 11 | use serde::Deserialize; 12 | use std::env::args; 13 | use std::process::exit; 14 | 15 | mod behavior; 16 | mod color; 17 | mod composition; 18 | mod debug; 19 | mod keybinding; 20 | mod setup; 21 | 22 | #[derive(Clone, Debug, Default, Deserialize)] 23 | pub struct Config { 24 | #[serde(default)] 25 | pub behavior: Behavior, 26 | 27 | #[serde(default)] 28 | pub color: Color, 29 | 30 | #[serde(default)] 31 | pub composition: Composition, 32 | 33 | #[serde(default)] 34 | pub debug: Debug, 35 | 36 | #[serde(default)] 37 | pub keybinding: Keybinding, 38 | 39 | #[serde(default)] 40 | pub setup: Setup, 41 | } 42 | 43 | impl Config { 44 | pub fn new() -> Self { 45 | info!("initializing config"); 46 | 47 | let config = Self::read_config_file().unwrap_or_default(); 48 | 49 | Self::parse_args(config, args().skip(1)) 50 | } 51 | 52 | #[rustfmt::skip] 53 | fn parse_args(mut config: Self, args: T) -> Self 54 | where 55 | T: IntoIterator, 56 | { 57 | for arg in args { 58 | let (key, value) = Self::split_arg(arg); 59 | match key.as_str() { 60 | "--behavior.file_action" => config.behavior.file_action = Self::parse_value((key, value)), 61 | "--behavior.quit_on_action" => config.behavior.quit_on_action = Self::parse_value((key, value)), 62 | "--behavior.path_node_sort" => config.behavior.path_node_sort = Self::parse_value((key, value)), 63 | "--behavior.scrolling" => config.behavior.scrolling = Self::parse_value((key, value)), 64 | "--color.background" => config.color.background = Self::parse_value((key, value)), 65 | "--color.foreground" => config.color.foreground = Self::parse_value((key, value)), 66 | "--composition.indent" => config.composition.indent = Self::parse_value((key, value)), 67 | "--composition.show_indent" => config.composition.show_indent = Self::parse_value((key, value)), 68 | "--composition.use_utf8" => config.composition.use_utf8 = Self::parse_value((key, value)), 69 | "--debug.enabled" => config.debug.enabled = Self::parse_value((key, value)), 70 | "--debug.padding_bot" => config.debug.padding_bot = Self::parse_value((key, value)), 71 | "--debug.padding_top" => config.debug.padding_top = Self::parse_value((key, value)), 72 | "--debug.spacing_bot" => config.debug.spacing_bot = Self::parse_value((key, value)), 73 | "--debug.spacing_top" => config.debug.spacing_top = Self::parse_value((key, value)), 74 | "--keybinding.collapse_dir" => config.keybinding.collapse_dir = Self::parse_value((key, value)), 75 | "--keybinding.entry_down" => config.keybinding.entry_down = Self::parse_value((key, value)), 76 | "--keybinding.entry_up" => config.keybinding.entry_up = Self::parse_value((key, value)), 77 | "--keybinding.expand_dir" => config.keybinding.expand_dir = Self::parse_value((key, value)), 78 | "--keybinding.file_action" => config.keybinding.file_action = Self::parse_value((key, value)), 79 | "--keybinding.quit" => config.keybinding.quit = Self::parse_value((key, value)), 80 | "--keybinding.reload" => config.keybinding.reload = Self::parse_value((key, value)), 81 | "--setup.working_dir" => config.setup.working_dir = Self::parse_value((key, value)), 82 | 83 | "--help" | "--version" => print_help(), 84 | "--" => break, 85 | _ => { 86 | warn!("unknown option {}", key); 87 | } 88 | } 89 | } 90 | 91 | info!("config loaded as:\n{:?}", config); 92 | config 93 | } 94 | 95 | fn split_arg(arg: String) -> (String, String) { 96 | println!("{}", arg); 97 | if let Some(equal_sign_index) = arg.find('=') { 98 | let before_split = arg.split_at(equal_sign_index); 99 | let after_split = arg.split_at(equal_sign_index + 1); 100 | return (String::from(before_split.0), String::from(after_split.1)); 101 | } 102 | (arg, String::from("")) 103 | } 104 | 105 | fn parse_value((key, value): (String, String)) -> F 106 | where 107 | F: std::str::FromStr, 108 | { 109 | value.parse().unwrap_or_else(|_| { 110 | println!("option '{}={}' was not parsable", key, value); 111 | exit(1); 112 | }) 113 | } 114 | 115 | fn read_config_file() -> std::io::Result { 116 | let config_dir = get_config_dir()?; 117 | 118 | let config_file = format!("{}/twilight-commander.toml", config_dir); 119 | 120 | let config_file_content = read_file(&config_file)?; 121 | 122 | toml::from_str(&config_file_content).map_err(|_| { 123 | std::io::Error::new( 124 | std::io::ErrorKind::NotFound, 125 | "could not read the config file", 126 | ) 127 | }) 128 | } 129 | } 130 | 131 | #[cfg(test)] 132 | mod tests { 133 | use super::*; 134 | 135 | #[test] 136 | fn test_parse_args() { 137 | let default_config = Config::default(); 138 | let args_vec = vec![ 139 | String::from("--behavior.file_action=file_action_test"), 140 | String::from("--behavior.path_node_sort=path_node_sort_test"), 141 | String::from("--behavior.scrolling=scrolling_test"), 142 | String::from("--color.background=background_test"), 143 | String::from("--color.foreground=foreground_test"), 144 | String::from("--debug.enabled=true"), 145 | String::from("--debug.padding_bot=111"), 146 | String::from("--debug.padding_top=222"), 147 | String::from("--debug.spacing_bot=333"), 148 | String::from("--debug.spacing_top=444"), 149 | String::from("--setup.working_dir=working_dir_test"), 150 | ]; 151 | 152 | let config = Config::parse_args(default_config, args_vec.into_iter()); 153 | 154 | assert_eq!( 155 | config.behavior.file_action, 156 | String::from("file_action_test") 157 | ); 158 | assert_eq!( 159 | config.behavior.path_node_sort, 160 | String::from("path_node_sort_test") 161 | ); 162 | assert_eq!(config.behavior.scrolling, String::from("scrolling_test")); 163 | assert_eq!(config.color.background, String::from("background_test")); 164 | assert_eq!(config.color.foreground, String::from("foreground_test")); 165 | assert_eq!(config.debug.enabled, true); 166 | assert_eq!(config.debug.padding_bot, 111); 167 | assert_eq!(config.debug.padding_top, 222); 168 | assert_eq!(config.debug.spacing_bot, 333); 169 | assert_eq!(config.debug.spacing_top, 444); 170 | assert_eq!(config.setup.working_dir, String::from("working_dir_test")); 171 | } 172 | 173 | #[test] 174 | fn test_parse_args_with_stopper() { 175 | let default_config = Config::default(); 176 | let args_vec = vec![ 177 | String::from("--behavior.file_action=file_action_test"), 178 | String::from("--behavior.path_node_sort=path_node_sort_test"), 179 | String::from("--behavior.scrolling=scrolling_test"), 180 | String::from("--color.background=background_test"), 181 | String::from("--color.foreground=foreground_test"), 182 | String::from("--"), 183 | String::from("--debug.enabled=true"), 184 | String::from("--debug.padding_bot=111"), 185 | String::from("--debug.padding_top=222"), 186 | String::from("--debug.spacing_bot=333"), 187 | String::from("--debug.spacing_top=444"), 188 | String::from("--setup.working_dir=working_dir_test"), 189 | ]; 190 | 191 | let config = Config::parse_args(default_config, args_vec.into_iter()); 192 | let def_conf = Config::default(); 193 | 194 | assert_eq!( 195 | config.behavior.file_action, 196 | String::from("file_action_test") 197 | ); 198 | assert_eq!( 199 | config.behavior.path_node_sort, 200 | String::from("path_node_sort_test") 201 | ); 202 | assert_eq!(config.behavior.scrolling, String::from("scrolling_test")); 203 | assert_eq!(config.color.background, String::from("background_test")); 204 | assert_eq!(config.color.foreground, String::from("foreground_test")); 205 | assert_eq!(config.debug.enabled, def_conf.debug.enabled); 206 | assert_eq!(config.debug.padding_bot, def_conf.debug.padding_bot); 207 | assert_eq!(config.debug.padding_top, def_conf.debug.padding_top); 208 | assert_eq!(config.debug.spacing_bot, def_conf.debug.spacing_bot); 209 | assert_eq!(config.debug.spacing_top, def_conf.debug.spacing_top); 210 | assert_eq!(config.setup.working_dir, def_conf.setup.working_dir); 211 | } 212 | 213 | #[test] 214 | fn test_parse_args_with_multiple_equals() { 215 | let default_config = Config::default(); 216 | let args_vec = 217 | vec![String::from("--behavior.file_action=(x=1; y=2; echo $x$y)")]; 218 | 219 | let config = Config::parse_args(default_config, args_vec.into_iter()); 220 | 221 | assert_eq!( 222 | config.behavior.file_action, 223 | String::from("(x=1; y=2; echo $x$y)") 224 | ); 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/model/path_node.rs: -------------------------------------------------------------------------------- 1 | use crate::model::compare_functions::PathNodeCompare; 2 | use crate::model::config::Config; 3 | use crate::model::tree_index::TreeIndex; 4 | use log::info; 5 | use std::fs::canonicalize; 6 | use std::path::PathBuf; 7 | 8 | mod debug; 9 | 10 | #[derive(Clone)] 11 | pub struct PathNode { 12 | pub children: Vec, 13 | pub display_text: String, 14 | pub is_dir: bool, 15 | pub is_err: bool, 16 | pub is_expanded: bool, 17 | pub path: PathBuf, 18 | } 19 | 20 | impl From<&str> for PathNode { 21 | fn from(working_dir: &str) -> Self { 22 | Self { 23 | children: Vec::new(), 24 | display_text: String::from(working_dir), 25 | is_dir: true, 26 | is_err: false, 27 | is_expanded: false, 28 | path: PathBuf::from(working_dir), 29 | } 30 | } 31 | } 32 | 33 | impl From for PathNode { 34 | fn from(working_dir: String) -> Self { 35 | Self { 36 | children: Vec::new(), 37 | display_text: working_dir.clone(), 38 | is_dir: true, 39 | is_err: false, 40 | is_expanded: false, 41 | path: PathBuf::from(working_dir), 42 | } 43 | } 44 | } 45 | 46 | impl PathNode { 47 | pub fn new_expanded(config: Config) -> Self { 48 | info!("initializing path node"); 49 | 50 | let mut path_node = Self::from(config.setup.working_dir.clone()); 51 | let path_node_compare = Self::get_path_node_compare(&config); 52 | path_node.expand_dir(&TreeIndex::new(), path_node_compare); 53 | 54 | path_node 55 | } 56 | 57 | pub fn get_absolute_path(&self) -> String { 58 | let canonicalized_path = canonicalize(self.path.as_path()).unwrap(); 59 | canonicalized_path.to_str().unwrap().to_string() 60 | } 61 | 62 | fn list_path_node_children( 63 | &mut self, 64 | compare: PathNodeCompare, 65 | ) -> Vec { 66 | let dirs = self.path.read_dir(); 67 | 68 | if dirs.is_err() { 69 | self.is_err = true; 70 | return Vec::new(); 71 | } 72 | 73 | let mut path_nodes = dirs 74 | .unwrap() 75 | .map(|dir_entry| { 76 | let dir_entry = dir_entry.unwrap(); 77 | 78 | PathNode { 79 | children: Vec::new(), 80 | display_text: dir_entry.file_name().into_string().unwrap(), 81 | is_dir: dir_entry.path().is_dir(), 82 | is_err: false, 83 | is_expanded: false, 84 | path: dir_entry.path(), 85 | } 86 | }) 87 | .collect::>(); 88 | 89 | path_nodes.sort_unstable_by(compare); 90 | 91 | path_nodes 92 | } 93 | 94 | pub fn expand_dir( 95 | &mut self, 96 | tree_index: &TreeIndex, 97 | compare: PathNodeCompare, 98 | ) { 99 | let mut path_node = self; 100 | for i in &tree_index.index { 101 | if path_node.children.len() > *i { 102 | path_node = &mut path_node.children[*i]; 103 | } 104 | } 105 | 106 | if !path_node.path.is_dir() { 107 | return; 108 | } 109 | 110 | path_node.is_expanded = true; 111 | path_node.children = path_node.list_path_node_children(compare); 112 | } 113 | 114 | pub fn collapse_dir(&mut self, tree_index: &TreeIndex) { 115 | let mut path_node = self; 116 | for i in &tree_index.index { 117 | path_node = &mut path_node.children[*i]; 118 | } 119 | 120 | path_node.is_expanded = false; 121 | path_node.children = Vec::new(); 122 | } 123 | 124 | fn flat_index_to_tree_index_rec( 125 | &self, 126 | flat_index: &mut usize, 127 | tree_index: &mut TreeIndex, 128 | ) -> bool { 129 | if *flat_index == 0 { 130 | return true; 131 | } 132 | 133 | for (c, child) in self.children.iter().enumerate() { 134 | *flat_index -= 1; 135 | 136 | tree_index.index.push(c); 137 | if child.flat_index_to_tree_index_rec(flat_index, tree_index) { 138 | return true; 139 | } 140 | tree_index.index.pop(); 141 | } 142 | 143 | false 144 | } 145 | 146 | pub fn flat_index_to_tree_index(&self, flat_index: usize) -> TreeIndex { 147 | let mut tree_index = TreeIndex::from(Vec::new()); 148 | self.flat_index_to_tree_index_rec( 149 | &mut (flat_index + 1), 150 | &mut tree_index, 151 | ); 152 | 153 | tree_index 154 | } 155 | 156 | pub fn tree_index_to_flat_index_rec( 157 | &self, 158 | target_tree_index: &TreeIndex, 159 | current_tree_index: &TreeIndex, 160 | ) -> usize { 161 | if current_tree_index >= target_tree_index { 162 | return 0; 163 | } 164 | 165 | if self.children.is_empty() { 166 | return 1; 167 | } 168 | 169 | let mut sum = 1; 170 | 171 | for (index, child) in self.children.iter().enumerate() { 172 | let mut new_current_tree_index = current_tree_index.clone(); 173 | new_current_tree_index.index.push(index); 174 | 175 | sum += child.tree_index_to_flat_index_rec( 176 | target_tree_index, 177 | &new_current_tree_index, 178 | ); 179 | } 180 | 181 | sum 182 | } 183 | 184 | pub fn tree_index_to_flat_index(&self, tree_index: &TreeIndex) -> usize { 185 | // We count the root directory, hence we have to subtract 1 to get the 186 | // proper index. 187 | self.tree_index_to_flat_index_rec(tree_index, &TreeIndex::new()) - 1 188 | } 189 | 190 | pub fn get_child_path_node(&self, tree_index: &TreeIndex) -> &Self { 191 | let mut child_node = self; 192 | for i in &tree_index.index { 193 | child_node = &child_node.children[*i]; 194 | } 195 | 196 | child_node 197 | } 198 | } 199 | 200 | #[cfg(test)] 201 | mod tests { 202 | use super::*; 203 | 204 | fn get_expanded_path_node() -> PathNode { 205 | let mut path_node = PathNode::from("./tests/test_dirs"); 206 | path_node 207 | .expand_dir(&TreeIndex::new(), PathNode::compare_dirs_top_simple); 208 | path_node.expand_dir( 209 | &TreeIndex::from(vec![0]), 210 | PathNode::compare_dirs_top_simple, 211 | ); 212 | path_node.expand_dir( 213 | &TreeIndex::from(vec![0, 0]), 214 | PathNode::compare_dirs_top_simple, 215 | ); 216 | path_node.expand_dir( 217 | &TreeIndex::from(vec![1]), 218 | PathNode::compare_dirs_top_simple, 219 | ); 220 | path_node.expand_dir( 221 | &TreeIndex::from(vec![1, 0]), 222 | PathNode::compare_dirs_top_simple, 223 | ); 224 | path_node.expand_dir( 225 | &TreeIndex::from(vec![1, 0, 2]), 226 | PathNode::compare_dirs_top_simple, 227 | ); 228 | path_node 229 | } 230 | 231 | mod get_child_path_node_tests { 232 | use super::*; 233 | 234 | #[test] 235 | fn first_dirs() { 236 | let path_node = { 237 | let mut path_node = PathNode::from("./tests/test_dirs"); 238 | path_node.expand_dir( 239 | &TreeIndex::new(), 240 | PathNode::compare_dirs_top_simple, 241 | ); 242 | path_node.expand_dir( 243 | &TreeIndex::from(vec![0]), 244 | PathNode::compare_dirs_top_simple, 245 | ); 246 | path_node.expand_dir( 247 | &TreeIndex::from(vec![0, 0]), 248 | PathNode::compare_dirs_top_simple, 249 | ); 250 | path_node 251 | }; 252 | 253 | let child_path_node = 254 | path_node.get_child_path_node(&TreeIndex::from(vec![0, 0, 0])); 255 | 256 | assert_eq!("file4", child_path_node.display_text); 257 | } 258 | 259 | #[test] 260 | fn complex_dirs() { 261 | let path_node = get_expanded_path_node(); 262 | 263 | let child_path_node = path_node 264 | .get_child_path_node(&TreeIndex::from(vec![1, 0, 2, 2])); 265 | 266 | assert_eq!("file12", child_path_node.display_text); 267 | } 268 | } 269 | 270 | mod tree_index_to_flat_index_tests { 271 | use super::*; 272 | 273 | #[test] 274 | fn complex_dirs() { 275 | let path_node = get_expanded_path_node(); 276 | 277 | let flat_index = 278 | path_node.tree_index_to_flat_index(&TreeIndex::from(vec![4])); 279 | 280 | assert_eq!(22, flat_index); 281 | } 282 | 283 | #[test] 284 | fn complex_dirs2() { 285 | let path_node = get_expanded_path_node(); 286 | 287 | let flat_index = 288 | path_node.tree_index_to_flat_index(&TreeIndex::from(vec![5])); 289 | 290 | assert_eq!(23, flat_index); 291 | } 292 | 293 | #[test] 294 | fn complex_dirs3() { 295 | let path_node = get_expanded_path_node(); 296 | 297 | let flat_index = path_node 298 | .tree_index_to_flat_index(&TreeIndex::from(vec![1, 0, 4])); 299 | 300 | assert_eq!(15, flat_index); 301 | } 302 | 303 | #[test] 304 | fn total_count() { 305 | let path_node = get_expanded_path_node(); 306 | 307 | let flat_index = path_node 308 | .tree_index_to_flat_index(&TreeIndex::from(vec![100_000])); 309 | 310 | assert_eq!(31, flat_index); 311 | } 312 | 313 | #[test] 314 | fn zero() { 315 | let path_node = get_expanded_path_node(); 316 | 317 | let flat_index = 318 | path_node.tree_index_to_flat_index(&TreeIndex::from(vec![0])); 319 | 320 | assert_eq!(0, flat_index); 321 | } 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/view/scroll.rs: -------------------------------------------------------------------------------- 1 | use crate::view::Pager; 2 | use std::io::Write; 3 | 4 | impl Pager { 5 | fn get_index_overshoot( 6 | index_under_test: i32, 7 | index_now: i32, 8 | index_delta: i32, 9 | ) -> Option { 10 | let index_before = index_now - index_delta; 11 | 12 | // cross from below 13 | if index_before <= index_under_test && index_now >= index_under_test { 14 | return Some(index_now - index_under_test); 15 | } 16 | 17 | // cross from above 18 | if index_before >= index_under_test && index_now <= index_under_test { 19 | return Some(index_now - index_under_test); 20 | } 21 | 22 | None 23 | } 24 | 25 | pub fn scroll_like_center( 26 | &self, 27 | cursor_row_delta: i32, 28 | text_entries_len: i32, 29 | ) -> i32 { 30 | let spacing_bot = self.config.debug.spacing_bot; 31 | let spacing_top = self.config.debug.spacing_top; 32 | 33 | let center_text_row = spacing_top - self.text_row 34 | + (self.terminal_rows - (spacing_bot + spacing_top)) / 2; 35 | let last_text_row = self.terminal_rows - (self.text_row + spacing_bot); 36 | 37 | // re-center a cursor row that is below the center (last text entry was 38 | // visible) in the case that a subdirectory is opened 39 | // in such a way that the bottom is not visible anymore 40 | if cursor_row_delta == 0 41 | && self.cursor_row - center_text_row > 0 42 | && self.cursor_row - center_text_row 43 | <= text_entries_len - last_text_row 44 | { 45 | return self.text_row - (self.cursor_row - center_text_row); 46 | } 47 | 48 | // cursor row is moved over the center 49 | if let Some(overshoot) = Self::get_index_overshoot( 50 | center_text_row, 51 | self.cursor_row, 52 | cursor_row_delta, 53 | ) { 54 | // no need to keep it centered when we reach the top or bottom 55 | if self.text_row >= spacing_top && cursor_row_delta < 0 { 56 | return self.text_row; 57 | } 58 | if self.text_row + text_entries_len 59 | <= self.terminal_rows - spacing_bot 60 | && cursor_row_delta > 0 61 | { 62 | return self.text_row; 63 | } 64 | 65 | // Prevent violating the spacing when centering on the cursor row. 66 | // E.g. when jumping to the parent directory and it is the topmost 67 | // entry do not want it centered. 68 | if self.text_row - overshoot > spacing_top { 69 | return spacing_top; 70 | } 71 | 72 | // keep it centered 73 | return self.text_row - overshoot; 74 | } 75 | 76 | // cursor row is beyond vision -> move the text row the minimal amount 77 | // to correct that 78 | if self.text_row + self.cursor_row < spacing_top { 79 | return spacing_top - self.cursor_row; 80 | } else if self.text_row + self.cursor_row 81 | > self.terminal_rows - (1 + spacing_bot) 82 | { 83 | return self.terminal_rows - (1 + spacing_bot + self.cursor_row); 84 | } 85 | 86 | self.text_row 87 | } 88 | 89 | pub fn scroll_like_editor(&self) -> i32 { 90 | let padding_bot = self.config.debug.padding_bot; 91 | let padding_top = self.config.debug.padding_top; 92 | let spacing_bot = self.config.debug.spacing_bot; 93 | let spacing_top = self.config.debug.spacing_top; 94 | 95 | if self.text_row + self.cursor_row < spacing_top + padding_top { 96 | return spacing_top + padding_top - self.cursor_row; 97 | } else if self.text_row + self.cursor_row 98 | > self.terminal_rows - (1 + spacing_bot + padding_bot) 99 | { 100 | return self.terminal_rows 101 | - (1 + spacing_bot + padding_bot + self.cursor_row); 102 | } 103 | 104 | self.text_row 105 | } 106 | } 107 | 108 | #[cfg(test)] 109 | mod tests { 110 | use super::*; 111 | use crate::model::config::Config; 112 | 113 | fn prepare_pager() -> Pager> { 114 | let mut config = Config::default(); 115 | config.debug.enabled = true; 116 | config.debug.padding_bot = 1; 117 | config.debug.padding_top = 1; 118 | config.debug.spacing_bot = 1; 119 | config.debug.spacing_top = 1; 120 | 121 | let out: Vec = Vec::new(); 122 | let mut pager = Pager::new(config, out); 123 | 124 | pager.terminal_cols = 100; 125 | pager.terminal_rows = 10; 126 | 127 | pager 128 | } 129 | 130 | mod get_index_overshoot_tests { 131 | use super::*; 132 | 133 | #[test] 134 | fn overshoot_from_below() { 135 | let overshoot = Pager::>::get_index_overshoot(10, 11, 3); 136 | assert_eq!(Some(1), overshoot); 137 | } 138 | 139 | #[test] 140 | fn overshoot_from_above() { 141 | let overshoot = Pager::>::get_index_overshoot(10, 7, -4); 142 | assert_eq!(Some(-3), overshoot); 143 | } 144 | 145 | #[test] 146 | fn no_overshoot_from_below() { 147 | let overshoot = Pager::>::get_index_overshoot(10, 7, 2); 148 | assert_eq!(None, overshoot); 149 | } 150 | 151 | #[test] 152 | fn no_overshoot_from_above() { 153 | let overshoot = Pager::>::get_index_overshoot(10, 14, -3); 154 | assert_eq!(None, overshoot); 155 | } 156 | } 157 | 158 | mod scroll_like_center_tests { 159 | use super::*; 160 | 161 | #[test] 162 | fn scroll_like_center_cursor_top_test() { 163 | let text_row = { 164 | let pager = prepare_pager(); 165 | pager.scroll_like_center(1, 17) 166 | }; 167 | 168 | assert_eq!(1, text_row); 169 | } 170 | 171 | #[test] 172 | fn scroll_like_center_text_moves_up1_test() { 173 | let text_row = { 174 | let mut pager = prepare_pager(); 175 | pager.cursor_row = 5; 176 | pager.scroll_like_center(1, 17) 177 | }; 178 | 179 | assert_eq!(0, text_row); 180 | } 181 | 182 | #[test] 183 | fn scroll_like_center_text_moves_up2_test() { 184 | let text_row = { 185 | let mut pager = prepare_pager(); 186 | pager.cursor_row = 6; 187 | pager.scroll_like_center(1, 17) 188 | }; 189 | 190 | assert_eq!(-1, text_row); 191 | } 192 | 193 | #[test] 194 | fn scroll_like_center_text_moves_down_test() { 195 | let text_row = { 196 | let mut pager = prepare_pager(); 197 | pager.cursor_row = 6; 198 | pager.scroll_like_center(-1, 17) 199 | }; 200 | 201 | assert_eq!(0, text_row); 202 | } 203 | 204 | #[test] 205 | fn scroll_like_center_cursor_bot_test() { 206 | let text_row = { 207 | let mut pager = prepare_pager(); 208 | pager.cursor_row = 9; 209 | pager.scroll_like_center(-1, 17) 210 | }; 211 | 212 | assert_eq!(-1, text_row); 213 | } 214 | 215 | #[test] 216 | fn scroll_like_center_cursor_bot_no_delta_test() { 217 | let text_row = { 218 | let mut pager = prepare_pager(); 219 | pager.cursor_row = 9; 220 | pager.scroll_like_center(0, 17) 221 | }; 222 | 223 | assert_eq!(-4, text_row); 224 | } 225 | 226 | #[test] 227 | fn scroll_like_center_cursor_with_overshoot() { 228 | let text_row = { 229 | let mut pager = prepare_pager(); 230 | println!("{}", pager.text_row); 231 | pager.cursor_row = 13; 232 | pager.text_row = -10; 233 | pager.scroll_like_center(-3, 123) 234 | }; 235 | 236 | assert_eq!(-8, text_row); 237 | } 238 | 239 | #[test] 240 | fn scroll_like_center_cursor_top_most_overshoot() { 241 | let text_row = { 242 | let mut pager = prepare_pager(); 243 | println!("{}", pager.text_row); 244 | pager.cursor_row = 0; 245 | pager.text_row = -10; 246 | pager.scroll_like_center(-16, 123) 247 | }; 248 | 249 | assert_eq!(1, text_row); 250 | } 251 | } 252 | 253 | mod scroll_like_editor_tests { 254 | use super::*; 255 | 256 | #[test] 257 | fn scroll_like_editor_cursor_top_test() { 258 | let text_row = { 259 | let pager = prepare_pager(); 260 | pager.scroll_like_editor() 261 | }; 262 | 263 | assert_eq!(2, text_row); 264 | } 265 | 266 | #[test] 267 | fn scroll_like_editor_text_moves_up1_test() { 268 | let text_row = { 269 | let mut pager = prepare_pager(); 270 | pager.cursor_row = 5; 271 | pager.scroll_like_editor() 272 | }; 273 | 274 | assert_eq!(0, text_row); 275 | } 276 | 277 | #[test] 278 | fn scroll_like_editor_text_moves_up2_test() { 279 | let text_row = { 280 | let mut pager = prepare_pager(); 281 | pager.cursor_row = 6; 282 | pager.scroll_like_editor() 283 | }; 284 | 285 | assert_eq!(0, text_row); 286 | } 287 | 288 | #[test] 289 | fn scroll_like_editor_text_moves_down_test() { 290 | let text_row = { 291 | let mut pager = prepare_pager(); 292 | pager.cursor_row = 6; 293 | pager.scroll_like_editor() 294 | }; 295 | 296 | assert_eq!(0, text_row); 297 | } 298 | 299 | #[test] 300 | fn scroll_like_editor_cursor_bot_test() { 301 | let text_row = { 302 | let mut pager = prepare_pager(); 303 | pager.cursor_row = 9; 304 | pager.scroll_like_editor() 305 | }; 306 | 307 | assert_eq!(-2, text_row); 308 | } 309 | 310 | #[test] 311 | fn scroll_like_editor_cursor_bot_no_delta_test() { 312 | let text_row = { 313 | let mut pager = prepare_pager(); 314 | pager.cursor_row = 9; 315 | pager.scroll_like_editor() 316 | }; 317 | 318 | assert_eq!(-2, text_row); 319 | } 320 | } 321 | } 322 | --------------------------------------------------------------------------------