All checks were successful
Build and Release / build-and-release (push) Successful in 55s
Display cache duration as "1.5s" instead of "Cache:1.5s" in bottom status bar for cleaner presentation alongside other technical metrics.
694 lines
26 KiB
Rust
694 lines
26 KiB
Rust
mod theme;
|
|
|
|
use crate::player::Player;
|
|
use crate::state::{AppState, PlayerState, ContextMenu, ContextMenuType, PlayMode};
|
|
use ratatui::{
|
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
|
style::{Color, Modifier, Style},
|
|
text::{Line, Span},
|
|
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Clear},
|
|
Frame,
|
|
};
|
|
use theme::Theme;
|
|
|
|
pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) -> (Rect, Rect, Rect) {
|
|
// Clear background
|
|
frame.render_widget(
|
|
Block::default().style(Theme::secondary()),
|
|
frame.area(),
|
|
);
|
|
|
|
// Three-section layout: title bar, main content, statusbar (like cm-dashboard)
|
|
let main_chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(1), // Title bar
|
|
Constraint::Min(0), // Main content
|
|
Constraint::Length(1), // Status bar
|
|
])
|
|
.split(frame.area());
|
|
|
|
// Main content: left (files) | right (status + playlist)
|
|
let content_chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
|
.split(main_chunks[1]);
|
|
|
|
render_title_bar(frame, state, player, main_chunks[0]);
|
|
render_file_panel(frame, state, content_chunks[0]);
|
|
render_right_panel(frame, state, content_chunks[1]);
|
|
render_status_bar(frame, state, player, main_chunks[2]);
|
|
|
|
// Show confirmation popup if needed
|
|
if state.show_refresh_confirm {
|
|
render_confirm_popup(frame, "Refresh library?", "This may take a while");
|
|
}
|
|
|
|
// Show context menu if needed
|
|
if let Some(ref menu) = state.context_menu {
|
|
render_context_menu(frame, menu);
|
|
}
|
|
|
|
// Return title bar area, file panel area, and playlist area for mouse event handling
|
|
(main_chunks[0], content_chunks[0], content_chunks[1])
|
|
}
|
|
|
|
fn highlight_search_matches<'a>(text: &str, query: &str, is_selected: bool) -> Vec<Span<'a>> {
|
|
let query_lower = query.to_lowercase();
|
|
|
|
let mut spans = Vec::new();
|
|
let mut query_chars = query_lower.chars();
|
|
let mut current_query_char = query_chars.next();
|
|
let mut current_segment = String::new();
|
|
|
|
for ch in text.chars() {
|
|
let ch_lower = ch.to_lowercase().next().unwrap();
|
|
|
|
if let Some(query_ch) = current_query_char {
|
|
if ch_lower == query_ch {
|
|
// Found a match - flush current segment
|
|
if !current_segment.is_empty() {
|
|
spans.push(Span::raw(current_segment.clone()));
|
|
current_segment.clear();
|
|
}
|
|
// Add matched character with styling
|
|
if is_selected {
|
|
// On selected row: bold black text on selection bar (yellow or blue)
|
|
spans.push(Span::styled(
|
|
ch.to_string(),
|
|
Style::default()
|
|
.fg(Theme::background())
|
|
.add_modifier(Modifier::BOLD),
|
|
));
|
|
} else {
|
|
// Other rows: just green text
|
|
spans.push(Span::styled(
|
|
ch.to_string(),
|
|
Style::default().fg(Theme::success()),
|
|
));
|
|
}
|
|
// Move to next query character
|
|
current_query_char = query_chars.next();
|
|
} else {
|
|
// No match - add to current segment
|
|
current_segment.push(ch);
|
|
}
|
|
} else {
|
|
// No more query characters to match
|
|
current_segment.push(ch);
|
|
}
|
|
}
|
|
|
|
// Flush remaining segment
|
|
if !current_segment.is_empty() {
|
|
spans.push(Span::raw(current_segment));
|
|
}
|
|
|
|
spans
|
|
}
|
|
|
|
fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
|
|
// Calculate visible height (subtract 2 for borders)
|
|
let visible_height = area.height.saturating_sub(2) as usize;
|
|
// Store visible height for keyboard navigation scroll calculations
|
|
state.file_panel_visible_height = visible_height;
|
|
|
|
let in_search = !state.focus_playlist && (state.search_mode || !state.search_matches.is_empty());
|
|
let search_query = if in_search { state.search_query.to_lowercase() } else { String::new() };
|
|
|
|
// Calculate how many items are below the visible area
|
|
let total_items = state.flattened_items.len();
|
|
let visible_end = state.scroll_offset + visible_height;
|
|
let items_below = if visible_end < total_items {
|
|
total_items - visible_end
|
|
} else {
|
|
0
|
|
};
|
|
|
|
// Reserve one line for "X more below" if needed
|
|
let list_visible_height = if items_below > 0 {
|
|
visible_height.saturating_sub(1)
|
|
} else {
|
|
visible_height
|
|
};
|
|
|
|
let mut items: Vec<ListItem> = state
|
|
.flattened_items
|
|
.iter()
|
|
.skip(state.scroll_offset)
|
|
.take(list_visible_height)
|
|
.enumerate()
|
|
.map(|(display_idx, item)| {
|
|
let idx = state.scroll_offset + display_idx;
|
|
let indent = " ".repeat(item.depth);
|
|
let mark = if state.marked_files.contains(&item.node.path) { "* " } else { "" };
|
|
|
|
// Build name with search highlighting
|
|
// Only show selection bar when file panel has focus
|
|
let is_selected = !state.focus_playlist && idx == state.selected_index;
|
|
|
|
// Add icon for directories and files
|
|
let icon = if item.node.is_dir {
|
|
let is_expanded = state.expanded_dirs.contains(&item.node.path);
|
|
// Nerd font folder icons: \u{eaf7} = open, \u{ea83} = closed
|
|
let icon_char = if is_expanded { "\u{eaf7} " } else { "\u{ea83} " };
|
|
|
|
// Bold black icon on selection bar, blue otherwise
|
|
if is_selected {
|
|
Span::styled(icon_char, Style::default().fg(Theme::background()).add_modifier(Modifier::BOLD))
|
|
} else {
|
|
Span::styled(icon_char, Style::default().fg(Theme::highlight()))
|
|
}
|
|
} else {
|
|
// File icons based on extension
|
|
let extension = item.node.path.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
|
|
let (icon_char, color) = match extension.as_str() {
|
|
// Audio files - music note icon
|
|
"mp3" | "flac" | "wav" | "ogg" | "m4a" | "aac" | "wma" | "opus" =>
|
|
("\u{f0e2a} ", Theme::success()), //
|
|
// Video files - film icon
|
|
"mp4" | "mkv" | "avi" | "mov" | "webm" | "flv" | "wmv" | "m4v" =>
|
|
("\u{f1c8} ", Theme::warning()), //
|
|
_ => (" ", Theme::foreground()),
|
|
};
|
|
|
|
if is_selected {
|
|
Span::styled(icon_char, Style::default().fg(Theme::background()).add_modifier(Modifier::BOLD))
|
|
} else {
|
|
Span::styled(icon_char, Style::default().fg(color))
|
|
}
|
|
};
|
|
let name_spans = if in_search && !search_query.is_empty() {
|
|
highlight_search_matches(&item.node.name, &search_query, is_selected)
|
|
} else {
|
|
vec![Span::raw(&item.node.name)]
|
|
};
|
|
|
|
let suffix = if item.node.is_dir { "/" } else { "" };
|
|
|
|
let base_style = if is_selected {
|
|
// Selection bar: yellow/orange when in search (typing or viewing results), blue otherwise
|
|
if in_search {
|
|
Theme::search_selected()
|
|
} else {
|
|
Theme::selected()
|
|
}
|
|
} else if state.marked_files.contains(&item.node.path) {
|
|
Theme::marked()
|
|
} else {
|
|
Theme::secondary()
|
|
};
|
|
|
|
let mut line_spans = vec![
|
|
Span::raw(indent),
|
|
Span::raw(mark),
|
|
icon,
|
|
];
|
|
line_spans.extend(name_spans);
|
|
line_spans.push(Span::raw(suffix));
|
|
|
|
let line = Line::from(line_spans);
|
|
|
|
ListItem::new(line).style(base_style)
|
|
})
|
|
.collect();
|
|
|
|
// Add "... X more below" message if content was truncated
|
|
if items_below > 0 {
|
|
let more_text = format!("... {} more below", items_below);
|
|
let more_item = ListItem::new(more_text)
|
|
.style(Style::default().fg(Theme::dim_foreground()).bg(Theme::background()));
|
|
items.push(more_item);
|
|
}
|
|
|
|
let list = List::new(items)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("files")
|
|
.style(Theme::widget_border_style())
|
|
.title_style(Theme::title_style()),
|
|
);
|
|
|
|
let mut list_state = ListState::default();
|
|
// Don't set selection to avoid automatic scrolling - we manage scroll manually
|
|
// Just set the offset (always 0 since we manually slice the items)
|
|
*list_state.offset_mut() = 0;
|
|
frame.render_stateful_widget(list, area, &mut list_state);
|
|
}
|
|
|
|
fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
|
|
// Calculate visible height (subtract 2 for borders)
|
|
let visible_height = area.height.saturating_sub(2) as usize;
|
|
// Store visible height for keyboard navigation scroll calculations
|
|
state.playlist_visible_height = visible_height;
|
|
|
|
// Calculate how many items are below the visible area
|
|
let total_playlist = state.playlist.len();
|
|
let visible_end = state.playlist_scroll_offset + visible_height;
|
|
let items_below = if visible_end < total_playlist {
|
|
total_playlist - visible_end
|
|
} else {
|
|
0
|
|
};
|
|
|
|
// Reserve one line for "X more below" if needed
|
|
let list_visible_height = if items_below > 0 {
|
|
visible_height.saturating_sub(1)
|
|
} else {
|
|
visible_height
|
|
};
|
|
|
|
// Check if in playlist search mode
|
|
let in_playlist_search = state.focus_playlist && (state.search_mode || !state.playlist_tab_search_results.is_empty() || !state.playlist_search_matches.is_empty());
|
|
let playlist_search_query = if in_playlist_search { state.search_query.to_lowercase() } else { String::new() };
|
|
|
|
// Playlist panel (no longer need the player status box)
|
|
let mut playlist_items: Vec<ListItem> = state
|
|
.playlist
|
|
.iter()
|
|
.skip(state.playlist_scroll_offset)
|
|
.take(list_visible_height)
|
|
.enumerate()
|
|
.map(|(display_idx, path)| {
|
|
let idx = state.playlist_scroll_offset + display_idx;
|
|
let filename = path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
|
|
|
let is_selected = state.focus_playlist && idx == state.selected_playlist_index;
|
|
let is_playing = idx == state.playlist_index;
|
|
|
|
// Build line with search highlighting if searching
|
|
let line = if in_playlist_search && !playlist_search_query.is_empty() {
|
|
Line::from(highlight_search_matches(&filename, &playlist_search_query, is_selected))
|
|
} else {
|
|
Line::from(filename)
|
|
};
|
|
|
|
let style = if is_selected && is_playing {
|
|
// Both selected and playing: selection bar with bold
|
|
if in_playlist_search {
|
|
Theme::search_selected().add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Theme::selected().add_modifier(Modifier::BOLD)
|
|
}
|
|
} else if is_selected {
|
|
// Selection bar when playlist is focused
|
|
if in_playlist_search {
|
|
Theme::search_selected()
|
|
} else {
|
|
Theme::selected()
|
|
}
|
|
} else if is_playing {
|
|
// Current playing file: white bold
|
|
Style::default()
|
|
.fg(Theme::bright_foreground())
|
|
.bg(Theme::background())
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Theme::secondary()
|
|
};
|
|
|
|
ListItem::new(line).style(style)
|
|
})
|
|
.collect();
|
|
|
|
// Add "... X more below" message if content was truncated
|
|
if items_below > 0 {
|
|
let more_text = format!("... {} more below", items_below);
|
|
let more_item = ListItem::new(more_text)
|
|
.style(Style::default().fg(Theme::dim_foreground()).bg(Theme::background()));
|
|
playlist_items.push(more_item);
|
|
}
|
|
|
|
let playlist_title = if !state.playlist.is_empty() {
|
|
format!("playlist [{}/{}]", state.playlist_index + 1, state.playlist.len())
|
|
} else {
|
|
"playlist (empty)".to_string()
|
|
};
|
|
|
|
let playlist_widget = List::new(playlist_items)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title(playlist_title)
|
|
.style(Theme::widget_border_style())
|
|
.title_style(Theme::title_style()),
|
|
);
|
|
|
|
let mut playlist_state = ListState::default();
|
|
// Don't set selection to avoid automatic scrolling - we manage scroll manually
|
|
// Just set the offset (always 0 since we manually slice the items)
|
|
*playlist_state.offset_mut() = 0;
|
|
frame.render_stateful_widget(playlist_widget, area, &mut playlist_state);
|
|
}
|
|
|
|
fn render_title_bar(frame: &mut Frame, state: &AppState, _player: &Player, area: Rect) {
|
|
let background_color = match state.player_state {
|
|
PlayerState::Playing => Theme::success(), // Green for playing
|
|
PlayerState::Paused => Theme::highlight(), // Blue for paused
|
|
PlayerState::Stopped => Theme::dim_foreground(), // Gray for stopped
|
|
};
|
|
|
|
// Split the title bar into left and right sections
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([Constraint::Length(18), Constraint::Min(0)])
|
|
.split(area);
|
|
|
|
// Left side: "cm-player" text with version
|
|
let title_text = format!(" cm-player v{}", env!("CARGO_PKG_VERSION"));
|
|
let left_span = Span::styled(
|
|
&title_text,
|
|
Style::default()
|
|
.fg(Theme::background())
|
|
.bg(background_color)
|
|
.add_modifier(Modifier::BOLD)
|
|
);
|
|
let left_title = Paragraph::new(Line::from(vec![left_span]))
|
|
.style(Style::default().bg(background_color));
|
|
frame.render_widget(left_title, chunks[0]);
|
|
|
|
// Right side: Status • Progress • Volume • Search (if active)
|
|
let mut right_spans = Vec::new();
|
|
|
|
if state.is_refreshing {
|
|
// Show only "Refreshing library..." when refreshing
|
|
right_spans.push(Span::styled(
|
|
"Refreshing library... ",
|
|
Style::default()
|
|
.fg(Theme::background())
|
|
.bg(background_color)
|
|
.add_modifier(Modifier::BOLD)
|
|
));
|
|
} else {
|
|
// Status (bold when playing)
|
|
let status_text = match state.player_state {
|
|
PlayerState::Stopped => "Stopped",
|
|
PlayerState::Playing => "Playing",
|
|
PlayerState::Paused => "Paused",
|
|
};
|
|
|
|
let status_style = if state.player_state == PlayerState::Playing {
|
|
Style::default()
|
|
.fg(Theme::background())
|
|
.bg(background_color)
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default()
|
|
.fg(Theme::background())
|
|
.bg(background_color)
|
|
};
|
|
|
|
right_spans.push(Span::styled(status_text.to_string(), status_style));
|
|
|
|
// Play mode indicator
|
|
let mode_text = match state.play_mode {
|
|
PlayMode::Normal => "",
|
|
PlayMode::Loop => " [Loop]",
|
|
};
|
|
if !mode_text.is_empty() {
|
|
right_spans.push(Span::styled(
|
|
mode_text.to_string(),
|
|
Style::default()
|
|
.fg(Theme::background())
|
|
.bg(background_color)
|
|
));
|
|
}
|
|
|
|
// Progress
|
|
let progress_text = if state.current_duration > 0.0 {
|
|
let position_mins = (state.current_position / 60.0) as u32;
|
|
let position_secs = (state.current_position % 60.0) as u32;
|
|
let duration_mins = (state.current_duration / 60.0) as u32;
|
|
let duration_secs = (state.current_duration % 60.0) as u32;
|
|
format!(
|
|
" • {:02}:{:02}/{:02}:{:02}",
|
|
position_mins, position_secs, duration_mins, duration_secs
|
|
)
|
|
} else {
|
|
" • 00:00/00:00".to_string()
|
|
};
|
|
|
|
right_spans.push(Span::styled(
|
|
progress_text,
|
|
Style::default()
|
|
.fg(Theme::background())
|
|
.bg(background_color)
|
|
));
|
|
|
|
// Volume
|
|
right_spans.push(Span::styled(
|
|
format!(" • Vol: {}% ", state.volume),
|
|
Style::default()
|
|
.fg(Theme::background())
|
|
.bg(background_color)
|
|
));
|
|
}
|
|
|
|
let right_title = Paragraph::new(Line::from(right_spans))
|
|
.style(Style::default().bg(background_color))
|
|
.alignment(Alignment::Right);
|
|
frame.render_widget(right_title, chunks[1]);
|
|
}
|
|
|
|
fn render_status_bar(frame: &mut Frame, state: &AppState, player: &Player, area: Rect) {
|
|
if state.search_mode {
|
|
// Show search prompt with current query and match count - LEFT aligned
|
|
let search_text = if state.focus_playlist {
|
|
// Searching in playlist
|
|
if !state.playlist_tab_search_results.is_empty() {
|
|
format!("/{}_ Playlist Search: {}/{}", state.search_query, state.playlist_tab_search_index + 1, state.playlist_tab_search_results.len())
|
|
} else if !state.search_query.is_empty() {
|
|
format!("/{}_ [no matches]", state.search_query)
|
|
} else {
|
|
format!("/{}_", state.search_query)
|
|
}
|
|
} else {
|
|
// Searching in file panel
|
|
if !state.tab_search_results.is_empty() {
|
|
format!("/{}_ Search: {}/{}", state.search_query, state.tab_search_index + 1, state.tab_search_results.len())
|
|
} else if !state.search_query.is_empty() {
|
|
format!("/{}_ [no matches]", state.search_query)
|
|
} else {
|
|
format!("/{}_", state.search_query)
|
|
}
|
|
};
|
|
let status_bar = Paragraph::new(search_text)
|
|
.style(Style::default().fg(Color::White).bg(Theme::background()));
|
|
frame.render_widget(status_bar, area);
|
|
} else if !state.search_matches.is_empty() {
|
|
// Show search navigation when file search results are active
|
|
let search_text = format!("/{} Search: {}/{}", state.search_query, state.search_match_index + 1, state.search_matches.len());
|
|
let status_bar = Paragraph::new(search_text)
|
|
.style(Style::default().fg(Color::White).bg(Theme::background()));
|
|
frame.render_widget(status_bar, area);
|
|
} else if !state.playlist_search_matches.is_empty() {
|
|
// Show search navigation when playlist search results are active
|
|
let search_text = format!("/{} Playlist Search: {}/{}", state.search_query, state.playlist_search_match_index + 1, state.playlist_search_matches.len());
|
|
let status_bar = Paragraph::new(search_text)
|
|
.style(Style::default().fg(Color::White).bg(Theme::background()));
|
|
frame.render_widget(status_bar, area);
|
|
} else if state.visual_mode {
|
|
// Show visual mode indicator
|
|
let visual_text = format!("-- VISUAL -- {} files marked", state.marked_files.len());
|
|
let status_bar = Paragraph::new(visual_text)
|
|
.style(Style::default().fg(Theme::foreground()).bg(Theme::background()));
|
|
frame.render_widget(status_bar, area);
|
|
} else {
|
|
// Normal mode: show media metadata if playing
|
|
// Split into left (artist/album/title) and right (technical info)
|
|
|
|
let mut left_parts = Vec::new();
|
|
let mut right_parts = Vec::new();
|
|
|
|
// Left side: Artist | Album | Title
|
|
if let Some(ref artist) = player.artist {
|
|
left_parts.push(artist.clone());
|
|
}
|
|
|
|
if let Some(ref album) = player.album {
|
|
left_parts.push(album.clone());
|
|
}
|
|
|
|
if let Some(ref title) = player.media_title {
|
|
left_parts.push(title.clone());
|
|
}
|
|
|
|
// Right side: Bitrate | Codec | Sample rate | Cache
|
|
if let Some(bitrate) = player.audio_bitrate {
|
|
right_parts.push(format!("{:.0} kbps", bitrate));
|
|
}
|
|
|
|
if let Some(ref codec) = player.audio_codec {
|
|
right_parts.push(codec.to_uppercase());
|
|
}
|
|
|
|
if let Some(samplerate) = player.sample_rate {
|
|
right_parts.push(format!("{} Hz", samplerate));
|
|
}
|
|
|
|
if let Some(cache_dur) = player.cache_duration {
|
|
if cache_dur > 0.0 {
|
|
right_parts.push(format!("{:.1}s", cache_dur));
|
|
}
|
|
}
|
|
|
|
// Create layout for left and right sections
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
|
.split(area);
|
|
|
|
// Left side text
|
|
let left_text = if !left_parts.is_empty() {
|
|
format!(" {}", left_parts.join(" | "))
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let left_bar = Paragraph::new(left_text)
|
|
.style(Style::default().fg(Theme::muted_text()).bg(Theme::background()))
|
|
.alignment(Alignment::Left);
|
|
frame.render_widget(left_bar, chunks[0]);
|
|
|
|
// Right side text
|
|
let right_text = if !right_parts.is_empty() {
|
|
format!("{} ", right_parts.join(" | "))
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let right_bar = Paragraph::new(right_text)
|
|
.style(Style::default().fg(Theme::muted_text()).bg(Theme::background()))
|
|
.alignment(Alignment::Right);
|
|
frame.render_widget(right_bar, chunks[1]);
|
|
}
|
|
}
|
|
|
|
fn render_confirm_popup(frame: &mut Frame, title: &str, message: &str) {
|
|
// Create centered popup area
|
|
let area = frame.area();
|
|
let popup_width = 52;
|
|
let popup_height = 7;
|
|
|
|
let popup_area = Rect {
|
|
x: (area.width.saturating_sub(popup_width)) / 2,
|
|
y: (area.height.saturating_sub(popup_height)) / 2,
|
|
width: popup_width.min(area.width),
|
|
height: popup_height.min(area.height),
|
|
};
|
|
|
|
// Use Clear widget to completely erase the background
|
|
frame.render_widget(Clear, popup_area);
|
|
|
|
// Render the popup block with solid background
|
|
let block = Block::default()
|
|
.title(title)
|
|
.borders(Borders::ALL)
|
|
.style(Style::default()
|
|
.bg(Theme::background())
|
|
.fg(Theme::bright_foreground()))
|
|
.title_style(Style::default()
|
|
.fg(Theme::bright_foreground())
|
|
.add_modifier(Modifier::BOLD));
|
|
|
|
let inner = block.inner(popup_area);
|
|
frame.render_widget(block, popup_area);
|
|
|
|
// Render message and prompt
|
|
let text_area = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(1), // Empty line
|
|
Constraint::Length(1), // Message
|
|
Constraint::Length(1), // Empty line
|
|
Constraint::Length(1), // Prompt
|
|
])
|
|
.split(inner);
|
|
|
|
let message_text = Paragraph::new(message)
|
|
.style(Style::default()
|
|
.fg(Theme::bright_foreground())
|
|
.bg(Theme::background()))
|
|
.alignment(Alignment::Center);
|
|
frame.render_widget(message_text, text_area[1]);
|
|
|
|
let prompt_text = Paragraph::new("Press 'y' to confirm or 'n' to cancel")
|
|
.style(Style::default()
|
|
.fg(Theme::foreground())
|
|
.bg(Theme::background()))
|
|
.alignment(Alignment::Center);
|
|
frame.render_widget(prompt_text, text_area[3]);
|
|
}
|
|
|
|
fn render_context_menu(frame: &mut Frame, menu: &ContextMenu) {
|
|
// Determine menu items based on type
|
|
let items = match menu.menu_type {
|
|
ContextMenuType::FilePanel => vec!["Play", "Add"],
|
|
ContextMenuType::Playlist => vec!["Remove", "Randomise"],
|
|
ContextMenuType::TitleBar => vec!["Stop", "Loop", "Refresh"],
|
|
};
|
|
|
|
// Calculate popup size
|
|
let width = 13;
|
|
let height = items.len() as u16 + 2; // +2 for borders
|
|
|
|
// Position popup near click location, but keep it on screen
|
|
let screen_width = frame.area().width;
|
|
let screen_height = frame.area().height;
|
|
|
|
let x = if menu.x + width < screen_width {
|
|
menu.x
|
|
} else {
|
|
screen_width.saturating_sub(width)
|
|
};
|
|
|
|
let y = if menu.y + height < screen_height {
|
|
menu.y
|
|
} else {
|
|
screen_height.saturating_sub(height)
|
|
};
|
|
|
|
let popup_area = Rect {
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
};
|
|
|
|
// Create menu items with selection highlight
|
|
let menu_items: Vec<ListItem> = items
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, item)| {
|
|
let style = if i == menu.selected_index {
|
|
Style::default()
|
|
.fg(Color::Black)
|
|
.bg(Color::White)
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default().fg(Theme::bright_foreground())
|
|
};
|
|
ListItem::new(*item).style(style)
|
|
})
|
|
.collect();
|
|
|
|
let menu_list = List::new(menu_items)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.style(Style::default().bg(Theme::background()).fg(Theme::bright_foreground()))
|
|
);
|
|
|
|
// Clear the area and render menu
|
|
frame.render_widget(Clear, popup_area);
|
|
frame.render_widget(menu_list, popup_area);
|
|
}
|