From b316184bf1ffbdec4e92ac2cf403ec05adcbc187 Mon Sep 17 00:00:00 2001 From: Anna Schumaker Date: Sun, 7 Aug 2022 10:15:47 -0400 Subject: [PATCH] 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 --- emmental/emmental.css | 5 ++ emmental/sidebar/section.py | 99 +++++++++++++++++++++++ tests/sidebar/test_section.py | 143 ++++++++++++++++++++++++++++++++++ tests/util/playlist.py | 3 + 4 files changed, 250 insertions(+) create mode 100644 emmental/sidebar/section.py create mode 100644 tests/sidebar/test_section.py diff --git a/emmental/emmental.css b/emmental/emmental.css index df2baa1..23ce8c1 100644 --- a/emmental/emmental.css +++ b/emmental/emmental.css @@ -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; } diff --git a/emmental/sidebar/section.py b/emmental/sidebar/section.py new file mode 100644 index 0000000..c2f10e3 --- /dev/null +++ b/emmental/sidebar/section.py @@ -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.""" diff --git a/tests/sidebar/test_section.py b/tests/sidebar/test_section.py new file mode 100644 index 0000000..d46776a --- /dev/null +++ b/tests/sidebar/test_section.py @@ -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) diff --git a/tests/util/playlist.py b/tests/util/playlist.py index 135d267..8e87aaf 100644 --- a/tests/util/playlist.py +++ b/tests/util/playlist.py @@ -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."""