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")