tracklist: Add Move Down and Move Up buttons to the OSD

These are used to manually rearrange the Tracks in the Playlist. The
buttons are only marked sensitive if one item is selected.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
Anna Schumaker 2023-04-30 14:34:48 -04:00
parent a6f59d9378
commit 78ea2904a1
3 changed files with 218 additions and 0 deletions

View File

@ -85,3 +85,7 @@ columnview.emmental-track-list > listview > row > cell > picture {
min-width: 36px;
border-radius: 15%;
}
box.emmental-move-buttons > button > image {
color: @accent_color;
}

View File

@ -105,6 +105,50 @@ class PlaylistView(Gtk.ListView):
"""Signal that the user has selected a Playlist."""
class MoveButtons(Gtk.Box):
"""Buttons for moving Tracks in the playlist."""
can_move_down = GObject.Property(type=bool, default=False)
can_move_up = GObject.Property(type=bool, default=False)
def __init__(self, **kwargs) -> None:
"""Initialize the Move Buttons."""
super().__init__(**kwargs)
self._down = Gtk.Button(icon_name="go-down-symbolic",
hexpand=True, sensitive=False)
self._up = Gtk.Button(icon_name="go-up-symbolic",
hexpand=True, sensitive=False)
self.bind_property("can-move-down", self._down, "sensitive")
self.bind_property("can-move-up", self._up, "sensitive")
self._down.connect("clicked", self.__clicked, "move-down")
self._up.connect("clicked", self.__clicked, "move-up")
self._down.add_css_class("opaque")
self._down.add_css_class("pill")
self._up.add_css_class("opaque")
self._up.add_css_class("pill")
self.add_css_class("emmental-move-buttons")
self.add_css_class("large-icons")
self.add_css_class("linked")
self.append(self._up)
self.append(self._down)
def __clicked(self, button: Gtk.Button, signal: str) -> None:
self.emit(signal)
@GObject.Signal
def move_down(self) -> None:
"""Signal that the move down button was clicked."""
@GObject.Signal
def move_up(self) -> None:
"""Signal that the move up button was clicked."""
class OSD(Gtk.Overlay):
"""An Overlay with extra controls for the Tracklist."""
@ -129,6 +173,9 @@ class OSD(Gtk.Overlay):
halign=Gtk.Align.END, valign=Gtk.Align.END,
margin_end=16, margin_bottom=16,
visible=False)
self._move = MoveButtons(halign=Gtk.Align.CENTER, valign=Gtk.Align.END,
margin_bottom=16, sensitive=False,
visible=False)
self._sizegroup = Gtk.SizeGroup.new(Gtk.SizeGroupMode.HORIZONTAL)
self._add.add_css_class("suggested-action")
@ -139,12 +186,16 @@ class OSD(Gtk.Overlay):
self.selection.connect("selection-changed", self.__selection_changed)
self._add.popover_child.connect("playlist-selected", self.__add_tracks)
self._remove.connect("clicked", self.__remove_clicked)
self._move.connect("move-down", self.__move_track_down)
self._move.connect("move-up", self.__move_track_up)
self._sizegroup.add_widget(self._add)
self._sizegroup.add_widget(self._remove)
self._sizegroup.add_widget(self._move)
self.add_overlay(self._add)
self.add_overlay(self._remove)
self.add_overlay(self._move)
def __get_selected_tracks(self) -> list:
selection = self.selection.get_selection()
@ -163,6 +214,22 @@ class OSD(Gtk.Overlay):
self.playlist.remove_track(track)
self.clear_selection()
def __move_track_down(self, move: MoveButtons) -> None:
if self.playlist is not None:
index = self.selection.get_selection().get_nth(0)
self.selection.get_model().set_incremental(False)
self.playlist.move_track_down(self.selection[index])
self.selection.get_model().set_incremental(True)
self.__update_visibility()
def __move_track_up(self, move: MoveButtons) -> None:
if self.playlist is not None:
index = self.selection.get_selection().get_nth(0)
self.selection.get_model().set_incremental(False)
self.playlist.move_track_up(self.selection[index])
self.selection.get_model().set_incremental(True)
self.__update_visibility()
def __selection_changed(self, selection: Gtk.SelectionModel,
position: int, n_items: int) -> None:
self.n_selected = selection.get_selection().get_size()
@ -172,9 +239,20 @@ class OSD(Gtk.Overlay):
def __update_visibility(self) -> None:
db_plist = None if self.playlist is None else self.playlist.playlist
user = False if db_plist is None else db_plist.user_tracks
movable = False if db_plist is None else db_plist.tracks_movable
self._add.set_visible(db_plist is not None and self.have_selected)
self._remove.set_visible(user and self.have_selected)
self._move.set_visible(movable and self.have_selected)
if self.n_selected == 1:
index = self.selection.get_selection().get_nth(0)
self._move.set_sensitive(True)
self._move.can_move_down = index < len(self.selection) - 1
self._move.can_move_up = index > 0
else:
self._move.set_sensitive(False)
def clear_selection(self, *args) -> None:
"""Clear the current selection."""
self.selection.unselect_all()

View File

