diff --git a/emmental/emmental.css b/emmental/emmental.css index b4a3c0e..8e134f4 100644 --- a/emmental/emmental.css +++ b/emmental/emmental.css @@ -78,3 +78,10 @@ columnview.emmental-track-list > listview > row > cell { columnview.emmental-track-list > listview > row > cell > label { padding: 0px 4px; } + +columnview.emmental-track-list > listview > row > cell > picture { + padding: 4px 0px; + min-height: 36px; + min-width: 36px; + border-radius: 15%; +} diff --git a/emmental/tracklist/row.py b/emmental/tracklist/row.py index 97a1330..458dd53 100644 --- a/emmental/tracklist/row.py +++ b/emmental/tracklist/row.py @@ -4,6 +4,7 @@ import datetime import dateutil.tz import pathlib from gi.repository import GObject +from gi.repository import Gdk from gi.repository import Gtk from .. import buttons from .. import factory @@ -41,15 +42,26 @@ class TrackRow(factory.ListRow): self.album_binding = album.bind_property(self.property, self.child, child_prop) + def bind_album_to_self(self, self_prop: str) -> None: + """Bind an album property to the TrackRow child.""" + album = self.item.get_medium().get_album() + self.set_property(self_prop, album.get_property(self.property)) + self.album_binding = album.bind_property(self.property, + self, self_prop) + def bind_to_self(self, item_prop: str, child_prop: str) -> None: """Bind an item property directly to a TrackRow property.""" self.bind_and_set(self.item, item_prop, self, child_prop) - def rebind_album(self, child_prop: str) -> None: + def rebind_album(self, child_prop: str, to_self: bool = False) -> None: """Rebind an album property to the TrackRow child.""" if self.album_binding is not None: self.album_binding.unbind() - self.bind_album(child_prop) + + if to_self: + self.bind_album_to_self(child_prop) + else: + self.bind_album(child_prop) @GObject.Property(type=bool, default=False) def active(self) -> bool: @@ -228,6 +240,45 @@ class AlbumString(InscriptionRow): self.bind_to_self("mediumid", "mediumid") +class AlbumCover(TrackRow): + """A Track Row to display Album art.""" + + Cache = dict() + + filepath = GObject.Property(type=GObject.TYPE_PYOBJECT) + + def __init__(self, listitem: Gtk.ListItem, property: str): + """Initialize an Album Cover row.""" + super().__init__(listitem, property) + self.child = Gtk.Picture(content_fit=Gtk.ContentFit.COVER) + self.connect("notify", self.__update_album_cover) + self.child.connect("query-tooltip", self.__query_tooltip) + + def __update_album_cover(self, row: TrackRow, param) -> None: + match param.name: + case "mediumid": self.rebind_album("filepath", to_self=True) + case "filepath": + if self.filepath is None: + texture = None + elif (texture := AlbumCover.Cache.get(self.filepath)) is None: + texture = Gdk.Texture.new_from_filename(str(self.filepath)) + AlbumCover.Cache[self.filepath] = texture + + self.child.set_paintable(texture) + self.child.set_has_tooltip(texture is not None) + + def __query_tooltip(self, child: Gtk.Picture, x: int, y: int, + keyboard_mode: bool, tooltip: Gtk.Tooltip) -> bool: + texture = AlbumCover.Cache.get(self.filepath) + tooltip.set_custom(Gtk.Picture.new_for_paintable(texture)) + return True + + def do_bind(self) -> None: + """Bind the Album Art path to the filepath property.""" + super().do_bind() + self.bind_to_self("mediumid", "mediumid") + + class FavoriteButton(TrackRow): """A TrackRow with an toggle to set Track favorite status.""" diff --git a/emmental/tracklist/trackview.py b/emmental/tracklist/trackview.py index 84b9152..1ff62a4 100644 --- a/emmental/tracklist/trackview.py +++ b/emmental/tracklist/trackview.py @@ -28,6 +28,7 @@ class TrackView(Gtk.Frame): model=self._selection) self._scrollwin = Gtk.ScrolledWindow(child=self._columnview) + self.__append_column("Art", "cover", row.AlbumCover, resizable=False) self.__append_column("Fav", "favorite", row.FavoriteButton, resizable=False) self.__append_column("Track", "number", row.TracknoString, diff --git a/tests/test_settings.py b/tests/test_settings.py index 92739bf..0874abf 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -121,6 +121,7 @@ class TestSettings(unittest.TestCase): def test_save_tracklist_column_width(self, mock_stdout: io.StringIO): """Test saving tracklist column widths.""" + self.assertEqual(self.settings["tracklist.art.size"], -1) self.assertEqual(self.settings["tracklist.fav.size"], -1) self.assertEqual(self.settings["tracklist.track.size"], 55) self.assertEqual(self.settings["tracklist.title.size"], 300) @@ -136,6 +137,7 @@ class TestSettings(unittest.TestCase): for column in self.win.tracklist.columns: column.set_fixed_width(123) + self.assertEqual(self.settings["tracklist.art.size"], 123) self.assertEqual(self.settings["tracklist.fav.size"], 123) self.assertEqual(self.settings["tracklist.track.size"], 123) self.assertEqual(self.settings["tracklist.title.size"], 123) @@ -150,6 +152,7 @@ class TestSettings(unittest.TestCase): def test_save_tracklist_column_visible(self, mock_stdout: io.StringIO): """Test saving tracklist column visibility.""" + self.assertTrue(self.settings["tracklist.art.visible"]) self.assertTrue(self.settings["tracklist.fav.visible"]) self.assertTrue(self.settings["tracklist.track.visible"]) self.assertTrue(self.settings["tracklist.title.visible"]) @@ -165,6 +168,7 @@ class TestSettings(unittest.TestCase): for column in self.win.tracklist.columns: column.set_visible(not column.get_visible()) + self.assertFalse(self.settings["tracklist.art.visible"]) self.assertFalse(self.settings["tracklist.fav.visible"]) self.assertFalse(self.settings["tracklist.track.visible"]) self.assertFalse(self.settings["tracklist.title.visible"]) diff --git a/tests/tracklist/test_row.py b/tests/tracklist/test_row.py index 046a70f..ba43b5d 100644 --- a/tests/tracklist/test_row.py +++ b/tests/tracklist/test_row.py @@ -8,6 +8,7 @@ import emmental.tracklist.row import tests.util import unittest.mock from gi.repository import GObject +from gi.repository import Gdk from gi.repository import Gtk from gi.repository import Adw @@ -20,7 +21,8 @@ class TestTrackRowWidgets(tests.util.TestCase): super().setUp() self.sql.playlists.load(now=True) self.library = self.sql.libraries.create(pathlib.Path("/a/b")) - self.album = self.sql.albums.create("Test Album", "Artist", "2023") + self.album = self.sql.albums.create("Test Album", "Artist", "2023", + cover=tests.util.COVER_JPG) self.medium = self.sql.media.create(self.album, "", number=1) self.year = self.sql.years.create(2023) self.track = self.sql.tracks.create(self.library, @@ -168,6 +170,38 @@ class TestTrackRowWidgets(tests.util.TestCase): row.unbind() self.assertIsNone(row.album_binding) + def test_album_cover(self): + """Test the Album Cover widget.""" + self.assertDictEqual(emmental.tracklist.row.AlbumCover.Cache, {}) + cache = emmental.tracklist.row.AlbumCover.Cache + + row = emmental.tracklist.row.AlbumCover(self.listitem, "cover") + self.assertIsInstance(row, emmental.tracklist.row.TrackRow) + self.assertIsInstance(row.child, Gtk.Picture) + self.assertEqual(row.property, "cover") + self.assertEqual(row.child.get_content_fit(), Gtk.ContentFit.COVER) + self.assertIsNone(row.filepath) + + row.bind() + self.assertEqual(row.filepath, tests.util.COVER_JPG) + + self.assertEqual(len(cache), 1) + self.assertIsInstance(cache[tests.util.COVER_JPG], Gdk.Texture) + self.assertEqual(row.child.get_paintable(), + cache[tests.util.COVER_JPG]) + self.assertTrue(row.child.get_has_tooltip()) + + self.album.cover = None + self.assertIsNone(row.filepath) + self.assertIsNone(row.child.get_paintable()) + self.assertFalse(row.child.get_has_tooltip()) + + album = self.sql.albums.create("Other Album", "Other Artist", "2023", + cover=tests.util.COVER_JPG) + medium = self.sql.media.create(album, "Other Medium", number=4) + self.track.mediumid = medium.mediumid + self.assertEqual(row.filepath, tests.util.COVER_JPG) + def test_favorite_button(self): """Test the Favorite Button widget.""" row = emmental.tracklist.row.FavoriteButton(self.listitem, "favorite") diff --git a/tests/tracklist/test_trackview.py b/tests/tracklist/test_trackview.py index 000bfbb..aeef852 100644 --- a/tests/tracklist/test_trackview.py +++ b/tests/tracklist/test_trackview.py @@ -93,7 +93,21 @@ class TestTrackViewColumns(tests.util.TestCase): self.columns = self.trackview.columns self.listitem = Gtk.ListItem() - def test_favorite_column(self, i: int = 0): + def test_artwork_column(self, i: int = 0): + """Test the favorite column.""" + self.assertEqual(self.columns[i].get_title(), "Art") + self.assertEqual(self.columns[i].get_fixed_width(), -1) + self.assertFalse(self.columns[i].get_resizable()) + self.assertTrue(self.columns[i].get_visible()) + + factory = self.columns[i].get_factory() + self.assertIsInstance(factory, emmental.factory.Factory) + self.assertEqual(factory.row_type, emmental.tracklist.row.AlbumCover) + + factory.emit("setup", self.listitem) + self.assertEqual(self.listitem.listrow.property, "cover") + + def test_favorite_column(self, i: int = 1): """Test the favorite column.""" self.assertEqual(self.columns[i].get_title(), "Fav") self.assertEqual(self.columns[i].get_fixed_width(), -1) @@ -108,7 +122,7 @@ class TestTrackViewColumns(tests.util.TestCase): factory.emit("setup", self.listitem) self.assertEqual(self.listitem.listrow.property, "favorite") - def test_trackno_column(self, i: int = 1): + def test_trackno_column(self, i: int = 2): """Test the track number column.""" self.assertEqual(self.columns[i].get_title(), "Track") self.assertEqual(self.columns[i].get_fixed_width(), 55) @@ -125,7 +139,7 @@ class TestTrackViewColumns(tests.util.TestCase): self.assertEqual(self.listitem.listrow.child.get_xalign(), 1.0) self.assertTrue(self.listitem.listrow.child.has_css_class("numeric")) - def test_title_column(self, i: int = 2): + def test_title_column(self, i: int = 3): """Test the title column.""" self.assertEqual(self.columns[i].get_title(), "Title") self.assertEqual(self.columns[i].get_fixed_width(), 300) @@ -141,7 +155,7 @@ class TestTrackViewColumns(tests.util.TestCase): self.assertEqual(self.listitem.listrow.child.get_xalign(), 0.0) self.assertFalse(self.listitem.listrow.child.has_css_class("numeric")) - def test_length_column(self, i: int = 3): + def test_length_column(self, i: int = 4): """Test the length column.""" self.assertEqual(self.columns[i].get_title(), "Length") self.assertEqual(self.columns[i].get_fixed_width(), -1) @@ -157,7 +171,7 @@ class TestTrackViewColumns(tests.util.TestCase): self.assertEqual(self.listitem.listrow.child.get_xalign(), 1.0) self.assertTrue(self.listitem.listrow.child.has_css_class("numeric")) - def test_artist_column(self, i: int = 4): + def test_artist_column(self, i: int = 5): """Test the artist column.""" self.assertEqual(self.columns[i].get_title(), "Artist") self.assertEqual(self.columns[i].get_fixed_width(), 250) @@ -173,7 +187,7 @@ class TestTrackViewColumns(tests.util.TestCase): self.assertEqual(self.listitem.listrow.child.get_xalign(), 0.0) self.assertFalse(self.listitem.listrow.child.has_css_class("numeric")) - def test_album_artist_column(self, i: int = 5): + def test_album_artist_column(self, i: int = 6): """Test the album artist column.""" self.assertEqual(self.columns[i].get_title(), "Album Artist") self.assertEqual(self.columns[i].get_fixed_width(), 250) @@ -189,7 +203,7 @@ class TestTrackViewColumns(tests.util.TestCase): self.assertEqual(self.listitem.listrow.child.get_xalign(), 0.0) self.assertFalse(self.listitem.listrow.child.has_css_class("numeric")) - def test_release_column(self, i: int = 6): + def test_release_column(self, i: int = 7): """Test the release date column.""" self.assertEqual(self.columns[i].get_title(), "Release") self.assertEqual(self.columns[i].get_fixed_width(), 115) @@ -205,7 +219,7 @@ class TestTrackViewColumns(tests.util.TestCase): self.assertEqual(self.listitem.listrow.child.get_xalign(), 0.0) self.assertTrue(self.listitem.listrow.child.has_css_class("numeric")) - def test_playcount_column(self, i: int = 7): + def test_playcount_column(self, i: int = 8): """Test the play count column.""" self.assertEqual(self.columns[i].get_title(), "Play Count") self.assertEqual(self.columns[i].get_fixed_width(), 135) @@ -222,7 +236,7 @@ class TestTrackViewColumns(tests.util.TestCase): self.assertEqual(self.listitem.listrow.child.get_xalign(), 0.0) self.assertTrue(self.listitem.listrow.child.has_css_class("numeric")) - def test_last_started_column(self, i: int = 8): + def test_last_started_column(self, i: int = 9): """Test the last started column.""" self.assertEqual(self.columns[i].get_title(), "Last Started") self.assertEqual(self.columns[i].get_fixed_width(), 250) @@ -239,7 +253,7 @@ class TestTrackViewColumns(tests.util.TestCase): self.assertEqual(self.listitem.listrow.child.get_xalign(), 0.0) self.assertTrue(self.listitem.listrow.child.has_css_class("numeric")) - def test_last_played_column(self, i: int = 9): + def test_last_played_column(self, i: int = 10): """Test the last played column.""" self.assertEqual(self.columns[i].get_title(), "Last Played") self.assertEqual(self.columns[i].get_fixed_width(), 250) @@ -256,7 +270,7 @@ class TestTrackViewColumns(tests.util.TestCase): self.assertEqual(self.listitem.listrow.child.get_xalign(), 0.0) self.assertTrue(self.listitem.listrow.child.has_css_class("numeric")) - def test_filepath_column(self, i: int = 10): + def test_filepath_column(self, i: int = 11): """Test the last played column.""" self.assertEqual(self.columns[i].get_title(), "Filepath") self.assertEqual(self.columns[i].get_fixed_width(), -1)