first commit

This commit is contained in:
sxyazi 2023-07-08 19:46:43 +08:00
commit 52aa00ed23
No known key found for this signature in database
69 changed files with 6979 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

2396
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

38
Cargo.toml Normal file
View File

@ -0,0 +1,38 @@
[package]
name = "ranger-rs"
version = "0.1.0"
edition = "2021"
[dependencies]
ansi-to-tui = "3.0.0"
anyhow = "1.0.71"
async-channel = "1.8.0"
base64 = "0.21.2"
console-subscriber = "0.1.9"
crossterm = { version = "0.26.1", features = [ "event-stream" ] }
futures = "0.3.28"
glob = "0.3.1"
image = "0.24.6"
indexmap = "2.0.0"
libc = "0.2.146"
md5 = "0.7.0"
notify = { version = "6.0.1", default-features = false, features = [ "macos_fsevent" ] }
once_cell = "1.18.0"
parking_lot = "0.12.1"
ratatui = "0.21.0"
serde = { version = "1.0.164", features = [ "derive" ] }
signal-hook-tokio = { version = "0.3.1", features = [ "futures-v0_3" ] }
syntect = "5.0.0"
tokio = { version = "1.28.2", features = [ "parking_lot", "macros", "rt-multi-thread", "sync", "fs", "process", "io-std", "io-util", "time" ] }
toml = "0.7.4"
tracing = "0.1.37"
tracing-appender = "0.2.2"
tracing-subscriber = "0.3.17"
trash = "3.0.3"
unicode-width = "0.1.10"
xdg = "2.5.0"
# [profile.release]
# strip = true
# lto = true
# panic = "abort"

21
LICENCE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 - sxyazi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

40
README.md Normal file
View File

@ -0,0 +1,40 @@
## Yazi - ⚡️ Blazing Fast Terminal File Manager
Yazi ("duck" in Chinese) is a terminal file manager written in Rust, based on non-blocking async I/O. It aims to provide an efficient, user-friendly, and configurable file management experience.
⚠️ Note: Yazi is currently in active development and may be unstable. The API is subject to change without prior notice. Please use it with caution in a non-production environment.
## Installation
Before getting started, ensure that the following dependencies are installed on your system:
- jq (optional, for JSON preview)
- ffmpegthumbnailer (optional, for video thumbnails)
- fzf (optional, for fuzzy search)
- rg (optional, for fuzzy search)
- zoxide (optional, for directory jumping)
Execute the following commands to clone the project and build Yazi:
```bash
git clone https://github.com/sxyazi/yazi.git
cd yazi
cargo build --release
```
## Usage
```bash
./target/release/yazi
```
## TODO
- Integration with zoxide for fast directory navigation
- Integration with fzf, rg for fuzzy file searching
- Support for Überzug++ for image previews with X11/wayland environment
- Batch renaming support
## License
Yazi is MIT licensed.

66
src/config/keymap/exec.rs Normal file
View File

@ -0,0 +1,66 @@
use std::{collections::BTreeMap, fmt};
use serde::{de::Visitor, Deserializer};
#[derive(Debug, Default)]
pub struct Exec {
pub cmd: String,
pub args: Vec<String>,
pub named: BTreeMap<String, String>,
}
impl From<&str> for Exec {
fn from(value: &str) -> Self {
let mut exec = Self::default();
for x in value.split_whitespace() {
if x.starts_with("--") {
let mut it = x[2..].splitn(2, '=');
let name = it.next().unwrap();
let value = it.next().unwrap_or("");
exec.named.insert(name.to_string(), value.to_string());
} else if exec.cmd.is_empty() {
exec.cmd = x.to_string();
} else {
exec.args.push(x.to_string());
}
}
exec
}
}
impl Exec {
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Exec>, D::Error>
where
D: Deserializer<'de>,
{
struct ExecVisitor;
impl<'de> Visitor<'de> for ExecVisitor {
type Value = Vec<Exec>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a command string, e.g. tab_switch 0")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut execs = Vec::new();
while let Some(value) = &seq.next_element::<String>()? {
execs.push(Exec::from(value.as_str()));
}
Ok(execs)
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(value.split(';').map(Exec::from).collect())
}
}
deserializer.deserialize_any(ExecVisitor)
}
}

112
src/config/keymap/key.rs Normal file
View File

@ -0,0 +1,112 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::{de::Visitor, Deserialize, Deserializer};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Key {
pub code: KeyCode,
pub shift: bool,
pub ctrl: bool,
pub alt: bool,
}
impl Default for Key {
fn default() -> Self { Self { code: KeyCode::Null, shift: false, ctrl: false, alt: false } }
}
impl From<&str> for Key {
fn from(value: &str) -> Self {
let mut key = Default::default();
if value.is_empty() {
return key;
}
if !value.starts_with("<") || !value.ends_with(">") {
let c = value.chars().next().unwrap();
key.code = KeyCode::Char(c);
key.shift = c.is_ascii_uppercase();
return key;
}
let mut it = value[1..value.len() - 1].split_inclusive('-').peekable();
while let Some(x) = it.next() {
match x {
"S-" => key.shift = true,
"C-" => key.ctrl = true,
"A-" => key.alt = true,
"Space" => key.code = KeyCode::Char(' '),
"Backspace" => key.code = KeyCode::Backspace,
"Enter" => key.code = KeyCode::Enter,
"Left" => key.code = KeyCode::Left,
"Right" => key.code = KeyCode::Right,
"Up" => key.code = KeyCode::Up,
"Down" => key.code = KeyCode::Down,
"Home" => key.code = KeyCode::Home,
"End" => key.code = KeyCode::End,
"PageUp" => key.code = KeyCode::PageUp,
"PageDown" => key.code = KeyCode::PageDown,
"Tab" => key.code = KeyCode::Tab,
"Delete" => key.code = KeyCode::Delete,
"Insert" => key.code = KeyCode::Insert,
"F1" => key.code = KeyCode::F(1),
"F2" => key.code = KeyCode::F(2),
"F3" => key.code = KeyCode::F(3),
"F4" => key.code = KeyCode::F(4),
"F5" => key.code = KeyCode::F(5),
"F6" => key.code = KeyCode::F(6),
"F7" => key.code = KeyCode::F(7),
"F8" => key.code = KeyCode::F(8),
"F9" => key.code = KeyCode::F(9),
"F10" => key.code = KeyCode::F(10),
"F11" => key.code = KeyCode::F(11),
"F12" => key.code = KeyCode::F(12),
"Esc" => key.code = KeyCode::Esc,
c if it.peek().is_none() => {
key.code = KeyCode::Char(c.chars().next().unwrap());
}
_ => {}
}
}
key
}
}
impl From<KeyEvent> for Key {
fn from(value: KeyEvent) -> Self {
let shift = if let KeyCode::Char(c) = value.code { c.is_ascii_uppercase() } else { false };
Self {
code: value.code,
shift: shift || value.modifiers.contains(KeyModifiers::SHIFT),
ctrl: value.modifiers.contains(KeyModifiers::CONTROL),
alt: value.modifiers.contains(KeyModifiers::ALT),
}
}
}
impl<'de> Deserialize<'de> for Key {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct KeyVisitor;
impl<'de> Visitor<'de> for KeyVisitor {
type Value = Key;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a key string, e.g. <C-a>")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(Key::from(value))
}
}
deserializer.deserialize_str(KeyVisitor)
}
}

View File

@ -0,0 +1,41 @@
use std::fs;
use serde::Deserialize;
use xdg::BaseDirectories;
use super::{Exec, Key};
#[derive(Deserialize, Debug)]
pub struct Single {
pub on: Vec<Key>,
#[serde(deserialize_with = "Exec::deserialize")]
pub exec: Vec<Exec>,
}
#[derive(Deserialize, Debug)]
pub struct Keymap {
pub manager: Vec<Single>,
pub tasks: Vec<Single>,
pub input: Vec<Single>,
}
impl Keymap {
pub fn new() -> Self {
#[derive(Deserialize)]
struct Inner {
keymap: Vec<Single>,
}
#[derive(Deserialize)]
struct All {
manager: Inner,
tasks: Inner,
input: Inner,
}
let path = BaseDirectories::new().unwrap().get_config_file("yazi/keymap.toml");
let all: All = toml::from_str(&fs::read_to_string(path).unwrap()).unwrap();
Self { manager: all.manager.keymap, tasks: all.tasks.keymap, input: all.input.keymap }
}
}

7
src/config/keymap/mod.rs Normal file
View File

@ -0,0 +1,7 @@
mod exec;
mod key;
mod keymap;
pub use exec::*;
pub use key::*;
pub use keymap::*;

View File

@ -0,0 +1,41 @@
use std::{env, fs, path::PathBuf};
use serde::Deserialize;
use xdg::BaseDirectories;
use super::SortBy;
#[derive(Deserialize, Debug)]
pub struct Manager {
#[serde(skip)]
pub cwd: PathBuf,
#[serde(skip)]
pub cache: PathBuf,
// Sorting
pub sort_by: SortBy,
pub sort_reverse: bool,
// Display
pub show_hidden: bool,
}
impl Manager {
pub fn new() -> Self {
#[derive(Deserialize)]
struct Outer {
manager: Manager,
}
let path = BaseDirectories::new().unwrap().get_config_file("yazi/yazi.toml");
let mut manager = toml::from_str::<Outer>(&fs::read_to_string(path).unwrap()).unwrap().manager;
manager.cwd = env::current_dir().unwrap_or("/".into());
manager.cache = "/tmp/yazi".into();
if !manager.cache.is_dir() {
fs::create_dir(&manager.cache).unwrap();
}
manager
}
}

View File

@ -0,0 +1,5 @@
mod manager;
mod sorting;
pub use manager::*;
pub use sorting::*;

View File

@ -0,0 +1,46 @@
use serde::{de::Visitor, Deserialize, Deserializer};
#[derive(Debug, Clone, Copy)]
pub enum SortBy {
Alphabetical,
Created,
Modified,
Size,
}
impl From<&str> for SortBy {
fn from(value: &str) -> Self {
match value {
"created" => Self::Created,
"modified" => Self::Modified,
"size" => Self::Size,
_ => Self::Alphabetical,
}
}
}
impl<'de> Deserialize<'de> for SortBy {
fn deserialize<D>(deserializer: D) -> Result<SortBy, D::Error>
where
D: Deserializer<'de>,
{
struct SortByVisitor;
impl<'de> Visitor<'de> for SortByVisitor {
type Value = SortBy;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a sort_by string, e.g. modified")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(SortBy::from(value))
}
}
deserializer.deserialize_str(SortByVisitor)
}
}

24
src/config/mod.rs Normal file
View File

@ -0,0 +1,24 @@
use once_cell::sync::Lazy;
pub mod keymap;
pub mod manager;
pub mod open;
mod pattern;
pub mod preview;
pub mod theme;
pub use pattern::*;
pub static KEYMAP: Lazy<keymap::Keymap> = Lazy::new(|| keymap::Keymap::new());
pub static MANAGER: Lazy<manager::Manager> = Lazy::new(|| manager::Manager::new());
pub static OPEN: Lazy<open::Open> = Lazy::new(|| open::Open::new());
pub static PREVIEW: Lazy<preview::Preview> = Lazy::new(|| preview::Preview::new());
pub static THEME: Lazy<theme::Theme> = Lazy::new(|| theme::Theme::new());
pub fn init() {
Lazy::force(&KEYMAP);
Lazy::force(&MANAGER);
Lazy::force(&OPEN);
Lazy::force(&PREVIEW);
Lazy::force(&THEME);
}

3
src/config/open/mod.rs Normal file
View File

@ -0,0 +1,3 @@
mod open;
pub use open::*;

65
src/config/open/open.rs Normal file
View File

@ -0,0 +1,65 @@
use std::{collections::BTreeMap, fs, path::Path};
use serde::Deserialize;
use xdg::BaseDirectories;
use crate::config::Pattern;
#[derive(Clone, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Opener {
pub cmd: String,
pub args: Vec<String>,
#[serde(default)]
pub block: bool,
#[serde(skip)]
pub spread: bool,
}
#[derive(Deserialize, Debug)]
pub struct Open {
#[serde(skip)]
openers: BTreeMap<String, Vec<Opener>>,
rules: Vec<OpenRule>,
}
#[derive(Deserialize, Debug)]
struct OpenRule {
name: Option<Pattern>,
mime: Option<Pattern>,
#[serde(rename = "use")]
use_: String,
}
impl Open {
pub fn new() -> Open {
#[derive(Deserialize)]
struct Outer {
opener: BTreeMap<String, Vec<Opener>>,
open: Open,
}
let path = BaseDirectories::new().unwrap().get_config_file("yazi/yazi.toml");
let mut outer = toml::from_str::<Outer>(&fs::read_to_string(path).unwrap()).unwrap();
for opener in outer.opener.values_mut() {
for one in opener.iter_mut() {
one.spread = one.args.iter().any(|a| a == "$*");
}
}
Self { openers: outer.opener, rules: outer.open.rules }
}
pub fn opener(&self, path: &Path, mime: &str) -> Option<&Opener> {
self.rules.iter().find_map(|rule| {
if rule.name.as_ref().map_or(false, |e| e.match_path(path, Some(false)))
|| rule.mime.as_ref().map_or(false, |m| m.matches(mime))
{
self.openers.get(&rule.use_).and_then(|v| v.first())
} else {
None
}
})
}
}

50
src/config/pattern.rs Normal file
View File

@ -0,0 +1,50 @@
use std::path::Path;
use serde::{de::Visitor, Deserialize, Deserializer};
#[derive(Debug)]
pub struct Pattern {
inner: glob::Pattern,
is_folder: bool,
}
impl Pattern {
pub fn matches(&self, str: &str) -> bool { self.inner.matches(str) }
pub fn match_path(&self, path: &Path, is_folder: Option<bool>) -> bool {
is_folder.map_or(true, |f| f == self.is_folder) && self.inner.matches_path(path)
}
}
impl From<&str> for Pattern {
fn from(value: &str) -> Self {
let is_folder = value.ends_with('/');
Self { inner: glob::Pattern::new(value.trim_end_matches('/')).unwrap_or_default(), is_folder }
}
}
impl<'de> Deserialize<'de> for Pattern {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct PatternVisitor;
impl<'de> Visitor<'de> for PatternVisitor {
type Value = Pattern;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a glob pattern, e.g. *.rs")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(Pattern::from(value))
}
}
deserializer.deserialize_str(PatternVisitor)
}
}

View File

@ -0,0 +1,3 @@
mod preview;
pub use preview::*;

View File

@ -0,0 +1,30 @@
use std::fs;
use serde::Deserialize;
use xdg::BaseDirectories;
#[derive(Deserialize, Debug)]
pub struct Manager {
pub sort_by: String,
pub sort_reverse: bool,
}
#[derive(Deserialize, Debug)]
pub struct Preview {
pub tab_size: u32,
pub max_width: u32,
pub max_height: u32,
}
impl Preview {
pub fn new() -> Self {
#[derive(Deserialize)]
struct Outer {
preview: Preview,
}
let path = BaseDirectories::new().unwrap().get_config_file("yazi/yazi.toml");
toml::from_str::<Outer>(&fs::read_to_string(path).unwrap()).unwrap().preview
}
}

