refactor(screen): extract UI related code to separate crate (#106)

Co-authored-by: Bertrand Chardon <bertrand.chardon@doctrine.fr>
This commit is contained in:
Alexandre Pasmantier 2024-12-08 13:46:30 +01:00 committed by GitHub
parent 6e35e1a50c
commit 54399e3777
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1797 additions and 1610 deletions

114
Cargo.lock generated
View File

@ -344,7 +344,7 @@ dependencies = [
"semver", "semver",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -364,9 +364,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.2" version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f34d93e62b03caf570cccc334cbc6c2fceca82f39211051345108adcba3eebdc" checksum = "27f657647bcff5394bf56c7317665bbf790a137a50eaaa5c6bfbb9e27a518f2d"
dependencies = [ dependencies = [
"shlex", "shlex",
] ]
@ -379,9 +379,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.22" version = "4.5.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69371e34337c4c984bbe322360c2547210bf632eb2814bbe78a6e87a2935bd2b" checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -389,9 +389,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.22" version = "4.5.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e24c1b4099818523236a8ca881d2b45db98dadfb4625cf6608c12069fcbbde1" checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@ -413,9 +413,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_lex" name = "clap_lex"
version = "0.7.3" version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afb84c814227b90d6895e01398aee0d8033c00e7466aca416fb6a8e0eb19d8a7" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]] [[package]]
name = "clipboard-win" name = "clipboard-win"
@ -1041,7 +1041,7 @@ dependencies = [
"parking_lot", "parking_lot",
"signal-hook", "signal-hook",
"smallvec", "smallvec",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1054,7 +1054,7 @@ dependencies = [
"gix-date", "gix-date",
"gix-utils", "gix-utils",
"itoa", "itoa",
"thiserror 2.0.4", "thiserror 2.0.5",
"winnow", "winnow",
] ]
@ -1064,7 +1064,7 @@ version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d48b897b4bbc881aea994b4a5bbb340a04979d7be9089791304e04a9fbc66b53" checksum = "d48b897b4bbc881aea994b4a5bbb340a04979d7be9089791304e04a9fbc66b53"
dependencies = [ dependencies = [
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1073,7 +1073,7 @@ version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6ffbeb3a5c0b8b84c3fe4133a6f8c82fa962f4caefe8d0762eced025d3eb4f7" checksum = "c6ffbeb3a5c0b8b84c3fe4133a6f8c82fa962f4caefe8d0762eced025d3eb4f7"
dependencies = [ dependencies = [
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1087,7 +1087,7 @@ dependencies = [
"gix-features", "gix-features",
"gix-hash", "gix-hash",
"memmap2", "memmap2",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1106,7 +1106,7 @@ dependencies = [
"memchr", "memchr",
"once_cell", "once_cell",
"smallvec", "smallvec",
"thiserror 2.0.4", "thiserror 2.0.5",
"unicode-bom", "unicode-bom",
"winnow", "winnow",
] ]
@ -1121,7 +1121,7 @@ dependencies = [
"bstr", "bstr",
"gix-path", "gix-path",
"libc", "libc",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1133,7 +1133,7 @@ dependencies = [
"bstr", "bstr",
"itoa", "itoa",
"jiff", "jiff",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1145,7 +1145,7 @@ dependencies = [
"bstr", "bstr",
"gix-hash", "gix-hash",
"gix-object", "gix-object",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1161,7 +1161,7 @@ dependencies = [
"gix-path", "gix-path",
"gix-ref", "gix-ref",
"gix-sec", "gix-sec",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1179,7 +1179,7 @@ dependencies = [
"once_cell", "once_cell",
"prodash", "prodash",
"sha1_smol", "sha1_smol",
"thiserror 2.0.4", "thiserror 2.0.5",
"walkdir", "walkdir",
] ]
@ -1213,7 +1213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5eccc17194ed0e67d49285e4853307e4147e95407f91c1c3e4a13ba9f4e4ce" checksum = "0b5eccc17194ed0e67d49285e4853307e4147e95407f91c1c3e4a13ba9f4e4ce"
dependencies = [ dependencies = [
"faster-hex", "faster-hex",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1252,7 +1252,7 @@ dependencies = [
"memmap2", "memmap2",
"rustix", "rustix",
"smallvec", "smallvec",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1263,7 +1263,7 @@ checksum = "1cd3ab68a452db63d9f3ebdacb10f30dba1fa0d31ac64f4203d395ed1102d940"
dependencies = [ dependencies = [
"gix-tempfile", "gix-tempfile",
"gix-utils", "gix-utils",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1282,7 +1282,7 @@ dependencies = [
"gix-validate", "gix-validate",
"itoa", "itoa",
"smallvec", "smallvec",
"thiserror 2.0.4", "thiserror 2.0.5",
"winnow", "winnow",
] ]
@ -1304,7 +1304,7 @@ dependencies = [
"gix-quote", "gix-quote",
"parking_lot", "parking_lot",
"tempfile", "tempfile",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1322,7 +1322,7 @@ dependencies = [
"gix-path", "gix-path",
"memmap2", "memmap2",
"smallvec", "smallvec",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1335,7 +1335,7 @@ dependencies = [
"gix-trace", "gix-trace",
"home", "home",
"once_cell", "once_cell",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1346,7 +1346,7 @@ checksum = "64a1e282216ec2ab2816cd57e6ed88f8009e634aec47562883c05ac8a7009a63"
dependencies = [ dependencies = [
"bstr", "bstr",
"gix-utils", "gix-utils",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1366,7 +1366,7 @@ dependencies = [
"gix-utils", "gix-utils",
"gix-validate", "gix-validate",
"memmap2", "memmap2",
"thiserror 2.0.4", "thiserror 2.0.5",
"winnow", "winnow",
] ]
@ -1381,7 +1381,7 @@ dependencies = [
"gix-revision", "gix-revision",
"gix-validate", "gix-validate",
"smallvec", "smallvec",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1399,7 +1399,7 @@ dependencies = [
"gix-object", "gix-object",
"gix-revwalk", "gix-revwalk",
"gix-trace", "gix-trace",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1414,7 +1414,7 @@ dependencies = [
"gix-hashtable", "gix-hashtable",
"gix-object", "gix-object",
"smallvec", "smallvec",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1464,7 +1464,7 @@ dependencies = [
"gix-object", "gix-object",
"gix-revwalk", "gix-revwalk",
"smallvec", "smallvec",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -1476,7 +1476,7 @@ dependencies = [
"bstr", "bstr",
"gix-features", "gix-features",
"gix-path", "gix-path",
"thiserror 2.0.4", "thiserror 2.0.5",
"url", "url",
] ]
@ -1497,7 +1497,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd520d09f9f585b34b32aba1d0b36ada89ab7fefb54a8ca3fe37fc482a750937" checksum = "cd520d09f9f585b34b32aba1d0b36ada89ab7fefb54a8ca3fe37fc482a750937"
dependencies = [ dependencies = [
"bstr", "bstr",
"thiserror 2.0.4", "thiserror 2.0.5",
] ]
[[package]] [[package]]
@ -2207,20 +2207,20 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]] [[package]]
name = "pest" name = "pest"
version = "2.7.14" version = "2.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879952a81a83930934cbf1786752d6dedc3b1f29e8f8fb2ad1d0a36f377cf442" checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc"
dependencies = [ dependencies = [
"memchr", "memchr",
"thiserror 1.0.69", "thiserror 2.0.5",
"ucd-trie", "ucd-trie",
] ]
[[package]] [[package]]
name = "pest_derive" name = "pest_derive"
version = "2.7.14" version = "2.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d214365f632b123a47fd913301e14c946c61d1c183ee245fa76eb752e59a02dd" checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e"
dependencies = [ dependencies = [
"pest", "pest",
"pest_generator", "pest_generator",
@ -2228,9 +2228,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_generator" name = "pest_generator"
version = "2.7.14" version = "2.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb55586734301717aea2ac313f50b2eb8f60d2fc3dc01d190eefa2e625f60c4e" checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b"
dependencies = [ dependencies = [
"pest", "pest",
"pest_meta", "pest_meta",
@ -2241,9 +2241,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_meta" name = "pest_meta"
version = "2.7.14" version = "2.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75da2a70cf4d9cb76833c990ac9cd3923c9a8905a8929789ce347c84564d03d" checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"pest", "pest",
@ -2854,6 +2854,7 @@ dependencies = [
"television-derive", "television-derive",
"television-fuzzy", "television-fuzzy",
"television-previewers", "television-previewers",
"television-screen",
"television-utils", "television-utils",
"tokio", "tokio",
"toml", "toml",
@ -2917,6 +2918,20 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "television-screen"
version = "0.0.10"
dependencies = [
"ansi-to-tui",
"color-eyre",
"ratatui",
"serde",
"syntect",
"television-channels",
"television-previewers",
"television-utils",
]
[[package]] [[package]]
name = "television-utils" name = "television-utils"
version = "0.0.10" version = "0.0.10"
@ -2929,6 +2944,7 @@ dependencies = [
"lazy_static", "lazy_static",
"syntect", "syntect",
"tracing", "tracing",
"unicode-width 0.2.0",
"winapi-util", "winapi-util",
] ]
@ -2956,11 +2972,11 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.4" version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f49a1853cf82743e3b7950f77e0f4d622ca36cf4317cba00c767838bac8d490" checksum = "643caef17e3128658ff44d85923ef2d28af81bb71e0d67bbfe1d76f19a73e053"
dependencies = [ dependencies = [
"thiserror-impl 2.0.4", "thiserror-impl 2.0.5",
] ]
[[package]] [[package]]
@ -2976,9 +2992,9 @@ dependencies = [
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.4" version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8381894bb3efe0c4acac3ded651301ceee58a15d47c2e34885ed1908ad667061" checksum = "995d0bbc9995d1f19d28b7215a9352b0fc3cd3a2d2ec95c2cadc485cdedbcdde"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View File

@ -10,17 +10,17 @@ repository = "https://github.com/alexpasmantier/television"
homepage = "https://github.com/alexpasmantier/television" homepage = "https://github.com/alexpasmantier/television"
keywords = ["search", "fuzzy", "preview", "tui", "terminal"] keywords = ["search", "fuzzy", "preview", "tui", "terminal"]
categories = [ categories = [
"command-line-utilities", "command-line-utilities",
"command-line-interface", "command-line-interface",
"concurrency", "concurrency",
"development-tools", "development-tools",
] ]
include = [ include = [
"LICENSE", "LICENSE",
"README.md", "README.md",
"crates/television/**/*.rs", "crates/television/**/*.rs",
"build.rs", "build.rs",
".config/config.toml", ".config/config.toml",
] ]
rust-version = "1.80.0" rust-version = "1.80.0"
@ -37,10 +37,10 @@ repository = "https://github.com/alexpasmantier/television"
homepage = "https://github.com/alexpasmantier/television" homepage = "https://github.com/alexpasmantier/television"
keywords = ["search", "fuzzy", "preview", "tui", "terminal"] keywords = ["search", "fuzzy", "preview", "tui", "terminal"]
categories = [ categories = [
"command-line-utilities", "command-line-utilities",
"command-line-interface", "command-line-interface",
"concurrency", "concurrency",
"development-tools", "development-tools",
] ]
include = ["LICENSE", "README.md", "crates/television/**/*.rs", "build.rs"] include = ["LICENSE", "README.md", "crates/television/**/*.rs", "build.rs"]
rust-version = "1.80.0" rust-version = "1.80.0"
@ -56,6 +56,7 @@ name = "tv"
# workspace dependencies # workspace dependencies
television-fuzzy = { path = "crates/television-fuzzy", version = "0.0.10" } television-fuzzy = { path = "crates/television-fuzzy", version = "0.0.10" }
television-derive = { path = "crates/television-derive", version = "0.0.10" } television-derive = { path = "crates/television-derive", version = "0.0.10" }
television-screen = { path = "crates/television-screen", version = "0.0.10" }
television-channels = { path = "crates/television-channels", version = "0.0.10" } television-channels = { path = "crates/television-channels", version = "0.0.10" }
television-previewers = { path = "crates/television-previewers", version = "0.0.10" } television-previewers = { path = "crates/television-previewers", version = "0.0.10" }
television-utils = { path = "crates/television-utils", version = "0.0.10" } television-utils = { path = "crates/television-utils", version = "0.0.10" }

View File

@ -0,0 +1,24 @@
[package]
name = "television-screen"
version = "0.0.10"
edition.workspace = true
description.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
homepage.workspace = true
keywords.workspace = true
categories.workspace = true
include.workspace = true
rust-version.workspace = true
readme.workspace = true
[dependencies]
ratatui = "0.29.0"
serde = "1.0.215"
television-utils = { path = "../television-utils", version="0.0.10" }
television-channels = { path = "../television-channels", version="0.0.10" }
television-previewers = { path = "../television-previewers", version="0.0.10" }
color-eyre = "0.6.3"
ansi-to-tui = "7.0.0"
syntect = "5.2.0"

View File

@ -0,0 +1,63 @@
use ratatui::style::Color;
pub const BORDER_COLOR: Color = Color::Blue;
pub const ACTION_COLOR: Color = Color::DarkGray;
// Styles
// input
pub const DEFAULT_INPUT_FG: Color = Color::LightRed;
pub const DEFAULT_RESULTS_COUNT_FG: Color = Color::LightRed;
// preview
pub const DEFAULT_PREVIEW_TITLE_FG: Color = Color::Blue;
pub const DEFAULT_SELECTED_PREVIEW_BG: Color = Color::Rgb(50, 50, 50);
pub const DEFAULT_PREVIEW_CONTENT_FG: Color = Color::Rgb(150, 150, 180);
pub const DEFAULT_PREVIEW_GUTTER_FG: Color = Color::Rgb(70, 70, 70);
pub const DEFAULT_PREVIEW_GUTTER_SELECTED_FG: Color =
Color::Rgb(255, 150, 150);
// Styles
pub const DEFAULT_RESULT_NAME_FG: Color = Color::Blue;
pub const DEFAULT_RESULT_PREVIEW_FG: Color = Color::Rgb(150, 150, 150);
pub const DEFAULT_RESULT_LINE_NUMBER_FG: Color = Color::Yellow;
pub const DEFAULT_RESULT_SELECTED_BG: Color = Color::Rgb(50, 50, 50);
pub const DEFAULT_RESULTS_LIST_MATCH_FOREGROUND_COLOR: Color = Color::Red;
pub struct ResultsListColors {
pub result_name_fg: Color,
pub result_preview_fg: Color,
pub result_line_number_fg: Color,
pub result_selected_bg: Color,
}
impl Default for ResultsListColors {
fn default() -> Self {
Self {
result_name_fg: DEFAULT_RESULT_NAME_FG,
result_preview_fg: DEFAULT_RESULT_PREVIEW_FG,
result_line_number_fg: DEFAULT_RESULT_LINE_NUMBER_FG,
result_selected_bg: DEFAULT_RESULT_SELECTED_BG,
}
}
}
#[allow(dead_code)]
impl ResultsListColors {
pub fn result_name_fg(mut self, color: Color) -> Self {
self.result_name_fg = color;
self
}
pub fn result_preview_fg(mut self, color: Color) -> Self {
self.result_preview_fg = color;
self
}
pub fn result_line_number_fg(mut self, color: Color) -> Self {
self.result_line_number_fg = color;
self
}
pub fn result_selected_bg(mut self, color: Color) -> Self {
self.result_selected_bg = color;
self
}
}

View File

@ -0,0 +1,79 @@
use super::layout::HelpBarLayout;
use crate::colors::BORDER_COLOR;
use crate::logo::build_logo_paragraph;
use crate::metadata::build_metadata_table;
use crate::mode::{mode_color, Mode};
use ratatui::layout::Rect;
use ratatui::prelude::{Color, Style};
use ratatui::widgets::{Block, BorderType, Borders, Padding, Table};
use ratatui::Frame;
use television_channels::channels::UnitChannel;
use television_utils::metadata::AppMetadata;
pub fn draw_logo_block(f: &mut Frame, area: Rect, color: Color) {
let logo_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(BORDER_COLOR))
.style(Style::default().fg(color))
.padding(Padding::horizontal(1));
let logo_paragraph = build_logo_paragraph().block(logo_block);
f.render_widget(logo_paragraph, area);
}
fn draw_metadata_block(
f: &mut Frame,
area: Rect,
mode: Mode,
current_channel: UnitChannel,
app_metadata: &AppMetadata,
) {
let metadata_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Blue))
.padding(Padding::horizontal(1))
.style(Style::default());
let metadata_table =
build_metadata_table(mode, current_channel, app_metadata)
.block(metadata_block);
f.render_widget(metadata_table, area);
}
fn draw_keymaps_block(f: &mut Frame, area: Rect, keymap_table: Table) {
let keymaps_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Blue))
.style(Style::default())
.padding(Padding::horizontal(1));
let keymaps_table = keymap_table.block(keymaps_block);
f.render_widget(keymaps_table, area);
}
pub fn draw_help_bar(
f: &mut Frame,
layout: &Option<HelpBarLayout>,
current_channel: UnitChannel,
keymap_table: Table,
mode: Mode,
app_metadata: &AppMetadata,
) {
if let Some(help_bar) = layout {
draw_metadata_block(
f,
help_bar.left,
mode,
current_channel,
app_metadata,
);
draw_keymaps_block(f, help_bar.middle, keymap_table);
draw_logo_block(f, help_bar.right, mode_color(mode));
}
}

View File

@ -0,0 +1,116 @@
use color_eyre::Result;
use ratatui::{
layout::{
Alignment, Constraint, Direction, Layout as RatatuiLayout, Rect,
},
style::{Style, Stylize},
text::{Line, Span},
widgets::{Block, BorderType, Borders, ListState, Paragraph},
Frame,
};
use television_utils::input::Input;
use crate::{
colors::{BORDER_COLOR, DEFAULT_INPUT_FG, DEFAULT_RESULTS_COUNT_FG},
spinner::{Spinner, SpinnerState},
};
// TODO: refactor arguments (e.g. use a struct for the spinner+state, same
#[allow(clippy::too_many_arguments)]
pub fn draw_input_box(
f: &mut Frame,
rect: Rect,
results_count: u32,
total_count: u32,
input_state: &mut Input,
results_picker_state: &mut ListState,
matcher_running: bool,
spinner: &Spinner,
spinner_state: &mut SpinnerState,
) -> Result<()> {
let input_block = Block::default()
.title_top(Line::from(" Pattern ").alignment(Alignment::Center))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(BORDER_COLOR))
.style(Style::default());
let input_block_inner = input_block.inner(rect);
if input_block_inner.area() == 0 {
return Ok(());
}
f.render_widget(input_block, rect);
// split input block into 4 parts: prompt symbol, input, result count, spinner
let inner_input_chunks = RatatuiLayout::default()
.direction(Direction::Horizontal)
.constraints([
// prompt symbol
Constraint::Length(2),
// input field
Constraint::Fill(1),
// result count
Constraint::Length(
3 * ((total_count as f32).log10().ceil() as u16 + 1) + 3,
),
// spinner
Constraint::Length(1),
])
.split(input_block_inner);
let arrow_block = Block::default();
let arrow = Paragraph::new(Span::styled(
"> ",
Style::default().fg(DEFAULT_INPUT_FG).bold(),
))
.block(arrow_block);
f.render_widget(arrow, inner_input_chunks[0]);
let interactive_input_block = Block::default();
// keep 2 for borders and 1 for cursor
let width = inner_input_chunks[1].width.max(3) - 3;
let scroll = input_state.visual_scroll(width as usize);
let input = Paragraph::new(input_state.value())
.scroll((0, u16::try_from(scroll)?))
.block(interactive_input_block)
.style(Style::default().fg(DEFAULT_INPUT_FG).bold().italic())
.alignment(Alignment::Left);
f.render_widget(input, inner_input_chunks[1]);
if matcher_running {
f.render_stateful_widget(
spinner,
inner_input_chunks[3],
spinner_state,
);
}
let result_count_block = Block::default();
let result_count_paragraph = Paragraph::new(Span::styled(
format!(
" {} / {} ",
if results_count == 0 {
0
} else {
results_picker_state.selected().unwrap_or(0) + 1
},
results_count,
),
Style::default().fg(DEFAULT_RESULTS_COUNT_FG).italic(),
))
.block(result_count_block)
.alignment(Alignment::Right);
f.render_widget(result_count_paragraph, inner_input_chunks[2]);
// Make the cursor visible and ask tui-rs to put it at the
// specified coordinates after rendering
f.set_cursor_position((
// Put cursor past the end of the input text
inner_input_chunks[1].x
+ u16::try_from(input_state.visual_cursor().max(scroll) - scroll)?,
// Move one line down, from the border to the input line
inner_input_chunks[1].y,
));
Ok(())
}

View File

@ -0,0 +1,279 @@
use std::{collections::HashMap, fmt::Display};
use crate::{
colors::ACTION_COLOR,
mode::{Mode, CHANNEL_COLOR, REMOTE_CONTROL_COLOR, SEND_TO_CHANNEL_COLOR},
};
use ratatui::{
layout::Constraint,
style::{Color, Style},
text::{Line, Span},
widgets::{Cell, Row, Table},
};
#[derive(Debug, Clone)]
pub struct DisplayableKeybindings {
bindings: HashMap<DisplayableAction, Vec<String>>,
}
impl DisplayableKeybindings {
pub fn new(bindings: HashMap<DisplayableAction, Vec<String>>) -> Self {
Self { bindings }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum DisplayableAction {
ResultsNavigation,
PreviewNavigation,
SelectEntry,
CopyEntryToClipboard,
SendToChannel,
ToggleRemoteControl,
Cancel,
Quit,
}
impl Display for DisplayableAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let action = match self {
DisplayableAction::ResultsNavigation => "Results navigation",
DisplayableAction::PreviewNavigation => "Preview navigation",
DisplayableAction::SelectEntry => "Select entry",
DisplayableAction::CopyEntryToClipboard => {
"Copy entry to clipboard"
}
DisplayableAction::SendToChannel => "Send to channel",
DisplayableAction::ToggleRemoteControl => "Toggle Remote control",
DisplayableAction::Cancel => "Cancel",
DisplayableAction::Quit => "Quit",
};
write!(f, "{}", action)
}
}
pub fn build_keybindings_table(
keybindings: &HashMap<Mode, DisplayableKeybindings>,
mode: Mode,
) -> Table<'_> {
match mode {
Mode::Channel => {
build_keybindings_table_for_channel(&keybindings[&mode])
}
Mode::RemoteControl => {
build_keybindings_table_for_channel_selection(&keybindings[&mode])
}
Mode::SendToChannel => {
build_keybindings_table_for_channel_transitions(
&keybindings[&mode],
)
}
}
}
fn build_keybindings_table_for_channel(
keybindings: &DisplayableKeybindings,
) -> Table<'_> {
// Results navigation
let results_navigation_keys = keybindings
.bindings
.get(&DisplayableAction::ResultsNavigation)
.unwrap();
let results_row = Row::new(build_cells_for_group(
"Results navigation",
results_navigation_keys,
CHANNEL_COLOR,
));
// Preview navigation
let preview_navigation_keys = keybindings
.bindings
.get(&DisplayableAction::PreviewNavigation)
.unwrap();
let preview_row = Row::new(build_cells_for_group(
"Preview navigation",
preview_navigation_keys,
CHANNEL_COLOR,
));
// Select entry
let select_entry_keys = keybindings
.bindings
.get(&DisplayableAction::SelectEntry)
.unwrap();
let select_entry_row = Row::new(build_cells_for_group(
"Select entry",
select_entry_keys,
CHANNEL_COLOR,
));
// Copy entry to clipboard
let copy_entry_keys = keybindings
.bindings
.get(&DisplayableAction::CopyEntryToClipboard)
.unwrap();
let copy_entry_row = Row::new(build_cells_for_group(
"Copy entry to clipboard",
copy_entry_keys,
CHANNEL_COLOR,
));
// 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,
CHANNEL_COLOR,
));
// Switch channels
let switch_channels_keys = keybindings
.bindings
.get(&DisplayableAction::ToggleRemoteControl)
.unwrap();
let switch_channels_row = Row::new(build_cells_for_group(
"Toggle Remote control",
switch_channels_keys,
CHANNEL_COLOR,
));
// MISC line (quit, help, etc.)
// Quit ⏼
let quit_keys =
keybindings.bindings.get(&DisplayableAction::Quit).unwrap();
let quit_row =
Row::new(build_cells_for_group("Quit", quit_keys, CHANNEL_COLOR));
let widths = vec![Constraint::Fill(1), Constraint::Fill(2)];
Table::new(
vec![
results_row,
preview_row,
select_entry_row,
copy_entry_row,
send_to_channel_row,
switch_channels_row,
quit_row,
],
widths,
)
}
fn build_keybindings_table_for_channel_selection(
keybindings: &DisplayableKeybindings,
) -> Table<'_> {
// Results navigation
let navigation_keys = keybindings
.bindings
.get(&DisplayableAction::ResultsNavigation)
.unwrap();
let results_row = Row::new(build_cells_for_group(
"Browse channels",
navigation_keys,
REMOTE_CONTROL_COLOR,
));
// Select entry
let select_entry_keys = keybindings
.bindings
.get(&DisplayableAction::SelectEntry)
.unwrap();
let select_entry_row = Row::new(build_cells_for_group(
"Select channel",
select_entry_keys,
REMOTE_CONTROL_COLOR,
));
// Remote control
let switch_channels_keys = keybindings
.bindings
.get(&DisplayableAction::ToggleRemoteControl)
.unwrap();
let switch_channels_row = Row::new(build_cells_for_group(
"Toggle Remote control",
switch_channels_keys,
REMOTE_CONTROL_COLOR,
));
Table::new(
vec![results_row, select_entry_row, switch_channels_row],
vec![Constraint::Fill(1), Constraint::Fill(2)],
)
}
fn build_keybindings_table_for_channel_transitions(
keybindings: &DisplayableKeybindings,
) -> Table<'_> {
// 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,
SEND_TO_CHANNEL_COLOR,
));
// 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,
SEND_TO_CHANNEL_COLOR,
));
// Cancel
let cancel_keys = keybindings
.bindings
.get(&DisplayableAction::Cancel)
.unwrap();
let cancel_row = Row::new(build_cells_for_group(
"Cancel",
cancel_keys,
SEND_TO_CHANNEL_COLOR,
));
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],
key_color: Color,
) -> Vec<Cell<'a>> {
// group name
let mut cells = vec![Cell::from(Span::styled(
group_name.to_owned() + ": ",
Style::default().fg(ACTION_COLOR),
))];
let spans = keys.iter().skip(1).fold(
vec![Span::styled(
keys[0].clone(),
Style::default().fg(key_color),
)],
|mut acc, key| {
acc.push(Span::raw(" / "));
acc.push(Span::styled(
key.to_owned(),
Style::default().fg(key_color),
));
acc
},
);
cells.push(Cell::from(Line::from(spans)));
cells
}

View File

@ -0,0 +1,13 @@
pub mod cache;
pub mod colors;
pub mod help;
pub mod input;
pub mod keybindings;
pub mod layout;
pub mod logo;
pub mod metadata;
pub mod mode;
pub mod preview;
pub mod remote_control;
pub mod results;
pub mod spinner;

View File

@ -0,0 +1,125 @@
use std::fmt::Display;
use crate::mode::{mode_color, Mode};
use ratatui::{
layout::Constraint,
style::{Color, Style},
text::Span,
widgets::{Cell, Row, Table},
};
use television_channels::channels::UnitChannel;
use television_utils::metadata::AppMetadata;
const METADATA_FIELD_NAME_COLOR: Color = Color::DarkGray;
const METADATA_FIELD_VALUE_COLOR: Color = Color::Gray;
impl Display for Mode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Mode::Channel => write!(f, "Channel"),
Mode::RemoteControl => write!(f, "Remote Control"),
Mode::SendToChannel => write!(f, "Send to Channel"),
}
}
}
pub fn build_metadata_table(
mode: Mode,
current_channel: UnitChannel,
app_metadata: &AppMetadata,
) -> Table<'_> {
let version_row = Row::new(vec![
Cell::from(Span::styled(
"version: ",
Style::default().fg(METADATA_FIELD_NAME_COLOR),
)),
Cell::from(Span::styled(
&app_metadata.version,
Style::default().fg(METADATA_FIELD_VALUE_COLOR),
)),
]);
let target_triple_row = Row::new(vec![
Cell::from(Span::styled(
"target triple: ",
Style::default().fg(METADATA_FIELD_NAME_COLOR),
)),
Cell::from(Span::styled(
&app_metadata.build.target_triple,
Style::default().fg(METADATA_FIELD_VALUE_COLOR),
)),
]);
let build_row = Row::new(vec![
Cell::from(Span::styled(
"build: ",
Style::default().fg(METADATA_FIELD_NAME_COLOR),
)),
Cell::from(Span::styled(
&app_metadata.build.rustc_version,
Style::default().fg(METADATA_FIELD_VALUE_COLOR),
)),
Cell::from(Span::styled(
" (",
Style::default().fg(METADATA_FIELD_NAME_COLOR),
)),
Cell::from(Span::styled(
&app_metadata.build.build_date,
Style::default().fg(METADATA_FIELD_VALUE_COLOR),
)),
Cell::from(Span::styled(
")",
Style::default().fg(METADATA_FIELD_NAME_COLOR),
)),
]);
let current_dir_row = Row::new(vec![
Cell::from(Span::styled(
"current directory: ",
Style::default().fg(METADATA_FIELD_NAME_COLOR),
)),
Cell::from(Span::styled(
std::env::current_dir()
.expect("Could not get current directory")
.display()
.to_string(),
Style::default().fg(METADATA_FIELD_VALUE_COLOR),
)),
]);
let current_channel_row = Row::new(vec![
Cell::from(Span::styled(
"current channel: ",
Style::default().fg(METADATA_FIELD_NAME_COLOR),
)),
Cell::from(Span::styled(
current_channel.to_string(),
Style::default().fg(METADATA_FIELD_VALUE_COLOR),
)),
]);
let current_mode_row = Row::new(vec![
Cell::from(Span::styled(
"current mode: ",
Style::default().fg(METADATA_FIELD_NAME_COLOR),
)),
Cell::from(Span::styled(
mode.to_string(),
Style::default().fg(mode_color(mode)),
)),
]);
let widths = vec![Constraint::Fill(1), Constraint::Fill(2)];
Table::new(
vec![
version_row,
target_triple_row,
build_row,
current_dir_row,
current_channel_row,
current_mode_row,
],
widths,
)
}

View File

@ -0,0 +1,22 @@
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
pub const CHANNEL_COLOR: Color = Color::Indexed(222);
pub const REMOTE_CONTROL_COLOR: Color = Color::Indexed(1);
pub const SEND_TO_CHANNEL_COLOR: Color = Color::Indexed(105);
pub fn mode_color(mode: Mode) -> Color {
match mode {
Mode::Channel => CHANNEL_COLOR,
Mode::RemoteControl => REMOTE_CONTROL_COLOR,
Mode::SendToChannel => SEND_TO_CHANNEL_COLOR,
}
}
// FIXME: Mode shouldn't be in the screen crate
#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)]
pub enum Mode {
Channel,
RemoteControl,
SendToChannel,
}

View File

@ -0,0 +1,413 @@
use crate::cache::RenderedPreviewCache;
use crate::colors::{
BORDER_COLOR, DEFAULT_PREVIEW_CONTENT_FG, DEFAULT_PREVIEW_GUTTER_FG,
DEFAULT_PREVIEW_GUTTER_SELECTED_FG, DEFAULT_PREVIEW_TITLE_FG,
DEFAULT_SELECTED_PREVIEW_BG,
};
use ansi_to_tui::IntoText;
use color_eyre::eyre::Result;
use ratatui::layout::{Alignment, Rect};
use ratatui::prelude::{Color, Line, Modifier, Span, Style, Stylize, Text};
use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap};
use ratatui::Frame;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use syntect::highlighting::Color as SyntectColor;
use television_channels::entry::Entry;
use television_previewers::previewers::{
Preview, PreviewContent, FILE_TOO_LARGE_MSG, PREVIEW_NOT_SUPPORTED_MSG,
};
use television_utils::strings::{
replace_non_printable, shrink_with_ellipsis, ReplaceNonPrintableConfig,
EMPTY_STRING,
};
#[allow(dead_code)]
const FILL_CHAR_SLANTED: char = '';
const FILL_CHAR_EMPTY: char = ' ';
pub fn build_preview_paragraph(
preview_block: Block,
inner: Rect,
preview_content: PreviewContent,
target_line: Option<u16>,
preview_scroll: u16,
) -> Paragraph {
match preview_content {
PreviewContent::AnsiText(text) => {
build_ansi_text_paragraph(text, preview_block, preview_scroll)
}
PreviewContent::PlainText(content) => build_plain_text_paragraph(
content,
preview_block,
target_line,
preview_scroll,
),
PreviewContent::PlainTextWrapped(content) => {
build_plain_text_wrapped_paragraph(content, preview_block)
}
PreviewContent::SyntectHighlightedText(highlighted_lines) => {
build_syntect_highlighted_paragraph(
highlighted_lines,
preview_block,
target_line,
preview_scroll,
)
}
// meta
PreviewContent::Loading => {
build_meta_preview_paragraph(inner, "Loading...", FILL_CHAR_EMPTY)
.block(preview_block)
.alignment(Alignment::Left)
.style(Style::default().add_modifier(Modifier::ITALIC))
}
PreviewContent::NotSupported => 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 => 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::Empty => Paragraph::new(Text::raw(EMPTY_STRING)),
}
}
fn build_ansi_text_paragraph(
text: String,
preview_block: Block,
preview_scroll: u16,
) -> Paragraph {
let text = replace_non_printable(
text.as_bytes(),
&ReplaceNonPrintableConfig {
replace_line_feed: false,
replace_control_characters: false,
..Default::default()
},
)
.0
.into_text()
.unwrap();
Paragraph::new(text)
.block(preview_block)
.scroll((preview_scroll, 0))
}
fn build_plain_text_paragraph(
text: Vec<String>,
preview_block: Block,
target_line: Option<u16>,
preview_scroll: u16,
) -> Paragraph {
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
)
{
DEFAULT_PREVIEW_GUTTER_SELECTED_FG
} else {
DEFAULT_PREVIEW_GUTTER_FG
},
)),
Span::styled("",
Style::default().fg(DEFAULT_PREVIEW_GUTTER_FG).dim()),
Span::styled(
line.to_string(),
Style::default().fg(DEFAULT_PREVIEW_CONTENT_FG).bg(
if matches!(target_line, Some(l) if l == u16::try_from(i).unwrap() + 1) {
DEFAULT_SELECTED_PREVIEW_BG
} else {
Color::Reset
},
),
),
]));
}
let text = Text::from(lines);
Paragraph::new(text)
.block(preview_block)
.scroll((preview_scroll, 0))
}
fn build_plain_text_wrapped_paragraph(
text: String,
preview_block: Block,
) -> Paragraph {
let mut lines = Vec::new();
for line in text.lines() {
lines.push(Line::styled(
line.to_string(),
Style::default().fg(DEFAULT_PREVIEW_CONTENT_FG),
));
}
let text = Text::from(lines);
Paragraph::new(text)
.block(preview_block)
.wrap(Wrap { trim: true })
}
fn build_syntect_highlighted_paragraph(
highlighted_lines: Vec<Vec<(syntect::highlighting::Style, String)>>,
preview_block: Block,
target_line: Option<u16>,
preview_scroll: u16,
) -> Paragraph {
compute_paragraph_from_highlighted_lines(
&highlighted_lines,
target_line.map(|l| l as usize),
)
.block(preview_block)
.alignment(Alignment::Left)
.scroll((preview_scroll, 0))
}
pub fn build_meta_preview_paragraph<'a>(
inner: Rect,
message: &str,
fill_char: char,
) -> Paragraph<'a> {
let message_len = message.len();
if message_len + 8 > inner.width as usize {
return Paragraph::new(Text::from(EMPTY_STRING));
}
let fill_char_str = fill_char.to_string();
let fill_line = fill_char_str.repeat(inner.width as usize);
// Build the paragraph content with slanted lines and center the custom message
let mut lines = Vec::new();
// Calculate the vertical center
let vertical_center = inner.height as usize / 2;
let horizontal_padding = (inner.width as usize - message_len) / 2 - 4;
// Fill the paragraph with slanted lines and insert the centered custom message
for i in 0..inner.height {
if i as usize == vertical_center {
// Center the message horizontally in the middle line
let line = format!(
"{} {} {}",
fill_char_str.repeat(horizontal_padding),
message,
fill_char_str.repeat(
inner.width as usize - horizontal_padding - message_len
)
);
lines.push(Line::from(line));
} else if i as usize + 1 == vertical_center
|| (i as usize).saturating_sub(1) == vertical_center
{
let line = format!(
"{} {} {}",
fill_char_str.repeat(horizontal_padding),
" ".repeat(message_len),
fill_char_str.repeat(
inner.width as usize - horizontal_padding - message_len
)
);
lines.push(Line::from(line));
} else {
lines.push(Line::from(fill_line.clone()));
}
}
// Create a paragraph with the generated content
Paragraph::new(Text::from(lines))
}
pub fn draw_preview_title_block(
f: &mut Frame,
rect: Rect,
preview: &Arc<Preview>,
use_nerd_font_icons: bool,
) -> Result<()> {
let mut preview_title_spans = Vec::new();
if preview.icon.is_some() && use_nerd_font_icons {
let icon = preview.icon.as_ref().unwrap();
preview_title_spans.push(Span::styled(
{
let mut icon_str = String::from(icon.icon);
icon_str.push(' ');
icon_str
},
Style::default().fg(Color::from_str(icon.color)?),
));
}
preview_title_spans.push(Span::styled(
shrink_with_ellipsis(
&replace_non_printable(
preview.title.as_bytes(),
&ReplaceNonPrintableConfig::default(),
)
.0,
rect.width.saturating_sub(4) as usize,
),
Style::default().fg(DEFAULT_PREVIEW_TITLE_FG).bold(),
));
let preview_title = Paragraph::new(Line::from(preview_title_spans))
.block(
Block::default()
.padding(Padding::horizontal(1))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(BORDER_COLOR)),
)
.alignment(Alignment::Left);
f.render_widget(preview_title, rect);
Ok(())
}
pub fn draw_preview_content_block(
f: &mut Frame,
rect: Rect,
entry: &Entry,
preview: &Arc<Preview>,
rendered_preview_cache: &Arc<Mutex<RenderedPreviewCache<'static>>>,
preview_scroll: u16,
) {
let preview_outer_block = Block::default()
.title_top(Line::from(" Preview ").alignment(Alignment::Center))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(BORDER_COLOR))
.style(Style::default())
.padding(Padding::right(1));
let preview_inner_block =
Block::default().style(Style::default()).padding(Padding {
top: 0,
right: 1,
bottom: 0,
left: 1,
});
let inner = preview_outer_block.inner(rect);
f.render_widget(preview_outer_block, rect);
let target_line = entry.line_number.map(|l| u16::try_from(l).unwrap_or(0));
let cache_key = compute_cache_key(entry);
// Check if the rendered preview content is already in the cache
if let Some(preview_paragraph) =
rendered_preview_cache.lock().unwrap().get(&cache_key)
{
let p = preview_paragraph.as_ref().clone();
f.render_widget(p.scroll((preview_scroll, 0)), inner);
return;
}
// If not, render the preview content and cache it if not empty
let rp = build_preview_paragraph(
preview_inner_block,
inner,
preview.content.clone(),
target_line,
preview_scroll,
);
if !preview.stale {
rendered_preview_cache
.lock()
.unwrap()
.insert(cache_key, &Arc::new(rp.clone()));
}
f.render_widget(
Arc::new(rp).as_ref().clone().scroll((preview_scroll, 0)),
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>,
) -> Paragraph<'static> {
let preview_lines: Vec<Line> = highlighted_lines
.iter()
.enumerate()
.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)
{
DEFAULT_PREVIEW_GUTTER_SELECTED_FG
} else {
DEFAULT_PREVIEW_GUTTER_FG
},
));
Line::from_iter(
std::iter::once(line_number)
.chain(std::iter::once(Span::styled(
"",
Style::default().fg(DEFAULT_PREVIEW_GUTTER_FG).dim(),
)))
.chain(l.iter().cloned().map(|sr| {
convert_syn_region_to_span(
&(sr.0, sr.1),
if line_specifier.is_some()
&& i == line_specifier
.unwrap()
.saturating_sub(1)
{
Some(SyntectColor {
r: 50,
g: 50,
b: 50,
a: 255,
})
} else {
None
},
)
})),
)
})
.collect();
Paragraph::new(preview_lines)
}
pub fn convert_syn_region_to_span<'a>(
syn_region: &(syntect::highlighting::Style, String),
background: Option<syntect::highlighting::Color>,
) -> Span<'a> {
let mut style = Style::default()
.fg(convert_syn_color_to_ratatui_color(syn_region.0.foreground));
if let Some(background) = background {
style = style.bg(convert_syn_color_to_ratatui_color(background));
}
style = match syn_region.0.font_style {
syntect::highlighting::FontStyle::BOLD => style.bold(),
syntect::highlighting::FontStyle::ITALIC => style.italic(),
syntect::highlighting::FontStyle::UNDERLINE => style.underlined(),
_ => style,
};
Span::styled(syn_region.1.clone(), style)
}
fn convert_syn_color_to_ratatui_color(
color: syntect::highlighting::Color,
) -> Color {
Color::Rgb(color.r, color.g, color.b)
}
fn compute_cache_key(entry: &Entry) -> String {
let mut cache_key = entry.name.clone();
if let Some(line_number) = entry.line_number {
cache_key.push_str(&line_number.to_string());
}
cache_key
}

