diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d72573f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,538 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "cc" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a0499c1dc64f458ad13872de75c0eb7e3fdb0e67964610c914b034fc5956e" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" +dependencies = [ + "errno-dragonfly", + "libc", + "winapi", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "exec" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "886b70328cba8871bfc025858e1de4be16b1d5088f2ba50b57816f4210672615" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "js-sys" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.169" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "numtoa" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "proc-macro2" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_termios" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb" + +[[package]] +name = "serde" +version = "1.0.216" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.216" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "syn" +version = "2.0.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53cbcb5a243bd33b7858b1d7f4aca2153490815872d86d955d6ea29f743c035" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termion" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eaa98560e51a2cf4f0bb884d8b2098a9ea11ecf3b7078e9c68242c74cc923a7" +dependencies = [ + "libc", + "libredox", + "numtoa", + "redox_termios", +] + +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "twilight-tree" +version = "0.1.0" +dependencies = [ + "chrono", + "exec", + "fern", + "log", + "serde", + "signal-hook", + "termion", + "toml", +] + +[[package]] +name = "unicode-ident" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" + +[[package]] +name = "wasm-bindgen" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4a1daca --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +authors = ["Awiteb "] +description = "Embeddable TUI files tree" +edition = "2021" +license = "MIT" +name = "twilight-tree" +version = "0.1.0" +rust-version = "1.66.1" + +[dependencies] +chrono = "0.4.39" +exec = "0.3.1" +fern = "0.7" +log = "0.4" +serde = { version = "1.0.216", features = ["derive"] } +signal-hook = "0.3.17" +termion = "4.0.3" +toml = "0.8.19" diff --git a/README.md b/README.md new file mode 100644 index 0000000..651c691 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +> This is a fork of [twilight-commander](https://github.com/golmman/twilight-commander) + +# twilight-tree + +A simple console tree file explorer for linux, similiar to NERDTree but independent of vim. +Developed and tested on Ubuntu 18.04 with xterm derivatives. + +## Build and install + +```sh +git clone https://git.4rs.nl/awiteb/twilight-tree +cd twilight-tree +cargo install --path . # this will install the binary in $HOME/.cargo/bin +``` + +## Implemented features + +### Configuration +The configuration is loaded as follows +1. load values from `$XDG_CONFIG_HOME/twilight-tree.toml` +2. else load values from `$HOME/.config/twilight-tree/twilight-tree.toml` +2. fill missing values with app defaults +3. overwrite values with defines from the command line options + +For a config file with the default values, see [twilight-tree.toml](./twilight-tree.toml). +The command line options are derived from the values defined inside the twilight-tree.toml . +E.g. +``` +[debug] +enabled = true +``` +is set with the option `--debug.enabled=true`. + +### Configurable key bindings + +The key bindings are configurable. For the set of configurable keys and key combinations consult the [event.rs](./src/model/event.rs). + +|default key|default configuration|action| +|---|---|---| +|up arrow|`--keybinding.entry_up=up`|move an entry up| +|down arrow|`--keybinding.entry_down=down`|move an entry down| +|left arrow|`--keybinding.collapse_dir=left`|collapse an entry directory or jump to parent if not collapsable| +|right arrow|`--keybinding.expand_dir=left`|expand an entry directory| +|r|`--keybinding.reload=r`|collapse all directories and reload root directory| +|return|`--keybinding.file_action=return`|perform configured file action| +|q|`--keybinding.quit=q`|quit| + +### Directory entry management + +#### File Action +The command line option / config value `--behavior.file_action` defines the action taken when the return key is pressed +on a file. The action is interpreted by `bash` and any occurence of `%s` will be replaced by the selected filename. +E.g. when enter is pressed on the file `.bashrc` in a twilight-tree process created with +``` +twilight-tree "--behavior.file_action=xterm -e 'cat %s; echo opened file: %s; bash'" +``` +then +``` +bash -c "xterm -e 'cat /home/user/.bashrc; echo opened file: /home/user/.bashrc; bash'" +``` +is executed, i.e.: +* a new xterm window is opened +* where the selected file (`.bashrc`) is printed to stdout +* then `opened file: ~/.bashrc` is printed +* `bash` prevents the window from closing. + +`--behavior.file_action` defaults to [true](https://en.wikipedia.org/wiki/True_and_false_(commands)), which does +(almost) nothing. + +### Scrolling modes +Specified with the option `--behaviour.scrolling` (default = `center`) + +* `center`: move the cursor until it is in the center, then move the text instead +* `editor`: move the cursor until it hits the top/bottom boundaries set by the `debug.paddin_top/bot` limits + +### Utf-8 support +In case your terminal does not support utf-8 you can disable it with `--composition.use_utf8=false`. + +### Logs +Logs are written to +1. `$XDG_CONFIG_HOME/tc.log` if XDG_CONFIG_HOME is defined +2. else they are placed in `$HOME/.config/twilight-tree/tc.log` + +## License +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details. diff --git a/src/controller/key_event_handler.rs b/src/controller/key_event_handler.rs new file mode 100644 index 0000000..b9ea405 --- /dev/null +++ b/src/controller/key_event_handler.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use crate::model::event::Event; +use crate::model::event::Key; +use std::io::stdin; +use std::sync::mpsc::SyncSender; +use std::sync::mpsc::{self, TryRecvError}; +use termion::input::TermRead; + +pub struct KeyEventHandler {} + +impl KeyEventHandler { + pub fn handle(sync_sender: SyncSender, rx: mpsc::Receiver<()>) { + let stdin = stdin(); + + for termion_event in stdin.events() { + if let Ok(termion_event) = termion_event { + let _ = sync_sender.send(Event::Key(Key::from(termion_event))); + } + match rx.try_recv() { + Ok(_) | Err(TryRecvError::Disconnected) => { + // println!("Terminating."); + break; + } + Err(TryRecvError::Empty) => {} + } + } + } +} diff --git a/src/controller/key_event_matcher/collapse_dir.rs b/src/controller/key_event_matcher/collapse_dir.rs new file mode 100644 index 0000000..6ca1996 --- /dev/null +++ b/src/controller/key_event_matcher/collapse_dir.rs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use crate::controller::EventQueue; +use crate::model::tree_index::TreeIndex; +use std::io::Write; + +impl EventQueue { + pub fn do_collapse_dir(&mut self) -> Option<()> { + let tree_index = self + .path_node_root + .flat_index_to_tree_index(self.pager.cursor_row as usize); + + let cursor_delta = self.get_parent_dir_cursor_delta(&tree_index); + + if cursor_delta == 0 { + self.path_node_root.collapse_dir(&tree_index); + } + + self.text_entries = self.composer.compose_path_node(&self.path_node_root); + + self.update_pager(cursor_delta); + Some(()) + } + + fn get_parent_dir_cursor_delta(&mut self, tree_index: &TreeIndex) -> i32 { + let child_path_node = self.path_node_root.get_child_path_node(tree_index); + if child_path_node.is_dir && child_path_node.is_expanded { + return 0; + } + + let parent_path_node_tree_index = tree_index.get_parent(); + if parent_path_node_tree_index == TreeIndex::new() { + return 0; + } + + let parent_flat_index = + self.path_node_root + .tree_index_to_flat_index(&parent_path_node_tree_index) as i32; + + parent_flat_index - self.pager.cursor_row + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::config::Config; + use crate::model::path_node::PathNode; + use crate::model::tree_index::TreeIndex; + use crate::view::composer::Composer; + use crate::view::Pager; + + // TODO: duplicate code, create test utils? + fn get_expanded_path_node() -> PathNode { + let mut path_node = PathNode::from("./tests/test_dirs"); + path_node.expand_dir(&TreeIndex::new(), PathNode::compare_dirs_top_simple); + path_node.expand_dir(&TreeIndex::from(vec![0]), PathNode::compare_dirs_top_simple); + path_node.expand_dir( + &TreeIndex::from(vec![0, 0]), + PathNode::compare_dirs_top_simple, + ); + path_node.expand_dir(&TreeIndex::from(vec![1]), PathNode::compare_dirs_top_simple); + path_node.expand_dir( + &TreeIndex::from(vec![1, 0]), + PathNode::compare_dirs_top_simple, + ); + path_node.expand_dir( + &TreeIndex::from(vec![1, 0, 2]), + PathNode::compare_dirs_top_simple, + ); + path_node + } + + fn prepare_event_queue() -> EventQueue> { + let config = Config::default(); + + let composer = Composer::from(config.clone()); + let pager = Pager::new(config.clone(), Vec::new()); + let path_node = PathNode::from(config.setup.working_dir.clone()); + + let mut event_queue = EventQueue::new(config, composer, pager, path_node); + + event_queue.path_node_root = get_expanded_path_node(); + + event_queue + } + + mod get_parent_dir_cursor_delta_tests { + use super::*; + + #[test] + fn expanded() { + let mut event_queue = prepare_event_queue(); + + let delta = event_queue.get_parent_dir_cursor_delta(&TreeIndex::from(vec![0])); + + assert_eq!(0, delta); + } + + #[test] + fn empty_tree_index() { + let mut event_queue = prepare_event_queue(); + + let delta = event_queue.get_parent_dir_cursor_delta(&TreeIndex::new()); + + assert_eq!(0, delta); + } + + #[test] + fn jump() { + let mut event_queue = prepare_event_queue(); + + let delta = event_queue.get_parent_dir_cursor_delta(&TreeIndex::from(vec![1, 0, 4])); + + assert_eq!(7, delta); + } + } +} diff --git a/src/controller/key_event_matcher/entry_down.rs b/src/controller/key_event_matcher/entry_down.rs new file mode 100644 index 0000000..ce201e6 --- /dev/null +++ b/src/controller/key_event_matcher/entry_down.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use crate::controller::EventQueue; +use std::io::Write; + +impl EventQueue { + pub fn do_entry_down(&mut self) -> Option<()> { + self.update_pager(1); + Some(()) + } +} diff --git a/src/controller/key_event_matcher/entry_up.rs b/src/controller/key_event_matcher/entry_up.rs new file mode 100644 index 0000000..2ff5d11 --- /dev/null +++ b/src/controller/key_event_matcher/entry_up.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use crate::controller::EventQueue; +use std::io::Write; + +impl EventQueue { + pub fn do_entry_up(&mut self) -> Option<()> { + self.update_pager(-1); + Some(()) + } +} diff --git a/src/controller/key_event_matcher/expand_dir.rs b/src/controller/key_event_matcher/expand_dir.rs new file mode 100644 index 0000000..ed8d1ac --- /dev/null +++ b/src/controller/key_event_matcher/expand_dir.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use crate::controller::EventQueue; +use std::io::Write; + +impl EventQueue { + pub fn do_expand_dir(&mut self) -> Option<()> { + let tree_index = self + .path_node_root + .flat_index_to_tree_index(self.pager.cursor_row as usize); + self.path_node_root + .expand_dir(&tree_index, self.path_node_compare); + self.text_entries = self.composer.compose_path_node(&self.path_node_root); + + self.update_pager(0); + Some(()) + } +} diff --git a/src/controller/key_event_matcher/file_action.rs b/src/controller/key_event_matcher/file_action.rs new file mode 100644 index 0000000..35b77ae --- /dev/null +++ b/src/controller/key_event_matcher/file_action.rs @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use crate::controller::EventQueue; +use log::info; +use std::io::Write; + +impl EventQueue { + pub fn do_file_action(&mut self) -> Option<()> { + let tree_index = self + .path_node_root + .flat_index_to_tree_index(self.pager.cursor_row as usize); + + let child_node = self.path_node_root.get_child_path_node(&tree_index); + + if !child_node.is_dir { + let file_path = &child_node.get_absolute_path(); + let file_action_replaced = self.config.behavior.file_action.replace("%s", file_path); + + info!("executing file action:\n{}", file_action_replaced); + + if self.config.behavior.quit_on_action { + self.command_to_run_on_exit = Some(file_action_replaced); + None + } else { + std::process::Command::new("bash") + .arg("-c") + .arg(file_action_replaced) + .spawn() + .unwrap() + .wait() + .unwrap(); + Some(()) + } + } else { + Some(()) + } + } +} diff --git a/src/controller/key_event_matcher/mod.rs b/src/controller/key_event_matcher/mod.rs new file mode 100644 index 0000000..0c887e0 --- /dev/null +++ b/src/controller/key_event_matcher/mod.rs @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use crate::controller::EventQueue; +use crate::model::event::Key; +use std::io::Write; + +mod collapse_dir; +mod entry_down; +mod entry_up; +mod expand_dir; +mod file_action; +mod quit; +mod reload; + +impl EventQueue { + #[rustfmt::skip] + pub fn match_key_event(&mut self, key: Key) -> Option<()> { + let ck = self.config.keybinding.clone(); + + if key == Key::from(ck.collapse_dir) { self.do_collapse_dir() } + else if key == Key::from(ck.entry_down) { self.do_entry_down() } + else if key == Key::from(ck.entry_up) { self.do_entry_up() } + else if key == Key::from(ck.expand_dir) { self.do_expand_dir() } + else if key == Key::from(ck.file_action) { self.do_file_action() } + else if key == Key::from(ck.quit) { self.do_quit() } + else if key == Key::from(ck.reload) { self.do_reload() } + else { Some(()) } + } + + fn update_pager(&mut self, cursor_delta: i32) { + self.pager.update( + cursor_delta, + &self.text_entries, + self.path_node_root.get_absolute_path(), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::config::Config; + use crate::model::path_node::PathNode; + use crate::view::composer::Composer; + use crate::view::Pager; + + fn prepare_event_queue() -> EventQueue> { + let config = Config::default(); + + let composer = Composer::from(config.clone()); + let pager = Pager::new(config.clone(), Vec::new()); + let path_node = PathNode::from(config.setup.working_dir.clone()); + + EventQueue::new(config, composer, pager, path_node) + } + + #[test] + fn match_key_event_default_test() { + let result = { + let mut event_queue = prepare_event_queue(); + event_queue.match_key_event(Key::from("nonsense")) + }; + + assert!(result.is_some()); + } + + #[test] + fn match_key_event_quit_test() { + let result = { + let mut event_queue = prepare_event_queue(); + event_queue.match_key_event(Key::from(event_queue.config.keybinding.quit.clone())) + }; + + assert!(result.is_none()); + } + + #[test] + fn match_key_event_reload_test() { + let result = { + let mut event_queue = prepare_event_queue(); + event_queue.match_key_event(Key::from(event_queue.config.keybinding.reload.clone())) + }; + + assert!(result.is_some()); + } + + #[test] + fn match_key_event_file_action_test() { + let result = { + let mut event_queue = prepare_event_queue(); + event_queue + .match_key_event(Key::from(event_queue.config.keybinding.file_action.clone())) + }; + + assert!(result.is_some()); + } + + #[test] + fn match_key_event_entry_up_test() { + let result = { + let mut event_queue = prepare_event_queue(); + event_queue.match_key_event(Key::from(event_queue.config.keybinding.entry_up.clone())) + }; + + assert!(result.is_some()); + } + + #[test] + fn match_key_event_entry_down_test() { + let result = { + let mut event_queue = prepare_event_queue(); + event_queue.match_key_event(Key::from(event_queue.config.keybinding.entry_down.clone())) + }; + + assert!(result.is_some()); + } + + #[test] + fn match_key_event_collapse_dir_test() { + let result = { + let mut event_queue = prepare_event_queue(); + event_queue.match_key_event(Key::from( + event_queue.config.keybinding.collapse_dir.clone(), + )) + }; + + assert!(result.is_some()); + } + + #[test] + fn match_key_event_expand_dir_test() { + let result = { + let mut event_queue = prepare_event_queue(); + event_queue.match_key_event(Key::from(event_queue.config.keybinding.expand_dir.clone())) + }; + + assert!(result.is_some()); + } +} diff --git a/src/controller/key_event_matcher/quit.rs b/src/controller/key_event_matcher/quit.rs new file mode 100644 index 0000000..9d2166c --- /dev/null +++ b/src/controller/key_event_matcher/quit.rs @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use crate::controller::EventQueue; +use std::io::Write; + +impl EventQueue { + pub fn do_quit(&mut self) -> Option<()> { + None + } +} diff --git a/src/controller/key_event_matcher/reload.rs b/src/controller/key_event_matcher/reload.rs new file mode 100644 index 0000000..ed2ccc6 --- /dev/null +++ b/src/controller/key_event_matcher/reload.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman +// Copyright (c) 2024 Awiteb + +use crate::controller::EventQueue; +use crate::model::path_node::PathNode; +use crate::model::tree_index::TreeIndex; +use std::io::Write; + +impl EventQueue { + pub fn do_reload(&mut self) -> Option<()> { + self.reload_openend_dirs(); + + self.text_entries = self.composer.compose_path_node(&self.path_node_root); + + self.update_pager(0); + + Some(()) + } + + fn reload_openend_dirs(&mut self) { + // backup the old path node structure + let old_path_node_root = self.path_node_root.clone(); + + // reset the root path node + self.path_node_root = PathNode::from(self.config.setup.working_dir.clone()); + self.path_node_root + .expand_dir(&TreeIndex::from(Vec::new()), self.path_node_compare); + + // restore the old path nodes structure for the root path node + self.restore_expansions(&old_path_node_root, &mut TreeIndex::new()); + } + + fn restore_expansions(&mut self, path_node: &PathNode, tree_index: &mut TreeIndex) { + for (c, child) in path_node.children.iter().enumerate() { + if child.is_expanded { + tree_index.index.push(c); + + self.path_node_root + .expand_dir(tree_index, self.path_node_compare); + self.restore_expansions(child, tree_index); + + tree_index.index.pop(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::config::Config; + use crate::model::path_node::PathNode; + use crate::model::tree_index::TreeIndex; + use crate::view::composer::Composer; + use crate::view::Pager; + + fn get_expanded_path_node(working_dir: &str) -> PathNode { + let mut path_node = PathNode::from(working_dir); + + path_node.expand_dir(&TreeIndex::new(), PathNode::compare_dirs_top_simple); + path_node.expand_dir(&TreeIndex::from(vec![0]), PathNode::compare_dirs_top_simple); + path_node.expand_dir( + &TreeIndex::from(vec![0, 0]), + PathNode::compare_dirs_top_simple, + ); + path_node.expand_dir(&TreeIndex::from(vec![1]), PathNode::compare_dirs_top_simple); + path_node.expand_dir( + &TreeIndex::from(vec![1, 0]), + PathNode::compare_dirs_top_simple, + ); + path_node.expand_dir( + &TreeIndex::from(vec![1, 0, 2]), + PathNode::compare_dirs_top_simple, + ); + path_node + } + + fn prepare_event_queue(working_dir: &str) -> EventQueue> { + let mut config = Config::default(); + config.setup.working_dir = String::from(working_dir); + + let composer = Composer::from(config.clone()); + let pager = Pager::new(config.clone(), Vec::new()); + let path_node = PathNode::from(config.setup.working_dir.clone()); + + let mut event_queue = EventQueue::new(config, composer, pager, path_node); + + event_queue.path_node_root = get_expanded_path_node(working_dir); + + event_queue + } + + #[test] + fn do_reload() { + // TODO: implement proper test + let mut event_queue = prepare_event_queue("./tests/test_dirs"); + event_queue.do_reload(); + } +} diff --git a/src/controller/mod.rs b/src/controller/mod.rs new file mode 100644 index 0000000..a551025 --- /dev/null +++ b/src/controller/mod.rs @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use crate::controller::key_event_handler::KeyEventHandler; +use crate::controller::resize_event_handler::ResizeEventHandler; +use crate::model::compare_functions::PathNodeCompare; +use crate::model::config::Config; +use crate::model::event::Event; +use crate::model::path_node::PathNode; +use crate::view::composer::Composer; +use crate::view::Pager; +use log::info; +use std::io::Write; +use std::sync::mpsc::sync_channel; +use std::sync::mpsc::Receiver; +use std::sync::mpsc::SyncSender; +use std::thread; + +mod key_event_handler; +mod key_event_matcher; +mod resize_event_handler; + +pub struct EventQueue { + config: Config, + composer: Composer, + pager: Pager, + path_node_root: PathNode, + path_node_compare: PathNodeCompare, + queue_receiver: Receiver, + queue_sender: SyncSender, + + // TODO: should be part of the view? + text_entries: Vec, + command_to_run_on_exit: Option, +} + +impl EventQueue { + pub fn new( + config: Config, + composer: Composer, + mut pager: Pager, + path_node_root: PathNode, + ) -> Self { + info!("initializing event queue"); + + let (queue_sender, queue_receiver): (SyncSender, Receiver) = + sync_channel(1024); + + let path_node_compare = PathNode::get_path_node_compare(&config); + + let text_entries = composer.compose_path_node(&path_node_root); + pager.update(0, &text_entries, path_node_root.get_absolute_path()); + let command_to_run_on_exit = None; + + Self { + config, + composer, + pager, + path_node_root, + path_node_compare, + queue_receiver, + queue_sender, + text_entries, + command_to_run_on_exit, + } + } + + pub fn handle_messages(&mut self) -> Option { + let (tx1, rx1) = std::sync::mpsc::channel(); + let (tx2, rx2) = std::sync::mpsc::channel(); + let sender1 = self.queue_sender.clone(); + let sender2 = self.queue_sender.clone(); + thread::spawn(move || KeyEventHandler::handle(sender1, rx1)); + thread::spawn(move || ResizeEventHandler::handle(sender2, rx2)); + + while self + .match_event(self.queue_receiver.recv().unwrap()) + .is_some() + {} + let _ = tx1.send(()); + let _ = tx2.send(()); + self.command_to_run_on_exit.clone() + } + + fn match_event(&mut self, event: Event) -> Option<()> { + match event { + Event::Key(key) => self.match_key_event(key), + Event::Resize => { + self.pager.update( + 0, + &self.text_entries, + self.path_node_root.get_absolute_path(), + ); + Some(()) + } + } + } +} diff --git a/src/controller/resize_event_handler.rs b/src/controller/resize_event_handler.rs new file mode 100644 index 0000000..d5f863f --- /dev/null +++ b/src/controller/resize_event_handler.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman +// Copyright (c) 2024 Awiteb + +use crate::model::event::Event; +use signal_hook::consts::SIGWINCH; +use signal_hook::low_level::{register, unregister}; +use std::sync::mpsc; +use std::sync::mpsc::SyncSender; + +pub struct ResizeEventHandler {} + +impl ResizeEventHandler { + pub fn handle(sync_sender: SyncSender, rx: mpsc::Receiver<()>) { + let hook_id = unsafe { + register(SIGWINCH, move || { + sync_sender.send(Event::Resize).unwrap(); + }) + }; + let _ = rx.recv(); + unregister(hook_id.unwrap()); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..00b9f88 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +extern crate chrono; +extern crate termion; +extern crate toml; +use controller::EventQueue; +use exec::execvp; +use log::info; +use model::config::Config; +use model::path_node::PathNode; +use std::io::stdout; +use termion::raw::IntoRawMode; +use utils::setup_logger; +use view::composer::Composer; +use view::Pager; + +mod controller; +mod model; +mod utils; +mod view; + +fn main() { + let command_to_run_on_exit = { + let _ = setup_logger(); + + let config = Config::new(); + + let composer = Composer::from(config.clone()); + + let pager = Pager::new(config.clone(), stdout().into_raw_mode().unwrap()); + + let path_node_root = PathNode::new_expanded(config.clone()); + + let mut event_queue = EventQueue::new(config, composer, pager, path_node_root); + + event_queue.handle_messages() + }; + + if let Some(cmd) = command_to_run_on_exit { + let _ = execvp("bash", &["bash", "-c", &cmd]); + }; + + info!("clean exit"); +} diff --git a/src/model/compare_functions.rs b/src/model/compare_functions.rs new file mode 100644 index 0000000..a72d8e7 --- /dev/null +++ b/src/model/compare_functions.rs @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use crate::model::config::Config; +use crate::model::path_node::PathNode; +use std::cmp::Ordering; + +pub type PathNodeCompare = fn(&PathNode, &PathNode) -> Ordering; + +impl PathNode { + pub fn compare_dirs_bot_simple(a: &PathNode, b: &PathNode) -> Ordering { + if a.is_dir && !b.is_dir { + return std::cmp::Ordering::Greater; + } else if !a.is_dir && b.is_dir { + return std::cmp::Ordering::Less; + } + + a.display_text.cmp(&b.display_text) + } + + pub fn compare_dirs_top_simple(a: &PathNode, b: &PathNode) -> Ordering { + if a.is_dir && !b.is_dir { + return std::cmp::Ordering::Less; + } else if !a.is_dir && b.is_dir { + return std::cmp::Ordering::Greater; + } + + a.display_text.cmp(&b.display_text) + } + + pub fn get_path_node_compare(config: &Config) -> PathNodeCompare { + let path_node_compare: fn(&PathNode, &PathNode) -> Ordering = + match config.behavior.path_node_sort.as_str() { + "dirs_bot_simple" => PathNode::compare_dirs_bot_simple, + "dirs_top_simple" => PathNode::compare_dirs_top_simple, + "none" => |_, _| Ordering::Equal, + _ => |_, _| Ordering::Equal, + }; + + path_node_compare + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cmp::Ordering::Greater; + use std::cmp::Ordering::Less; + + mod compare_dirs_bot_simple_tests { + use super::*; + + #[test] + fn dir_to_dir() { + let dir_a = get_dir("dir_a"); + let dir_b = get_dir("dir_b"); + + let order = PathNode::compare_dirs_bot_simple(&dir_a, &dir_b); + + assert_eq!(Less, order); + } + + #[test] + fn dir_to_file() { + let dir = get_dir("something"); + let file = get_file("something"); + + let order = PathNode::compare_dirs_bot_simple(&dir, &file); + + assert_eq!(Greater, order); + } + + #[test] + fn file_to_file() { + let file_a = get_file("file_a"); + let file_b = get_file("file_b"); + + let order = PathNode::compare_dirs_bot_simple(&file_a, &file_b); + + assert_eq!(Less, order); + } + } + + mod compare_dirs_top_simple_tests { + use super::*; + + #[test] + fn dir_to_dir() { + let dir_a = get_dir("dir_a"); + let dir_b = get_dir("dir_b"); + + let order = PathNode::compare_dirs_top_simple(&dir_a, &dir_b); + + assert_eq!(Less, order); + } + + #[test] + fn dir_to_file() { + let dir = get_dir("something"); + let file = get_file("something"); + + let order = PathNode::compare_dirs_top_simple(&dir, &file); + + assert_eq!(Less, order); + } + + #[test] + fn file_to_file() { + let file_a = get_file("file_a"); + let file_b = get_file("file_b"); + + let order = PathNode::compare_dirs_top_simple(&file_a, &file_b); + + assert_eq!(Less, order); + } + } + + fn get_dir(name: &str) -> PathNode { + let mut path_node = PathNode::from("."); + path_node.is_dir = true; + path_node.display_text = String::from(name); + path_node + } + + fn get_file(name: &str) -> PathNode { + let mut path_node = PathNode::from("."); + path_node.is_dir = false; + path_node.display_text = String::from(name); + path_node + } +} diff --git a/src/model/config/behavior.rs b/src/model/config/behavior.rs new file mode 100644 index 0000000..7a61f00 --- /dev/null +++ b/src/model/config/behavior.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize)] +pub struct Behavior { + #[serde(default = "Behavior::default_file_action")] + pub file_action: String, + + #[serde(default = "Behavior::default_path_node_sort")] + pub path_node_sort: String, + + #[serde(default = "Behavior::default_scrolling")] + pub scrolling: String, + + #[serde(default = "Behavior::default_quit_on_action")] + pub quit_on_action: bool, +} + +impl Default for Behavior { + fn default() -> Behavior { + Behavior { + file_action: Self::default_file_action(), + path_node_sort: Self::default_path_node_sort(), + scrolling: Self::default_scrolling(), + quit_on_action: Self::default_quit_on_action(), + } + } +} + +impl Behavior { + fn default_file_action() -> String { + String::from("true") // do nothing! + } + + fn default_path_node_sort() -> String { + String::from("dirs_top_simple") + } + + fn default_scrolling() -> String { + String::from("center") + } + + fn default_quit_on_action() -> bool { + false + } +} diff --git a/src/model/config/color.rs b/src/model/config/color.rs new file mode 100644 index 0000000..8942253 --- /dev/null +++ b/src/model/config/color.rs @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize)] +pub struct Color { + #[serde(default = "Color::default_background")] + pub background: String, + + #[serde(default = "Color::default_foreground")] + pub foreground: String, +} + +impl Default for Color { + fn default() -> Self { + Color { + background: Self::default_background(), + foreground: Self::default_foreground(), + } + } +} + +impl Color { + fn default_background() -> String { + String::from("000000") + } + + fn default_foreground() -> String { + String::from("FFFFFF") + } +} diff --git a/src/model/config/composition.rs b/src/model/config/composition.rs new file mode 100644 index 0000000..bda30a4 --- /dev/null +++ b/src/model/config/composition.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize)] +pub struct Composition { + #[serde(default = "Composition::default_indent")] + pub indent: i32, + + #[serde(default = "Composition::default_show_indent")] + pub show_indent: bool, + + #[serde(default = "Composition::default_use_utf8")] + pub use_utf8: bool, +} + +impl Default for Composition { + fn default() -> Composition { + Composition { + indent: Self::default_indent(), + show_indent: Self::default_show_indent(), + use_utf8: Self::default_use_utf8(), + } + } +} + +impl Composition { + fn default_indent() -> i32 { + 2 + } + + fn default_show_indent() -> bool { + false + } + + fn default_use_utf8() -> bool { + true + } +} diff --git a/src/model/config/debug.rs b/src/model/config/debug.rs new file mode 100644 index 0000000..e68cf5f --- /dev/null +++ b/src/model/config/debug.rs @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize)] +pub struct Debug { + #[serde(default = "Debug::default_enabled")] + pub enabled: bool, + + #[serde(default = "Debug::default_padding_bot")] + pub padding_bot: i32, + + #[serde(default = "Debug::default_padding_top")] + pub padding_top: i32, + + #[serde(default = "Debug::default_spacing_bot")] + pub spacing_bot: i32, + + #[serde(default = "Debug::default_spacing_top")] + pub spacing_top: i32, +} + +impl Default for Debug { + fn default() -> Self { + Self { + enabled: Self::default_enabled(), + padding_bot: Self::default_padding_bot(), + padding_top: Self::default_padding_top(), + spacing_bot: Self::default_spacing_bot(), + spacing_top: Self::default_spacing_top(), + } + } +} + +impl Debug { + fn default_enabled() -> bool { + false + } + + fn default_padding_bot() -> i32 { + 3 + } + + fn default_padding_top() -> i32 { + 3 + } + + fn default_spacing_bot() -> i32 { + 2 + } + + fn default_spacing_top() -> i32 { + 2 + } +} diff --git a/src/model/config/keybinding.rs b/src/model/config/keybinding.rs new file mode 100644 index 0000000..bfbbff3 --- /dev/null +++ b/src/model/config/keybinding.rs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize)] +pub struct Keybinding { + #[serde(default = "Keybinding::default_quit")] + pub quit: String, + + #[serde(default = "Keybinding::default_entry_up")] + pub entry_up: String, + + #[serde(default = "Keybinding::default_entry_down")] + pub entry_down: String, + + #[serde(default = "Keybinding::default_expand_dir")] + pub expand_dir: String, + + #[serde(default = "Keybinding::default_collapse_dir")] + pub collapse_dir: String, + + #[serde(default = "Keybinding::default_file_action")] + pub file_action: String, + + #[serde(default = "Keybinding::default_reload")] + pub reload: String, +} + +impl Default for Keybinding { + fn default() -> Self { + Self { + quit: Self::default_quit(), + entry_up: Self::default_entry_up(), + entry_down: Self::default_entry_down(), + expand_dir: Self::default_expand_dir(), + collapse_dir: Self::default_collapse_dir(), + file_action: Self::default_file_action(), + reload: Self::default_reload(), + } + } +} + +impl Keybinding { + fn default_quit() -> String { + String::from("q") + } + + fn default_entry_up() -> String { + String::from("up") + } + + fn default_entry_down() -> String { + String::from("down") + } + + fn default_expand_dir() -> String { + String::from("right") + } + + fn default_collapse_dir() -> String { + String::from("left") + } + + fn default_file_action() -> String { + String::from("return") + } + + fn default_reload() -> String { + String::from("r") + } +} diff --git a/src/model/config/mod.rs b/src/model/config/mod.rs new file mode 100644 index 0000000..0ed014e --- /dev/null +++ b/src/model/config/mod.rs @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman +// Copyright (c) 2024 Awiteb + +use crate::model::config::behavior::Behavior; +use crate::model::config::color::Color; +use crate::model::config::composition::Composition; +use crate::model::config::debug::Debug; +use crate::model::config::keybinding::Keybinding; +use crate::model::config::setup::Setup; +use crate::utils::get_config_dir; +use crate::utils::print_help; +use crate::utils::read_file; +use log::{info, warn}; +use serde::Deserialize; +use std::env::args; +use std::process::exit; + +mod behavior; +mod color; +mod composition; +mod debug; +mod keybinding; +mod setup; + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct Config { + #[serde(default)] + pub behavior: Behavior, + + #[serde(default)] + pub color: Color, + + #[serde(default)] + pub composition: Composition, + + #[serde(default)] + pub debug: Debug, + + #[serde(default)] + pub keybinding: Keybinding, + + #[serde(default)] + pub setup: Setup, +} + +impl Config { + pub fn new() -> Self { + info!("initializing config"); + + let config = Self::read_config_file().unwrap_or_default(); + + Self::parse_args(config, args().skip(1)) + } + + #[rustfmt::skip] + fn parse_args(mut config: Self, args: T) -> Self + where + T: IntoIterator, + { + for arg in args { + let (key, value) = Self::split_arg(arg); + match key.as_str() { + "--behavior.file_action" => config.behavior.file_action = Self::parse_value((key, value)), + "--behavior.quit_on_action" => config.behavior.quit_on_action = Self::parse_value((key, value)), + "--behavior.path_node_sort" => config.behavior.path_node_sort = Self::parse_value((key, value)), + "--behavior.scrolling" => config.behavior.scrolling = Self::parse_value((key, value)), + "--color.background" => config.color.background = Self::parse_value((key, value)), + "--color.foreground" => config.color.foreground = Self::parse_value((key, value)), + "--composition.indent" => config.composition.indent = Self::parse_value((key, value)), + "--composition.show_indent" => config.composition.show_indent = Self::parse_value((key, value)), + "--composition.use_utf8" => config.composition.use_utf8 = Self::parse_value((key, value)), + "--debug.enabled" => config.debug.enabled = Self::parse_value((key, value)), + "--debug.padding_bot" => config.debug.padding_bot = Self::parse_value((key, value)), + "--debug.padding_top" => config.debug.padding_top = Self::parse_value((key, value)), + "--debug.spacing_bot" => config.debug.spacing_bot = Self::parse_value((key, value)), + "--debug.spacing_top" => config.debug.spacing_top = Self::parse_value((key, value)), + "--keybinding.collapse_dir" => config.keybinding.collapse_dir = Self::parse_value((key, value)), + "--keybinding.entry_down" => config.keybinding.entry_down = Self::parse_value((key, value)), + "--keybinding.entry_up" => config.keybinding.entry_up = Self::parse_value((key, value)), + "--keybinding.expand_dir" => config.keybinding.expand_dir = Self::parse_value((key, value)), + "--keybinding.file_action" => config.keybinding.file_action = Self::parse_value((key, value)), + "--keybinding.quit" => config.keybinding.quit = Self::parse_value((key, value)), + "--keybinding.reload" => config.keybinding.reload = Self::parse_value((key, value)), + "--setup.working_dir" => config.setup.working_dir = Self::parse_value((key, value)), + + "--help" | "--version" => print_help(), + "--" => break, + _ => { + warn!("unknown option {}", key); + } + } + } + + info!("config loaded as:\n{:?}", config); + config + } + + fn split_arg(arg: String) -> (String, String) { + println!("{}", arg); + if let Some(equal_sign_index) = arg.find('=') { + let before_split = arg.split_at(equal_sign_index); + let after_split = arg.split_at(equal_sign_index + 1); + return (String::from(before_split.0), String::from(after_split.1)); + } + (arg, String::from("")) + } + + fn parse_value((key, value): (String, String)) -> F + where + F: std::str::FromStr, + { + value.parse().unwrap_or_else(|_| { + println!("option '{}={}' was not parsable", key, value); + exit(1); + }) + } + + fn read_config_file() -> std::io::Result { + let config_dir = get_config_dir()?; + + let config_file = format!("{}/twilight-commander.toml", config_dir); + + let config_file_content = read_file(&config_file)?; + + toml::from_str(&config_file_content).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "could not read the config file", + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_args() { + let default_config = Config::default(); + let args_vec = vec![ + String::from("--behavior.file_action=file_action_test"), + String::from("--behavior.path_node_sort=path_node_sort_test"), + String::from("--behavior.scrolling=scrolling_test"), + String::from("--color.background=background_test"), + String::from("--color.foreground=foreground_test"), + String::from("--debug.enabled=true"), + String::from("--debug.padding_bot=111"), + String::from("--debug.padding_top=222"), + String::from("--debug.spacing_bot=333"), + String::from("--debug.spacing_top=444"), + String::from("--setup.working_dir=working_dir_test"), + ]; + + let config = Config::parse_args(default_config, args_vec); + + assert_eq!( + config.behavior.file_action, + String::from("file_action_test") + ); + assert_eq!( + config.behavior.path_node_sort, + String::from("path_node_sort_test") + ); + assert_eq!(config.behavior.scrolling, String::from("scrolling_test")); + assert_eq!(config.color.background, String::from("background_test")); + assert_eq!(config.color.foreground, String::from("foreground_test")); + assert!(config.debug.enabled); + assert_eq!(config.debug.padding_bot, 111); + assert_eq!(config.debug.padding_top, 222); + assert_eq!(config.debug.spacing_bot, 333); + assert_eq!(config.debug.spacing_top, 444); + assert_eq!(config.setup.working_dir, String::from("working_dir_test")); + } + + #[test] + fn test_parse_args_with_stopper() { + let default_config = Config::default(); + let args_vec = vec![ + String::from("--behavior.file_action=file_action_test"), + String::from("--behavior.path_node_sort=path_node_sort_test"), + String::from("--behavior.scrolling=scrolling_test"), + String::from("--color.background=background_test"), + String::from("--color.foreground=foreground_test"), + String::from("--"), + String::from("--debug.enabled=true"), + String::from("--debug.padding_bot=111"), + String::from("--debug.padding_top=222"), + String::from("--debug.spacing_bot=333"), + String::from("--debug.spacing_top=444"), + String::from("--setup.working_dir=working_dir_test"), + ]; + + let config = Config::parse_args(default_config, args_vec); + let def_conf = Config::default(); + + assert_eq!( + config.behavior.file_action, + String::from("file_action_test") + ); + assert_eq!( + config.behavior.path_node_sort, + String::from("path_node_sort_test") + ); + assert_eq!(config.behavior.scrolling, String::from("scrolling_test")); + assert_eq!(config.color.background, String::from("background_test")); + assert_eq!(config.color.foreground, String::from("foreground_test")); + assert_eq!(config.debug.enabled, def_conf.debug.enabled); + assert_eq!(config.debug.padding_bot, def_conf.debug.padding_bot); + assert_eq!(config.debug.padding_top, def_conf.debug.padding_top); + assert_eq!(config.debug.spacing_bot, def_conf.debug.spacing_bot); + assert_eq!(config.debug.spacing_top, def_conf.debug.spacing_top); + assert_eq!(config.setup.working_dir, def_conf.setup.working_dir); + } + + #[test] + fn test_parse_args_with_multiple_equals() { + let default_config = Config::default(); + let args_vec = vec![String::from("--behavior.file_action=(x=1; y=2; echo $x$y)")]; + + let config = Config::parse_args(default_config, args_vec); + + assert_eq!( + config.behavior.file_action, + String::from("(x=1; y=2; echo $x$y)") + ); + } +} diff --git a/src/model/config/setup.rs b/src/model/config/setup.rs new file mode 100644 index 0000000..d12a150 --- /dev/null +++ b/src/model/config/setup.rs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize)] +pub struct Setup { + #[serde(default = "Setup::default_working_dir")] + pub working_dir: String, +} + +impl Default for Setup { + fn default() -> Self { + Setup { + working_dir: Self::default_working_dir(), + } + } +} + +impl Setup { + fn default_working_dir() -> String { + String::from(".") + } +} diff --git a/src/model/event.rs b/src/model/event.rs new file mode 100644 index 0000000..c26c281 --- /dev/null +++ b/src/model/event.rs @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +type TEvent = termion::event::Event; +type TKey = termion::event::Key; + +#[derive(Clone, Debug, PartialEq)] +pub struct Key { + inner: termion::event::Event, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Event { + Resize, + Key(Key), +} + +impl From for Key { + fn from(t_event: TEvent) -> Key { + Key { inner: t_event } + } +} + +impl From<&str> for Key { + fn from(s: &str) -> Key { + Key::from(convert_str_to_termion_event(s)) + } +} + +impl From for Key { + fn from(s: String) -> Key { + Key::from(convert_str_to_termion_event(&s)) + } +} + +fn convert_str_to_termion_event(s: &str) -> TEvent { + if s.chars().count() == 1 { + return TEvent::Key(TKey::Char(s.chars().last().unwrap())); + } + + if s.starts_with("alt+") && s.len() == 5 { + return TEvent::Key(TKey::Alt(s.chars().last().unwrap())); + } + + if s.starts_with("ctrl+") && s.len() == 6 { + return TEvent::Key(TKey::Ctrl(s.chars().last().unwrap())); + } + + match s { + // f keys + "f1" => TEvent::Key(TKey::F(1)), + "f2" => TEvent::Key(TKey::F(2)), + "f3" => TEvent::Key(TKey::F(3)), + "f4" => TEvent::Key(TKey::F(4)), + "f5" => TEvent::Key(TKey::F(5)), + "f6" => TEvent::Key(TKey::F(6)), + "f7" => TEvent::Key(TKey::F(7)), + "f8" => TEvent::Key(TKey::F(8)), + "f9" => TEvent::Key(TKey::F(9)), + "f10" => TEvent::Key(TKey::F(10)), + "f11" => TEvent::Key(TKey::F(11)), + "f12" => TEvent::Key(TKey::F(12)), + + // special keys + "backspace" => TEvent::Key(TKey::Backspace), + "left" => TEvent::Key(TKey::Left), + "right" => TEvent::Key(TKey::Right), + "up" => TEvent::Key(TKey::Up), + "down" => TEvent::Key(TKey::Down), + "home" => TEvent::Key(TKey::Home), + "end" => TEvent::Key(TKey::End), + "page_up" => TEvent::Key(TKey::PageUp), + "page_down" => TEvent::Key(TKey::PageDown), + "delete" => TEvent::Key(TKey::Delete), + "insert" => TEvent::Key(TKey::Insert), + "esc" => TEvent::Key(TKey::Esc), + "return" => TEvent::Key(TKey::Char('\n')), + "tab" => TEvent::Key(TKey::Char('\t')), + + // special key combinations + + // arrow keys + "ctrl+left" => TEvent::Unsupported(vec![27, 91, 49, 59, 53, 68]), + "ctrl+right" => TEvent::Unsupported(vec![27, 91, 49, 59, 53, 67]), + "ctrl+up" => TEvent::Unsupported(vec![27, 91, 49, 59, 53, 65]), + "ctrl+down" => TEvent::Unsupported(vec![27, 91, 49, 59, 53, 66]), + "shift+left" => TEvent::Unsupported(vec![27, 91, 49, 59, 50, 68]), + "shift+right" => TEvent::Unsupported(vec![27, 91, 49, 59, 50, 67]), + "shift+up" => TEvent::Unsupported(vec![27, 91, 49, 59, 50, 65]), + "shift+down" => TEvent::Unsupported(vec![27, 91, 49, 59, 50, 66]), + "alt+shift+left" => TEvent::Unsupported(vec![27, 91, 49, 59, 52, 68]), + "alt+shift+right" => TEvent::Unsupported(vec![27, 91, 49, 59, 52, 67]), + "alt+shift+up" => TEvent::Unsupported(vec![27, 91, 49, 59, 52, 65]), + "alt+shift+down" => TEvent::Unsupported(vec![27, 91, 49, 59, 52, 66]), + "shift+alt+left" => TEvent::Unsupported(vec![27, 91, 49, 59, 52, 68]), + "shift+alt+right" => TEvent::Unsupported(vec![27, 91, 49, 59, 52, 67]), + "shift+alt+up" => TEvent::Unsupported(vec![27, 91, 49, 59, 52, 65]), + "shift+alt+down" => TEvent::Unsupported(vec![27, 91, 49, 59, 52, 66]), + + // default + _ => TEvent::Unsupported(Vec::new()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_from_termion_event_test() { + assert_eq!( + Key::from(TEvent::Unsupported(vec![27, 91, 49, 59, 52, 66])), + Key { + inner: TEvent::Unsupported(vec![27, 91, 49, 59, 52, 66]) + } + ); + } + + #[test] + fn key_from_str_test() { + assert_eq!( + Key::from("shift+alt+down"), + Key { + inner: TEvent::Unsupported(vec![27, 91, 49, 59, 52, 66]) + } + ); + } + + #[test] + fn key_from_string_test() { + assert_eq!( + Key::from(String::from("shift+alt+up")), + Key { + inner: TEvent::Unsupported(vec![27, 91, 49, 59, 52, 65]) + } + ); + } + + mod convert_str_to_termion_event_tests { + use super::super::*; + #[test] + fn nonsense() { + assert_eq!( + TEvent::Unsupported(Vec::new()), + convert_str_to_termion_event("x1") + ); + assert_eq!( + TEvent::Unsupported(Vec::new()), + convert_str_to_termion_event("alt+x1") + ); + assert_eq!( + TEvent::Unsupported(Vec::new()), + convert_str_to_termion_event("ctrl+x1") + ); + } + + #[test] + fn single_digit() { + assert_eq!( + TEvent::Key(TKey::Char('x')), + convert_str_to_termion_event("x") + ); + } + #[test] + fn alt_digit() { + assert_eq!( + TEvent::Key(TKey::Alt('x')), + convert_str_to_termion_event("alt+x") + ); + } + #[test] + fn ctrl_digit() { + assert_eq!( + TEvent::Key(TKey::Ctrl('x')), + convert_str_to_termion_event("ctrl+x") + ); + } + #[test] + fn f_key() { + assert_eq!(TEvent::Key(TKey::F(5)), convert_str_to_termion_event("f5")); + } + #[test] + fn special_key() { + assert_eq!( + TEvent::Key(TKey::PageDown), + convert_str_to_termion_event("page_down") + ); + } + #[test] + fn special_key_comination() { + assert_eq!( + TEvent::Unsupported(vec![27, 91, 49, 59, 52, 65]), + convert_str_to_termion_event("alt+shift+up") + ); + } + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 0000000..73f3cce --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +pub mod compare_functions; +pub mod config; +pub mod event; +pub mod path_node; +pub mod tree_index; + +#[cfg(test)] +mod tests { + use crate::model::config::Config; + use crate::model::path_node::PathNode; + use crate::model::tree_index::TreeIndex; + use crate::view::composer::Composer; + + #[test] + fn test_integration_with_path_node_sort_dirs_top_simple() { + let mut config = Config::default(); + config.setup.working_dir = String::from("./tests/test_dirs"); + + let composer = Composer::from(config.clone()); + let mut path_node = PathNode::from(config.setup.working_dir); + let path_node_compare = PathNode::compare_dirs_top_simple; + assert_eq!(0, composer.compose_path_node(&path_node).len()); + + // expand_dir + path_node.expand_dir(&TreeIndex::from(Vec::new()), path_node_compare); + assert_eq!( + 13, + composer.compose_path_node(&path_node).len(), + "expanding the root directory" + ); + + path_node.expand_dir(&TreeIndex::from(vec![3]), path_node_compare); + assert_eq!( + 13, + composer.compose_path_node(&path_node).len(), + "expanding a file does nothing" + ); + + path_node.expand_dir(&TreeIndex::from(vec![1]), path_node_compare); + assert_eq!(17, composer.compose_path_node(&path_node).len()); + + path_node.expand_dir(&TreeIndex::from(vec![1, 0]), path_node_compare); + assert_eq!(23, composer.compose_path_node(&path_node).len()); + + path_node.expand_dir(&TreeIndex::from(vec![1, 0, 2]), path_node_compare); + assert_eq!(26, composer.compose_path_node(&path_node).len()); + + path_node.expand_dir(&TreeIndex::from(vec![1, 0, 2, 1]), path_node_compare); + assert_eq!(29, composer.compose_path_node(&path_node).len()); + + // tree_index_to_flat_index + let flat_index = TreeIndex::from(vec![7, 2, 4, 0, 0]).to_flat_index(); + assert_eq!(17, flat_index); + + // flat_index_to_tree_index + let tree_index = path_node.flat_index_to_tree_index(9); + assert_eq!(vec![1, 0, 2, 1, 1], tree_index.index); + + let tree_index = path_node.flat_index_to_tree_index(10); + assert_eq!(vec![1, 0, 2, 1, 2], tree_index.index); + + let tree_index = path_node.flat_index_to_tree_index(11); + assert_eq!(vec![1, 0, 2, 2], tree_index.index); + + let tree_index = path_node.flat_index_to_tree_index(15); + assert_eq!(vec![1, 1], tree_index.index); + + // collapse_dir + path_node.collapse_dir(&TreeIndex::from(vec![1, 0, 2, 1])); + assert_eq!( + 26, + composer.compose_path_node(&path_node).len(), + "reducing the last opened dir" + ); + + path_node.collapse_dir(&TreeIndex::from(vec![1, 0])); + assert_eq!( + 17, + composer.compose_path_node(&path_node).len(), + "reducing lots of sub dirs" + ); + } +} diff --git a/src/model/path_node/debug.rs b/src/model/path_node/debug.rs new file mode 100644 index 0000000..c81ed9d --- /dev/null +++ b/src/model/path_node/debug.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use crate::model::config::Config; +use crate::model::path_node::PathNode; +use crate::view::composer::Composer; +use std::fmt::Debug; +use std::fmt::Formatter; +use std::fmt::Result; + +impl Debug for PathNode { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let composer = Composer::from(Config::new()); + + let entries = composer.compose_path_node(self); + + for (index, entry) in entries.iter().enumerate() { + writeln!(f, "{:4}|{}", index, entry)?; + } + + Ok(()) + } +} diff --git a/src/model/path_node/mod.rs b/src/model/path_node/mod.rs new file mode 100644 index 0000000..5b7e701 --- /dev/null +++ b/src/model/path_node/mod.rs @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use crate::model::compare_functions::PathNodeCompare; +use crate::model::config::Config; +use crate::model::tree_index::TreeIndex; +use log::info; +use std::fs::canonicalize; +use std::path::PathBuf; + +mod debug; + +#[derive(Clone)] +pub struct PathNode { + pub children: Vec, + pub display_text: String, + pub is_dir: bool, + pub is_err: bool, + pub is_expanded: bool, + pub path: PathBuf, +} + +impl From<&str> for PathNode { + fn from(working_dir: &str) -> Self { + Self { + children: Vec::new(), + display_text: String::from(working_dir), + is_dir: true, + is_err: false, + is_expanded: false, + path: PathBuf::from(working_dir), + } + } +} + +impl From for PathNode { + fn from(working_dir: String) -> Self { + Self { + children: Vec::new(), + display_text: working_dir.clone(), + is_dir: true, + is_err: false, + is_expanded: false, + path: PathBuf::from(working_dir), + } + } +} + +impl PathNode { + pub fn new_expanded(config: Config) -> Self { + info!("initializing path node"); + + let mut path_node = Self::from(config.setup.working_dir.clone()); + let path_node_compare = Self::get_path_node_compare(&config); + path_node.expand_dir(&TreeIndex::new(), path_node_compare); + + path_node + } + + pub fn get_absolute_path(&self) -> String { + let canonicalized_path = canonicalize(self.path.as_path()).unwrap(); + canonicalized_path.to_str().unwrap().to_string() + } + + fn list_path_node_children(&mut self, compare: PathNodeCompare) -> Vec { + let dirs = self.path.read_dir(); + + if dirs.is_err() { + self.is_err = true; + return Vec::new(); + } + + let mut path_nodes = dirs + .unwrap() + .map(|dir_entry| { + let dir_entry = dir_entry.unwrap(); + + PathNode { + children: Vec::new(), + display_text: dir_entry.file_name().into_string().unwrap(), + is_dir: dir_entry.path().is_dir(), + is_err: false, + is_expanded: false, + path: dir_entry.path(), + } + }) + .collect::>(); + + path_nodes.sort_unstable_by(compare); + + path_nodes + } + + pub fn expand_dir(&mut self, tree_index: &TreeIndex, compare: PathNodeCompare) { + let mut path_node = self; + for i in &tree_index.index { + if path_node.children.len() > *i { + path_node = &mut path_node.children[*i]; + } + } + + if !path_node.path.is_dir() { + return; + } + + path_node.is_expanded = true; + path_node.children = path_node.list_path_node_children(compare); + } + + pub fn collapse_dir(&mut self, tree_index: &TreeIndex) { + let mut path_node = self; + for i in &tree_index.index { + path_node = &mut path_node.children[*i]; + } + + path_node.is_expanded = false; + path_node.children = Vec::new(); + } + + fn flat_index_to_tree_index_rec( + &self, + flat_index: &mut usize, + tree_index: &mut TreeIndex, + ) -> bool { + if *flat_index == 0 { + return true; + } + + for (c, child) in self.children.iter().enumerate() { + *flat_index -= 1; + + tree_index.index.push(c); + if child.flat_index_to_tree_index_rec(flat_index, tree_index) { + return true; + } + tree_index.index.pop(); + } + + false + } + + pub fn flat_index_to_tree_index(&self, flat_index: usize) -> TreeIndex { + let mut tree_index = TreeIndex::from(Vec::new()); + self.flat_index_to_tree_index_rec(&mut (flat_index + 1), &mut tree_index); + + tree_index + } + + pub fn tree_index_to_flat_index_rec( + &self, + target_tree_index: &TreeIndex, + current_tree_index: &TreeIndex, + ) -> usize { + if current_tree_index >= target_tree_index { + return 0; + } + + if self.children.is_empty() { + return 1; + } + + let mut sum = 1; + + for (index, child) in self.children.iter().enumerate() { + let mut new_current_tree_index = current_tree_index.clone(); + new_current_tree_index.index.push(index); + + sum += child.tree_index_to_flat_index_rec(target_tree_index, &new_current_tree_index); + } + + sum + } + + pub fn tree_index_to_flat_index(&self, tree_index: &TreeIndex) -> usize { + // We count the root directory, hence we have to subtract 1 to get the + // proper index. + self.tree_index_to_flat_index_rec(tree_index, &TreeIndex::new()) - 1 + } + + pub fn get_child_path_node(&self, tree_index: &TreeIndex) -> &Self { + let mut child_node = self; + for i in &tree_index.index { + child_node = &child_node.children[*i]; + } + + child_node + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn get_expanded_path_node() -> PathNode { + let mut path_node = PathNode::from("./tests/test_dirs"); + path_node.expand_dir(&TreeIndex::new(), PathNode::compare_dirs_top_simple); + path_node.expand_dir(&TreeIndex::from(vec![0]), PathNode::compare_dirs_top_simple); + path_node.expand_dir( + &TreeIndex::from(vec![0, 0]), + PathNode::compare_dirs_top_simple, + ); + path_node.expand_dir(&TreeIndex::from(vec![1]), PathNode::compare_dirs_top_simple); + path_node.expand_dir( + &TreeIndex::from(vec![1, 0]), + PathNode::compare_dirs_top_simple, + ); + path_node.expand_dir( + &TreeIndex::from(vec![1, 0, 2]), + PathNode::compare_dirs_top_simple, + ); + path_node + } + + mod get_child_path_node_tests { + use super::*; + + #[test] + fn first_dirs() { + let path_node = { + let mut path_node = PathNode::from("./tests/test_dirs"); + path_node.expand_dir(&TreeIndex::new(), PathNode::compare_dirs_top_simple); + path_node.expand_dir(&TreeIndex::from(vec![0]), PathNode::compare_dirs_top_simple); + path_node.expand_dir( + &TreeIndex::from(vec![0, 0]), + PathNode::compare_dirs_top_simple, + ); + path_node + }; + + let child_path_node = path_node.get_child_path_node(&TreeIndex::from(vec![0, 0, 0])); + + assert_eq!("file4", child_path_node.display_text); + } + + #[test] + fn complex_dirs() { + let path_node = get_expanded_path_node(); + + let child_path_node = path_node.get_child_path_node(&TreeIndex::from(vec![1, 0, 2, 2])); + + assert_eq!("file12", child_path_node.display_text); + } + } + + mod tree_index_to_flat_index_tests { + use super::*; + + #[test] + fn complex_dirs() { + let path_node = get_expanded_path_node(); + + let flat_index = path_node.tree_index_to_flat_index(&TreeIndex::from(vec![4])); + + assert_eq!(22, flat_index); + } + + #[test] + fn complex_dirs2() { + let path_node = get_expanded_path_node(); + + let flat_index = path_node.tree_index_to_flat_index(&TreeIndex::from(vec![5])); + + assert_eq!(23, flat_index); + } + + #[test] + fn complex_dirs3() { + let path_node = get_expanded_path_node(); + + let flat_index = path_node.tree_index_to_flat_index(&TreeIndex::from(vec![1, 0, 4])); + + assert_eq!(15, flat_index); + } + + #[test] + fn total_count() { + let path_node = get_expanded_path_node(); + + let flat_index = path_node.tree_index_to_flat_index(&TreeIndex::from(vec![100_000])); + + assert_eq!(31, flat_index); + } + + #[test] + fn zero() { + let path_node = get_expanded_path_node(); + + let flat_index = path_node.tree_index_to_flat_index(&TreeIndex::from(vec![0])); + + assert_eq!(0, flat_index); + } + } +} diff --git a/src/model/tree_index.rs b/src/model/tree_index.rs new file mode 100644 index 0000000..7249312 --- /dev/null +++ b/src/model/tree_index.rs @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +#[derive(Clone, Debug, PartialEq, PartialOrd)] +pub struct TreeIndex { + pub index: Vec, +} + +impl From> for TreeIndex { + fn from(index: Vec) -> Self { + Self { index } + } +} + +impl TreeIndex { + pub fn new() -> Self { + Self { index: vec![] } + } + + pub fn get_parent(&self) -> Self { + if self.index.is_empty() { + return Self { index: vec![] }; + } + + let mut index = self.index.clone(); + index.pop().unwrap(); + + Self { index } + } + + #[allow(dead_code)] // TODO: remove? + pub fn to_flat_index(&self) -> usize { + if self.index.is_empty() { + return 0; + } + + let mut flat_index = 0; + for i in &self.index { + flat_index += i + 1; + } + + flat_index - 1 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod get_parent_tests { + use super::*; + + #[test] + fn empty() { + let tree_index = TreeIndex::new(); + let parent = tree_index.get_parent(); + assert_eq!(TreeIndex::new(), parent); + } + + #[test] + fn minimal() { + let tree_index = TreeIndex::from(vec![0]); + let parent = tree_index.get_parent(); + assert_eq!(TreeIndex::new(), parent); + } + + #[test] + fn zeroes() { + let tree_index = TreeIndex::from(vec![0, 0, 0, 0, 0]); + let parent = tree_index.get_parent(); + assert_eq!(TreeIndex::from(vec![0, 0, 0, 0]), parent); + } + + #[test] + fn complex() { + let tree_index = TreeIndex::from(vec![3, 4, 6, 7, 1]); + let parent = tree_index.get_parent(); + assert_eq!(TreeIndex::from(vec![3, 4, 6, 7]), parent); + } + } + + mod to_flat_index_tests { + use super::*; + + #[test] + fn empty() { + let tree_index = TreeIndex::new(); + let flat_index = tree_index.to_flat_index(); + assert_eq!(0, flat_index); + } + + #[test] + fn minimal() { + let tree_index = TreeIndex::from(vec![0]); + let flat_index = tree_index.to_flat_index(); + assert_eq!(0, flat_index); + } + + #[test] + fn zeroes() { + let tree_index = TreeIndex::from(vec![0, 0, 0, 0, 0]); + let flat_index = tree_index.to_flat_index(); + assert_eq!(4, flat_index); + } + + #[test] + fn complex() { + let tree_index = TreeIndex::from(vec![3, 4, 6, 7, 1]); + let flat_index = tree_index.to_flat_index(); + assert_eq!(25, flat_index); + } + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..f9f0d8f --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use log::info; +use std::fs::File; +use std::io::Read; +use std::panic::set_hook; +use std::process::exit; + +pub fn read_file(file_name: &str) -> std::io::Result { + let mut file = File::open(file_name)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + Ok(contents) +} + +pub fn print_help() { + println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); + println!(r#"usage: twilight-tree [--key1=value1 --key2=value2 ...]"#); + exit(0); +} + +pub fn setup_logger() -> Result<(), fern::InitError> { + let log_file_path = format!("{}/{}", get_config_dir()?, "tc.log"); + + fern::Dispatch::new() + .format(|out, message, record| { + let target = record.target(); + let target_split_at = 0.max(target.len() as i32 - 20) as usize; + let target_short = target.split_at(target_split_at); + + out.finish(format_args!( + "[{}][{:05}][{:>20}] {}", + chrono::Local::now().to_rfc3339_opts(::chrono::SecondsFormat::Millis, true), + record.level(), + target_short.1, + message + )) + }) + .level(log::LevelFilter::Debug) + .chain(fern::log_file(log_file_path)?) + .apply()?; + + set_hook(Box::new(|panic_info| { + if let Some(p) = panic_info.payload().downcast_ref::() { + info!("{:?}, \npayload: {}", panic_info, p,); + } else if let Some(p) = panic_info.payload().downcast_ref::<&str>() { + info!("{:?}, \npayload: {}", panic_info, p,); + } else { + info!("{:?}", panic_info); + } + })); + + info!( + r#"starting... + +_|_ o|o _ |__|_ _ _ ._ _ ._ _ _.._ _| _ ._ + |_\/\/|||(_|| ||_ (_(_)| | || | |(_|| |(_|(/_| + _| + "# + ); + + info!("logger initialized"); + + Ok(()) +} + +pub fn get_config_dir() -> std::io::Result { + if let Ok(xdg_config_home) = std::env::var("XDG_CONFIG_HOME") { + Ok(xdg_config_home) + } else if let Ok(home) = std::env::var("HOME") { + Ok(format!("{}/.config/twilight-tree", home)) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "no HOME or XDG_CONFIG_HOME variable is defined", + )) + } +} diff --git a/src/view/composer.rs b/src/view/composer.rs new file mode 100644 index 0000000..134edcd --- /dev/null +++ b/src/view/composer.rs @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use crate::model::config::Config; +use crate::model::path_node::PathNode; +use log::info; + +pub struct Composer { + config: Config, +} + +impl From for Composer { + fn from(config: Config) -> Self { + info!("initializing composer"); + Self { config } + } +} + +impl Composer { + pub fn truncate_string(string: &str, desired_char_count: usize) -> String { + if desired_char_count < 1 { + return String::new(); + } + + if desired_char_count >= string.chars().count() { + return String::from(string); + } + + let truncated = match string.char_indices().nth(desired_char_count - 1) { + None => string, + Some((idx, _)) => &string[..idx], + }; + + format!("{}~", truncated) + } + + pub fn compose_path_node(&self, path_node: &PathNode) -> Vec { + let mut result = Vec::new(); + + self.compose_path_node_recursive(path_node, &mut result, 0); + + result + } + + fn compose_path_node_recursive( + &self, + path_node: &PathNode, + texts: &mut Vec, + depth: usize, + ) { + for child in &path_node.children { + let dir_prefix = self.get_dir_prefix(child); + let dir_suffix = self.get_dir_suffix(child); + let indent = self.get_indent(depth); + + let text = format!( + "{}{}{}{}", + indent, + dir_prefix, + child.display_text.clone(), + dir_suffix, + ); + texts.push(text); + self.compose_path_node_recursive(child, texts, depth + 1); + } + } + + fn get_dir_prefix(&self, path_node: &PathNode) -> String { + let (err_char, expanded_char, reduced_char) = if self.config.composition.use_utf8 { + ('⨯', '▼', '▶') + } else { + ('x', 'v', '>') + }; + + let expanded_indicator = if path_node.is_err { + err_char + } else if path_node.is_expanded { + expanded_char + } else { + reduced_char + }; + + if path_node.is_dir { + format!("{} ", expanded_indicator) + } else { + String::from(" ") + } + } + + fn get_dir_suffix(&self, path_node: &PathNode) -> String { + if path_node.is_dir { + String::from("/") + } else { + String::from("") + } + } + + fn get_indent(&self, depth: usize) -> String { + let indent_char = if !self.config.composition.show_indent { + ' ' + } else if self.config.composition.use_utf8 { + '·' + } else { + '-' + }; + let indent = " ".repeat(self.config.composition.indent as usize - 1); + + format!("{}{}", indent_char, indent).repeat(depth) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_string_test() { + let tc = Composer::truncate_string; + assert_eq!(tc("hello world", 5), "hell~"); + assert_eq!(tc("hello world", 1), "~"); + assert_eq!(tc("hello world", 0), ""); + assert_eq!(tc("aaa▶bbb▶ccc", 8), "aaa▶bbb~"); + assert_eq!(tc("aaa▶bbb▶ccc", 6), "aaa▶b~"); + assert_eq!(tc("aaa▶bbb▶ccc", 4), "aaa~"); + } +} diff --git a/src/view/mod.rs b/src/view/mod.rs new file mode 100644 index 0000000..f5cd69b --- /dev/null +++ b/src/view/mod.rs @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use crate::model::config::Config; +use crate::view::composer::Composer; +use log::info; +use std::io::Write; + +pub mod composer; +mod print; +mod scroll; +mod update; + +pub struct Pager { + config: Config, + pub cursor_row: i32, + out: W, + terminal_cols: i32, + terminal_rows: i32, + text_row: i32, +} + +impl Pager { + pub fn new(config: Config, mut out: W) -> Self { + info!("initializing pager"); + + write!( + out, + "{}{}{}", + termion::cursor::Hide, + termion::cursor::Goto(1, 1), + termion::clear::All, + ) + .unwrap(); + + Self { + config, + cursor_row: 0, + out, + terminal_cols: 0, + terminal_rows: 0, + text_row: 0, + } + } +} + +impl Drop for Pager { + fn drop(&mut self) { + write!( + self, + "{}{}{}", + termion::clear::All, + termion::cursor::Goto(1, 1), + termion::cursor::Show, + ) + .unwrap(); + } +} + +impl Write for Pager { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.out.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.out.flush() + } +} diff --git a/src/view/print.rs b/src/view/print.rs new file mode 100644 index 0000000..c6ccf71 --- /dev/null +++ b/src/view/print.rs @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman +// Copyright (c) 2024 Awiteb + +use crate::view::Composer; +use crate::view::Pager; +use std::io::Write; +use termion::{color, style}; + +impl Pager { + pub fn print_clear(&mut self) { + write!(self, "{}", termion::clear::All).unwrap(); + } + + pub fn print_text_entry(&mut self, text_entry: &str, row: i32) { + write!( + self, + "{}{}{}", + termion::cursor::Goto(1, row as u16), + Composer::truncate_string(text_entry, self.terminal_cols as usize), + style::Reset + ) + .unwrap(); + } + + pub fn print_text_entry_emphasized(&mut self, text_entry: &str, row: i32) { + write!( + self, + "{}{}{}{}", + termion::cursor::Goto(1, row as u16), + color::Bg(color::Blue), + Composer::truncate_string(text_entry, self.terminal_cols as usize), + style::Reset + ) + .unwrap(); + } + + pub fn print_header(&mut self, text: &str) { + write!( + self, + "{}{}", + termion::cursor::Goto(1, 1), + Composer::truncate_string(text, self.terminal_cols as usize), + ) + .unwrap(); + } + + pub fn print_footer(&mut self, text: &str) { + write!( + self, + "{}{}", + termion::cursor::Goto(1, 1 + self.terminal_rows as u16), + Composer::truncate_string(text, self.terminal_cols as usize), + ) + .unwrap(); + } + + pub fn print_debug_info(&mut self) { + if !self.config.debug.enabled { + return; + } + + let padding_bot = self.config.debug.padding_bot; + let padding_top = self.config.debug.padding_top; + let spacing_bot = self.config.debug.spacing_bot; + let spacing_top = self.config.debug.spacing_top; + + // line numbers + for i in 0..self.terminal_rows { + write!(self, "{} L{i}", termion::cursor::Goto(50, 1 + i as u16)).unwrap(); + } + + // padding_top debug + for i in 0..padding_bot { + write!( + self, + "{}~~~ padding_bot", + termion::cursor::Goto(30, (self.terminal_rows - (spacing_bot + i)) as u16) + ) + .unwrap(); + } + + for i in 0..padding_top { + write!( + self, + "{}~~~ padding_top", + termion::cursor::Goto(30, (1 + spacing_top + i) as u16) + ) + .unwrap(); + } + + // spacing_top debug + for i in 0..spacing_bot { + write!( + self, + "{}--- spacing_bot", + termion::cursor::Goto(30, (self.terminal_rows - i) as u16) + ) + .unwrap(); + } + for i in 0..spacing_top { + write!( + self, + "{}--- spacing_top", + termion::cursor::Goto(30, 1 + i as u16) + ) + .unwrap(); + } + + // debug info + let terminal_rows = self.terminal_rows; + let terminal_cols = self.terminal_cols; + let cursor_row = self.cursor_row; + let text_row = self.text_row; + + write!( + self, + "{}rows: {}, cols: {}", + termion::cursor::Goto(1, (self.terminal_rows - 1) as u16), + terminal_rows, + terminal_cols + ) + .unwrap(); + write!( + self, + "{}cursor_row: {}, text_row: {}", + termion::cursor::Goto(1, self.terminal_rows as u16), + cursor_row, + text_row + ) + .unwrap(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::config::Config; + + fn prepare_pager() -> Pager> { + let mut config = Config::default(); + config.debug.enabled = true; + config.debug.padding_bot = 1; + config.debug.padding_top = 1; + config.debug.spacing_bot = 1; + config.debug.spacing_top = 1; + + let out: Vec = Vec::new(); + let mut pager = Pager::new(config, out); + + pager.terminal_cols = 100; + pager.terminal_rows = 10; + + pager + } + + fn get_result(pager: Pager>) -> Option { + let pager_out = pager.out.clone(); + Some(String::from(std::str::from_utf8(&pager_out).unwrap())) + } + + #[test] + fn print_clear_test() { + let result = { + let mut pager = prepare_pager(); + pager.print_clear(); + get_result(pager) + }; + + assert_eq!("\u{1b}[?25l\u{1b}[1;1H\u{1b}[2J\u{1b}[2J", result.unwrap()); + } + + #[test] + fn print_text_entry_test() { + let result = { + let mut pager = prepare_pager(); + pager.print_text_entry("--- test 123 ---", 42); + get_result(pager) + }; + + assert_eq!( + "\u{1b}[?25l\u{1b}[1;1H\u{1b}[2J\u{1b}[42;1H--- test 123 ---\u{1b}[m", + result.unwrap(), + ); + } + + #[test] + fn print_text_entry_emphasized_test() { + let result = { + let mut pager = prepare_pager(); + pager.print_text_entry_emphasized("--- test 123 ---", 42); + get_result(pager) + }; + + assert_eq!( + "\u{1b}[?25l\u{1b}[1;1H\u{1b}[2J\u{1b}[42;1H\u{1b}[48;5;4m--- test 123 ---\u{1b}[m", + result.unwrap(), + ); + } + + #[test] + fn print_header_test() { + let result = { + let mut pager = prepare_pager(); + pager.print_header("--- test 123 ---"); + get_result(pager) + }; + + assert_eq!( + "\u{1b}[?25l\u{1b}[1;1H\u{1b}[2J\u{1b}[1;1H--- test 123 ---", + result.unwrap(), + ); + } + + #[test] + fn print_footer_test() { + let result = { + let mut pager = prepare_pager(); + pager.print_footer("--- test 123 ---"); + get_result(pager) + }; + + assert_eq!( + "\u{1b}[?25l\u{1b}[1;1H\u{1b}[2J\u{1b}[11;1H--- test 123 ---", + result.unwrap(), + ); + } + + #[test] + fn print_debug_info_test() { + let result = { + let mut pager = prepare_pager(); + pager.print_debug_info(); + get_result(pager) + } + .unwrap(); + + assert!(result.contains("~~~ padding_bot")); + assert!(result.contains("~~~ padding_top")); + assert!(result.contains("--- spacing_bot")); + assert!(result.contains("--- spacing_top")); + assert!(result.contains("cols: 100")); + assert!(result.contains("rows: 10")); + assert!(result.contains("cursor_row: 0")); + assert!(result.contains("text_row: 0")); + } +} diff --git a/src/view/scroll.rs b/src/view/scroll.rs new file mode 100644 index 0000000..3bf1382 --- /dev/null +++ b/src/view/scroll.rs @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use crate::view::Pager; +use std::io::Write; + +impl Pager { + fn get_index_overshoot(index_under_test: i32, index_now: i32, index_delta: i32) -> Option { + let index_before = index_now - index_delta; + + // cross from below + if index_before <= index_under_test && index_now >= index_under_test { + return Some(index_now - index_under_test); + } + + // cross from above + if index_before >= index_under_test && index_now <= index_under_test { + return Some(index_now - index_under_test); + } + + None + } + + pub fn scroll_like_center(&self, cursor_row_delta: i32, text_entries_len: i32) -> i32 { + let spacing_bot = self.config.debug.spacing_bot; + let spacing_top = self.config.debug.spacing_top; + + let center_text_row = + spacing_top - self.text_row + (self.terminal_rows - (spacing_bot + spacing_top)) / 2; + let last_text_row = self.terminal_rows - (self.text_row + spacing_bot); + + // re-center a cursor row that is below the center (last text entry was + // visible) in the case that a subdirectory is opened + // in such a way that the bottom is not visible anymore + if cursor_row_delta == 0 + && self.cursor_row - center_text_row > 0 + && self.cursor_row - center_text_row <= text_entries_len - last_text_row + { + return self.text_row - (self.cursor_row - center_text_row); + } + + // cursor row is moved over the center + if let Some(overshoot) = + Self::get_index_overshoot(center_text_row, self.cursor_row, cursor_row_delta) + { + // no need to keep it centered when we reach the top or bottom + if self.text_row >= spacing_top && cursor_row_delta < 0 { + return self.text_row; + } + if self.text_row + text_entries_len <= self.terminal_rows - spacing_bot + && cursor_row_delta > 0 + { + return self.text_row; + } + + // Prevent violating the spacing when centering on the cursor row. + // E.g. when jumping to the parent directory and it is the topmost + // entry do not want it centered. + if self.text_row - overshoot > spacing_top { + return spacing_top; + } + + // keep it centered + return self.text_row - overshoot; + } + + // cursor row is beyond vision -> move the text row the minimal amount + // to correct that + if self.text_row + self.cursor_row < spacing_top { + return spacing_top - self.cursor_row; + } else if self.text_row + self.cursor_row > self.terminal_rows - (1 + spacing_bot) { + return self.terminal_rows - (1 + spacing_bot + self.cursor_row); + } + + self.text_row + } + + pub fn scroll_like_editor(&self) -> i32 { + let padding_bot = self.config.debug.padding_bot; + let padding_top = self.config.debug.padding_top; + let spacing_bot = self.config.debug.spacing_bot; + let spacing_top = self.config.debug.spacing_top; + + if self.text_row + self.cursor_row < spacing_top + padding_top { + return spacing_top + padding_top - self.cursor_row; + } else if self.text_row + self.cursor_row + > self.terminal_rows - (1 + spacing_bot + padding_bot) + { + return self.terminal_rows - (1 + spacing_bot + padding_bot + self.cursor_row); + } + + self.text_row + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::config::Config; + + fn prepare_pager() -> Pager> { + let mut config = Config::default(); + config.debug.enabled = true; + config.debug.padding_bot = 1; + config.debug.padding_top = 1; + config.debug.spacing_bot = 1; + config.debug.spacing_top = 1; + + let out: Vec = Vec::new(); + let mut pager = Pager::new(config, out); + + pager.terminal_cols = 100; + pager.terminal_rows = 10; + + pager + } + + mod get_index_overshoot_tests { + use super::*; + + #[test] + fn overshoot_from_below() { + let overshoot = Pager::>::get_index_overshoot(10, 11, 3); + assert_eq!(Some(1), overshoot); + } + + #[test] + fn overshoot_from_above() { + let overshoot = Pager::>::get_index_overshoot(10, 7, -4); + assert_eq!(Some(-3), overshoot); + } + + #[test] + fn no_overshoot_from_below() { + let overshoot = Pager::>::get_index_overshoot(10, 7, 2); + assert_eq!(None, overshoot); + } + + #[test] + fn no_overshoot_from_above() { + let overshoot = Pager::>::get_index_overshoot(10, 14, -3); + assert_eq!(None, overshoot); + } + } + + mod scroll_like_center_tests { + use super::*; + + #[test] + fn scroll_like_center_cursor_top_test() { + let text_row = { + let pager = prepare_pager(); + pager.scroll_like_center(1, 17) + }; + + assert_eq!(1, text_row); + } + + #[test] + fn scroll_like_center_text_moves_up1_test() { + let text_row = { + let mut pager = prepare_pager(); + pager.cursor_row = 5; + pager.scroll_like_center(1, 17) + }; + + assert_eq!(0, text_row); + } + + #[test] + fn scroll_like_center_text_moves_up2_test() { + let text_row = { + let mut pager = prepare_pager(); + pager.cursor_row = 6; + pager.scroll_like_center(1, 17) + }; + + assert_eq!(-1, text_row); + } + + #[test] + fn scroll_like_center_text_moves_down_test() { + let text_row = { + let mut pager = prepare_pager(); + pager.cursor_row = 6; + pager.scroll_like_center(-1, 17) + }; + + assert_eq!(0, text_row); + } + + #[test] + fn scroll_like_center_cursor_bot_test() { + let text_row = { + let mut pager = prepare_pager(); + pager.cursor_row = 9; + pager.scroll_like_center(-1, 17) + }; + + assert_eq!(-1, text_row); + } + + #[test] + fn scroll_like_center_cursor_bot_no_delta_test() { + let text_row = { + let mut pager = prepare_pager(); + pager.cursor_row = 9; + pager.scroll_like_center(0, 17) + }; + + assert_eq!(-4, text_row); + } + + #[test] + fn scroll_like_center_cursor_with_overshoot() { + let text_row = { + let mut pager = prepare_pager(); + println!("{}", pager.text_row); + pager.cursor_row = 13; + pager.text_row = -10; + pager.scroll_like_center(-3, 123) + }; + + assert_eq!(-8, text_row); + } + + #[test] + fn scroll_like_center_cursor_top_most_overshoot() { + let text_row = { + let mut pager = prepare_pager(); + println!("{}", pager.text_row); + pager.cursor_row = 0; + pager.text_row = -10; + pager.scroll_like_center(-16, 123) + }; + + assert_eq!(1, text_row); + } + } + + mod scroll_like_editor_tests { + use super::*; + + #[test] + fn scroll_like_editor_cursor_top_test() { + let text_row = { + let pager = prepare_pager(); + pager.scroll_like_editor() + }; + + assert_eq!(2, text_row); + } + + #[test] + fn scroll_like_editor_text_moves_up1_test() { + let text_row = { + let mut pager = prepare_pager(); + pager.cursor_row = 5; + pager.scroll_like_editor() + }; + + assert_eq!(0, text_row); + } + + #[test] + fn scroll_like_editor_text_moves_up2_test() { + let text_row = { + let mut pager = prepare_pager(); + pager.cursor_row = 6; + pager.scroll_like_editor() + }; + + assert_eq!(0, text_row); + } + + #[test] + fn scroll_like_editor_text_moves_down_test() { + let text_row = { + let mut pager = prepare_pager(); + pager.cursor_row = 6; + pager.scroll_like_editor() + }; + + assert_eq!(0, text_row); + } + + #[test] + fn scroll_like_editor_cursor_bot_test() { + let text_row = { + let mut pager = prepare_pager(); + pager.cursor_row = 9; + pager.scroll_like_editor() + }; + + assert_eq!(-2, text_row); + } + + #[test] + fn scroll_like_editor_cursor_bot_no_delta_test() { + let text_row = { + let mut pager = prepare_pager(); + pager.cursor_row = 9; + pager.scroll_like_editor() + }; + + assert_eq!(-2, text_row); + } + } +} diff --git a/src/view/update.rs b/src/view/update.rs new file mode 100644 index 0000000..c5718f9 --- /dev/null +++ b/src/view/update.rs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2019-2022 golmman + +use crate::view::Pager; +use std::io::Write; +use termion::terminal_size; + +impl Pager { + fn update_terminal_size(&mut self) { + let (terminal_cols_raw, terminal_rows_raw) = terminal_size().unwrap(); + self.terminal_cols = i32::from(terminal_cols_raw); + self.terminal_rows = i32::from(terminal_rows_raw); + } + + fn update_cursor_row(&mut self, cursor_row_delta: i32, text_entries_len: i32) { + self.cursor_row += cursor_row_delta; + if self.cursor_row < 0 { + self.cursor_row = text_entries_len - 1; + } + if self.cursor_row >= text_entries_len { + self.cursor_row = 0; + } + } + + pub fn update(&mut self, cursor_row_delta: i32, text_entries: &[String], header_text: String) { + self.update_terminal_size(); + + let spacing_bot = self.config.debug.spacing_bot; + let spacing_top = self.config.debug.spacing_top; + + let text_entries_len = text_entries.len() as i32; + + self.update_cursor_row(cursor_row_delta, text_entries_len); + + self.text_row = match self.config.behavior.scrolling.as_str() { + "center" => self.scroll_like_center(cursor_row_delta, text_entries_len), + "editor" => self.scroll_like_editor(), + _ => 0, + }; + + let displayable_rows = self.terminal_rows - (spacing_bot + spacing_top); + + let first_index = spacing_top - self.text_row; + + // clear screen + self.print_clear(); + + // print rows + for i in 0..displayable_rows { + let index = first_index + i; + + if index >= 0 && index < text_entries.len() as i32 { + let text_entry = &text_entries[index as usize]; + + if index == self.cursor_row { + self.print_text_entry_emphasized(text_entry, 1 + spacing_top + i) + } else { + self.print_text_entry(text_entry, 1 + spacing_top + i); + } + } + } + + let footer_text = format!("[{}/{}]", self.cursor_row + 1, text_entries_len); + + self.print_header(&header_text); + self.print_footer(&footer_text); + + self.print_debug_info(); + + self.flush().unwrap(); + } +} diff --git a/tests/test_dirs/dir0/dir3/file4 b/tests/test_dirs/dir0/dir3/file4 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir0/dir3/file5 b/tests/test_dirs/dir0/dir3/file5 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir0/dir4/file16 b/tests/test_dirs/dir0/dir4/file16 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir0/dir5/file6 b/tests/test_dirs/dir0/dir5/file6 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir1/dir6/dir10/file28 b/tests/test_dirs/dir1/dir6/dir10/file28 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir1/dir6/dir8/file17 b/tests/test_dirs/dir1/dir6/dir8/file17 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir1/dir6/dir9/dir11/file18 b/tests/test_dirs/dir1/dir6/dir9/dir11/file18 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir1/dir6/dir9/dir12/file13 b/tests/test_dirs/dir1/dir6/dir9/dir12/file13 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir1/dir6/dir9/dir12/file14 b/tests/test_dirs/dir1/dir6/dir9/dir12/file14 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir1/dir6/dir9/dir12/file15 b/tests/test_dirs/dir1/dir6/dir9/dir12/file15 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir1/dir6/dir9/file12 b/tests/test_dirs/dir1/dir6/dir9/file12 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir1/dir6/file10 b/tests/test_dirs/dir1/dir6/file10 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir1/dir6/file11 b/tests/test_dirs/dir1/dir6/file11 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir1/dir6/file9 b/tests/test_dirs/dir1/dir6/file9 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir1/dir7/file19 b/tests/test_dirs/dir1/dir7/file19 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir1/file7 b/tests/test_dirs/dir1/file7 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir1/file8 b/tests/test_dirs/dir1/file8 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/dir2/file20 b/tests/test_dirs/dir2/file20 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/file0 b/tests/test_dirs/file0 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/file1 b/tests/test_dirs/file1 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/file2 b/tests/test_dirs/file2 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/file21 b/tests/test_dirs/file21 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/file22 b/tests/test_dirs/file22 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/file23 b/tests/test_dirs/file23 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/file24 b/tests/test_dirs/file24 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/file25 b/tests/test_dirs/file25 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/file26 b/tests/test_dirs/file26 new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_dirs/file27 b/tests/test_dirs/file27 new file mode 100644 index 0000000..e69de29 diff --git a/twilight-tree.toml b/twilight-tree.toml new file mode 100644 index 0000000..db913f6 --- /dev/null +++ b/twilight-tree.toml @@ -0,0 +1,56 @@ +[behavior] +# command interpreted by bash when pressing the file_action key +file_action = "true" + +# when true the program will quit after first action +quit_on_action = false + +# determines the compare function used for sorting entries +# enum: none, dirs_top_simple, dirs_bot_simple +# TODO: rename to entry_sort +path_node_sort = "dirs_top_simple" + +# the scrollung algorithm used +# enum: center, editor +scrolling = "center" + +# the amount of entries skipped when the skip keys are pressed +skip_amount = 5 + + +[composition] +# indention used for subentries +indent = 2 + +# when true shows visual markers for indention whitespaces +show_indent = false + +# when true uses utf8 characters +use_utf8 = true + +[debug] +# enables debug mode +enabled = false + +# the minimum distance of the highlighted entry to the spacing +padding_bot = 3 +padding_top = 3 + +# the number of lines not uses for entries +spacing_bot = 2 +spacing_top = 2 + +[keybinding] +collapse_dir = "left" +entry_down = "down" +entry_up = "up" +expand_dir = "right" +file_action = "return" +quit = "q" +reload = "r" +skip_up = "ctrl+up" +skip_down = "ctrl+down" + +[setup] +# the working directory used when starting +working_dir = "."