treewide: fix various UI bugs; optimize crypto dependencies & format

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
This commit is contained in:
raf 2026-02-10 12:56:05 +03:00
commit 3ccddce7fd
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
178 changed files with 58342 additions and 54241 deletions

View file

@ -1,85 +1,84 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Cell, Row, Table};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::Span,
widgets::{Block, Borders, Cell, Row, Table},
};
use super::format_date;
use crate::app::AppState;
/// Return a color for an audit action string.
fn action_color(action: &str) -> Color {
match action {
"imported" | "import" | "created" => Color::Green,
"deleted" | "delete" | "removed" => Color::Red,
"tagged" | "tag_added" => Color::Cyan,
"untagged" | "tag_removed" => Color::Yellow,
"updated" | "modified" | "edited" => Color::Blue,
"scanned" | "scan" => Color::Magenta,
_ => Color::White,
}
match action {
"imported" | "import" | "created" => Color::Green,
"deleted" | "delete" | "removed" => Color::Red,
"tagged" | "tag_added" => Color::Cyan,
"untagged" | "tag_removed" => Color::Yellow,
"updated" | "modified" | "edited" => Color::Blue,
"scanned" | "scan" => Color::Magenta,
_ => Color::White,
}
}
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Action", "Media ID", "Details", "Date"]).style(
let header = Row::new(vec!["Action", "Media ID", "Details", "Date"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.audit_log
.iter()
.enumerate()
.map(|(i, entry)| {
let style = if Some(i) == state.audit_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
};
let rows: Vec<Row> = state
.audit_log
.iter()
.enumerate()
.map(|(i, entry)| {
let style = if Some(i) == state.audit_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
let color = action_color(&entry.action);
let action_cell = Cell::from(Span::styled(
entry.action.clone(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
let color = action_color(&entry.action);
let action_cell = Cell::from(Span::styled(
entry.action.clone(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
// Truncate media ID for display
let media_display = entry
.media_id
.as_deref()
.map(|id| {
if id.len() > 12 {
format!("{}...", &id[..12])
} else {
id.to_string()
}
})
.unwrap_or_else(|| "-".into());
Row::new(vec![
action_cell,
Cell::from(media_display),
Cell::from(entry.details.clone().unwrap_or_else(|| "-".into())),
Cell::from(format_date(&entry.timestamp).to_string()),
])
.style(style)
// Truncate media ID for display
let media_display = entry
.media_id
.as_deref()
.map(|id| {
if id.len() > 12 {
format!("{}...", &id[..12])
} else {
id.to_string()
}
})
.collect();
.unwrap_or_else(|| "-".into());
let title = format!(" Audit Log ({}) ", state.audit_log.len());
Row::new(vec![
action_cell,
Cell::from(media_display),
Cell::from(entry.details.clone().unwrap_or_else(|| "-".into())),
Cell::from(format_date(&entry.timestamp).to_string()),
])
.style(style)
})
.collect();
let table = Table::new(
rows,
[
ratatui::layout::Constraint::Percentage(18),
ratatui::layout::Constraint::Percentage(22),
ratatui::layout::Constraint::Percentage(40),
ratatui::layout::Constraint::Percentage(20),
],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
let title = format!(" Audit Log ({}) ", state.audit_log.len());
f.render_widget(table, area);
let table = Table::new(rows, [
ratatui::layout::Constraint::Percentage(18),
ratatui::layout::Constraint::Percentage(22),
ratatui::layout::Constraint::Percentage(40),
ratatui::layout::Constraint::Percentage(20),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}

View file

@ -1,64 +1,66 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Row, Table};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Row, Table},
};
use super::format_date;
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Name", "Kind", "Description", "Members", "Created"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
let header =
Row::new(vec!["Name", "Kind", "Description", "Members", "Created"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.collections
.iter()
.enumerate()
.map(|(i, col)| {
let style = if Some(i) == state.collection_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
let rows: Vec<Row> = state
.collections
.iter()
.enumerate()
.map(|(i, col)| {
let style = if Some(i) == state.collection_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
// We show the filter_query as a proxy for member info when kind is "smart"
let members_display = if col.kind == "smart" {
col.filter_query
.as_deref()
.map(|q| format!("filter: {q}"))
.unwrap_or_else(|| "-".to_string())
} else {
"-".to_string()
};
// We show the filter_query as a proxy for member info when kind is
// "smart"
let members_display = if col.kind == "smart" {
col
.filter_query
.as_deref()
.map(|q| format!("filter: {q}"))
.unwrap_or_else(|| "-".to_string())
} else {
"-".to_string()
};
Row::new(vec![
col.name.clone(),
col.kind.clone(),
col.description.clone().unwrap_or_else(|| "-".into()),
members_display,
format_date(&col.created_at).to_string(),
])
.style(style)
})
.collect();
Row::new(vec![
col.name.clone(),
col.kind.clone(),
col.description.clone().unwrap_or_else(|| "-".into()),
members_display,
format_date(&col.created_at).to_string(),
])
.style(style)
})
.collect();
let title = format!(" Collections ({}) ", state.collections.len());
let title = format!(" Collections ({}) ", state.collections.len());
let table = Table::new(
rows,
[
ratatui::layout::Constraint::Percentage(25),
ratatui::layout::Constraint::Percentage(12),
ratatui::layout::Constraint::Percentage(28),
ratatui::layout::Constraint::Percentage(15),
ratatui::layout::Constraint::Percentage(20),
],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
let table = Table::new(rows, [
ratatui::layout::Constraint::Percentage(25),
ratatui::layout::Constraint::Percentage(12),
ratatui::layout::Constraint::Percentage(28),
ratatui::layout::Constraint::Percentage(15),
ratatui::layout::Constraint::Percentage(20),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
f.render_widget(table, area);
}

View file

@ -1,55 +1,57 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let section_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let section_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let pad = " ";
let pad = " ";
let mut lines = vec![
Line::default(),
Line::from(Span::styled("--- Database Statistics ---", section_style)),
];
let mut lines = vec![
Line::default(),
Line::from(Span::styled("--- Database Statistics ---", section_style)),
];
if let Some(ref stats) = state.database_stats {
for (key, value) in stats {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(format!("{key:<20}"), label_style),
Span::styled(value.to_string(), value_style),
]));
}
} else {
lines.push(Line::from(vec![
Span::raw(pad),
Span::raw("Press 'r' to load database statistics"),
]));
if let Some(ref stats) = state.database_stats {
for (key, value) in stats {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(format!("{key:<20}"), label_style),
Span::styled(value.to_string(), value_style),
]));
}
lines.push(Line::default());
lines.push(Line::from(Span::styled("--- Actions ---", section_style)));
} else {
lines.push(Line::from(vec![
Span::raw(pad),
Span::raw("v: Vacuum database"),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::raw("Esc: Return to library"),
Span::raw(pad),
Span::raw("Press 'r' to load database statistics"),
]));
}
let paragraph =
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Database "));
lines.push(Line::default());
lines.push(Line::from(Span::styled("--- Actions ---", section_style)));
lines.push(Line::from(vec![
Span::raw(pad),
Span::raw("v: Vacuum database"),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::raw("Esc: Return to library"),
]));
f.render_widget(paragraph, area);
let paragraph = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(" Database "));
f.render_widget(paragraph, area);
}

View file

@ -1,223 +1,229 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use super::{format_date, format_duration, format_size, media_type_color};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let item = match &state.selected_media {
Some(item) => item,
None => {
let msg = Paragraph::new("No item selected")
.block(Block::default().borders(Borders::ALL).title(" Detail "));
f.render_widget(msg, area);
return;
}
};
let item = match &state.selected_media {
Some(item) => item,
None => {
let msg = Paragraph::new("No item selected")
.block(Block::default().borders(Borders::ALL).title(" Detail "));
f.render_widget(msg, area);
return;
},
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0)])
.split(area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0)])
.split(area);
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let dim_style = Style::default().fg(Color::DarkGray);
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let dim_style = Style::default().fg(Color::DarkGray);
let pad = " ";
let label_width = 14;
let make_label = |name: &str| -> String { format!("{name:<label_width$}") };
let pad = " ";
let label_width = 14;
let make_label = |name: &str| -> String { format!("{name:<label_width$}") };
let mut lines: Vec<Line> = Vec::new();
let mut lines: Vec<Line> = Vec::new();
// Section: File Info
lines.push(Line::from(Span::styled(
"--- File Info ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
// Section: File Info
lines.push(Line::from(Span::styled(
"--- File Info ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Name"), label_style),
Span::styled(&item.file_name, value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Path"), label_style),
Span::styled(&item.path, dim_style),
]));
let type_color = media_type_color(&item.media_type);
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Type"), label_style),
Span::styled(&item.media_type, Style::default().fg(type_color)),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Size"), label_style),
Span::styled(format_size(item.file_size), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Hash"), label_style),
Span::styled(&item.content_hash, dim_style),
]));
if item.has_thumbnail {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Name"), label_style),
Span::styled(&item.file_name, value_style),
Span::raw(pad),
Span::styled(make_label("Thumbnail"), label_style),
Span::styled("Yes", Style::default().fg(Color::Green)),
]));
}
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Path"), label_style),
Span::styled(&item.path, dim_style),
]));
lines.push(Line::default()); // blank line
let type_color = media_type_color(&item.media_type);
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Type"), label_style),
Span::styled(&item.media_type, Style::default().fg(type_color)),
]));
// Section: Metadata
lines.push(Line::from(Span::styled(
"--- Metadata ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Size"), label_style),
Span::styled(format_size(item.file_size), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Title"), label_style),
Span::styled(item.title.as_deref().unwrap_or("-"), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Hash"), label_style),
Span::styled(&item.content_hash, dim_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Artist"), label_style),
Span::styled(item.artist.as_deref().unwrap_or("-"), value_style),
]));
if item.has_thumbnail {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Thumbnail"), label_style),
Span::styled("Yes", Style::default().fg(Color::Green)),
]));
}
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Album"), label_style),
Span::styled(item.album.as_deref().unwrap_or("-"), value_style),
]));
lines.push(Line::default()); // blank line
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Genre"), label_style),
Span::styled(item.genre.as_deref().unwrap_or("-"), value_style),
]));
// Section: Metadata
lines.push(Line::from(Span::styled(
"--- Metadata ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Year"), label_style),
Span::styled(
item
.year
.map(|y| y.to_string())
.unwrap_or_else(|| "-".to_string()),
value_style,
),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Title"), label_style),
Span::styled(item.title.as_deref().unwrap_or("-"), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Artist"), label_style),
Span::styled(item.artist.as_deref().unwrap_or("-"), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Album"), label_style),
Span::styled(item.album.as_deref().unwrap_or("-"), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Genre"), label_style),
Span::styled(item.genre.as_deref().unwrap_or("-"), value_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Year"), label_style),
Span::styled(
item.year
.map(|y| y.to_string())
.unwrap_or_else(|| "-".to_string()),
value_style,
),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Duration"), label_style),
Span::styled(
item.duration_secs
.map(format_duration)
.unwrap_or_else(|| "-".to_string()),
value_style,
),
]));
// Description
if let Some(ref desc) = item.description
&& !desc.is_empty()
{
lines.push(Line::default());
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Description"), label_style),
Span::styled(desc.as_str(), value_style),
]));
}
// Custom fields
if !item.custom_fields.is_empty() {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Custom Fields ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
let mut fields: Vec<_> = item.custom_fields.iter().collect();
fields.sort_by_key(|(k, _)| k.as_str());
for (key, field) in fields {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(format!("{key:<label_width$}"), label_style),
Span::styled(
format!("{} ({})", field.value, field.field_type),
value_style,
),
]));
}
}
// Tags section
if !state.tags.is_empty() {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Tags ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
let tag_names: Vec<&str> = state.tags.iter().map(|t| t.name.as_str()).collect();
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(tag_names.join(", "), Style::default().fg(Color::Green)),
]));
}
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Duration"), label_style),
Span::styled(
item
.duration_secs
.map(format_duration)
.unwrap_or_else(|| "-".to_string()),
value_style,
),
]));
// Description
if let Some(ref desc) = item.description
&& !desc.is_empty()
{
lines.push(Line::default());
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Description"), label_style),
Span::styled(desc.as_str(), value_style),
]));
}
// Section: Timestamps
// Custom fields
if !item.custom_fields.is_empty() {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Timestamps ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
"--- Custom Fields ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(vec![
let mut fields: Vec<_> = item.custom_fields.iter().collect();
fields.sort_by_key(|(k, _)| k.as_str());
for (key, field) in fields {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Created"), label_style),
Span::styled(format_date(&item.created_at), dim_style),
]));
Span::styled(format!("{key:<label_width$}"), label_style),
Span::styled(
format!("{} ({})", field.value, field.field_type),
value_style,
),
]));
}
}
// Tags section
if !state.tags.is_empty() {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"--- Tags ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
let tag_names: Vec<&str> =
state.tags.iter().map(|t| t.name.as_str()).collect();
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Updated"), label_style),
Span::styled(format_date(&item.updated_at), dim_style),
Span::raw(pad),
Span::styled(tag_names.join(", "), Style::default().fg(Color::Green)),
]));
}
let title = if let Some(ref title_str) = item.title {
format!(" Detail: {} ", title_str)
} else {
format!(" Detail: {} ", item.file_name)
};
lines.push(Line::default());
let detail = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(title));
// Section: Timestamps
lines.push(Line::from(Span::styled(
"--- Timestamps ---",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
f.render_widget(detail, chunks[0]);
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Created"), label_style),
Span::styled(format_date(&item.created_at), dim_style),
]));
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(make_label("Updated"), label_style),
Span::styled(format_date(&item.updated_at), dim_style),
]));
let title = if let Some(ref title_str) = item.title {
format!(" Detail: {} ", title_str)
} else {
format!(" Detail: {} ", item.file_name)
};
let detail = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(detail, chunks[0]);
}