@ -166,6 +166,71 @@ class TestPlaylistView(tests.util.TestCase):
selected.assert_called_with(self.view, self.sql.playlists.favorites)
class TestMoveButtons(unittest.TestCase):
"""Test the Tracklist move up & down buttons."""
def setUp(self):
"""Set up common variables."""
self.move = emmental.tracklist.selection.MoveButtons()
def test_init(self):
"""Test that the move buttons were set up properly."""
self.assertIsInstance(self.move, Gtk.Box)
self.assertTrue(self.move.has_css_class("emmental-move-buttons"))
self.assertTrue(self.move.has_css_class("large-icons"))
self.assertTrue(self.move.has_css_class("linked"))
def test_move_down(self):
"""Test the move down button."""
self.assertIsInstance(self.move._down, Gtk.Button)
self.assertEqual(self.move._down.get_icon_name(), "go-down-symbolic")
self.assertTrue(self.move._down.has_css_class("opaque"))
self.assertTrue(self.move._down.has_css_class("pill"))
self.assertTrue(self.move._down.get_hexpand())
move_down = unittest.mock.Mock()
self.move.connect("move-down", move_down)
self.move._down.emit("clicked")
move_down.assert_called()
self.assertEqual(self.move._up.get_next_sibling(), self.move._down)
def test_can_move_down(self):
"""Test the can-move-down property."""
self.assertFalse(self.move.can_move_down)
self.assertFalse(self.move._down.get_sensitive())
self.move.can_move_down = True
self.assertTrue(self.move._down.get_sensitive())
self.move.can_move_down = False
self.assertFalse(self.move._down.get_sensitive())
def test_move_up(self):
"""Test the move up button."""
self.assertIsInstance(self.move._up, Gtk.Button)
self.assertEqual(self.move._up.get_icon_name(), "go-up-symbolic")
self.assertTrue(self.move._up.has_css_class("opaque"))
self.assertTrue(self.move._up.has_css_class("pill"))
self.assertTrue(self.move._up.get_hexpand())
move_up = unittest.mock.Mock()
self.move.connect("move-up", move_up)
self.move._up.emit("clicked")
move_up.assert_called()
self.assertEqual(self.move.get_first_child(), self.move._up)
def test_can_move_up(self):
"""Test the can-move-up property."""
self.assertFalse(self.move.can_move_up)
self.assertFalse(self.move._up.get_sensitive())
self.move.can_move_up = True
self.assertTrue(self.move._up.get_sensitive())
self.move.can_move_up = False
self.assertFalse(self.move._up.get_sensitive())
class TestOsd(tests.util.TestCase):
"""Test the Tracklist OSD."""
@ -287,6 +352,77 @@ class TestOsd(tests.util.TestCase):
unittest.mock.call(self.model[1]),
unittest.mock.call(self.model[2])])
def test_move_buttons(self):
"""Test the move buttons."""
self.assertIsInstance(self.osd._move,
emmental.tracklist.selection.MoveButtons)
self.assertEqual(self.osd._move.get_halign(), Gtk.Align.CENTER)
self.assertEqual(self.osd._move.get_valign(), Gtk.Align.END)
self.assertEqual(self.osd._move.get_margin_bottom(), 16)
self.assertIn(self.osd._move, self.osd._sizegroup.get_widgets())
self.assertIn(self.osd._move, self.osd)
def test_move_button_clicks(self):
"""Test clicking the move buttons."""
set_incremental = unittest.mock.Mock()
self.model.set_incremental = set_incremental
self.osd.playlist = self.playlist
self.selection.select_item(1, True)
with unittest.mock.patch.object(self.playlist,
"move_track_down") as mock_move_down:
self.osd._move.emit("move-down")
mock_move_down.assert_called_with(self.model[1])
set_incremental.assert_has_calls([unittest.mock.call(False),
unittest.mock.call(True)])
set_incremental.reset_mock()
with unittest.mock.patch.object(self.playlist,
"move_track_up") as mock_move_up:
self.osd._move.emit("move-up")
mock_move_up.assert_called_with(self.model[1])
set_incremental.assert_has_calls([unittest.mock.call(False),
unittest.mock.call(True)])
def test_move_buttons_sensitive(self):
"""Test the move button sensitivity."""
self.assertFalse(self.osd._move.get_sensitive())
self.osd.playlist = self.playlist
self.selection.select_item(0, True)
self.assertTrue(self.osd._move.get_sensitive())
self.assertTrue(self.osd._move.can_move_down)
self.assertFalse(self.osd._move.can_move_up)
self.selection.select_item(1, True)
self.assertTrue(self.osd._move.get_sensitive())
self.assertTrue(self.osd._move.can_move_down)
self.assertTrue(self.osd._move.can_move_up)
self.selection.select_item(2, True)
self.assertTrue(self.osd._move.get_sensitive())
self.assertFalse(self.osd._move.can_move_down)
self.assertTrue(self.osd._move.can_move_up)
self.selection.select_item(1, False)
self.assertFalse(self.osd._move.get_sensitive())
def test_move_buttons_visible(self):
"""Test the move button visibility."""
self.assertFalse(self.osd._move.get_visible())
self.selection.select_item(0, True)
self.assertFalse(self.osd._move.get_visible())
self.osd.playlist = self.playlist
self.selection.select_item(1, True)
self.assertTrue(self.osd._move.get_visible())
self.db_plist.tracks_movable = False
self.selection.select_item(2, True)
self.assertFalse(self.osd._move.get_visible())
def test_selection_properties(self):
"""Test updating properties when the selection changes."""
self.assertFalse(self.osd.have_selected)