mirror of
https://github.com/alexpasmantier/television.git
synced 2025-07-20 17:10:01 +00:00
a first implementation
This commit is contained in:
parent
b514e7ce43
commit
e5f1ebcf71
@ -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
18
.config/config.toml
Normal 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
20
.gitignore
vendored
@ -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
1402
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
63
Cargo.toml
63
Cargo.toml
@ -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
39
TODO.md
Normal 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)
|
||||
|
45
crates/television/action.rs
Normal file
45
crates/television/action.rs
Normal 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
265
crates/television/app.rs
Normal 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)
|
||||
}
|
||||
}
|
93
crates/television/channels.rs
Normal file
93
crates/television/channels.rs
Normal 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),
|
||||
}
|
203
crates/television/channels/alias.rs
Normal file
203
crates/television/channels/alias.rs
Normal 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
|
||||
}
|
||||
}
|
150
crates/television/channels/env.rs
Normal file
150
crates/television/channels/env.rs
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
140
crates/television/channels/files.rs
Normal file
140
crates/television/channels/files.rs
Normal 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(¤t_dir)
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into();
|
||||
});
|
||||
}
|
||||
}
|
||||
ignore::WalkState::Continue
|
||||
})
|
||||
});
|
||||
}
|
221
crates/television/channels/grep.rs
Normal file
221
crates/television/channels/grep.rs
Normal 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(¤t_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
|
||||
})
|
||||
});
|
||||
}
|
123
crates/television/channels/stdin.rs
Normal file
123
crates/television/channels/stdin.rs
Normal 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
|
||||
}
|
||||
}
|
@ -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
|
1
crates/television/components.rs
Normal file
1
crates/television/components.rs
Normal file
@ -0,0 +1 @@
|
||||
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
92
crates/television/entry.rs
Normal file
92
crates/television/entry.rs
Normal 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,
|
||||
};
|
@ -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
214
crates/television/event.rs
Normal 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,
|
||||
}
|
||||
}
|
25
crates/television/fuzzy.rs
Normal file
25
crates/television/fuzzy.rs
Normal 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);
|
@ -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
116
crates/television/main.rs
Normal 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()
|
||||
}
|
96
crates/television/previewers.rs
Normal file
96
crates/television/previewers.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
19
crates/television/previewers/basic.rs
Normal file
19
crates/television/previewers/basic.rs
Normal 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()),
|
||||
})
|
||||
}
|
||||
}
|
111
crates/television/previewers/cache.rs
Normal file
111
crates/television/previewers/cache.rs
Normal 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)
|
||||
}
|
||||
}
|
45
crates/television/previewers/env.rs
Normal file
45
crates/television/previewers/env.rs
Normal 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()
|
||||
}
|
320
crates/television/previewers/files.rs
Normal file
320
crates/television/previewers/files.rs
Normal 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
119
crates/television/render.rs
Normal 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(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
883
crates/television/television.rs
Normal file
883
crates/television/television.rs
Normal 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
113
crates/television/tui.rs
Normal 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
176
crates/television/ui.rs
Normal 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)
|
||||
}
|
588
crates/television/ui/input.rs
Normal file
588
crates/television/ui/input.rs
Normal 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 = "Hello, world!".into();
|
||||
assert_eq!(input.cursor(), 13);
|
||||
assert_eq!(input.visual_cursor(), 23);
|
||||
assert_eq!(input.visual_scroll(6), 18);
|
||||
}
|
||||
}
|
75
crates/television/ui/input/backend.rs
Normal file
75
crates/television/ui/input/backend.rs
Normal 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());
|
||||
}
|
||||
}
|
4
crates/television/utils.rs
Normal file
4
crates/television/utils.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod files;
|
||||
pub mod indices;
|
||||
pub mod strings;
|
||||
pub mod ui;
|
399
crates/television/utils/files.rs
Normal file
399
crates/television/utils/files.rs
Normal 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();
|
||||
}
|
31
crates/television/utils/indices.rs
Normal file
31
crates/television/utils/indices.rs
Normal 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,
|
||||
)
|
||||
}
|
123
crates/television/utils/strings.rs
Normal file
123
crates/television/utils/strings.rs
Normal 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,
|
||||
)
|
||||
}
|
24
crates/television/utils/ui.rs
Normal file
24
crates/television/utils/ui.rs
Normal 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
46
crates/television_derive/Cargo.lock
generated
Normal 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"
|
13
crates/television_derive/Cargo.toml
Normal file
13
crates/television_derive/Cargo.toml
Normal 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
|
80
crates/television_derive/src/lib.rs
Normal file
80
crates/television_derive/src/lib.rs
Normal 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
2
rustfmt.toml
Normal file
@ -0,0 +1,2 @@
|
||||
edition = "2021"
|
||||
max_width = 79
|
@ -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,
|
||||
}
|
177
src/app.rs
177
src/app.rs
@ -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(())
|
||||
}
|
||||
}
|
@ -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<()>;
|
||||
}
|
@ -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(())
|
||||
}
|
||||
}
|
@ -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(())
|
||||
}
|
||||
}
|
25
src/main.rs
25
src/main.rs
@ -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(())
|
||||
}
|
235
src/tui.rs
235
src/tui.rs
@ -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();
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user