From 70799fa50f861b4af6ce543643073c6641c77d1e Mon Sep 17 00:00:00 2001 From: Anna Schumaker Date: Thu, 11 Aug 2022 11:27:37 -0400 Subject: [PATCH] sidebar: Create a LibraryRow This is a preconfigured row for displaying library paths. It includes a switch to enable & disable the path, buttons for removing and updating a path, and a progress bar for displaying scan progress. I use the "update-symbolic" and "stop-sign-large-symbolic" icons from the gnome icon library for this widget. Signed-off-by: Anna Schumaker --- emmental/emmental.css | 4 + emmental/sidebar/row.py | 62 ++++++++ .../actions/stop-sign-large-symbolic.svg | 2 + icons/scalable/actions/update-symbolic.svg | 2 + tests/sidebar/test_row.py | 133 ++++++++++++++++++ 5 files changed, 203 insertions(+) create mode 100644 icons/scalable/actions/stop-sign-large-symbolic.svg create mode 100644 icons/scalable/actions/update-symbolic.svg diff --git a/emmental/emmental.css b/emmental/emmental.css index ee9beb0..df2baa1 100644 --- a/emmental/emmental.css +++ b/emmental/emmental.css @@ -60,3 +60,7 @@ image.emmental-sidebar-arrow:checked { button.emmental-delete>image { color: @destructive_color; } + +button.emmental-stop>image { + color: @red_3; +} diff --git a/emmental/sidebar/row.py b/emmental/sidebar/row.py index e1574f1..640edd6 100644 --- a/emmental/sidebar/row.py +++ b/emmental/sidebar/row.py @@ -93,3 +93,65 @@ class PlaylistRow(BaseRow): def __on_delete(self, button: Gtk.Button) -> None: if self.playlist is not None: self.playlist.delete() + + +class LibraryRow(BaseRow): + """An advaced playlist row with extra actions for library management.""" + + enabled = GObject.Property(type=bool, default=True) + online = GObject.Property(type=bool, default=True) + scanning = GObject.Property(type=bool, default=False) + progress = GObject.Property(type=float) + + def __init__(self, **kwargs): + """Initialize a LibraryRow.""" + super().__init__(**kwargs) + self._box = Gtk.Box() + self._overlay = Gtk.Overlay(child=self._box) + self._switch = Gtk.Switch(active=self.enabled, valign=Gtk.Align.CENTER) + self._title = PlaylistTitle(margin_start=12, margin_end=12) + self._scan = Gtk.Button(icon_name="update", has_frame=False, + valign=Gtk.Align.CENTER) + self._stop = Gtk.Button(icon_name="stop-sign-large", has_frame=False, + valign=Gtk.Align.CENTER, visible=False) + self._delete = Gtk.Button(icon_name="big-x-symbolic", + valign=Gtk.Align.CENTER, has_frame=False) + self._progress = Gtk.ProgressBar(valign=Gtk.Align.END, visible=False) + + self.bind_property("enabled", self._switch, "active", + GObject.BindingFlags.BIDIRECTIONAL) + self.bind_property("online", self._switch, "sensitive") + + self.bind_property("name", self._title, "title") + self.bind_property("count", self._title, "count") + self.bind_property("online", self._title, "sensitive") + self.bind_property("enabled", self._title, "sensitive") + + self.bind_property("scanning", self._stop, "visible") + self.bind_property("scanning", self._scan, "visible", + GObject.BindingFlags.INVERT_BOOLEAN) + self.bind_property("scanning", self._progress, "visible") + self.bind_property("progress", self._progress, "fraction") + + self._delete.connect("clicked", self.__on_button_press, "delete") + self._scan.connect("clicked", self.__on_button_press, "scan") + self._stop.connect("clicked", self.__on_button_press, "stop") + + self._delete.add_css_class("emmental-delete") + self._stop.add_css_class("emmental-stop") + self._progress.add_css_class("osd") + + self._box.append(self._switch) + self._box.append(self._title) + self._box.append(self._scan) + self._box.append(self._stop) + self._box.append(self._delete) + self._overlay.add_overlay(self._progress) + self.append(self._overlay) + + def __on_button_press(self, button: Gtk.Button, action: str) -> None: + if self.playlist is not None: + match action: + case "delete": self.playlist.delete() + case "scan": self.playlist.scan() + case "stop": self.playlist.stop() diff --git a/icons/scalable/actions/stop-sign-large-symbolic.svg b/icons/scalable/actions/stop-sign-large-symbolic.svg new file mode 100644 index 0000000..55a00a2 --- /dev/null +++ b/icons/scalable/actions/stop-sign-large-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/icons/scalable/actions/update-symbolic.svg b/icons/scalable/actions/update-symbolic.svg new file mode 100644 index 0000000..8366791 --- /dev/null +++ b/icons/scalable/actions/update-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/tests/sidebar/test_row.py b/tests/sidebar/test_row.py index 1eecfec..b846ffd 100644 --- a/tests/sidebar/test_row.py +++ b/tests/sidebar/test_row.py @@ -177,3 +177,136 @@ class TestPlaylistRow(unittest.TestCase): self.row.playlist = self.playlist self.row._title.emit("request-rename", "New Name") self.playlist.rename.assert_called_with("New Name") + + +class TestLibraryRow(unittest.TestCase): + """Tests our library row with extra widgets.""" + + def setUp(self): + """Set up common variables.""" + super().setUp() + self.row = emmental.sidebar.row.LibraryRow() + self.playlist = emmental.db.playlist.Playlist(Gio.ListStore(), + propertyid=12345, + name="Test Playlist") + + def test_init(self): + """Test that the library row is configured properly.""" + self.assertIsInstance(self.row, emmental.sidebar.row.BaseRow) + self.assertIsInstance(self.row._box, Gtk.Box) + self.assertIsInstance(self.row._overlay, Gtk.Overlay) + + self.assertEqual(self.row.get_first_child(), self.row._overlay) + self.assertEqual(self.row._overlay.get_child(), self.row._box) + + def test_switch(self): + """Test the switch widget and enabled property.""" + self.assertIsInstance(self.row._switch, Gtk.Switch) + self.assertEqual(self.row._switch.get_valign(), Gtk.Align.CENTER) + self.assertEqual(self.row._box.get_first_child(), self.row._switch) + + self.assertTrue(self.row.enabled) + self.assertTrue(self.row._switch.get_active()) + self.assertTrue(self.row._title.get_sensitive()) + + self.row.enabled = False + self.assertFalse(self.row._switch.get_active()) + self.assertFalse(self.row._title.get_sensitive()) + + self.row._switch.set_active(True) + self.assertTrue(self.row.enabled) + self.assertTrue(self.row._title.get_sensitive()) + + def test_progress(self): + """Test the progress bar widget and property.""" + self.assertIsInstance(self.row._progress, Gtk.ProgressBar) + self.assertEqual(self.row._progress.get_valign(), Gtk.Align.END) + self.assertTrue(self.row._progress.has_css_class("osd")) + self.assertIn(self.row._progress, self.row._overlay) + + self.assertEqual(self.row.progress, 0.0) + + self.row.progress = 0.42 + self.assertEqual(self.row._progress.get_fraction(), 0.42) + + def test_title(self): + """Test the title widget and properties.""" + self.assertIsInstance(self.row._title, + emmental.sidebar.title.PlaylistTitle) + self.assertEqual(self.row._title.get_margin_start(), 12) + self.assertEqual(self.row._title.get_margin_end(), 12) + self.assertEqual(self.row._switch.get_next_sibling(), self.row._title) + + self.row.name = "/a/b/c" + self.row.count = 42 + self.assertEqual(self.row._title.title, "/a/b/c") + self.assertEqual(self.row._title.count, 42) + + def test_scan(self): + """Test the scan button.""" + self.assertIsInstance(self.row._scan, Gtk.Button) + self.assertEqual(self.row._scan.get_icon_name(), "update") + self.assertEqual(self.row._scan.get_valign(), Gtk.Align.CENTER) + self.assertFalse(self.row._scan.get_has_frame()) + self.assertEqual(self.row._title.get_next_sibling(), self.row._scan) + + self.row._scan.emit("clicked") + + self.playlist.scan = unittest.mock.Mock() + self.row.playlist = self.playlist + self.row._scan.emit("clicked") + self.playlist.scan.assert_called() + + def test_stop(self): + """Test the stop button.""" + self.assertIsInstance(self.row._stop, Gtk.Button) + self.assertEqual(self.row._stop.get_icon_name(), "stop-sign-large") + self.assertEqual(self.row._stop.get_valign(), Gtk.Align.CENTER) + self.assertFalse(self.row._stop.get_has_frame()) + self.assertTrue(self.row._stop.has_css_class("emmental-stop")) + self.assertEqual(self.row._scan.get_next_sibling(), self.row._stop) + + self.row._stop.emit("clicked") + + self.playlist.stop = unittest.mock.Mock() + self.row.playlist = self.playlist + self.row._stop.emit("clicked") + self.playlist.stop.assert_called() + + def test_delete(self): + """Test the delete button.""" + self.assertIsInstance(self.row._delete, Gtk.Button) + self.assertEqual(self.row._delete.get_icon_name(), "big-x-symbolic") + self.assertEqual(self.row._delete.get_valign(), Gtk.Align.CENTER) + self.assertFalse(self.row._delete.get_has_frame()) + self.assertTrue(self.row._delete.has_css_class("emmental-delete")) + self.assertEqual(self.row._stop.get_next_sibling(), self.row._delete) + + self.row._delete.emit("clicked") + + self.playlist.delete = unittest.mock.Mock() + self.row.playlist = self.playlist + self.row._delete.emit("clicked") + self.playlist.delete.assert_called() + + def test_online(self): + """Test the online property.""" + self.assertTrue(self.row.online) + self.assertTrue(self.row._switch.get_sensitive()) + self.assertTrue(self.row._title.get_sensitive()) + + self.row.online = False + self.assertFalse(self.row._switch.get_sensitive()) + self.assertFalse(self.row._title.get_sensitive()) + + def test_scaning(self): + """Test the scanning property.""" + self.assertFalse(self.row.scanning) + self.assertFalse(self.row._progress.get_visible()) + self.assertFalse(self.row._stop.get_visible()) + self.assertTrue(self.row._scan.get_visible()) + + self.row.scanning = True + self.assertTrue(self.row._progress.get_visible()) + self.assertTrue(self.row._stop.get_visible()) + self.assertFalse(self.row._scan.get_visible())