Compare commits

...

36 Commits
0.11.9 ... main

Author SHA1 Message Date
Alex Pasmantier
738fe08fbb
chore(rust): update rust edition to 2024 and version to 1.87 (#528) 2025-06-05 15:01:27 +02:00
alexandre pasmantier
aac7e4dc45 docs: update terminal emulators compatibility list 2025-05-26 23:35:50 +02:00
LM
0f4d87915b
feat(shell): improve zsh completion system (#525)
this pr adds the possibility of starting television from a different
directory and it gives the option of complete a partial match. For
example

```fish
cd ~/.config/<ctrl-t>    # opens television at '~/.config/' with an empty search bar
cd ~/.config/fi<ctrl-t>    # opens television at '~/.config/' with 'fi' in the search bar
```

solves (for zsh shell)
https://github.com/alexpasmantier/television/issues/438
2025-05-26 21:25:06 +02:00
Alex Pasmantier
dfbdd65107
fix(config): use the config default_channel field as a fallback when no channel is specified (#524)
Fixes #520 

fyi @lalvarezt
2025-05-26 21:16:06 +02:00
Alex Pasmantier
7bbf538898
docs(utils): add documentation for string formatting logic (#517) 2025-05-20 23:24:16 +02:00
Alex Pasmantier
6b3c4ee773
fix(cable): don't panic when unable to format user template with entry (#516) 2025-05-20 22:59:41 +02:00
Alexandre Pasmantier
9127e419fb docs: add index.md 2025-05-17 13:37:15 +02:00
Alex Pasmantier
11c2ef4eef chore: create github action workflow for pages 2025-05-17 12:13:02 +02:00
Alex Pasmantier
3b3a0ec1ff
feat(windows): add text channel with preview offset for windows (#514)
![image](https://github.com/user-attachments/assets/9f8935db-82fe-4235-8584-ab8b0508b20f)
2025-05-16 12:23:09 +02:00
github-actions[bot]
d106adafc0
chore(changelog): update changelog (auto) (#513)
This PR was created by a GitHub Action to update the changelog.

Co-authored-by: alexpasmantier <47638216+alexpasmantier@users.noreply.github.com>
2025-05-16 01:52:19 +02:00
Alex Pasmantier
ca09c503ca
refactor!(cable): update cable channel preview configuration format + add custom preview offsets (#511)
BREAKING CHANGE: the format of the cable channel files and more
specifically the preview specification is updated to be a single table
named `preview` (with keys `command`, `delimiter`, and `offset`) instead
of three flat fields.

```toml
preview_command = "echo 3"
preview_delimiter = " "
preview_offset = "{1}"
```

becomes: 

```toml
preview.command = "echo 3"
preview.delimiter = " "
preview.offset = "{1}"
```
2025-05-16 01:07:12 +02:00
kapobajza
39dd9efd5d
fix(shell): Paste not working in zsh shell integration (#512)
Fixes https://github.com/alexpasmantier/television/issues/393
2025-05-15 15:08:20 +02:00
Alexandre Pasmantier
0f6b29ba81
chore: add sponsorhips button to the repo 2025-05-14 23:01:25 +02:00
Alexandre Pasmantier
1741a15e52
fix(preview): add a post-processing step to clean out ansi text from non-displayable characters (#509) 2025-05-14 22:57:43 +02:00
Alexandre Pasmantier
cfe49ce81c
feat(remote): redirect Action::Quit to Action::ToggleRemoteControl when in remote mode (#508)
Fixes #505
2025-05-14 21:31:10 +02:00
Alexandre Pasmantier
fc2f8b9473
perf(previews): avoid unnecessary preview content copy (#507) 2025-05-14 21:25:25 +02:00
Alexandre Pasmantier
67c067ff40
refactor(previewer): a much more efficient preview system for tv (#506)
Broke away the previewer logic into its own tokio task communicating
with the main thread over two mpsc channels.

Most of the previewer code is now much simpler and less verbose. 

This brings quite a nice bump to performance and overall UI
responsiveness and also makes the previewer consume less cpu resources.
2025-05-14 20:22:53 +02:00
Alexandre Pasmantier
1a5fa5dd4c
refactor(channels): some renaming and refactoring the channels module (#503) 2025-05-11 17:00:31 +02:00
nkxxll
cd33151bac
fix(layout): double check whether preview is enabled (#499) 2025-05-08 22:03:47 +02:00
Alexandre Pasmantier
2b2654b6aa
refactor: drop TelevisionChannel enum and all associated macros (#498)
This drops the `TelevisionChannel` enum which served as a unified
interface for builtin and cable channels as well as all related macros
and the `television-derive` package.
This simplifies the code quite a lot and will improve overall
maintainability.
2025-05-06 11:18:32 +02:00
Alexandre Pasmantier
58d73dbeba
refactor!(previews): drop builtin previewers and all related code and dependencies (#495)
BREAKING CHANGE: No more builtin previews which means channels currently using `:files:` and other builtins will now need to rely on external tools (examples to come).
2025-05-06 00:07:50 +02:00
Swann Castel
1086899ba7
feat(ui): add a UI portrait mode #489 (#496)
#489, This PR adds support for television orientation,
input_bar_position is preserved and activating/deactivating the preview
works for both :

1. Landscape (default orientation)

a. input_bar_position = "top"


![image](https://github.com/user-attachments/assets/08f334c0-0780-46ee-aa80-0ccdd1a473eb)

![image](https://github.com/user-attachments/assets/9df6faf5-2211-4fa0-8f08-28688c48e8ee)

b. input_bar_position = "bottom"


![image](https://github.com/user-attachments/assets/223c9814-d1c6-4ed1-b407-b5ba67ebaa06)

![image](https://github.com/user-attachments/assets/9be95c92-f10b-4dec-b423-c7de1f8534d1)

2. Portrait (new orientation)

a. input_bar_position = "top"


![image](https://github.com/user-attachments/assets/71d07092-a175-4978-9ee6-c8978eb83cde)

![image](https://github.com/user-attachments/assets/64d891f4-7acc-456b-a01d-14cab54bff15)

b. input_bar_position = "bottom"


![image](https://github.com/user-attachments/assets/b2c527eb-2c47-404e-8f6d-685b54be71af)

![image](https://github.com/user-attachments/assets/39c9bcaa-cbd5-4d4b-81d8-1e7cfb5bfa10)
2025-05-05 22:37:15 +02:00
LM
be8008e97d
feat(shell): improve fish completion system (#494)
this pr adds the possibility of starting television from a different
directory and it gives the option of complete a partial match. For
example

```fish
cd ~/.config/<ctrl-t>    # opens television at '~/.config/' with an empty search bar
cd ~/.config/fi<ctrl-t>    # opens television at '~/.config/' with 'fi' in the search bar
```

solves (for fish shell)
https://github.com/alexpasmantier/television/issues/438
2025-05-02 15:45:01 +02:00
Alexandre Pasmantier
cc27b5ec6b refactor: drop dependency to the ignore crate (#493) 2025-05-01 23:05:56 +02:00
Alexandre Pasmantier
f887a2390e
feat(cli): add a --ui-scale [0,100] cli parameter (#492) 2025-05-01 20:46:05 +02:00
github-actions[bot]
64c599ef10
chore(changelog): update changelog (auto) (#491)
This PR was created by a GitHub Action to update the changelog.

Co-authored-by: alexpasmantier <47638216+alexpasmantier@users.noreply.github.com>
2025-05-01 17:43:30 +02:00
Alexandre Pasmantier
b9f42e8c29
refactor(preview): simplify channel previews code and remove intermediate PreviewKind struct (#490) 2025-05-01 17:42:01 +02:00
Swann Castel
dbff3a330b
fix(alias): move terminal raw mode before loading bat assets #444 (#484) 2025-04-29 21:42:12 +02:00
Alexandre Pasmantier
e2f52b835d
refactor(cable): improve naming and documentation for prototypes.rs (#487) 2025-04-29 18:31:57 +02:00
Alexandre Pasmantier
4385317e06
refactor(cable): split cable related code into separate submodules (#486)
This refactors `television/channels/cable.rs` into two additional
submodules:
- `television/channels/cable/preview.rs`
- `television/channels/cable/prototypes.rs`
2025-04-29 18:07:42 +02:00
Alexandre Pasmantier
0514a914b6
fix(alias): rename the aliases channel to alias (#485) 2025-04-29 17:41:57 +02:00
Alexandre Pasmantier
d3bb3b0a56
docs: cleanup old todo list (#483) 2025-04-28 00:10:58 +02:00
Alexandre Pasmantier
c2f4cc258f
refactor: tv no longer needs to write the default cable channel recipes to the user's configuration directory (#482)
The cable channel prototypes are now compiled into the binary itself,
and users can override them with a custom cable file.
2025-04-28 00:04:38 +02:00
Alexandre Pasmantier
67677fb917
refactor!: all channels are now cable channels (#479)
- tv's default channel (when lauching `tv`) is now configurable via the
`default_channel` configuration option
- add `RUST_BACKTRACE=1` and `--nocapture` to ci testing for better
debugging
- remove all builtin channels and associated glue code as well as the
`ToCliChannel` and `ToUnitChannel` derive macros
- recode all builtin channels using shell commands (along with `fd`,
`bat`, and `rg`)
- add support for interactive shell commands inside cable channels
- drop the `send_to_channel` feature until further notice (will be
reimplemented later on in a more generic and customizable way)
2025-04-27 23:50:14 +02:00
nkxxll
1f0c178a2d
fix(results): remove keymap hint if help is disabled (#480)
fixes #471
2025-04-26 00:09:49 +00:00
github-actions[bot]
a602dda347
chore(changelog): update changelog (auto) (#478)
This PR was created by a GitHub Action to update the changelog.

Co-authored-by: alexpasmantier <47638216+alexpasmantier@users.noreply.github.com>
2025-04-22 01:26:31 +02:00
85 changed files with 2109 additions and 7306 deletions

View File

@ -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
View File

@ -0,0 +1,3 @@
# These are supported funding model platforms
github: [alexpasmantier]

View File

@ -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
View 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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
View File

@ -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)

View File

@ -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)
},

View File

@ -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]]

View File

@ -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
View File

@ -0,0 +1,2 @@
# Television
A cross-platform, fast and extensible general purpose fuzzy finder 📺

View File

@ -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 | ✅ |

View File

@ -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>

View File

@ -1,3 +1,3 @@
[toolchain]
channel = "1.83"
channel = "1.87"
components = ["rustfmt", "clippy", "rust-analyzer"]

View File

@ -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

View File

@ -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()
}

View File

@ -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
)]))
);
}
}

View File

@ -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));
}
}

View File

@ -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();
});
});
}

View File

@ -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
}
}

View File

@ -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(&current_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
})
});
}

View File

@ -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");
}

View File

@ -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
}
}

View File

@ -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(&current_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
})
});
}

View File

@ -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
})
});
}

