pinakes/crates/pinakes-tui/src/ui/mod.rs
NotAShelf 8129c5a6e7
pinakes-tui: cover more API routes in the TUI crate
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id14b6f82d3b9f3c27bee9c214a1bdedc6a6a6964
2026-03-22 17:58:42 +03:00

238 lines
7 KiB
Rust

pub mod admin;
pub mod audit;
pub mod books;
pub mod collections;
pub mod database;
pub mod detail;
pub mod duplicates;
pub mod import;
pub mod library;
pub mod metadata_edit;
pub mod playlists;
pub mod queue;
pub mod search;
pub mod settings;
pub mod statistics;
pub mod tags;
pub mod tasks;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Tabs},
};
use crate::app::{AppState, View};
/// Format a file size in bytes into a human-readable string.
#[expect(
clippy::cast_precision_loss,
reason = "file sizes beyond 2^52 bytes are unlikely in practice"
)]
pub fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
/// Format duration in seconds into hh:mm:ss format.
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "duration seconds are always non-negative and within u64 range"
)]
pub fn format_duration(secs: f64) -> String {
let total = secs as u64;
let h = total / 3600;
let m = (total % 3600) / 60;
let s = total % 60;
if h > 0 {
format!("{h:02}:{m:02}:{s:02}")
} else {
format!("{m:02}:{s:02}")
}
}
/// Trim a timestamp string to just the date portion (YYYY-MM-DD).
pub fn format_date(timestamp: &str) -> &str {
// Timestamps are typically "2024-01-15T10:30:00Z" or similar
if timestamp.len() >= 10 {
&timestamp[..10]
} else {
timestamp
}
}
/// Return a color based on media type string.
pub fn media_type_color(media_type: &str) -> Color {
match media_type {
t if t.starts_with("audio") => Color::Green,
t if t.starts_with("video") => Color::Magenta,
t if t.starts_with("image") => Color::Yellow,
t if t.starts_with("application/pdf") => Color::Red,
t if t.starts_with("text") => Color::Cyan,
_ => Color::White,
}
}
pub fn render(f: &mut Frame, state: &AppState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(f.area());
render_tabs(f, state, chunks[0]);
match state.current_view {
View::Library => library::render(f, state, chunks[1]),
View::Search => search::render(f, state, chunks[1]),
View::Detail => detail::render(f, state, chunks[1]),
View::Tags => tags::render(f, state, chunks[1]),
View::Collections => collections::render(f, state, chunks[1]),
View::Audit => audit::render(f, state, chunks[1]),
View::Import => import::render(f, state, chunks[1]),
View::Settings => settings::render(f, state, chunks[1]),
View::Duplicates => duplicates::render(f, state, chunks[1]),
View::Database => database::render(f, state, chunks[1]),
View::MetadataEdit => metadata_edit::render(f, state, chunks[1]),
View::Queue => queue::render(f, state, chunks[1]),
View::Statistics => statistics::render(f, state, chunks[1]),
View::Tasks => tasks::render(f, state, chunks[1]),
View::Books => books::render(f, state, chunks[1]),
View::Playlists => playlists::render(f, state, chunks[1]),
View::Admin => admin::render(f, state, chunks[1]),
}
render_status_bar(f, state, chunks[2]);
}
fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) {
let titles: Vec<Line> = vec![
"Library",
"Search",
"Tags",
"Collections",
"Audit",
"Books",
"Queue",
"Stats",
"Tasks",
"Playlists",
"Admin",
]
.into_iter()
.map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White))))
.collect();
let selected = match state.current_view {
View::Library
| View::Detail
| View::Import
| View::Settings
| View::MetadataEdit => 0,
View::Search => 1,
View::Tags => 2,
View::Collections => 3,
View::Audit | View::Duplicates | View::Database => 4,
View::Books => 5,
View::Queue => 6,
View::Statistics => 7,
View::Tasks => 8,
View::Playlists => 9,
View::Admin => 10,
};
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::ALL).title(" Pinakes "))
.select(selected)
.style(Style::default().fg(Color::Gray))
.highlight_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(tabs, area);
}
fn render_status_bar(f: &mut Frame, state: &AppState, area: Rect) {
let status = state.status_message.as_ref().map_or_else(
|| {
match state.current_view {
View::Tags => {
" q:Quit j/k:Nav Home/End:Top/Bot n:New d:Delete r:Refresh \
Tab:Switch"
.to_string()
},
View::Collections => {
" q:Quit j/k:Nav Home/End:Top/Bot d:Delete r:Refresh Tab:Switch"
.to_string()
},
View::Audit => {
" q:Quit j/k:Nav Home/End:Top/Bot r:Refresh Tab:Switch"
.to_string()
},
View::Detail => {
" q:Quit Esc:Back o:Open e:Edit p:Page +:Tag -:Untag f:Fav \
R:Rate c:Comment E:Enrich U:Subtitles t:Transcode r:Refresh"
.to_string()
},
View::Playlists => {
" q:Quit j/k:Nav n:New d:Delete Enter:Items S:Shuffle Esc:Back"
.to_string()
},
View::Admin => " q:Quit j/k:Nav Tab:Switch tab d:Del user/device \
w:Test webhook r:Refresh Esc:Back"
.to_string(),
View::Import => {
" Enter:Import Esc:Cancel s:Scan libraries ?:Help".to_string()
},
View::Settings => " q:Quit Esc:Back ?:Help".to_string(),
View::Duplicates => " q:Quit j/k:Nav r:Refresh Esc:Back".to_string(),
View::Database => " q:Quit v:Vacuum r:Refresh Esc:Back".to_string(),
View::MetadataEdit => {
" Tab:Next field Enter:Save Esc:Cancel".to_string()
},
View::Queue => {
" q:Quit j/k:Nav Enter:Play d:Remove N:Next P:Prev R:Repeat \
S:Shuffle C:Clear"
.to_string()
},
View::Statistics => " q:Quit r:Refresh Esc:Back ?:Help".to_string(),
View::Tasks => {
" q:Quit j/k:Nav Enter:Toggle R:Run Now r:Refresh Esc:Back"
.to_string()
},
View::Books => {
" q:Quit j/k:Nav Home/End:Top/Bot Tab:Sub-view r:Refresh \
Esc:Back"
.to_string()
},
_ => {
" q:Quit /:Search i:Import o:Open t:Tags c:Coll a:Audit \
b:Books D:Dupes B:DB Q:Queue X:Stats T:Tasks ?:Help"
.to_string()
},
}
},
String::clone,
);
let paragraph = Paragraph::new(Line::from(Span::styled(
status,
Style::default().fg(Color::DarkGray),
)));
f.render_widget(paragraph, area);
}