10 Commits

Author SHA1 Message Date
2953d73487 Use lowercase for panel labels
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Change "Files" to "files" and "Playlist" to "playlist" in the title bar
for consistent lowercase styling. Also remove unused render_info_popup
function.
2025-12-13 12:58:06 +01:00
cddfedf1a0 Add live file counter to library refresh
All checks were successful
Build and Release / build-and-release (push) Successful in 1m20s
Show real-time progress during library scanning with an atomic counter
that updates every 100ms. The refresh popup displays the number of
media files found as they are discovered, providing immediate feedback
without slowing down the scan operation.
2025-12-13 11:51:32 +01:00
821a844fe0 Add dynamic title bar colors based on player state
Title bar background color changes:
- Gray: Stopped or Paused
- Green: Playing
- Red: Error (when last_error is set)

Main content border remains gray.
2025-12-13 10:34:55 +01:00
b7cc219f40 Prevent auto-advance when MPV is force killed
Set previous_player_state to Stopped and clear current_file when MPV dies to prevent the auto-advance logic from triggering.
2025-12-12 21:18:22 +01:00
b5fde2d5d4 Handle MPV force kill gracefully
When MPV is killed externally, detect the dead process and automatically recreate it for next playback. Connection errors no longer crash the application - they're logged and handled silently.
2025-12-12 19:52:48 +01:00
d53542afa6 Eliminate code duplication with unified action functions
All checks were successful
Build and Release / build-and-release (push) Successful in 55s
Create action functions for stop, volume, and seek operations and
use them consistently across keyboard handlers, mouse handlers, and
API handlers. This eliminates duplicate logic and ensures consistent
behavior across all input methods.

Also fixes stop command triggering auto-advance by setting the
skip_position_update flag to prevent the Playing→Stopped transition
from being interpreted as a natural track ending.
2025-12-12 16:19:56 +01:00
be9ee8c005 Move refresh status to centered popup
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Display "Refreshing library..." in a centered popup overlay instead
of showing it in the title bar. This makes the refresh status more
prominent and cleaner.
2025-12-12 15:45:12 +01:00
7c083cfb0e Filter out empty directories during library scan
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Skip directories that contain no media files or non-empty subdirectories.
This prevents empty folders from appearing in the file list, which can
occur when NFS cache is stale or when directories are emptied.
2025-12-12 15:34:29 +01:00
b438065c23 Use small triangle arrow for playlist playing indicator
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Replace large arrow with small triangle (▸) to match the style
used for directory indicators before nerd fonts were introduced.
2025-12-12 13:08:00 +01:00
0fa26db116 Add playing indicator arrow in playlist
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Display a small arrow (▶) to the left of the currently playing track in the playlist, making it easier to identify which song is playing.
2025-12-12 12:57:12 +01:00
7 changed files with 398 additions and 585 deletions

View File

@@ -1,6 +1,6 @@
[package]
name = "cm-player"
version = "0.1.27"
version = "0.1.37"
edition = "2021"
[dependencies]

View File

