feat: recursively show directories in files list (#105)

This commit is contained in:
Tarasov Aleksandr
2026-05-15 21:41:12 +03:00
committed by GitHub
parent d974a93c04
commit 8155cceac8
4 changed files with 253 additions and 133 deletions
+230 -125
View File
@@ -6,11 +6,16 @@ use egui::{
use egui_dnd::dnd; use egui_dnd::dnd;
use egui_extras::{Column, TableBuilder}; use egui_extras::{Column, TableBuilder};
use egui_material_icons::icons::*; use egui_material_icons::icons::*;
use pwsp::types::gui::AudioPlayerState;
use pwsp::types::socket::Request; use pwsp::types::socket::Request;
use pwsp::types::{audio_player::TrackInfo, gui::AppState}; use pwsp::types::{audio_player::TrackInfo, gui::AppState};
use pwsp::utils::gui::{format_time_pair, make_request_async}; use pwsp::utils::gui::{format_time_pair, make_request_async};
use rust_i18n::t; use rust_i18n::t;
use std::{path::Path, time::Instant}; use std::{
cmp::Ordering,
path::{Path, PathBuf},
time::Instant,
};
enum TrackAction { enum TrackAction {
Pause(u32), Pause(u32),
@@ -26,6 +31,13 @@ enum HotkeyAction {
Play(String), Play(String),
} }
enum FileAction {
Play(PathBuf, bool),
StopAndPlay(u32, PathBuf, bool),
AssignHotkey(PathBuf),
SetSelected(PathBuf),
}
impl SoundpadGui { impl SoundpadGui {
fn get_volume_icon(volume: f32) -> &'static str { fn get_volume_icon(volume: f32) -> &'static str {
if volume > 0.7 { if volume > 0.7 {
@@ -782,144 +794,237 @@ impl SoundpadGui {
ui.set_min_height(area_size.y); ui.set_min_height(area_size.y);
ui.vertical(|ui| { ui.vertical(|ui| {
let mut actions = Vec::new();
let files = self.get_filtered_files(); let files = self.get_filtered_files();
for entry_path in files { for entry_path in files {
let file_name = entry_path Self::draw_tree_node(
.file_name() ui,
.unwrap_or_default() entry_path,
.to_string_lossy() &mut self.app_state,
.to_string(); &self.audio_player_state,
&mut actions,
);
}
ui.horizontal(|ui| { for action in actions {
// Hotkey badge match action {
let hotkey_badge = self.get_hotkey_badge(&entry_path); FileAction::Play(path, concurrent) => self.play_file(&path, concurrent),
if let Some(badge) = &hotkey_badge { FileAction::StopAndPlay(id, path, concurrent) => {
ui.label( self.stop(Some(id));
RichText::new(badge) self.play_file(&path, concurrent);
.small()
.monospace()
.color(Color32::from_rgb(100, 200, 100)),
);
} }
FileAction::AssignHotkey(path) => {
let mut file_button_text = RichText::new(&file_name); self.app_state.assigning_hotkey_for_file = Some(path);
if let Some(current_file) = &self.app_state.selected_file self.app_state.hotkey_capture_active = true;
&& current_file.eq(&entry_path)
{
file_button_text = file_button_text.color(Color32::WHITE);
} }
FileAction::SetSelected(path) => {
let file_button = Button::new(file_button_text).frame(false).truncate(); self.app_state.selected_file = Some(path);
let file_button_response = ui.add(file_button);
if file_button_response.clicked() {
ui.input(|i| {
if i.modifiers.ctrl {
self.play_file(&entry_path, true);
} else if i.modifiers.shift
&& let Some(last_track) =
self.audio_player_state.tracks.last()
{
self.stop(Some(last_track.id));
self.play_file(&entry_path, true);
} else {
self.play_file(&entry_path, false);
}
});
self.app_state.selected_file = Some(entry_path.clone());
} }
}
// Context menu
file_button_response.context_menu(|ui| {
if ui
.button(format!(
"{} {}",
ICON_BOLT.codepoint,
t!("gui.context.files.play_solo")
))
.clicked()
{
self.play_file(&entry_path, false);
self.app_state.selected_file = Some(entry_path.clone());
}
if ui
.button(format!(
"{} {}",
ICON_ADD.codepoint,
t!("gui.context.files.add_new")
))
.clicked()
{
self.play_file(&entry_path, true);
self.app_state.selected_file = Some(entry_path.clone());
}
if ui
.button(format!(
"{} {}",
ICON_SWAP_HORIZ.codepoint,
t!("gui.context.files.replace_last")
))
.clicked()
&& let Some(last_track) = self.audio_player_state.tracks.last()
{
self.stop(Some(last_track.id));
self.play_file(&entry_path, true);
self.app_state.selected_file = Some(entry_path.clone());
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_OPEN_IN_BROWSER.codepoint,
t!("gui.context.files.show_in_fm")
))
.clicked()
&& let Err(e) = opener::reveal(&entry_path)
{
eprintln!("Failed to open file manager: {}", e);
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_KEYBOARD.codepoint,
t!("gui.context.files.asign_hotkey")
))
.clicked()
{
self.app_state.assigning_hotkey_for_file =
Some(entry_path.clone());
self.app_state.hotkey_capture_active = true;
ui.close();
}
});
});
} }
}); });
}); });
}); });
} }
fn get_hotkey_badge(&self, path: &Path) -> Option<String> { fn draw_tree_node(
for slot in &self.app_state.hotkey_config.slots { ui: &mut Ui,
if slot.action.name == "play" path: std::path::PathBuf,
&& let Some(file_path_str) = slot.action.args.get("file_path") app_state: &mut AppState,
&& Path::new(file_path_str) == path audio_player_state: &AudioPlayerState,
{ actions: &mut Vec<FileAction>,
if let Some(chord) = &slot.key_chord { ) {
return Some(format!("[{}]", chord)); if path.is_dir() {
} else { let dir_name = path
return Some(format!("[{}]", slot.slot)); .file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
CollapsingHeader::new(dir_name)
.id_salt(&path)
.show(ui, |ui| {
let children = if let Some(cached) = app_state.dir_cache.get(&path) {
cached.clone()
} else {
let mut read = Vec::new();
if let Ok(entries) = std::fs::read_dir(&path) {
for entry in entries.filter_map(|e| e.ok()) {
read.push(entry.path());
}
}
read.sort_by(|a, b| {
let a_is_dir = a.is_dir();
let b_is_dir = b.is_dir();
if a_is_dir && !b_is_dir {
Ordering::Less
} else if !a_is_dir && b_is_dir {
Ordering::Greater
} else {
a.cmp(b)
}
});
app_state.dir_cache.insert(path.clone(), read.clone());
read
};
let search_query = app_state.search_query.to_lowercase();
let search_query = search_query.trim();
for child in children {
if !child.is_dir() {
if !crate::gui::SUPPORTED_EXTENSIONS.contains(
&child
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
) {
continue;
}
if !search_query.is_empty() {
let file_name = child
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !file_name.to_lowercase().contains(search_query) {
continue;
}
}
}
Self::draw_tree_node(ui, child, app_state, audio_player_state, actions);
}
});
} else {
let file_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
ui.horizontal(|ui| {
// Hotkey badge
let mut hotkey_badge = None;
for slot in &app_state.hotkey_config.slots {
if slot.action.name == "play"
&& let Some(file_path_str) = slot.action.args.get("file_path")
&& Path::new(file_path_str) == path
{
if let Some(chord) = &slot.key_chord {
hotkey_badge = Some(format!("[{}]", chord));
} else {
hotkey_badge = Some(format!("[{}]", slot.slot));
}
break;
}
} }
}
if let Some(badge) = &hotkey_badge {
ui.label(
RichText::new(badge)
.small()
.monospace()
.color(Color32::from_rgb(100, 200, 100)),
);
}
let mut file_button_text = RichText::new(&file_name);
if let Some(current_file) = &app_state.selected_file
&& current_file.eq(&path)
{
file_button_text = file_button_text.color(Color32::WHITE);
}
let file_button = Button::new(file_button_text).frame(false).truncate();
let file_button_response = ui.add(file_button);
if file_button_response.clicked() {
ui.input(|i| {
if i.modifiers.ctrl {
actions.push(FileAction::Play(path.clone(), true));
} else if i.modifiers.shift
&& let Some(last_track) = audio_player_state.tracks.last()
{
actions.push(FileAction::StopAndPlay(
last_track.id,
path.clone(),
true,
));
} else {
actions.push(FileAction::Play(path.clone(), false));
}
});
actions.push(FileAction::SetSelected(path.clone()));
}
// Context menu
file_button_response.context_menu(|ui| {
if ui
.button(format!(
"{} {}",
ICON_BOLT.codepoint,
t!("gui.context.files.play_solo")
))
.clicked()
{
actions.push(FileAction::Play(path.clone(), false));
actions.push(FileAction::SetSelected(path.clone()));
}
if ui
.button(format!(
"{} {}",
ICON_ADD.codepoint,
t!("gui.context.files.add_new")
))
.clicked()
{
actions.push(FileAction::Play(path.clone(), true));
actions.push(FileAction::SetSelected(path.clone()));
}
if ui
.button(format!(
"{} {}",
ICON_SWAP_HORIZ.codepoint,
t!("gui.context.files.replace_last")
))
.clicked()
&& let Some(last_track) = audio_player_state.tracks.last()
{
actions.push(FileAction::StopAndPlay(last_track.id, path.clone(), true));
actions.push(FileAction::SetSelected(path.clone()));
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_OPEN_IN_BROWSER.codepoint,
t!("gui.context.files.show_in_fm")
))
.clicked()
&& let Err(e) = opener::reveal(&path)
{
eprintln!("Failed to open file manager: {}", e);
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_KEYBOARD.codepoint,
t!("gui.context.files.asign_hotkey")
))
.clicked()
{
actions.push(FileAction::AssignHotkey(path.clone()));
ui.close();
}
});
});
} }
None
} }
fn draw_footer(&mut self, ui: &mut Ui) { fn draw_footer(&mut self, ui: &mut Ui) {
+16 -5
View File
@@ -21,6 +21,7 @@ use pwsp::{
}; };
use rfd::FileDialog; use rfd::FileDialog;
use std::{ use std::{
cmp::Ordering,
fs, fs,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::{Arc, Mutex}, sync::{Arc, Mutex},
@@ -110,14 +111,14 @@ impl SoundpadGui {
self.app_state.current_dir = Some(path.clone()); self.app_state.current_dir = Some(path.clone());
match path.read_dir() { match path.read_dir() {
Ok(read_dir) => { Ok(read_dir) => {
self.app_state.files = read_dir self.app_state.listed_files = read_dir
.filter_map(|res| res.ok()) .filter_map(|res| res.ok())
.map(|entry| entry.path()) .map(|entry| entry.path())
.collect(); .collect();
} }
Err(e) => { Err(e) => {
eprintln!("Failed to read directory {:?}: {}", path, e); eprintln!("Failed to read directory {:?}: {}", path, e);
self.app_state.files.clear(); self.app_state.listed_files.clear();
} }
} }
} }
@@ -157,8 +158,18 @@ impl SoundpadGui {
} }
pub fn get_filtered_files(&self) -> Vec<PathBuf> { pub fn get_filtered_files(&self) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = self.app_state.files.iter().cloned().collect(); let mut files: Vec<PathBuf> = self.app_state.listed_files.iter().cloned().collect();
files.sort(); files.sort_by(|a, b| {
let a_is_dir = a.is_dir();
let b_is_dir = b.is_dir();
if a_is_dir && !b_is_dir {
Ordering::Less
} else if !a_is_dir && b_is_dir {
Ordering::Greater
} else {
a.cmp(b)
}
});
let search_query = self.app_state.search_query.to_lowercase(); let search_query = self.app_state.search_query.to_lowercase();
let search_query = search_query.trim(); let search_query = search_query.trim();
@@ -167,7 +178,7 @@ impl SoundpadGui {
.into_iter() .into_iter()
.filter(|entry_path| { .filter(|entry_path| {
if entry_path.is_dir() { if entry_path.is_dir() {
return false; return true;
} }
if !SUPPORTED_EXTENSIONS.contains( if !SUPPORTED_EXTENSIONS.contains(
+1 -1
View File
@@ -16,7 +16,7 @@ impl App for SoundpadGui {
&& current_dir == &path && current_dir == &path
{ {
self.app_state.current_dir = None; self.app_state.current_dir = None;
self.app_state.files.clear(); self.app_state.listed_files.clear();
} }
} }
+6 -2
View File
@@ -44,14 +44,18 @@ pub struct AppState {
pub dirs_to_remove: HashSet<PathBuf>, pub dirs_to_remove: HashSet<PathBuf>,
pub selected_file: Option<PathBuf>, pub selected_file: Option<PathBuf>,
pub files: HashSet<PathBuf>, pub listed_files: HashSet<PathBuf>,
pub listed_dirs: HashSet<PathBuf>,
pub dir_cache: HashMap<PathBuf, Vec<PathBuf>>,
pub show_hotkeys: bool, pub show_hotkeys: bool,
pub hotkey_capture_active: bool,
pub hotkey_config: HotkeyConfig, pub hotkey_config: HotkeyConfig,
pub hotkey_search_query: String, pub hotkey_search_query: String,
pub assigning_hotkey_slot: Option<String>, pub assigning_hotkey_slot: Option<String>,
pub assigning_hotkey_for_file: Option<PathBuf>, pub assigning_hotkey_for_file: Option<PathBuf>,
pub hotkey_capture_active: bool,
} }
#[derive(Default, Debug, Clone)] #[derive(Default, Debug, Clone)]