tracklist: Rework the Sort Order button to use a ListBox

I convert my SortRow widget into a Gtk.ListBoxRow that has the same
functionality. The main benefit is that it looks nicer in the
Gtk.Popover compared to the Gtk.ListView that I had been using.

I also connect to the listbox "row-activated" signal so I can handle
clicking a specific sort row in the list. Clicking a disabled sort row
will enable it, and clicking an enabled one will reverse the sort order.
I think this is what feels the most natural to the user.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
Anna Schumaker 2023-08-11 22:43:28 -04:00
parent f6481f0182
commit 9f240bbc8b
2 changed files with 170 additions and 188 deletions

View File

@ -5,7 +5,6 @@ from gi.repository import Gio
from gi.repository import Gtk
from . import sorter
from .. import buttons
from .. import factory
class VisibleRow(Gtk.ListBoxRow):
@ -111,81 +110,57 @@ class ShuffleButton(buttons.ImageToggle):
self.icon_opacity = 1.0 if self.active else 0.5
class SortFieldWidget(Gtk.Box):
"""A Widget to display in the Sort Order button popover."""
class SortRow(Gtk.ListBoxRow):
"""A ListBoxRow for managing Sort Order."""
active = GObject.Property(type=bool, default=False)
sort_field = GObject.Property(type=sorter.SortField)
def __init__(self) -> None:
"""Initialize a SortField Widget."""
super().__init__(spacing=6)
self._enabled = Gtk.Switch(valign=Gtk.Align.CENTER)
self._name = Gtk.Label(hexpand=True, sensitive=False)
def __init__(self, sort_field: sorter.SortField):
"""Initialize a Sort Row."""
super().__init__(sort_field=sort_field, active=sort_field.enabled,
child=Gtk.Box(margin_start=6, margin_end=6,
margin_top=6, margin_bottom=6,
spacing=6))
self._switch = Gtk.Switch(active=self.active, valign=Gtk.Align.CENTER)
self._label = Gtk.Label(label=sort_field.name, hexpand=True,
sensitive=self.active, xalign=0.0)
self._reverse = buttons.ImageToggle("arrow1-up", "arrow1-down",
large_icon=False, sensitive=False)
self._box = Gtk.Box(sensitive=False)
active=sort_field.reversed,
sensitive=self.active,
has_frame=False)
self._move_box = Gtk.Box(sensitive=self.active)
self._move_up = Gtk.Button(icon_name="go-up-symbolic")
self._move_down = Gtk.Button(icon_name="go-down-symbolic")
self._enabled.bind_property("active", self._name, "sensitive")
self._enabled.bind_property("active", self._reverse, "sensitive")
self._enabled.bind_property("active", self._box, "sensitive")
self._switch.connect("notify::active", self.__toggle_enabled)
self._reverse.connect("toggled", self.__reverse)
self._move_up.connect("clicked", self.__move_up)
self._move_down.connect("clicked", self.__move_down)
self._enabled.connect("notify::active", self.__notify_enabled)
self._reverse.connect("clicked", self.__reverse)
self._move_up.connect("clicked", self.__move_item_up)
self._move_down.connect("clicked", self.__move_item_down)
self.props.child.append(self._switch)
self.props.child.append(self._label)
self.props.child.append(self._reverse)
self.props.child.append(self._move_box)
self.append(self._enabled)
self.append(self._name)
self.append(self._reverse)
self.append(self._box)
self._move_box.append(self._move_up)
self._move_box.append(self._move_down)
self._move_box.add_css_class("linked")
self._box.append(self._move_up)
self._box.append(self._move_down)
self._box.add_css_class("linked")
def __move_item_down(self, button: Gtk.Button) -> None:
if self.sort_field is not None:
self.sort_field.move_down()
def __move_item_up(self, button: Gtk.Button) -> None:
if self.sort_field is not None:
self.sort_field.move_up()
def __notify_enabled(self, switch: Gtk.Switch, param) -> None:
if self.sort_field is not None:
if switch.get_active():
self.sort_field.enable()
else:
self.sort_field.disable()
def __toggle_enabled(self, switch: Gtk.Switch, param) -> None:
if switch.props.active:
self.sort_field.enable()
else:
self.sort_field.disable()
def __reverse(self, button: buttons.ImageToggle) -> None:
if self.sort_field is not None:
self.sort_field.reverse()
self.sort_field.reverse()
def set_sort_field(self, field: sorter.SortField | None) -> None:
"""Set the Sort Field displayed by this Widget."""
self.sort_field = field
self._name.set_text(field.name if field is not None else "")
self._enabled.set_active(field is not None and field.enabled)
self._reverse.active = field is not None and field.reversed
def __move_up(self, button: Gtk.Button) -> None:
self.sort_field.move_up()
class SortRow(factory.ListRow):
"""A row for managing Sort Order."""
def __init__(self, listitem: Gtk.ListItem):
"""Initialize a Sort Row."""
super().__init__(listitem=listitem, child=SortFieldWidget())
def do_bind(self) -> None:
"""Bind Sort Field properties to the Widget."""
self.child.set_sort_field(self.item)
def do_unbind(self) -> None:
"""Unbind properties from the widget."""
self.child.set_sort_field(None)
def __move_down(self, button: Gtk.Button) -> None:
self.sort_field.move_down()
class SortButton(buttons.PopoverButton):
@ -198,13 +173,22 @@ class SortButton(buttons.PopoverButton):
"""Initialize the Sort button."""
super().__init__(has_frame=False, model=sorter.SortOrderModel(),
icon_name="view-list-ordered-symbolic", **kwargs)
self._selection = Gtk.NoSelection(model=self.model)
self._factory = factory.Factory(row_type=SortRow)
self.popover_child = Gtk.ListView(model=self._selection,
factory=self._factory,
show_separators=True)
self.popover_child = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
self.popover_child.bind_model(self.model, self.__create_func)
self.popover_child.connect("row-activated", self.__row_activated)
self.popover_child.add_css_class("boxed-list")
self.model.bind_property("sort-order", self, "sort-order")
def __create_func(self, sort_field: sorter.SortField) -> SortRow:
return SortRow(sort_field)
def __row_activated(self, box: Gtk.ListBox, row: SortRow) -> None:
if row.active:
row._reverse.active = not row.sort_field.reversed
else:
row.sort_field.enable()
def set_sort_order(self, newval: str) -> None:
"""Directly set the sort order."""
self.model.set_sort_order(newval)