View File

@ -0,0 +1,144 @@
use std::collections::HashMap;
use crate::logo::build_remote_logo_paragraph;
use crate::mode::REMOTE_CONTROL_COLOR;
use crate::results::build_results_list;
use television_channels::entry::Entry;
use television_utils::input::Input;
use crate::colors::{ResultsListColors, BORDER_COLOR, DEFAULT_INPUT_FG};
use color_eyre::eyre::Result;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::prelude::Style;
use ratatui::style::{Color, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, BorderType, Borders, ListDirection, ListState, Padding, Paragraph,
};
use ratatui::Frame;
pub fn draw_remote_control(
f: &mut Frame,
rect: Rect,
entries: &[Entry],
use_nerd_font_icons: bool,
picker_state: &mut ListState,
input_state: &mut Input,
icon_color_cache: &mut HashMap<String, Color>,
) -> Result<()> {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(20),
]
.as_ref(),
)
.split(rect);
draw_rc_channels(
f,
layout[0],
entries,
use_nerd_font_icons,
picker_state,
icon_color_cache,
);
draw_rc_input(f, layout[1], input_state)?;
draw_rc_logo(f, layout[2]);
Ok(())
}
fn draw_rc_channels(
f: &mut Frame,
area: Rect,
entries: &[Entry],
use_nerd_font_icons: bool,
picker_state: &mut ListState,
icon_color_cache: &mut HashMap<String, Color>,
) {
let rc_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(BORDER_COLOR))
.style(Style::default())
.padding(Padding::right(1));
let channel_list = build_results_list(
rc_block,
entries,
ListDirection::TopToBottom,
Some(
ResultsListColors::default().result_name_fg(REMOTE_CONTROL_COLOR),
),
use_nerd_font_icons,
icon_color_cache,
);
f.render_stateful_widget(channel_list, area, picker_state);
}
fn draw_rc_input(f: &mut Frame, area: Rect, input: &mut Input) -> Result<()> {
let input_block = Block::default()
.title_top(Line::from("Remote Control").alignment(Alignment::Center))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(BORDER_COLOR))
.style(Style::default());
let input_block_inner = input_block.inner(area);
f.render_widget(input_block, area);
// split input block into 2 parts: prompt symbol, input
let inner_input_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
// prompt symbol
Constraint::Length(2),
// input field
Constraint::Fill(1),
])
.split(input_block_inner);
let prompt_symbol_block = Block::default();
let arrow = Paragraph::new(Span::styled(
"> ",
Style::default().fg(DEFAULT_INPUT_FG).bold(),
))
.block(prompt_symbol_block);
f.render_widget(arrow, inner_input_chunks[0]);
let interactive_input_block = Block::default();
// keep 2 for borders and 1 for cursor
let width = inner_input_chunks[1].width.max(3) - 3;
let scroll = input.visual_scroll(width as usize);
let input_paragraph = Paragraph::new(input.value())
.scroll((0, u16::try_from(scroll)?))
.block(interactive_input_block)
.style(Style::default().fg(DEFAULT_INPUT_FG).bold().italic())
.alignment(Alignment::Left);
f.render_widget(input_paragraph, inner_input_chunks[1]);
// Make the cursor visible and ask tui-rs to put it at the
// specified coordinates after rendering
f.set_cursor_position((
// Put cursor past the end of the input text
inner_input_chunks[1].x
+ u16::try_from(input.visual_cursor().max(scroll) - scroll)?,
// Move one line down, from the border to the input line
inner_input_chunks[1].y,
));
Ok(())
}
fn draw_rc_logo(f: &mut Frame, area: Rect) {
let logo_block =
Block::default().style(Style::default().fg(REMOTE_CONTROL_COLOR));
let logo_paragraph = build_remote_logo_paragraph()
.alignment(Alignment::Center)
.block(logo_block);
f.render_widget(logo_paragraph, area);
}

