From f2dcf2e0fea0f839a4ecc903b9bcf6709f07fcc1 Mon Sep 17 00:00:00 2001 From: Tarasov Aleksandr <55220741+arabianq@users.noreply.github.com> Date: Sun, 17 May 2026 18:36:02 +0300 Subject: [PATCH] refactor: Split large draw.rs into modular views (#113) Replaced the monolithic `src/gui/draw.rs` with a new `src/gui/views` directory module. The GUI drawing logic is now cleanly separated into distinct files: - `body.rs` - `footer.rs` - `header.rs` - `hotkey_capture.rs` - `hotkeys.rs` - `settings.rs` - `waiting_for_daemon.rs` This organization vastly improves readability and maintainability without altering functionality. All shared helpers are centralized in `src/gui/views/mod.rs` and imports are strictly managed. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- src/gui/draw.rs | 1126 --------------------------- src/gui/mod.rs | 2 +- src/gui/views/body.rs | 436 +++++++++++ src/gui/views/footer.rs | 86 ++ src/gui/views/header.rs | 196 +++++ src/gui/views/hotkey_capture.rs | 32 + src/gui/views/hotkeys.rs | 311 ++++++++ src/gui/views/mod.rs | 32 + src/gui/views/settings.rs | 64 ++ src/gui/views/waiting_for_daemon.rs | 14 + 10 files changed, 1172 insertions(+), 1127 deletions(-) delete mode 100644 src/gui/draw.rs create mode 100644 src/gui/views/body.rs create mode 100644 src/gui/views/footer.rs create mode 100644 src/gui/views/header.rs create mode 100644 src/gui/views/hotkey_capture.rs create mode 100644 src/gui/views/hotkeys.rs create mode 100644 src/gui/views/mod.rs create mode 100644 src/gui/views/settings.rs create mode 100644 src/gui/views/waiting_for_daemon.rs diff --git a/src/gui/draw.rs b/src/gui/draw.rs deleted file mode 100644 index 2129593..0000000 --- a/src/gui/draw.rs +++ /dev/null @@ -1,1126 +0,0 @@ -use crate::gui::SoundpadGui; -use egui::{ - Align, AtomExt, Button, CollapsingHeader, Color32, ComboBox, CursorIcon, FontFamily, Label, - Layout, RichText, ScrollArea, Sense, Slider, TextEdit, Ui, Vec2, -}; -use egui_dnd::dnd; -use egui_extras::{Column, TableBuilder}; -use egui_material_icons::icons::*; -use pwsp::types::gui::AudioPlayerState; -use pwsp::types::socket::Request; -use pwsp::types::{audio_player::TrackInfo, gui::AppState}; -use pwsp::utils::gui::{format_time_pair, make_request_async}; -use rust_i18n::t; -use std::{ - cmp::Ordering, - path::{Path, PathBuf}, - time::Instant, -}; - -enum TrackAction { - Pause(u32), - Resume(u32), - ToggleLoop(u32), - Stop(u32), -} - -enum HotkeyAction { - Remove(String), - Capture(String), - ClearChord(String), - Play(String), -} - -enum FileAction { - Play(PathBuf, bool), - StopAndPlay(u32, PathBuf, bool), - AssignHotkey(PathBuf), -} - -impl SoundpadGui { - fn get_volume_icon(volume: f32) -> &'static str { - if volume > 0.7 { - ICON_VOLUME_UP.codepoint - } else if volume <= 0.0 { - ICON_VOLUME_OFF.codepoint - } else if volume < 0.3 { - ICON_VOLUME_MUTE.codepoint - } else { - ICON_VOLUME_DOWN.codepoint - } - } - - pub fn draw(&mut self, ui: &mut Ui) { - self.draw_header(ui); - self.draw_body(ui); - ui.separator(); - self.draw_footer(ui); - } - - pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) { - ui.centered_and_justified(|ui| { - ui.label( - RichText::new("Waiting for PWSP daemon to start...") - .size(34.0) - .monospace(), - ); - }); - } - - pub fn draw_hotkey_capture(&mut self, ui: &mut Ui) { - ui.vertical_centered(|ui| { - ui.add_space(ui.available_height() / 3.0); - ui.label( - RichText::new(t!("gui.hotkeys.capture.header")) - .size(18.0) - .color(Color32::YELLOW) - .monospace(), - ); - ui.add_space(10.0); - let target = if let Some(slot) = &self.app_state.assigning_hotkey_slot { - format!("{} '{}'", t!("gui.hotkeys.capture.for"), slot) - } else if let Some(path) = &self.app_state.assigning_hotkey_for_file { - format!( - "{} '{}'", - t!("gui.hotkeys.capture.for"), - path.file_name().unwrap_or_default().to_string_lossy() - ) - } else { - String::new() - }; - ui.label(RichText::new(target).size(16.0)); - ui.add_space(10.0); - ui.label(t!("gui.hotkeys.capture.cancel")); - }); - } - - pub fn draw_settings(&mut self, ui: &mut Ui) { - ui.vertical(|ui| { - ui.spacing_mut().item_spacing.y = 5.0; - // --------- Back Button and Title ---------- - ui.horizontal_top(|ui| { - let back_button = Button::new(ICON_ARROW_BACK).frame(false); - let back_button_response = ui.add(back_button); - if back_button_response.clicked() { - self.app_state.show_settings = false; - } - - ui.add_space(ui.available_width() / 2.0 - 40.0); - - ui.label( - RichText::new(t!("gui.settings.header")) - .color(Color32::WHITE) - .monospace(), - ); - }); - // -------------------------------- - - ui.separator(); - ui.add_space(20.0); - - // --------- Checkboxes ---------- - let save_volume_response = ui.checkbox( - &mut self.config.save_volume, - t!("gui.settings.remember_volume"), - ); - let save_input_response = - ui.checkbox(&mut self.config.save_input, t!("gui.settings.remember_mic")); - let save_scale_response = ui.checkbox( - &mut self.config.save_scale_factor, - t!("gui.settings.remember_ui_scale"), - ); - let pause_on_exit_response = ui.checkbox( - &mut self.config.pause_on_exit, - t!("gui.settings.pause_on_window_close"), - ); - - if save_volume_response.changed() - || save_input_response.changed() - || save_scale_response.changed() - || pause_on_exit_response.changed() - { - self.config.save_to_file().ok(); - } - // -------------------------------- - - ui.with_layout(Layout::bottom_up(Align::Min), |ui| { - ui.label(t!( - "gui.settings.version", - version = env!("CARGO_PKG_VERSION") - )); - }); - }); - } - - pub fn draw_hotkeys(&mut self, ui: &mut Ui) { - ui.vertical(|ui| { - ui.spacing_mut().item_spacing.y = 5.0; - - self.draw_hotkeys_header(ui); - ui.separator(); - - self.draw_hotkeys_search(ui); - ui.separator(); - ui.add_space(5.0); - - let action = self.draw_hotkeys_table(ui); - - if let Some(action) = action { - self.handle_hotkey_action(action); - } - }); - } - - fn draw_hotkeys_header(&mut self, ui: &mut Ui) { - ui.horizontal(|ui| { - let back_button = Button::new(ICON_ARROW_BACK).frame(false); - if ui.add(back_button).clicked() { - self.app_state.show_hotkeys = false; - } - - ui.vertical_centered(|ui| { - ui.label( - RichText::new(t!("gui.hotkeys.header")) - .color(Color32::WHITE) - .monospace(), - ); - }); - }); - } - - fn draw_hotkeys_search(&mut self, ui: &mut Ui) { - ui.horizontal(|ui| { - ui.menu_button( - format!( - "{} {}", - ICON_ADD.codepoint, - t!("gui.hotkeys.add_command_select") - ), - |ui| { - let mut selected_cmd = None; - if ui.button(t!("gui.hotkeys.toggle_pause_command")).clicked() { - selected_cmd = Some(("cmd_toggle_pause", Request::toggle_pause(None))); - } - if ui.button(t!("gui.hotkeys.stop_playback_command")).clicked() { - selected_cmd = Some(("cmd_stop", Request::stop(None))); - } - if ui - .button(t!("gui.hotkeys.pause_playback_command")) - .clicked() - { - selected_cmd = Some(("cmd_pause", Request::pause(None))); - } - if ui - .button(t!("gui.hotkeys.resume_playback_command")) - .clicked() - { - selected_cmd = Some(("cmd_resume", Request::resume(None))); - } - if ui.button(t!("gui.hotkeys.toggle_loop_command")).clicked() { - selected_cmd = Some(("cmd_toggle_loop", Request::toggle_loop(None))); - } - - if let Some((slot_name, req)) = selected_cmd { - make_request_async(Request::set_hotkey_action(slot_name, &req)); - self.app_state - .hotkey_config - .set_slot(slot_name.to_string(), req); - self.app_state.assigning_hotkey_slot = Some(slot_name.to_string()); - self.app_state.hotkey_capture_active = true; - ui.close(); - } - }, - ); - - ui.add_space(10.0); - - ui.add( - TextEdit::singleline(&mut self.app_state.hotkey_search_query) - .hint_text(t!("gui.hotkeys.search_placeholder")) - .desired_width(f32::INFINITY), - ); - }); - } - - fn draw_hotkeys_table(&mut self, ui: &mut Ui) -> Option { - let conflicts = self.app_state.hotkey_config.find_conflicts(); - let conflict_slots: std::collections::HashSet<&str> = - conflicts.into_iter().flat_map(|(a, b)| [a, b]).collect(); - - let search = self.app_state.hotkey_search_query.to_lowercase(); - let mut action: Option = None; - - let slots: Vec<_> = self - .app_state - .hotkey_config - .slots - .iter() - .filter(|s| { - if search.is_empty() { - return true; - } - s.slot.to_lowercase().contains(&search) - || format!("{:?}", s.action).to_lowercase().contains(&search) - || s.key_chord - .as_deref() - .unwrap_or("") - .to_lowercase() - .contains(&search) - }) - .collect(); - - let available_width = ui.available_width(); - let col_width = (available_width / 4.0).max(80.0); - - TableBuilder::new(ui) - .striped(true) - .column(Column::exact(col_width).clip(true)) // Slot - .column(Column::exact(col_width).clip(true)) // Sound / Action name - .column(Column::exact(col_width).clip(true)) // Key Chord - .column(Column::exact(col_width).clip(true)) // Actions - .header(30.0, |mut header| { - header.col(|ui| { - ui.label( - RichText::new(t!("gui.hotkeys.column_slot")) - .strong() - .monospace() - .color(Color32::LIGHT_GRAY), - ); - }); - header.col(|ui| { - ui.label( - RichText::new(t!("gui.hotkeys.column_sound")) - .strong() - .monospace() - .color(Color32::LIGHT_GRAY), - ); - }); - header.col(|ui| { - ui.label( - RichText::new(t!("gui.hotkeys.column_key_chord")) - .strong() - .monospace() - .color(Color32::LIGHT_GRAY), - ); - }); - header.col(|ui| { - ui.label( - RichText::new(t!("gui.hotkeys.column_actions")) - .strong() - .monospace() - .color(Color32::LIGHT_GRAY), - ); - }); - }) - .body(|mut body| { - if slots.is_empty() { - body.row(30.0, |mut row| { - row.col(|_| {}); - row.col(|ui| { - ui.label( - RichText::new(t!("gui.hotkeys.no_hotkeys_configured")) - .color(Color32::GRAY), - ); - }); - row.col(|_| {}); - row.col(|_| {}); - }); - return; - } - - for slot in &slots { - body.row(30.0, |mut row| { - // Column 1: Slot - row.col(|ui| { - ui.horizontal(|ui| { - if conflict_slots.contains(slot.slot.as_str()) { - ui.label( - RichText::new(ICON_WARNING.codepoint) - .color(Color32::from_rgb(255, 165, 0)), - ) - .on_hover_text("Key chord conflict"); - } - ui.add( - Label::new(RichText::new(&slot.slot).monospace()).truncate(), - ); - }); - }); - - // Column 2: Sound / Action name - row.col(|ui| { - let action_name = match slot.action.name.as_str() { - "play" => { - if let Some(file_path_str) = slot.action.args.get("file_path") { - Path::new(file_path_str) - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - } else { - "Play".to_string() - } - } - "toggle_pause" => "Toggle Pause".to_string(), - "pause" => "Pause Playback".to_string(), - "resume" => "Resume Playback".to_string(), - "stop" => "Stop Playback".to_string(), - "toggle_loop" => "Toggle Loop".to_string(), - other => other.to_string(), - }; - ui.add(Label::new(RichText::new(action_name).monospace()).truncate()); - }); - - // Column 3: Key Chord - row.col(|ui| { - let chord_text = slot.key_chord.as_deref().unwrap_or("(none)"); - ui.add( - Label::new(RichText::new(chord_text).monospace().color( - if slot.key_chord.is_some() { - Color32::from_rgb(100, 200, 100) - } else { - Color32::GRAY - }, - )) - .truncate(), - ); - }); - - // Column 4: Actions - row.col(|ui| { - ui.horizontal(|ui| { - if ui - .add(Button::new(ICON_DELETE).frame(false)) - .on_hover_text("Remove slot") - .clicked() - { - action = Some(HotkeyAction::Remove(slot.slot.clone())); - } - if ui - .add(Button::new(ICON_KEYBOARD).frame(false)) - .on_hover_text("Set key chord") - .clicked() - { - action = Some(HotkeyAction::Capture(slot.slot.clone())); - } - if slot.key_chord.is_some() - && ui - .add(Button::new(ICON_BACKSPACE).frame(false)) - .on_hover_text("Clear key chord") - .clicked() - { - action = Some(HotkeyAction::ClearChord(slot.slot.clone())); - } - if ui - .add(Button::new(ICON_PLAY_ARROW).frame(false)) - .on_hover_text("Play") - .clicked() - { - action = Some(HotkeyAction::Play(slot.slot.clone())); - } - }); - }); - }); - } - }); - - action - } - - fn handle_hotkey_action(&mut self, action: HotkeyAction) { - match action { - HotkeyAction::Remove(slot) => { - make_request_async(Request::clear_hotkey(&slot)); - self.app_state.hotkey_config.remove_slot(&slot); - } - HotkeyAction::Capture(slot) => { - self.app_state.assigning_hotkey_slot = Some(slot); - self.app_state.hotkey_capture_active = true; - } - HotkeyAction::ClearChord(slot) => { - make_request_async(Request::clear_hotkey_key(&slot)); - self.app_state.hotkey_config.set_key_chord(&slot, None); - } - HotkeyAction::Play(slot) => { - self.play_hotkey_slot(&slot); - } - } - } - - fn draw_header(&mut self, ui: &mut Ui) { - ui.vertical_centered_justified(|ui| { - if self.audio_player_state.tracks.is_empty() { - ui.label("No tracks playing"); - return; - } - - let mut action = None; - - for track in &self.audio_player_state.tracks { - CollapsingHeader::new( - RichText::new( - track - .path - .file_stem() - .unwrap_or_default() - .to_str() - .unwrap_or_default(), - ) - .color(Color32::WHITE) - .family(FontFamily::Monospace), - ) - .default_open(true) - .show(ui, |ui| { - if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, track) { - action = Some(act); - } - }); - ui.separator(); - } - - if let Some(action) = action { - match action { - TrackAction::Pause(id) => self.pause(Some(id)), - TrackAction::Resume(id) => self.resume(Some(id)), - TrackAction::ToggleLoop(id) => self.toggle_loop(Some(id)), - TrackAction::Stop(id) => self.stop(Some(id)), - } - } - }); - } - - fn draw_playback_controls(ui: &mut Ui, track: &TrackInfo) -> Option { - let mut action = None; - - let play_button = Button::new(if track.paused { - ICON_PLAY_ARROW - } else { - ICON_PAUSE - }) - .corner_radius(15.0); - - if ui.add_sized([30.0, 30.0], play_button).clicked() { - action = Some(if track.paused { - TrackAction::Resume(track.id) - } else { - TrackAction::Pause(track.id) - }); - } - - let loop_button = Button::new( - RichText::new(if track.looped { - ICON_REPEAT_ONE - } else { - ICON_REPEAT - }) - .size(18.0), - ) - .frame(false); - - if ui.add_sized([15.0, 30.0], loop_button).clicked() { - action = Some(TrackAction::ToggleLoop(track.id)); - } - - action - } - - fn draw_position_control( - ui: &mut Ui, - ui_state: &mut pwsp::types::gui::TrackUiState, - track: &TrackInfo, - default_slider_width: f32, - ) { - let duration = track.duration.unwrap_or(1.0); - let position_slider = Slider::new(&mut ui_state.position_slider_value, 0.0..=duration) - .show_value(false) - .step_by(0.01); - - let position_slider_width = ui.available_width() - - (30.0 * 3.0) - - default_slider_width - - (ui.spacing().item_spacing.x * 6.0); - - ui.spacing_mut().slider_width = position_slider_width; - if ui.add_sized([30.0, 30.0], position_slider).drag_stopped() { - ui_state.position_dragged = true; - } - - let time_label = - Label::new(RichText::new(format_time_pair(track.position, duration)).monospace()); - ui.add_sized([30.0, 30.0], time_label); - } - - fn draw_volume_control( - ui: &mut Ui, - ui_state: &mut pwsp::types::gui::TrackUiState, - track: &TrackInfo, - default_slider_width: f32, - ) { - let volume_icon = Self::get_volume_icon(track.volume); - let volume_label = Label::new(RichText::new(volume_icon).size(18.0)); - ui.add_sized([30.0, 30.0], volume_label) - .on_hover_text(format!("Volume: {:.0}%", track.volume * 100.0)); - - let volume_slider = Slider::new(&mut ui_state.volume_slider_value, 0.0..=1.0) - .show_value(false) - .step_by(0.01); - - ui.spacing_mut().slider_width = default_slider_width - 30.0; - ui.spacing_mut().item_spacing.x = 0.0; - - if ui.add_sized([30.0, 30.0], volume_slider).drag_stopped() { - ui_state.volume_dragged = true; - } - } - - fn draw_stop_control(ui: &mut Ui, track: &TrackInfo) -> Option { - let stop_button = Button::new(ICON_CLOSE).frame(false); - if ui.add_sized([30.0, 30.0], stop_button).clicked() { - Some(TrackAction::Stop(track.id)) - } else { - None - } - } - - fn draw_track_control( - ui: &mut Ui, - app_state: &mut AppState, - track: &TrackInfo, - ) -> Option { - let ui_state = app_state.track_ui_states.entry(track.id).or_default(); - - let should_update_position = !ui_state.position_dragged - && ui_state - .ignore_position_update_until - .map(|t| Instant::now() > t) - .unwrap_or(true); - - if should_update_position { - ui_state.position_slider_value = track.position; - } - - let should_update_volume = !ui_state.volume_dragged - && ui_state - .ignore_volume_update_until - .map(|t| Instant::now() > t) - .unwrap_or(true); - - if should_update_volume { - ui_state.volume_slider_value = track.volume; - } - - let mut action = None; - - ui.horizontal_top(|ui| { - if let Some(act) = Self::draw_playback_controls(ui, track) { - action = Some(act); - } - - let default_slider_width = ui.spacing().slider_width; - Self::draw_position_control(ui, ui_state, track, default_slider_width); - Self::draw_volume_control(ui, ui_state, track, default_slider_width); - - if let Some(act) = Self::draw_stop_control(ui, track) { - action = Some(act); - } - }); - - action - } - - fn draw_body(&mut self, ui: &mut Ui) { - let left_panel_width = self - .config - .left_panel_width - .max(100.0) - .min(ui.available_width() - 100.0); - let dirs_size = Vec2::new(left_panel_width, ui.available_height() - 40.0); - - ui.horizontal(|ui| { - self.draw_dirs(ui, dirs_size); - - let (rect, response) = ui.allocate_at_least( - Vec2::new(ui.spacing().item_spacing.x, ui.available_height()), - Sense::click_and_drag(), - ); - - if ui.is_rect_visible(rect) { - let stroke = ui.visuals().widgets.noninteractive.bg_stroke; - ui.painter().vline(rect.center().x, rect.y_range(), stroke); - } - - let vertical_separator_response = - response.on_hover_and_drag_cursor(CursorIcon::ResizeHorizontal); - - if vertical_separator_response.dragged() { - self.config.left_panel_width += vertical_separator_response.drag_delta().x; - self.config.left_panel_width = self.config.left_panel_width.clamp(100.0, 500.0); - } - - if vertical_separator_response.drag_stopped() { - self.config.save_to_file().ok(); - } - - let files_size = Vec2::new(ui.available_width(), ui.available_height() - 40.0); - self.draw_files(ui, files_size); - }); - } - - fn draw_dirs(&mut self, ui: &mut Ui, area_size: Vec2) { - ui.vertical(|ui| { - ui.set_min_width(area_size.x); - ui.set_min_height(area_size.y); - - ScrollArea::vertical().id_salt(0).show(ui, |ui| { - ui.set_min_width(area_size.x); - - let mut dirs = std::mem::take(&mut self.app_state.dirs); - let mut dir_to_open = None; - - dnd(ui, "dnd_directories").show_vec(&mut dirs, |ui, item, handle, _state| { - let path = item; - ui.horizontal(|ui| { - handle.ui(ui, |ui| { - ui.label(ICON_DRAG_INDICATOR.codepoint); - }); - let name = path - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| path.to_string_lossy().to_string()); - - let mut dir_button_text = RichText::new(name.clone()); - if let Some(current_dir) = &self.app_state.current_dir - && current_dir.eq(&*path) - { - dir_button_text = dir_button_text.color(Color32::WHITE); - } - - let dir_button = - Button::new(dir_button_text.atom_max_width(area_size.x)).frame(false); - - let dir_button_response = ui.add(dir_button); - if dir_button_response.clicked() { - dir_to_open = Some(path.clone()); - } - - let delete_dir_button = Button::new(ICON_DELETE).frame(false); - let delete_dir_button_response = - ui.add_sized([18.0, 18.0], delete_dir_button); - if delete_dir_button_response.clicked() { - self.app_state.dirs_to_remove.insert(path.clone()); - } - - // Context menu - dir_button_response.context_menu(|ui| { - if ui - .button(format!( - "{} {}", - ICON_OPEN_IN_NEW.codepoint, - t!("gui.context.dirs.open") - )) - .clicked() - { - dir_to_open = Some(path.clone()); - } - - if ui - .button(format!( - "{} {}", - ICON_OPEN_IN_BROWSER.codepoint, - t!("gui.context.dirs.open_in_fm") - )) - .clicked() - && let Err(e) = opener::open(&path) - { - eprintln!("Failed to open file manager: {}", e); - } - - ui.separator(); - - if ui - .button(format!( - "{} {}", - ICON_DELETE.codepoint, - t!("gui.context.dirs.remove") - )) - .clicked() - { - self.app_state.dirs_to_remove.insert(path.clone()); - } - }); - }); - }); - self.app_state.dirs = dirs; - - if let Some(path) = dir_to_open { - self.open_dir(&path); - } - - ui.horizontal(|ui| { - let add_dirs_button = Button::new(ICON_ADD).frame(false); - let add_dirs_button_response = ui.add_sized([18.0, 18.0], add_dirs_button); - if add_dirs_button_response.clicked() { - self.add_dirs(); - } - }); - - ui.with_layout(Layout::bottom_up(Align::Min), |ui| { - let play_file_button = Button::new(t!("gui.play_file_button")); - let play_file_button_response = ui.add(play_file_button); - if play_file_button_response.clicked() { - self.open_file(); - } - }); - }); - }); - } - - fn draw_files_search_field(&mut self, ui: &mut Ui) { - ui.horizontal(|ui| { - let search_field_response = ui.add_sized( - [ui.available_width(), 22.0], - TextEdit::singleline(&mut self.app_state.search_query) - .hint_text(t!("gui.search_placeholder")), - ); - - if self.app_state.force_focus_search { - search_field_response.request_focus(); - self.app_state.force_focus_search = false; - } - - self.app_state.search_field_id = Some(search_field_response.id); - }); - } - - fn draw_files_list(&mut self, ui: &mut Ui, area_size: Vec2) { - ScrollArea::vertical().id_salt(1).show(ui, |ui| { - ui.set_min_width(area_size.x); - ui.set_min_height(area_size.y); - - ui.vertical(|ui| { - let mut actions = Vec::new(); - let files = self.get_filtered_files(); - for entry_path in files { - Self::draw_tree_node( - ui, - entry_path, - &mut self.app_state, - &self.audio_player_state, - &mut actions, - ); - } - - for action in actions { - match action { - FileAction::Play(path, concurrent) => self.play_file(&path, concurrent), - FileAction::StopAndPlay(id, path, concurrent) => { - self.stop(Some(id)); - self.play_file(&path, concurrent); - } - FileAction::AssignHotkey(path) => { - self.app_state.assigning_hotkey_for_file = Some(path); - self.app_state.hotkey_capture_active = true; - } - } - } - }); - }); - } - - fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) { - ui.vertical(|ui| { - self.draw_files_search_field(ui); - ui.separator(); - self.draw_files_list(ui, area_size); - }); - } - - fn draw_tree_node_dir( - ui: &mut Ui, - path: std::path::PathBuf, - app_state: &mut AppState, - audio_player_state: &AudioPlayerState, - actions: &mut Vec, - ) { - let dir_name = path - .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); - } - }); - } - - fn draw_tree_node_file( - ui: &mut Ui, - path: std::path::PathBuf, - app_state: &mut AppState, - audio_player_state: &AudioPlayerState, - actions: &mut Vec, - ) { - 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 file_button_text = RichText::new(&file_name); - - 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)); - } - }); - } - - // 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)); - } - - if ui - .button(format!( - "{} {}", - ICON_ADD.codepoint, - t!("gui.context.files.add_new") - )) - .clicked() - { - actions.push(FileAction::Play(path.clone(), true)); - } - - 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)); - } - - 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(); - } - }); - }); - } - - fn draw_tree_node( - ui: &mut Ui, - path: std::path::PathBuf, - app_state: &mut AppState, - audio_player_state: &AudioPlayerState, - actions: &mut Vec, - ) { - if path.is_dir() { - Self::draw_tree_node_dir(ui, path, app_state, audio_player_state, actions); - } else { - Self::draw_tree_node_file(ui, path, app_state, audio_player_state, actions); - } - } - - fn draw_footer(&mut self, ui: &mut Ui) { - ui.add_space(5.0); - ui.horizontal(|ui| { - // ---------- Microphone selection ---------- - let mics = &self.audio_player_state.all_inputs_sorted; - - let mut selected_input = self.audio_player_state.current_input.to_owned(); - let prev_input = selected_input.to_owned(); - ComboBox::from_label(t!("gui.choose_mic_select")) - .height(30.0) - .selected_text( - self.audio_player_state - .all_inputs - .get(&selected_input) - .unwrap_or(&String::new()), - ) - .show_ui(ui, |ui| { - for (name, nick) in mics { - ui.selectable_value(&mut selected_input, name.clone(), nick); - } - }); - - if selected_input != prev_input { - self.set_input(selected_input); - } - // -------------------------------- - - // ---------- Master Volume Slider ---------- - let volume_icon = Self::get_volume_icon(self.audio_player_state.volume); - let volume_label = Label::new(RichText::new(volume_icon).size(18.0)); - ui.add_sized([18.0, 18.0], volume_label) - .on_hover_text(format!( - "Master Volume: {:.0}%", - self.audio_player_state.volume * 100.0 - )); - - let should_update_volume = !self.app_state.volume_dragged - && self - .app_state - .ignore_volume_update_until - .map(|t| Instant::now() > t) - .unwrap_or(true); - - if should_update_volume { - self.app_state.volume_slider_value = self.audio_player_state.volume; - } - - let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0) - .show_value(false) - .step_by(0.01); - let volume_slider_response = ui.add_sized([150.0, 18.0], volume_slider); - if volume_slider_response.drag_stopped() { - self.app_state.volume_dragged = true; - } - // ------------------------------------------ - - ui.add_space(ui.available_width() - 18.0 * 2.0 - ui.spacing().item_spacing.x * 2.0); - - // ---------- Hotkeys button ---------- - let hotkeys_button = - Button::new(ICON_KEYBOARD.atom_size(Vec2::new(18.0, 18.0))).frame(false); - let hotkeys_button_response = ui.add_sized([18.0, 18.0], hotkeys_button); - if hotkeys_button_response.clicked() { - self.app_state.show_hotkeys = true; - } - hotkeys_button_response.on_hover_text("Hotkeys (H)"); - // -------------------------------- - - // ---------- Settings button ---------- - let settings_button = - Button::new(ICON_SETTINGS.atom_size(Vec2::new(18.0, 18.0))).frame(false); - let settings_button_response = ui.add_sized([18.0, 18.0], settings_button); - if settings_button_response.clicked() { - self.app_state.show_settings = true; - } - // -------------------------------- - }); - } -} diff --git a/src/gui/mod.rs b/src/gui/mod.rs index bef9a1a..3cbc458 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1,6 +1,6 @@ -mod draw; mod input; mod update; +mod views; use anyhow::{Result, anyhow}; use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native}; diff --git a/src/gui/views/body.rs b/src/gui/views/body.rs new file mode 100644 index 0000000..0784e5e --- /dev/null +++ b/src/gui/views/body.rs @@ -0,0 +1,436 @@ +use crate::gui::SoundpadGui; +use egui::{ + Align, AtomExt, Button, CollapsingHeader, Color32, CursorIcon, Layout, RichText, ScrollArea, Sense, + TextEdit, Ui, Vec2, +}; +use egui_dnd::dnd; +use egui_material_icons::icons::*; +use pwsp::types::{gui::AppState, gui::AudioPlayerState}; +use rust_i18n::t; +use std::{cmp::Ordering, path::Path, path::PathBuf}; + +pub(crate) enum FileAction { + Play(PathBuf, bool), + StopAndPlay(u32, PathBuf, bool), + AssignHotkey(PathBuf), +} + +impl SoundpadGui { + pub fn draw_body(&mut self, ui: &mut Ui) { + let left_panel_width = self + .config + .left_panel_width + .max(100.0) + .min(ui.available_width() - 100.0); + let dirs_size = Vec2::new(left_panel_width, ui.available_height() - 40.0); + + ui.horizontal(|ui| { + self.draw_dirs(ui, dirs_size); + + let (rect, response) = ui.allocate_at_least( + Vec2::new(ui.spacing().item_spacing.x, ui.available_height()), + Sense::click_and_drag(), + ); + + if ui.is_rect_visible(rect) { + let stroke = ui.visuals().widgets.noninteractive.bg_stroke; + ui.painter().vline(rect.center().x, rect.y_range(), stroke); + } + + let vertical_separator_response = + response.on_hover_and_drag_cursor(CursorIcon::ResizeHorizontal); + + if vertical_separator_response.dragged() { + self.config.left_panel_width += vertical_separator_response.drag_delta().x; + self.config.left_panel_width = self.config.left_panel_width.clamp(100.0, 500.0); + } + + if vertical_separator_response.drag_stopped() { + self.config.save_to_file().ok(); + } + + let files_size = Vec2::new(ui.available_width(), ui.available_height() - 40.0); + self.draw_files(ui, files_size); + }); + } + + fn draw_dirs(&mut self, ui: &mut Ui, area_size: Vec2) { + ui.vertical(|ui| { + ui.set_min_width(area_size.x); + ui.set_min_height(area_size.y); + + ScrollArea::vertical().id_salt(0).show(ui, |ui| { + ui.set_min_width(area_size.x); + + let mut dirs = std::mem::take(&mut self.app_state.dirs); + let mut dir_to_open = None; + + dnd(ui, "dnd_directories").show_vec(&mut dirs, |ui, item, handle, _state| { + let path = item; + ui.horizontal(|ui| { + handle.ui(ui, |ui| { + ui.label(ICON_DRAG_INDICATOR.codepoint); + }); + let name = path + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string_lossy().to_string()); + + let mut dir_button_text = RichText::new(name.clone()); + if let Some(current_dir) = &self.app_state.current_dir + && current_dir.eq(&*path) + { + dir_button_text = dir_button_text.color(Color32::WHITE); + } + + let dir_button = + Button::new(dir_button_text.atom_max_width(area_size.x)).frame(false); + + let dir_button_response = ui.add(dir_button); + if dir_button_response.clicked() { + dir_to_open = Some(path.clone()); + } + + let delete_dir_button = Button::new(ICON_DELETE).frame(false); + let delete_dir_button_response = + ui.add_sized([18.0, 18.0], delete_dir_button); + if delete_dir_button_response.clicked() { + self.app_state.dirs_to_remove.insert(path.clone()); + } + + // Context menu + dir_button_response.context_menu(|ui| { + if ui + .button(format!( + "{} {}", + ICON_OPEN_IN_NEW.codepoint, + t!("gui.context.dirs.open") + )) + .clicked() + { + dir_to_open = Some(path.clone()); + } + + if ui + .button(format!( + "{} {}", + ICON_OPEN_IN_BROWSER.codepoint, + t!("gui.context.dirs.open_in_fm") + )) + .clicked() + && let Err(e) = opener::open(&path) + { + eprintln!("Failed to open file manager: {}", e); + } + + ui.separator(); + + if ui + .button(format!( + "{} {}", + ICON_DELETE.codepoint, + t!("gui.context.dirs.remove") + )) + .clicked() + { + self.app_state.dirs_to_remove.insert(path.clone()); + } + }); + }); + }); + self.app_state.dirs = dirs; + + if let Some(path) = dir_to_open { + self.open_dir(&path); + } + + ui.horizontal(|ui| { + let add_dirs_button = Button::new(ICON_ADD).frame(false); + let add_dirs_button_response = ui.add_sized([18.0, 18.0], add_dirs_button); + if add_dirs_button_response.clicked() { + self.add_dirs(); + } + }); + + ui.with_layout(Layout::bottom_up(Align::Min), |ui| { + let play_file_button = Button::new(t!("gui.play_file_button")); + let play_file_button_response = ui.add(play_file_button); + if play_file_button_response.clicked() { + self.open_file(); + } + }); + }); + }); + } + + fn draw_files_search_field(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + let search_field_response = ui.add_sized( + [ui.available_width(), 22.0], + TextEdit::singleline(&mut self.app_state.search_query) + .hint_text(t!("gui.search_placeholder")), + ); + + if self.app_state.force_focus_search { + search_field_response.request_focus(); + self.app_state.force_focus_search = false; + } + + self.app_state.search_field_id = Some(search_field_response.id); + }); + } + + fn draw_files_list(&mut self, ui: &mut Ui, area_size: Vec2) { + ScrollArea::vertical().id_salt(1).show(ui, |ui| { + ui.set_min_width(area_size.x); + ui.set_min_height(area_size.y); + + ui.vertical(|ui| { + let mut actions = Vec::new(); + let files = self.get_filtered_files(); + for entry_path in files { + Self::draw_tree_node( + ui, + entry_path, + &mut self.app_state, + &self.audio_player_state, + &mut actions, + ); + } + + for action in actions { + match action { + FileAction::Play(path, concurrent) => self.play_file(&path, concurrent), + FileAction::StopAndPlay(id, path, concurrent) => { + self.stop(Some(id)); + self.play_file(&path, concurrent); + } + FileAction::AssignHotkey(path) => { + self.app_state.assigning_hotkey_for_file = Some(path); + self.app_state.hotkey_capture_active = true; + } + } + } + }); + }); + } + + fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) { + ui.vertical(|ui| { + self.draw_files_search_field(ui); + ui.separator(); + self.draw_files_list(ui, area_size); + }); + } + + fn draw_tree_node_dir( + ui: &mut Ui, + path: std::path::PathBuf, + app_state: &mut AppState, + audio_player_state: &AudioPlayerState, + actions: &mut Vec, + ) { + let dir_name = path + .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); + } + }); + } + + fn draw_tree_node_file( + ui: &mut Ui, + path: std::path::PathBuf, + app_state: &mut AppState, + audio_player_state: &AudioPlayerState, + actions: &mut Vec, + ) { + 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 file_button_text = RichText::new(&file_name); + + 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)); + } + }); + } + + // 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)); + } + + if ui + .button(format!( + "{} {}", + ICON_ADD.codepoint, + t!("gui.context.files.add_new") + )) + .clicked() + { + actions.push(FileAction::Play(path.clone(), true)); + } + + 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)); + } + + 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(); + } + }); + }); + } + + fn draw_tree_node( + ui: &mut Ui, + path: std::path::PathBuf, + app_state: &mut AppState, + audio_player_state: &AudioPlayerState, + actions: &mut Vec, + ) { + if path.is_dir() { + Self::draw_tree_node_dir(ui, path, app_state, audio_player_state, actions); + } else { + Self::draw_tree_node_file(ui, path, app_state, audio_player_state, actions); + } + } +} diff --git a/src/gui/views/footer.rs b/src/gui/views/footer.rs new file mode 100644 index 0000000..9bd3ce5 --- /dev/null +++ b/src/gui/views/footer.rs @@ -0,0 +1,86 @@ +use crate::gui::SoundpadGui; +use egui::{AtomExt, Button, ComboBox, Label, RichText, Slider, Ui, Vec2}; +use egui_material_icons::icons::*; +use rust_i18n::t; +use std::time::Instant; + +impl SoundpadGui { + pub fn draw_footer(&mut self, ui: &mut Ui) { + ui.add_space(5.0); + ui.horizontal(|ui| { + // ---------- Microphone selection ---------- + let mics = &self.audio_player_state.all_inputs_sorted; + + let mut selected_input = self.audio_player_state.current_input.to_owned(); + let prev_input = selected_input.to_owned(); + ComboBox::from_label(t!("gui.choose_mic_select")) + .height(30.0) + .selected_text( + self.audio_player_state + .all_inputs + .get(&selected_input) + .unwrap_or(&String::new()), + ) + .show_ui(ui, |ui| { + for (name, nick) in mics { + ui.selectable_value(&mut selected_input, name.clone(), nick); + } + }); + + if selected_input != prev_input { + self.set_input(selected_input); + } + // -------------------------------- + + // ---------- Master Volume Slider ---------- + let volume_icon = Self::get_volume_icon(self.audio_player_state.volume); + let volume_label = Label::new(RichText::new(volume_icon).size(18.0)); + ui.add_sized([18.0, 18.0], volume_label) + .on_hover_text(format!( + "Master Volume: {:.0}%", + self.audio_player_state.volume * 100.0 + )); + + let should_update_volume = !self.app_state.volume_dragged + && self + .app_state + .ignore_volume_update_until + .map(|t| Instant::now() > t) + .unwrap_or(true); + + if should_update_volume { + self.app_state.volume_slider_value = self.audio_player_state.volume; + } + + let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0) + .show_value(false) + .step_by(0.01); + let volume_slider_response = ui.add_sized([150.0, 18.0], volume_slider); + if volume_slider_response.drag_stopped() { + self.app_state.volume_dragged = true; + } + // ------------------------------------------ + + ui.add_space(ui.available_width() - 18.0 * 2.0 - ui.spacing().item_spacing.x * 2.0); + + // ---------- Hotkeys button ---------- + let hotkeys_button = + Button::new(ICON_KEYBOARD.atom_size(Vec2::new(18.0, 18.0))).frame(false); + let hotkeys_button_response = ui.add_sized([18.0, 18.0], hotkeys_button); + if hotkeys_button_response.clicked() { + self.app_state.show_hotkeys = true; + } + hotkeys_button_response.on_hover_text("Hotkeys (H)"); + // -------------------------------- + + // ---------- Settings button ---------- + let settings_button = + Button::new(ICON_SETTINGS.atom_size(Vec2::new(18.0, 18.0))).frame(false); + let settings_button_response = ui.add_sized([18.0, 18.0], settings_button); + if settings_button_response.clicked() { + self.app_state.show_settings = true; + } + // -------------------------------- + }); + } +} diff --git a/src/gui/views/header.rs b/src/gui/views/header.rs new file mode 100644 index 0000000..99479a7 --- /dev/null +++ b/src/gui/views/header.rs @@ -0,0 +1,196 @@ +use crate::gui::SoundpadGui; +use egui::{Button, CollapsingHeader, Color32, FontFamily, Label, RichText, Slider, Ui}; +use egui_material_icons::icons::*; +use pwsp::types::{audio_player::TrackInfo, gui::AppState}; +use pwsp::utils::gui::format_time_pair; +use std::time::Instant; + +pub(crate) enum TrackAction { + Pause(u32), + Resume(u32), + ToggleLoop(u32), + Stop(u32), +} + +impl SoundpadGui { + pub fn draw_header(&mut self, ui: &mut Ui) { + ui.vertical_centered_justified(|ui| { + if self.audio_player_state.tracks.is_empty() { + ui.label("No tracks playing"); + return; + } + + let mut action = None; + + for track in &self.audio_player_state.tracks { + CollapsingHeader::new( + RichText::new( + track + .path + .file_stem() + .unwrap_or_default() + .to_str() + .unwrap_or_default(), + ) + .color(Color32::WHITE) + .family(FontFamily::Monospace), + ) + .default_open(true) + .show(ui, |ui| { + if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, track) { + action = Some(act); + } + }); + ui.separator(); + } + + if let Some(action) = action { + match action { + TrackAction::Pause(id) => self.pause(Some(id)), + TrackAction::Resume(id) => self.resume(Some(id)), + TrackAction::ToggleLoop(id) => self.toggle_loop(Some(id)), + TrackAction::Stop(id) => self.stop(Some(id)), + } + } + }); + } + + fn draw_playback_controls(ui: &mut Ui, track: &TrackInfo) -> Option { + let mut action = None; + + let play_button = Button::new(if track.paused { + ICON_PLAY_ARROW + } else { + ICON_PAUSE + }) + .corner_radius(15.0); + + if ui.add_sized([30.0, 30.0], play_button).clicked() { + action = Some(if track.paused { + TrackAction::Resume(track.id) + } else { + TrackAction::Pause(track.id) + }); + } + + let loop_button = Button::new( + RichText::new(if track.looped { + ICON_REPEAT_ONE + } else { + ICON_REPEAT + }) + .size(18.0), + ) + .frame(false); + + if ui.add_sized([15.0, 30.0], loop_button).clicked() { + action = Some(TrackAction::ToggleLoop(track.id)); + } + + action + } + + fn draw_position_control( + ui: &mut Ui, + ui_state: &mut pwsp::types::gui::TrackUiState, + track: &TrackInfo, + default_slider_width: f32, + ) { + let duration = track.duration.unwrap_or(1.0); + let position_slider = Slider::new(&mut ui_state.position_slider_value, 0.0..=duration) + .show_value(false) + .step_by(0.01); + + let position_slider_width = ui.available_width() + - (30.0 * 3.0) + - default_slider_width + - (ui.spacing().item_spacing.x * 6.0); + + ui.spacing_mut().slider_width = position_slider_width; + if ui.add_sized([30.0, 30.0], position_slider).drag_stopped() { + ui_state.position_dragged = true; + } + + let time_label = + Label::new(RichText::new(format_time_pair(track.position, duration)).monospace()); + ui.add_sized([30.0, 30.0], time_label); + } + + fn draw_volume_control( + ui: &mut Ui, + ui_state: &mut pwsp::types::gui::TrackUiState, + track: &TrackInfo, + default_slider_width: f32, + ) { + let volume_icon = Self::get_volume_icon(track.volume); + let volume_label = Label::new(RichText::new(volume_icon).size(18.0)); + ui.add_sized([30.0, 30.0], volume_label) + .on_hover_text(format!("Volume: {:.0}%", track.volume * 100.0)); + + let volume_slider = Slider::new(&mut ui_state.volume_slider_value, 0.0..=1.0) + .show_value(false) + .step_by(0.01); + + ui.spacing_mut().slider_width = default_slider_width - 30.0; + ui.spacing_mut().item_spacing.x = 0.0; + + if ui.add_sized([30.0, 30.0], volume_slider).drag_stopped() { + ui_state.volume_dragged = true; + } + } + + fn draw_stop_control(ui: &mut Ui, track: &TrackInfo) -> Option { + let stop_button = Button::new(ICON_CLOSE).frame(false); + if ui.add_sized([30.0, 30.0], stop_button).clicked() { + Some(TrackAction::Stop(track.id)) + } else { + None + } + } + + fn draw_track_control( + ui: &mut Ui, + app_state: &mut AppState, + track: &TrackInfo, + ) -> Option { + let ui_state = app_state.track_ui_states.entry(track.id).or_default(); + + let should_update_position = !ui_state.position_dragged + && ui_state + .ignore_position_update_until + .map(|t| Instant::now() > t) + .unwrap_or(true); + + if should_update_position { + ui_state.position_slider_value = track.position; + } + + let should_update_volume = !ui_state.volume_dragged + && ui_state + .ignore_volume_update_until + .map(|t| Instant::now() > t) + .unwrap_or(true); + + if should_update_volume { + ui_state.volume_slider_value = track.volume; + } + + let mut action = None; + + ui.horizontal_top(|ui| { + if let Some(act) = Self::draw_playback_controls(ui, track) { + action = Some(act); + } + + let default_slider_width = ui.spacing().slider_width; + Self::draw_position_control(ui, ui_state, track, default_slider_width); + Self::draw_volume_control(ui, ui_state, track, default_slider_width); + + if let Some(act) = Self::draw_stop_control(ui, track) { + action = Some(act); + } + }); + + action + } +} diff --git a/src/gui/views/hotkey_capture.rs b/src/gui/views/hotkey_capture.rs new file mode 100644 index 0000000..bf701c2 --- /dev/null +++ b/src/gui/views/hotkey_capture.rs @@ -0,0 +1,32 @@ +use crate::gui::SoundpadGui; +use egui::{Color32, RichText, Ui}; +use rust_i18n::t; + +impl SoundpadGui { + pub fn draw_hotkey_capture(&mut self, ui: &mut Ui) { + ui.vertical_centered(|ui| { + ui.add_space(ui.available_height() / 3.0); + ui.label( + RichText::new(t!("gui.hotkeys.capture.header")) + .size(18.0) + .color(Color32::YELLOW) + .monospace(), + ); + ui.add_space(10.0); + let target = if let Some(slot) = &self.app_state.assigning_hotkey_slot { + format!("{} '{}'", t!("gui.hotkeys.capture.for"), slot) + } else if let Some(path) = &self.app_state.assigning_hotkey_for_file { + format!( + "{} '{}'", + t!("gui.hotkeys.capture.for"), + path.file_name().unwrap_or_default().to_string_lossy() + ) + } else { + String::new() + }; + ui.label(RichText::new(target).size(16.0)); + ui.add_space(10.0); + ui.label(t!("gui.hotkeys.capture.cancel")); + }); + } +} diff --git a/src/gui/views/hotkeys.rs b/src/gui/views/hotkeys.rs new file mode 100644 index 0000000..75564e5 --- /dev/null +++ b/src/gui/views/hotkeys.rs @@ -0,0 +1,311 @@ +use crate::gui::SoundpadGui; +use egui::{Button, Color32, Label, RichText, TextEdit, Ui}; +use egui_extras::{Column, TableBuilder}; +use egui_material_icons::icons::*; +use pwsp::types::socket::Request; +use pwsp::utils::gui::make_request_async; +use rust_i18n::t; +use std::path::Path; + +pub(crate) enum HotkeyAction { + Remove(String), + Capture(String), + ClearChord(String), + Play(String), +} + +impl SoundpadGui { + pub fn draw_hotkeys(&mut self, ui: &mut Ui) { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 5.0; + + self.draw_hotkeys_header(ui); + ui.separator(); + + self.draw_hotkeys_search(ui); + ui.separator(); + ui.add_space(5.0); + + let action = self.draw_hotkeys_table(ui); + + if let Some(action) = action { + self.handle_hotkey_action(action); + } + }); + } + + fn draw_hotkeys_header(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + let back_button = Button::new(ICON_ARROW_BACK).frame(false); + if ui.add(back_button).clicked() { + self.app_state.show_hotkeys = false; + } + + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("gui.hotkeys.header")) + .color(Color32::WHITE) + .monospace(), + ); + }); + }); + } + + fn draw_hotkeys_search(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.menu_button( + format!( + "{} {}", + ICON_ADD.codepoint, + t!("gui.hotkeys.add_command_select") + ), + |ui| { + let mut selected_cmd = None; + if ui.button(t!("gui.hotkeys.toggle_pause_command")).clicked() { + selected_cmd = Some(("cmd_toggle_pause", Request::toggle_pause(None))); + } + if ui.button(t!("gui.hotkeys.stop_playback_command")).clicked() { + selected_cmd = Some(("cmd_stop", Request::stop(None))); + } + if ui + .button(t!("gui.hotkeys.pause_playback_command")) + .clicked() + { + selected_cmd = Some(("cmd_pause", Request::pause(None))); + } + if ui + .button(t!("gui.hotkeys.resume_playback_command")) + .clicked() + { + selected_cmd = Some(("cmd_resume", Request::resume(None))); + } + if ui.button(t!("gui.hotkeys.toggle_loop_command")).clicked() { + selected_cmd = Some(("cmd_toggle_loop", Request::toggle_loop(None))); + } + + if let Some((slot_name, req)) = selected_cmd { + make_request_async(Request::set_hotkey_action(slot_name, &req)); + self.app_state + .hotkey_config + .set_slot(slot_name.to_string(), req); + self.app_state.assigning_hotkey_slot = Some(slot_name.to_string()); + self.app_state.hotkey_capture_active = true; + ui.close(); + } + }, + ); + + ui.add_space(10.0); + + ui.add( + TextEdit::singleline(&mut self.app_state.hotkey_search_query) + .hint_text(t!("gui.hotkeys.search_placeholder")) + .desired_width(f32::INFINITY), + ); + }); + } + + fn draw_hotkeys_table(&mut self, ui: &mut Ui) -> Option { + let conflicts = self.app_state.hotkey_config.find_conflicts(); + let conflict_slots: std::collections::HashSet<&str> = + conflicts.into_iter().flat_map(|(a, b)| [a, b]).collect(); + + let search = self.app_state.hotkey_search_query.to_lowercase(); + let mut action: Option = None; + + let slots: Vec<_> = self + .app_state + .hotkey_config + .slots + .iter() + .filter(|s| { + if search.is_empty() { + return true; + } + s.slot.to_lowercase().contains(&search) + || format!("{:?}", s.action).to_lowercase().contains(&search) + || s.key_chord + .as_deref() + .unwrap_or("") + .to_lowercase() + .contains(&search) + }) + .collect(); + + let available_width = ui.available_width(); + let col_width = (available_width / 4.0).max(80.0); + + TableBuilder::new(ui) + .striped(true) + .column(Column::exact(col_width).clip(true)) // Slot + .column(Column::exact(col_width).clip(true)) // Sound / Action name + .column(Column::exact(col_width).clip(true)) // Key Chord + .column(Column::exact(col_width).clip(true)) // Actions + .header(30.0, |mut header| { + header.col(|ui| { + ui.label( + RichText::new(t!("gui.hotkeys.column_slot")) + .strong() + .monospace() + .color(Color32::LIGHT_GRAY), + ); + }); + header.col(|ui| { + ui.label( + RichText::new(t!("gui.hotkeys.column_sound")) + .strong() + .monospace() + .color(Color32::LIGHT_GRAY), + ); + }); + header.col(|ui| { + ui.label( + RichText::new(t!("gui.hotkeys.column_key_chord")) + .strong() + .monospace() + .color(Color32::LIGHT_GRAY), + ); + }); + header.col(|ui| { + ui.label( + RichText::new(t!("gui.hotkeys.column_actions")) + .strong() + .monospace() + .color(Color32::LIGHT_GRAY), + ); + }); + }) + .body(|mut body| { + if slots.is_empty() { + body.row(30.0, |mut row| { + row.col(|_| {}); + row.col(|ui| { + ui.label( + RichText::new(t!("gui.hotkeys.no_hotkeys_configured")) + .color(Color32::GRAY), + ); + }); + row.col(|_| {}); + row.col(|_| {}); + }); + return; + } + + for slot in &slots { + body.row(30.0, |mut row| { + // Column 1: Slot + row.col(|ui| { + ui.horizontal(|ui| { + if conflict_slots.contains(slot.slot.as_str()) { + ui.label( + RichText::new(ICON_WARNING.codepoint) + .color(Color32::from_rgb(255, 165, 0)), + ) + .on_hover_text("Key chord conflict"); + } + ui.add( + Label::new(RichText::new(&slot.slot).monospace()).truncate(), + ); + }); + }); + + // Column 2: Sound / Action name + row.col(|ui| { + let action_name = match slot.action.name.as_str() { + "play" => { + if let Some(file_path_str) = slot.action.args.get("file_path") { + Path::new(file_path_str) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + } else { + "Play".to_string() + } + } + "toggle_pause" => "Toggle Pause".to_string(), + "pause" => "Pause Playback".to_string(), + "resume" => "Resume Playback".to_string(), + "stop" => "Stop Playback".to_string(), + "toggle_loop" => "Toggle Loop".to_string(), + other => other.to_string(), + }; + ui.add(Label::new(RichText::new(action_name).monospace()).truncate()); + }); + + // Column 3: Key Chord + row.col(|ui| { + let chord_text = slot.key_chord.as_deref().unwrap_or("(none)"); + ui.add( + Label::new(RichText::new(chord_text).monospace().color( + if slot.key_chord.is_some() { + Color32::from_rgb(100, 200, 100) + } else { + Color32::GRAY + }, + )) + .truncate(), + ); + }); + + // Column 4: Actions + row.col(|ui| { + ui.horizontal(|ui| { + if ui + .add(Button::new(ICON_DELETE).frame(false)) + .on_hover_text("Remove slot") + .clicked() + { + action = Some(HotkeyAction::Remove(slot.slot.clone())); + } + if ui + .add(Button::new(ICON_KEYBOARD).frame(false)) + .on_hover_text("Set key chord") + .clicked() + { + action = Some(HotkeyAction::Capture(slot.slot.clone())); + } + if slot.key_chord.is_some() + && ui + .add(Button::new(ICON_BACKSPACE).frame(false)) + .on_hover_text("Clear key chord") + .clicked() + { + action = Some(HotkeyAction::ClearChord(slot.slot.clone())); + } + if ui + .add(Button::new(ICON_PLAY_ARROW).frame(false)) + .on_hover_text("Play") + .clicked() + { + action = Some(HotkeyAction::Play(slot.slot.clone())); + } + }); + }); + }); + } + }); + + action + } + + fn handle_hotkey_action(&mut self, action: HotkeyAction) { + match action { + HotkeyAction::Remove(slot) => { + make_request_async(Request::clear_hotkey(&slot)); + self.app_state.hotkey_config.remove_slot(&slot); + } + HotkeyAction::Capture(slot) => { + self.app_state.assigning_hotkey_slot = Some(slot); + self.app_state.hotkey_capture_active = true; + } + HotkeyAction::ClearChord(slot) => { + make_request_async(Request::clear_hotkey_key(&slot)); + self.app_state.hotkey_config.set_key_chord(&slot, None); + } + HotkeyAction::Play(slot) => { + self.play_hotkey_slot(&slot); + } + } + } +} diff --git a/src/gui/views/mod.rs b/src/gui/views/mod.rs new file mode 100644 index 0000000..d73949c --- /dev/null +++ b/src/gui/views/mod.rs @@ -0,0 +1,32 @@ +use crate::gui::SoundpadGui; +use egui::Ui; +use egui_material_icons::icons::*; + +mod body; +mod footer; +mod header; +mod hotkey_capture; +mod hotkeys; +mod settings; +mod waiting_for_daemon; + +impl SoundpadGui { + pub(crate) fn get_volume_icon(volume: f32) -> &'static str { + if volume > 0.7 { + ICON_VOLUME_UP.codepoint + } else if volume <= 0.0 { + ICON_VOLUME_OFF.codepoint + } else if volume < 0.3 { + ICON_VOLUME_MUTE.codepoint + } else { + ICON_VOLUME_DOWN.codepoint + } + } + + pub fn draw(&mut self, ui: &mut Ui) { + self.draw_header(ui); + self.draw_body(ui); + ui.separator(); + self.draw_footer(ui); + } +} diff --git a/src/gui/views/settings.rs b/src/gui/views/settings.rs new file mode 100644 index 0000000..5673e75 --- /dev/null +++ b/src/gui/views/settings.rs @@ -0,0 +1,64 @@ +use crate::gui::SoundpadGui; +use egui::{Align, Button, Color32, Layout, RichText, Ui}; +use egui_material_icons::icons::ICON_ARROW_BACK; +use rust_i18n::t; + +impl SoundpadGui { + pub fn draw_settings(&mut self, ui: &mut Ui) { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 5.0; + // --------- Back Button and Title ---------- + ui.horizontal_top(|ui| { + let back_button = Button::new(ICON_ARROW_BACK).frame(false); + let back_button_response = ui.add(back_button); + if back_button_response.clicked() { + self.app_state.show_settings = false; + } + + ui.add_space(ui.available_width() / 2.0 - 40.0); + + ui.label( + RichText::new(t!("gui.settings.header")) + .color(Color32::WHITE) + .monospace(), + ); + }); + // -------------------------------- + + ui.separator(); + ui.add_space(20.0); + + // --------- Checkboxes ---------- + let save_volume_response = ui.checkbox( + &mut self.config.save_volume, + t!("gui.settings.remember_volume"), + ); + let save_input_response = + ui.checkbox(&mut self.config.save_input, t!("gui.settings.remember_mic")); + let save_scale_response = ui.checkbox( + &mut self.config.save_scale_factor, + t!("gui.settings.remember_ui_scale"), + ); + let pause_on_exit_response = ui.checkbox( + &mut self.config.pause_on_exit, + t!("gui.settings.pause_on_window_close"), + ); + + if save_volume_response.changed() + || save_input_response.changed() + || save_scale_response.changed() + || pause_on_exit_response.changed() + { + self.config.save_to_file().ok(); + } + // -------------------------------- + + ui.with_layout(Layout::bottom_up(Align::Min), |ui| { + ui.label(t!( + "gui.settings.version", + version = env!("CARGO_PKG_VERSION") + )); + }); + }); + } +} diff --git a/src/gui/views/waiting_for_daemon.rs b/src/gui/views/waiting_for_daemon.rs new file mode 100644 index 0000000..07ebaa7 --- /dev/null +++ b/src/gui/views/waiting_for_daemon.rs @@ -0,0 +1,14 @@ +use crate::gui::SoundpadGui; +use egui::{RichText, Ui}; + +impl SoundpadGui { + pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) { + ui.centered_and_justified(|ui| { + ui.label( + RichText::new("Waiting for PWSP daemon to start...") + .size(34.0) + .monospace(), + ); + }); + } +}