View File

@ -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],
}

View 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'");
}
}

View 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)
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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, &current_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, &current_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
}
}
}

View File

@ -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>,
}

View File

@ -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);
}
}

View File

@ -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))

View File

@ -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"))]

View File

@ -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 {}

View File

@ -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)
);
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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),
);
}

View File

@ -123,6 +123,7 @@ impl Picker {
}
}
#[allow(clippy::doc_overindented_list_items)]
#[cfg(test)]
mod tests {
use super::*;

View File

@ -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)
}
}

View File

@ -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.

View File

@ -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]),
}
}
}

View File

@ -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))
}
}

View File

@ -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()
})
);
}

View File

@ -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)
}
}

View File

@ -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));
}
}

View File

@ -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,
})
}
}

View File

@ -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, &regex::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: &regex::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'");
}
}

View File

@ -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()
}

View File

@ -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,
))
}

View File

@ -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,
))
}

View File

@ -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
View 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.");
}

View 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,
)
}
}

View File

@ -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()?;

View File

@ -51,5 +51,4 @@ pub struct InputColorscheme {
pub struct ModeColorscheme {
pub channel: Color,
pub remote_control: Color,
pub send_to_channel: Color,
}

View File

@ -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,

View File

@ -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(),

View File

@ -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],

View File

@ -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(

View File

@ -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)
}

View File

@ -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"),
}
}
}

View File

@ -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,
}
}

View File

@ -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)
}

View File

@ -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(

View File

@ -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,

View File

@ -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) {

View File

@ -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};

View File

@ -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 {

View File

@ -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
}

View File

@ -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())
}

View File

@ -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])
}
}

View File

@ -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;

View File

@ -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 {

View File

@ -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

View File

@ -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
}

View File

@ -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: &regex::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::*;

View File

@ -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
}
}

View File

@ -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 {