View File

@ -1,71 +1,23 @@
use crate::television::Television; use crate::colors::{
use crate::ui::layout::InputPosition; ResultsListColors, BORDER_COLOR,
use crate::ui::BORDER_COLOR; DEFAULT_RESULTS_LIST_MATCH_FOREGROUND_COLOR,
};
use crate::layout::InputPosition;
use color_eyre::eyre::Result; use color_eyre::eyre::Result;
use ratatui::layout::{Alignment, Rect}; use ratatui::layout::{Alignment, Rect};
use ratatui::prelude::{Color, Line, Span, Style}; use ratatui::prelude::{Color, Line, Span, Style};
use ratatui::widgets::{ use ratatui::widgets::{
Block, BorderType, Borders, List, ListDirection, Padding, Block, BorderType, Borders, List, ListDirection, ListState, Padding,
}; };
use ratatui::Frame; use ratatui::Frame;
use std::collections::HashMap; use std::collections::HashMap;
use std::str::FromStr; use std::str::FromStr;
use television_channels::channels::OnAir;
use television_channels::entry::Entry; use television_channels::entry::Entry;
use television_utils::strings::{ use television_utils::strings::{
make_matched_string_printable, next_char_boundary, make_matched_string_printable, next_char_boundary,
slice_at_char_boundaries, slice_at_char_boundaries,
}; };
// Styles
const DEFAULT_RESULT_NAME_FG: Color = Color::Blue;
const DEFAULT_RESULT_PREVIEW_FG: Color = Color::Rgb(150, 150, 150);
const DEFAULT_RESULT_LINE_NUMBER_FG: Color = Color::Yellow;
const DEFAULT_RESULT_SELECTED_BG: Color = Color::Rgb(50, 50, 50);
const DEFAULT_RESULTS_LIST_MATCH_FOREGROUND_COLOR: Color = Color::Red;
pub struct ResultsListColors {
pub result_name_fg: Color,
pub result_preview_fg: Color,
pub result_line_number_fg: Color,
pub result_selected_bg: Color,
}
impl Default for ResultsListColors {
fn default() -> Self {
Self {
result_name_fg: DEFAULT_RESULT_NAME_FG,
result_preview_fg: DEFAULT_RESULT_PREVIEW_FG,
result_line_number_fg: DEFAULT_RESULT_LINE_NUMBER_FG,
result_selected_bg: DEFAULT_RESULT_SELECTED_BG,
}
}
}
#[allow(dead_code)]
impl ResultsListColors {
pub fn result_name_fg(mut self, color: Color) -> Self {
self.result_name_fg = color;
self
}
pub fn result_preview_fg(mut self, color: Color) -> Self {
self.result_preview_fg = color;
self
}
pub fn result_line_number_fg(mut self, color: Color) -> Self {
self.result_line_number_fg = color;
self
}
pub fn result_selected_bg(mut self, color: Color) -> Self {
self.result_selected_bg = color;
self
}
}
pub fn build_results_list<'a, 'b>( pub fn build_results_list<'a, 'b>(
results_block: Block<'b>, results_block: Block<'b>,
entries: &'a [Entry], entries: &'a [Entry],
@ -186,48 +138,35 @@ where
.block(results_block) .block(results_block)
} }
impl Television { pub fn draw_results_list(
pub(crate) fn draw_results_list( f: &mut Frame,
&mut self, rect: Rect,
f: &mut Frame, entries: &[Entry],
rect: Rect, relative_picker_state: &mut ListState,
) -> Result<()> { input_bar_position: InputPosition,
let results_block = Block::default() use_nerd_font_icons: bool,
.title_top(Line::from(" Results ").alignment(Alignment::Center)) icon_color_cache: &mut HashMap<String, Color>,
.borders(Borders::ALL) ) -> Result<()> {
.border_type(BorderType::Rounded) let results_block = Block::default()
.border_style(Style::default().fg(BORDER_COLOR)) .title_top(Line::from(" Results ").alignment(Alignment::Center))
.style(Style::default()) .borders(Borders::ALL)
.padding(Padding::right(1)); .border_type(BorderType::Rounded)
.border_style(Style::default().fg(BORDER_COLOR))
.style(Style::default())
.padding(Padding::right(1));
let result_count = self.channel.result_count(); let results_list = build_results_list(
if result_count > 0 && self.results_picker.selected().is_none() { results_block,
self.results_picker.select(Some(0)); entries,
self.results_picker.relative_select(Some(0)); match input_bar_position {
} InputPosition::Bottom => ListDirection::BottomToTop,
InputPosition::Top => ListDirection::TopToBottom,
},
None,
use_nerd_font_icons,
icon_color_cache,
);
let entries = self.channel.results( f.render_stateful_widget(results_list, rect, relative_picker_state);
rect.height.saturating_sub(2).into(), Ok(())
u32::try_from(self.results_picker.offset())?,
);
let results_list = build_results_list(
results_block,
&entries,
match self.config.ui.input_bar_position {
InputPosition::Bottom => ListDirection::BottomToTop,
InputPosition::Top => ListDirection::TopToBottom,
},
None,
self.config.ui.use_nerd_font_icons,
&mut self.icon_color_cache,
);
f.render_stateful_widget(
results_list,
rect,
&mut self.results_picker.relative_state,
);
Ok(())
}
} }