View file

@ -1,59 +1,62 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem},
};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let items: Vec<ListItem> = if state.duplicate_groups.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" No duplicates found. Press 'r' to refresh.",
Style::default().fg(Color::DarkGray),
)))]
} else {
let mut list_items = Vec::new();
for (i, group) in state.duplicate_groups.iter().enumerate() {
// Show truncated hash (first 16 chars) for identification
let hash_display = if group.content_hash.len() > 16 {
&group.content_hash[..16]
} else {
&group.content_hash
};
let header = format!(
"Group {} ({} items, hash: {}...)",
i + 1,
group.items.len(),
hash_display
);
list_items.push(ListItem::new(Line::from(Span::styled(
header,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
))));
for item in &group.items {
let line = format!(" {} - {}", item.file_name, item.path);
let is_selected = state
.duplicates_selected
.map(|sel| sel == list_items.len())
.unwrap_or(false);
let style = if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
list_items.push(ListItem::new(Line::from(Span::styled(line, style))));
}
list_items.push(ListItem::new(Line::default()));
}
list_items
};
let items: Vec<ListItem> = if state.duplicate_groups.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" No duplicates found. Press 'r' to refresh.",
Style::default().fg(Color::DarkGray),
)))]
} else {
let mut list_items = Vec::new();
for (i, group) in state.duplicate_groups.iter().enumerate() {
// Show truncated hash (first 16 chars) for identification
let hash_display = if group.content_hash.len() > 16 {
&group.content_hash[..16]
} else {
&group.content_hash
};
let header = format!(
"Group {} ({} items, hash: {}...)",
i + 1,
group.items.len(),
hash_display
);
list_items.push(ListItem::new(Line::from(Span::styled(
header,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
))));
for item in &group.items {
let line = format!(" {} - {}", item.file_name, item.path);
let is_selected = state
.duplicates_selected
.map(|sel| sel == list_items.len())
.unwrap_or(false);
let style = if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
list_items.push(ListItem::new(Line::from(Span::styled(line, style))));
}
list_items.push(ListItem::new(Line::default()));
}
list_items
};
let list = List::new(items).block(Block::default().borders(Borders::ALL).title(" Duplicates "));
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(" Duplicates "));
f.render_widget(list, area);
f.render_widget(list, area);
}