39
src/config/theme/color.rs Normal file
View File

@ -0,0 +1,39 @@
use anyhow::Result;
use ratatui::style;
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Color {
pub fg: String,
pub bg: String,
}
impl Color {
pub fn fg_rgb(&self) -> style::Color {
if self.fg.len() < 7 {
return style::Color::Reset;
}
let convert = || -> Result<style::Color> {
Ok(style::Color::Rgb(
u8::from_str_radix(&self.fg[1..3], 16)?,
u8::from_str_radix(&self.fg[3..5], 16)?,
u8::from_str_radix(&self.fg[5..7], 16)?,
))
};
convert().unwrap_or(style::Color::Reset)
}
pub fn bg_rgb(&self) -> style::Color {
if self.bg.len() < 7 {
return style::Color::Reset;
}
let convert = || -> Result<style::Color> {
Ok(style::Color::Rgb(
u8::from_str_radix(&self.bg[1..3], 16)?,
u8::from_str_radix(&self.bg[3..5], 16)?,
u8::from_str_radix(&self.bg[5..7], 16)?,
))
};
convert().unwrap_or(style::Color::Reset)
}
}

45
src/config/theme/icon.rs Normal file
View File

@ -0,0 +1,45 @@
use std::fmt;
use serde::{de::Visitor, Deserializer};
use crate::config::Pattern;
#[derive(Debug)]
pub struct Icon {
pub name: Pattern,
pub display: String,
}
impl Icon {
pub fn new(name: String, display: String) -> Self {
Self { name: Pattern::from(name.as_ref()), display }
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Icon>, D::Error>
where
D: Deserializer<'de>,
{
struct IconVisitor;
impl<'de> Visitor<'de> for IconVisitor {
type Value = Vec<Icon>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a icon rule, e.g. \"*.md\" = \"\"")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let mut icons = Vec::new();
while let Some((key, value)) = &map.next_entry::<String, String>()? {
icons.push(Icon::new(key.clone(), value.clone()));
}
Ok(icons)
}
}
deserializer.deserialize_map(IconVisitor)
}
}

7
src/config/theme/mod.rs Normal file
View File

@ -0,0 +1,7 @@
mod color;
mod icon;
mod theme;
pub use color::*;
pub use icon::*;
pub use theme::*;

56
src/config/theme/theme.rs Normal file
View File

@ -0,0 +1,56 @@
use std::{fs, path::PathBuf};
use serde::Deserialize;
use xdg::BaseDirectories;
use super::{Color, Icon};
use crate::misc::absolute_path;
#[derive(Deserialize)]
pub struct Mode {
pub normal: Color,
pub select: Color,
pub unselect: Color,
}
#[derive(Deserialize)]
pub struct Tab {
pub active: Color,
pub inactive: Color,
}
#[derive(Deserialize)]
pub struct Selection {
pub normal: Color,
pub hovered: Color,
pub selected: Color,
}
#[derive(Deserialize)]
pub struct Filetype {}
#[derive(Deserialize)]
pub struct Syntect {
pub theme: PathBuf,
}
#[derive(Deserialize)]
pub struct Theme {
pub mode: Mode,
pub tab: Tab,
pub selection: Selection,
pub filetype: Filetype,
pub syntect: Syntect,
#[serde(deserialize_with = "Icon::deserialize")]
pub icons: Vec<Icon>,
}
impl Theme {
pub fn new() -> Self {
let path = BaseDirectories::new().unwrap().get_config_file("yazi/theme.toml");
let mut parsed: Self = toml::from_str(&fs::read_to_string(path).unwrap()).unwrap();
parsed.syntect.theme = absolute_path(&parsed.syntect.theme);
parsed
}
}

49
src/core/adapter/kitty.rs Normal file
View File

@ -0,0 +1,49 @@
use std::io::Write;
use anyhow::Result;
use base64::{engine::general_purpose, Engine};
use image::DynamicImage;
pub struct Kitty;
impl Kitty {
pub fn image_show(img: DynamicImage) -> Result<Vec<u8>> {
fn output(raw: Vec<u8>, format: u8, size: (u32, u32)) -> Result<Vec<u8>> {
let b64 = general_purpose::STANDARD.encode(raw).chars().collect::<Vec<_>>();
let mut it = b64.chunks(4096).peekable();
let mut buf = Vec::with_capacity(b64.len() + it.len() * 50);
if let Some(first) = it.next() {
write!(
buf,
"\x1b_Ga=d\x1b\\\x1b_Ga=T,f={},s={},v={},m={};{}\x1b\\",
format,
size.0,
size.1,
it.peek().is_some() as u8,
first.iter().collect::<String>(),
)?;
}
while let Some(chunk) = it.next() {
write!(
buf,
"\x1b_Gm={};{}\x1b\\",
it.peek().is_some() as u8,
chunk.iter().collect::<String>()
)?;
}
Ok(buf)
}
let size = (img.width(), img.height());
match img {
DynamicImage::ImageRgb8(v) => output(v.into_raw(), 24, size),
DynamicImage::ImageRgba8(v) => output(v.into_raw(), 32, size),
v => output(v.to_rgb8().into_raw(), 24, size),
}
}
#[inline]
pub fn image_hide() -> &'static [u8; 8] { b"\x1b_Ga=d\x1b\\" }
}

1
src/core/adapter/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod kitty;

95
src/core/event.rs Normal file
View File

@ -0,0 +1,95 @@
use std::path::PathBuf;
use anyhow::Result;
use crossterm::event::KeyEvent;
use indexmap::IndexMap;
use tokio::sync::{mpsc::Sender, oneshot};
use super::{FolderItem, InputOpt, PreviewData};
static mut TX: Option<Sender<Event>> = None;
pub enum Event {
Quit,
Stop(bool, oneshot::Sender<()>),
Key(KeyEvent),
Render(String),
Resize(u16, u16),
Refresh,
Files(PathBuf, IndexMap<PathBuf, FolderItem>),
Hover,
Mimetype(PathBuf, String),
Preview(PathBuf, PreviewData),
Input(InputOpt, oneshot::Sender<Result<String>>),
Open(Vec<PathBuf>),
Progress(u8, u32),
}
impl Event {
#[inline]
pub fn init(tx: Sender<Event>) {
unsafe {
TX.replace(tx);
}
}
#[inline]
pub fn emit(self) {
let tx = unsafe { TX.as_ref().unwrap() };
tokio::spawn(async {
tx.send(self).await.ok();
});
}
pub async fn wait<T>(self, rx: oneshot::Receiver<T>) -> T {
let tx = unsafe { TX.as_ref().unwrap() };
tx.send(self).await.ok();
rx.await.unwrap()
}
}
#[macro_export]
macro_rules! emit {
(Stop($state:expr)) => {{
let (tx, rx) = tokio::sync::oneshot::channel();
$crate::core::Event::Stop($state, tx).wait(rx)
}};
(Key($key:expr)) => {
$crate::core::Event::Key($key).emit();
};
(Render) => {
$crate::core::Event::Render(format!("{}:{}", file!(), line!())).emit();
};
(Resize($cols:expr, $rows:expr)) => {
$crate::core::Event::Resize($cols, $rows).emit();
};
(Files($path:expr, $items:expr)) => {
$crate::core::Event::Files($path, $items).emit();
};
(Mimetype($path:expr, $mime:expr)) => {
$crate::core::Event::Mimetype($path, $mime).emit();
};
(Preview($path:expr, $data:expr)) => {
$crate::core::Event::Preview($path, $data).emit();
};
(Input($opt:expr)) => {{
let (tx, rx) = tokio::sync::oneshot::channel();
$crate::core::Event::Input($opt, tx).wait(rx)
}};
(Open($files:expr)) => {
$crate::core::Event::Open($files).emit();
};
(Progress($percent:expr, $tasks:expr)) => {
$crate::core::Event::Progress($percent, $tasks).emit();
};
($event:ident) => {
$crate::core::Event::$event.emit();
};
}

312
src/core/input/input.rs Normal file
View File