View File

@ -2,10 +2,6 @@ use ratatui::{
buffer::Buffer, layout::Rect, style::Style, widgets::StatefulWidget, buffer::Buffer, layout::Rect, style::Style, widgets::StatefulWidget,
}; };
//const FRAMES: &[char] = &[
// '⠄', '⠆', '⠇', '⠋', '⠙', '⠸', '⠰', '⠠', '⠰', '⠸', '⠙', '⠋', '⠇', '⠆',
//];
const FRAMES: &[&str] = &["", "", "", "", "", "", "", "", "", ""]; const FRAMES: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
/// A spinner widget. /// A spinner widget.
@ -69,3 +65,17 @@ impl StatefulWidget for Spinner {
state.tick(); state.tick();
} }
} }
impl StatefulWidget for &Spinner {
type State = SpinnerState;
/// Renders the spinner in the given area.
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
buf.set_string(
area.left(),
area.top(),
self.frame(state.current_frame),
Style::default(),
);
state.tick();
}
}

View File

@ -21,6 +21,7 @@ bat = { version = "0.24.0", default-features = false, features = ["regex-onig"]
directories = "5.0.1" directories = "5.0.1"
syntect = "5.2.0" syntect = "5.2.0"
gag = "1.0.0" gag = "1.0.0"
unicode-width = "0.2.0"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winapi-util = "0.1.9" winapi-util = "0.1.9"

View File

@ -1,19 +1,3 @@
use crate::television::Television;
use crate::ui::BORDER_COLOR;
use color_eyre::eyre::Result;
use ratatui::layout::{
Alignment, Constraint, Direction, Layout as RatatuiLayout, Rect,
};
use ratatui::prelude::{Span, Style};
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use ratatui::Frame;
use television_channels::channels::OnAir;
pub mod actions;
pub mod backend;
/// Input requests are used to change the input state. /// Input requests are used to change the input state.
/// ///
/// Different backends can be used to convert events into requests. /// Different backends can be used to convert events into requests.
@ -45,18 +29,7 @@ pub struct StateChanged {
#[allow(clippy::module_name_repetitions)] #[allow(clippy::module_name_repetitions)]
pub type InputResponse = Option<StateChanged>; pub type InputResponse = Option<StateChanged>;
/// The input buffer with cursor support. /// An input buffer with cursor support.
///
/// Example:
///
/// ```
/// use tui_input::Input;
///
/// let input: Input = "Hello World".into();
///
/// assert_eq!(input.cursor(), 11);
/// assert_eq!(input.to_string(), "Hello World");
/// ```
#[derive(Default, Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct Input { pub struct Input {
value: String, value: String,
@ -411,114 +384,6 @@ impl std::fmt::Display for Input {
} }
} }
impl Television {
pub(crate) fn draw_input_box(
&mut self,
f: &mut Frame,
rect: Rect,
) -> Result<()> {
let input_block = Block::default()
.title_top(Line::from(" Pattern ").alignment(Alignment::Center))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(BORDER_COLOR))
.style(Style::default());
let input_block_inner = input_block.inner(rect);
if input_block_inner.area() == 0 {
return Ok(());
}
f.render_widget(input_block, rect);
// split input block into 4 parts: prompt symbol, input, result count, spinner
let total_count = self.channel.total_count();
let inner_input_chunks = RatatuiLayout::default()
.direction(Direction::Horizontal)
.constraints([
// prompt symbol
Constraint::Length(2),
// input field
Constraint::Fill(1),
// result count
Constraint::Length(
3 * ((total_count as f32).log10().ceil() as u16 + 1) + 3,
),
// spinner
Constraint::Length(1),
])
.split(input_block_inner);
let arrow_block = Block::default();
let arrow = Paragraph::new(Span::styled(
"> ",
Style::default()
.fg(crate::television::DEFAULT_INPUT_FG)
.bold(),
))
.block(arrow_block);
f.render_widget(arrow, inner_input_chunks[0]);
let interactive_input_block = Block::default();
// keep 2 for borders and 1 for cursor
let width = inner_input_chunks[1].width.max(3) - 3;
let scroll = self.results_picker.input.visual_scroll(width as usize);
let input = Paragraph::new(self.results_picker.input.value())
.scroll((0, u16::try_from(scroll)?))
.block(interactive_input_block)
.style(
Style::default()
.fg(crate::television::DEFAULT_INPUT_FG)
.bold()
.italic(),
)
.alignment(Alignment::Left);
f.render_widget(input, inner_input_chunks[1]);
if self.channel.running() {
f.render_stateful_widget(
self.spinner,
inner_input_chunks[3],
&mut self.spinner_state,
);
}
let result_count = self.channel.result_count();
let result_count_block = Block::default();
let result_count_paragraph = Paragraph::new(Span::styled(
format!(
" {} / {} ",
if result_count == 0 {
0
} else {
self.results_picker.selected().unwrap_or(0) + 1
},
result_count,
),
Style::default()
.fg(crate::television::DEFAULT_RESULTS_COUNT_FG)
.italic(),
))
.block(result_count_block)
.alignment(Alignment::Right);
f.render_widget(result_count_paragraph, inner_input_chunks[2]);
// Make the cursor visible and ask tui-rs to put it at the
// specified coordinates after rendering
f.set_cursor_position((
// Put cursor past the end of the input text
inner_input_chunks[1].x
+ u16::try_from(
self.results_picker.input.visual_cursor().max(scroll)
- scroll,
)?,
// Move one line down, from the border to the input line
inner_input_chunks[1].y,
));
Ok(())
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
const TEXT: &str = "first second, third."; const TEXT: &str = "first second, third.";

View File

@ -2,6 +2,8 @@ pub mod cache;
pub mod command; pub mod command;
pub mod files; pub mod files;
pub mod indices; pub mod indices;
pub mod input;
pub mod metadata;
pub mod stdin; pub mod stdin;
pub mod strings; pub mod strings;
pub mod syntax; pub mod syntax;

View File

@ -0,0 +1,39 @@
pub struct BuildMetadata {
pub rustc_version: String,
pub build_date: String,
pub target_triple: String,
}
impl BuildMetadata {
pub fn new(
rustc_version: String,
build_date: String,
target_triple: String,
) -> Self {
Self {
rustc_version,
build_date,
target_triple,
}
}
}
pub struct AppMetadata {
pub version: String,
pub build: BuildMetadata,
pub current_directory: String,
}
impl AppMetadata {
pub fn new(
version: String,
build: BuildMetadata,
current_directory: String,
) -> Self {
Self {
version,
build,
current_directory,
}
}
}

View File

@ -1,13 +1,13 @@
use std::collections::HashMap;
use std::ops::Deref;
use std::sync::Arc; use std::sync::Arc;
use color_eyre::Result; use color_eyre::Result;
use television_screen::mode::Mode;
use tokio::sync::{mpsc, Mutex}; use tokio::sync::{mpsc, Mutex};
use tracing::{debug, info}; use tracing::{debug, info};
use crate::config::{parse_key, KeyBindings}; use crate::config::parse_key;
use crate::television::{Mode, Television}; use crate::keymap::Keymap;
use crate::television::Television;
use crate::{ use crate::{
action::Action, action::Action,
config::Config, config::Config,
@ -17,46 +17,6 @@ use crate::{
use television_channels::channels::TelevisionChannel; use television_channels::channels::TelevisionChannel;
use television_channels::entry::Entry; use television_channels::entry::Entry;
#[derive(Default, Debug)]
pub struct Keymap(pub HashMap<Mode, HashMap<Key, Action>>);
impl Deref for Keymap {
type Target = HashMap<Mode, HashMap<Key, Action>>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<&KeyBindings> for Keymap {
fn from(keybindings: &KeyBindings) -> Self {
let mut keymap = HashMap::new();
for (mode, bindings) in keybindings.iter() {
let mut mode_keymap = HashMap::new();
for (action, key) in bindings {
mode_keymap.insert(*key, action.clone());
}
keymap.insert(*mode, mode_keymap);
}
Self(keymap)
}
}
impl Keymap {
pub fn with_mode_mappings(
mut self,
mode: Mode,
mappings: Vec<(Key, Action)>,
) -> Result<Self> {
let mode_keymap = self.0.get_mut(&mode).ok_or_else(|| {
color_eyre::eyre::eyre!("Mode {:?} not found", mode)
})?;
for (key, action) in mappings {
mode_keymap.insert(key, action);
}
Ok(self)
}
}
/// The main application struct that holds the state of the application. /// The main application struct that holds the state of the application.
pub struct App { pub struct App {
keymap: Keymap, keymap: Keymap,

View File

@ -155,7 +155,7 @@ mod tests {
use super::*; use super::*;
use crate::action::Action; use crate::action::Action;
use crate::config::keybindings::parse_key; use crate::config::keybindings::parse_key;
use crate::television::Mode; use television_screen::mode::Mode;
#[test] #[test]
fn test_config() -> Result<()> { fn test_config() -> Result<()> {

View File

@ -1,10 +1,10 @@
use crate::action::Action; use crate::action::Action;
use crate::event::{convert_raw_event_to_key, Key}; use crate::event::{convert_raw_event_to_key, Key};
use crate::television::Mode;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::{Deserialize, Deserializer}; use serde::{Deserialize, Deserializer};
use std::collections::HashMap; use std::collections::HashMap;
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use television_screen::mode::Mode;
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct KeyBindings(pub config::Map<Mode, config::Map<Action, Key>>); pub struct KeyBindings(pub config::Map<Mode, config::Map<Action, Key>>);

View File

@ -1,10 +1,10 @@
use crate::television::Mode;
use ratatui::prelude::{Color, Modifier, Style}; use ratatui::prelude::{Color, Modifier, Style};
use serde::{Deserialize, Deserializer}; use serde::{Deserialize, Deserializer};
use std::{ use std::{
collections::HashMap, collections::HashMap,
ops::{Deref, DerefMut}, ops::{Deref, DerefMut},
}; };
use television_screen::mode::Mode;
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct Styles(pub HashMap<Mode, HashMap<String, Style>>); pub struct Styles(pub HashMap<Mode, HashMap<String, Style>>);

View File

@ -2,7 +2,7 @@ use config::ValueKind;
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use crate::ui::layout::InputPosition; use television_screen::layout::InputPosition;
const DEFAULT_UI_SCALE: u16 = 90; const DEFAULT_UI_SCALE: u16 = 90;

View File

@ -0,0 +1,17 @@
use crate::action::Action;
use television_utils::input::InputRequest;
pub fn convert_action_to_input_request(
action: &Action,
) -> Option<InputRequest> {
match action {
Action::AddInputChar(c) => Some(InputRequest::InsertChar(*c)),
Action::DeletePrevChar => Some(InputRequest::DeletePrevChar),
Action::DeleteNextChar => Some(InputRequest::DeleteNextChar),
Action::GoToPrevChar => Some(InputRequest::GoToPrevChar),
Action::GoToNextChar => Some(InputRequest::GoToNextChar),
Action::GoToInputStart => Some(InputRequest::GoToStart),
Action::GoToInputEnd => Some(InputRequest::GoToEnd),
_ => None,
}
}

View File

@ -0,0 +1,49 @@
use std::collections::HashMap;
use std::ops::Deref;
use color_eyre::Result;
use television_screen::mode::Mode;
use crate::action::Action;
use crate::config::KeyBindings;
use crate::event::Key;
#[derive(Default, Debug)]
pub struct Keymap(pub HashMap<Mode, HashMap<Key, Action>>);
impl Deref for Keymap {
type Target = HashMap<Mode, HashMap<Key, Action>>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<&KeyBindings> for Keymap {
fn from(keybindings: &KeyBindings) -> Self {
let mut keymap = HashMap::new();
for (mode, bindings) in keybindings.iter() {
let mut mode_keymap = HashMap::new();
for (action, key) in bindings {
mode_keymap.insert(*key, action.clone());
}
keymap.insert(*mode, mode_keymap);
}
Self(keymap)
}
}
impl Keymap {
pub fn with_mode_mappings(
mut self,
mode: Mode,
mappings: Vec<(Key, Action)>,
) -> Result<Self> {
let mode_keymap = self.0.get_mut(&mode).ok_or_else(|| {
color_eyre::eyre::eyre!("Mode {:?} not found", mode)
})?;
for (key, action) in mappings {
mode_keymap.insert(key, action);
}
Ok(self)
}
}

View File

@ -20,12 +20,13 @@ pub mod cli;
pub mod config; pub mod config;
pub mod errors; pub mod errors;
pub mod event; pub mod event;
pub mod input;
pub mod keymap;
pub mod logging; pub mod logging;
pub mod picker; pub mod picker;
pub mod render; pub mod render;
pub mod television; pub mod television;
pub mod tui; pub mod tui;
pub mod ui;
#[tokio::main(flavor = "multi_thread")] #[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<()> { async fn main() -> Result<()> {

View File

@ -1,6 +1,5 @@
use crate::ui::input::Input;
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use television_utils::strings::EMPTY_STRING; use television_utils::{input::Input, strings::EMPTY_STRING};
#[derive(Debug)] #[derive(Debug)]
pub struct Picker { pub struct Picker {

View File

@ -1,16 +1,11 @@
use crate::config::KeyBindings;
use crate::input::convert_action_to_input_request;
use crate::picker::Picker; use crate::picker::Picker;
use crate::ui::{ use crate::{action::Action, config::Config};
cache::RenderedPreviewCache, use crate::{cable::load_cable_channels, keymap::Keymap};
input::actions::InputActionHandler,
layout::{Dimensions, InputPosition, Layout},
spinner::Spinner,
};
use crate::{action::Action, config::Config, ui::spinner::SpinnerState};
use crate::{app::Keymap, cable::load_cable_channels};
use color_eyre::Result; use color_eyre::Result;
use copypasta::{ClipboardContext, ClipboardProvider}; use copypasta::{ClipboardContext, ClipboardProvider};
use ratatui::{layout::Rect, style::Color, Frame}; use ratatui::{layout::Rect, style::Color, Frame};
use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use television_channels::channels::{ use television_channels::channels::{
@ -19,16 +14,24 @@ use television_channels::channels::{
}; };
use television_channels::entry::{Entry, ENTRY_PLACEHOLDER}; use television_channels::entry::{Entry, ENTRY_PLACEHOLDER};
use television_previewers::previewers::Previewer; use television_previewers::previewers::Previewer;
use television_screen::cache::RenderedPreviewCache;
use television_screen::help::draw_help_bar;
use television_screen::input::draw_input_box;
use television_screen::keybindings::{
build_keybindings_table, DisplayableAction, DisplayableKeybindings,
};
use television_screen::layout::{Dimensions, InputPosition, Layout};
use television_screen::mode::Mode;
use television_screen::preview::{
draw_preview_content_block, draw_preview_title_block,
};
use television_screen::remote_control::draw_remote_control;
use television_screen::results::draw_results_list;
use television_screen::spinner::{Spinner, SpinnerState};
use television_utils::metadata::{AppMetadata, BuildMetadata};
use television_utils::strings::EMPTY_STRING; use television_utils::strings::EMPTY_STRING;
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)]
pub enum Mode {
Channel,
RemoteControl,
SendToChannel,
}
pub struct Television { pub struct Television {
action_tx: Option<UnboundedSender<Action>>, action_tx: Option<UnboundedSender<Action>>,
pub config: Config, pub config: Config,
@ -48,6 +51,7 @@ pub struct Television {
pub rendered_preview_cache: Arc<Mutex<RenderedPreviewCache<'static>>>, pub rendered_preview_cache: Arc<Mutex<RenderedPreviewCache<'static>>>,
pub(crate) spinner: Spinner, pub(crate) spinner: Spinner,
pub(crate) spinner_state: SpinnerState, pub(crate) spinner_state: SpinnerState,
pub app_metadata: AppMetadata,
} }
impl Television { impl Television {
@ -61,6 +65,18 @@ impl Television {
let keymap = Keymap::from(&config.keybindings); let keymap = Keymap::from(&config.keybindings);
let builtin_channels = load_builtin_channels(); let builtin_channels = load_builtin_channels();
let cable_channels = load_cable_channels().unwrap_or_default(); let cable_channels = load_cable_channels().unwrap_or_default();
let app_metadata = AppMetadata::new(
env!("CARGO_PKG_VERSION").to_string(),
BuildMetadata::new(
env!("VERGEN_RUSTC_SEMVER").to_string(),
env!("VERGEN_BUILD_DATE").to_string(),
env!("VERGEN_CARGO_TARGET_TRIPLE").to_string(),
),
std::env::current_dir()
.expect("Could not get current directory")
.to_string_lossy()
.to_string(),
);
channel.find(EMPTY_STRING); channel.find(EMPTY_STRING);
let spinner = Spinner::default(); let spinner = Spinner::default();
@ -87,9 +103,18 @@ impl Television {
)), )),
spinner, spinner,
spinner_state: SpinnerState::from(&spinner), spinner_state: SpinnerState::from(&spinner),
app_metadata,
} }
} }
pub fn init_remote_control(&mut self) {
let builtin_channels = load_builtin_channels();
let cable_channels = load_cable_channels().unwrap_or_default();
self.remote_control = TelevisionChannel::RemoteControl(
RemoteControl::new(builtin_channels, Some(cable_channels)),
);
}
pub fn current_channel(&self) -> UnitChannel { pub fn current_channel(&self) -> UnitChannel {
UnitChannel::from(&self.channel) UnitChannel::from(&self.channel)
} }
@ -174,7 +199,7 @@ impl Television {
match self.mode { match self.mode {
Mode::Channel => self.results_picker.reset_selection(), Mode::Channel => self.results_picker.reset_selection(),
Mode::RemoteControl | Mode::SendToChannel => { Mode::RemoteControl | Mode::SendToChannel => {
self.rc_picker.reset_selection() self.rc_picker.reset_selection();
} }
} }
} }
@ -183,7 +208,7 @@ impl Television {
match self.mode { match self.mode {
Mode::Channel => self.results_picker.reset_input(), Mode::Channel => self.results_picker.reset_input(),
Mode::RemoteControl | Mode::SendToChannel => { Mode::RemoteControl | Mode::SendToChannel => {
self.rc_picker.reset_input() self.rc_picker.reset_input();
} }
} }
} }
@ -209,11 +234,6 @@ impl Television {
} }
} }
// Styles
// input
pub(crate) const DEFAULT_INPUT_FG: Color = Color::LightRed;
pub(crate) const DEFAULT_RESULTS_COUNT_FG: Color = Color::LightRed;
impl Television { impl Television {
/// Register an action handler that can send actions for processing if necessary. /// Register an action handler that can send actions for processing if necessary.
/// ///
@ -253,7 +273,8 @@ impl Television {
&mut self.rc_picker.input &mut self.rc_picker.input
} }
}; };
input.handle_action(&action); input
.handle(convert_action_to_input_request(&action).unwrap());
match action { match action {
Action::AddInputChar(_) Action::AddInputChar(_)
| Action::DeletePrevChar | Action::DeletePrevChar
@ -292,10 +313,12 @@ impl Television {
Action::ToggleRemoteControl => match self.mode { Action::ToggleRemoteControl => match self.mode {
Mode::Channel => { Mode::Channel => {
self.mode = Mode::RemoteControl; self.mode = Mode::RemoteControl;
self.init_remote_control();
} }
Mode::RemoteControl => { Mode::RemoteControl => {
// this resets the RC picker // this resets the RC picker
self.reset_picker_input(); self.reset_picker_input();
self.init_remote_control();
self.remote_control.find(EMPTY_STRING); self.remote_control.find(EMPTY_STRING);
self.reset_picker_selection(); self.reset_picker_selection();
self.mode = Mode::Channel; self.mode = Mode::Channel;
@ -382,16 +405,54 @@ impl Television {
); );
// help bar (metadata, keymaps, logo) // help bar (metadata, keymaps, logo)
self.draw_help_bar(f, &layout.help_bar)?; draw_help_bar(
f,
&layout.help_bar,
self.current_channel(),
build_keybindings_table(
&self.config.keybindings.to_displayable(),
self.mode,
),
self.mode,
&self.app_metadata,
);
self.results_area_height = u32::from(layout.results.height - 2); // 2 for the borders self.results_area_height =
u32::from(layout.results.height.saturating_sub(2)); // 2 for the borders
self.preview_pane_height = layout.preview_window.height; self.preview_pane_height = layout.preview_window.height;
// results list // results list
self.draw_results_list(f, layout.results)?; let result_count = self.channel.result_count();
if result_count > 0 && self.results_picker.selected().is_none() {
self.results_picker.select(Some(0));
self.results_picker.relative_select(Some(0));
}
let entries = self.channel.results(
self.results_area_height,
u32::try_from(self.results_picker.offset())?,
);
draw_results_list(
f,
layout.results,
&entries,
&mut self.results_picker.relative_state,
self.config.ui.input_bar_position,
self.config.ui.use_nerd_font_icons,
&mut self.icon_color_cache,
)?;
// input box // input box
self.draw_input_box(f, layout.input)?; draw_input_box(
f,
layout.input,
result_count,
self.channel.total_count(),
&mut self.results_picker.input,
&mut self.results_picker.state,
self.channel.running(),
&self.spinner,
&mut self.spinner_state,
)?;
let selected_entry = self let selected_entry = self
.get_selected_entry(Some(Mode::Channel)) .get_selected_entry(Some(Mode::Channel))
@ -400,20 +461,199 @@ impl Television {
// preview title // preview title
self.current_preview_total_lines = preview.total_lines(); self.current_preview_total_lines = preview.total_lines();
self.draw_preview_title_block(f, layout.preview_title, &preview)?; draw_preview_title_block(
f,
layout.preview_title,
&preview,
self.config.ui.use_nerd_font_icons,
)?;
// preview content // preview content
self.draw_preview_content_block( // initialize preview scroll
self.maybe_init_preview_scroll(
selected_entry
.line_number
.map(|l| u16::try_from(l).unwrap_or(0)),
layout.preview_window.height,
);
draw_preview_content_block(
f, f,
layout.preview_window, layout.preview_window,
&selected_entry, &selected_entry,
&preview, &preview,
&self.rendered_preview_cache,
self.preview_scroll.unwrap_or(0),
); );
// remote control // remote control
if matches!(self.mode, Mode::RemoteControl | Mode::SendToChannel) { if matches!(self.mode, Mode::RemoteControl | Mode::SendToChannel) {
self.draw_remote_control(f, layout.remote_control.unwrap())?; // NOTE: this should be done in the `update` method
let result_count = self.remote_control.result_count();
if result_count > 0 && self.rc_picker.selected().is_none() {
self.rc_picker.select(Some(0));
self.rc_picker.relative_select(Some(0));
}
let entries = self.remote_control.results(
area.height.saturating_sub(2).into(),
u32::try_from(self.rc_picker.offset())?,
);
draw_remote_control(
f,
layout.remote_control.unwrap(),
&entries,
self.config.ui.use_nerd_font_icons,
&mut self.rc_picker.state,
&mut self.rc_picker.input,
&mut self.icon_color_cache,
)?;
} }
Ok(()) Ok(())
} }
pub fn maybe_init_preview_scroll(
&mut self,
target_line: Option<u16>,
height: u16,
) {
if self.preview_scroll.is_none() && !self.channel.running() {
self.preview_scroll =
Some(target_line.unwrap_or(0).saturating_sub(height / 3));
}
}
}
impl KeyBindings {
pub fn to_displayable(&self) -> HashMap<Mode, DisplayableKeybindings> {
// channel mode keybindings
let channel_bindings: HashMap<DisplayableAction, Vec<String>> =
HashMap::from_iter(vec![
(
DisplayableAction::ResultsNavigation,
serialized_keys_for_actions(
self,
&[
Action::SelectPrevEntry,
Action::SelectNextEntry,
Action::SelectPrevPage,
Action::SelectNextPage,
],
),
),
(
DisplayableAction::PreviewNavigation,
serialized_keys_for_actions(
self,
&[
Action::ScrollPreviewHalfPageUp,
Action::ScrollPreviewHalfPageDown,
],
),
),
(
DisplayableAction::SelectEntry,
serialized_keys_for_actions(self, &[Action::SelectEntry]),
),
(
DisplayableAction::CopyEntryToClipboard,
serialized_keys_for_actions(
self,
&[Action::CopyEntryToClipboard],
),
),
(
DisplayableAction::SendToChannel,
serialized_keys_for_actions(
self,
&[Action::ToggleSendToChannel],
),
),
(
DisplayableAction::ToggleRemoteControl,
serialized_keys_for_actions(
self,
&[Action::ToggleRemoteControl],
),
),
(
DisplayableAction::Quit,
serialized_keys_for_actions(self, &[Action::Quit]),
),
]);
// remote control mode keybindings
let remote_control_bindings: HashMap<DisplayableAction, Vec<String>> =
HashMap::from_iter(vec![
(
DisplayableAction::ResultsNavigation,
serialized_keys_for_actions(
self,
&[Action::SelectPrevEntry, Action::SelectNextEntry],
),
),
(
DisplayableAction::SelectEntry,
serialized_keys_for_actions(self, &[Action::SelectEntry]),
),
(
DisplayableAction::ToggleRemoteControl,
serialized_keys_for_actions(
self,
&[Action::ToggleRemoteControl],
),
),
]);
// send to channel mode keybindings
let send_to_channel_bindings: HashMap<DisplayableAction, Vec<String>> =
HashMap::from_iter(vec![
(
DisplayableAction::ResultsNavigation,
serialized_keys_for_actions(
self,
&[Action::SelectPrevEntry, Action::SelectNextEntry],
),
),
(
DisplayableAction::SelectEntry,
serialized_keys_for_actions(self, &[Action::SelectEntry]),
),
(
DisplayableAction::Cancel,
serialized_keys_for_actions(
self,
&[Action::ToggleSendToChannel],
),
),
]);
HashMap::from_iter(vec![
(Mode::Channel, DisplayableKeybindings::new(channel_bindings)),
(
Mode::RemoteControl,
DisplayableKeybindings::new(remote_control_bindings),
),
(
Mode::SendToChannel,
DisplayableKeybindings::new(send_to_channel_bindings),
),
])
}
}
fn serialized_keys_for_actions(
keybindings: &KeyBindings,
actions: &[Action],
) -> Vec<String> {
actions
.iter()
.map(|a| {
keybindings
.get(&Mode::Channel)
.unwrap()
.get(a)
.unwrap()
.clone()
.to_string()
})
.collect()
} }

View File

@ -1,16 +0,0 @@
use ratatui::style::Color;
pub mod cache;
pub(crate) mod help;
pub mod input;
pub mod keymap;
pub mod layout;
pub mod logo;
pub mod metadata;
mod mode;
pub mod preview;
mod remote_control;
pub mod results;
pub mod spinner;
pub const BORDER_COLOR: Color = Color::Blue;

View File

@ -1,68 +0,0 @@
use super::layout::HelpBarLayout;
use crate::television::Television;
use crate::ui::logo::build_logo_paragraph;
use crate::ui::mode::mode_color;
use crate::ui::BORDER_COLOR;
use ratatui::layout::Rect;
use ratatui::prelude::{Color, Style};
use ratatui::widgets::{Block, BorderType, Borders, Padding};
use ratatui::Frame;
pub fn draw_logo_block(f: &mut Frame, area: Rect, color: Color) {
let logo_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(BORDER_COLOR))
.style(Style::default().fg(color))
.padding(Padding::horizontal(1));
let logo_paragraph = build_logo_paragraph().block(logo_block);
f.render_widget(logo_paragraph, area);
}
impl Television {
pub(crate) fn draw_help_bar(
&self,
f: &mut Frame,
layout: &Option<HelpBarLayout>,
) -> color_eyre::Result<()> {
if let Some(help_bar) = layout {
self.draw_metadata_block(f, help_bar.left);
self.draw_keymaps_block(f, help_bar.middle)?;
draw_logo_block(f, help_bar.right, mode_color(self.mode));
}
Ok(())
}
fn draw_metadata_block(&self, f: &mut Frame, area: Rect) {
let metadata_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(BORDER_COLOR))
.padding(Padding::horizontal(1))
.style(Style::default());
let metadata_table = self.build_metadata_table().block(metadata_block);
f.render_widget(metadata_table, area);
}
fn draw_keymaps_block(
&self,
f: &mut Frame,
area: Rect,
) -> color_eyre::Result<()> {
let keymaps_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(BORDER_COLOR))
.style(Style::default())
.padding(Padding::horizontal(1));
let keymaps_table = self.build_keymap_table()?.block(keymaps_block);
f.render_widget(keymaps_table, area);
Ok(())
}
}

View File

@ -1,30 +0,0 @@
use crate::action::Action;
use crate::ui::input::{Input, InputRequest, StateChanged};
/// This makes the `Action` type compatible with the `Input` logic.
pub trait InputActionHandler {
// Handle Key event.
fn handle_action(&mut self, action: &Action) -> Option<StateChanged>;
}
impl InputActionHandler for Input {
/// Handle Key event.
fn handle_action(&mut self, action: &Action) -> Option<StateChanged> {
match action {
Action::AddInputChar(c) => {
self.handle(InputRequest::InsertChar(*c))
}
Action::DeletePrevChar => {
self.handle(InputRequest::DeletePrevChar)
}
Action::DeleteNextChar => {
self.handle(InputRequest::DeleteNextChar)
}
Action::GoToPrevChar => self.handle(InputRequest::GoToPrevChar),
Action::GoToNextChar => self.handle(InputRequest::GoToNextChar),
Action::GoToInputStart => self.handle(InputRequest::GoToStart),
Action::GoToInputEnd => self.handle(InputRequest::GoToEnd),
_ => None,
}
}
}

View File

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

View File

@ -1,297 +0,0 @@
use color_eyre::eyre::{OptionExt, Result};
use ratatui::{
layout::Constraint,
style::{Color, Style},
text::{Line, Span},
widgets::{Cell, Row, Table},
};
use std::collections::HashMap;
use crate::ui::mode::mode_color;
use crate::{
action::Action,
event::Key,
television::{Mode, Television},
};
const ACTION_COLOR: Color = Color::DarkGray;
impl Television {
pub fn build_keymap_table<'a>(&self) -> Result<Table<'a>> {
match self.mode {
Mode::Channel => self.build_keymap_table_for_channel(),
Mode::RemoteControl => {
self.build_keymap_table_for_channel_selection()
}
Mode::SendToChannel => {
self.build_keymap_table_for_channel_transitions()
}
}
}
fn build_keymap_table_for_channel<'a>(&self) -> Result<Table<'a>> {
let keymap = self.keymap_for_mode()?;
let key_color = mode_color(self.mode);
// Results navigation
let prev = keys_for_action(keymap, &Action::SelectPrevEntry);
let next = keys_for_action(keymap, &Action::SelectNextEntry);
let results_row = Row::new(build_cells_for_key_groups(
"Results navigation",
vec![prev, next],
key_color,
));
// Preview navigation
let up_keys =
keys_for_action(keymap, &Action::ScrollPreviewHalfPageUp);
let down_keys =
keys_for_action(keymap, &Action::ScrollPreviewHalfPageDown);
let preview_row = Row::new(build_cells_for_key_groups(
"Preview navigation",
vec![up_keys, down_keys],
key_color,
));
// Select entry
let select_entry_keys = keys_for_action(keymap, &Action::SelectEntry);
let select_entry_row = Row::new(build_cells_for_key_groups(
"Select entry",
vec![select_entry_keys],
key_color,
));
// Copy entry to clipboard
let copy_entry_keys =
keys_for_action(keymap, &Action::CopyEntryToClipboard);
let copy_entry_row = Row::new(build_cells_for_key_groups(
"Copy entry to clipboard",
vec![copy_entry_keys],
key_color,
));
// Send to channel
let send_to_channel_keys =
keys_for_action(keymap, &Action::ToggleSendToChannel);
let send_to_channel_row = Row::new(build_cells_for_key_groups(
"Send results to",
vec![send_to_channel_keys],
key_color,
));
// Switch channels
let switch_channels_keys =
keys_for_action(keymap, &Action::ToggleRemoteControl);
let switch_channels_row = Row::new(build_cells_for_key_groups(
"Toggle Remote control",
vec![switch_channels_keys],
key_color,
));
// MISC line (quit, help, etc.)
// Quit ⏼
let quit_keys = keys_for_action(keymap, &Action::Quit);
let quit_row = Row::new(build_cells_for_key_groups(
"Quit",
vec![quit_keys],
key_color,
));
let widths = vec![Constraint::Fill(1), Constraint::Fill(2)];
Ok(Table::new(
vec![
results_row,
preview_row,
select_entry_row,
copy_entry_row,
send_to_channel_row,
switch_channels_row,
quit_row,
],
widths,
))
}
fn build_keymap_table_for_channel_selection<'a>(
&self,
) -> Result<Table<'a>> {
let keymap = self.keymap_for_mode()?;
let key_color = mode_color(self.mode);
// Results navigation
let prev = keys_for_action(keymap, &Action::SelectPrevEntry);
let next = keys_for_action(keymap, &Action::SelectNextEntry);
let results_row = Row::new(build_cells_for_key_groups(
"Browse channels",
vec![prev, next],
key_color,
));
// Select entry
let select_entry_keys = keys_for_action(keymap, &Action::SelectEntry);
let select_entry_row = Row::new(build_cells_for_key_groups(
"Select channel",
vec![select_entry_keys],
key_color,
));
// Remote control
let switch_channels_keys =
keys_for_action(keymap, &Action::ToggleRemoteControl);
let switch_channels_row = Row::new(build_cells_for_key_groups(
"Toggle Remote control",
vec![switch_channels_keys],
key_color,
));
// Quit
let quit_keys = keys_for_action(keymap, &Action::Quit);
let quit_row = Row::new(build_cells_for_key_groups(
"Quit",
vec![quit_keys],
key_color,
));
Ok(Table::new(
vec![results_row, select_entry_row, switch_channels_row, quit_row],
vec![Constraint::Fill(1), Constraint::Fill(2)],
))
}
fn build_keymap_table_for_channel_transitions<'a>(
&self,
) -> Result<Table<'a>> {
let keymap = self.keymap_for_mode()?;
let key_color = mode_color(self.mode);
// Results navigation
let prev = keys_for_action(keymap, &Action::SelectPrevEntry);
let next = keys_for_action(keymap, &Action::SelectNextEntry);
let results_row = Row::new(build_cells_for_key_groups(
"Browse channels",
vec![prev, next],
key_color,
));
// Select entry
let select_entry_keys = keys_for_action(keymap, &Action::SelectEntry);
let select_entry_row = Row::new(build_cells_for_key_groups(
"Send to channel",
vec![select_entry_keys],
key_color,
));
// Cancel
let cancel_keys =
keys_for_action(keymap, &Action::ToggleSendToChannel);
let cancel_row = Row::new(build_cells_for_key_groups(
"Cancel",
vec![cancel_keys],
key_color,
));
// Quit
let quit_keys = keys_for_action(keymap, &Action::Quit);
let quit_row = Row::new(build_cells_for_key_groups(
"Quit",
vec![quit_keys],
key_color,
));
Ok(Table::new(
vec![results_row, select_entry_row, cancel_row, quit_row],
vec![Constraint::Fill(1), Constraint::Fill(2)],
))
}
/// Get the keymap for the current mode.
///
/// # Returns
/// A reference to the keymap for the current mode.
fn keymap_for_mode(&self) -> Result<&HashMap<Key, Action>> {
let keymap = self
.keymap
.get(&self.mode)
.ok_or_eyre("No keybindings found for the current Mode")?;
Ok(keymap)
}
}
/// Build the corresponding spans for a group of keys.
///
/// # Example
/// ```rust
/// use ratatui::text::Span;
/// use television::ui::help::build_spans_for_key_groups;
///
/// let key_groups = vec![
/// // alternate keys for the `SelectNextEntry` action
/// vec!["j".to_string(), "n".to_string()],
/// // alternate keys for the `SelectPrevEntry` action
/// vec!["k".to_string(), "p".to_string()],
/// ];
/// let spans = build_spans_for_key_groups("↕ Results", key_groups);
///
/// assert_eq!(spans.len(), 5);
/// ```
fn build_cells_for_key_groups(
group_name: &str,
key_groups: Vec<Vec<String>>,
key_color: Color,
) -> Vec<Cell> {
if key_groups.is_empty() || key_groups.iter().all(Vec::is_empty) {
return vec![group_name.into(), "No keybindings".into()];
}
let non_empty_groups = key_groups.iter().filter(|keys| !keys.is_empty());
let mut cells = vec![Cell::from(Span::styled(
group_name.to_owned() + ": ",
Style::default().fg(ACTION_COLOR),
))];
let mut spans = Vec::new();
let key_group_spans: Vec<Span> = non_empty_groups
.map(|keys| {
let key_group = keys.join(", ");
Span::styled(key_group, Style::default().fg(key_color))
})
.collect();
key_group_spans.iter().enumerate().for_each(|(i, span)| {
spans.push(span.clone());
if i < key_group_spans.len() - 1 {
spans.push(Span::styled(" / ", Style::default().fg(key_color)));
}
});
cells.push(Cell::from(Line::from(spans)));
cells
}
/// Get the keys for a given action.
///
/// # Example
/// ```rust
/// use std::collections::HashMap;
/// use television::action::Action;
/// use television::ui::help::keys_for_action;
///
/// let mut keymap = HashMap::new();
/// keymap.insert('j', Action::SelectNextEntry);
/// keymap.insert('k', Action::SelectPrevEntry);
///
/// let keys = keys_for_action(&keymap, Action::SelectNextEntry);
///
/// assert_eq!(keys, vec!["j"]);
/// ```
fn keys_for_action(
keymap: &HashMap<Key, Action>,
action: &Action,
) -> Vec<String> {
keymap
.iter()
.filter(|(_key, act)| *act == action)
.map(|(key, _act)| format!("{key}"))
.collect()
}

