feat(remote): rework remote UI and add description and requirements panels

This commit is contained in:
alexandre pasmantier 2025-06-22 16:27:31 +02:00 committed by Alex Pasmantier
parent a2ebbb3557
commit 7067a2ba93
20 changed files with 367 additions and 94 deletions

24
Cargo.lock generated
View File

@ -623,6 +623,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "env_home"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
[[package]]
name = "equivalent"
version = "1.0.2"
@ -1953,6 +1959,7 @@ dependencies = [
"ureq",
"vt100",
"walkdir",
"which",
"winapi-util",
]
@ -2453,6 +2460,17 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "which"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d"
dependencies = [
"env_home",
"rustix 1.0.5",
"winsafe",
]
[[package]]
name = "winapi"
version = "0.3.9"
@ -2643,6 +2661,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "winsafe"
version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"

View File

@ -59,6 +59,7 @@ ureq = "3.0.11"
serde_json = "1.0.140"
colored = "3.0.0"
serde_with = "3.13.0"
which = "8.0.0"
# target specific dependencies

View File

@ -2,6 +2,7 @@
[metadata]
name = "bash-history"
description = "A channel to select from your bash history"
requirements = ["bash"]
[source]
command = "sed '1!G;h;$!d' ${HISTFILE:-${HOME}/.bash_history}"

View File

@ -2,7 +2,7 @@
[metadata]
name = "docker-images"
description = "A channel to select from Docker images"
requirements = ["docker"]
requirements = ["docker", "jq"]
[source]
command = "docker images --format '{{.Repository}}:{{.Tag}} {{.ID}}'"

View File

@ -2,6 +2,7 @@
[metadata]
name = "fish-history"
description = "A channel to select from your fish history"
requirements = ["fish"]
[source]
command = "fish -c 'history'"

View File

@ -1,6 +1,7 @@
[metadata]
name = "git-branch"
description = "A channel to select from git branches"
requirements = ["git"]
[source]
command = "git --no-pager branch --all --format=\"%(refname:short)\""

View File

@ -1,6 +1,7 @@
[metadata]
name = "git-diff"
description = "A channel to select files from git diff commands"
requirements = ["git"]
[source]
command = "git diff --name-only HEAD"

View File

@ -1,6 +1,7 @@
[metadata]
name = "git-log"
description = "A channel to select from git log entries"
requirements = ["git"]
[source]
command = "git log --oneline --date=short --pretty=\"format:%h %s %an %cd\" \"$@\""

View File

@ -1,6 +1,7 @@
[metadata]
name = "git-reflog"
description = "A channel to select from git reflog entries"
requirements = ["git"]
[source]
command = "git reflog"

View File

@ -1,9 +1,14 @@
[metadata]
name = "git-repos"
description = "A channel to select from git repositories on your local machine"
requirements = ["fd", "git"]
description = """
A channel to select from git repositories on your local machine.
This channel uses `fd` to find directories that contain a `.git` subdirectory, and then allows you to preview the git log of the selected repository.
"""
[source]
# this is a macos version of the command. While perfectly usable on linux, you may want to tweak it a bit.
command = "fd -g .git -HL -t d -d 10 --prune ~ -E 'Library' -E 'Application Support' --exec dirname {}"
display = "{split:/:-1}"

View File

@ -1,6 +1,7 @@
[metadata]
name = "zsh-history"
description = "A channel to select from your zsh history"
requirements = ["zsh"]
[source]
command = "sed '1!G;h;$!d' ${HISTFILE:-${HOME}/.zsh_history}"

View File

