From efe2611422d72c678277d42f38e86ad99c338af1 Mon Sep 17 00:00:00 2001 From: Anna Schumaker Date: Sat, 27 Jan 2024 22:31:56 -0500 Subject: [PATCH] header: Add a PasswordEntry for inputting the ListenBrainz token The user can fill this out to connect to their listenbrainz account and submit listens. I add a listenbrainz logo icon based on their icon from the website. I also create a symbolic version that I end up using in the popover menu. Signed-off-by: Anna Schumaker --- emmental/header/__init__.py | 30 ++++++++++++ emmental/header/listenbrainz.py | 14 ++++++ .../actions/listenbrainz-logo-symbolic.svg | 49 +++++++++++++++++++ icons/scalable/actions/listenbrainz-logo.svg | 47 ++++++++++++++++++ tests/header/test_header.py | 46 ++++++++++++++++- tests/header/test_listenbrainz.py | 25 ++++++++++ 6 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 emmental/header/listenbrainz.py create mode 100644 icons/scalable/actions/listenbrainz-logo-symbolic.svg create mode 100644 icons/scalable/actions/listenbrainz-logo.svg create mode 100644 tests/header/test_listenbrainz.py diff --git a/emmental/header/__init__.py b/emmental/header/__init__.py index 4457b39..a8f01fa 100644 --- a/emmental/header/__init__.py +++ b/emmental/header/__init__.py @@ -9,6 +9,7 @@ from ..action import ActionEntry from .. import db from .. import buttons from .. import gsetup +from . import listenbrainz from . import open from . import replaygain from . import volume @@ -34,6 +35,7 @@ class Header(Gtk.HeaderBar): sql = GObject.Property(type=db.Connection) title = GObject.Property(type=str) subtitle = GObject.Property(type=str) + listenbrainz_token = GObject.Property(type=str) show_sidebar = GObject.Property(type=bool, default=False) bg_enabled = GObject.Property(type=bool, default=False) bg_volume = GObject.Property(type=float, default=0.5) @@ -50,10 +52,12 @@ class Header(Gtk.HeaderBar): icon = "sidebar-show-symbolic" self._show_sidebar = Gtk.ToggleButton(icon_name=icon, has_frame=False) self._open = open.OpenRow() + self._listenbrainz = listenbrainz.ListenBrainzRow() self._menu_box = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE) self._menu_box.add_css_class("boxed-list") self._menu_box.append(self._open) + self._menu_box.append(self._listenbrainz) if __debug__: self._settings = settings.Row(sql) @@ -85,6 +89,7 @@ class Header(Gtk.HeaderBar): self.bind_property("title", self._title, "title") self.bind_property("subtitle", self._title, "subtitle") + self.bind_property("listenbrainz-token", self._listenbrainz, "text") self.bind_property("show-sidebar", self._show_sidebar, "active", GObject.BindingFlags.BIDIRECTIONAL) self.bind_property("bg-enabled", self._background, "enabled", @@ -104,7 +109,9 @@ class Header(Gtk.HeaderBar): self.pack_end(self._vol_button) self.set_title_widget(self._title) + self._menu_button.props.popover.connect("closed", self.__menu_closed) self._open.connect("track-requested", self.__track_requested) + self._listenbrainz.connect("apply", self.__listenbrainz_apply) self.connect("notify", self.__notify) def __run_settings(self, button: Gtk.Button) -> None: @@ -129,10 +136,33 @@ class Header(Gtk.HeaderBar): f"normalizing: {rg_status}") self._vol_button.set_tooltip_text(status) + def __listenbrainz_apply(self, entry: Adw.PasswordEntryRow) -> None: + self.listenbrainz_token = entry.get_text() + self._menu_button.popdown() + + def __menu_closed(self, popover: Gtk.Popover) -> None: + self._listenbrainz.props.text = self.listenbrainz_token + def __track_requested(self, button: open.OpenRow, path: pathlib.Path) -> None: self.emit("track-requested", path) + @GObject.Property(type=bool, default=True) + def listenbrainz_token_valid(self) -> bool: + """Check if we think the listenbrainz token is valid.""" + return not self._listenbrainz.has_css_class("warning") + + @listenbrainz_token_valid.setter + def listenbrainz_token_valid(self, valid: bool) -> None: + if valid: + self._menu_button.remove_css_class("warning") + self._listenbrainz.remove_css_class("warning") + else: + win = self.get_ancestor(Gtk.Window) + win.post_toast("listenbrainz: user token is invalid") + self._menu_button.add_css_class("warning") + self._listenbrainz.add_css_class("warning") + @property def accelerators(self) -> list[ActionEntry]: """Get a list of accelerators for the Header.""" diff --git a/emmental/header/listenbrainz.py b/emmental/header/listenbrainz.py new file mode 100644 index 0000000..e7efbdd --- /dev/null +++ b/emmental/header/listenbrainz.py @@ -0,0 +1,14 @@ +# Copyright 2024 (c) Anna Schumaker. +"""A custom Adw.PasswordEntryRow to set the user token.""" +from gi.repository import Gtk +from gi.repository import Adw + + +def ListenBrainzRow() -> Adw.PasswordEntryRow: + """Create a new PasswordEntryRow for entering the user token.""" + row = Adw.PasswordEntryRow(title="ListenBrainz User Token", + show_apply_button=True) + row.prefix = Gtk.Image(icon_name="listenbrainz-logo-symbolic") + + row.add_prefix(row.prefix) + return row diff --git a/icons/scalable/actions/listenbrainz-logo-symbolic.svg b/icons/scalable/actions/listenbrainz-logo-symbolic.svg new file mode 100644 index 0000000..2c9cc60 --- /dev/null +++ b/icons/scalable/actions/listenbrainz-logo-symbolic.svg @@ -0,0 +1,49 @@ + + + + + + + + + diff --git a/icons/scalable/actions/listenbrainz-logo.svg b/icons/scalable/actions/listenbrainz-logo.svg new file mode 100644 index 0000000..b7dc970 --- /dev/null +++ b/icons/scalable/actions/listenbrainz-logo.svg @@ -0,0 +1,47 @@ + + + + + + + + + diff --git a/tests/header/test_header.py b/tests/header/test_header.py index 23fe2f6..a5daf7f 100644 --- a/tests/header/test_header.py +++ b/tests/header/test_header.py @@ -62,11 +62,55 @@ class TestHeader(tests.util.TestCase): self.header._open.emit("track-requested", pathlib.Path("/a/b/c/1.ogg")) signal.assert_called_with(self.header, pathlib.Path("/a/b/c/1.ogg")) + def test_listenbrainz(self): + """Check that the ListenBrainzRow is set up correctly.""" + self.assertIsInstance(self.header._listenbrainz, Adw.PasswordEntryRow) + self.assertEqual(self.header._menu_box.get_row_at_index(1), + self.header._listenbrainz) + + self.assertEqual(self.header.listenbrainz_token, "") + self.assertEqual(self.header._listenbrainz.props.text, "") + + self.header.listenbrainz_token = "abcde" + self.assertEqual(self.header._listenbrainz.props.text, "abcde") + + with unittest.mock.patch.object(self.header._menu_button, + "popdown") as mock_popdown: + self.header._listenbrainz.props.text = "fghij" + self.header._listenbrainz.emit("apply") + self.assertEqual(self.header.listenbrainz_token, "fghij") + mock_popdown.assert_called() + + self.header._listenbrainz.props.text = "abcde" + self.header._menu_button.get_popover().emit("closed") + self.assertEqual(self.header._listenbrainz.props.text, "fghij") + + def test_listenbrainz_token_valid(self): + """Test the listenbrainz-token-valid property.""" + win = Gtk.Window(titlebar=self.header) + win.post_toast = unittest.mock.Mock() + + self.assertTrue(self.header.listenbrainz_token_valid) + + self.header.listenbrainz_token_valid = False + self.assertTrue(self.header._menu_button.has_css_class("warning")) + self.assertTrue(self.header._listenbrainz.has_css_class("warning")) + self.assertFalse(self.header.listenbrainz_token_valid) + win.post_toast.assert_called_with( + "listenbrainz: user token is invalid") + + win.post_toast.reset_mock() + self.header.listenbrainz_token_valid = True + self.assertFalse(self.header._menu_button.has_css_class("warning")) + self.assertFalse(self.header._listenbrainz.has_css_class("warning")) + self.assertTrue(self.header.listenbrainz_token_valid) + win.post_toast.assert_not_called() + def test_settings(self): """Check that the SettingsRow is set up correctly.""" self.assertIsInstance(self.header._settings, emmental.header.settings.Row) - self.assertEqual(self.header._menu_box.get_row_at_index(1), + self.assertEqual(self.header._menu_box.get_row_at_index(2), self.header._settings) def test_menu_button(self): diff --git a/tests/header/test_listenbrainz.py b/tests/header/test_listenbrainz.py new file mode 100644 index 0000000..62fad72 --- /dev/null +++ b/tests/header/test_listenbrainz.py @@ -0,0 +1,25 @@ +# Copyright 2024 (c) Anna Schumaker. +"""Tests our Listenbrainz User Token entry.""" +import emmental.header.listenbrainz +import unittest +from gi.repository import Gtk +from gi.repository import Adw + + +class TestListenbrainzRow(unittest.TestCase): + """Test the ListenBrainzRow.""" + + def setUp(self): + """Set up common variables.""" + self.row = emmental.header.listenbrainz.ListenBrainzRow() + + def test_init(self): + """Test that the ListenBrainzRow was set up properly.""" + self.assertIsInstance(self.row, Adw.PasswordEntryRow) + self.assertIsInstance(self.row.prefix, Gtk.Image) + + self.assertEqual(self.row.props.title, "ListenBrainz User Token") + self.assertTrue(self.row.props.show_apply_button) + + self.assertEqual(self.row.prefix.props.icon_name, + "listenbrainz-logo-symbolic")