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)