diff --git a/Cargo.lock b/Cargo.lock index a0fd0f3c..521c20cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1221,9 +1221,9 @@ checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" [[package]] name = "memchr" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc" +checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" [[package]] name = "miniaudio" @@ -2292,18 +2292,18 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9ff14f98b1a4b289c6248a023c1c2fa1491062964e9fed67ab29c4e4da4a052" +checksum = "2ca517f43f0fb96e0c3072ed5c275fe5eece87e8cb52f4a77b69226d3b1c9df8" dependencies = [ "lazy_static", ] [[package]] name = "tracing-subscriber" -version = "0.2.19" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab69019741fca4d98be3c62d2b75254528b5432233fd8a4d2739fec20278de48" +checksum = "b9cbe87a2fa7e35900ce5de20220a582a9483a7063811defce79d7cbd59d4cfe" dependencies = [ "ansi_term", "sharded-slab", diff --git a/psst-gui/src/controller/input.rs b/psst-gui/src/controller/input.rs index 0100a118..9aee086c 100644 --- a/psst-gui/src/controller/input.rs +++ b/psst-gui/src/controller/input.rs @@ -5,6 +5,7 @@ use druid::{ }; use crate::cmd; +use druid::widget::ValueTextBox; pub struct InputController { on_submit: Option>, @@ -32,6 +33,32 @@ impl Controller> for InputController { event: &Event, data: &mut String, env: &Env, + ) { + self.match_event(child, ctx, event, data, env) + } +} + +impl Controller> for InputController { + fn event( + &mut self, + child: &mut ValueTextBox, + ctx: &mut EventCtx, + event: &Event, + data: &mut String, + env: &Env, + ) { + self.match_event(child, ctx, event, data, env) + } +} + +impl InputController { + fn match_event( + &mut self, + child: &mut impl Widget, + ctx: &mut EventCtx, + event: &Event, + data: &mut String, + env: &Env, ) { match event { Event::Command(cmd) if cmd.is(cmd::SET_FOCUS) => { diff --git a/psst-gui/src/controller/mod.rs b/psst-gui/src/controller/mod.rs index 2fe5e1dd..1caa6979 100644 --- a/psst-gui/src/controller/mod.rs +++ b/psst-gui/src/controller/mod.rs @@ -6,6 +6,7 @@ mod on_cmd; mod on_cmd_async; mod playback; mod session; +mod shortcut_formatter; pub use debounce::Debounce; pub use ex_click::ExClick; @@ -15,3 +16,4 @@ pub use on_cmd::OnCmd; pub use on_cmd_async::OnCmdAsync; pub use playback::PlaybackController; pub use session::SessionController; +pub use shortcut_formatter::ShortcutFormatter; diff --git a/psst-gui/src/controller/playback.rs b/psst-gui/src/controller/playback.rs index cbab06e2..30405f83 100644 --- a/psst-gui/src/controller/playback.rs +++ b/psst-gui/src/controller/playback.rs @@ -7,7 +7,7 @@ use crossbeam_channel::Sender; use druid::{ im::Vector, widget::{prelude::*, Controller}, - Code, ExtEventSink, InternalLifeCycle, KbKey, WindowHandle, + ExtEventSink, InternalLifeCycle, WindowHandle, }; use psst_core::{ audio_normalize::NormalizationLevel, @@ -21,6 +21,7 @@ use souvlaki::{ MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig, }; +use crate::data::matches; use crate::{ cmd, data::{ @@ -394,23 +395,23 @@ where ctx.set_handled(); } // - Event::KeyDown(key) if key.code == Code::Space => { + Event::KeyDown(key) if matches(key, &data.config.shortcuts.play_resume) => { self.pause_or_resume(); ctx.set_handled(); } - Event::KeyDown(key) if key.code == Code::ArrowRight => { + Event::KeyDown(key) if matches(key, &data.config.shortcuts.next_song) => { self.next(); ctx.set_handled(); } - Event::KeyDown(key) if key.code == Code::ArrowLeft => { + Event::KeyDown(key) if matches(key, &data.config.shortcuts.previous_song) => { self.previous(); ctx.set_handled(); } - Event::KeyDown(key) if key.key == KbKey::Character("+".to_string()) => { + Event::KeyDown(key) if matches(key, &data.config.shortcuts.volume_increase) => { data.playback.volume = (data.playback.volume + 0.1).min(1.0); ctx.set_handled(); } - Event::KeyDown(key) if key.key == KbKey::Character("-".to_string()) => { + Event::KeyDown(key) if matches(key, &data.config.shortcuts.volume_decrease) => { data.playback.volume = (data.playback.volume - 0.1).max(0.0); ctx.set_handled(); } diff --git a/psst-gui/src/controller/shortcut_formatter.rs b/psst-gui/src/controller/shortcut_formatter.rs new file mode 100644 index 00000000..7b27b153 --- /dev/null +++ b/psst-gui/src/controller/shortcut_formatter.rs @@ -0,0 +1,30 @@ +use crate::data::KbShortcut; +use druid::text::{Formatter, Selection, Validation, ValidationError}; +use std::str::FromStr; + +pub struct ShortcutFormatter; + +impl Formatter for ShortcutFormatter { + fn format(&self, value: &String) -> String { + value.to_string() + } + + fn validate_partial_input(&self, input: &str, _: &Selection) -> Validation { + match KbShortcut::from_str(input) { + Ok(_) => Validation::success(), + Err(_) => Validation::failure(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Invalid Shortcut", + )), + } + } + + fn value(&self, input: &str) -> Result { + Ok(KbShortcut::from_str(input) + .or(Err(ValidationError::new(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Invalid Shortcut", + ))))? + .to_string()) + } +} diff --git a/psst-gui/src/data/config.rs b/psst-gui/src/data/config.rs index 70c065d0..c7100314 100644 --- a/psst-gui/src/data/config.rs +++ b/psst-gui/src/data/config.rs @@ -11,6 +11,7 @@ use psst_core::{ use serde::{Deserialize, Serialize}; use super::Promise; +use druid::lens::Field; #[derive(Clone, Debug, Data, Lens)] pub struct Preferences { @@ -34,6 +35,7 @@ impl Preferences { pub enum PreferencesTab { General, Cache, + Shortcuts, } #[derive(Clone, Debug, Data, Lens)] @@ -72,6 +74,7 @@ pub struct Config { pub audio_quality: AudioQuality, pub theme: Theme, pub volume: f64, + pub shortcuts: KbShortcuts, } impl Default for Config { @@ -81,6 +84,7 @@ impl Default for Config { audio_quality: Default::default(), theme: Default::default(), volume: 1.0, + shortcuts: Default::default(), } } } @@ -193,3 +197,56 @@ impl Default for Theme { Self::Light } } + +#[derive(Clone, Debug, Eq, PartialEq, Data, Lens, Serialize, Deserialize)] +pub struct KbShortcuts { + pub play_resume: String, + pub volume_increase: String, + pub volume_decrease: String, + pub next_song: String, + pub previous_song: String, +} + +impl KbShortcuts { + pub fn to_desc_with_lens( + &self, + ) -> Vec<( + Field &String, fn(&mut KbShortcuts) -> &mut String>, + String, + )> { + vec![ + ( + druid::lens!(KbShortcuts, play_resume), + "Play/Resume".to_string(), + ), + ( + druid::lens!(KbShortcuts, volume_increase), + "Volume Increase".to_string(), + ), + ( + druid::lens!(KbShortcuts, volume_decrease), + "Volume Decrease".to_string(), + ), + ( + druid::lens!(KbShortcuts, next_song), + "Next Song".to_string(), + ), + ( + druid::lens!(KbShortcuts, previous_song), + "Previous Song".to_string(), + ), + ] + } +} + +impl Default for KbShortcuts { + fn default() -> Self { + Self { + play_resume: "Space".to_string(), + volume_increase: "+".to_string(), + volume_decrease: "-".to_string(), + next_song: "ArrowRight".to_string(), + previous_song: "ArrowLeft".to_string(), + } + } +} diff --git a/psst-gui/src/data/kbshortcut.rs b/psst-gui/src/data/kbshortcut.rs new file mode 100644 index 00000000..2b87556c --- /dev/null +++ b/psst-gui/src/data/kbshortcut.rs @@ -0,0 +1,90 @@ +use druid::Data; +use druid_shell::{Code, KbKey, KeyEvent}; +use std::fmt; +use std::str::FromStr; + +#[derive(Clone, Debug, Eq, PartialEq)] +/// Keyboard Shortcut comprised of either a [`KbKey`] or a [`Code`] +pub enum KbShortcut { + Key(KbKey), + Code(Code), +} + +impl Data for KbShortcut { + fn same(&self, other: &Self) -> bool { + self == other + } +} + +impl KbShortcut { + /// Return whether a given [`KeyEvent`] matches this shortcut + pub fn matches(&self, event: &KeyEvent) -> bool { + match self { + KbShortcut::Key(key) => &event.key == key, + KbShortcut::Code(code) => &event.code == code, + } + } +} + +/// Given a [`&str`], return the corresponding [`Code`] or an `Err` if there is no corresponding +/// code. +/// TODO: Add more String-to-Code mappings +fn kb_code_from_str(s: &str) -> Result { + match s { + "NumpadAdd" => Ok(Code::NumpadAdd), + "Minus" => Ok(Code::Minus), + "Space" => Ok(Code::Space), + "ArrowRight" => Ok(Code::ArrowRight), + "ArrowLeft" => Ok(Code::ArrowLeft), + "ArrowUp" => Ok(Code::ArrowUp), + "ArrowDown" => Ok(Code::ArrowDown), + "Backspace" => Ok(Code::Backspace), + "Enter" => Ok(Code::Enter), + "Tab" => Ok(Code::Tab), + _ => Err(()), + } +} + +impl fmt::Display for KbShortcut { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + KbShortcut::Key(key) => { + write!(f, "{}", key.to_string()) + } + KbShortcut::Code(code) => { + write!(f, "{}", code.to_string()) + } + } + } +} + +#[derive(Debug, Clone)] +pub struct ParseShortcutError; + +impl fmt::Display for ParseShortcutError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "Error parsing shortcut!") + } +} + +impl FromStr for KbShortcut { + type Err = ParseShortcutError; + + fn from_str(s: &str) -> Result { + if let Ok(code) = kb_code_from_str(s) { + Ok(KbShortcut::Code(code)) + } else if s.len() == 1 { + Ok(KbShortcut::Key(KbKey::Character(s.to_string()))) + } else { + Err(ParseShortcutError) + } + } +} + +/// Return whether a given [`KeyEvent`] matches the code or character given in `str` +pub fn matches(key_event: &KeyEvent, str: &String) -> bool { + if let Ok(shortcut) = KbShortcut::from_str(&str) { + return shortcut.matches(key_event); + } + false +} diff --git a/psst-gui/src/data/mod.rs b/psst-gui/src/data/mod.rs index c3559b2c..7daa2126 100644 --- a/psst-gui/src/data/mod.rs +++ b/psst-gui/src/data/mod.rs @@ -3,6 +3,7 @@ mod artist; mod config; mod ctx; mod id; +mod kbshortcut; mod nav; mod playback; mod playlist; @@ -24,8 +25,11 @@ use psst_core::session::SessionService; pub use crate::data::{ album::{Album, AlbumDetail, AlbumLink, AlbumType, Copyright, CopyrightType}, artist::{Artist, ArtistAlbums, ArtistDetail, ArtistLink, ArtistTracks}, - config::{AudioQuality, Authentication, Config, Preferences, PreferencesTab, Theme}, + config::{ + AudioQuality, Authentication, Config, KbShortcuts, Preferences, PreferencesTab, Theme, + }, ctx::Ctx, + kbshortcut::{matches, KbShortcut}, nav::{Nav, SpotifyUrl}, playback::{ NowPlaying, Playback, PlaybackOrigin, PlaybackPayload, PlaybackState, QueueBehavior, diff --git a/psst-gui/src/ui/mod.rs b/psst-gui/src/ui/mod.rs index 15c70216..9f99ea0f 100644 --- a/psst-gui/src/ui/mod.rs +++ b/psst-gui/src/ui/mod.rs @@ -52,7 +52,7 @@ pub fn preferences_window() -> WindowDesc { let win = WindowDesc::new(preferences_widget()) .title("Preferences") .window_size(win_size) - .resizable(false) + .resizable(true) .show_title(false) .transparent_titlebar(true); if cfg!(target_os = "macos") { @@ -80,6 +80,7 @@ fn root_widget() -> impl Widget { .with_child(sidebar_menu_widget()) .with_default_spacer() .with_flex_child(playlists, 1.0) + .with_default_spacer() .with_child(volume_slider()) .with_default_spacer() .with_child(user::user_widget()) diff --git a/psst-gui/src/ui/preferences.rs b/psst-gui/src/ui/preferences.rs index 5aebaacd..bcdf9018 100644 --- a/psst-gui/src/ui/preferences.rs +++ b/psst-gui/src/ui/preferences.rs @@ -12,9 +12,10 @@ use psst_core::connection::Credentials; use crate::{ cmd, - controller::InputController, + controller::{InputController, ShortcutFormatter}, data::{ - AppState, AudioQuality, Authentication, Config, Preferences, PreferencesTab, Promise, Theme, + AppState, AudioQuality, Authentication, Config, KbShortcuts, Preferences, PreferencesTab, + Promise, Theme, }, widget::{icons, Border, Empty, MyWidgetExt}, }; @@ -28,9 +29,10 @@ pub fn preferences_widget() -> impl Widget { let active = ViewSwitcher::new( |state: &AppState, _| state.preferences.active, - |active: &PreferencesTab, _, _| match active { + |active: &PreferencesTab, data, _| match active { PreferencesTab::General => general_tab_widget().boxed(), PreferencesTab::Cache => cache_tab_widget().boxed(), + PreferencesTab::Shortcuts => shortcuts_tab_widget(&data.config.shortcuts).boxed(), }, ) .padding(theme::grid(4.0)) @@ -76,6 +78,12 @@ fn tabs_widget() -> impl Widget { )) .with_default_spacer() .with_child(tab_widget("Cache", &icons::STORAGE, PreferencesTab::Cache)) + // TODO: Add new icon + .with_child(tab_widget( + "Shortcuts", + &icons::PLAYLIST, + PreferencesTab::Shortcuts, + )) } fn general_tab_widget() -> impl Widget { @@ -332,3 +340,49 @@ impl> Controller for MeasureCacheSize { child.lifecycle(ctx, event, data, env); } } + +/// Generate the shortcut tab widget +/// Pass [`KbShortcuts`] that should be manipulated (e.g. from `data`) +fn shortcuts_tab_widget(shortcuts: &KbShortcuts) -> impl Widget { + let mut col = Flex::column().cross_axis_alignment(CrossAxisAlignment::Start); + + col = col + .with_child(Label::new("Playback Shortcuts").with_font(theme::UI_FONT_MEDIUM)) + .with_spacer(theme::grid(2.0)) + .with_child( + Label::new("Enter a single character or a code as defined\nby crate keyboard-types") + .with_font(theme::UI_FONT), + ) + .with_spacer(theme::grid(2.0)); + + // TODO: Add option to record shortcuts + for shortcut in shortcuts.to_desc_with_lens() { + let (lens, description) = shortcut; + + col = col + .with_child(Label::new(description).with_font(theme::UI_FONT)) + .with_child( + TextBox::new() + .with_formatter(ShortcutFormatter) + .validate_while_editing(false) + .update_data_while_editing(true) + .controller(InputController::new()) + .env_scope(|env, _| env.set(theme::WIDE_WIDGET_WIDTH, theme::grid(16.0))) + .lens(AppState::config.then(Config::shortcuts.then(lens))), + ) + .with_spacer(theme::grid(2.0)); + } + col = col.with_child( + Button::new("Save") + .on_click(|ctx, config: &mut Config, _| { + ctx.request_focus(); + config.save(); + //ctx.submit_command(commands::CLOSE_WINDOW); + }) + .fix_width(theme::grid(10.0)) + .align_right() + .lens(AppState::config), + ); + + col.controller(Authenticate::new()) +}