@ -0,0 +1,312 @@
use anyhow::{anyhow, Result};
use ratatui::layout::Rect;
use tokio::sync::oneshot::Sender;
use unicode_width::UnicodeWidthStr;
use crate::misc::{tty_size, CharKind};
pub struct Input {
title: String,
value: String,
position: (u16, u16),
op: InputOp,
range: Option<(usize, usize)>,
mode: InputMode,
offset: usize,
cursor: usize,
callback: Option<Sender<Result<String>>>,
pub visible: bool,
}
pub struct InputOpt {
pub title: String,
pub value: String,
pub position: (u16, u16),
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum InputMode {
Normal,
#[default]
Insert,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum InputOp {
#[default]
None,
Delete(bool),
Yank,
}
impl Input {
pub fn show(&mut self, opt: InputOpt, tx: Sender<Result<String>>) {
self.title = opt.title;
self.value = opt.value;
self.position = opt.position;
self.mode = InputMode::Insert;
self.cursor = self.count();
self.offset = self.value.width().saturating_sub(50);
self.callback = Some(tx);
self.visible = true;
}
pub fn close(&mut self, submit: bool) -> bool {
self.visible = false;
if let Some(cb) = self.callback.take() {
let _ = cb.send(if submit { Ok(self.value.clone()) } else { Err(anyhow!("canceled")) });
}
true
}
pub fn escape(&mut self) -> bool {
match self.mode {
InputMode::Normal => {
self.range = None;
}
InputMode::Insert => {
self.mode = InputMode::Normal;
self.move_(-1);
}
}
true
}
pub fn insert(&mut self, append: bool) -> bool {
if self.mode != InputMode::Normal {
return false;
}
self.mode = InputMode::Insert;
if append {
self.move_(1);
}
true
}
pub fn visual(&mut self) -> bool {
if self.mode != InputMode::Normal {
return false;
}
self.range = Some((self.cursor, self.cursor));
true
}
pub fn move_(&mut self, step: isize) -> bool {
let old = self.cursor;
let mut include = false;
if step <= 0 {
self.cursor = self.cursor.saturating_sub(step.abs() as usize);
} else {
let count = self.count();
self.cursor += step as usize;
if self.cursor >= count {
include = true;
self.cursor = if self.mode == InputMode::Insert { count } else { count.saturating_sub(1) };
}
}
if self.cursor != old {
if self.cursor < self.offset {
self.offset = self.cursor;
} else if self.cursor > self.offset + 50 {
self.offset = self.cursor.saturating_sub(50);
}
}
self.handle_op(include) || self.cursor != old
}
pub fn backward(&mut self) -> bool {
if self.cursor == 0 {
return self.handle_op(false);
}
let idx = self.idx(self.cursor).unwrap_or(self.value.len());
let mut it = self.value[..idx].chars().rev().enumerate();
let mut prev = CharKind::new(it.next().unwrap().1);
for (i, c) in it {
let c = CharKind::new(c);
if prev != CharKind::Space && prev != c {
return self.move_(-(i as isize));
}
prev = c;
}
if prev != CharKind::Space {
return self.move_(-(self.value.len() as isize));
}
false
}
pub fn forward(&mut self, end: bool) -> bool {
if self.value.is_empty() {
return self.handle_op(false);
}
let mut it = self.value.chars().skip(self.cursor).enumerate();
let mut prev = CharKind::new(it.next().unwrap().1);
for (i, c) in it {
let c = CharKind::new(c);
let b = if end {
prev != CharKind::Space && prev != c && i != 1
} else {
c != CharKind::Space && c != prev
};
if b {
return self.move_(if end { i - 1 } else { i } as isize);
}
prev = c;
}
self.move_(self.value.len() as isize)
}
pub fn type_(&mut self, c: char) -> bool {
if self.cursor < 1 {
self.value.insert(0, c);
} else if self.cursor == self.count() {
self.value.push(c);
} else {
self.value.insert(self.idx(self.cursor).unwrap(), c);
}
self.move_(1)
}
pub fn backspace(&mut self) -> bool {
if self.cursor < 1 {
return false;
} else if self.cursor == self.count() {
self.value.pop();
} else {
self.value.remove(self.idx(self.cursor - 1).unwrap());
}
self.move_(-1)
}
pub fn delete(&mut self, insert: bool) -> bool {
match self.op {
InputOp::None => {
self.op = InputOp::Delete(insert);
if self.range.is_some() {
return self.handle_op(true);
}
self.range = Some((self.cursor, self.cursor));
false
}
InputOp::Delete(_) => {
self.move_(-(self.value.len() as isize));
self.value.clear();
self.op = InputOp::None;
self.range = None;
self.mode = if insert { InputMode::Insert } else { InputMode::Normal };
true
}
_ => false,
}
}
fn handle_op(&mut self, include: bool) -> bool {
if let Some(ref mut range) = self.range {
*range = (range.0.min(self.cursor), range.0.max(self.cursor));
}
match self.op {
InputOp::None => return false,
InputOp::Delete(insert) => {
let range = self.range.take().unwrap();
if !self.value.is_empty() {
let (start, end) = (self.idx(range.0), self.idx(range.1 + include as usize));
self.value.drain(start.unwrap()..end.unwrap());
}
self.mode = if insert {
self.cursor = range.0.min(self.count());
InputMode::Insert
} else {
self.cursor = range.0.min(self.count().saturating_sub(1));
InputMode::Normal
};
}
InputOp::Yank => {}
}
self.op = InputOp::None;
true
}
}
impl Input {
#[inline]
pub fn title(&self) -> String { self.title.clone() }
#[inline]
pub fn value(&self) -> String { self.value.clone() }
#[inline]
pub fn area(&self) -> Rect {
Rect { x: self.position.0, y: self.position.1 + 2, width: 50, height: 3 }
}
#[inline]
pub fn mode(&self) -> InputMode { self.mode }
#[inline]
pub fn cursor(&self) -> (u16, u16) {
let width = self
.value
.chars()
.enumerate()
.take_while(|(i, _)| *i < self.cursor)
.map(|(_, c)| c)
.collect::<String>()
.width() as u16;
let area = self.area();
(area.x + width + 1, area.y + 1)
}
#[inline]
pub fn top_position() -> (u16, u16) { ((tty_size().ws_col / 2).saturating_sub(25), 2) }
#[inline]
fn count(&self) -> usize { self.value.chars().count() }
#[inline]
fn idx(&self, n: usize) -> Option<usize> {
self
.value
.char_indices()
.nth(n)
.map(|(i, _)| i)
.or_else(|| if n == self.count() { Some(self.value.len()) } else { None })
}
}
impl Default for Input {
fn default() -> Self {
Self {
title: "".to_string(),
value: "".to_string(),
position: Default::default(),
op: Default::default(),
mode: Default::default(),
cursor: 0,
offset: 0,
range: None,
visible: false,
callback: None,
}
}
}

3
src/core/input/mod.rs Normal file
View File

@ -0,0 +1,3 @@
mod input;
pub use input::*;

254
src/core/manager/folder.rs Normal file
View File

@ -0,0 +1,254 @@
use std::{fs::Metadata, path::{Path, PathBuf}, usize};
use indexmap::{map::Slice, IndexMap};
use ratatui::layout::Rect;
use tokio::fs;
use super::{ALL_RATIO, CURRENT_RATIO, DIR_PADDING, PARENT_RATIO};
use crate::{config::{manager::SortBy, MANAGER}, emit, misc::tty_size};
#[derive(Default)]
pub struct Folder {
pub cwd: PathBuf,
items: IndexMap<PathBuf, FolderItem>,
offset: usize,
cursor: usize,
sort: FolderSort,
show_hidden: bool,
}
#[derive(Clone)]
pub struct FolderItem {
pub name: String,
pub path: PathBuf,
pub meta: Metadata,
pub length: Option<u64>,
pub is_link: bool,
pub is_hidden: bool,
pub is_selected: bool,
}
struct FolderSort {
pub by: SortBy,
pub reverse: bool,
}
impl Default for FolderSort {
fn default() -> Self { Self { by: MANAGER.sort_by, reverse: MANAGER.sort_reverse } }
}
impl Folder {
pub fn new(cwd: &Path) -> Self {
Self { cwd: cwd.to_path_buf(), show_hidden: MANAGER.show_hidden, ..Default::default() }
}
#[inline]
pub fn limit() -> usize { tty_size().ws_row.saturating_sub(DIR_PADDING) as usize }
pub async fn read(path: &Path) {
let mut iter = match fs::read_dir(path).await {
Ok(it) => it,
Err(_) => return,
};
let mut items = IndexMap::new();
while let Ok(Some(item)) = iter.next_entry().await {
let mut meta = if let Ok(meta) = item.metadata().await { meta } else { continue };
let is_link = meta.is_symlink();
if is_link {
meta = fs::metadata(&path).await.unwrap_or(meta);
}
let path = item.path();
let name = item.file_name().to_string_lossy().to_string();
let length = if meta.is_dir() { None } else { Some(meta.len()) };
let is_hidden = name.starts_with('.');
items.insert(path.clone(), FolderItem {
name,
path,
meta,
length,
is_link,
is_hidden,
is_selected: false,
});
}
emit!(Files(path.to_path_buf(), items));
}
pub fn sort(&mut self) {
fn cmp<T: Ord>(a: T, b: T, reverse: bool) -> std::cmp::Ordering {
if reverse { b.cmp(&a) } else { a.cmp(&b) }
}
let reverse = self.sort.reverse;
match self.sort.by {
SortBy::Alphabetical => self.items.sort_by(|_, a, _, b| cmp(&a.name, &b.name, reverse)),
SortBy::Created => self.items.sort_by(|_, a, _, b| {
if let (Ok(a), Ok(b)) = (a.meta.created(), b.meta.created()) {
return cmp(a, b, reverse);
}
std::cmp::Ordering::Equal
}),
SortBy::Modified => self.items.sort_by(|_, a, _, b| {
if let (Ok(a), Ok(b)) = (a.meta.modified(), b.meta.modified()) {
return cmp(a, b, reverse);
}
std::cmp::Ordering::Equal
}),
SortBy::Size => {
self.items.sort_by(|_, a, _, b| cmp(a.length.unwrap_or(0), b.length.unwrap_or(0), reverse))
}
}
}
pub fn update(&mut self, mut items: IndexMap<PathBuf, FolderItem>) -> bool {
if !self.show_hidden {
items.retain(|_, item| !item.is_hidden);
}
for (path, item) in &mut items {
if let Some(old) = self.items.get(path) {
item.length = old.length;
item.is_selected = old.is_selected;
}
}
let len = items.len();
self.items = items;
self.cursor = self.cursor.min(len.saturating_sub(1));
self.offset = self.offset.min(len);
self.sort();
true
}
pub fn next(&mut self, step: usize) -> bool {
let len = self.items.len();
if len == 0 {
return false;
}
let old = self.cursor;
self.cursor = (self.cursor + step).min(len - 1);
let limit = Self::limit();
if self.cursor >= (self.offset + limit).min(len).saturating_sub(5) {
self.offset = len.saturating_sub(limit).min(self.offset + self.cursor - old);
}
old != self.cursor
}
pub fn prev(&mut self, step: usize) -> bool {
let old = self.cursor;
self.cursor = self.cursor.saturating_sub(step);
if self.cursor < self.offset + 5 {
self.offset = self.offset.saturating_sub(old - self.cursor);
}
old != self.cursor
}
pub fn hidden(&mut self, show: Option<bool>) -> bool {
if show.is_none() || self.show_hidden != show.unwrap() {
self.show_hidden = !self.show_hidden;
emit!(Refresh);
}
false
}
pub fn paginate(&self) -> &Slice<PathBuf, FolderItem> {
let end = (self.offset + Self::limit()).min(self.items.len());
self.items.get_range(self.offset..end).unwrap()
}
pub fn select(&mut self, idx: Option<usize>, state: Option<bool>) -> bool {
let len = self.items.len();
let mut apply = |idx: usize, state: Option<bool>| -> bool {
if state.is_none() {
self.items[idx].is_selected = !self.items[idx].is_selected;
return true;
}
let state = state.unwrap();
if state != self.items[idx].is_selected {
self.items[idx].is_selected = state;
return true;
}
false
};
if let Some(idx) = idx {
if idx < len {
return apply(idx, state);
}
} else {
let mut applied = false;
for i in 0..len {
if apply(i, state) {
applied = true;
}
}
return applied;
}
false
}
pub fn selected(&self) -> Option<Vec<PathBuf>> {
let v = self
.items
.iter()
.filter(|(_, item)| item.is_selected)
.map(|(path, _)| path.clone())
.collect::<Vec<_>>();
if v.is_empty() { None } else { Some(v) }
}
pub fn hover(&mut self, path: &Path) -> bool {
if self.hovered().map(|h| h.path.as_path()) == Some(path) {
return false;
}
let new = self.position(path).unwrap_or(self.cursor);
if new > self.cursor { self.next(new - self.cursor) } else { self.prev(self.cursor - new) }
}
}
impl Folder {
#[inline]
pub fn hovered(&self) -> Option<&FolderItem> {
self.items.get_index(self.cursor).map(|(_, item)| item)
}
#[inline]
pub fn cursor(&self) -> usize { self.cursor }
#[inline]
pub fn rel_cursor(&self) -> usize { self.cursor - self.offset }
#[inline]
pub fn position(&self, path: &Path) -> Option<usize> {
self.items.iter().position(|(p, _)| p == path)
}
#[inline]
pub fn rect_current(&self, path: &Path) -> Option<Rect> {
let pos = self.position(path)? - self.offset;
let s = tty_size();
Some(Rect {
x: (s.ws_col as u32 * PARENT_RATIO / ALL_RATIO) as u16,
y: pos as u16,
width: (s.ws_col as u32 * CURRENT_RATIO / ALL_RATIO) as u16,
height: 1,
})
}
}

252
src/core/manager/manager.rs Normal file
View File

@ -0,0 +1,252 @@
use std::{collections::{BTreeSet, HashMap, HashSet}, path::PathBuf};
use indexmap::IndexMap;
use ratatui::layout::Rect;
use tokio::fs;
use tracing::trace;
use super::{FolderItem, PreviewData, Tab, Tabs, Watcher};
use crate::{core::{Folder, Input, InputOpt, Precache}, emit};
pub struct Manager {
tabs: Tabs,
yanked: (bool, HashSet<PathBuf>),
watcher: Watcher,
mimetype: HashMap<PathBuf, String>,
}
impl Manager {
pub fn new() -> Self {
Self {
tabs: Tabs::new(),
yanked: Default::default(),
watcher: Watcher::init(),
mimetype: Default::default(),
}
}
pub fn refresh(&mut self) {
self.watcher.trigger(&self.current().cwd);
if let Some(p) = self.parent() {
self.watcher.trigger(&p.cwd);
}
emit!(Hover);
let mut to_watch = BTreeSet::new();
for tab in self.tabs.iter() {
to_watch.insert(tab.current.cwd.clone());
if let Some(ref p) = tab.parent {
to_watch.insert(p.cwd.clone());
}
if let Some(ref h) = tab.current.hovered() {
to_watch.insert(h.path.clone());
}
}
self.watcher.watch(to_watch);
}
pub fn preview(&mut self) -> bool {
let hovered = if let Some(h) = self.hovered() {
h.clone()
} else {
return self.active_mut().preview.reset();
};
if hovered.meta.is_dir() {
self.active_mut().preview.go(&hovered.path, "inode/directory");
if self.active().history(&hovered.path).is_some() {
emit!(Preview(hovered.path, PreviewData::Folder));
}
} else if let Some(mime) = self.mimetype.get(&hovered.path).cloned() {
self.active_mut().preview.go(&hovered.path, &mime);
} else {
tokio::spawn(async move {
if let Ok(mime) = Precache::mimetype(&vec![hovered.path.clone()]).await {
if let Some(Some(mime)) = mime.first() {
emit!(Mimetype(hovered.path, mime.clone()));
}
}
});
}
false
}
pub fn close(&mut self) -> bool {
if self.tabs.len() > 1 {
return self.tabs.close(self.tabs.idx());
}
emit!(Quit);
return false;
}
pub fn yank(&mut self, cut: bool) -> bool {
self.yanked.0 = cut;
self.yanked.1.clear();
self.yanked.1.extend(self.selected());
false
}
#[inline]
pub fn yanked(&self) -> &(bool, HashSet<PathBuf>) { &self.yanked }
pub fn create(&self) -> bool {
let pos = Input::top_position();
let cwd = self.current().cwd.clone();
tokio::spawn(async move {
let result = emit!(Input(InputOpt {
title: "Create:".to_string(),
value: "".to_string(),
position: pos,
}))
.await;
if let Ok(name) = result {
let path = cwd.join(&name);
if name.ends_with('/') {
fs::create_dir_all(path).await.ok();
} else {
fs::create_dir_all(path.parent().unwrap()).await.ok();
fs::File::create(path).await.ok();
}
}
});
false
}
pub fn rename(&self) -> bool {
let selected = self.selected();
if selected.is_empty() {
return false;
}
if selected.len() > 1 {
return self.bulk_rename();
}
let rect = self.current().rect_current(&selected[0]).unwrap();
tokio::spawn(async move {
let result = emit!(Input(InputOpt {
title: "Rename:".to_string(),
value: selected[0].file_name().unwrap().to_string_lossy().to_string(),
position: (rect.x, rect.y),
}))
.await;
if let Ok(new) = result {
let to = selected[0].parent().unwrap().join(new);
fs::rename(&selected[0], to).await.ok();
}
});
false
}
fn bulk_rename(&self) -> bool { false }
pub fn selected(&self) -> Vec<PathBuf> {
self
.current()
.selected()
.or_else(|| self.hovered().map(|h| vec![h.path.clone()]))
.unwrap_or_default()
}
pub async fn mimetype(&mut self, files: &Vec<PathBuf>) -> Vec<Option<String>> {
let todo =
files.iter().filter(|&p| !self.mimetype.contains_key(p)).cloned().collect::<Vec<_>>();
if let Ok(mime) = Precache::mimetype(&todo).await {
let mut it = todo.iter().zip(mime);
while let Some((p, Some(m))) = it.next() {
self.mimetype.insert(p.clone(), m);
}
}
files.into_iter().map(|p| self.mimetype.get(p).cloned()).collect()
}
pub fn update_files(&mut self, path: PathBuf, items: IndexMap<PathBuf, FolderItem>) -> bool {
let cwd = self.current().cwd.clone();
let hovered = self.hovered().map(|h| h.path.clone());
let mut b = if self.current().cwd == path {
self.current_mut().update(items)
} else if matches!(self.parent(), Some(p) if p.cwd == path) {
self.active_mut().parent.as_mut().unwrap().update(items)
} else {
self
.active_mut()
.history
.entry(path.clone())
.or_insert_with(|| Folder::new(&path))
.update(items);
matches!(self.hovered(), Some(h) if h.path == path)
};
b |= self.active_mut().parent.as_mut().map_or(false, |p| p.hover(&cwd));
b |= hovered.as_ref().map_or(false, |h| self.current_mut().hover(h));
if hovered != self.hovered().map(|h| h.path.clone()) {
emit!(Hover);
}
b
}
pub fn update_mimetype(&mut self, path: PathBuf, mimetype: String) -> bool {
if matches!(self.mimetype.get(&path), Some(m) if m == &mimetype) {
return false;
}
self.mimetype.insert(path, mimetype);
self.preview();
true
}
pub fn update_preview(&mut self, path: PathBuf, data: PreviewData) -> bool {
let hovered = if let Some(h) = self.current().hovered() {
h.path.clone()
} else {
return self.active_mut().preview.reset();
};
if hovered != path {
return false;
}
let preview = &mut self.active_mut().preview;
preview.path = path;
preview.data = data;
true
}
}
impl Manager {
#[inline]
pub fn tabs(&self) -> &Tabs { &self.tabs }
#[inline]
pub fn tabs_mut(&mut self) -> &mut Tabs { &mut self.tabs }
#[inline]
pub fn active(&self) -> &Tab { self.tabs.active() }
#[inline]
pub fn active_mut(&mut self) -> &mut Tab { self.tabs.active_mut() }
#[inline]
pub fn current(&self) -> &Folder { &self.tabs.active().current }
#[inline]
pub fn current_mut(&mut self) -> &mut Folder { &mut self.tabs.active_mut().current }
#[inline]
pub fn parent(&self) -> &Option<Folder> { &self.tabs.active().parent }
#[inline]
pub fn hovered(&self) -> Option<&FolderItem> { self.tabs.active().current.hovered() }
}

25
src/core/manager/mod.rs Normal file
View File

@ -0,0 +1,25 @@
mod folder;
mod manager;
mod mode;
mod preview;
mod tab;
mod tabs;
mod watcher;
pub use folder::*;
pub use manager::*;
pub use mode::*;
pub use preview::*;
pub use tab::*;
pub use tabs::*;
pub use watcher::*;
pub const PARENT_RATIO: u32 = 1;
pub const CURRENT_RATIO: u32 = 4;
pub const PREVIEW_RATIO: u32 = 3;
pub const ALL_RATIO: u32 = PARENT_RATIO + CURRENT_RATIO + PREVIEW_RATIO;
pub const DIR_PADDING: u16 = 2;
pub const PREVIEW_BORDER: u16 = 2;
pub const PREVIEW_PADDING: u16 = 2;

41
src/core/manager/mode.rs Normal file
View File

@ -0,0 +1,41 @@
use std::fmt::Display;
use crate::config::{theme, THEME};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
#[default]
Normal,
Select(usize),
Unselect(usize),
}
impl Mode {
#[inline]
pub fn color(&self) -> &theme::Color {
match *self {
Mode::Normal => &THEME.mode.normal,
Mode::Select(_) => &THEME.mode.select,
Mode::Unselect(_) => &THEME.mode.unselect,
}
}
#[inline]
pub fn start(&self) -> Option<usize> {
match self {
Mode::Normal => None,
Mode::Select(n) => Some(*n),
Mode::Unselect(n) => Some(*n),
}
}
}
impl Display for Mode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
Mode::Normal => write!(f, "NORMAL"),
Mode::Select(_) => write!(f, "SELECT"),
Mode::Unselect(_) => write!(f, "UN-SEL"),
}
}
}

