diff --git a/emmental/tracklist/selection.py b/emmental/tracklist/selection.py index 24c5cd6..b731dd9 100644 --- a/emmental/tracklist/selection.py +++ b/emmental/tracklist/selection.py @@ -1,11 +1,110 @@ # Copyright 2023 (c) Anna Schumaker. """An OSD to show when tracks are selected.""" from gi.repository import GObject +from gi.repository import Gdk from gi.repository import Gtk from gi.repository import Adw +from ..buttons import PopoverButton +from .. import db +from .. import factory from .. import playlist +class PlaylistRowWidget(Gtk.Box): + """A row widget for Playlists.""" + + name = GObject.Property(type=str) + image = GObject.Property(type=GObject.TYPE_PYOBJECT) + + def __init__(self): + """Initialize a PlaylistRowWidget.""" + super().__init__() + self._icon = Adw.Avatar(size=32) + self._label = Gtk.Label(xalign=0.0) + + self.bind_property("name", self._label, "label") + self.bind_property("name", self._icon, "text") + self.connect("notify::name", self.__name_changed) + self.connect("notify::image", self.__image_changed) + + self.append(self._icon) + self.append(self._label) + + def __name_changed(self, row: Gtk.Box, param) -> None: + match self.name: + case "Favorite Tracks": icon = "heart-filled-symbolic" + case "Queued Tracks": icon = "music-queue-symbolic" + case _: icon = "playlist2-symbolic" + self._icon.set_icon_name(icon) + + def __image_changed(self, row: Gtk.Box, param) -> None: + if self.image is not None and self.image.is_file(): + texture = Gdk.Texture.new_from_filename(str(self.image)) + else: + texture = None + self._icon.set_custom_image(texture) + + +class PlaylistRow(factory.ListRow): + """A list row for displaying Playlists.""" + + def __init__(self, listitem: Gtk.ListItem): + """Initialize a PlaylistRow.""" + super().__init__(listitem) + self.child = PlaylistRowWidget() + + def do_bind(self): + """Bind a Playlist to this Row.""" + self.bind_and_set_property("name", "name") + self.bind_and_set_property("image", "image") + + +class UserTracksFilter(Gtk.Filter): + """Filters for tracks with user-tracks set to True.""" + + playlist = GObject.Property(type=db.playlist.Playlist) + + def __init__(self): + """Initialize the UserTracksFilter.""" + super().__init__() + self.connect("notify::playlist", self.__playlist_changed) + + def __playlist_changed(self, filter: Gtk.Filter, param) -> None: + self.changed(Gtk.FilterChange.DIFFERENT) + + def do_match(self, playlist: db.playlist.Playlist) -> bool: + """Check if a specific playlist has user-tracks set to True.""" + return playlist.user_tracks and playlist != self.playlist + + +class PlaylistView(Gtk.ListView): + """A ListView for selecting Playlists.""" + + playlist = GObject.Property(type=db.playlist.Playlist) + + def __init__(self, sql: db.Connection): + """Initialize the PlaylistView.""" + super().__init__(show_separators=True, single_click_activate=True) + self._filtered = Gtk.FilterListModel(model=sql.playlists, + filter=UserTracksFilter()) + self._selection = Gtk.NoSelection(model=self._filtered) + self._factory = factory.Factory(PlaylistRow) + + self.connect("activate", self.__playlist_activated) + self.bind_property("playlist", self._filtered.get_filter(), "playlist") + self.add_css_class("rich-list") + + self.set_model(self._selection) + self.set_factory(self._factory) + + def __playlist_activated(self, view: Gtk.ListView, position: int) -> None: + self.emit("playlist-selected", self._selection[position]) + + @GObject.Signal(arg_types=(db.playlists.Playlist,)) + def playlist_selected(self, playlist: db.playlists.Playlist) -> None: + """Signal that the user has selected a Playlist.""" + + class OSD(Gtk.Overlay): """An Overlay with extra controls for the Tracklist.""" @@ -15,21 +114,36 @@ class OSD(Gtk.Overlay): have_selected = GObject.Property(type=bool, default=False) n_selected = GObject.Property(type=int) - def __init__(self, selection: Gtk.SelectionModel, **kwargs): + def __init__(self, sql: db.Connection, + selection: Gtk.SelectionModel, **kwargs): """Initialize an OSD.""" super().__init__(selection=selection, **kwargs) + self._add = PopoverButton(child=Adw.ButtonContent(label="Add", + icon_name="list-add-symbolic"), + halign=Gtk.Align.START, valign=Gtk.Align.END, + margin_start=16, margin_bottom=16, + direction=Gtk.ArrowType.UP, visible=False, + popover_child=PlaylistView(sql)) self._remove = Gtk.Button(child=Adw.ButtonContent(label="Remove", icon_name="list-remove-symbolic"), halign=Gtk.Align.END, valign=Gtk.Align.END, margin_end=16, margin_bottom=16, visible=False) + self._sizegroup = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL) + self._add.add_css_class("suggested-action") + self._add.add_css_class("pill") self._remove.add_css_class("destructive-action") self._remove.add_css_class("pill") self.selection.connect("selection-changed", self.__selection_changed) + self._add.popover_child.connect("playlist-selected", self.__add_tracks) self._remove.connect("clicked", self.__remove_clicked) + self._sizegroup.add_widget(self._add) + self._sizegroup.add_widget(self._remove) + + self.add_overlay(self._add) self.add_overlay(self._remove) def __get_selected_tracks(self) -> list: @@ -37,6 +151,12 @@ class OSD(Gtk.Overlay): return [self.selection.get_item(selection.get_nth(n)) for n in range(selection.get_size())] + def __add_tracks(self, view: PlaylistView, + playlist: db.playlists.Playlist) -> None: + for track in self.__get_selected_tracks(): + playlist.add_track(track) + self.clear_selection() + def __remove_clicked(self, button: Gtk.Button) -> None: if self.playlist is not None: for track in self.__get_selected_tracks(): @@ -52,6 +172,7 @@ class OSD(Gtk.Overlay): def __update_visibility(self) -> None: db_plist = None if self.playlist is None else self.playlist.playlist user = False if db_plist is None else db_plist.user_tracks + self._add.set_visible(db_plist is not None and self.have_selected) self._remove.set_visible(user and self.have_selected) def clear_selection(self, *args) -> None: @@ -62,3 +183,5 @@ class OSD(Gtk.Overlay): """Reset the OSD.""" self.selection.unselect_all() self.__selection_changed(self.selection, 0, 0) + if self.playlist is not None: + self._add.popover_child.playlist = self.playlist.playlist diff --git a/emmental/tracklist/trackview.py b/emmental/tracklist/trackview.py index 174e1dc..85b19a3 100644 --- a/emmental/tracklist/trackview.py +++ b/emmental/tracklist/trackview.py @@ -31,7 +31,7 @@ class TrackView(Gtk.Frame): enable_rubberband=True, model=self._selection) self._scrollwin = Gtk.ScrolledWindow(child=self._columnview) - self._osd = selection.OSD(self._selection, child=self._scrollwin) + self._osd = selection.OSD(sql, self._selection, child=self._scrollwin) self.__append_column("Art", "cover", row.AlbumCover, resizable=False) self.__append_column("Fav", "favorite", row.FavoriteButton, diff --git a/tests/tracklist/test_selection.py b/tests/tracklist/test_selection.py index 17c9642..11463b9 100644 --- a/tests/tracklist/test_selection.py +++ b/tests/tracklist/test_selection.py @@ -1,5 +1,7 @@ # Copyright 2023 (c) Anna Schumaker. """Tests our Tracklist Selection OSD.""" +import pathlib +import unittest import emmental.buttons import emmental.tracklist.selection import tests.util @@ -8,6 +10,162 @@ from gi.repository import Gtk from gi.repository import Adw +class TestPlaylistRowWidget(unittest.TestCase): + """Test the Playlist Row Widget.""" + + def setUp(self): + """Set up common variables.""" + self.widget = emmental.tracklist.selection.PlaylistRowWidget() + + def test_init(self): + """Test that the Playlist Row Widget is set up properly.""" + self.assertIsInstance(self.widget, Gtk.Box) + + def test_label(self): + """Test the Playlist Row Widget label.""" + self.widget.name = "Test Playlist Name" + self.assertIsInstance(self.widget._label, Gtk.Label) + self.assertEqual(self.widget._label.get_text(), "Test Playlist Name") + self.assertEqual(self.widget._label.get_xalign(), 0.0) + self.assertEqual(self.widget._icon.get_next_sibling(), + self.widget._label) + + def test_icon(self): + """Test the Playlist Row Widget icon.""" + self.assertIsInstance(self.widget._icon, Adw.Avatar) + self.assertEqual(self.widget.get_first_child(), self.widget._icon) + self.assertEqual(self.widget._icon.get_size(), 32) + + self.widget.name = "Favorite Tracks" + self.assertEqual(self.widget._icon.get_icon_name(), + "heart-filled-symbolic") + self.assertEqual(self.widget._icon.get_text(), "Favorite Tracks") + + self.widget.name = "Queued Tracks" + self.assertEqual(self.widget._icon.get_icon_name(), + "music-queue-symbolic") + self.assertEqual(self.widget._icon.get_text(), "Queued Tracks") + + self.widget.name = "Any Other Name" + self.assertEqual(self.widget._icon.get_icon_name(), + "playlist2-symbolic") + self.assertEqual(self.widget._icon.get_text(), "Any Other Name") + + def test_image(self): + """Test the Playlist Row Widget image.""" + self.assertIsNone(self.widget.image) + self.widget.image = tests.util.COVER_JPG + self.assertIsNotNone(self.widget._icon.get_custom_image()) + self.widget.image = None + self.assertIsNone(self.widget._icon.get_custom_image()) + self.widget.image = pathlib.Path("/a/b/c.jpg") + self.assertIsNone(self.widget._icon.get_custom_image()) + + +class TestPlaylistRow(tests.util.TestCase): + """Test the PlaylistRow widget.""" + + def setUp(self): + """Set up common variables.""" + super().setUp() + self.playlist = self.sql.playlists.create("Test Playlist") + self.listitem = Gtk.ListItem() + self.listitem.get_item = unittest.mock.Mock(return_value=self.playlist) + self.row = emmental.tracklist.selection.PlaylistRow(self.listitem) + + def test_init(self): + """Test that the PlaylistRow is initialized properly.""" + self.assertIsInstance(self.row, emmental.factory.ListRow) + self.assertIsInstance(self.row.child, + emmental.tracklist.selection.PlaylistRowWidget) + + def test_bind(self): + """Test binding the PlaylistRow.""" + self.row.bind() + self.assertEqual(self.row.child.name, "Test Playlist") + self.assertIsNone(self.row.child.image) + + self.playlist.image = pathlib.Path("/a/b/c.jpg") + self.assertEqual(self.row.child.image, pathlib.Path("/a/b/c.jpg")) + + +class TestUserTracksFilter(tests.util.TestCase): + """Test the UserTracksFilter.""" + + def setUp(self): + """Set up common variables.""" + super().setUp() + self.sql.playlists.load(now=True) + self.filter = emmental.tracklist.selection.UserTracksFilter() + self.db_plist = self.sql.playlists.create("Test Playlist") + + def test_init(self): + """Test that the filter is initialized properly.""" + self.assertIsInstance(self.filter, Gtk.Filter) + self.assertIsNone(self.filter.playlist) + + def test_changed(self): + """Test the filter changed property.""" + changed = unittest.mock.Mock() + self.filter.connect("changed", changed) + self.filter.playlist = self.db_plist + changed.assert_called_with(self.filter, Gtk.FilterChange.DIFFERENT) + + def test_match(self): + """Test the filter match function.""" + self.assertTrue(self.filter.match(self.db_plist)) + + self.db_plist.user_tracks = False + self.assertFalse(self.filter.match(self.db_plist)) + + self.filter.playlist = self.db_plist + self.db_plist.user_tracks = True + self.assertFalse(self.filter.match(self.db_plist)) + + +class TestPlaylistView(tests.util.TestCase): + """Test the PlaylistView widget.""" + + def setUp(self): + """Set up common variables.""" + super().setUp() + self.sql.playlists.load(now=True) + self.view = emmental.tracklist.selection.PlaylistView(self.sql) + + def test_init(self): + """Test that the Playlist View is set up properly.""" + self.assertIsInstance(self.view, Gtk.ListView) + + self.assertTrue(self.view.get_show_separators()) + self.assertTrue(self.view.get_single_click_activate()) + self.assertTrue(self.view.has_css_class("rich-list")) + + def test_models(self): + """Test that the models have been connected correctly.""" + self.assertIsInstance(self.view._selection, Gtk.NoSelection) + self.assertIsInstance(self.view._filtered, Gtk.FilterListModel) + self.assertIsInstance(self.view._filtered.get_filter(), + emmental.tracklist.selection.UserTracksFilter) + + self.assertEqual(self.view.get_model(), self.view._selection) + self.assertEqual(self.view._selection.get_model(), self.view._filtered) + self.assertEqual(self.view._filtered.get_model(), self.sql.playlists) + + def test_factory(self): + """Test that the factory has been configured correctly.""" + self.assertIsInstance(self.view._factory, emmental.factory.Factory) + self.assertEqual(self.view.get_factory(), self.view._factory) + self.assertEqual(self.view._factory.row_type, + emmental.tracklist.selection.PlaylistRow) + + def test_activate(self): + """Test activating a Playlist Row for adding tracks.""" + selected = unittest.mock.Mock() + self.view.connect("playlist-selected", selected) + self.view.emit("activate", 0) + selected.assert_called_with(self.view, self.sql.playlists.favorites) + + class TestOsd(tests.util.TestCase): """Test the Tracklist OSD.""" @@ -19,14 +177,67 @@ class TestOsd(tests.util.TestCase): self.db_plist) self.model = Gtk.StringList.new(["Test", "OSD", "Strings"]) self.selection = Gtk.MultiSelection(model=self.model) - self.osd = emmental.tracklist.selection.OSD(self.selection) + self.osd = emmental.tracklist.selection.OSD(self.sql, self.selection) def test_init(self): """Test that the OSD is set up properly.""" self.assertIsInstance(self.osd, Gtk.Overlay) + self.assertIsInstance(self.osd._sizegroup, Gtk.SizeGroup) self.assertEqual(self.osd.selection, self.selection) self.assertIsNone(self.osd.playlist) + self.assertEqual(self.osd._sizegroup.get_mode(), + Gtk.SizeGroupMode.HORIZONTAL) + + def test_add_button(self): + """Test the add tracks button.""" + self.assertIsInstance(self.osd._add, emmental.buttons.PopoverButton) + self.assertIsInstance(self.osd._add.get_child(), Adw.ButtonContent) + self.assertIsInstance(self.osd._add.popover_child, + emmental.tracklist.selection.PlaylistView) + self.assertEqual(self.osd._add.get_child().get_icon_name(), + "list-add-symbolic") + self.assertEqual(self.osd._add.get_child().get_label(), "Add") + self.assertEqual(self.osd._add.get_halign(), Gtk.Align.START) + self.assertEqual(self.osd._add.get_valign(), Gtk.Align.END) + self.assertEqual(self.osd._add.get_margin_start(), 16) + self.assertEqual(self.osd._add.get_margin_bottom(), 16) + self.assertEqual(self.osd._add.get_direction(), Gtk.ArrowType.UP) + self.assertFalse(self.osd._add.get_visible()) + + self.assertTrue(self.osd._add.has_css_class("suggested-action")) + self.assertTrue(self.osd._add.has_css_class("pill")) + + self.assertIn(self.osd._add, self.osd) + self.assertIn(self.osd._add, self.osd._sizegroup.get_widgets()) + + self.osd.playlist = self.playlist + self.osd.reset() + self.assertEqual(self.osd._add.popover_child.playlist, self.db_plist) + + def test_add_button_visible(self): + """Test the add button visiblity.""" + self.assertFalse(self.osd._add.get_visible()) + + self.selection.select_item(0, True) + self.assertFalse(self.osd._add.get_visible()) + + self.osd.playlist = self.playlist + self.selection.select_item(1, True) + self.assertTrue(self.osd._remove.get_visible()) + + def test_add_button_activate(self): + """Test activating a playlist in the add button.""" + with unittest.mock.patch.object(self.db_plist, + "add_track") as mock_add: + self.selection.select_all() + self.osd._add.popover_child.emit("playlist-selected", + self.db_plist) + mock_add.assert_has_calls([unittest.mock.call(self.model[0]), + unittest.mock.call(self.model[1]), + unittest.mock.call(self.model[2])]) + self.assertEqual(self.osd.n_selected, 0) + def test_remove_button(self): """Test the remove tracks button.""" self.assertIsInstance(self.osd._remove, Gtk.Button) @@ -43,6 +254,7 @@ class TestOsd(tests.util.TestCase): self.assertTrue(self.osd._remove.has_css_class("pill")) self.assertIn(self.osd._remove, self.osd) + self.assertIn(self.osd._remove, self.osd._sizegroup.get_widgets()) def test_remove_button_visible(self): """Test the remove button visiblity."""