sidebar: Create a Section class
This class combines a header with an initially hidden ListView that can be configured to list our playlists. It also implements "playlist-activated" and "playlist-selected" signals to signal user interaction. Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
parent
976f465cec
commit
b316184bf1
|
@ -57,6 +57,11 @@ image.emmental-sidebar-arrow:checked {
|
|||
color: @accent_color;
|
||||
}
|
||||
|
||||
box.emmental-sidebar-section>button {
|
||||
border-radius: 0%;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
button.emmental-delete>image {
|
||||
color: @destructive_color;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""A sidebar Header attached to a hidden ListView for selecting playlists."""
|
||||
import typing
|
||||
from gi.repository import GObject
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gtk
|
||||
from .. import db
|
||||
from .. import factory
|
||||
from . import header
|
||||
from . import row
|
||||
|
||||
|
||||
class Section(header.Header):
|
||||
"""A widget for displaying a group of playlists with a header."""
|
||||
|
||||
table = GObject.Property(type=db.playlist.Table)
|
||||
|
||||
def __init__(self, table: db.playlist.Table,
|
||||
row_type: typing.Type[row.TreeRow], **kwargs):
|
||||
"""Set up our sidebar Section."""
|
||||
super().__init__(table=table, pending=table.queue.running, **kwargs)
|
||||
self._selection = Gtk.SingleSelection(model=self.table.treemodel,
|
||||
autoselect=False)
|
||||
self._factory = factory.Factory(row_type=row_type)
|
||||
self._listview = Gtk.ListView.new(self._selection, self._factory)
|
||||
|
||||
self.reveal_widget = Gtk.ScrolledWindow(child=self._listview)
|
||||
self.reveal_widget.set_policy(Gtk.PolicyType.NEVER,
|
||||
Gtk.PolicyType.AUTOMATIC)
|
||||
|
||||
table.queue.bind_property("running", self, "pending")
|
||||
table.queue.bind_property("progress", self, "progress")
|
||||
|
||||
table.treemodel.connect("items-changed", self.__model_items_changed)
|
||||
self._selection.connect("selection-changed", self.__selection_changed)
|
||||
self._listview.connect("activate", self.__activated)
|
||||
|
||||
self._listview.add_css_class("navigation-sidebar")
|
||||
self.add_css_class("emmental-sidebar-section")
|
||||
|
||||
def __activated(self, view: Gtk.ListView, position: int) -> None:
|
||||
if playlist := self.__get_playlist(position):
|
||||
self.emit("playlist-activated", playlist)
|
||||
|
||||
def __get_playlist(self, index: int) -> db.playlist.Playlist | None:
|
||||
if item := self._selection.get_item(index):
|
||||
return item.get_item()
|
||||
|
||||
def __model_items_changed(self, model: Gtk.TreeModel, position: int,
|
||||
removed: int, added: int) -> None:
|
||||
self.subtitle = self.do_get_subtitle(len(model))
|
||||
|
||||
def __selection_changed(self, model: Gtk.SingleSelection,
|
||||
position: int, n_items: int) -> None:
|
||||
if (index := model.get_selected()) != Gtk.INVALID_LIST_POSITION:
|
||||
self.emit("playlist-selected", self.__get_playlist(index))
|
||||
|
||||
def do_get_subtitle(self, n_items: int) -> str:
|
||||
"""Return a new subtitle for the section."""
|
||||
raise NotImplementedError
|
||||
|
||||
def clear_selection(self) -> None:
|
||||
"""Clear the selected playlist."""
|
||||
self._selection.set_selected(Gtk.INVALID_LIST_POSITION)
|
||||
|
||||
def playlist_index(self, playlist: db.playlist.Playlist) -> int | None:
|
||||
"""Find the index of a specific playlist in the tree."""
|
||||
if playlist.parent is None:
|
||||
index = -1
|
||||
depth = 0
|
||||
else:
|
||||
index = self.playlist_index(playlist.parent)
|
||||
parent = self._selection.get_item(index)
|
||||
parent.set_expanded(True)
|
||||
depth = parent.get_depth() + 1
|
||||
|
||||
for index in range(index + 1, len(self._selection)):
|
||||
item = self._selection.get_item(index)
|
||||
if item.get_depth() > depth:
|
||||
continue
|
||||
if item.get_depth() < depth:
|
||||
break
|
||||
if item.get_item() == playlist:
|
||||
return index
|
||||
|
||||
def select_playlist(self, playlist: db.playlist.Playlist) -> None:
|
||||
"""Select the requested playlist."""
|
||||
if (index := self.playlist_index(playlist)) is not None:
|
||||
self._selection.select_item(index, True)
|
||||
self._listview.activate_action("list.scroll-to-item",
|
||||
GLib.Variant.new_uint32(index))
|
||||
|
||||
@GObject.Signal(arg_types=(db.playlist.Playlist,))
|
||||
def playlist_activated(self, playlist: db.playlist.Playlist):
|
||||
"""Signal that a playlist has been activated."""
|
||||
|
||||
@GObject.Signal(arg_types=(db.playlist.Playlist,))
|
||||
def playlist_selected(self, playlist: db.playlist.Playlist):
|
||||
"""Signal that the selected playlist has changed."""
|
|
@ -0,0 +1,143 @@
|
|||
# Copyright 2022 (c) Anna Schumaker.
|
||||
"""Tests our sidebar section widget."""
|
||||
import emmental.db
|
||||
import emmental.sidebar.section
|
||||
import tests.util
|
||||
import unittest.mock
|
||||
from gi.repository import GLib
|
||||
from gi.repository import Gtk
|
||||
|
||||
|
||||
class TestSection(tests.util.TestCase):
|
||||
"""Test our sidebar section widget."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
super().setUp()
|
||||
self.table = tests.util.playlist.MockTable(self.sql)
|
||||
self.row_type = emmental.sidebar.row.TreeRow
|
||||
self.section = emmental.sidebar.section.Section(self.table,
|
||||
self.row_type)
|
||||
|
||||
def test_init(self):
|
||||
"""Test that the sidebar Section is set up properly."""
|
||||
self.assertIsInstance(self.section, emmental.sidebar.header.Header)
|
||||
self.assertIsInstance(self.section.reveal_widget, Gtk.ScrolledWindow)
|
||||
|
||||
self.assertEqual(self.section.reveal_widget.get_policy(),
|
||||
(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC))
|
||||
|
||||
self.assertTrue(self.section.has_css_class("emmental-sidebar-section"))
|
||||
|
||||
def test_listview(self):
|
||||
"""Test the listview widget."""
|
||||
self.assertIsInstance(self.section._selection, Gtk.SingleSelection)
|
||||
self.assertIsInstance(self.section._factory, emmental.factory.Factory)
|
||||
self.assertIsInstance(self.section._listview, Gtk.ListView)
|
||||
self.assertEqual(self.section.table, self.table)
|
||||
|
||||
self.assertEqual(self.section._selection.get_model(),
|
||||
self.table.treemodel)
|
||||
self.assertFalse(self.section._selection.get_autoselect())
|
||||
|
||||
self.assertEqual(self.section._factory.row_type, self.row_type)
|
||||
|
||||
self.assertEqual(self.section._listview.get_model(),
|
||||
self.section._selection)
|
||||
self.assertEqual(self.section._listview.get_factory(),
|
||||
self.section._factory)
|
||||
self.assertTrue(self.section._listview.has_css_class(
|
||||
"navigation-sidebar"))
|
||||
|
||||
self.assertEqual(self.section.reveal_widget.get_child(),
|
||||
self.section._listview)
|
||||
|
||||
def test_progress(self):
|
||||
"""Test that the header progress property is wired up correctly."""
|
||||
self.assertFalse(self.section.pending)
|
||||
self.assertEqual(self.section.progress, 0.0)
|
||||
|
||||
self.table.queue.running = True
|
||||
self.table.queue.progress = 0.5
|
||||
self.assertTrue(self.section.pending)
|
||||
self.assertEqual(self.section.progress, 0.5)
|
||||
|
||||
section2 = emmental.sidebar.section.Section(self.table, self.row_type)
|
||||
self.assertTrue(section2.pending)
|
||||
|
||||
def test_do_get_subtitle(self):
|
||||
"""Test updating the subtitle when the treemodel items change."""
|
||||
with self.assertRaises(NotImplementedError):
|
||||
self.assertEqual(self.section.do_get_subtitle(42))
|
||||
|
||||
self.section.do_get_subtitle = unittest.mock.Mock()
|
||||
self.section.do_get_subtitle.return_value = "My Subtitle"
|
||||
self.table.treemodel.items_changed(0, 0, 0)
|
||||
self.assertEqual(self.section.subtitle, "My Subtitle")
|
||||
self.section.do_get_subtitle.assert_called_with(0)
|
||||
|
||||
def test_clear_selection(self):
|
||||
"""Test clearing the currently selected playlist."""
|
||||
with unittest.mock.patch.object(self.section._selection,
|
||||
"set_selected") as mock_set_selected:
|
||||
self.section.clear_selection()
|
||||
mock_set_selected.assert_called_with(Gtk.INVALID_LIST_POSITION)
|
||||
|
||||
def test_playlist_index(self):
|
||||
"""Test finding the index of a specific playlist."""
|
||||
self.section.do_get_subtitle = unittest.mock.Mock(return_value="")
|
||||
playlists = [self.table.create(f"Playlist {i}") for i in range(3)]
|
||||
|
||||
for i, playlist in enumerate(playlists):
|
||||
with self.subTest(i=i, playlist=playlist):
|
||||
self.assertEqual(self.section.playlist_index(playlist), i)
|
||||
|
||||
table2 = tests.util.playlist.MockTable(self.sql)
|
||||
table2.do_sql_update = unittest.mock.Mock(return_value=True)
|
||||
children = [table2.create(f"Child {i}") for i in range(3)]
|
||||
playlists[1].children = Gtk.FilterListModel.new(table2, None)
|
||||
|
||||
for i, child in enumerate(children):
|
||||
child.parent = playlists[1]
|
||||
with self.subTest(i=i, child=child):
|
||||
self.assertEqual(self.section.playlist_index(child), i + 2)
|
||||
|
||||
def test_select_playlist(self):
|
||||
"""Test selecting a specific playlist."""
|
||||
self.section.do_get_subtitle = unittest.mock.Mock(return_value="")
|
||||
|
||||
playlist_selected = unittest.mock.Mock()
|
||||
self.section.connect("playlist-selected", playlist_selected)
|
||||
playlist = self.table.create("Test Playlist")
|
||||
playlist_selected.assert_not_called()
|
||||
|
||||
with unittest.mock.patch.object(self.section._listview,
|
||||
"activate_action") as mock_action:
|
||||
self.section.select_playlist(playlist)
|
||||
playlist_selected.assert_called_with(self.section, playlist)
|
||||
mock_action.assert_called_with("list.scroll-to-item",
|
||||
GLib.Variant.new_uint32(0))
|
||||
|
||||
def test_playlist_selected(self):
|
||||
"""Test selecting a playlist in the list."""
|
||||
self.section.do_get_subtitle = unittest.mock.Mock(return_value="")
|
||||
playlist_selected = unittest.mock.Mock()
|
||||
self.section.connect("playlist-selected", playlist_selected)
|
||||
|
||||
playlist = self.table.create("Test Playlist")
|
||||
self.section._selection.set_selected(0)
|
||||
playlist_selected.assert_called_with(self.section, playlist)
|
||||
|
||||
playlist_selected.reset_mock()
|
||||
self.section.clear_selection()
|
||||
playlist_selected.assert_not_called()
|
||||
|
||||
def test_playlist_activated(self):
|
||||
"""Test activating a playlist."""
|
||||
self.section.do_get_subtitle = unittest.mock.Mock(return_value="")
|
||||
playlist_activated = unittest.mock.Mock()
|
||||
self.section.connect("playlist-activated", playlist_activated)
|
||||
|
||||
playlist = self.table.create("Test Playlist")
|
||||
self.section._listview.emit("activate", 0)
|
||||
playlist_activated.assert_called_with(self.section, playlist)
|
|
@ -2,11 +2,14 @@
|
|||
"""Mock Playlist and Table objects for testing."""
|
||||
import emmental.db.playlist
|
||||
import sqlite3
|
||||
from gi.repository import GObject
|
||||
|
||||
|
||||
class MockPlaylist(emmental.db.playlist.Playlist):
|
||||
"""A fake Playlist for testing."""
|
||||
|
||||
parent = GObject.Property(type=emmental.db.playlist.Playlist)
|
||||
|
||||
@property
|
||||
def primary_key(self) -> int:
|
||||
"""Get the primary_key of this playlist."""
|
||||
|
|
Loading…
Reference in New Issue