View File

@ -1,123 +0,0 @@
use std::fmt::Display;
use ratatui::{
layout::Constraint,
style::{Color, Style},
text::Span,
widgets::{Cell, Row, Table},
};
use crate::television::{Mode, Television};
use crate::ui::mode::mode_color;
const METADATA_FIELD_NAME_COLOR: Color = Color::DarkGray;
const METADATA_FIELD_VALUE_COLOR: Color = Color::Gray;
impl Display for Mode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Mode::Channel => write!(f, "Channel"),
Mode::RemoteControl => write!(f, "Remote Control"),
Mode::SendToChannel => write!(f, "Send to Channel"),
}
}
}
impl Television {
pub fn build_metadata_table<'a>(&self) -> Table<'a> {
let version_row = Row::new(vec![
Cell::from(Span::styled(
"version: ",
Style::default().fg(METADATA_FIELD_NAME_COLOR),
)),
Cell::from(Span::styled(
env!("CARGO_PKG_VERSION"),
Style::default().fg(METADATA_FIELD_VALUE_COLOR),
)),
]);
let target_triple_row = Row::new(vec![
Cell::from(Span::styled(
"target triple: ",
Style::default().fg(METADATA_FIELD_NAME_COLOR),
)),
Cell::from(Span::styled(
env!("VERGEN_CARGO_TARGET_TRIPLE"),
Style::default().fg(METADATA_FIELD_VALUE_COLOR),
)),
]);
let build_row = Row::new(vec![
Cell::from(Span::styled(
"build: ",
Style::default().fg(METADATA_FIELD_NAME_COLOR),
)),
Cell::from(Span::styled(
env!("VERGEN_RUSTC_SEMVER"),
Style::default().fg(METADATA_FIELD_VALUE_COLOR),
)),
Cell::from(Span::styled(
" (",
Style::default().fg(METADATA_FIELD_NAME_COLOR),
)),
Cell::from(Span::styled(
env!("VERGEN_BUILD_DATE"),
Style::default().fg(METADATA_FIELD_VALUE_COLOR),
)),
Cell::from(Span::styled(
")",
Style::default().fg(METADATA_FIELD_NAME_COLOR),
)),
]);
let current_dir_row = Row::new(vec![
Cell::from(Span::styled(
"current directory: ",
Style::default().fg(METADATA_FIELD_NAME_COLOR),
)),
Cell::from(Span::styled(
std::env::current_dir()
.expect("Could not get current directory")
.display()
.to_string(),
Style::default().fg(METADATA_FIELD_VALUE_COLOR),
)),
]);
let current_channel_row = Row::new(vec![
Cell::from(Span::styled(
"current channel: ",
Style::default().fg(METADATA_FIELD_NAME_COLOR),
)),
Cell::from(Span::styled(
self.current_channel().to_string(),
Style::default().fg(METADATA_FIELD_VALUE_COLOR),
)),
]);
let current_mode_row = Row::new(vec![
Cell::from(Span::styled(
"current mode: ",
Style::default().fg(METADATA_FIELD_NAME_COLOR),
)),
Cell::from(Span::styled(
self.mode.to_string(),
Style::default().fg(mode_color(self.mode)),
)),
]);
let widths = vec![Constraint::Fill(1), Constraint::Fill(2)];
Table::new(
vec![
version_row,
target_triple_row,
build_row,
current_dir_row,
current_channel_row,
current_mode_row,
],
widths,
)
}
}