157
src/core/manager/preview.rs Normal file
View File

@ -0,0 +1,157 @@
use std::{fs::File, io::BufReader, path::{Path, PathBuf}, sync::OnceLock};
use adapter::kitty::Kitty;
use anyhow::{anyhow, Context, Result};
use image::imageops::FilterType;
use ratatui::layout::Rect;
use syntect::{easy::HighlightLines, highlighting::{Theme, ThemeSet}, parsing::SyntaxSet, util::as_24_bit_terminal_escaped};
use tokio::{fs, task::JoinHandle};
use super::{Folder, ALL_RATIO, PREVIEW_BORDER, PREVIEW_PADDING, PREVIEW_RATIO};
use crate::{config::{PREVIEW, THEME}, core::{adapter, Precache}, emit, misc::{first_n_lines, tty_ratio, tty_size}};
static SYNTECT_SYNTAX: OnceLock<SyntaxSet> = OnceLock::new();
static SYNTECT_THEME: OnceLock<Theme> = OnceLock::new();
#[derive(Debug)]
pub struct Preview {
pub path: PathBuf,
pub data: PreviewData,
handle: Option<JoinHandle<()>>,
}
#[derive(Debug, Default)]
pub enum PreviewData {
#[default]
None,
Folder,
Text(String),
Image(Vec<u8>),
}
impl Preview {
pub fn new() -> Self {
Self { path: Default::default(), data: Default::default(), handle: Default::default() }
}
fn size() -> (u16, u16) {
let s = tty_size();
let col = (s.ws_col as u32 * PREVIEW_RATIO / ALL_RATIO) as u16;
(col.saturating_sub(PREVIEW_BORDER), s.ws_row.saturating_sub(PREVIEW_PADDING))
}
pub fn go(&mut self, path: &Path, mime: &str) {
if let Some(handle) = self.handle.take() {
handle.abort();
}
let (path, mime) = (path.to_path_buf(), mime.to_string());
self.handle = Some(tokio::spawn(async move {
let result = if mime == "inode/directory" {
Self::folder(&path).await
} else if mime == "application/json" {
Self::json(&path).await.map(PreviewData::Text)
} else if mime.starts_with("text/") || mime.ends_with("/xml") {
Self::highlight(&path).await.map(PreviewData::Text)
} else if mime.starts_with("image/") {
Self::image(&path).await.map(PreviewData::Image)
} else if mime.starts_with("video/") {
Self::video(&path).await.map(PreviewData::Image)
} else {
Err(anyhow!("Unsupported mimetype: {}", mime))
};
emit!(Preview(path, result.unwrap_or_default()));
}));
}
pub fn reset(&mut self) -> bool {
if self.path == PathBuf::default() {
return false;
}
self.path = Default::default();
self.data = Default::default();
true
}
pub async fn folder(path: &Path) -> Result<PreviewData> {
Folder::read(&path).await;
Ok(PreviewData::Folder)
}
pub async fn image(mut path: &Path) -> Result<Vec<u8>> {
let cache = Precache::cache(path);
if cache.exists() {
path = cache.as_path();
}
let (w, h) = {
let r = tty_ratio();
let (w, h) = Self::size();
let (w, h) = ((w as f64 * r.0) as u32, (h as f64 * r.1) as u32);
(w.min(PREVIEW.max_width), h.min(PREVIEW.max_height))
};
let file = fs::read(path).await?;
tokio::task::spawn_blocking(move || -> Result<Vec<u8>> {
let img = image::load_from_memory(&file)?;
Kitty::image_show(if img.width() > w || img.height() > h {
img.resize(w, h, FilterType::Triangle)
} else {
img
})
})
.await?
}
pub async fn video(path: &Path) -> Result<Vec<u8>> {
Precache::video(path).await?;
let cache = Precache::cache(path);
Self::image(&cache).await
}
pub async fn json(path: &Path) -> Result<String> {
Ok(
Precache::json(path)
.await?
.lines()
.take(Self::size().1 as usize)
.collect::<Vec<_>>()
.join("\n"),
)
}
pub async fn highlight(path: &Path) -> Result<String> {
let syntax = SYNTECT_SYNTAX.get_or_init(|| SyntaxSet::load_defaults_newlines());
let theme = SYNTECT_THEME.get_or_init(|| {
let from_file = || -> Result<Theme> {
let file = File::open(&THEME.syntect.theme)?;
Ok(ThemeSet::load_from_reader(&mut BufReader::new(file))?)
};
from_file().unwrap_or_else(|_| ThemeSet::load_defaults().themes["base16-ocean.dark"].clone())
});
let ext = path.extension().context("no extension found")?.to_string_lossy().to_string();
let lines = first_n_lines(path, Self::size().1 as usize).await?;
tokio::task::spawn_blocking(move || -> Result<String> {
let mut buf = "".to_string();
if let Some(syn) = syntax.find_syntax_by_extension(&ext) {
let mut h = HighlightLines::new(syn, theme);
let tab = " ".repeat(PREVIEW.tab_size as usize);
for line in lines {
let line = line.replace('\t', &tab);
let ranges = h.highlight_line(&line, &syntax)?;
buf.push_str(&as_24_bit_terminal_escaped(&ranges, false));
buf.push('\n');
}
}
Ok(buf)
})
.await?
}
}

144
src/core/manager/tab.rs Normal file
View File

@ -0,0 +1,144 @@
use std::{collections::BTreeMap, mem, path::{Path, PathBuf}};
use super::{Folder, Mode, Preview};
use crate::emit;
pub struct Tab {
pub(super) current: Folder,
pub(super) parent: Option<Folder>,
pub(super) mode: Mode,
pub(super) history: BTreeMap<PathBuf, Folder>,
pub(super) preview: Preview,
}
impl Tab {
pub fn new(path: &Path) -> Self {
Self {
current: Folder::new(path),
parent: path.parent().map(|p| Folder::new(p)),
mode: Default::default(),
history: Default::default(),
preview: Preview::new(),
}
}
pub fn escape(&mut self) -> bool {
if matches!(self.mode, Mode::Select(_) | Mode::Unselect(_)) {
self.mode = Mode::Normal;
return true;
}
self.select_all(Some(false))
}
pub fn arrow(&mut self, step: isize) -> bool {
let before = self.current.cursor();
let ok = if step > 0 {
self.current.next(step as usize)
} else {
self.current.prev(step.abs() as usize)
};
if !ok {
return false;
}
// Visual selection
if let Some(start) = self.mode.start() {
let after = self.current.cursor();
if (after > before && before < start) || (after < before && before > start) {
for i in before.min(start)..=start.max(before) {
self.current.select(Some(i), Some(false));
}
}
for i in start.min(after)..=after.max(start) {
self.current.select(Some(i), Some(true));
}
}
emit!(Hover);
true
}
pub fn enter(&mut self) -> bool {
let hovered = if let Some(h) = self.current.hovered() {
h.clone()
} else {
return false;
};
if !hovered.meta.is_dir() {
emit!(Open(self.current.selected().unwrap_or(vec![hovered.path])));
return false;
}
let current = self.history.remove(&hovered.path).unwrap_or_else(|| Folder::new(&hovered.path));
let parent = mem::replace(&mut self.current, current);
if self.parent.is_none() {
self.parent = Some(parent);
} else {
let cwd = self.parent.as_ref().unwrap().cwd.clone();
let pparent = mem::replace(self.parent.as_mut().unwrap(), parent);
self.history.insert(cwd, pparent);
}
emit!(Refresh);
true
}
pub fn leave(&mut self) -> bool {
let parent = if let Some(p) = &self.parent {
p.cwd.clone()
} else {
return false;
};
let pparent = parent.parent().map(|p| self.history.remove(p).unwrap_or_else(|| Folder::new(p)));
let cwd = self.current.cwd.clone();
let parent = mem::replace(&mut self.parent, pparent).unwrap();
let current = mem::replace(&mut self.current, parent);
self.history.insert(cwd, current);
emit!(Refresh);
true
}
pub fn back(&mut self) -> bool { todo!() }
pub fn forward(&mut self) -> bool { todo!() }
pub fn select(&mut self, state: Option<bool>) -> bool {
let idx = Some(self.current.cursor());
self.current.select(idx, state)
}
pub fn select_all(&mut self, state: Option<bool>) -> bool { self.current.select(None, state) }
pub fn visual_mode(&mut self, unsel: bool) -> bool {
let idx = self.current.cursor();
if unsel {
self.mode = Mode::Unselect(idx);
self.current.select(Some(idx), Some(false));
} else {
self.mode = Mode::Select(idx);
self.current.select(Some(idx), Some(true));
};
true
}
}
impl Tab {
#[inline]
pub fn mode(&self) -> &Mode { &self.mode }
#[inline]
pub fn history(&self, path: &Path) -> Option<&Folder> { self.history.get(path) }
#[inline]
pub fn preview(&self) -> &Preview { &self.preview }
}

98
src/core/manager/tabs.rs Normal file
View File

@ -0,0 +1,98 @@
use std::path::Path;
use super::Tab;
use crate::{config::MANAGER, emit};
const MAX_TABS: usize = 9;
pub struct Tabs {
idx: usize,
items: Vec<Tab>,
}
impl Tabs {
pub fn new() -> Self {
let tabs = Self { idx: 0, items: vec![Tab::new(&MANAGER.cwd)] };
emit!(Refresh);
tabs
}
pub fn create(&mut self, path: &Path) -> bool {
if self.items.len() >= MAX_TABS {
return false;
}
self.items.insert(self.idx + 1, Tab::new(path));
self.set_idx(self.idx + 1);
true
}
pub fn switch(&mut self, idx: isize, rel: bool) -> bool {
let idx = if rel { self.absolute(idx) } else { idx as usize };
if idx == self.idx || idx >= self.items.len() {
return false;
}
self.set_idx(idx);
true
}
pub fn swap(&mut self, rel: isize) -> bool {
let idx = self.absolute(rel);
if idx == self.idx {
return false;
}
self.items.swap(self.idx, idx);
self.set_idx(idx);
true
}
pub fn close(&mut self, idx: usize) -> bool {
let len = self.items.len();
if len <= 1 || idx as usize >= len {
return false;
}
self.items.remove(idx);
if idx == self.idx {
self.set_idx(self.absolute(1));
}
true
}
#[inline]
fn absolute(&self, rel: isize) -> usize {
if rel > 0 {
(self.idx + rel as usize).min(self.items.len() - 1)
} else {
self.idx.saturating_sub(rel.abs() as usize)
}
}
#[inline]
fn set_idx(&mut self, idx: usize) {
self.idx = idx;
emit!(Refresh);
}
}
impl Tabs {
#[inline]
pub fn idx(&self) -> usize { self.idx }
#[inline]
pub fn len(&self) -> usize { self.items.len() }
#[inline]
pub fn iter(&self) -> impl Iterator<Item = &Tab> { self.items.iter() }
#[inline]
pub fn active(&self) -> &Tab { &self.items[self.idx] }
#[inline]
pub(super) fn active_mut(&mut self) -> &mut Tab { &mut self.items[self.idx] }
}

View File

@ -0,0 +1,81 @@
use std::{collections::BTreeSet, path::{Path, PathBuf}};
use notify::{RecommendedWatcher, Watcher as _Watcher};
use tokio::sync::mpsc::{self, Sender};
use super::Folder;
use crate::emit;
pub struct Watcher {
tx: Sender<PathBuf>,
watcher: RecommendedWatcher,
watched: BTreeSet<PathBuf>,
}
impl Watcher {
pub fn init() -> Self {
let (watcher, tx) = Self::start();
Self { tx, watcher, watched: Default::default() }
}
fn start() -> (RecommendedWatcher, Sender<PathBuf>) {
let (tx, mut rx) = mpsc::channel(50);
let watcher = RecommendedWatcher::new(
{
let tx = tx.clone();
move |res: Result<notify::Event, notify::Error>| {
if res.is_err() {
return;
}
let event = res.unwrap();
match event.kind {
notify::EventKind::Create(_) => {}
notify::EventKind::Modify(_) => {}
notify::EventKind::Remove(_) => {}
_ => return,
}
let path = if event.paths.len() > 0 {
event.paths[0].parent().unwrap_or(&event.paths[0])
} else {
return;
};
tx.blocking_send(path.to_path_buf()).ok();
}
},
notify::Config::default(),
)
.unwrap();
tokio::spawn(async move {
while let Some(path) = rx.recv().await {
Folder::read(&path).await;
}
});
(watcher, tx)
}
pub(super) fn watch(&mut self, to_watch: BTreeSet<PathBuf>) {
for p in to_watch.difference(&self.watched) {
self.watcher.watch(&p, notify::RecursiveMode::NonRecursive).ok();
}
for p in self.watched.difference(&to_watch) {
self.watcher.unwatch(p).ok();
}
self.watched = to_watch;
}
pub(super) fn trigger(&self, path: &Path) {
let tx = self.tx.clone();
let path = path.to_path_buf();
tokio::spawn(async move {
tx.send(path).await.ok();
});
}
}

11
src/core/mod.rs Normal file
View File

@ -0,0 +1,11 @@
mod adapter;
mod event;
mod input;
mod manager;
mod tasks;
pub use adapter::*;
pub use event::*;
pub use input::*;
pub use manager::*;
pub use tasks::*;

315
src/core/tasks/file.rs Normal file
View File

