mirror of
https://github.com/alexpasmantier/television.git
synced 2025-06-06 11:35:25 +00:00
Compare commits
36 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
738fe08fbb | ||
![]() |
aac7e4dc45 | ||
![]() |
0f4d87915b | ||
![]() |
dfbdd65107 | ||
![]() |
7bbf538898 | ||
![]() |
6b3c4ee773 | ||
![]() |
9127e419fb | ||
![]() |
11c2ef4eef | ||
![]() |
3b3a0ec1ff | ||
![]() |
d106adafc0 | ||
![]() |
ca09c503ca | ||
![]() |
39dd9efd5d | ||
![]() |
0f6b29ba81 | ||
![]() |
1741a15e52 | ||
![]() |
cfe49ce81c | ||
![]() |
fc2f8b9473 | ||
![]() |
67c067ff40 | ||
![]() |
1a5fa5dd4c | ||
![]() |
cd33151bac | ||
![]() |
2b2654b6aa | ||
![]() |
58d73dbeba | ||
![]() |
1086899ba7 | ||
![]() |
be8008e97d | ||
![]() |
cc27b5ec6b | ||
![]() |
f887a2390e | ||
![]() |
64c599ef10 | ||
![]() |
b9f42e8c29 | ||
![]() |
dbff3a330b | ||
![]() |
e2f52b835d | ||
![]() |
4385317e06 | ||
![]() |
0514a914b6 | ||
![]() |
d3bb3b0a56 | ||
![]() |
c2f4cc258f | ||
![]() |
67677fb917 | ||
![]() |
1f0c178a2d | ||
![]() |
a602dda347 |
@ -17,6 +17,7 @@
|
||||
# ----------------------------------------------------------------------------
|
||||
frame_rate = 60 # DEPRECATED: this option is no longer used
|
||||
tick_rate = 50
|
||||
default_channel = "files"
|
||||
|
||||
[ui]
|
||||
# Whether to use nerd font icons in the UI
|
||||
@ -49,6 +50,8 @@ show_help_bar = false
|
||||
show_preview_panel = true
|
||||
# Where to place the input bar in the UI (top or bottom)
|
||||
input_bar_position = "top"
|
||||
# What orientation should tv be (landscape or portrait)
|
||||
orientation = "landscape"
|
||||
# DEPRECATED: title is now always displayed at the top as part of the border
|
||||
# Where to place the preview title in the UI (top or bottom)
|
||||
# preview_title_position = "top"
|
||||
@ -58,15 +61,6 @@ input_bar_position = "top"
|
||||
# directory in your configuration directory (see the `config.toml` location above).
|
||||
theme = "default"
|
||||
|
||||
# Previewers settings
|
||||
# ----------------------------------------------------------------------------
|
||||
[previewers.file]
|
||||
# The theme to use for syntax highlighting.
|
||||
# Bulitin syntax highlighting uses the same syntax highlighting engine as bat.
|
||||
# To get a list of your currently available themes, run `bat --list-themes`
|
||||
# Note that setting the BAT_THEME environment variable will override this setting.
|
||||
theme = "TwoDark"
|
||||
|
||||
# Keybindings
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
|
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [alexpasmantier]
|
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@ -11,14 +11,18 @@ jobs:
|
||||
test:
|
||||
name: Test Suite
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUST_BACKTRACE: 1
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install fd
|
||||
run: sudo apt install -y fd-find && sudo ln -s $(which fdfind) /usr/bin/fd
|
||||
- name: Run tests
|
||||
run: cargo test --locked --all-features --workspace
|
||||
run: cargo test --locked --all-features --workspace -- --nocapture
|
||||
|
||||
rustfmt:
|
||||
name: Rustfmt
|
||||
|
43
.github/workflows/static.yml
vendored
Normal file
43
.github/workflows/static.yml
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
# Simple workflow for deploying static content to GitHub Pages
|
||||
name: Deploy static content to Pages
|
||||
|
||||
on:
|
||||
# Runs on pushes targeting the default branch
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Single deploy job since we're just deploying
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
# Upload entire repository
|
||||
path: '.'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
93
CHANGELOG.md
93
CHANGELOG.md
@ -4,6 +4,99 @@ All notable changes to this project will be documented in this file.
|
||||
<!-- ignore lint rules that are often triggered by content generated from commits / git-cliff -->
|
||||
<!-- markdownlint-disable line-length no-bare-urls ul-style emphasis-style -->
|
||||
|
||||
|
||||
## [unreleased]
|
||||
|
||||
### ⛰️ Features
|
||||
|
||||
- [f887a23](https://github.com/alexpasmantier/television/commit/f887a2390ede0a5f30d61f2bb9d4e1e421109d63) *(cli)* Add a `--ui-scale` [0,100] cli parameter by @alexpasmantier in [#492](https://github.com/alexpasmantier/television/pull/492)
|
||||
|
||||
- [cfe49ce](https://github.com/alexpasmantier/television/commit/cfe49ce81c1eb428b7c38fe5b524d67141099946) *(remote)* Redirect `Action::Quit` to `Action::ToggleRemoteControl` when in remote mode by @alexpasmantier in [#508](https://github.com/alexpasmantier/television/pull/508)
|
||||
|
||||
- [be8008e](https://github.com/alexpasmantier/television/commit/be8008e97d5ab5063aff27bea52b6315b9f878f7) *(shell)* Improve fish completion system by @lalvarezt in [#494](https://github.com/alexpasmantier/television/pull/494)
|
||||
|
||||
- [1086899](https://github.com/alexpasmantier/television/commit/1086899ba76f9b3377a4f67d8d7aef5da2cd310d) *(ui)* Add a UI portrait mode #489 by @cr4ftx in [#496](https://github.com/alexpasmantier/television/pull/496)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- [dbff3a3](https://github.com/alexpasmantier/television/commit/dbff3a330b169c422ae384e373b934dceb8e01b2) *(alias)* Move terminal raw mode before loading bat assets #444 by @cr4ftx in [#484](https://github.com/alexpasmantier/television/pull/484)
|
||||
|
||||
- [0514a91](https://github.com/alexpasmantier/television/commit/0514a914b630719391d66df61eb9d53c58933c3f) *(alias)* Rename the aliases channel to `alias` by @alexpasmantier in [#485](https://github.com/alexpasmantier/television/pull/485)
|
||||
|
||||
- [cd33151](https://github.com/alexpasmantier/television/commit/cd33151bac9422dcef8edcfd16a6553228611631) *(layout)* Double check whether preview is enabled by @nkxxll in [#499](https://github.com/alexpasmantier/television/pull/499)
|
||||
|
||||
- [1741a15](https://github.com/alexpasmantier/television/commit/1741a15e526ea0a304bb1cccb5f75bb46d42a6a2) *(preview)* Add a post-processing step to clean out ansi text from non-displayable characters by @alexpasmantier in [#509](https://github.com/alexpasmantier/television/pull/509)
|
||||
|
||||
- [1f0c178](https://github.com/alexpasmantier/television/commit/1f0c178a2d79ccf1e6cbe13ea3ec246f987bfbf2) *(results)* Remove keymap hint if help is disabled by @nkxxll in [#480](https://github.com/alexpasmantier/television/pull/480)
|
||||
|
||||
- [39dd9ef](https://github.com/alexpasmantier/television/commit/39dd9efd5dfa1fb36281f9f97b753152af82095f) *(shell)* Paste not working in zsh shell integration by @kapobajza in [#512](https://github.com/alexpasmantier/television/pull/512)
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- [e2f52b8](https://github.com/alexpasmantier/television/commit/e2f52b835d6447c251d7fca6724cf409ed153546) *(cable)* Improve naming and documentation for `prototypes.rs` by @alexpasmantier in [#487](https://github.com/alexpasmantier/television/pull/487)
|
||||
|
||||
- [4385317](https://github.com/alexpasmantier/television/commit/4385317e069db287d8d86f987e11e079a7ff6d1c) *(cable)* Split cable related code into separate submodules by @alexpasmantier in [#486](https://github.com/alexpasmantier/television/pull/486)
|
||||
|
||||
- [1a5fa5d](https://github.com/alexpasmantier/television/commit/1a5fa5dd4cb485e2b0b08301ca457fa1c6d06094) *(channels)* Some renaming and refactoring the channels module by @alexpasmantier in [#503](https://github.com/alexpasmantier/television/pull/503)
|
||||
|
||||
- [b9f42e8](https://github.com/alexpasmantier/television/commit/b9f42e8c29a7eca86a91a6cb00d9c4ee46bb2bd3) *(preview)* Simplify channel previews code and remove intermediate `PreviewKind` struct by @alexpasmantier in [#490](https://github.com/alexpasmantier/television/pull/490)
|
||||
|
||||
- [67c067f](https://github.com/alexpasmantier/television/commit/67c067ff40f97eef9090c2a5addca5da50a7fa0f) *(previewer)* A much more efficient preview system for tv by @alexpasmantier in [#506](https://github.com/alexpasmantier/television/pull/506)
|
||||
|
||||
- [2b2654b](https://github.com/alexpasmantier/television/commit/2b2654b6aab86707577c0bb5c65301106422e737) *(uncategorized)* Drop TelevisionChannel enum and all associated macros by @alexpasmantier in [#498](https://github.com/alexpasmantier/television/pull/498)
|
||||
|
||||
- [cc27b5e](https://github.com/alexpasmantier/television/commit/cc27b5ec6bf3a5a71d6785558e57976db9f2d129) *(uncategorized)* Drop dependency to the `ignore` crate by @alexpasmantier
|
||||
|
||||
- [c2f4cc2](https://github.com/alexpasmantier/television/commit/c2f4cc258f5f3b21601e8c7ce98f4584222813b2) *(uncategorized)* Tv no longer needs to write the default cable channel recipes to the user's configuration directory by @alexpasmantier in [#482](https://github.com/alexpasmantier/television/pull/482)
|
||||
|
||||
- [67677fb](https://github.com/alexpasmantier/television/commit/67677fb917b6d59d8217eaf6369b95f5ba940ff0) *(uncategorized)* All channels are now cable channels by @alexpasmantier in [#479](https://github.com/alexpasmantier/television/pull/479) [**breaking**]
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- [d3bb3b0](https://github.com/alexpasmantier/television/commit/d3bb3b0a5610b6896a698f89afcf2fb7a2aab44a) *(uncategorized)* Cleanup old todo list by @alexpasmantier in [#483](https://github.com/alexpasmantier/television/pull/483)
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- [fc2f8b9](https://github.com/alexpasmantier/television/commit/fc2f8b9473d1d84712951184da8d4e59edeedc86) *(previews)* Avoid unnecessary preview content copy by @alexpasmantier in [#507](https://github.com/alexpasmantier/television/pull/507)
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- [64c599e](https://github.com/alexpasmantier/television/commit/64c599ef103d18e852d1070c6b313800646f1940) *(changelog)* Update changelog (auto) by @github-actions[bot] in [#491](https://github.com/alexpasmantier/television/pull/491)
|
||||
|
||||
- [a602dda](https://github.com/alexpasmantier/television/commit/a602dda34758f9f4a24f1c77b589216c12b9cfba) *(changelog)* Update changelog (auto) by @github-actions[bot] in [#478](https://github.com/alexpasmantier/television/pull/478)
|
||||
|
||||
- [0f6b29b](https://github.com/alexpasmantier/television/commit/0f6b29ba817f54da7c6cc694c21127c8588709a0) *(uncategorized)* Add sponsorhips button to the repo by @alexpasmantier
|
||||
|
||||
|
||||
|
||||
### New Contributors
|
||||
* @kapobajza made their first contribution in [#512](https://github.com/alexpasmantier/television/pull/512)
|
||||
* @cr4ftx made their first contribution in [#496](https://github.com/alexpasmantier/television/pull/496)
|
||||
* @lalvarezt made their first contribution in [#494](https://github.com/alexpasmantier/television/pull/494)
|
||||
|
||||
|
||||
## [0.11.9](https://github.com/alexpasmantier/television/releases/tag/0.11.9) - 2025-04-21
|
||||
|
||||
### ⛰️ Features
|
||||
|
||||
- [bbbdcb0](https://github.com/alexpasmantier/television/commit/bbbdcb02710ffe656fa49567ecd247813523b557) *(cli)* Add substring matching with `--exact` flag by @nkxxll in [#477](https://github.com/alexpasmantier/television/pull/477)
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- [ce02824](https://github.com/alexpasmantier/television/commit/ce02824f3c4c6a750a30651b478ff255d68ff0de) *(stdin)* Avoid unnecessary allocations when streaming from stdin by @alexpasmantier in [#475](https://github.com/alexpasmantier/television/pull/475)
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- [433d7fa](https://github.com/alexpasmantier/television/commit/433d7fa27057d43be4d9cd6cefd64a79339eb2a6) *(changelog)* Update changelog (auto) by @github-actions[bot] in [#474](https://github.com/alexpasmantier/television/pull/474)
|
||||
|
||||
- [f28c18e](https://github.com/alexpasmantier/television/commit/f28c18ed64b50e9be7b95fcfbfd9536837c3ebe3) *(uncategorized)* Release version 0.11.9 by @alexpasmantier
|
||||
|
||||
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/alexpasmantier/television/compare/0.11.8...0.11.9
|
||||
|
||||
|
||||
## [0.11.8](https://github.com/alexpasmantier/television/releases/tag/0.11.8) - 2025-04-20
|
||||
|
||||
### ⛰️ Features
|
||||
|
1121
Cargo.lock
generated
1121
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
30
Cargo.toml
30
Cargo.toml
@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "television"
|
||||
version = "0.11.9"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
description = "A cross-platform, fast and extensible general purpose fuzzy finder TUI."
|
||||
license = "MIT"
|
||||
authors = ["Alexandre Pasmantier <alex.pasmant@gmail.com>"]
|
||||
@ -24,15 +24,13 @@ include = [
|
||||
"build.rs",
|
||||
"man",
|
||||
]
|
||||
rust-version = "1.83"
|
||||
rust-version = "1.87"
|
||||
build = "build.rs"
|
||||
|
||||
[lib]
|
||||
path = "television/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
television-derive = { path = "television-derive", version = "0.0.26" }
|
||||
|
||||
anyhow = "1.0"
|
||||
base64 = "0.22.1"
|
||||
directories = "6.0"
|
||||
@ -48,20 +46,13 @@ ratatui = { version = "0.29", features = ["serde", "macros"] }
|
||||
better-panic = "0.3"
|
||||
signal-hook = "0.3"
|
||||
human-panic = "2.0"
|
||||
ignore = "0.4"
|
||||
strum = { version = "0.26", features = ["derive"] }
|
||||
regex = "1.11"
|
||||
parking_lot = "0.12"
|
||||
nom = "7.1"
|
||||
thiserror = "2.0"
|
||||
simdutf8 = { version = "0.1", optional = true }
|
||||
smallvec = { version = "1.13", features = ["const_generics"] }
|
||||
gag = "1.0"
|
||||
nucleo = "0.5"
|
||||
toml = "0.8"
|
||||
image = "0.25"
|
||||
syntect = { package = "syntect", version = "5.2", default-features = false }
|
||||
bat = { package = "bat", version = "0.25", default-features = false }
|
||||
lazy-regex = { version = "3.4.1", features = [
|
||||
"lite",
|
||||
], default-features = false }
|
||||
ansi-to-tui = "7.0.0"
|
||||
|
||||
|
||||
# target specific dependencies
|
||||
@ -80,15 +71,6 @@ clipboard-win = "5.4.0"
|
||||
criterion = { version = "0.5", features = ["async_tokio"] }
|
||||
tempfile = "3.16.0"
|
||||
|
||||
[features]
|
||||
simd = ["dep:simdutf8"]
|
||||
zero-copy = []
|
||||
# Use fancy-regex for aarch64 Linux
|
||||
fancy = ["syntect/regex-fancy", "bat/regex-fancy"]
|
||||
# Use oniguruma for other platforms
|
||||
onig = ["syntect/regex-onig", "bat/regex-onig"]
|
||||
default = ["zero-copy", "simd", "onig"]
|
||||
|
||||
|
||||
[build-dependencies]
|
||||
clap = { version = "4.5", features = ["derive", "cargo"] }
|
||||
|
55
TODO.md
55
TODO.md
@ -1,55 +0,0 @@
|
||||
# bugs
|
||||
|
||||
- [x] index out of bounds when resizing the terminal to a very small size
|
||||
- [x] meta previews in cache are not terminal size aware
|
||||
|
||||
# tasks
|
||||
|
||||
- [x] preview navigation
|
||||
- [ ] add a way to open the selected file in the default editor (or maybe that
|
||||
should be achieved using pipes?) --> xargs
|
||||
- [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?)
|
||||
- [x] documentation
|
||||
|
||||
## improvements
|
||||
|
||||
- [x] async finder initialization
|
||||
- [x] async finder search
|
||||
- [x] use nucleo for env
|
||||
- [x] better keymaps
|
||||
- [ ] mutualize placeholder previews in cache (really not a priority)
|
||||
- [x] better abstractions for channels / separation / isolation so that others
|
||||
can contribute new ones easily
|
||||
- [x] 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
|
||||
- [x] profile using dyn Traits instead of an enum for channels (might degrade performance by storing on the heap)
|
||||
- [x] I feel like the finder abstraction is a superfluous layer, maybe just use
|
||||
the channel directly?
|
||||
- [x] support for images is implemented but do we really want that in the core?
|
||||
it's quite heavy
|
||||
- [x] shrink entry names that are too long (from the middle)
|
||||
- [ ] more syntaxes for the previewer https://www.sublimetext.com/docs/syntax.html#include-syntax
|
||||
- [ ] more preview colorschemes
|
||||
|
||||
## feature ideas
|
||||
|
||||
- [x] environment variables
|
||||
- [x] 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)
|
||||
- [x] add a way of copying the selected entry name/value to the clipboard
|
||||
- [ ] have a keybinding to send all current entries to stdout
|
||||
- [x] git repositories channel (crawl the filesystem for git repos)
|
@ -1,25 +1,26 @@
|
||||
use criterion::criterion_group;
|
||||
use criterion::{black_box, Criterion};
|
||||
use criterion::{Criterion, black_box};
|
||||
use devicons::FileIcon;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::layout::Alignment;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::{Line, Style};
|
||||
use ratatui::style::Color;
|
||||
use ratatui::widgets::{Block, BorderType, Borders, ListDirection, Padding};
|
||||
use ratatui::Terminal;
|
||||
use std::path::PathBuf;
|
||||
use television::action::Action;
|
||||
use television::channels::entry::into_ranges;
|
||||
use television::channels::entry::{Entry, PreviewType};
|
||||
use television::channels::OnAir;
|
||||
use television::channels::{files::Channel, TelevisionChannel};
|
||||
use television::config::{Config, ConfigEnv};
|
||||
use television::screen::colors::ResultsColorscheme;
|
||||
use television::screen::results::build_results_list;
|
||||
use television::television::Television;
|
||||
use television::{
|
||||
action::Action,
|
||||
channels::{
|
||||
entry::{Entry, into_ranges},
|
||||
prototypes::Cable,
|
||||
},
|
||||
config::{Config, ConfigEnv},
|
||||
screen::{colors::ResultsColorscheme, results::build_results_list},
|
||||
television::Television,
|
||||
};
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn draw_results_list(c: &mut Criterion) {
|
||||
// FIXME: there's probably a way to have this as a benchmark asset
|
||||
// possible as a JSON file and to load it for the benchmark using Serde
|
||||
@ -35,7 +36,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#7e8e91",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/README.md".to_string(),
|
||||
@ -47,7 +47,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#dddddd",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/re.pyi".to_string(),
|
||||
@ -59,7 +58,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/io.pyi".to_string(),
|
||||
@ -71,7 +69,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/gc.pyi".to_string(),
|
||||
@ -83,7 +80,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/uu.pyi".to_string(),
|
||||
@ -95,7 +91,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/nt.pyi".to_string(),
|
||||
@ -107,7 +102,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/dis.pyi".to_string(),
|
||||
@ -119,7 +113,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/imp.pyi".to_string(),
|
||||
@ -131,7 +124,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/bdb.pyi".to_string(),
|
||||
@ -143,7 +135,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/abc.pyi".to_string(),
|
||||
@ -155,7 +146,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/cgi.pyi".to_string(),
|
||||
@ -167,7 +157,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/bz2.pyi".to_string(),
|
||||
@ -179,7 +168,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/grp.pyi".to_string(),
|
||||
@ -191,7 +179,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/ast.pyi".to_string(),
|
||||
@ -203,7 +190,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/csv.pyi".to_string(),
|
||||
@ -215,7 +201,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/pdb.pyi".to_string(),
|
||||
@ -227,7 +212,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/pwd.pyi".to_string(),
|
||||
@ -239,7 +223,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/ssl.pyi".to_string(),
|
||||
@ -251,7 +234,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/tty.pyi".to_string(),
|
||||
@ -263,7 +245,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/nis.pyi".to_string(),
|
||||
@ -275,7 +256,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/pty.pyi".to_string(),
|
||||
@ -287,7 +267,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/cmd.pyi".to_string(),
|
||||
@ -299,7 +278,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/tests/utils.py".to_string(),
|
||||
@ -311,7 +289,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/pyproject.toml".to_string(),
|
||||
@ -323,7 +300,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#9c4221",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/MAINTAINERS.md".to_string(),
|
||||
@ -335,7 +311,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#dddddd",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/enum.pyi".to_string(),
|
||||
@ -347,7 +322,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/hmac.pyi".to_string(),
|
||||
@ -359,7 +333,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/uuid.pyi".to_string(),
|
||||
@ -371,7 +344,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/glob.pyi".to_string(),
|
||||
@ -383,7 +355,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/_ast.pyi".to_string(),
|
||||
@ -395,7 +366,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/_csv.pyi".to_string(),
|
||||
@ -407,7 +377,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/code.pyi".to_string(),
|
||||
@ -419,7 +388,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/spwd.pyi".to_string(),
|
||||
@ -431,7 +399,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/_msi.pyi".to_string(),
|
||||
@ -443,7 +410,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
},
|
||||
Entry {
|
||||
name: "typeshed/stdlib/time.pyi".to_string(),
|
||||
@ -454,7 +420,6 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
color: "#ffbc03",
|
||||
}),
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Files,
|
||||
name_match_ranges: Some(into_ranges(&[0, 1, 2, 3])),
|
||||
value_match_ranges: None,
|
||||
},
|
||||
@ -492,12 +457,15 @@ pub fn draw_results_list(c: &mut Criterion) {
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(clippy::missing_panics_doc)]
|
||||
pub fn draw(c: &mut Criterion) {
|
||||
let width = 250;
|
||||
let height = 80;
|
||||
|
||||
let rt = Runtime::new().unwrap();
|
||||
|
||||
let cable = Cable::default();
|
||||
|
||||
c.bench_function("draw", |b| {
|
||||
b.to_async(&rt).iter_batched(
|
||||
// FIXME: this is kind of hacky
|
||||
@ -506,24 +474,26 @@ pub fn draw(c: &mut Criterion) {
|
||||
let backend = TestBackend::new(width, height);
|
||||
let terminal = Terminal::new(backend).unwrap();
|
||||
let (tx, _) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut channel =
|
||||
TelevisionChannel::Files(Channel::new(vec![
|
||||
PathBuf::from("."),
|
||||
]));
|
||||
channel.find("television");
|
||||
let channel_prototype = cable.get_channel("files");
|
||||
// Wait for the channel to finish loading
|
||||
let mut tv = Television::new(
|
||||
tx, channel, config, None, false, false, false,
|
||||
tx,
|
||||
&channel_prototype,
|
||||
config,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Cable::default(),
|
||||
);
|
||||
tv.find("television");
|
||||
for _ in 0..5 {
|
||||
// tick the matcher
|
||||
let _ = tv.channel.results(10, 0);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
tv.select_next_entry(10);
|
||||
let _ = tv.update_preview_state(
|
||||
&tv.get_selected_entry(None).unwrap(),
|
||||
);
|
||||
let _ = tv.update_preview_state(&tv.get_selected_entry(None));
|
||||
let _ = tv.update(&Action::Tick);
|
||||
(tv, terminal)
|
||||
},
|
||||
|
@ -1,41 +1,82 @@
|
||||
# Files
|
||||
[[cable_channel]]
|
||||
name = "files"
|
||||
source_command = "fd -t f"
|
||||
preview.command = "bat -n --color=always {}"
|
||||
|
||||
# Text
|
||||
[[cable_channel]]
|
||||
name = "text"
|
||||
source_command = "rg . --no-heading --line-number"
|
||||
preview.command = "bat -n --color=always {0}"
|
||||
preview.delimiter = ":"
|
||||
preview.offset = "{1}"
|
||||
|
||||
# Directories
|
||||
[[cable_channel]]
|
||||
name = "dirs"
|
||||
source_command = "fd -t d"
|
||||
preview.command = "ls -la --color=always {}"
|
||||
|
||||
# Environment variables
|
||||
[[cable_channel]]
|
||||
name = "env"
|
||||
source_command = "printenv"
|
||||
preview.command = "cut -d= -f2 <<< ${0} | cut -d\" \" -f2- | sed 's/:/\\n/g'"
|
||||
|
||||
# Aliases
|
||||
[[cable_channel]]
|
||||
name = "alias"
|
||||
source_command = "alias"
|
||||
interactive = true
|
||||
|
||||
# GIT
|
||||
[[cable_channel]]
|
||||
name = "git-repos"
|
||||
# this is a MacOS version but feel free to override it to fit your needs
|
||||
source_command = "fd -g .git -HL -t d -d 10 --prune ~ -E 'Library' -E 'Application Support' --exec dirname {}"
|
||||
preview.command = "cd {} && git log -n 200 --pretty=medium --all --graph --color"
|
||||
|
||||
[[cable_channel]]
|
||||
name = "git-diff"
|
||||
source_command = "git diff --name-only"
|
||||
preview_command = "git diff --color=always {0}"
|
||||
preview.command = "git diff --color=always {0}"
|
||||
|
||||
[[cable_channel]]
|
||||
name = "git-reflog"
|
||||
source_command = 'git reflog'
|
||||
preview_command = 'git show -p --stat --pretty=fuller --color=always {0}'
|
||||
preview.command = 'git show -p --stat --pretty=fuller --color=always {0}'
|
||||
|
||||
|
||||
[[cable_channel]]
|
||||
name = "git-log"
|
||||
source_command = "git log --oneline --date=short --pretty=\"format:%h %s %an %cd\" \"$@\""
|
||||
preview_command = "git show -p --stat --pretty=fuller --color=always {0}"
|
||||
preview.command = 'git show -p --stat --pretty=fuller --color=always {0}'
|
||||
|
||||
[[cable_channel]]
|
||||
name = "git-branch"
|
||||
source_command = "git --no-pager branch --all --format=\"%(refname:short)\""
|
||||
preview_command = "git show -p --stat --pretty=fuller --color=always {0}"
|
||||
preview.command = 'git show -p --stat --pretty=fuller --color=always {0}'
|
||||
|
||||
# Docker
|
||||
[[cable_channel]]
|
||||
name = "docker-images"
|
||||
source_command = "docker image list --format \"{{.ID}}\""
|
||||
preview_command = "docker image inspect {0} | jq -C"
|
||||
preview.command = "docker image inspect {0} | jq -C"
|
||||
|
||||
|
||||
# S3
|
||||
[[cable_channel]]
|
||||
name = "s3-buckets"
|
||||
source_command = "aws s3 ls | cut -d \" \" -f 3"
|
||||
preview_command = "aws s3 ls s3://{0}"
|
||||
preview.command = "aws s3 ls s3://{0}"
|
||||
|
||||
|
||||
# Dotfiles
|
||||
[[cable_channel]]
|
||||
name = "my-dotfiles"
|
||||
source_command = "fd -t f . $HOME/.config"
|
||||
preview_command = ":files:"
|
||||
preview.command = "bat -n --color=always {}"
|
||||
|
||||
# Shell history
|
||||
[[cable_channel]]
|
||||
|
@ -1,41 +1,72 @@
|
||||
# Files
|
||||
[[cable_channel]]
|
||||
name = "files"
|
||||
source_command = "Get-ChildItem -Recurse -File | Select-Object -ExpandProperty FullName"
|
||||
preview.command = "bat -n --color=always {}"
|
||||
|
||||
# Text
|
||||
[[cable_channel]]
|
||||
name = "text"
|
||||
source_command = "rg . --no-heading --line-number"
|
||||
preview.command = "bat -n --color=always {0}"
|
||||
preview.delimiter = ":"
|
||||
preview.offset = "{1}"
|
||||
|
||||
# Directories
|
||||
[[cable_channel]]
|
||||
name = "dirs"
|
||||
source_command = "Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName"
|
||||
preview.command = "ls -l {}"
|
||||
|
||||
# Environment variables
|
||||
[[cable_channel]]
|
||||
name = "env"
|
||||
source_command = "Get-ChildItem Env:"
|
||||
|
||||
# Aliases
|
||||
[[cable_channel]]
|
||||
name = "alias"
|
||||
source_command = "Get-Alias"
|
||||
|
||||
# GIT
|
||||
[[cable_channel]]
|
||||
name = "git-repos"
|
||||
source_command = "Get-ChildItem -Path 'C:\\Users' -Recurse -Directory -Force -ErrorAction SilentlyContinue | Where-Object { Test-Path \"$($_.FullName)\\.git\" } | Select-Object -ExpandProperty FullName"
|
||||
preview.command = "cd '{}' ; git log -n 200 --pretty=medium --all --graph --color"
|
||||
|
||||
[[cable_channel]]
|
||||
name = "git-diff"
|
||||
source_command = "git diff --name-only"
|
||||
preview_command = "git diff --color=always -- {0}"
|
||||
preview.command = "git diff --color=always {}"
|
||||
|
||||
[[cable_channel]]
|
||||
name = "git-reflog"
|
||||
source_command = 'git reflog'
|
||||
preview_command = 'git show -p --stat --pretty=fuller --color=always {0}'
|
||||
source_command = "git reflog"
|
||||
preview.command = "git show -p --stat --pretty=fuller --color=always {0}"
|
||||
|
||||
[[cable_channel]]
|
||||
name = "git-log"
|
||||
source_command = "git log --oneline --date=short --pretty=\"format:%h %s %an %cd\" \"$@\""
|
||||
preview_command = "git show -p --stat --pretty=fuller --color=always {0}"
|
||||
source_command = "git log --oneline --date=short --pretty='format:%h %s %an %cd'"
|
||||
preview.command = "git show -p --stat --pretty=fuller --color=always {0}"
|
||||
|
||||
[[cable_channel]]
|
||||
name = "git-branch"
|
||||
source_command = "git --no-pager branch --all --format=\"%(refname:short)\""
|
||||
preview_command = "git show -p --stat --pretty=fuller --color=always {0}"
|
||||
source_command = "git branch --all --format='%(refname:short)'"
|
||||
preview.command = "git show -p --stat --pretty=fuller --color=always {0}"
|
||||
|
||||
# Docker
|
||||
[[cable_channel]]
|
||||
name = "docker-images"
|
||||
source_command = "docker image list --format \"{{.ID}}\""
|
||||
preview_command = "docker image inspect {0} | jq -C"
|
||||
source_command = "docker image ls --format '{{.ID}}'"
|
||||
preview.command = "docker image inspect {0} | jq -C"
|
||||
|
||||
# Dotfiles (adapted to common Windows dotfile locations)
|
||||
[[cable_channel]]
|
||||
name = "my-dotfiles"
|
||||
source_command = "Get-ChildItem -Recurse -File -Path \"$env:USERPROFILE\\AppData\\Roaming\\\""
|
||||
preview.command = "bat -n --color=always {}"
|
||||
|
||||
# Shell history
|
||||
[[cable_channel]]
|
||||
name = "zsh-history"
|
||||
source_command = "tail -r $HISTFILE | cut -d\";\" -f 2-"
|
||||
|
||||
[[cable_channel]]
|
||||
name = "bash-history"
|
||||
source_command = "tail -r $HISTFILE"
|
||||
|
||||
[[cable_channel]]
|
||||
name = "fish-history"
|
||||
source_command = "fish -c 'history'"
|
||||
|
||||
|
||||
name = "powershell-history"
|
||||
source_command = "Get-Content (Get-PSReadLineOption).HistorySavePath | Select-Object -Last 500"
|
||||
|
2
docs/index.md
Normal file
2
docs/index.md
Normal file
@ -0,0 +1,2 @@
|
||||
# Television
|
||||
A cross-platform, fast and extensible general purpose fuzzy finder 📺
|
@ -12,7 +12,7 @@ Here is a list of terminal emulators that have currently been tested with `telev
|
||||
| Konsole | Linux | ✅ |
|
||||
| Terminator | Linux | ✅ |
|
||||
| Xterm | Linux | ✅ |
|
||||
| Cmder | Windows | ✖️ |
|
||||
| Cmder | Windows | ✅ |
|
||||
| Foot | Linux | ✅ |
|
||||
| Rio | macOS, Linux, Windows | ✅ |
|
||||
| Warp | macOS | ✅ |
|
||||
|
21
man/tv.1
21
man/tv.1
@ -1,10 +1,10 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH television 1 "television 0.11.8"
|
||||
.TH television 1 "television 0.11.9"
|
||||
.SH NAME
|
||||
television \- A cross\-platform, fast and extensible general purpose fuzzy finder TUI.
|
||||
.SH SYNOPSIS
|
||||
\fBtelevision\fR [\fB\-p\fR|\fB\-\-preview\fR] [\fB\-\-no\-preview\fR] [\fB\-\-delimiter\fR] [\fB\-t\fR|\fB\-\-tick\-rate\fR] [\fB\-f\fR|\fB\-\-frame\-rate\fR] [\fB\-k\fR|\fB\-\-keybindings\fR] [\fB\-i\fR|\fB\-\-input\fR] [\fB\-\-custom\-header\fR] [\fB\-\-autocomplete\-prompt\fR] [\fB\-\-select\-1\fR] [\fB\-\-no\-remote\fR] [\fB\-\-no\-help\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fICHANNEL\fR] [\fIPATH\fR] [\fIsubcommands\fR]
|
||||
\fBtelevision\fR [\fB\-p\fR|\fB\-\-preview\fR] [\fB\-\-no\-preview\fR] [\fB\-\-delimiter\fR] [\fB\-t\fR|\fB\-\-tick\-rate\fR] [\fB\-f\fR|\fB\-\-frame\-rate\fR] [\fB\-k\fR|\fB\-\-keybindings\fR] [\fB\-i\fR|\fB\-\-input\fR] [\fB\-\-custom\-header\fR] [\fB\-\-autocomplete\-prompt\fR] [\fB\-\-exact\fR] [\fB\-\-select\-1\fR] [\fB\-\-no\-remote\fR] [\fB\-\-no\-help\fR] [\fB\-\-ui\-scale\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] [\fICHANNEL\fR] [\fIPATH\fR] [\fIsubcommands\fR]
|
||||
.SH DESCRIPTION
|
||||
A cross\-platform, fast and extensible general purpose fuzzy finder TUI.
|
||||
.SH OPTIONS
|
||||
@ -73,6 +73,13 @@ This can be used to automatically select a channel based on the input
|
||||
prompt by using the `shell_integration` mapping in the configuration
|
||||
file.
|
||||
.TP
|
||||
\fB\-\-exact\fR
|
||||
Use substring matching instead of fuzzy matching.
|
||||
|
||||
This will use substring matching instead of fuzzy matching when
|
||||
searching for entries. This is useful when the user wants to search for
|
||||
an exact match instead of a fuzzy match e.g. to improve performance.
|
||||
.TP
|
||||
\fB\-\-select\-1\fR
|
||||
Automatically select and output the first entry if there is only one
|
||||
entry.
|
||||
@ -102,13 +109,19 @@ when the user wants `tv` to run with a minimal interface (e.g. when
|
||||
using it as a file picker for a script or embedding it in a larger
|
||||
application).
|
||||
.TP
|
||||
\fB\-\-ui\-scale\fR=\fIINTEGER\fR [default: 100]
|
||||
Change the display size in relation to the available area.
|
||||
|
||||
This will crop the UI to a centered rectangle of the specified
|
||||
percentage of the available area (e.g. 0.5 for 50% x 50%).
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
Print help (see a summary with \*(Aq\-h\*(Aq)
|
||||
.TP
|
||||
\fB\-V\fR, \fB\-\-version\fR
|
||||
Print version
|
||||
.TP
|
||||
[\fICHANNEL\fR] [default: files]
|
||||
[\fICHANNEL\fR]
|
||||
Which channel shall we watch?
|
||||
|
||||
A list of the available channels can be displayed using the
|
||||
@ -133,6 +146,6 @@ Initializes shell completion ("tv init zsh")
|
||||
television\-help(1)
|
||||
Print this message or the help of the given subcommand(s)
|
||||
.SH VERSION
|
||||
v0.11.8
|
||||
v0.11.9
|
||||
.SH AUTHORS
|
||||
Alexandre Pasmantier <alex.pasmant@gmail.com>
|
||||
|
@ -1,3 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.83"
|
||||
channel = "1.87"
|
||||
components = ["rustfmt", "clippy", "rust-analyzer"]
|
||||
|
@ -1,26 +0,0 @@
|
||||
[package]
|
||||
name = "television-derive"
|
||||
version = "0.0.26"
|
||||
edition = "2021"
|
||||
description = "The revolution will be televised."
|
||||
license = "MIT"
|
||||
authors = ["Alexandre Pasmantier <alex.pasmant@gmail.com>"]
|
||||
repository = "https://github.com/alexpasmantier/television"
|
||||
homepage = "https://github.com/alexpasmantier/television"
|
||||
keywords = ["search", "fuzzy", "preview", "tui", "terminal"]
|
||||
categories = [
|
||||
"command-line-utilities",
|
||||
"command-line-interface",
|
||||
"concurrency",
|
||||
"development-tools",
|
||||
]
|
||||
rust-version = "1.83"
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = "1.0.93"
|
||||
quote = "1.0.38"
|
||||
syn = "2.0.96"
|
||||
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
@ -1,381 +0,0 @@
|
||||
use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
|
||||
/// This macro generates a `CliChannel` enum and the necessary glue code
|
||||
/// to convert into a `TelevisionChannel` member:
|
||||
///
|
||||
/// ```ignore
|
||||
/// use television::channels::{TelevisionChannel, OnAir};
|
||||
/// use television-derive::ToCliChannel;
|
||||
/// use television::channels::{files, text};
|
||||
///
|
||||
/// #[derive(ToCliChannel)]
|
||||
/// enum TelevisionChannel {
|
||||
/// Files(files::Channel),
|
||||
/// Text(text::Channel),
|
||||
/// // ...
|
||||
/// }
|
||||
///
|
||||
/// let television_channel: TelevisionChannel = CliTvChannel::Files.to_channel();
|
||||
///
|
||||
/// assert!(matches!(television_channel, TelevisionChannel::Files(_)));
|
||||
/// ```
|
||||
///
|
||||
/// The `CliChannel` enum is used to select channels from the command line.
|
||||
///
|
||||
/// Any variant that should not be included in the CLI should be annotated with
|
||||
/// `#[exclude_from_cli]`.
|
||||
#[proc_macro_derive(ToCliChannel, attributes(exclude_from_cli))]
|
||||
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 has_attribute(attrs: &[syn::Attribute], attribute: &str) -> bool {
|
||||
attrs.iter().any(|attr| attr.path().is_ident(attribute))
|
||||
}
|
||||
|
||||
const EXCLUDE_FROM_CLI: &str = "exclude_from_cli";
|
||||
|
||||
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: Vec<_> = variants
|
||||
.iter()
|
||||
.filter(|variant| !has_attribute(&variant.attrs, EXCLUDE_FROM_CLI))
|
||||
.map(|variant| {
|
||||
let variant_name = &variant.ident;
|
||||
quote! {
|
||||
#variant_name
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cli_enum = quote! {
|
||||
use clap::ValueEnum;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::{Display, EnumIter, EnumString};
|
||||
use std::default::Default;
|
||||
|
||||
#[derive(Debug, Clone, ValueEnum, EnumIter, EnumString, Default, Copy, PartialEq, Eq, Serialize, Deserialize, Display)]
|
||||
#[strum(serialize_all = "kebab_case")]
|
||||
pub enum CliTvChannel {
|
||||
#[default]
|
||||
#(#cli_enum_variants),*
|
||||
}
|
||||
};
|
||||
|
||||
// Generate the match arms for the `to_channel` method
|
||||
let arms = variants.iter().filter(
|
||||
|variant| !has_attribute(&variant.attrs, EXCLUDE_FROM_CLI),
|
||||
).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 => TelevisionChannel::#variant_name(#inner_type::default())
|
||||
}
|
||||
} 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) -> TelevisionChannel {
|
||||
match self {
|
||||
#(#arms),*
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_channels() -> Vec<String> {
|
||||
use strum::IntoEnumIterator;
|
||||
Self::iter().map(|v| v.to_string()).collect()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
gen.into()
|
||||
}
|
||||
|
||||
/// This macro generates the `OnAir` trait implementation for the given enum.
|
||||
///
|
||||
/// The `OnAir` trait is used to interact with the different television channels
|
||||
/// and forwards the method calls to the corresponding channel variants.
|
||||
///
|
||||
/// Example:
|
||||
/// ```ignore
|
||||
/// use television-derive::Broadcast;
|
||||
/// use television::channels::{TelevisionChannel, OnAir};
|
||||
/// use television::channels::{files, text};
|
||||
///
|
||||
/// #[derive(Broadcast)]
|
||||
/// enum TelevisionChannel {
|
||||
/// Files(files::Channel),
|
||||
/// Text(text::Channel),
|
||||
/// }
|
||||
///
|
||||
/// let mut channel = TelevisionChannel::Files(files::Channel::default());
|
||||
///
|
||||
/// // Use the `OnAir` trait methods directly on TelevisionChannel
|
||||
/// channel.find("pattern");
|
||||
/// let results = channel.results(10, 0);
|
||||
/// let result = channel.get_result(0);
|
||||
/// let result_count = channel.result_count();
|
||||
/// let total_count = channel.total_count();
|
||||
/// let running = channel.running();
|
||||
/// channel.shutdown();
|
||||
/// ```
|
||||
#[proc_macro_derive(Broadcast)]
|
||||
pub fn tv_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_tv_channel(&ast)
|
||||
}
|
||||
|
||||
fn impl_tv_channel(ast: &syn::DeriveInput) -> TokenStream {
|
||||
// Ensure the struct is an enum
|
||||
let variants = if let syn::Data::Enum(data_enum) = &ast.data {
|
||||
&data_enum.variants
|
||||
} else {
|
||||
panic!("#[derive(OnAir)] is only defined for enums");
|
||||
};
|
||||
|
||||
// Ensure the enum has at least one variant
|
||||
assert!(
|
||||
!variants.is_empty(),
|
||||
"#[derive(OnAir)] requires at least one variant"
|
||||
);
|
||||
|
||||
let enum_name = &ast.ident;
|
||||
|
||||
let variant_names: Vec<_> = variants.iter().map(|v| &v.ident).collect();
|
||||
|
||||
// Generate the trait implementation for the TelevisionChannel trait
|
||||
let trait_impl = quote! {
|
||||
impl OnAir for #enum_name {
|
||||
fn find(&mut self, pattern: &str) {
|
||||
match self {
|
||||
#(
|
||||
#enum_name::#variant_names(ref mut channel) => {
|
||||
channel.find(pattern);
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
|
||||
match self {
|
||||
#(
|
||||
#enum_name::#variant_names(ref mut channel) => {
|
||||
channel.results(num_entries, offset)
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn get_result(&self, index: u32) -> Option<Entry> {
|
||||
match self {
|
||||
#(
|
||||
#enum_name::#variant_names(ref channel) => {
|
||||
channel.get_result(index)
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_entries(&self) -> &FxHashSet<Entry> {
|
||||
match self {
|
||||
#(
|
||||
#enum_name::#variant_names(ref channel) => {
|
||||
channel.selected_entries()
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_selection(&mut self, entry: &Entry) {
|
||||
match self {
|
||||
#(
|
||||
#enum_name::#variant_names(ref mut channel) => {
|
||||
channel.toggle_selection(entry)
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn result_count(&self) -> u32 {
|
||||
match self {
|
||||
#(
|
||||
#enum_name::#variant_names(ref channel) => {
|
||||
channel.result_count()
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn total_count(&self) -> u32 {
|
||||
match self {
|
||||
#(
|
||||
#enum_name::#variant_names(ref channel) => {
|
||||
channel.total_count()
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn running(&self) -> bool {
|
||||
match self {
|
||||
#(
|
||||
#enum_name::#variant_names(ref channel) => {
|
||||
channel.running()
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown(&self) {
|
||||
match self {
|
||||
#(
|
||||
#enum_name::#variant_names(ref channel) => {
|
||||
channel.shutdown()
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_preview(&self) -> bool {
|
||||
match self {
|
||||
#(
|
||||
#enum_name::#variant_names(ref channel) => {
|
||||
channel.supports_preview()
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
trait_impl.into()
|
||||
}
|
||||
|
||||
/// This macro generates a `UnitChannel` enum and the necessary glue code
|
||||
/// to convert from and to a `TelevisionChannel` member.
|
||||
///
|
||||
/// The `UnitChannel` enum is used as a unit variant of the `TelevisionChannel`
|
||||
/// enum.
|
||||
#[proc_macro_derive(ToUnitChannel, attributes(exclude_from_unit))]
|
||||
pub fn unit_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_unit_channel(&ast)
|
||||
}
|
||||
|
||||
const EXCLUDE_FROM_UNIT: &str = "exclude_from_unit";
|
||||
|
||||
fn impl_unit_channel(ast: &syn::DeriveInput) -> TokenStream {
|
||||
// Ensure the struct is an enum
|
||||
let variants = if let syn::Data::Enum(data_enum) = &ast.data {
|
||||
&data_enum.variants
|
||||
} else {
|
||||
panic!("#[derive(UnitChannel)] is only defined for enums");
|
||||
};
|
||||
|
||||
// Ensure the enum has at least one variant
|
||||
assert!(
|
||||
!variants.is_empty(),
|
||||
"#[derive(UnitChannel)] requires at least one variant"
|
||||
);
|
||||
|
||||
let variant_names: Vec<_> = variants
|
||||
.iter()
|
||||
.filter(|variant| !has_attribute(&variant.attrs, EXCLUDE_FROM_UNIT))
|
||||
.map(|v| &v.ident)
|
||||
.collect();
|
||||
|
||||
let excluded_variants: Vec<_> = variants
|
||||
.iter()
|
||||
.filter(|variant| has_attribute(&variant.attrs, EXCLUDE_FROM_UNIT))
|
||||
.map(|v| &v.ident)
|
||||
.collect();
|
||||
|
||||
// Generate a unit enum from the given enum
|
||||
let unit_enum = quote! {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Hash)]
|
||||
#[strum(serialize_all = "kebab_case")]
|
||||
pub enum UnitChannel {
|
||||
#(
|
||||
#variant_names,
|
||||
)*
|
||||
}
|
||||
};
|
||||
|
||||
// Generate Into<TelevisionChannel> implementation
|
||||
let into_impl = quote! {
|
||||
impl Into<TelevisionChannel> for UnitChannel {
|
||||
fn into(self) -> TelevisionChannel {
|
||||
match self {
|
||||
#(
|
||||
UnitChannel::#variant_names => TelevisionChannel::#variant_names(Default::default()),
|
||||
)*
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Generate From<&TelevisionChannel> implementation
|
||||
let from_impl = quote! {
|
||||
impl From<&TelevisionChannel> for UnitChannel {
|
||||
fn from(channel: &TelevisionChannel) -> Self {
|
||||
match channel {
|
||||
#(
|
||||
TelevisionChannel::#variant_names(_) => Self::#variant_names,
|
||||
)*
|
||||
#(
|
||||
TelevisionChannel::#excluded_variants(_) => panic!("Cannot convert excluded variant to unit channel."),
|
||||
)*
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let gen = quote! {
|
||||
#unit_enum
|
||||
#into_impl
|
||||
#from_impl
|
||||
};
|
||||
|
||||
gen.into()
|
||||
}
|
@ -4,16 +4,17 @@ use anyhow::Result;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, trace};
|
||||
|
||||
use crate::channels::entry::{Entry, PreviewType};
|
||||
use crate::channels::{OnAir, TelevisionChannel};
|
||||
use crate::config::{default_tick_rate, Config};
|
||||
use crate::keymap::Keymap;
|
||||
use crate::render::UiState;
|
||||
use crate::television::{Mode, Television};
|
||||
use crate::{
|
||||
action::Action,
|
||||
channels::{
|
||||
entry::Entry,
|
||||
prototypes::{Cable, ChannelPrototype},
|
||||
},
|
||||
config::{Config, default_tick_rate},
|
||||
event::{Event, EventLoop, Key},
|
||||
render::{render, RenderingTask},
|
||||
keymap::Keymap,
|
||||
render::{RenderingTask, UiState, render},
|
||||
television::{Mode, Television},
|
||||
};
|
||||
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
@ -122,7 +123,6 @@ impl From<ActionOutcome> for AppOutput {
|
||||
ActionOutcome::Input(input) => Self {
|
||||
selected_entries: Some(FxHashSet::from_iter([Entry::new(
|
||||
input,
|
||||
PreviewType::None,
|
||||
)])),
|
||||
},
|
||||
ActionOutcome::None => Self {
|
||||
@ -137,10 +137,11 @@ const ACTION_BUF_SIZE: usize = 8;
|
||||
|
||||
impl App {
|
||||
pub fn new(
|
||||
channel: TelevisionChannel,
|
||||
channel_prototype: &ChannelPrototype,
|
||||
config: Config,
|
||||
input: Option<String>,
|
||||
options: AppOptions,
|
||||
cable_channels: &Cable,
|
||||
) -> Self {
|
||||
let (action_tx, action_rx) = mpsc::unbounded_channel();
|
||||
let (render_tx, render_rx) = mpsc::unbounded_channel();
|
||||
@ -152,12 +153,13 @@ impl App {
|
||||
let (ui_state_tx, ui_state_rx) = mpsc::unbounded_channel();
|
||||
let television = Television::new(
|
||||
action_tx.clone(),
|
||||
channel,
|
||||
channel_prototype,
|
||||
config,
|
||||
input,
|
||||
options.no_remote,
|
||||
options.no_help,
|
||||
options.exact,
|
||||
cable_channels.clone(),
|
||||
);
|
||||
|
||||
Self {
|
||||
@ -200,7 +202,7 @@ impl App {
|
||||
// Event loop
|
||||
if !headless {
|
||||
debug!("Starting backend event loop");
|
||||
let event_loop = EventLoop::new(self.options.tick_rate, true);
|
||||
let event_loop = EventLoop::new(self.options.tick_rate);
|
||||
self.event_rx = event_loop.rx;
|
||||
self.event_abort_tx = event_loop.abort_tx;
|
||||
}
|
||||
@ -362,9 +364,14 @@ impl App {
|
||||
}
|
||||
match action {
|
||||
Action::Quit => {
|
||||
if self.television.mode == Mode::RemoteControl {
|
||||
self.action_tx
|
||||
.send(Action::ToggleRemoteControl)?;
|
||||
} else {
|
||||
self.should_quit = true;
|
||||
self.render_tx.send(RenderingTask::Quit)?;
|
||||
}
|
||||
}
|
||||
Action::Suspend => {
|
||||
self.should_suspend = true;
|
||||
self.render_tx.send(RenderingTask::Suspend)?;
|
||||
@ -410,7 +417,7 @@ impl App {
|
||||
// forward action to the television handler
|
||||
if let Some(action) = self.television.update(&action)? {
|
||||
self.action_tx.send(action)?;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ActionOutcome::None)
|
||||
@ -435,32 +442,3 @@ impl App {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::channels::stdin::Channel as StdinChannel;
|
||||
|
||||
#[test]
|
||||
fn test_maybe_select_1() {
|
||||
let mut app = App::new(
|
||||
TelevisionChannel::Stdin(StdinChannel::new(PreviewType::None)),
|
||||
Config::default(),
|
||||
None,
|
||||
AppOptions::default(),
|
||||
);
|
||||
app.television
|
||||
.results_picker
|
||||
.entries
|
||||
.push(Entry::new("test".to_string(), PreviewType::None));
|
||||
let outcome = app.maybe_select_1();
|
||||
assert!(outcome.is_some());
|
||||
assert_eq!(
|
||||
outcome.unwrap(),
|
||||
ActionOutcome::Entries(FxHashSet::from_iter([Entry::new(
|
||||
"test".to_string(),
|
||||
PreviewType::None
|
||||
)]))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -2,17 +2,19 @@ use std::path::PathBuf;
|
||||
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use crate::channels::cable::{CableChannelPrototype, CableChannels};
|
||||
use anyhow::Result;
|
||||
use tracing::{debug, error};
|
||||
|
||||
use crate::config::get_config_dir;
|
||||
use crate::{
|
||||
channels::prototypes::{Cable, ChannelPrototype},
|
||||
config::get_config_dir,
|
||||
};
|
||||
|
||||
/// Just a proxy struct to deserialize prototypes
|
||||
#[derive(Debug, serde::Deserialize, Default)]
|
||||
struct ChannelPrototypes {
|
||||
pub struct CableSpec {
|
||||
#[serde(rename = "cable_channel")]
|
||||
prototypes: Vec<CableChannelPrototype>,
|
||||
pub prototypes: Vec<ChannelPrototype>,
|
||||
}
|
||||
|
||||
const CABLE_FILE_NAME_SUFFIX: &str = "channels";
|
||||
@ -26,8 +28,6 @@ const DEFAULT_CABLE_CHANNELS: &str =
|
||||
const DEFAULT_CABLE_CHANNELS: &str =
|
||||
include_str!("../cable/windows-channels.toml");
|
||||
|
||||
const DEFAULT_CABLE_CHANNELS_FILE_NAME: &str = "default_channels.toml";
|
||||
|
||||
/// Load the cable configuration from the config directory.
|
||||
///
|
||||
/// Cable is loaded by compiling all files that match the following
|
||||
@ -40,39 +40,35 @@ const DEFAULT_CABLE_CHANNELS_FILE_NAME: &str = "default_channels.toml";
|
||||
/// ├── my_channels.toml
|
||||
/// └── windows_channels.toml
|
||||
/// ```
|
||||
pub fn load_cable_channels() -> Result<CableChannels> {
|
||||
pub fn load_cable() -> Result<Cable> {
|
||||
let config_dir = get_config_dir();
|
||||
|
||||
// list all files in the config directory
|
||||
let files = std::fs::read_dir(&config_dir)?;
|
||||
|
||||
// filter the files that match the pattern
|
||||
let mut file_paths: Vec<PathBuf> = files
|
||||
let file_paths: Vec<PathBuf> = files
|
||||
.filter_map(|f| f.ok().map(|f| f.path()))
|
||||
.filter(|p| is_cable_file_format(p) && p.is_file())
|
||||
.collect();
|
||||
|
||||
debug!("Found cable channel files: {:?}", file_paths);
|
||||
|
||||
// If no cable provider files are found, write the default provider for the current
|
||||
// platform to the config directory
|
||||
if file_paths.is_empty() {
|
||||
debug!("No user defined cable channels found");
|
||||
// write the default cable channels to the config directory
|
||||
let default_channels_path =
|
||||
config_dir.join(DEFAULT_CABLE_CHANNELS_FILE_NAME);
|
||||
std::fs::write(&default_channels_path, DEFAULT_CABLE_CHANNELS)?;
|
||||
file_paths.push(default_channels_path);
|
||||
}
|
||||
|
||||
let user_defined_prototypes = file_paths.iter().fold(
|
||||
Vec::<CableChannelPrototype>::new(),
|
||||
let default_prototypes =
|
||||
toml::from_str::<CableSpec>(DEFAULT_CABLE_CHANNELS)
|
||||
.expect("Failed to parse default cable channels");
|
||||
|
||||
let prototypes = file_paths.iter().fold(
|
||||
Vec::<ChannelPrototype>::new(),
|
||||
|mut acc, p| {
|
||||
match toml::from_str::<ChannelPrototypes>(
|
||||
match toml::from_str::<CableSpec>(
|
||||
&std::fs::read_to_string(p)
|
||||
.expect("Unable to read configuration file"),
|
||||
) {
|
||||
Ok(prototypes) => acc.extend(prototypes.prototypes),
|
||||
Ok(pts) => acc.extend(pts.prototypes),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to parse cable channel file {:?}: {}",
|
||||
@ -84,13 +80,21 @@ pub fn load_cable_channels() -> Result<CableChannels> {
|
||||
},
|
||||
);
|
||||
|
||||
debug!("Loaded cable channels: {:?}", user_defined_prototypes);
|
||||
debug!("Loaded {} custom cable channels", prototypes.len());
|
||||
if prototypes.is_empty() {
|
||||
debug!("No custom cable channels found");
|
||||
}
|
||||
|
||||
let mut cable_channels = FxHashMap::default();
|
||||
for prototype in user_defined_prototypes {
|
||||
// custom prototypes take precedence over default ones
|
||||
for prototype in default_prototypes
|
||||
.prototypes
|
||||
.into_iter()
|
||||
.chain(prototypes.into_iter())
|
||||
{
|
||||
cable_channels.insert(prototype.name.clone(), prototype);
|
||||
}
|
||||
Ok(CableChannels(cable_channels))
|
||||
Ok(Cable(cable_channels))
|
||||
}
|
||||
|
||||
fn is_cable_file_format<P>(p: P) -> bool
|
||||
@ -100,10 +104,10 @@ where
|
||||
let p = p.as_ref();
|
||||
p.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map_or(false, |s| s.ends_with(CABLE_FILE_NAME_SUFFIX))
|
||||
.map_or_else(|| false, |s| s.ends_with(CABLE_FILE_NAME_SUFFIX))
|
||||
&& p.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map_or(false, |e| e.to_lowercase() == CABLE_FILE_FORMAT)
|
||||
.map_or_else(|| false, |e| e.to_lowercase() == CABLE_FILE_FORMAT)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -114,8 +118,5 @@ mod tests {
|
||||
fn test_is_cable_file() {
|
||||
let path = std::path::Path::new("cable_channels.toml");
|
||||
assert!(is_cable_file_format(path));
|
||||
|
||||
let path = std::path::Path::new(DEFAULT_CABLE_CHANNELS_FILE_NAME);
|
||||
assert!(is_cable_file_format(path));
|
||||
}
|
||||
}
|
||||
|
@ -1,182 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::channels::entry::Entry;
|
||||
use crate::channels::entry::PreviewType;
|
||||
use crate::channels::OnAir;
|
||||
use crate::matcher::{config::Config, injector::Injector, Matcher};
|
||||
use crate::utils::command::shell_command;
|
||||
use crate::utils::indices::sep_name_and_value_indices;
|
||||
use crate::utils::shell::Shell;
|
||||
use devicons::FileIcon;
|
||||
use rustc_hash::FxBuildHasher;
|
||||
use rustc_hash::FxHashSet;
|
||||
use tracing::debug;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Alias {
|
||||
name: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl Alias {
|
||||
fn new(name: String, value: String) -> Self {
|
||||
Self { name, value }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Channel {
|
||||
matcher: Matcher<Alias>,
|
||||
file_icon: FileIcon,
|
||||
selected_entries: FxHashSet<Entry>,
|
||||
crawl_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
const NUM_THREADS: usize = 1;
|
||||
|
||||
const FILE_ICON_STR: &str = "nu";
|
||||
|
||||
fn get_raw_aliases(shell: Shell) -> Vec<String> {
|
||||
// this needs to be run in an interactive shell in order to get the aliases
|
||||
let mut command = shell_command(true);
|
||||
|
||||
let output = match shell {
|
||||
Shell::PowerShell => {
|
||||
command.arg("Get-Alias | Format-List -Property Name, Definition")
|
||||
}
|
||||
Shell::Cmd => command.arg("doskey /macros"),
|
||||
_ => command.arg("-i").arg("alias").arg("2>/dev/null"),
|
||||
}
|
||||
.output()
|
||||
.expect("failed to execute process");
|
||||
|
||||
let aliases = String::from_utf8_lossy(&output.stdout);
|
||||
aliases.lines().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub fn new() -> Self {
|
||||
let matcher = Matcher::new(Config::default().n_threads(NUM_THREADS));
|
||||
let injector = matcher.injector();
|
||||
let crawl_handle = tokio::spawn(load_aliases(injector));
|
||||
|
||||
Self {
|
||||
matcher,
|
||||
file_icon: FileIcon::from(FILE_ICON_STR),
|
||||
selected_entries: HashSet::with_hasher(FxBuildHasher),
|
||||
crawl_handle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Channel {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl OnAir for Channel {
|
||||
fn find(&mut self, pattern: &str) {
|
||||
self.matcher.find(pattern);
|
||||
}
|
||||
|
||||
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
|
||||
self.matcher.tick();
|
||||
self.matcher
|
||||
.results(num_entries, offset)
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let (
|
||||
name_indices,
|
||||
value_indices,
|
||||
should_add_name_indices,
|
||||
should_add_value_indices,
|
||||
) = sep_name_and_value_indices(
|
||||
item.match_indices,
|
||||
u32::try_from(item.inner.name.len()).unwrap(),
|
||||
);
|
||||
|
||||
let mut entry =
|
||||
Entry::new(item.inner.name.clone(), PreviewType::EnvVar)
|
||||
.with_value(item.inner.value)
|
||||
.with_icon(self.file_icon);
|
||||
|
||||
if should_add_name_indices {
|
||||
entry = entry.with_name_match_indices(&name_indices);
|
||||
}
|
||||
|
||||
if should_add_value_indices {
|
||||
entry = entry.with_value_match_indices(&value_indices);
|
||||
}
|
||||
|
||||
entry
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_result(&self, index: u32) -> Option<Entry> {
|
||||
self.matcher.get_result(index).map(|item| {
|
||||
Entry::new(item.inner.name.clone(), PreviewType::EnvVar)
|
||||
.with_value(item.inner.value)
|
||||
.with_icon(self.file_icon)
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_entries(&self) -> &FxHashSet<Entry> {
|
||||
&self.selected_entries
|
||||
}
|
||||
|
||||
fn toggle_selection(&mut self, entry: &Entry) {
|
||||
if self.selected_entries.contains(entry) {
|
||||
self.selected_entries.remove(entry);
|
||||
} else {
|
||||
self.selected_entries.insert(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn result_count(&self) -> u32 {
|
||||
self.matcher.matched_item_count
|
||||
}
|
||||
|
||||
fn total_count(&self) -> u32 {
|
||||
self.matcher.total_item_count
|
||||
}
|
||||
|
||||
fn running(&self) -> bool {
|
||||
self.matcher.status.running || !self.crawl_handle.is_finished()
|
||||
}
|
||||
|
||||
fn shutdown(&self) {}
|
||||
|
||||
fn supports_preview(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::unused_async)]
|
||||
async fn load_aliases(injector: Injector<Alias>) {
|
||||
let shell = Shell::from_env().unwrap_or_default();
|
||||
debug!("Current shell: {}", shell);
|
||||
let raw_aliases = get_raw_aliases(shell);
|
||||
|
||||
raw_aliases
|
||||
.iter()
|
||||
.filter_map(|alias| {
|
||||
let mut parts = alias.split('=');
|
||||
if let Some(name) = parts.next() {
|
||||
if let Some(value) = parts.next() {
|
||||
return Some(Alias::new(
|
||||
name.to_string(),
|
||||
value.to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
debug!("Invalid alias format: {}", alias);
|
||||
}
|
||||
None
|
||||
})
|
||||
.for_each(|alias| {
|
||||
let () = injector.push(alias, |e, cols| {
|
||||
cols[0] = (e.name.clone() + &e.value).into();
|
||||
});
|
||||
});
|
||||
}
|
@ -1,116 +1,139 @@
|
||||
use rustc_hash::FxHashMap;
|
||||
use std::{
|
||||
fmt::{self, Display, Formatter},
|
||||
ops::Deref,
|
||||
};
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::Result;
|
||||
use regex::Regex;
|
||||
use rustc_hash::{FxBuildHasher, FxHashSet};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::channels::entry::{Entry, PreviewCommand, PreviewType};
|
||||
use crate::channels::OnAir;
|
||||
use crate::channels::{
|
||||
entry::Entry, preview::PreviewCommand, prototypes::ChannelPrototype,
|
||||
};
|
||||
use crate::matcher::Matcher;
|
||||
use crate::matcher::{config::Config, injector::Injector};
|
||||
use crate::utils::command::shell_command;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PreviewKind {
|
||||
Command(PreviewCommand),
|
||||
Builtin(PreviewType),
|
||||
None,
|
||||
}
|
||||
use crate::utils::strings::format_string;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct Channel {
|
||||
pub name: String,
|
||||
matcher: Matcher<String>,
|
||||
entries_command: String,
|
||||
preview_kind: PreviewKind,
|
||||
pub preview_command: Option<PreviewCommand>,
|
||||
selected_entries: FxHashSet<Entry>,
|
||||
crawl_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Default for Channel {
|
||||
fn default() -> Self {
|
||||
Self::new(
|
||||
"Files",
|
||||
Self::new(&ChannelPrototype::new(
|
||||
"files",
|
||||
"find . -type f",
|
||||
Some(PreviewCommand::new("bat -n --color=always {}", ":")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CableChannelPrototype> for Channel {
|
||||
fn from(prototype: CableChannelPrototype) -> Self {
|
||||
Self::new(
|
||||
&prototype.name,
|
||||
&prototype.source_command,
|
||||
match prototype.preview_command {
|
||||
Some(command) => Some(PreviewCommand::new(
|
||||
&command,
|
||||
&prototype
|
||||
.preview_delimiter
|
||||
.unwrap_or(DEFAULT_DELIMITER.to_string()),
|
||||
)),
|
||||
None => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_preview_kind(command: &PreviewCommand) -> Result<PreviewKind> {
|
||||
debug!("Parsing preview kind for command: {:?}", command);
|
||||
let re = Regex::new(r"^\:(\w+)\:$").unwrap();
|
||||
if let Some(captures) = re.captures(&command.command) {
|
||||
let preview_type = PreviewType::try_from(&captures[1])?;
|
||||
Ok(PreviewKind::Builtin(preview_type))
|
||||
} else {
|
||||
Ok(PreviewKind::Command(command.clone()))
|
||||
false,
|
||||
Some(PreviewCommand::new("cat {}", ":", None)),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub fn new(
|
||||
name: &str,
|
||||
entries_command: &str,
|
||||
preview_command: Option<PreviewCommand>,
|
||||
) -> Self {
|
||||
pub fn new(prototype: &ChannelPrototype) -> Self {
|
||||
let matcher = Matcher::new(Config::default());
|
||||
let injector = matcher.injector();
|
||||
let crawl_handle = tokio::spawn(load_candidates(
|
||||
entries_command.to_string(),
|
||||
prototype.source_command.to_string(),
|
||||
prototype.interactive,
|
||||
injector,
|
||||
));
|
||||
let preview_kind = match preview_command {
|
||||
Some(command) => {
|
||||
parse_preview_kind(&command).unwrap_or_else(|_| {
|
||||
panic!("Invalid preview command: {command}")
|
||||
})
|
||||
}
|
||||
None => PreviewKind::None,
|
||||
};
|
||||
debug!("Preview kind: {:?}", preview_kind);
|
||||
Self {
|
||||
matcher,
|
||||
entries_command: entries_command.to_string(),
|
||||
preview_kind,
|
||||
name: name.to_string(),
|
||||
preview_command: prototype.preview_command.clone(),
|
||||
name: prototype.name.to_string(),
|
||||
selected_entries: HashSet::with_hasher(FxBuildHasher),
|
||||
crawl_handle,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find(&mut self, pattern: &str) {
|
||||
self.matcher.find(pattern);
|
||||
}
|
||||
|
||||
pub fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
|
||||
self.matcher.tick();
|
||||
self.matcher
|
||||
.results(num_entries, offset)
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let path = item.matched_string;
|
||||
Entry::new(path).with_name_match_indices(&item.match_indices)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_result(&self, index: u32) -> Option<Entry> {
|
||||
self.matcher.get_result(index).map(|item| {
|
||||
let name = item.matched_string;
|
||||
if let Some(cmd) = &self.preview_command {
|
||||
if let Some(offset_expr) = &cmd.offset_expr {
|
||||
let offset_string =
|
||||
format_string(offset_expr, &name, &cmd.delimiter);
|
||||
let offset_str = {
|
||||
offset_string
|
||||
.strip_prefix('\'')
|
||||
.and_then(|s| s.strip_suffix('\''))
|
||||
.unwrap_or(&offset_string)
|
||||
};
|
||||
|
||||
return Entry::new(name).with_line_number(
|
||||
offset_str.parse::<usize>().unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"Failed to parse line number from {}",
|
||||
offset_str
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Entry::new(name)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn selected_entries(&self) -> &FxHashSet<Entry> {
|
||||
&self.selected_entries
|
||||
}
|
||||
|
||||
pub fn toggle_selection(&mut self, entry: &Entry) {
|
||||
if self.selected_entries.contains(entry) {
|
||||
self.selected_entries.remove(entry);
|
||||
} else {
|
||||
self.selected_entries.insert(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn result_count(&self) -> u32 {
|
||||
self.matcher.matched_item_count
|
||||
}
|
||||
|
||||
pub fn total_count(&self) -> u32 {
|
||||
self.matcher.total_item_count
|
||||
}
|
||||
|
||||
pub fn running(&self) -> bool {
|
||||
self.matcher.status.running || !self.crawl_handle.is_finished()
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {}
|
||||
|
||||
pub fn supports_preview(&self) -> bool {
|
||||
self.preview_command.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::unused_async)]
|
||||
async fn load_candidates(command: String, injector: Injector<String>) {
|
||||
async fn load_candidates(
|
||||
command: String,
|
||||
interactive: bool,
|
||||
injector: Injector<String>,
|
||||
) {
|
||||
debug!("Loading candidates from command: {:?}", command);
|
||||
let mut child = shell_command(false)
|
||||
let mut child = shell_command(interactive)
|
||||
.arg(command)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@ -126,6 +149,7 @@ async fn load_candidates(command: String, injector: Injector<String>) {
|
||||
if let Ok(l) = line {
|
||||
if !l.trim().is_empty() {
|
||||
let () = injector.push(l, |e, cols| {
|
||||
// PERF: maybe we can avoid cloning here by using &Utf32Str
|
||||
cols[0] = e.clone().into();
|
||||
});
|
||||
produced_output = true;
|
||||
@ -147,112 +171,3 @@ async fn load_candidates(command: String, injector: Injector<String>) {
|
||||
}
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
impl OnAir for Channel {
|
||||
fn find(&mut self, pattern: &str) {
|
||||
self.matcher.find(pattern);
|
||||
}
|
||||
|
||||
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
|
||||
self.matcher.tick();
|
||||
self.matcher
|
||||
.results(num_entries, offset)
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let path = item.matched_string;
|
||||
Entry::new(
|
||||
path,
|
||||
match &self.preview_kind {
|
||||
PreviewKind::Command(ref preview_command) => {
|
||||
PreviewType::Command(preview_command.clone())
|
||||
}
|
||||
PreviewKind::Builtin(preview_type) => {
|
||||
preview_type.clone()
|
||||
}
|
||||
PreviewKind::None => PreviewType::None,
|
||||
},
|
||||
)
|
||||
.with_name_match_indices(&item.match_indices)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_result(&self, index: u32) -> Option<Entry> {
|
||||
self.matcher.get_result(index).map(|item| {
|
||||
let path = item.matched_string;
|
||||
Entry::new(
|
||||
path,
|
||||
match &self.preview_kind {
|
||||
PreviewKind::Command(ref preview_command) => {
|
||||
PreviewType::Command(preview_command.clone())
|
||||
}
|
||||
PreviewKind::Builtin(preview_type) => preview_type.clone(),
|
||||
PreviewKind::None => PreviewType::None,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_entries(&self) -> &FxHashSet<Entry> {
|
||||
&self.selected_entries
|
||||
}
|
||||
|
||||
fn toggle_selection(&mut self, entry: &Entry) {
|
||||
if self.selected_entries.contains(entry) {
|
||||
self.selected_entries.remove(entry);
|
||||
} else {
|
||||
self.selected_entries.insert(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn result_count(&self) -> u32 {
|
||||
self.matcher.matched_item_count
|
||||
}
|
||||
|
||||
fn total_count(&self) -> u32 {
|
||||
self.matcher.total_item_count
|
||||
}
|
||||
|
||||
fn running(&self) -> bool {
|
||||
self.matcher.status.running || !self.crawl_handle.is_finished()
|
||||
}
|
||||
|
||||
fn shutdown(&self) {}
|
||||
|
||||
fn supports_preview(&self) -> bool {
|
||||
self.preview_kind != PreviewKind::None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize, PartialEq)]
|
||||
pub struct CableChannelPrototype {
|
||||
pub name: String,
|
||||
pub source_command: String,
|
||||
pub preview_command: Option<String>,
|
||||
#[serde(default = "default_delimiter")]
|
||||
pub preview_delimiter: Option<String>,
|
||||
}
|
||||
|
||||
pub const DEFAULT_DELIMITER: &str = " ";
|
||||
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn default_delimiter() -> Option<String> {
|
||||
Some(DEFAULT_DELIMITER.to_string())
|
||||
}
|
||||
|
||||
impl Display for CableChannelPrototype {
|
||||
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
|
||||
write!(f, "{}", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, Default)]
|
||||
pub struct CableChannels(pub FxHashMap<String, CableChannelPrototype>);
|
||||
|
||||
impl Deref for CableChannels {
|
||||
type Target = FxHashMap<String, CableChannelPrototype>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
@ -1,186 +0,0 @@
|
||||
use crate::channels::entry::{Entry, PreviewCommand, PreviewType};
|
||||
use crate::channels::{OnAir, TelevisionChannel};
|
||||
use crate::matcher::{config::Config, injector::Injector, Matcher};
|
||||
use crate::utils::files::{get_default_num_threads, walk_builder};
|
||||
use devicons::FileIcon;
|
||||
use rustc_hash::{FxBuildHasher, FxHashSet};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct Channel {
|
||||
matcher: Matcher<String>,
|
||||
crawl_handle: tokio::task::JoinHandle<()>,
|
||||
// PERF: cache results (to make deleting characters smoother) with
|
||||
// a shallow stack of sub-patterns as keys (e.g. "a", "ab", "abc")
|
||||
selected_entries: FxHashSet<Entry>,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub fn new(paths: Vec<PathBuf>) -> Self {
|
||||
let matcher = Matcher::new(Config::default().match_paths(true));
|
||||
// start loading files in the background
|
||||
let crawl_handle = tokio::spawn(load_dirs(paths, matcher.injector()));
|
||||
Channel {
|
||||
matcher,
|
||||
crawl_handle,
|
||||
selected_entries: HashSet::with_hasher(FxBuildHasher),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Channel {
|
||||
fn default() -> Self {
|
||||
Self::new(vec![std::env::current_dir().unwrap()])
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&mut TelevisionChannel> for Channel {
|
||||
fn from(value: &mut TelevisionChannel) -> Self {
|
||||
match value {
|
||||
c @ TelevisionChannel::GitRepos(_) => {
|
||||
let entries = if c.selected_entries().is_empty() {
|
||||
c.results(c.result_count(), 0)
|
||||
} else {
|
||||
c.selected_entries().iter().cloned().collect()
|
||||
};
|
||||
Self::new(
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| PathBuf::from(entry.name.clone()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
c @ TelevisionChannel::Dirs(_) => {
|
||||
let entries = if c.selected_entries().is_empty() {
|
||||
c.results(c.result_count(), 0)
|
||||
} else {
|
||||
c.selected_entries().iter().cloned().collect()
|
||||
};
|
||||
Self::new(
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| PathBuf::from(&entry.name))
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
const PREVIEW_COMMAND: &str = "ls -la --color=always {}";
|
||||
|
||||
#[cfg(windows)]
|
||||
const PREVIEW_COMMAND: &str = "dir /Q {}";
|
||||
|
||||
impl OnAir for Channel {
|
||||
fn find(&mut self, pattern: &str) {
|
||||
self.matcher.find(pattern);
|
||||
}
|
||||
|
||||
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
|
||||
self.matcher.tick();
|
||||
self.matcher
|
||||
.results(num_entries, offset)
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let path = item.matched_string;
|
||||
Entry::new(
|
||||
path.clone(),
|
||||
PreviewType::Command(PreviewCommand::new(
|
||||
PREVIEW_COMMAND,
|
||||
" ",
|
||||
)),
|
||||
)
|
||||
.with_name_match_indices(&item.match_indices)
|
||||
.with_icon(FileIcon::from(&path))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_result(&self, index: u32) -> Option<Entry> {
|
||||
self.matcher.get_result(index).map(|item| {
|
||||
let path = item.matched_string;
|
||||
Entry::new(
|
||||
path.clone(),
|
||||
PreviewType::Command(PreviewCommand::new(
|
||||
PREVIEW_COMMAND,
|
||||
" ",
|
||||
)),
|
||||
)
|
||||
.with_icon(FileIcon::from(&path))
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_entries(&self) -> &FxHashSet<Entry> {
|
||||
&self.selected_entries
|
||||
}
|
||||
|
||||
fn toggle_selection(&mut self, entry: &Entry) {
|
||||
if self.selected_entries.contains(entry) {
|
||||
self.selected_entries.remove(entry);
|
||||
} else {
|
||||
self.selected_entries.insert(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn result_count(&self) -> u32 {
|
||||
self.matcher.matched_item_count
|
||||
}
|
||||
|
||||
fn total_count(&self) -> u32 {
|
||||
self.matcher.total_item_count
|
||||
}
|
||||
|
||||
fn running(&self) -> bool {
|
||||
self.matcher.status.running || !self.crawl_handle.is_finished()
|
||||
}
|
||||
|
||||
fn shutdown(&self) {
|
||||
self.crawl_handle.abort();
|
||||
}
|
||||
|
||||
fn supports_preview(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::unused_async)]
|
||||
async fn load_dirs(paths: Vec<PathBuf>, injector: Injector<String>) {
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let current_dir = std::env::current_dir().unwrap();
|
||||
let mut builder =
|
||||
walk_builder(&paths[0], get_default_num_threads(), None, None);
|
||||
paths[1..].iter().for_each(|path| {
|
||||
builder.add(path);
|
||||
});
|
||||
let walker = builder.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_dir() {
|
||||
let dir_path = &entry
|
||||
.path()
|
||||
.strip_prefix(¤t_dir)
|
||||
.unwrap_or(entry.path())
|
||||
.to_string_lossy();
|
||||
if dir_path == "" {
|
||||
return ignore::WalkState::Continue;
|
||||
}
|
||||
let () = injector.push(dir_path.to_string(), |e, cols| {
|
||||
cols[0] = e.to_string().into();
|
||||
});
|
||||
}
|
||||
}
|
||||
ignore::WalkState::Continue
|
||||
})
|
||||
});
|
||||
}
|
@ -1,17 +1,10 @@
|
||||
use std::{
|
||||
fmt::Display,
|
||||
fmt::Write,
|
||||
hash::{Hash, Hasher},
|
||||
};
|
||||
|
||||
use devicons::FileIcon;
|
||||
use strum::EnumString;
|
||||
|
||||
// NOTE: having an enum for entry types would be nice since it would allow
|
||||
// having a nicer implementation for transitions between channels. This would
|
||||
// permit implementing `From<EntryType>` for channels which would make the
|
||||
// channel convertible from any other that yields `EntryType`.
|
||||
// This needs pondering since it does bring another level of abstraction and
|
||||
// adds a layer of complexity.
|
||||
#[derive(Clone, Debug, Eq)]
|
||||
pub struct Entry {
|
||||
/// The name of the entry.
|
||||
@ -26,8 +19,6 @@ pub struct Entry {
|
||||
pub icon: Option<FileIcon>,
|
||||
/// The optional line number associated with the entry.
|
||||
pub line_number: Option<usize>,
|
||||
/// The type of preview associated with the entry.
|
||||
pub preview_type: PreviewType,
|
||||
}
|
||||
|
||||
impl Hash for Entry {
|
||||
@ -87,10 +78,10 @@ impl Entry {
|
||||
///
|
||||
/// Additional fields can be set using the builder pattern.
|
||||
/// ```
|
||||
/// use television::channels::entry::{Entry, PreviewType};
|
||||
/// use television::channels::entry::Entry;
|
||||
/// use devicons::FileIcon;
|
||||
///
|
||||
/// let entry = Entry::new("name".to_string(), PreviewType::EnvVar)
|
||||
/// let entry = Entry::new("name".to_string())
|
||||
/// .with_value("value".to_string())
|
||||
/// .with_name_match_indices(&vec![0])
|
||||
/// .with_value_match_indices(&vec![0])
|
||||
@ -100,12 +91,11 @@ impl Entry {
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `name` - The name of the entry.
|
||||
/// * `preview_type` - The type of preview associated with the entry.
|
||||
///
|
||||
/// # Returns
|
||||
/// A new entry with the given name and preview type.
|
||||
/// The other fields are set to `None` by default.
|
||||
pub fn new(name: String, preview_type: PreviewType) -> Self {
|
||||
pub fn new(name: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
value: None,
|
||||
@ -113,7 +103,6 @@ impl Entry {
|
||||
value_match_ranges: None,
|
||||
icon: None,
|
||||
line_number: None,
|
||||
preview_type,
|
||||
}
|
||||
}
|
||||
|
||||
@ -145,55 +134,12 @@ impl Entry {
|
||||
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}"));
|
||||
write!(repr, ":{}", line_number).unwrap();
|
||||
}
|
||||
repr
|
||||
}
|
||||
}
|
||||
|
||||
pub const ENTRY_PLACEHOLDER: Entry = Entry {
|
||||
name: String::new(),
|
||||
value: None,
|
||||
name_match_ranges: None,
|
||||
value_match_ranges: None,
|
||||
icon: None,
|
||||
line_number: None,
|
||||
preview_type: PreviewType::EnvVar,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Hash, Default)]
|
||||
pub struct PreviewCommand {
|
||||
pub command: String,
|
||||
pub delimiter: String,
|
||||
}
|
||||
|
||||
impl PreviewCommand {
|
||||
pub fn new(command: &str, delimiter: &str) -> Self {
|
||||
Self {
|
||||
command: command.to_string(),
|
||||
delimiter: delimiter.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for PreviewCommand {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{self:?}")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Hash, Default, EnumString)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum PreviewType {
|
||||
Basic,
|
||||
EnvVar,
|
||||
Files,
|
||||
#[strum(disabled)]
|
||||
Command(PreviewCommand),
|
||||
#[default]
|
||||
None,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -231,7 +177,6 @@ mod tests {
|
||||
value_match_ranges: None,
|
||||
icon: None,
|
||||
line_number: None,
|
||||
preview_type: PreviewType::Basic,
|
||||
};
|
||||
assert_eq!(entry.stdout_repr(), "test name with spaces");
|
||||
}
|
||||
@ -245,7 +190,6 @@ mod tests {
|
||||
value_match_ranges: None,
|
||||
icon: None,
|
||||
line_number: Some(a),
|
||||
preview_type: PreviewType::Basic,
|
||||
};
|
||||
assert_eq!(entry.stdout_repr(), "test_file_name.rs:10");
|
||||
}
|
||||
|
@ -1,132 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use devicons::FileIcon;
|
||||
use rustc_hash::{FxBuildHasher, FxHashSet};
|
||||
|
||||
use super::OnAir;
|
||||
use crate::channels::entry::{Entry, PreviewType};
|
||||
use crate::matcher::{config::Config, Matcher};
|
||||
use crate::utils::indices::sep_name_and_value_indices;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct EnvVar {
|
||||
name: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
pub struct Channel {
|
||||
matcher: Matcher<EnvVar>,
|
||||
file_icon: FileIcon,
|
||||
selected_entries: FxHashSet<Entry>,
|
||||
}
|
||||
|
||||
const NUM_THREADS: usize = 1;
|
||||
const FILE_ICON_STR: &str = "config";
|
||||
|
||||
impl Channel {
|
||||
pub fn new() -> Self {
|
||||
let matcher = Matcher::new(Config::default().n_threads(NUM_THREADS));
|
||||
let injector = matcher.injector();
|
||||
for (name, value) in std::env::vars() {
|
||||
let () = injector.push(
|
||||
EnvVar {
|
||||
name: name.clone(),
|
||||
value: value.clone(),
|
||||
},
|
||||
|e, cols| {
|
||||
cols[0] = (e.name.clone() + &e.value).into();
|
||||
},
|
||||
);
|
||||
}
|
||||
Channel {
|
||||
matcher,
|
||||
file_icon: FileIcon::from(FILE_ICON_STR),
|
||||
selected_entries: HashSet::with_hasher(FxBuildHasher),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Channel {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl OnAir for Channel {
|
||||
fn find(&mut self, pattern: &str) {
|
||||
self.matcher.find(pattern);
|
||||
}
|
||||
|
||||
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
|
||||
self.matcher.tick();
|
||||
self.matcher
|
||||
.results(num_entries, offset)
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let (
|
||||
name_indices,
|
||||
value_indices,
|
||||
should_add_name_indices,
|
||||
should_add_value_indices,
|
||||
) = sep_name_and_value_indices(
|
||||
item.match_indices,
|
||||
u32::try_from(item.inner.name.len()).unwrap(),
|
||||
);
|
||||
|
||||
let mut entry =
|
||||
Entry::new(item.inner.name.clone(), PreviewType::EnvVar)
|
||||
.with_value(item.inner.value)
|
||||
.with_icon(self.file_icon);
|
||||
|
||||
if should_add_name_indices {
|
||||
entry = entry.with_name_match_indices(&name_indices);
|
||||
}
|
||||
|
||||
if should_add_value_indices {
|
||||
entry = entry.with_value_match_indices(&value_indices);
|
||||
}
|
||||
|
||||
entry
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_result(&self, index: u32) -> Option<Entry> {
|
||||
self.matcher.get_result(index).map(|item| {
|
||||
Entry::new(item.inner.name.clone(), PreviewType::EnvVar)
|
||||
.with_value(item.inner.value)
|
||||
.with_icon(self.file_icon)
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_entries(&self) -> &FxHashSet<Entry> {
|
||||
&self.selected_entries
|
||||
}
|
||||
|
||||
fn toggle_selection(&mut self, entry: &Entry) {
|
||||
if self.selected_entries.contains(entry) {
|
||||
self.selected_entries.remove(entry);
|
||||
} else {
|
||||
self.selected_entries.insert(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn result_count(&self) -> u32 {
|
||||
self.matcher.matched_item_count
|
||||
}
|
||||
|
||||
fn total_count(&self) -> u32 {
|
||||
self.matcher.total_item_count
|
||||
}
|
||||
|
||||
fn running(&self) -> bool {
|
||||
self.matcher.status.running
|
||||
}
|
||||
|
||||
fn shutdown(&self) {}
|
||||
|
||||
fn supports_preview(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
@ -1,190 +0,0 @@
|
||||
use crate::channels::entry::{Entry, PreviewType};
|
||||
use crate::channels::{OnAir, TelevisionChannel};
|
||||
use crate::matcher::{config::Config, injector::Injector, Matcher};
|
||||
use crate::utils::files::{get_default_num_threads, walk_builder};
|
||||
use devicons::FileIcon;
|
||||
use rustc_hash::{FxBuildHasher, FxHashSet};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct Channel {
|
||||
matcher: Matcher<String>,
|
||||
crawl_handle: tokio::task::JoinHandle<()>,
|
||||
// PERF: cache results (to make deleting characters smoother) with
|
||||
// a shallow stack of sub-patterns as keys (e.g. "a", "ab", "abc")
|
||||
selected_entries: FxHashSet<Entry>,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub fn new(paths: Vec<PathBuf>) -> Self {
|
||||
let matcher = Matcher::new(Config::default().match_paths(true));
|
||||
// start loading files in the background
|
||||
let crawl_handle = tokio::spawn(load_files(paths, matcher.injector()));
|
||||
Channel {
|
||||
matcher,
|
||||
crawl_handle,
|
||||
selected_entries: HashSet::with_hasher(FxBuildHasher),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Channel {
|
||||
fn default() -> Self {
|
||||
Self::new(vec![std::env::current_dir().unwrap()])
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&mut TelevisionChannel> for Channel {
|
||||
fn from(value: &mut TelevisionChannel) -> Self {
|
||||
match value {
|
||||
c @ TelevisionChannel::GitRepos(_) => {
|
||||
let entries = if c.selected_entries().is_empty() {
|
||||
c.results(c.result_count(), 0)
|
||||
} else {
|
||||
c.selected_entries().iter().cloned().collect()
|
||||
};
|
||||
Self::new(
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| PathBuf::from(entry.name.clone()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
c @ TelevisionChannel::Files(_) => {
|
||||
let entries = if c.selected_entries().is_empty() {
|
||||
c.results(c.result_count(), 0)
|
||||
} else {
|
||||
c.selected_entries().iter().cloned().collect()
|
||||
};
|
||||
Self::new(
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| PathBuf::from(entry.name.clone()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
c @ TelevisionChannel::Text(_) => {
|
||||
let entries = if c.selected_entries().is_empty() {
|
||||
c.results(c.result_count(), 0)
|
||||
} else {
|
||||
c.selected_entries().iter().cloned().collect()
|
||||
};
|
||||
Self::new(
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| PathBuf::from(&entry.name))
|
||||
.collect::<FxHashSet<_>>()
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
c @ TelevisionChannel::Dirs(_) => {
|
||||
let entries = c.results(c.result_count(), 0);
|
||||
Self::new(
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| PathBuf::from(&entry.name))
|
||||
.collect::<FxHashSet<_>>()
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OnAir for Channel {
|
||||
fn find(&mut self, pattern: &str) {
|
||||
self.matcher.find(pattern);
|
||||
}
|
||||
|
||||
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
|
||||
self.matcher.tick();
|
||||
self.matcher
|
||||
.results(num_entries, offset)
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let path = item.matched_string;
|
||||
Entry::new(path.clone(), PreviewType::Files)
|
||||
.with_name_match_indices(&item.match_indices)
|
||||
.with_icon(FileIcon::from(&path))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_result(&self, index: u32) -> Option<Entry> {
|
||||
self.matcher.get_result(index).map(|item| {
|
||||
let path = item.matched_string;
|
||||
Entry::new(path.clone(), PreviewType::Files)
|
||||
.with_icon(FileIcon::from(&path))
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_entries(&self) -> &FxHashSet<Entry> {
|
||||
&self.selected_entries
|
||||
}
|
||||
|
||||
fn toggle_selection(&mut self, entry: &Entry) {
|
||||
if self.selected_entries.contains(entry) {
|
||||
self.selected_entries.remove(entry);
|
||||
} else {
|
||||
self.selected_entries.insert(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn result_count(&self) -> u32 {
|
||||
self.matcher.matched_item_count
|
||||
}
|
||||
|
||||
fn total_count(&self) -> u32 {
|
||||
self.matcher.total_item_count
|
||||
}
|
||||
|
||||
fn running(&self) -> bool {
|
||||
self.matcher.status.running || !self.crawl_handle.is_finished()
|
||||
}
|
||||
|
||||
fn shutdown(&self) {
|
||||
self.crawl_handle.abort();
|
||||
}
|
||||
|
||||
fn supports_preview(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::unused_async)]
|
||||
async fn load_files(paths: Vec<PathBuf>, injector: Injector<String>) {
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let current_dir = std::env::current_dir().unwrap();
|
||||
let mut builder =
|
||||
walk_builder(&paths[0], get_default_num_threads(), None, None);
|
||||
paths[1..].iter().for_each(|path| {
|
||||
builder.add(path);
|
||||
});
|
||||
let walker = builder.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() {
|
||||
let file_path = &entry
|
||||
.path()
|
||||
.strip_prefix(¤t_dir)
|
||||
.unwrap_or(entry.path())
|
||||
.to_string_lossy();
|
||||
let () =
|
||||
injector.push(file_path.to_string(), |e, cols| {
|
||||
cols[0] = e.to_string().into();
|
||||
});
|
||||
}
|
||||
}
|
||||
ignore::WalkState::Continue
|
||||
})
|
||||
});
|
||||
}
|
@ -1,204 +0,0 @@
|
||||
use devicons::FileIcon;
|
||||
use directories::BaseDirs;
|
||||
use ignore::overrides::OverrideBuilder;
|
||||
use rustc_hash::{FxBuildHasher, FxHashSet};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::channels::entry::{Entry, PreviewCommand, PreviewType};
|
||||
use crate::channels::OnAir;
|
||||
use crate::matcher::{config::Config, injector::Injector, Matcher};
|
||||
use crate::utils::files::{get_default_num_threads, walk_builder};
|
||||
|
||||
pub struct Channel {
|
||||
matcher: Matcher<String>,
|
||||
icon: FileIcon,
|
||||
crawl_handle: JoinHandle<()>,
|
||||
selected_entries: FxHashSet<Entry>,
|
||||
preview_command: PreviewCommand,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub fn new() -> Self {
|
||||
let matcher = Matcher::new(Config::default().match_paths(true));
|
||||
let base_dirs = BaseDirs::new().unwrap();
|
||||
let crawl_handle = tokio::spawn(crawl_for_repos(
|
||||
base_dirs.home_dir().to_path_buf(),
|
||||
matcher.injector(),
|
||||
));
|
||||
|
||||
let preview_command = PreviewCommand {
|
||||
command: String::from(
|
||||
"cd {} && git log -n 200 --pretty=medium --all --graph --color",
|
||||
),
|
||||
delimiter: ":".to_string(),
|
||||
};
|
||||
|
||||
Channel {
|
||||
matcher,
|
||||
icon: FileIcon::from("git"),
|
||||
crawl_handle,
|
||||
selected_entries: HashSet::with_hasher(FxBuildHasher),
|
||||
preview_command,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Channel {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl OnAir for Channel {
|
||||
fn find(&mut self, pattern: &str) {
|
||||
self.matcher.find(pattern);
|
||||
}
|
||||
|
||||
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
|
||||
self.matcher.tick();
|
||||
self.matcher
|
||||
.results(num_entries, offset)
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let path = item.matched_string;
|
||||
Entry::new(
|
||||
path,
|
||||
PreviewType::Command(self.preview_command.clone()),
|
||||
)
|
||||
.with_name_match_indices(&item.match_indices)
|
||||
.with_icon(self.icon)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_result(&self, index: u32) -> Option<Entry> {
|
||||
self.matcher.get_result(index).map(|item| {
|
||||
let path = item.matched_string;
|
||||
Entry::new(
|
||||
path,
|
||||
PreviewType::Command(self.preview_command.clone()),
|
||||
)
|
||||
.with_icon(self.icon)
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_entries(&self) -> &FxHashSet<Entry> {
|
||||
&self.selected_entries
|
||||
}
|
||||
|
||||
fn toggle_selection(&mut self, entry: &Entry) {
|
||||
if self.selected_entries.contains(entry) {
|
||||
self.selected_entries.remove(entry);
|
||||
} else {
|
||||
self.selected_entries.insert(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn result_count(&self) -> u32 {
|
||||
self.matcher.matched_item_count
|
||||
}
|
||||
|
||||
fn total_count(&self) -> u32 {
|
||||
self.matcher.total_item_count
|
||||
}
|
||||
|
||||
fn running(&self) -> bool {
|
||||
self.matcher.status.running || !self.crawl_handle.is_finished()
|
||||
}
|
||||
|
||||
fn shutdown(&self) {
|
||||
debug!("Shutting down git repos channel");
|
||||
self.crawl_handle.abort();
|
||||
}
|
||||
|
||||
fn supports_preview(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn get_ignored_paths() -> Vec<PathBuf> {
|
||||
let mut ignored_paths = Vec::new();
|
||||
|
||||
if let Some(base_dirs) = BaseDirs::new() {
|
||||
let home = base_dirs.home_dir();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
ignored_paths.push(home.join("Library"));
|
||||
ignored_paths.push(home.join("Applications"));
|
||||
ignored_paths.push(home.join("Music"));
|
||||
ignored_paths.push(home.join("Pictures"));
|
||||
ignored_paths.push(home.join("Movies"));
|
||||
ignored_paths.push(home.join("Downloads"));
|
||||
ignored_paths.push(home.join("Public"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
ignored_paths.push(home.join(".cache"));
|
||||
ignored_paths.push(home.join(".config"));
|
||||
ignored_paths.push(home.join(".local"));
|
||||
ignored_paths.push(home.join(".thumbnails"));
|
||||
ignored_paths.push(home.join("Downloads"));
|
||||
ignored_paths.push(home.join("Public"));
|
||||
ignored_paths.push(home.join("snap"));
|
||||
ignored_paths.push(home.join(".snap"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
ignored_paths.push(home.join("AppData"));
|
||||
ignored_paths.push(home.join("Downloads"));
|
||||
ignored_paths.push(home.join("Documents"));
|
||||
ignored_paths.push(home.join("Music"));
|
||||
ignored_paths.push(home.join("Pictures"));
|
||||
ignored_paths.push(home.join("Videos"));
|
||||
}
|
||||
|
||||
// Common paths to ignore for all platforms
|
||||
ignored_paths.push(home.join("node_modules"));
|
||||
ignored_paths.push(home.join("venv"));
|
||||
ignored_paths.push(PathBuf::from("/tmp"));
|
||||
}
|
||||
|
||||
ignored_paths
|
||||
}
|
||||
#[allow(clippy::unused_async)]
|
||||
async fn crawl_for_repos(starting_point: PathBuf, injector: Injector<String>) {
|
||||
let mut walker_overrides_builder = OverrideBuilder::new(&starting_point);
|
||||
walker_overrides_builder.add(".git").unwrap();
|
||||
let walker = walk_builder(
|
||||
&starting_point,
|
||||
get_default_num_threads(),
|
||||
Some(walker_overrides_builder.build().unwrap()),
|
||||
Some(get_ignored_paths()),
|
||||
)
|
||||
.build_parallel();
|
||||
|
||||
walker.run(|| {
|
||||
let injector = injector.clone();
|
||||
Box::new(move |result| {
|
||||
if let Ok(entry) = result {
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
// if the entry is a .git directory, add its parent to the list of git repos
|
||||
if entry.path().ends_with(".git") {
|
||||
let parent_path =
|
||||
&entry.path().parent().unwrap().to_string_lossy();
|
||||
debug!("Found git repo: {:?}", parent_path);
|
||||
let () = injector.push(
|
||||
parent_path.to_string(),
|
||||
|e, cols| {
|
||||
cols[0] = e.clone().into();
|
||||
},
|
||||
);
|
||||
return ignore::WalkState::Skip;
|
||||
}
|
||||
}
|
||||
}
|
||||
ignore::WalkState::Continue
|
||||
})
|
||||
});
|
||||
}
|
@ -1,311 +1,5 @@
|
||||
use crate::channels::entry::Entry;
|
||||
use anyhow::Result;
|
||||
use rustc_hash::FxHashSet;
|
||||
use television_derive::{Broadcast, ToCliChannel, ToUnitChannel};
|
||||
|
||||
pub mod alias;
|
||||
pub mod cable;
|
||||
pub mod dirs;
|
||||
pub mod entry;
|
||||
pub mod env;
|
||||
pub mod files;
|
||||
pub mod git_repos;
|
||||
pub mod preview;
|
||||
pub mod prototypes;
|
||||
pub mod remote_control;
|
||||
pub mod stdin;
|
||||
pub mod text;
|
||||
|
||||
/// The interface that all television channels must implement.
|
||||
///
|
||||
/// # Note
|
||||
/// The `OnAir` trait requires the `Send` trait to be implemented as well.
|
||||
/// This is necessary to allow the channels to be used with the tokio
|
||||
/// runtime, which requires `Send` in order to be able to send tasks between
|
||||
/// worker threads safely.
|
||||
///
|
||||
/// # 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.
|
||||
/// ```ignore
|
||||
/// 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.
|
||||
/// ```ignore
|
||||
/// fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry>;
|
||||
/// ```
|
||||
/// - `get_result`: Get a specific result by its index.
|
||||
/// ```ignore
|
||||
/// fn get_result(&self, index: u32) -> Option<Entry>;
|
||||
/// ```
|
||||
/// - `result_count`: Get the number of results currently available.
|
||||
/// ```ignore
|
||||
/// fn result_count(&self) -> u32;
|
||||
/// ```
|
||||
/// - `total_count`: Get the total number of entries currently available (e.g.
|
||||
/// the haystack).
|
||||
/// ```ignore
|
||||
/// fn total_count(&self) -> u32;
|
||||
/// ```
|
||||
///
|
||||
pub trait OnAir: 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 currently selected entries.
|
||||
fn selected_entries(&self) -> &FxHashSet<Entry>;
|
||||
|
||||
/// Toggles selection for the entry under the cursor.
|
||||
fn toggle_selection(&mut self, entry: &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;
|
||||
|
||||
/// Check if the channel is currently running.
|
||||
fn running(&self) -> bool;
|
||||
|
||||
/// Turn off
|
||||
fn shutdown(&self);
|
||||
|
||||
/// Whether this channel supports previewing entries.
|
||||
fn supports_preview(&self) -> bool;
|
||||
}
|
||||
|
||||
/// The available television channels.
|
||||
///
|
||||
/// Each channel is represented by a variant of the enum and should implement
|
||||
/// the `OnAir` trait.
|
||||
///
|
||||
/// # Important
|
||||
/// When adding a new channel, make sure to add a new variant to this enum and
|
||||
/// implement the `OnAir` trait for it.
|
||||
///
|
||||
/// # Derive
|
||||
/// ## `CliChannel`
|
||||
/// 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.
|
||||
///
|
||||
/// ## `Broadcast`
|
||||
/// The `Broadcast` derive macro generates the necessary glue code to
|
||||
/// automatically forward method calls to the corresponding channel variant.
|
||||
/// This allows to use the `OnAir` trait methods directly on the `TelevisionChannel`
|
||||
/// enum. In a more straightforward way, it implements the `OnAir` trait for the
|
||||
/// `TelevisionChannel` enum.
|
||||
///
|
||||
/// ## `UnitChannel`
|
||||
/// This macro generates an enum with unit variants that can be used instead
|
||||
/// of carrying the actual channel instances around. It also generates the necessary
|
||||
/// glue code to automatically create a channel instance from the selected enum variant.
|
||||
#[allow(dead_code, clippy::module_name_repetitions)]
|
||||
#[derive(ToUnitChannel, ToCliChannel, Broadcast)]
|
||||
pub enum TelevisionChannel {
|
||||
/// The environment variables channel.
|
||||
///
|
||||
/// This channel allows to search through environment variables.
|
||||
Env(env::Channel),
|
||||
/// The files channel.
|
||||
///
|
||||
/// This channel allows to search through files.
|
||||
Files(files::Channel),
|
||||
/// The git repositories channel.
|
||||
///
|
||||
/// This channel allows to search through git repositories.
|
||||
GitRepos(git_repos::Channel),
|
||||
/// The dirs channel.
|
||||
///
|
||||
/// This channel allows to search through directories.
|
||||
Dirs(dirs::Channel),
|
||||
/// The text channel.
|
||||
///
|
||||
/// This channel allows to search through the contents of text files.
|
||||
Text(text::Channel),
|
||||
/// The standard input channel.
|
||||
///
|
||||
/// This channel allows to search through whatever is passed through stdin.
|
||||
#[exclude_from_cli]
|
||||
Stdin(stdin::Channel),
|
||||
/// The alias channel.
|
||||
///
|
||||
/// This channel allows to search through aliases.
|
||||
Alias(alias::Channel),
|
||||
/// The remote control channel.
|
||||
///
|
||||
/// This channel allows to switch between different channels.
|
||||
#[exclude_from_unit]
|
||||
#[exclude_from_cli]
|
||||
RemoteControl(remote_control::RemoteControl),
|
||||
/// A custom channel.
|
||||
///
|
||||
/// This channel allows to search through custom data.
|
||||
#[exclude_from_cli]
|
||||
Cable(cable::Channel),
|
||||
}
|
||||
|
||||
impl From<&Entry> for TelevisionChannel {
|
||||
fn from(entry: &Entry) -> Self {
|
||||
UnitChannel::try_from(entry.name.as_str()).unwrap().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl TelevisionChannel {
|
||||
pub fn zap(&self, channel_name: &str) -> Result<TelevisionChannel> {
|
||||
match self {
|
||||
TelevisionChannel::RemoteControl(remote_control) => {
|
||||
remote_control.zap(channel_name)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> String {
|
||||
match self {
|
||||
TelevisionChannel::Cable(channel) => channel.name.clone(),
|
||||
TelevisionChannel::Stdin(_) => String::from("Stdin"),
|
||||
_ => UnitChannel::from(self).to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! variant_to_module {
|
||||
(Files) => {
|
||||
files::Channel
|
||||
};
|
||||
(Text) => {
|
||||
text::Channel
|
||||
};
|
||||
(Dirs) => {
|
||||
dirs::Channel
|
||||
};
|
||||
(GitRepos) => {
|
||||
git_repos::Channel
|
||||
};
|
||||
(Env) => {
|
||||
env::Channel
|
||||
};
|
||||
(Stdin) => {
|
||||
stdin::Channel
|
||||
};
|
||||
(Alias) => {
|
||||
alias::Channel
|
||||
};
|
||||
(RemoteControl) => {
|
||||
remote_control::RemoteControl
|
||||
};
|
||||
}
|
||||
|
||||
/// A macro that generates two methods for the `TelevisionChannel` enum based on
|
||||
/// the transitions defined in the macro call.
|
||||
///
|
||||
/// The first method `available_transitions` returns a list of possible transitions
|
||||
/// from the current channel.
|
||||
///
|
||||
/// The second method `transition_to` transitions from the current channel to the
|
||||
/// target channel.
|
||||
///
|
||||
/// # Example
|
||||
/// The following example defines transitions from the `Files` channel to the `Text`
|
||||
/// channel and from the `GitRepos` channel to the `Files` and `Text` channels.
|
||||
/// ```ignore
|
||||
/// define_transitions! {
|
||||
/// // The `Files` channel can transition to the `Text` channel.
|
||||
/// Files => [Text],
|
||||
/// // The `GitRepos` channel can transition to the `Files` and `Text` channels.
|
||||
/// GitRepos => [Files, Text],
|
||||
/// }
|
||||
/// ```
|
||||
/// This will generate the following methods for the `TelevisionChannel` enum:
|
||||
/// ```ignore
|
||||
/// impl TelevisionChannel {
|
||||
/// pub fn available_transitions(&self) -> Vec<UnitChannel> {
|
||||
/// match self {
|
||||
/// TelevisionChannel::Files(_) => vec![UnitChannel::Text],
|
||||
/// TelevisionChannel::GitRepos(_) => vec![UnitChannel::Files, UnitChannel::Text],
|
||||
/// _ => Vec::new(),
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// pub fn transition_to(self, target: UnitChannel) -> TelevisionChannel {
|
||||
/// match (self, target) {
|
||||
/// (tv_channel @ TelevisionChannel::Files(_), UnitChannel::Text) => {
|
||||
/// TelevisionChannel::Text(text::Channel::from(tv_channel))
|
||||
/// },
|
||||
/// (tv_channel @ TelevisionChannel::GitRepos(_), UnitChannel::Files) => {
|
||||
/// TelevisionChannel::Files(files::Channel::from(tv_channel))
|
||||
/// },
|
||||
/// (tv_channel @ TelevisionChannel::GitRepos(_), UnitChannel::Text) => {
|
||||
/// TelevisionChannel::Text(text::Channel::from(tv_channel))
|
||||
/// },
|
||||
/// _ => unreachable!(),
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
///
|
||||
macro_rules! define_transitions {
|
||||
(
|
||||
$(
|
||||
$from_variant:ident => [ $($to_variant:ident),* $(,)? ],
|
||||
)*
|
||||
) => {
|
||||
impl TelevisionChannel {
|
||||
pub fn available_transitions(&self) -> Vec<UnitChannel> {
|
||||
match self {
|
||||
$(
|
||||
TelevisionChannel::$from_variant(_) => vec![
|
||||
$( UnitChannel::$to_variant ),*
|
||||
],
|
||||
)*
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transition_to(&mut self, target: UnitChannel) -> TelevisionChannel {
|
||||
match (self, target) {
|
||||
$(
|
||||
$(
|
||||
(tv_channel @ TelevisionChannel::$from_variant(_), UnitChannel::$to_variant) => {
|
||||
TelevisionChannel::$to_variant(
|
||||
<variant_to_module!($to_variant)>::from(tv_channel)
|
||||
)
|
||||
},
|
||||
)*
|
||||
)*
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Define the transitions between the different channels.
|
||||
//
|
||||
// This is where the transitions between the different channels are defined.
|
||||
// The transitions are defined as a list of tuples where the first element
|
||||
// is the source channel and the second element is a list of potential target channels.
|
||||
define_transitions! {
|
||||
Text => [Files, Text],
|
||||
Files => [Files, Text],
|
||||
Dirs => [Files, Text, Dirs],
|
||||
GitRepos => [Files, Text, Dirs],
|
||||
}
|
||||
|
125
television/channels/preview.rs
Normal file
125
television/channels/preview.rs
Normal file
@ -0,0 +1,125 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{channels::entry::Entry, utils::strings::format_string};
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Hash, Default, Deserialize)]
|
||||
pub struct PreviewCommand {
|
||||
pub command: String,
|
||||
#[serde(default = "default_delimiter")]
|
||||
pub delimiter: String,
|
||||
#[serde(rename = "offset")]
|
||||
pub offset_expr: Option<String>,
|
||||
}
|
||||
|
||||
pub const DEFAULT_DELIMITER: &str = " ";
|
||||
|
||||
/// The default delimiter to use for the preview command to use to split
|
||||
/// entries into multiple referenceable parts.
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn default_delimiter() -> String {
|
||||
DEFAULT_DELIMITER.to_string()
|
||||
}
|
||||
|
||||
impl PreviewCommand {
|
||||
pub fn new(
|
||||
command: &str,
|
||||
delimiter: &str,
|
||||
offset_expr: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
command: command.to_string(),
|
||||
delimiter: delimiter.to_string(),
|
||||
offset_expr,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format the command with the entry name and provided placeholders.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use television::channels::{preview::PreviewCommand, entry::Entry};
|
||||
///
|
||||
/// let command = PreviewCommand {
|
||||
/// command: "something {} {2} {0}".to_string(),
|
||||
/// delimiter: ":".to_string(),
|
||||
/// offset_expr: None,
|
||||
/// };
|
||||
/// let entry = Entry::new("a:given:entry:to:preview".to_string());
|
||||
///
|
||||
/// let formatted_command = command.format_with(&entry);
|
||||
///
|
||||
/// assert_eq!(formatted_command, "something 'a:given:entry:to:preview' 'entry' 'a'");
|
||||
/// ```
|
||||
pub fn format_with(&self, entry: &Entry) -> String {
|
||||
format_string(&self.command, &entry.name, &self.delimiter)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for PreviewCommand {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{self:?}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::channels::entry::Entry;
|
||||
|
||||
#[test]
|
||||
fn test_format_command() {
|
||||
let command = PreviewCommand {
|
||||
command: "something {} {2} {0}".to_string(),
|
||||
delimiter: ":".to_string(),
|
||||
offset_expr: None,
|
||||
};
|
||||
let entry = Entry::new("an:entry:to:preview".to_string());
|
||||
let formatted_command = command.format_with(&entry);
|
||||
|
||||
assert_eq!(
|
||||
formatted_command,
|
||||
"something 'an:entry:to:preview' 'to' 'an'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_command_no_placeholders() {
|
||||
let command = PreviewCommand {
|
||||
command: "something".to_string(),
|
||||
delimiter: ":".to_string(),
|
||||
offset_expr: None,
|
||||
};
|
||||
let entry = Entry::new("an:entry:to:preview".to_string());
|
||||
let formatted_command = command.format_with(&entry);
|
||||
|
||||
assert_eq!(formatted_command, "something");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_command_with_global_placeholder_only() {
|
||||
let command = PreviewCommand {
|
||||
command: "something {}".to_string(),
|
||||
delimiter: ":".to_string(),
|
||||
offset_expr: None,
|
||||
};
|
||||
let entry = Entry::new("an:entry:to:preview".to_string());
|
||||
let formatted_command = command.format_with(&entry);
|
||||
|
||||
assert_eq!(formatted_command, "something 'an:entry:to:preview'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_command_with_positional_placeholders_only() {
|
||||
let command = PreviewCommand {
|
||||
command: "something {0} -t {2}".to_string(),
|
||||
delimiter: ":".to_string(),
|
||||
offset_expr: None,
|
||||
};
|
||||
let entry = Entry::new("an:entry:to:preview".to_string());
|
||||
let formatted_command = command.format_with(&entry);
|
||||
|
||||
assert_eq!(formatted_command, "something 'an' -t 'to'");
|
||||
}
|
||||
}
|
149
television/channels/prototypes.rs
Normal file
149
television/channels/prototypes.rs
Normal file
@ -0,0 +1,149 @@
|
||||
use rustc_hash::FxHashMap;
|
||||
use std::{
|
||||
fmt::{self, Display, Formatter},
|
||||
ops::Deref,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
cable::CableSpec, channels::preview::PreviewCommand,
|
||||
cli::unknown_channel_exit,
|
||||
};
|
||||
|
||||
/// A prototype for cable channels.
|
||||
///
|
||||
/// This can be seen as a cable channel specification, which is used to
|
||||
/// create a cable channel.
|
||||
///
|
||||
/// The prototype contains the following fields:
|
||||
/// - `name`: The name of the channel. This will be used to identify the
|
||||
/// channel throughout the application and in UI menus.
|
||||
/// - `source_command`: The command to run to get the source for the channel.
|
||||
/// This is a shell command that will be run in the background.
|
||||
/// - `interactive`: Whether the source command should be run in an interactive
|
||||
/// shell. This is useful for commands that need the user's environment e.g.
|
||||
/// `alias`.
|
||||
/// - `preview_command`: The command to run on each entry to get the preview
|
||||
/// for the channel. If this is not `None`, the channel will display a preview
|
||||
/// pane with the output of this command.
|
||||
/// - `preview_delimiter`: The delimiter to use to split an entry into
|
||||
/// multiple parts that can then be referenced in the preview command (e.g.
|
||||
/// `{1} + {2}`).
|
||||
/// - `preview_offset`: a litteral expression that will be interpreted later on
|
||||
/// in order to determine the vertical offset at which the preview should be
|
||||
/// displayed.
|
||||
///
|
||||
/// # Example
|
||||
/// The default files channel might look something like this:
|
||||
/// ```toml
|
||||
/// [[cable_channel]]
|
||||
/// name = "files"
|
||||
/// source_command = "fd -t f"
|
||||
/// preview_command = "cat {}"
|
||||
/// ```
|
||||
#[derive(Clone, Debug, serde::Deserialize, PartialEq)]
|
||||
pub struct ChannelPrototype {
|
||||
pub name: String,
|
||||
pub source_command: String,
|
||||
#[serde(default)]
|
||||
pub interactive: bool,
|
||||
#[serde(rename = "preview")]
|
||||
pub preview_command: Option<PreviewCommand>,
|
||||
}
|
||||
|
||||
const STDIN_CHANNEL_NAME: &str = "stdin";
|
||||
const STDIN_SOURCE_COMMAND: &str = "cat";
|
||||
|
||||
impl ChannelPrototype {
|
||||
pub fn new(
|
||||
name: &str,
|
||||
source_command: &str,
|
||||
interactive: bool,
|
||||
preview_command: Option<PreviewCommand>,
|
||||
) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
source_command: source_command.to_string(),
|
||||
interactive,
|
||||
preview_command,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stdin(preview: Option<PreviewCommand>) -> Self {
|
||||
Self {
|
||||
name: STDIN_CHANNEL_NAME.to_string(),
|
||||
source_command: STDIN_SOURCE_COMMAND.to_string(),
|
||||
interactive: false,
|
||||
preview_command: preview,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_preview(self, preview_command: Option<PreviewCommand>) -> Self {
|
||||
Self::new(
|
||||
&self.name,
|
||||
&self.source_command,
|
||||
self.interactive,
|
||||
preview_command,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub const DEFAULT_PROTOTYPE_NAME: &str = "files";
|
||||
|
||||
impl Display for ChannelPrototype {
|
||||
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
|
||||
write!(f, "{}", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A neat `HashMap` of channel prototypes indexed by their name.
|
||||
///
|
||||
/// This is used to store cable channel prototypes throughout the application
|
||||
/// in a way that facilitates answering questions like "what's the prototype
|
||||
/// for `files`?" or "does this channel exist?".
|
||||
#[derive(Debug, serde::Deserialize, Clone)]
|
||||
pub struct Cable(pub FxHashMap<String, ChannelPrototype>);
|
||||
|
||||
impl Deref for Cable {
|
||||
type Target = FxHashMap<String, ChannelPrototype>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Cable {
|
||||
pub fn get_channel(&self, name: &str) -> ChannelPrototype {
|
||||
self.get(name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| unknown_channel_exit(name))
|
||||
}
|
||||
|
||||
pub fn has_channel(&self, name: &str) -> bool {
|
||||
self.contains_key(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A default cable channels specification that is compiled into the
|
||||
/// application.
|
||||
#[cfg(unix)]
|
||||
const DEFAULT_CABLE_CHANNELS_FILE: &str =
|
||||
include_str!("../../cable/unix-channels.toml");
|
||||
/// A default cable channels specification that is compiled into the
|
||||
/// application.
|
||||
#[cfg(not(unix))]
|
||||
const DEFAULT_CABLE_CHANNELS_FILE: &str =
|
||||
include_str!("../../cable/windows-channels.toml");
|
||||
|
||||
impl Default for Cable {
|
||||
/// Fallback to the default cable channels specification (the template file
|
||||
/// included in the repo).
|
||||
fn default() -> Self {
|
||||
let s = toml::from_str::<CableSpec>(DEFAULT_CABLE_CHANNELS_FILE)
|
||||
.expect("Unable to parse default cable channels");
|
||||
let mut prototypes = FxHashMap::default();
|
||||
for prototype in s.prototypes {
|
||||
prototypes.insert(prototype.name.clone(), prototype);
|
||||
}
|
||||
Cable(prototypes)
|
||||
}
|
||||
}
|
@ -1,127 +1,56 @@
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Display;
|
||||
|
||||
use crate::channels::cable::{CableChannelPrototype, CableChannels};
|
||||
use crate::channels::entry::{Entry, PreviewType};
|
||||
use crate::channels::{CliTvChannel, OnAir, TelevisionChannel, UnitChannel};
|
||||
use crate::matcher::{config::Config, Matcher};
|
||||
use crate::{
|
||||
channels::{
|
||||
entry::Entry,
|
||||
prototypes::{Cable, ChannelPrototype},
|
||||
},
|
||||
matcher::{Matcher, config::Config},
|
||||
};
|
||||
use anyhow::Result;
|
||||
use clap::ValueEnum;
|
||||
use devicons::FileIcon;
|
||||
use rustc_hash::{FxBuildHasher, FxHashSet};
|
||||
|
||||
use super::cable;
|
||||
|
||||
pub struct RemoteControl {
|
||||
matcher: Matcher<RCButton>,
|
||||
cable_channels: Option<CableChannels>,
|
||||
selected_entries: FxHashSet<Entry>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum RCButton {
|
||||
Channel(UnitChannel),
|
||||
CableChannel(CableChannelPrototype),
|
||||
}
|
||||
|
||||
impl Display for RCButton {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RCButton::Channel(channel) => write!(f, "{channel}"),
|
||||
RCButton::CableChannel(prototype) => write!(f, "{prototype}"),
|
||||
}
|
||||
}
|
||||
matcher: Matcher<String>,
|
||||
cable_channels: Option<Cable>,
|
||||
}
|
||||
|
||||
const NUM_THREADS: usize = 1;
|
||||
|
||||
impl RemoteControl {
|
||||
pub fn new(
|
||||
builtin_channels: Vec<UnitChannel>,
|
||||
cable_channels: Option<CableChannels>,
|
||||
) -> Self {
|
||||
pub fn new(cable_channels: Option<Cable>) -> Self {
|
||||
let matcher = Matcher::new(Config::default().n_threads(NUM_THREADS));
|
||||
let injector = matcher.injector();
|
||||
let buttons =
|
||||
builtin_channels.into_iter().map(RCButton::Channel).chain(
|
||||
cable_channels
|
||||
.as_ref()
|
||||
.map(|channels| {
|
||||
channels.iter().map(|(_, prototype)| {
|
||||
RCButton::CableChannel(prototype.clone())
|
||||
})
|
||||
})
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
);
|
||||
for button in buttons {
|
||||
let () = injector.push(button.clone(), |e, cols| {
|
||||
for c in cable_channels.as_ref().unwrap_or(&Cable::default()).keys() {
|
||||
let () = injector.push(c.clone(), |e, cols| {
|
||||
cols[0] = e.to_string().into();
|
||||
});
|
||||
}
|
||||
RemoteControl {
|
||||
matcher,
|
||||
cable_channels,
|
||||
selected_entries: HashSet::with_hasher(FxBuildHasher),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_transitions_from(
|
||||
television_channel: &TelevisionChannel,
|
||||
) -> Self {
|
||||
Self::new(television_channel.available_transitions(), None)
|
||||
}
|
||||
|
||||
pub fn zap(&self, channel_name: &str) -> Result<TelevisionChannel> {
|
||||
pub fn zap(&self, channel_name: &str) -> Result<ChannelPrototype> {
|
||||
match self
|
||||
.cable_channels
|
||||
.as_ref()
|
||||
.and_then(|channels| channels.get(channel_name).cloned())
|
||||
{
|
||||
Some(prototype) => {
|
||||
Ok(TelevisionChannel::Cable(cable::Channel::from(prototype)))
|
||||
}
|
||||
None => match UnitChannel::try_from(channel_name) {
|
||||
Ok(channel) => Ok(channel.into()),
|
||||
Err(_) => Err(anyhow::anyhow!(
|
||||
Some(prototype) => Ok(prototype),
|
||||
None => Err(anyhow::anyhow!(
|
||||
"No channel or cable channel prototype found for {}",
|
||||
channel_name
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RemoteControl {
|
||||
fn default() -> Self {
|
||||
Self::new(
|
||||
CliTvChannel::value_variants()
|
||||
.iter()
|
||||
.flat_map(|v| UnitChannel::try_from(v.to_string().as_str()))
|
||||
.collect(),
|
||||
None,
|
||||
)
|
||||
Self::new(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_builtin_channels(
|
||||
filter_out_cable_names: Option<&[&String]>,
|
||||
) -> Vec<UnitChannel> {
|
||||
let mut value_variants = CliTvChannel::value_variants()
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(f) = filter_out_cable_names {
|
||||
value_variants.retain(|v| !f.iter().any(|c| *c == v));
|
||||
}
|
||||
|
||||
value_variants
|
||||
.iter()
|
||||
.flat_map(|v| UnitChannel::try_from(v.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
const TV_ICON: FileIcon = FileIcon {
|
||||
icon: '📺',
|
||||
color: "#000000",
|
||||
@ -132,57 +61,47 @@ const CABLE_ICON: FileIcon = FileIcon {
|
||||
color: "#000000",
|
||||
};
|
||||
|
||||
impl OnAir for RemoteControl {
|
||||
fn find(&mut self, pattern: &str) {
|
||||
impl RemoteControl {
|
||||
pub fn find(&mut self, pattern: &str) {
|
||||
self.matcher.find(pattern);
|
||||
}
|
||||
|
||||
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
|
||||
pub fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
|
||||
self.matcher.tick();
|
||||
self.matcher
|
||||
.results(num_entries, offset)
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let path = item.matched_string;
|
||||
Entry::new(path, PreviewType::Basic)
|
||||
Entry::new(path)
|
||||
.with_name_match_indices(&item.match_indices)
|
||||
.with_icon(match item.inner {
|
||||
RCButton::Channel(_) => TV_ICON,
|
||||
RCButton::CableChannel(_) => CABLE_ICON,
|
||||
})
|
||||
.with_icon(CABLE_ICON)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_result(&self, index: u32) -> Option<Entry> {
|
||||
pub fn get_result(&self, index: u32) -> Option<Entry> {
|
||||
self.matcher.get_result(index).map(|item| {
|
||||
let path = item.matched_string;
|
||||
Entry::new(path, PreviewType::Basic).with_icon(TV_ICON)
|
||||
Entry::new(path).with_icon(TV_ICON)
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_entries(&self) -> &FxHashSet<Entry> {
|
||||
&self.selected_entries
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
fn toggle_selection(&mut self, entry: &Entry) {}
|
||||
|
||||
fn result_count(&self) -> u32 {
|
||||
pub fn result_count(&self) -> u32 {
|
||||
self.matcher.matched_item_count
|
||||
}
|
||||
|
||||
fn total_count(&self) -> u32 {
|
||||
pub fn total_count(&self) -> u32 {
|
||||
self.matcher.total_item_count
|
||||
}
|
||||
|
||||
fn running(&self) -> bool {
|
||||
pub fn running(&self) -> bool {
|
||||
self.matcher.status.running
|
||||
}
|
||||
|
||||
fn shutdown(&self) {}
|
||||
pub fn shutdown(&self) {}
|
||||
|
||||
fn supports_preview(&self) -> bool {
|
||||
pub fn supports_preview(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
@ -1,156 +0,0 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
io::{stdin, BufRead},
|
||||
thread::spawn,
|
||||
};
|
||||
|
||||
use rustc_hash::{FxBuildHasher, FxHashSet};
|
||||
use tracing::debug;
|
||||
|
||||
use super::OnAir;
|
||||
use crate::channels::entry::{Entry, PreviewType};
|
||||
use crate::matcher::{config::Config, injector::Injector, Matcher};
|
||||
|
||||
pub struct Channel {
|
||||
matcher: Matcher<String>,
|
||||
preview_type: PreviewType,
|
||||
selected_entries: FxHashSet<Entry>,
|
||||
instream_handle: std::thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub fn new(preview_type: PreviewType) -> Self {
|
||||
let matcher = Matcher::new(Config::default());
|
||||
let injector = matcher.injector();
|
||||
|
||||
let instream_handle = spawn(move || stream_from_stdin(&injector));
|
||||
|
||||
Self {
|
||||
matcher,
|
||||
preview_type,
|
||||
selected_entries: HashSet::with_hasher(FxBuildHasher),
|
||||
instream_handle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Channel {
|
||||
fn default() -> Self {
|
||||
Self::new(PreviewType::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<E> for Channel
|
||||
where
|
||||
E: AsRef<Vec<String>>,
|
||||
{
|
||||
fn from(entries: E) -> Self {
|
||||
let matcher = Matcher::new(Config::default());
|
||||
let injector = matcher.injector();
|
||||
|
||||
let entries = entries.as_ref().clone();
|
||||
|
||||
let instream_handle = spawn(move || {
|
||||
for entry in entries {
|
||||
injector.push(entry.clone(), |e, cols| {
|
||||
cols[0] = e.to_string().into();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
matcher,
|
||||
preview_type: PreviewType::default(),
|
||||
selected_entries: HashSet::with_hasher(FxBuildHasher),
|
||||
instream_handle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
fn stream_from_stdin(injector: &Injector<String>) {
|
||||
let mut stdin = stdin().lock();
|
||||
let mut buffer = String::new();
|
||||
|
||||
let instant = std::time::Instant::now();
|
||||
loop {
|
||||
match stdin.read_line(&mut buffer) {
|
||||
Ok(c) if c > 0 => {
|
||||
let trimmed = buffer.trim();
|
||||
if !trimmed.is_empty() {
|
||||
injector.push(trimmed.to_owned(), |e, cols| {
|
||||
cols[0] = e.clone().into();
|
||||
});
|
||||
}
|
||||
buffer.clear();
|
||||
}
|
||||
Ok(0) => {
|
||||
debug!("EOF");
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
debug!("Error reading from stdin");
|
||||
if instant.elapsed() > TIMEOUT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OnAir for Channel {
|
||||
fn find(&mut self, pattern: &str) {
|
||||
self.matcher.find(pattern);
|
||||
}
|
||||
|
||||
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
|
||||
self.matcher.tick();
|
||||
self.matcher
|
||||
.results(num_entries, offset)
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
// NOTE: we're passing `PreviewType::Basic` here just as a placeholder
|
||||
// to avoid storing the preview command multiple times for each item.
|
||||
Entry::new(item.matched_string, PreviewType::Basic)
|
||||
.with_name_match_indices(&item.match_indices)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_result(&self, index: u32) -> Option<Entry> {
|
||||
self.matcher.get_result(index).map(|item| {
|
||||
Entry::new(item.matched_string, self.preview_type.clone())
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_entries(&self) -> &FxHashSet<Entry> {
|
||||
&self.selected_entries
|
||||
}
|
||||
|
||||
fn toggle_selection(&mut self, entry: &Entry) {
|
||||
if self.selected_entries.contains(entry) {
|
||||
self.selected_entries.remove(entry);
|
||||
} else {
|
||||
self.selected_entries.insert(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn result_count(&self) -> u32 {
|
||||
self.matcher.matched_item_count
|
||||
}
|
||||
|
||||
fn total_count(&self) -> u32 {
|
||||
self.matcher.total_item_count
|
||||
}
|
||||
|
||||
fn running(&self) -> bool {
|
||||
self.matcher.status.running || !self.instream_handle.is_finished()
|
||||
}
|
||||
|
||||
fn shutdown(&self) {}
|
||||
|
||||
fn supports_preview(&self) -> bool {
|
||||
self.preview_type != PreviewType::None
|
||||
}
|
||||
}
|
@ -1,387 +0,0 @@
|
||||
use super::{OnAir, TelevisionChannel};
|
||||
use crate::channels::entry::{Entry, PreviewType};
|
||||
use crate::matcher::{config::Config, injector::Injector, Matcher};
|
||||
use crate::utils::files::{get_default_num_threads, walk_builder};
|
||||
use crate::utils::strings::{
|
||||
proportion_of_printable_ascii_characters, PRINTABLE_ASCII_THRESHOLD,
|
||||
};
|
||||
use devicons::FileIcon;
|
||||
use ignore::WalkState;
|
||||
use rustc_hash::{FxBuildHasher, FxHashSet};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fs::File,
|
||||
io::{BufRead, Read, Seek},
|
||||
path::{Path, PathBuf},
|
||||
sync::{atomic::AtomicUsize, Arc},
|
||||
};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
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: Matcher<CandidateLine>,
|
||||
crawl_handle: tokio::task::JoinHandle<()>,
|
||||
selected_entries: FxHashSet<Entry>,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub fn new(directories: Vec<PathBuf>) -> Self {
|
||||
let matcher = Matcher::new(Config::default());
|
||||
// start loading files in the background
|
||||
let crawl_handle = tokio::spawn(crawl_for_candidates(
|
||||
directories,
|
||||
matcher.injector(),
|
||||
));
|
||||
Channel {
|
||||
matcher,
|
||||
crawl_handle,
|
||||
selected_entries: HashSet::with_hasher(FxBuildHasher),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_file_paths(file_paths: Vec<PathBuf>) -> Self {
|
||||
let matcher = Matcher::new(Config::default());
|
||||
let injector = matcher.injector();
|
||||
let current_dir = std::env::current_dir().unwrap();
|
||||
let crawl_handle = tokio::spawn(async move {
|
||||
let mut lines_in_mem = 0;
|
||||
for path in file_paths {
|
||||
if lines_in_mem > MAX_LINES_IN_MEM {
|
||||
break;
|
||||
}
|
||||
if let Some(injected_lines) =
|
||||
try_inject_lines(&injector, ¤t_dir, &path)
|
||||
{
|
||||
lines_in_mem += injected_lines;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Channel {
|
||||
matcher,
|
||||
crawl_handle,
|
||||
selected_entries: HashSet::with_hasher(FxBuildHasher),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_text_entries(entries: Vec<Entry>) -> Self {
|
||||
let matcher = Matcher::new(Config::default());
|
||||
let injector = matcher.injector();
|
||||
let load_handle = tokio::spawn(async move {
|
||||
for entry in entries.into_iter().take(MAX_LINES_IN_MEM) {
|
||||
let v = entry.value.unwrap();
|
||||
injector.push(
|
||||
CandidateLine::new(
|
||||
entry.name.into(),
|
||||
v,
|
||||
entry.line_number.unwrap(),
|
||||
),
|
||||
|e, cols| {
|
||||
cols[0] = e.line.clone().into();
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Channel {
|
||||
matcher,
|
||||
crawl_handle: load_handle,
|
||||
selected_entries: HashSet::with_hasher(FxBuildHasher),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Channel {
|
||||
fn default() -> Self {
|
||||
Self::new(vec![std::env::current_dir().unwrap()])
|
||||
}
|
||||
}
|
||||
|
||||
/// Since we're limiting the number of lines in memory, it makes sense to also limit the number of files
|
||||
/// we're willing to search in when piping from the `Files` channel.
|
||||
/// This prevents blocking the UI for too long when piping from a channel with a lot of files.
|
||||
///
|
||||
/// This should be calculated based on the number of lines we're willing to keep in memory:
|
||||
/// `MAX_LINES_IN_MEM / 100` (assuming 100 lines per file on average).
|
||||
const MAX_PIPED_FILES: usize = MAX_LINES_IN_MEM / 200;
|
||||
|
||||
impl From<&mut TelevisionChannel> for Channel {
|
||||
fn from(value: &mut TelevisionChannel) -> Self {
|
||||
match value {
|
||||
c @ TelevisionChannel::Files(_) => {
|
||||
let entries = if c.selected_entries().is_empty() {
|
||||
c.results(
|
||||
c.result_count().min(
|
||||
u32::try_from(MAX_PIPED_FILES).unwrap_or(u32::MAX),
|
||||
),
|
||||
0,
|
||||
)
|
||||
} else {
|
||||
c.selected_entries().iter().cloned().collect()
|
||||
};
|
||||
Self::from_file_paths(
|
||||
entries
|
||||
.iter()
|
||||
.flat_map(|entry| {
|
||||
PathBuf::from(entry.name.clone()).canonicalize()
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
c @ TelevisionChannel::GitRepos(_) => {
|
||||
let entries = if c.selected_entries().is_empty() {
|
||||
c.results(c.result_count(), 0)
|
||||
} else {
|
||||
c.selected_entries().iter().cloned().collect()
|
||||
};
|
||||
Self::new(
|
||||
entries
|
||||
.iter()
|
||||
.flat_map(|entry| {
|
||||
PathBuf::from(entry.name.clone()).canonicalize()
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
c @ TelevisionChannel::Text(_) => {
|
||||
let entries = if c.selected_entries().is_empty() {
|
||||
c.results(c.result_count(), 0)
|
||||
} else {
|
||||
c.selected_entries().iter().cloned().collect()
|
||||
};
|
||||
Self::from_text_entries(entries)
|
||||
}
|
||||
c @ TelevisionChannel::Dirs(_) => {
|
||||
let entries = if c.selected_entries().is_empty() {
|
||||
c.results(c.result_count(), 0)
|
||||
} else {
|
||||
c.selected_entries().iter().cloned().collect()
|
||||
};
|
||||
Self::new(
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| PathBuf::from(&entry.name))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OnAir for Channel {
|
||||
fn find(&mut self, pattern: &str) {
|
||||
self.matcher.find(pattern);
|
||||
}
|
||||
|
||||
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
|
||||
self.matcher.tick();
|
||||
self.matcher
|
||||
.results(num_entries, offset)
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let line = item.matched_string;
|
||||
let display_path =
|
||||
item.inner.path.to_string_lossy().to_string();
|
||||
Entry::new(display_path, PreviewType::Files)
|
||||
.with_value(line)
|
||||
.with_value_match_indices(&item.match_indices)
|
||||
.with_icon(FileIcon::from(item.inner.path.as_path()))
|
||||
.with_line_number(item.inner.line_number)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_result(&self, index: u32) -> Option<Entry> {
|
||||
self.matcher.get_result(index).map(|item| {
|
||||
let display_path = item.inner.path.to_string_lossy().to_string();
|
||||
Entry::new(display_path, PreviewType::Files)
|
||||
.with_icon(FileIcon::from(item.inner.path.as_path()))
|
||||
.with_line_number(item.inner.line_number)
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_entries(&self) -> &FxHashSet<Entry> {
|
||||
&self.selected_entries
|
||||
}
|
||||
|
||||
fn toggle_selection(&mut self, entry: &Entry) {
|
||||
if self.selected_entries.contains(entry) {
|
||||
self.selected_entries.remove(entry);
|
||||
} else {
|
||||
self.selected_entries.insert(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn result_count(&self) -> u32 {
|
||||
self.matcher.matched_item_count
|
||||
}
|
||||
|
||||
fn total_count(&self) -> u32 {
|
||||
self.matcher.total_item_count
|
||||
}
|
||||
|
||||
fn running(&self) -> bool {
|
||||
self.matcher.status.running || !self.crawl_handle.is_finished()
|
||||
}
|
||||
|
||||
fn shutdown(&self) {
|
||||
self.crawl_handle.abort();
|
||||
}
|
||||
|
||||
fn supports_preview(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The maximum file size we're willing to search in.
|
||||
///
|
||||
/// This is to prevent taking humongous amounts of memory when searching in
|
||||
/// a lot of files (e.g. starting tv in $HOME).
|
||||
const MAX_FILE_SIZE: u64 = 4 * 1024 * 1024;
|
||||
|
||||
/// The maximum number of lines we're willing to keep in memory.
|
||||
///
|
||||
/// TODO: this should be configurable by the user depending on the amount of
|
||||
/// memory they have/are willing to use.
|
||||
///
|
||||
/// This is to prevent taking humongous amounts of memory when searching in
|
||||
/// a lot of files (e.g. starting tv in $HOME).
|
||||
///
|
||||
/// This is a soft limit, we might go over it a bit.
|
||||
///
|
||||
/// A typical line should take somewhere around 100 bytes in memory (for utf8 english text),
|
||||
/// so this should take around 100 x `10_000_000` = 1GB of memory.
|
||||
const MAX_LINES_IN_MEM: usize = 10_000_000;
|
||||
|
||||
#[allow(clippy::unused_async)]
|
||||
async fn crawl_for_candidates(
|
||||
directories: Vec<PathBuf>,
|
||||
injector: Injector<CandidateLine>,
|
||||
) {
|
||||
if directories.is_empty() {
|
||||
return;
|
||||
}
|
||||
let current_dir = std::env::current_dir().unwrap();
|
||||
let mut walker =
|
||||
walk_builder(&directories[0], get_default_num_threads(), None, None);
|
||||
directories[1..].iter().for_each(|path| {
|
||||
walker.add(path);
|
||||
});
|
||||
|
||||
let lines_in_mem = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
walker.build_parallel().run(|| {
|
||||
let injector = injector.clone();
|
||||
let current_dir = current_dir.clone();
|
||||
let lines_in_mem = lines_in_mem.clone();
|
||||
Box::new(move |result| {
|
||||
if lines_in_mem.load(std::sync::atomic::Ordering::Relaxed)
|
||||
> MAX_LINES_IN_MEM
|
||||
{
|
||||
return WalkState::Quit;
|
||||
}
|
||||
if let Ok(entry) = result {
|
||||
if entry.file_type().unwrap().is_file() {
|
||||
if let Ok(m) = entry.metadata() {
|
||||
if m.len() > MAX_FILE_SIZE {
|
||||
return WalkState::Continue;
|
||||
}
|
||||
}
|
||||
// try to inject the lines of the file
|
||||
if let Some(injected_lines) =
|
||||
try_inject_lines(&injector, ¤t_dir, entry.path())
|
||||
{
|
||||
lines_in_mem.fetch_add(
|
||||
injected_lines,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
WalkState::Continue
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn try_inject_lines(
|
||||
injector: &Injector<CandidateLine>,
|
||||
current_dir: &PathBuf,
|
||||
path: &Path,
|
||||
) -> Option<usize> {
|
||||
match File::open(path) {
|
||||
Ok(file) => {
|
||||
// is the file a text-based 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
|
||||
|| proportion_of_printable_ascii_characters(
|
||||
&buffer[..bytes_read],
|
||||
) < PRINTABLE_ASCII_THRESHOLD
|
||||
{
|
||||
debug!("Skipping non-text file {:?}", path);
|
||||
return None;
|
||||
}
|
||||
reader.seek(std::io::SeekFrom::Start(0)).unwrap();
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error reading file {:?}: {:?}", path, e);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// read the lines of the file
|
||||
let mut line_number = 0;
|
||||
let mut injected_lines = 0;
|
||||
for maybe_line in reader.lines() {
|
||||
match maybe_line {
|
||||
Ok(l) => {
|
||||
line_number += 1;
|
||||
if l.is_empty() {
|
||||
trace!("Empty line");
|
||||
continue;
|
||||
}
|
||||
let candidate = CandidateLine::new(
|
||||
path.strip_prefix(current_dir)
|
||||
.unwrap_or(path)
|
||||
.to_path_buf(),
|
||||
l.clone(),
|
||||
line_number,
|
||||
);
|
||||
let () = injector.push(candidate, |e, cols| {
|
||||
cols[0] = e.line.clone().into();
|
||||
});
|
||||
injected_lines += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error reading line: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(injected_lines)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error opening file {:?}: {:?}", path, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
@ -9,20 +9,14 @@ pub struct Cli {
|
||||
/// A list of the available channels can be displayed using the
|
||||
/// `list-channels` command. The channel can also be changed from within
|
||||
/// the application.
|
||||
#[arg(
|
||||
value_enum,
|
||||
default_value = "files",
|
||||
index = 1,
|
||||
verbatim_doc_comment
|
||||
)]
|
||||
pub channel: String,
|
||||
#[arg(value_enum, index = 1, verbatim_doc_comment)]
|
||||
pub channel: Option<String>,
|
||||
|
||||
/// A preview command to use with the stdin channel.
|
||||
/// A preview command to use with the current channel.
|
||||
///
|
||||
/// If provided, the preview command will be executed and formatted using
|
||||
/// the entry.
|
||||
/// Example: "bat -n --color=always {}" (where {} will be replaced with
|
||||
/// the entry)
|
||||
/// Example: "cat {}" (where {} will be replaced with the entry)
|
||||
///
|
||||
/// Parts of the entry can be extracted positionally using the `delimiter`
|
||||
/// option.
|
||||
@ -31,6 +25,14 @@ pub struct Cli {
|
||||
#[arg(short, long, value_name = "STRING", verbatim_doc_comment)]
|
||||
pub preview: Option<String>,
|
||||
|
||||
/// A preview line number offset template to use to scroll the preview to for each
|
||||
/// entry.
|
||||
///
|
||||
/// This template uses the same syntax as the `preview` option and will be formatted
|
||||
/// using the currently selected entry.
|
||||
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
|
||||
pub preview_offset: Option<String>,
|
||||
|
||||
/// Disable the preview panel entirely on startup.
|
||||
#[arg(long, default_value = "false", verbatim_doc_comment)]
|
||||
pub no_preview: bool,
|
||||
@ -139,6 +141,18 @@ pub struct Cli {
|
||||
#[arg(long, default_value = "false", verbatim_doc_comment)]
|
||||
pub no_help: bool,
|
||||
|
||||
/// Change the display size in relation to the available area.
|
||||
///
|
||||
/// This will crop the UI to a centered rectangle of the specified
|
||||
/// percentage of the available area (e.g. 0.5 for 50% x 50%).
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "INTEGER",
|
||||
default_value = "100",
|
||||
verbatim_doc_comment
|
||||
)]
|
||||
pub ui_scale: u16,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
}
|
||||
|
@ -1,18 +1,17 @@
|
||||
use rustc_hash::FxHashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{Result, anyhow};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::channels::cable::{parse_preview_kind, PreviewKind};
|
||||
use crate::channels::{
|
||||
cable::CableChannelPrototype, entry::PreviewCommand, CliTvChannel,
|
||||
};
|
||||
use crate::cli::args::{Cli, Command};
|
||||
use crate::config::KeyBindings;
|
||||
use crate::{
|
||||
cable,
|
||||
config::{get_config_dir, get_data_dir},
|
||||
channels::{
|
||||
preview::PreviewCommand,
|
||||
prototypes::{Cable, ChannelPrototype},
|
||||
},
|
||||
cli::args::{Cli, Command},
|
||||
config::{KeyBindings, get_config_dir, get_data_dir},
|
||||
};
|
||||
|
||||
pub mod args;
|
||||
@ -20,8 +19,8 @@ pub mod args;
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PostProcessedCli {
|
||||
pub channel: ParsedCliChannel,
|
||||
pub preview_kind: PreviewKind,
|
||||
pub channel: Option<String>,
|
||||
pub preview_command: Option<PreviewCommand>,
|
||||
pub no_preview: bool,
|
||||
pub tick_rate: Option<f64>,
|
||||
pub frame_rate: Option<f64>,
|
||||
@ -35,13 +34,14 @@ pub struct PostProcessedCli {
|
||||
pub select_1: bool,
|
||||
pub no_remote: bool,
|
||||
pub no_help: bool,
|
||||
pub ui_scale: u16,
|
||||
}
|
||||
|
||||
impl Default for PostProcessedCli {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
channel: ParsedCliChannel::Builtin(CliTvChannel::Files),
|
||||
preview_kind: PreviewKind::None,
|
||||
channel: None,
|
||||
preview_command: None,
|
||||
no_preview: false,
|
||||
tick_rate: None,
|
||||
frame_rate: None,
|
||||
@ -55,62 +55,40 @@ impl Default for PostProcessedCli {
|
||||
select_1: false,
|
||||
no_remote: false,
|
||||
no_help: false,
|
||||
ui_scale: 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Cli> for PostProcessedCli {
|
||||
fn from(cli: Cli) -> Self {
|
||||
// parse literal keybindings passed through the CLI
|
||||
let keybindings: Option<KeyBindings> = cli.keybindings.map(|kb| {
|
||||
parse_keybindings_literal(&kb, CLI_KEYBINDINGS_DELIMITER)
|
||||
.map_err(|e| {
|
||||
cli_parsing_error_exit(&e.to_string());
|
||||
})
|
||||
.unwrap()
|
||||
pub fn post_process(cli: Cli, cable: &Cable) -> PostProcessedCli {
|
||||
// Parse literal keybindings passed through the CLI
|
||||
let keybindings = cli.keybindings.as_ref().map(|kb| {
|
||||
parse_keybindings_literal(kb, CLI_KEYBINDINGS_DELIMITER)
|
||||
.unwrap_or_else(|e| cli_parsing_error_exit(&e.to_string()))
|
||||
});
|
||||
|
||||
// parse the preview command if provided
|
||||
let preview_kind = cli
|
||||
.preview
|
||||
.map(|preview| PreviewCommand {
|
||||
command: preview,
|
||||
// Parse the preview command if provided
|
||||
let preview_command = cli.preview.as_ref().map(|preview| PreviewCommand {
|
||||
command: preview.clone(),
|
||||
delimiter: cli.delimiter.clone(),
|
||||
})
|
||||
.map_or(PreviewKind::None, |preview_command| {
|
||||
parse_preview_kind(&preview_command)
|
||||
.map_err(|e| {
|
||||
cli_parsing_error_exit(&e.to_string());
|
||||
})
|
||||
.unwrap()
|
||||
offset_expr: cli.preview_offset.clone(),
|
||||
});
|
||||
|
||||
let channel: ParsedCliChannel;
|
||||
let working_directory: Option<String>;
|
||||
|
||||
match parse_channel(&cli.channel) {
|
||||
Ok(p) => {
|
||||
channel = p;
|
||||
working_directory = cli.working_directory;
|
||||
}
|
||||
Err(_) => {
|
||||
// if the path is provided as first argument and it exists, use it as the working
|
||||
// directory and default to the files channel
|
||||
if cli.working_directory.is_none()
|
||||
&& Path::new(&cli.channel).exists()
|
||||
{
|
||||
channel = ParsedCliChannel::Builtin(CliTvChannel::Files);
|
||||
working_directory = Some(cli.channel.clone());
|
||||
// Determine channel and working_directory
|
||||
let (channel, working_directory) = match &cli.channel {
|
||||
Some(c) if !cable.has_channel(c) => {
|
||||
if cli.working_directory.is_none() && Path::new(c).exists() {
|
||||
(None, Some(c.clone()))
|
||||
} else {
|
||||
unknown_channel_exit(&cli.channel);
|
||||
unreachable!();
|
||||
}
|
||||
unknown_channel_exit(c);
|
||||
}
|
||||
}
|
||||
_ => (cli.channel.clone(), cli.working_directory.clone()),
|
||||
};
|
||||
|
||||
Self {
|
||||
PostProcessedCli {
|
||||
channel,
|
||||
preview_kind,
|
||||
preview_command,
|
||||
no_preview: cli.no_preview,
|
||||
tick_rate: cli.tick_rate,
|
||||
frame_rate: cli.frame_rate,
|
||||
@ -124,35 +102,20 @@ impl From<Cli> for PostProcessedCli {
|
||||
select_1: cli.select_1,
|
||||
no_remote: cli.no_remote,
|
||||
no_help: cli.no_help,
|
||||
}
|
||||
ui_scale: cli.ui_scale,
|
||||
}
|
||||
}
|
||||
|
||||
fn cli_parsing_error_exit(message: &str) {
|
||||
fn cli_parsing_error_exit(message: &str) -> ! {
|
||||
eprintln!("Error parsing CLI arguments: {message}\n");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
fn unknown_channel_exit(channel: &str) {
|
||||
eprintln!("Unknown channel: {channel}\n");
|
||||
pub fn unknown_channel_exit(channel: &str) -> ! {
|
||||
eprintln!("Channel not found: {channel}\n");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ParsedCliChannel {
|
||||
Builtin(CliTvChannel),
|
||||
Cable(CableChannelPrototype),
|
||||
}
|
||||
|
||||
impl ParsedCliChannel {
|
||||
pub fn name(&self) -> String {
|
||||
match self {
|
||||
Self::Builtin(c) => c.to_string(),
|
||||
Self::Cable(c) => c.name.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CLI_KEYBINDINGS_DELIMITER: char = ';';
|
||||
|
||||
/// Parse a keybindings literal into a `KeyBindings` struct.
|
||||
@ -174,45 +137,8 @@ fn parse_keybindings_literal(
|
||||
toml::from_str(&toml_definition).map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
pub fn parse_channel(channel: &str) -> Result<ParsedCliChannel> {
|
||||
let cable_channels = cable::load_cable_channels().unwrap_or_default();
|
||||
// try to parse the channel as a cable channel
|
||||
cable_channels
|
||||
.iter()
|
||||
.find(|(k, _)| k.to_lowercase() == channel)
|
||||
.map_or_else(
|
||||
|| {
|
||||
// try to parse the channel as a builtin channel
|
||||
CliTvChannel::try_from(channel)
|
||||
.map(ParsedCliChannel::Builtin)
|
||||
.map_err(|_| anyhow!("Unknown channel: '{}'", channel))
|
||||
},
|
||||
|(_, v)| Ok(ParsedCliChannel::Cable(v.clone())),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_cable_channels() -> Vec<String> {
|
||||
cable::load_cable_channels()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn list_builtin_channels() -> Vec<String> {
|
||||
CliTvChannel::all_channels()
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn list_channels() {
|
||||
println!("\x1b[4mBuiltin channels:\x1b[0m");
|
||||
for c in list_builtin_channels() {
|
||||
println!("\t{c}");
|
||||
}
|
||||
println!("\n\x1b[4mCustom channels:\x1b[0m");
|
||||
for c in list_cable_channels().iter().map(|c| c.to_lowercase()) {
|
||||
for c in cable::load_cable().unwrap_or_default().keys() {
|
||||
println!("\t{c}");
|
||||
}
|
||||
}
|
||||
@ -243,13 +169,18 @@ pub fn list_channels() {
|
||||
pub fn guess_channel_from_prompt(
|
||||
prompt: &str,
|
||||
command_mapping: &FxHashMap<String, String>,
|
||||
fallback_channel: ParsedCliChannel,
|
||||
) -> Result<ParsedCliChannel> {
|
||||
fallback_channel: &str,
|
||||
cable: &Cable,
|
||||
) -> ChannelPrototype {
|
||||
debug!("Guessing channel from prompt: {}", prompt);
|
||||
// git checkout -qf
|
||||
// --- -------- --- <---------
|
||||
let fallback = cable
|
||||
.get(fallback_channel)
|
||||
.expect("Fallback channel not found in cable channels")
|
||||
.clone();
|
||||
if prompt.trim().is_empty() {
|
||||
return Ok(fallback_channel);
|
||||
return fallback;
|
||||
}
|
||||
let rev_prompt_words = prompt.split_whitespace().rev();
|
||||
let mut stack = Vec::new();
|
||||
@ -263,7 +194,7 @@ pub fn guess_channel_from_prompt(
|
||||
for word in rev_prompt_words.clone() {
|
||||
// if the stack is empty, we have a match
|
||||
if stack.is_empty() {
|
||||
return parse_channel(channel);
|
||||
return cable.get_channel(channel);
|
||||
}
|
||||
// if the word matches the top of the stack, pop it
|
||||
if stack.last() == Some(&word) {
|
||||
@ -272,14 +203,14 @@ pub fn guess_channel_from_prompt(
|
||||
}
|
||||
// if the stack is empty, we have a match
|
||||
if stack.is_empty() {
|
||||
return parse_channel(channel);
|
||||
return cable.get_channel(channel);
|
||||
}
|
||||
// reset the stack
|
||||
stack.clear();
|
||||
}
|
||||
|
||||
debug!("No match found, falling back to default channel");
|
||||
Ok(fallback_channel)
|
||||
fallback
|
||||
}
|
||||
|
||||
const VERSION_MESSAGE: &str = env!("CARGO_PKG_VERSION");
|
||||
@ -316,10 +247,7 @@ Data directory: {data_dir_path}"
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{
|
||||
action::Action, channels::entry::PreviewType, config::Binding,
|
||||
event::Key,
|
||||
};
|
||||
use crate::{action::Action, config::Binding, event::Key};
|
||||
|
||||
use super::*;
|
||||
|
||||
@ -327,24 +255,22 @@ mod tests {
|
||||
#[allow(clippy::float_cmp)]
|
||||
fn test_from_cli() {
|
||||
let cli = Cli {
|
||||
channel: "files".to_string(),
|
||||
channel: Some("files".to_string()),
|
||||
preview: Some("bat -n --color=always {}".to_string()),
|
||||
delimiter: ":".to_string(),
|
||||
working_directory: Some("/home/user".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let post_processed_cli: PostProcessedCli = cli.into();
|
||||
let cable = cable::load_cable().unwrap_or_default();
|
||||
let post_processed_cli = post_process(cli, &cable);
|
||||
|
||||
assert_eq!(
|
||||
post_processed_cli.channel,
|
||||
ParsedCliChannel::Builtin(CliTvChannel::Files)
|
||||
);
|
||||
assert_eq!(
|
||||
post_processed_cli.preview_kind,
|
||||
PreviewKind::Command(PreviewCommand {
|
||||
post_processed_cli.preview_command,
|
||||
Some(PreviewCommand {
|
||||
command: "bat -n --color=always {}".to_string(),
|
||||
delimiter: ":".to_string()
|
||||
delimiter: ":".to_string(),
|
||||
offset_expr: None,
|
||||
})
|
||||
);
|
||||
assert_eq!(post_processed_cli.tick_rate, None);
|
||||
@ -359,17 +285,14 @@ mod tests {
|
||||
#[allow(clippy::float_cmp)]
|
||||
fn test_from_cli_no_args() {
|
||||
let cli = Cli {
|
||||
channel: ".".to_string(),
|
||||
channel: Some(".".to_string()),
|
||||
delimiter: ":".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let post_processed_cli: PostProcessedCli = cli.into();
|
||||
let cable = cable::load_cable().unwrap_or_default();
|
||||
let post_processed_cli = post_process(cli, &cable);
|
||||
|
||||
assert_eq!(
|
||||
post_processed_cli.channel,
|
||||
ParsedCliChannel::Builtin(CliTvChannel::Files)
|
||||
);
|
||||
assert_eq!(
|
||||
post_processed_cli.working_directory,
|
||||
Some(".".to_string())
|
||||
@ -377,44 +300,10 @@ mod tests {
|
||||
assert_eq!(post_processed_cli.command, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builtin_previewer_files() {
|
||||
let cli = Cli {
|
||||
channel: "files".to_string(),
|
||||
preview: Some(":files:".to_string()),
|
||||
delimiter: ":".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let post_processed_cli: PostProcessedCli = cli.into();
|
||||
|
||||
assert_eq!(
|
||||
post_processed_cli.preview_kind,
|
||||
PreviewKind::Builtin(PreviewType::Files)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builtin_previewer_env() {
|
||||
let cli = Cli {
|
||||
channel: "files".to_string(),
|
||||
preview: Some(":env_var:".to_string()),
|
||||
delimiter: ":".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let post_processed_cli: PostProcessedCli = cli.into();
|
||||
|
||||
assert_eq!(
|
||||
post_processed_cli.preview_kind,
|
||||
PreviewKind::Builtin(PreviewType::EnvVar)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_keybindings() {
|
||||
let cli = Cli {
|
||||
channel: "files".to_string(),
|
||||
channel: Some("files".to_string()),
|
||||
preview: Some(":env_var:".to_string()),
|
||||
delimiter: ":".to_string(),
|
||||
keybindings: Some(
|
||||
@ -424,7 +313,8 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let post_processed_cli: PostProcessedCli = cli.into();
|
||||
let cable = cable::load_cable().unwrap_or_default();
|
||||
let post_processed_cli = post_process(cli, &cable);
|
||||
|
||||
let mut expected = KeyBindings::default();
|
||||
expected.insert(Action::Quit, Binding::SingleKey(Key::Esc));
|
||||
@ -436,15 +326,17 @@ mod tests {
|
||||
assert_eq!(post_processed_cli.keybindings, Some(expected));
|
||||
}
|
||||
|
||||
fn guess_channel_from_prompt_setup(
|
||||
) -> (FxHashMap<String, String>, ParsedCliChannel) {
|
||||
/// Returns a tuple containing a command mapping and a fallback channel.
|
||||
fn guess_channel_from_prompt_setup<'a>()
|
||||
-> (FxHashMap<String, String>, &'a str, Cable) {
|
||||
let mut command_mapping = FxHashMap::default();
|
||||
command_mapping.insert("vim".to_string(), "files".to_string());
|
||||
command_mapping.insert("export".to_string(), "env".to_string());
|
||||
|
||||
(
|
||||
command_mapping,
|
||||
ParsedCliChannel::Builtin(CliTvChannel::Env),
|
||||
"env",
|
||||
cable::load_cable().unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -452,44 +344,50 @@ mod tests {
|
||||
fn test_guess_channel_from_prompt_present() {
|
||||
let prompt = "vim -d file1";
|
||||
|
||||
let (command_mapping, fallback) = guess_channel_from_prompt_setup();
|
||||
let (command_mapping, fallback, channels) =
|
||||
guess_channel_from_prompt_setup();
|
||||
|
||||
let channel =
|
||||
guess_channel_from_prompt(prompt, &command_mapping, fallback)
|
||||
.unwrap();
|
||||
let channel = guess_channel_from_prompt(
|
||||
prompt,
|
||||
&command_mapping,
|
||||
fallback,
|
||||
&channels,
|
||||
);
|
||||
|
||||
assert_eq!(channel.name(), "files");
|
||||
assert_eq!(channel.name, "files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_guess_channel_from_prompt_fallback() {
|
||||
let prompt = "git checkout ";
|
||||
|
||||
let (command_mapping, fallback) = guess_channel_from_prompt_setup();
|
||||
let (command_mapping, fallback, channels) =
|
||||
guess_channel_from_prompt_setup();
|
||||
|
||||
let channel = guess_channel_from_prompt(
|
||||
prompt,
|
||||
&command_mapping,
|
||||
fallback.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
fallback,
|
||||
&channels,
|
||||
);
|
||||
|
||||
assert_eq!(channel, fallback);
|
||||
assert_eq!(channel.name, fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_guess_channel_from_prompt_empty() {
|
||||
let prompt = "";
|
||||
|
||||
let (command_mapping, fallback) = guess_channel_from_prompt_setup();
|
||||
let (command_mapping, fallback, channels) =
|
||||
guess_channel_from_prompt_setup();
|
||||
|
||||
let channel = guess_channel_from_prompt(
|
||||
prompt,
|
||||
&command_mapping,
|
||||
fallback.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
fallback,
|
||||
&channels,
|
||||
);
|
||||
|
||||
assert_eq!(channel, fallback);
|
||||
assert_eq!(channel.name, fallback);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
use crate::action::Action;
|
||||
use crate::event::{convert_raw_event_to_key, Key};
|
||||
use crate::event::{Key, convert_raw_event_to_key};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
@ -144,7 +144,7 @@ fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) {
|
||||
current = &rest[6..];
|
||||
}
|
||||
_ => break, // break out of the loop if no known prefix is detected
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
(current, modifiers)
|
||||
@ -273,8 +273,7 @@ pub fn parse_key(raw: &str) -> anyhow::Result<Key, String> {
|
||||
raw
|
||||
} else {
|
||||
let raw = raw.strip_prefix('<').unwrap_or(raw);
|
||||
let raw = raw.strip_suffix('>').unwrap_or(raw);
|
||||
raw
|
||||
raw.strip_suffix('>').unwrap_or(raw)
|
||||
};
|
||||
let key_event = parse_key_event(raw)?;
|
||||
Ok(convert_raw_event_to_key(key_event))
|
||||
|
@ -8,16 +8,16 @@ use std::{
|
||||
use anyhow::{Context, Result};
|
||||
use directories::ProjectDirs;
|
||||
pub use keybindings::merge_keybindings;
|
||||
pub use keybindings::{parse_key, Binding, KeyBindings};
|
||||
use previewers::PreviewersConfig;
|
||||
pub use keybindings::{Binding, KeyBindings, parse_key};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shell_integration::ShellIntegrationConfig;
|
||||
pub use themes::Theme;
|
||||
use tracing::{debug, warn};
|
||||
pub use ui::UiConfig;
|
||||
|
||||
use crate::channels::prototypes::DEFAULT_PROTOTYPE_NAME;
|
||||
|
||||
mod keybindings;
|
||||
mod previewers;
|
||||
pub mod shell_integration;
|
||||
mod themes;
|
||||
mod ui;
|
||||
@ -36,6 +36,13 @@ pub struct AppConfig {
|
||||
pub frame_rate: f64,
|
||||
#[serde(default = "default_tick_rate")]
|
||||
pub tick_rate: f64,
|
||||
/// The default channel to use when no channel is specified
|
||||
#[serde(default = "default_channel")]
|
||||
pub default_channel: String,
|
||||
}
|
||||
|
||||
fn default_channel() -> String {
|
||||
DEFAULT_PROTOTYPE_NAME.to_string()
|
||||
}
|
||||
|
||||
impl Hash for AppConfig {
|
||||
@ -49,7 +56,6 @@ impl Hash for AppConfig {
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Hash)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
/// General application configuration
|
||||
#[allow(clippy::struct_field_names)]
|
||||
@ -61,9 +67,6 @@ pub struct Config {
|
||||
/// UI configuration
|
||||
#[serde(default)]
|
||||
pub ui: UiConfig,
|
||||
/// Previewers configuration
|
||||
#[serde(default)]
|
||||
pub previewers: PreviewersConfig,
|
||||
/// Shell integration configuration
|
||||
#[serde(default)]
|
||||
pub shell_integration: ShellIntegrationConfig,
|
||||
@ -135,7 +138,10 @@ impl Config {
|
||||
Ok(final_cfg)
|
||||
} else {
|
||||
// otherwise, create the default configuration file
|
||||
warn!("No config file found at {:?}, creating default configuration file at that location.", config_env.config_dir);
|
||||
warn!(
|
||||
"No config file found at {:?}, creating default configuration file at that location.",
|
||||
config_env.config_dir
|
||||
);
|
||||
// create the default configuration file in the user's config directory
|
||||
std::fs::write(
|
||||
config_env.config_dir.join(CONFIG_FILE_NAME),
|
||||
@ -192,7 +198,6 @@ impl Config {
|
||||
application: user.application,
|
||||
keybindings: user.keybindings,
|
||||
ui: user.ui,
|
||||
previewers: user.previewers,
|
||||
shell_integration: user.shell_integration,
|
||||
}
|
||||
}
|
||||
@ -211,14 +216,13 @@ pub fn get_data_dir() -> PathBuf {
|
||||
.filter(|p| p.is_absolute())
|
||||
});
|
||||
|
||||
let directory = if let Some(s) = data_folder {
|
||||
if let Some(s) = data_folder {
|
||||
s
|
||||
} else if let Some(proj_dirs) = project_directory() {
|
||||
proj_dirs.data_local_dir().to_path_buf()
|
||||
} else {
|
||||
PathBuf::from("../../../../..").join(".data")
|
||||
};
|
||||
directory
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_config_dir() -> PathBuf {
|
||||
@ -233,14 +237,12 @@ pub fn get_config_dir() -> PathBuf {
|
||||
.map(|p| p.join(PROJECT_NAME))
|
||||
.filter(|p| p.is_absolute())
|
||||
});
|
||||
let directory = if let Some(s) = config_dir {
|
||||
if let Some(s) = config_dir {
|
||||
s
|
||||
} else if cfg!(unix) {
|
||||
// default to ~/.config/television for unix systems
|
||||
if let Some(base_dirs) = directories::BaseDirs::new() {
|
||||
let cfg_dir =
|
||||
base_dirs.home_dir().join(".config").join("television");
|
||||
cfg_dir
|
||||
base_dirs.home_dir().join(".config").join("television")
|
||||
} else {
|
||||
PathBuf::from("../../../../..").join(".config")
|
||||
}
|
||||
@ -248,8 +250,7 @@ pub fn get_config_dir() -> PathBuf {
|
||||
proj_dirs.config_local_dir().to_path_buf()
|
||||
} else {
|
||||
PathBuf::from("../../../../..").join("../../../../../.config")
|
||||
};
|
||||
directory
|
||||
}
|
||||
}
|
||||
|
||||
fn project_directory() -> Option<ProjectDirs> {
|
||||
@ -321,7 +322,6 @@ mod tests {
|
||||
assert_eq!(config.application, default_config.application);
|
||||
assert_eq!(config.keybindings, default_config.keybindings);
|
||||
assert_eq!(config.ui, default_config.ui);
|
||||
assert_eq!(config.previewers, default_config.previewers);
|
||||
// backwards compatibility
|
||||
assert_eq!(
|
||||
config.shell_integration.commands,
|
||||
@ -341,7 +341,7 @@ mod tests {
|
||||
theme = "television"
|
||||
|
||||
[previewers.file]
|
||||
theme = "Visual Studio Dark"
|
||||
theme = "something"
|
||||
|
||||
[keybindings]
|
||||
toggle_help = ["ctrl-a", "ctrl-b"]
|
||||
@ -375,8 +375,6 @@ mod tests {
|
||||
default_config.application.frame_rate = 30.0;
|
||||
default_config.ui.ui_scale = 40;
|
||||
default_config.ui.theme = "television".to_string();
|
||||
default_config.previewers.file.theme =
|
||||
"Visual Studio Dark".to_string();
|
||||
default_config.keybindings.extend({
|
||||
let mut map = FxHashMap::default();
|
||||
map.insert(
|
||||
@ -399,7 +397,6 @@ mod tests {
|
||||
assert_eq!(config.application, default_config.application);
|
||||
assert_eq!(config.keybindings, default_config.keybindings);
|
||||
assert_eq!(config.ui, default_config.ui);
|
||||
assert_eq!(config.previewers, default_config.previewers);
|
||||
assert_eq!(
|
||||
config.shell_integration.commands,
|
||||
[(&String::from("git add"), &String::from("git-diff"))]
|
||||
|
@ -1,40 +0,0 @@
|
||||
use crate::preview::{previewers, PreviewerConfig};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Default, PartialEq, Hash)]
|
||||
pub struct PreviewersConfig {
|
||||
#[serde(default)]
|
||||
pub basic: BasicPreviewerConfig,
|
||||
pub file: FilePreviewerConfig,
|
||||
#[serde(default)]
|
||||
pub env_var: EnvVarPreviewerConfig,
|
||||
}
|
||||
|
||||
impl From<PreviewersConfig> for PreviewerConfig {
|
||||
fn from(val: PreviewersConfig) -> Self {
|
||||
PreviewerConfig::default()
|
||||
.file(previewers::files::FilePreviewerConfig::new(val.file.theme))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Default, PartialEq, Hash)]
|
||||
pub struct BasicPreviewerConfig {}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Hash)]
|
||||
#[serde(default)]
|
||||
pub struct FilePreviewerConfig {
|
||||
//pub max_file_size: u64,
|
||||
pub theme: String,
|
||||
}
|
||||
|
||||
impl Default for FilePreviewerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
//max_file_size: 1024 * 1024,
|
||||
theme: String::from("TwoDark"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Default, PartialEq, Hash)]
|
||||
pub struct EnvVarPreviewerConfig {}
|
@ -109,7 +109,6 @@ pub struct Theme {
|
||||
// modes
|
||||
pub channel_mode_fg: Color,
|
||||
pub remote_control_mode_fg: Color,
|
||||
pub send_to_channel_mode_fg: Color,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
@ -180,7 +179,6 @@ struct Inner {
|
||||
//modes
|
||||
channel_mode_fg: String,
|
||||
remote_control_mode_fg: String,
|
||||
send_to_channel_mode_fg: String,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Theme {
|
||||
@ -308,15 +306,6 @@ impl<'de> Deserialize<'de> for Theme {
|
||||
&inner.remote_control_mode_fg
|
||||
))
|
||||
})?,
|
||||
send_to_channel_mode_fg: Color::from_str(
|
||||
&inner.send_to_channel_mode_fg,
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
serde::de::Error::custom(format!(
|
||||
"invalid color {}",
|
||||
&inner.send_to_channel_mode_fg
|
||||
))
|
||||
})?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -439,7 +428,6 @@ impl Into<ModeColorscheme> for &Theme {
|
||||
ModeColorscheme {
|
||||
channel: (&self.channel_mode_fg).into(),
|
||||
remote_control: (&self.remote_control_mode_fg).into(),
|
||||
send_to_channel: (&self.send_to_channel_mode_fg).into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -466,7 +454,6 @@ mod tests {
|
||||
preview_title_fg = "bright-white"
|
||||
channel_mode_fg = "bright-white"
|
||||
remote_control_mode_fg = "bright-white"
|
||||
send_to_channel_mode_fg = "bright-white"
|
||||
"##;
|
||||
let theme: Theme = toml::from_str(theme_content).unwrap();
|
||||
assert_eq!(
|
||||
@ -496,10 +483,6 @@ mod tests {
|
||||
theme.remote_control_mode_fg,
|
||||
Color::Ansi(ANSIColor::BrightWhite)
|
||||
);
|
||||
assert_eq!(
|
||||
theme.send_to_channel_mode_fg,
|
||||
Color::Ansi(ANSIColor::BrightWhite)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -519,7 +502,6 @@ mod tests {
|
||||
preview_title_fg = "bright-white"
|
||||
channel_mode_fg = "bright-white"
|
||||
remote_control_mode_fg = "bright-white"
|
||||
send_to_channel_mode_fg = "bright-white"
|
||||
"##;
|
||||
let theme: Theme = toml::from_str(theme_content).unwrap();
|
||||
assert_eq!(theme.background, None);
|
||||
@ -549,9 +531,5 @@ mod tests {
|
||||
theme.remote_control_mode_fg,
|
||||
Color::Ansi(ANSIColor::BrightWhite)
|
||||
);
|
||||
assert_eq!(
|
||||
theme.send_to_channel_mode_fg,
|
||||
Color::Ansi(ANSIColor::BrightWhite)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,20 +1,25 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::screen::layout::{InputPosition, PreviewTitlePosition};
|
||||
use crate::screen::layout::{
|
||||
InputPosition, Orientation, PreviewTitlePosition,
|
||||
};
|
||||
|
||||
use super::themes::DEFAULT_THEME;
|
||||
|
||||
const DEFAULT_UI_SCALE: u16 = 100;
|
||||
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Hash)]
|
||||
#[serde(default)]
|
||||
pub struct UiConfig {
|
||||
pub use_nerd_font_icons: bool,
|
||||
pub ui_scale: u16,
|
||||
pub no_help: bool,
|
||||
pub show_help_bar: bool,
|
||||
pub show_preview_panel: bool,
|
||||
#[serde(default)]
|
||||
pub input_bar_position: InputPosition,
|
||||
pub orientation: Orientation,
|
||||
pub preview_title_position: Option<PreviewTitlePosition>,
|
||||
pub theme: String,
|
||||
pub custom_header: Option<String>,
|
||||
@ -25,9 +30,11 @@ impl Default for UiConfig {
|
||||
Self {
|
||||
use_nerd_font_icons: false,
|
||||
ui_scale: DEFAULT_UI_SCALE,
|
||||
no_help: false,
|
||||
show_help_bar: false,
|
||||
show_preview_panel: true,
|
||||
input_bar_position: InputPosition::Top,
|
||||
orientation: Orientation::Landscape,
|
||||
preview_title_position: None,
|
||||
theme: String::from(DEFAULT_THEME),
|
||||
custom_header: None,
|
||||
|
@ -1,7 +1,7 @@
|
||||
use std::{hash::Hash, time::Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use ratatui::{layout::Rect, Frame};
|
||||
use ratatui::{Frame, layout::Rect};
|
||||
use rustc_hash::FxHashSet;
|
||||
|
||||
use crate::{
|
||||
@ -9,7 +9,7 @@ use crate::{
|
||||
channels::entry::Entry,
|
||||
config::Config,
|
||||
picker::Picker,
|
||||
preview::PreviewState,
|
||||
previewer::state::PreviewState,
|
||||
screen::{
|
||||
colors::Colorscheme, help::draw_help_bar, input::draw_input_box,
|
||||
keybindings::build_keybindings_table, layout::Layout,
|
||||
@ -59,7 +59,7 @@ impl Hash for ChannelState {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Hash)]
|
||||
#[derive(Debug, Clone)]
|
||||
/// The state of the main thread `Television` struct.
|
||||
///
|
||||
/// This struct is passed along to the UI thread as part of the `Ctx` struct.
|
||||
@ -131,37 +131,37 @@ impl Ctx {
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Ctx {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.tv_state == other.tv_state
|
||||
&& self.config == other.config
|
||||
&& self.colorscheme == other.colorscheme
|
||||
&& self.app_metadata == other.app_metadata
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Ctx {}
|
||||
|
||||
impl Hash for Ctx {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.tv_state.hash(state);
|
||||
self.config.hash(state);
|
||||
self.colorscheme.hash(state);
|
||||
self.app_metadata.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Ctx {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.instant.cmp(&other.instant))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Ctx {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.instant.cmp(&other.instant)
|
||||
}
|
||||
}
|
||||
// impl PartialEq for Ctx {
|
||||
// fn eq(&self, other: &Self) -> bool {
|
||||
// self.tv_state == other.tv_state
|
||||
// && self.config == other.config
|
||||
// && self.colorscheme == other.colorscheme
|
||||
// && self.app_metadata == other.app_metadata
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// impl Eq for Ctx {}
|
||||
//
|
||||
// impl Hash for Ctx {
|
||||
// fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
// self.tv_state.hash(state);
|
||||
// self.config.hash(state);
|
||||
// self.colorscheme.hash(state);
|
||||
// self.app_metadata.hash(state);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// impl PartialOrd for Ctx {
|
||||
// fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
// Some(self.instant.cmp(&other.instant))
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// impl Ord for Ctx {
|
||||
// fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
// self.instant.cmp(&other.instant)
|
||||
// }
|
||||
// }
|
||||
|
||||
/// Draw the current UI frame based on the given context.
|
||||
///
|
||||
@ -179,12 +179,14 @@ impl Ord for Ctx {
|
||||
/// This layout can then be sent back to the main thread to serve for tasks where having that
|
||||
/// information can be useful or lead to optimizations.
|
||||
pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<Layout> {
|
||||
let show_preview =
|
||||
ctx.config.ui.show_preview_panel && ctx.tv_state.preview_state.enabled;
|
||||
let show_remote = !matches!(ctx.tv_state.mode, Mode::Channel);
|
||||
|
||||
let layout =
|
||||
Layout::build(area, &ctx.config.ui, show_remote, show_preview);
|
||||
let layout = Layout::build(
|
||||
area,
|
||||
&ctx.config.ui,
|
||||
show_remote,
|
||||
ctx.tv_state.preview_state.enabled,
|
||||
);
|
||||
|
||||
// help bar (metadata, keymaps, logo)
|
||||
draw_help_bar(
|
||||
@ -225,6 +227,7 @@ pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<Layout> {
|
||||
.to_string(),
|
||||
// only show the preview keybinding hint if there's actually something to preview
|
||||
ctx.tv_state.preview_state.enabled,
|
||||
ctx.config.ui.no_help,
|
||||
)?;
|
||||
|
||||
// input box
|
||||
@ -240,12 +243,13 @@ pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<Layout> {
|
||||
&ctx.tv_state.spinner,
|
||||
&ctx.colorscheme,
|
||||
&ctx.config.ui.custom_header,
|
||||
&ctx.config.ui.input_bar_position,
|
||||
)?;
|
||||
|
||||
if layout.preview_window.is_some() {
|
||||
if let Some(preview_rect) = layout.preview_window {
|
||||
draw_preview_content_block(
|
||||
f,
|
||||
layout.preview_window.unwrap(),
|
||||
preview_rect,
|
||||
&ctx.tv_state.preview_state,
|
||||
ctx.config.ui.use_nerd_font_icons,
|
||||
&ctx.colorscheme,
|
||||
|
@ -8,8 +8,8 @@ use std::{
|
||||
|
||||
use crossterm::event::{
|
||||
KeyCode::{
|
||||
BackTab, Backspace, Char, Delete, Down, End, Enter, Esc, Home, Insert,
|
||||
Left, PageDown, PageUp, Right, Tab, Up, F,
|
||||
BackTab, Backspace, Char, Delete, Down, End, Enter, Esc, F, Home,
|
||||
Insert, Left, PageDown, PageUp, Right, Tab, Up,
|
||||
},
|
||||
KeyEvent, KeyEventKind, KeyModifiers,
|
||||
};
|
||||
@ -115,9 +115,7 @@ impl Display for Key {
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
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 {
|
||||
@ -162,8 +160,7 @@ fn flush_existing_events() {
|
||||
}
|
||||
|
||||
impl EventLoop {
|
||||
// FIXME: this init parameter doesn't seem to be used anymore
|
||||
pub fn new(tick_rate: f64, init: bool) -> Self {
|
||||
pub fn new(tick_rate: f64) -> Self {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let tick_interval = Duration::from_secs_f64(1.0 / tick_rate);
|
||||
|
||||
@ -171,8 +168,6 @@ impl EventLoop {
|
||||
|
||||
flush_existing_events();
|
||||
|
||||
if init {
|
||||
//let mut reader = crossterm::event::EventStream::new();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let delay = tokio::time::sleep(tick_interval);
|
||||
@ -219,7 +214,6 @@ impl EventLoop {
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
//tx,
|
||||
|
@ -12,7 +12,7 @@ pub mod keymap;
|
||||
pub mod logging;
|
||||
pub mod matcher;
|
||||
pub mod picker;
|
||||
pub mod preview;
|
||||
pub mod previewer;
|
||||
pub mod render;
|
||||
pub mod screen;
|
||||
pub mod television;
|
||||
|
@ -1,5 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
|
||||
use crate::config::get_data_dir;
|
||||
|
||||
|
@ -1,29 +1,29 @@
|
||||
use std::env;
|
||||
use std::io::{stdout, BufWriter, IsTerminal, Write};
|
||||
use std::io::{BufWriter, IsTerminal, Write, stdout};
|
||||
use std::path::Path;
|
||||
use std::process::exit;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use television::channels::cable::PreviewKind;
|
||||
use television::cli::parse_channel;
|
||||
use television::utils::clipboard::CLIPBOARD;
|
||||
use television::cable::load_cable;
|
||||
use television::cli::post_process;
|
||||
use television::{
|
||||
channels::prototypes::{Cable, ChannelPrototype},
|
||||
utils::clipboard::CLIPBOARD,
|
||||
};
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
use television::app::{App, AppOptions};
|
||||
use television::channels::{
|
||||
entry::PreviewType, stdin::Channel as StdinChannel, TelevisionChannel,
|
||||
};
|
||||
use television::cli::{
|
||||
args::{Cli, Command},
|
||||
guess_channel_from_prompt, list_channels, ParsedCliChannel,
|
||||
PostProcessedCli,
|
||||
args::{Cli, Command},
|
||||
guess_channel_from_prompt, list_channels,
|
||||
};
|
||||
|
||||
use television::config::{merge_keybindings, Config, ConfigEnv};
|
||||
use television::config::{Config, ConfigEnv, merge_keybindings};
|
||||
use television::utils::shell::render_autocomplete_script_template;
|
||||
use television::utils::{
|
||||
shell::{completion_script, Shell},
|
||||
shell::{Shell, completion_script},
|
||||
stdin::is_readable_stdin,
|
||||
};
|
||||
|
||||
@ -37,13 +37,17 @@ async fn main() -> Result<()> {
|
||||
// process the CLI arguments
|
||||
let cli = Cli::parse();
|
||||
debug!("CLI: {:?}", cli);
|
||||
let args: PostProcessedCli = cli.into();
|
||||
debug!("PostProcessedCli: {:?}", args);
|
||||
|
||||
// load the configuration file
|
||||
debug!("Loading configuration...");
|
||||
let mut config = Config::new(&ConfigEnv::init()?)?;
|
||||
|
||||
debug!("Loading cable channels...");
|
||||
let cable = load_cable().unwrap_or_default();
|
||||
|
||||
let args = post_process(cli, &cable);
|
||||
debug!("PostProcessedCli: {:?}", args);
|
||||
|
||||
// optionally handle subcommands
|
||||
debug!("Handling subcommands...");
|
||||
args.command
|
||||
@ -59,8 +63,8 @@ async fn main() -> Result<()> {
|
||||
|
||||
// determine the channel to use based on the CLI arguments and configuration
|
||||
debug!("Determining channel...");
|
||||
let channel =
|
||||
determine_channel(args.clone(), &config, is_readable_stdin())?;
|
||||
let channel_prototype =
|
||||
determine_channel(&args, &config, is_readable_stdin(), &cable);
|
||||
|
||||
CLIPBOARD.with(<_>::default);
|
||||
|
||||
@ -72,7 +76,8 @@ async fn main() -> Result<()> {
|
||||
args.no_help,
|
||||
config.application.tick_rate,
|
||||
);
|
||||
let mut app = App::new(channel, config, args.input, options);
|
||||
let mut app =
|
||||
App::new(&channel_prototype, config, args.input, options, &cable);
|
||||
stdout().flush()?;
|
||||
debug!("Running application...");
|
||||
let output = app.run(stdout().is_terminal(), false).await?;
|
||||
@ -105,6 +110,7 @@ fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) {
|
||||
if let Some(header) = &args.custom_header {
|
||||
config.ui.custom_header = Some(header.to_string());
|
||||
}
|
||||
config.ui.ui_scale = args.ui_scale;
|
||||
}
|
||||
|
||||
pub fn set_current_dir(path: &String) -> Result<()> {
|
||||
@ -144,49 +150,48 @@ pub fn handle_subcommands(command: &Command, config: &Config) -> Result<()> {
|
||||
}
|
||||
|
||||
pub fn determine_channel(
|
||||
args: PostProcessedCli,
|
||||
args: &PostProcessedCli,
|
||||
config: &Config,
|
||||
readable_stdin: bool,
|
||||
) -> Result<TelevisionChannel> {
|
||||
cable: &Cable,
|
||||
) -> ChannelPrototype {
|
||||
if readable_stdin {
|
||||
debug!("Using stdin channel");
|
||||
Ok(TelevisionChannel::Stdin(StdinChannel::new(
|
||||
match &args.preview_kind {
|
||||
PreviewKind::Command(ref preview_command) => {
|
||||
PreviewType::Command(preview_command.clone())
|
||||
}
|
||||
PreviewKind::Builtin(preview_type) => preview_type.clone(),
|
||||
PreviewKind::None => PreviewType::None,
|
||||
},
|
||||
)))
|
||||
} else if let Some(prompt) = args.autocomplete_prompt {
|
||||
ChannelPrototype::stdin(args.preview_command.clone())
|
||||
} else if let Some(prompt) = &args.autocomplete_prompt {
|
||||
debug!("Using autocomplete prompt: {:?}", prompt);
|
||||
let channel = guess_channel_from_prompt(
|
||||
&prompt,
|
||||
let channel_prototype = guess_channel_from_prompt(
|
||||
prompt,
|
||||
&config.shell_integration.commands,
|
||||
parse_channel(&config.shell_integration.fallback_channel)?,
|
||||
)?;
|
||||
debug!("Using guessed channel: {:?}", channel);
|
||||
match channel {
|
||||
ParsedCliChannel::Builtin(c) => Ok(c.to_channel()),
|
||||
ParsedCliChannel::Cable(c) => {
|
||||
Ok(TelevisionChannel::Cable(c.into()))
|
||||
}
|
||||
}
|
||||
&config.shell_integration.fallback_channel,
|
||||
cable,
|
||||
);
|
||||
debug!("Using guessed channel: {:?}", channel_prototype);
|
||||
channel_prototype
|
||||
} else {
|
||||
debug!("Using {:?} channel", args.channel);
|
||||
match args.channel {
|
||||
ParsedCliChannel::Builtin(c) => Ok(c.to_channel()),
|
||||
ParsedCliChannel::Cable(c) => {
|
||||
Ok(TelevisionChannel::Cable(c.into()))
|
||||
}
|
||||
let channel = args
|
||||
.channel
|
||||
.as_ref()
|
||||
.unwrap_or(&config.application.default_channel)
|
||||
.clone();
|
||||
|
||||
let mut prototype = cable.get_channel(&channel);
|
||||
// use cli preview command if any
|
||||
if let Some(pc) = &args.preview_command {
|
||||
prototype.preview_command = Some(pc.clone());
|
||||
}
|
||||
|
||||
prototype
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rustc_hash::FxHashMap;
|
||||
use television::{
|
||||
cable::load_cable,
|
||||
channels::{preview::PreviewCommand, prototypes::ChannelPrototype},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
@ -194,43 +199,40 @@ mod tests {
|
||||
args: &PostProcessedCli,
|
||||
config: &Config,
|
||||
readable_stdin: bool,
|
||||
expected_channel: &TelevisionChannel,
|
||||
expected_channel: &ChannelPrototype,
|
||||
cable_channels: Option<Cable>,
|
||||
) {
|
||||
let channels: Cable =
|
||||
cable_channels.unwrap_or_else(|| load_cable().unwrap_or_default());
|
||||
let channel =
|
||||
determine_channel(args.clone(), config, readable_stdin).unwrap();
|
||||
determine_channel(args, config, readable_stdin, &channels);
|
||||
|
||||
assert!(
|
||||
channel.name() == expected_channel.name(),
|
||||
assert_eq!(
|
||||
channel.name, expected_channel.name,
|
||||
"Expected {:?} but got {:?}",
|
||||
expected_channel.name(),
|
||||
channel.name()
|
||||
expected_channel.name, channel.name
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_determine_channel_readable_stdin() {
|
||||
let channel = television::cli::ParsedCliChannel::Builtin(
|
||||
television::channels::CliTvChannel::Env,
|
||||
);
|
||||
let args = PostProcessedCli {
|
||||
channel,
|
||||
..Default::default()
|
||||
};
|
||||
#[test]
|
||||
/// Test that the channel is stdin when stdin is readable
|
||||
fn test_determine_channel_readable_stdin() {
|
||||
let args = PostProcessedCli::default();
|
||||
let config = Config::default();
|
||||
assert_is_correct_channel(
|
||||
&args,
|
||||
&config,
|
||||
true,
|
||||
&TelevisionChannel::Stdin(StdinChannel::new(PreviewType::None)),
|
||||
&ChannelPrototype::new("stdin", "cat", false, None),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_determine_channel_autocomplete_prompt() {
|
||||
#[test]
|
||||
fn test_determine_channel_autocomplete_prompt() {
|
||||
let autocomplete_prompt = Some("cd".to_string());
|
||||
let expected_channel = television::channels::TelevisionChannel::Dirs(
|
||||
television::channels::dirs::Channel::default(),
|
||||
);
|
||||
let expected_channel =
|
||||
ChannelPrototype::new("dirs", "ls {}", false, None);
|
||||
let args = PostProcessedCli {
|
||||
autocomplete_prompt,
|
||||
..Default::default()
|
||||
@ -251,14 +253,18 @@ mod tests {
|
||||
};
|
||||
config.shell_integration.merge_triggers();
|
||||
|
||||
assert_is_correct_channel(&args, &config, false, &expected_channel);
|
||||
assert_is_correct_channel(
|
||||
&args,
|
||||
&config,
|
||||
false,
|
||||
&expected_channel,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_determine_channel_standard_case() {
|
||||
let channel = television::cli::ParsedCliChannel::Builtin(
|
||||
television::channels::CliTvChannel::Dirs,
|
||||
);
|
||||
#[test]
|
||||
fn test_determine_channel_standard_case() {
|
||||
let channel = Some(String::from("dirs"));
|
||||
let args = PostProcessedCli {
|
||||
channel,
|
||||
..Default::default()
|
||||
@ -268,9 +274,52 @@ mod tests {
|
||||
&args,
|
||||
&config,
|
||||
false,
|
||||
&TelevisionChannel::Dirs(
|
||||
television::channels::dirs::Channel::default(),
|
||||
),
|
||||
&ChannelPrototype::new("dirs", "", false, None),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_channel_config_fallback() {
|
||||
let cable = Cable::default();
|
||||
let args = PostProcessedCli {
|
||||
channel: None,
|
||||
..Default::default()
|
||||
};
|
||||
let mut config = Config::default();
|
||||
config.application.default_channel = String::from("dirs");
|
||||
assert_is_correct_channel(
|
||||
&args,
|
||||
&config,
|
||||
false,
|
||||
&cable.get_channel("dirs"),
|
||||
Some(cable),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_channel_with_cli_preview() {
|
||||
let cable = Cable::default();
|
||||
|
||||
let preview_command = PreviewCommand::new("echo hello", ",", None);
|
||||
|
||||
let args = PostProcessedCli {
|
||||
channel: Some(String::from("dirs")),
|
||||
preview_command: Some(preview_command),
|
||||
..Default::default()
|
||||
};
|
||||
let config = Config::default();
|
||||
|
||||
let expected_prototype = cable
|
||||
.get_channel("dirs")
|
||||
.set_preview(args.preview_command.clone());
|
||||
|
||||
assert_is_correct_channel(
|
||||
&args,
|
||||
&config,
|
||||
false,
|
||||
&expected_prototype,
|
||||
Some(cable),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -123,6 +123,7 @@ impl Picker {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::doc_overindented_list_items)]
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
@ -1,34 +0,0 @@
|
||||
#![allow(unused_imports)]
|
||||
//! This module provides a way to parse ansi escape codes and convert them to ratatui objects.
|
||||
//!
|
||||
//! This code is a modified version of [ansi_to_tui](https://github.com/ratatui/ansi-to-tui).
|
||||
|
||||
// mod ansi;
|
||||
pub mod code;
|
||||
pub mod error;
|
||||
pub mod parser;
|
||||
pub use error::Error;
|
||||
use ratatui::text::Text;
|
||||
|
||||
/// `IntoText` will convert any type that has a `AsRef<[u8]>` to a Text.
|
||||
pub trait IntoText {
|
||||
/// Convert the type to a Text.
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
fn into_text(&self) -> Result<Text<'static>, Error>;
|
||||
/// Convert the type to a Text while trying to copy as less as possible
|
||||
#[cfg(feature = "zero-copy")]
|
||||
fn to_text(&self) -> Result<Text<'_>, Error>;
|
||||
}
|
||||
impl<T> IntoText for T
|
||||
where
|
||||
T: AsRef<[u8]>,
|
||||
{
|
||||
fn into_text(&self) -> Result<Text<'static>, Error> {
|
||||
Ok(parser::text(self.as_ref())?.1)
|
||||
}
|
||||
|
||||
#[cfg(feature = "zero-copy")]
|
||||
fn to_text(&self) -> Result<Text<'_>, Error> {
|
||||
Ok(parser::text_fast(self.as_ref())?.1)
|
||||
}
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
Copyright 2021 Uttarayan Mondal
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
@ -1,140 +0,0 @@
|
||||
use ratatui::style::Color;
|
||||
|
||||
/// This enum stores most types of ansi escape sequences
|
||||
///
|
||||
/// You can turn an escape sequence to this enum variant using
|
||||
/// `AnsiCode::from(code: u8)`
|
||||
/// This doesn't support all of them but does support most of them.
|
||||
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
#[non_exhaustive]
|
||||
pub enum AnsiCode {
|
||||
/// Reset the terminal
|
||||
Reset,
|
||||
/// Set font to bold
|
||||
Bold,
|
||||
/// Set font to faint
|
||||
Faint,
|
||||
/// Set font to italic
|
||||
Italic,
|
||||
/// Set font to underline
|
||||
Underline,
|
||||
/// Set cursor to slowblink
|
||||
SlowBlink,
|
||||
/// Set cursor to rapidblink
|
||||
RapidBlink,
|
||||
/// Invert the colors
|
||||
Reverse,
|
||||
/// Conceal text
|
||||
Conceal,
|
||||
/// Display crossed out text
|
||||
CrossedOut,
|
||||
/// Choose primary font
|
||||
PrimaryFont,
|
||||
/// Choose alternate font
|
||||
AlternateFont,
|
||||
/// Choose alternate fonts 1-9
|
||||
#[allow(dead_code)]
|
||||
AlternateFonts(u8), // = 11..19, // from 11 to 19
|
||||
/// Fraktur ? No clue
|
||||
Fraktur,
|
||||
/// Turn off bold
|
||||
BoldOff,
|
||||
/// Set text to normal
|
||||
Normal,
|
||||
/// Turn off Italic
|
||||
NotItalic,
|
||||
/// Turn off underline
|
||||
UnderlineOff,
|
||||
/// Turn off blinking
|
||||
BlinkOff,
|
||||
// 26 ?
|
||||
/// Don't invert colors
|
||||
InvertOff,
|
||||
/// Reveal text
|
||||
Reveal,
|
||||
/// Turn off Crossedout text
|
||||
CrossedOutOff,
|
||||
/// Set foreground color (4-bit)
|
||||
ForegroundColor(Color), //, 31..37//Issue 60553 https://github.com/rust-lang/rust/issues/60553
|
||||
/// Set foreground color (8-bit and 24-bit)
|
||||
SetForegroundColor,
|
||||
/// Default foreground color
|
||||
DefaultForegroundColor,
|
||||
/// Set background color (4-bit)
|
||||
BackgroundColor(Color), // 41..47
|
||||
/// Set background color (8-bit and 24-bit)
|
||||
SetBackgroundColor,
|
||||
/// Default background color
|
||||
DefaultBackgroundColor, // 49
|
||||
/// Other / non supported escape codes
|
||||
Code(Vec<u8>),
|
||||
}
|
||||
|
||||
impl From<u8> for AnsiCode {
|
||||
fn from(code: u8) -> Self {
|
||||
match code {
|
||||
0 => AnsiCode::Reset,
|
||||
1 => AnsiCode::Bold,
|
||||
2 => AnsiCode::Faint,
|
||||
3 => AnsiCode::Italic,
|
||||
4 => AnsiCode::Underline,
|
||||
5 => AnsiCode::SlowBlink,
|
||||
6 => AnsiCode::RapidBlink,
|
||||
7 => AnsiCode::Reverse,
|
||||
8 => AnsiCode::Conceal,
|
||||
9 => AnsiCode::CrossedOut,
|
||||
10 => AnsiCode::PrimaryFont,
|
||||
11 => AnsiCode::AlternateFont,
|
||||
// AnsiCode::// AlternateFont = 11..19, // from 11 to 19
|
||||
20 => AnsiCode::Fraktur,
|
||||
21 => AnsiCode::BoldOff,
|
||||
22 => AnsiCode::Normal,
|
||||
23 => AnsiCode::NotItalic,
|
||||
24 => AnsiCode::UnderlineOff,
|
||||
25 => AnsiCode::BlinkOff,
|
||||
// 26 ?
|
||||
27 => AnsiCode::InvertOff,
|
||||
28 => AnsiCode::Reveal,
|
||||
29 => AnsiCode::CrossedOutOff,
|
||||
30 => AnsiCode::ForegroundColor(Color::Black),
|
||||
31 => AnsiCode::ForegroundColor(Color::Red),
|
||||
32 => AnsiCode::ForegroundColor(Color::Green),
|
||||
33 => AnsiCode::ForegroundColor(Color::Yellow),
|
||||
34 => AnsiCode::ForegroundColor(Color::Blue),
|
||||
35 => AnsiCode::ForegroundColor(Color::Magenta),
|
||||
36 => AnsiCode::ForegroundColor(Color::Cyan),
|
||||
37 => AnsiCode::ForegroundColor(Color::Gray),
|
||||
38 => AnsiCode::SetForegroundColor,
|
||||
39 => AnsiCode::DefaultForegroundColor,
|
||||
40 => AnsiCode::BackgroundColor(Color::Black),
|
||||
41 => AnsiCode::BackgroundColor(Color::Red),
|
||||
42 => AnsiCode::BackgroundColor(Color::Green),
|
||||
43 => AnsiCode::BackgroundColor(Color::Yellow),
|
||||
44 => AnsiCode::BackgroundColor(Color::Blue),
|
||||
45 => AnsiCode::BackgroundColor(Color::Magenta),
|
||||
46 => AnsiCode::BackgroundColor(Color::Cyan),
|
||||
47 => AnsiCode::BackgroundColor(Color::Gray),
|
||||
48 => AnsiCode::SetBackgroundColor,
|
||||
49 => AnsiCode::DefaultBackgroundColor,
|
||||
90 => AnsiCode::ForegroundColor(Color::DarkGray),
|
||||
91 => AnsiCode::ForegroundColor(Color::LightRed),
|
||||
92 => AnsiCode::ForegroundColor(Color::LightGreen),
|
||||
93 => AnsiCode::ForegroundColor(Color::LightYellow),
|
||||
94 => AnsiCode::ForegroundColor(Color::LightBlue),
|
||||
95 => AnsiCode::ForegroundColor(Color::LightMagenta),
|
||||
96 => AnsiCode::ForegroundColor(Color::LightCyan),
|
||||
#[allow(clippy::match_same_arms)]
|
||||
97 => AnsiCode::ForegroundColor(Color::White),
|
||||
100 => AnsiCode::BackgroundColor(Color::DarkGray),
|
||||
101 => AnsiCode::BackgroundColor(Color::LightRed),
|
||||
102 => AnsiCode::BackgroundColor(Color::LightGreen),
|
||||
103 => AnsiCode::BackgroundColor(Color::LightYellow),
|
||||
104 => AnsiCode::BackgroundColor(Color::LightBlue),
|
||||
105 => AnsiCode::BackgroundColor(Color::LightMagenta),
|
||||
106 => AnsiCode::BackgroundColor(Color::LightCyan),
|
||||
107 => AnsiCode::ForegroundColor(Color::White),
|
||||
code => AnsiCode::Code(vec![code]),
|
||||
}
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
/// This enum stores the error types
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum Error {
|
||||
/// Stack is empty (should never happen)
|
||||
#[error("Internal error: stack is empty")]
|
||||
NomError(String),
|
||||
|
||||
/// Error parsing the input as utf-8
|
||||
#[cfg(feature = "simd")]
|
||||
/// Cannot determine the foreground or background
|
||||
#[error("{0:?}")]
|
||||
Utf8Error(#[from] simdutf8::basic::Utf8Error),
|
||||
|
||||
#[cfg(not(feature = "simd"))]
|
||||
/// Cannot determine the foreground or background
|
||||
#[error("{0:?}")]
|
||||
Utf8Error(#[from] std::string::FromUtf8Error),
|
||||
}
|
||||
|
||||
impl From<nom::Err<nom::error::Error<&[u8]>>> for Error {
|
||||
fn from(e: nom::Err<nom::error::Error<&[u8]>>) -> Self {
|
||||
Self::NomError(format!("{:?}", e))
|
||||
}
|
||||
}
|
@ -1,461 +0,0 @@
|
||||
use crate::preview::ansi::code::AnsiCode;
|
||||
use nom::{
|
||||
branch::alt,
|
||||
bytes::complete::{tag, take, take_till, take_while},
|
||||
character::{
|
||||
complete::{char, i64, not_line_ending, u8},
|
||||
is_alphabetic,
|
||||
},
|
||||
combinator::{map_res, opt},
|
||||
multi::fold_many0,
|
||||
sequence::{delimited, preceded, tuple},
|
||||
IResult, Parser,
|
||||
};
|
||||
use ratatui::{
|
||||
style::{Color, Modifier, Style, Stylize},
|
||||
text::{Line, Span, Text},
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
enum ColorType {
|
||||
/// Eight Bit color
|
||||
EightBit,
|
||||
/// 24-bit color or true color
|
||||
TrueColor,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct AnsiItem {
|
||||
code: AnsiCode,
|
||||
color: Option<Color>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct AnsiStates {
|
||||
pub items: SmallVec<[AnsiItem; 2]>,
|
||||
pub style: Style,
|
||||
}
|
||||
|
||||
impl From<AnsiStates> for Style {
|
||||
fn from(states: AnsiStates) -> Self {
|
||||
let mut style = states.style;
|
||||
for item in states.items {
|
||||
match item.code {
|
||||
AnsiCode::Bold => style = style.add_modifier(Modifier::BOLD),
|
||||
AnsiCode::Faint => style = style.add_modifier(Modifier::DIM),
|
||||
AnsiCode::Normal => {
|
||||
style = style
|
||||
.remove_modifier(Modifier::BOLD)
|
||||
.remove_modifier(Modifier::DIM);
|
||||
}
|
||||
AnsiCode::Italic => {
|
||||
style = style.add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
AnsiCode::Underline => {
|
||||
style = style.add_modifier(Modifier::UNDERLINED);
|
||||
}
|
||||
AnsiCode::SlowBlink => {
|
||||
style = style.add_modifier(Modifier::SLOW_BLINK);
|
||||
}
|
||||
AnsiCode::RapidBlink => {
|
||||
style = style.add_modifier(Modifier::RAPID_BLINK);
|
||||
}
|
||||
AnsiCode::Reverse => {
|
||||
style = style.add_modifier(Modifier::REVERSED);
|
||||
}
|
||||
AnsiCode::Conceal => {
|
||||
style = style.add_modifier(Modifier::HIDDEN);
|
||||
}
|
||||
AnsiCode::CrossedOut => {
|
||||
style = style.add_modifier(Modifier::CROSSED_OUT);
|
||||
}
|
||||
AnsiCode::DefaultForegroundColor => {
|
||||
style = style.fg(Color::Reset);
|
||||
}
|
||||
AnsiCode::SetForegroundColor => {
|
||||
if let Some(color) = item.color {
|
||||
style = style.fg(color);
|
||||
}
|
||||
}
|
||||
AnsiCode::ForegroundColor(color) => style = style.fg(color),
|
||||
AnsiCode::Reset => style = style.fg(Color::Reset),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
style
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
pub(crate) fn text(mut s: &[u8]) -> IResult<&[u8], Text<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
let mut last_style = Style::new();
|
||||
while let Ok((remaining, (line, style))) = line(last_style)(s) {
|
||||
lines.push(line);
|
||||
last_style = style;
|
||||
s = remaining;
|
||||
if s.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok((s, Text::from(lines)))
|
||||
}
|
||||
|
||||
#[cfg(feature = "zero-copy")]
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
pub(crate) fn text_fast(mut s: &[u8]) -> IResult<&[u8], Text<'_>> {
|
||||
let mut lines = Vec::new();
|
||||
let mut last = Style::new();
|
||||
while let Ok((c, (line, style))) = line_fast(last)(s) {
|
||||
lines.push(line);
|
||||
last = style;
|
||||
s = c;
|
||||
if s.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok((s, Text::from(lines)))
|
||||
}
|
||||
|
||||
fn line(
|
||||
style: Style,
|
||||
) -> impl Fn(&[u8]) -> IResult<&[u8], (Line<'static>, Style)> {
|
||||
// let style_: Style = Default::default();
|
||||
move |s: &[u8]| -> IResult<&[u8], (Line<'static>, Style)> {
|
||||
// consume s until a line ending is found
|
||||
let (s, mut text) = not_line_ending(s)?;
|
||||
// discard the line ending
|
||||
let (s, _) = opt(alt((tag("\r\n"), tag("\n"))))(s)?;
|
||||
let mut spans = Vec::new();
|
||||
// carry over the style from the previous line (passed in as an argument)
|
||||
let mut last_style = style;
|
||||
// parse spans from the given text
|
||||
while let Ok((remaining, span)) = span(last_style)(text) {
|
||||
// Since reset now tracks separately we can skip the reset check
|
||||
last_style = last_style.patch(span.style);
|
||||
|
||||
if !span.content.is_empty() {
|
||||
spans.push(span);
|
||||
}
|
||||
text = remaining;
|
||||
if text.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: what is last_style here
|
||||
Ok((s, (Line::from(spans), last_style)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "zero-copy")]
|
||||
fn line_fast(
|
||||
style: Style,
|
||||
) -> impl Fn(&[u8]) -> IResult<&[u8], (Line<'_>, Style)> {
|
||||
// let style_: Style = Default::default();
|
||||
move |s: &[u8]| -> IResult<&[u8], (Line<'_>, Style)> {
|
||||
let (s, mut text) = not_line_ending(s)?;
|
||||
let (s, _) = opt(alt((tag("\r\n"), tag("\n"))))(s)?;
|
||||
let mut spans = Vec::new();
|
||||
let mut last = style;
|
||||
while let Ok((s, span)) = span_fast(last)(text) {
|
||||
last = last.patch(span.style);
|
||||
// If the spans is empty then it might be possible that the style changes
|
||||
// but there is no text change
|
||||
if !span.content.is_empty() {
|
||||
spans.push(span);
|
||||
}
|
||||
text = s;
|
||||
if text.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((s, (Line::from(spans), last)))
|
||||
}
|
||||
}
|
||||
|
||||
// fn span(s: &[u8]) -> IResult<&[u8], tui::text::Span> {
|
||||
fn span(
|
||||
last: Style,
|
||||
) -> impl Fn(&[u8]) -> IResult<&[u8], Span<'static>, nom::error::Error<&[u8]>>
|
||||
{
|
||||
move |s: &[u8]| -> IResult<&[u8], Span<'static>> {
|
||||
let mut last_style = last;
|
||||
// optionally consume a style
|
||||
let (s, maybe_style) = opt(style(last_style))(s)?;
|
||||
|
||||
// consume until an escape sequence is found
|
||||
#[cfg(feature = "simd")]
|
||||
let (s, text) = map_res(take_while(|c| c != b'\x1b'), |t| {
|
||||
simdutf8::basic::from_utf8(t)
|
||||
})(s)?;
|
||||
|
||||
#[cfg(not(feature = "simd"))]
|
||||
let (s, text) =
|
||||
map_res(take_while(|c| c != b'\x1b'), |t| std::str::from_utf8(t))(
|
||||
s,
|
||||
)?;
|
||||
|
||||
// if a style was found, patch the last style with it
|
||||
if let Some(st) = maybe_style.flatten() {
|
||||
last_style = last_style.patch(st);
|
||||
}
|
||||
|
||||
Ok((s, Span::styled(text.to_owned(), last_style)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "zero-copy")]
|
||||
fn span_fast(
|
||||
last: Style,
|
||||
) -> impl Fn(&[u8]) -> IResult<&[u8], Span<'_>, nom::error::Error<&[u8]>> {
|
||||
move |s: &[u8]| -> IResult<&[u8], Span<'_>> {
|
||||
let mut last = last;
|
||||
let (s, style) = opt(style(last))(s)?;
|
||||
|
||||
#[cfg(feature = "simd")]
|
||||
let (s, text) = map_res(take_while(|c| c != b'\x1b'), |t| {
|
||||
simdutf8::basic::from_utf8(t)
|
||||
})(s)?;
|
||||
|
||||
#[cfg(not(feature = "simd"))]
|
||||
let (s, text) =
|
||||
map_res(take_while(|c| c != b'\x1b'), |t| std::str::from_utf8(t))(
|
||||
s,
|
||||
)?;
|
||||
|
||||
if let Some(style) = style.flatten() {
|
||||
last = last.patch(style);
|
||||
}
|
||||
|
||||
Ok((s, Span::styled(text, last)))
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn style(
|
||||
style: Style,
|
||||
) -> impl Fn(&[u8]) -> IResult<&[u8], Option<Style>, nom::error::Error<&[u8]>>
|
||||
{
|
||||
move |s: &[u8]| -> IResult<&[u8], Option<Style>> {
|
||||
let (s, r) = match opt(ansi_sgr_code)(s)? {
|
||||
(s, Some(r)) => {
|
||||
// This would correspond to an implicit reset code (\x1b[m)
|
||||
if r.is_empty() {
|
||||
let mut sv = SmallVec::<[AnsiItem; 2]>::new();
|
||||
sv.push(AnsiItem {
|
||||
code: AnsiCode::Reset,
|
||||
color: None,
|
||||
});
|
||||
(s, Some(sv))
|
||||
} else {
|
||||
(s, Some(r))
|
||||
}
|
||||
}
|
||||
(s, None) => {
|
||||
let (s, _) = any_escape_sequence(s)?;
|
||||
(s, None)
|
||||
}
|
||||
};
|
||||
Ok((s, r.map(|r| Style::from(AnsiStates { style, items: r }))))
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete ANSI SGR code
|
||||
fn ansi_sgr_code(
|
||||
s: &[u8],
|
||||
) -> IResult<&[u8], smallvec::SmallVec<[AnsiItem; 2]>, nom::error::Error<&[u8]>>
|
||||
{
|
||||
delimited(
|
||||
tag("\x1b["),
|
||||
fold_many0(
|
||||
ansi_sgr_item,
|
||||
smallvec::SmallVec::new,
|
||||
|mut items, item| {
|
||||
items.push(item);
|
||||
items
|
||||
},
|
||||
),
|
||||
char('m'),
|
||||
)(s)
|
||||
}
|
||||
|
||||
fn any_escape_sequence(s: &[u8]) -> IResult<&[u8], Option<&[u8]>> {
|
||||
// Attempt to consume most escape codes, including a single escape char.
|
||||
//
|
||||
// Most escape codes begin with ESC[ and are terminated by an alphabetic character,
|
||||
// but OSC codes begin with ESC] and are terminated by an ascii bell (\x07)
|
||||
// and a truncated/invalid code may just be a standalone ESC or not be terminated.
|
||||
//
|
||||
// We should try to consume as much of it as possible to match behavior of most terminals;
|
||||
// where we fail at that we should at least consume the escape char to avoid infinitely looping
|
||||
|
||||
let (input, garbage) = preceded(
|
||||
char('\x1b'),
|
||||
opt(alt((
|
||||
delimited(char('['), take_till(is_alphabetic), opt(take(1u8))),
|
||||
delimited(char(']'), take_till(|c| c == b'\x07'), opt(take(1u8))),
|
||||
))),
|
||||
)(s)?;
|
||||
Ok((input, garbage))
|
||||
}
|
||||
|
||||
/// An ANSI SGR attribute
|
||||
fn ansi_sgr_item(s: &[u8]) -> IResult<&[u8], AnsiItem> {
|
||||
let (s, c) = u8(s)?;
|
||||
let code = AnsiCode::from(c);
|
||||
let (s, color) = match code {
|
||||
AnsiCode::SetForegroundColor | AnsiCode::SetBackgroundColor => {
|
||||
let (s, _) = opt(tag(";"))(s)?;
|
||||
let (s, color) = color(s)?;
|
||||
(s, Some(color))
|
||||
}
|
||||
_ => (s, None),
|
||||
};
|
||||
let (s, _) = opt(tag(";"))(s)?;
|
||||
Ok((s, AnsiItem { code, color }))
|
||||
}
|
||||
|
||||
fn color(s: &[u8]) -> IResult<&[u8], Color> {
|
||||
let (s, c_type) = color_type(s)?;
|
||||
let (s, _) = opt(tag(";"))(s)?;
|
||||
match c_type {
|
||||
ColorType::TrueColor => {
|
||||
let (s, (r, _, g, _, b)) =
|
||||
tuple((u8, tag(";"), u8, tag(";"), u8))(s)?;
|
||||
Ok((s, Color::Rgb(r, g, b)))
|
||||
}
|
||||
ColorType::EightBit => {
|
||||
let (s, index) = u8(s)?;
|
||||
Ok((s, Color::Indexed(index)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn color_type(s: &[u8]) -> IResult<&[u8], ColorType> {
|
||||
let (s, t) = i64(s)?;
|
||||
// NOTE: This isn't opt because a color type must always be followed by a color
|
||||
// let (s, _) = opt(tag(";"))(s)?;
|
||||
let (s, _) = tag(";")(s)?;
|
||||
match t {
|
||||
2 => Ok((s, ColorType::TrueColor)),
|
||||
5 => Ok((s, ColorType::EightBit)),
|
||||
_ => Err(nom::Err::Error(nom::error::Error::new(
|
||||
s,
|
||||
nom::error::ErrorKind::Alt,
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_test() {
|
||||
let c = color(b"2;255;255;255").unwrap();
|
||||
assert_eq!(c.1, Color::Rgb(255, 255, 255));
|
||||
let c = color(b"5;255").unwrap();
|
||||
assert_eq!(c.1, Color::Indexed(255));
|
||||
let err = color(b"10;255");
|
||||
assert_ne!(err, Ok(c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_color_reset() {
|
||||
let t = text(b"\x1b[33msome arbitrary text\x1b[0m\nmore text")
|
||||
.unwrap()
|
||||
.1;
|
||||
assert_eq!(
|
||||
t,
|
||||
Text::from(vec![
|
||||
Line::from(vec![Span::styled(
|
||||
"some arbitrary text",
|
||||
Style::default().fg(Color::Yellow)
|
||||
),]),
|
||||
Line::from(Span::from("more text").fg(Color::Reset)),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_color_reset_implicit_escape() {
|
||||
let t = text(b"\x1b[33msome arbitrary text\x1b[m\nmore text")
|
||||
.unwrap()
|
||||
.1;
|
||||
assert_eq!(
|
||||
t,
|
||||
Text::from(vec![
|
||||
Line::from(vec![Span::styled(
|
||||
"some arbitrary text",
|
||||
Style::default().fg(Color::Yellow)
|
||||
),]),
|
||||
Line::from(Span::from("more text").fg(Color::Reset)),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi_items_test() {
|
||||
let sc = Style::default();
|
||||
let t = style(sc)(b"\x1b[38;2;3;3;3m").unwrap().1.unwrap();
|
||||
assert_eq!(
|
||||
t,
|
||||
Style::from(AnsiStates {
|
||||
style: sc,
|
||||
items: vec![AnsiItem {
|
||||
code: AnsiCode::SetForegroundColor,
|
||||
color: Some(Color::Rgb(3, 3, 3))
|
||||
}]
|
||||
.into()
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
style(sc)(b"\x1b[38;5;3m").unwrap().1.unwrap(),
|
||||
Style::from(AnsiStates {
|
||||
style: sc,
|
||||
items: vec![AnsiItem {
|
||||
code: AnsiCode::SetForegroundColor,
|
||||
color: Some(Color::Indexed(3))
|
||||
}]
|
||||
.into()
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
style(sc)(b"\x1b[38;5;3;48;5;3m").unwrap().1.unwrap(),
|
||||
Style::from(AnsiStates {
|
||||
style: sc,
|
||||
items: vec![
|
||||
AnsiItem {
|
||||
code: AnsiCode::SetForegroundColor,
|
||||
color: Some(Color::Indexed(3))
|
||||
},
|
||||
AnsiItem {
|
||||
code: AnsiCode::SetBackgroundColor,
|
||||
color: Some(Color::Indexed(3))
|
||||
}
|
||||
]
|
||||
.into()
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
style(sc)(b"\x1b[38;5;3;48;5;3;1m").unwrap().1.unwrap(),
|
||||
Style::from(AnsiStates {
|
||||
style: sc,
|
||||
items: vec![
|
||||
AnsiItem {
|
||||
code: AnsiCode::SetForegroundColor,
|
||||
color: Some(Color::Indexed(3))
|
||||
},
|
||||
AnsiItem {
|
||||
code: AnsiCode::SetBackgroundColor,
|
||||
color: Some(Color::Indexed(3))
|
||||
},
|
||||
AnsiItem {
|
||||
code: AnsiCode::Bold,
|
||||
color: None
|
||||
}
|
||||
]
|
||||
.into()
|
||||
})
|
||||
);
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
use rustc_hash::FxHashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::preview::Preview;
|
||||
use crate::utils::cache::RingSet;
|
||||
use tracing::debug;
|
||||
|
||||
/// Default size of the preview cache: 100 entries.
|
||||
///
|
||||
/// This does seem kind of arbitrary for now, will need to play around with it.
|
||||
/// At the moment, files over 4 MB are not previewed, so the cache size
|
||||
/// should never exceed 400 MB.
|
||||
const DEFAULT_PREVIEW_CACHE_SIZE: usize = 100;
|
||||
|
||||
/// A cache for previews.
|
||||
/// The cache is implemented as an LRU cache with a fixed size.
|
||||
#[derive(Debug)]
|
||||
pub struct PreviewCache {
|
||||
entries: FxHashMap<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: FxHashMap::default(),
|
||||
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(), Arc::clone(preview));
|
||||
if let Some(oldest_key) = self.ring_set.push(key) {
|
||||
debug!("Cache full, removing oldest entry: {}", oldest_key);
|
||||
self.entries.remove(&oldest_key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the preview for the given key, or insert a new preview if it doesn't exist.
|
||||
#[allow(dead_code)]
|
||||
pub fn get_or_insert<F>(&mut self, key: String, f: F) -> Arc<Preview>
|
||||
where
|
||||
F: FnOnce() -> Preview,
|
||||
{
|
||||
if let Some(preview) = self.get(&key) {
|
||||
preview
|
||||
} else {
|
||||
let preview = Arc::new(f());
|
||||
self.insert(key, &preview);
|
||||
preview
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PreviewCache {
|
||||
fn default() -> Self {
|
||||
PreviewCache::new(DEFAULT_PREVIEW_CACHE_SIZE)
|
||||
}
|
||||
}
|
@ -1,272 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::channels::entry::{Entry, PreviewType};
|
||||
use devicons::FileIcon;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
pub mod ansi;
|
||||
pub mod cache;
|
||||
pub mod previewers;
|
||||
|
||||
// previewer types
|
||||
use crate::utils::cache::RingSet;
|
||||
use crate::utils::image::ImagePreviewWidget;
|
||||
use crate::utils::syntax::HighlightedLines;
|
||||
pub use previewers::basic::BasicPreviewer;
|
||||
pub use previewers::basic::BasicPreviewerConfig;
|
||||
pub use previewers::command::CommandPreviewer;
|
||||
pub use previewers::command::CommandPreviewerConfig;
|
||||
pub use previewers::env::EnvVarPreviewer;
|
||||
pub use previewers::env::EnvVarPreviewerConfig;
|
||||
pub use previewers::files::FilePreviewer;
|
||||
pub use previewers::files::FilePreviewerConfig;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Hash)]
|
||||
pub enum PreviewContent {
|
||||
Empty,
|
||||
FileTooLarge,
|
||||
SyntectHighlightedText(HighlightedLines),
|
||||
Loading,
|
||||
Timeout,
|
||||
NotSupported,
|
||||
PlainText(Vec<String>),
|
||||
PlainTextWrapped(String),
|
||||
AnsiText(String),
|
||||
Image(ImagePreviewWidget),
|
||||
}
|
||||
|
||||
impl PreviewContent {
|
||||
pub fn total_lines(&self) -> u16 {
|
||||
match self {
|
||||
PreviewContent::SyntectHighlightedText(hl_lines) => {
|
||||
hl_lines.lines.len().try_into().unwrap_or(u16::MAX)
|
||||
}
|
||||
PreviewContent::PlainText(lines) => {
|
||||
lines.len().try_into().unwrap_or(u16::MAX)
|
||||
}
|
||||
PreviewContent::AnsiText(text) => {
|
||||
text.lines().count().try_into().unwrap_or(u16::MAX)
|
||||
}
|
||||
PreviewContent::Image(image) => {
|
||||
image.height().try_into().unwrap_or(u16::MAX)
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const PREVIEW_NOT_SUPPORTED_MSG: &str =
|
||||
"Preview for this file type is not supported";
|
||||
pub const FILE_TOO_LARGE_MSG: &str = "File too large";
|
||||
pub const LOADING_MSG: &str = "Loading...";
|
||||
pub const TIMEOUT_MSG: &str = "Preview timed out";
|
||||
|
||||
/// A preview of an entry.
|
||||
///
|
||||
/// # Fields
|
||||
/// - `title`: The title of the preview.
|
||||
/// - `content`: The content of the preview.
|
||||
#[derive(Clone, Debug, PartialEq, Hash)]
|
||||
pub struct Preview {
|
||||
pub title: String,
|
||||
pub content: PreviewContent,
|
||||
pub icon: Option<FileIcon>,
|
||||
/// If the preview is partial, this field contains the byte offset
|
||||
/// up to which the preview holds.
|
||||
pub partial_offset: Option<usize>,
|
||||
pub total_lines: u16,
|
||||
}
|
||||
|
||||
impl Default for Preview {
|
||||
fn default() -> Self {
|
||||
Preview {
|
||||
title: String::new(),
|
||||
content: PreviewContent::Empty,
|
||||
icon: None,
|
||||
partial_offset: None,
|
||||
total_lines: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Preview {
|
||||
pub fn new(
|
||||
title: String,
|
||||
content: PreviewContent,
|
||||
icon: Option<FileIcon>,
|
||||
partial_offset: Option<usize>,
|
||||
total_lines: u16,
|
||||
) -> Self {
|
||||
Preview {
|
||||
title,
|
||||
content,
|
||||
icon,
|
||||
partial_offset,
|
||||
total_lines,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Hash)]
|
||||
pub struct PreviewState {
|
||||
pub enabled: bool,
|
||||
pub preview: Arc<Preview>,
|
||||
pub scroll: u16,
|
||||
pub target_line: Option<u16>,
|
||||
}
|
||||
|
||||
const PREVIEW_MIN_SCROLL_LINES: u16 = 3;
|
||||
|
||||
impl PreviewState {
|
||||
pub fn new(
|
||||
enabled: bool,
|
||||
preview: Arc<Preview>,
|
||||
scroll: u16,
|
||||
target_line: Option<u16>,
|
||||
) -> Self {
|
||||
PreviewState {
|
||||
enabled,
|
||||
preview,
|
||||
scroll,
|
||||
target_line,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_down(&mut self, offset: u16) {
|
||||
self.scroll = self.scroll.saturating_add(offset).min(
|
||||
self.preview
|
||||
.total_lines
|
||||
.saturating_sub(PREVIEW_MIN_SCROLL_LINES),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn scroll_up(&mut self, offset: u16) {
|
||||
self.scroll = self.scroll.saturating_sub(offset);
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.preview = Arc::new(Preview::default());
|
||||
self.scroll = 0;
|
||||
self.target_line = None;
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
&mut self,
|
||||
preview: Arc<Preview>,
|
||||
scroll: u16,
|
||||
target_line: Option<u16>,
|
||||
) {
|
||||
if self.preview.title != preview.title {
|
||||
self.preview = preview;
|
||||
self.scroll = scroll;
|
||||
self.target_line = target_line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Previewer {
|
||||
basic: BasicPreviewer,
|
||||
file: FilePreviewer,
|
||||
env_var: EnvVarPreviewer,
|
||||
command: CommandPreviewer,
|
||||
requests: RingSet<Entry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PreviewerConfig {
|
||||
basic: BasicPreviewerConfig,
|
||||
file: FilePreviewerConfig,
|
||||
env_var: EnvVarPreviewerConfig,
|
||||
command: CommandPreviewerConfig,
|
||||
}
|
||||
|
||||
impl PreviewerConfig {
|
||||
pub fn basic(mut self, config: BasicPreviewerConfig) -> Self {
|
||||
self.basic = config;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn file(mut self, config: FilePreviewerConfig) -> Self {
|
||||
self.file = config;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn env_var(mut self, config: EnvVarPreviewerConfig) -> Self {
|
||||
self.env_var = config;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
const REQUEST_STACK_SIZE: usize = 10;
|
||||
|
||||
impl Previewer {
|
||||
pub fn new(config: Option<PreviewerConfig>) -> Self {
|
||||
let config = config.unwrap_or_default();
|
||||
Previewer {
|
||||
basic: BasicPreviewer::new(Some(config.basic)),
|
||||
file: FilePreviewer::new(Some(config.file)),
|
||||
env_var: EnvVarPreviewer::new(Some(config.env_var)),
|
||||
command: CommandPreviewer::new(Some(config.command)),
|
||||
requests: RingSet::with_capacity(REQUEST_STACK_SIZE),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_request(
|
||||
&mut self,
|
||||
entry: &Entry,
|
||||
preview_window: Option<Rect>,
|
||||
) -> Option<Arc<Preview>> {
|
||||
match &entry.preview_type {
|
||||
PreviewType::Basic => Some(self.basic.preview(entry)),
|
||||
PreviewType::EnvVar => Some(self.env_var.preview(entry)),
|
||||
PreviewType::Files => self.file.preview(entry, preview_window),
|
||||
PreviewType::Command(cmd) => self.command.preview(entry, cmd),
|
||||
PreviewType::None => Some(Arc::new(Preview::default())),
|
||||
}
|
||||
}
|
||||
|
||||
fn cached(&self, entry: &Entry) -> Option<Arc<Preview>> {
|
||||
match &entry.preview_type {
|
||||
PreviewType::Basic => Some(self.basic.preview(entry)),
|
||||
PreviewType::EnvVar => Some(self.env_var.preview(entry)),
|
||||
PreviewType::Files => self.file.cached(entry),
|
||||
PreviewType::Command(_) => self.command.cached(entry),
|
||||
PreviewType::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
// we could use a target scroll here to make the previewer
|
||||
// faster, but since it's already running in the background and quite
|
||||
// fast for most standard file sizes, plus we're caching the previews,
|
||||
// I'm not sure the extra complexity is worth it.
|
||||
pub fn preview(
|
||||
&mut self,
|
||||
entry: &Entry,
|
||||
preview_window: Option<Rect>,
|
||||
) -> Option<Arc<Preview>> {
|
||||
// check if we have a preview for the current request
|
||||
if let Some(preview) = self.cached(entry) {
|
||||
return Some(preview);
|
||||
}
|
||||
|
||||
// otherwise, if we haven't acknowledged the request yet, acknowledge it
|
||||
self.requests.push(entry.clone());
|
||||
|
||||
// lookup request stack and return the most recent preview available
|
||||
for request in self.requests.back_to_front() {
|
||||
if let Some(preview) =
|
||||
self.dispatch_request(&request, preview_window)
|
||||
{
|
||||
return Some(preview);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn set_config(&mut self, config: PreviewerConfig) {
|
||||
self.basic = BasicPreviewer::new(Some(config.basic));
|
||||
self.file = FilePreviewer::new(Some(config.file));
|
||||
self.env_var = EnvVarPreviewer::new(Some(config.env_var));
|
||||
}
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::channels::entry::Entry;
|
||||
use crate::preview::{Preview, PreviewContent};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct BasicPreviewer {
|
||||
_config: BasicPreviewerConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct BasicPreviewerConfig {}
|
||||
|
||||
impl BasicPreviewer {
|
||||
pub fn new(config: Option<BasicPreviewerConfig>) -> Self {
|
||||
BasicPreviewer {
|
||||
_config: config.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn preview(&self, entry: &Entry) -> Arc<Preview> {
|
||||
Arc::new(Preview {
|
||||
title: entry.name.clone(),
|
||||
content: PreviewContent::PlainTextWrapped(entry.name.clone()),
|
||||
icon: entry.icon,
|
||||
partial_offset: None,
|
||||
total_lines: 1,
|
||||
})
|
||||
}
|
||||
}
|
@ -1,292 +0,0 @@
|
||||
use crate::channels::entry::{Entry, PreviewCommand};
|
||||
use crate::preview::cache::PreviewCache;
|
||||
use crate::preview::{Preview, PreviewContent};
|
||||
use crate::utils::command::shell_command;
|
||||
use parking_lot::Mutex;
|
||||
use regex::Regex;
|
||||
use rustc_hash::FxHashSet;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tracing::debug;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct CommandPreviewer {
|
||||
cache: Arc<Mutex<PreviewCache>>,
|
||||
config: CommandPreviewerConfig,
|
||||
concurrent_preview_tasks: Arc<AtomicU8>,
|
||||
in_flight_previews: Arc<Mutex<FxHashSet<String>>>,
|
||||
command_re: Regex,
|
||||
}
|
||||
|
||||
impl Default for CommandPreviewer {
|
||||
fn default() -> Self {
|
||||
CommandPreviewer::new(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandPreviewerConfig {
|
||||
delimiter: String,
|
||||
}
|
||||
|
||||
const DEFAULT_DELIMITER: &str = " ";
|
||||
|
||||
impl Default for CommandPreviewerConfig {
|
||||
fn default() -> Self {
|
||||
CommandPreviewerConfig {
|
||||
delimiter: String::from(DEFAULT_DELIMITER),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandPreviewerConfig {
|
||||
pub fn new(delimiter: &str) -> Self {
|
||||
CommandPreviewerConfig {
|
||||
delimiter: String::from(delimiter),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_PREVIEW_TASKS: u8 = 3;
|
||||
|
||||
impl CommandPreviewer {
|
||||
pub fn new(config: Option<CommandPreviewerConfig>) -> Self {
|
||||
let config = config.unwrap_or_default();
|
||||
CommandPreviewer {
|
||||
cache: Arc::new(Mutex::new(PreviewCache::default())),
|
||||
config,
|
||||
concurrent_preview_tasks: Arc::new(AtomicU8::new(0)),
|
||||
in_flight_previews: Arc::new(Mutex::new(FxHashSet::default())),
|
||||
command_re: Regex::new(r"\{(\d+)\}").unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cached(&self, entry: &Entry) -> Option<Arc<Preview>> {
|
||||
self.cache.lock().get(&entry.name)
|
||||
}
|
||||
|
||||
pub fn preview(
|
||||
&mut self,
|
||||
entry: &Entry,
|
||||
command: &PreviewCommand,
|
||||
) -> Option<Arc<Preview>> {
|
||||
if let Some(preview) = self.cached(entry) {
|
||||
Some(preview)
|
||||
} else {
|
||||
// preview is not in cache, spawn a task to compute the preview
|
||||
debug!("Preview cache miss for {:?}", entry.name);
|
||||
self.handle_preview_request(entry, command);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_preview_request(
|
||||
&mut self,
|
||||
entry: &Entry,
|
||||
command: &PreviewCommand,
|
||||
) {
|
||||
if self.in_flight_previews.lock().contains(&entry.name) {
|
||||
debug!("Preview already in flight for {:?}", entry.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if self.concurrent_preview_tasks.load(Ordering::Relaxed)
|
||||
< MAX_CONCURRENT_PREVIEW_TASKS
|
||||
{
|
||||
self.in_flight_previews.lock().insert(entry.name.clone());
|
||||
self.concurrent_preview_tasks
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
let cache = self.cache.clone();
|
||||
let entry_c = entry.clone();
|
||||
let concurrent_tasks = self.concurrent_preview_tasks.clone();
|
||||
let command = command.clone();
|
||||
let in_flight_previews = self.in_flight_previews.clone();
|
||||
let command_re = self.command_re.clone();
|
||||
tokio::spawn(async move {
|
||||
try_preview(
|
||||
&command,
|
||||
&entry_c,
|
||||
&cache,
|
||||
&concurrent_tasks,
|
||||
&in_flight_previews,
|
||||
&command_re,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
debug!(
|
||||
"Too many concurrent preview tasks, skipping {:?}",
|
||||
entry.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format the command with the entry name and provided placeholders
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use television::channels::entry::{PreviewCommand, PreviewType, Entry};
|
||||
/// use television::preview::previewers::command::format_command;
|
||||
///
|
||||
/// let command = PreviewCommand {
|
||||
/// command: "something {} {2} {0}".to_string(),
|
||||
/// delimiter: ":".to_string(),
|
||||
/// };
|
||||
/// let entry = Entry::new("a:given:entry:to:preview".to_string(), PreviewType::Command(command.clone()));
|
||||
/// let formatted_command = format_command(&command, &entry, ®ex::Regex::new(r"\{(\d+)\}").unwrap());
|
||||
///
|
||||
/// assert_eq!(formatted_command, "something 'a:given:entry:to:preview' 'entry' 'a'");
|
||||
/// ```
|
||||
pub fn format_command(
|
||||
command: &PreviewCommand,
|
||||
entry: &Entry,
|
||||
command_re: &Regex,
|
||||
) -> String {
|
||||
let parts = entry.name.split(&command.delimiter).collect::<Vec<&str>>();
|
||||
debug!("Parts: {:?}", parts);
|
||||
|
||||
let mut formatted_command = command
|
||||
.command
|
||||
.replace("{}", format!("'{}'", entry.name).as_str());
|
||||
|
||||
formatted_command = command_re
|
||||
.replace_all(&formatted_command, |caps: ®ex::Captures| {
|
||||
let index =
|
||||
caps.get(1).unwrap().as_str().parse::<usize>().unwrap();
|
||||
format!("'{}'", parts[index])
|
||||
})
|
||||
.to_string();
|
||||
|
||||
formatted_command
|
||||
}
|
||||
|
||||
pub fn try_preview(
|
||||
command: &PreviewCommand,
|
||||
entry: &Entry,
|
||||
cache: &Arc<Mutex<PreviewCache>>,
|
||||
concurrent_tasks: &Arc<AtomicU8>,
|
||||
in_flight_previews: &Arc<Mutex<FxHashSet<String>>>,
|
||||
command_re: &Regex,
|
||||
) {
|
||||
debug!("Computing preview for {:?}", entry.name);
|
||||
let command = format_command(command, entry, command_re);
|
||||
debug!("Formatted preview command: {:?}", command);
|
||||
|
||||
let child = shell_command(false)
|
||||
.arg(&command)
|
||||
.output()
|
||||
.expect("failed to execute process");
|
||||
|
||||
if child.status.success() {
|
||||
let content = String::from_utf8_lossy(&child.stdout);
|
||||
let preview = Arc::new(Preview::new(
|
||||
entry.name.clone(),
|
||||
PreviewContent::AnsiText(content.to_string()),
|
||||
None,
|
||||
None,
|
||||
u16::try_from(content.lines().count()).unwrap_or(u16::MAX),
|
||||
));
|
||||
|
||||
cache.lock().insert(entry.name.clone(), &preview);
|
||||
} else {
|
||||
let content = String::from_utf8_lossy(&child.stderr);
|
||||
let preview = Arc::new(Preview::new(
|
||||
entry.name.clone(),
|
||||
PreviewContent::AnsiText(content.to_string()),
|
||||
None,
|
||||
None,
|
||||
u16::try_from(content.lines().count()).unwrap_or(u16::MAX),
|
||||
));
|
||||
cache.lock().insert(entry.name.clone(), &preview);
|
||||
}
|
||||
|
||||
concurrent_tasks.fetch_sub(1, Ordering::Relaxed);
|
||||
in_flight_previews.lock().remove(&entry.name);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::channels::entry::{Entry, PreviewType};
|
||||
|
||||
#[test]
|
||||
fn test_format_command() {
|
||||
let command = PreviewCommand {
|
||||
command: "something {} {2} {0}".to_string(),
|
||||
delimiter: ":".to_string(),
|
||||
};
|
||||
let entry = Entry::new(
|
||||
"an:entry:to:preview".to_string(),
|
||||
PreviewType::Command(command.clone()),
|
||||
);
|
||||
let formatted_command = format_command(
|
||||
&command,
|
||||
&entry,
|
||||
&Regex::new(r"\{(\d+)\}").unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
formatted_command,
|
||||
"something 'an:entry:to:preview' 'to' 'an'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_command_no_placeholders() {
|
||||
let command = PreviewCommand {
|
||||
command: "something".to_string(),
|
||||
delimiter: ":".to_string(),
|
||||
};
|
||||
let entry = Entry::new(
|
||||
"an:entry:to:preview".to_string(),
|
||||
PreviewType::Command(command.clone()),
|
||||
);
|
||||
let formatted_command = format_command(
|
||||
&command,
|
||||
&entry,
|
||||
&Regex::new(r"\{(\d+)\}").unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(formatted_command, "something");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_command_with_global_placeholder_only() {
|
||||
let command = PreviewCommand {
|
||||
command: "something {}".to_string(),
|
||||
delimiter: ":".to_string(),
|
||||
};
|
||||
let entry = Entry::new(
|
||||
"an:entry:to:preview".to_string(),
|
||||
PreviewType::Command(command.clone()),
|
||||
);
|
||||
let formatted_command = format_command(
|
||||
&command,
|
||||
&entry,
|
||||
&Regex::new(r"\{(\d+)\}").unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(formatted_command, "something 'an:entry:to:preview'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_command_with_positional_placeholders_only() {
|
||||
let command = PreviewCommand {
|
||||
command: "something {0} -t {2}".to_string(),
|
||||
delimiter: ":".to_string(),
|
||||
};
|
||||
let entry = Entry::new(
|
||||
"an:entry:to:preview".to_string(),
|
||||
PreviewType::Command(command.clone()),
|
||||
);
|
||||
let formatted_command = format_command(
|
||||
&command,
|
||||
&entry,
|
||||
&Regex::new(r"\{(\d+)\}").unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(formatted_command, "something 'an' -t 'to'");
|
||||
}
|
||||
}
|
@ -1,50 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::channels::entry;
|
||||
use crate::preview::{Preview, PreviewContent};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EnvVarPreviewer {
|
||||
_config: EnvVarPreviewerConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EnvVarPreviewerConfig {}
|
||||
|
||||
impl EnvVarPreviewer {
|
||||
pub fn new(config: Option<EnvVarPreviewerConfig>) -> Self {
|
||||
EnvVarPreviewer {
|
||||
_config: config.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn preview(&self, entry: &entry::Entry) -> Arc<Preview> {
|
||||
let content = entry.value.as_ref().map(|preview| {
|
||||
maybe_add_newline_after_colon(preview, &entry.name)
|
||||
});
|
||||
let total_lines = content.as_ref().map_or_else(
|
||||
|| 1,
|
||||
|c| u16::try_from(c.lines().count()).unwrap_or(u16::MAX),
|
||||
);
|
||||
|
||||
Arc::new(Preview {
|
||||
title: entry.name.clone(),
|
||||
content: match content {
|
||||
Some(content) => PreviewContent::PlainTextWrapped(content),
|
||||
None => PreviewContent::Empty,
|
||||
},
|
||||
icon: entry.icon,
|
||||
partial_offset: None,
|
||||
total_lines,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
@ -1,379 +0,0 @@
|
||||
use crate::utils::files::{read_into_lines_capped, ReadResult};
|
||||
use crate::utils::syntax::HighlightedLines;
|
||||
use image::ImageReader;
|
||||
use parking_lot::Mutex;
|
||||
use ratatui::layout::Rect;
|
||||
use rustc_hash::{FxBuildHasher, FxHashSet};
|
||||
use std::collections::HashSet;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, Seek};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{
|
||||
atomic::{AtomicU8, Ordering},
|
||||
Arc,
|
||||
};
|
||||
use syntect::{highlighting::Theme, parsing::SyntaxSet};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
use crate::channels::entry;
|
||||
use crate::preview::cache::PreviewCache;
|
||||
use crate::preview::{previewers::meta, Preview, PreviewContent};
|
||||
use crate::utils::image::ImagePreviewWidget;
|
||||
use crate::utils::{
|
||||
files::FileType,
|
||||
strings::preprocess_line,
|
||||
syntax::{self, load_highlighting_assets, HighlightingAssetsExt},
|
||||
};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FilePreviewer {
|
||||
cache: Arc<Mutex<PreviewCache>>,
|
||||
pub syntax_set: Arc<SyntaxSet>,
|
||||
pub syntax_theme: Arc<Theme>,
|
||||
concurrent_preview_tasks: Arc<AtomicU8>,
|
||||
in_flight_previews: Arc<Mutex<FxHashSet<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FilePreviewerConfig {
|
||||
pub theme: String,
|
||||
}
|
||||
|
||||
impl FilePreviewerConfig {
|
||||
pub fn new(theme: String) -> Self {
|
||||
FilePreviewerConfig { theme }
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_PREVIEW_TASKS: u8 = 3;
|
||||
|
||||
const BAT_THEME_ENV_VAR: &str = "BAT_THEME";
|
||||
|
||||
impl FilePreviewer {
|
||||
pub fn new(config: Option<FilePreviewerConfig>) -> Self {
|
||||
let hl_assets = load_highlighting_assets();
|
||||
let syntax_set = hl_assets.get_syntax_set().unwrap().clone();
|
||||
|
||||
let theme_name = match std::env::var(BAT_THEME_ENV_VAR) {
|
||||
Ok(t) => t,
|
||||
Err(_) => match config {
|
||||
Some(c) => c.theme,
|
||||
// this will error and default back nicely
|
||||
None => "unknown".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let theme = hl_assets.get_theme_no_output(&theme_name).clone();
|
||||
|
||||
FilePreviewer {
|
||||
cache: Arc::new(Mutex::new(PreviewCache::default())),
|
||||
syntax_set: Arc::new(syntax_set),
|
||||
syntax_theme: Arc::new(theme),
|
||||
concurrent_preview_tasks: Arc::new(AtomicU8::new(0)),
|
||||
in_flight_previews: Arc::new(Mutex::new(HashSet::with_hasher(
|
||||
FxBuildHasher,
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cached(&self, entry: &entry::Entry) -> Option<Arc<Preview>> {
|
||||
self.cache.lock().get(&entry.name)
|
||||
}
|
||||
|
||||
pub fn preview(
|
||||
&mut self,
|
||||
entry: &entry::Entry,
|
||||
preview_window: Option<Rect>,
|
||||
) -> Option<Arc<Preview>> {
|
||||
if let Some(preview) = self.cached(entry) {
|
||||
trace!("Preview cache hit for {:?}", entry.name);
|
||||
if preview.partial_offset.is_some() {
|
||||
// preview is partial, spawn a task to compute the next chunk
|
||||
// and return the partial preview
|
||||
debug!("Spawning partial preview task for {:?}", entry.name);
|
||||
self.handle_preview_request(
|
||||
entry,
|
||||
Some(preview.clone()),
|
||||
preview_window,
|
||||
);
|
||||
}
|
||||
Some(preview)
|
||||
} else {
|
||||
// preview is not in cache, spawn a task to compute the preview
|
||||
trace!("Preview cache miss for {:?}", entry.name);
|
||||
self.handle_preview_request(entry, None, preview_window);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_preview_request(
|
||||
&mut self,
|
||||
entry: &entry::Entry,
|
||||
partial_preview: Option<Arc<Preview>>,
|
||||
preview_window: Option<Rect>,
|
||||
) {
|
||||
if self.in_flight_previews.lock().contains(&entry.name) {
|
||||
trace!("Preview already in flight for {:?}", entry.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if self.concurrent_preview_tasks.load(Ordering::Relaxed)
|
||||
< MAX_CONCURRENT_PREVIEW_TASKS
|
||||
{
|
||||
self.in_flight_previews.lock().insert(entry.name.clone());
|
||||
self.concurrent_preview_tasks
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
let cache = self.cache.clone();
|
||||
let entry_c = entry.clone();
|
||||
let syntax_set = self.syntax_set.clone();
|
||||
let syntax_theme = self.syntax_theme.clone();
|
||||
let concurrent_tasks = self.concurrent_preview_tasks.clone();
|
||||
let in_flight_previews = self.in_flight_previews.clone();
|
||||
tokio::spawn(async move {
|
||||
try_preview(
|
||||
&entry_c,
|
||||
partial_preview,
|
||||
&cache,
|
||||
&syntax_set,
|
||||
&syntax_theme,
|
||||
&concurrent_tasks,
|
||||
&in_flight_previews,
|
||||
preview_window,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn cache_preview(&mut self, key: String, preview: &Arc<Preview>) {
|
||||
self.cache.lock().insert(key, preview);
|
||||
}
|
||||
}
|
||||
|
||||
/// The size of the buffer used to read the file in bytes.
|
||||
/// This ends up being the max size of partial previews.
|
||||
const PARTIAL_BUFREAD_SIZE: usize = 5 * 1024 * 1024;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn try_preview(
|
||||
entry: &entry::Entry,
|
||||
partial_preview: Option<Arc<Preview>>,
|
||||
cache: &Arc<Mutex<PreviewCache>>,
|
||||
syntax_set: &Arc<SyntaxSet>,
|
||||
syntax_theme: &Arc<Theme>,
|
||||
concurrent_tasks: &Arc<AtomicU8>,
|
||||
in_flight_previews: &Arc<Mutex<FxHashSet<String>>>,
|
||||
preview_window: Option<Rect>,
|
||||
) {
|
||||
debug!("Computing preview for {:?}", entry.name);
|
||||
let path = PathBuf::from(&entry.name);
|
||||
|
||||
// if we're dealing with a partial preview, no need to re-check for textual content
|
||||
if partial_preview.is_some()
|
||||
|| matches!(FileType::from(&path), FileType::Text)
|
||||
{
|
||||
debug!("File is text-based: {:?}", entry.name);
|
||||
match File::open(path) {
|
||||
Ok(mut file) => {
|
||||
// if we're dealing with a partial preview, seek to the provided offset
|
||||
// and use the previous state to compute the next chunk of the preview
|
||||
let cached_lines = if let Some(p) = partial_preview {
|
||||
if let PreviewContent::SyntectHighlightedText(hl) =
|
||||
&p.content
|
||||
{
|
||||
let _ = file.seek(std::io::SeekFrom::Start(
|
||||
// this is always Some in this case
|
||||
p.partial_offset.unwrap() as u64,
|
||||
));
|
||||
Some(hl.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// compute the highlighted version in the background
|
||||
match read_into_lines_capped(file, PARTIAL_BUFREAD_SIZE) {
|
||||
ReadResult::Full(lines) => {
|
||||
if let Some(content) = compute_highlighted_text_preview(
|
||||
entry,
|
||||
&lines
|
||||
.iter()
|
||||
.map(|l| preprocess_line(l).0 + "\n")
|
||||
.collect::<Vec<_>>(),
|
||||
syntax_set,
|
||||
syntax_theme,
|
||||
cached_lines.as_ref(),
|
||||
) {
|
||||
let total_lines = content.total_lines();
|
||||
let preview = Arc::new(Preview::new(
|
||||
entry.name.clone(),
|
||||
content,
|
||||
entry.icon,
|
||||
None,
|
||||
total_lines,
|
||||
));
|
||||
cache.lock().insert(entry.name.clone(), &preview);
|
||||
}
|
||||
}
|
||||
ReadResult::Partial(p) => {
|
||||
if let Some(content) = compute_highlighted_text_preview(
|
||||
entry,
|
||||
&p.lines
|
||||
.iter()
|
||||
.map(|l| preprocess_line(l).0 + "\n")
|
||||
.collect::<Vec<_>>(),
|
||||
syntax_set,
|
||||
syntax_theme,
|
||||
cached_lines.as_ref(),
|
||||
) {
|
||||
let total_lines = content.total_lines();
|
||||
let preview = Arc::new(Preview::new(
|
||||
entry.name.clone(),
|
||||
content,
|
||||
entry.icon,
|
||||
Some(p.bytes_read),
|
||||
total_lines,
|
||||
));
|
||||
cache.lock().insert(entry.name.clone(), &preview);
|
||||
}
|
||||
}
|
||||
ReadResult::Error(e) => {
|
||||
warn!("Error reading file: {:?}", e);
|
||||
let p = meta::not_supported(&entry.name);
|
||||
cache.lock().insert(entry.name.clone(), &p);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error opening file: {:?}", e);
|
||||
let p = meta::not_supported(&entry.name);
|
||||
cache.lock().insert(entry.name.clone(), &p);
|
||||
}
|
||||
}
|
||||
} else if matches!(FileType::from(&path), FileType::Image) {
|
||||
cache.lock().insert(
|
||||
entry.name.clone(),
|
||||
&meta::loading(&format!("Loading {}", entry.name)),
|
||||
);
|
||||
|
||||
debug!("File {:?} is an image", entry.name);
|
||||
let option_image = match ImageReader::open(path) {
|
||||
Ok(reader) => match reader.with_guessed_format() {
|
||||
Ok(reader) => match reader.decode() {
|
||||
Ok(image) => Some(image),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Error impossible to decode {}: {:?}",
|
||||
entry.name, e
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Error impossible to guess the format of {}: {:?}",
|
||||
entry.name, e
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("Error opening image {}: {:?}", entry.name, e);
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(image) = option_image {
|
||||
let preview_window_dimension = preview_window.map(|rect| {
|
||||
(
|
||||
u32::from(rect.width.saturating_sub(2)),
|
||||
u32::from(rect.height.saturating_sub(2)),
|
||||
) // - 2 for the margin
|
||||
});
|
||||
let image_preview_widget = ImagePreviewWidget::from_dynamic_image(
|
||||
image,
|
||||
preview_window_dimension,
|
||||
);
|
||||
let total_lines =
|
||||
image_preview_widget.height().try_into().unwrap_or(u16::MAX);
|
||||
let content = PreviewContent::Image(image_preview_widget);
|
||||
let preview = Arc::new(Preview::new(
|
||||
entry.name.clone(),
|
||||
content,
|
||||
entry.icon,
|
||||
None,
|
||||
total_lines,
|
||||
));
|
||||
cache.lock().insert(entry.name.clone(), &preview);
|
||||
} else {
|
||||
let p = meta::not_supported(&entry.name);
|
||||
cache.lock().insert(entry.name.clone(), &p);
|
||||
}
|
||||
} else {
|
||||
debug!("File format isn't supported for preview: {:?}", entry.name);
|
||||
let preview = meta::not_supported(&entry.name);
|
||||
cache.lock().insert(entry.name.clone(), &preview);
|
||||
}
|
||||
concurrent_tasks.fetch_sub(1, Ordering::Relaxed);
|
||||
in_flight_previews.lock().remove(&entry.name);
|
||||
}
|
||||
|
||||
fn compute_highlighted_text_preview(
|
||||
entry: &entry::Entry,
|
||||
lines: &[String],
|
||||
syntax_set: &SyntaxSet,
|
||||
syntax_theme: &Theme,
|
||||
previous_lines: Option<&HighlightedLines>,
|
||||
) -> Option<PreviewContent> {
|
||||
debug!(
|
||||
"Computing highlights in the background for {:?}",
|
||||
entry.name
|
||||
);
|
||||
|
||||
match syntax::compute_highlights_incremental(
|
||||
&PathBuf::from(&entry.name),
|
||||
lines,
|
||||
syntax_set,
|
||||
syntax_theme,
|
||||
previous_lines,
|
||||
) {
|
||||
Ok(highlighted_lines) => {
|
||||
Some(PreviewContent::SyntectHighlightedText(highlighted_lines))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error computing highlights: {:?}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This should be enough for most terminal sizes
|
||||
const TEMP_PLAIN_TEXT_PREVIEW_HEIGHT: usize = 200;
|
||||
|
||||
#[allow(dead_code)]
|
||||
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);
|
||||
// PERF: instead of using lines(), maybe check for the length of the first line instead and
|
||||
// truncate accordingly (since this is just a temp preview)
|
||||
for maybe_line in reader.lines() {
|
||||
match maybe_line {
|
||||
Ok(line) => lines.push(preprocess_line(&line).0),
|
||||
Err(e) => {
|
||||
warn!("Error reading file: {:?}", e);
|
||||
return meta::not_supported(title);
|
||||
}
|
||||
}
|
||||
if lines.len() >= TEMP_PLAIN_TEXT_PREVIEW_HEIGHT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let total_lines = u16::try_from(lines.len()).unwrap_or(u16::MAX);
|
||||
Arc::new(Preview::new(
|
||||
title.to_string(),
|
||||
PreviewContent::PlainText(lines),
|
||||
None,
|
||||
None,
|
||||
total_lines,
|
||||
))
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
use crate::preview::{Preview, PreviewContent};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn not_supported(title: &str) -> Arc<Preview> {
|
||||
Arc::new(Preview::new(
|
||||
title.to_string(),
|
||||
PreviewContent::NotSupported,
|
||||
None,
|
||||
None,
|
||||
1,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn file_too_large(title: &str) -> Arc<Preview> {
|
||||
Arc::new(Preview::new(
|
||||
title.to_string(),
|
||||
PreviewContent::FileTooLarge,
|
||||
None,
|
||||
None,
|
||||
1,
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn loading(title: &str) -> Arc<Preview> {
|
||||
Arc::new(Preview::new(
|
||||
title.to_string(),
|
||||
PreviewContent::Loading,
|
||||
None,
|
||||
None,
|
||||
1,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn timeout(title: &str) -> Arc<Preview> {
|
||||
Arc::new(Preview::new(
|
||||
title.to_string(),
|
||||
PreviewContent::Timeout,
|
||||
None,
|
||||
None,
|
||||
1,
|
||||
))
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
pub mod basic;
|
||||
pub mod command;
|
||||
pub mod env;
|
||||
pub mod files;
|
||||
pub mod meta;
|
256
television/previewer/mod.rs
Normal file
256
television/previewer/mod.rs
Normal file
@ -0,0 +1,256 @@
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use devicons::FileIcon;
|
||||
use tokio::{
|
||||
sync::mpsc::{UnboundedReceiver, UnboundedSender},
|
||||
time::timeout,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{
|
||||
channels::{entry::Entry, preview::PreviewCommand},
|
||||
utils::{
|
||||
command::shell_command,
|
||||
strings::{ReplaceNonPrintableConfig, replace_non_printable},
|
||||
},
|
||||
};
|
||||
|
||||
pub mod state;
|
||||
|
||||
pub struct Config {
|
||||
request_max_age: Duration,
|
||||
job_timeout: Duration,
|
||||
}
|
||||
|
||||
pub const DEFAULT_REQUEST_MAX_AGE: Duration = Duration::from_millis(1000);
|
||||
pub const DEFAULT_JOB_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
request_max_age: DEFAULT_REQUEST_MAX_AGE,
|
||||
job_timeout: DEFAULT_JOB_TIMEOUT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum Request {
|
||||
Preview(Ticket),
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
impl PartialOrd for Request {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Request {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
match (self, other) {
|
||||
// Shutdown signals always have priority
|
||||
(Self::Shutdown, _) => Ordering::Greater,
|
||||
(_, Self::Shutdown) => Ordering::Less,
|
||||
// Otherwise fall back to ticket age comparison
|
||||
(Self::Preview(t1), Self::Preview(t2)) => t1.cmp(t2),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub struct Ticket {
|
||||
entry: Entry,
|
||||
timestamp: Instant,
|
||||
}
|
||||
|
||||
impl PartialOrd for Ticket {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Ticket {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.age().cmp(&other.age())
|
||||
}
|
||||
}
|
||||
|
||||
impl Ticket {
|
||||
pub fn new(entry: Entry) -> Self {
|
||||
Self {
|
||||
entry,
|
||||
timestamp: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn age(&self) -> Duration {
|
||||
Instant::now().duration_since(self.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Preview {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub icon: Option<FileIcon>,
|
||||
pub total_lines: u16,
|
||||
}
|
||||
|
||||
const DEFAULT_PREVIEW_TITLE: &str = "Select an entry to preview";
|
||||
|
||||
impl Default for Preview {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: DEFAULT_PREVIEW_TITLE.to_string(),
|
||||
content: String::new(),
|
||||
icon: None,
|
||||
total_lines: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Preview {
|
||||
fn new(
|
||||
title: &str,
|
||||
content: String,
|
||||
icon: Option<FileIcon>,
|
||||
total_lines: u16,
|
||||
) -> Self {
|
||||
Self {
|
||||
title: title.to_string(),
|
||||
content,
|
||||
icon,
|
||||
total_lines,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Previewer {
|
||||
config: Config,
|
||||
// FIXME: maybe use a bounded channel here with a single slot
|
||||
requests: UnboundedReceiver<Request>,
|
||||
last_job_entry: Option<Entry>,
|
||||
preview_command: PreviewCommand,
|
||||
results: UnboundedSender<Preview>,
|
||||
}
|
||||
|
||||
impl Previewer {
|
||||
pub fn new(
|
||||
preview_command: PreviewCommand,
|
||||
config: Config,
|
||||
receiver: UnboundedReceiver<Request>,
|
||||
sender: UnboundedSender<Preview>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
requests: receiver,
|
||||
last_job_entry: None,
|
||||
preview_command,
|
||||
results: sender,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(mut self) {
|
||||
let mut buffer = Vec::with_capacity(32);
|
||||
loop {
|
||||
let num = self.requests.recv_many(&mut buffer, 32).await;
|
||||
if num > 0 {
|
||||
debug!("Previewer received {num} request(s)!");
|
||||
// only keep the newest request
|
||||
match buffer.drain(..).max().unwrap() {
|
||||
Request::Preview(ticket) => {
|
||||
if ticket.age() > self.config.request_max_age {
|
||||
debug!("Preview request is stale, skipping");
|
||||
continue;
|
||||
}
|
||||
let results_handle = self.results.clone();
|
||||
let command =
|
||||
self.preview_command.format_with(&ticket.entry);
|
||||
self.last_job_entry = Some(ticket.entry.clone());
|
||||
// try to execute the preview with a timeout
|
||||
match timeout(
|
||||
self.config.job_timeout,
|
||||
tokio::spawn(async move {
|
||||
try_preview(
|
||||
&command,
|
||||
&ticket.entry,
|
||||
&results_handle,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
debug!("Preview job completed successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Preview job timeout: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Request::Shutdown => {
|
||||
debug!(
|
||||
"Received shutdown signal, breaking out of the previewer loop."
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
"Preview request channel closed and no messages left, breaking out of the previewer loop."
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_preview(
|
||||
command: &str,
|
||||
entry: &Entry,
|
||||
results_handle: &UnboundedSender<Preview>,
|
||||
) {
|
||||
debug!("Preview command: {}", command);
|
||||
|
||||
let child = shell_command(false)
|
||||
.arg(command)
|
||||
.output()
|
||||
.expect("failed to execute process");
|
||||
|
||||
let preview: Preview = {
|
||||
if child.status.success() {
|
||||
let (content, _) = replace_non_printable(
|
||||
&child.stdout,
|
||||
ReplaceNonPrintableConfig::default()
|
||||
.keep_line_feed()
|
||||
.keep_control_characters(),
|
||||
);
|
||||
Preview::new(
|
||||
&entry.name,
|
||||
content.to_string(),
|
||||
None,
|
||||
u16::try_from(content.lines().count()).unwrap_or(u16::MAX),
|
||||
)
|
||||
} else {
|
||||
let (content, _) = replace_non_printable(
|
||||
&child.stderr,
|
||||
ReplaceNonPrintableConfig::default()
|
||||
.keep_line_feed()
|
||||
.keep_control_characters(),
|
||||
);
|
||||
Preview::new(
|
||||
&entry.name,
|
||||
content.to_string(),
|
||||
None,
|
||||
u16::try_from(content.lines().count()).unwrap_or(u16::MAX),
|
||||
)
|
||||
}
|
||||
};
|
||||
results_handle
|
||||
.send(preview)
|
||||
.expect("Unable to send preview result to main thread.");
|
||||
}
|
99
television/previewer/state.rs
Normal file
99
television/previewer/state.rs
Normal file
@ -0,0 +1,99 @@
|
||||
use crate::previewer::Preview;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PreviewState {
|
||||
pub enabled: bool,
|
||||
pub preview: Preview,
|
||||
pub scroll: u16,
|
||||
pub target_line: Option<u16>,
|
||||
}
|
||||
|
||||
const PREVIEW_MIN_SCROLL_LINES: u16 = 3;
|
||||
pub const ANSI_BEFORE_CONTEXT_SIZE: u16 = 3;
|
||||
const ANSI_CONTEXT_SIZE: usize = 500;
|
||||
|
||||
impl PreviewState {
|
||||
pub fn new(
|
||||
enabled: bool,
|
||||
preview: Preview,
|
||||
scroll: u16,
|
||||
target_line: Option<u16>,
|
||||
) -> Self {
|
||||
PreviewState {
|
||||
enabled,
|
||||
preview,
|
||||
scroll,
|
||||
target_line,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_down(&mut self, offset: u16) {
|
||||
self.scroll = self.scroll.saturating_add(offset).min(
|
||||
self.preview
|
||||
.total_lines
|
||||
.saturating_sub(PREVIEW_MIN_SCROLL_LINES),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn scroll_up(&mut self, offset: u16) {
|
||||
self.scroll = self.scroll.saturating_sub(offset);
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.preview = Preview::default();
|
||||
self.scroll = 0;
|
||||
self.target_line = None;
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
&mut self,
|
||||
preview: Preview,
|
||||
scroll: u16,
|
||||
target_line: Option<u16>,
|
||||
) {
|
||||
if self.preview.title != preview.title || self.scroll != scroll {
|
||||
self.preview = preview;
|
||||
self.scroll = scroll;
|
||||
self.target_line = target_line;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_render_context(&self) -> Self {
|
||||
let num_skipped_lines =
|
||||
self.scroll.saturating_sub(ANSI_BEFORE_CONTEXT_SIZE);
|
||||
let cropped_content = self
|
||||
.preview
|
||||
.content
|
||||
.lines()
|
||||
.skip(num_skipped_lines as usize)
|
||||
.take(ANSI_CONTEXT_SIZE)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let target_line: Option<u16> =
|
||||
if let Some(target_line) = self.target_line {
|
||||
if num_skipped_lines < target_line
|
||||
&& (target_line - num_skipped_lines)
|
||||
<= u16::try_from(ANSI_CONTEXT_SIZE).unwrap()
|
||||
{
|
||||
Some(target_line.saturating_sub(num_skipped_lines))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
PreviewState::new(
|
||||
self.enabled,
|
||||
Preview::new(
|
||||
&self.preview.title,
|
||||
cropped_content,
|
||||
self.preview.icon,
|
||||
self.preview.total_lines,
|
||||
),
|
||||
num_skipped_lines,
|
||||
target_line,
|
||||
)
|
||||
}
|
||||
}
|
@ -2,7 +2,7 @@ use anyhow::Result;
|
||||
use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
|
||||
use crossterm::{execute, queue};
|
||||
use ratatui::layout::Rect;
|
||||
use std::io::{stderr, stdout, LineWriter};
|
||||
use std::io::{LineWriter, stderr, stdout};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
@ -11,7 +11,7 @@ use crate::draw::Ctx;
|
||||
use crate::screen::layout::Layout;
|
||||
use crate::{action::Action, draw::draw, tui::Tui};
|
||||
|
||||
#[derive(Debug, PartialEq, PartialOrd, Ord, Eq, Clone)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RenderingTask {
|
||||
ClearScreen,
|
||||
Render(Box<Ctx>),
|
||||
@ -89,15 +89,29 @@ pub async fn render(
|
||||
tui.enter()?;
|
||||
|
||||
let mut buffer = Vec::with_capacity(256);
|
||||
let mut num_instructions;
|
||||
let mut frame_start;
|
||||
|
||||
// Rendering loop
|
||||
'rendering: while render_rx.recv_many(&mut buffer, 256).await > 0 {
|
||||
frame_start = std::time::Instant::now();
|
||||
// deduplicate events
|
||||
buffer.sort_unstable();
|
||||
buffer.dedup();
|
||||
for event in buffer.drain(..) {
|
||||
num_instructions = buffer.len();
|
||||
if let Some(last_render) = buffer
|
||||
.iter()
|
||||
.rfind(|e| matches!(e, RenderingTask::Render(_)))
|
||||
{
|
||||
buffer.push(last_render.clone());
|
||||
}
|
||||
|
||||
for event in buffer
|
||||
.drain(..)
|
||||
.enumerate()
|
||||
.filter(|(i, e)| {
|
||||
!matches!(e, RenderingTask::Render(_))
|
||||
|| *i == num_instructions
|
||||
})
|
||||
.map(|(_, val)| val)
|
||||
{
|
||||
match event {
|
||||
RenderingTask::ClearScreen => {
|
||||
tui.terminal.clear()?;
|
||||
|
@ -51,5 +51,4 @@ pub struct InputColorscheme {
|
||||
pub struct ModeColorscheme {
|
||||
pub channel: Color,
|
||||
pub remote_control: Color,
|
||||
pub send_to_channel: Color,
|
||||
}
|
||||
|
@ -5,10 +5,10 @@ use crate::screen::metadata::build_metadata_table;
|
||||
use crate::screen::mode::mode_color;
|
||||
use crate::television::Mode;
|
||||
use crate::utils::metadata::AppMetadata;
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::{Color, Style};
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Padding, Table};
|
||||
use ratatui::Frame;
|
||||
|
||||
pub fn draw_logo_block(
|
||||
f: &mut Frame,
|
||||
|
@ -1,17 +1,21 @@
|
||||
use crate::utils::input::Input;
|
||||
use anyhow::Result;
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{
|
||||
Alignment, Constraint, Direction, Layout as RatatuiLayout, Rect,
|
||||
},
|
||||
style::{Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, BorderType, Borders, ListState, Paragraph},
|
||||
Frame,
|
||||
widgets::{
|
||||
Block, BorderType, Borders, ListState, Paragraph, block::Position,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::screen::{colors::Colorscheme, spinner::Spinner};
|
||||
|
||||
use super::layout::InputPosition;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw_input_box(
|
||||
f: &mut Frame,
|
||||
@ -25,13 +29,18 @@ pub fn draw_input_box(
|
||||
spinner: &Spinner,
|
||||
colorscheme: &Colorscheme,
|
||||
custom_header: &Option<String>,
|
||||
input_bar_position: &InputPosition,
|
||||
) -> Result<()> {
|
||||
let header = custom_header.as_deref().unwrap_or(channel_name);
|
||||
let input_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(colorscheme.general.border_fg))
|
||||
.title_top(
|
||||
.title_position(match input_bar_position {
|
||||
InputPosition::Top => Position::Top,
|
||||
InputPosition::Bottom => Position::Bottom,
|
||||
})
|
||||
.title(
|
||||
Line::from(String::from(" ") + header + " ")
|
||||
.style(Style::default().fg(colorscheme.mode.channel).bold())
|
||||
.centered(),
|
||||
|
@ -56,13 +56,6 @@ impl KeyBindings {
|
||||
&[Action::CopyEntryToClipboard],
|
||||
),
|
||||
),
|
||||
(
|
||||
DisplayableAction::SendToChannel,
|
||||
serialized_keys_for_actions(
|
||||
self,
|
||||
&[Action::ToggleSendToChannel],
|
||||
),
|
||||
),
|
||||
(
|
||||
DisplayableAction::ToggleRemoteControl,
|
||||
serialized_keys_for_actions(
|
||||
@ -101,41 +94,12 @@ impl KeyBindings {
|
||||
),
|
||||
]);
|
||||
|
||||
// send to channel mode keybindings
|
||||
let send_to_channel_bindings: FxHashMap<
|
||||
DisplayableAction,
|
||||
Vec<String>,
|
||||
> = FxHashMap::from_iter(vec![
|
||||
(
|
||||
DisplayableAction::ResultsNavigation,
|
||||
serialized_keys_for_actions(
|
||||
self,
|
||||
&[Action::SelectPrevEntry, Action::SelectNextEntry],
|
||||
),
|
||||
),
|
||||
(
|
||||
DisplayableAction::SelectEntry,
|
||||
serialized_keys_for_actions(self, &[Action::ConfirmSelection]),
|
||||
),
|
||||
(
|
||||
DisplayableAction::Cancel,
|
||||
serialized_keys_for_actions(
|
||||
self,
|
||||
&[Action::ToggleSendToChannel],
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
FxHashMap::from_iter(vec![
|
||||
(Mode::Channel, DisplayableKeybindings::new(channel_bindings)),
|
||||
(
|
||||
Mode::RemoteControl,
|
||||
DisplayableKeybindings::new(remote_control_bindings),
|
||||
),
|
||||
(
|
||||
Mode::SendToChannel,
|
||||
DisplayableKeybindings::new(send_to_channel_bindings),
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
@ -167,7 +131,6 @@ pub enum DisplayableAction {
|
||||
PreviewNavigation,
|
||||
SelectEntry,
|
||||
CopyEntryToClipboard,
|
||||
SendToChannel,
|
||||
ToggleRemoteControl,
|
||||
Cancel,
|
||||
Quit,
|
||||
@ -183,7 +146,6 @@ impl Display for DisplayableAction {
|
||||
DisplayableAction::CopyEntryToClipboard => {
|
||||
"Copy entry to clipboard"
|
||||
}
|
||||
DisplayableAction::SendToChannel => "Send to channel",
|
||||
DisplayableAction::ToggleRemoteControl => "Toggle Remote control",
|
||||
DisplayableAction::Cancel => "Cancel",
|
||||
DisplayableAction::Quit => "Quit",
|
||||
@ -207,12 +169,6 @@ pub fn build_keybindings_table<'a>(
|
||||
&keybindings[&mode],
|
||||
colorscheme,
|
||||
),
|
||||
Mode::SendToChannel => {
|
||||
build_keybindings_table_for_channel_transitions(
|
||||
&keybindings[&mode],
|
||||
colorscheme,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -268,18 +224,6 @@ fn build_keybindings_table_for_channel<'a>(
|
||||
colorscheme.mode.channel,
|
||||
));
|
||||
|
||||
// Send to channel
|
||||
let send_to_channel_keys = keybindings
|
||||
.bindings
|
||||
.get(&DisplayableAction::SendToChannel)
|
||||
.unwrap();
|
||||
let send_to_channel_row = Row::new(build_cells_for_group(
|
||||
"Send results to",
|
||||
send_to_channel_keys,
|
||||
colorscheme.help.metadata_field_name_fg,
|
||||
colorscheme.mode.channel,
|
||||
));
|
||||
|
||||
// Switch channels
|
||||
let switch_channels_keys = keybindings
|
||||
.bindings
|
||||
@ -300,7 +244,6 @@ fn build_keybindings_table_for_channel<'a>(
|
||||
preview_row,
|
||||
select_entry_row,
|
||||
copy_entry_row,
|
||||
send_to_channel_row,
|
||||
switch_channels_row,
|
||||
],
|
||||
widths,
|
||||
@ -353,52 +296,6 @@ fn build_keybindings_table_for_channel_selection<'a>(
|
||||
)
|
||||
}
|
||||
|
||||
fn build_keybindings_table_for_channel_transitions<'a>(
|
||||
keybindings: &'a DisplayableKeybindings,
|
||||
colorscheme: &'a Colorscheme,
|
||||
) -> Table<'a> {
|
||||
// Results navigation
|
||||
let results_navigation_keys = keybindings
|
||||
.bindings
|
||||
.get(&DisplayableAction::ResultsNavigation)
|
||||
.unwrap();
|
||||
let results_row = Row::new(build_cells_for_group(
|
||||
"Browse channels",
|
||||
results_navigation_keys,
|
||||
colorscheme.help.metadata_field_name_fg,
|
||||
colorscheme.mode.send_to_channel,
|
||||
));
|
||||
|
||||
// Select entry
|
||||
let select_entry_keys = keybindings
|
||||
.bindings
|
||||
.get(&DisplayableAction::SelectEntry)
|
||||
.unwrap();
|
||||
let select_entry_row = Row::new(build_cells_for_group(
|
||||
"Send to channel",
|
||||
select_entry_keys,
|
||||
colorscheme.help.metadata_field_name_fg,
|
||||
colorscheme.mode.send_to_channel,
|
||||
));
|
||||
|
||||
// Cancel
|
||||
let cancel_keys = keybindings
|
||||
.bindings
|
||||
.get(&DisplayableAction::Cancel)
|
||||
.unwrap();
|
||||
let cancel_row = Row::new(build_cells_for_group(
|
||||
"Cancel",
|
||||
cancel_keys,
|
||||
colorscheme.help.metadata_field_name_fg,
|
||||
colorscheme.mode.send_to_channel,
|
||||
));
|
||||
|
||||
Table::new(
|
||||
vec![results_row, select_entry_row, cancel_row],
|
||||
vec![Constraint::Fill(1), Constraint::Fill(2)],
|
||||
)
|
||||
}
|
||||
|
||||
fn build_cells_for_group<'a>(
|
||||
group_name: &str,
|
||||
keys: &'a [String],
|
||||
|
@ -66,6 +66,17 @@ impl Display for InputPosition {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Hash,
|
||||
)]
|
||||
pub enum Orientation {
|
||||
#[serde(rename = "landscape")]
|
||||
#[default]
|
||||
Landscape,
|
||||
#[serde(rename = "portrait")]
|
||||
Portrait,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, Deserialize, Serialize, Default, PartialEq, Hash,
|
||||
)]
|
||||
@ -129,7 +140,6 @@ impl Layout {
|
||||
ui_config: &UiConfig,
|
||||
show_remote: bool,
|
||||
show_preview: bool,
|
||||
//
|
||||
) -> Self {
|
||||
let show_preview = show_preview && ui_config.show_preview_panel;
|
||||
let dimensions = Dimensions::from(ui_config.ui_scale);
|
||||
@ -168,26 +178,57 @@ impl Layout {
|
||||
help_bar_layout = None;
|
||||
}
|
||||
|
||||
// split the main block into 1, 2, or 3 vertical chunks
|
||||
// (results + preview + remote)
|
||||
let mut constraints = vec![Constraint::Fill(1)];
|
||||
if show_preview {
|
||||
constraints.push(Constraint::Fill(1));
|
||||
}
|
||||
if show_remote {
|
||||
// in order to fit with the help bar logo
|
||||
constraints.push(Constraint::Length(24));
|
||||
}
|
||||
let vt_chunks = layout::Layout::default()
|
||||
let remote_constraints = if show_remote {
|
||||
vec![Constraint::Fill(1), Constraint::Length(24)]
|
||||
} else {
|
||||
vec![Constraint::Fill(1)]
|
||||
};
|
||||
let remote_chunks = layout::Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(constraints)
|
||||
.constraints(remote_constraints)
|
||||
.split(main_rect);
|
||||
|
||||
// left block: results + input field
|
||||
let remote_control = if show_remote {
|
||||
Some(remote_chunks[1])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// split the main block into 1 or 2 chunks
|
||||
// (results + preview)
|
||||
let constraints = if show_preview {
|
||||
vec![Constraint::Fill(1), Constraint::Fill(1)]
|
||||
} else {
|
||||
vec![Constraint::Fill(1)]
|
||||
};
|
||||
|
||||
let main_chunks = layout::Layout::default()
|
||||
.direction(match ui_config.orientation {
|
||||
Orientation::Portrait => Direction::Vertical,
|
||||
Orientation::Landscape => Direction::Horizontal,
|
||||
})
|
||||
.constraints(constraints)
|
||||
.split(remote_chunks[0]);
|
||||
|
||||
// result block: results + input field
|
||||
let results_constraints =
|
||||
vec![Constraint::Min(3), Constraint::Length(3)];
|
||||
|
||||
let left_chunks = layout::Layout::default()
|
||||
let (result_window, preview_window) = if show_preview {
|
||||
match (ui_config.orientation, ui_config.input_bar_position) {
|
||||
(Orientation::Landscape, _)
|
||||
| (Orientation::Portrait, InputPosition::Top) => {
|
||||
(main_chunks[0], Some(main_chunks[1]))
|
||||
}
|
||||
(Orientation::Portrait, InputPosition::Bottom) => {
|
||||
(main_chunks[1], Some(main_chunks[0]))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(main_chunks[0], None)
|
||||
};
|
||||
|
||||
let result_chunks = layout::Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(match ui_config.input_bar_position {
|
||||
InputPosition::Top => {
|
||||
@ -195,25 +236,10 @@ impl Layout {
|
||||
}
|
||||
InputPosition::Bottom => results_constraints,
|
||||
})
|
||||
.split(vt_chunks[0]);
|
||||
.split(result_window);
|
||||
let (input, results) = match ui_config.input_bar_position {
|
||||
InputPosition::Bottom => (left_chunks[1], left_chunks[0]),
|
||||
InputPosition::Top => (left_chunks[0], left_chunks[1]),
|
||||
};
|
||||
|
||||
// right block: preview title + preview
|
||||
let mut remote_idx = 1;
|
||||
let preview_window = if show_preview {
|
||||
remote_idx += 1;
|
||||
Some(vt_chunks[1])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let remote_control = if show_remote {
|
||||
Some(vt_chunks[remote_idx])
|
||||
} else {
|
||||
None
|
||||
InputPosition::Bottom => (result_chunks[1], result_chunks[0]),
|
||||
InputPosition::Top => (result_chunks[0], result_chunks[1]),
|
||||
};
|
||||
|
||||
Self::new(
|
||||
|
@ -21,8 +21,7 @@ pub fn build_logo_paragraph<'a>() -> Paragraph<'a> {
|
||||
.lines()
|
||||
.map(std::convert::Into::into)
|
||||
.collect::<Vec<_>>();
|
||||
let logo_paragraph = Paragraph::new(lines);
|
||||
logo_paragraph
|
||||
Paragraph::new(lines)
|
||||
}
|
||||
|
||||
const REMOTE_LOGO: &str = r"
|
||||
@ -51,6 +50,5 @@ pub fn build_remote_logo_paragraph<'a>() -> Paragraph<'a> {
|
||||
.lines()
|
||||
.map(std::convert::Into::into)
|
||||
.collect::<Vec<_>>();
|
||||
let logo_paragraph = Paragraph::new(lines);
|
||||
logo_paragraph
|
||||
Paragraph::new(lines)
|
||||
}
|
||||
|
@ -15,7 +15,6 @@ impl Display for Mode {
|
||||
match self {
|
||||
Mode::Channel => write!(f, "Channel"),
|
||||
Mode::RemoteControl => write!(f, "Remote Control"),
|
||||
Mode::SendToChannel => write!(f, "Send to Channel"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,5 @@ pub fn mode_color(mode: Mode, colorscheme: &ModeColorscheme) -> Color {
|
||||
match mode {
|
||||
Mode::Channel => colorscheme.channel,
|
||||
Mode::RemoteControl => colorscheme.remote_control,
|
||||
Mode::SendToChannel => colorscheme.send_to_channel,
|
||||
}
|
||||
}
|
||||
|
@ -1,47 +1,20 @@
|
||||
use crate::preview::PreviewState;
|
||||
use crate::preview::{
|
||||
ansi::IntoText, PreviewContent, FILE_TOO_LARGE_MSG, LOADING_MSG,
|
||||
PREVIEW_NOT_SUPPORTED_MSG, TIMEOUT_MSG,
|
||||
};
|
||||
use crate::screen::colors::{Colorscheme, PreviewColorscheme};
|
||||
use crate::utils::image::ImagePreviewWidget;
|
||||
use crate::previewer::state::PreviewState;
|
||||
use crate::screen::colors::Colorscheme;
|
||||
use crate::utils::strings::{
|
||||
replace_non_printable, shrink_with_ellipsis, ReplaceNonPrintableConfig,
|
||||
EMPTY_STRING,
|
||||
EMPTY_STRING, ReplaceNonPrintableConfig, replace_non_printable,
|
||||
shrink_with_ellipsis,
|
||||
};
|
||||
use ansi_to_tui::IntoText;
|
||||
use anyhow::Result;
|
||||
use devicons::FileIcon;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::widgets::{
|
||||
Block, BorderType, Borders, Padding, Paragraph, Widget, Wrap,
|
||||
};
|
||||
use ratatui::Frame;
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
prelude::{Color, Line, Modifier, Span, Style, Stylize, Text},
|
||||
prelude::{Color, Line, Span, Style, Stylize, Text},
|
||||
};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[allow(dead_code)]
|
||||
const FILL_CHAR_SLANTED: char = '╱';
|
||||
const FILL_CHAR_EMPTY: char = ' ';
|
||||
|
||||
pub enum PreviewWidget<'a> {
|
||||
Paragraph(Paragraph<'a>),
|
||||
Image(ImagePreviewWidget),
|
||||
}
|
||||
impl Widget for PreviewWidget<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer)
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
match self {
|
||||
PreviewWidget::Paragraph(p) => p.render(area, buf),
|
||||
PreviewWidget::Image(image) => image.render(area, buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw_preview_content_block(
|
||||
f: &mut Frame,
|
||||
@ -59,25 +32,19 @@ pub fn draw_preview_content_block(
|
||||
use_nerd_font_icons,
|
||||
)?;
|
||||
// render the preview content
|
||||
let rp = build_preview_widget(
|
||||
inner,
|
||||
&preview_state.preview.content,
|
||||
preview_state.target_line,
|
||||
preview_state.scroll,
|
||||
colorscheme,
|
||||
let rp = build_preview_paragraph(
|
||||
preview_state,
|
||||
colorscheme.preview.highlight_bg,
|
||||
);
|
||||
f.render_widget(rp, inner);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_preview_widget<'a>(
|
||||
inner: Rect,
|
||||
preview_content: &'a PreviewContent,
|
||||
target_line: Option<u16>,
|
||||
preview_scroll: u16,
|
||||
colorscheme: &'a Colorscheme,
|
||||
) -> PreviewWidget<'a> {
|
||||
pub fn build_preview_paragraph(
|
||||
preview_state: &PreviewState,
|
||||
highlight_bg: Color,
|
||||
) -> Paragraph<'_> {
|
||||
let preview_block =
|
||||
Block::default().style(Style::default()).padding(Padding {
|
||||
top: 0,
|
||||
@ -86,191 +53,30 @@ pub fn build_preview_widget<'a>(
|
||||
left: 1,
|
||||
});
|
||||
|
||||
match preview_content {
|
||||
PreviewContent::AnsiText(text) => PreviewWidget::Paragraph(
|
||||
build_ansi_text_paragraph(text, preview_block, preview_scroll),
|
||||
),
|
||||
PreviewContent::PlainText(content) => {
|
||||
PreviewWidget::Paragraph(build_plain_text_paragraph(
|
||||
content,
|
||||
build_ansi_text_paragraph(
|
||||
&preview_state.preview.content,
|
||||
preview_block,
|
||||
target_line,
|
||||
preview_scroll,
|
||||
colorscheme.preview,
|
||||
))
|
||||
}
|
||||
PreviewContent::PlainTextWrapped(content) => PreviewWidget::Paragraph(
|
||||
build_plain_text_wrapped_paragraph(
|
||||
content,
|
||||
preview_block,
|
||||
colorscheme.preview,
|
||||
preview_state.target_line,
|
||||
highlight_bg,
|
||||
)
|
||||
.scroll((preview_scroll, 0)),
|
||||
),
|
||||
PreviewContent::SyntectHighlightedText(highlighted_lines) => {
|
||||
PreviewWidget::Paragraph(build_syntect_highlighted_paragraph(
|
||||
&highlighted_lines.lines,
|
||||
preview_block,
|
||||
target_line,
|
||||
preview_scroll,
|
||||
colorscheme.preview,
|
||||
inner.height,
|
||||
))
|
||||
}
|
||||
PreviewContent::Image(image) => PreviewWidget::Image(image.clone()),
|
||||
|
||||
// meta
|
||||
PreviewContent::Loading => PreviewWidget::Paragraph(
|
||||
build_meta_preview_paragraph(inner, LOADING_MSG, FILL_CHAR_EMPTY)
|
||||
.block(preview_block)
|
||||
.alignment(Alignment::Left)
|
||||
.style(Style::default().add_modifier(Modifier::ITALIC)),
|
||||
),
|
||||
PreviewContent::NotSupported => PreviewWidget::Paragraph(
|
||||
build_meta_preview_paragraph(
|
||||
inner,
|
||||
PREVIEW_NOT_SUPPORTED_MSG,
|
||||
FILL_CHAR_EMPTY,
|
||||
)
|
||||
.block(preview_block)
|
||||
.alignment(Alignment::Left)
|
||||
.style(Style::default().add_modifier(Modifier::ITALIC)),
|
||||
),
|
||||
PreviewContent::FileTooLarge => PreviewWidget::Paragraph(
|
||||
build_meta_preview_paragraph(
|
||||
inner,
|
||||
FILE_TOO_LARGE_MSG,
|
||||
FILL_CHAR_EMPTY,
|
||||
)
|
||||
.block(preview_block)
|
||||
.alignment(Alignment::Left)
|
||||
.style(Style::default().add_modifier(Modifier::ITALIC)),
|
||||
),
|
||||
|
||||
PreviewContent::Timeout => PreviewWidget::Paragraph(
|
||||
build_meta_preview_paragraph(inner, TIMEOUT_MSG, FILL_CHAR_EMPTY)
|
||||
.block(preview_block)
|
||||
.alignment(Alignment::Left)
|
||||
.style(Style::default().add_modifier(Modifier::ITALIC)),
|
||||
),
|
||||
PreviewContent::Empty => {
|
||||
PreviewWidget::Paragraph(Paragraph::new(Text::raw(EMPTY_STRING)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ANSI_BEFORE_CONTEXT_SIZE: u16 = 10;
|
||||
const ANSI_CONTEXT_SIZE: usize = 150;
|
||||
|
||||
fn build_ansi_text_paragraph<'a>(
|
||||
text: &'a str,
|
||||
preview_block: Block<'a>,
|
||||
preview_scroll: u16,
|
||||
) -> Paragraph<'a> {
|
||||
let lines = text.lines();
|
||||
let skip =
|
||||
preview_scroll.saturating_sub(ANSI_BEFORE_CONTEXT_SIZE) as usize;
|
||||
let context = lines
|
||||
.skip(skip)
|
||||
.take(ANSI_CONTEXT_SIZE)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let mut text = "\n".repeat(skip);
|
||||
text.push_str(
|
||||
&replace_non_printable(
|
||||
context.as_bytes(),
|
||||
&ReplaceNonPrintableConfig {
|
||||
replace_line_feed: false,
|
||||
replace_control_characters: false,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.0,
|
||||
);
|
||||
|
||||
Paragraph::new(text.into_text().unwrap())
|
||||
.block(preview_block)
|
||||
.scroll((preview_scroll, 0))
|
||||
}
|
||||
|
||||
fn build_plain_text_paragraph<'a>(
|
||||
text: &'a [String],
|
||||
preview_block: Block<'a>,
|
||||
target_line: Option<u16>,
|
||||
preview_scroll: u16,
|
||||
colorscheme: PreviewColorscheme,
|
||||
highlight_bg: Color,
|
||||
) -> Paragraph<'a> {
|
||||
let mut lines = Vec::new();
|
||||
for (i, line) in text.iter().enumerate() {
|
||||
lines.push(Line::from(vec![
|
||||
build_line_number_span(i + 1).style(Style::default().fg(
|
||||
if matches!(
|
||||
target_line,
|
||||
Some(l) if l == u16::try_from(i).unwrap_or(0) + 1
|
||||
)
|
||||
{
|
||||
colorscheme.gutter_selected_fg
|
||||
} else {
|
||||
colorscheme.gutter_fg
|
||||
},
|
||||
)),
|
||||
Span::styled(" │ ",
|
||||
Style::default().fg(colorscheme.gutter_fg).dim()),
|
||||
Span::styled(
|
||||
line.to_string(),
|
||||
Style::default().fg(colorscheme.content_fg).bg(
|
||||
if matches!(target_line, Some(l) if l == u16::try_from(i).unwrap() + 1) {
|
||||
colorscheme.highlight_bg
|
||||
} else {
|
||||
Color::Reset
|
||||
},
|
||||
),
|
||||
),
|
||||
]));
|
||||
let mut t = text.into_text().unwrap();
|
||||
if let Some(target_line) = target_line {
|
||||
// Highlight the target line
|
||||
if let Some(line) = t.lines.get_mut((target_line - 1) as usize) {
|
||||
for span in &mut line.spans {
|
||||
span.style = span.style.bg(highlight_bg);
|
||||
}
|
||||
let text = Text::from(lines);
|
||||
Paragraph::new(text)
|
||||
.block(preview_block)
|
||||
.scroll((preview_scroll, 0))
|
||||
}
|
||||
|
||||
fn build_plain_text_wrapped_paragraph<'a>(
|
||||
text: &'a str,
|
||||
preview_block: Block<'a>,
|
||||
colorscheme: PreviewColorscheme,
|
||||
) -> Paragraph<'a> {
|
||||
let mut lines = Vec::new();
|
||||
for line in text.lines() {
|
||||
lines.push(Line::styled(
|
||||
line.to_string(),
|
||||
Style::default().fg(colorscheme.content_fg),
|
||||
));
|
||||
}
|
||||
let text = Text::from(lines);
|
||||
Paragraph::new(text)
|
||||
.block(preview_block)
|
||||
.wrap(Wrap { trim: true })
|
||||
}
|
||||
|
||||
fn build_syntect_highlighted_paragraph<'a>(
|
||||
highlighted_lines: &'a [Vec<(syntect::highlighting::Style, String)>],
|
||||
preview_block: Block<'a>,
|
||||
target_line: Option<u16>,
|
||||
preview_scroll: u16,
|
||||
colorscheme: PreviewColorscheme,
|
||||
height: u16,
|
||||
) -> Paragraph<'a> {
|
||||
compute_paragraph_from_highlighted_lines(
|
||||
highlighted_lines,
|
||||
target_line.map(|l| l as usize),
|
||||
preview_scroll,
|
||||
colorscheme,
|
||||
height,
|
||||
)
|
||||
.block(preview_block)
|
||||
.alignment(Alignment::Left)
|
||||
//.scroll((preview_scroll, 0))
|
||||
}
|
||||
Paragraph::new(t).block(preview_block)
|
||||
}
|
||||
|
||||
pub fn build_meta_preview_paragraph<'a>(
|
||||
@ -381,81 +187,3 @@ fn draw_content_outer_block(
|
||||
f.render_widget(preview_outer_block, rect);
|
||||
Ok(inner)
|
||||
}
|
||||
|
||||
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>,
|
||||
preview_scroll: u16,
|
||||
colorscheme: PreviewColorscheme,
|
||||
height: u16,
|
||||
) -> Paragraph<'static> {
|
||||
let preview_lines: Vec<Line> = highlighted_lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(preview_scroll.saturating_sub(1).into())
|
||||
.take(height.into())
|
||||
.map(|(i, l)| {
|
||||
let line_number =
|
||||
build_line_number_span(i + 1).style(Style::default().fg(
|
||||
if line_specifier.is_some()
|
||||
&& i == line_specifier.unwrap().saturating_sub(1)
|
||||
{
|
||||
colorscheme.gutter_selected_fg
|
||||
} else {
|
||||
colorscheme.gutter_fg
|
||||
},
|
||||
));
|
||||
Line::from_iter(
|
||||
std::iter::once(line_number)
|
||||
.chain(std::iter::once(Span::styled(
|
||||
" │ ",
|
||||
Style::default().fg(colorscheme.gutter_fg).dim(),
|
||||
)))
|
||||
.chain(l.iter().cloned().map(|sr| {
|
||||
convert_syn_region_to_span(
|
||||
&(sr.0, sr.1),
|
||||
if line_specifier.is_some()
|
||||
&& i == line_specifier
|
||||
.unwrap()
|
||||
.saturating_sub(1)
|
||||
{
|
||||
Some(colorscheme.highlight_bg)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
})),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Paragraph::new(preview_lines)
|
||||
}
|
||||
|
||||
pub fn convert_syn_region_to_span<'a>(
|
||||
syn_region: &(syntect::highlighting::Style, String),
|
||||
background: Option<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(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,
|
||||
) -> Color {
|
||||
Color::Rgb(color.r, color.g, color.b)
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ use crate::television::Mode;
|
||||
use crate::utils::input::Input;
|
||||
|
||||
use anyhow::Result;
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||
use ratatui::prelude::Style;
|
||||
use ratatui::style::{Color, Stylize};
|
||||
@ -14,7 +15,6 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{
|
||||
Block, BorderType, Borders, ListDirection, ListState, Padding, Paragraph,
|
||||
};
|
||||
use ratatui::Frame;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw_remote_control(
|
||||
|
@ -4,14 +4,15 @@ use crate::screen::layout::InputPosition;
|
||||
use crate::utils::indices::truncate_highlighted_string;
|
||||
use crate::utils::strings::make_matched_string_printable;
|
||||
use anyhow::Result;
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Alignment, Rect};
|
||||
use ratatui::prelude::{Color, Line, Span, Style};
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::widgets::{
|
||||
Block, BorderType, Borders, List, ListDirection, ListState, Padding,
|
||||
};
|
||||
use ratatui::Frame;
|
||||
use rustc_hash::FxHashSet;
|
||||
use std::fmt::Write;
|
||||
use std::str::FromStr;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@ -73,7 +74,8 @@ fn build_result_line<'a>(
|
||||
entry,
|
||||
area_width,
|
||||
use_icons,
|
||||
selected_entries.map_or(false, |selected| selected.contains(entry)),
|
||||
selected_entries
|
||||
.map_or_else(|| false, |selected| selected.contains(entry)),
|
||||
);
|
||||
// optional selection symbol
|
||||
if let Some(selected_entries) = selected_entries {
|
||||
@ -253,10 +255,14 @@ pub fn draw_results_list(
|
||||
help_keybinding: &str,
|
||||
preview_keybinding: &str,
|
||||
preview_togglable: bool,
|
||||
no_help: bool,
|
||||
) -> Result<()> {
|
||||
let mut toggle_hints = format!(" help: <{help_keybinding}> ",);
|
||||
let mut toggle_hints = String::new();
|
||||
if !no_help {
|
||||
write!(toggle_hints, " help: <{}> ", help_keybinding)?;
|
||||
}
|
||||
if preview_togglable {
|
||||
toggle_hints.push_str(&format!(" preview: <{preview_keybinding}> ",));
|
||||
write!(toggle_hints, " preview: <{}> ", preview_keybinding)?;
|
||||
}
|
||||
|
||||
let results_block = Block::default()
|
||||
@ -290,14 +296,11 @@ pub fn draw_results_list(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::channels::entry::PreviewType;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_result_line() {
|
||||
let entry =
|
||||
Entry::new(String::from("something nice"), PreviewType::None)
|
||||
let entry = Entry::new(String::from("something nice"))
|
||||
.with_name_match_indices(
|
||||
// something nice
|
||||
// 012345678901234
|
||||
@ -327,7 +330,7 @@ mod tests {
|
||||
fn test_build_result_line_multibyte_chars() {
|
||||
let entry =
|
||||
// See https://github.com/alexpasmantier/television/issues/439
|
||||
Entry::new(String::from("ジェイムス下地 - REDLINE Original Soundtrack - 06 - ROBOWORLD TV.mp3"), PreviewType::None)
|
||||
Entry::new(String::from("ジェイムス下地 - REDLINE Original Soundtrack - 06 - ROBOWORLD TV.mp3"))
|
||||
.with_name_match_indices(&[27, 28, 29, 30, 31]);
|
||||
let result_line = build_result_line(
|
||||
&entry,
|
||||
|
@ -1,34 +1,42 @@
|
||||
use crate::action::Action;
|
||||
use crate::cable::load_cable_channels;
|
||||
use crate::channels::entry::{Entry, ENTRY_PLACEHOLDER};
|
||||
use crate::channels::{
|
||||
remote_control::{load_builtin_channels, RemoteControl},
|
||||
OnAir, TelevisionChannel, UnitChannel,
|
||||
use crate::{
|
||||
action::Action,
|
||||
channels::{
|
||||
cable::Channel as CableChannel,
|
||||
entry::Entry,
|
||||
prototypes::{Cable, ChannelPrototype},
|
||||
remote_control::RemoteControl,
|
||||
},
|
||||
config::{Config, Theme},
|
||||
draw::{ChannelState, Ctx, TvState},
|
||||
input::convert_action_to_input_request,
|
||||
picker::Picker,
|
||||
previewer::{
|
||||
Config as PreviewerConfig, Preview, Previewer,
|
||||
Request as PreviewRequest, Ticket, state::PreviewState,
|
||||
},
|
||||
render::UiState,
|
||||
screen::{
|
||||
colors::Colorscheme,
|
||||
layout::InputPosition,
|
||||
spinner::{Spinner, SpinnerState},
|
||||
},
|
||||
utils::{
|
||||
clipboard::CLIPBOARD, metadata::AppMetadata, strings::EMPTY_STRING,
|
||||
},
|
||||
};
|
||||
use crate::config::{Config, Theme};
|
||||
use crate::draw::{ChannelState, Ctx, TvState};
|
||||
use crate::input::convert_action_to_input_request;
|
||||
use crate::picker::Picker;
|
||||
use crate::preview::{Preview, PreviewState, Previewer};
|
||||
use crate::render::UiState;
|
||||
use crate::screen::colors::Colorscheme;
|
||||
use crate::screen::layout::InputPosition;
|
||||
use crate::screen::spinner::{Spinner, SpinnerState};
|
||||
use crate::utils::clipboard::CLIPBOARD;
|
||||
use crate::utils::metadata::AppMetadata;
|
||||
use crate::utils::strings::EMPTY_STRING;
|
||||
use anyhow::Result;
|
||||
use rustc_hash::{FxBuildHasher, FxHashSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio::sync::mpsc::{
|
||||
UnboundedReceiver, UnboundedSender, unbounded_channel,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)]
|
||||
pub enum Mode {
|
||||
Channel,
|
||||
RemoteControl,
|
||||
SendToChannel,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)]
|
||||
@ -40,15 +48,17 @@ pub enum MatchingMode {
|
||||
pub struct Television {
|
||||
action_tx: UnboundedSender<Action>,
|
||||
pub config: Config,
|
||||
pub channel: TelevisionChannel,
|
||||
pub remote_control: Option<TelevisionChannel>,
|
||||
pub channel: CableChannel,
|
||||
pub remote_control: Option<RemoteControl>,
|
||||
pub mode: Mode,
|
||||
pub currently_selected: Option<Entry>,
|
||||
pub current_pattern: String,
|
||||
pub matching_mode: MatchingMode,
|
||||
pub results_picker: Picker,
|
||||
pub rc_picker: Picker,
|
||||
pub previewer: Previewer,
|
||||
pub preview_state: PreviewState,
|
||||
pub preview_handles:
|
||||
Option<(UnboundedSender<PreviewRequest>, UnboundedReceiver<Preview>)>,
|
||||
pub spinner: Spinner,
|
||||
pub spinner_state: SpinnerState,
|
||||
pub app_metadata: AppMetadata,
|
||||
@ -59,25 +69,27 @@ pub struct Television {
|
||||
}
|
||||
|
||||
impl Television {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
action_tx: UnboundedSender<Action>,
|
||||
mut channel: TelevisionChannel,
|
||||
channel_prototype: &ChannelPrototype,
|
||||
mut config: Config,
|
||||
input: Option<String>,
|
||||
no_remote: bool,
|
||||
no_help: bool,
|
||||
exact: bool,
|
||||
cable_channels: Cable,
|
||||
) -> Self {
|
||||
let mut results_picker = Picker::new(input.clone());
|
||||
if config.ui.input_bar_position == InputPosition::Bottom {
|
||||
results_picker = results_picker.inverted();
|
||||
}
|
||||
let previewer = Previewer::new(Some(config.previewers.clone().into()));
|
||||
let cable_channels = load_cable_channels().unwrap_or_default();
|
||||
let builtin_channels = load_builtin_channels(Some(
|
||||
&cable_channels.keys().collect::<Vec<_>>(),
|
||||
));
|
||||
|
||||
// previewer
|
||||
let preview_handles = Self::setup_previewer(channel_prototype);
|
||||
|
||||
let mut channel = CableChannel::new(channel_prototype);
|
||||
|
||||
let app_metadata = AppMetadata::new(
|
||||
env!("CARGO_PKG_VERSION").to_string(),
|
||||
@ -93,7 +105,7 @@ impl Television {
|
||||
|
||||
let preview_state = PreviewState::new(
|
||||
channel.supports_preview(),
|
||||
Arc::new(Preview::default()),
|
||||
Preview::default(),
|
||||
0,
|
||||
None,
|
||||
);
|
||||
@ -101,14 +113,12 @@ impl Television {
|
||||
let remote_control = if no_remote {
|
||||
None
|
||||
} else {
|
||||
Some(TelevisionChannel::RemoteControl(RemoteControl::new(
|
||||
builtin_channels,
|
||||
Some(cable_channels),
|
||||
)))
|
||||
Some(RemoteControl::new(Some(cable_channels)))
|
||||
};
|
||||
|
||||
if no_help {
|
||||
config.ui.show_help_bar = false;
|
||||
config.ui.no_help = true;
|
||||
}
|
||||
|
||||
let matching_mode = if exact {
|
||||
@ -123,12 +133,13 @@ impl Television {
|
||||
channel,
|
||||
remote_control,
|
||||
mode: Mode::Channel,
|
||||
currently_selected: None,
|
||||
current_pattern: EMPTY_STRING.to_string(),
|
||||
results_picker,
|
||||
matching_mode,
|
||||
rc_picker: Picker::default(),
|
||||
previewer,
|
||||
preview_state,
|
||||
preview_handles,
|
||||
spinner,
|
||||
spinner_state: SpinnerState::from(&spinner),
|
||||
app_metadata,
|
||||
@ -139,35 +150,45 @@ impl Television {
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_previewer(
|
||||
channel_prototype: &ChannelPrototype,
|
||||
) -> Option<(UnboundedSender<PreviewRequest>, UnboundedReceiver<Preview>)>
|
||||
{
|
||||
if channel_prototype.preview_command.is_some() {
|
||||
let (pv_request_tx, pv_request_rx) = unbounded_channel();
|
||||
let (pv_preview_tx, pv_preview_rx) = unbounded_channel();
|
||||
let previewer = Previewer::new(
|
||||
channel_prototype.preview_command.clone().unwrap(),
|
||||
PreviewerConfig::default(),
|
||||
pv_request_rx,
|
||||
pv_preview_tx,
|
||||
);
|
||||
tokio::spawn(async move { previewer.run().await });
|
||||
Some((pv_request_tx, pv_preview_rx))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_ui_state(&mut self, ui_state: UiState) {
|
||||
self.ui_state = ui_state;
|
||||
}
|
||||
|
||||
pub fn init_remote_control(&mut self) {
|
||||
let cable_channels = load_cable_channels().unwrap_or_default();
|
||||
let builtin_channels = load_builtin_channels(Some(
|
||||
&cable_channels.keys().collect::<Vec<_>>(),
|
||||
));
|
||||
self.remote_control = Some(TelevisionChannel::RemoteControl(
|
||||
RemoteControl::new(builtin_channels, Some(cable_channels)),
|
||||
));
|
||||
}
|
||||
|
||||
pub fn dump_context(&self) -> Ctx {
|
||||
let channel_state = ChannelState::new(
|
||||
self.channel.name(),
|
||||
self.channel.name.clone(),
|
||||
self.channel.selected_entries().clone(),
|
||||
self.channel.total_count(),
|
||||
self.channel.running(),
|
||||
);
|
||||
let tv_state = TvState::new(
|
||||
self.mode,
|
||||
self.get_selected_entry(Some(Mode::Channel)),
|
||||
self.currently_selected.clone(),
|
||||
self.results_picker.clone(),
|
||||
self.rc_picker.clone(),
|
||||
channel_state,
|
||||
self.spinner,
|
||||
self.preview_state.clone(),
|
||||
self.preview_state.for_render_context(),
|
||||
);
|
||||
|
||||
Ctx::new(
|
||||
@ -175,26 +196,34 @@ impl Television {
|
||||
self.config.clone(),
|
||||
self.colorscheme.clone(),
|
||||
self.app_metadata.clone(),
|
||||
// now timestamp
|
||||
std::time::Instant::now(),
|
||||
self.ui_state.layout,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn current_channel(&self) -> UnitChannel {
|
||||
UnitChannel::from(&self.channel)
|
||||
pub fn current_channel(&self) -> String {
|
||||
self.channel.name.clone()
|
||||
}
|
||||
|
||||
pub fn change_channel(&mut self, channel: TelevisionChannel) {
|
||||
pub fn change_channel(&mut self, channel_prototype: &ChannelPrototype) {
|
||||
self.preview_state.reset();
|
||||
self.preview_state.enabled =
|
||||
channel_prototype.preview_command.is_some();
|
||||
self.reset_picker_selection();
|
||||
self.reset_picker_input();
|
||||
self.current_pattern = EMPTY_STRING.to_string();
|
||||
self.channel.shutdown();
|
||||
self.channel = channel;
|
||||
if let Some((sender, _)) = &self.preview_handles {
|
||||
sender
|
||||
.send(PreviewRequest::Shutdown)
|
||||
.expect("Failed to send shutdown signal to previewer");
|
||||
}
|
||||
self.preview_handles = Self::setup_previewer(channel_prototype);
|
||||
self.channel = CableChannel::new(channel_prototype);
|
||||
debug!("Changed channel to {:?}", channel_prototype);
|
||||
}
|
||||
|
||||
fn find(&mut self, pattern: &str) {
|
||||
pub fn find(&mut self, pattern: &str) {
|
||||
match self.mode {
|
||||
Mode::Channel => {
|
||||
self.channel.find(
|
||||
@ -202,8 +231,10 @@ impl Television {
|
||||
.as_str(),
|
||||
);
|
||||
}
|
||||
Mode::RemoteControl | Mode::SendToChannel => {
|
||||
self.remote_control.as_mut().unwrap().find(pattern);
|
||||
Mode::RemoteControl => {
|
||||
if let Some(rc) = self.remote_control.as_mut() {
|
||||
rc.find(pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -232,13 +263,11 @@ impl Television {
|
||||
}
|
||||
None
|
||||
}
|
||||
Mode::RemoteControl | Mode::SendToChannel => {
|
||||
Mode::RemoteControl => {
|
||||
if let Some(i) = self.rc_picker.selected() {
|
||||
return self
|
||||
.remote_control
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get_result(i.try_into().unwrap());
|
||||
if let Some(rc) = &self.remote_control {
|
||||
return rc.get_result(i.try_into().unwrap());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@ -267,7 +296,7 @@ impl Television {
|
||||
Mode::Channel => {
|
||||
(self.channel.result_count(), &mut self.results_picker)
|
||||
}
|
||||
Mode::RemoteControl | Mode::SendToChannel => (
|
||||
Mode::RemoteControl => (
|
||||
self.remote_control.as_ref().unwrap().total_count(),
|
||||
&mut self.rc_picker,
|
||||
),
|
||||
@ -287,7 +316,7 @@ impl Television {
|
||||
Mode::Channel => {
|
||||
(self.channel.result_count(), &mut self.results_picker)
|
||||
}
|
||||
Mode::RemoteControl | Mode::SendToChannel => (
|
||||
Mode::RemoteControl => (
|
||||
self.remote_control.as_ref().unwrap().total_count(),
|
||||
&mut self.rc_picker,
|
||||
),
|
||||
@ -305,7 +334,7 @@ impl Television {
|
||||
fn reset_picker_selection(&mut self) {
|
||||
match self.mode {
|
||||
Mode::Channel => self.results_picker.reset_selection(),
|
||||
Mode::RemoteControl | Mode::SendToChannel => {
|
||||
Mode::RemoteControl => {
|
||||
self.rc_picker.reset_selection();
|
||||
}
|
||||
}
|
||||
@ -314,7 +343,7 @@ impl Television {
|
||||
fn reset_picker_input(&mut self) {
|
||||
match self.mode {
|
||||
Mode::Channel => self.results_picker.reset_input(),
|
||||
Mode::RemoteControl | Mode::SendToChannel => {
|
||||
Mode::RemoteControl => {
|
||||
self.rc_picker.reset_input();
|
||||
}
|
||||
}
|
||||
@ -373,21 +402,23 @@ impl Television {
|
||||
|
||||
pub fn update_preview_state(
|
||||
&mut self,
|
||||
selected_entry: &Entry,
|
||||
selected_entry: &Option<Entry>,
|
||||
) -> Result<()> {
|
||||
if self.config.ui.show_preview_panel && self.channel.supports_preview()
|
||||
{
|
||||
// preview content
|
||||
if let Some(preview) = self
|
||||
.previewer
|
||||
.preview(selected_entry, self.ui_state.layout.preview_window)
|
||||
{
|
||||
// only update if the preview content has changed
|
||||
if self.preview_state.preview.title != preview.title {
|
||||
self.preview_state.update(
|
||||
preview,
|
||||
// scroll to center the selected entry
|
||||
selected_entry
|
||||
if selected_entry.is_none() {
|
||||
self.preview_state.reset();
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((sender, receiver)) = &mut self.preview_handles {
|
||||
// preview requests
|
||||
if *selected_entry != self.currently_selected {
|
||||
sender.send(PreviewRequest::Preview(Ticket::new(
|
||||
selected_entry.as_ref().unwrap().clone(),
|
||||
)))?;
|
||||
}
|
||||
// available previews
|
||||
let entry = selected_entry.as_ref().unwrap();
|
||||
if let Ok(preview) = receiver.try_recv() {
|
||||
let scroll = entry
|
||||
.line_number
|
||||
.unwrap_or(0)
|
||||
.saturating_sub(
|
||||
@ -395,24 +426,23 @@ impl Television {
|
||||
.ui_state
|
||||
.layout
|
||||
.preview_window
|
||||
.map_or(0, |w| w.height)
|
||||
.map_or(0, |w| w.height.saturating_sub(2)) // borders
|
||||
/ 2)
|
||||
.into(),
|
||||
)
|
||||
.saturating_add(3) // 3 lines above the center
|
||||
.try_into()
|
||||
// if the scroll doesn't fit in a u16, just scroll to the top
|
||||
// this is a current limitation of ratatui
|
||||
.unwrap_or(0),
|
||||
selected_entry
|
||||
.line_number
|
||||
.and_then(|l| l.try_into().ok()),
|
||||
.unwrap_or(0);
|
||||
self.preview_state.update(
|
||||
preview,
|
||||
scroll,
|
||||
entry.line_number.and_then(|l| l.try_into().ok()),
|
||||
);
|
||||
self.action_tx.send(Action::Render)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.preview_state.reset();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -452,9 +482,7 @@ impl Television {
|
||||
pub fn handle_input_action(&mut self, action: &Action) {
|
||||
let input = match self.mode {
|
||||
Mode::Channel => &mut self.results_picker.input,
|
||||
Mode::RemoteControl | Mode::SendToChannel => {
|
||||
&mut self.rc_picker.input
|
||||
}
|
||||
Mode::RemoteControl => &mut self.rc_picker.input,
|
||||
};
|
||||
input.handle(convert_action_to_input_request(action).unwrap());
|
||||
match action {
|
||||
@ -468,7 +496,6 @@ impl Television {
|
||||
self.current_pattern.clone_from(&new_pattern);
|
||||
self.find(&new_pattern);
|
||||
self.reset_picker_selection();
|
||||
self.preview_state.reset();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@ -482,32 +509,9 @@ impl Television {
|
||||
match self.mode {
|
||||
Mode::Channel => {
|
||||
self.mode = Mode::RemoteControl;
|
||||
self.init_remote_control();
|
||||
}
|
||||
Mode::RemoteControl => {
|
||||
// this resets the RC picker
|
||||
self.reset_picker_input();
|
||||
self.init_remote_control();
|
||||
self.remote_control.as_mut().unwrap().find(EMPTY_STRING);
|
||||
self.reset_picker_selection();
|
||||
self.mode = Mode::Channel;
|
||||
}
|
||||
Mode::SendToChannel => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_toggle_send_to_channel(&mut self) {
|
||||
if self.remote_control.is_none() {
|
||||
return;
|
||||
}
|
||||
match self.mode {
|
||||
Mode::Channel | Mode::RemoteControl => {
|
||||
self.mode = Mode::SendToChannel;
|
||||
self.remote_control = Some(TelevisionChannel::RemoteControl(
|
||||
RemoteControl::with_transitions_from(&self.channel),
|
||||
));
|
||||
}
|
||||
Mode::SendToChannel => {
|
||||
self.reset_picker_input();
|
||||
self.remote_control.as_mut().unwrap().find(EMPTY_STRING);
|
||||
self.reset_picker_selection();
|
||||
@ -518,8 +522,8 @@ impl Television {
|
||||
|
||||
pub fn handle_toggle_selection(&mut self, action: &Action) {
|
||||
if matches!(self.mode, Mode::Channel) {
|
||||
if let Some(entry) = self.get_selected_entry(None) {
|
||||
self.channel.toggle_selection(&entry);
|
||||
if let Some(entry) = &self.currently_selected {
|
||||
self.channel.toggle_selection(entry);
|
||||
if matches!(action, Action::ToggleSelectionDown) {
|
||||
self.select_next_entry(1);
|
||||
} else {
|
||||
@ -546,19 +550,7 @@ impl Television {
|
||||
self.reset_picker_input();
|
||||
self.remote_control.as_mut().unwrap().find(EMPTY_STRING);
|
||||
self.mode = Mode::Channel;
|
||||
self.change_channel(new_channel);
|
||||
}
|
||||
}
|
||||
Mode::SendToChannel => {
|
||||
if let Some(entry) = self.get_selected_entry(None) {
|
||||
let new_channel = self
|
||||
.channel
|
||||
.transition_to(entry.name.as_str().try_into()?);
|
||||
self.reset_picker_selection();
|
||||
self.reset_picker_input();
|
||||
self.remote_control.as_mut().unwrap().find(EMPTY_STRING);
|
||||
self.mode = Mode::Channel;
|
||||
self.change_channel(new_channel);
|
||||
self.change_channel(&new_channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -594,15 +586,12 @@ impl Television {
|
||||
self.handle_input_action(action);
|
||||
}
|
||||
Action::SelectNextEntry => {
|
||||
self.preview_state.reset();
|
||||
self.select_next_entry(1);
|
||||
}
|
||||
Action::SelectPrevEntry => {
|
||||
self.preview_state.reset();
|
||||
self.select_prev_entry(1);
|
||||
}
|
||||
Action::SelectNextPage => {
|
||||
self.preview_state.reset();
|
||||
self.select_next_entry(
|
||||
self.ui_state
|
||||
.layout
|
||||
@ -613,7 +602,6 @@ impl Television {
|
||||
);
|
||||
}
|
||||
Action::SelectPrevPage => {
|
||||
self.preview_state.reset();
|
||||
self.select_prev_entry(
|
||||
self.ui_state
|
||||
.layout
|
||||
@ -643,9 +631,6 @@ impl Television {
|
||||
Action::CopyEntryToClipboard => {
|
||||
self.handle_copy_entry_to_clipboard();
|
||||
}
|
||||
Action::ToggleSendToChannel => {
|
||||
self.handle_toggle_send_to_channel();
|
||||
}
|
||||
Action::ToggleHelp => {
|
||||
if self.no_help {
|
||||
return Ok(());
|
||||
@ -674,12 +659,11 @@ impl Television {
|
||||
self.update_rc_picker_state();
|
||||
}
|
||||
|
||||
let selected_entry = self
|
||||
.get_selected_entry(Some(Mode::Channel))
|
||||
.unwrap_or(ENTRY_PLACEHOLDER);
|
||||
|
||||
if self.mode == Mode::Channel {
|
||||
let selected_entry = self.get_selected_entry(None);
|
||||
self.update_preview_state(&selected_entry)?;
|
||||
|
||||
self.currently_selected = selected_entry;
|
||||
}
|
||||
self.ticks += 1;
|
||||
|
||||
Ok(if self.should_render(action) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
io::{stderr, LineWriter, Write},
|
||||
io::{LineWriter, Write, stderr},
|
||||
ops::{Deref, DerefMut},
|
||||
};
|
||||
|
||||
@ -9,8 +9,8 @@ use crossterm::{
|
||||
event::DisableMouseCapture,
|
||||
execute,
|
||||
terminal::{
|
||||
disable_raw_mode, enable_raw_mode, is_raw_mode_enabled,
|
||||
EnterAlternateScreen, LeaveAlternateScreen,
|
||||
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
|
||||
enable_raw_mode, is_raw_mode_enabled,
|
||||
},
|
||||
};
|
||||
use ratatui::{backend::CrosstermBackend, layout::Size};
|
||||
|
@ -82,7 +82,7 @@ impl Clipboard {
|
||||
#[cfg(unix)]
|
||||
pub async fn set(&self, s: impl AsRef<std::ffi::OsStr>) {
|
||||
use std::{
|
||||
io::{stderr, BufWriter},
|
||||
io::{BufWriter, stderr},
|
||||
process::Stdio,
|
||||
};
|
||||
|
||||
@ -150,7 +150,7 @@ impl Clipboard {
|
||||
mod osc52 {
|
||||
use std::ffi::OsStr;
|
||||
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SetClipboard {
|
||||
|
@ -1,5 +1,8 @@
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(not(unix))]
|
||||
use tracing::warn;
|
||||
|
||||
use super::shell::Shell;
|
||||
|
||||
pub fn shell_command(interactive: bool) -> Command {
|
||||
@ -17,5 +20,10 @@ pub fn shell_command(interactive: bool) -> Command {
|
||||
cmd.arg("-i");
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
if interactive {
|
||||
warn!("Interactive mode is not supported on Windows.");
|
||||
}
|
||||
|
||||
cmd
|
||||
}
|
||||
|
@ -5,14 +5,12 @@ use std::io::BufRead;
|
||||
use std::io::BufReader;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use ignore::{overrides::Override, types::TypesBuilder, WalkBuilder};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::utils::strings::{
|
||||
proportion_of_printable_ascii_characters, PRINTABLE_ASCII_THRESHOLD,
|
||||
PRINTABLE_ASCII_THRESHOLD, proportion_of_printable_ascii_characters,
|
||||
};
|
||||
use crate::utils::threads::default_num_threads;
|
||||
|
||||
@ -67,38 +65,6 @@ pub fn get_default_num_threads() -> usize {
|
||||
*DEFAULT_NUM_THREADS.get_or_init(default_num_threads)
|
||||
}
|
||||
|
||||
pub fn walk_builder(
|
||||
path: &Path,
|
||||
n_threads: usize,
|
||||
overrides: Option<Override>,
|
||||
ignore_paths: Option<Vec<PathBuf>>,
|
||||
) -> 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());
|
||||
|
||||
// ignore paths
|
||||
if let Some(paths) = ignore_paths {
|
||||
builder.filter_entry(move |e| {
|
||||
let path = e.path();
|
||||
if paths.iter().any(|p| path.starts_with(p)) {
|
||||
debug!("Ignoring path: {:?}", path);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
builder.threads(n_threads);
|
||||
if let Some(ov) = overrides {
|
||||
builder.overrides(ov);
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
pub fn get_file_size(path: &Path) -> Option<u64> {
|
||||
std::fs::metadata(path).ok().map(|m| m.len())
|
||||
}
|
||||
|
@ -1,185 +0,0 @@
|
||||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, Pixel, Rgba};
|
||||
use ratatui::buffer::{Buffer, Cell};
|
||||
use ratatui::layout::{Position, Rect};
|
||||
use ratatui::prelude::Color;
|
||||
use ratatui::widgets::Widget;
|
||||
use std::fmt::Debug;
|
||||
use std::hash::Hash;
|
||||
|
||||
static PIXEL_STRING: &str = "▀";
|
||||
const FILTER_TYPE: FilterType = FilterType::Lanczos3;
|
||||
|
||||
// use to reduce the size of the image before storing it
|
||||
const DEFAULT_CACHED_WIDTH: u32 = 50;
|
||||
const DEFAULT_CACHED_HEIGHT: u32 = 100;
|
||||
|
||||
const GRAY: Rgba<u8> = Rgba([242, 242, 242, 255]);
|
||||
const WHITE: Rgba<u8> = Rgba([255, 255, 255, 255]);
|
||||
|
||||
#[derive(Clone, Debug, Hash, PartialEq)]
|
||||
pub struct ImagePreviewWidget {
|
||||
cells: Vec<Vec<Cell>>,
|
||||
}
|
||||
|
||||
impl Widget for &ImagePreviewWidget {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let height = self.height();
|
||||
let width = self.width();
|
||||
// offset of the left top corner where the image is centered
|
||||
let total_width = usize::from(area.width) + 2 * usize::from(area.x);
|
||||
let x_offset = total_width.saturating_sub(width) / 2 + 1;
|
||||
let total_height = usize::from(area.height) + 2 * usize::from(area.y);
|
||||
let y_offset = total_height.saturating_sub(height) / 2;
|
||||
|
||||
let (area_border_up, area_border_down) =
|
||||
(area.y, area.y + area.height);
|
||||
let (area_border_left, area_border_right) =
|
||||
(area.x, area.x + area.width);
|
||||
for (y, row) in self.cells.iter().enumerate() {
|
||||
let pos_y = u16::try_from(y_offset + y).unwrap_or(u16::MAX);
|
||||
if pos_y >= area_border_up && pos_y < area_border_down {
|
||||
for (x, cell) in row.iter().enumerate() {
|
||||
let pos_x =
|
||||
u16::try_from(x_offset + x).unwrap_or(u16::MAX);
|
||||
if pos_x >= area_border_left && pos_x <= area_border_right
|
||||
{
|
||||
if let Some(buf_cell) =
|
||||
buf.cell_mut(Position::new(pos_x, pos_y))
|
||||
{
|
||||
*buf_cell = cell.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ImagePreviewWidget {
|
||||
pub fn new(cells: Vec<Vec<Cell>>) -> ImagePreviewWidget {
|
||||
ImagePreviewWidget { cells }
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
self.cells.len()
|
||||
}
|
||||
pub fn width(&self) -> usize {
|
||||
if self.height() > 0 {
|
||||
self.cells[0].len()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_dynamic_image(
|
||||
dynamic_image: DynamicImage,
|
||||
dimension: Option<(u32, u32)>,
|
||||
) -> Self {
|
||||
let (window_width, window_height) =
|
||||
dimension.unwrap_or((DEFAULT_CACHED_WIDTH, DEFAULT_CACHED_HEIGHT));
|
||||
let (max_width, max_height) = (window_width, window_height * 2 - 2); // -2 to have some space with the title
|
||||
|
||||
// first quick resize
|
||||
let big_resized_image = if dynamic_image.width() > max_width * 4
|
||||
|| dynamic_image.height() > max_height * 4
|
||||
{
|
||||
dynamic_image.resize(
|
||||
max_width * 4,
|
||||
max_height * 4,
|
||||
FilterType::Nearest,
|
||||
)
|
||||
} else {
|
||||
dynamic_image
|
||||
};
|
||||
|
||||
// this time resize with the filter
|
||||
let resized_image = if big_resized_image.width() > max_width
|
||||
|| big_resized_image.height() > max_height
|
||||
{
|
||||
big_resized_image.resize(max_width, max_height, FILTER_TYPE)
|
||||
} else {
|
||||
big_resized_image
|
||||
};
|
||||
|
||||
let cells = Self::cells_from_dynamic_image(resized_image);
|
||||
ImagePreviewWidget::new(cells)
|
||||
}
|
||||
|
||||
fn cells_from_dynamic_image(image: DynamicImage) -> Vec<Vec<Cell>> {
|
||||
let image_rgba = image.into_rgba8();
|
||||
|
||||
//creation of the grid of cell
|
||||
image_rgba
|
||||
// iter over pair of rows
|
||||
.rows()
|
||||
.step_by(2)
|
||||
.zip(image_rgba.rows().skip(1).step_by(2))
|
||||
.enumerate()
|
||||
.map(|(double_row_y, (row_1, row_2))| {
|
||||
// create rows of cells
|
||||
row_1
|
||||
.into_iter()
|
||||
.zip(row_2)
|
||||
.enumerate()
|
||||
.map(|(x, (color_up, color_down))| {
|
||||
let position = (x, double_row_y);
|
||||
DoublePixel::new(*color_up, *color_down)
|
||||
.add_grid_background(position)
|
||||
.into_cell()
|
||||
})
|
||||
.collect::<Vec<Cell>>()
|
||||
})
|
||||
.collect::<Vec<Vec<Cell>>>()
|
||||
}
|
||||
}
|
||||
|
||||
// util to convert Rgba into ratatui's Cell
|
||||
struct DoublePixel {
|
||||
color_up: Rgba<u8>,
|
||||
color_down: Rgba<u8>,
|
||||
}
|
||||
impl DoublePixel {
|
||||
pub fn new(color_up: Rgba<u8>, color_down: Rgba<u8>) -> Self {
|
||||
Self {
|
||||
color_up,
|
||||
color_down,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_grid_background(mut self, position: (usize, usize)) -> Self {
|
||||
let color_up = self.color_up.0;
|
||||
let color_down = self.color_down.0;
|
||||
self.color_up = Self::blend_with_background(color_up, position, 0);
|
||||
self.color_down = Self::blend_with_background(color_down, position, 1);
|
||||
self
|
||||
}
|
||||
|
||||
fn blend_with_background(
|
||||
color: impl Into<Rgba<u8>>,
|
||||
position: (usize, usize),
|
||||
offset: usize,
|
||||
) -> Rgba<u8> {
|
||||
let color = color.into();
|
||||
if color[3] == 255 {
|
||||
color
|
||||
} else {
|
||||
let is_white = (position.0 + position.1 * 2 + offset) % 2 == 0;
|
||||
let mut base = if is_white { WHITE } else { GRAY };
|
||||
base.blend(&color);
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_cell(self) -> Cell {
|
||||
let mut cell = Cell::new(PIXEL_STRING);
|
||||
cell.set_bg(Self::convert_image_color_to_ratatui_color(
|
||||
self.color_down,
|
||||
))
|
||||
.set_fg(Self::convert_image_color_to_ratatui_color(self.color_up));
|
||||
cell
|
||||
}
|
||||
|
||||
fn convert_image_color_to_ratatui_color(color: Rgba<u8>) -> Color {
|
||||
Color::Rgb(color[0], color[1], color[2])
|
||||
}
|
||||
}
|
@ -3,7 +3,6 @@ pub mod clipboard;
|
||||
pub mod command;
|
||||
pub mod files;
|
||||
pub mod hashmaps;
|
||||
pub mod image;
|
||||
pub mod indices;
|
||||
pub mod input;
|
||||
pub mod metadata;
|
||||
@ -11,5 +10,4 @@ pub mod rocell;
|
||||
pub mod shell;
|
||||
pub mod stdin;
|
||||
pub mod strings;
|
||||
pub mod syntax;
|
||||
pub mod threads;
|
||||
|
@ -1,10 +1,11 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use crate::cli::args::Shell as CliShell;
|
||||
use crate::config::shell_integration::ShellIntegrationConfig;
|
||||
use anyhow::Result;
|
||||
use strum::Display;
|
||||
use tracing::debug;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Display)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Shell {
|
||||
Bash,
|
||||
Zsh,
|
||||
@ -25,6 +26,18 @@ impl Default for Shell {
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Shell {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Shell::Bash => write!(f, "bash"),
|
||||
Shell::Zsh => write!(f, "zsh"),
|
||||
Shell::Fish => write!(f, "fish"),
|
||||
Shell::PowerShell => write!(f, "powershell"),
|
||||
Shell::Cmd => write!(f, "cmd"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SHELL_ENV_VAR: &str = "SHELL";
|
||||
|
||||
impl TryFrom<&str> for Shell {
|
||||
|
@ -1,15 +1,118 @@
|
||||
function tv_smart_autocomplete
|
||||
# prefix (lhs of cursor)
|
||||
set -l current_prompt (commandline -cp)
|
||||
function __tv_parse_commandline --description 'Parse the current command line token and return split of existing filepath, and tv query'
|
||||
# credits to the junegunn/fzf project
|
||||
# https://github.com/junegunn/fzf/blob/9c1a47acf7453f9dad5905b7f23ad06e5195d51f/shell/key-bindings.fish#L53-L131
|
||||
|
||||
set -l output (tv --autocomplete-prompt "$current_prompt")
|
||||
set -l tv_query ''
|
||||
set -l prefix ''
|
||||
set -l dir '.'
|
||||
|
||||
if test -n "$output"
|
||||
# add a space if the prompt does not end with one (unless the prompt is an implicit cd, e.g. '\.')
|
||||
string match -q -r '.*( |./)$' -- "$current_prompt" || set output " $output"
|
||||
commandline -i "$output"
|
||||
commandline -f repaint
|
||||
# Set variables containing the major and minor fish version numbers, using
|
||||
# a method compatible with all supported fish versions.
|
||||
set -l -- fish_major (string match -r -- '^\d+' $version)
|
||||
set -l -- fish_minor (string match -r -- '^\d+\.(\d+)' $version)[2]
|
||||
|
||||
# fish v3.3.0 and newer: Don't use option prefix if " -- " is preceded.
|
||||
set -l -- match_regex '(?<tv_query>[\s\S]*?(?=\n?$)$)'
|
||||
set -l -- prefix_regex '^-[^\s=]+=|^-(?!-)\S'
|
||||
if test "$fish_major" -eq 3 -a "$fish_minor" -lt 3
|
||||
or string match -q -v -- '* -- *' (string sub -l (commandline -Cp) -- (commandline -p))
|
||||
set -- match_regex "(?<prefix>$prefix_regex)?$match_regex"
|
||||
end
|
||||
|
||||
# Set $prefix and expanded $tv_query with preserved trailing newlines.
|
||||
if test "$fish_major" -ge 4
|
||||
# fish v4.0.0 and newer
|
||||
string match -q -r -- $match_regex (commandline --current-token --tokens-expanded | string collect -N)
|
||||
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
|
||||
# fish v3.2.0 - v3.7.1 (last v3)
|
||||
string match -q -r -- $match_regex (commandline --current-token --tokenize | string collect -N)
|
||||
eval set -- tv_query (string escape -n -- $tv_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)' '')
|
||||
else
|
||||
# fish older than v3.2.0 (v3.1b1 - v3.1.2)
|
||||
set -l -- cl_token (commandline --current-token --tokenize | string collect -N)
|
||||
set -- prefix (string match -r -- $prefix_regex $cl_token)
|
||||
set -- tv_query (string replace -- "$prefix" '' $cl_token | string collect -N)
|
||||
eval set -- tv_query (string escape -n -- $tv_query | string replace -r -a '^\\\(?=~)|\\\(?=\$\w)|\\\n\\\n$' '')
|
||||
end
|
||||
|
||||
if test -n "$tv_query"
|
||||
# Normalize path in $tv_query, set $dir to the longest existing directory.
|
||||
if test \( "$fish_major" -ge 4 \) -o \( "$fish_major" -eq 3 -a "$fish_minor" -ge 5 \)
|
||||
# fish v3.5.0 and newer
|
||||
set -- tv_query (path normalize -- $tv_query)
|
||||
set -- dir $tv_query
|
||||
while not path is -d $dir
|
||||
set -- dir (path dirname $dir)
|
||||
end
|
||||
else
|
||||
# fish older than v3.5.0 (v3.1b1 - v3.4.1)
|
||||
if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
|
||||
# fish v3.2.0 - v3.4.1
|
||||
string match -q -r -- '(?<tv_query>^[\s\S]*?(?=\n?$)$)' \
|
||||
(string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $tv_query | string collect -N)
|
||||
else
|
||||
# fish v3.1b1 - v3.1.2
|
||||
set -- tv_query (string replace -r -a -- '(?<=/)/|(?<!^)/+(?!\n)$' '' $tv_query | string collect -N)
|
||||
eval set -- tv_query (string escape -n -- $tv_query | string replace -r '\\\n$' '')
|
||||
end
|
||||
set -- dir $tv_query
|
||||
while not test -d "$dir"
|
||||
set -- dir (dirname -z -- "$dir" | string split0)
|
||||
end
|
||||
end
|
||||
|
||||
if not string match -q -- '.' $dir; or string match -q -r -- '^\./|^\.$' $tv_query
|
||||
# Strip $dir from $tv_query - preserve trailing newlines.
|
||||
if test "$fish_major" -ge 4
|
||||
# fish v4.0.0 and newer
|
||||
string match -q -r -- '^'(string escape --style=regex -- $dir)'/?(?<tv_query>[\s\S]*)' $tv_query
|
||||
else if test "$fish_major" -eq 3 -a "$fish_minor" -ge 2
|
||||
# fish v3.2.0 - v3.7.1 (last v3)
|
||||
string match -q -r -- '^/?(?<tv_query>[\s\S]*?(?=\n?$)$)' \
|
||||
(string replace -- "$dir" '' $tv_query | string collect -N)
|
||||
else
|
||||
# fish older than v3.2.0 (v3.1b1 - v3.1.2)
|
||||
set -- tv_query (string replace -- "$dir" '' $tv_query | string collect -N)
|
||||
eval set -- tv_query (string escape -n -- $tv_query | string replace -r -a '^/?|\\\n$' '')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Ensure $dir ends with a slash if it's a directory
|
||||
if test -d "$dir"; and not string match -q '*/$' -- $dir
|
||||
set dir "$dir/"
|
||||
end
|
||||
|
||||
string escape -n -- "$dir" "$tv_query" "$prefix"
|
||||
|
||||
end
|
||||
|
||||
function tv_smart_autocomplete
|
||||
set -l commandline (__tv_parse_commandline)
|
||||
set -lx dir $commandline[1]
|
||||
set -l tv_query $commandline[2]
|
||||
|
||||
# prefix (lhs of cursor)
|
||||
set -l current_prompt (commandline --current-process)
|
||||
|
||||
if set -l result (tv $dir --autocomplete-prompt "$current_prompt" --input $tv_query)
|
||||
# Remove last token from commandline.
|
||||
commandline -t ''
|
||||
|
||||
# If dir is the current directory, i.e. './' , clear it.
|
||||
# If the pattern './foo' './bar' instead of 'foo' 'bar' is desired then comment out the check below
|
||||
if test "$dir" = "./"
|
||||
set dir ""
|
||||
end
|
||||
|
||||
for i in $result
|
||||
commandline -it -- $dir(string escape -- $i)' '
|
||||
# optional, if you want to replace '/home/foo/' with '~/', comment out above and uncomment below
|
||||
# commandline -it -- (string replace --all $HOME '~' $dir(string escape -- $i))' '
|
||||
end
|
||||
end
|
||||
|
||||
commandline -f repaint
|
||||
end
|
||||
|
||||
function tv_shell_history
|
||||
|
@ -1,35 +1,92 @@
|
||||
_tv_smart_autocomplete() {
|
||||
emulate -L zsh
|
||||
zle -I
|
||||
# credits to the junegunn/fzf project
|
||||
# https://github.com/junegunn/fzf/blob/d18c0bf6948b4707684fe77631aff26a17cbc4fa/shell/completion.zsh
|
||||
|
||||
# prefix (lhs of cursor)
|
||||
local current_prompt
|
||||
current_prompt=$LBUFFER
|
||||
|
||||
local output
|
||||
output=$(tv --autocomplete-prompt "$current_prompt" $* | tr '\n' ' ')
|
||||
|
||||
if [[ -n $output ]]; then
|
||||
# suffix (rhs of cursor)
|
||||
local rhs=$RBUFFER
|
||||
# add a space if the prompt does not end with one
|
||||
[[ "${current_prompt}" != *" " ]] && current_prompt="${current_prompt} "
|
||||
|
||||
LBUFFER=$current_prompt$output
|
||||
CURSOR=${#LBUFFER}
|
||||
RBUFFER=$rhs
|
||||
|
||||
zle reset-prompt
|
||||
# uncomment this to automatically accept the line
|
||||
# (i.e. run the command without having to press enter twice)
|
||||
# zle accept-line
|
||||
_disable_bracketed_paste() {
|
||||
# Check if bracketed paste is defined, for compatibility with older versions
|
||||
if [[ -n $zle_bracketed_paste ]]; then
|
||||
print -nr ${zle_bracketed_paste[2]} >/dev/tty
|
||||
fi
|
||||
}
|
||||
|
||||
_enable_bracketed_paste() {
|
||||
# Check if bracketed paste is defined, for compatibility with older versions
|
||||
if [[ -n $zle_bracketed_paste ]]; then
|
||||
print -nr ${zle_bracketed_paste[1]} >/dev/tty
|
||||
fi
|
||||
}
|
||||
|
||||
__tv_path_completion() {
|
||||
local base lbuf suffix tail dir leftover matches
|
||||
base=$1
|
||||
lbuf=$2
|
||||
suffix=""
|
||||
tail=" "
|
||||
|
||||
eval "base=$base" 2> /dev/null || return
|
||||
[[ $base = *"/"* ]] && dir="$base"
|
||||
while [ 1 ]; do
|
||||
if [[ -z "$dir" || -d ${dir} ]]; then
|
||||
leftover=${base/#"$dir"}
|
||||
leftover=${leftover/#\/}
|
||||
[ -z "$dir" ] && dir='.'
|
||||
[ "$dir" != "/" ] && dir="${dir/%\//}"
|
||||
matches=$(
|
||||
shift
|
||||
tv "$dir" --autocomplete-prompt "$lbuf" --input "$leftover" < /dev/tty | while read -r item; do
|
||||
item="${item%$suffix}$suffix"
|
||||
dirP="$dir/"
|
||||
[[ $dirP = "./" ]] && dirP=""
|
||||
echo -n -E "$dirP${(q)item} "
|
||||
done
|
||||
)
|
||||
matches=${matches% }
|
||||
if [ -n "$matches" ]; then
|
||||
LBUFFER="$lbuf$matches$tail"
|
||||
fi
|
||||
zle reset-prompt
|
||||
break
|
||||
fi
|
||||
dir=$(dirname "$dir")
|
||||
dir=${dir%/}/
|
||||
done
|
||||
}
|
||||
|
||||
_tv_smart_autocomplete() {
|
||||
_disable_bracketed_paste
|
||||
|
||||
local tokens prefix trigger lbuf
|
||||
setopt localoptions noshwordsplit noksh_arrays noposixbuiltins
|
||||
|
||||
# http://zsh.sourceforge.net/FAQ/zshfaq03.html
|
||||
# http://zsh.sourceforge.net/Doc/Release/Expansion.html#Parameter-Expansion-Flags
|
||||
tokens=(${(z)LBUFFER})
|
||||
if [ ${#tokens} -lt 1 ]; then
|
||||
zle ${fzf_default_completion:-expand-or-complete}
|
||||
return
|
||||
fi
|
||||
|
||||
[[ ${LBUFFER[-1]} == ' ' ]] && tokens+=("")
|
||||
|
||||
if [[ ${LBUFFER} = *"${tokens[-2]-}${tokens[-1]}" ]]; then
|
||||
tokens[-2]="${tokens[-2]-}${tokens[-1]}"
|
||||
tokens=(${tokens[0,-2]})
|
||||
fi
|
||||
|
||||
lbuf=$LBUFFER
|
||||
prefix=${tokens[-1]}
|
||||
[ -n "${tokens[-1]}" ] && lbuf=${lbuf:0:-${#tokens[-1]}}
|
||||
|
||||
__tv_path_completion "$prefix" "$lbuf"
|
||||
|
||||
_enable_bracketed_paste
|
||||
}
|
||||
|
||||
_tv_shell_history() {
|
||||
emulate -L zsh
|
||||
zle -I
|
||||
|
||||
_disable_bracketed_paste
|
||||
|
||||
local current_prompt
|
||||
current_prompt=$LBUFFER
|
||||
|
||||
@ -46,6 +103,8 @@ _tv_shell_history() {
|
||||
# (i.e. run the command without having to press enter twice)
|
||||
# zle accept-line
|
||||
fi
|
||||
|
||||
_enable_bracketed_paste
|
||||
}
|
||||
|
||||
|
||||
|
@ -1,3 +1,5 @@
|
||||
use lazy_regex::{Lazy, Regex, regex};
|
||||
|
||||
/// Returns the index of the next character boundary in the given string.
|
||||
///
|
||||
/// If the given index is already a character boundary, it is returned as is.
|
||||
@ -210,6 +212,16 @@ impl ReplaceNonPrintableConfig {
|
||||
self.tab_width = tab_width;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn keep_line_feed(&mut self) -> &mut Self {
|
||||
self.replace_line_feed = false;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn keep_control_characters(&mut self) -> &mut Self {
|
||||
self.replace_control_characters = false;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ReplaceNonPrintableConfig {
|
||||
@ -552,6 +564,39 @@ pub fn shrink_with_ellipsis(s: &str, max_length: usize) -> String {
|
||||
format!("{first_half}…{second_half}")
|
||||
}
|
||||
|
||||
pub static CMD_RE: &Lazy<Regex> = regex!(r"\{(\d+)\}");
|
||||
|
||||
/// Formats a prototype string with the given template and source strings.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use television::utils::strings::format_string;
|
||||
///
|
||||
/// let template = "cat {} {1}";
|
||||
/// let source = "foo:bar:baz";
|
||||
/// let delimiter = ":";
|
||||
///
|
||||
/// let formatted = format_string(template, source, delimiter);
|
||||
/// assert_eq!(formatted, "cat 'foo:bar:baz' 'bar'");
|
||||
/// ```
|
||||
pub fn format_string(template: &str, source: &str, delimiter: &str) -> String {
|
||||
let parts = source.split(delimiter).collect::<Vec<&str>>();
|
||||
|
||||
let mut formatted_string =
|
||||
template.replace("{}", format!("'{}'", source).as_str());
|
||||
|
||||
formatted_string = CMD_RE
|
||||
.replace_all(&formatted_string, |caps: ®ex::Captures| {
|
||||
let index =
|
||||
// these unwraps are safe because of the regex pattern
|
||||
caps.get(1).unwrap().as_str().parse::<usize>().unwrap();
|
||||
format!("'{}'", parts.get(index).unwrap_or(&""))
|
||||
})
|
||||
.to_string();
|
||||
|
||||
formatted_string
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
@ -1,284 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use bat::assets::HighlightingAssets;
|
||||
use gag::Gag;
|
||||
use std::path::{Path, PathBuf};
|
||||
use syntect::easy::HighlightLines;
|
||||
use syntect::highlighting::{
|
||||
HighlightIterator, HighlightState, Highlighter, Style, Theme,
|
||||
};
|
||||
use syntect::parsing::{ParseState, ScopeStack, SyntaxReference, SyntaxSet};
|
||||
use tracing::warn;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HighlightingState {
|
||||
parse_state: ParseState,
|
||||
highlight_state: HighlightState,
|
||||
}
|
||||
|
||||
impl HighlightingState {
|
||||
pub fn new(
|
||||
parse_state: ParseState,
|
||||
highlight_state: HighlightState,
|
||||
) -> Self {
|
||||
Self {
|
||||
parse_state,
|
||||
highlight_state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LineHighlighter<'a> {
|
||||
highlighter: Highlighter<'a>,
|
||||
pub parse_state: ParseState,
|
||||
pub highlight_state: HighlightState,
|
||||
}
|
||||
|
||||
impl<'a> LineHighlighter<'a> {
|
||||
pub fn new(
|
||||
syntax: &SyntaxReference,
|
||||
theme: &'a Theme,
|
||||
) -> LineHighlighter<'a> {
|
||||
let highlighter = Highlighter::new(theme);
|
||||
let highlight_state =
|
||||
HighlightState::new(&highlighter, ScopeStack::new());
|
||||
Self {
|
||||
highlighter,
|
||||
parse_state: ParseState::new(syntax),
|
||||
highlight_state,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn from_state(
|
||||
state: HighlightingState,
|
||||
theme: &'a Theme,
|
||||
) -> LineHighlighter<'a> {
|
||||
Self {
|
||||
highlighter: Highlighter::new(theme),
|
||||
parse_state: state.parse_state,
|
||||
highlight_state: state.highlight_state,
|
||||
}
|
||||
}
|
||||
|
||||
/// Highlights a line of a file
|
||||
pub fn highlight_line<'b>(
|
||||
&mut self,
|
||||
line: &'b str,
|
||||
syntax_set: &SyntaxSet,
|
||||
) -> Result<Vec<(Style, &'b str)>, syntect::Error> {
|
||||
let ops = self.parse_state.parse_line(line, syntax_set)?;
|
||||
let iter = HighlightIterator::new(
|
||||
&mut self.highlight_state,
|
||||
&ops[..],
|
||||
line,
|
||||
&self.highlighter,
|
||||
);
|
||||
Ok(iter.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[deprecated(
|
||||
note = "Use `compute_highlights_incremental` instead, which also returns the state"
|
||||
)]
|
||||
pub fn compute_highlights_for_path(
|
||||
file_path: &Path,
|
||||
lines: &[String],
|
||||
syntax_set: &SyntaxSet,
|
||||
syntax_theme: &Theme,
|
||||
) -> Result<Vec<Vec<(Style, String)>>> {
|
||||
let syntax = set_syntax_set(syntax_set, file_path);
|
||||
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)
|
||||
}
|
||||
|
||||
fn set_syntax_set<'a>(
|
||||
syntax_set: &'a SyntaxSet,
|
||||
file_path: &Path,
|
||||
) -> &'a SyntaxReference {
|
||||
syntax_set
|
||||
.find_syntax_for_file(file_path)
|
||||
.unwrap_or(None)
|
||||
.unwrap_or_else(|| {
|
||||
warn!(
|
||||
"No syntax found for {:?}, defaulting to plain text",
|
||||
file_path
|
||||
);
|
||||
syntax_set.find_syntax_plain_text()
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Hash)]
|
||||
pub struct HighlightedLines {
|
||||
pub lines: Vec<Vec<(Style, String)>>,
|
||||
//pub state: Option<HighlightingState>,
|
||||
}
|
||||
|
||||
impl HighlightedLines {
|
||||
pub fn new(
|
||||
lines: Vec<Vec<(Style, String)>>,
|
||||
_state: &Option<HighlightingState>,
|
||||
) -> Self {
|
||||
Self { lines, /*state*/ }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_highlights_incremental(
|
||||
file_path: &Path,
|
||||
lines: &[String],
|
||||
syntax_set: &SyntaxSet,
|
||||
syntax_theme: &Theme,
|
||||
_cached_lines: Option<&HighlightedLines>,
|
||||
) -> Result<HighlightedLines> {
|
||||
let mut highlighted_lines: Vec<_>;
|
||||
let mut highlighter: LineHighlighter;
|
||||
|
||||
//if let Some(HighlightedLines {
|
||||
// lines: c_lines,
|
||||
// state: Some(s),
|
||||
//}) = cached_lines
|
||||
//{
|
||||
// highlighter = LineHighlighter::from_state(s, syntax_theme);
|
||||
// highlighted_lines = c_lines;
|
||||
//} else {
|
||||
// let syntax = set_syntax_set(syntax_set, file_path);
|
||||
// highlighter = LineHighlighter::new(syntax, syntax_theme);
|
||||
// highlighted_lines = Vec::new();
|
||||
//};
|
||||
let syntax = set_syntax_set(syntax_set, file_path);
|
||||
highlighter = LineHighlighter::new(syntax, syntax_theme);
|
||||
highlighted_lines = Vec::with_capacity(lines.len());
|
||||
|
||||
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(HighlightedLines::new(
|
||||
highlighted_lines,
|
||||
&Some(HighlightingState::new(
|
||||
highlighter.parse_state.clone(),
|
||||
highlighter.highlight_state.clone(),
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn compute_highlights_for_line<'a>(
|
||||
line: &'a str,
|
||||
syntax_set: &SyntaxSet,
|
||||
syntax_theme: &Theme,
|
||||
file_path: &str,
|
||||
) -> Result<Vec<(Style, &'a str)>> {
|
||||
let syntax = syntax_set.find_syntax_for_file(file_path)?;
|
||||
match syntax {
|
||||
None => {
|
||||
warn!(
|
||||
"No syntax found for path {:?}, defaulting to plain text",
|
||||
file_path
|
||||
);
|
||||
Ok(vec![(Style::default(), line)])
|
||||
}
|
||||
Some(syntax) => {
|
||||
let mut highlighter = HighlightLines::new(syntax, syntax_theme);
|
||||
Ok(highlighter.highlight_line(line, syntax_set)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Based on code from https://github.com/sharkdp/bat e981e974076a926a38f124b7d8746de2ca5f0a28
|
||||
//
|
||||
// Copyright (c) 2018-2023 bat-developers (https://github.com/sharkdp/bat).
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
use directories::BaseDirs;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::env;
|
||||
|
||||
/// Wrapper for 'dirs' that treats `MacOS` more like `Linux`, by following the XDG specification.
|
||||
///
|
||||
/// This means that the `XDG_CACHE_HOME` and `XDG_CONFIG_HOME` environment variables are
|
||||
/// checked first. The fallback directories are `~/.cache/bat` and `~/.config/bat`, respectively.
|
||||
pub struct BatProjectDirs {
|
||||
cache_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl BatProjectDirs {
|
||||
fn new() -> Option<BatProjectDirs> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let cache_dir_op = env::var_os("XDG_CACHE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_absolute())
|
||||
.or_else(|| BaseDirs::new().map(|d| d.home_dir().join(".cache")));
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let cache_dir_op = BaseDirs::new().map(|d| d.cache_dir().to_owned());
|
||||
|
||||
let cache_dir = cache_dir_op.map(|d| d.join("bat"))?;
|
||||
|
||||
Some(BatProjectDirs { cache_dir })
|
||||
}
|
||||
|
||||
pub fn cache_dir(&self) -> &Path {
|
||||
&self.cache_dir
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_highlighting_assets() -> HighlightingAssets {
|
||||
let project_dirs = BatProjectDirs::new()
|
||||
.unwrap_or_else(|| panic!("Could not get home directory"));
|
||||
|
||||
HighlightingAssets::from_cache(project_dirs.cache_dir())
|
||||
.unwrap_or_else(|_| HighlightingAssets::from_binary())
|
||||
}
|
||||
|
||||
pub trait HighlightingAssetsExt {
|
||||
fn get_theme_no_output(&self, theme_name: &str) -> &Theme;
|
||||
}
|
||||
|
||||
impl HighlightingAssetsExt for HighlightingAssets {
|
||||
/// Get a theme by name. If the theme is not found, the default theme is returned.
|
||||
///
|
||||
/// This is an ugly hack to work around the fact that bat actually prints a warning
|
||||
/// to stderr when a theme is not found which might mess up the TUI. This function
|
||||
/// suppresses that warning by temporarily redirecting stderr and stdout.
|
||||
fn get_theme_no_output(&self, theme_name: &str) -> &Theme {
|
||||
let _e = Gag::stderr();
|
||||
let _o = Gag::stdout();
|
||||
let theme = self.get_theme(theme_name);
|
||||
theme
|
||||
}
|
||||
}
|
32
tests/app.rs
32
tests/app.rs
@ -3,7 +3,7 @@ use std::{collections::HashSet, path::PathBuf, time::Duration};
|
||||
use television::{
|
||||
action::Action,
|
||||
app::{App, AppOptions},
|
||||
channels::TelevisionChannel,
|
||||
channels::prototypes::{Cable, ChannelPrototype},
|
||||
config::default_config_from_file,
|
||||
};
|
||||
use tokio::{task::JoinHandle, time::timeout};
|
||||
@ -21,21 +21,19 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
/// The app is started in a separate task and can be interacted with by sending
|
||||
/// actions to the action channel.
|
||||
fn setup_app(
|
||||
channel: Option<TelevisionChannel>,
|
||||
channel_prototype: Option<ChannelPrototype>,
|
||||
select_1: bool,
|
||||
exact: bool,
|
||||
) -> (
|
||||
JoinHandle<television::app::AppOutput>,
|
||||
tokio::sync::mpsc::UnboundedSender<Action>,
|
||||
) {
|
||||
let chan: TelevisionChannel = channel.unwrap_or_else(|| {
|
||||
let chan: ChannelPrototype = channel_prototype.unwrap_or_else(|| {
|
||||
let target_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("target_dir");
|
||||
std::env::set_current_dir(&target_dir).unwrap();
|
||||
TelevisionChannel::Files(television::channels::files::Channel::new(
|
||||
vec![target_dir],
|
||||
))
|
||||
Cable::default().get("files").unwrap().clone()
|
||||
});
|
||||
let mut config = default_config_from_file().unwrap();
|
||||
// this speeds up the tests
|
||||
@ -49,7 +47,7 @@ fn setup_app(
|
||||
false,
|
||||
config.application.tick_rate,
|
||||
);
|
||||
let mut app = App::new(chan, config, input, options);
|
||||
let mut app = App::new(&chan, config, input, options, &Cable::default());
|
||||
|
||||
// retrieve the app's action channel handle in order to send a quit action
|
||||
let tx = app.action_tx.clone();
|
||||
@ -214,11 +212,9 @@ async fn test_app_exact_search_positive() {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
|
||||
async fn test_app_exits_when_select_1_and_only_one_result() {
|
||||
let channel =
|
||||
TelevisionChannel::Stdin(television::channels::stdin::Channel::from(
|
||||
vec!["file1.txt".to_string()],
|
||||
));
|
||||
let (f, tx) = setup_app(Some(channel), true, false);
|
||||
let prototype =
|
||||
ChannelPrototype::new("some_channel", "echo file1.txt", false, None);
|
||||
let (f, tx) = setup_app(Some(prototype), true, false);
|
||||
|
||||
// tick a few times to get the results
|
||||
for _ in 0..=10 {
|
||||
@ -248,11 +244,13 @@ async fn test_app_exits_when_select_1_and_only_one_result() {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
|
||||
async fn test_app_does_not_exit_when_select_1_and_more_than_one_result() {
|
||||
let channel =
|
||||
TelevisionChannel::Stdin(television::channels::stdin::Channel::from(
|
||||
vec!["file1.txt".to_string(), "file2.txt".to_string()],
|
||||
));
|
||||
let (f, tx) = setup_app(Some(channel), true, false);
|
||||
let prototype = ChannelPrototype::new(
|
||||
"some_channel",
|
||||
"echo 'file1.txt\nfile2.txt'",
|
||||
false,
|
||||
None,
|
||||
);
|
||||
let (f, tx) = setup_app(Some(prototype), true, false);
|
||||
|
||||
// tick a few times to get the results
|
||||
for _ in 0..=10 {
|
||||
|
Loading…
x
Reference in New Issue
Block a user