@ -145,9 +145,10 @@ where
Some(prototype)
}
Err(e) => {
error!(
"Failed to parse cable channel file {:?}: {}",
p, e
eprintln!(
"Failed to parse cable channel file {}: {}",
p.display(),
e
);
None
}

View File

@ -1,6 +1,7 @@
use anyhow::Result;
use std::fmt::{self, Display, Formatter};
use std::hash::{Hash, Hasher};
use which::which;
use crate::config::Binding;
use crate::{
@ -161,6 +162,12 @@ pub struct ChannelKeyBindings {
pub bindings: KeyBindings,
}
impl ChannelKeyBindings {
pub fn channel_shortcut(&self) -> Option<&Binding> {
self.shortcut.as_ref()
}
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct ChannelPrototype {
pub metadata: Metadata,
@ -247,7 +254,39 @@ pub struct Metadata {
pub name: String,
pub description: Option<String>,
#[serde(default)]
requirements: Vec<String>,
pub requirements: Vec<BinaryRequirement>,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
#[serde(transparent)]
pub struct BinaryRequirement {
pub bin_name: String,
#[serde(skip)]
met: bool,
}
impl BinaryRequirement {
pub fn new(bin_name: &str) -> Self {
Self {
bin_name: bin_name.to_string(),
met: false,
}
}
/// Check if the required binary is available in the system's PATH.
///
/// This method updates the requirement's state in place to reflect whether the binary was
/// found.
pub fn init(&mut self) {
self.met = which(&self.bin_name).is_ok();
}
/// Whether the requirement is available in the system's PATH.
///
/// This should be called after `init()`.
pub fn is_met(&self) -> bool {
self.met
}
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]

View File

@ -1,6 +1,9 @@
use crate::{
cable::Cable,
channels::{entry::into_ranges, prototypes::ChannelPrototype},
channels::{
entry::into_ranges,
prototypes::{BinaryRequirement, ChannelPrototype},
},
config::Binding,
matcher::{Matcher, config::Config},
screen::result_item::ResultItem,
@ -12,14 +15,18 @@ pub struct CableEntry {
pub channel_name: String,
pub match_ranges: Option<Vec<(u32, u32)>>,
pub shortcut: Option<Binding>,
pub description: Option<String>,
pub requirements: Vec<BinaryRequirement>,
}
impl CableEntry {
pub fn new(name: String, shortcut: Option<Binding>) -> Self {
pub fn new(name: String, shortcut: Option<&Binding>) -> Self {
CableEntry {
channel_name: name,
match_ranges: None,
shortcut,
shortcut: shortcut.cloned(),
description: None,
requirements: Vec::new(),
}
}
@ -27,6 +34,19 @@ impl CableEntry {
self.match_ranges = Some(into_ranges(indices));
self
}
pub fn with_description(mut self, description: Option<String>) -> Self {
self.description = description;
self
}
pub fn with_requirements(
mut self,
requirements: Vec<BinaryRequirement>,
) -> Self {
self.requirements = requirements;
self
}
}
impl ResultItem for CableEntry {
@ -49,7 +69,7 @@ impl ResultItem for CableEntry {
}
pub struct RemoteControl {
matcher: Matcher<String>,
matcher: Matcher<CableEntry>,
pub cable_channels: Cable,
}
@ -60,9 +80,30 @@ impl RemoteControl {
let matcher =
Matcher::new(&Config::default().n_threads(Some(NUM_THREADS)));
let injector = matcher.injector();
for c in cable_channels.keys() {
let () = injector.push(c.clone(), |e, cols| {
cols[0] = e.to_string().into();
for (channel_name, prototype) in cable_channels.iter() {
let channel_shortcut = prototype
.keybindings
.as_ref()
.and_then(|kb| kb.channel_shortcut());
let cable_entry =
CableEntry::new(channel_name.to_string(), channel_shortcut)
.with_description(prototype.metadata.description.clone())
.with_requirements(
// check if the prototype has binary requirements
// and whether they are met
prototype
.metadata
.requirements
.iter()
.cloned()
.map(|mut r| {
r.init();
r
})
.collect(),
);
let () = injector.push(cable_entry, |e, cols| {
cols[0] = e.channel_name.clone().into();
});
}
RemoteControl {
@ -95,25 +136,13 @@ impl RemoteControl {
self.matcher
.results(num_entries, offset)
.into_iter()
.map(|item| {
CableEntry::new(
item.matched_string.clone(),
self.cable_channels
.get_channel_shortcut(&item.matched_string),
)
.with_match_indices(&item.match_indices)
})
.map(|item| item.inner.with_match_indices(&item.match_indices))
.collect()
}
pub fn get_result(&self, index: u32) -> CableEntry {
let item = self.matcher.get_result(index).expect("Invalid index");
CableEntry::new(
item.matched_string.clone(),
self.cable_channels
.get_channel_shortcut(&item.matched_string),
)
.with_match_indices(&item.match_indices)
item.inner.with_match_indices(&item.match_indices)
}
pub fn result_count(&self) -> u32 {

View File

@ -270,7 +270,6 @@ pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<Layout> {
ctx.config.ui.use_nerd_font_icons,
&mut ctx.tv_state.rc_picker.state.clone(),
&mut ctx.tv_state.rc_picker.input.clone(),
&ctx.tv_state.mode,
&ctx.colorscheme,
)?;
}

View File

@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
use crate::config::UiConfig;
use crate::screen::constants::LOGO_WIDTH;
use crate::screen::logo::REMOTE_LOGO_HEIGHT_U16;
pub struct Dimensions {
pub x: u16,
@ -110,6 +111,8 @@ pub struct Layout {
pub remote_control: Option<Rect>,
}
const REMOTE_PANEL_WIDTH_PERCENTAGE: u16 = 62;
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
@ -182,22 +185,6 @@ impl Layout {
help_bar_layout = None;
}
let remote_constraints = if show_remote {
vec![Constraint::Fill(1), Constraint::Length(LOGO_WIDTH)]
} else {
vec![Constraint::Fill(1)]
};
let remote_chunks = layout::Layout::default()
.direction(Direction::Horizontal)
.constraints(remote_constraints)
.split(main_rect);
let remote_control = if show_remote {
Some(remote_chunks[1])
} else {
None
};
// Define the constraints for the results area (results list + input bar).
// We keep this near the top so we can derive the input-bar height before
// calculating the preview/results split.
@ -226,7 +213,7 @@ impl Layout {
if ui_config.orientation == Orientation::Portrait
&& input_bar_height > 0
{
let total_height = remote_chunks[0].height;
let total_height = main_rect.height;
if total_height > input_bar_height {
let available_height = total_height - input_bar_height;
preview_percentage = raw_preview_percentage
@ -266,7 +253,7 @@ impl Layout {
Orientation::Landscape => Direction::Horizontal,
})
.constraints(constraints)
.split(remote_chunks[0]);
.split(main_rect);
// ------------------------------------------------------------------
// Determine the rectangles for input, results list and optional preview
@ -387,19 +374,33 @@ impl Layout {
}
// Perform the split now that we have the final constraints vector
let port_chunks = layout::Layout::default()
let portrait_chunks = layout::Layout::default()
.direction(Direction::Vertical)
.constraints(portrait_constraints)
.split(remote_chunks[0]);
.split(main_rect);
let input_rect = port_chunks[input_idx];
let results_rect = port_chunks[results_idx];
let preview_rect = preview_idx.map(|idx| port_chunks[idx]);
let input_rect = portrait_chunks[input_idx];
let results_rect = portrait_chunks[results_idx];
let preview_rect = preview_idx.map(|idx| portrait_chunks[idx]);
(input_rect, results_rect, preview_rect)
}
};
// the remote control is a centered popup
let remote_control = if show_remote {
let remote_control_rect = centered_rect_with_dimensions(
&Dimensions::new(
area.width * REMOTE_PANEL_WIDTH_PERCENTAGE / 100,
REMOTE_LOGO_HEIGHT_U16,
),
area,
);
Some(remote_control_rect)
} else {
None
};
Self::new(
help_bar_layout,
results,
@ -412,13 +413,20 @@ impl Layout {
/// helper function to create a centered rect using up certain percentage of the available rect `r`
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let height = r.height.saturating_mul(percent_y) / 100;
let width = r.width.saturating_mul(percent_x) / 100;
centered_rect_with_dimensions(&Dimensions::new(width, height), r)
}
fn centered_rect_with_dimensions(dimensions: &Dimensions, r: Rect) -> Rect {
// Cut the given rectangle into three vertical pieces
let popup_layout = layout::Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Fill(1),
Constraint::Length(dimensions.y),
Constraint::Fill(1),
])
.split(r);
@ -426,9 +434,9 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
layout::Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Fill(1),
Constraint::Length(dimensions.x),
Constraint::Fill(1),
])
.split(popup_layout[1])[1] // Return the middle chunk
}

View File

@ -24,7 +24,7 @@ pub fn build_logo_paragraph<'a>() -> Paragraph<'a> {
Paragraph::new(lines)
}
const REMOTE_LOGO: &str = r"
pub const REMOTE_LOGO: &str = r"
_____________
/ \
| (*) (#) |
@ -32,18 +32,24 @@ const REMOTE_LOGO: &str = r"
| (1) (2) (3) |
| (4) (5) (6) |
| (7) (8) (9) |
| |
| _ |
| | | |
| (_¯(0)¯_) |
| | | |
| ¯ |
| |
| |
| === === === |
| |
| T.V |
`-------------´";
| TV |
`-------------´
";
pub const REMOTE_LOGO_WIDTH: usize = 15;
#[allow(clippy::cast_possible_truncation)]
pub const REMOTE_LOGO_WIDTH_U16: u16 = REMOTE_LOGO_WIDTH as u16;
pub const REMOTE_LOGO_HEIGHT: usize = 19;
#[allow(clippy::cast_possible_truncation)]
pub const REMOTE_LOGO_HEIGHT_U16: u16 = REMOTE_LOGO_HEIGHT as u16;
pub fn build_remote_logo_paragraph<'a>() -> Paragraph<'a> {
let lines = REMOTE_LOGO

View File

@ -1,8 +1,9 @@
use crate::channels::prototypes::BinaryRequirement;
use crate::channels::remote_control::CableEntry;
use crate::screen::colors::{Colorscheme, GeneralColorscheme};
use crate::screen::logo::build_remote_logo_paragraph;
use crate::screen::mode::mode_color;
use crate::television::Mode;
use crate::screen::logo::{
REMOTE_LOGO_WIDTH_U16, build_remote_logo_paragraph,
};
use crate::utils::input::Input;
use crate::screen::result_item;
@ -12,7 +13,8 @@ use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::prelude::{Color, Line, Span, Style};
use ratatui::style::Stylize;
use ratatui::widgets::{
Block, BorderType, Borders, ListDirection, ListState, Padding, Paragraph,
Block, BorderType, Borders, Clear, ListDirection, ListState, Padding,
Paragraph, Wrap,
};
#[allow(clippy::too_many_arguments)]
@ -23,20 +25,178 @@ pub fn draw_remote_control(
use_nerd_font_icons: bool,
picker_state: &mut ListState,
input_state: &mut Input,
mode: &Mode,
colorscheme: &Colorscheme,
) -> Result<()> {
let layout = Layout::default()
.direction(Direction::Vertical)
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(20),
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Length(REMOTE_LOGO_WIDTH_U16 + 2),
]
.as_ref(),
)
.split(rect);
// Clear the popup area
f.render_widget(Clear, rect);
let selected_entry = entries.get(picker_state.selected().unwrap_or(0));
draw_rc_logo(f, layout[2], &colorscheme.general);
draw_search_panel(
f,
layout[0],
entries,
use_nerd_font_icons,
picker_state,
colorscheme,
input_state,
)?;
draw_information_panel(f, layout[1], selected_entry, colorscheme);
Ok(())
}
fn draw_information_panel(
f: &mut Frame,
rect: Rect,
selected_entry: Option<&CableEntry>,
colorscheme: &Colorscheme,
) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(3)].as_ref())
.split(rect);
draw_description_block(f, layout[0], selected_entry, colorscheme);
draw_requirements_block(f, layout[1], selected_entry, colorscheme);
}
fn draw_description_block(
f: &mut Frame,
area: Rect,
selected_entry: Option<&CableEntry>,
colorscheme: &Colorscheme,
) {
let description_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(colorscheme.general.border_fg))
.title_top(Line::from(" Description ").alignment(Alignment::Center))
.style(
Style::default()
.bg(colorscheme.general.background.unwrap_or_default()),
)
.padding(Padding::right(1));
let description = if let Some(entry) = selected_entry {
entry
.description
.clone()
.unwrap_or_else(|| "No description available.".to_string())
} else {
String::new()
};
let description_paragraph = Paragraph::new(description)
.block(description_block)
.style(Style::default().italic())
.alignment(Alignment::Left)
.wrap(Wrap { trim: true });
f.render_widget(description_paragraph, area);
}
fn draw_requirements_block(
f: &mut Frame,
area: Rect,
selected_entry: Option<&CableEntry>,
colorscheme: &Colorscheme,
) {
let mut requirements_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(colorscheme.general.border_fg))
.style(
Style::default()
.bg(colorscheme.general.background.unwrap_or_default()),
)
.padding(Padding::right(1));
if selected_entry.is_none() {
// If no entry is selected, just render an empty block
let title = Line::from(" Requirements ")
.alignment(Alignment::Center)
.italic();
f.render_widget(requirements_block.title_top(title), area);
return;
}
let selected_entry = selected_entry.unwrap();
let spans = selected_entry.requirements.iter().fold(
Vec::new(),
|mut acc, requirement| {
acc.push(Span::styled(
format!("{} ", &requirement.bin_name),
Style::default()
.fg(if requirement.is_met() {
Color::Green
} else {
Color::Red
})
.bold()
.italic(),
));
acc
},
);
requirements_block = requirements_block.title_top(
Line::from({
let mut title = vec![Span::from(" Requirements ")];
if spans.is_empty()
|| selected_entry
.requirements
.iter()
.all(BinaryRequirement::is_met)
{
title.push(Span::styled(
"[OK] ",
Style::default().fg(Color::Green),
));
} else {
title.push(Span::styled(
"[MISSING] ",
Style::default().fg(Color::Red),
));
}
title
})
.style(Style::default().italic())
.alignment(Alignment::Center),
);
let requirements_paragraph = Paragraph::new(Line::from(spans))
.block(requirements_block)
.alignment(Alignment::Center);
f.render_widget(requirements_paragraph, area);
}
fn draw_search_panel(
f: &mut Frame,
area: Rect,
entries: &[CableEntry],
use_nerd_font_icons: bool,
picker_state: &mut ListState,
colorscheme: &Colorscheme,
input: &mut Input,
) -> Result<()> {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(3)].as_ref())
.split(area);
draw_rc_channels(
f,
layout[0],
@ -45,14 +205,7 @@ pub fn draw_remote_control(
picker_state,
colorscheme,
);
draw_rc_input(f, layout[1], input_state, colorscheme)?;
draw_rc_logo(
f,
layout[2],
mode_color(*mode, &colorscheme.mode),
&colorscheme.general,
);
Ok(())
draw_rc_input(f, layout[1], input, colorscheme)
}
fn draw_rc_channels(
@ -67,6 +220,11 @@ fn draw_rc_channels(
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(colorscheme.general.border_fg))
.title_top(
Line::from(" Channels ")
.alignment(Alignment::Center)
.italic(),
)
.style(
Style::default()
.bg(colorscheme.general.background.unwrap_or_default()),
@ -93,7 +251,9 @@ fn draw_rc_input(
colorscheme: &Colorscheme,
) -> Result<()> {
let input_block = Block::default()
.title_top(Line::from("Remote Control").alignment(Alignment::Center))
.title_top(
Line::from(" Search ").alignment(Alignment::Center).italic(),
)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(colorscheme.general.border_fg))
@ -153,17 +313,10 @@ fn draw_rc_input(
Ok(())
}
fn draw_rc_logo(
f: &mut Frame,
area: Rect,
mode_color: Color,
colorscheme: &GeneralColorscheme,
) {
let logo_block = Block::default().style(
Style::default()
.fg(mode_color)
.bg(colorscheme.background.unwrap_or_default()),
);
fn draw_rc_logo(f: &mut Frame, area: Rect, colorscheme: &GeneralColorscheme) {
let logo_block = Block::default()
.style(Style::default().bg(colorscheme.background.unwrap_or_default()))
.padding(Padding::horizontal(1));
let logo_paragraph = build_remote_logo_paragraph()
.alignment(Alignment::Center)

View File

@ -53,7 +53,7 @@ fn no_remote() {
tester.send(&ctrl('t'));
// Check that the remote control is not shown
tester.assert_not_tui_frame_contains("──Remote Control──");
tester.assert_not_tui_frame_contains("(1) (2) (3)");
// Exit the application
tester.send(&ctrl('c'));
@ -94,7 +94,7 @@ fn multiple_keybindings() {
tester.assert_tui_running(&mut child);
tester.send(&ctrl('t'));
tester.assert_tui_frame_contains("──Remote Control──");
tester.assert_tui_frame_contains("(1) (2) (3)");
tester.send("a");
tester.send("a");

View File

@ -11,7 +11,8 @@ fn tv_remote_control_shows() {
// open remote control mode
tester.send(&ctrl('t'));
tester.assert_tui_frame_contains("──Remote Control──");
// FIXME: me being lazy
tester.assert_tui_frame_contains("(1) (2) (3)");
// exit remote then app
tester.send(&ctrl('c'));