a first implementation

This commit is contained in:
Alexandre Pasmantier 2024-09-15 21:59:21 +02:00
parent b514e7ce43
commit e5f1ebcf71
50 changed files with 6351 additions and 1002 deletions

View File

@ -1,10 +1,24 @@
{
"keybindings": {
"Home": {
"Input": {
"<q>": "Quit", // Quit the application
"<Ctrl-d>": "Quit", // Another way to quit
"<Ctrl-c>": "Quit", // Yet another way to quit
"<Ctrl-z>": "Suspend" // Suspend the application
"<esc>": "Quit", // Quit the application
"<ctrl-c>": "Quit", // Yet another way to quit
"<ctrl-z>": "Suspend", // Suspend the application
"<tab>": "GoToNextPane", // Move to the next pane
"<backtab>": "GoToPrevPane", // Move to the previous pane
// "<ctrl-up>": "GoToPaneUp", // Move to the pane above
// "<ctrl-down>": "GoToPaneDown", // Move to the pane below
"<ctrl-left>": "GoToPaneLeft", // Move to the pane to the left
"<ctrl-right>": "GoToPaneRight", // Move to the pane to the right
"<down>": "SelectNextEntry", // Move to the next entry
"<up>": "SelectPrevEntry", // Move to the previous entry
"<ctrl-n>": "SelectNextEntry", // Move to the next entry
"<ctrl-p>": "SelectPrevEntry", // Move to the previous entry
"<ctrl-down>": "ScrollPreviewDown", // Scroll the preview down
"<ctrl-up>": "ScrollPreviewUp", // Scroll the preview up
"<ctrl-d>": "ScrollPreviewHalfPageDown", // Scroll the preview half a page down
"<ctrl-u>": "ScrollPreviewHalfPageUp", // Scroll the preview half a page up
},
}
}

18
.config/config.toml Normal file
View File

@ -0,0 +1,18 @@
[keybindings.Input]
q = "Quit"
esc = "Quit"
ctrl-c = "Quit"
ctrl-z = "Suspend"
tab = "GoToNextPane"
backtab = "GoToPrevPane"
ctrl-left = "GoToPaneLeft"
ctrl-right = "GoToPaneRight"
down = "SelectNextEntry"
up = "SelectPrevEntry"
ctrl-n = "SelectNextEntry"
ctrl-p = "SelectPrevEntry"
ctrl-down = "ScrollPreviewDown"
ctrl-up = "ScrollPreviewUp"
ctrl-d = "ScrollPreviewHalfPageDown"
ctrl-u = "ScrollPreviewHalfPageUp"
enter = "SelectEntry"

20
.gitignore vendored
View File

@ -1,2 +1,20 @@
/target
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# RustRover
.idea/
.data/*.log

1402
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,45 +3,94 @@ name = "television-rs"
version = "0.1.0"
edition = "2021"
description = "The revolution will be televised"
license = "Apache-2.0"
authors = ["Alexandre Pasmantier <alex.pasmant@gmail.com>"]
build = "build.rs"
repository = "https://github.com/alexpasmantier/television"
keywords = ["search", "fuzzy", "preview", "tui", "terminal"]
categories = [
"command-line-utilities",
"command-line-interface",
"concurrency",
"development-tools",
]
[[bin]]
bench = false
path = "crates/television/main.rs"
name = "tv"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[workspace]
members = [
"crates/television_derive",
]
[dependencies]
television-derive = { path = "crates/television_derive" }
better-panic = "0.3.0"
clap = { version = "4.4.5", features = [
"derive",
"cargo",
"wrap_help",
"unicode",
"string",
"unstable-styles",
"derive",
"cargo",
"wrap_help",
"unicode",
"string",
"unstable-styles",
] }
color-eyre = "0.6.3"
config = "0.14.0"
crossterm = { version = "0.28.1", features = ["serde", "event-stream"] }
crossterm = { version = "0.28.1", features = ["serde"] }
derive_deref = "1.1.1"
devicons = "0.5.4"
directories = "5.0.1"
futures = "0.3.30"
fuzzy-matcher = "0.3.7"
human-panic = "2.0.1"
ignore = "0.4.23"
image = "0.25.2"
impl-enum = "0.3.1"
infer = "0.16.0"
json5 = "0.4.1"
lazy_static = "1.5.0"
libc = "0.2.158"
nucleo = "0.5.0"
nucleo-matcher = "0.3.1"
parking_lot = "0.12.3"
pretty_assertions = "1.4.0"
ratatui = { version = "0.28.1", features = ["serde", "macros"] }
ratatui-image = "1.0.5"
regex = "1.10.6"
serde = { version = "1.0.208", features = ["derive"] }
serde_json = "1.0.125"
signal-hook = "0.3.17"
strip-ansi-escapes = "0.2.0"
strum = { version = "0.26.3", features = ["derive"] }
syntect = "5.2.0"
tokio = { version = "1.39.3", features = ["full"] }
tokio-stream = "0.1.16"
tokio-util = "0.7.11"
toml = "0.8.19"
tracing = "0.1.40"
tracing-error = "0.2.0"
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] }
unicode-width = "0.2.0"
[build-dependencies]
anyhow = "1.0.86"
vergen-gix = { version = "1.0.0", features = ["build", "cargo"] }
[profile.release]
opt-level = 3
debug = "none"
strip = "symbols"
debug-assertions = false
overflow-checks = false
lto = "thin"
panic = "abort"
[target.'cfg(target_os = "macos")'.dependencies]
crossterm = { version = "0.28.1", features = ["serde", "use-dev-tty"] }

39
TODO.md Normal file
View File

@ -0,0 +1,39 @@
# tasks
- [x] preview navigation
- [ ] add a way to open the selected file in the default editor
- [x] maybe filter out image types etc. for now
- [x] return selected entry on exit
- [x] piping output to another command
- [x] piping custom entries from stdin (e.g. `ls | tv`, what bout choosing previewers in that case? Some AUTO mode?)
## bugs
- [x] sanitize input (tabs, \0, etc) (see https://github.com/autobib/nucleo-picker/blob/d51dec9efd523e88842c6eda87a19c0a492f4f36/src/lib.rs#L212-L227)
## improvements
- [x] async finder initialization
- [x] async finder search
- [x] use nucleo for env
- [ ] better keymaps
- [ ] mutualize placeholder previews in cache (really not a priority)
- [ ] better abstractions for channels / separation / isolation so that others can contribute new ones easily
- [ ] channel selection in the UI (separate menu or top panel or something)
- [x] only render highlighted lines that are visible
- [x] only ever read a portion of the file for the temp preview
- [ ] make layout an attribute of the channel?
- [ ] I feel like the finder abstraction is a superfluous layer, maybe just use the channel directly?
## feature ideas
- [ ] some sort of iterative fuzzy file explorer (preview contents of folders on the right, enter to go in etc.) maybe
with mixed previews of files and folders
- [x] environment variables
- [ ] aliases
- [ ] shell history
- [x] text
- [ ] text in documents (pdfs, archives, ...) (rga, adapters) https://github.com/jrmuizel/pdf-extract
- [x] fd
- [ ] recent directories
- [ ] git (commits, branches, status, diff, ...)
- [ ] makefile commands
- [ ] remote files (s3, ...)
- [ ] custom actions as part of a channel (mappable)

View File

@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
use strum::Display;
#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)]
pub enum Action {
// input actions
AddInputChar(char),
DeletePrevChar,
DeleteNextChar,
GoToPrevChar,
GoToNextChar,
GoToInputStart,
GoToInputEnd,
// rendering actions
Render,
Resize(u16, u16),
ClearScreen,
// results actions
SelectEntry,
SelectNextEntry,
SelectPrevEntry,
// navigation actions
GoToPaneUp,
GoToPaneDown,
GoToPaneLeft,
GoToPaneRight,
GoToNextPane,
GoToPrevPane,
// preview actions
ScrollPreviewUp,
ScrollPreviewDown,
ScrollPreviewHalfPageUp,
ScrollPreviewHalfPageDown,
OpenEntry,
// application actions
Tick,
Suspend,
Resume,
Quit,
Help,
Error(String),
NoOp,
// channel actions
SyncFinderResults,
}

265
crates/television/app.rs Normal file
View File

@ -0,0 +1,265 @@
/// NOTE: outdated
///
/// The general idea
/// ┌──────────────────────────────────────────────────────────────────────────────────────────────────────┐
/// │ │
/// │ rendering thread event thread main thread │
/// │ │
/// │ │ │ │ │
/// │ │
/// │ │ │ │ │
/// │ │
/// │ │ │ │ │
/// │ ┌───────┴───────┐ │
/// │ │ │ │ │ │
/// │ │ receive event │ │
/// │ │ │ │ │ │
/// │ └───────┬───────┘ │
/// │ │ │ │ │
/// │ ▼ │
/// │ │ ┌──────────────────┐ ┌──────────┴─────────┐ │
/// │ │ │ │ │ │
/// │ │ │ send on event_rx ├────────────►│ receive event_rx │ │
/// │ │ │ │ │ │
/// │ │ └──────────────────┘ └──────────┬─────────┘ │
/// │ │ │
/// │ │ ▼ │
/// │ ┌────────────────────┐ │
/// │ │ │ map to action │ │
/// │ └──────────┬─────────┘ │
/// │ │ ▼ │
/// │ ┌────────────────────┐ │
/// │ │ │ send on action_tx │ │
/// │ └──────────┬─────────┘ │
/// │ │ │
/// │ │
/// │ │ ┌──────────┴─────────┐ │
/// │ │ receive action_rx │ │
/// │ │ └──────────┬─────────┘ │
/// │ ┌───────────┴────────────┐ ▼ │
/// │ │ │ ┌────────────────────┐ │
/// │ │ receive render_rx │◄────────────────────────────────────────────────┤ dispatch action │ │
/// │ │ │ └──────────┬─────────┘ │
/// │ └───────────┬────────────┘ │ │
/// │ │ │ │
/// │ ▼ ▼ │
/// │ ┌────────────────────────┐ ┌────────────────────┐ │
/// │ │ render components │ │ update components │ │
/// │ └────────────────────────┘ └────────────────────┘ │
/// │ │
/// └──────────────────────────────────────────────────────────────────────────────────────────────────────┘
use std::sync::Arc;
use color_eyre::Result;
use serde::{Deserialize, Serialize};
use tokio::sync::{mpsc, Mutex};
use tracing::{debug, info};
use crate::channels::CliTvChannel;
use crate::television::Television;
use crate::{
action::Action,
config::Config,
entry::Entry,
event::{Event, EventLoop, Key},
render::{render, RenderingTask},
};
pub struct App {
config: Config,
// maybe move these two into config instead of passing them
// via the cli?
tick_rate: f64,
frame_rate: f64,
television: Arc<Mutex<Television>>,
should_quit: bool,
should_suspend: bool,
mode: Mode,
action_tx: mpsc::UnboundedSender<Action>,
action_rx: mpsc::UnboundedReceiver<Action>,
event_rx: mpsc::UnboundedReceiver<Event<Key>>,
event_abort_tx: mpsc::UnboundedSender<()>,
render_tx: mpsc::UnboundedSender<RenderingTask>,
}
#[derive(
Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize,
)]
pub enum Mode {
#[default]
Help,
Input,
Preview,
Results,
}
impl App {
pub fn new(
channel: CliTvChannel,
tick_rate: f64,
frame_rate: f64,
) -> Result<Self> {
let (action_tx, action_rx) = mpsc::unbounded_channel();
let (render_tx, _) = mpsc::unbounded_channel();
let (_, event_rx) = mpsc::unbounded_channel();
let (event_abort_tx, _) = mpsc::unbounded_channel();
let television = Arc::new(Mutex::new(Television::new(channel)));
Ok(Self {
tick_rate,
frame_rate,
television,
should_quit: false,
should_suspend: false,
config: Config::new()?,
mode: Mode::Input,
action_tx,
action_rx,
event_rx,
event_abort_tx,
render_tx,
})
}
pub async fn run(&mut self, is_output_tty: bool) -> Result<Option<Entry>> {
info!("Starting backend event loop");
let event_loop = EventLoop::new(self.tick_rate, true);
self.event_rx = event_loop.rx;
self.event_abort_tx = event_loop.abort_tx;
// Rendering loop
debug!("Starting rendering loop");
let (render_tx, render_rx) = mpsc::unbounded_channel();
self.render_tx = render_tx.clone();
let action_tx_r = self.action_tx.clone();
let config_r = self.config.clone();
let television_r = self.television.clone();
let frame_rate = self.frame_rate;
let rendering_task = tokio::spawn(async move {
render(
render_rx,
//render_tx,
action_tx_r,
config_r,
television_r,
frame_rate,
is_output_tty,
)
.await
});
// event handling loop
debug!("Starting event handling loop");
let action_tx = self.action_tx.clone();
loop {
// handle event and convert to action
if let Some(event) = self.event_rx.recv().await {
let action = self.convert_event_to_action(event).await;
action_tx.send(action)?;
}
let maybe_selected = self.handle_actions().await?;
if self.should_quit {
// send a termination signal to the event loop
self.event_abort_tx.send(())?;
// wait for the rendering task to finish
rendering_task.await??;
return Ok(maybe_selected);
}
}
}
async fn convert_event_to_action(&self, event: Event<Key>) -> Action {
match event {
Event::Input(keycode) => {
info!("{:?}", keycode);
// if the current component is the television
// and the mode is input, automatically handle
// (these mappings aren't exposed to the user)
if self.television.lock().await.is_input_focused() {
match keycode {
Key::Backspace => return Action::DeletePrevChar,
Key::Delete => return Action::DeleteNextChar,
Key::Left => return Action::GoToPrevChar,
Key::Right => return Action::GoToNextChar,
Key::Home | Key::Ctrl('a') => {
return Action::GoToInputStart
}
Key::End | Key::Ctrl('e') => {
return Action::GoToInputEnd
}
Key::Char(c) => return Action::AddInputChar(c),
_ => {}
}
}
return self
.config
.keybindings
.get(&self.mode)
.and_then(|keymap| keymap.get(&keycode).cloned())
.unwrap_or(if let Key::Char(c) = keycode {
Action::AddInputChar(c)
} else {
Action::NoOp
});
}
// terminal events
Event::Tick => Action::Tick,
Event::Resize(x, y) => Action::Resize(x, y),
Event::FocusGained => Action::Resume,
Event::FocusLost => Action::Suspend,
_ => Action::NoOp,
}
}
async fn handle_actions(&mut self) -> Result<Option<Entry>> {
while let Ok(action) = self.action_rx.try_recv() {
if action != Action::Tick && action != Action::Render {
debug!("{action:?}");
}
match action {
Action::Quit => {
self.should_quit = true;
self.render_tx.send(RenderingTask::Quit)?;
}
Action::Suspend => {
self.should_suspend = true;
self.render_tx.send(RenderingTask::Suspend)?;
}
Action::Resume => {
self.should_suspend = false;
self.render_tx.send(RenderingTask::Resume)?;
}
Action::SelectEntry => {
self.should_quit = true;
self.render_tx.send(RenderingTask::Quit)?;
return Ok(self
.television
.lock()
.await
.get_selected_entry());
}
Action::ClearScreen => {
self.render_tx.send(RenderingTask::ClearScreen)?
}
Action::Resize(w, h) => {
self.render_tx.send(RenderingTask::Resize(w, h))?
}
Action::Render => {
self.render_tx.send(RenderingTask::Render)?
}
_ => {}
}
if let Some(action) =
self.television.lock().await.update(action.clone()).await?
{
self.action_tx.send(action)?;
};
}
Ok(None)
}
}

View File

@ -0,0 +1,93 @@
use crate::entry::Entry;
use television_derive::CliChannel;
mod alias;
mod env;
mod files;
mod grep;
mod stdin;
/// The interface that all television channels must implement.
///
/// # Important
/// The `TelevisionChannel` requires the `Send` trait to be implemented as
/// well. This is necessary to allow the channels to be used in a
/// multi-threaded environment.
///
/// # Methods
/// - `find`: Find entries that match the given pattern. This method does not
/// return anything and instead typically stores the results internally for
/// later retrieval allowing to perform the search in the background while
/// incrementally polling the results.
/// ```rust
/// fn find(&mut self, pattern: &str);
/// ```
/// - `results`: Get the results of the search (at a given point in time, see
/// above). This method returns a specific portion of entries that match the
/// search pattern. The `num_entries` parameter specifies the number of
/// entries to return and the `offset` parameter specifies the starting index
/// of the entries to return.
/// ```rust
/// fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry>;
/// ```
/// - `get_result`: Get a specific result by its index.
/// ```rust
/// fn get_result(&self, index: u32) -> Option<Entry>;
/// ```
/// - `result_count`: Get the number of results currently available.
/// ```rust
/// fn result_count(&self) -> u32;
/// ```
/// - `total_count`: Get the total number of entries currently available (e.g.
/// the haystack).
/// ```rust
/// fn total_count(&self) -> u32;
/// ```
///
pub trait TelevisionChannel: Send {
/// Find entries that match the given pattern.
///
/// This method does not return anything and instead typically stores the
/// results internally for later retrieval allowing to perform the search
/// in the background while incrementally polling the results with
/// `results`.
fn find(&mut self, pattern: &str);
/// Get the results of the search (that are currently available).
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry>;
/// Get a specific result by its index.
fn get_result(&self, index: u32) -> Option<Entry>;
/// Get the number of results currently available.
fn result_count(&self) -> u32;
/// Get the total number of entries currently available.
fn total_count(&self) -> u32;
}
/// The available television channels.
///
/// Each channel is represented by a variant of the enum and should implement
/// the `TelevisionChannel` trait.
///
/// # Important
/// When adding a new channel, make sure to add a new variant to this enum and
/// implement the `TelevisionChannel` trait for it.
///
/// # Derive
/// The `CliChannel` derive macro generates the necessary glue code to
/// automatically create the corresponding `CliTvChannel` enum with unit
/// variants that can be used to select the channel from the command line.
/// It also generates the necessary glue code to automatically create a channel
/// instance from the selected CLI enum variant.
///
#[allow(dead_code, clippy::module_name_repetitions)]
#[derive(CliChannel)]
pub enum AvailableChannels {
Env(env::Channel),
Files(files::Channel),
Grep(grep::Channel),
Stdin(stdin::Channel),
Alias(alias::Channel),
}

View File

@ -0,0 +1,203 @@
use std::sync::Arc;
use devicons::FileIcon;
use nucleo::{Config, Nucleo};
use tracing::debug;
use crate::channels::TelevisionChannel;
use crate::entry::Entry;
use crate::fuzzy::MATCHER;
use crate::previewers::PreviewType;
use crate::utils::indices::sep_name_and_value_indices;
#[derive(Debug, Clone)]
struct Alias {
name: String,
value: String,
}
pub struct Channel {
matcher: Nucleo<Alias>,
last_pattern: String,
file_icon: FileIcon,
result_count: u32,
total_count: u32,
}
const NUM_THREADS: usize = 1;
const FILE_ICON_STR: &str = "nu";
const SHELL_ENV_VAR: &str = "SHELL";
fn get_current_shell() -> Option<String> {
std::env::var(SHELL_ENV_VAR).ok()
}
fn get_raw_aliases(shell: &str) -> Vec<String> {
match shell {
"bash" => {
let output = std::process::Command::new("bash")
.arg("-i")
.arg("-c")
.arg("alias")
.output()
.expect("failed to execute process");
let aliases = String::from_utf8(output.stdout).unwrap();
aliases
.lines()
.map(std::string::ToString::to_string)
.collect()
}
"zsh" => {
let output = std::process::Command::new("zsh")
.arg("-i")
.arg("-c")
.arg("alias")
.output()
.expect("failed to execute process");
let aliases = String::from_utf8(output.stdout).unwrap();
aliases
.lines()
.map(std::string::ToString::to_string)
.collect()
}
_ => Vec::new(),
}
}
impl Channel {
pub fn new() -> Self {
let raw_shell = get_current_shell().unwrap_or("bash".to_string());
let shell = raw_shell.split('/').last().unwrap();
debug!("Current shell: {}", shell);
let raw_aliases = get_raw_aliases(shell);
debug!("Aliases: {:?}", raw_aliases);
let parsed_aliases = raw_aliases
.iter()
.map(|alias| {
let mut parts = alias.split('=');
let name = parts.next().unwrap().to_string();
let value = parts.next().unwrap().to_string();
Alias { name, value }
})
.collect::<Vec<_>>();
let matcher = Nucleo::new(
Config::DEFAULT,
Arc::new(|| {}),
Some(NUM_THREADS),
1,
);
let injector = matcher.injector();
for alias in parsed_aliases {
let _ = injector.push(alias.clone(), |_, cols| {
cols[0] = (alias.name.clone() + &alias.value).into();
});
}
Self {
matcher,
last_pattern: String::new(),
file_icon: FileIcon::from(FILE_ICON_STR),
result_count: 0,
total_count: 0,
}
}
const MATCHER_TICK_TIMEOUT: u64 = 10;
}
impl TelevisionChannel for Channel {
fn find(&mut self, pattern: &str) {
if pattern != self.last_pattern {
self.matcher.pattern.reparse(
0,
pattern,
nucleo::pattern::CaseMatching::Smart,
nucleo::pattern::Normalization::Smart,
pattern.starts_with(&self.last_pattern),
);
self.last_pattern = pattern.to_string();
}
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT);
let snapshot = self.matcher.snapshot();
if status.changed {
self.result_count = snapshot.matched_item_count();
self.total_count = snapshot.item_count();
}
let mut col_indices = Vec::new();
let mut matcher = MATCHER.lock();
let icon = self.file_icon;
snapshot
.matched_items(
offset
..(num_entries + offset)
.min(snapshot.matched_item_count()),
)
.map(move |item| {
snapshot.pattern().column_pattern(0).indices(
item.matcher_columns[0].slice(..),
&mut matcher,
&mut col_indices,
);
col_indices.sort_unstable();
col_indices.dedup();
let (
name_indices,
value_indices,
should_add_name_indices,
should_add_value_indices,
) = sep_name_and_value_indices(
&mut col_indices,
u32::try_from(item.data.name.len()).unwrap(),
);
let mut entry =
Entry::new(item.data.name.clone(), PreviewType::EnvVar)
.with_value(item.data.value.clone())
.with_icon(icon);
if should_add_name_indices {
entry = entry.with_name_match_ranges(
name_indices.into_iter().map(|i| (i, i + 1)).collect(),
);
}
if should_add_value_indices {
entry = entry.with_value_match_ranges(
value_indices
.into_iter()
.map(|i| (i, i + 1))
.collect(),
);
}
entry
})
.collect()
}
fn get_result(&self, index: u32) -> Option<super::Entry> {
let snapshot = self.matcher.snapshot();
snapshot.get_matched_item(index).map(|item| {
Entry::new(item.data.name.clone(), PreviewType::EnvVar)
.with_value(item.data.value.clone())
.with_icon(self.file_icon)
})
}
fn result_count(&self) -> u32 {
self.result_count
}
fn total_count(&self) -> u32 {
self.total_count
}
}

View File

@ -0,0 +1,150 @@
use devicons::FileIcon;
use nucleo::{
pattern::{CaseMatching, Normalization},
Config, Nucleo,
};
use std::sync::Arc;
use super::TelevisionChannel;
use crate::entry::Entry;
use crate::fuzzy::MATCHER;
use crate::previewers::PreviewType;
use crate::utils::indices::sep_name_and_value_indices;
struct EnvVar {
name: String,
value: String,
}
#[allow(clippy::module_name_repetitions)]
pub struct Channel {
matcher: Nucleo<EnvVar>,
last_pattern: String,
file_icon: FileIcon,
result_count: u32,
total_count: u32,
}
const NUM_THREADS: usize = 1;
const FILE_ICON_STR: &str = "config";
impl Channel {
pub fn new() -> Self {
let matcher = Nucleo::new(
Config::DEFAULT,
Arc::new(|| {}),
Some(NUM_THREADS),
1,
);
let injector = matcher.injector();
for (name, value) in std::env::vars() {
let _ = injector.push(EnvVar { name, value }, |e, cols| {
cols[0] = (e.name.clone() + &e.value).into();
});
}
Channel {
matcher,
last_pattern: String::new(),
file_icon: FileIcon::from(FILE_ICON_STR),
result_count: 0,
total_count: 0,
}
}
const MATCHER_TICK_TIMEOUT: u64 = 10;
}
impl TelevisionChannel for Channel {
fn find(&mut self, pattern: &str) {
if pattern != self.last_pattern {
self.matcher.pattern.reparse(
0,
pattern,
CaseMatching::Smart,
Normalization::Smart,
pattern.starts_with(&self.last_pattern),
);
self.last_pattern = pattern.to_string();
}
}
fn result_count(&self) -> u32 {
self.result_count
}
fn total_count(&self) -> u32 {
self.total_count
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT);
let snapshot = self.matcher.snapshot();
if status.changed {
self.result_count = snapshot.matched_item_count();
self.total_count = snapshot.item_count();
}
let mut col_indices = Vec::new();
let mut matcher = MATCHER.lock();
let icon = self.file_icon;
snapshot
.matched_items(
offset
..(num_entries + offset)
.min(snapshot.matched_item_count()),
)
.map(move |item| {
snapshot.pattern().column_pattern(0).indices(
item.matcher_columns[0].slice(..),
&mut matcher,
&mut col_indices,
);
col_indices.sort_unstable();
col_indices.dedup();
let (
name_indices,
value_indices,
should_add_name_indices,
should_add_value_indices,
) = sep_name_and_value_indices(
&mut col_indices,
u32::try_from(item.data.name.len()).unwrap(),
);
let mut entry =
Entry::new(item.data.name.clone(), PreviewType::EnvVar)
.with_value(item.data.value.clone())
.with_icon(icon);
if should_add_name_indices {
entry = entry.with_name_match_ranges(
name_indices.into_iter().map(|i| (i, i + 1)).collect(),
);
}
if should_add_value_indices {
entry = entry.with_value_match_ranges(
value_indices
.into_iter()
.map(|i| (i, i + 1))
.collect(),
);
}
entry
})
.collect()
}
fn get_result(&self, index: u32) -> Option<Entry> {
let snapshot = self.matcher.snapshot();
snapshot.get_matched_item(index).map(|item| {
let name = item.data.name.clone();
let value = item.data.value.clone();
Entry::new(name, PreviewType::EnvVar)
.with_value(value)
.with_icon(self.file_icon)
})
}
}

View File

@ -0,0 +1,140 @@
use devicons::FileIcon;
use nucleo::{
pattern::{CaseMatching, Normalization},
Config, Injector, Nucleo,
};
use std::{path::PathBuf, sync::Arc};
use ignore::DirEntry;
use super::TelevisionChannel;
use crate::entry::Entry;
use crate::fuzzy::MATCHER;
use crate::previewers::PreviewType;
use crate::utils::files::{walk_builder, DEFAULT_NUM_THREADS};
pub struct Channel {
matcher: Nucleo<DirEntry>,
last_pattern: String,
result_count: u32,
total_count: u32,
}
impl Channel {
pub fn new() -> Self {
let matcher = Nucleo::new(
Config::DEFAULT.match_paths(),
Arc::new(|| {}),
None,
1,
);
// start loading files in the background
tokio::spawn(load_files(
std::env::current_dir().unwrap(),
matcher.injector(),
));
Channel {
matcher,
last_pattern: String::new(),
result_count: 0,
total_count: 0,
}
}
const MATCHER_TICK_TIMEOUT: u64 = 10;
}
impl TelevisionChannel for Channel {
fn find(&mut self, pattern: &str) {
if pattern != self.last_pattern {
self.matcher.pattern.reparse(
0,
pattern,
CaseMatching::Smart,
Normalization::Smart,
pattern.starts_with(&self.last_pattern),
);
self.last_pattern = pattern.to_string();
}
}
fn result_count(&self) -> u32 {
self.result_count
}
fn total_count(&self) -> u32 {
self.total_count
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT);
let snapshot = self.matcher.snapshot();
if status.changed {
self.result_count = snapshot.matched_item_count();
self.total_count = snapshot.item_count();
}
let mut indices = Vec::new();
let mut matcher = MATCHER.lock();
snapshot
.matched_items(
offset
..(num_entries + offset)
.min(snapshot.matched_item_count()),
)
.map(move |item| {
snapshot.pattern().column_pattern(0).indices(
item.matcher_columns[0].slice(..),
&mut matcher,
&mut indices,
);
indices.sort_unstable();
indices.dedup();
let indices = indices.drain(..);
let path = item.matcher_columns[0].to_string();
Entry::new(path.clone(), PreviewType::Files)
.with_name_match_ranges(
indices.map(|i| (i, i + 1)).collect(),
)
.with_icon(FileIcon::from(&path))
})
.collect()
}
fn get_result(&self, index: u32) -> Option<Entry> {
let snapshot = self.matcher.snapshot();
snapshot.get_matched_item(index).map(|item| {
let path = item.matcher_columns[0].to_string();
Entry::new(path.clone(), PreviewType::Files)
.with_icon(FileIcon::from(&path))
})
}
}
#[allow(clippy::unused_async)]
async fn load_files(path: PathBuf, injector: Injector<DirEntry>) {
let current_dir = std::env::current_dir().unwrap();
let walker = walk_builder(&path, *DEFAULT_NUM_THREADS).build_parallel();
walker.run(|| {
let injector = injector.clone();
let current_dir = current_dir.clone();
Box::new(move |result| {
if let Ok(entry) = result {
if entry.file_type().unwrap().is_file() {
// Send the path via the async channel
let _ = injector.push(entry, |e, cols| {
cols[0] = e
.path()
.strip_prefix(&current_dir)
.unwrap()
.to_string_lossy()
.into();
});
}
}
ignore::WalkState::Continue
})
});
}

View File

@ -0,0 +1,221 @@
use devicons::FileIcon;
use nucleo::{
pattern::{CaseMatching, Normalization},
Config, Injector, Nucleo,
};
use std::{
fs::File,
io::{BufRead, Read, Seek},
path::PathBuf,
sync::Arc,
};
use tracing::info;
use super::TelevisionChannel;
use crate::entry::Entry;
use crate::fuzzy::MATCHER;
use crate::previewers::PreviewType;
use crate::utils::{
files::{
is_not_text, is_valid_utf8, walk_builder, DEFAULT_NUM_THREADS,
},
strings::preprocess_line,
};
#[derive(Debug)]
struct CandidateLine {
path: PathBuf,
line: String,
line_number: usize,
}
impl CandidateLine {
fn new(path: PathBuf, line: String, line_number: usize) -> Self {
CandidateLine {
path,
line,
line_number,
}
}
}
#[allow(clippy::module_name_repetitions)]
pub struct Channel {
matcher: Nucleo<CandidateLine>,
last_pattern: String,
result_count: u32,
total_count: u32,
}
impl Channel {
pub fn new() -> Self {
let matcher = Nucleo::new(Config::DEFAULT, Arc::new(|| {}), None, 1);
// start loading files in the background
tokio::spawn(load_candidates(
std::env::current_dir().unwrap(),
matcher.injector(),
));
Channel {
matcher,
last_pattern: String::new(),
result_count: 0,
total_count: 0,
}
}
const MATCHER_TICK_TIMEOUT: u64 = 10;
}
impl TelevisionChannel for Channel {
fn find(&mut self, pattern: &str) {
if pattern != self.last_pattern {
self.matcher.pattern.reparse(
0,
pattern,
CaseMatching::Smart,
Normalization::Smart,
pattern.starts_with(&self.last_pattern),
);
self.last_pattern = pattern.to_string();
}
}
fn result_count(&self) -> u32 {
self.result_count
}
fn total_count(&self) -> u32 {
self.total_count
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT);
let snapshot = self.matcher.snapshot();
if status.changed {
self.result_count = snapshot.matched_item_count();
self.total_count = snapshot.item_count();
}
let mut indices = Vec::new();
let mut matcher = MATCHER.lock();
snapshot
.matched_items(
offset
..(num_entries + offset)
.min(snapshot.matched_item_count()),
)
.map(move |item| {
snapshot.pattern().column_pattern(0).indices(
item.matcher_columns[0].slice(..),
&mut matcher,
&mut indices,
);
indices.sort_unstable();
indices.dedup();
let indices = indices.drain(..);
let line = item.matcher_columns[0].to_string();
let display_path =
item.data.path.to_string_lossy().to_string();
Entry::new(
display_path.clone() + &item.data.line_number.to_string(),
PreviewType::Files,
)
.with_display_name(display_path)
.with_value(line)
.with_value_match_ranges(indices.map(|i| (i, i + 1)).collect())
.with_icon(FileIcon::from(item.data.path.as_path()))
.with_line_number(item.data.line_number)
})
.collect()
}
fn get_result(&self, index: u32) -> Option<Entry> {
let snapshot = self.matcher.snapshot();
snapshot.get_matched_item(index).map(|item| {
let display_path = item.data.path.to_string_lossy().to_string();
Entry::new(display_path.clone(), PreviewType::Files)
.with_display_name(
display_path.clone()
+ ":"
+ &item.data.line_number.to_string(),
)
.with_line_number(item.data.line_number)
})
}
}
#[allow(clippy::unused_async)]
async fn load_candidates(path: PathBuf, injector: Injector<CandidateLine>) {
let current_dir = std::env::current_dir().unwrap();
let walker = walk_builder(&path, *DEFAULT_NUM_THREADS).build_parallel();
walker.run(|| {
let injector = injector.clone();
let current_dir = current_dir.clone();
Box::new(move |result| {
if let Ok(entry) = result {
if entry.file_type().unwrap().is_file() {
// iterate over the lines of the file
match File::open(entry.path()) {
Ok(file) => {
let mut reader = std::io::BufReader::new(&file);
let mut buffer = [0u8; 128];
match reader.read(&mut buffer) {
Ok(bytes_read) => {
if (bytes_read == 0)
|| is_not_text(&buffer)
.unwrap_or(false)
|| !is_valid_utf8(&buffer)
{
return ignore::WalkState::Continue;
}
reader
.seek(std::io::SeekFrom::Start(0))
.unwrap();
}
Err(_) => {
return ignore::WalkState::Continue;
}
}
let mut line_number = 0;
for maybe_line in reader.lines() {
match maybe_line {
Ok(l) => {
line_number += 1;
let candidate = CandidateLine::new(
entry
.path()
.strip_prefix(&current_dir)
.unwrap()
.to_path_buf(),
preprocess_line(&l),
line_number,
);
// Send the line via the async channel
let _ = injector.push(
candidate,
|c, cols| {
cols[0] =
c.line.clone().into();
},
);
}
Err(e) => {
info!("Error reading line: {:?}", e);
break;
}
}
}
}
Err(e) => {
info!("Error opening file: {:?}", e);
}
}
}
}
ignore::WalkState::Continue
})
});
}

View File

@ -0,0 +1,123 @@
use std::{io::BufRead, sync::Arc};
use devicons::FileIcon;
use nucleo::{Config, Nucleo};
use tracing::debug;
use crate::entry::Entry;
use crate::fuzzy::MATCHER;
use crate::previewers::PreviewType;
use super::TelevisionChannel;
pub struct Channel {
matcher: Nucleo<String>,
last_pattern: String,
result_count: u32,
total_count: u32,
icon: FileIcon,
}
const NUM_THREADS: usize = 2;
impl Channel {
pub fn new() -> Self {
let mut lines = Vec::new();
for line in std::io::stdin().lock().lines().map_while(Result::ok) {
debug!("Read line: {:?}", line);
lines.push(line);
}
let matcher = Nucleo::new(
Config::DEFAULT,
Arc::new(|| {}),
Some(NUM_THREADS),
1,
);
let injector = matcher.injector();
for line in &lines {
let _ = injector.push(line.clone(), |e, cols| {
cols[0] = e.clone().into();
});
}
Self {
matcher,
last_pattern: String::new(),
result_count: 0,
total_count: 0,
icon: FileIcon::from("nu"),
}
}
const MATCHER_TICK_TIMEOUT: u64 = 10;
}
impl TelevisionChannel for Channel {
// maybe this could be sort of automatic with a blanket impl (making Finder generic over
// its matcher type or something)
fn find(&mut self, pattern: &str) {
if pattern != self.last_pattern {
self.matcher.pattern.reparse(
0,
pattern,
nucleo::pattern::CaseMatching::Smart,
nucleo::pattern::Normalization::Smart,
pattern.starts_with(&self.last_pattern),
);
self.last_pattern = pattern.to_string();
}
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
let status = self.matcher.tick(Self::MATCHER_TICK_TIMEOUT);
let snapshot = self.matcher.snapshot();
if status.changed {
self.result_count = snapshot.matched_item_count();
self.total_count = snapshot.item_count();
}
let mut indices = Vec::new();
let mut matcher = MATCHER.lock();
let icon = self.icon;
snapshot
.matched_items(
offset
..(num_entries + offset)
.min(snapshot.matched_item_count()),
)
.map(move |item| {
snapshot.pattern().column_pattern(0).indices(
item.matcher_columns[0].slice(..),
&mut matcher,
&mut indices,
);
indices.sort_unstable();
indices.dedup();
let indices = indices.drain(..);
let content = item.matcher_columns[0].to_string();
Entry::new(content.clone(), PreviewType::Basic)
.with_name_match_ranges(
indices.map(|i| (i, i + 1)).collect(),
)
.with_icon(icon)
})
.collect()
}
fn get_result(&self, index: u32) -> Option<super::Entry> {
let snapshot = self.matcher.snapshot();
snapshot.get_matched_item(index).map(|item| {
let content = item.matcher_columns[0].to_string();
Entry::new(content.clone(), PreviewType::Basic)
.with_icon(self.icon)
})
}
fn result_count(&self) -> u32 {
self.result_count
}
fn total_count(&self) -> u32 {
self.total_count
}
}

View File

@ -1,12 +1,17 @@
use clap::Parser;
use crate::channels::CliTvChannel;
use crate::config::{get_config_dir, get_data_dir};
#[derive(Parser, Debug)]
#[command(author, version = version(), about)]
pub struct Cli {
/// Which channel shall we watch?
#[arg(value_enum, default_value = "files")]
pub channel: CliTvChannel,
/// Tick rate, i.e. number of ticks per second
#[arg(short, long, value_name = "FLOAT", default_value_t = 4.0)]
#[arg(short, long, value_name = "FLOAT", default_value_t = 50.0)]
pub tick_rate: f64,
/// Frame rate, i.e. number of frames per second

View File

@ -0,0 +1 @@

View File

@ -1,6 +1,4 @@
#![allow(dead_code)] // Remove this once you start using the code
use std::{collections::HashMap, env, path::PathBuf};
use std::{collections::HashMap, env, num::NonZeroUsize, path::PathBuf};
use color_eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
@ -11,9 +9,14 @@ use ratatui::style::{Color, Modifier, Style};
use serde::{de::Deserializer, Deserialize};
use tracing::error;
use crate::{action::Action, app::Mode};
use crate::{
action::Action,
app::Mode,
event::{convert_raw_event_to_key, Key},
};
const CONFIG: &str = include_str!("../.config/config.json5");
//const CONFIG: &str = include_str!("../.config/config.json5");
const CONFIG: &str = include_str!("../../.config/config.toml");
#[derive(Clone, Debug, Deserialize, Default)]
pub struct AppConfig {
@ -34,7 +37,8 @@ pub struct Config {
}
lazy_static! {
pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string();
pub static ref PROJECT_NAME: String =
env!("CARGO_CRATE_NAME").to_uppercase().to_string();
pub static ref DATA_FOLDER: Option<PathBuf> =
env::var(format!("{}_DATA", PROJECT_NAME.clone()))
.ok()
@ -47,7 +51,8 @@ lazy_static! {
impl Config {
pub fn new() -> Result<Self, config::ConfigError> {
let default_config: Config = json5::from_str(CONFIG).unwrap();
//let default_config: Config = json5::from_str(CONFIG).unwrap();
let default_config: Config = toml::from_str(CONFIG).unwrap();
let data_dir = get_data_dir();
let config_dir = get_config_dir();
let mut builder = config::Config::builder()
@ -80,9 +85,7 @@ impl Config {
for (mode, default_bindings) in default_config.keybindings.iter() {
let user_bindings = cfg.keybindings.entry(*mode).or_default();
for (key, cmd) in default_bindings.iter() {
user_bindings
.entry(key.clone())
.or_insert_with(|| cmd.clone());
user_bindings.entry(*key).or_insert_with(|| cmd.clone());
}
}
for (mode, default_styles) in default_config.styles.iter() {
@ -119,25 +122,28 @@ pub fn get_config_dir() -> PathBuf {
}
fn project_directory() -> Option<ProjectDirs> {
ProjectDirs::from("com", "kdheepak", env!("CARGO_PKG_NAME"))
ProjectDirs::from("com", "alexpasmantier", env!("CARGO_PKG_NAME"))
}
#[derive(Clone, Debug, Default, Deref, DerefMut)]
pub struct KeyBindings(pub HashMap<Mode, HashMap<Vec<KeyEvent>, Action>>);
pub struct KeyBindings(pub HashMap<Mode, HashMap<Key, Action>>);
impl<'de> Deserialize<'de> for KeyBindings {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let parsed_map = HashMap::<Mode, HashMap<String, Action>>::deserialize(deserializer)?;
let parsed_map =
HashMap::<Mode, HashMap<String, Action>>::deserialize(
deserializer,
)?;
let keybindings = parsed_map
.into_iter()
.map(|(mode, inner_map)| {
let converted_inner_map = inner_map
.into_iter()
.map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd))
.map(|(key_str, cmd)| (parse_key(&key_str).unwrap(), cmd))
.collect();
(mode, converted_inner_map)
})
@ -291,31 +297,32 @@ pub fn key_event_to_string(key_event: &KeyEvent) -> String {
key
}
pub fn parse_key_sequence(raw: &str) -> Result<Vec<KeyEvent>, String> {
if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() {
pub fn parse_key(raw: &str) -> Result<Key, String> {
if raw.chars().filter(|c| *c == '>').count()
!= raw.chars().filter(|c| *c == '<').count()
{
return Err(format!("Unable to parse `{}`", raw));
}
let raw = if !raw.contains("><") {
let raw = raw.strip_prefix('<').unwrap_or(raw);
let raw = raw.strip_prefix('>').unwrap_or(raw);
let raw = raw.strip_suffix('>').unwrap_or(raw);
raw
} else {
raw
};
let sequences = raw
.split("><")
.map(|seq| {
if let Some(s) = seq.strip_prefix('<') {
s
} else if let Some(s) = seq.strip_suffix('>') {
s
} else {
seq
}
})
.collect::<Vec<_>>();
let key_event = parse_key_event(raw)?;
Ok(convert_raw_event_to_key(key_event))
}
sequences.into_iter().map(parse_key_event).collect()
pub fn default_num_threads() -> NonZeroUsize {
// default to 1 thread if we can't determine the number of available threads
let default = NonZeroUsize::MIN;
// never use more than 32 threads to avoid startup overhead
let limit = NonZeroUsize::new(32).unwrap();
std::thread::available_parallelism()
.unwrap_or(default)
.min(limit)
}
#[derive(Clone, Debug, Default, Deref, DerefMut)]
@ -326,7 +333,10 @@ impl<'de> Deserialize<'de> for Styles {
where
D: Deserializer<'de>,
{
let parsed_map = HashMap::<Mode, HashMap<String, String>>::deserialize(deserializer)?;
let parsed_map =
HashMap::<Mode, HashMap<String, String>>::deserialize(
deserializer,
)?;
let styles = parsed_map
.into_iter()
@ -405,9 +415,12 @@ fn parse_color(s: &str) -> Option<Color> {
.unwrap_or_default();
Some(Color::Indexed(c))
} else if s.contains("rgb") {
let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8;
let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8;
let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8;
let red =
(s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8;
let green =
(s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8;
let blue =
(s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8;
let c = 16 + red * 36 + green * 6 + blue;
Some(Color::Indexed(c))
} else if s == "bold black" {
@ -480,7 +493,8 @@ mod tests {
#[test]
fn test_process_color_string() {
let (color, modifiers) = process_color_string("underline bold inverse gray");
let (color, modifiers) =
process_color_string("underline bold inverse gray");
assert_eq!(color, "gray");
assert!(modifiers.contains(Modifier::UNDERLINED));
assert!(modifiers.contains(Modifier::BOLD));
@ -505,9 +519,9 @@ mod tests {
let c = Config::new()?;
assert_eq!(
c.keybindings
.get(&Mode::Home)
.get(&Mode::Input)
.unwrap()
.get(&parse_key_sequence("<q>").unwrap_or_default())
.get(&parse_key("<q>").unwrap())
.unwrap(),
&Action::Quit
);
@ -562,7 +576,10 @@ mod tests {
assert_eq!(
parse_key_event("ctrl-shift-enter").unwrap(),
KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
KeyEvent::new(
KeyCode::Enter,
KeyModifiers::CONTROL | KeyModifiers::SHIFT
)
);
}

View File

@ -0,0 +1,92 @@
use devicons::FileIcon;
use crate::previewers::PreviewType;
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub struct Entry {
pub name: String,
display_name: Option<String>,
pub value: Option<String>,
pub name_match_ranges: Option<Vec<(u32, u32)>>,
pub value_match_ranges: Option<Vec<(u32, u32)>>,
pub icon: Option<FileIcon>,
pub line_number: Option<usize>,
pub preview_type: PreviewType,
}
impl Entry {
pub fn new(name: String, preview_type: PreviewType) -> Self {
Self {
name,
display_name: None,
value: None,
name_match_ranges: None,
value_match_ranges: None,
icon: None,
line_number: None,
preview_type,
}
}
pub fn with_display_name(mut self, display_name: String) -> Self {
self.display_name = Some(display_name);
self
}
pub fn with_value(mut self, value: String) -> Self {
self.value = Some(value);
self
}
pub fn with_name_match_ranges(
mut self,
name_match_ranges: Vec<(u32, u32)>,
) -> Self {
self.name_match_ranges = Some(name_match_ranges);
self
}
pub fn with_value_match_ranges(
mut self,
value_match_ranges: Vec<(u32, u32)>,
) -> Self {
self.value_match_ranges = Some(value_match_ranges);
self
}
pub fn with_icon(mut self, icon: FileIcon) -> Self {
self.icon = Some(icon);
self
}
pub fn with_line_number(mut self, line_number: usize) -> Self {
self.line_number = Some(line_number);
self
}
pub fn display_name(&self) -> &str {
self.display_name.as_ref().unwrap_or(&self.name)
}
pub fn stdout_repr(&self) -> String {
let mut repr = self.name.clone();
if let Some(line_number) = self.line_number {
repr.push_str(&format!(":{line_number}"));
}
if let Some(preview) = &self.value {
repr.push_str(&format!("\n{preview}"));
}
repr
}
}
pub const ENTRY_PLACEHOLDER: Entry = Entry {
name: String::new(),
display_name: None,
value: None,
name_match_ranges: None,
value_match_ranges: None,
icon: None,
line_number: None,
preview_type: PreviewType::EnvVar,
};

View File

@ -15,7 +15,7 @@ pub fn init() -> Result<()> {
.into_hooks();
eyre_hook.install()?;
std::panic::set_hook(Box::new(move |panic_info| {
if let Ok(mut t) = crate::tui::Tui::new() {
if let Ok(mut t) = crate::tui::Tui::new(std::io::stderr()) {
if let Err(r) = t.exit() {
error!("Unable to exit Terminal: {:?}", r);
}
@ -27,8 +27,9 @@ pub fn init() -> Result<()> {
let metadata = metadata!();
let file_path = handle_dump(&metadata, panic_info);
// prints human-panic message
print_msg(file_path, &metadata)
.expect("human-panic: printing error message to console failed");
print_msg(file_path, &metadata).expect(
"human-panic: printing error message to console failed",
);
eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr
}
let msg = format!("{}", panic_hook.panic_report(panic_info));

214
crates/television/event.rs Normal file
View File

@ -0,0 +1,214 @@
use std::{
future::Future,
pin::Pin,
task::{Context, Poll as TaskPoll},
time::Duration,
};
use crossterm::event::{
KeyCode::{
BackTab, Backspace, Char, Delete, Down, End, Enter, Esc, Home, Insert,
Left, Null, PageDown, PageUp, Right, Tab, Up, F,
},
KeyEvent, KeyModifiers,
};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tracing::warn;
#[derive(Debug, Clone, Copy)]
pub enum Event<I> {
Closed,
Input(I),
FocusLost,
FocusGained,
Resize(u16, u16),
Tick,
}
#[derive(
Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Hash,
)]
pub enum Key {
Backspace,
Enter,
Left,
Right,
Up,
Down,
CtrlBackspace,
CtrlEnter,
CtrlLeft,
CtrlRight,
CtrlUp,
CtrlDown,
CtrlDelete,
AltBackspace,
AltDelete,
Home,
End,
PageUp,
PageDown,
BackTab,
Delete,
Insert,
F(u8),
Char(char),
Alt(char),
Ctrl(char),
Null,
Esc,
Tab,
}
pub struct EventLoop {
pub rx: mpsc::UnboundedReceiver<Event<Key>>,
//tx: mpsc::UnboundedSender<Event<Key>>,
pub abort_tx: mpsc::UnboundedSender<()>,
//tick_rate: std::time::Duration,
}
struct PollFuture {
timeout: Duration,
}
impl Future for PollFuture {
type Output = bool;
fn poll(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> TaskPoll<Self::Output> {
// Polling crossterm::event::poll, which is a blocking call
// Spawn it in a separate task, to avoid blocking async runtime
match crossterm::event::poll(self.timeout) {
Ok(true) => TaskPoll::Ready(true),
Ok(false) => {
// Register the task to be polled again after a delay to avoid busy-looping
cx.waker().wake_by_ref();
TaskPoll::Pending
}
Err(_) => TaskPoll::Ready(false),
}
}
}
async fn poll_event(timeout: Duration) -> bool {
PollFuture { timeout }.await
}
impl EventLoop {
pub fn new(tick_rate: f64, init: bool) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
let _tx = tx.clone();
let tick_interval =
tokio::time::Duration::from_secs_f64(1.0 / tick_rate);
let (abort, mut abort_recv) = mpsc::unbounded_channel();
if init {
//let mut reader = crossterm::event::EventStream::new();
tokio::spawn(async move {
loop {
//let event = reader.next();
let delay = tokio::time::sleep(tick_interval);
let event_available = poll_event(tick_interval);
tokio::select! {
// if we receive a message on the abort channel, stop the event loop
_ = abort_recv.recv() => {
_tx.send(Event::Closed).unwrap_or_else(|_| warn!("Unable to send Closed event"));
_tx.send(Event::Tick).unwrap_or_else(|_| warn!("Unable to send Tick event"));
break;
},
// if `delay` completes, pass to the next event "frame"
_ = delay => {
_tx.send(Event::Tick).unwrap_or_else(|_| warn!("Unable to send Tick event"));
},
// if the receiver dropped the channel, stop the event loop
_ = _tx.closed() => break,
// if an event was received, process it
_ = event_available => {
let maybe_event = crossterm::event::read();
match maybe_event {
Ok(crossterm::event::Event::Key(key)) => {
let key = convert_raw_event_to_key(key);
_tx.send(Event::Input(key)).unwrap_or_else(|_| warn!("Unable to send {:?} event", key));
},
Ok(crossterm::event::Event::FocusLost) => {
_tx.send(Event::FocusLost).unwrap_or_else(|_| warn!("Unable to send FocusLost event"));
},
Ok(crossterm::event::Event::FocusGained) => {
_tx.send(Event::FocusGained).unwrap_or_else(|_| warn!("Unable to send FocusGained event"));
},
Ok(crossterm::event::Event::Resize(x, y)) => {
_tx.send(Event::Resize(x, y)).unwrap_or_else(|_| warn!("Unable to send Resize event"));
},
_ => {}
}
}
}
}
});
}
Self {
//tx,
rx,
//tick_rate,
abort_tx: abort,
}
}
}
pub fn convert_raw_event_to_key(event: KeyEvent) -> Key {
match event.code {
Backspace => match event.modifiers {
KeyModifiers::CONTROL => Key::CtrlBackspace,
KeyModifiers::ALT => Key::AltBackspace,
_ => Key::Backspace,
},
Delete => match event.modifiers {
KeyModifiers::CONTROL => Key::CtrlDelete,
KeyModifiers::ALT => Key::AltDelete,
_ => Key::Delete,
},
Enter => match event.modifiers {
KeyModifiers::CONTROL => Key::CtrlEnter,
_ => Key::Enter,
},
Up => match event.modifiers {
KeyModifiers::CONTROL => Key::CtrlUp,
_ => Key::Up,
},
Down => match event.modifiers {
KeyModifiers::CONTROL => Key::CtrlDown,
_ => Key::Down,
},
Left => match event.modifiers {
KeyModifiers::CONTROL => Key::CtrlLeft,
_ => Key::Left,
},
Right => match event.modifiers {
KeyModifiers::CONTROL => Key::CtrlRight,
_ => Key::Right,
},
Home => Key::Home,
End => Key::End,
PageUp => Key::PageUp,
PageDown => Key::PageDown,
Tab => Key::Tab,
BackTab => Key::BackTab,
Insert => Key::Insert,
F(k) => Key::F(k),
Null => Key::Null,
Esc => Key::Esc,
Char(c) => match event.modifiers {
KeyModifiers::NONE | KeyModifiers::SHIFT => Key::Char(c),
KeyModifiers::CONTROL => Key::Ctrl(c),
KeyModifiers::ALT => Key::Alt(c),
_ => Key::Null,
},
_ => Key::Null,
}
}

View File

@ -0,0 +1,25 @@
use parking_lot::Mutex;
use std::ops::DerefMut;
pub struct LazyMutex<T> {
inner: Mutex<Option<T>>,
init: fn() -> T,
}
impl<T> LazyMutex<T> {
pub const fn new(init: fn() -> T) -> Self {
Self {
inner: Mutex::new(None),
init,
}
}
pub fn lock(&self) -> impl DerefMut<Target = T> + '_ {
parking_lot::MutexGuard::map(self.inner.lock(), |val| {
val.get_or_insert_with(self.init)
})
}
}
pub static MATCHER: LazyMutex<nucleo::Matcher> =
LazyMutex::new(nucleo::Matcher::default);

View File

@ -5,7 +5,6 @@ use tracing_subscriber::{fmt, prelude::*, EnvFilter};
use crate::config;
lazy_static::lazy_static! {
pub static ref LOG_ENV: String = format!("{}_LOGLEVEL", config::PROJECT_NAME.clone());
pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME"));
}
@ -14,20 +13,14 @@ pub fn init() -> Result<()> {
std::fs::create_dir_all(directory.clone())?;
let log_path = directory.join(LOG_FILE.clone());
let log_file = std::fs::File::create(log_path)?;
let env_filter = EnvFilter::builder().with_default_directive(tracing::Level::INFO.into());
// If the `RUST_LOG` environment variable is set, use that as the default, otherwise use the
// value of the `LOG_ENV` environment variable. If the `LOG_ENV` environment variable contains
// errors, then this will return an error.
let env_filter = env_filter
.try_from_env()
.or_else(|_| env_filter.with_env_var(LOG_ENV.clone()).from_env())?;
let file_subscriber = fmt::layer()
.with_file(true)
.with_line_number(true)
.with_writer(log_file)
.with_target(false)
.with_ansi(false)
.with_filter(env_filter);
.with_filter(EnvFilter::from_default_env());
tracing_subscriber::registry()
.with(file_subscriber)
.with(ErrorLayer::default())

116
crates/television/main.rs Normal file
View File

@ -0,0 +1,116 @@
use std::io::{stdout, IsTerminal, Write};
use clap::Parser;
use color_eyre::Result;
use tracing::{debug, info};
use crate::app::App;
use crate::channels::CliTvChannel;
use crate::cli::Cli;
mod action;
mod app;
mod channels;
mod cli;
mod components;
mod config;
mod entry;
mod errors;
mod event;
mod fuzzy;
mod logging;
mod previewers;
mod render;
mod tui;
mod utils;
pub mod television;
mod ui;
#[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<()> {
crate::errors::init()?;
crate::logging::init()?;
let args = Cli::parse();
let mut app: App = App::new(
{
if is_readable_stdin() {
debug!("Using stdin channel");
CliTvChannel::Stdin
} else {
debug!("Using {:?} channel", args.channel);
args.channel
}
},
args.tick_rate,
args.frame_rate,
)?;
if let Some(entry) = app.run(stdout().is_terminal()).await? {
// print entry to stdout
stdout().flush()?;
info!("{:?}", entry);
writeln!(stdout(), "{}", entry.stdout_repr())?;
}
Ok(())
}
pub fn is_readable_stdin() -> bool {
use std::io::IsTerminal;
#[cfg(unix)]
fn imp() -> bool {
use std::{
fs::File,
os::{fd::AsFd, unix::fs::FileTypeExt},
};
let stdin = std::io::stdin();
let Ok(fd) = stdin.as_fd().try_clone_to_owned() else {
return false;
};
let file = File::from(fd);
let Ok(md) = file.metadata() else {
return false;
};
let ft = md.file_type();
let is_file = ft.is_file();
let is_fifo = ft.is_fifo();
let is_socket = ft.is_socket();
is_file || is_fifo || is_socket
}
#[cfg(windows)]
fn imp() -> bool {
let stdin = winapi_util::HandleRef::stdin();
let typ = match winapi_util::file::typ(stdin) {
Ok(typ) => typ,
Err(err) => {
log::debug!(
"for heuristic stdin detection on Windows, \
could not get file type of stdin \
(thus assuming stdin is not readable): {err}",
);
return false;
}
};
let is_disk = typ.is_disk();
let is_pipe = typ.is_pipe();
let is_readable = is_disk || is_pipe;
log::debug!(
"for heuristic stdin detection on Windows, \
found that is_disk={is_disk} and is_pipe={is_pipe}, \
and thus concluded that is_stdin_readable={is_readable}",
);
is_readable
}
#[cfg(not(any(unix, windows)))]
fn imp() -> bool {
log::debug!("on non-{{Unix,Windows}}, assuming stdin is not readable");
false
}
!std::io::stdin().is_terminal() && imp()
}

View File

@ -0,0 +1,96 @@
use std::sync::Arc;
use crate::entry::Entry;
mod basic;
mod cache;
mod env;
mod files;
// previewer types
pub use basic::BasicPreviewer;
pub use env::EnvVarPreviewer;
pub use files::FilePreviewer;
use ratatui_image::protocol::StatefulProtocol;
use syntect::highlighting::Style;
#[derive(Debug, Clone, Eq, PartialEq, Hash, Default)]
pub enum PreviewType {
#[default]
Basic,
EnvVar,
Files,
}
#[derive(Clone)]
pub enum PreviewContent {
Empty,
FileTooLarge,
HighlightedText(Vec<Vec<(Style, String)>>),
Image(Box<dyn StatefulProtocol>),
Loading,
NotSupported,
PlainText(Vec<String>),
PlainTextWrapped(String),
}
pub const PREVIEW_NOT_SUPPORTED_MSG: &str =
"Preview for this file type is not yet supported";
pub const FILE_TOO_LARGE_MSG: &str = "File too large";
/// A preview of an entry.
///
/// # Fields
/// - `title`: The title of the preview.
/// - `content`: The content of the preview.
#[derive(Clone)]
pub struct Preview {
pub title: String,
pub content: PreviewContent,
}
impl Default for Preview {
fn default() -> Self {
Preview {
title: String::new(),
content: PreviewContent::Empty,
}
}
}
impl Preview {
pub fn new(title: String, content: PreviewContent) -> Self {
Preview { title, content }
}
pub fn total_lines(&self) -> u16 {
match &self.content {
PreviewContent::HighlightedText(lines) => lines.len() as u16,
_ => 0,
}
}
}
pub struct Previewer {
basic: BasicPreviewer,
file: FilePreviewer,
env_var: EnvVarPreviewer,
}
impl Previewer {
pub fn new() -> Self {
Previewer {
basic: BasicPreviewer::new(),
file: FilePreviewer::new(),
env_var: EnvVarPreviewer::new(),
}
}
pub async fn preview(&mut self, entry: &Entry) -> Arc<Preview> {
match entry.preview_type {
PreviewType::Basic => self.basic.preview(entry),
PreviewType::EnvVar => self.env_var.preview(entry),
PreviewType::Files => self.file.preview(entry).await,
}
}
}

View File

@ -0,0 +1,19 @@
use std::sync::Arc;
use crate::entry::Entry;
use crate::previewers::{Preview, PreviewContent};
pub struct BasicPreviewer {}
impl BasicPreviewer {
pub fn new() -> Self {
BasicPreviewer {}
}
pub fn preview(&self, entry: &Entry) -> Arc<Preview> {
Arc::new(Preview {
title: entry.name.clone(),
content: PreviewContent::PlainTextWrapped(entry.name.clone()),
})
}
}

View File

@ -0,0 +1,111 @@
use std::{
collections::{HashMap, HashSet, VecDeque},
sync::Arc,
};
use tracing::debug;
use crate::previewers::Preview;
/// TODO: add unit tests
/// A ring buffer that also keeps track of the keys it contains to avoid duplicates.
///
/// I'm planning on using this as a backend LRU-cache for the preview cache.
/// Basic idea:
/// - When a new key is pushed, if it's already in the buffer, do nothing.
/// - If the buffer is full, remove the oldest key and push the new key.
struct RingSet<T> {
ring_buffer: VecDeque<T>,
known_keys: HashSet<T>,
capacity: usize,
}
impl<T> RingSet<T>
where
T: Eq + std::hash::Hash + Clone + std::fmt::Debug,
{
pub fn with_capacity(capacity: usize) -> Self {
RingSet {
ring_buffer: VecDeque::with_capacity(capacity),
known_keys: HashSet::with_capacity(capacity),
capacity,
}
}
/// Push a new item to the back of the buffer, removing the oldest item if the buffer is full.
/// Returns the item that was removed, if any.
/// If the item is already in the buffer, do nothing and return None.
pub fn push(&mut self, item: T) -> Option<T> {
// If the key is already in the buffer, do nothing
if self.contains(&item) {
debug!("Key already in ring buffer: {:?}", item);
return None;
}
let mut popped_key = None;
// If the buffer is full, remove the oldest key (e.g. pop from the front of the buffer)
if self.ring_buffer.len() >= self.capacity {
popped_key = self.pop();
}
// finally, push the new key to the back of the buffer
self.ring_buffer.push_back(item.clone());
self.known_keys.insert(item);
popped_key
}
fn pop(&mut self) -> Option<T> {
if let Some(item) = self.ring_buffer.pop_front() {
debug!("Removing key from ring buffer: {:?}", item);
self.known_keys.remove(&item);
Some(item)
} else {
None
}
}
fn contains(&self, key: &T) -> bool {
self.known_keys.contains(key)
}
}
/// Default size of the preview cache.
/// This does seem kind of arbitrary for now, will need to play around with it.
const DEFAULT_PREVIEW_CACHE_SIZE: usize = 100;
/// A cache for previews.
/// The cache is implemented as a LRU cache with a fixed size.
pub struct PreviewCache {
entries: HashMap<String, Arc<Preview>>,
ring_set: RingSet<String>,
}
impl PreviewCache {
/// Create a new preview cache with the given capacity.
pub fn new(capacity: usize) -> Self {
PreviewCache {
entries: HashMap::new(),
ring_set: RingSet::with_capacity(capacity),
}
}
pub fn get(&self, key: &str) -> Option<Arc<Preview>> {
self.entries.get(key).cloned()
}
/// Insert a new preview into the cache.
/// If the cache is full, the oldest entry will be removed.
/// If the key is already in the cache, the preview will be updated.
pub fn insert(&mut self, key: String, preview: Arc<Preview>) {
debug!("Inserting preview into cache: {}", key);
self.entries.insert(key.clone(), preview.clone());
if let Some(oldest_key) = self.ring_set.push(key) {
debug!("Cache full, removing oldest entry: {}", oldest_key);
self.entries.remove(&oldest_key);
}
}
}
impl Default for PreviewCache {
fn default() -> Self {
PreviewCache::new(DEFAULT_PREVIEW_CACHE_SIZE)
}
}

View File

@ -0,0 +1,45 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::entry;
use crate::previewers::{Preview, PreviewContent};
pub struct EnvVarPreviewer {
cache: HashMap<entry::Entry, Arc<Preview>>,
}
impl EnvVarPreviewer {
pub fn new() -> Self {
EnvVarPreviewer {
cache: HashMap::new(),
}
}
pub fn preview(&mut self, entry: &entry::Entry) -> Arc<Preview> {
// check if we have that preview in the cache
if let Some(preview) = self.cache.get(entry) {
return preview.clone();
}
let preview = Arc::new(Preview {
title: entry.name.clone(),
content: if let Some(preview) = &entry.value {
PreviewContent::PlainTextWrapped(
maybe_add_newline_after_colon(preview, &entry.name),
)
} else {
PreviewContent::Empty
},
});
self.cache.insert(entry.clone(), preview.clone());
preview
}
}
const PATH: &str = "PATH";
fn maybe_add_newline_after_colon(s: &str, name: &str) -> String {
if name.contains(PATH) {
return s.replace(":", "\n");
}
s.to_string()
}

View File

@ -0,0 +1,320 @@
use color_eyre::Result;
use image::{ImageReader, Rgb};
use ratatui_image::picker::Picker;
use std::fs::File;
use std::io::{BufRead, BufReader, Read, Seek};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use syntect::easy::HighlightLines;
use tokio::sync::Mutex;
use syntect::{
highlighting::{Style, Theme, ThemeSet},
parsing::SyntaxSet,
};
use tracing::{debug, info, warn};
use crate::entry;
use crate::previewers::{Preview, PreviewContent};
use crate::utils::files::is_valid_utf8;
use crate::utils::files::FileType;
use crate::utils::files::{
get_file_size, is_known_text_extension,
};
use crate::utils::strings::preprocess_line;
use super::cache::PreviewCache;
pub struct FilePreviewer {
cache: Arc<Mutex<PreviewCache>>,
syntax_set: Arc<SyntaxSet>,
syntax_theme: Arc<Theme>,
image_picker: Arc<Mutex<Picker>>,
}
impl FilePreviewer {
pub fn new() -> Self {
let syntax_set = SyntaxSet::load_defaults_nonewlines();
let theme_set = ThemeSet::load_defaults();
info!("getting image picker");
let image_picker = get_image_picker();
info!("got image picker");
FilePreviewer {
cache: Arc::new(Mutex::new(PreviewCache::default())),
syntax_set: Arc::new(syntax_set),
syntax_theme: Arc::new(
theme_set.themes["base16-ocean.dark"].clone(),
),
image_picker: Arc::new(Mutex::new(image_picker)),
}
}
async fn compute_image_preview(&self, entry: &entry::Entry) {
let cache = self.cache.clone();
let picker = self.image_picker.clone();
let entry_c = entry.clone();
tokio::spawn(async move {
info!("Loading image: {:?}", entry_c.name);
if let Ok(dyn_image) =
ImageReader::open(entry_c.name.clone()).unwrap().decode()
{
let image = picker.lock().await.new_resize_protocol(dyn_image);
let preview = Arc::new(Preview::new(
entry_c.name.clone(),
PreviewContent::Image(image),
));
cache
.lock()
.await
.insert(entry_c.name.clone(), preview.clone());
}
});
}
async fn compute_highlighted_text_preview(
&self,
entry: &entry::Entry,
reader: BufReader<File>,
) {
let cache = self.cache.clone();
let syntax_set = self.syntax_set.clone();
let syntax_theme = self.syntax_theme.clone();
let entry_c = entry.clone();
tokio::spawn(async move {
debug!(
"Computing highlights in the background for {:?}",
entry_c.name
);
let lines: Vec<String> =
reader.lines().map_while(Result::ok).collect();
match compute_highlights(
&PathBuf::from(&entry_c.name),
lines,
&syntax_set,
&syntax_theme,
) {
Ok(highlighted_lines) => {
debug!(
"Successfully computed highlights for {:?}",
entry_c.name
);
cache.lock().await.insert(
entry_c.name.clone(),
Arc::new(Preview::new(
entry_c.name,
PreviewContent::HighlightedText(highlighted_lines),
)),
);
debug!("Inserted highlighted preview into cache");
}
Err(e) => {
warn!("Error computing highlights: {:?}", e);
}
};
});
}
/// The maximum file size that we will try to preview.
/// 4 MB
const MAX_FILE_SIZE: u64 = 4 * 1024 * 1024;
fn get_file_type(&self, path: &Path) -> FileType {
debug!("Getting file type for {:?}", path);
let mut file_type = match infer::get_from_path(path) {
Ok(Some(t)) => {
let mime_type = t.mime_type();
if mime_type.contains("image") {
FileType::Image
} else if mime_type.contains("text") {
FileType::Text
} else {
FileType::Other
}
}
_ => FileType::Unknown,
};
// if the file type is unknown, try to determine it from the extension or the content
if matches!(file_type, FileType::Unknown) {
if is_known_text_extension(path) {
file_type = FileType::Text;
} else if let Ok(mut f) = File::open(path) {
let mut buffer = [0u8; 256];
if let Ok(bytes_read) = f.read(&mut buffer) {
if bytes_read > 0 && is_valid_utf8(&buffer) {
file_type = FileType::Text;
}
}
}
}
debug!("File type for {:?}: {:?}", path, file_type);
file_type
}
async fn cache_preview(&mut self, key: String, preview: Arc<Preview>) {
self.cache.lock().await.insert(key, preview);
}
pub async fn preview(&mut self, entry: &entry::Entry) -> Arc<Preview> {
let path_buf = PathBuf::from(&entry.name);
// do we have a preview in cache for that entry?
if let Some(preview) = self.cache.lock().await.get(&entry.name) {
return preview.clone();
}
debug!("No preview in cache for {:?}", entry.name);
// check file size
if get_file_size(&path_buf).map_or(false, |s| s > Self::MAX_FILE_SIZE)
{
debug!("File too large: {:?}", entry.name);
let preview = file_too_large(&entry.name);
self.cache_preview(entry.name.clone(), preview.clone())
.await;
return preview;
}
// try to determine file type
debug!("Computing preview for {:?}", entry.name);
match self.get_file_type(&path_buf) {
FileType::Text => {
match File::open(&path_buf) {
Ok(file) => {
// insert a non-highlighted version of the preview into the cache
let reader = BufReader::new(&file);
let preview = plain_text_preview(&entry.name, reader);
self.cache_preview(
entry.name.clone(),
preview.clone(),
)
.await;
// compute the highlighted version in the background
let mut reader =
BufReader::new(file.try_clone().unwrap());
reader.seek(std::io::SeekFrom::Start(0)).unwrap();
self.compute_highlighted_text_preview(entry, reader)
.await;
preview
}
Err(e) => {
warn!("Error opening file: {:?}", e);
let p = not_supported(&entry.name);
self.cache_preview(entry.name.clone(), p.clone())
.await;
p
}
}
}
FileType::Image => {
debug!("Previewing image file: {:?}", entry.name);
// insert a loading preview into the cache
let preview = loading(&entry.name);
self.cache_preview(entry.name.clone(), preview.clone())
.await;
// compute the image preview in the background
self.compute_image_preview(entry).await;
preview
}
FileType::Other => {
debug!("Previewing other file: {:?}", entry.name);
let preview = not_supported(&entry.name);
self.cache_preview(entry.name.clone(), preview.clone())
.await;
preview
}
FileType::Unknown => {
debug!("Unknown file type: {:?}", entry.name);
let preview = not_supported(&entry.name);
self.cache_preview(entry.name.clone(), preview.clone())
.await;
preview
}
}
}
}
fn get_image_picker() -> Picker {
let mut picker = match Picker::from_termios() {
Ok(p) => p,
Err(_) => Picker::new((7, 14)),
};
picker.guess_protocol();
picker.background_color = Some(Rgb::<u8>([255, 0, 255]));
picker
}
/// This should be enough to most standard terminal sizes
const TEMP_PLAIN_TEXT_PREVIEW_HEIGHT: usize = 200;
fn plain_text_preview(title: &str, reader: BufReader<&File>) -> Arc<Preview> {
debug!("Creating plain text preview for {:?}", title);
let mut lines = Vec::with_capacity(TEMP_PLAIN_TEXT_PREVIEW_HEIGHT);
for maybe_line in reader.lines() {
match maybe_line {
Ok(line) => lines.push(preprocess_line(&line)),
Err(e) => {
warn!("Error reading file: {:?}", e);
return not_supported(title);
}
}
if lines.len() >= TEMP_PLAIN_TEXT_PREVIEW_HEIGHT {
break;
}
}
Arc::new(Preview::new(
title.to_string(),
PreviewContent::PlainText(lines),
))
}
fn not_supported(title: &str) -> Arc<Preview> {
Arc::new(Preview::new(
title.to_string(),
PreviewContent::NotSupported,
))
}
fn file_too_large(title: &str) -> Arc<Preview> {
Arc::new(Preview::new(
title.to_string(),
PreviewContent::FileTooLarge,
))
}
fn loading(title: &str) -> Arc<Preview> {
Arc::new(Preview::new(title.to_string(), PreviewContent::Loading))
}
fn compute_highlights(
file_path: &Path,
lines: Vec<String>,
syntax_set: &SyntaxSet,
syntax_theme: &Theme,
) -> Result<Vec<Vec<(Style, String)>>> {
let syntax =
syntax_set
.find_syntax_for_file(file_path)?
.unwrap_or_else(|| {
warn!(
"No syntax found for {:?}, defaulting to plain text",
file_path
);
syntax_set.find_syntax_plain_text()
});
let mut highlighter = HighlightLines::new(syntax, syntax_theme);
let mut highlighted_lines = Vec::new();
for line in lines {
let hl_regions = highlighter.highlight_line(&line, syntax_set)?;
highlighted_lines.push(
hl_regions
.iter()
.map(|(style, text)| (*style, (*text).to_string()))
.collect(),
);
}
Ok(highlighted_lines)
}

119
crates/television/render.rs Normal file
View File

@ -0,0 +1,119 @@
use color_eyre::Result;
use ratatui::layout::Rect;
use std::{
io::{stderr, stdout, LineWriter},
sync::Arc,
};
use tracing::{debug, warn};
use tokio::{
select,
sync::{mpsc, Mutex},
};
use crate::television::Television;
use crate::{
action::Action, config::Config,
tui::Tui,
};
#[derive(Debug)]
pub enum RenderingTask {
ClearScreen,
Render,
Resize(u16, u16),
Resume,
Suspend,
Quit,
}
#[derive(Debug, Clone)]
enum IoStream {
Stdout,
BufferedStderr,
}
impl IoStream {
fn to_stream(&self) -> Box<dyn std::io::Write + Send> {
match self {
IoStream::Stdout => Box::new(stdout()),
IoStream::BufferedStderr => Box::new(LineWriter::new(stderr())),
}
}
}
pub async fn render(
mut render_rx: mpsc::UnboundedReceiver<RenderingTask>,
action_tx: mpsc::UnboundedSender<Action>,
config: Config,
television: Arc<Mutex<Television>>,
frame_rate: f64,
is_output_tty: bool,
) -> Result<()> {
let stream = if is_output_tty {
debug!("Rendering to stdout");
IoStream::Stdout.to_stream()
} else {
debug!("Rendering to stderr");
IoStream::BufferedStderr.to_stream()
};
let mut tui = Tui::new(stream)?.frame_rate(frame_rate);
debug!("Entering tui");
tui.enter()?;
debug!("Registering action handler and config handler");
television
.lock()
.await
.register_action_handler(action_tx.clone())?;
television
.lock()
.await
.register_config_handler(config.clone())?;
// Rendering loop
loop {
select! {
_ = tokio::time::sleep(tokio::time::Duration::from_secs_f64(1.0 / frame_rate)) => {
action_tx.send(Action::Render)?;
}
maybe_task = render_rx.recv() => {
if let Some(task) = maybe_task {
match task {
RenderingTask::ClearScreen => {
tui.terminal.clear()?;
}
RenderingTask::Render => {
let mut television = television.lock().await;
tui.terminal.draw(|frame| {
if let Err(err) = television.draw(frame, frame.area()) {
warn!("Failed to draw: {:?}", err);
let _ = action_tx
.send(Action::Error(format!("Failed to draw: {err:?}")));
}
})?;
}
RenderingTask::Resize(w, h) => {
tui.resize(Rect::new(0, 0, w, h))?;
action_tx.send(Action::Render)?;
}
RenderingTask::Suspend => {
tui.suspend()?;
action_tx.send(Action::Resume)?;
action_tx.send(Action::ClearScreen)?;
tui.enter()?;
}
RenderingTask::Resume => {
tui.enter()?;
}
RenderingTask::Quit => {
tui.exit()?;
break Ok(());
}
}
}
}
}
}
}

View File

@ -0,0 +1,883 @@
use color_eyre::Result;
use futures::executor::block_on;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span, Text},
widgets::{
block::{Position, Title},
Block, BorderType, Borders, ListState, Padding, Paragraph, Wrap,
},
Frame,
};
use ratatui_image::StatefulImage;
use std::{collections::HashMap, str::FromStr, sync::Arc};
use syntect;
use syntect::highlighting::Color as SyntectColor;
use tokio::sync::mpsc::UnboundedSender;
use crate::channels::{CliTvChannel, TelevisionChannel};
use crate::entry::{Entry, ENTRY_PLACEHOLDER};
use crate::previewers::{
Preview, PreviewContent, Previewer, FILE_TOO_LARGE_MSG,
PREVIEW_NOT_SUPPORTED_MSG,
};
use crate::ui::input::{Input, InputRequest, StateChanged};
use crate::ui::{build_results_list, create_layout, get_border_style};
use crate::{action::Action, config::Config};
#[derive(PartialEq, Copy, Clone)]
enum Pane {
Results,
Preview,
Input,
}
static PANES: [Pane; 3] = [Pane::Input, Pane::Results, Pane::Preview];
pub struct Television {
action_tx: Option<UnboundedSender<Action>>,
config: Config,
channel: Box<dyn TelevisionChannel>,
current_pattern: String,
current_pane: Pane,
input: Input,
picker_state: ListState,
relative_picker_state: ListState,
picker_view_offset: usize,
results_area_height: u32,
previewer: Previewer,
preview_scroll: Option<u16>,
preview_pane_height: u16,
current_preview_total_lines: u16,
meta_paragraph_cache: HashMap<String, Paragraph<'static>>,
}
const EMPTY_STRING: &str = "";
impl Television {
pub fn new(cli_channel: CliTvChannel) -> Self {
let mut tv_channel = cli_channel.to_channel();
tv_channel.find(EMPTY_STRING);
Self {
action_tx: None,
config: Config::default(),
channel: tv_channel,
current_pattern: EMPTY_STRING.to_string(),
current_pane: Pane::Input,
input: Input::new(EMPTY_STRING.to_string()),
picker_state: ListState::default(),
relative_picker_state: ListState::default(),
picker_view_offset: 0,
results_area_height: 0,
previewer: Previewer::new(),
preview_scroll: None,
preview_pane_height: 0,
current_preview_total_lines: 0,
meta_paragraph_cache: HashMap::new(),
}
}
fn find(&mut self, pattern: &str) {
self.channel.find(pattern);
}
pub fn get_selected_entry(&self) -> Option<Entry> {
self.picker_state
.selected()
.and_then(|i| self.channel.get_result(u32::try_from(i).unwrap()))
}
pub fn select_prev_entry(&mut self) {
if self.channel.result_count() == 0 {
return;
}
let new_index = (self.picker_state.selected().unwrap_or(0) + 1)
% self.channel.result_count() as usize;
self.picker_state.select(Some(new_index));
if new_index == 0 {
self.picker_view_offset = 0;
self.relative_picker_state.select(Some(0));
return;
}
if self.relative_picker_state.selected().unwrap_or(0)
== self.results_area_height as usize - 3
{
self.picker_view_offset += 1;
self.relative_picker_state.select(Some(
self.picker_state
.selected()
.unwrap_or(0)
.min(self.results_area_height as usize - 3),
));
} else {
self.relative_picker_state.select(Some(
(self.relative_picker_state.selected().unwrap_or(0) + 1)
.min(self.picker_state.selected().unwrap_or(0)),
));
}
}
pub fn select_next_entry(&mut self) {
if self.channel.result_count() == 0 {
return;
}
let selected = self.picker_state.selected().unwrap_or(0);
let relative_selected =
self.relative_picker_state.selected().unwrap_or(0);
if selected > 0 {
self.picker_state.select(Some(selected - 1));
self.relative_picker_state
.select(Some(relative_selected.saturating_sub(1)));
if relative_selected == 0 {
self.picker_view_offset =
self.picker_view_offset.saturating_sub(1);
}
} else {
self.picker_view_offset = (self
.channel
.result_count()
.saturating_sub(self.results_area_height - 2))
as usize;
self.picker_state.select(Some(
(self.channel.result_count() as usize).saturating_sub(1),
));
self.relative_picker_state
.select(Some(self.results_area_height as usize - 3));
}
}
fn reset_preview_scroll(&mut self) {
self.preview_scroll = None;
}
pub fn scroll_preview_down(&mut self, offset: u16) {
if self.preview_scroll.is_none() {
self.preview_scroll = Some(0);
}
if let Some(scroll) = self.preview_scroll {
self.preview_scroll = Some(
(scroll + offset).min(
self.current_preview_total_lines
.saturating_sub(2 * self.preview_pane_height / 3),
),
);
}
}
pub fn scroll_preview_up(&mut self, offset: u16) {
if let Some(scroll) = self.preview_scroll {
self.preview_scroll = Some(scroll.saturating_sub(offset));
}
}
fn get_current_pane_index(&self) -> usize {
PANES
.iter()
.position(|pane| *pane == self.current_pane)
.unwrap()
}
pub fn next_pane(&mut self) {
let current_index = self.get_current_pane_index();
let next_index = (current_index + 1) % PANES.len();
self.current_pane = PANES[next_index];
}
pub fn previous_pane(&mut self) {
let current_index = self.get_current_pane_index();
let previous_index = if current_index == 0 {
PANES.len() - 1
} else {
current_index - 1
};
self.current_pane = PANES[previous_index];
}
/// ┌───────────────────┐┌─────────────┐
/// │ Results ││ Preview │
/// │ ││ │
/// │ ││ │
/// │ ││ │
/// └───────────────────┘│ │
/// ┌───────────────────┐│ │
/// │ Search x ││ │
/// └───────────────────┘└─────────────┘
pub fn move_to_pane_on_top(&mut self) {
if self.current_pane == Pane::Input {
self.current_pane = Pane::Results;
}
}
/// ┌───────────────────┐┌─────────────┐
/// │ Results x ││ Preview │
/// │ ││ │
/// │ ││ │
/// │ ││ │
/// └───────────────────┘│ │
/// ┌───────────────────┐│ │
/// │ Search ││ │
/// └───────────────────┘└─────────────┘
pub fn move_to_pane_below(&mut self) {
if self.current_pane == Pane::Results {
self.current_pane = Pane::Input;
}
}
/// ┌───────────────────┐┌─────────────┐
/// │ Results x ││ Preview │
/// │ ││ │
/// │ ││ │
/// │ ││ │
/// └───────────────────┘│ │
/// ┌───────────────────┐│ │
/// │ Search x ││ │
/// └───────────────────┘└─────────────┘
pub fn move_to_pane_right(&mut self) {
match self.current_pane {
Pane::Results | Pane::Input => {
self.current_pane = Pane::Preview;
}
_ => {}
}
}
/// ┌───────────────────┐┌─────────────┐
/// │ Results ││ Preview x │
/// │ ││ │
/// │ ││ │
/// │ ││ │
/// └───────────────────┘│ │
/// ┌───────────────────┐│ │
/// │ Search ││ │
/// └───────────────────┘└─────────────┘
pub fn move_to_pane_left(&mut self) {
if self.current_pane == Pane::Preview {
self.current_pane = Pane::Results;
}
}
pub fn is_input_focused(&self) -> bool {
Pane::Input == self.current_pane
}
}
// UI size
const UI_WIDTH_PERCENT: u16 = 95;
const UI_HEIGHT_PERCENT: u16 = 95;
// Misc
const FOUR_SPACES: &str = " ";
// Styles
// results
const DEFAULT_RESULT_NAME_FG: Color = Color::Blue;
const DEFAULT_RESULT_PREVIEW_FG: Color = Color::Rgb(150, 150, 150);
const DEFAULT_RESULT_LINE_NUMBER_FG: Color = Color::Yellow;
// input
const DEFAULT_INPUT_FG: Color = Color::Rgb(200, 200, 200);
const DEFAULT_RESULTS_COUNT_FG: Color = Color::Rgb(150, 150, 150);
// preview
const DEFAULT_PREVIEW_TITLE_FG: Color = Color::Blue;
const DEFAULT_SELECTED_PREVIEW_BG: Color = Color::Rgb(50, 50, 50);
const DEFAULT_PREVIEW_CONTENT_FG: Color = Color::Rgb(150, 150, 180);
const DEFAULT_PREVIEW_GUTTER_FG: Color = Color::Rgb(70, 70, 70);
const DEFAULT_PREVIEW_GUTTER_SELECTED_FG: Color = Color::Rgb(255, 150, 150);
impl Television {
/// Register an action handler that can send actions for processing if necessary.
///
/// # Arguments
///
/// * `tx` - An unbounded sender that can send actions.
///
/// # Returns
///
/// * `Result<()>` - An Ok result or an error.
pub fn register_action_handler(
&mut self,
tx: UnboundedSender<Action>,
) -> Result<()> {
self.action_tx = Some(tx.clone());
Ok(())
}
/// Register a configuration handler that provides configuration settings if necessary.
///
/// # Arguments
///
/// * `config` - Configuration settings.
///
/// # Returns
///
/// * `Result<()>` - An Ok result or an error.
pub fn register_config_handler(&mut self, config: Config) -> Result<()> {
self.config = config;
Ok(())
}
/// Update the state of the component based on a received action.
///
/// # Arguments
///
/// * `action` - An action that may modify the state of the television.
///
/// # Returns
///
/// * `Result<Option<Action>>` - An action to be processed or none.
pub async fn update(&mut self, action: Action) -> Result<Option<Action>> {
match action {
Action::GoToPaneUp => {
self.move_to_pane_on_top();
}
Action::GoToPaneDown => {
self.move_to_pane_below();
}
Action::GoToPaneLeft => {
self.move_to_pane_left();
}
Action::GoToPaneRight => {
self.move_to_pane_right();
}
Action::GoToNextPane => {
self.next_pane();
}
Action::GoToPrevPane => {
self.previous_pane();
}
// handle input actions
Action::AddInputChar(_)
| Action::DeletePrevChar
| Action::DeleteNextChar
| Action::GoToInputEnd
| Action::GoToInputStart
| Action::GoToNextChar
| Action::GoToPrevChar
if self.is_input_focused() =>
{
self.input.handle_action(&action);
match action {
Action::AddInputChar(_)
| Action::DeletePrevChar
| Action::DeleteNextChar => {
let new_pattern = self.input.value().to_string();
if new_pattern != self.current_pattern {
self.current_pattern.clone_from(&new_pattern);
self.find(&new_pattern);
self.reset_preview_scroll();
self.picker_state.select(Some(0));
self.relative_picker_state.select(Some(0));
self.picker_view_offset = 0;
}
}
_ => {}
}
}
Action::SelectNextEntry => {
self.select_next_entry();
self.reset_preview_scroll();
}
Action::SelectPrevEntry => {
self.select_prev_entry();
self.reset_preview_scroll();
}
Action::ScrollPreviewDown => self.scroll_preview_down(1),
Action::ScrollPreviewUp => self.scroll_preview_up(1),
Action::ScrollPreviewHalfPageDown => self.scroll_preview_down(20),
Action::ScrollPreviewHalfPageUp => self.scroll_preview_up(20),
_ => {}
}
Ok(None)
}
/// Render the television on the screen.
///
/// # Arguments
///
/// * `f` - A frame used for rendering.
/// * `area` - The area in which the television should be drawn.
///
/// # Returns
///
/// * `Result<()>` - An Ok result or an error.
pub fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let (results_area, input_area, preview_title_area, preview_area) =
create_layout(area);
self.results_area_height = u32::from(results_area.height);
self.preview_pane_height = preview_area.height;
// top left block: results
let results_block = Block::default()
.title(
Title::from(" Results ")
.position(Position::Top)
.alignment(Alignment::Center),
)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_border_style(Pane::Results == self.current_pane))
.style(Style::default())
.padding(Padding::right(1));
if self.channel.result_count() > 0
&& self.picker_state.selected().is_none()
{
self.picker_state.select(Some(0));
self.relative_picker_state.select(Some(0));
}
let entries = self.channel.results(
(results_area.height - 2).into(),
u32::try_from(self.picker_view_offset).unwrap(),
);
let results_list = build_results_list(results_block, &entries);
frame.render_stateful_widget(
results_list,
results_area,
&mut self.relative_picker_state,
);
// bottom left block: input
let input_block = Block::default()
.title(
Title::from(" Pattern ")
.position(Position::Top)
.alignment(Alignment::Center),
)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_border_style(Pane::Input == self.current_pane))
.style(Style::default());
let input_block_inner = input_block.inner(input_area);
frame.render_widget(input_block, input_area);
let inner_input_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(2),
Constraint::Fill(1),
Constraint::Length(
3 * ((self.channel.total_count() as f32).log10().ceil()
as u16
+ 1)
+ 3,
),
])
.split(input_block_inner);
let arrow_block = Block::default();
let arrow = Paragraph::new(Span::styled("> ", Style::default()))
.block(arrow_block);
frame.render_widget(arrow, inner_input_chunks[0]);
let interactive_input_block = Block::default();
// keep 2 for borders and 1 for cursor
let width = inner_input_chunks[1].width.max(3) - 3;
let scroll = self.input.visual_scroll(width as usize);
let input = Paragraph::new(self.input.value())
.scroll((0, u16::try_from(scroll).unwrap()))
.block(interactive_input_block)
.style(Style::default().fg(DEFAULT_INPUT_FG))
.alignment(Alignment::Left);
frame.render_widget(input, inner_input_chunks[1]);
let result_count_block = Block::default();
let result_count = Paragraph::new(Span::styled(
format!(
" {} / {} ",
if self.channel.result_count() == 0 {
0
} else {
self.picker_state.selected().unwrap_or(0) + 1
},
self.channel.result_count(),
),
Style::default().fg(DEFAULT_RESULTS_COUNT_FG),
))
.block(result_count_block)
.alignment(Alignment::Right);
frame.render_widget(result_count, inner_input_chunks[2]);
if let Pane::Input = self.current_pane {
// Make the cursor visible and ask tui-rs to put it at the
// specified coordinates after rendering
frame.set_cursor_position((
// Put cursor past the end of the input text
inner_input_chunks[1].x
+ u16::try_from(
(self.input.visual_cursor()).max(scroll) - scroll,
)
.unwrap(),
// Move one line down, from the border to the input line
inner_input_chunks[1].y,
));
}
// top right block: preview title
let selected_entry =
self.get_selected_entry().unwrap_or(ENTRY_PLACEHOLDER);
let preview = block_on(self.previewer.preview(&selected_entry));
self.current_preview_total_lines = preview.total_lines();
let mut preview_title_spans = Vec::new();
if let Some(icon) = &selected_entry.icon {
preview_title_spans.push(Span::styled(
icon.to_string(),
Style::default().fg(Color::from_str(icon.color).unwrap()),
));
preview_title_spans.push(Span::raw(" "));
}
preview_title_spans.push(Span::styled(
preview.title.clone(),
Style::default().fg(DEFAULT_PREVIEW_TITLE_FG),
));
let preview_title = Paragraph::new(Line::from(preview_title_spans))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_border_style(false)),
)
.alignment(Alignment::Left);
frame.render_widget(preview_title, preview_title_area);
// file preview
let preview_outer_block = Block::default()
.title(
Title::from(" Preview ")
.position(Position::Top)
.alignment(Alignment::Center),
)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(get_border_style(Pane::Preview == self.current_pane))
.style(Style::default())
.padding(Padding::right(1));
let preview_inner_block =
Block::default().style(Style::default()).padding(Padding {
top: 0,
right: 1,
bottom: 0,
left: 1,
});
let inner = preview_outer_block.inner(preview_area);
frame.render_widget(preview_outer_block, preview_area);
if let PreviewContent::Image(img) = &preview.content {
let image_component = StatefulImage::new(None);
frame.render_stateful_widget(
image_component,
inner,
&mut img.clone(),
);
} else {
let preview_block = self.build_preview_paragraph(
preview_inner_block,
inner,
&preview,
selected_entry
.line_number
// FIXME: this actually might panic in some edge cases
.map(|l| u16::try_from(l).unwrap()),
);
frame.render_widget(preview_block, inner);
}
Ok(())
}
}
impl Television {
const FILL_CHAR_SLANTED: char = '';
const FILL_CHAR_EMPTY: char = ' ';
fn build_preview_paragraph<'b>(
&'b mut self,
preview_block: Block<'b>,
inner: Rect,
preview: &Arc<Preview>,
target_line: Option<u16>,
) -> Paragraph<'b> {
self.maybe_init_preview_scroll(target_line, inner.height);
match &preview.content {
PreviewContent::PlainText(content) => {
let mut lines = Vec::new();
for (i, line) in content.iter().enumerate() {
lines.push(Line::from(vec![
build_line_number_span(i + 1).style(Style::default().fg(
// FIXME: this actually might panic in some edge cases
if matches!(
target_line,
Some(l) if l == u16::try_from(i).unwrap() + 1
)
{
DEFAULT_PREVIEW_GUTTER_SELECTED_FG
} else {
DEFAULT_PREVIEW_GUTTER_FG
},
)),
Span::styled("",
Style::default().fg(DEFAULT_PREVIEW_GUTTER_FG).dim()),
Span::styled(
line.to_string(),
Style::default().fg(DEFAULT_PREVIEW_CONTENT_FG).bg(
if matches!(target_line, Some(l) if l == u16::try_from(i).unwrap() + 1) {
DEFAULT_SELECTED_PREVIEW_BG
} else {
Color::Reset
},
),
),
]));
}
let text = Text::from(lines);
Paragraph::new(text)
.block(preview_block)
.scroll((self.preview_scroll.unwrap_or(0), 0))
}
PreviewContent::PlainTextWrapped(content) => {
let mut lines = Vec::new();
for line in content.lines() {
lines.push(Line::styled(
line.to_string(),
Style::default().fg(DEFAULT_PREVIEW_CONTENT_FG),
));
}
let text = Text::from(lines);
Paragraph::new(text)
.block(preview_block)
.wrap(Wrap { trim: true })
}
PreviewContent::HighlightedText(highlighted_lines) => {
compute_paragraph_from_highlighted_lines(
highlighted_lines,
target_line.map(|l| l as usize),
self.preview_scroll.unwrap_or(0),
self.preview_pane_height,
)
.block(preview_block)
.alignment(Alignment::Left)
.scroll((self.preview_scroll.unwrap_or(0), 0))
}
// meta
PreviewContent::Loading => self
.build_meta_preview_paragraph(
inner,
"Loading...",
Self::FILL_CHAR_EMPTY,
)
.block(preview_block)
.alignment(Alignment::Left)
.style(Style::default().add_modifier(Modifier::ITALIC)),
PreviewContent::NotSupported => self
.build_meta_preview_paragraph(
inner,
PREVIEW_NOT_SUPPORTED_MSG,
Self::FILL_CHAR_SLANTED,
)
.block(preview_block)
.alignment(Alignment::Left)
.style(Style::default().add_modifier(Modifier::ITALIC)),
PreviewContent::FileTooLarge => self
.build_meta_preview_paragraph(
inner,
FILE_TOO_LARGE_MSG,
Self::FILL_CHAR_SLANTED,
)
.block(preview_block)
.alignment(Alignment::Left)
.style(Style::default().add_modifier(Modifier::ITALIC)),
_ => Paragraph::new(Text::raw(EMPTY_STRING)),
}
}
fn maybe_init_preview_scroll(
&mut self,
target_line: Option<u16>,
height: u16,
) {
if self.preview_scroll.is_none() {
self.preview_scroll =
Some(target_line.unwrap_or(0).saturating_sub(height / 3));
}
}
fn build_meta_preview_paragraph<'a>(
&mut self,
inner: Rect,
message: &str,
fill_char: char,
) -> Paragraph<'a> {
if let Some(paragraph) = self.meta_paragraph_cache.get(message) {
return paragraph.clone();
}
let message_len = message.len();
let fill_char_str = fill_char.to_string();
let fill_line = fill_char_str.repeat(inner.width as usize);
// Build the paragraph content with slanted lines and center the custom message
let mut lines = Vec::new();
// Calculate the vertical center
let vertical_center = inner.height as usize / 2;
let horizontal_padding = (inner.width as usize - message_len) / 2 - 4;
// Fill the paragraph with slanted lines and insert the centered custom message
for i in 0..inner.height {
if i as usize == vertical_center {
// Center the message horizontally in the middle line
let line = format!(
"{} {} {}",
fill_char_str.repeat(horizontal_padding),
message,
fill_char_str.repeat(
inner.width as usize
- horizontal_padding
- message_len
)
);
lines.push(Line::from(line));
} else if i as usize + 1 == vertical_center
|| (i as usize).saturating_sub(1) == vertical_center
{
let line = format!(
"{} {} {}",
fill_char_str.repeat(horizontal_padding),
" ".repeat(message_len),
fill_char_str.repeat(
inner.width as usize
- horizontal_padding
- message_len
)
);
lines.push(Line::from(line));
} else {
lines.push(Line::from(fill_line.clone()));
}
}
// Create a paragraph with the generated content
let p = Paragraph::new(Text::from(lines));
self.meta_paragraph_cache
.insert(message.to_string(), p.clone());
p
}
}
/// This makes the `Action` type compatible with the `Input` logic.
pub trait InputActionHandler {
// Handle Key event.
fn handle_action(&mut self, action: &Action) -> Option<StateChanged>;
}
impl InputActionHandler for Input {
/// Handle Key event.
fn handle_action(&mut self, action: &Action) -> Option<StateChanged> {
match action {
Action::AddInputChar(c) => {
self.handle(InputRequest::InsertChar(*c))
}
Action::DeletePrevChar => {
self.handle(InputRequest::DeletePrevChar)
}
Action::DeleteNextChar => {
self.handle(InputRequest::DeleteNextChar)
}
Action::GoToPrevChar => self.handle(InputRequest::GoToPrevChar),
Action::GoToNextChar => self.handle(InputRequest::GoToNextChar),
Action::GoToInputStart => self.handle(InputRequest::GoToStart),
Action::GoToInputEnd => self.handle(InputRequest::GoToEnd),
_ => None,
}
}
}
fn build_line_number_span<'a>(line_number: usize) -> Span<'a> {
Span::from(format!("{line_number:5} "))
}
fn compute_paragraph_from_highlighted_lines(
highlighted_lines: &[Vec<(syntect::highlighting::Style, String)>],
line_specifier: Option<usize>,
scroll: u16,
preview_pane_height: u16,
) -> Paragraph<'static> {
let preview_lines: Vec<Line> = highlighted_lines
.iter()
.enumerate()
.map(|(i, l)| {
if i < scroll as usize
|| i >= (scroll + preview_pane_height) as usize
{
return Line::from(Span::raw(EMPTY_STRING));
}
let line_number =
build_line_number_span(i + 1).style(Style::default().fg(
if line_specifier.is_some()
&& i == line_specifier.unwrap() - 1
{
DEFAULT_PREVIEW_GUTTER_SELECTED_FG
} else {
DEFAULT_PREVIEW_GUTTER_FG
},
));
Line::from_iter(
std::iter::once(line_number)
.chain(std::iter::once(Span::styled(
"",
Style::default().fg(DEFAULT_PREVIEW_GUTTER_FG).dim(),
)))
.chain(l.iter().cloned().map(|sr| {
convert_syn_region_to_span(
&(sr.0, sr.1.replace('\t', FOUR_SPACES)),
if line_specifier.is_some()
&& i == line_specifier.unwrap() - 1
{
Some(SyntectColor {
r: 50,
g: 50,
b: 50,
a: 255,
})
} else {
None
},
)
})),
)
})
.collect();
Paragraph::new(preview_lines)
}
fn convert_syn_region_to_span<'a>(
syn_region: &(syntect::highlighting::Style, String),
background: Option<syntect::highlighting::Color>,
) -> Span<'a> {
let mut style = Style::default()
.fg(convert_syn_color_to_ratatui_color(syn_region.0.foreground));
if let Some(background) = background {
style = style.bg(convert_syn_color_to_ratatui_color(background));
}
style = match syn_region.0.font_style {
syntect::highlighting::FontStyle::BOLD => style.bold(),
syntect::highlighting::FontStyle::ITALIC => style.italic(),
syntect::highlighting::FontStyle::UNDERLINE => style.underlined(),
_ => style,
};
Span::styled(syn_region.1.clone(), style)
}
fn convert_syn_color_to_ratatui_color(
color: syntect::highlighting::Color,
) -> ratatui::style::Color {
ratatui::style::Color::Rgb(color.r, color.g, color.b)
}

113
crates/television/tui.rs Normal file
View File

@ -0,0 +1,113 @@
use std::{
io::{stderr, LineWriter, Write},
ops::{Deref, DerefMut},
};
use color_eyre::Result;
use crossterm::{
cursor, execute,
terminal::{
disable_raw_mode, enable_raw_mode, is_raw_mode_enabled,
EnterAlternateScreen, LeaveAlternateScreen,
},
};
use ratatui::{backend::CrosstermBackend, layout::Size};
use tokio::task::JoinHandle;
use tracing::debug;
pub struct Tui<W>
where
W: Write,
{
pub task: JoinHandle<()>,
pub frame_rate: f64,
pub terminal: ratatui::Terminal<CrosstermBackend<W>>,
}
impl<W> Tui<W>
where
W: Write,
{
pub fn new(writer: W) -> Result<Self> {
Ok(Self {
task: tokio::spawn(async {}),
frame_rate: 60.0,
terminal: ratatui::Terminal::new(CrosstermBackend::new(writer))?,
})
}
pub fn frame_rate(mut self, frame_rate: f64) -> Self {
self.frame_rate = frame_rate;
self
}
pub fn size(&self) -> Result<Size> {
Ok(self.terminal.size()?)
}
pub fn enter(&mut self) -> Result<()> {
enable_raw_mode()?;
let mut buffered_stderr = LineWriter::new(stderr());
execute!(buffered_stderr, EnterAlternateScreen)?;
self.terminal.clear()?;
execute!(buffered_stderr, cursor::Hide)?;
Ok(())
}
pub fn exit(&mut self) -> Result<()> {
if is_raw_mode_enabled()? {
debug!("Exiting terminal");
disable_raw_mode()?;
let mut buffered_stderr = LineWriter::new(stderr());
execute!(buffered_stderr, cursor::Show)?;
execute!(buffered_stderr, LeaveAlternateScreen)?;
}
Ok(())
}
pub fn suspend(&mut self) -> Result<()> {
self.exit()?;
#[cfg(not(windows))]
signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?;
Ok(())
}
pub fn resume(&mut self) -> Result<()> {
self.enter()?;
Ok(())
}
}
impl<W> Deref for Tui<W>
where
W: Write,
{
type Target = ratatui::Terminal<CrosstermBackend<W>>;
fn deref(&self) -> &Self::Target {
&self.terminal
}
}
impl<W> DerefMut for Tui<W>
where
W: Write,
{
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.terminal
}
}
impl<W> Drop for Tui<W>
where
W: Write,
{
fn drop(&mut self) {
match self.exit() {
Ok(_) => debug!("Successfully exited terminal"),
Err(e) => debug!("Failed to exit terminal: {:?}", e),
}
}
}

176
crates/television/ui.rs Normal file
View File

@ -0,0 +1,176 @@
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style, Stylize},
text::{Line, Span},
widgets::{Block, List, ListDirection},
};
use std::str::FromStr;
use crate::entry::Entry;
use crate::utils::strings::{next_char_boundary, slice_at_char_boundaries};
use crate::utils::ui::centered_rect;
pub mod input;
// UI size
const UI_WIDTH_PERCENT: u16 = 90;
const UI_HEIGHT_PERCENT: u16 = 90;
// Styles
// results
const DEFAULT_RESULT_NAME_FG: Color = Color::Blue;
const DEFAULT_RESULT_PREVIEW_FG: Color = Color::Rgb(150, 150, 150);
const DEFAULT_RESULT_LINE_NUMBER_FG: Color = Color::Yellow;
// input
const DEFAULT_INPUT_FG: Color = Color::Rgb(200, 200, 200);
const DEFAULT_RESULTS_COUNT_FG: Color = Color::Rgb(150, 150, 150);
// preview
const DEFAULT_PREVIEW_TITLE_FG: Color = Color::Blue;
const DEFAULT_SELECTED_PREVIEW_BG: Color = Color::Rgb(50, 50, 50);
const DEFAULT_PREVIEW_CONTENT_FG: Color = Color::Rgb(150, 150, 180);
const DEFAULT_PREVIEW_GUTTER_FG: Color = Color::Rgb(70, 70, 70);
const DEFAULT_PREVIEW_GUTTER_SELECTED_FG: Color = Color::Rgb(255, 150, 150);
pub fn get_border_style(focused: bool) -> Style {
if focused {
Style::default().fg(Color::Green)
} else {
// TODO: make this depend on self.config
Style::default().fg(Color::Rgb(90, 90, 110)).dim()
}
}
pub fn create_layout(area: Rect) -> (Rect, Rect, Rect, Rect) {
let main_block = centered_rect(UI_WIDTH_PERCENT, UI_HEIGHT_PERCENT, area);
// split the main block into two vertical chunks
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(main_block);
// left block: results + input field
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(10), Constraint::Length(3)])
.split(chunks[0]);
// right block: preview title + preview
let right_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(10)])
.split(chunks[1]);
(
left_chunks[0],
left_chunks[1],
right_chunks[0],
right_chunks[1],
)
}
pub fn build_results_list<'a, 'b>(
results_block: Block<'b>,
entries: &'a [Entry],
) -> List<'a>
where
'b: 'a,
{
List::new(entries.iter().map(|entry| {
let mut spans = Vec::new();
// optional icon
if let Some(icon) = &entry.icon {
spans.push(Span::styled(
icon.to_string(),
Style::default().fg(Color::from_str(icon.color).unwrap()),
));
spans.push(Span::raw(" "));
}
// entry name
if let Some(name_match_ranges) = &entry.name_match_ranges {
let mut last_match_end = 0;
for (start, end) in name_match_ranges
.iter()
.map(|(s, e)| (*s as usize, *e as usize))
{
spans.push(Span::styled(
slice_at_char_boundaries(
&entry.name,
last_match_end,
start,
),
Style::default()
.fg(DEFAULT_RESULT_NAME_FG)
.bold()
.italic(),
));
spans.push(Span::styled(
slice_at_char_boundaries(&entry.name, start, end),
Style::default().fg(Color::Red).bold().italic(),
));
last_match_end = end;
}
spans.push(Span::styled(
&entry.name[next_char_boundary(&entry.name, last_match_end)..],
Style::default().fg(DEFAULT_RESULT_NAME_FG).bold().italic(),
));
} else {
spans.push(Span::styled(
entry.display_name(),
Style::default().fg(DEFAULT_RESULT_NAME_FG).bold().italic(),
));
}
// optional line number
if let Some(line_number) = entry.line_number {
spans.push(Span::styled(
format!(":{line_number}"),
Style::default().fg(DEFAULT_RESULT_LINE_NUMBER_FG),
));
}
// optional preview
if let Some(preview) = &entry.value {
spans.push(Span::raw(": "));
if let Some(preview_match_ranges) = &entry.value_match_ranges {
if !preview_match_ranges.is_empty() {
let mut last_match_end = 0;
for (start, end) in preview_match_ranges
.iter()
.map(|(s, e)| (*s as usize, *e as usize))
{
spans.push(Span::styled(
slice_at_char_boundaries(
preview,
last_match_end,
start,
),
Style::default().fg(DEFAULT_RESULT_PREVIEW_FG),
));
spans.push(Span::styled(
slice_at_char_boundaries(preview, start, end),
Style::default().fg(Color::Red),
));
last_match_end = end;
}
spans.push(Span::styled(
&preview[next_char_boundary(
preview,
preview_match_ranges.last().unwrap().1 as usize,
)..],
Style::default().fg(DEFAULT_RESULT_PREVIEW_FG),
));
}
} else {
spans.push(Span::styled(
preview,
Style::default().fg(DEFAULT_RESULT_PREVIEW_FG),
));
}
}
Line::from(spans)
}))
.direction(ListDirection::BottomToTop)
.highlight_style(Style::default().bg(Color::Rgb(50, 50, 50)))
.highlight_symbol("> ")
.block(results_block)
}

View File

@ -0,0 +1,588 @@
pub mod backend;
/// Input requests are used to change the input state.
///
/// Different backends can be used to convert events into requests.
#[allow(clippy::module_name_repetitions)]
#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)]
pub enum InputRequest {
SetCursor(usize),
InsertChar(char),
GoToPrevChar,
GoToNextChar,
GoToPrevWord,
GoToNextWord,
GoToStart,
GoToEnd,
DeletePrevChar,
DeleteNextChar,
DeletePrevWord,
DeleteNextWord,
DeleteLine,
DeleteTillEnd,
}
#[derive(Debug, PartialOrd, PartialEq, Eq, Clone, Copy, Hash)]
pub struct StateChanged {
pub value: bool,
pub cursor: bool,
}
#[allow(clippy::module_name_repetitions)]
pub type InputResponse = Option<StateChanged>;
/// The input buffer with cursor support.
///
/// Example:
///
/// ```
/// use tui_input::Input;
///
/// let input: Input = "Hello World".into();
///
/// assert_eq!(input.cursor(), 11);
/// assert_eq!(input.to_string(), "Hello World");
/// ```
#[derive(Default, Debug, Clone)]
pub struct Input {
value: String,
cursor: usize,
}
impl Input {
/// Initialize a new instance with a given value
/// Cursor will be set to the given value's length.
pub fn new(value: String) -> Self {
let len = value.chars().count();
Self { value, cursor: len }
}
/// Set the value manually.
/// Cursor will be set to the given value's length.
pub fn with_value(mut self, value: String) -> Self {
self.cursor = value.chars().count();
self.value = value;
self
}
/// Set the cursor manually.
/// If the input is larger than the value length, it'll be auto adjusted.
pub fn with_cursor(mut self, cursor: usize) -> Self {
self.cursor = cursor.min(self.value.chars().count());
self
}
// Reset the cursor and value to default
pub fn reset(&mut self) {
self.cursor = Default::default();
self.value = String::default();
}
/// Handle request and emit response.
#[allow(clippy::too_many_lines)]
pub fn handle(&mut self, req: InputRequest) -> InputResponse {
use InputRequest::{
DeleteLine, DeleteNextChar, DeleteNextWord, DeletePrevChar,
DeletePrevWord, DeleteTillEnd, GoToEnd, GoToNextChar,
GoToNextWord, GoToPrevChar, GoToPrevWord, GoToStart, InsertChar,
SetCursor,
};
match req {
SetCursor(pos) => {
let pos = pos.min(self.value.chars().count());
if self.cursor == pos {
None
} else {
self.cursor = pos;
Some(StateChanged {
value: false,
cursor: true,
})
}
}
InsertChar(c) => {
if self.cursor == self.value.chars().count() {
self.value.push(c);
} else {
self.value = self
.value
.chars()
.take(self.cursor)
.chain(
std::iter::once(c)
.chain(self.value.chars().skip(self.cursor)),
)
.collect();
}
self.cursor += 1;
Some(StateChanged {
value: true,
cursor: true,
})
}
DeletePrevChar => {
if self.cursor == 0 {
None
} else {
self.cursor -= 1;
self.value = self
.value
.chars()
.enumerate()
.filter(|(i, _)| i != &self.cursor)
.map(|(_, c)| c)
.collect();
Some(StateChanged {
value: true,
cursor: true,
})
}
}
DeleteNextChar => {
if self.cursor == self.value.chars().count() {
None
} else {
self.value = self
.value
.chars()
.enumerate()
.filter(|(i, _)| i != &self.cursor)
.map(|(_, c)| c)
.collect();
Some(StateChanged {
value: true,
cursor: false,
})
}
}
GoToPrevChar => {
if self.cursor == 0 {
None
} else {
self.cursor -= 1;
Some(StateChanged {
value: false,
cursor: true,
})
}
}
GoToPrevWord => {
if self.cursor == 0 {
None
} else {
self.cursor = self
.value
.chars()
.rev()
.skip(
self.value.chars().count().max(self.cursor)
- self.cursor,
)
.skip_while(|c| !c.is_alphanumeric())
.skip_while(|c| c.is_alphanumeric())
.count();
Some(StateChanged {
value: false,
cursor: true,
})
}
}
GoToNextChar => {
if self.cursor == self.value.chars().count() {
None
} else {
self.cursor += 1;
Some(StateChanged {
value: false,
cursor: true,
})
}
}
GoToNextWord => {
if self.cursor == self.value.chars().count() {
None
} else {
self.cursor = self
.value
.chars()
.enumerate()
.skip(self.cursor)
.skip_while(|(_, c)| c.is_alphanumeric())
.find(|(_, c)| c.is_alphanumeric())
.map(|(i, _)| i)
.unwrap_or_else(|| self.value.chars().count());
Some(StateChanged {
value: false,
cursor: true,
})
}
}
DeleteLine => {
if self.value.is_empty() {
None
} else {
let cursor = self.cursor;
self.value = "".into();
self.cursor = 0;
Some(StateChanged {
value: true,
cursor: self.cursor == cursor,
})
}
}
DeletePrevWord => {
if self.cursor == 0 {
None
} else {
let remaining = self.value.chars().skip(self.cursor);
let rev = self
.value
.chars()
.rev()
.skip(
self.value.chars().count().max(self.cursor)
- self.cursor,
)
.skip_while(|c| !c.is_alphanumeric())
.skip_while(|c| c.is_alphanumeric())
.collect::<Vec<char>>();
let rev_len = rev.len();
self.value =
rev.into_iter().rev().chain(remaining).collect();
self.cursor = rev_len;
Some(StateChanged {
value: true,
cursor: true,
})
}
}
DeleteNextWord => {
if self.cursor == self.value.chars().count() {
None
} else {
self.value = self
.value
.chars()
.take(self.cursor)
.chain(
self.value
.chars()
.skip(self.cursor)
.skip_while(|c| c.is_alphanumeric())
.skip_while(|c| !c.is_alphanumeric()),
)
.collect();
Some(StateChanged {
value: true,
cursor: false,
})
}
}
GoToStart => {
if self.cursor == 0 {
None
} else {
self.cursor = 0;
Some(StateChanged {
value: false,
cursor: true,
})
}
}
GoToEnd => {
let count = self.value.chars().count();
if self.cursor == count {
None
} else {
self.cursor = count;
Some(StateChanged {
value: false,
cursor: true,
})
}
}
DeleteTillEnd => {
self.value = self.value.chars().take(self.cursor).collect();
Some(StateChanged {
value: true,
cursor: false,
})
}
}
}
/// Get a reference to the current value.
pub fn value(&self) -> &str {
self.value.as_str()
}
/// Get the currect cursor placement.
pub fn cursor(&self) -> usize {
self.cursor
}
/// Get the current cursor position with account for multispace characters.
pub fn visual_cursor(&self) -> usize {
if self.cursor == 0 {
return 0;
}
// Safe, because the end index will always be within bounds
unicode_width::UnicodeWidthStr::width(unsafe {
self.value.get_unchecked(
0..self
.value
.char_indices()
.nth(self.cursor)
.map_or_else(|| self.value.len(), |(index, _)| index),
)
})
}
/// Get the scroll position with account for multispace characters.
pub fn visual_scroll(&self, width: usize) -> usize {
let scroll = (self.visual_cursor()).max(width) - width;
let mut uscroll = 0;
let mut chars = self.value().chars();
while uscroll < scroll {
match chars.next() {
Some(c) => {
uscroll +=
unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
}
None => break,
}
}
uscroll
}
}
impl From<Input> for String {
fn from(input: Input) -> Self {
input.value
}
}
impl From<String> for Input {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl From<&str> for Input {
fn from(value: &str) -> Self {
Self::new(value.into())
}
}
impl std::fmt::Display for Input {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.value.fmt(f)
}
}
#[cfg(test)]
mod tests {
const TEXT: &str = "first second, third.";
use super::*;
#[test]
fn format() {
let input: Input = TEXT.into();
println!("{}", input);
println!("{}", input);
}
#[test]
fn set_cursor() {
let mut input: Input = TEXT.into();
let req = InputRequest::SetCursor(3);
let resp = input.handle(req);
assert_eq!(
resp,
Some(StateChanged {
value: false,
cursor: true,
})
);
assert_eq!(input.value(), "first second, third.");
assert_eq!(input.cursor(), 3);
let req = InputRequest::SetCursor(30);
let resp = input.handle(req);
assert_eq!(input.cursor(), TEXT.chars().count());
assert_eq!(
resp,
Some(StateChanged {
value: false,
cursor: true,
})
);
let req = InputRequest::SetCursor(TEXT.chars().count());
let resp = input.handle(req);
assert_eq!(input.cursor(), TEXT.chars().count());
assert_eq!(resp, None);
}
#[test]
fn insert_char() {
let mut input: Input = TEXT.into();
let req = InputRequest::InsertChar('x');
let resp = input.handle(req);
assert_eq!(
resp,
Some(StateChanged {
value: true,
cursor: true,
})
);
assert_eq!(input.value(), "first second, third.x");
assert_eq!(input.cursor(), TEXT.chars().count() + 1);
input.handle(req);
assert_eq!(input.value(), "first second, third.xx");
assert_eq!(input.cursor(), TEXT.chars().count() + 2);
let mut input = input.with_cursor(3);
input.handle(req);
assert_eq!(input.value(), "firxst second, third.xx");
assert_eq!(input.cursor(), 4);
input.handle(req);
assert_eq!(input.value(), "firxxst second, third.xx");
assert_eq!(input.cursor(), 5);
}
#[test]
fn go_to_prev_char() {
let mut input: Input = TEXT.into();
let req = InputRequest::GoToPrevChar;
let resp = input.handle(req);
assert_eq!(
resp,
Some(StateChanged {
value: false,
cursor: true,
})
);
assert_eq!(input.value(), "first second, third.");
assert_eq!(input.cursor(), TEXT.chars().count() - 1);
let mut input = input.with_cursor(3);
input.handle(req);
assert_eq!(input.value(), "first second, third.");
assert_eq!(input.cursor(), 2);
input.handle(req);
assert_eq!(input.value(), "first second, third.");
assert_eq!(input.cursor(), 1);
}
#[test]
fn remove_unicode_chars() {
let mut input: Input = "¡test¡".into();
let req = InputRequest::DeletePrevChar;
let resp = input.handle(req);
assert_eq!(
resp,
Some(StateChanged {
value: true,
cursor: true,
})
);
assert_eq!(input.value(), "¡test");
assert_eq!(input.cursor(), 5);
input.handle(InputRequest::GoToStart);
let req = InputRequest::DeleteNextChar;
let resp = input.handle(req);
assert_eq!(
resp,
Some(StateChanged {
value: true,
cursor: false,
})
);
assert_eq!(input.value(), "test");
assert_eq!(input.cursor(), 0);
}
#[test]
fn insert_unicode_chars() {
let mut input = Input::from("¡test¡").with_cursor(5);
let req = InputRequest::InsertChar('☆');
let resp = input.handle(req);
assert_eq!(
resp,
Some(StateChanged {
value: true,
cursor: true,
})
);
assert_eq!(input.value(), "¡test☆¡");
assert_eq!(input.cursor(), 6);
input.handle(InputRequest::GoToStart);
input.handle(InputRequest::GoToNextChar);
let req = InputRequest::InsertChar('☆');
let resp = input.handle(req);
assert_eq!(
resp,
Some(StateChanged {
value: true,
cursor: true,
})
);
assert_eq!(input.value(), "¡☆test☆¡");
assert_eq!(input.cursor(), 2);
}
#[test]
fn multispace_characters() {
let input: Input = ", !".into();
assert_eq!(input.cursor(), 13);
assert_eq!(input.visual_cursor(), 23);
assert_eq!(input.visual_scroll(6), 18);
}
}

View File

@ -0,0 +1,75 @@
use super::{Input, InputRequest, StateChanged};
use ratatui::crossterm::event::{
Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
};
/// Converts crossterm event into input requests.
/// TODO: make these keybindings configurable.
pub fn to_input_request(evt: &CrosstermEvent) -> Option<InputRequest> {
use InputRequest::*;
use KeyCode::*;
match evt {
CrosstermEvent::Key(KeyEvent {
code,
modifiers,
kind,
state: _,
}) if *kind == KeyEventKind::Press => match (*code, *modifiers) {
(Backspace, KeyModifiers::NONE) => Some(DeletePrevChar),
(Delete, KeyModifiers::NONE) => Some(DeleteNextChar),
(Tab, KeyModifiers::NONE) => None,
(Left, KeyModifiers::NONE) => Some(GoToPrevChar),
//(Left, KeyModifiers::CONTROL) => Some(GoToPrevWord),
(Right, KeyModifiers::NONE) => Some(GoToNextChar),
//(Right, KeyModifiers::CONTROL) => Some(GoToNextWord),
//(Char('u'), KeyModifiers::CONTROL) => Some(DeleteLine),
(Char('w'), KeyModifiers::CONTROL)
| (Backspace, KeyModifiers::ALT) => Some(DeletePrevWord),
(Delete, KeyModifiers::CONTROL) => Some(DeleteNextWord),
//(Char('k'), KeyModifiers::CONTROL) => Some(DeleteTillEnd),
(Char('a'), KeyModifiers::CONTROL)
| (Home, KeyModifiers::NONE) => Some(GoToStart),
(Char('e'), KeyModifiers::CONTROL) | (End, KeyModifiers::NONE) => {
Some(GoToEnd)
}
(Char(c), KeyModifiers::NONE) => Some(InsertChar(c)),
(Char(c), KeyModifiers::SHIFT) => Some(InsertChar(c)),
(_, _) => None,
},
_ => None,
}
}
/// Import this trait to implement `Input::handle_event()` for crossterm.
pub trait EventHandler {
/// Handle crossterm event.
fn handle_event(&mut self, evt: &CrosstermEvent) -> Option<StateChanged>;
}
impl EventHandler for Input {
/// Handle crossterm event.
fn handle_event(&mut self, evt: &CrosstermEvent) -> Option<StateChanged> {
to_input_request(evt).and_then(|req| self.handle(req))
}
}
#[cfg(test)]
mod tests {
use ratatui::crossterm::event::{KeyEventKind, KeyEventState};
use super::*;
#[test]
fn handle_tab() {
let evt = CrosstermEvent::Key(KeyEvent {
code: KeyCode::Tab,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
});
let req = to_input_request(&evt);
assert!(req.is_none());
}
}

View File

@ -0,0 +1,4 @@
pub mod files;
pub mod indices;
pub mod strings;
pub mod ui;

View File

@ -0,0 +1,399 @@
use std::collections::HashSet;
use std::path::Path;
use ignore::{types::TypesBuilder, WalkBuilder};
use infer::Infer;
use lazy_static::lazy_static;
use crate::config::default_num_threads;
lazy_static::lazy_static! {
pub static ref DEFAULT_NUM_THREADS: usize = default_num_threads().into();
}
pub fn walk_builder(path: &Path, n_threads: usize) -> WalkBuilder {
let mut builder = WalkBuilder::new(path);
// ft-based filtering
let mut types_builder = TypesBuilder::new();
types_builder.add_defaults();
builder.types(types_builder.build().unwrap());
builder.threads(n_threads);
builder
}
pub fn get_file_size(path: &Path) -> Option<u64> {
std::fs::metadata(path).ok().map(|m| m.len())
}
#[derive(Debug)]
pub enum FileType {
Text,
Image,
Other,
Unknown,
}
pub fn is_not_text(bytes: &[u8]) -> Option<bool> {
let infer = Infer::new();
match infer.get(bytes) {
Some(t) => {
let mime_type = t.mime_type();
if mime_type.contains("image")
|| mime_type.contains("video")
|| mime_type.contains("audio")
|| mime_type.contains("archive")
|| mime_type.contains("book")
|| mime_type.contains("font")
{
Some(true)
} else {
None
}
}
None => None,
}
}
pub fn is_valid_utf8(bytes: &[u8]) -> bool {
std::str::from_utf8(bytes).is_ok()
}
pub fn is_known_text_extension(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| KNOWN_TEXT_FILE_EXTENSIONS.contains(ext))
}
lazy_static! {
static ref KNOWN_TEXT_FILE_EXTENSIONS: HashSet<&'static str> = [
"ada",
"adb",
"ads",
"applescript",
"as",
"asc",
"ascii",
"ascx",
"asm",
"asmx",
"asp",
"aspx",
"atom",
"au3",
"awk",
"bas",
"bash",
"bashrc",
"bat",
"bbcolors",
"bcp",
"bdsgroup",
"bdsproj",
"bib",
"bowerrc",
"c",
"cbl",
"cc",
"cfc",
"cfg",
"cfm",
"cfml",
"cgi",
"cjs",
"clj",
"cljs",
"cls",
"cmake",
"cmd",
"cnf",
"cob",
"code-snippets",
"coffee",
"coffeekup",
"conf",
"cp",
"cpp",
"cpt",
"cpy",
"crt",
"cs",
"csh",
"cson",
"csproj",
"csr",
"css",
"csslintrc",
"csv",
"ctl",
"curlrc",
"cxx",
"d",
"dart",
"dfm",
"diff",
"dof",
"dpk",
"dpr",
"dproj",
"dtd",
"eco",
"editorconfig",
"ejs",
"el",
"elm",
"emacs",
"eml",
"ent",
"erb",
"erl",
"eslintignore",
"eslintrc",
"ex",
"exs",
"f",
"f03",
"f77",
"f90",
"f95",
"fish",
"for",
"fpp",
"frm",
"fs",
"fsproj",
"fsx",
"ftn",
"gemrc",
"gemspec",
"gitattributes",
"gitconfig",
"gitignore",
"gitkeep",
"gitmodules",
"go",
"gpp",
"gradle",
"graphql",
"groovy",
"groupproj",
"grunit",
"gtmpl",
"gvimrc",
"h",
"haml",
"hbs",
"hgignore",
"hh",
"hpp",
"hrl",
"hs",
"hta",
"htaccess",
"htc",
"htm",
"html",
"htpasswd",
"hxx",
"iced",
"iml",
"inc",
"inf",
"info",
"ini",
"ino",
"int",
"irbrc",
"itcl",
"itermcolors",
"itk",
"jade",
"java",
"jhtm",
"jhtml",
"js",
"jscsrc",
"jshintignore",
"jshintrc",
"json",
"json5",
"jsonld",
"jsp",
"jspx",
"jsx",
"ksh",
"less",
"lhs",
"lisp",
"log",
"ls",
"lsp",
"lua",
"m",
"m4",
"mak",
"map",
"markdown",
"master",
"md",
"mdown",
"mdwn",
"mdx",
"metadata",
"mht",
"mhtml",
"mjs",
"mk",
"mkd",
"mkdn",
"mkdown",
"ml",
"mli",
"mm",
"mxml",
"nfm",
"nfo",
"noon",
"npmignore",
"npmrc",
"nuspec",
"nvmrc",
"ops",
"pas",
"pasm",
"patch",
"pbxproj",
"pch",
"pem",
"pg",
"php",
"php3",
"php4",
"php5",
"phpt",
"phtml",
"pir",
"pl",
"pm",
"pmc",
"pod",
"pot",
"prettierrc",
"properties",
"props",
"pt",
"pug",
"purs",
"py",
"pyx",
"r",
"rake",
"rb",
"rbw",
"rc",
"rdoc",
"rdoc_options",
"resx",
"rexx",
"rhtml",
"rjs",
"rlib",
"ron",
"rs",
"rss",
"rst",
"rtf",
"rvmrc",
"rxml",
"s",
"sass",
"scala",
"scm",
"scss",
"seestyle",
"sh",
"shtml",
"sln",
"sls",
"spec",
"sql",
"sqlite",
"sqlproj",
"srt",
"ss",
"sss",
"st",
"strings",
"sty",
"styl",
"stylus",
"sub",
"sublime-build",
"sublime-commands",
"sublime-completions",
"sublime-keymap",
"sublime-macro",
"sublime-menu",
"sublime-project",
"sublime-settings",
"sublime-workspace",
"sv",
"svc",
"svg",
"swift",
"t",
"tcl",
"tcsh",
"terminal",
"tex",
"text",
"textile",
"tg",
"tk",
"tmLanguage",
"tmpl",
"tmTheme",
"tpl",
"ts",
"tsv",
"tsx",
"tt",
"tt2",
"ttml",
"twig",
"txt",
"v",
"vb",
"vbproj",
"vbs",
"vcproj",
"vcxproj",
"vh",
"vhd",
"vhdl",
"vim",
"viminfo",
"vimrc",
"vm",
"vue",
"webapp",
"webmanifest",
"wsc",
"x-php",
"xaml",
"xht",
"xhtml",
"xml",
"xs",
"xsd",
"xsl",
"xslt",
"y",
"yaml",
"yml",
"zsh",
"zshrc",
]
.into();
}

View File

@ -0,0 +1,31 @@
pub fn sep_name_and_value_indices(
indices: &mut Vec<u32>,
name_len: u32,
) -> (Vec<u32>, Vec<u32>, bool, bool) {
let mut name_indices = Vec::new();
let mut value_indices = Vec::new();
let mut should_add_name_indices = false;
let mut should_add_value_indices = false;
for i in indices.drain(..) {
if i < name_len {
name_indices.push(i);
should_add_name_indices = true;
} else {
value_indices.push(i - name_len);
should_add_value_indices = true;
}
}
name_indices.sort_unstable();
name_indices.dedup();
value_indices.sort_unstable();
value_indices.dedup();
(
name_indices,
value_indices,
should_add_name_indices,
should_add_value_indices,
)
}

View File

@ -0,0 +1,123 @@
use lazy_static::lazy_static;
use std::fmt::Write;
pub fn next_char_boundary(s: &str, start: usize) -> usize {
let mut i = start;
while !s.is_char_boundary(i) {
i += 1;
}
i
}
pub fn prev_char_boundary(s: &str, start: usize) -> usize {
let mut i = start;
while !s.is_char_boundary(i) {
i -= 1;
}
i
}
pub fn slice_at_char_boundaries(
s: &str,
start_byte_index: usize,
end_byte_index: usize,
) -> &str {
&s[prev_char_boundary(s, start_byte_index)
..next_char_boundary(s, end_byte_index)]
}
pub fn slice_up_to_char_boundary(s: &str, byte_index: usize) -> &str {
let mut char_index = byte_index;
while !s.is_char_boundary(char_index) {
char_index -= 1;
}
&s[..char_index]
}
fn try_parse_utf8_char(input: &[u8]) -> Option<(char, usize)> {
let str_from_utf8 = |seq| std::str::from_utf8(seq).ok();
let decoded = input
.get(0..1)
.and_then(str_from_utf8)
.map(|c| (c, 1))
.or_else(|| input.get(0..2).and_then(str_from_utf8).map(|c| (c, 2)))
.or_else(|| input.get(0..3).and_then(str_from_utf8).map(|c| (c, 3)))
.or_else(|| input.get(0..4).and_then(str_from_utf8).map(|c| (c, 4)));
decoded.map(|(seq, n)| (seq.chars().next().unwrap(), n))
}
lazy_static! {
static ref NULL_SYMBOL: char = char::from_u32(0x2400).unwrap();
}
const SPACE_CHARACTER: char = ' ';
const TAB_CHARACTER: char = '\t';
const LINE_FEED_CHARACTER: char = '\x0A';
const DELETE_CHARACTER: char = '\x7F';
const BOM_CHARACTER: char = '\u{FEFF}';
const NULL_CHARACTER: char = '\x00';
const UNIT_SEPARATOR_CHARACTER: char = '\u{001F}';
const APPLICATION_PROGRAM_COMMAND_CHARACTER: char = '\u{009F}';
pub fn replace_nonprintable(input: &[u8], tab_width: usize) -> String {
let mut output = String::new();
let mut idx = 0;
let len = input.len();
while idx < len {
if let Some((chr, skip_ahead)) = try_parse_utf8_char(&input[idx..]) {
idx += skip_ahead;
match chr {
// space
SPACE_CHARACTER => output.push(' '),
// tab
TAB_CHARACTER => output.push_str(&" ".repeat(tab_width)),
// line feed
LINE_FEED_CHARACTER => {
output.push_str("\x0A");
}
// ASCII control characters from 0x00 to 0x1F
NULL_CHARACTER..=UNIT_SEPARATOR_CHARACTER => {
output.push(*NULL_SYMBOL)
}
// control characters from \u{007F} to \u{009F}
DELETE_CHARACTER..=APPLICATION_PROGRAM_COMMAND_CHARACTER => {
output.push(*NULL_SYMBOL)
}
// don't print BOMs
BOM_CHARACTER => {}
// unicode characters above 0x0700 seem unstable with ratatui
c if c > '\u{0700}' => {
output.push(*NULL_SYMBOL);
}
// everything else
c => output.push(c),
}
} else {
write!(output, "\\x{:02X}", input[idx]).ok();
idx += 1;
}
}
output
}
const MAX_LINE_LENGTH: usize = 500;
pub fn preprocess_line(line: &str) -> String {
replace_nonprintable(
{
if line.len() > MAX_LINE_LENGTH {
slice_up_to_char_boundary(line, MAX_LINE_LENGTH)
} else {
line
}
}
.trim_end_matches(['\r', '\n', '\0'])
.as_bytes(),
2,
)
}

View File

@ -0,0 +1,24 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
/// helper function to create a centered rect using up certain percentage of the available rect `r`
pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
// Cut the given rectangle into three vertical pieces
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
// Then cut the middle vertical piece into three width-wise pieces
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1] // Return the middle chunk
}

46
crates/television_derive/Cargo.lock generated Normal file
View File

@ -0,0 +1,46 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "channel_derive"
version = "0.1.0"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
dependencies = [
"proc-macro2",
]
[[package]]
name = "syn"
version = "2.0.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"

View File

@ -0,0 +1,13 @@
[package]
name = "television-derive"
version = "0.1.0"
edition = "2021"
[dependencies]
proc-macro2 = "1.0.87"
quote = "1.0.37"
syn = "2.0.79"
[lib]
proc-macro = true

View File

@ -0,0 +1,80 @@
use proc_macro::TokenStream;
use quote::quote;
#[proc_macro_derive(CliChannel)]
pub fn cli_channel_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree
// that we can manipulate
let ast = syn::parse(input).unwrap();
// Build the trait implementation
impl_cli_channel(&ast)
}
fn impl_cli_channel(ast: &syn::DeriveInput) -> TokenStream {
// check that the struct is an enum
let variants = if let syn::Data::Enum(data_enum) = &ast.data {
&data_enum.variants
} else {
panic!("#[derive(CliChannel)] is only defined for enums");
};
// check that the enum has at least one variant
assert!(
!variants.is_empty(),
"#[derive(CliChannel)] requires at least one variant"
);
// create the CliTvChannel enum
let cli_enum_variants = variants.iter().map(|variant| {
let variant_name = &variant.ident;
quote! {
#variant_name
}
});
let cli_enum = quote! {
use clap::ValueEnum;
#[derive(Debug, Clone, ValueEnum, Default, Copy)]
pub enum CliTvChannel {
#[default]
#(#cli_enum_variants),*
}
};
// Generate the match arms for the `to_channel` method
let arms = variants.iter().map(|variant| {
let variant_name = &variant.ident;
// Get the inner type of the variant, assuming it is the first field of the variant
if let syn::Fields::Unnamed(fields) = &variant.fields {
if fields.unnamed.len() == 1 {
// Get the inner type of the variant (e.g., EnvChannel)
let inner_type = &fields.unnamed[0].ty;
quote! {
CliTvChannel::#variant_name => Box::new(#inner_type::new())
}
} else {
panic!("Enum variants should have exactly one unnamed field.");
}
} else {
panic!("Enum variants expected to only have unnamed fields.");
}
});
let gen = quote! {
#cli_enum
impl CliTvChannel {
pub fn to_channel(self) -> Box<dyn TelevisionChannel> {
match self {
#(#arms),*
}
}
}
};
gen.into()
}

2
rustfmt.toml Normal file
View File

@ -0,0 +1,2 @@
edition = "2021"
max_width = 79

View File

@ -1,15 +0,0 @@
use serde::{Deserialize, Serialize};
use strum::Display;
#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)]
pub enum Action {
Tick,
Render,
Resize(u16, u16),
Suspend,
Resume,
Quit,
ClearScreen,
Error(String),
Help,
}

View File

@ -1,177 +0,0 @@
use color_eyre::Result;
use crossterm::event::KeyEvent;
use ratatui::prelude::Rect;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tracing::{debug, info};
use crate::{
action::Action,
components::{fps::FpsCounter, home::Home, Component},
config::Config,
tui::{Event, Tui},
};
pub struct App {
config: Config,
tick_rate: f64,
frame_rate: f64,
components: Vec<Box<dyn Component>>,
should_quit: bool,
should_suspend: bool,
mode: Mode,
last_tick_key_events: Vec<KeyEvent>,
action_tx: mpsc::UnboundedSender<Action>,
action_rx: mpsc::UnboundedReceiver<Action>,
}
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Mode {
#[default]
Home,
}
impl App {
pub fn new(tick_rate: f64, frame_rate: f64) -> Result<Self> {
let (action_tx, action_rx) = mpsc::unbounded_channel();
Ok(Self {
tick_rate,
frame_rate,
components: vec![Box::new(Home::new()), Box::new(FpsCounter::default())],
should_quit: false,
should_suspend: false,
config: Config::new()?,
mode: Mode::Home,
last_tick_key_events: Vec::new(),
action_tx,
action_rx,
})
}
pub async fn run(&mut self) -> Result<()> {
let mut tui = Tui::new()?
// .mouse(true) // uncomment this line to enable mouse support
.tick_rate(self.tick_rate)
.frame_rate(self.frame_rate);
tui.enter()?;
for component in self.components.iter_mut() {
component.register_action_handler(self.action_tx.clone())?;
}
for component in self.components.iter_mut() {
component.register_config_handler(self.config.clone())?;
}
for component in self.components.iter_mut() {
component.init(tui.size()?)?;
}
let action_tx = self.action_tx.clone();
loop {
self.handle_events(&mut tui).await?;
self.handle_actions(&mut tui)?;
if self.should_suspend {
tui.suspend()?;
action_tx.send(Action::Resume)?;
action_tx.send(Action::ClearScreen)?;
// tui.mouse(true);
tui.enter()?;
} else if self.should_quit {
tui.stop()?;
break;
}
}
tui.exit()?;
Ok(())
}
async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> {
let Some(event) = tui.next_event().await else {
return Ok(());
};
let action_tx = self.action_tx.clone();
match event {
Event::Quit => action_tx.send(Action::Quit)?,
Event::Tick => action_tx.send(Action::Tick)?,
Event::Render => action_tx.send(Action::Render)?,
Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
Event::Key(key) => self.handle_key_event(key)?,
_ => {}
}
for component in self.components.iter_mut() {
if let Some(action) = component.handle_events(Some(event.clone()))? {
action_tx.send(action)?;
}
}
Ok(())
}
fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> {
let action_tx = self.action_tx.clone();
let Some(keymap) = self.config.keybindings.get(&self.mode) else {
return Ok(());
};
match keymap.get(&vec![key]) {
Some(action) => {
info!("Got action: {action:?}");
action_tx.send(action.clone())?;
}
_ => {
// If the key was not handled as a single key action,
// then consider it for multi-key combinations.
self.last_tick_key_events.push(key);
// Check for multi-key combinations
if let Some(action) = keymap.get(&self.last_tick_key_events) {
info!("Got action: {action:?}");
action_tx.send(action.clone())?;
}
}
}
Ok(())
}
fn handle_actions(&mut self, tui: &mut Tui) -> Result<()> {
while let Ok(action) = self.action_rx.try_recv() {
if action != Action::Tick && action != Action::Render {
debug!("{action:?}");
}
match action {
Action::Tick => {
self.last_tick_key_events.drain(..);
}
Action::Quit => self.should_quit = true,
Action::Suspend => self.should_suspend = true,
Action::Resume => self.should_suspend = false,
Action::ClearScreen => tui.terminal.clear()?,
Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
Action::Render => self.render(tui)?,
_ => {}
}
for component in self.components.iter_mut() {
if let Some(action) = component.update(action.clone())? {
self.action_tx.send(action)?
};
}
}
Ok(())
}
fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> Result<()> {
tui.resize(Rect::new(0, 0, w, h))?;
self.render(tui)?;
Ok(())
}
fn render(&mut self, tui: &mut Tui) -> Result<()> {
tui.draw(|frame| {
for component in self.components.iter_mut() {
if let Err(err) = component.draw(frame, frame.area()) {
let _ = self
.action_tx
.send(Action::Error(format!("Failed to draw: {:?}", err)));
}
}
})?;
Ok(())
}
}

View File

@ -1,125 +0,0 @@
use color_eyre::Result;
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::{
layout::{Rect, Size},
Frame,
};
use tokio::sync::mpsc::UnboundedSender;
use crate::{action::Action, config::Config, tui::Event};
pub mod fps;
pub mod home;
/// `Component` is a trait that represents a visual and interactive element of the user interface.
///
/// Implementors of this trait can be registered with the main application loop and will be able to
/// receive events, update state, and be rendered on the screen.
pub trait Component {
/// Register an action handler that can send actions for processing if necessary.
///
/// # Arguments
///
/// * `tx` - An unbounded sender that can send actions.
///
/// # Returns
///
/// * `Result<()>` - An Ok result or an error.
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
let _ = tx; // to appease clippy
Ok(())
}
/// Register a configuration handler that provides configuration settings if necessary.
///
/// # Arguments
///
/// * `config` - Configuration settings.
///
/// # Returns
///
/// * `Result<()>` - An Ok result or an error.
fn register_config_handler(&mut self, config: Config) -> Result<()> {
let _ = config; // to appease clippy
Ok(())
}
/// Initialize the component with a specified area if necessary.
///
/// # Arguments
///
/// * `area` - Rectangular area to initialize the component within.
///
/// # Returns
///
/// * `Result<()>` - An Ok result or an error.
fn init(&mut self, area: Size) -> Result<()> {
let _ = area; // to appease clippy
Ok(())
}
/// Handle incoming events and produce actions if necessary.
///
/// # Arguments
///
/// * `event` - An optional event to be processed.
///
/// # Returns
///
/// * `Result<Option<Action>>` - An action to be processed or none.
fn handle_events(&mut self, event: Option<Event>) -> Result<Option<Action>> {
let action = match event {
Some(Event::Key(key_event)) => self.handle_key_event(key_event)?,
Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?,
_ => None,
};
Ok(action)
}
/// Handle key events and produce actions if necessary.
///
/// # Arguments
///
/// * `key` - A key event to be processed.
///
/// # Returns
///
/// * `Result<Option<Action>>` - An action to be processed or none.
fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
let _ = key; // to appease clippy
Ok(None)
}
/// Handle mouse events and produce actions if necessary.
///
/// # Arguments
///
/// * `mouse` - A mouse event to be processed.
///
/// # Returns
///
/// * `Result<Option<Action>>` - An action to be processed or none.
fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result<Option<Action>> {
let _ = mouse; // to appease clippy
Ok(None)
}
/// Update the state of the component based on a received action. (REQUIRED)
///
/// # Arguments
///
/// * `action` - An action that may modify the state of the component.
///
/// # Returns
///
/// * `Result<Option<Action>>` - An action to be processed or none.
fn update(&mut self, action: Action) -> Result<Option<Action>> {
let _ = action; // to appease clippy
Ok(None)
}
/// Render the component on the screen. (REQUIRED)
///
/// # Arguments
///
/// * `f` - A frame used for rendering.
/// * `area` - The area in which the component should be drawn.
///
/// # Returns
///
/// * `Result<()>` - An Ok result or an error.
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()>;
}

View File

@ -1,91 +0,0 @@
use std::time::Instant;
use color_eyre::Result;
use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Style, Stylize},
text::Span,
widgets::Paragraph,
Frame,
};
use super::Component;
use crate::action::Action;
#[derive(Debug, Clone, PartialEq)]
pub struct FpsCounter {
last_tick_update: Instant,
tick_count: u32,
ticks_per_second: f64,
last_frame_update: Instant,
frame_count: u32,
frames_per_second: f64,
}
impl Default for FpsCounter {
fn default() -> Self {
Self::new()
}
}
impl FpsCounter {
pub fn new() -> Self {
Self {
last_tick_update: Instant::now(),
tick_count: 0,
ticks_per_second: 0.0,
last_frame_update: Instant::now(),
frame_count: 0,
frames_per_second: 0.0,
}
}
fn app_tick(&mut self) -> Result<()> {
self.tick_count += 1;
let now = Instant::now();
let elapsed = (now - self.last_tick_update).as_secs_f64();
if elapsed >= 1.0 {
self.ticks_per_second = self.tick_count as f64 / elapsed;
self.last_tick_update = now;
self.tick_count = 0;
}
Ok(())
}
fn render_tick(&mut self) -> Result<()> {
self.frame_count += 1;
let now = Instant::now();
let elapsed = (now - self.last_frame_update).as_secs_f64();
if elapsed >= 1.0 {
self.frames_per_second = self.frame_count as f64 / elapsed;
self.last_frame_update = now;
self.frame_count = 0;
}
Ok(())
}
}
impl Component for FpsCounter {
fn update(&mut self, action: Action) -> Result<Option<Action>> {
match action {
Action::Tick => self.app_tick()?,
Action::Render => self.render_tick()?,
_ => {}
};
Ok(None)
}
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let [top, _] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
let message = format!(
"{:.2} ticks/sec, {:.2} FPS",
self.ticks_per_second, self.frames_per_second
);
let span = Span::styled(message, Style::new().dim());
let paragraph = Paragraph::new(span).right_aligned();
frame.render_widget(paragraph, top);
Ok(())
}
}

View File

@ -1,48 +0,0 @@
use color_eyre::Result;
use ratatui::{prelude::*, widgets::*};
use tokio::sync::mpsc::UnboundedSender;
use super::Component;
use crate::{action::Action, config::Config};
#[derive(Default)]
pub struct Home {
command_tx: Option<UnboundedSender<Action>>,
config: Config,
}
impl Home {
pub fn new() -> Self {
Self::default()
}
}
impl Component for Home {
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
self.command_tx = Some(tx);
Ok(())
}
fn register_config_handler(&mut self, config: Config) -> Result<()> {
self.config = config;
Ok(())
}
fn update(&mut self, action: Action) -> Result<Option<Action>> {
match action {
Action::Tick => {
// add any logic here that should run on every tick
}
Action::Render => {
// add any logic here that should run on every render
}
_ => {}
}
Ok(None)
}
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
frame.render_widget(Paragraph::new("hello world"), area);
Ok(())
}
}

View File

@ -1,25 +0,0 @@
use clap::Parser;
use cli::Cli;
use color_eyre::Result;
use crate::app::App;
mod action;
mod app;
mod cli;
mod components;
mod config;
mod errors;
mod logging;
mod tui;
#[tokio::main]
async fn main() -> Result<()> {
crate::errors::init()?;
crate::logging::init()?;
let args = Cli::parse();
let mut app = App::new(args.tick_rate, args.frame_rate)?;
app.run().await?;
Ok(())
}

View File

@ -1,235 +0,0 @@
#![allow(dead_code)] // Remove this once you start using the code
use std::{
io::{stdout, Stdout},
ops::{Deref, DerefMut},
time::Duration,
};
use color_eyre::Result;
use crossterm::{
cursor,
event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind, MouseEvent,
},
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
};
use futures::{FutureExt, StreamExt};
use ratatui::backend::CrosstermBackend as Backend;
use serde::{Deserialize, Serialize};
use tokio::{
sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
task::JoinHandle,
time::interval,
};
use tokio_util::sync::CancellationToken;
use tracing::error;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Event {
Init,
Quit,
Error,
Closed,
Tick,
Render,
FocusGained,
FocusLost,
Paste(String),
Key(KeyEvent),
Mouse(MouseEvent),
Resize(u16, u16),
}
pub struct Tui {
pub terminal: ratatui::Terminal<Backend<Stdout>>,
pub task: JoinHandle<()>,
pub cancellation_token: CancellationToken,
pub event_rx: UnboundedReceiver<Event>,
pub event_tx: UnboundedSender<Event>,
pub frame_rate: f64,
pub tick_rate: f64,
pub mouse: bool,
pub paste: bool,
}
impl Tui {
pub fn new() -> Result<Self> {
let (event_tx, event_rx) = mpsc::unbounded_channel();
Ok(Self {
terminal: ratatui::Terminal::new(Backend::new(stdout()))?,
task: tokio::spawn(async {}),
cancellation_token: CancellationToken::new(),
event_rx,
event_tx,
frame_rate: 60.0,
tick_rate: 4.0,
mouse: false,
paste: false,
})
}
pub fn tick_rate(mut self, tick_rate: f64) -> Self {
self.tick_rate = tick_rate;
self
}
pub fn frame_rate(mut self, frame_rate: f64) -> Self {
self.frame_rate = frame_rate;
self
}
pub fn mouse(mut self, mouse: bool) -> Self {
self.mouse = mouse;
self
}
pub fn paste(mut self, paste: bool) -> Self {
self.paste = paste;
self
}
pub fn start(&mut self) {
self.cancel(); // Cancel any existing task
self.cancellation_token = CancellationToken::new();
let event_loop = Self::event_loop(
self.event_tx.clone(),
self.cancellation_token.clone(),
self.tick_rate,
self.frame_rate,
);
self.task = tokio::spawn(async {
event_loop.await;
});
}
async fn event_loop(
event_tx: UnboundedSender<Event>,
cancellation_token: CancellationToken,
tick_rate: f64,
frame_rate: f64,
) {
let mut event_stream = EventStream::new();
let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate));
let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate));
// if this fails, then it's likely a bug in the calling code
event_tx
.send(Event::Init)
.expect("failed to send init event");
loop {
let event = tokio::select! {
_ = cancellation_token.cancelled() => {
break;
}
_ = tick_interval.tick() => Event::Tick,
_ = render_interval.tick() => Event::Render,
crossterm_event = event_stream.next().fuse() => match crossterm_event {
Some(Ok(event)) => match event {
CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key),
CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse),
CrosstermEvent::Resize(x, y) => Event::Resize(x, y),
CrosstermEvent::FocusLost => Event::FocusLost,
CrosstermEvent::FocusGained => Event::FocusGained,
CrosstermEvent::Paste(s) => Event::Paste(s),
_ => continue, // ignore other events
}
Some(Err(_)) => Event::Error,
None => break, // the event stream has stopped and will not produce any more events
},
};
if event_tx.send(event).is_err() {
// the receiver has been dropped, so there's no point in continuing the loop
break;
}
}
cancellation_token.cancel();
}
pub fn stop(&self) -> Result<()> {
self.cancel();
let mut counter = 0;
while !self.task.is_finished() {
std::thread::sleep(Duration::from_millis(1));
counter += 1;
if counter > 50 {
self.task.abort();
}
if counter > 100 {
error!("Failed to abort task in 100 milliseconds for unknown reason");
break;
}
}
Ok(())
}
pub fn enter(&mut self) -> Result<()> {
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?;
if self.mouse {
crossterm::execute!(stdout(), EnableMouseCapture)?;
}
if self.paste {
crossterm::execute!(stdout(), EnableBracketedPaste)?;
}
self.start();
Ok(())
}
pub fn exit(&mut self) -> Result<()> {
self.stop()?;
if crossterm::terminal::is_raw_mode_enabled()? {
self.flush()?;
if self.paste {
crossterm::execute!(stdout(), DisableBracketedPaste)?;
}
if self.mouse {
crossterm::execute!(stdout(), DisableMouseCapture)?;
}
crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?;
crossterm::terminal::disable_raw_mode()?;
}
Ok(())
}
pub fn cancel(&self) {
self.cancellation_token.cancel();
}
pub fn suspend(&mut self) -> Result<()> {
self.exit()?;
#[cfg(not(windows))]
signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?;
Ok(())
}
pub fn resume(&mut self) -> Result<()> {
self.enter()?;
Ok(())
}
pub async fn next_event(&mut self) -> Option<Event> {
self.event_rx.recv().await
}
}
impl Deref for Tui {
type Target = ratatui::Terminal<Backend<Stdout>>;
fn deref(&self) -> &Self::Target {
&self.terminal
}
}
impl DerefMut for Tui {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.terminal
}
}
impl Drop for Tui {
fn drop(&mut self) {
self.exit().unwrap();
}
}