diff --git a/emmental/sidebar/playlist.py b/emmental/sidebar/playlist.py new file mode 100644 index 0000000..0a4dead --- /dev/null +++ b/emmental/sidebar/playlist.py @@ -0,0 +1,79 @@ +# Copyright 2022 (c) Anna Schumaker. +"""Displays user and system playlists.""" +import collections +from gi.repository import Gtk +from .. import db +from .. import buttons +from . import row +from . import section + +ATTR_NAMES = ["icon_name", "activatable", "modifiable"] +Attrs = collections.namedtuple("Attrs", ATTR_NAMES) + +PLAYLIST_ATTRS = { + "Collection": Attrs("library-music", True, False), + "Favorite Tracks": Attrs("heart-filled", True, False), + "Most Played Tracks": Attrs("right-large", True, False), + "New Tracks": Attrs("music-note-single", True, False), + "Previous Tracks": Attrs("media-skip-backward", False, False), + "Queued Tracks": Attrs("music-queue", True, False), + "Unplayed Tracks": Attrs("circle-outline-thick", True, False), +} +FALLBACK_ATTR = Attrs("playlist2", True, True) + + +class PlaylistRow(row.TreeRow): + """A factory for making PlaylistRows.""" + + def __init__(self, *args, **kwargs): + """Initialize a PlaylistRow.""" + super().__init__(*args, indented=False, **kwargs) + self.child = row.PlaylistRow() + + def do_bind(self) -> None: + """Bind a Playlist to this Row.""" + super().do_bind() + attr = PLAYLIST_ATTRS.get(self.item.name, FALLBACK_ATTR) + self.listitem.set_activatable(attr.activatable) + self.child.icon_name = f"{attr.icon_name}-symbolic" + self.child.modifiable = attr.modifiable + self.bind_and_set_property("image", "filepath", bidirectional=True) + + +class Section(section.Section): + """A sidebar Section for user and system playlists.""" + + def __init__(self, table: db.playlists.Table): + """Initialize our playlist section.""" + super().__init__(table, PlaylistRow, icon_name="playlist2", + title="Playlists", subtitle="0 playlists") + self._entry = Gtk.Entry(placeholder_text="add new playlist", + primary_icon_name="list-add") + self.extra_widget = buttons.PopoverButton(icon_name="document-new", + has_frame=False, + popover_child=self._entry) + + self._entry.connect("activate", self.__add_new_playlist) + self._entry.connect("changed", self.__entry_changed) + self._entry.connect("icon-release", self.__entry_icon_release) + + def __add_new_playlist(self, entry: Gtk.Entry) -> None: + if self.table.create(entry.get_text()) is not None: + self.extra_widget.popdown() + + def __entry_changed(self, entry: Gtk.Entry) -> None: + icon_name = "edit-clear-symbolic" if entry.get_text() != "" else "" + self._entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, + icon_name) + + def __entry_icon_release(self, entry: Gtk.Entry, + pos: Gtk.EntryIconPosition) -> None: + if pos == Gtk.EntryIconPosition.PRIMARY: + self.__add_new_playlist(entry) + else: + entry.set_text("") + + def do_get_subtitle(self, n_playlists: int) -> str: + """Return a subtitle for this section.""" + s_playlists = "s" if n_playlists != 1 else "" + return f"{n_playlists} playlist{s_playlists}" diff --git a/icons/scalable/actions/circle-outline-thick-symbolic.svg b/icons/scalable/actions/circle-outline-thick-symbolic.svg new file mode 100644 index 0000000..49d2b1a --- /dev/null +++ b/icons/scalable/actions/circle-outline-thick-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/icons/scalable/actions/library-music-symbolic.svg b/icons/scalable/actions/library-music-symbolic.svg new file mode 100644 index 0000000..9d0423f --- /dev/null +++ b/icons/scalable/actions/library-music-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/icons/scalable/actions/music-note-single-symbolic.svg b/icons/scalable/actions/music-note-single-symbolic.svg new file mode 100644 index 0000000..eaf82e1 --- /dev/null +++ b/icons/scalable/actions/music-note-single-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/icons/scalable/actions/music-queue-symbolic.svg b/icons/scalable/actions/music-queue-symbolic.svg new file mode 100644 index 0000000..ebb17f5 --- /dev/null +++ b/icons/scalable/actions/music-queue-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/icons/scalable/actions/playlist2-symbolic.svg b/icons/scalable/actions/playlist2-symbolic.svg new file mode 100644 index 0000000..2a700fb --- /dev/null +++ b/icons/scalable/actions/playlist2-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/icons/scalable/actions/right-large-symbolic.svg b/icons/scalable/actions/right-large-symbolic.svg new file mode 100644 index 0000000..1b59d17 --- /dev/null +++ b/icons/scalable/actions/right-large-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/tests/sidebar/__init__.py b/tests/sidebar/__init__.py new file mode 100644 index 0000000..d645978 --- /dev/null +++ b/tests/sidebar/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2023 (c) Anna Schumaker. +"""Needed to fix the unique basename problem with pytest.""" diff --git a/tests/sidebar/test_playlist.py b/tests/sidebar/test_playlist.py new file mode 100644 index 0000000..4813ffe --- /dev/null +++ b/tests/sidebar/test_playlist.py @@ -0,0 +1,140 @@ +# Copyright 2022 (c) Anna Schumaker. +"""Tests our user & system playlists section.""" +import emmental.sidebar.playlist +import tests.util +import unittest.mock +from gi.repository import Gtk + + +class TestPlaylists(tests.util.TestCase): + """Test our Playlists section.""" + + def setUp(self): + """Set up common variables.""" + super().setUp() + self.playlists = emmental.sidebar.playlist.Section(self.sql.playlists) + self.sql.playlists.queue.enabled = False + + def test_attributes(self): + """Test playlist name -> attribute mapping.""" + self.assertListEqual(emmental.sidebar.playlist.ATTR_NAMES, + ["icon_name", "activatable", "modifiable"]) + self.assertDictEqual(emmental.sidebar.playlist.PLAYLIST_ATTRS, + {"Collection": + ("library-music", True, False), + "Favorite Tracks": + ("heart-filled", True, False), + "Most Played Tracks": + ("right-large", True, False), + "New Tracks": + ("music-note-single", True, False), + "Previous Tracks": + ("media-skip-backward", False, False), + "Queued Tracks": + ("music-queue", True, False), + "Unplayed Tracks": + ("circle-outline-thick", True, False)}) + self.assertEqual(emmental.sidebar.playlist.FALLBACK_ATTR, + ("playlist2", True, True)) + + def test_init(self): + """Test that the playlists section is set up correctly.""" + self.assertIsInstance(self.playlists, emmental.sidebar.section.Section) + self.assertEqual(self.playlists.table, self.sql.playlists) + self.assertEqual(self.playlists.title, "Playlists") + self.assertEqual(self.playlists.icon_name, "playlist2") + self.assertEqual(self.playlists._factory.row_type, + emmental.sidebar.playlist.PlaylistRow) + + def test_extra_widget(self): + """Test the playlist section extra widget.""" + self.assertIsInstance(self.playlists.extra_widget, + emmental.buttons.PopoverButton) + self.assertEqual(self.playlists.extra_widget.get_icon_name(), + "document-new") + self.assertEqual(self.playlists.extra_widget.popover_child, + self.playlists._entry) + self.assertFalse(self.playlists.extra_widget.get_has_frame()) + + def test_entry(self): + """Test activating the entry.""" + self.assertIsInstance(self.playlists._entry, Gtk.Entry) + self.assertEqual(self.playlists._entry.get_placeholder_text(), + "add new playlist") + self.assertEqual(self.playlists._entry.get_icon_name( + Gtk.EntryIconPosition.PRIMARY), "list-add") + self.assertIsNone(self.playlists._entry.get_icon_name( + Gtk.EntryIconPosition.SECONDARY)) + + with unittest.mock.patch.object(self.playlists.extra_widget, + "popdown") as mock_popdown: + self.playlists._entry.emit("activate") + self.assertEqual(len(self.sql.playlists), 0) + mock_popdown.assert_not_called() + + self.playlists._entry.set_text("Test 1") + self.playlists._entry.emit("activate") + self.assertEqual(len(self.sql.playlists), 1) + self.assertEqual(self.sql.playlists.get_item(0).name, "Test 1") + mock_popdown.assert_called() + + mock_popdown.reset_mock() + self.playlists._entry.set_text("Test 2") + self.playlists._entry.emit("icon-release", + Gtk.EntryIconPosition.PRIMARY) + self.assertEqual(len(self.sql.playlists), 2) + self.assertEqual(self.sql.playlists.get_item(1).name, "Test 2") + mock_popdown.assert_called() + + self.playlists._entry.set_text("Test 3") + self.assertEqual(self.playlists._entry.get_icon_name( + Gtk.EntryIconPosition.SECONDARY), + "edit-clear-symbolic") + self.playlists._entry.emit("icon-release", + Gtk.EntryIconPosition.SECONDARY) + self.assertEqual(self.playlists._entry.get_text(), "") + self.assertIsNone(self.playlists._entry.get_icon_name( + Gtk.EntryIconPosition.SECONDARY)) + + def test_bind_playlists(self): + """Test binding to system playlists.""" + playlist = self.sql.playlists.create("Test Playlist") + playlist.image = tests.util.COVER_JPG + treeitem = Gtk.TreeListRow() + listitem = Gtk.ListItem() + listitem.get_item = unittest.mock.Mock(return_value=treeitem) + row = emmental.sidebar.playlist.PlaylistRow(listitem) + + self.assertIsInstance(row.child, emmental.sidebar.row.PlaylistRow) + self.assertFalse(row.indented) + + self.sql.playlists.load() + fallback = emmental.sidebar.playlist.FALLBACK_ATTR + for name in ["Collection", "Favorite Tracks", "New Tracks", + "Previous Tracks", "Queued Tracks", "Test Playlist"]: + attr = emmental.sidebar.playlist.PLAYLIST_ATTRS.get(name, fallback) + playlist = self.sql.playlists.lookup(name) + treeitem.get_item = unittest.mock.Mock(return_value=playlist) + + with self.subTest(name=name): + row.bind() + self.assertEqual(row.child.playlist, playlist) + self.assertEqual(row.child.name, name) + self.assertEqual(row.child.filepath, playlist.image) + self.assertEqual(row.child.icon_name, + f"{attr.icon_name}-symbolic") + self.assertEqual(listitem.get_activatable(), attr.activatable) + self.assertEqual(row.child.modifiable, attr.modifiable) + + if name == "Test Playlist": + row.child._icon.filepath = None + self.assertIsNone(playlist.image) + row.unbind() + + def test_subtitle(self): + """Test that the subtitle is set properly.""" + self.assertEqual(self.playlists.subtitle, "0 playlists") + self.sql.playlists.create("Test Playlist") + self.assertEqual(self.playlists.subtitle, "1 playlist") + self.sql.playlists.load() + self.assertEqual(self.playlists.subtitle, "8 playlists")