Christoffer Martinsson ae80e9a5db
All checks were successful
Build and Release / build-and-release (push) Successful in 51s
Improve search mode UX and fix playback bugs
Search mode improvements:
- Search results persist until explicitly cleared
- Bold black highlighted chars on selection bar
- Fix fuzzy match scoring to select first occurrence
- Search info moved to bottom status bar

Keybinding changes:
- J/K for next/prev track (was n/p)
- H/L for seeking (was arrow keys)
- Simplified status bar shortcuts

UI improvements:
- Dynamic title bar color (green=playing, blue=paused, gray=stopped)
- White bold text for current playlist item
- Removed mouse capture for terminal text selection

Bug fixes:
- Fix auto-advance triggering multiple times when restarting from stopped state
2025-12-06 22:14:57 +01:00

648 lines
22 KiB
Rust

use crate::cache::{Cache, FileTreeNode};
use crate::config::Config;
use std::collections::HashSet;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlayerState {
Stopped,
Playing,
Paused,
}
pub struct AppState {
pub cache: Cache,
pub config: Config,
pub selected_index: usize,
pub scroll_offset: usize,
pub player_state: PlayerState,
pub current_file: Option<PathBuf>,
pub current_position: f64,
pub current_duration: f64,
pub volume: i64,
pub should_quit: bool,
pub flattened_items: Vec<FlattenedItem>,
pub expanded_dirs: HashSet<PathBuf>,
pub marked_files: HashSet<PathBuf>,
pub playlist: Vec<PathBuf>,
pub playlist_index: usize,
pub is_refreshing: bool,
pub search_mode: bool,
pub search_query: String,
pub search_matches: Vec<PathBuf>,
pub search_match_index: usize,
pub tab_search_results: Vec<PathBuf>,
pub tab_search_index: usize,
pub visual_mode: bool,
pub visual_anchor: usize,
pub saved_expanded_dirs: HashSet<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct FlattenedItem {
pub node: FileTreeNode,
pub depth: usize,
}
impl AppState {
pub fn new(cache: Cache, config: Config) -> Self {
// Start with all directories collapsed (vifm-style)
let expanded_dirs = HashSet::new();
let flattened_items = flatten_tree(&cache.file_tree, 0, &expanded_dirs);
Self {
cache,
config,
selected_index: 0,
scroll_offset: 0,
player_state: PlayerState::Stopped,
current_file: None,
current_position: 0.0,
current_duration: 0.0,
volume: 100,
should_quit: false,
flattened_items,
expanded_dirs,
marked_files: HashSet::new(),
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,
visual_mode: false,
visual_anchor: 0,
saved_expanded_dirs: HashSet::new(),
}
}
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;
}
// Update visual selection if in visual mode
if self.visual_mode {
self.update_visual_selection();
}
}
}
pub fn move_selection_down(&mut self) {
if self.selected_index < self.flattened_items.len().saturating_sub(1) {
self.selected_index += 1;
// Update visual selection if in visual mode
if self.visual_mode {
self.update_visual_selection();
}
}
}
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)
}
pub fn collapse_selected(&mut self) {
let item = self.get_selected_item().cloned();
if let Some(item) = item {
if item.node.is_dir {
let path = item.node.path.clone();
let was_expanded = self.expanded_dirs.contains(&path);
if was_expanded {
// Close the expanded folder and keep selection on it
self.expanded_dirs.remove(&path);
self.rebuild_flattened_items();
// Find the collapsed folder and select it
if let Some(idx) = self.flattened_items.iter().position(|i| i.node.path == path) {
self.selected_index = idx;
}
} else {
// Folder is collapsed, close parent instead and jump to it
if let Some(parent) = path.parent() {
let parent_buf = parent.to_path_buf();
self.expanded_dirs.remove(&parent_buf);
self.rebuild_flattened_items();
// Jump to parent folder
if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.node.path == parent_buf) {
self.selected_index = parent_idx;
}
}
}
} else {
// Close parent folder when on a file and jump to it
if let Some(parent) = item.node.path.parent() {
let parent_buf = parent.to_path_buf();
self.expanded_dirs.remove(&parent_buf);
self.rebuild_flattened_items();
// Jump to parent folder
if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.node.path == parent_buf) {
self.selected_index = parent_idx;
}
}
}
}
}
pub fn expand_selected(&mut self) {
if let Some(item) = self.get_selected_item() {
if item.node.is_dir {
let path = item.node.path.clone();
self.expanded_dirs.insert(path);
self.rebuild_flattened_items();
}
}
}
pub fn toggle_mark(&mut self) {
if self.visual_mode {
// Exit visual mode and mark all files in range
self.update_visual_selection();
self.visual_mode = false;
} else {
// Enter visual mode
self.visual_mode = true;
self.visual_anchor = self.selected_index;
// Clear previous marks when entering visual mode
self.marked_files.clear();
// Mark current file
if let Some(item) = self.get_selected_item() {
if !item.node.is_dir {
self.marked_files.insert(item.node.path.clone());
}
}
}
}
fn update_visual_selection(&mut self) {
// Clear marks
self.marked_files.clear();
// Mark all files between anchor and current position
let start = self.visual_anchor.min(self.selected_index);
let end = self.visual_anchor.max(self.selected_index);
for i in start..=end {
if let Some(item) = self.flattened_items.get(i) {
if !item.node.is_dir {
self.marked_files.insert(item.node.path.clone());
}
}
}
}
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() {
// Play marked files
self.playlist = self.marked_files.iter().cloned().collect();
self.playlist.sort();
self.playlist_index = 0;
if let Some(first) = self.playlist.first() {
self.current_file = Some(first.clone());
self.player_state = PlayerState::Playing;
}
} else if let Some(item) = self.get_selected_item() {
let node = item.node.clone();
if node.is_dir {
// Play all files in directory
self.playlist = collect_files_from_node(&node);
self.playlist_index = 0;
if let Some(first) = self.playlist.first() {
self.current_file = Some(first.clone());
self.player_state = PlayerState::Playing;
}
} else {
// Play single file
let path = node.path.clone();
self.playlist = vec![path.clone()];
self.playlist_index = 0;
self.current_file = Some(path);
self.player_state = PlayerState::Playing;
}
}
}
pub fn play_next(&mut self) {
if self.playlist_index + 1 < self.playlist.len() {
self.playlist_index += 1;
self.current_file = Some(self.playlist[self.playlist_index].clone());
self.player_state = PlayerState::Playing;
}
}
pub fn refresh_flattened_items(&mut self) {
// Keep current expanded state after rescan
self.rebuild_flattened_items();
}
pub fn rebuild_flattened_items(&mut self) {
self.flattened_items = flatten_tree(&self.cache.file_tree, 0, &self.expanded_dirs);
if self.selected_index >= self.flattened_items.len() {
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;
// Save current folder state
self.saved_expanded_dirs = self.expanded_dirs.clone();
}
pub fn exit_search_mode(&mut self) {
self.search_mode = false;
self.tab_search_results.clear();
self.tab_search_index = 0;
// Restore folder state from before search
self.expanded_dirs = self.saved_expanded_dirs.clone();
self.rebuild_flattened_items();
}
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;
}
// Add index to preserve original tree order when scores are equal
let mut indexed_matches: Vec<(PathBuf, i32, usize)> = matching_paths_with_scores
.into_iter()
.enumerate()
.map(|(idx, (path, score))| (path, score, idx))
.collect();
// Sort by score (highest first), then by original index to prefer first occurrence
indexed_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
// Store all matches for tab completion
self.tab_search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect();
self.tab_search_index = 0;
// Close all folders and expand only for the best match
self.expanded_dirs.clear();
let best_match = self.tab_search_results[0].clone();
let mut parent = best_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 the best match in the flattened list and jump to it
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 and preserve order
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;
}
// Add index to preserve original tree order when scores are equal
let mut indexed_matches: Vec<(PathBuf, i32, usize)> = matching_paths_with_scores
.into_iter()
.enumerate()
.map(|(idx, (path, score))| (path, score, idx))
.collect();
// Sort by score (highest first), then by original index to prefer first occurrence
indexed_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
let matching_paths_with_scores: Vec<(PathBuf, i32)> = indexed_matches
.into_iter()
.map(|(path, score, _)| (path, score))
.collect();
let matching_paths: Vec<PathBuf> = matching_paths_with_scores.iter().map(|(path, _)| path.clone()).collect();
// Store matching paths (not indices, as they change when folders collapse)
self.search_matches = matching_paths;
if !self.search_matches.is_empty() {
self.search_match_index = 0;
// Close all folders and expand only for first match
self.expanded_dirs.clear();
let first_match = self.search_matches[0].clone();
let mut parent = first_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 first match in flattened list
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == first_match) {
self.selected_index = idx;
}
}
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();
let target_path = self.search_matches[self.search_match_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = target_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 path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == target_path) {
self.selected_index = idx;
}
}
}
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;
}
let target_path = self.search_matches[self.search_match_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = target_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 path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == target_path) {
self.selected_index = idx;
}
}
}
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();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
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();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
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> {
let mut result = Vec::new();
for node in nodes {
let is_expanded = expanded_dirs.contains(&node.path);
result.push(FlattenedItem {
node: node.clone(),
depth,
});
if node.is_dir && !node.children.is_empty() && is_expanded {
result.extend(flatten_tree(&node.children, depth + 1, expanded_dirs));
}
}
result
}
fn collect_files_from_node(node: &FileTreeNode) -> Vec<PathBuf> {
let mut files = Vec::new();
if node.is_dir {
for child in &node.children {
files.extend(collect_files_from_node(child));
}
} else {
files.push(node.path.clone());
}
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;
}
}
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);
}
}
}