@ -0,0 +1,315 @@
use std::{collections::VecDeque, fs::Metadata, path::{Path, PathBuf}};
use anyhow::Result;
use futures::{future::BoxFuture, FutureExt};
use tokio::{fs, io::{self, ErrorKind::{AlreadyExists, NotFound}}, sync::mpsc};
use tracing::{info, trace};
use trash::{macos::{DeleteMethod, TrashContextExtMacos}, TrashContext};
use super::TaskOp;
use crate::misc::{calculate_size, copy_with_progress};
pub(super) struct File {
rx: async_channel::Receiver<FileOp>,
tx: async_channel::Sender<FileOp>,
sch: mpsc::UnboundedSender<TaskOp>,
}
#[derive(Debug)]
pub(super) enum FileOp {
Paste(FileOpPaste),
Link(FileOpLink),
Delete(FileOpDelete),
Trash(FileOpTrash),
}
#[derive(Clone, Debug)]
pub(super) struct FileOpPaste {
pub id: usize,
pub from: PathBuf,
pub to: PathBuf,
pub cut: bool,
pub follow: bool,
pub retry: u8,
}
#[derive(Clone, Debug)]
pub(super) struct FileOpLink {
pub id: usize,
pub from: PathBuf,
pub to: PathBuf,
pub cut: bool,
pub length: u64,
}
#[derive(Clone, Debug)]
pub(super) struct FileOpDelete {
pub id: usize,
pub target: PathBuf,
pub length: u64,
}
#[derive(Clone, Debug)]
pub(super) struct FileOpTrash {
pub id: usize,
pub target: PathBuf,
pub length: u64,
}
impl File {
pub(super) fn new(sch: mpsc::UnboundedSender<TaskOp>) -> Self {
let (tx, rx) = async_channel::unbounded();
Self { tx, rx, sch }
}
#[inline]
pub(super) async fn recv(&self) -> Result<(usize, FileOp)> {
Ok(match self.rx.recv().await? {
FileOp::Paste(t) => (t.id, FileOp::Paste(t)),
FileOp::Link(t) => (t.id, FileOp::Link(t)),
FileOp::Delete(t) => (t.id, FileOp::Delete(t)),
FileOp::Trash(t) => (t.id, FileOp::Trash(t)),
})
}
pub(super) async fn work(&self, task: &mut FileOp) -> Result<()> {
match task {
FileOp::Paste(task) => {
match fs::remove_file(&task.to).await {
Err(e) if e.kind() != NotFound => Err(e)?,
_ => {}
}
let mut it = copy_with_progress(&task.from, &task.to);
while let Some(res) = it.recv().await {
match res {
Ok(0) => {
if task.cut {
fs::remove_file(&task.from).await.ok();
}
break;
}
Ok(n) => {
trace!("Paste task advanced {}: {:?}", n, task);
self.sch.send(TaskOp::Adv(task.id, 0, n))?
}
Err(e) if e.kind() == NotFound => {
trace!("Paste task partially done: {:?}", task);
break;
}
// Operation not permitted (os error 1)
// Attribute not found (os error 93)
Err(e) if task.retry < 3 && matches!(e.raw_os_error(), Some(1) | Some(93)) => {
trace!("Paste task retry: {:?}", task);
task.retry += 1;
return Ok(self.tx.send(FileOp::Paste(task.clone())).await?);
}
Err(e) => Err(e)?,
}
}
self.sch.send(TaskOp::Adv(task.id, 1, 0))?;
}
FileOp::Link(task) => {
let src = match fs::read_link(&task.from).await {
Ok(src) => src,
Err(e) if e.kind() == NotFound => {
trace!("Link task partially done: {:?}", task);
return Ok(self.sch.send(TaskOp::Adv(task.id, 1, task.length))?);
}
Err(e) => Err(e)?,
};
match fs::remove_file(&task.to).await {
Err(e) if e.kind() != NotFound => Err(e)?,
_ => fs::symlink(src, &task.to).await?,
}
if task.cut {
fs::remove_file(&task.from).await.ok();
}
self.sch.send(TaskOp::Adv(task.id, 1, task.length))?;
}
FileOp::Delete(task) => {
if let Err(e) = fs::remove_file(&task.target).await {
if e.kind() != NotFound && fs::symlink_metadata(&task.target).await.is_ok() {
info!("Delete task failed: {:?}, {}", task, e);
Err(e)?
}
}
self.sch.send(TaskOp::Adv(task.id, 1, task.length))?
}
FileOp::Trash(task) => {
#[cfg(target_os = "macos")]
{
let mut ctx = TrashContext::default();
ctx.set_delete_method(DeleteMethod::NsFileManager);
ctx.delete(&task.target)?;
}
#[cfg(not(target_os = "macos"))]
{
trash::delete(&task.target)?;
}
self.sch.send(TaskOp::Adv(task.id, 1, task.length))?;
}
}
Ok(())
}
fn done(&self, id: usize) -> Result<()> { Ok(self.sch.send(TaskOp::Done(id))?) }
pub(super) async fn paste(&self, mut task: FileOpPaste) -> Result<()> {
if task.cut {
match fs::rename(&task.from, &task.to).await {
Ok(_) => return self.done(task.id),
Err(e) if e.kind() == NotFound => return self.done(task.id),
_ => {}
}
}
let meta = Self::metadata(&task.from, task.follow).await?;
if !meta.is_dir() {
let id = task.id;
self.sch.send(TaskOp::New(id, meta.len()))?;
if meta.is_file() {
self.tx.send(FileOp::Paste(task)).await?;
} else if meta.is_symlink() {
self.tx.send(FileOp::Link(task.to_link(meta.len()))).await?;
}
return self.done(id);
}
let root = task.to.clone();
let skip = task.from.components().count();
let mut dirs = VecDeque::from([task.from]);
while let Some(src) = dirs.pop_front() {
let dest = root.join(src.components().skip(skip).collect::<PathBuf>());
match fs::create_dir(&dest).await {
Err(e) if e.kind() != AlreadyExists => {
info!("Create dir failed: {:?}, {}", dest, e);
continue;
}
_ => {}
}
let mut it = match fs::read_dir(&src).await {
Ok(it) => it,
Err(e) => {
info!("Read dir failed: {:?}, {}", src, e);
continue;
}
};
while let Ok(Some(entry)) = it.next_entry().await {
let src = entry.path();
let meta = if let Ok(meta) = Self::metadata(&src, task.follow).await {
meta
} else {
continue;
};
if meta.is_dir() {
dirs.push_back(src);
continue;
}
task.to = dest.join(src.file_name().unwrap());
task.from = src;
self.sch.send(TaskOp::New(task.id, meta.len()))?;
if meta.is_file() {
trace!("Paste: {:?} -> {:?}", task.from, task.to);
self.tx.send(FileOp::Paste(task.clone())).await?;
} else if meta.is_symlink() {
trace!("Link: {:?} -> {:?}", task.from, task.to);
self.tx.send(FileOp::Link(task.to_link(meta.len()))).await?;
}
}
}
self.done(task.id)
}
pub(super) async fn delete(&self, mut task: FileOpDelete) -> Result<()> {
let meta = fs::symlink_metadata(&task.target).await?;
if !meta.is_dir() {
let id = task.id;
task.length = meta.len();
self.sch.send(TaskOp::New(id, meta.len()))?;
self.tx.send(FileOp::Delete(task)).await?;
return self.done(id);
}
let mut dirs = VecDeque::from([task.target]);
while let Some(target) = dirs.pop_front() {
let mut it = match fs::read_dir(target).await {
Ok(it) => it,
Err(_) => continue,
};
while let Ok(Some(entry)) = it.next_entry().await {
let meta = match entry.metadata().await {
Ok(m) => m,
Err(_) => continue,
};
if meta.is_dir() {
dirs.push_front(entry.path());
continue;
}
task.target = entry.path();
task.length = meta.len();
self.sch.send(TaskOp::New(task.id, meta.len()))?;
self.tx.send(FileOp::Delete(task.clone())).await?;
}
}
self.done(task.id)
}
pub(super) async fn trash(&self, mut task: FileOpTrash) -> Result<()> {
let id = task.id;
task.length = calculate_size(&task.target).await;
self.sch.send(TaskOp::New(id, task.length))?;
self.tx.send(FileOp::Trash(task)).await?;
self.done(id)
}
async fn metadata(path: &Path, follow: bool) -> io::Result<Metadata> {
if !follow {
return fs::symlink_metadata(path).await;
}
let meta = fs::metadata(path).await;
if meta.is_ok() { meta } else { fs::symlink_metadata(path).await }
}
pub(super) fn remove_empty_dirs(dir: &Path) -> BoxFuture<()> {
trace!("Remove empty dirs: {:?}", dir);
async move {
let mut it = match fs::read_dir(dir).await {
Ok(it) => it,
Err(_) => return,
};
while let Ok(Some(entry)) = it.next_entry().await {
if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) {
let path = entry.path();
Self::remove_empty_dirs(&path).await;
fs::remove_dir(path).await.ok();
}
}
fs::remove_dir(dir).await.ok();
}
.boxed()
}
}
impl FileOpPaste {
fn to_link(&self, length: u64) -> FileOpLink {
FileOpLink { id: self.id, from: self.from.clone(), to: self.to.clone(), cut: self.cut, length }
}
}

14
src/core/tasks/mod.rs Normal file
View File

@ -0,0 +1,14 @@
mod file;
mod precache;
mod process;
mod scheduler;
mod tasks;
pub(crate) use file::*;
pub use precache::*;
pub(crate) use process::*;
pub use scheduler::*;
pub use tasks::*;
pub const TASKS_PADDING: u16 = 2;
pub const TASKS_PERCENT: u16 = 80;

View File

