diff --git a/data/res/style.css b/data/res/style.css index 57514fa..fb4e8b9 100644 --- a/data/res/style.css +++ b/data/res/style.css @@ -56,55 +56,65 @@ font-style: italic; } -.program { +.program-tile { padding: 12px; min-width: 200px; + transition: transform 100ms; } -.program .title { +.program-tile:hover { + transform: scale(1.01); +} + +.program-tile:active { + transform: scale(0.99); +} + +.program-tile .title { margin-top: 6px; font-size: larger; font-weight: bold; } -.program.highlight { - color: white; - transition: transform 100ms; +.program-design-button { + min-width: 24px; + min-height: 24px; } -.program.highlight.program1 { +.program-design-button:checked { + box-shadow: 0 0 0 3px var(--accent-bg-color); +} + +.program-1 { + color: white; background: linear-gradient(-225deg, #ac32e4 0%, #7918f2 48%, #4801ff 100%); } -.program.highlight.program2 { +.program-2 { + color: white; background: linear-gradient(145deg, #f12711, #f5af19); } -.program.highlight.program3 { +.program-3 { + color: white; background: linear-gradient(-80deg, #ad5389, #3c1053); } -.program.highlight.program4 { +.program-4 { + color: white; background: linear-gradient(140deg, #136797, #0b486b); } -.program.highlight.program5 { +.program-5 { + color: white; background: linear-gradient(100deg, #6a9113, #141517); } -.program.highlight.program6 { +.program-6 { + color: white; background: linear-gradient(120deg, #870000, #190a05); } - -.program.highlight:hover { - transform: scale(1.01); -} - -.program.highlight:active { - transform: scale(0.99); -} - .selector>contents { padding: 0; } diff --git a/data/ui/editor/program.blp b/data/ui/editor/program.blp new file mode 100644 index 0000000..c27491e --- /dev/null +++ b/data/ui/editor/program.blp @@ -0,0 +1,229 @@ +using Gtk 4.0; +using Adw 1; + +template $MusicusProgramEditor: Adw.NavigationPage { + title: _("Program"); + + Adw.ToolbarView { + [top] + Adw.HeaderBar header_bar {} + + Gtk.ScrolledWindow { + Adw.Clamp { + Gtk.Box { + orientation: vertical; + margin-bottom: 24; + margin-start: 12; + margin-end: 12; + + Gtk.Label { + label: _("Appearance"); + xalign: 0; + margin-top: 24; + + styles [ + "heading", + ] + } + + Gtk.ListBox { + selection-mode: none; + margin-top: 12; + + styles [ + "boxed-list", + ] + + Adw.EntryRow title_row { + title: _("Title"); + } + + Adw.EntryRow description_row { + title: _("Description"); + } + + Adw.PreferencesRow design_row { + title: _("Design"); + activatable: false; + focusable: false; + + Gtk.Box { + orientation: vertical; + spacing: 8; + margin-start: 12; + margin-end: 12; + margin-top: 6; + margin-bottom: 6; + + Gtk.Label { + label: _("Design"); + xalign: 0.0; + + styles [ + "subtitle", + ] + } + + Gtk.Box { + spacing: 6; + + Gtk.ToggleButton { + action-name: "program.set-design"; + action-target: "'program-1'"; + + styles [ + "program-design-button", + "program-1", + "circular", + ] + } + + Gtk.ToggleButton { + action-name: "program.set-design"; + action-target: "'program-2'"; + + styles [ + "program-design-button", + "program-2", + "circular", + ] + } + + Gtk.ToggleButton { + action-name: "program.set-design"; + action-target: "'program-3'"; + + styles [ + "program-design-button", + "program-3", + "circular", + ] + } + + Gtk.ToggleButton { + action-name: "program.set-design"; + action-target: "'program-4'"; + + styles [ + "program-design-button", + "program-4", + "circular", + ] + } + + Gtk.ToggleButton { + action-name: "program.set-design"; + action-target: "'program-5'"; + + styles [ + "program-design-button", + "program-5", + "circular", + ] + } + + Gtk.ToggleButton { + action-name: "program.set-design"; + action-target: "'program-6'"; + + styles [ + "program-design-button", + "program-6", + "circular", + ] + } + } + } + } + } + + Gtk.Label { + label: _("Settings"); + xalign: 0; + margin-top: 24; + + styles [ + "heading", + ] + } + + Gtk.ListBox { + selection-mode: none; + margin-top: 12; + + styles [ + "boxed-list", + ] + + $MusicusSliderRow { + title: _("Prefer recordings that haven't been played for a long time"); + suffix: _("%"); + + adjustment: Gtk.Adjustment prefer_least_recently_played_adjustment { + lower: 0; + upper: 100; + step-increment: 1; + page-increment: 10; + }; + } + + $MusicusSliderRow { + title: _("Prefer recordings that were recently added"); + suffix: _("%"); + + adjustment: Gtk.Adjustment prefer_recently_added_adjustment { + lower: 0; + upper: 100; + step-increment: 1; + page-increment: 10; + }; + } + + $MusicusSliderRow { + title: _("Avoid repeating composers"); + suffix: _(" min"); + + adjustment: Gtk.Adjustment avoid_repeated_composers_adjustment { + lower: 0; + upper: 120; + step-increment: 10; + page-increment: 30; + }; + } + + $MusicusSliderRow { + title: _("Avoid repeating instruments"); + suffix: _(" min"); + + adjustment: Gtk.Adjustment avoid_repeated_instruments_adjustment { + lower: 0; + upper: 120; + step-increment: 10; + page-increment: 30; + }; + } + + Adw.SwitchRow play_full_recordings_row { + title: _("Play full recordings"); + } + } + + Gtk.ListBox { + selection-mode: none; + margin-top: 24; + + styles [ + "boxed-list", + ] + + Adw.ButtonRow save_row { + title: _("_Save program"); + use-underline: true; + activated => $save() swapped; + } + } + } + } + } + } +} diff --git a/data/ui/program_tile.blp b/data/ui/program_tile.blp index e4b683d..df6768c 100644 --- a/data/ui/program_tile.blp +++ b/data/ui/program_tile.blp @@ -1,32 +1,46 @@ using Gtk 4.0; -template $MusicusProgramTile : Gtk.FlowBoxChild { - styles ["program", "card", "activatable"] - +template $MusicusProgramTile: Gtk.FlowBoxChild { + styles [ + "program-tile", + "card", + "activatable", + ] + Gtk.Box { orientation: vertical; - + Gtk.Button edit_button { - styles ["flat", "circular"] halign: end; icon-name: "document-edit-symbolic"; + clicked => $edit_button_clicked() swapped; + + styles [ + "flat", + "circular", + ] } Gtk.Label title_label { - styles ["title"] halign: start; margin-top: 24; wrap: true; max-width-chars: 0; + styles [ + "title", + ] } - + Gtk.Label description_label { - styles ["description"] margin-top: 6; halign: start; wrap: true; max-width-chars: 0; + + styles [ + "description", + ] } } -} \ No newline at end of file +} diff --git a/src/editor/mod.rs b/src/editor/mod.rs index ff21b6a..9f3c742 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -2,6 +2,7 @@ pub mod album; pub mod ensemble; pub mod instrument; pub mod person; +pub mod program; pub mod recording; pub mod role; pub mod tracks; diff --git a/src/editor/program.rs b/src/editor/program.rs new file mode 100644 index 0000000..708c7b0 --- /dev/null +++ b/src/editor/program.rs @@ -0,0 +1,181 @@ +use std::{cell::OnceCell, str::FromStr}; + +use adw::{prelude::*, subclass::prelude::*}; +use gtk::{ + gio, + glib::{self, subclass::Signal}, +}; +use once_cell::sync::Lazy; + +use crate::{ + program::{Program, ProgramDesign}, + slider_row::SliderRow, +}; + +mod imp { + use super::*; + + #[derive(Debug, Default, gtk::CompositeTemplate)] + #[template(file = "data/ui/editor/program.blp")] + pub struct ProgramEditor { + pub navigation: OnceCell, + pub action_group: OnceCell, + + #[template_child] + pub title_row: TemplateChild, + #[template_child] + pub description_row: TemplateChild, + #[template_child] + pub prefer_least_recently_played_adjustment: TemplateChild, + #[template_child] + pub prefer_recently_added_adjustment: TemplateChild, + #[template_child] + pub avoid_repeated_composers_adjustment: TemplateChild, + #[template_child] + pub avoid_repeated_instruments_adjustment: TemplateChild, + #[template_child] + pub play_full_recordings_row: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ProgramEditor { + const NAME: &'static str = "MusicusProgramEditor"; + type Type = super::ProgramEditor; + type ParentType = adw::NavigationPage; + + fn class_init(klass: &mut Self::Class) { + SliderRow::static_type(); + klass.bind_template(); + klass.bind_template_instance_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for ProgramEditor { + fn signals() -> &'static [Signal] { + static SIGNALS: Lazy> = Lazy::new(|| { + vec![Signal::builder("save") + .param_types([Program::static_type()]) + .build()] + }); + + SIGNALS.as_ref() + } + + fn constructed(&self) { + self.parent_constructed(); + + let set_design_action = gio::ActionEntry::builder("set-design") + .parameter_type(Some(&glib::VariantTy::STRING)) + .state(glib::Variant::from("program-1")) + .build(); + + let actions = gio::SimpleActionGroup::new(); + actions.add_action_entries([set_design_action]); + self.obj().insert_action_group("program", Some(&actions)); + self.action_group.set(actions).unwrap(); + } + } + + impl WidgetImpl for ProgramEditor {} + impl NavigationPageImpl for ProgramEditor {} +} + +glib::wrapper! { + pub struct ProgramEditor(ObjectSubclass) + @extends gtk::Widget, adw::NavigationPage; +} + +#[gtk::template_callbacks] +impl ProgramEditor { + pub fn new(navigation: &adw::NavigationView, program: Option<&Program>) -> Self { + let obj: Self = glib::Object::new(); + + if let Some(program) = program { + if let Some(title) = program.title() { + obj.imp().title_row.set_text(&title); + } + + if let Some(description) = program.description() { + obj.imp().description_row.set_text(&description); + } + + if let Err(err) = obj.activate_action( + "program.set-design", + Some(&glib::Variant::from(&program.design().to_string())), + ) { + log::warn!("Failed to initialize program design buttons: {err:?}"); + } + + obj.imp() + .prefer_least_recently_played_adjustment + .set_value(program.prefer_least_recently_played() * 100.0); + + obj.imp() + .prefer_recently_added_adjustment + .set_value(program.prefer_recently_added() * 100.0); + + obj.imp() + .avoid_repeated_composers_adjustment + .set_value(program.avoid_repeated_composers() as f64); + + obj.imp() + .avoid_repeated_instruments_adjustment + .set_value(program.avoid_repeated_instruments() as f64); + + obj.imp() + .play_full_recordings_row + .set_active(program.play_full_recordings()); + } + + obj.imp().navigation.set(navigation.to_owned()).unwrap(); + obj + } + + pub fn connect_save(&self, f: F) -> glib::SignalHandlerId { + self.connect_local("save", true, move |values| { + let obj = values[0].get::().unwrap(); + let program = values[1].get::().unwrap(); + f(&obj, program); + None + }) + } + + #[template_callback] + fn save(&self) { + let program = Program::new( + &self.imp().title_row.text(), + &self.imp().description_row.text(), + ProgramDesign::from_str( + &self + .imp() + .action_group + .get() + .unwrap() + .action_state("set-design") + .map(|v| v.get::().unwrap_or_default()) + .unwrap_or_default(), + ) + .unwrap_or_default(), + ); + + program.set_prefer_least_recently_played( + self.imp().prefer_least_recently_played_adjustment.value() / 100.0, + ); + program + .set_prefer_recently_added(self.imp().prefer_recently_added_adjustment.value() / 100.0); + program.set_avoid_repeated_composers( + self.imp().avoid_repeated_composers_adjustment.value() as i32, + ); + program.set_avoid_repeated_instruments( + self.imp().avoid_repeated_instruments_adjustment.value() as i32, + ); + program.set_play_full_recordings(self.imp().play_full_recordings_row.is_active()); + + self.emit_by_name::<()>("save", &[&program]); + self.imp().navigation.get().unwrap().pop(); + } +} diff --git a/src/program.rs b/src/program.rs index 7a25482..8c31bcb 100644 --- a/src/program.rs +++ b/src/program.rs @@ -1,4 +1,7 @@ -use std::cell::{Cell, RefCell}; +use std::{ + cell::{Cell, RefCell}, + str::FromStr, +}; use anyhow::Result; use gtk::{gio, glib, glib::Properties, prelude::*, subclass::prelude::*}; @@ -156,10 +159,15 @@ impl Program { } } +impl Default for Program { + fn default() -> Self { + glib::Object::new() + } +} + #[derive(glib::Enum, Serialize, Deserialize, Eq, PartialEq, Clone, Copy, Debug)] #[enum_type(name = "MusicusProgramDesign")] pub enum ProgramDesign { - Generic, Program1, Program2, Program3, @@ -168,8 +176,43 @@ pub enum ProgramDesign { Program6, } -impl Default for ProgramDesign { - fn default() -> Self { - Self::Generic +impl ProgramDesign { + pub fn css_class(&self) -> String { + self.to_string() + } +} + +impl Default for ProgramDesign { + fn default() -> Self { + Self::Program1 + } +} + +impl ToString for ProgramDesign { + fn to_string(&self) -> String { + String::from(match self { + ProgramDesign::Program1 => "program-1", + ProgramDesign::Program2 => "program-2", + ProgramDesign::Program3 => "program-3", + ProgramDesign::Program4 => "program-4", + ProgramDesign::Program5 => "program-5", + ProgramDesign::Program6 => "program-6", + }) + } +} + +impl FromStr for ProgramDesign { + type Err = (); + + fn from_str(s: &str) -> std::result::Result { + match s { + "program-1" => Ok(ProgramDesign::Program1), + "program-2" => Ok(ProgramDesign::Program2), + "program-3" => Ok(ProgramDesign::Program3), + "program-4" => Ok(ProgramDesign::Program4), + "program-5" => Ok(ProgramDesign::Program5), + "program-6" => Ok(ProgramDesign::Program6), + _ => Err(()), + } } } diff --git a/src/program_tile.rs b/src/program_tile.rs index 9e5c034..0e4d356 100644 --- a/src/program_tile.rs +++ b/src/program_tile.rs @@ -1,12 +1,13 @@ -use std::cell::OnceCell; +use std::cell::{OnceCell, RefCell}; use gtk::{ - glib::{self, Properties}, + gio, + glib::{self, clone, Properties}, prelude::*, subclass::prelude::*, }; -use crate::program::{Program, ProgramDesign}; +use crate::{config, editor::program::ProgramEditor, program::Program}; mod imp { use super::*; @@ -16,7 +17,11 @@ mod imp { #[template(file = "data/ui/program_tile.blp")] pub struct ProgramTile { #[property(get, construct_only)] - pub program: OnceCell, + pub navigation: OnceCell, + #[property(get, construct_only)] + pub key: OnceCell, + #[property(get, set = Self::set_program)] + pub program: RefCell, #[template_child] pub edit_button: TemplateChild, @@ -34,6 +39,7 @@ mod imp { fn class_init(klass: &mut Self::Class) { klass.bind_template(); + klass.bind_template_instance_callbacks(); } fn instance_init(obj: &glib::subclass::InitializingObject) { @@ -42,10 +48,46 @@ mod imp { } #[glib::derived_properties] - impl ObjectImpl for ProgramTile {} + impl ObjectImpl for ProgramTile { + fn constructed(&self) { + self.parent_constructed(); + + let settings = gio::Settings::new(config::APP_ID); + self.set_program_from_settings(&settings); + + let obj = self.obj().to_owned(); + settings.connect_changed(Some(&self.key.get().unwrap()), move |settings, _| { + obj.imp().set_program_from_settings(settings); + }); + } + } impl WidgetImpl for ProgramTile {} impl FlowBoxChildImpl for ProgramTile {} + + impl ProgramTile { + fn set_program_from_settings(&self, settings: &gio::Settings) { + match Program::deserialize(&settings.string(self.key.get().unwrap())) { + Ok(program) => self.set_program(&program), + Err(err) => log::error!("Failed to deserialize program from settings: {err:?}"), + } + } + + fn set_program(&self, program: &Program) { + self.obj() + .set_css_classes(&["program-tile", &program.design().css_class()]); + + if let Some(title) = program.title() { + self.title_label.set_label(&title); + } + + if let Some(description) = program.description() { + self.description_label.set_label(&description); + } + + self.program.replace(program.to_owned()); + } + } } glib::wrapper! { @@ -53,35 +95,33 @@ glib::wrapper! { @extends gtk::Widget, gtk::FlowBoxChild; } +#[gtk::template_callbacks] impl ProgramTile { - pub fn new(program: Program) -> Self { + pub fn new_for_setting(navigation: &adw::NavigationView, key: &str) -> Self { let obj: Self = glib::Object::builder() - .property("program", &program) + .property("navigation", navigation) + .property("key", key) .build(); - let imp = obj.imp(); - - if program.design() != ProgramDesign::Generic { - obj.add_css_class("highlight"); - obj.add_css_class(match program.design() { - ProgramDesign::Generic => "generic", - ProgramDesign::Program1 => "program1", - ProgramDesign::Program2 => "program2", - ProgramDesign::Program3 => "program3", - ProgramDesign::Program4 => "program4", - ProgramDesign::Program5 => "program5", - ProgramDesign::Program6 => "program6", - }) - } - - if let Some(title) = program.title() { - imp.title_label.set_label(&title); - } - - if let Some(description) = program.description() { - imp.description_label.set_label(&description); - } - obj } + + #[template_callback] + fn edit_button_clicked(&self) { + let editor = ProgramEditor::new(&self.navigation(), Some(&self.program())); + + editor.connect_save(clone!( + #[weak(rename_to = obj)] + self, + move |_, program| { + let settings = gio::Settings::new(config::APP_ID); + if let Err(err) = settings.set_string(&obj.key(), &program.serialize()) { + log::error!("Failed to save program to settings: {err:?}"); + }; + obj.set_program(&program); + } + )); + + self.navigation().push(&editor); + } } diff --git a/src/search_page.rs b/src/search_page.rs index 78e34e2..86b8cef 100644 --- a/src/search_page.rs +++ b/src/search_page.rs @@ -12,7 +12,6 @@ use gtk::{ use crate::{ album_page::AlbumPage, album_tile::AlbumTile, - config, db::models::*, editor::{ ensemble::EnsembleEditor, instrument::InstrumentEditor, person::PersonEditor, @@ -50,7 +49,7 @@ mod imp { pub query: OnceCell, pub highlight: RefCell>, - pub programs: RefCell>, + pub program_tiles: RefCell>, pub composers: RefCell>, pub performers: RefCell>, pub ensembles: RefCell>, @@ -180,21 +179,11 @@ impl SearchPage { .build(); if query.is_empty() { - let settings = gio::Settings::new(&config::APP_ID); - - let programs = vec![ - Program::deserialize(&settings.string("program1")).unwrap(), - Program::deserialize(&settings.string("program2")).unwrap(), - Program::deserialize(&settings.string("program3")).unwrap(), - ]; - - for program in &programs { + for key in &["program1", "program2", "program3"] { obj.imp() .programs_flow_box - .append(&ProgramTile::new(program.to_owned())); + .append(&ProgramTile::new_for_setting(navigation, key)); } - - obj.imp().programs.replace(programs); } obj.imp().query.set(query).unwrap(); @@ -326,9 +315,11 @@ impl SearchPage { let imp = self.imp(); if imp.programs_flow_box.is_visible() { - if let Some(program) = imp.programs.borrow().first().cloned() { - self.player().set_program(program); - self.player().play_from_program(); + if let Some(widget) = imp.programs_flow_box.first_child() { + if let Ok(program_tile) = widget.downcast::() { + self.player().set_program(program_tile.program()); + self.player().play_from_program(); + } } } else { let mut new_query = self.imp().query.get().unwrap().clone();