refactor(ui): communicate ui state to tv using channels

This commit is contained in:
Alexandre Pasmantier 2025-03-04 22:17:44 +01:00
parent e2a0fb2047
commit d8703ce0e6
21 changed files with 591 additions and 532 deletions

1
Cargo.lock generated
View File

@ -1696,7 +1696,6 @@ dependencies = [
"gag",
"human-panic",
"ignore",
"lazy_static",
"nom",
"nucleo",
"parking_lot",

View File

@ -34,7 +34,6 @@ anyhow = "1.0"
base64 = "0.22.1"
directories = "6.0"
devicons = "0.6"
lazy_static = "1.5"
tokio = { version = "1.43", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@ -8,6 +8,7 @@ use crate::channels::entry::Entry;
use crate::channels::TelevisionChannel;
use crate::config::{parse_key, Config};
use crate::keymap::Keymap;
use crate::render::UiState;
use crate::television::{Mode, Television};
use crate::{
action::Action,
@ -37,6 +38,9 @@ pub struct App {
event_abort_tx: mpsc::UnboundedSender<()>,
/// A sender channel for rendering tasks.
render_tx: mpsc::UnboundedSender<RenderingTask>,
/// A channel that listens to UI updates.
ui_state_rx: mpsc::UnboundedReceiver<UiState>,
ui_state_tx: mpsc::UnboundedSender<UiState>,
}
/// The outcome of an action.
@ -104,6 +108,7 @@ impl App {
.collect(),
)?;
debug!("{:?}", keymap);
let (ui_state_tx, ui_state_rx) = mpsc::unbounded_channel();
let television =
Television::new(action_tx.clone(), channel, config, input);
@ -118,6 +123,8 @@ impl App {
event_rx,
event_abort_tx,
render_tx,
ui_state_rx,
ui_state_tx,
})
}
@ -145,9 +152,10 @@ impl App {
debug!("Starting rendering loop");
let (render_tx, render_rx) = mpsc::unbounded_channel();
self.render_tx = render_tx.clone();
let ui_state_tx = self.ui_state_tx.clone();
let action_tx_r = self.action_tx.clone();
let rendering_task = tokio::spawn(async move {
render(render_rx, action_tx_r, is_output_tty).await
render(render_rx, action_tx_r, ui_state_tx, is_output_tty).await
});
self.action_tx.send(Action::Render)?;
@ -298,9 +306,14 @@ impl App {
self.render_tx.send(RenderingTask::Resize(w, h))?;
}
Action::Render => {
// forward to the rendering task
self.render_tx.send(RenderingTask::Render(
self.television.dump_context(),
Box::new(self.television.dump_context()),
))?;
// update the television UI state with the previous frame
if let Ok(ui_state) = self.ui_state_rx.try_recv() {
self.television.update_ui_state(ui_state);
}
}
_ => {}
}

View File

@ -9,7 +9,6 @@ use std::io::{BufRead, BufReader};
use std::process::Stdio;
use anyhow::Result;
use lazy_static::lazy_static;
use regex::Regex;
use rustc_hash::{FxBuildHasher, FxHashSet};
use tracing::debug;
@ -64,13 +63,10 @@ impl From<CableChannelPrototype> for Channel {
}
}
lazy_static! {
static ref BUILTIN_PREVIEW_RE: Regex = Regex::new(r"^:(\w+):$").unwrap();
}
fn parse_preview_kind(command: &PreviewCommand) -> Result<PreviewKind> {
debug!("Parsing preview kind for command: {:?}", command);
if let Some(captures) = BUILTIN_PREVIEW_RE.captures(&command.command) {
let re = Regex::new(r"^\:(\w+)\:$").unwrap();
if let Some(captures) = re.captures(&command.command) {
let preview_type = PreviewType::try_from(&captures[1])?;
Ok(PreviewKind::Builtin(preview_type))
} else {

View File

@ -1,7 +1,7 @@
use crate::channels::entry::{Entry, PreviewCommand, PreviewType};
use crate::channels::{OnAir, TelevisionChannel};
use crate::matcher::{config::Config, injector::Injector, Matcher};
use crate::utils::files::{walk_builder, DEFAULT_NUM_THREADS};
use crate::utils::files::{get_default_num_threads, walk_builder};
use devicons::FileIcon;
use rustc_hash::{FxBuildHasher, FxHashSet};
use std::collections::HashSet;
@ -151,7 +151,7 @@ async fn load_dirs(paths: Vec<PathBuf>, injector: Injector<String>) {
}
let current_dir = std::env::current_dir().unwrap();
let mut builder =
walk_builder(&paths[0], *DEFAULT_NUM_THREADS, None, None);
walk_builder(&paths[0], get_default_num_threads(), None, None);
paths[1..].iter().for_each(|path| {
builder.add(path);
});

View File

@ -1,7 +1,7 @@
use crate::channels::entry::{Entry, PreviewType};
use crate::channels::{OnAir, TelevisionChannel};
use crate::matcher::{config::Config, injector::Injector, Matcher};
use crate::utils::files::{walk_builder, DEFAULT_NUM_THREADS};
use crate::utils::files::{get_default_num_threads, walk_builder};
use devicons::FileIcon;
use rustc_hash::{FxBuildHasher, FxHashSet};
use std::collections::HashSet;
@ -157,7 +157,7 @@ async fn load_files(paths: Vec<PathBuf>, injector: Injector<String>) {
}
let current_dir = std::env::current_dir().unwrap();
let mut builder =
walk_builder(&paths[0], *DEFAULT_NUM_THREADS, None, None);
walk_builder(&paths[0], get_default_num_threads(), None, None);
paths[1..].iter().for_each(|path| {
builder.add(path);
});

View File

@ -1,7 +1,6 @@
use devicons::FileIcon;
use directories::BaseDirs;
use ignore::overrides::OverrideBuilder;
use lazy_static::lazy_static;
use rustc_hash::{FxBuildHasher, FxHashSet};
use std::collections::HashSet;
use std::path::PathBuf;
@ -11,13 +10,14 @@ use tracing::debug;
use crate::channels::entry::{Entry, PreviewCommand, PreviewType};
use crate::channels::OnAir;
use crate::matcher::{config::Config, injector::Injector, Matcher};
use crate::utils::files::{walk_builder, DEFAULT_NUM_THREADS};
use crate::utils::files::{get_default_num_threads, walk_builder};
pub struct Channel {
matcher: Matcher<String>,
icon: FileIcon,
crawl_handle: JoinHandle<()>,
selected_entries: FxHashSet<Entry>,
preview_command: PreviewCommand,
}
impl Channel {
@ -28,11 +28,20 @@ impl Channel {
base_dirs.home_dir().to_path_buf(),
matcher.injector(),
));
let preview_command = PreviewCommand {
command: String::from(
"cd {} && git log -n 200 --pretty=medium --all --graph --color",
),
delimiter: ":".to_string(),
};
Channel {
matcher,
icon: FileIcon::from("git"),
crawl_handle,
selected_entries: HashSet::with_hasher(FxBuildHasher),
preview_command,
}
}
}
@ -43,15 +52,6 @@ impl Default for Channel {
}
}
lazy_static! {
static ref PREVIEW_COMMAND: PreviewCommand = PreviewCommand {
command: String::from(
"cd {} && git log -n 200 --pretty=medium --all --graph --color",
),
delimiter: ":".to_string(),
};
}
impl OnAir for Channel {
fn find(&mut self, pattern: &str) {
self.matcher.find(pattern);
@ -64,9 +64,12 @@ impl OnAir for Channel {
.into_iter()
.map(|item| {
let path = item.matched_string;
Entry::new(path, PreviewType::Command(PREVIEW_COMMAND.clone()))
.with_name_match_ranges(&item.match_indices)
.with_icon(self.icon)
Entry::new(
path,
PreviewType::Command(self.preview_command.clone()),
)
.with_name_match_ranges(&item.match_indices)
.with_icon(self.icon)
})
.collect()
}
@ -74,8 +77,11 @@ impl OnAir for Channel {
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
let path = item.matched_string;
Entry::new(path, PreviewType::Command(PREVIEW_COMMAND.clone()))
.with_icon(self.icon)
Entry::new(
path,
PreviewType::Command(self.preview_command.clone()),
)
.with_icon(self.icon)
})
}
@ -162,7 +168,7 @@ async fn crawl_for_repos(starting_point: PathBuf, injector: Injector<String>) {
walker_overrides_builder.add(".git").unwrap();
let walker = walk_builder(
&starting_point,
*DEFAULT_NUM_THREADS,
get_default_num_threads(),
Some(walker_overrides_builder.build().unwrap()),
Some(get_ignored_paths()),
)

View File

@ -1,7 +1,7 @@
use super::{OnAir, TelevisionChannel};
use crate::channels::entry::{Entry, PreviewType};
use crate::matcher::{config::Config, injector::Injector, Matcher};
use crate::utils::files::{walk_builder, DEFAULT_NUM_THREADS};
use crate::utils::files::{get_default_num_threads, walk_builder};
use crate::utils::strings::{
proportion_of_printable_ascii_characters, PRINTABLE_ASCII_THRESHOLD,
};
@ -277,7 +277,7 @@ async fn crawl_for_candidates(
}
let current_dir = std::env::current_dir().unwrap();
let mut walker =
walk_builder(&directories[0], *DEFAULT_NUM_THREADS, None, None);
walk_builder(&directories[0], get_default_num_threads(), None, None);
directories[1..].iter().for_each(|path| {
walker.add(path);
});

View File

@ -9,7 +9,6 @@ use anyhow::{Context, Result};
use directories::ProjectDirs;
use keybindings::merge_keybindings;
pub use keybindings::{parse_key, Binding, KeyBindings};
use lazy_static::lazy_static;
use previewers::PreviewersConfig;
use serde::Deserialize;
use shell_integration::ShellIntegrationConfig;
@ -70,23 +69,7 @@ pub struct Config {
pub shell_integration: ShellIntegrationConfig,
}
lazy_static! {
pub static ref PROJECT_NAME: String = String::from("television");
pub static ref PROJECT_NAME_UPPER: String = PROJECT_NAME.to_uppercase();
pub static ref DATA_FOLDER: Option<PathBuf> =
// if `TELEVISION_DATA` is set, use that as the data directory
env::var_os(format!("{}_DATA", PROJECT_NAME_UPPER.clone())).map(PathBuf::from).or_else(|| {
// otherwise, use the XDG data directory
env::var_os("XDG_DATA_HOME").map(PathBuf::from).map(|p| p.join(PROJECT_NAME.as_str())).filter(|p| p.is_absolute())
});
pub static ref CONFIG_FOLDER: Option<PathBuf> =
// if `TELEVISION_CONFIG` is set, use that as the television config directory
env::var_os(format!("{}_CONFIG", PROJECT_NAME_UPPER.clone())).map(PathBuf::from).or_else(|| {
// otherwise, use the XDG config directory + 'television'
env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).map(|p| p.join(PROJECT_NAME.as_str())).filter(|p| p.is_absolute())
});
}
const PROJECT_NAME: &str = "television";
const CONFIG_FILE_NAME: &str = "config.toml";
pub struct ConfigEnv {
@ -184,7 +167,19 @@ impl Config {
}
pub fn get_data_dir() -> PathBuf {
let directory = if let Some(s) = DATA_FOLDER.clone() {
// if `TELEVISION_DATA` is set, use that as the data directory
let data_folder =
env::var_os(format!("{}_DATA", PROJECT_NAME.to_uppercase()))
.map(PathBuf::from)
.or_else(|| {
// otherwise, use the XDG data directory
env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.map(|p| p.join(PROJECT_NAME))
.filter(|p| p.is_absolute())
});
let directory = if let Some(s) = data_folder {
debug!("Using data directory: {:?}", s);
s
} else if let Some(proj_dirs) = project_directory() {
@ -197,7 +192,18 @@ pub fn get_data_dir() -> PathBuf {
}
pub fn get_config_dir() -> PathBuf {
let directory = if let Some(s) = CONFIG_FOLDER.clone() {
// if `TELEVISION_CONFIG` is set, use that as the television config directory
let config_dir =
env::var_os(format!("{}_CONFIG", PROJECT_NAME.to_uppercase()))
.map(PathBuf::from)
.or_else(|| {
// otherwise, use the XDG config directory + 'television'
env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.map(|p| p.join(PROJECT_NAME))
.filter(|p| p.is_absolute())
});
let directory = if let Some(s) = config_dir {
debug!("Config directory: {:?}", s);
s
} else if cfg!(unix) {

View File

@ -128,10 +128,10 @@ impl Theme {
pub fn from_builtin(
name: &str,
) -> Result<Self, Box<dyn std::error::Error>> {
let theme_content: &str = builtin::BUILTIN_THEMES.get(name).map_or(
builtin::BUILTIN_THEMES.get(DEFAULT_THEME).unwrap(),
|t| *t,
);
let builtin_themes = builtin::builtin_themes();
let theme_content: &str = builtin_themes
.get(name)
.map_or(builtin_themes.get(DEFAULT_THEME).unwrap(), |t| *t);
let theme = toml::from_str(theme_content)?;
Ok(theme)
}

View File

@ -1,43 +1,39 @@
use rustc_hash::FxHashMap;
use lazy_static::lazy_static;
lazy_static! {
pub static ref BUILTIN_THEMES: FxHashMap<&'static str, &'static str> = {
let mut m = FxHashMap::default();
m.insert("default", include_str!("../../../themes/default.toml"));
m.insert(
"television",
include_str!("../../../themes/television.toml"),
);
m.insert(
"gruvbox-dark",
include_str!("../../../themes/gruvbox-dark.toml"),
);
m.insert(
"gruvbox-light",
include_str!("../../../themes/gruvbox-light.toml"),
);
m.insert(
"catppuccin",
include_str!("../../../themes/catppuccin.toml"),
);
m.insert("nord-dark", include_str!("../../../themes/nord-dark.toml"));
m.insert(
"solarized-dark",
include_str!("../../../themes/solarized-dark.toml"),
);
m.insert(
"solarized-light",
include_str!("../../../themes/solarized-light.toml"),
);
m.insert("dracula", include_str!("../../../themes/dracula.toml"));
m.insert("monokai", include_str!("../../../themes/monokai.toml"));
m.insert("onedark", include_str!("../../../themes/onedark.toml"));
m.insert(
"tokyonight",
include_str!("../../../themes/tokyonight.toml"),
);
m
};
pub fn builtin_themes() -> FxHashMap<&'static str, &'static str> {
let mut m = FxHashMap::default();
m.insert("default", include_str!("../../../themes/default.toml"));
m.insert(
"television",
include_str!("../../../themes/television.toml"),
);
m.insert(
"gruvbox-dark",
include_str!("../../../themes/gruvbox-dark.toml"),
);
m.insert(
"gruvbox-light",
include_str!("../../../themes/gruvbox-light.toml"),
);
m.insert(
"catppuccin",
include_str!("../../../themes/catppuccin.toml"),
);
m.insert("nord-dark", include_str!("../../../themes/nord-dark.toml"));
m.insert(
"solarized-dark",
include_str!("../../../themes/solarized-dark.toml"),
);
m.insert(
"solarized-light",
include_str!("../../../themes/solarized-light.toml"),
);
m.insert("dracula", include_str!("../../../themes/dracula.toml"));
m.insert("monokai", include_str!("../../../themes/monokai.toml"));
m.insert("onedark", include_str!("../../../themes/onedark.toml"));
m.insert(
"tokyonight",
include_str!("../../../themes/tokyonight.toml"),
);
m
}

View File

@ -3,7 +3,6 @@ use std::{hash::Hash, time::Instant};
use anyhow::Result;
use ratatui::{layout::Rect, Frame};
use rustc_hash::FxHashSet;
use tokio::sync::mpsc::Sender;
use crate::{
action::Action,
@ -18,7 +17,7 @@ use crate::{
remote_control::draw_remote_control, results::draw_results_list,
spinner::Spinner,
},
television::{Message, Mode},
television::Mode,
utils::metadata::AppMetadata,
};
@ -61,7 +60,6 @@ impl Hash for ChannelState {
pub struct TvState {
pub mode: Mode,
pub selected_entry: Option<Entry>,
pub results_area_height: u16,
pub results_picker: Picker,
pub rc_picker: Picker,
pub channel_state: ChannelState,
@ -74,7 +72,6 @@ impl TvState {
pub fn new(
mode: Mode,
selected_entry: Option<Entry>,
results_area_height: u16,
results_picker: Picker,
rc_picker: Picker,
channel_state: ChannelState,
@ -84,7 +81,6 @@ impl TvState {
Self {
mode,
selected_entry,
results_area_height,
results_picker,
rc_picker,
channel_state,
@ -100,8 +96,8 @@ pub struct Ctx {
pub config: Config,
pub colorscheme: Colorscheme,
pub app_metadata: AppMetadata,
pub tv_tx_handle: Sender<Message>,
pub instant: Instant,
pub layout: Layout,
}
impl Ctx {
@ -110,16 +106,16 @@ impl Ctx {
config: Config,
colorscheme: Colorscheme,
app_metadata: AppMetadata,
tv_tx_handle: Sender<Message>,
instant: Instant,
layout: Layout,
) -> Self {
Self {
tv_state,
config,
colorscheme,
app_metadata,
tv_tx_handle,
instant,
layout,
}
}
}
@ -156,7 +152,7 @@ impl Ord for Ctx {
}
}
pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<()> {
pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<Layout> {
let selected_entry = ctx
.tv_state
.selected_entry
@ -185,14 +181,6 @@ pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<()> {
&ctx.colorscheme,
);
if layout.results.height.saturating_sub(2)
!= ctx.tv_state.results_area_height
{
ctx.tv_tx_handle.try_send(Message::ResultListHeightChanged(
layout.results.height.saturating_sub(2),
))?;
}
// results list
draw_results_list(
f,
@ -259,5 +247,5 @@ pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<()> {
)?;
}
Ok(())
Ok(layout)
}

View File

@ -3,14 +3,10 @@ use tracing_subscriber::{fmt, prelude::*, EnvFilter};
use crate::config::get_data_dir;
lazy_static::lazy_static! {
pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME"));
}
pub fn init() -> Result<()> {
let directory = get_data_dir();
std::fs::create_dir_all(directory.clone())?;
let log_path = directory.join(LOG_FILE.clone());
let log_path = directory.join(format!("{}.log", env!("CARGO_PKG_NAME")));
let log_file = std::fs::File::create(log_path)?;
let file_subscriber = fmt::layer()
.with_file(true)

View File

@ -2,7 +2,6 @@ use crate::channels::entry::{Entry, PreviewCommand};
use crate::preview::cache::PreviewCache;
use crate::preview::{Preview, PreviewContent};
use crate::utils::command::shell_command;
use lazy_static::lazy_static;
use parking_lot::Mutex;
use regex::Regex;
use rustc_hash::FxHashSet;
@ -11,12 +10,19 @@ use std::sync::Arc;
use tracing::debug;
#[allow(dead_code)]
#[derive(Debug, Default)]
#[derive(Debug)]
pub struct CommandPreviewer {
cache: Arc<Mutex<PreviewCache>>,
config: CommandPreviewerConfig,
concurrent_preview_tasks: Arc<AtomicU8>,
in_flight_previews: Arc<Mutex<FxHashSet<String>>>,
command_re: Regex,
}
impl Default for CommandPreviewer {
fn default() -> Self {
CommandPreviewer::new(None)
}
}
#[allow(dead_code)]
@ -53,6 +59,7 @@ impl CommandPreviewer {
config,
concurrent_preview_tasks: Arc::new(AtomicU8::new(0)),
in_flight_previews: Arc::new(Mutex::new(FxHashSet::default())),
command_re: Regex::new(r"\{(\d+)\}").unwrap(),
}
}
@ -96,6 +103,7 @@ impl CommandPreviewer {
let concurrent_tasks = self.concurrent_preview_tasks.clone();
let command = command.clone();
let in_flight_previews = self.in_flight_previews.clone();
let command_re = self.command_re.clone();
tokio::spawn(async move {
try_preview(
&command,
@ -103,6 +111,7 @@ impl CommandPreviewer {
&cache,
&concurrent_tasks,
&in_flight_previews,
&command_re,
);
});
} else {
@ -114,11 +123,6 @@ impl CommandPreviewer {
}
}
lazy_static! {
static ref COMMAND_PLACEHOLDER_REGEX: Regex =
Regex::new(r"\{(\d+)\}").unwrap();
}
/// Format the command with the entry name and provided placeholders
///
/// # Example
@ -131,11 +135,15 @@ lazy_static! {
/// delimiter: ":".to_string(),
/// };
/// let entry = Entry::new("a:given:entry:to:preview".to_string(), PreviewType::Command(command.clone()));
/// let formatted_command = format_command(&command, &entry);
/// let formatted_command = format_command(&command, &entry, &regex::Regex::new(r"\{(\d+)\}").unwrap());
///
/// assert_eq!(formatted_command, "something 'a:given:entry:to:preview' 'entry' 'a'");
/// ```
pub fn format_command(command: &PreviewCommand, entry: &Entry) -> String {
pub fn format_command(
command: &PreviewCommand,
entry: &Entry,
command_re: &Regex,
) -> String {
let parts = entry.name.split(&command.delimiter).collect::<Vec<&str>>();
debug!("Parts: {:?}", parts);
@ -143,7 +151,7 @@ pub fn format_command(command: &PreviewCommand, entry: &Entry) -> String {
.command
.replace("{}", format!("'{}'", entry.name).as_str());
formatted_command = COMMAND_PLACEHOLDER_REGEX
formatted_command = command_re
.replace_all(&formatted_command, |caps: &regex::Captures| {
let index =
caps.get(1).unwrap().as_str().parse::<usize>().unwrap();
@ -160,9 +168,10 @@ pub fn try_preview(
cache: &Arc<Mutex<PreviewCache>>,
concurrent_tasks: &Arc<AtomicU8>,
in_flight_previews: &Arc<Mutex<FxHashSet<String>>>,
command_re: &Regex,
) {
debug!("Computing preview for {:?}", entry.name);
let command = format_command(command, entry);
let command = format_command(command, entry, command_re);
debug!("Formatted preview command: {:?}", command);
let child = shell_command()
@ -212,7 +221,11 @@ mod tests {
"an:entry:to:preview".to_string(),
PreviewType::Command(command.clone()),
);
let formatted_command = format_command(&command, &entry);
let formatted_command = format_command(
&command,
&entry,
&Regex::new(r"\{(\d+)\}").unwrap(),
);
assert_eq!(
formatted_command,
@ -230,7 +243,11 @@ mod tests {
"an:entry:to:preview".to_string(),
PreviewType::Command(command.clone()),
);
let formatted_command = format_command(&command, &entry);
let formatted_command = format_command(
&command,
&entry,
&Regex::new(r"\{(\d+)\}").unwrap(),
);
assert_eq!(formatted_command, "something");
}
@ -245,7 +262,11 @@ mod tests {
"an:entry:to:preview".to_string(),
PreviewType::Command(command.clone()),
);
let formatted_command = format_command(&command, &entry);
let formatted_command = format_command(
&command,
&entry,
&Regex::new(r"\{(\d+)\}").unwrap(),
);
assert_eq!(formatted_command, "something 'an:entry:to:preview'");
}
@ -260,7 +281,11 @@ mod tests {
"an:entry:to:preview".to_string(),
PreviewType::Command(command.clone()),
);
let formatted_command = format_command(&command, &entry);
let formatted_command = format_command(
&command,
&entry,
&Regex::new(r"\{(\d+)\}").unwrap(),
);
assert_eq!(formatted_command, "something 'an' -t 'to'");
}

View File

@ -8,12 +8,13 @@ use tracing::{debug, warn};
use tokio::sync::mpsc;
use crate::draw::Ctx;
use crate::screen::layout::Layout;
use crate::{action::Action, draw::draw, tui::Tui};
#[derive(Debug, PartialEq, PartialOrd, Ord, Eq, Clone)]
pub enum RenderingTask {
ClearScreen,
Render(Ctx),
Render(Box<Ctx>),
Resize(u16, u16),
Resume,
Suspend,
@ -35,9 +36,21 @@ impl IoStream {
}
}
#[derive(Default)]
pub struct UiState {
pub layout: Layout,
}
impl UiState {
pub fn new(layout: Layout) -> Self {
Self { layout }
}
}
pub async fn render(
mut render_rx: mpsc::UnboundedReceiver<RenderingTask>,
action_tx: mpsc::UnboundedSender<Action>,
ui_state_tx: mpsc::UnboundedSender<UiState>,
is_output_tty: bool,
) -> Result<()> {
let stream = if is_output_tty {
@ -73,13 +86,19 @@ pub async fn render(
if size.width.checked_mul(size.height).is_some() {
queue!(stderr(), BeginSynchronizedUpdate).ok();
tui.terminal.draw(|frame| {
if let Err(err) =
draw(&context, frame, frame.area())
{
warn!("Failed to draw: {:?}", err);
let _ = action_tx.send(Action::Error(
format!("Failed to draw: {err:?}"),
));
match draw(&context, frame, frame.area()) {
Ok(layout) => {
if layout != context.layout {
let _ = ui_state_tx
.send(UiState::new(layout));
}
}
Err(err) => {
warn!("Failed to draw: {:?}", err);
let _ = action_tx.send(Action::Error(
format!("Failed to draw: {err:?}"),
));
}
}
})?;
execute!(stderr(), EndSynchronizedUpdate).ok();

View File

@ -29,7 +29,7 @@ impl Default for Dimensions {
}
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HelpBarLayout {
pub left: Rect,
pub middle: Rect,
@ -82,6 +82,7 @@ impl Display for PreviewTitlePosition {
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Layout {
pub help_bar: Option<HelpBarLayout>,
pub results: Rect,
@ -90,6 +91,17 @@ pub struct Layout {
pub remote_control: Option<Rect>,
}
impl Default for Layout {
/// Having a default layout with a non-zero height for the results area
/// is important for the initial rendering of the application. For the first
/// frame, this avoids not rendering any results at all since the picker's contents
/// depend on the height of the results area which is not known until the first
/// frame is rendered.
fn default() -> Self {
Self::new(None, Rect::new(0, 0, 0, 100), Rect::default(), None, None)
}
}
impl Layout {
#[allow(clippy::too_many_arguments)]
pub fn new(

View File

@ -10,6 +10,7 @@ use crate::draw::{ChannelState, Ctx, TvState};
use crate::input::convert_action_to_input_request;
use crate::picker::Picker;
use crate::preview::{PreviewState, Previewer};
use crate::render::UiState;
use crate::screen::colors::Colorscheme;
use crate::screen::layout::InputPosition;
use crate::screen::spinner::{Spinner, SpinnerState};
@ -20,7 +21,7 @@ use anyhow::Result;
use rustc_hash::{FxBuildHasher, FxHashSet};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use tokio::sync::mpsc::{Receiver, Sender, UnboundedSender};
use tokio::sync::mpsc::UnboundedSender;
#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)]
pub enum Mode {
@ -38,7 +39,6 @@ pub struct Television {
pub current_pattern: String,
pub results_picker: Picker,
pub rc_picker: Picker,
results_area_height: u16,
pub previewer: Previewer,
pub preview_state: PreviewState,
pub spinner: Spinner,
@ -46,16 +46,7 @@ pub struct Television {
pub app_metadata: AppMetadata,
pub colorscheme: Colorscheme,
pub ticks: u64,
// these are really here as a means to communicate between the render thread
// and the main thread to update `Television`'s state without needing to pass
// a mutable reference to `draw`
pub inner_rx: Receiver<Message>,
pub inner_tx: Sender<Message>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Message {
ResultListHeightChanged(u16),
pub ui_state: UiState,
}
impl Television {
@ -87,8 +78,6 @@ impl Television {
channel.find(&input.unwrap_or(EMPTY_STRING.to_string()));
let spinner = Spinner::default();
// capacity is quite arbitrary here, we can adjust it later
let (inner_tx, inner_rx) = tokio::sync::mpsc::channel(10);
Self {
action_tx,
@ -101,7 +90,6 @@ impl Television {
current_pattern: EMPTY_STRING.to_string(),
results_picker,
rc_picker: Picker::default(),
results_area_height: 0,
previewer,
preview_state: PreviewState::default(),
spinner,
@ -109,11 +97,14 @@ impl Television {
app_metadata,
colorscheme,
ticks: 0,
inner_rx,
inner_tx,
ui_state: UiState::default(),
}
}
pub fn update_ui_state(&mut self, ui_state: UiState) {
self.ui_state = ui_state;
}
pub fn init_remote_control(&mut self) {
let cable_channels = load_cable_channels().unwrap_or_default();
let builtin_channels = load_builtin_channels(Some(
@ -134,7 +125,6 @@ impl Television {
let tv_state = TvState::new(
self.mode,
self.get_selected_entry(Some(Mode::Channel)),
self.results_area_height,
self.results_picker.clone(),
self.rc_picker.clone(),
channel_state,
@ -147,9 +137,9 @@ impl Television {
self.config.clone(),
self.colorscheme.clone(),
self.app_metadata.clone(),
self.inner_tx.clone(),
// now timestamp
std::time::Instant::now(),
self.ui_state.layout,
)
}
@ -229,7 +219,7 @@ impl Television {
picker.select_prev(
step,
result_count as usize,
self.results_area_height as usize,
self.ui_state.layout.results.height.saturating_sub(2) as usize,
);
}
@ -248,7 +238,7 @@ impl Television {
picker.select_next(
step,
result_count as usize,
self.results_area_height as usize,
self.ui_state.layout.results.height.saturating_sub(2) as usize,
);
}
@ -315,6 +305,7 @@ impl Television {
{
// preview content
if let Some(preview) = self.previewer.preview(selected_entry) {
// only update if the preview content has changed
if self.preview_state.preview.title != preview.title {
self.preview_state.update(
preview,
@ -323,7 +314,13 @@ impl Television {
.line_number
.unwrap_or(0)
.saturating_sub(
(self.results_area_height / 2).into(),
(self
.ui_state
.layout
.preview_window
.map_or(0, |w| w.height)
/ 2)
.into(),
)
.try_into()?,
selected_entry
@ -348,7 +345,7 @@ impl Television {
}
self.results_picker.entries = self.channel.results(
self.results_area_height.into(),
self.ui_state.layout.results.height.into(),
u32::try_from(self.results_picker.offset()).unwrap(),
);
self.results_picker.total_items = self.channel.result_count();
@ -364,7 +361,7 @@ impl Television {
self.rc_picker.entries = self.remote_control.results(
// this'll be more than the actual rc height but it's fine
self.results_area_height.into(),
self.ui_state.layout.results.height.into(),
u32::try_from(self.rc_picker.offset()).unwrap(),
);
self.rc_picker.total_items = self.remote_control.total_count();
@ -513,11 +510,25 @@ impl Television {
}
Action::SelectNextPage => {
self.preview_state.reset();
self.select_next_entry(self.results_area_height.into());
self.select_next_entry(
self.ui_state
.layout
.results
.height
.saturating_sub(2)
.into(),
);
}
Action::SelectPrevPage => {
self.preview_state.reset();
self.select_prev_entry(self.results_area_height.into());
self.select_prev_entry(
self.ui_state
.layout
.results
.height
.saturating_sub(2)
.into(),
);
}
Action::ScrollPreviewDown => self.preview_state.scroll_down(1),
Action::ScrollPreviewUp => self.preview_state.scroll_up(1),
@ -559,13 +570,6 @@ impl Television {
///
/// This function may return an Action that'll be processed by the parent `App`.
pub fn update(&mut self, action: &Action) -> Result<Option<Action>> {
if let Ok(Message::ResultListHeightChanged(height)) =
self.inner_rx.try_recv()
{
self.results_area_height = height;
self.action_tx.send(Action::Render)?;
}
self.handle_action(action)?;
self.update_results_picker_state();

View File

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

View File

@ -1,5 +1,3 @@
use lazy_static::lazy_static;
/// Returns the index of the next character boundary in the given string.
///
/// If the given index is already a character boundary, it is returned as is.
@ -151,14 +149,11 @@ pub fn try_parse_utf8_char(input: &[u8]) -> Option<(char, usize)> {
decoded.map(|(seq, n)| (seq.chars().next().unwrap(), n))
}
lazy_static! {
/// The Unicode symbol to use for non-printable characters.
static ref NULL_SYMBOL: char = char::from_u32(0x2400).unwrap();
}
pub const EMPTY_STRING: &str = "";
pub const TAB_WIDTH: usize = 4;
/// The Unicode symbol to use for non-printable characters.
const NULL_SYMBOL: char = '\u{2400}';
const TAB_CHARACTER: char = '\t';
const LINE_FEED_CHARACTER: char = '\x0A';
const DELETE_CHARACTER: char = '\x7F';
@ -304,7 +299,7 @@ pub fn replace_non_printable(
| BOM_CHARACTER
if config.replace_control_characters =>
{
output.push(*NULL_SYMBOL);
output.push(NULL_SYMBOL);
}
// CJK Unified Ideographs
// ex: 解
@ -337,13 +332,13 @@ pub fn replace_non_printable(
}
// Unicode characters above 0x0700 seem unstable with ratatui
c if c > '\u{0700}' => {
output.push(*NULL_SYMBOL);
output.push(NULL_SYMBOL);
}
// everything else
c => output.push(c),
}
} else {
output.push(*NULL_SYMBOL);
output.push(NULL_SYMBOL);
idx += 1;
}
}

View File

@ -224,7 +224,6 @@ pub fn compute_highlights_for_line<'a>(
// SOFTWARE.
use directories::BaseDirs;
use lazy_static::lazy_static;
#[cfg(target_os = "macos")]
use std::env;
@ -258,13 +257,11 @@ impl BatProjectDirs {
}
}
lazy_static! {
pub static ref PROJECT_DIRS: BatProjectDirs = BatProjectDirs::new()
.unwrap_or_else(|| panic!("Could not get home directory"));
}
pub fn load_highlighting_assets() -> HighlightingAssets {
HighlightingAssets::from_cache(PROJECT_DIRS.cache_dir())
let project_dirs = BatProjectDirs::new()
.unwrap_or_else(|| panic!("Could not get home directory"));
HighlightingAssets::from_cache(project_dirs.cache_dir())
.unwrap_or_else(|_| HighlightingAssets::from_binary())
}

View File

@ -5,7 +5,7 @@ use std::num::NonZeroUsize;
/// This will use the number of available threads if possible, but will default to 1 if the number
/// of available threads cannot be determined. It will also never use more than 32 threads to avoid
/// startup overhead.
pub fn default_num_threads() -> NonZeroUsize {
pub fn default_num_threads() -> usize {
// default to 1 thread if we can't determine the number of available threads
let default = NonZeroUsize::MIN;
// never use more than 32 threads to avoid startup overhead
@ -14,4 +14,5 @@ pub fn default_num_threads() -> NonZeroUsize {
std::thread::available_parallelism()
.unwrap_or(default)
.min(limit)
.get()
}