@ -0,0 +1,94 @@
use std::path::{Path, PathBuf};
use anyhow::{bail, Result};
use image::imageops::FilterType;
use tokio::{fs, process::Command};
use crate::{config::PREVIEW, misc::valid_mimetype};
#[derive(Default)]
pub struct Precache;
impl Precache {
pub fn cache(path: &Path) -> PathBuf {
PathBuf::from(format!("/tmp/yazi/{:x}", md5::compute(path.to_string_lossy().as_bytes())))
}
pub async fn mimetype(files: &Vec<PathBuf>) -> Result<Vec<Option<String>>> {
if files.is_empty() {
return Ok(vec![]);
}
let output = Command::new("file")
.args(["-bL", "--mime-type"])
.args(files)
.kill_on_drop(true)
.output()
.await?;
if !output.status.success() {
bail!("failed to get mimetype: {}", String::from_utf8_lossy(&output.stderr));
}
Ok(
String::from_utf8_lossy(&output.stdout)
.trim()
.lines()
.map(|s| if valid_mimetype(s) { Some(s.to_string()) } else { None })
.collect(),
)
}
pub async fn json(path: &Path) -> Result<String> {
let output = Command::new("jq")
.args(["-C", "--indent", &PREVIEW.tab_size.to_string(), "."])
.arg(path)
.kill_on_drop(true)
.output()
.await?;
if !output.status.success() {
bail!("failed to get json: {}", String::from_utf8_lossy(&output.stderr));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub async fn image(path: &Path) -> Result<()> {
let cache = Self::cache(path);
if cache.exists() {
return Ok(());
}
let img = image::load_from_memory(&fs::read(path).await?)?;
let (w, h) = (PREVIEW.max_width, PREVIEW.max_height);
let img = if img.width() > w || img.height() > h {
img.resize(w, h, FilterType::Triangle)
} else {
img
};
Ok(img.save(cache)?)
}
pub async fn video(path: &Path) -> Result<()> {
let cache = Self::cache(path);
if cache.exists() {
return Ok(());
}
let output = Command::new("ffmpegthumbnailer")
.arg("-i")
.arg(path)
.arg("-o")
.arg(cache)
.args(["-q", "6", "-c", "jpeg", "-s", &PREVIEW.max_width.to_string()])
.kill_on_drop(true)
.output()
.await?;
if !output.status.success() {
bail!("failed to generate video thumbnail: {}", String::from_utf8_lossy(&output.stderr));
}
Ok(())
}
}

85
src/core/tasks/process.rs Normal file
View File

@ -0,0 +1,85 @@
use anyhow::Result;
use tokio::{process::Command, select, sync::{mpsc, oneshot, Semaphore}};
use tracing::trace;
use super::TaskOp;
use crate::emit;
pub(super) struct Process {
rx: async_channel::Receiver<ProcessOp>,
tx: async_channel::Sender<ProcessOp>,
sch: mpsc::UnboundedSender<TaskOp>,
blocker: Semaphore,
}
#[derive(Debug)]
pub(super) enum ProcessOp {
Open(ProcessOpOpen),
}
#[derive(Debug)]
pub(super) struct ProcessOpOpen {
pub id: usize,
pub cmd: String,
pub args: Vec<String>,
pub block: bool,
pub cancel: oneshot::Sender<()>,
}
impl Process {
pub(super) fn new(sch: mpsc::UnboundedSender<TaskOp>) -> Self {
let (tx, rx) = async_channel::unbounded();
Self { tx, rx, sch, blocker: Semaphore::new(1) }
}
#[inline]
pub(super) async fn recv(&self) -> Result<(usize, ProcessOp)> {
Ok(match self.rx.recv().await? {
ProcessOp::Open(t) => (t.id, ProcessOp::Open(t)),
})
}
pub(super) async fn work(&self, task: &mut ProcessOp) -> Result<()> {
match task {
ProcessOp::Open(task) => {
trace!("Open task: {:?}", task);
if !task.block {
select! {
_ = task.cancel.closed() => {},
Ok(status) = Command::new(&task.cmd).args(&task.args).kill_on_drop(true).status() => {
trace!("{} exited with {:?}", task.cmd, status);
}
}
return Ok(self.sch.send(TaskOp::Adv(task.id, 1, 0))?);
}
let _guard = self.blocker.acquire().await.unwrap();
emit!(Stop(true)).await;
match Command::new(&task.cmd).args(&task.args).kill_on_drop(true).spawn() {
Ok(mut child) => {
child.wait().await.ok();
}
Err(e) => {
trace!("Failed to spawn {}: {}", task.cmd, e);
}
}
emit!(Stop(false)).await;
self.sch.send(TaskOp::Adv(task.id, 1, 0))?;
}
}
Ok(())
}
fn done(&self, id: usize) -> Result<()> { Ok(self.sch.send(TaskOp::Done(id))?) }
pub(super) async fn open(&self, task: ProcessOpOpen) -> Result<()> {
let id = task.id;
self.sch.send(TaskOp::New(id, 0))?;
self.tx.send(ProcessOp::Open(task)).await?;
self.done(id)
}
}

368
src/core/tasks/scheduler.rs Normal file
View File

@ -0,0 +1,368 @@
use std::{collections::BTreeMap, path::PathBuf, sync::Arc, time::Duration};
use async_channel::{Receiver, Sender};
use futures::{future::BoxFuture, FutureExt};
use parking_lot::RwLock;
use tokio::{fs, select, sync::{mpsc::{self, UnboundedReceiver}, oneshot}, time::sleep};
use tracing::{info, trace};
use super::{File, FileOpDelete, FileOpPaste, FileOpTrash, Process, ProcessOpOpen, Task, TaskOp, TaskStage};
use crate::{config::open::Opener, emit, misc::unique_path};
#[derive(Default)]
pub(super) struct Running {
incer: usize,
hooks: BTreeMap<usize, Box<dyn (FnOnce(bool) -> BoxFuture<'static, ()>) + Send + Sync>>,
all: BTreeMap<usize, Task>,
}
impl Running {
fn add(&mut self, name: String) -> usize {
self.incer += 1;
self.all.insert(self.incer, Task::new(self.incer, name));
self.incer
}
#[inline]
fn get(&mut self, id: usize) -> Option<&mut Task> { self.all.get_mut(&id) }
#[inline]
pub(super) fn len(&self) -> usize { self.all.len() }
#[inline]
fn exists(&self, id: usize) -> bool { self.all.contains_key(&id) }
#[inline]
pub(super) fn values(&self) -> impl Iterator<Item = &Task> { self.all.values() }
#[inline]
fn is_empty(&self) -> bool { self.all.is_empty() }
fn try_remove(&mut self, id: usize, stage: TaskStage) -> Option<BoxFuture<'static, ()>> {
if let Some(task) = self.get(id) {
if stage > task.stage {
task.stage = stage;
}
match task.stage {
TaskStage::Pending => return None,
TaskStage::Dispatched => {
if task.processed < task.found {
return None;
}
if let Some(hook) = self.hooks.remove(&id) {
return Some(hook(false));
}
}
TaskStage::Hooked => {}
}
self.all.remove(&id);
}
None
}
}
pub struct Scheduler {
file: Arc<File>,
process: Arc<Process>,
todo: Sender<BoxFuture<'static, ()>>,
pub(super) running: Arc<RwLock<Running>>,
}
impl Scheduler {
pub(super) fn start() -> Self {
let (todo_tx, todo_rx) = async_channel::unbounded();
let (prog_tx, prog_rx) = mpsc::unbounded_channel();
let scheduler = Self {
file: Arc::new(File::new(prog_tx.clone())),
process: Arc::new(Process::new(prog_tx)),
todo: todo_tx,
running: Default::default(),
};
for _ in 0..3 {
scheduler.schedule_micro(todo_rx.clone());
}
for _ in 0..5 {
scheduler.schedule_macro(todo_rx.clone());
}
scheduler.progress(prog_rx);
scheduler
}
fn schedule_micro(&self, rx: Receiver<BoxFuture<'static, ()>>) {
tokio::spawn(async move {
loop {
if let Ok(fut) = rx.recv().await {
fut.await;
}
}
});
}
fn schedule_macro(&self, rx: Receiver<BoxFuture<'static, ()>>) {
let file = self.file.clone();
let process = self.process.clone();
let running = self.running.clone();
tokio::spawn(async move {
loop {
if let Ok(fut) = rx.try_recv() {
fut.await;
continue;
}
select! {
Ok(fut) = rx.recv() => {
fut.await;
}
Ok((id, mut task)) = file.recv() => {
if !running.read().exists(id) {
trace!("Skipping task {:?} as it was removed", task);
continue;
}
if let Err(e) = file.work(&mut task).await {
info!("Failed to work on task {:?}: {}", task, e);
} else {
trace!("Finished task {:?}", task);
}
}
Ok((id, mut task)) = process.recv() => {
if !running.read().exists(id) {
trace!("Skipping task {:?} as it was removed", task);
continue;
}
if let Err(e) = process.work(&mut task).await {
info!("Failed to work on task {:?}: {}", task, e);
} else {
trace!("Finished task {:?}", task);
}
}
}
}
});
}
fn progress(&self, mut rx: UnboundedReceiver<TaskOp>) {
let todo = self.todo.clone();
let running = self.running.clone();
tokio::spawn(async move {
while let Some(task) = rx.recv().await {
match task {
TaskOp::New(id, size) => {
if let Some(task) = running.write().get(id) {
task.found += 1;
task.todo += size;
}
}
TaskOp::Adv(id, processed, size) => {
let mut running = running.write();
if let Some(task) = running.get(id) {
task.processed += processed;
task.done += size;
}
if processed > 0 {
if let Some(fut) = running.try_remove(id, TaskStage::Pending) {
todo.send_blocking(fut).ok();
}
}
}
TaskOp::Done(id) => {
if let Some(fut) = running.write().try_remove(id, TaskStage::Dispatched) {
todo.send_blocking(fut).ok();
}
}
}
}
});
let running = self.running.clone();
let mut last = 100;
tokio::spawn(async move {
loop {
sleep(Duration::from_secs(1)).await;
if running.read().is_empty() {
if last != 100 {
last = 100;
emit!(Progress(100, 0));
}
continue;
}
let mut tasks = 0u32;
let mut left = 0;
let mut progress = (0, 0);
for task in running.read().values() {
tasks += 1;
left += task.found.saturating_sub(task.processed);
progress = (progress.0 + task.done, progress.1 + task.todo);
}
let mut new = match progress.1 {
0 => 100u8,
_ => 100.min(progress.0 * 100 / progress.1) as u8,
};
if tasks != 0 {
new = new.min(99);
left = left.max(1);
}
if new != last {
last = new;
emit!(Progress(new, left));
}
}
});
}
pub(super) fn cancel(&self, id: usize) -> bool {
let mut running = self.running.write();
let b = running.all.remove(&id).is_some();
if let Some(hook) = running.hooks.remove(&id) {
self.todo.send_blocking(hook(true)).ok();
}
b
}
pub(super) fn file_cut(&self, from: PathBuf, mut to: PathBuf, force: bool) {
let mut running = self.running.write();
let id = running.add(format!("Cut {:?} to {:?}", from, to));
running.hooks.insert(id, {
let from = from.clone();
let running = self.running.clone();
Box::new(move |canceled: bool| {
async move {
if !canceled {
File::remove_empty_dirs(&from).await;
}
running.write().try_remove(id, TaskStage::Hooked);
}
.boxed()
})
});
let _ = self.todo.send_blocking({
let file = self.file.clone();
async move {
if !force {
to = unique_path(to).await;
}
file.paste(FileOpPaste { id, from, to, cut: true, follow: false, retry: 0 }).await.ok();
}
.boxed()
});
}
pub(super) fn file_copy(&self, from: PathBuf, mut to: PathBuf, force: bool, follow: bool) {
let name = format!("Copy {:?} to {:?}", from, to);
let id = self.running.write().add(name);
let _ = self.todo.send_blocking({
let file = self.file.clone();
async move {
if !force {
to = unique_path(to).await;
}
file.paste(FileOpPaste { id, from, to, cut: false, follow, retry: 0 }).await.ok();
}
.boxed()
});
}
pub(super) fn file_delete(&self, target: PathBuf) {
let mut running = self.running.write();
let id = running.add(format!("Delete {:?}", target));
running.hooks.insert(id, {
let target = target.clone();
let running = self.running.clone();
Box::new(move |canceled: bool| {
async move {
if !canceled {
fs::remove_dir_all(target).await.ok();
}
running.write().try_remove(id, TaskStage::Hooked);
}
.boxed()
})
});
let _ = self.todo.send_blocking({
let file = self.file.clone();
async move {
file.delete(FileOpDelete { id, target, length: 0 }).await.ok();
}
.boxed()
});
}
pub(super) fn file_trash(&self, target: PathBuf) {
let name = format!("Trash {:?}", target);
let id = self.running.write().add(name);
let _ = self.todo.send_blocking({
let file = self.file.clone();
async move {
file.trash(FileOpTrash { id, target, length: 0 }).await.ok();
}
.boxed()
});
}
pub(super) fn process_open(&self, opener: &Opener, args: &[String]) {
let args = opener
.args
.iter()
.map_while(|a| {
if !a.starts_with('$') {
return Some(vec![a.clone()]);
}
if a == "$*" {
return Some(args.to_vec());
}
a[1..].parse().ok().and_then(|n: usize| args.get(n)).map(|a| vec![a.clone()])
})
.flatten()
.collect::<Vec<_>>();
let mut running = self.running.write();
let id = running.add(format!("Exec `{} {}`", opener.cmd, args.join(" ")));
let (cancel_tx, mut cancel_rx) = oneshot::channel();
running.hooks.insert(id, {
let running = self.running.clone();
Box::new(move |canceled: bool| {
async move {
if canceled {
cancel_rx.close();
}
running.write().try_remove(id, TaskStage::Hooked);
}
.boxed()
})
});
let _ = self.todo.send_blocking({
let process = self.process.clone();
let opener = opener.clone();
async move {
process
.open(ProcessOpOpen { id, cmd: opener.cmd, args, block: opener.block, cancel: cancel_tx })
.await
.ok();
}
.boxed()
});
}
}

164
src/core/tasks/tasks.rs Normal file
View File

@ -0,0 +1,164 @@
use std::{collections::{BTreeMap, HashSet}, path::PathBuf};
use tracing::trace;
use super::{Scheduler, TASKS_PADDING, TASKS_PERCENT};
use crate::{config::OPEN, misc::tty_size};
#[derive(Clone, Debug)]
pub struct Task {
pub id: usize,
pub name: String,
pub stage: TaskStage,
pub found: u32,
pub processed: u32,
pub todo: u64,
pub done: u64,
}
impl Task {
pub fn new(id: usize, name: String) -> Self {
Self { id, name, stage: Default::default(), found: 0, processed: 0, todo: 0, done: 0 }
}
}
#[derive(Debug)]
pub enum TaskOp {
// task_id, size
New(usize, u64),
// task_id, processed, size
Adv(usize, u32, u64),
// task_id
Done(usize),
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd)]
pub enum TaskStage {
#[default]
Pending,
Dispatched,
Hooked,
}
pub struct Tasks {
scheduler: Scheduler,
pub visible: bool,
pub cursor: usize,
pub progress: (u8, u32),
}
impl Tasks {
pub fn start() -> Self {
Self { scheduler: Scheduler::start(), visible: false, cursor: 0, progress: (100, 0) }
}
#[inline]
pub fn limit() -> usize {
(tty_size().ws_row * TASKS_PERCENT / 100).saturating_sub(TASKS_PADDING) as usize
}
pub fn toggle(&mut self) -> bool {
self.visible = !self.visible;
true
}
pub fn next(&mut self) -> bool {
let limit = Self::limit().min(self.scheduler.running.read().len());
let old = self.cursor;
self.cursor = limit.saturating_sub(1).min(self.cursor + 1);
old != self.cursor
}
pub fn prev(&mut self) -> bool {
let old = self.cursor;
self.cursor = self.cursor.saturating_sub(1);
old != self.cursor
}
pub fn paginate(&self) -> Vec<Task> {
let running = self.scheduler.running.read();
running.values().take(Self::limit()).cloned().collect::<Vec<_>>()
}
pub fn cancel(&self) -> bool {
let id = self.scheduler.running.read().values().skip(self.cursor).next().map(|t| t.id);
id.map(|id| self.scheduler.cancel(id)).unwrap_or(false)
}
pub fn file_open(&self, targets: Vec<(PathBuf, String)>) -> bool {
let mut openers = BTreeMap::new();
for target in targets {
if let Some(opener) = OPEN.opener(&target.0, &target.1) {
openers
.entry(opener.clone())
.or_insert_with(|| vec![])
.push(target.0.to_string_lossy().into_owned());
}
}
for (opener, args) in openers {
if opener.spread {
self.scheduler.process_open(&opener, &args);
continue;
}
for target in args {
self.scheduler.process_open(&opener, &[target]);
}
}
false
}
pub fn file_cut(&self, src: &HashSet<PathBuf>, dest: PathBuf, force: bool) -> bool {
for p in src {
let to = dest.join(p.file_name().unwrap());
if force && *p == to {
trace!("file_cut: same file, skipping {:?}", to);
} else {
self.scheduler.file_cut(p.clone(), to, force);
}
}
false
}
pub fn file_copy(
&self,
src: &HashSet<PathBuf>,
dest: PathBuf,
force: bool,
follow: bool,
) -> bool {
for p in src {
let to = dest.join(p.file_name().unwrap());
if force && *p == to {
trace!("file_copy: same file, skipping {:?}", to);
} else {
self.scheduler.file_copy(p.clone(), to, force, follow);
}
}
false
}
pub fn file_remove(&self, targets: Vec<PathBuf>, permanently: bool) -> bool {
for p in targets {
if permanently {
self.scheduler.file_delete(p);
} else {
self.scheduler.file_trash(p);
}
}
false
}
pub fn update_progress(&mut self, percent: u8, left: u32) -> bool {
if self.progress.0 == percent {
return false;
}
self.progress = (percent, left);
true
}
}

15
src/main.rs Normal file
View File

@ -0,0 +1,15 @@
use ui::App;
mod config;
mod core;
mod misc;
mod ui;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// console_subscriber::init();
config::init();
App::run().await
}

18
src/misc/chars.rs Normal file
View File

@ -0,0 +1,18 @@
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum CharKind {
Space,
Punct,
Other,
}
impl CharKind {
pub fn new(c: char) -> Self {
if c.is_whitespace() {
Self::Space
} else if c.is_ascii_punctuation() {
Self::Punct
} else {
Self::Other
}
}
}

13
src/misc/defer.rs Normal file
View File

@ -0,0 +1,13 @@
pub struct Defer<F: FnOnce()>(Option<F>);
impl<F: FnOnce()> Defer<F> {
pub fn new(f: F) -> Self { Defer(Some(f)) }
}
impl<F: FnOnce()> Drop for Defer<F> {
fn drop(&mut self) {
if let Some(f) = self.0.take() {
f();
}
}
}

198
src/misc/fns.rs Normal file
View File

@ -0,0 +1,198 @@
use std::{collections::VecDeque, env, path::{Path, PathBuf}};
use anyhow::Result;
use libc::{ioctl, winsize, STDOUT_FILENO, TIOCGWINSZ};
use tokio::{fs::{self, File}, io::{self, AsyncBufReadExt, BufReader}, select, sync::{mpsc, oneshot}, time};
#[inline]
pub fn tty_size() -> winsize {
unsafe {
let s: winsize = std::mem::zeroed();
ioctl(STDOUT_FILENO, TIOCGWINSZ, &s);
s
}
}
#[inline]
pub fn tty_ratio() -> (f64, f64) {
let s = tty_size();
(f64::from(s.ws_xpixel) / f64::from(s.ws_col), f64::from(s.ws_ypixel) / f64::from(s.ws_row))
}
pub fn absolute_path(p: &Path) -> PathBuf {
if p.starts_with("~") {
if let Ok(home) = env::var("HOME") {
let mut expanded = PathBuf::new();
expanded.push(home);
expanded.push(p.strip_prefix("~").unwrap());
return expanded;
}
}
p.to_path_buf()
}
pub fn readable_path(p: &Path) -> String {
if let Ok(home) = env::var("HOME") {
if let Ok(p) = p.strip_prefix(home) {
return format!("~/{}", p.display());
}
}
p.display().to_string()
}
pub async fn unique_path(mut p: PathBuf) -> PathBuf {
let name = if let Some(name) = p.file_name() {
name.to_os_string()
} else {
return p;
};
let mut i = 0;
while fs::symlink_metadata(&p).await.is_ok() {
i += 1;
let mut name = name.clone();
name.push(format!("_{}", i));
p.set_file_name(name);
}
p
}
#[inline]
pub fn optinal_bool(s: &str) -> Option<bool> {
if s == "true" {
Some(true)
} else if s == "false" {
Some(false)
} else {
None
}
}
pub async fn first_n_lines(path: &Path, n: usize) -> Result<Vec<String>> {
let mut lines = Vec::new();
let mut it = BufReader::new(File::open(path).await?).lines();
for _ in 0..n {
if let Some(line) = it.next_line().await? {
lines.push(line);
} else {
break;
}
}
Ok(lines)
}
pub async fn calculate_size(path: &Path) -> u64 {
let mut total = 0;
let mut stack = VecDeque::from([path.to_path_buf()]);
while let Some(path) = stack.pop_front() {
let meta = if let Ok(meta) = fs::symlink_metadata(&path).await {
meta
} else {
continue;
};
if !meta.is_dir() {
total += meta.len();
continue;
}
let mut it = if let Ok(it) = fs::read_dir(path).await {
it
} else {
continue;
};
while let Ok(Some(entry)) = it.next_entry().await {
let meta = if let Ok(m) = entry.metadata().await {
m
} else {
continue;
};
if meta.is_dir() {
stack.push_back(entry.path());
} else {
total += meta.len();
}
}
}
total
}
pub fn copy_with_progress(from: &Path, to: &Path) -> mpsc::Receiver<Result<u64, io::Error>> {
let (tx, rx) = mpsc::channel(1);
let (tick_tx, mut tick_rx) = oneshot::channel();
tokio::spawn({
let (from, to) = (from.to_path_buf(), to.to_path_buf());
async move {
let _ = match fs::copy(from, to).await {
Ok(len) => tick_tx.send(Ok(len)),
Err(e) => tick_tx.send(Err(e)),
};
}
});
tokio::spawn({
let tx = tx.clone();
let to = to.to_path_buf();
async move {
let mut last = 0;
let mut exit = None;
loop {
select! {
res = &mut tick_rx => exit = Some(res.unwrap()),
_ = tx.closed() => break,
_ = time::sleep(time::Duration::from_secs(1)) => (),
}
match exit {
Some(Ok(len)) => {
if len > last {
tx.send(Ok(len - last)).await.ok();
}
tx.send(Ok(0)).await.ok();
break;
}
Some(Err(e)) => {
tx.send(Err(e)).await.ok();
break;
}
None => {}
}
let len = fs::symlink_metadata(&to).await.map(|m| m.len()).unwrap_or(0);
if len > last {
tx.send(Ok(len - last)).await.ok();
last = len;
}
}
}
});
rx
}
pub fn valid_mimetype(str: &str) -> bool {
let parts = str.split('/').collect::<Vec<_>>();
if parts.len() != 2 {
return false;
}
let b = match parts[0] {
"application" => true,
"audio" => true,
"example" => true,
"font" => true,
"image" => true,
"message" => true,
"model" => true,
"multipart" => true,
"text" => true,
"video" => true,
_ => false,
};
b && !parts[1].is_empty()
}

