Improve search and playlist management
- Add folder priority boost in fuzzy search scoring - Show search results in top status bar after Enter - Change keybindings: v for mark, a for add to playlist, c for clear - Add playlist management: add_to_playlist() and clear_playlist() - Fix playlist index reset when clearing playlist - Display incremental search status in bottom bar while typing
This commit is contained in:
parent
e44c9e5bba
commit
b535d0e9cb
126
src/main.rs
126
src/main.rs
@ -7,7 +7,7 @@ mod ui;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
@ -105,7 +105,7 @@ async fn run_app<B: ratatui::backend::Backend>(
|
||||
if event::poll(std::time::Duration::from_millis(100))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind == KeyEventKind::Press {
|
||||
handle_key_event(state, player, key.code).await?;
|
||||
handle_key_event(state, player, key).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -118,51 +118,109 @@ async fn run_app<B: ratatui::backend::Backend>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_key_event(state: &mut AppState, player: &mut player::Player, key_code: KeyCode) -> Result<()> {
|
||||
match key_code {
|
||||
KeyCode::Char('q') => {
|
||||
async fn handle_key_event(state: &mut AppState, player: &mut player::Player, key: KeyEvent) -> Result<()> {
|
||||
// Handle search mode separately
|
||||
if state.search_mode {
|
||||
match key.code {
|
||||
KeyCode::Char(c) => {
|
||||
state.append_search_char(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
state.backspace_search();
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
state.tab_search_next();
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
state.tab_search_prev();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
state.execute_search();
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
state.exit_search_mode();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Char('q'), _) => {
|
||||
state.should_quit = true;
|
||||
}
|
||||
KeyCode::Char('k') => {
|
||||
state.move_selection_up();
|
||||
(KeyCode::Char('/'), _) => {
|
||||
state.enter_search_mode();
|
||||
}
|
||||
KeyCode::Char('j') => {
|
||||
state.move_selection_down();
|
||||
(KeyCode::Esc, _) => {
|
||||
state.search_matches.clear();
|
||||
}
|
||||
KeyCode::Char('h') => {
|
||||
state.collapse_selected();
|
||||
}
|
||||
KeyCode::Char('l') => {
|
||||
state.expand_selected();
|
||||
}
|
||||
KeyCode::Char('t') => {
|
||||
state.toggle_mark();
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
state.clear_marks();
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
state.play_next();
|
||||
if let Some(ref path) = state.current_file {
|
||||
player.play(path)?;
|
||||
tracing::info!("Next track: {:?}", path);
|
||||
(KeyCode::Char('n'), _) => {
|
||||
if !state.search_matches.is_empty() {
|
||||
state.next_search_match();
|
||||
} else {
|
||||
// If stopped, start from current index (0), otherwise go to next
|
||||
if state.player_state == PlayerState::Stopped && !state.playlist.is_empty() {
|
||||
state.current_file = Some(state.playlist[state.playlist_index].clone());
|
||||
state.player_state = PlayerState::Playing;
|
||||
if let Some(ref path) = state.current_file {
|
||||
player.play(path)?;
|
||||
tracing::info!("Starting playlist: {:?}", path);
|
||||
}
|
||||
} else {
|
||||
state.play_next();
|
||||
if let Some(ref path) = state.current_file {
|
||||
player.play(path)?;
|
||||
tracing::info!("Next track: {:?}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
(KeyCode::Char('N'), KeyModifiers::SHIFT) => {
|
||||
state.prev_search_match();
|
||||
}
|
||||
(KeyCode::Char('d'), KeyModifiers::CONTROL) => {
|
||||
state.page_down();
|
||||
}
|
||||
(KeyCode::Char('u'), KeyModifiers::CONTROL) => {
|
||||
state.page_up();
|
||||
}
|
||||
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => {
|
||||
state.move_selection_up();
|
||||
}
|
||||
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => {
|
||||
state.move_selection_down();
|
||||
}
|
||||
(KeyCode::Char('h'), _) => {
|
||||
state.collapse_selected();
|
||||
}
|
||||
(KeyCode::Char('l'), _) => {
|
||||
state.expand_selected();
|
||||
}
|
||||
(KeyCode::Char('v'), _) => {
|
||||
state.toggle_mark();
|
||||
}
|
||||
(KeyCode::Char('a'), _) => {
|
||||
state.add_to_playlist();
|
||||
}
|
||||
(KeyCode::Char('c'), _) => {
|
||||
state.clear_playlist();
|
||||
}
|
||||
(KeyCode::Char('p'), _) => {
|
||||
state.play_previous();
|
||||
if let Some(ref path) = state.current_file {
|
||||
player.play(path)?;
|
||||
tracing::info!("Previous track: {:?}", path);
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
(KeyCode::Enter, _) => {
|
||||
state.play_selection();
|
||||
if let Some(ref path) = state.current_file {
|
||||
player.play(path)?;
|
||||
tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len());
|
||||
}
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
(KeyCode::Char(' '), _) => {
|
||||
match state.player_state {
|
||||
PlayerState::Playing => {
|
||||
player.pause()?;
|
||||
@ -177,31 +235,31 @@ async fn handle_key_event(state: &mut AppState, player: &mut player::Player, key
|
||||
PlayerState::Stopped => {}
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
(KeyCode::Left, _) => {
|
||||
if state.player_state != PlayerState::Stopped {
|
||||
player.seek(-10.0)?;
|
||||
tracing::info!("Seek backward 10s");
|
||||
}
|
||||
}
|
||||
KeyCode::Right => {
|
||||
(KeyCode::Right, _) => {
|
||||
if state.player_state != PlayerState::Stopped {
|
||||
player.seek(10.0)?;
|
||||
tracing::info!("Seek forward 10s");
|
||||
}
|
||||
}
|
||||
KeyCode::Char('+') | KeyCode::Char('=') => {
|
||||
(KeyCode::Char('+'), _) | (KeyCode::Char('='), _) => {
|
||||
let new_volume = (state.volume + 5).min(100);
|
||||
state.volume = new_volume;
|
||||
player.set_volume(new_volume)?;
|
||||
tracing::info!("Volume: {}%", new_volume);
|
||||
}
|
||||
KeyCode::Char('-') => {
|
||||
(KeyCode::Char('-'), _) => {
|
||||
let new_volume = (state.volume - 5).max(0);
|
||||
state.volume = new_volume;
|
||||
player.set_volume(new_volume)?;
|
||||
tracing::info!("Volume: {}%", new_volume);
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
(KeyCode::Char('r'), _) => {
|
||||
state.is_refreshing = true;
|
||||
tracing::info!("Rescanning...");
|
||||
let cache_dir = cache::get_cache_dir()?;
|
||||
|
||||
330
src/state/mod.rs
330
src/state/mod.rs
@ -27,6 +27,12 @@ pub struct AppState {
|
||||
pub playlist: Vec<PathBuf>,
|
||||
pub playlist_index: usize,
|
||||
pub is_refreshing: bool,
|
||||
pub search_mode: bool,
|
||||
pub search_query: String,
|
||||
pub search_matches: Vec<usize>,
|
||||
pub search_match_index: usize,
|
||||
pub tab_search_results: Vec<PathBuf>,
|
||||
pub tab_search_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@ -60,12 +66,22 @@ impl AppState {
|
||||
playlist: Vec::new(),
|
||||
playlist_index: 0,
|
||||
is_refreshing: false,
|
||||
search_mode: false,
|
||||
search_query: String::new(),
|
||||
search_matches: Vec::new(),
|
||||
search_match_index: 0,
|
||||
tab_search_results: Vec::new(),
|
||||
tab_search_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_selection_up(&mut self) {
|
||||
if self.selected_index > 0 {
|
||||
self.selected_index -= 1;
|
||||
// Scroll up when selection reaches top
|
||||
if self.selected_index < self.scroll_offset {
|
||||
self.scroll_offset = self.selected_index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,6 +91,31 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_scroll_offset(&mut self, visible_height: usize) {
|
||||
// Scroll down when selection reaches bottom
|
||||
if self.selected_index >= self.scroll_offset + visible_height {
|
||||
self.scroll_offset = self.selected_index - visible_height + 1;
|
||||
}
|
||||
// Scroll up when selection reaches top
|
||||
if self.selected_index < self.scroll_offset {
|
||||
self.scroll_offset = self.selected_index;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_down(&mut self) {
|
||||
// Move down by half page (vim Ctrl-D behavior)
|
||||
let half_page = 10; // Default half page size
|
||||
let new_index = (self.selected_index + half_page).min(self.flattened_items.len().saturating_sub(1));
|
||||
self.selected_index = new_index;
|
||||
}
|
||||
|
||||
pub fn page_up(&mut self) {
|
||||
// Move up by half page (vim Ctrl-U behavior)
|
||||
let half_page = 10; // Default half page size
|
||||
let new_index = self.selected_index.saturating_sub(half_page);
|
||||
self.selected_index = new_index;
|
||||
}
|
||||
|
||||
pub fn get_selected_item(&self) -> Option<&FlattenedItem> {
|
||||
self.flattened_items.get(self.selected_index)
|
||||
}
|
||||
@ -130,6 +171,43 @@ impl AppState {
|
||||
self.marked_files.clear();
|
||||
}
|
||||
|
||||
pub fn clear_playlist(&mut self) {
|
||||
self.playlist.clear();
|
||||
self.playlist_index = 0;
|
||||
self.player_state = PlayerState::Stopped;
|
||||
self.current_file = None;
|
||||
}
|
||||
|
||||
pub fn add_to_playlist(&mut self) {
|
||||
// Add marked files or current selection to playlist
|
||||
if !self.marked_files.is_empty() {
|
||||
// Add marked files
|
||||
for path in &self.marked_files {
|
||||
if !self.playlist.contains(path) {
|
||||
self.playlist.push(path.clone());
|
||||
}
|
||||
}
|
||||
self.playlist.sort();
|
||||
} else if let Some(item) = self.get_selected_item() {
|
||||
let node = item.node.clone();
|
||||
if node.is_dir {
|
||||
// Add all files in directory
|
||||
let files = collect_files_from_node(&node);
|
||||
for path in files {
|
||||
if !self.playlist.contains(&path) {
|
||||
self.playlist.push(path);
|
||||
}
|
||||
}
|
||||
self.playlist.sort();
|
||||
} else {
|
||||
// Add single file
|
||||
if !self.playlist.contains(&node.path) {
|
||||
self.playlist.push(node.path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play_selection(&mut self) {
|
||||
// Priority: marked files > directory > single file
|
||||
if !self.marked_files.is_empty() {
|
||||
@ -189,6 +267,197 @@ impl AppState {
|
||||
self.selected_index = self.flattened_items.len().saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enter_search_mode(&mut self) {
|
||||
self.search_mode = true;
|
||||
self.search_query.clear();
|
||||
self.search_matches.clear();
|
||||
self.search_match_index = 0;
|
||||
self.tab_search_results.clear();
|
||||
self.tab_search_index = 0;
|
||||
}
|
||||
|
||||
pub fn exit_search_mode(&mut self) {
|
||||
self.search_mode = false;
|
||||
self.tab_search_results.clear();
|
||||
self.tab_search_index = 0;
|
||||
}
|
||||
|
||||
pub fn append_search_char(&mut self, c: char) {
|
||||
self.search_query.push(c);
|
||||
self.perform_incremental_search();
|
||||
}
|
||||
|
||||
pub fn backspace_search(&mut self) {
|
||||
self.search_query.pop();
|
||||
self.perform_incremental_search();
|
||||
}
|
||||
|
||||
fn perform_incremental_search(&mut self) {
|
||||
if self.search_query.is_empty() {
|
||||
self.tab_search_results.clear();
|
||||
self.tab_search_index = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all matching paths with scores from the entire tree
|
||||
let mut matching_paths_with_scores = Vec::new();
|
||||
collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores);
|
||||
|
||||
if matching_paths_with_scores.is_empty() {
|
||||
self.tab_search_results.clear();
|
||||
self.tab_search_index = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by score (highest first)
|
||||
matching_paths_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
// Store all matches for tab completion
|
||||
self.tab_search_results = matching_paths_with_scores.iter().map(|(path, _)| path.clone()).collect();
|
||||
self.tab_search_index = 0;
|
||||
|
||||
// Expand parent directories of ALL matches (not just best match)
|
||||
// This ensures folders deep in the tree become visible
|
||||
for (path, _) in &matching_paths_with_scores {
|
||||
let mut parent = path.parent();
|
||||
while let Some(p) = parent {
|
||||
self.expanded_dirs.insert(p.to_path_buf());
|
||||
parent = p.parent();
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild flattened items
|
||||
self.rebuild_flattened_items();
|
||||
|
||||
// Find the best match in the flattened list and jump to it
|
||||
let best_match = &self.tab_search_results[0];
|
||||
if let Some(idx) = self.flattened_items.iter().position(|item| &item.node.path == best_match) {
|
||||
self.selected_index = idx;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute_search(&mut self) {
|
||||
if self.search_query.is_empty() {
|
||||
self.search_mode = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all matching paths with scores
|
||||
let mut matching_paths_with_scores = Vec::new();
|
||||
collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores);
|
||||
|
||||
if matching_paths_with_scores.is_empty() {
|
||||
self.search_mode = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by score (highest first)
|
||||
matching_paths_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
let matching_paths: Vec<PathBuf> = matching_paths_with_scores.iter().map(|(path, _)| path.clone()).collect();
|
||||
|
||||
// Expand all parent directories
|
||||
for path in &matching_paths {
|
||||
let mut parent = path.parent();
|
||||
while let Some(p) = parent {
|
||||
self.expanded_dirs.insert(p.to_path_buf());
|
||||
parent = p.parent();
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild flattened items
|
||||
self.rebuild_flattened_items();
|
||||
|
||||
// Find indices of matches in the flattened list
|
||||
self.search_matches = matching_paths
|
||||
.iter()
|
||||
.filter_map(|path| {
|
||||
self.flattened_items
|
||||
.iter()
|
||||
.position(|item| &item.node.path == path)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !self.search_matches.is_empty() {
|
||||
self.search_match_index = 0;
|
||||
self.selected_index = self.search_matches[0];
|
||||
}
|
||||
|
||||
self.search_mode = false;
|
||||
}
|
||||
|
||||
pub fn next_search_match(&mut self) {
|
||||
if !self.search_matches.is_empty() {
|
||||
self.search_match_index = (self.search_match_index + 1) % self.search_matches.len();
|
||||
self.selected_index = self.search_matches[self.search_match_index];
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev_search_match(&mut self) {
|
||||
if !self.search_matches.is_empty() {
|
||||
if self.search_match_index == 0 {
|
||||
self.search_match_index = self.search_matches.len() - 1;
|
||||
} else {
|
||||
self.search_match_index -= 1;
|
||||
}
|
||||
self.selected_index = self.search_matches[self.search_match_index];
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tab_search_next(&mut self) {
|
||||
if self.tab_search_results.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cycle to next match
|
||||
self.tab_search_index = (self.tab_search_index + 1) % self.tab_search_results.len();
|
||||
let next_match = self.tab_search_results[self.tab_search_index].clone();
|
||||
|
||||
// Expand parent directories
|
||||
let mut parent = next_match.parent();
|
||||
while let Some(p) = parent {
|
||||
self.expanded_dirs.insert(p.to_path_buf());
|
||||
parent = p.parent();
|
||||
}
|
||||
|
||||
// Rebuild flattened items
|
||||
self.rebuild_flattened_items();
|
||||
|
||||
// Find and select the match
|
||||
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == next_match) {
|
||||
self.selected_index = idx;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tab_search_prev(&mut self) {
|
||||
if self.tab_search_results.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cycle to previous match
|
||||
if self.tab_search_index == 0 {
|
||||
self.tab_search_index = self.tab_search_results.len() - 1;
|
||||
} else {
|
||||
self.tab_search_index -= 1;
|
||||
}
|
||||
let prev_match = self.tab_search_results[self.tab_search_index].clone();
|
||||
|
||||
// Expand parent directories
|
||||
let mut parent = prev_match.parent();
|
||||
while let Some(p) = parent {
|
||||
self.expanded_dirs.insert(p.to_path_buf());
|
||||
parent = p.parent();
|
||||
}
|
||||
|
||||
// Rebuild flattened items
|
||||
self.rebuild_flattened_items();
|
||||
|
||||
// Find and select the match
|
||||
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == prev_match) {
|
||||
self.selected_index = idx;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn flatten_tree(nodes: &[FileTreeNode], depth: usize, expanded_dirs: &HashSet<PathBuf>) -> Vec<FlattenedItem> {
|
||||
@ -224,3 +493,64 @@ fn collect_files_from_node(node: &FileTreeNode) -> Vec<PathBuf> {
|
||||
|
||||
files
|
||||
}
|
||||
|
||||
fn fuzzy_match(text: &str, query: &str) -> Option<i32> {
|
||||
let text_lower = text.to_lowercase();
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let mut text_chars = text_lower.chars();
|
||||
let mut score = 0;
|
||||
let mut prev_match_idx = 0;
|
||||
let mut consecutive_bonus = 0;
|
||||
|
||||
for query_char in query_lower.chars() {
|
||||
let mut found = false;
|
||||
let mut current_idx = prev_match_idx;
|
||||
|
||||
for text_char in text_chars.by_ref() {
|
||||
current_idx += 1;
|
||||
if text_char == query_char {
|
||||
found = true;
|
||||
// Bonus for consecutive matches
|
||||
if current_idx == prev_match_idx + 1 {
|
||||
consecutive_bonus += 10;
|
||||
} else {
|
||||
consecutive_bonus = 0;
|
||||
}
|
||||
// Bonus for matching at word start
|
||||
if current_idx == 1 || text_lower.chars().nth(current_idx - 2).map_or(false, |c| !c.is_alphanumeric()) {
|
||||
score += 15;
|
||||
}
|
||||
score += consecutive_bonus;
|
||||
// Penalty for gap
|
||||
score -= (current_idx - prev_match_idx - 1) as i32;
|
||||
prev_match_idx = current_idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Bonus for shorter strings (better matches)
|
||||
score += 100 - text_lower.len() as i32;
|
||||
|
||||
Some(score)
|
||||
}
|
||||
|
||||
fn collect_matching_paths(nodes: &[FileTreeNode], query: &str, matches: &mut Vec<(PathBuf, i32)>) {
|
||||
for node in nodes {
|
||||
if let Some(mut score) = fuzzy_match(&node.name, query) {
|
||||
// Give folders a significant boost so they appear before files
|
||||
if node.is_dir {
|
||||
score += 50;
|
||||
}
|
||||
matches.push((node.path.clone(), score));
|
||||
}
|
||||
if node.is_dir && !node.children.is_empty() {
|
||||
collect_matching_paths(&node.children, query, matches);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
215
src/ui/mod.rs
215
src/ui/mod.rs
@ -1,13 +1,22 @@
|
||||
mod theme;
|
||||
|
||||
use crate::state::{AppState, PlayerState};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph},
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use theme::Theme;
|
||||
|
||||
pub fn render(frame: &mut Frame, state: &mut AppState) {
|
||||
// Clear background
|
||||
frame.render_widget(
|
||||
Block::default().style(Theme::secondary()),
|
||||
frame.area(),
|
||||
);
|
||||
|
||||
pub fn render(frame: &mut Frame, state: &AppState) {
|
||||
// Three-section layout: title bar, main content, statusbar (like cm-dashboard)
|
||||
let main_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
@ -30,7 +39,11 @@ pub fn render(frame: &mut Frame, state: &AppState) {
|
||||
render_status_bar(frame, state, main_chunks[2]);
|
||||
}
|
||||
|
||||
fn render_file_panel(frame: &mut Frame, state: &AppState, area: Rect) {
|
||||
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;
|
||||
state.update_scroll_offset(visible_height);
|
||||
|
||||
let items: Vec<ListItem> = state
|
||||
.flattened_items
|
||||
.iter()
|
||||
@ -42,13 +55,13 @@ fn render_file_panel(frame: &mut Frame, state: &AppState, area: Rect) {
|
||||
let text = format!("{}{}{}{}", indent, mark, item.node.name, suffix);
|
||||
|
||||
let style = if idx == state.selected_index {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||
Theme::selected()
|
||||
} else if item.node.is_dir {
|
||||
Style::default().fg(Color::Blue)
|
||||
Theme::directory()
|
||||
} else if state.marked_files.contains(&item.node.path) {
|
||||
Style::default().fg(Color::Yellow)
|
||||
Theme::marked()
|
||||
} else {
|
||||
Style::default()
|
||||
Theme::secondary()
|
||||
};
|
||||
|
||||
ListItem::new(text).style(style)
|
||||
@ -60,39 +73,18 @@ fn render_file_panel(frame: &mut Frame, state: &AppState, area: Rect) {
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Media Files (Cached)")
|
||||
.style(Style::default().fg(Color::White)),
|
||||
.style(Theme::widget_border_style())
|
||||
.title_style(Theme::title_style()),
|
||||
);
|
||||
|
||||
frame.render_widget(list, area);
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(Some(state.selected_index));
|
||||
*list_state.offset_mut() = state.scroll_offset;
|
||||
frame.render_stateful_widget(list, area, &mut list_state);
|
||||
}
|
||||
|
||||
fn render_right_panel(frame: &mut Frame, state: &AppState, area: Rect) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(0)])
|
||||
.split(area);
|
||||
|
||||
// Combined status line: Progress | Volume
|
||||
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()
|
||||
};
|
||||
|
||||
let combined_status = format!("{} • Vol: {}%", progress_text, state.volume);
|
||||
let status_widget = Paragraph::new(combined_status)
|
||||
.block(Block::default().borders(Borders::ALL).title("Player"))
|
||||
.style(Style::default().fg(Color::White));
|
||||
frame.render_widget(status_widget, chunks[0]);
|
||||
|
||||
// Playlist panel
|
||||
// Playlist panel (no longer need the player status box)
|
||||
let playlist_items: Vec<ListItem> = state
|
||||
.playlist
|
||||
.iter()
|
||||
@ -104,9 +96,9 @@ fn render_right_panel(frame: &mut Frame, state: &AppState, area: Rect) {
|
||||
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
||||
|
||||
let style = if idx == state.playlist_index && state.player_state != PlayerState::Stopped {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||
Theme::selected()
|
||||
} else {
|
||||
Style::default()
|
||||
Theme::secondary()
|
||||
};
|
||||
|
||||
ListItem::new(filename).style(style)
|
||||
@ -124,36 +116,145 @@ fn render_right_panel(frame: &mut Frame, state: &AppState, area: Rect) {
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(playlist_title)
|
||||
.style(Style::default().fg(Color::White)),
|
||||
.style(Theme::widget_border_style())
|
||||
.title_style(Theme::title_style()),
|
||||
);
|
||||
|
||||
frame.render_widget(playlist_widget, chunks[1]);
|
||||
let mut playlist_state = ListState::default();
|
||||
if state.player_state != PlayerState::Stopped && !state.playlist.is_empty() {
|
||||
playlist_state.select(Some(state.playlist_index));
|
||||
}
|
||||
frame.render_stateful_widget(playlist_widget, area, &mut playlist_state);
|
||||
}
|
||||
|
||||
fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) {
|
||||
let status_text = if state.is_refreshing {
|
||||
"Refreshing library..."
|
||||
let background_color = Theme::success(); // Green for normal operation
|
||||
|
||||
// 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 {
|
||||
match state.player_state {
|
||||
// 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));
|
||||
|
||||
// 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)
|
||||
));
|
||||
|
||||
// Add search info if active
|
||||
if !state.search_matches.is_empty() {
|
||||
right_spans.push(Span::styled(
|
||||
format!(" • Search: {}/{} ", state.search_match_index + 1, state.search_matches.len()),
|
||||
Style::default()
|
||||
.fg(Theme::background())
|
||||
.bg(background_color)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
));
|
||||
} else {
|
||||
right_spans.push(Span::styled(
|
||||
" ",
|
||||
Style::default()
|
||||
.fg(Theme::background())
|
||||
.bg(background_color)
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let title_text = format!("cm-player • {}", status_text);
|
||||
let title = Paragraph::new(title_text)
|
||||
.style(Style::default().fg(Color::Black).bg(Color::Cyan));
|
||||
|
||||
frame.render_widget(title, area);
|
||||
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, area: Rect) {
|
||||
let shortcuts = "↑↓/jk: Navigate • h/l: Fold • t: Mark • c: Clear • Enter: Play • Space: Pause • ←→: Seek • +/-: Volume • n/p: Next/Prev • r: Rescan • q: Quit";
|
||||
|
||||
let status_bar = Paragraph::new(shortcuts)
|
||||
.style(Style::default().fg(Color::White))
|
||||
.alignment(Alignment::Center);
|
||||
|
||||
frame.render_widget(status_bar, area);
|
||||
fn render_status_bar(frame: &mut Frame, state: &AppState, area: Rect) {
|
||||
if state.search_mode {
|
||||
// Show search prompt with current query and match count - LEFT aligned
|
||||
let search_text = 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(Theme::foreground()).bg(Theme::background()));
|
||||
frame.render_widget(status_bar, area);
|
||||
} else {
|
||||
// Normal mode shortcuts (always shown when not in search mode)
|
||||
let shortcuts = "/: Search • v: Mark • a: Add to Playlist • c: Clear Playlist • Enter: Play • Space: Pause • ←→: Seek • +/-: Volume • n/p: Next/Prev • r: Rescan • q: Quit";
|
||||
let status_bar = Paragraph::new(shortcuts)
|
||||
.style(Style::default().fg(Theme::muted_text()).bg(Theme::background()))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(status_bar, area);
|
||||
}
|
||||
}
|
||||
|
||||
116
src/ui/theme.rs
Normal file
116
src/ui/theme.rs
Normal file
@ -0,0 +1,116 @@
|
||||
use ratatui::style::{Color, Style};
|
||||
|
||||
/// cm-dashboard color palette
|
||||
pub struct Theme;
|
||||
|
||||
impl Theme {
|
||||
// Primary colors
|
||||
pub fn foreground() -> Color {
|
||||
Color::Rgb(198, 198, 198) // #c6c6c6
|
||||
}
|
||||
|
||||
pub fn dim_foreground() -> Color {
|
||||
Color::Rgb(112, 112, 112) // #707070
|
||||
}
|
||||
|
||||
pub fn bright_foreground() -> Color {
|
||||
Color::Rgb(255, 255, 255) // #ffffff
|
||||
}
|
||||
|
||||
pub fn background() -> Color {
|
||||
Color::Rgb(38, 38, 38) // #262626
|
||||
}
|
||||
|
||||
// Semantic colors
|
||||
pub fn normal_blue() -> Color {
|
||||
Color::Rgb(135, 175, 215) // #87afd7
|
||||
}
|
||||
|
||||
pub fn normal_green() -> Color {
|
||||
Color::Rgb(175, 215, 135) // #afd787
|
||||
}
|
||||
|
||||
pub fn normal_yellow() -> Color {
|
||||
Color::Rgb(215, 175, 95) // #d7af5f
|
||||
}
|
||||
|
||||
pub fn normal_red() -> Color {
|
||||
Color::Rgb(215, 84, 0) // #d75400
|
||||
}
|
||||
|
||||
pub fn normal_cyan() -> Color {
|
||||
Color::Rgb(160, 160, 160) // #a0a0a0
|
||||
}
|
||||
|
||||
// Semantic mappings
|
||||
pub fn primary_text() -> Color {
|
||||
Color::Rgb(238, 238, 238) // #eeeeee
|
||||
}
|
||||
|
||||
pub fn secondary_text() -> Color {
|
||||
Self::foreground()
|
||||
}
|
||||
|
||||
pub fn muted_text() -> Color {
|
||||
Self::dim_foreground()
|
||||
}
|
||||
|
||||
pub fn border() -> Color {
|
||||
Self::dim_foreground()
|
||||
}
|
||||
|
||||
pub fn border_title() -> Color {
|
||||
Self::bright_foreground()
|
||||
}
|
||||
|
||||
pub fn highlight() -> Color {
|
||||
Self::normal_blue()
|
||||
}
|
||||
|
||||
pub fn success() -> Color {
|
||||
Self::normal_green()
|
||||
}
|
||||
|
||||
pub fn warning() -> Color {
|
||||
Self::normal_yellow()
|
||||
}
|
||||
|
||||
pub fn error() -> Color {
|
||||
Self::normal_red()
|
||||
}
|
||||
|
||||
// Styles
|
||||
pub fn widget_border_style() -> Style {
|
||||
Style::default().fg(Self::border()).bg(Self::background())
|
||||
}
|
||||
|
||||
pub fn title_style() -> Style {
|
||||
Style::default()
|
||||
.fg(Self::border_title())
|
||||
.bg(Self::background())
|
||||
}
|
||||
|
||||
pub fn secondary() -> Style {
|
||||
Style::default()
|
||||
.fg(Self::secondary_text())
|
||||
.bg(Self::background())
|
||||
}
|
||||
|
||||
pub fn selected() -> Style {
|
||||
Style::default()
|
||||
.fg(Self::background())
|
||||
.bg(Self::highlight())
|
||||
}
|
||||
|
||||
pub fn directory() -> Style {
|
||||
Style::default()
|
||||
.fg(Self::normal_blue())
|
||||
.bg(Self::background())
|
||||
}
|
||||
|
||||
pub fn marked() -> Style {
|
||||
Style::default()
|
||||
.fg(Self::warning())
|
||||
.bg(Self::background())
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user