@@ -16,6 +16,10 @@ use ratatui::{backend::CrosstermBackend, Terminal};
use state::{AppState, PlayerState};
use std::io::{self, BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use tracing_subscriber;
// UI update intervals and thresholds
@@ -262,14 +266,44 @@ fn action_toggle_play_pause(state: &mut AppState, player: &mut player::Player) -
Ok(())
}
fn action_stop(state: &mut AppState, player: &mut player::Player) -> Result<()> {
fn action_stop(state: &mut AppState, player: &mut player::Player, skip_position_update: &mut bool) -> Result<()> {
player.stop()?;
state.current_position = 0.0;
state.current_duration = 0.0;
*skip_position_update = true; // Prevent auto-advance on manual stop
tracing::info!("Stopped");
Ok(())
}
fn action_volume_up(state: &mut AppState, player: &mut player::Player) -> Result<()> {
state.volume = (state.volume + 5).min(100);
player.set_volume(state.volume)?;
tracing::info!("Volume: {}%", state.volume);
Ok(())
}
fn action_volume_down(state: &mut AppState, player: &mut player::Player) -> Result<()> {
state.volume = (state.volume - 5).max(0);
player.set_volume(state.volume)?;
tracing::info!("Volume: {}%", state.volume);
Ok(())
}
fn action_volume_set(state: &mut AppState, player: &mut player::Player, volume: i64) -> Result<()> {
state.volume = volume.clamp(0, 100);
player.set_volume(state.volume)?;
tracing::info!("Volume: {}%", state.volume);
Ok(())
}
fn action_seek(player: &mut player::Player, seconds: f64) -> Result<()> {
if player.get_player_state() != Some(PlayerState::Stopped) {
player.seek(seconds)?;
tracing::info!("Seek {}s", seconds);
}
Ok(())
}
fn action_remove_from_playlist(state: &mut AppState, player: &mut player::Player) -> Result<()> {
let was_playing_removed = state.playlist_index == state.selected_playlist_index;
let was_playing = player.get_player_state() == Some(PlayerState::Playing);
@@ -415,7 +449,7 @@ fn handle_context_menu_action(menu_type: state::ContextMenuType, selected: usize
}
state::ContextMenuType::TitleBar => {
match selected {
0 => action_stop(state, player)?,
0 => action_stop(state, player, skip_position_update)?,
1 => {
state.cycle_play_mode();
tracing::info!("Play mode: {:?}", state.play_mode);
@@ -453,26 +487,11 @@ fn run_app<B: ratatui::backend::Backend>(
tracing::debug!("Processing API command: {:?}", cmd);
match cmd {
api::ApiCommand::PlayPause => {
if let Some(player_state) = player.get_player_state() {
match player_state {
PlayerState::Stopped => {
// Play current file or first in playlist
if state.current_file.is_none() && !state.playlist.is_empty() {
state.current_file = Some(state.playlist[0].clone());
}
if let Some(ref file) = state.current_file {
player.play(file)?;
}
}
PlayerState::Playing => player.pause()?,
PlayerState::Paused => player.resume()?,
}
state_changed = true;
}
action_toggle_play_pause(state, player)?;
state_changed = true;
}
api::ApiCommand::Stop => {
player.stop()?;
state.current_file = None;
action_stop(state, player, &mut skip_position_update)?;
state_changed = true;
}
api::ApiCommand::Next => {
@@ -484,26 +503,23 @@ fn run_app<B: ratatui::backend::Backend>(
state_changed = true;
}
api::ApiCommand::VolumeUp => {
state.volume = (state.volume + 5).min(100);
player.set_volume(state.volume)?;
action_volume_up(state, player)?;
state_changed = true;
}
api::ApiCommand::VolumeDown => {
state.volume = (state.volume - 5).max(0);
player.set_volume(state.volume)?;
action_volume_down(state, player)?;
state_changed = true;
}
api::ApiCommand::VolumeSet { volume } => {
state.volume = volume.clamp(0, 100);
player.set_volume(state.volume)?;
action_volume_set(state, player, volume)?;
state_changed = true;
}
api::ApiCommand::SeekForward { seconds } => {
player.seek(seconds)?;
action_seek(player, seconds)?;
state_changed = true;
}
api::ApiCommand::SeekBackward { seconds } => {
player.seek(-seconds)?;
action_seek(player, -seconds)?;
state_changed = true;
}
api::ApiCommand::GetStatus => {
@@ -516,15 +532,18 @@ fn run_app<B: ratatui::backend::Backend>(
}
}
// Check if mpv process died (e.g., user closed video window)
// Check if mpv process died (e.g., user closed video window or force killed)
if !player.is_process_alive() {
if let Some(player_state) = player.get_player_state() {
if player_state != PlayerState::Stopped {
state.current_position = 0.0;
state.current_duration = 0.0;
state_changed = true;
}
}
tracing::info!("MPV process died, recreating...");
state.current_position = 0.0;
state.current_duration = 0.0;
state.current_file = None;
state_changed = true;
skip_position_update = true; // Prevent auto-advance when MPV was killed
// Recreate player for next playback
*player = player::Player::new()?;
previous_player_state = Some(PlayerState::Stopped);
}
// Always update all properties in one batch to keep state synchronized with MPV
@@ -554,8 +573,10 @@ fn run_app<B: ratatui::backend::Backend>(
// Check if track ended and play next
// When MPV finishes playing a file, it goes to idle (Stopped state)
// Detect Playing → Stopped transition = track ended, play next
// But skip this check if we just manually stopped (skip_position_update flag)
if previous_player_state == Some(PlayerState::Playing)
&& player_state == PlayerState::Stopped
&& !skip_position_update
{
let should_continue = state.play_next();
// play_next() returns true if should continue playing, false if should stop
@@ -660,15 +681,50 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
KeyCode::Char('y') | KeyCode::Char('Y') => {
state.show_refresh_confirm = false;
state.is_refreshing = true;
terminal.draw(|f| { let _ = ui::render(f, state, player); })?; // Show "Refreshing library..." immediately
tracing::info!("Rescanning...");
state.refresh_file_count = 0;
terminal.draw(|f| { let _ = ui::render(f, state, player); })?;
let cache_dir = cache::get_cache_dir()?;
let new_cache = scanner::scan_paths(&state.config.scan_paths.paths)?;
// Delete old cache files to ensure fresh scan
let _ = std::fs::remove_file(cache_dir.join("file_tree.json"));
let _ = std::fs::remove_file(cache_dir.join("metadata.json"));
// Create atomic counter for file count
let counter = Arc::new(AtomicUsize::new(0));
let counter_clone = Arc::clone(&counter);
// Spawn background thread to perform scan
let paths = state.config.scan_paths.paths.clone();
let scan_thread = thread::spawn(move || {
scanner::scan_paths(&paths, &counter_clone)
});
// Poll counter and update UI while scanning
while !scan_thread.is_finished() {
state.refresh_file_count = counter.load(Ordering::Relaxed);
terminal.draw(|f| { let _ = ui::render(f, state, player); })?;
thread::sleep(Duration::from_millis(100));
}
// Get the result
let new_cache = scan_thread.join().map_err(|_| anyhow::anyhow!("Scan thread panicked"))??;
new_cache.save(&cache_dir)?;
// Replace old cache completely
state.cache = new_cache;
state.refresh_flattened_items();
state.refresh_flattened_items(); // This also cleans up playlist and expanded_dirs
// If current file was removed, stop playback
if state.current_file.is_none() {
player.stop()?;
state.current_position = 0.0;
state.current_duration = 0.0;
}
state.is_refreshing = false;
tracing::info!("Rescan complete");
state.refresh_file_count = 0;
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
state.show_refresh_confirm = false;
@@ -681,6 +737,20 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
// Handle search mode separately
if state.search_mode {
match key.code {
KeyCode::Char('n') => {
if state.focus_playlist {
state.next_playlist_search_result();
} else {
state.next_search_result();
}
}
KeyCode::Char('N') => {
if state.focus_playlist {
state.prev_playlist_search_result();
} else {
state.prev_search_result();
}
}
KeyCode::Char(c) => {
if state.focus_playlist {
state.append_playlist_search_char(c);
@@ -695,20 +765,6 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
state.backspace_search();
}
}
KeyCode::Tab => {
if state.focus_playlist {
state.playlist_tab_search_next();
} else {
state.tab_search_next();
}
}
KeyCode::BackTab => {
if state.focus_playlist {
state.playlist_tab_search_prev();
} else {
state.tab_search_prev();
}
}
KeyCode::Enter => {
if state.focus_playlist {
state.execute_playlist_search();
@@ -771,32 +827,11 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
}
}
(KeyCode::Esc, _) => {
if !state.search_matches.is_empty() {
state.search_matches.clear();
}
if !state.playlist_search_matches.is_empty() {
state.playlist_search_matches.clear();
state.playlist_tab_search_results.clear();
}
if state.visual_mode {
state.visual_mode = false;
state.marked_files.clear();
}
}
(KeyCode::Char('n'), _) => {
if !state.search_matches.is_empty() {
state.next_search_match();
} else if !state.playlist_search_matches.is_empty() {
state.next_playlist_search_match();
}
}
(KeyCode::Char('N'), KeyModifiers::SHIFT) => {
if !state.search_matches.is_empty() {
state.prev_search_match();
} else if !state.playlist_search_matches.is_empty() {
state.prev_playlist_search_match();
}
}
(KeyCode::Char('J'), KeyModifiers::SHIFT) => {
// Next track
action_navigate_track(state, player, 1, skip_position_update)?;
@@ -879,7 +914,7 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
}
}
(KeyCode::Char('s'), _) => {
action_stop(state, player)?;
action_stop(state, player, skip_position_update)?;
}
(KeyCode::Char('m'), _) => {
state.cycle_play_mode();
@@ -893,28 +928,16 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
action_toggle_play_pause(state, player)?;
}
(KeyCode::Char('H'), KeyModifiers::SHIFT) => {
if player.get_player_state() != Some(PlayerState::Stopped) {
player.seek(-10.0)?;
tracing::info!("Seek backward 10s");
}
action_seek(player, -10.0)?;
}
(KeyCode::Char('L'), KeyModifiers::SHIFT) => {
if player.get_player_state() != Some(PlayerState::Stopped) {
player.seek(10.0)?;
tracing::info!("Seek forward 10s");
}
action_seek(player, 10.0)?;
}
(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);
action_volume_up(state, player)?;
}
(KeyCode::Char('-'), _) => {
let new_volume = (state.volume - 5).max(0);
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
action_volume_down(state, player)?;
}
(KeyCode::Char('r'), _) => {
state.show_refresh_confirm = true;
@@ -1017,10 +1040,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
&& y < title_bar_area.y + title_bar_area.height
{
// Scroll on title bar = decrease volume
let new_volume = (state.volume - 5).max(0);
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
action_volume_down(state, player)?;
} else if x >= playlist_area.x
&& x < playlist_area.x + playlist_area.width
&& y >= playlist_area.y
@@ -1043,10 +1063,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
&& y < title_bar_area.y + title_bar_area.height
{
// Scroll on title bar = increase volume
let new_volume = (state.volume + 5).min(100);
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
action_volume_up(state, player)?;
} else if x >= playlist_area.x
&& x < playlist_area.x + playlist_area.width
&& y >= playlist_area.y

View File

@@ -76,8 +76,10 @@ impl Player {
let stream = match UnixStream::connect(&self.socket_path) {
Ok(s) => s,
Err(e) => {
// Connection failed - MPV probably not ready yet
return Err(anyhow::anyhow!("Failed to connect: {}", e));
// Connection failed - MPV probably not ready yet or has been killed
// Don't propagate error - just leave socket as None
tracing::debug!("Failed to connect to MPV socket: {}", e);
return Ok(());
}
};

View File

@@ -1,6 +1,7 @@
use crate::cache::{Cache, FileMetadata, FileTreeNode};
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use walkdir::WalkDir;
const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "wav", "ogg", "m4a", "aac", "opus", "wma"];
@@ -33,7 +34,7 @@ pub fn is_video_file(path: &Path) -> bool {
}
}
pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> {
fn scan_directory_internal(root_path: &Path, counter: &AtomicUsize) -> Result<FileTreeNode> {
let name = root_path
.file_name()
.unwrap_or_default()
@@ -62,10 +63,16 @@ pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> {
if entry.file_type().is_dir() {
// Recursively scan subdirectories
if let Ok(child_node) = scan_directory(path) {
node.children.push(child_node);
if let Ok(child_node) = scan_directory_internal(path, counter) {
// Only add directory if it contains media files or non-empty subdirectories
if !child_node.children.is_empty() {
node.children.push(child_node);
}
}
} else if is_media_file(path) {
// Increment counter for each media file found
counter.fetch_add(1, Ordering::Relaxed);
// Add media file
let file_name = path
.file_name()
@@ -97,13 +104,13 @@ pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> {
Ok(node)
}
pub fn scan_paths(paths: &[PathBuf]) -> Result<Cache> {
pub fn scan_paths(paths: &[PathBuf], counter: &AtomicUsize) -> Result<Cache> {
let mut cache = Cache::new();
for path in paths {
if path.exists() {
tracing::info!("Scanning path: {:?}", path);
let tree_node = scan_directory(path)?;
let tree_node = scan_directory_internal(path, counter)?;
// Collect all metadata from the tree
collect_metadata(&tree_node, &mut cache);

View File

@@ -74,14 +74,10 @@ pub struct AppState {
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 playlist_search_matches: Vec<usize>,
pub playlist_search_match_index: usize,
pub playlist_tab_search_results: Vec<usize>,
pub playlist_tab_search_index: usize,
pub search_results: Vec<PathBuf>,
pub search_result_index: usize,
pub playlist_search_results: Vec<usize>,
pub playlist_search_result_index: usize,
pub visual_mode: bool,
pub visual_anchor: usize,
pub saved_expanded_dirs: HashSet<PathBuf>,
@@ -92,6 +88,8 @@ pub struct AppState {
pub last_click_is_playlist: bool,
pub context_menu: Option<ContextMenu>,
pub play_mode: PlayMode,
pub last_error: Option<String>,
pub refresh_file_count: usize,
}
#[derive(Debug, Clone)]
@@ -131,14 +129,10 @@ impl AppState {
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,
playlist_search_matches: Vec::new(),
playlist_search_match_index: 0,
playlist_tab_search_results: Vec::new(),
playlist_tab_search_index: 0,
search_results: Vec::new(),
search_result_index: 0,
playlist_search_results: Vec::new(),
playlist_search_result_index: 0,
visual_mode: false,
visual_anchor: 0,
saved_expanded_dirs: HashSet::new(),
@@ -149,6 +143,8 @@ impl AppState {
last_click_is_playlist: false,
context_menu: None,
play_mode: PlayMode::Normal,
last_error: None,
refresh_file_count: 0,
}
}
@@ -587,8 +583,84 @@ impl AppState {
}
pub fn refresh_flattened_items(&mut self) {
// Keep current expanded state after rescan
// Clean up expanded_dirs - remove paths that no longer exist in new cache
self.cleanup_expanded_dirs();
// Rebuild view with cleaned expanded state
self.rebuild_flattened_items();
// Clean up playlist - remove files that no longer exist in cache
self.cleanup_playlist();
}
fn cleanup_expanded_dirs(&mut self) {
// Build a set of valid directory paths from the cache
let mut valid_dirs = std::collections::HashSet::new();
fn collect_dirs(node: &crate::cache::FileTreeNode, dirs: &mut std::collections::HashSet<std::path::PathBuf>) {
if node.is_dir {
dirs.insert(node.path.clone());
}
for child in &node.children {
collect_dirs(child, dirs);
}
}
for root in &self.cache.file_tree {
collect_dirs(root, &mut valid_dirs);
}
// Remove invalid paths from expanded_dirs
let original_len = self.expanded_dirs.len();
self.expanded_dirs.retain(|path| valid_dirs.contains(path));
if self.expanded_dirs.len() < original_len {
tracing::info!("Cleaned up expanded_dirs: removed {} invalid paths", original_len - self.expanded_dirs.len());
}
}
fn cleanup_playlist(&mut self) {
// Build a set of valid paths from the cache for fast lookup
let mut valid_paths = std::collections::HashSet::new();
fn collect_paths(node: &crate::cache::FileTreeNode, paths: &mut std::collections::HashSet<std::path::PathBuf>) {
if !node.is_dir {
paths.insert(node.path.clone());
}
for child in &node.children {
collect_paths(child, paths);
}
}
for root in &self.cache.file_tree {
collect_paths(root, &mut valid_paths);
}
// Check if current file is invalid
let current_file_invalid = if let Some(ref current) = self.current_file {
!valid_paths.contains(current)
} else {
false
};
if current_file_invalid {
self.current_file = None;
tracing::info!("Current playing file was deleted, cleared current_file");
}
// Remove files from playlist that don't exist in cache
let original_len = self.playlist.len();
self.playlist.retain(|path| valid_paths.contains(path));
// Adjust indices if playlist was modified
if self.playlist.len() < original_len {
// Ensure playlist_index is valid
if self.playlist_index >= self.playlist.len() && !self.playlist.is_empty() {
self.playlist_index = self.playlist.len() - 1;
}
// Ensure selected_playlist_index is valid
if self.selected_playlist_index >= self.playlist.len() && !self.playlist.is_empty() {
self.selected_playlist_index = self.playlist.len() - 1;
}
tracing::info!("Cleaned up playlist: removed {} deleted files", original_len - self.playlist.len());
}
}
pub fn rebuild_flattened_items(&mut self) {
@@ -604,16 +676,12 @@ impl AppState {
if self.focus_playlist {
// Clear playlist search state
self.playlist_search_matches.clear();
self.playlist_search_match_index = 0;
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
self.playlist_search_results.clear();
self.playlist_search_result_index = 0;
} else {
// Clear file search state
self.search_matches.clear();
self.search_match_index = 0;
self.tab_search_results.clear();
self.tab_search_index = 0;
self.search_results.clear();
self.search_result_index = 0;
// Save current folder state
self.saved_expanded_dirs = self.expanded_dirs.clone();
}
@@ -621,15 +689,16 @@ impl AppState {
pub fn exit_search_mode(&mut self) {
self.search_mode = false;
self.search_query.clear();
if self.focus_playlist {
// Clear playlist search state
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
self.playlist_search_results.clear();
self.playlist_search_result_index = 0;
} else {
// Clear file search state
self.tab_search_results.clear();
self.tab_search_index = 0;
self.search_results.clear();
self.search_result_index = 0;
// Restore folder state from before search
self.expanded_dirs = self.saved_expanded_dirs.clone();
self.rebuild_flattened_items();
@@ -648,9 +717,8 @@ impl AppState {
fn perform_incremental_search(&mut self) {
if self.search_query.is_empty() {
self.tab_search_results.clear();
self.tab_search_index = 0;
// Don't rebuild tree on every keystroke - only when exiting search
self.search_results.clear();
self.search_result_index = 0;
return;
}
@@ -659,8 +727,8 @@ impl AppState {
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;
self.search_results.clear();
self.search_result_index = 0;
return;
}
@@ -674,22 +742,30 @@ impl AppState {
// 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;
// Store all matches
self.search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect();
self.search_result_index = 0;
// Only expand and rebuild if this is a new best match
let best_match = self.tab_search_results[0].clone();
// Jump to first match
self.jump_to_current_search_result();
}
fn jump_to_current_search_result(&mut self) {
if self.search_results.is_empty() {
return;
}
let current_match = self.search_results[self.search_result_index].clone();
// Check if we need to expand folders for this match
let needs_expand = best_match.ancestors()
let needs_expand = current_match.ancestors()
.skip(1) // Skip the file itself
.any(|p| !self.expanded_dirs.contains(p));
if needs_expand {
// Close all folders and expand only for the best match
// Close all folders and expand only for the current match
self.expanded_dirs.clear();
let mut parent = best_match.parent();
let mut parent = current_match.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
@@ -699,8 +775,8 @@ impl AppState {
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.path == best_match) {
// Find the match in the flattened list and jump to it
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == current_match) {
self.selected_index = idx;
// Scroll to show the match
@@ -718,233 +794,26 @@ impl AppState {
}
}
pub fn next_search_result(&mut self) {
if !self.search_results.is_empty() {
self.search_result_index = (self.search_result_index + 1) % self.search_results.len();
self.jump_to_current_search_result();
}
}
pub fn prev_search_result(&mut self) {
if !self.search_results.is_empty() {
self.search_result_index = if self.search_result_index == 0 {
self.search_results.len() - 1
} else {
self.search_result_index - 1
};
self.jump_to_current_search_result();
}
}
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.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.path == target_path) {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
}
}
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.path == target_path) {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
}
}
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.path == next_match) {
self.selected_index = idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
}
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.path == prev_match) {
self.selected_index = idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
self.exit_search_mode();
}
pub fn append_playlist_search_char(&mut self, c: char) {
@@ -959,8 +828,8 @@ impl AppState {
fn perform_playlist_incremental_search(&mut self) {
if self.search_query.is_empty() {
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
self.playlist_search_results.clear();
self.playlist_search_result_index = 0;
return;
}
@@ -977,21 +846,29 @@ impl AppState {
.collect();
if matching_indices_with_scores.is_empty() {
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
self.playlist_search_results.clear();
self.playlist_search_result_index = 0;
return;
}
// Sort by score (highest first)
matching_indices_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
// Store all matches for tab completion
self.playlist_tab_search_results = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect();
self.playlist_tab_search_index = 0;
// Store all matches
self.playlist_search_results = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect();
self.playlist_search_result_index = 0;
// Jump to best match
let best_match_idx = self.playlist_tab_search_results[0];
self.selected_playlist_index = best_match_idx;
// Jump to first match
self.jump_to_current_playlist_search_result();
}
fn jump_to_current_playlist_search_result(&mut self) {
if self.playlist_search_results.is_empty() {
return;
}
let match_idx = self.playlist_search_results[self.playlist_search_result_index];
self.selected_playlist_index = match_idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
@@ -1007,174 +884,26 @@ impl AppState {
}
}
pub fn playlist_tab_search_next(&mut self) {
if self.playlist_tab_search_results.is_empty() {
return;
}
// Cycle to next match
self.playlist_tab_search_index = (self.playlist_tab_search_index + 1) % self.playlist_tab_search_results.len();
let next_match_idx = self.playlist_tab_search_results[self.playlist_tab_search_index];
self.selected_playlist_index = next_match_idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
self.playlist_visible_height,
self.playlist.len()
);
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
pub fn next_playlist_search_result(&mut self) {
if !self.playlist_search_results.is_empty() {
self.playlist_search_result_index = (self.playlist_search_result_index + 1) % self.playlist_search_results.len();
self.jump_to_current_playlist_search_result();
}
}
pub fn playlist_tab_search_prev(&mut self) {
if self.playlist_tab_search_results.is_empty() {
return;
}
// Cycle to previous match
if self.playlist_tab_search_index == 0 {
self.playlist_tab_search_index = self.playlist_tab_search_results.len() - 1;
} else {
self.playlist_tab_search_index -= 1;
}
let prev_match_idx = self.playlist_tab_search_results[self.playlist_tab_search_index];
self.selected_playlist_index = prev_match_idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
self.playlist_visible_height,
self.playlist.len()
);
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
pub fn prev_playlist_search_result(&mut self) {
if !self.playlist_search_results.is_empty() {
self.playlist_search_result_index = if self.playlist_search_result_index == 0 {
self.playlist_search_results.len() - 1
} else {
self.playlist_search_result_index - 1
};
self.jump_to_current_playlist_search_result();
}
}
pub fn execute_playlist_search(&mut self) {
if self.search_query.is_empty() {
self.search_mode = false;
return;
}
// Collect all matching indices with scores
let mut matching_indices_with_scores: Vec<(usize, i32)> = self.playlist
.iter()
.enumerate()
.filter_map(|(idx, path)| {
let filename = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
fuzzy_match(filename, &self.search_query).map(|score| (idx, score))
})
.collect();
if matching_indices_with_scores.is_empty() {
self.search_mode = false;
return;
}
// Sort by score (highest first)
matching_indices_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
// Store matching indices
self.playlist_search_matches = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect();
if !self.playlist_search_matches.is_empty() {
self.playlist_search_match_index = 0;
let first_match_idx = self.playlist_search_matches[0];
self.selected_playlist_index = first_match_idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
self.search_mode = false;
}
pub fn next_playlist_search_match(&mut self) {
if !self.playlist_search_matches.is_empty() {
self.playlist_search_match_index = (self.playlist_search_match_index + 1) % self.playlist_search_matches.len();
let match_idx = self.playlist_search_matches[self.playlist_search_match_index];
self.selected_playlist_index = match_idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
}
pub fn prev_playlist_search_match(&mut self) {
if !self.playlist_search_matches.is_empty() {
if self.playlist_search_match_index == 0 {
self.playlist_search_match_index = self.playlist_search_matches.len() - 1;
} else {
self.playlist_search_match_index -= 1;
}
let match_idx = self.playlist_search_matches[self.playlist_search_match_index];
self.selected_playlist_index = match_idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
self.exit_search_mode();
}
}

View File

@@ -46,18 +46,18 @@ pub fn render(frame: &mut Frame, state: &mut AppState, player: &mut Player) -> (
// Add playlist counter
let playlist_text = if !state.playlist.is_empty() {
format!("Playlist [{}/{}]", state.playlist_index + 1, state.playlist.len())
format!("playlist [{}/{}]", state.playlist_index + 1, state.playlist.len())
} else {
"Playlist (empty)".to_string()
"playlist (empty)".to_string()
};
let title = Line::from(vec![
Span::styled("Files", file_style),
Span::styled("files", file_style),
Span::raw(" | "),
Span::styled(playlist_text, playlist_style),
]);
// Create one border around the entire content area
// Create one border around the entire content area with fixed gray border
let main_block = Block::default()
.borders(Borders::ALL)
.title(title)
@@ -77,6 +77,11 @@ pub fn render(frame: &mut Frame, state: &mut AppState, player: &mut Player) -> (
render_status_bar(frame, state, player, main_chunks[2]);
// Show refreshing popup if scanning
if state.is_refreshing {
render_refresh_popup(frame, state.refresh_file_count);
}
// Show confirmation popup if needed
if state.show_refresh_confirm {
render_confirm_popup(frame, "Refresh library?", "This may take a while");
@@ -157,7 +162,7 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect, _tab_m
// 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 in_search = !state.focus_playlist && state.search_mode;
let search_query = if in_search { state.search_query.to_lowercase() } else { String::new() };
// Calculate how many items are below the visible area
@@ -301,7 +306,7 @@ fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect, _tab_
};
// 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 in_playlist_search = state.focus_playlist && state.search_mode;
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)
@@ -321,12 +326,17 @@ fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect, _tab_
let is_selected = state.focus_playlist && idx == state.selected_playlist_index;
let is_playing = idx == state.playlist_index;
// Add playing indicator arrow
let indicator = if is_playing { "" } else { " " };
// 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))
let mut line_spans = vec![Span::raw(indicator)];
if in_playlist_search && !playlist_search_query.is_empty() {
line_spans.extend(highlight_search_matches(&filename, &playlist_search_query, is_selected));
} else {
Line::from(filename)
};
line_spans.push(Span::raw(filename));
}
let line = Line::from(line_spans);
let style = if is_selected && is_playing {
// Both selected and playing: selection bar with bold
@@ -374,12 +384,17 @@ fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect, _tab_
}
fn render_title_bar(frame: &mut Frame, state: &AppState, player: &mut Player, area: Rect) {
// Default to stopped if we can't query MPV
// Get player state
let player_state = player.get_player_state().unwrap_or(PlayerState::Stopped);
let background_color = match player_state {
PlayerState::Playing => Theme::success(), // Green for playing
PlayerState::Paused => Theme::highlight(), // Blue for paused
PlayerState::Stopped => Theme::dim_foreground(), // Gray for stopped
// Title bar background color: red for error, gray for stopped/paused, green for playing
let background_color = if state.last_error.is_some() {
Theme::error()
} else {
match player_state {
PlayerState::Playing => Theme::success(), // Green for playing
PlayerState::Paused | PlayerState::Stopped => Theme::border(), // Gray for paused/stopped
}
};
// Split the title bar into left and right sections
@@ -404,16 +419,7 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, player: &mut Player, ar
// 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 player_state {
PlayerState::Stopped => "Stopped",
@@ -501,8 +507,8 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, player: &mut Player, a
// Show search prompt with current query and match count - LEFT aligned
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())
if !state.playlist_search_results.is_empty() {
format!("/{}_ Playlist Search: {}/{}", state.search_query, state.playlist_search_result_index + 1, state.playlist_search_results.len())
} else if !state.search_query.is_empty() {
format!("/{}_ [no matches]", state.search_query)
} else {
@@ -510,20 +516,14 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, player: &mut Player, a
}
} 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())
if !state.search_results.is_empty() {
format!("/{}_ Search: {}/{}", state.search_query, state.search_result_index + 1, state.search_results.len())
} else if !state.search_query.is_empty() {
format!("/{}_ [no matches]", state.search_query)
} else {
format!("/{}_", state.search_query)
}
}
} else if !state.search_matches.is_empty() {
// Show search navigation when file search results are active
format!("/{} Search: {}/{}", state.search_query, state.search_match_index + 1, state.search_matches.len())
} else if !state.playlist_search_matches.is_empty() {
// Show search navigation when playlist search results are active
format!("/{} Playlist Search: {}/{}", state.search_query, state.playlist_search_match_index + 1, state.playlist_search_matches.len())
} else if state.visual_mode {
// Show visual mode indicator
format!("-- VISUAL -- {} files marked", state.marked_files.len())
@@ -719,6 +719,56 @@ fn render_progress_bar(frame: &mut Frame, _state: &AppState, player: &mut Player
frame.render_widget(progress_widget, area);
}
fn render_refresh_popup(frame: &mut Frame, file_count: usize) {
// Create centered popup area - bigger for two lines
let area = frame.area();
let popup_width = 50;
let popup_height = 5;
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()
.borders(Borders::ALL)
.style(Style::default()
.bg(Theme::background())
.fg(Theme::bright_foreground()));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
// Build two-line message
let lines = if file_count > 0 {
vec![
Line::from("Refreshing library..."),
Line::from(""),
Line::from(format!("{} files found", file_count))
.style(Style::default().fg(Theme::highlight())),
]
} else {
vec![
Line::from("Refreshing library..."),
]
};
// Render message centered
let message_widget = Paragraph::new(lines)
.alignment(Alignment::Center)
.style(Style::default()
.fg(Theme::bright_foreground())
.bg(Theme::background()));
frame.render_widget(message_widget, inner);
}
fn render_confirm_popup(frame: &mut Frame, title: &str, message: &str) {
// Create centered popup area
let area = frame.area();

View File

@@ -34,6 +34,10 @@ impl Theme {
Color::Rgb(215, 175, 95) // #d7af5f
}
pub fn normal_red() -> Color {
Color::Rgb(215, 95, 95) // #d75f5f
}
// Semantic mappings
pub fn secondary_text() -> Color {
Self::foreground()
@@ -59,6 +63,10 @@ impl Theme {
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())