From 78ea2904a12db3a1ea144839b5de4cafab848770 Mon Sep 17 00:00:00 2001 From: Anna Schumaker Date: Sun, 30 Apr 2023 14:34:48 -0400 Subject: [PATCH] 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 --- emmental/emmental.css | 4 + emmental/tracklist/selection.py | 78 +++++++++++++++++ tests/tracklist/test_selection.py | 136 ++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+) diff --git a/emmental/emmental.css b/emmental/emmental.css index 8e134f4..3ed36f9 100644 --- a/emmental/emmental.css +++ b/emmental/emmental.css @@ -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; +} diff --git a/emmental/tracklist/selection.py b/emmental/tracklist/selection.py index b731dd9..7aaab28 100644 --- a/emmental/tracklist/selection.py +++ b/emmental/tracklist/selection.py @@ -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() diff --git a/tests/tracklist/test_selection.py b/tests/tracklist/test_selection.py index 11463b9..49e25f5 100644 --- a/tests/tracklist/test_selection.py +++ b/tests/tracklist/test_selection.py @@ -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)