From 915a59a46bfc8ba8aad15094048466da83cdad08 Mon Sep 17 00:00:00 2001 From: Anna Schumaker Date: Thu, 12 Jan 2023 16:37:32 -0500 Subject: [PATCH] tracklist: Create LengthString, PlayCountString, TimestampString, and PathString TrackRows These are specially configured TrackRows that take a non-string Track property and convert it into a string displayed in the Inscription. I use them to add Length, Play Count, Last Started, Last Played, and Filepath columns to the TrackView. Signed-off-by: Anna Schumaker --- emmental/tracklist/row.py | 95 ++++++++++++++++++++++++++++++- emmental/tracklist/trackview.py | 15 ++++- tests/test_settings.py | 20 +++++++ tests/tracklist/test_row.py | 66 +++++++++++++++++++++ tests/tracklist/test_trackview.py | 91 ++++++++++++++++++++++++++++- 5 files changed, 282 insertions(+), 5 deletions(-) diff --git a/emmental/tracklist/row.py b/emmental/tracklist/row.py index 18f3537..62dc413 100644 --- a/emmental/tracklist/row.py +++ b/emmental/tracklist/row.py @@ -1,5 +1,8 @@ # Copyright 2023 (c) Anna Schumaker. """Widgets for displaying Track information in the TrackView.""" +import datetime +import dateutil.tz +import pathlib from gi.repository import GObject from gi.repository import Gtk from .. import factory @@ -52,11 +55,14 @@ class TrackRow(factory.ListRow): class InscriptionRow(TrackRow): """Base class for Track Rows displaying a Gtk.Inscription.""" - def __init__(self, listitem: Gtk.ListItem, property: str): + def __init__(self, listitem: Gtk.ListItem, property: str, + xalign: float = 0.0, numeric: bool = False): """Initialize a LabelRow.""" super().__init__(listitem, property) - self.child = Gtk.Inscription(xalign=0.0) + self.child = Gtk.Inscription(xalign=xalign) self.child.bind_property("text", self.child, "tooltip-text") + if numeric: + self.child.add_css_class("numeric") class TrackString(InscriptionRow): @@ -66,3 +72,88 @@ class TrackString(InscriptionRow): """Bind a track string to the Track Inscription.""" super().do_bind() self.bind_and_set_property(self.property, "text") + + +class LengthString(InscriptionRow): + """An InscriptionRow displaying a length Track property.""" + + def do_bind(self) -> None: + """Bind the track length to the length property.""" + super().do_bind() + self.bind_and_set(self.item, self.property, self, "length") + + @GObject.Property(type=float) + def length(self) -> float: + """Get the current length.""" + return getattr(self, "__length", 0.0) + + @length.setter + def length(self, newval: float) -> None: + self.__length = newval + self.child.set_text("{}:{:02d}".format(*divmod(round(newval), 60))) + + +class PlayCountString(InscriptionRow): + """An InscriptionRow displaying a playcount Track property.""" + + def do_bind(self) -> None: + """Bind the track playcount to the playcount property.""" + super().do_bind() + self.bind_and_set(self.item, self.property, self, "playcount") + + @GObject.Property(type=int) + def playcount(self) -> int: + """Get the current playcount.""" + return getattr(self, "__playcount", 0) + + @playcount.setter + def playcount(self, newval: int) -> None: + self.__playcount = newval + match newval: + case 0: self.child.set_text("Unplayed") + case 1: self.child.set_text("Played 1 time") + case _: self.child.set_text(f"Played {newval} times") + + +class TimestampString(InscriptionRow): + """An InscriptionRow displaying a datetime Track property.""" + + TZ_UTC = dateutil.tz.tzutc() + + def do_bind(self) -> None: + """Bind the track datetime property to the timestamp property.""" + super().do_bind() + self.bind_and_set(self.item, self.property, self, "timestamp") + + @GObject.Property(type=GObject.TYPE_PYOBJECT) + def timestamp(self) -> datetime.datetime: + """Get the current timestamp.""" + return getattr(self, "__timestamp", None) + + @timestamp.setter + def timestamp(self, newval: datetime.datetime) -> None: + self.__timestamp = newval + if newval is None: + self.child.set_text("Never") + else: + local = newval.replace(tzinfo=TimestampString.TZ_UTC).astimezone() + self.child.set_text(local.replace(tzinfo=None).strftime("%c")) + + +class PathString(InscriptionRow): + """An InscriptionRow displaying a pathlib.Path Track property.""" + + def do_bind(self) -> None: + """Bind the track path property to the path property.""" + super().do_bind() + self.bind_and_set(self.item, self.property, self, "path") + + @GObject.Property(type=GObject.TYPE_PYOBJECT) + def path(self) -> pathlib.Path: + """Get the current path.""" + return getattr(self, "__path", None) + + @path.setter + def path(self, newval: pathlib.Path) -> None: + self.__path = newval + self.child.set_text(str(newval)) diff --git a/emmental/tracklist/trackview.py b/emmental/tracklist/trackview.py index 1d90dbd..088678d 100644 --- a/emmental/tracklist/trackview.py +++ b/emmental/tracklist/trackview.py @@ -29,7 +29,17 @@ class TrackView(Gtk.Frame): self._scrollwin = Gtk.ScrolledWindow(child=self._columnview) self.__append_column("Title", "title", row.TrackString, width=300) + self.__append_column("Length", "length", row.LengthString, + xalign=1.0, numeric=True) self.__append_column("Artist", "artist", row.TrackString, width=250) + self.__append_column("Play Count", "playcount", row.PlayCountString, + width=135, numeric=True) + self.__append_column("Last Started", "laststarted", + row.TimestampString, width=250, + visible=False, numeric=True) + self.__append_column("Last Played", "lastplayed", + row.TimestampString, width=250, numeric=True) + self.__append_column("Filepath", "path", row.PathString, visible=False) self.bind_property("playlist", self._filtermodel, "model") self._selection.bind_property("n-items", self, "n-tracks") @@ -40,8 +50,9 @@ class TrackView(Gtk.Frame): self.set_child(self._scrollwin) def __append_column(self, title: str, property: str, row_type: type, - *, width: int = -1, visible: bool = True) -> None: - fctry = factory.Factory(row_type=row_type, property=property) + *, width: int = -1, visible: bool = True, + **kwargs) -> None: + fctry = factory.Factory(row_type=row_type, property=property, **kwargs) col = Gtk.ColumnViewColumn(title=title, factory=fctry, visible=visible, resizable=True, fixed_width=width) self._columnview.append_column(col) diff --git a/tests/test_settings.py b/tests/test_settings.py index aae35ec..f168567 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -122,21 +122,41 @@ 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.title.size"], 300) + self.assertEqual(self.settings["tracklist.length.size"], -1) self.assertEqual(self.settings["tracklist.artist.size"], 250) + self.assertEqual(self.settings["tracklist.play-count.size"], 135) + self.assertEqual(self.settings["tracklist.last-started.size"], 250) + self.assertEqual(self.settings["tracklist.last-played.size"], 250) + self.assertEqual(self.settings["tracklist.filepath.size"], -1) for column in self.win.tracklist.columns: column.set_fixed_width(123) self.assertEqual(self.settings["tracklist.title.size"], 123) + self.assertEqual(self.settings["tracklist.length.size"], 123) self.assertEqual(self.settings["tracklist.artist.size"], 123) + self.assertEqual(self.settings["tracklist.play-count.size"], 123) + self.assertEqual(self.settings["tracklist.last-started.size"], 123) + self.assertEqual(self.settings["tracklist.last-played.size"], 123) + self.assertEqual(self.settings["tracklist.filepath.size"], 123) def test_save_tracklist_column_visible(self, mock_stdout: io.StringIO): """Test saving tracklist column visibility.""" self.assertTrue(self.settings["tracklist.title.visible"]) + self.assertTrue(self.settings["tracklist.length.visible"]) self.assertTrue(self.settings["tracklist.artist.visible"]) + self.assertTrue(self.settings["tracklist.play-count.visible"]) + self.assertFalse(self.settings["tracklist.last-started.visible"]) + self.assertTrue(self.settings["tracklist.last-played.visible"]) + self.assertFalse(self.settings["tracklist.filepath.visible"]) for column in self.win.tracklist.columns: column.set_visible(not column.get_visible()) self.assertFalse(self.settings["tracklist.title.visible"]) + self.assertFalse(self.settings["tracklist.length.visible"]) self.assertFalse(self.settings["tracklist.artist.visible"]) + self.assertFalse(self.settings["tracklist.play-count.visible"]) + self.assertTrue(self.settings["tracklist.last-started.visible"]) + self.assertFalse(self.settings["tracklist.last-played.visible"]) + self.assertTrue(self.settings["tracklist.filepath.visible"]) diff --git a/tests/tracklist/test_row.py b/tests/tracklist/test_row.py index 33f47aa..8545f29 100644 --- a/tests/tracklist/test_row.py +++ b/tests/tracklist/test_row.py @@ -1,5 +1,7 @@ # Copyright 2023 (c) Anna Schumaker. """Tests our TrackView Row widgets.""" +import datetime +import dateutil.tz import pathlib import emmental.factory import emmental.tracklist.row @@ -65,10 +67,16 @@ class TestTrackRowWidgets(tests.util.TestCase): self.assertIsInstance(row, emmental.tracklist.row.TrackRow) self.assertIsInstance(row.child, Gtk.Inscription) self.assertEqual(row.child.get_xalign(), 0.0) + self.assertFalse(row.child.has_css_class("numeric")) row.child.set_text("Test Text") self.assertEqual(row.child.get_tooltip_text(), "Test Text") + row = emmental.tracklist.row.InscriptionRow(self.listitem, "property", + xalign=1.0, numeric=True) + self.assertEqual(row.child.get_xalign(), 1.0) + self.assertTrue(row.child.has_css_class("numeric")) + def test_track_string(self): """Test the Track String widget.""" row = emmental.tracklist.row.TrackString(self.listitem, "title") @@ -79,3 +87,61 @@ class TestTrackRowWidgets(tests.util.TestCase): self.assertEqual(row.child.get_text(), "Test Title") self.track.title = "New Title" self.assertEqual(row.child.get_text(), "New Title") + + def test_length_string(self): + """Test the LengthString widget.""" + row = emmental.tracklist.row.LengthString(self.listitem, "length") + self.assertIsInstance(row, emmental.tracklist.row.InscriptionRow) + self.assertEqual(row.property, "length") + self.assertEqual(row.length, 0.0) + + row.bind() + self.assertEqual(row.child.get_text(), "0:00") + self.track.length = 123.45 + self.assertEqual(row.child.get_text(), "2:03") + self.track.length = 123.84 + self.assertEqual(row.child.get_text(), "2:04") + + def test_playcount_string(self): + """Test the PlayCountString widget.""" + row = emmental.tracklist.row.PlayCountString(self.listitem, + "playcount") + self.assertIsInstance(row, emmental.tracklist.row.InscriptionRow) + self.assertEqual(row.property, "playcount") + self.assertEqual(row.playcount, 0) + + row.bind() + self.assertEqual(row.child.get_text(), "Unplayed") + self.track.playcount = 1 + self.assertEqual(row.child.get_text(), "Played 1 time") + self.track.playcount = 2 + self.assertEqual(row.child.get_text(), "Played 2 times") + self.track.playcount = 3 + self.assertEqual(row.child.get_text(), "Played 3 times") + + def test_timestamp_string(self): + """Test the TimestampString widget.""" + row = emmental.tracklist.row.TimestampString(self.listitem, + "laststarted") + self.assertIsInstance(row, emmental.tracklist.row.InscriptionRow) + self.assertEqual(row.TZ_UTC, dateutil.tz.tzutc()) + self.assertEqual(row.property, "laststarted") + self.assertEqual(row.timestamp, None) + + row.bind() + self.assertEqual(row.child.get_text(), "Never") + + local = datetime.datetime.now() + utc = local.astimezone(dateutil.tz.tzutc()) + self.track.laststarted = utc.replace(tzinfo=None) + self.assertEqual(row.child.get_text(), local.strftime("%c")) + + def test_path_string(self): + """Test the PathString widget.""" + row = emmental.tracklist.row.PathString(self.listitem, "path") + self.assertIsInstance(row, emmental.tracklist.row.InscriptionRow) + self.assertEqual(row.property, "path") + self.assertEqual(row.path, None) + + row.bind() + self.assertEqual(row.child.get_text(), str(pathlib.Path("/a/b/1.ogg"))) diff --git a/tests/tracklist/test_trackview.py b/tests/tracklist/test_trackview.py index 6ee754f..bd0f188 100644 --- a/tests/tracklist/test_trackview.py +++ b/tests/tracklist/test_trackview.py @@ -103,10 +103,29 @@ class TestTrackViewColumns(tests.util.TestCase): factory = self.columns[i].get_factory() self.assertIsInstance(factory, emmental.factory.Factory) self.assertEqual(factory.row_type, emmental.tracklist.row.TrackString) + factory.emit("setup", self.listitem) self.assertEqual(self.listitem.listrow.property, "title") + self.assertEqual(self.listitem.listrow.child.get_xalign(), 0.0) + self.assertFalse(self.listitem.listrow.child.has_css_class("numeric")) - def test_artist_column(self, i: int = 1): + def test_length_column(self, i: int = 1): + """Test the length column.""" + self.assertEqual(self.columns[i].get_title(), "Length") + self.assertEqual(self.columns[i].get_fixed_width(), -1) + self.assertTrue(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.LengthString) + + factory.emit("setup", self.listitem) + self.assertEqual(self.listitem.listrow.property, "length") + 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 = 2): """Test the artist column.""" self.assertEqual(self.columns[i].get_title(), "Artist") self.assertEqual(self.columns[i].get_fixed_width(), 250) @@ -116,5 +135,75 @@ class TestTrackViewColumns(tests.util.TestCase): factory = self.columns[i].get_factory() self.assertIsInstance(factory, emmental.factory.Factory) self.assertEqual(factory.row_type, emmental.tracklist.row.TrackString) + factory.emit("setup", self.listitem) self.assertEqual(self.listitem.listrow.property, "artist") + self.assertEqual(self.listitem.listrow.child.get_xalign(), 0.0) + self.assertFalse(self.listitem.listrow.child.has_css_class("numeric")) + + def test_playcount_column(self, i: int = 3): + """Test the play count column.""" + self.assertEqual(self.columns[i].get_title(), "Play Count") + self.assertEqual(self.columns[i].get_fixed_width(), 135) + self.assertTrue(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.PlayCountString) + + factory.emit("setup", self.listitem) + self.assertEqual(self.listitem.listrow.property, "playcount") + 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 = 4): + """Test the last started column.""" + self.assertEqual(self.columns[i].get_title(), "Last Started") + self.assertEqual(self.columns[i].get_fixed_width(), 250) + self.assertTrue(self.columns[i].get_resizable()) + self.assertFalse(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.TimestampString) + + factory.emit("setup", self.listitem) + self.assertEqual(self.listitem.listrow.property, "laststarted") + 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 = 5): + """Test the last played column.""" + self.assertEqual(self.columns[i].get_title(), "Last Played") + self.assertEqual(self.columns[i].get_fixed_width(), 250) + self.assertTrue(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.TimestampString) + + factory.emit("setup", self.listitem) + self.assertEqual(self.listitem.listrow.property, "lastplayed") + 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 = 6): + """Test the last played column.""" + self.assertEqual(self.columns[i].get_title(), "Filepath") + self.assertEqual(self.columns[i].get_fixed_width(), -1) + self.assertTrue(self.columns[i].get_resizable()) + self.assertFalse(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.PathString) + + factory.emit("setup", self.listitem) + self.assertEqual(self.listitem.listrow.property, "path") + self.assertEqual(self.listitem.listrow.child.get_xalign(), 0.0) + self.assertFalse(self.listitem.listrow.child.has_css_class("numeric"))