View file

@ -1,65 +1,77 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let input = Paragraph::new(state.import_input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(" Import File (enter path and press Enter) "),
)
.style(if state.input_mode {
Style::default().fg(Color::Cyan)
} else {
Style::default()
});
f.render_widget(input, chunks[0]);
let input = Paragraph::new(state.import_input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(" Import File (enter path and press Enter) "),
)
.style(if state.input_mode {
Style::default().fg(Color::Cyan)
} else {
Style::default()
});
f.render_widget(input, chunks[0]);
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let key_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let key_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let help_lines = vec![
Line::default(),
Line::from(Span::styled(
" Import a file or trigger a library scan",
label_style,
)),
Line::default(),
Line::from(vec![
Span::styled(" Enter", key_style),
Span::raw(" Import the file at the entered path"),
]),
Line::from(vec![
Span::styled(" Esc", key_style),
Span::raw(" Cancel and return to library"),
]),
Line::from(vec![
Span::styled(" s", key_style),
Span::raw(" Trigger a full library scan (scans all configured directories)"),
]),
Line::default(),
Line::from(Span::styled(" Tips:", label_style)),
Line::from(" - Enter an absolute path to a media file (e.g. /home/user/music/song.mp3)"),
Line::from(" - The file will be copied into the managed library"),
Line::from(" - Duplicates are detected by content hash and will be skipped"),
Line::from(" - Press 's' (without typing a path) to scan all library directories"),
];
let help_lines = vec![
Line::default(),
Line::from(Span::styled(
" Import a file or trigger a library scan",
label_style,
)),
Line::default(),
Line::from(vec![
Span::styled(" Enter", key_style),
Span::raw(" Import the file at the entered path"),
]),
Line::from(vec![
Span::styled(" Esc", key_style),
Span::raw(" Cancel and return to library"),
]),
Line::from(vec![
Span::styled(" s", key_style),
Span::raw(
" Trigger a full library scan (scans all configured \
directories)",
),
]),
Line::default(),
Line::from(Span::styled(" Tips:", label_style)),
Line::from(
" - Enter an absolute path to a media file (e.g. \
/home/user/music/song.mp3)",
),
Line::from(" - The file will be copied into the managed library"),
Line::from(
" - Duplicates are detected by content hash and will be skipped",
),
Line::from(
" - Press 's' (without typing a path) to scan all library directories",
),
];
let help =
Paragraph::new(help_lines).block(Block::default().borders(Borders::ALL).title(" Help "));
f.render_widget(help, chunks[1]);
let help = Paragraph::new(help_lines)
.block(Block::default().borders(Borders::ALL).title(" Help "));
f.render_widget(help, chunks[1]);
}

