diff --git a/data/ui/error_dialog.blp b/data/ui/error_dialog.blp new file mode 100644 index 0000000..db4cffd --- /dev/null +++ b/data/ui/error_dialog.blp @@ -0,0 +1,39 @@ +using Gtk 4.0; +using Adw 1; + +template $MusicusErrorDialog: Adw.Dialog { + content-width: 600; + content-height: 400; + + Adw.ToastOverlay toast_overlay { + Adw.ToolbarView { + [top] + Adw.HeaderBar { + title-widget: Adw.WindowTitle { + title: _("Error"); + }; + + [end] + Gtk.Button { + icon-name: "edit-copy-symbolic"; + tooltip-text: _("Copy details to clipboard"); + clicked => $copy() swapped; + + styles [ + "flat", + ] + } + } + + Gtk.ScrolledWindow { + Gtk.Label error_label { + xalign: 0.0; + margin-start: 12; + margin-end: 12; + margin-top: 12; + margin-bottom: 12; + } + } + } + } +} diff --git a/data/ui/window.blp b/data/ui/window.blp index 3009ca0..dd96599 100644 --- a/data/ui/window.blp +++ b/data/ui/window.blp @@ -1,27 +1,30 @@ using Gtk 4.0; using Adw 1; -template $MusicusWindow : Adw.ApplicationWindow { +template $MusicusWindow: Adw.ApplicationWindow { title: _("Musicus"); - Adw.ToolbarView { - Gtk.Stack stack { - transition-type: over_up_down; + Adw.ToastOverlay toast_overlay { + Adw.ToolbarView { + Gtk.Stack stack { + transition-type: over_up_down; - Gtk.StackPage { - name: "navigation"; - child: Adw.NavigationView navigation_view { - $MusicusWelcomePage { - folder-selected => $set_library_folder() swapped; - } - }; + Gtk.StackPage { + name: "navigation"; + + child: Adw.NavigationView navigation_view { + $MusicusWelcomePage { + folder-selected => $set_library_folder() swapped; + } + }; + } + } + + [bottom] + Gtk.Revealer player_bar_revealer { + reveal-child: true; + transition-type: slide_up; } } - - [bottom] - Gtk.Revealer player_bar_revealer { - reveal-child: true; - transition-type: slide_up; - } } -} \ No newline at end of file +} diff --git a/src/album_page.rs b/src/album_page.rs index ee4ce53..42bbee8 100644 --- a/src/album_page.rs +++ b/src/album_page.rs @@ -1,15 +1,16 @@ use std::cell::OnceCell; use adw::subclass::prelude::*; +use gettextrs::gettext; use gtk::{ gio, - glib::{self, Properties}, + glib::{self, clone, Properties}, prelude::*, }; use crate::{ db::models::*, editor::album::AlbumEditor, library::Library, player::Player, - playlist_item::PlaylistItem, recording_tile::RecordingTile, + playlist_item::PlaylistItem, recording_tile::RecordingTile, util::error_dialog::ErrorDialog, }; mod imp { @@ -19,6 +20,9 @@ mod imp { #[properties(wrapper_type = super::AlbumPage)] #[template(file = "data/ui/album_page.blp")] pub struct AlbumPage { + #[property(get, construct_only)] + pub toast_overlay: OnceCell, + #[property(get, construct_only)] pub navigation: OnceCell, @@ -90,10 +94,28 @@ mod imp { }) .build(); - // let obj = self.obj().to_owned(); + let obj = self.obj().to_owned(); let delete_action = gio::ActionEntry::builder("delete") .activate(move |_, _, _| { - log::error!("Delete not implemented"); + if let Err(err) = obj + .library() + .delete_album(&obj.imp().album.get().unwrap().album_id) + { + let toast = adw::Toast::builder() + .title(&gettext("Failed to delete album")) + .button_label("Details") + .build(); + + toast.connect_button_clicked(clone!( + #[weak] + obj, + move |_| { + ErrorDialog::present(&err, &obj); + } + )); + + obj.toast_overlay().add_toast(toast); + } }) .build(); @@ -115,12 +137,14 @@ glib::wrapper! { #[gtk::template_callbacks] impl AlbumPage { pub fn new( + toast_overlay: &adw::ToastOverlay, navigation: &adw::NavigationView, library: &Library, player: &Player, album: Album, ) -> Self { let obj: Self = glib::Object::builder() + .property("toast-overlay", toast_overlay) .property("navigation", navigation) .property("library", library) .property("player", player) diff --git a/src/library.rs b/src/library.rs index 5b85e89..ba6e80a 100644 --- a/src/library.rs +++ b/src/library.rs @@ -879,6 +879,18 @@ impl Library { Ok(()) } + pub fn delete_person(&self, person_id: &str) -> Result<()> { + let connection = &mut *self.imp().connection.get().unwrap().lock().unwrap(); + + diesel::delete(persons::table) + .filter(persons::person_id.eq(person_id)) + .execute(connection)?; + + self.changed(); + + Ok(()) + } + pub fn create_instrument(&self, name: TranslatedString) -> Result { let connection = &mut *self.imp().connection.get().unwrap().lock().unwrap(); @@ -921,6 +933,18 @@ impl Library { Ok(()) } + pub fn delete_instrument(&self, instrument_id: &str) -> Result<()> { + let connection = &mut *self.imp().connection.get().unwrap().lock().unwrap(); + + diesel::delete(instruments::table) + .filter(instruments::instrument_id.eq(instrument_id)) + .execute(connection)?; + + self.changed(); + + Ok(()) + } + pub fn create_role(&self, name: TranslatedString) -> Result { let connection = &mut *self.imp().connection.get().unwrap().lock().unwrap(); @@ -962,6 +986,18 @@ impl Library { Ok(()) } + pub fn delete_role(&self, role_id: &str) -> Result<()> { + let connection = &mut *self.imp().connection.get().unwrap().lock().unwrap(); + + diesel::delete(roles::table) + .filter(roles::role_id.eq(role_id)) + .execute(connection)?; + + self.changed(); + + Ok(()) + } + pub fn create_work( &self, name: TranslatedString, @@ -1175,6 +1211,18 @@ impl Library { Ok(()) } + pub fn delete_work(&self, work_id: &str) -> Result<()> { + let connection = &mut *self.imp().connection.get().unwrap().lock().unwrap(); + + diesel::delete(works::table) + .filter(works::work_id.eq(work_id)) + .execute(connection)?; + + self.changed(); + + Ok(()) + } + pub fn create_ensemble(&self, name: TranslatedString) -> Result { let connection = &mut *self.imp().connection.get().unwrap().lock().unwrap(); @@ -1223,6 +1271,18 @@ impl Library { Ok(()) } + pub fn delete_ensemble(&self, ensemble_id: &str) -> Result<()> { + let connection = &mut *self.imp().connection.get().unwrap().lock().unwrap(); + + diesel::delete(ensembles::table) + .filter(ensembles::ensemble_id.eq(ensemble_id)) + .execute(connection)?; + + self.changed(); + + Ok(()) + } + pub fn create_recording( &self, work: Work, @@ -1345,6 +1405,18 @@ impl Library { Ok(()) } + pub fn delete_recording(&self, recording_id: &str) -> Result<()> { + let connection = &mut *self.imp().connection.get().unwrap().lock().unwrap(); + + diesel::delete(recordings::table) + .filter(recordings::recording_id.eq(recording_id)) + .execute(connection)?; + + self.changed(); + + Ok(()) + } + pub fn create_album( &self, name: TranslatedString, @@ -1427,6 +1499,18 @@ impl Library { Ok(()) } + pub fn delete_album(&self, album_id: &str) -> Result<()> { + let connection = &mut *self.imp().connection.get().unwrap().lock().unwrap(); + + diesel::delete(albums::table) + .filter(albums::album_id.eq(album_id)) + .execute(connection)?; + + self.changed(); + + Ok(()) + } + /// Import a track into the music library. // TODO: Support mediums. pub fn import_track( diff --git a/src/search_page.rs b/src/search_page.rs index f6b4f17..78e34e2 100644 --- a/src/search_page.rs +++ b/src/search_page.rs @@ -5,7 +5,7 @@ use formatx::formatx; use gettextrs::gettext; use gtk::{ gio, - glib::{self, Properties}, + glib::{self, clone, Properties}, prelude::*, }; @@ -25,6 +25,7 @@ use crate::{ recording_tile::RecordingTile, search_tag::Tag, tag_tile::TagTile, + util::error_dialog::ErrorDialog, }; mod imp { @@ -34,6 +35,9 @@ mod imp { #[properties(wrapper_type = super::SearchPage)] #[template(file = "data/ui/search_page.blp")] pub struct SearchPage { + #[property(get, construct_only)] + pub toast_overlay: OnceCell, + #[property(get, construct_only)] pub navigation: OnceCell, @@ -162,12 +166,14 @@ glib::wrapper! { #[gtk::template_callbacks] impl SearchPage { pub fn new( + toast_overlay: &adw::ToastOverlay, navigation: &adw::NavigationView, library: &Library, player: &Player, query: LibraryQuery, ) -> Self { let obj: Self = glib::Object::builder() + .property("toast-overlay", toast_overlay) .property("navigation", navigation) .property("library", library) .property("player", player) @@ -230,24 +236,82 @@ impl SearchPage { } fn delete(&self) { - log::warn!("Deletion not implemented"); + if let Some(highlight) = &*self.imp().highlight.borrow() { + match highlight { + Tag::Composer(person) | Tag::Performer(person) => { + if let Err(err) = self.library().delete_person(&person.person_id) { + let toast = adw::Toast::builder() + .title(&gettext("Failed to delete person")) + .button_label("Details") + .build(); - // if let Some(highlight) = &*self.imp().highlight.borrow() { - // match highlight { - // Tag::Composer(person) | Tag::Performer(person) => { - // // TODO - // } - // Tag::Ensemble(ensemble) => { - // // TODO - // } - // Tag::Instrument(instrument) => { - // // TODO - // } - // Tag::Work(work) => { - // // TODO - // } - // } - // } + toast.connect_button_clicked(clone!( + #[weak(rename_to = obj)] + self, + move |_| { + ErrorDialog::present(&err, &obj); + } + )); + + self.toast_overlay().add_toast(toast); + } + } + Tag::Ensemble(ensemble) => { + if let Err(err) = self.library().delete_ensemble(&ensemble.ensemble_id) { + let toast = adw::Toast::builder() + .title(&gettext("Failed to delete ensemble")) + .button_label("Details") + .build(); + + toast.connect_button_clicked(clone!( + #[weak(rename_to = obj)] + self, + move |_| { + ErrorDialog::present(&err, &obj); + } + )); + + self.toast_overlay().add_toast(toast); + } + } + Tag::Instrument(instrument) => { + if let Err(err) = self.library().delete_instrument(&instrument.instrument_id) { + let toast = adw::Toast::builder() + .title(&gettext("Failed to delete instrument")) + .button_label("Details") + .build(); + + toast.connect_button_clicked(clone!( + #[weak(rename_to = obj)] + self, + move |_| { + ErrorDialog::present(&err, &obj); + } + )); + + self.toast_overlay().add_toast(toast); + } + } + Tag::Work(work) => { + if let Err(err) = self.library().delete_work(&work.work_id) { + let toast = adw::Toast::builder() + .title(&gettext("Failed to delete work")) + .button_label("Details") + .build(); + + toast.connect_button_clicked(clone!( + #[weak(rename_to = obj)] + self, + move |_| { + ErrorDialog::present(&err, &obj); + } + )); + + self.toast_overlay().add_toast(toast); + } + } + } + } } #[template_callback] @@ -297,6 +361,7 @@ impl SearchPage { if query_changed { self.navigation().push(&SearchPage::new( + &self.toast_overlay(), &self.navigation(), &self.library(), &self.player(), @@ -325,6 +390,7 @@ impl SearchPage { } self.navigation().push(&SearchPage::new( + &self.toast_overlay(), &self.navigation(), &self.library(), &self.player(), @@ -347,6 +413,7 @@ impl SearchPage { fn show_album(&self, album: &Album) { self.navigation().push(&AlbumPage::new( + &self.toast_overlay(), &self.navigation(), &self.library(), &self.player(), diff --git a/src/util.rs b/src/util.rs index bec2b2c..a5330ef 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,5 +1,6 @@ pub mod activatable_row; pub mod drag_widget; +pub mod error_dialog; use gtk::glib; use lazy_static::lazy_static; diff --git a/src/util/error_dialog.rs b/src/util/error_dialog.rs new file mode 100644 index 0000000..50d8977 --- /dev/null +++ b/src/util/error_dialog.rs @@ -0,0 +1,79 @@ +use std::cell::OnceCell; + +use adw::{prelude::*, subclass::prelude::*}; +use gettextrs::gettext; +use gtk::{ + gdk, + glib::{self, Properties}, +}; + +mod imp { + use super::*; + + #[derive(Properties, Debug, Default, gtk::CompositeTemplate)] + #[properties(wrapper_type = super::ErrorDialog)] + #[template(file = "data/ui/error_dialog.blp")] + pub struct ErrorDialog { + #[property(get, construct_only)] + pub error_text: OnceCell, + + #[template_child] + pub toast_overlay: TemplateChild, + + #[template_child] + pub error_label: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ErrorDialog { + const NAME: &'static str = "MusicusErrorDialog"; + type Type = super::ErrorDialog; + type ParentType = adw::Dialog; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_instance_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for ErrorDialog { + fn constructed(&self) { + self.parent_constructed(); + self.error_label.set_label(&self.obj().error_text()); + } + } + + impl WidgetImpl for ErrorDialog {} + impl AdwDialogImpl for ErrorDialog {} +} + +glib::wrapper! { + pub struct ErrorDialog(ObjectSubclass) + @extends gtk::Widget, adw::Dialog; +} + +#[gtk::template_callbacks] +impl ErrorDialog { + pub fn present(err: &anyhow::Error, parent: &impl IsA) { + let obj: Self = glib::Object::builder() + .property("error-text", &format!("{err:?}")) + .build(); + + obj.present(Some(parent)); + } + + #[template_callback] + fn copy(&self) { + if let Some(display) = gdk::Display::default() { + display.clipboard().set_text(&self.error_text()); + self.imp() + .toast_overlay + .add_toast(adw::Toast::new(&gettext("Copied to clipboard"))); + } + } +} diff --git a/src/window.rs b/src/window.rs index 6eeec93..7e6433c 100644 --- a/src/window.rs +++ b/src/window.rs @@ -29,6 +29,8 @@ mod imp { pub player: Player, pub process_manager: ProcessManager, + #[template_child] + pub toast_overlay: TemplateChild, #[template_child] pub stack: TemplateChild, #[template_child] @@ -242,6 +244,7 @@ impl Window { fn reset_view(&self) { let navigation = self.imp().navigation_view.get(); navigation.replace(&[SearchPage::new( + &self.imp().toast_overlay, &navigation, self.imp().library.borrow().as_ref().unwrap(), &self.imp().player,