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:
parent
ff9724a274
commit
a6f59d9378
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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."""
|
||||
|
|
Loading…
Reference in New Issue