View file

@ -1,97 +1,101 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Cell, Row, Table};
use ratatui::{
Frame,
layout::{Constraint, Rect},
style::{Color, Modifier, Style},
text::Span,
widgets::{Block, Borders, Cell, Row, Table},
};
use super::{format_duration, format_size, media_type_color};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["", "Title / Name", "Type", "Duration", "Year", "Size"]).style(
let header =
Row::new(vec!["", "Title / Name", "Type", "Duration", "Year", "Size"])
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.media_list
.iter()
.enumerate()
.map(|(i, item)| {
let is_cursor = Some(i) == state.selected_index;
let is_selected = state.selected_items.contains(&item.id);
let rows: Vec<Row> = state
.media_list
.iter()
.enumerate()
.map(|(i, item)| {
let is_cursor = Some(i) == state.selected_index;
let is_selected = state.selected_items.contains(&item.id);
let style = if is_cursor {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else if is_selected {
Style::default().fg(Color::Black).bg(Color::Green)
} else {
Style::default()
};
let style = if is_cursor {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else if is_selected {
Style::default().fg(Color::Black).bg(Color::Green)
} else {
Style::default()
};
// Selection marker
let marker = if is_selected { "[*]" } else { "[ ]" };
let marker_style = if is_selected {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
// Selection marker
let marker = if is_selected { "[*]" } else { "[ ]" };
let marker_style = if is_selected {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let display_name = item.title.as_deref().unwrap_or(&item.file_name).to_string();
let display_name =
item.title.as_deref().unwrap_or(&item.file_name).to_string();
let type_color = media_type_color(&item.media_type);
let type_cell = Cell::from(Span::styled(
item.media_type.clone(),
Style::default().fg(type_color),
));
let type_color = media_type_color(&item.media_type);
let type_cell = Cell::from(Span::styled(
item.media_type.clone(),
Style::default().fg(type_color),
));
let duration = item
.duration_secs
.map(format_duration)
.unwrap_or_else(|| "-".to_string());
let duration = item
.duration_secs
.map(format_duration)
.unwrap_or_else(|| "-".to_string());
let year = item
.year
.map(|y| y.to_string())
.unwrap_or_else(|| "-".to_string());
let year = item
.year
.map(|y| y.to_string())
.unwrap_or_else(|| "-".to_string());
Row::new(vec![
Cell::from(Span::styled(marker, marker_style)),
Cell::from(display_name),
type_cell,
Cell::from(duration),
Cell::from(year),
Cell::from(format_size(item.file_size)),
])
.style(style)
})
.collect();
Row::new(vec![
Cell::from(Span::styled(marker, marker_style)),
Cell::from(display_name),
type_cell,
Cell::from(duration),
Cell::from(year),
Cell::from(format_size(item.file_size)),
])
.style(style)
})
.collect();
let page = (state.page_offset / state.page_size) + 1;
let item_count = state.media_list.len();
let selected_count = state.selected_items.len();
let title = if selected_count > 0 {
format!(" Library (page {page}, {item_count} items, {selected_count} selected) ")
} else {
format!(" Library (page {page}, {item_count} items) ")
};
let table = Table::new(
rows,
[
Constraint::Length(3), // Selection marker
Constraint::Percentage(33), // Title
Constraint::Percentage(18), // Type
Constraint::Percentage(13), // Duration
Constraint::Percentage(8), // Year
Constraint::Percentage(18), // Size
],
let page = (state.page_offset / state.page_size) + 1;
let item_count = state.media_list.len();
let selected_count = state.selected_items.len();
let title = if selected_count > 0 {
format!(
" Library (page {page}, {item_count} items, {selected_count} selected) "
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
} else {
format!(" Library (page {page}, {item_count} items) ")
};
f.render_widget(table, area);
let table = Table::new(rows, [
Constraint::Length(3), // Selection marker
Constraint::Percentage(33), // Title
Constraint::Percentage(18), // Type
Constraint::Percentage(13), // Duration
Constraint::Percentage(8), // Year
Constraint::Percentage(18), // Size
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
}

View file

@ -1,83 +1,85 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
// Header
let title = if let Some(ref media) = state.selected_media {
format!(" Edit: {} ", media.file_name)
} else {
" Edit Metadata ".to_string()
};
// Header
let title = if let Some(ref media) = state.selected_media {
format!(" Edit: {} ", media.file_name)
} else {
" Edit Metadata ".to_string()
};
let header = Paragraph::new(Line::from(Span::styled(
&title,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)))
.block(Block::default().borders(Borders::ALL));
let header = Paragraph::new(Line::from(Span::styled(
&title,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)))
.block(Block::default().borders(Borders::ALL));
f.render_widget(header, chunks[0]);
f.render_widget(header, chunks[0]);
// Edit fields
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let active_style = Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD);
let pad = " ";
// Edit fields
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let active_style = Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD);
let pad = " ";
let fields = [
("Title", &state.edit_title),
("Artist", &state.edit_artist),
("Album", &state.edit_album),
("Genre", &state.edit_genre),
("Year", &state.edit_year),
("Description", &state.edit_description),
];
let fields = [
("Title", &state.edit_title),
("Artist", &state.edit_artist),
("Album", &state.edit_album),
("Genre", &state.edit_genre),
("Year", &state.edit_year),
("Description", &state.edit_description),
];
let mut lines = Vec::new();
lines.push(Line::default());
let mut lines = Vec::new();
lines.push(Line::default());
for (i, (label, value)) in fields.iter().enumerate() {
let is_active = state.edit_field_index == Some(i);
let style = if is_active { active_style } else { label_style };
let cursor = if is_active { "> " } else { pad };
lines.push(Line::from(vec![
Span::raw(cursor),
Span::styled(format!("{label:<14}"), style),
Span::styled(value.as_str(), value_style),
if is_active {
Span::styled("_", Style::default().fg(Color::Green))
} else {
Span::raw("")
},
]));
}
lines.push(Line::default());
for (i, (label, value)) in fields.iter().enumerate() {
let is_active = state.edit_field_index == Some(i);
let style = if is_active { active_style } else { label_style };
let cursor = if is_active { "> " } else { pad };
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(
"Tab: Next field Enter: Save Esc: Cancel",
Style::default().fg(Color::DarkGray),
),
Span::raw(cursor),
Span::styled(format!("{label:<14}"), style),
Span::styled(value.as_str(), value_style),
if is_active {
Span::styled("_", Style::default().fg(Color::Green))
} else {
Span::raw("")
},
]));
}
let editor =
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Fields "));
lines.push(Line::default());
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(
"Tab: Next field Enter: Save Esc: Cancel",
Style::default().fg(Color::DarkGray),
),
]));
f.render_widget(editor, chunks[1]);
let editor = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(" Fields "));
f.render_widget(editor, chunks[1]);
}