View File

@ -195,123 +195,123 @@ class TestShuffleButtons(unittest.TestCase):
self.assertEqual(self.shuffle.icon_opacity, 0.5)
class TestSortFieldWidget(unittest.TestCase):
"""Test the Sort Field widget."""
class TestSortRow(unittest.TestCase):
"""Test the Sort Row ListBoxRow."""
def setUp(self):
"""Set up common variables."""
self.sort = emmental.tracklist.buttons.SortFieldWidget()
self.model = emmental.tracklist.sorter.SortOrderModel()
self.model[0].enable()
self.model[0].reverse()
self.model[1].reverse()
self.row1 = emmental.tracklist.buttons.SortRow(self.model[0])
self.row2 = emmental.tracklist.buttons.SortRow(self.model[1])
def test_init(self):
"""Test that the Sort Field Widget is configured correctly."""
self.assertIsInstance(self.sort, Gtk.Box)
self.assertIsInstance(self.sort._box, Gtk.Box)
self.assertIsInstance(self.sort._name, Gtk.Label)
self.assertIsInstance(self.row1, Gtk.ListBoxRow)
self.assertIsInstance(self.row1.props.child, Gtk.Box)
self.assertTrue(self.sort._name.get_hexpand())
self.assertEqual(self.row1.props.child.props.margin_start, 6)
self.assertEqual(self.row1.props.child.props.margin_end, 6)
self.assertEqual(self.row1.props.child.props.margin_top, 6)
self.assertEqual(self.row1.props.child.props.margin_bottom, 6)
self.assertEqual(self.row1.props.child.props.spacing, 6)
self.assertEqual(self.sort.get_spacing(), 6)
self.assertEqual(self.sort._enabled.get_next_sibling(),
self.sort._name)
self.assertEqual(self.sort._reverse.get_next_sibling(),
self.sort._box)
self.assertTrue(self.sort._box.has_css_class("linked"))
self.assertEqual(self.row1.sort_field, self.model[0])
self.assertTrue(self.row1.active)
def test_set_sort_field(self):
"""Test setting a sort field to the Sort Field Widget."""
self.assertIsNone(self.sort.sort_field)
self.assertEqual(self.row2.sort_field, self.model[1])
self.assertFalse(self.row2.active)
self.sort.set_sort_field(self.model[0])
self.assertEqual(self.sort.sort_field, self.model[0])
self.assertEqual(self.sort._name.get_text(), self.model[0].name)
self.assertTrue(self.sort._enabled.get_active())
self.assertTrue(self.sort._reverse.active)
def test_switch(self):
"""Test the SortRow switch."""
self.assertIsInstance(self.row1._switch, Gtk.Switch)
self.assertEqual(self.row1._switch.props.valign, Gtk.Align.CENTER)
self.assertEqual(self.row1._switch.props.parent, self.row1.props.child)
self.sort.set_sort_field(None)
self.assertIsNone(self.sort.sort_field)
self.assertEqual(self.sort._name.get_text(), "")
self.assertFalse(self.sort._enabled.get_active())
self.assertFalse(self.sort._reverse.active)
self.assertTrue(self.row1._switch.props.active)
self.assertFalse(self.row2._switch.props.active)
def test_enabled(self):
"""Test enabling and disabling a sort field."""
self.assertIsInstance(self.sort._enabled, Gtk.Switch)
self.assertEqual(self.sort._enabled.get_valign(), Gtk.Align.CENTER)
self.assertEqual(self.sort.get_first_child(), self.sort._enabled)
with unittest.mock.patch.object(self.model[0],
"disable") as mock_disable:
self.row1._switch.props.active = False
mock_disable.assert_called()
self.sort._enabled.set_active(True)
with unittest.mock.patch.object(self.model[0],
"enable") as mock_enable:
self.row1._switch.props.active = True
mock_enable.assert_called()
self.sort.set_sort_field(self.model[1])
self.assertFalse(self.sort._name.get_sensitive())
self.assertFalse(self.sort._box.get_sensitive())
self.assertFalse(self.sort._reverse.get_sensitive())
def test_label(self):
"""Test the SortRow title label."""
self.assertIsInstance(self.row1._label, Gtk.Label)
self.assertEqual(self.row1._switch.get_next_sibling(),
self.row1._label)
self.sort._enabled.set_active(True)
self.assertTrue(self.model[1].enabled)
self.assertTrue(self.sort._name.get_sensitive())
self.assertTrue(self.sort._box.get_sensitive())
self.assertTrue(self.sort._reverse.get_sensitive())
self.assertEqual(self.row1._label.props.label, self.model[0].name)
self.assertEqual(self.row1._label.props.xalign, 0.0)
self.assertTrue(self.row1._label.props.hexpand)
self.sort._enabled.set_active(False)
self.assertFalse(self.model[1].enabled)
self.assertFalse(self.sort._name.get_sensitive())
self.assertFalse(self.sort._box.get_sensitive())
self.assertFalse(self.sort._reverse.get_sensitive())
def test_move_down(self):
"""Test the moving a sort field down."""
self.assertIsInstance(self.sort._move_down, Gtk.Button)
self.assertEqual(self.sort._move_down.get_icon_name(),
"go-down-symbolic")
self.assertEqual(self.sort._move_up.get_next_sibling(),
self.sort._move_down)
self.sort._move_down.emit("clicked")
(field := self.model[0]).enable()
self.model[1].enable()
self.sort.set_sort_field(field)
self.sort._move_down.emit("clicked")
self.assertEqual(self.model.index(field), 1)
def test_move_up(self):
"""Test the moving a sort field."""
self.assertIsInstance(self.sort._move_up, Gtk.Button)
self.assertEqual(self.sort._move_up.get_icon_name(), "go-up-symbolic")
self.assertEqual(self.sort._box.get_first_child(),
self.sort._move_up)
self.sort._move_up.emit("clicked")
self.model[0].enable()
(field := self.model[1]).enable()
self.sort.set_sort_field(field)
self.sort._move_up.emit("clicked")
self.assertEqual(self.model.index(field), 0)
self.assertTrue(self.row1._label.props.sensitive)
self.assertFalse(self.row2._label.props.sensitive)
def test_reverse(self):
"""Test reversing a sort field."""
self.assertIsInstance(self.sort._reverse, emmental.buttons.ImageToggle)
self.assertEqual(self.sort._name.get_next_sibling(),
self.sort._reverse)
"""Test the SortRow reverse button."""
self.assertIsInstance(self.row1._reverse, emmental.buttons.ImageToggle)
self.assertEqual(self.row1._label.get_next_sibling(),
self.row1._reverse)
self.assertEqual(self.sort._reverse.active_icon_name, "arrow1-up")
self.assertEqual(self.sort._reverse.inactive_icon_name, "arrow1-down")
self.assertFalse(self.sort._reverse.large_icon)
self.assertEqual(self.row1._reverse.active_icon_name, "arrow1-up")
self.assertEqual(self.row1._reverse.inactive_icon_name, "arrow1-down")
self.assertFalse(self.row1._reverse.props.has_frame)
self.assertFalse(self.row1._reverse.large_icon)
self.sort._reverse.emit("clicked")
self.assertFalse(self.row1._reverse.props.active)
self.assertTrue(self.row1._reverse.props.sensitive)
self.sort.set_sort_field(self.model[0])
self.sort._reverse.emit("clicked")
self.assertFalse(self.model[0].reversed)
self.sort._reverse.emit("clicked")
self.assertTrue(self.model[0].reversed)
self.assertTrue(self.row2._reverse.props.active)
self.assertFalse(self.row2._reverse.props.sensitive)
with unittest.mock.patch.object(self.model[0],
"reverse") as mock_reverse:
self.row1._reverse.emit("toggled")
mock_reverse.assert_called()
def test_move_box(self):
"""Test the box containing the move up & down buttons."""
self.assertIsInstance(self.row1._move_box, Gtk.Box)
self.assertEqual(self.row1._reverse.get_next_sibling(),
self.row1._move_box)
self.assertTrue(self.row1._move_box.has_css_class("linked"))
self.assertTrue(self.row1._move_box.props.sensitive)
self.assertFalse(self.row2._move_box.props.sensitive)
def test_move_up(self):
"""Test the move up button."""
self.assertIsInstance(self.row1._move_up, Gtk.Button)
self.assertEqual(self.row1._move_up.get_icon_name(),
"go-up-symbolic")
self.assertEqual(self.row1._move_up.props.parent,
self.row1._move_box)
with unittest.mock.patch.object(self.model[0],
"move_up") as mock_move_up:
self.row1._move_up.emit("clicked")
mock_move_up.assert_called()
def test_move_down(self):
"""Test the move down button."""
self.assertIsInstance(self.row1._move_down, Gtk.Button)
self.assertEqual(self.row1._move_down.get_icon_name(),
"go-down-symbolic")
self.assertEqual(self.row1._move_up.get_next_sibling(),
self.row1._move_down)
with unittest.mock.patch.object(self.model[0],
"move_down") as mock_move_down:
self.row1._move_down.emit("clicked")
mock_move_down.assert_called()
class TestSortButton(unittest.TestCase):
@ -331,38 +331,36 @@ class TestSortButton(unittest.TestCase):
def test_popover_child(self):
"""Test that the popover_child is configured correctly."""
self.assertIsInstance(self.sort.popover_child, Gtk.ListView)
self.assertIsInstance(self.sort.model,
emmental.tracklist.sorter.SortOrderModel)
self.assertIsInstance(self.sort._selection, Gtk.NoSelection)
self.assertIsInstance(self.sort._factory, emmental.factory.Factory)
self.assertIsInstance(self.sort.popover_child, Gtk.ListBox)
self.assertEqual(self.sort.popover_child.props.selection_mode,
Gtk.SelectionMode.NONE)
self.assertTrue(self.sort.popover_child.has_css_class("boxed-list"))
self.assertTrue(self.sort.popover_child.get_show_separators())
def test_create_func(self):
"""Test that the Gtk.ListBox creates SortRows correctly."""
row = self.sort.popover_child.get_row_at_index(0)
self.assertIsInstance(row, emmental.tracklist.buttons.SortRow)
self.assertEqual(row.sort_field, self.sort.model[0])
self.assertEqual(self.sort.popover_child.get_model(),
self.sort._selection)
self.assertEqual(self.sort._selection.get_model(), self.sort.model)
self.assertEqual(self.sort.popover_child.get_factory(),
self.sort._factory)
self.assertEqual(self.sort._factory.row_type,
emmental.tracklist.buttons.SortRow)
def test_activate(self):
"""Test activating a Gtk.ListBox sort row."""
row = self.sort.popover_child.get_row_at_index(0)
field = row.sort_field
self.assertFalse(field.enabled)
self.assertFalse(field.reversed)
def test_sort_row(self):
"""Test the Sort Row object."""
(field := self.sort.model[0]).enable()
listitem = Gtk.ListItem()
listitem.get_item = lambda: field
self.sort.model[1].enable()
with unittest.mock.patch.object(field, "enable") as mock_enable:
self.sort.popover_child.emit("row-activated", row)
mock_enable.assert_called()
row = emmental.tracklist.buttons.SortRow(listitem)
self.assertIsInstance(row, emmental.factory.ListRow)
self.assertIsInstance(row.child,
emmental.tracklist.buttons.SortFieldWidget)
mock_enable.reset_mock()
row.active = True
row.bind()
self.assertEqual(row.child.sort_field, field)
row.unbind()
self.assertIsNone(row.child.sort_field)
with unittest.mock.patch.object(field, "reverse") as mock_reverse:
self.sort.popover_child.emit("row-activated", row)
self.assertTrue(row._reverse.active)
mock_enable.assert_not_called()
mock_reverse.assert_called()
def test_sort_order(self):
"""Test the sort-order property."""