View File

@ -1,14 +0,0 @@
use crate::television::Mode;
use ratatui::style::Color;
const CHANNEL_COLOR: Color = Color::Indexed(222);
const REMOTE_CONTROL_COLOR: Color = Color::Indexed(1);
const SEND_TO_CHANNEL_COLOR: Color = Color::Indexed(105);
pub fn mode_color(mode: Mode) -> Color {
match mode {
Mode::Channel => CHANNEL_COLOR,
Mode::RemoteControl => REMOTE_CONTROL_COLOR,
Mode::SendToChannel => SEND_TO_CHANNEL_COLOR,
}
}

View File

@ -1,457 +0,0 @@
use crate::television::Television;
use crate::ui::BORDER_COLOR;
use ansi_to_tui::IntoText;
use color_eyre::eyre::Result;
use ratatui::layout::{Alignment, Rect};
use ratatui::prelude::{Color, Line, Modifier, Span, Style, Stylize, Text};
use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap};
use ratatui::Frame;
use std::str::FromStr;
use std::sync::Arc;
use syntect::highlighting::Color as SyntectColor;
use television_channels::channels::OnAir;
use television_channels::entry::Entry;
use television_previewers::previewers::{
Preview, PreviewContent, FILE_TOO_LARGE_MSG, PREVIEW_NOT_SUPPORTED_MSG,
};
use television_utils::strings::{
replace_non_printable, shrink_with_ellipsis, ReplaceNonPrintableConfig,
EMPTY_STRING,
};
// preview
pub const DEFAULT_PREVIEW_TITLE_FG: Color = Color::Blue;
const DEFAULT_SELECTED_PREVIEW_BG: Color = Color::Rgb(50, 50, 50);
const DEFAULT_PREVIEW_CONTENT_FG: Color = Color::Rgb(150, 150, 180);
const DEFAULT_PREVIEW_GUTTER_FG: Color = Color::Rgb(70, 70, 70);
const DEFAULT_PREVIEW_GUTTER_SELECTED_FG: Color = Color::Rgb(255, 150, 150);
impl Television {
pub(crate) fn draw_preview_title_block(
&self,
f: &mut Frame,
rect: Rect,
preview: &Arc<Preview>,
) -> Result<()> {
let mut preview_title_spans = Vec::new();
if preview.icon.is_some() && self.config.ui.use_nerd_font_icons {
let icon = preview.icon.as_ref().unwrap();
preview_title_spans.push(Span::styled(
{
let mut icon_str = String::from(icon.icon);
icon_str.push(' ');
icon_str
},
Style::default().fg(Color::from_str(icon.color)?),
));
}
preview_title_spans.push(Span::styled(
shrink_with_ellipsis(
&replace_non_printable(
preview.title.as_bytes(),
&ReplaceNonPrintableConfig::default(),
)
.0,
rect.width.saturating_sub(4) as usize,
),
Style::default().fg(DEFAULT_PREVIEW_TITLE_FG).bold(),
));
let preview_title = Paragraph::new(Line::from(preview_title_spans))
.block(
Block::default()
.padding(Padding::horizontal(1))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(BORDER_COLOR)),
)
.alignment(Alignment::Left);
f.render_widget(preview_title, rect);
Ok(())
}
pub(crate) fn draw_preview_content_block(
&mut self,
f: &mut Frame,
rect: Rect,
entry: &Entry,
preview: &Arc<Preview>,
) {
let preview_outer_block = Block::default()
.title_top(Line::from(" Preview ").alignment(Alignment::Center))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(BORDER_COLOR))
.style(Style::default())
.padding(Padding::right(1));
let preview_inner_block =
Block::default().style(Style::default()).padding(Padding {
top: 0,
right: 1,
bottom: 0,
left: 1,
});
let inner = preview_outer_block.inner(rect);
f.render_widget(preview_outer_block, rect);
let target_line =
entry.line_number.map(|l| u16::try_from(l).unwrap_or(0));
let cache_key = compute_cache_key(entry);
self.maybe_init_preview_scroll(target_line, inner.height);
// Check if the rendered preview content is already in the cache
if let Some(preview_paragraph) =
self.rendered_preview_cache.lock().unwrap().get(&cache_key)
{
let p = preview_paragraph.as_ref().clone();
f.render_widget(
p.scroll((self.preview_scroll.unwrap_or(0), 0)),
inner,
);
return;
}
// If not, render the preview content and cache it if not empty
let rp = Self::build_preview_paragraph(
preview_inner_block,
inner,
preview.content.clone(),
target_line,
self.preview_scroll,
);
if !preview.stale {
self.rendered_preview_cache
.lock()
.unwrap()
.insert(cache_key, &Arc::new(rp.clone()));
}
f.render_widget(
Arc::new(rp)
.as_ref()
.clone()
.scroll((self.preview_scroll.unwrap_or(0), 0)),
inner,
);
}
#[allow(dead_code)]
const FILL_CHAR_SLANTED: char = '';
const FILL_CHAR_EMPTY: char = ' ';
// FIXME: I broke the previewer (srolling is not working as intended)
// and it looks like the previewer displays the wrong previews
pub fn build_preview_paragraph(
preview_block: Block,
inner: Rect,
preview_content: PreviewContent,
target_line: Option<u16>,
preview_scroll: Option<u16>,
) -> Paragraph {
match preview_content {
PreviewContent::AnsiText(text) => Self::build_ansi_text_paragraph(
text,
preview_block,
preview_scroll,
),
PreviewContent::PlainText(content) => {
Self::build_plain_text_paragraph(
content,
preview_block,
target_line,
preview_scroll,
)
}
PreviewContent::PlainTextWrapped(content) => {
Self::build_plain_text_wrapped_paragraph(
content,
preview_block,
)
}
PreviewContent::SyntectHighlightedText(highlighted_lines) => {
Self::build_syntect_highlighted_paragraph(
highlighted_lines,
preview_block,
target_line,
preview_scroll,
)
}
// meta
PreviewContent::Loading => Self::build_meta_preview_paragraph(
inner,
"Loading...",
Self::FILL_CHAR_EMPTY,
)
.block(preview_block)
.alignment(Alignment::Left)
.style(Style::default().add_modifier(Modifier::ITALIC)),
PreviewContent::NotSupported => {
Self::build_meta_preview_paragraph(
inner,
PREVIEW_NOT_SUPPORTED_MSG,
Self::FILL_CHAR_EMPTY,
)
.block(preview_block)
.alignment(Alignment::Left)
.style(Style::default().add_modifier(Modifier::ITALIC))
}
PreviewContent::FileTooLarge => {
Self::build_meta_preview_paragraph(
inner,
FILE_TOO_LARGE_MSG,
Self::FILL_CHAR_EMPTY,
)
.block(preview_block)
.alignment(Alignment::Left)
.style(Style::default().add_modifier(Modifier::ITALIC))
}
PreviewContent::Empty => Paragraph::new(Text::raw(EMPTY_STRING)),
}
}
fn build_ansi_text_paragraph(
text: String,
preview_block: Block,
preview_scroll: Option<u16>,
) -> Paragraph {
let text = replace_non_printable(
text.as_bytes(),
&ReplaceNonPrintableConfig {
replace_line_feed: false,
replace_control_characters: false,
..Default::default()
},
)
.0
.into_text()
.unwrap();
Paragraph::new(text)
.block(preview_block)
.scroll((preview_scroll.unwrap_or(0), 0))
}
fn build_plain_text_paragraph(
text: Vec<String>,
preview_block: Block,
target_line: Option<u16>,
preview_scroll: Option<u16>,
) -> Paragraph {
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
)
{
DEFAULT_PREVIEW_GUTTER_SELECTED_FG
} else {
DEFAULT_PREVIEW_GUTTER_FG
},
)),
Span::styled("",
Style::default().fg(DEFAULT_PREVIEW_GUTTER_FG).dim()),
Span::styled(
line.to_string(),
Style::default().fg(DEFAULT_PREVIEW_CONTENT_FG).bg(
if matches!(target_line, Some(l) if l == u16::try_from(i).unwrap() + 1) {
DEFAULT_SELECTED_PREVIEW_BG
} else {
Color::Reset
},
),
),
]));
}
let text = Text::from(lines);
Paragraph::new(text)
.block(preview_block)
.scroll((preview_scroll.unwrap_or(0), 0))
}
fn build_plain_text_wrapped_paragraph(
text: String,
preview_block: Block,
) -> Paragraph {
let mut lines = Vec::new();
for line in text.lines() {
lines.push(Line::styled(
line.to_string(),
Style::default().fg(DEFAULT_PREVIEW_CONTENT_FG),
));
}
let text = Text::from(lines);
Paragraph::new(text)
.block(preview_block)
.wrap(Wrap { trim: true })
}
fn build_syntect_highlighted_paragraph(
highlighted_lines: Vec<Vec<(syntect::highlighting::Style, String)>>,
preview_block: Block,
target_line: Option<u16>,
preview_scroll: Option<u16>,
) -> Paragraph {
compute_paragraph_from_highlighted_lines(
&highlighted_lines,
target_line.map(|l| l as usize),
)
.block(preview_block)
.alignment(Alignment::Left)
.scroll((preview_scroll.unwrap_or(0), 0))
}
pub fn maybe_init_preview_scroll(
&mut self,
target_line: Option<u16>,
height: u16,
) {
if self.preview_scroll.is_none() && !self.channel.running() {
self.preview_scroll =
Some(target_line.unwrap_or(0).saturating_sub(height / 3));
}
}
pub fn build_meta_preview_paragraph<'a>(
inner: Rect,
message: &str,
fill_char: char,
) -> Paragraph<'a> {
let message_len = message.len();
if message_len + 8 > inner.width as usize {
return Paragraph::new(Text::from(EMPTY_STRING));
}
let fill_char_str = fill_char.to_string();
let fill_line = fill_char_str.repeat(inner.width as usize);
// Build the paragraph content with slanted lines and center the custom message
let mut lines = Vec::new();
// Calculate the vertical center
let vertical_center = inner.height as usize / 2;
let horizontal_padding = (inner.width as usize - message_len) / 2 - 4;
// Fill the paragraph with slanted lines and insert the centered custom message
for i in 0..inner.height {
if i as usize == vertical_center {
// Center the message horizontally in the middle line
let line = format!(
"{} {} {}",
fill_char_str.repeat(horizontal_padding),
message,
fill_char_str.repeat(
inner.width as usize
- horizontal_padding
- message_len
)
);
lines.push(Line::from(line));
} else if i as usize + 1 == vertical_center
|| (i as usize).saturating_sub(1) == vertical_center
{
let line = format!(
"{} {} {}",
fill_char_str.repeat(horizontal_padding),
" ".repeat(message_len),
fill_char_str.repeat(
inner.width as usize
- horizontal_padding
- message_len
)
);
lines.push(Line::from(line));
} else {
lines.push(Line::from(fill_line.clone()));
}
}
// Create a paragraph with the generated content
Paragraph::new(Text::from(lines))
}
}
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>,
) -> Paragraph<'static> {
let preview_lines: Vec<Line> = highlighted_lines
.iter()
.enumerate()
.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)
{
DEFAULT_PREVIEW_GUTTER_SELECTED_FG
} else {
DEFAULT_PREVIEW_GUTTER_FG
},
));
Line::from_iter(
std::iter::once(line_number)
.chain(std::iter::once(Span::styled(
"",
Style::default().fg(DEFAULT_PREVIEW_GUTTER_FG).dim(),
)))
.chain(l.iter().cloned().map(|sr| {
convert_syn_region_to_span(
&(sr.0, sr.1),
if line_specifier.is_some()
&& i == line_specifier
.unwrap()
.saturating_sub(1)
{
Some(SyntectColor {
r: 50,
g: 50,
b: 50,
a: 255,
})
} else {
None
},
)
})),
)
})
.collect();
Paragraph::new(preview_lines)
}
pub fn convert_syn_region_to_span<'a>(
syn_region: &(syntect::highlighting::Style, String),
background: Option<syntect::highlighting::Color>,
) -> Span<'a> {
let mut style = Style::default()
.fg(convert_syn_color_to_ratatui_color(syn_region.0.foreground));
if let Some(background) = background {
style = style.bg(convert_syn_color_to_ratatui_color(background));
}
style = match syn_region.0.font_style {
syntect::highlighting::FontStyle::BOLD => style.bold(),
syntect::highlighting::FontStyle::ITALIC => style.italic(),
syntect::highlighting::FontStyle::UNDERLINE => style.underlined(),
_ => style,
};
Span::styled(syn_region.1.clone(), style)
}
fn convert_syn_color_to_ratatui_color(
color: syntect::highlighting::Color,
) -> Color {
Color::Rgb(color.r, color.g, color.b)
}
fn compute_cache_key(entry: &Entry) -> String {
let mut cache_key = entry.name.clone();
if let Some(line_number) = entry.line_number {
cache_key.push_str(&line_number.to_string());
}
cache_key
}