7
src/misc/mod.rs Normal file
View File

@ -0,0 +1,7 @@
mod chars;
mod defer;
mod fns;
pub use chars::*;
pub use defer::*;
pub use fns::*;

115
src/ui/app.rs Normal file
View File

@ -0,0 +1,115 @@
use anyhow::{Ok, Result};
use crossterm::event::KeyEvent;
use tokio::sync::oneshot::{self};
use super::{root::Root, Ctx, Executor, Logs, Signals, Term};
use crate::{config::keymap::Key, core::Event, emit};
pub struct App {
cx: Ctx,
term: Option<Term>,
signals: Signals,
}
impl App {
pub async fn run() -> Result<()> {
let _log = Logs::init()?;
let term = Term::start()?;
let signals = Signals::start()?;
let mut app = Self { cx: Ctx::new(), term: Some(term), signals };
while let Some(event) = app.signals.rx.recv().await {
match event {
Event::Quit => break,
Event::Stop(state, tx) => app.dispatch_stop(state, tx),
Event::Key(key) => app.dispatch_key(key),
Event::Render(_) => app.dispatch_render(),
Event::Resize(..) => app.dispatch_resize(),
event => app.dispatch_module(event).await,
}
}
Ok(())
}
fn dispatch_stop(&mut self, state: bool, tx: oneshot::Sender<()>) {
if state {
self.signals.stop_term(true);
self.term = None;
} else {
self.term = Some(Term::start().unwrap());
self.signals.stop_term(false);
emit!(Render);
}
tx.send(()).ok();
}
fn dispatch_key(&mut self, key: KeyEvent) {
let key = Key::from(key);
if Executor::handle(&mut self.cx, key) {
emit!(Render);
}
}
fn dispatch_render(&mut self) {
if let Some(term) = &mut self.term {
let _ = term.draw(|f| {
f.render_widget(Root::new(&mut self.cx), f.size());
if let Some((x, y)) = self.cx.cursor {
f.set_cursor(x, y);
}
});
}
}
fn dispatch_resize(&mut self) {
self.cx.manager.preview();
emit!(Render);
}
async fn dispatch_module(&mut self, event: Event) {
let manager = &mut self.cx.manager;
match event {
Event::Refresh => {
manager.refresh();
}
Event::Files(path, files) => {
if manager.update_files(path, files) {
emit!(Render);
}
}
Event::Hover => {
if manager.preview() {
emit!(Render);
}
}
Event::Mimetype(file, mime) => {
if manager.update_mimetype(file, mime) {
emit!(Render);
}
}
Event::Preview(file, data) => {
manager.update_preview(file, data);
emit!(Render);
}
Event::Input(opt, tx) => {
self.cx.input.show(opt, tx);
emit!(Render);
}
Event::Open(files) => {
let mime = self.cx.manager.mimetype(&files).await;
let targets = files.into_iter().zip(mime).map_while(|(f, m)| m.map(|m| (f, m))).collect();
self.cx.tasks.file_open(targets);
}
Event::Progress(percent, left) => {
self.cx.tasks.update_progress(percent, left);
emit!(Render);
}
_ => unreachable!(),
}
}
}

21
src/ui/context.rs Normal file
View File

@ -0,0 +1,21 @@
use crate::core::{Input, Manager, Tasks};
pub struct Ctx {
pub cursor: Option<(u16, u16)>,
pub manager: Manager,
pub input: Input,
pub tasks: Tasks,
}
impl Ctx {
pub fn new() -> Self {
Self {
cursor: None,
manager: Manager::new(),
input: Input::default(),
tasks: Tasks::start(),
}
}
}

186
src/ui/dispatcher.rs Normal file
View File

@ -0,0 +1,186 @@
use crossterm::event::KeyCode;
use super::Ctx;
use crate::{config::{keymap::{Exec, Key, Single}, KEYMAP}, core::InputMode, emit, misc::optinal_bool};
pub struct Executor;
impl Executor {
pub fn handle(cx: &mut Ctx, key: Key) -> bool {
let layer = if cx.input.visible {
2
} else if cx.tasks.visible {
1
} else {
0
};
let mut render = false;
let mut matched = false;
let keymap = [&KEYMAP.manager, &KEYMAP.tasks, &KEYMAP.input][layer];
for Single { on, exec } in keymap {
if on.len() < 1 || on[0] != key {
continue;
}
matched = true;
for e in exec {
if layer == 0 {
render = Self::manager(cx, e) || render;
} else if layer == 1 {
render = Self::tasks(cx, e) || render;
} else if layer == 2 {
render = Self::input(cx, Some(e), key.code) || render;
}
}
}
if layer == 2 && !matched {
render = Self::input(cx, None, key.code);
}
render
}
fn manager(cx: &mut Ctx, exec: &Exec) -> bool {
match exec.cmd.as_str() {
"escape" => cx.manager.active_mut().escape(),
"quit" => {
emit!(Quit);
false
}
"close" => cx.manager.close(),
// Navigation
"arrow" => {
let step = exec.args.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
cx.manager.active_mut().arrow(step)
}
"leave" => cx.manager.active_mut().leave(),
"enter" => cx.manager.active_mut().enter(),
"back" => cx.manager.active_mut().back(),
"forward" => cx.manager.active_mut().forward(),
// Selection
"select" => {
let state = exec.named.get("state").cloned().unwrap_or("none".to_string());
cx.manager.active_mut().select(optinal_bool(&state))
}
"visual_mode" => cx.manager.active_mut().visual_mode(exec.named.contains_key("unselect")),
"select_all" => {
let state = exec.named.get("state").cloned().unwrap_or("none".to_string());
cx.manager.active_mut().select_all(optinal_bool(&state))
}
// Operation
"yank" => cx.manager.yank(exec.named.contains_key("cut")),
"paste" => {
let dest = cx.manager.current().cwd.clone();
let (cut, src) = cx.manager.yanked();
let force = exec.named.contains_key("force");
if *cut {
cx.tasks.file_cut(src, dest, force)
} else {
cx.tasks.file_copy(src, dest, force, exec.named.contains_key("follow"))
}
}
"remove" => {
cx.tasks.file_remove(cx.manager.selected(), exec.named.contains_key("permanently"))
}
"create" => cx.manager.create(),
"rename" => cx.manager.rename(),
"hidden" => cx.manager.current_mut().hidden(match exec.args.get(0).map(|s| s.as_str()) {
Some("show") => Some(true),
Some("hide") => Some(false),
_ => None,
}),
// Tabs
"tab_create" => {
let path = if exec.named.contains_key("current") {
cx.manager.current().cwd.clone()
} else {
exec.args.get(0).map(|p| p.into()).unwrap_or("/".into())
};
cx.manager.tabs_mut().create(&path)
}
"tab_close" => {
let idx = exec.args.get(0).and_then(|i| i.parse().ok()).unwrap_or(0);
cx.manager.tabs_mut().close(idx)
}
"tab_switch" => {
let step = exec.args.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
let rel = exec.named.contains_key("relative");
cx.manager.tabs_mut().switch(step, rel)
}
"tab_swap" => {
let step = exec.args.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
cx.manager.tabs_mut().swap(step)
}
// Tasks
"tasks_show" => cx.tasks.toggle(),
_ => false,
}
}
fn tasks(cx: &mut Ctx, exec: &Exec) -> bool {
match exec.cmd.as_str() {
"close" => cx.tasks.toggle(),
"arrow" => {
let step = exec.args.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
if step > 0 { cx.tasks.next() } else { cx.tasks.prev() }
}
"cancel" => cx.tasks.cancel(),
_ => false,
}
}
fn input(cx: &mut Ctx, exec: Option<&Exec>, code: KeyCode) -> bool {
let exec = if let Some(e) = exec {
e
} else {
if cx.input.mode() == InputMode::Insert {
if let KeyCode::Char(c) = code {
return cx.input.type_(c);
}
}
return false;
};
match cx.input.mode() {
InputMode::Normal => match exec.cmd.as_str() {
"close" => cx.input.close(exec.named.contains_key("submit")),
"escape" => cx.input.escape(),
"insert" => cx.input.insert(exec.named.contains_key("append")),
"visual" => cx.input.visual(),
"move" => {
let step = exec.args.get(0).and_then(|s| s.parse().ok()).unwrap_or(0);
cx.input.move_(step)
}
"backward" => cx.input.backward(),
"forward" => cx.input.forward(exec.named.contains_key("end-of-word")),
"delete" => cx.input.delete(exec.named.contains_key("insert")),
_ => false,
},
InputMode::Insert => match exec.cmd.as_str() {
"close" => cx.input.close(exec.named.contains_key("submit")),
"escape" => cx.input.escape(),
"backspace" => cx.input.backspace(),
_ => {
if let KeyCode::Char(c) = code {
return cx.input.type_(c);
}
false
}
},
}
}
}

28
src/ui/header/layout.rs Normal file
View File

@ -0,0 +1,28 @@
use ratatui::{buffer::Buffer, layout::{self, Constraint, Direction, Rect}, style::{Color, Style}, widgets::{Paragraph, Widget}};
use super::tabs::Tabs;
use crate::{misc::readable_path, ui::Ctx};
pub struct Layout<'a> {
cx: &'a Ctx,
}
impl<'a> Layout<'a> {
pub fn new(cx: &'a Ctx) -> Self { Self { cx } }
}
impl<'a> Widget for Layout<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let chunks = layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
.split(area);
let manager = &self.cx.manager;
Paragraph::new(readable_path(&manager.current().cwd))
.style(Style::default().fg(Color::Cyan))
.render(chunks[0], buf);
Tabs::new(self.cx).render(chunks[1], buf);
}
}

5
src/ui/header/mod.rs Normal file
View File

@ -0,0 +1,5 @@
mod layout;
mod tabs;
pub use layout::*;
pub use tabs::*;

33
src/ui/header/tabs.rs Normal file
View File

@ -0,0 +1,33 @@
use ratatui::{buffer::Buffer, layout::{Alignment, Rect}, style::{Color, Style}, text::{Line, Span}, widgets::{Paragraph, Widget}};
use crate::ui::Ctx;
pub struct Tabs<'a> {
cx: &'a Ctx,
}
impl<'a> Tabs<'a> {
pub fn new(cx: &'a Ctx) -> Self { Self { cx } }
}
impl<'a> Widget for Tabs<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let tabs = self.cx.manager.tabs();
let spans = Line::from(
tabs
.iter()
.enumerate()
.map(|(i, _)| {
if i == tabs.idx() {
Span::styled(format!(" {} ", i + 1), Style::default().fg(Color::Black).bg(Color::Blue))
} else {
Span::styled(format!(" {} ", i + 1), Style::default().fg(Color::Gray).bg(Color::Black))
}
})
.collect::<Vec<_>>(),
);
Paragraph::new(spans).alignment(Alignment::Right).render(area, buf);
}
}

40
src/ui/input.rs Normal file
View File

@ -0,0 +1,40 @@
use ratatui::{buffer::Buffer, layout::Rect, style::{Color, Style}, text::Line, widgets::{Block, BorderType, Borders, Clear, Paragraph, Widget}};
use super::{Ctx, Term};
use crate::core::InputMode;
pub struct Input<'a> {
cx: &'a Ctx,
}
impl<'a> Input<'a> {
pub fn new(cx: &'a Ctx) -> Self { Self { cx } }
}
impl<'a> Widget for Input<'a> {
fn render(self, _: Rect, buf: &mut Buffer) {
let input = &self.cx.input;
let area = input.area();
Clear.render(area, buf);
Paragraph::new(input.value())
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue))
.border_type(BorderType::Rounded)
.title({
let mut line = Line::from(input.title());
line.patch_style(Style::default().fg(Color::White));
line
}),
)
.style(Style::default().fg(Color::White))
.render(area, buf);
let _ = match input.mode() {
InputMode::Insert => Term::set_cursor_bar(),
_ => Term::set_cursor_block(),
};
}
}

24
src/ui/logs.rs Normal file
View File

@ -0,0 +1,24 @@
use anyhow::{Context, Result};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{fmt, prelude::__tracing_subscriber_SubscriberExt, Registry};
pub struct Logs {}
impl Logs {
pub fn init() -> Result<WorkerGuard> {
let root = xdg::BaseDirectories::with_prefix("yazi")
.context("failed to get XDG base directories")?
.get_state_home();
let appender = tracing_appender::rolling::hourly(root, "yazi.log");
let (handle, guard) = tracing_appender::non_blocking(appender);
// let filter = EnvFilter::from_default_env();
let subscriber = Registry::default().with(fmt::layer().compact().with_writer(handle));
tracing::subscriber::set_global_default(subscriber)
.context("setting default subscriber failed")?;
Ok(guard)
}
}

75
src/ui/manager/folder.rs Normal file
View File

@ -0,0 +1,75 @@
use ratatui::{buffer::Buffer, layout::Rect, style::{Color, Modifier, Style}, widgets::{List, ListItem, Widget}};
use crate::{config::THEME, core};
pub struct Folder<'a> {
folder: &'a core::Folder,
is_preview: bool,
is_selection: bool,
}
impl<'a> Folder<'a> {
pub fn new(folder: &'a core::Folder) -> Self {
Self { folder, is_preview: false, is_selection: false }
}
#[inline]
pub fn with_preview(mut self, state: bool) -> Self {
self.is_preview = state;
self
}
#[inline]
pub fn with_selection(mut self, state: bool) -> Self {
self.is_selection = state;
self
}
}
impl<'a> Widget for Folder<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let items = self
.folder
.paginate()
.iter()
.enumerate()
.map(|(i, (_, v))| {
let icon = THEME
.icons
.iter()
.find(|x| x.name.match_path(&v.path, Some(v.meta.is_dir())))
.map(|x| x.display.as_ref())
.unwrap_or("");
let item = ListItem::new(if v.is_selected {
format!("> {} {}", icon, v.name)
} else {
format!("{} {}", icon, v.name)
});
let mut style = Style::default();
if self.is_selection {
if i == self.folder.rel_cursor() {
style = style.fg(Color::Black).bg(Color::Red);
} else if v.is_selected {
style = style.fg(Color::Red);
}
} else if self.is_preview {
if i == self.folder.rel_cursor() {
style = style.add_modifier(Modifier::UNDERLINED)
}
} else {
if i == self.folder.rel_cursor() {
style = style.fg(Color::Black).bg(Color::Yellow);
} else if v.is_selected {
style = style.fg(Color::Red);
}
}
item.style(style)
})
.collect::<Vec<_>>();
List::new(items).render(area, buf);
}
}

