tracklist: Add a LoopButton

This is an ImageToggle button that has been configured to cycle between
3 states corresponding to no looping, playlist looping, and track
looping.

I also update the Tracklist to look for changes in the visible Playlist
to update the Loop button.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
Anna Schumaker 2022-10-26 10:34:01 -04:00
parent 2d19d78eb6
commit ed1d990e74
4 changed files with 181 additions and 3 deletions

View File

@ -4,9 +4,10 @@ from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gio
from gi.repository import Gtk
from ..playlist.playlist import Playlist
from ..playlist.previous import Previous
from .. import db
from .. import entry
from .. import playlist
from . import buttons
from . import trackview
@ -15,13 +16,13 @@ class Card(Gtk.Box):
"""Our Tracklist."""
sql = GObject.Property(type=db.Connection)
playlist = GObject.Property(type=playlist.playlist.Playlist)
def __init__(self, sql: db.Connection, **kwargs):
"""Set up the Tracklist widget."""
super().__init__(sql=sql, orientation=Gtk.Orientation.VERTICAL,
spacing=6, **kwargs)
self._top_left = Gtk.Box()
self._top_right = Gtk.Box(sensitive=False)
self._top_box = Gtk.CenterBox(margin_top=6, margin_start=6,
margin_end=6)
self._filter = entry.Filter("tracks", hexpand=True,
@ -31,28 +32,48 @@ class Card(Gtk.Box):
self._visible_cols = buttons.VisibleColumns(self._trackview.columns)
self._unselect = Gtk.Button(icon_name="edit-select-none-symbolic",
has_frame=False, sensitive=False)
self._loop = buttons.LoopButton()
self._top_left.append(self._visible_cols)
self._top_left.append(self._unselect)
self._top_right.append(self._loop)
self._top_box.set_start_widget(self._top_left)
self._top_box.set_center_widget(self._filter)
self._top_box.set_end_widget(self._top_right)
self.append(self._top_box)
self.append(self._trackview)
self._trackview.bind_property("have-selected", self._unselect,
"sensitive")
self.bind_property("playlist", self._trackview, "playlist")
self._filter.connect("search-changed", self.__search_changed)
self._unselect.connect("clicked", self.__clear_selection)
self._loop.connect("notify::state", self.__update_loop_state)
self.add_css_class("card")
def __clear_selection(self, unselect: Gtk.Button) -> None:
self._trackview.clear_selected_tracks()
def __playlist_notify(self, playlist: Playlist, param) -> None:
match param.name:
case "loop":
self._loop.state = playlist.loop
case "playlist":
self.__set_button_state()
def __set_button_state(self) -> None:
can_disable = self.playlist.playlist != self.sql.playlists.collection
self._loop.can_disable = can_disable
self._loop.state = self.playlist.loop
def __update_loop_state(self, loop: buttons.LoopButton, param) -> None:
if self.playlist.loop != loop.state:
self.playlist.loop = loop.state
def __search_changed(self, filter: entry.Filter) -> None:
self.sql.tracks.filter(filter.get_query())
@ -69,3 +90,19 @@ class Card(Gtk.Box):
def columns(self) -> Gio.ListModel:
"""Get the columns displayed in the Tracklist."""
return self._trackview.columns
@GObject.Property(type=Playlist)
def playlist(self) -> Playlist:
"""Get the currently visible Playlist."""
return self._trackview.playlist
@playlist.setter
def playlist(self, newval: Playlist) -> None:
if self._trackview.playlist is not None:
self._trackview.playlist.disconnect_by_func(self.__playlist_notify)
self._trackview.playlist = newval
if newval is not None:
self._top_right.set_sensitive(not isinstance(newval, Previous))
self.__set_button_state()
newval.connect("notify", self.__playlist_notify)

View File

@ -42,3 +42,45 @@ class VisibleColumns(buttons.PopoverButton):
column = Gtk.ColumnViewColumn(factory=factory, title=title,
fixed_width=width)
self.popover_child.append_column(column)
class LoopButton(buttons.ImageToggle):
"""A button for setting Loop state of a Playlist."""
can_disable = GObject.Property(type=bool, default=True)
def __init__(self, **kwargs):
"""Initialize a Loop Button."""
super().__init__(active_icon_name="media-playlist-repeat-song",
inactive_icon_name="media-playlist-repeat",
icon_size=Gtk.IconSize.NORMAL, state="None",
has_frame=False, **kwargs)
def do_clicked(self):
"""Cycle though Loop states."""
match (self.state, self.can_disable):
case ("None", _): self.state = "Playlist"
case ("Playlist", _): self.state = "Track"
case ("Track", True): self.state = "None"
case ("Track", False): self.state = "Playlist"
@GObject.Property(type=str)
def state(self) -> str:
"""Get the current state of the button."""
match (self.active, self.icon_opacity):
case (True, 1.0): return "Track"
case (False, 1.0): return "Playlist"
case (_, _): return "None"
@state.setter
def state(self, newval: str) -> None:
match (newval, self.can_disable):
case ("None", True):
self.active = False
self.icon_opacity = 0.5
case ("Playlist", _):
self.active = False
self.icon_opacity = 1.0
case ("Track", _):
self.active = True
self.icon_opacity = 1.0

View File

@ -69,3 +69,65 @@ class TestVisibleColumns(unittest.TestCase):
self.assertFalse(switch.child.get_active())
switch.child.set_active(True)
item.set_visible(True)
class TestLoopButton(unittest.TestCase):
"""Test the LoopButton."""
def setUp(self):
"""Set up common variables."""
self.loop = emmental.tracklist.buttons.LoopButton()
def test_init(self):
"""Test that the loop button is set up properly."""
self.assertIsInstance(self.loop, emmental.buttons.ImageToggle)
self.assertEqual(self.loop.active_icon_name,
"media-playlist-repeat-song")
self.assertEqual(self.loop.inactive_icon_name, "media-playlist-repeat")
self.assertEqual(self.loop.icon_size, Gtk.IconSize.NORMAL)
self.assertFalse(self.loop.get_has_frame())
def test_state(self):
"""Test changing the state property."""
self.assertTrue(self.loop.can_disable)
self.assertEqual(self.loop.state, "None")
self.assertAlmostEqual(self.loop.icon_opacity, 0.5, delta=0.005)
self.assertFalse(self.loop.active)
self.loop.state = "Playlist"
self.assertEqual(self.loop.state, "Playlist")
self.assertEqual(self.loop.icon_opacity, 1.0)
self.assertFalse(self.loop.active)
self.loop.state = "Track"
self.assertEqual(self.loop.state, "Track")
self.assertEqual(self.loop.icon_opacity, 1.0)
self.assertTrue(self.loop.active)
self.loop.can_disable = False
self.loop.state = "None"
self.assertEqual(self.loop.state, "Track")
self.assertTrue(self.loop.active)
self.loop.can_disable = True
self.loop.state = "None"
self.assertAlmostEqual(self.loop.icon_opacity, 0.5, delta=0.005)
self.assertFalse(self.loop.active)
def test_click(self):
"""Test cycling through states when clicked."""
self.assertEqual(self.loop.state, "None")
self.loop.emit("clicked")
self.assertEqual(self.loop.state, "Playlist")
self.loop.emit("clicked")
self.assertEqual(self.loop.state, "Track")
self.loop.emit("clicked")
self.assertEqual(self.loop.state, "None")
self.loop.state = "Playlist"
self.loop.can_disable = False
self.loop.emit("clicked")
self.assertEqual(self.loop.state, "Track")
self.loop.emit("clicked")
self.assertEqual(self.loop.state, "Playlist")

View File

@ -13,6 +13,7 @@ class TestTracklist(tests.util.TestCase):
def setUp(self):
"""Set up common variables."""
super().setUp()
self.sql.playlists.load(now=True)
self.tracklist = emmental.tracklist.Card(self.sql)
self.db_plist = self.sql.playlists.create("Test Playlist")
self.playlist = emmental.playlist.playlist.Playlist(self.sql,
@ -23,6 +24,7 @@ class TestTracklist(tests.util.TestCase):
self.assertIsInstance(self.tracklist, Gtk.Box)
self.assertIsInstance(self.tracklist._top_box, Gtk.CenterBox)
self.assertIsInstance(self.tracklist._top_left, Gtk.Box)
self.assertIsInstance(self.tracklist._top_right, Gtk.Box)
self.assertEqual(self.tracklist.sql, self.sql)
self.assertEqual(self.tracklist.get_spacing(), 6)
@ -39,6 +41,8 @@ class TestTracklist(tests.util.TestCase):
self.tracklist._top_box)
self.assertEqual(self.tracklist._top_box.get_start_widget(),
self.tracklist._top_left)
self.assertEqual(self.tracklist._top_box.get_end_widget(),
self.tracklist._top_right)
self.assertTrue(self.tracklist.has_css_class("card"))
@ -89,6 +93,29 @@ class TestTracklist(tests.util.TestCase):
self.tracklist._filter.emit("search-changed")
mock_filter.assert_called_with("*test text*")
def test_loop_button(self):
"""Test the loop button."""
self.assertIsInstance(self.tracklist._loop,
emmental.tracklist.buttons.LoopButton)
self.assertEqual(self.tracklist._top_right.get_first_child(),
self.tracklist._loop)
self.tracklist._loop.can_disable = False
self.playlist.loop = "Track"
self.tracklist.playlist = self.playlist
self.assertTrue(self.tracklist._loop.can_disable)
self.assertEqual(self.tracklist._loop.state, "Track")
self.playlist.playlist = self.sql.playlists.collection
self.assertFalse(self.tracklist._loop.can_disable)
self.assertEqual(self.tracklist._loop.state, "Playlist")
self.playlist.loop = "Track"
self.assertEqual(self.tracklist._loop.state, "Track")
self.tracklist._loop.state = "Playlist"
self.assertEqual(self.playlist.loop, "Playlist")
def test_trackview(self):
"""Test the Trackview widget."""
self.assertIsInstance(self.tracklist._trackview,
@ -104,10 +131,20 @@ class TestTracklist(tests.util.TestCase):
def test_playlist(self):
"""Test the playlist property."""
self.assertIsNone(self.tracklist.playlist)
self.assertFalse(self.tracklist._top_right.get_sensitive())
self.tracklist.playlist = self.playlist
self.assertEqual(self.tracklist.playlist, self.playlist)
self.assertEqual(self.tracklist._trackview.playlist, self.playlist)
self.assertTrue(self.tracklist._top_right.get_sensitive())
db_prev = self.sql.playlists.previous
previous = emmental.playlist.previous.Previous(self.sql, db_prev)
self.tracklist.playlist = previous
self.assertFalse(self.tracklist._top_right.get_sensitive())
self.tracklist.playlist.playlist = None
self.tracklist.playlist = None
@unittest.mock.patch("gi.repository.GLib.idle_add")
def test_scroll_to_track(self, mock_idle_add: unittest.mock.Mock):