View file

@ -13,178 +13,188 @@ pub mod statistics;
pub mod tags;
pub mod tasks;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Tabs};
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.
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))
}
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.
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}")
}
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
}
// 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,
}
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());
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]);
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]),
}
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]),
}
render_status_bar(f, state, chunks[2]);
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",
"Queue",
"Stats",
"Tasks",
]
.into_iter()
.map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White))))
.collect();
let titles: Vec<Line> = vec![
"Library",
"Search",
"Tags",
"Collections",
"Audit",
"Queue",
"Stats",
"Tasks",
]
.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::Queue => 5,
View::Statistics => 6,
View::Tasks => 7,
};
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::Queue => 5,
View::Statistics => 6,
View::Tasks => 7,
};
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),
);
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);
f.render_widget(tabs, area);
}
fn render_status_bar(f: &mut Frame, state: &AppState, area: Rect) {
let status = if let Some(ref msg) = state.status_message {
msg.clone()
} 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 +:Tag -:Untag r:Refresh ?:Help".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()
}
_ => {
" q:Quit /:Search i:Import o:Open t:Tags c:Coll a:Audit D:Dupes B:DB Q:Queue X:Stats T:Tasks ?:Help"
.to_string()
}
}
};
let status = if let Some(ref msg) = state.status_message {
msg.clone()
} 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 +:Tag -:Untag r:Refresh ?:Help"
.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()
},
_ => " q:Quit /:Search i:Import o:Open t:Tags c:Coll a:Audit \
D:Dupes B:DB Q:Queue X:Stats T:Tasks ?:Help"
.to_string(),
}
};
let paragraph = Paragraph::new(Line::from(Span::styled(
status,
Style::default().fg(Color::DarkGray),
)));
f.render_widget(paragraph, area);
let paragraph = Paragraph::new(Line::from(Span::styled(
status,
Style::default().fg(Color::DarkGray),
)));
f.render_widget(paragraph, area);
}

View file

@ -1,69 +1,72 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem},
};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let items: Vec<ListItem> = if state.play_queue.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" Queue is empty. Select items in the library and press 'q' to add.",
Style::default().fg(Color::DarkGray),
)))]
} else {
state
.play_queue
.iter()
.enumerate()
.map(|(i, item)| {
let is_current = state.queue_current_index == Some(i);
let is_selected = state.queue_selected == Some(i);
let prefix = if is_current { ">> " } else { " " };
let type_color = super::media_type_color(&item.media_type);
let id_suffix = if item.media_id.len() > 8 {
&item.media_id[item.media_id.len() - 8..]
} else {
&item.media_id
};
let text = if let Some(ref artist) = item.artist {
format!("{prefix}{} - {} [{}]", item.title, artist, id_suffix)
} else {
format!("{prefix}{} [{}]", item.title, id_suffix)
};
let items: Vec<ListItem> = if state.play_queue.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" Queue is empty. Select items in the library and press 'q' to add.",
Style::default().fg(Color::DarkGray),
)))]
} else {
state
.play_queue
.iter()
.enumerate()
.map(|(i, item)| {
let is_current = state.queue_current_index == Some(i);
let is_selected = state.queue_selected == Some(i);
let prefix = if is_current { ">> " } else { " " };
let type_color = super::media_type_color(&item.media_type);
let id_suffix = if item.media_id.len() > 8 {
&item.media_id[item.media_id.len() - 8..]
} else {
&item.media_id
};
let text = if let Some(ref artist) = item.artist {
format!("{prefix}{} - {} [{}]", item.title, artist, id_suffix)
} else {
format!("{prefix}{} [{}]", item.title, id_suffix)
};
let style = if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if is_current {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(type_color)
};
let style = if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if is_current {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(type_color)
};
ListItem::new(Line::from(Span::styled(text, style)))
})
.collect()
};
ListItem::new(Line::from(Span::styled(text, style)))
})
.collect()
};
let repeat_str = match state.queue_repeat {
0 => "Off",
1 => "One",
_ => "All",
};
let shuffle_str = if state.queue_shuffle { "On" } else { "Off" };
let title = format!(
" Queue ({}) | Repeat: {} | Shuffle: {} ",
state.play_queue.len(),
repeat_str,
shuffle_str,
);
let repeat_str = match state.queue_repeat {
0 => "Off",
1 => "One",
_ => "All",
};
let shuffle_str = if state.queue_shuffle { "On" } else { "Off" };
let title = format!(
" Queue ({}) | Repeat: {} | Shuffle: {} ",
state.play_queue.len(),
repeat_str,
shuffle_str,
);
let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title));
let list =
List::new(items).block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(list, area);
f.render_widget(list, area);
}

View file