47
src/ui/manager/layout.rs Normal file
View File

@ -0,0 +1,47 @@
use ratatui::{buffer::Buffer, layout::{self, Constraint, Direction, Rect}, widgets::{Block, Borders, Widget}};
use super::{Folder, Preview};
use crate::{core::{Mode, ALL_RATIO, CURRENT_RATIO, PARENT_RATIO, PREVIEW_RATIO}, ui::Ctx};
pub struct Layout<'a> {
cx: &'a Ctx,
}
impl<'a> Layout<'a> {
pub fn new(cx: &'a Ctx) -> Self { Self { cx } }
}
impl<'a> Widget for Layout<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let manager = &self.cx.manager;
let chunks = layout::Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Ratio(PARENT_RATIO, ALL_RATIO),
Constraint::Ratio(CURRENT_RATIO, ALL_RATIO),
Constraint::Ratio(PREVIEW_RATIO, ALL_RATIO),
]
.as_ref(),
)
.split(area);
// Parent
let block = Block::default().borders(Borders::RIGHT);
if let Some(ref parent) = manager.parent() {
Folder::new(parent).render(block.inner(chunks[0]), buf);
}
block.render(chunks[0], buf);
// Current
Folder::new(&manager.current())
.with_selection(matches!(manager.active().mode(), Mode::Select(_)))
.render(chunks[1], buf);
// Preview
let block = Block::default().borders(Borders::LEFT);
Preview::new(self.cx).render(block.inner(chunks[2]), buf);
block.render(chunks[2], buf);
}
}

8
src/ui/manager/mod.rs Normal file
View File

@ -0,0 +1,8 @@
mod folder;
mod layout;
mod preview;
pub use folder::*;
pub use layout::*;
pub use preview::*;

58
src/ui/manager/preview.rs Normal file
View File

@ -0,0 +1,58 @@
use std::io::{stdout, Write};
use ansi_to_tui::IntoText;
use ratatui::{buffer::Buffer, layout::Rect, widgets::{Paragraph, Widget}};
use super::Folder;
use crate::{core::{kitty::Kitty, PreviewData}, ui::{Ctx, Term}};
pub struct Preview<'a> {
cx: &'a Ctx,
}
impl<'a> Preview<'a> {
pub fn new(cx: &'a Ctx) -> Self { Self { cx } }
}
impl<'a> Widget for Preview<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
if self.cx.input.visible || self.cx.tasks.visible {
stdout().write(Kitty::image_hide()).ok();
return;
}
let manager = &self.cx.manager;
let hovered = if let Some(h) = manager.hovered() {
h.clone()
} else {
stdout().write(Kitty::image_hide()).ok();
return;
};
let preview = manager.active().preview();
if preview.path != hovered.path {
return;
}
if !matches!(preview.data, PreviewData::Image(_)) {
stdout().write(Kitty::image_hide()).ok();
}
match &preview.data {
PreviewData::None => {}
PreviewData::Folder => {
if let Some(folder) = manager.active().history(&hovered.path) {
Folder::new(folder).with_preview(true).render(area, buf);
}
}
PreviewData::Text(s) => {
let p = Paragraph::new(s.as_bytes().into_text().unwrap());
p.render(area, buf);
}
PreviewData::Image(b) => {
Term::move_to(area.x, area.y).ok();
stdout().write(b).ok();
}
}
}
}

20
src/ui/mod.rs Normal file
View File

@ -0,0 +1,20 @@
mod app;
mod context;
mod dispatcher;
mod header;
mod input;
mod logs;
mod manager;
mod root;
mod signals;
mod status;
mod tasks;
mod term;
pub use app::*;
pub use context::*;
pub use dispatcher::*;
pub use input::*;
pub use logs::*;
pub use signals::*;
pub use term::*;

35
src/ui/root.rs Normal file
View File

@ -0,0 +1,35 @@
use ratatui::{buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, widgets::Widget};
use super::{header, manager, status, tasks, Ctx, Input};
pub struct Root<'a> {
cx: &'a mut Ctx,
}
impl<'a> Root<'a> {
pub fn new(cx: &'a mut Ctx) -> Self { Self { cx } }
}
impl<'a> Widget for Root<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)].as_ref())
.split(area);
header::Layout::new(self.cx).render(chunks[0], buf);
manager::Layout::new(self.cx).render(chunks[1], buf);
status::Layout::new(self.cx).render(chunks[2], buf);
if self.cx.tasks.visible {
tasks::Layout::new(self.cx).render(area, buf);
}
if self.cx.input.visible {
Input::new(self.cx).render(area, buf);
self.cx.cursor = Some(self.cx.input.cursor());
} else {
self.cx.cursor = None;
}
}
}

88
src/ui/signals.rs Normal file
View File

@ -0,0 +1,88 @@
use anyhow::Result;
use crossterm::event::{Event as CrosstermEvent, EventStream};
use futures::StreamExt;
use libc::{SIGHUP, SIGINT, SIGQUIT, SIGTERM};
use tokio::{select, sync::{mpsc::{self, Receiver, Sender}, oneshot}, task::JoinHandle};
use crate::core::Event;
pub struct Signals {
pub tx: Sender<Event>,
pub rx: Receiver<Event>,
term_stop_tx: Option<oneshot::Sender<()>>,
term_stop_rx: Option<oneshot::Receiver<()>>,
}
impl Signals {
pub fn start() -> Result<Self> {
let (tx, rx) = mpsc::channel(500);
let (term_tx, term_rx) = oneshot::channel();
let mut signals =
Self { tx: tx.clone(), rx, term_stop_tx: Some(term_tx), term_stop_rx: Some(term_rx) };
signals.spawn_system_task()?;
signals.spawn_crossterm_task();
Event::init(tx);
Ok(signals)
}
fn spawn_system_task(&self) -> Result<JoinHandle<()>> {
let tx = self.tx.clone();
let mut signals = signal_hook_tokio::Signals::new([SIGHUP, SIGTERM, SIGINT, SIGQUIT])?;
Ok(tokio::spawn(async move {
while let Some(signal) = signals.next().await {
match signal {
SIGHUP | SIGTERM | SIGINT | SIGQUIT => {
if tx.send(Event::Quit).await.is_err() {
break;
}
}
_ => {}
}
}
}))
}
fn spawn_crossterm_task(&mut self) -> JoinHandle<()> {
let tx = self.tx.clone();
let mut stop_rx = self.term_stop_rx.take().unwrap();
tokio::spawn(async move {
let mut reader = EventStream::new();
loop {
select! {
_ = &mut stop_rx => break,
Some(Ok(event)) = reader.next() => {
let event = match event {
CrosstermEvent::Key(key) => Event::Key(key),
CrosstermEvent::Resize(cols, rows) => Event::Resize(cols, rows),
_ => continue,
};
if tx.send(event).await.is_err() {
break;
}
}
}
}
})
}
pub fn stop_term(&mut self, state: bool) {
if state == self.term_stop_tx.is_none() {
return;
}
if let Some(tx) = self.term_stop_tx.take() {
tx.send(()).ok();
} else {
let (tx, rx) = oneshot::channel();
(self.term_stop_tx, self.term_stop_rx) = (Some(tx), Some(rx));
self.spawn_crossterm_task();
}
}
}

51
src/ui/status/layout.rs Normal file
View File

@ -0,0 +1,51 @@
use ratatui::{buffer::Buffer, layout::{self, Constraint, Direction, Rect}, style::{Color, Modifier, Style}, widgets::{Paragraph, Widget}};
use super::Progress;
use crate::ui::Ctx;
pub struct Layout<'a> {
cx: &'a Ctx,
}
impl<'a> Layout<'a> {
pub fn new(cx: &'a Ctx) -> Self { Self { cx } }
}
impl<'a> Widget for Layout<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let mode = self.cx.manager.active().mode();
let chunks = layout::Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Length(1),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Length(1),
Constraint::Min(0),
]
.as_ref(),
)
.split(area);
Paragraph::new("").style(Style::default().fg(mode.color().bg_rgb())).render(chunks[0], buf);
Paragraph::new(format!(" {} ", mode))
.style(
Style::default()
.fg(mode.color().fg_rgb())
.bg(mode.color().bg_rgb())
.add_modifier(Modifier::BOLD),
)
.render(chunks[1], buf);
Paragraph::new(" master ")
.style(Style::default().fg(mode.color().bg_rgb()).bg(Color::Rgb(72, 77, 102)))
.render(chunks[2], buf);
Paragraph::new("").style(Style::default().fg(Color::Rgb(72, 77, 102))).render(chunks[3], buf);
Progress::new(self.cx).render(chunks[4], buf);
}
}

5
src/ui/status/mod.rs Normal file
View File

@ -0,0 +1,5 @@
mod layout;
mod progress;
pub use layout::*;
pub use progress::*;

27
src/ui/status/progress.rs Normal file
View File

@ -0,0 +1,27 @@
use ratatui::{style::{Color, Style}, widgets::{Gauge, Widget}};
use crate::ui::Ctx;
pub struct Progress<'a> {
cx: &'a Ctx,
}
impl<'a> Progress<'a> {
pub fn new(cx: &'a Ctx) -> Self { Self { cx } }
}
impl<'a> Widget for Progress<'a> {
fn render(self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
let progress = &self.cx.tasks.progress;
if progress.0 >= 100 {
return;
}
Gauge::default()
.gauge_style(Style::default().fg(Color::Yellow))
.percent(progress.0 as u16)
.label(format!("{}%, {} left", progress.0, progress.1))
.use_unicode(true)
.render(area, buf);
}
}

19
src/ui/tasks/clear.rs Normal file
View File

@ -0,0 +1,19 @@
use ratatui::{buffer::Buffer, layout::Rect, widgets::{self, Widget}};
pub struct Clear;
impl Widget for Clear {
fn render(self, mut area: Rect, buf: &mut Buffer) {
if area.x > 0 {
area.x -= 1;
area.width += 2;
}
if area.y > 0 {
area.y -= 1;
area.height += 2;
}
widgets::Clear.render(area, buf)
}
}

72
src/ui/tasks/layout.rs Normal file
View File

@ -0,0 +1,72 @@
use ratatui::{buffer::Buffer, layout::{self, Alignment, Constraint, Direction, Rect}, style::{Color, Modifier, Style}, widgets::{Block, BorderType, Borders, List, ListItem, Padding, Widget}};
use super::Clear;
use crate::{core::TASKS_PERCENT, ui::Ctx};
pub struct Layout<'a> {
cx: &'a Ctx,
}
impl<'a> Layout<'a> {
pub fn new(cx: &'a Ctx) -> Self { Self { cx } }
pub fn area(area: Rect) -> Rect {
let chunk = layout::Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Percentage((100 - TASKS_PERCENT) / 2),
Constraint::Percentage(TASKS_PERCENT),
Constraint::Percentage((100 - TASKS_PERCENT) / 2),
]
.as_ref(),
)
.split(area)[1];
let chunk = layout::Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Percentage((100 - TASKS_PERCENT) / 2),
Constraint::Percentage(TASKS_PERCENT),
Constraint::Percentage((100 - TASKS_PERCENT) / 2),
]
.as_ref(),
)
.split(chunk)[1];
chunk
}
}
impl<'a> Widget for Layout<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let area = Self::area(area);
Clear.render(area, buf);
let block = Block::default()
.title("Tasks")
.title_alignment(Alignment::Center)
.padding(Padding::new(0, 0, 1, 1))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Rgb(128, 174, 250)));
block.clone().render(area, buf);
let tasks = &self.cx.tasks;
let items = tasks
.paginate()
.iter()
.enumerate()
.map(|(i, v)| {
let mut item = ListItem::new(v.name.clone());
if i == tasks.cursor {
item = item.style(Style::default().add_modifier(Modifier::UNDERLINED));
}
item
})
.collect::<Vec<_>>();
List::new(items).render(block.inner(area), buf);
}
}

5
src/ui/tasks/mod.rs Normal file
View File

@ -0,0 +1,5 @@
mod clear;
mod layout;
pub use clear::*;
pub use layout::*;

67
src/ui/term.rs Normal file
View File

@ -0,0 +1,67 @@
use std::{io::{stdout, Stdout}, ops::{Deref, DerefMut}};
use anyhow::Result;
use crossterm::{cursor::{MoveTo, SetCursorStyle}, event::{DisableBracketedPaste, DisableFocusChange, EnableBracketedPaste, EnableFocusChange, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}, execute, queue, terminal::{disable_raw_mode, enable_raw_mode, supports_keyboard_enhancement, EnterAlternateScreen, LeaveAlternateScreen}};
use ratatui::{backend::CrosstermBackend, Terminal};
pub struct Term {
inner: Terminal<CrosstermBackend<Stdout>>,
csi_u: bool,
}
impl Term {
pub fn start() -> Result<Self> {
let mut term = Self { inner: Terminal::new(CrosstermBackend::new(stdout()))?, csi_u: false };
enable_raw_mode()?;
execute!(stdout(), EnterAlternateScreen, EnableBracketedPaste, EnableFocusChange)?;
term.csi_u = matches!(supports_keyboard_enhancement(), Ok(true));
if term.csi_u {
queue!(
stdout(),
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
)?;
}
term.hide_cursor()?;
term.clear()?;
Ok(term)
}
pub fn move_to(x: u16, y: u16) -> Result<()> { Ok(execute!(stdout(), MoveTo(x, y))?) }
pub fn set_cursor_block() -> Result<()> { Ok(execute!(stdout(), SetCursorStyle::BlinkingBlock)?) }
pub fn set_cursor_bar() -> Result<()> { Ok(execute!(stdout(), SetCursorStyle::BlinkingBar)?) }
}
impl Drop for Term {
fn drop(&mut self) {
let mut f = || -> Result<()> {
if self.csi_u {
execute!(stdout(), PopKeyboardEnhancementFlags)?;
}
execute!(stdout(), DisableFocusChange, DisableBracketedPaste, LeaveAlternateScreen)?;
self.show_cursor()?;
Ok(disable_raw_mode()?)
};
f().ok();
}
}
impl Deref for Term {
type Target = Terminal<CrosstermBackend<Stdout>>;
fn deref(&self) -> &Self::Target { &self.inner }
}
impl DerefMut for Term {
fn deref_mut(&mut self) -> &mut Self::Target { &mut self.inner }
}