sidebar: Create a Section for Playlists

This section creates PlaylistRows for displaying user and system
playlists.  We also hook into the database playlist table to provide a
way to create new playlists. I add several new icons from the
icon-library to use for the section header and playlists.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
Anna Schumaker 2022-08-11 12:09:21 -04:00
parent eb0b005c75
commit d49b033b0d
9 changed files with 233 additions and 0 deletions

View File

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

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 8 1 c -3.855469 0 -7 3.144531 -7 7 s 3.144531 7 7 7 s 7 -3.144531 7 -7 s -3.144531 -7 -7 -7 z m 0 2 c 2.753906 0 5 2.246094 5 5 s -2.246094 5 -5 5 s -5 -2.246094 -5 -5 s 2.246094 -5 5 -5 z m 0 0" fill="#222222"/></svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><g fill="#222222"><path d="m 5 0 c -1.089844 0 -2 0.910156 -2 2 v 9 c 0 1.089844 0.910156 2 2 2 h 9 c 1.089844 0 2 -0.910156 2 -2 v -9 c 0 -1.089844 -0.910156 -2 -2 -2 z m 8 2 v 2 h -2 v 5 c 0 1.105469 -0.894531 2 -2 2 s -2 -0.894531 -2 -2 s 0.894531 -2 2 -2 c 0.351562 0 0.695312 0.09375 1 0.269531 v -5.269531 z m 0 0"/><path d="m 2 3 c -1.089844 0 -2 0.910156 -2 2 v 9 c 0 1.089844 0.910156 2 2 2 h 9 c 1.089844 0 2 -0.910156 2 -2 h -11 z m 0 0"/></g></svg>

After

Width:  |  Height:  |  Size: 587 B

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 9 1 v 7.34375 c -0.453125 -0.210938 -0.96875 -0.34375 -1.5 -0.34375 c -1.921875 0 -3.5 1.578125 -3.5 3.5 s 1.578125 3.5 3.5 3.5 c 1.910156 0 3.480469 -1.5625 3.5 -3.46875 c 0 -0.011719 0 -0.019531 0 -0.03125 v -7.5 h 4 v -3 z m 0 0" fill="#222222"/></svg>

After

Width:  |  Height:  |  Size: 393 B

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><g fill="#222222"><path d="m 6.847656 2 c -1.007812 0 -1.847656 0.839844 -1.847656 1.84375 v 8.3125 c 0 1.003906 0.839844 1.84375 1.847656 1.84375 h 7.308594 c 1.003906 0 1.84375 -0.839844 1.84375 -1.84375 v -8.3125 c 0 -1.003906 -0.839844 -1.84375 -1.84375 -1.84375 z m 1.152344 3 h 1 c 0.199219 0 0.390625 0.058594 0.554688 0.167969 l 3 2 c 0.59375 0.394531 0.59375 1.269531 0 1.664062 l -3 2 c -0.164063 0.109375 -0.355469 0.167969 -0.554688 0.167969 h -1 z m 0 0"/><path d="m 4 2 c -1.089844 0 -2 0.910156 -2 2 v 8 c 0 1.089844 0.910156 2 2 2 z m 0 0"/><path d="m 1 2.28125 c -0.59375 0.351562 -1 0.992188 -1 1.71875 v 8 c 0 0.726562 0.40625 1.367188 1 1.71875 z m 0 0"/></g></svg>

After

Width:  |  Height:  |  Size: 812 B

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><g fill="#222222"><path d="m 3 3 v 2 h 10 v -2 z m 0 4 v 2 h 7.980469 v -2 z m 0 4 v 2 h 5.011719 l -0.011719 -2 z m 0 0"/><path d="m 12 7 v 3.269531 c -0.304688 -0.175781 -0.648438 -0.269531 -1 -0.269531 c -1.105469 0 -2 0.894531 -2 2 s 0.894531 2 2 2 s 2 -0.894531 2 -2 v -3 h 2 v -2 z m 0 0"/></g></svg>

After

Width:  |  Height:  |  Size: 433 B

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 4 0.996094 v 1 c 0 0.296875 0.125 0.558594 0.328125 0.742187 l 5.257813 5.257813 l -5.257813 5.261718 l -0.035156 0.03125 c -0.179688 0.183594 -0.292969 0.433594 -0.292969 0.707032 v 1 h 1 c 0.277344 0 0.527344 -0.109375 0.707031 -0.292969 l 0.035157 -0.03125 l 6.671874 -6.675781 l -6.671874 -6.671875 c -0.183594 -0.199219 -0.449219 -0.328125 -0.742188 -0.328125 z m 0 0" fill="#222222"/></svg>

After

Width:  |  Height:  |  Size: 534 B

View File

@ -0,0 +1,2 @@
# Copyright 2023 (c) Anna Schumaker.
"""Needed to fix the unique basename problem with pytest."""

View File

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