@ -1,103 +1,104 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::Span,
widgets::{Block, Borders, Cell, Paragraph, Row, Table},
};
use super::{format_size, media_type_color};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
// Search input
let input = Paragraph::new(state.search_input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(" Search (type and press Enter) "),
)
.style(if state.input_mode {
Style::default().fg(Color::Cyan)
} else {
Style::default()
});
f.render_widget(input, chunks[0]);
// Results
let header = Row::new(vec!["", "Name", "Type", "Artist", "Size"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.search_results
.iter()
.enumerate()
.map(|(i, item)| {
let is_cursor = Some(i) == state.search_selected;
let is_selected = state.selected_items.contains(&item.id);
let style = if is_cursor {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else if is_selected {
Style::default().fg(Color::Black).bg(Color::Green)
} else {
Style::default()
};
// Selection marker
let marker = if is_selected { "[*]" } else { "[ ]" };
let marker_style = if is_selected {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let type_color = media_type_color(&item.media_type);
let type_cell = Cell::from(Span::styled(
item.media_type.clone(),
Style::default().fg(type_color),
));
Row::new(vec![
Cell::from(Span::styled(marker, marker_style)),
Cell::from(item.file_name.clone()),
type_cell,
Cell::from(item.artist.clone().unwrap_or_default()),
Cell::from(format_size(item.file_size)),
])
.style(style)
})
.collect();
let shown = state.search_results.len();
let total = state.search_total_count;
let selected_count = state.selected_items.len();
let results_title = if selected_count > 0 {
format!(" Results: {shown} shown, {total} total, {selected_count} selected ")
} else {
format!(" Results: {shown} shown, {total} total ")
};
let table = Table::new(
rows,
[
Constraint::Length(3), // Selection marker
Constraint::Percentage(33), // Name
Constraint::Percentage(18), // Type
Constraint::Percentage(23), // Artist
Constraint::Percentage(18), // Size
],
// Search input
let input = Paragraph::new(state.search_input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title(" Search (type and press Enter) "),
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(results_title));
.style(if state.input_mode {
Style::default().fg(Color::Cyan)
} else {
Style::default()
});
f.render_widget(input, chunks[0]);
f.render_widget(table, chunks[1]);
// Results
let header = Row::new(vec!["", "Name", "Type", "Artist", "Size"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.search_results
.iter()
.enumerate()
.map(|(i, item)| {
let is_cursor = Some(i) == state.search_selected;
let is_selected = state.selected_items.contains(&item.id);
let style = if is_cursor {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else if is_selected {
Style::default().fg(Color::Black).bg(Color::Green)
} else {
Style::default()
};
// Selection marker
let marker = if is_selected { "[*]" } else { "[ ]" };
let marker_style = if is_selected {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let type_color = media_type_color(&item.media_type);
let type_cell = Cell::from(Span::styled(
item.media_type.clone(),
Style::default().fg(type_color),
));
Row::new(vec![
Cell::from(Span::styled(marker, marker_style)),
Cell::from(item.file_name.clone()),
type_cell,
Cell::from(item.artist.clone().unwrap_or_default()),
Cell::from(format_size(item.file_size)),
])
.style(style)
})
.collect();
let shown = state.search_results.len();
let total = state.search_total_count;
let selected_count = state.selected_items.len();
let results_title = if selected_count > 0 {
format!(
" Results: {shown} shown, {total} total, {selected_count} selected "
)
} else {
format!(" Results: {shown} shown, {total} total ")
};
let table = Table::new(rows, [
Constraint::Length(3), // Selection marker
Constraint::Percentage(33), // Name
Constraint::Percentage(18), // Type
Constraint::Percentage(23), // Artist
Constraint::Percentage(18), // Size
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(results_title));
f.render_widget(table, chunks[1]);
}

View file

@ -1,82 +1,84 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let section_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(Color::White);
let section_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let pad = " ";
let pad = " ";
let lines = vec![
Line::default(),
Line::from(Span::styled("--- Connection ---", section_style)),
Line::from(vec![
Span::raw(pad),
Span::styled("Server URL: ", label_style),
Span::styled(&state.server_url, value_style),
]),
Line::default(),
Line::from(Span::styled("--- Library ---", section_style)),
Line::from(vec![
Span::raw(pad),
Span::styled("Total items: ", label_style),
Span::styled(state.total_media_count.to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("Page size: ", label_style),
Span::styled(state.page_size.to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("Current page: ", label_style),
Span::styled(
((state.page_offset / state.page_size) + 1).to_string(),
value_style,
),
]),
Line::default(),
Line::from(Span::styled("--- State ---", section_style)),
Line::from(vec![
Span::raw(pad),
Span::styled("Tags loaded: ", label_style),
Span::styled(state.tags.len().to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("All tags: ", label_style),
Span::styled(state.all_tags.len().to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("Collections: ", label_style),
Span::styled(state.collections.len().to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("Audit entries: ", label_style),
Span::styled(state.audit_log.len().to_string(), value_style),
]),
Line::default(),
Line::from(Span::styled("--- Shortcuts ---", section_style)),
Line::from(vec![
Span::raw(pad),
Span::raw("Press Esc to return to the library view"),
]),
];
let lines = vec![
Line::default(),
Line::from(Span::styled("--- Connection ---", section_style)),
Line::from(vec![
Span::raw(pad),
Span::styled("Server URL: ", label_style),
Span::styled(&state.server_url, value_style),
]),
Line::default(),
Line::from(Span::styled("--- Library ---", section_style)),
Line::from(vec![
Span::raw(pad),
Span::styled("Total items: ", label_style),
Span::styled(state.total_media_count.to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("Page size: ", label_style),
Span::styled(state.page_size.to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("Current page: ", label_style),
Span::styled(
((state.page_offset / state.page_size) + 1).to_string(),
value_style,
),
]),
Line::default(),
Line::from(Span::styled("--- State ---", section_style)),
Line::from(vec![
Span::raw(pad),
Span::styled("Tags loaded: ", label_style),
Span::styled(state.tags.len().to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("All tags: ", label_style),
Span::styled(state.all_tags.len().to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("Collections: ", label_style),
Span::styled(state.collections.len().to_string(), value_style),
]),
Line::from(vec![
Span::raw(pad),
Span::styled("Audit entries: ", label_style),
Span::styled(state.audit_log.len().to_string(), value_style),
]),
Line::default(),
Line::from(Span::styled("--- Shortcuts ---", section_style)),
Line::from(vec![
Span::raw(pad),
Span::raw("Press Esc to return to the library view"),
]),
];
let settings =
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Settings "));
let settings = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(" Settings "));
f.render_widget(settings, area);
f.render_widget(settings, area);
}

View file

@ -1,183 +1,189 @@
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Row, Table},
};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let Some(ref stats) = state.library_stats else {
let msg = Paragraph::new("Loading statistics... (press X to refresh)")
.block(Block::default().borders(Borders::ALL).title(" Statistics "));
f.render_widget(msg, area);
return;
};
let Some(ref stats) = state.library_stats else {
let msg = Paragraph::new("Loading statistics... (press X to refresh)")
.block(Block::default().borders(Borders::ALL).title(" Statistics "));
f.render_widget(msg, area);
return;
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(8), // Overview
Constraint::Length(10), // Media by type
Constraint::Min(6), // Top tags & collections
])
.split(area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(8), // Overview
Constraint::Length(10), // Media by type
Constraint::Min(6), // Top tags & collections
])
.split(area);
// Overview section
let overview_lines = vec![
Line::from(vec![
Span::styled(" Total Media: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.total_media.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("Total Size: ", Style::default().fg(Color::Gray)),
Span::styled(
super::format_size(stats.total_size_bytes),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Avg Size: ", Style::default().fg(Color::Gray)),
Span::styled(
super::format_size(stats.avg_file_size_bytes),
Style::default().fg(Color::White),
),
]),
Line::from(vec![
Span::styled(" Tags: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.total_tags.to_string(),
Style::default().fg(Color::Green),
),
Span::raw(" "),
Span::styled("Collections: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.total_collections.to_string(),
Style::default().fg(Color::Green),
),
Span::raw(" "),
Span::styled("Duplicates: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.total_duplicates.to_string(),
Style::default().fg(Color::Yellow),
),
]),
Line::from(vec![
Span::styled(" Newest: ", Style::default().fg(Color::Gray)),
Span::styled(
stats
.newest_item
.as_deref()
.map(super::format_date)
.unwrap_or("-"),
Style::default().fg(Color::White),
),
Span::raw(" "),
Span::styled("Oldest: ", Style::default().fg(Color::Gray)),
Span::styled(
stats
.oldest_item
.as_deref()
.map(super::format_date)
.unwrap_or("-"),
Style::default().fg(Color::White),
),
]),
];
// Overview section
let overview_lines = vec![
Line::from(vec![
Span::styled(" Total Media: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.total_media.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("Total Size: ", Style::default().fg(Color::Gray)),
Span::styled(
super::format_size(stats.total_size_bytes),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Avg Size: ", Style::default().fg(Color::Gray)),
Span::styled(
super::format_size(stats.avg_file_size_bytes),
Style::default().fg(Color::White),
),
]),
Line::from(vec![
Span::styled(" Tags: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.total_tags.to_string(),
Style::default().fg(Color::Green),
),
Span::raw(" "),
Span::styled("Collections: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.total_collections.to_string(),
Style::default().fg(Color::Green),
),
Span::raw(" "),
Span::styled("Duplicates: ", Style::default().fg(Color::Gray)),
Span::styled(
stats.total_duplicates.to_string(),
Style::default().fg(Color::Yellow),
),
]),
Line::from(vec![
Span::styled(" Newest: ", Style::default().fg(Color::Gray)),
Span::styled(
stats
.newest_item
.as_deref()
.map(super::format_date)
.unwrap_or("-"),
Style::default().fg(Color::White),
),
Span::raw(" "),
Span::styled("Oldest: ", Style::default().fg(Color::Gray)),
Span::styled(
stats
.oldest_item
.as_deref()
.map(super::format_date)
.unwrap_or("-"),
Style::default().fg(Color::White),
),
]),
];
let overview = Paragraph::new(overview_lines)
.block(Block::default().borders(Borders::ALL).title(" Overview "));
f.render_widget(overview, chunks[0]);
let overview = Paragraph::new(overview_lines)
.block(Block::default().borders(Borders::ALL).title(" Overview "));
f.render_widget(overview, chunks[0]);
// Media by Type table
let type_rows: Vec<Row> = stats
.media_by_type
.iter()
.map(|tc| {
let color = super::media_type_color(&tc.name);
Row::new(vec![
Span::styled(tc.name.clone(), Style::default().fg(color)),
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
])
})
.collect();
// Media by Type table
let type_rows: Vec<Row> = stats
.media_by_type
.iter()
.map(|tc| {
let color = super::media_type_color(&tc.name);
Row::new(vec![
Span::styled(tc.name.clone(), Style::default().fg(color)),
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
])
})
.collect();
let storage_rows: Vec<Row> = stats
.storage_by_type
.iter()
.map(|tc| {
Row::new(vec![
Span::styled(tc.name.clone(), Style::default().fg(Color::Gray)),
Span::styled(
super::format_size(tc.count),
Style::default().fg(Color::White),
),
])
})
.collect();
let storage_rows: Vec<Row> = stats
.storage_by_type
.iter()
.map(|tc| {
Row::new(vec![
Span::styled(tc.name.clone(), Style::default().fg(Color::Gray)),
Span::styled(
super::format_size(tc.count),
Style::default().fg(Color::White),
),
])
})
.collect();
let type_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[1]);
let type_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[1]);
let type_table = Table::new(type_rows, [Constraint::Min(20), Constraint::Length(10)]).block(
Block::default()
.borders(Borders::ALL)
.title(" Media by Type "),
let type_table =
Table::new(type_rows, [Constraint::Min(20), Constraint::Length(10)]).block(
Block::default()
.borders(Borders::ALL)
.title(" Media by Type "),
);
f.render_widget(type_table, type_cols[0]);
f.render_widget(type_table, type_cols[0]);
let storage_table = Table::new(storage_rows, [Constraint::Min(20), Constraint::Length(12)])
.block(
Block::default()
.borders(Borders::ALL)
.title(" Storage by Type "),
);
f.render_widget(storage_table, type_cols[1]);
// Top tags and collections
let bottom_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[2]);
let tag_rows: Vec<Row> = stats
.top_tags
.iter()
.map(|tc| {
Row::new(vec![
Span::styled(tc.name.clone(), Style::default().fg(Color::Green)),
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
])
})
.collect();
let tags_table = Table::new(tag_rows, [Constraint::Min(20), Constraint::Length(10)])
.block(Block::default().borders(Borders::ALL).title(" Top Tags "));
f.render_widget(tags_table, bottom_cols[0]);
let col_rows: Vec<Row> = stats
.top_collections
.iter()
.map(|tc| {
Row::new(vec![
Span::styled(tc.name.clone(), Style::default().fg(Color::Magenta)),
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
])
})
.collect();
let cols_table = Table::new(col_rows, [Constraint::Min(20), Constraint::Length(10)]).block(
let storage_table =
Table::new(storage_rows, [Constraint::Min(20), Constraint::Length(12)])
.block(
Block::default()
.borders(Borders::ALL)
.title(" Top Collections "),
.borders(Borders::ALL)
.title(" Storage by Type "),
);
f.render_widget(storage_table, type_cols[1]);
// Top tags and collections
let bottom_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[2]);
let tag_rows: Vec<Row> = stats
.top_tags
.iter()
.map(|tc| {
Row::new(vec![
Span::styled(tc.name.clone(), Style::default().fg(Color::Green)),
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
])
})
.collect();
let tags_table =
Table::new(tag_rows, [Constraint::Min(20), Constraint::Length(10)])
.block(Block::default().borders(Borders::ALL).title(" Top Tags "));
f.render_widget(tags_table, bottom_cols[0]);
let col_rows: Vec<Row> = stats
.top_collections
.iter()
.map(|tc| {
Row::new(vec![
Span::styled(tc.name.clone(), Style::default().fg(Color::Magenta)),
Span::styled(tc.count.to_string(), Style::default().fg(Color::White)),
])
})
.collect();
let cols_table =
Table::new(col_rows, [Constraint::Min(20), Constraint::Length(10)]).block(
Block::default()
.borders(Borders::ALL)
.title(" Top Collections "),
);
f.render_widget(cols_table, bottom_cols[1]);
f.render_widget(cols_table, bottom_cols[1]);
}

View file

@ -1,61 +1,62 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Row, Table};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Row, Table},
};
use super::format_date;
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let header = Row::new(vec!["Name", "Parent", "Created"]).style(
let header = Row::new(vec!["Name", "Parent", "Created"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = state
.tags
.iter()
.enumerate()
.map(|(i, tag)| {
let style = if Some(i) == state.tag_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
};
let rows: Vec<Row> = state
.tags
.iter()
.enumerate()
.map(|(i, tag)| {
let style = if Some(i) == state.tag_selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default()
};
// Resolve parent tag name from the tags list itself
let parent_display = match &tag.parent_id {
Some(pid) => {
state
.tags
.iter()
.find(|t| t.id == *pid)
.map(|t| t.name.clone())
.unwrap_or_else(|| pid.chars().take(8).collect::<String>() + "...")
},
None => "-".to_string(),
};
// Resolve parent tag name from the tags list itself
let parent_display = match &tag.parent_id {
Some(pid) => state
.tags
.iter()
.find(|t| t.id == *pid)
.map(|t| t.name.clone())
.unwrap_or_else(|| pid.chars().take(8).collect::<String>() + "..."),
None => "-".to_string(),
};
Row::new(vec![
tag.name.clone(),
parent_display,
format_date(&tag.created_at).to_string(),
])
.style(style)
})
.collect();
Row::new(vec![
tag.name.clone(),
parent_display,
format_date(&tag.created_at).to_string(),
])
.style(style)
})
.collect();
let title = format!(" Tags ({}) ", state.tags.len());
let title = format!(" Tags ({}) ", state.tags.len());
let table = Table::new(rows, [
ratatui::layout::Constraint::Percentage(40),
ratatui::layout::Constraint::Percentage(30),
ratatui::layout::Constraint::Percentage(30),
])
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
let table = Table::new(
rows,
[
ratatui::layout::Constraint::Percentage(40),
ratatui::layout::Constraint::Percentage(30),
ratatui::layout::Constraint::Percentage(30),
],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(table, area);
f.render_widget(table, area);
}

View file

@ -1,69 +1,73 @@
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem},
};
use crate::app::AppState;
pub fn render(f: &mut Frame, state: &AppState, area: Rect) {
let items: Vec<ListItem> = if state.scheduled_tasks.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" No scheduled tasks. Press T to refresh.",
Style::default().fg(Color::DarkGray),
)))]
} else {
state
.scheduled_tasks
.iter()
.enumerate()
.map(|(i, task)| {
let is_selected = state.scheduled_tasks_selected == Some(i);
let enabled_marker = if task.enabled { "[ON] " } else { "[OFF]" };
let enabled_color = if task.enabled {
Color::Green
} else {
Color::DarkGray
};
let items: Vec<ListItem> = if state.scheduled_tasks.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" No scheduled tasks. Press T to refresh.",
Style::default().fg(Color::DarkGray),
)))]
} else {
state
.scheduled_tasks
.iter()
.enumerate()
.map(|(i, task)| {
let is_selected = state.scheduled_tasks_selected == Some(i);
let enabled_marker = if task.enabled { "[ON] " } else { "[OFF]" };
let enabled_color = if task.enabled {
Color::Green
} else {
Color::DarkGray
};
let last_run = task
.last_run
.as_deref()
.map(super::format_date)
.unwrap_or("-");
let next_run = task
.next_run
.as_deref()
.map(super::format_date)
.unwrap_or("-");
let status = task.last_status.as_deref().unwrap_or("-");
// Show abbreviated task ID (first 8 chars)
let task_id_short = if task.id.len() > 8 {
&task.id[..8]
} else {
&task.id
};
let last_run = task
.last_run
.as_deref()
.map(super::format_date)
.unwrap_or("-");
let next_run = task
.next_run
.as_deref()
.map(super::format_date)
.unwrap_or("-");
let status = task.last_status.as_deref().unwrap_or("-");
// Show abbreviated task ID (first 8 chars)
let task_id_short = if task.id.len() > 8 {
&task.id[..8]
} else {
&task.id
};
let text = format!(
" {enabled_marker} [{task_id_short}] {:<20} {:<16} Last: {:<12} Next: {:<12} Status: {}",
task.name, task.schedule, last_run, next_run, status
);
let text = format!(
" {enabled_marker} [{task_id_short}] {:<20} {:<16} Last: {:<12} \
Next: {:<12} Status: {}",
task.name, task.schedule, last_run, next_run, status
);
let style = if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(enabled_color)
};
let style = if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(enabled_color)
};
ListItem::new(Line::from(Span::styled(text, style)))
})
.collect()
};
ListItem::new(Line::from(Span::styled(text, style)))
})
.collect()
};
let title = format!(" Scheduled Tasks ({}) ", state.scheduled_tasks.len());
let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title));
let title = format!(" Scheduled Tasks ({}) ", state.scheduled_tasks.len());
let list =
List::new(items).block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(list, area);
f.render_widget(list, area);
}