View File

@ -1,153 +0,0 @@
use crate::television::Television;
use crate::ui::logo::build_remote_logo_paragraph;
use crate::ui::mode::mode_color;
use crate::ui::results::{build_results_list, ResultsListColors};
use crate::ui::BORDER_COLOR;
use color_eyre::eyre::Result;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::prelude::Style;
use ratatui::style::{Color, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, BorderType, Borders, ListDirection, Padding, Paragraph,
};
use ratatui::Frame;
use television_channels::channels::OnAir;
impl Television {
pub fn draw_remote_control(
&mut self,
f: &mut Frame,
rect: Rect,
) -> Result<()> {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(20),
]
.as_ref(),
)
.split(rect);
self.draw_rc_channels(f, &layout[0])?;
self.draw_rc_input(f, &layout[1])?;
draw_rc_logo(f, layout[2], mode_color(self.mode));
Ok(())
}
fn draw_rc_channels(&mut self, f: &mut Frame, area: &Rect) -> Result<()> {
let rc_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(BORDER_COLOR))
.style(Style::default())
.padding(Padding::right(1));
let result_count = self.remote_control.result_count();
if result_count > 0 && self.rc_picker.selected().is_none() {
self.rc_picker.select(Some(0));
self.rc_picker.relative_select(Some(0));
}
let entries = self.remote_control.results(
area.height.saturating_sub(2).into(),
u32::try_from(self.rc_picker.offset())?,
);
let channel_list = build_results_list(
rc_block,
&entries,
ListDirection::TopToBottom,
Some(
ResultsListColors::default()
.result_name_fg(mode_color(self.mode)),
),
self.config.ui.use_nerd_font_icons,
&mut self.icon_color_cache,
);
f.render_stateful_widget(
channel_list,
*area,
&mut self.rc_picker.state,
);
Ok(())
}
fn draw_rc_input(&mut self, f: &mut Frame, area: &Rect) -> Result<()> {
let input_block = Block::default()
.title_top(
Line::from("Remote Control").alignment(Alignment::Center),
)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(BORDER_COLOR))
.style(Style::default());
let input_block_inner = input_block.inner(*area);
f.render_widget(input_block, *area);
// split input block into 2 parts: prompt symbol, input
let inner_input_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
// prompt symbol
Constraint::Length(2),
// input field
Constraint::Fill(1),
])
.split(input_block_inner);
let prompt_symbol_block = Block::default();
let arrow = Paragraph::new(Span::styled(
"> ",
Style::default()
.fg(crate::television::DEFAULT_INPUT_FG)
.bold(),
))
.block(prompt_symbol_block);
f.render_widget(arrow, inner_input_chunks[0]);
let interactive_input_block = Block::default();
// keep 2 for borders and 1 for cursor
let width = inner_input_chunks[1].width.max(3) - 3;
let scroll = self.rc_picker.input.visual_scroll(width as usize);
let input = Paragraph::new(self.rc_picker.input.value())
.scroll((0, u16::try_from(scroll)?))
.block(interactive_input_block)
.style(
Style::default()
.fg(crate::television::DEFAULT_INPUT_FG)
.bold()
.italic(),
)
.alignment(Alignment::Left);
f.render_widget(input, inner_input_chunks[1]);
// Make the cursor visible and ask tui-rs to put it at the
// specified coordinates after rendering
f.set_cursor_position((
// Put cursor past the end of the input text
inner_input_chunks[1].x
+ u16::try_from(
self.rc_picker.input.visual_cursor().max(scroll) - scroll,
)?,
// Move one line down, from the border to the input line
inner_input_chunks[1].y,
));
Ok(())
}
}
fn draw_rc_logo(f: &mut Frame, area: Rect, color: Color) {
let logo_block = Block::default().style(Style::default().fg(color));
let logo_paragraph = build_remote_logo_paragraph()
.alignment(Alignment::Center)
.block(logo_block);
f.render_widget(logo_paragraph, area);
}