tracklist: Add an Add Tracks button to the TrackView OSD

The Add Tracks button is a popover button configured to display a list
of playlists that tracks could be added to. I take some extra care to
make sure we only display playlists that have their user-tracks property
set to True, and to hide the currently visible playlist from the list.

Additionally, I create a horizontal size group so the Add and Remove
buttons are the same size.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
Anna Schumaker 2023-01-18 14:44:38 -05:00
parent ff9724a274
commit a6f59d9378
3 changed files with 338 additions and 3 deletions

View File

@ -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

View File

@ -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,

View File

@ -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."""