Widget Creation Help - A Horizontal ListBox

Some guidance please.

I’m trying to implement a custom widget that presents a list of widgets. It is to have two buttons that move a selected item up or down the list.

I have successfully implemented the my widget using the ListBox and two Buttons. Easy. My problem is ListBox does not implement Orientable since I need the list to be horizontal. Therefore, I have been trying to simply recreate a horizontal version of the ListBox and ran into a problem. How Do I re-create the selectable functionality of a ListBox? Any suggestions welcome.

PS: I using Python 3.12 and GTK4

Regards

Do you have any specific needs? Because I believe it might be simpler to use a GridView in the case of horizontal lists than to create a new Widget. Or even use a simple Box to add the items, which works perfectly if you have a small number of items.

But as for recreating the selectable function, it’s going to take a bit of work. A possible way would be:

Create a widget to represent your list item and then place the actual item inside it.

This widget must have a function to detect when it is clicked and change the appearance of the item accordingly.

Furthermore, it would have to tell its list widget that it was clicked so that the list widget would tell all other items to deselect.

The list widget would have to take care of the selection when it happens by the keyboard, telling the widget the correct item to select.

And that’s just the most basic of what I can remember right now.

Thank you Diego for responding.

I took a look at the docs for GridView and quickly realised it was a little heavy weight solution for my needs. However I fell across the FlowBox, it looked a lot lighter than GridView and similar to ListBox in its capability. In Short I prototyped a possible solution, (see below). My prototype is definitely incomplete and work in progress. However thoughts, suggestions and comments al ways welcome.

Prototype solution:

"""
This example presents a list [A, B, C].
A selected item in the list can be moved left or right in the list by clicking the left or right button.
A button may be insensitive if the item selected is either the first or list item.
"""
# FIXME: when initialised why does the first list item indicate it is selected
# FIXME: When moving the first list item left it ends up as the second list item the list. The action should be ignored.
# FIXME: when the first list item is selected the move left should be insensitive. Similarly when the last item
#  has been selected the move right button should be insensitive.
from icecream import ic
import sys
import gi

gi.require_version('Gtk', '4.0')
from gi.repository import Gtk


class ListRow[T](Gtk.Box):
    def __init__(self):
        Gtk.Box.__init__(self)
        Gtk.Box.set_orientation(self, Gtk.Orientation.HORIZONTAL)

        self.selected_index: int = 0  # the list index of the selected item
        self.selected_flag: bool = False  # True for a currently active selection otherwise False.
        self._model: list[T] = []  # internal model

        # Lay-outs for the FlowBox and Buttons
        left_button_box: Gtk.Box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        left_button_box.set_halign(align=Gtk.Align.START)
        left_button_box.set_hexpand(True)
        left_button_box.set_vexpand(True)
        left_button_box.set_margin_end(5)
        Gtk.Box.append(self, left_button_box)

        widget_box: Gtk.Box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        widget_box.set_halign(align=Gtk.Align.CENTER)
        widget_box.set_hexpand(True)
        widget_box.set_vexpand(True)
        Gtk.Box.append(self, widget_box)

        right_button_box: Gtk.Box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        right_button_box.set_halign(align=Gtk.Align.END)
        right_button_box.set_hexpand(True)
        right_button_box.set_vexpand(True)
        right_button_box.set_margin_start(5)
        Gtk.Box.append(self, right_button_box)

        self.list_row: Gtk.FlowBox = Gtk.FlowBox(orientation=Gtk.Orientation.VERTICAL)
        self.list_row.connect('child-activated', self.selected_cb)
        self.list_row.set_selection_mode(mode=Gtk.SelectionMode.SINGLE)
        widget_box.append(self.list_row)

        self.move_left_button: Gtk.Button = Gtk.Button(label='left')
        self.move_left_button.set_icon_name(icon_name="pan-start-symbolic")
        self.move_left_button.set_halign(align=Gtk.Align.START)
        self.move_left_button.connect('clicked', self._move_left_cb)
        self.move_left_button.set_sensitive(False)
        left_button_box.append(self.move_left_button)

        self.move_right_button: Gtk.Button = Gtk.Button(label='right')
        self.move_right_button.set_icon_name(icon_name="pan-end-symbolic")
        self.move_right_button.set_halign(align=Gtk.Align.END)
        self.move_right_button.connect('clicked', self._move_right_cb)
        self.move_right_button.set_sensitive(False)
        right_button_box.append(self.move_right_button)

        self._paint_list()
        return

    def _paint_list(self) -> None:
        try:
            while True:
                child: T = self.list_row.get_first_child()
                self.list_row.remove(child)
        except TypeError as error:
            pass
        finally:
            for item in self._model:
                ic(item)
                self.list_row.append(item)
            return

    def _action_move(self, direction: str) -> None:
        """
        :param direction:
        :returns: Nothing
        """
        temp_model: list[T]
        insert_index: int

        while self.selected_flag:
            item: T = self._model[self.selected_index]

            if direction == "left":
                insert_index = self.selected_index - 1
                self._model.pop(self.selected_index)
                self._model.insert(insert_index, item)

            if direction == "right":
                insert_index = self.selected_index + 1
                self._model.pop(self.selected_index)
                self._model.insert(insert_index, item)

            self.selected_index = insert_index
            self.selected_flag = False
            self.move_left_button.set_sensitive(False)
            self.move_right_button.set_sensitive(False)
            self._paint_list()
        return

    def append(self, widget_item: T) -> None:
        """
        :param widget_item:
        :returns: Nothing
        """
        widget_item.set_margin_start(5)
        widget_item.set_margin_end(5)
        self._model.append(widget_item)
        self._paint_list()
        return

    # CALLBACK
    def selected_cb(self, _: Gtk.FlowBox, selected: Gtk.FlowBoxChild) -> None:
        """
        :param _: unused
        :param selected:
        :returns: Nothing
        """
        self.selected_index = selected.get_index()
        self.selected_flag = True

        self.move_left_button.set_sensitive(True)
        self.move_right_button.set_sensitive(True)
        return

    # CALLBACK
    def _move_left_cb(self, *_) -> None:
        """
        :param _: unused
        :returns: Nothing
        """
        self._action_move("left")
        return

    # CALLBACK
    def _move_right_cb(self, *_) -> None:
        """
        :param _: unused
        :returns: Nothing
        """
        self._action_move("right")
        return


class MainWindow(Gtk.ApplicationWindow):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # Test Code
        box: Gtk.Box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        self.set_child(box)

        title: Gtk.Label = Gtk.Label(label="Execute in the following order:")
        title.set_halign(align=Gtk.Align.START)
        title.set_valign(align=Gtk.Align.START)
        title.set_margin_end(margin=20)
        box.append(title)

        self.list_view: ListRow = ListRow()
        box.append(self.list_view)

        # add List Widgets to test the list action:
        self.list_view.append(Gtk.Label(label="ALPHA"))
        self.list_view.append(Gtk.Label(label="BETA"))
        self.list_view.append(Gtk.Label(label="GAMMA"))
        return


class MyApp(Gtk.Application):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.win = None
        self.connect('activate', self.on_activate)

    def on_activate(self, app):
        self.win = MainWindow(application=app)
        self.win.present()


if __name__ == "__main__":
    app = MyApp(application_id="com.example.GtkApplication")
    app.run(sys.argv)

Hey Greg, first of all, I’m a python newbie and your use of type hints was quite informative to read, thanks.

I tested your example and a FlowBox really seems like the best way to go. I noticed that you created a “model” property to handle the children of the FlowBox but that’s not really necessary. I took the liberty of simplifying your code a bit and adding the necessary exceptions for when the element is the first or the last.

from gi.repository import Gtk
from icecream import ic
import sys
import gi

gi.require_version('Gtk', '4.0')


class ListRow(Gtk.Box):
    def __init__(self):
        Gtk.Box.__init__(self)
        Gtk.Box.set_orientation(self, Gtk.Orientation.HORIZONTAL)

        self.lenght = 0

        # Lay-outs for the FlowBox and Buttons
        left_button_box: Gtk.Box = Gtk.Box(
            orientation=Gtk.Orientation.HORIZONTAL)
        left_button_box.set_halign(align=Gtk.Align.START)
        left_button_box.set_hexpand(True)
        left_button_box.set_vexpand(True)
        left_button_box.set_margin_end(5)
        Gtk.Box.append(self, left_button_box)

        widget_box: Gtk.Box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        widget_box.set_halign(align=Gtk.Align.CENTER)
        widget_box.set_hexpand(True)
        widget_box.set_vexpand(True)
        Gtk.Box.append(self, widget_box)

        right_button_box: Gtk.Box = Gtk.Box(
            orientation=Gtk.Orientation.HORIZONTAL)
        right_button_box.set_halign(align=Gtk.Align.END)
        right_button_box.set_hexpand(True)
        right_button_box.set_vexpand(True)
        right_button_box.set_margin_start(5)
        Gtk.Box.append(self, right_button_box)

        self.list_row: Gtk.FlowBox = Gtk.FlowBox(
            orientation=Gtk.Orientation.VERTICAL)
        # self.list_row.connect('child-activated', self.selected_cb)
        self.list_row.set_selection_mode(mode=Gtk.SelectionMode.SINGLE)
        widget_box.append(self.list_row)

        self.move_left_button: Gtk.Button = Gtk.Button(label='left')
        self.move_left_button.set_icon_name(icon_name="pan-start-symbolic")
        self.move_left_button.set_halign(align=Gtk.Align.START)
        self.move_left_button.connect('clicked', self._move, "left")
        # self.move_left_button.set_sensitive(False)
        left_button_box.append(self.move_left_button)

        self.move_right_button: Gtk.Button = Gtk.Button(label='right')
        self.move_right_button.set_icon_name(icon_name="pan-end-symbolic")
        self.move_right_button.set_halign(align=Gtk.Align.END)
        self.move_right_button.connect('clicked', self._move, "right")
        # self.move_right_button.set_sensitive(False)
        right_button_box.append(self.move_right_button)

        return

    def _move(self, button, direction: str) -> None:
        """
        :param direction:
        :returns: Nothing
        """
        selected_list = self.list_row.get_selected_children()
        selected = selected_list[0]
        index = selected.get_index()
        item = selected.get_child()

        if direction == "left":

            if index == 0:
                return

            insert_index = index - 1

        if direction == "right":

            if index == (self.lenght - 1):
                return

            insert_index = index + 1

        self.list_row.remove(item)
        selected.set_child(None)
        self.list_row.insert(item, insert_index)
        new = self.list_row.get_child_at_index(insert_index)
        self.list_row.select_child(new)
        return

    def append(self, widget_item) -> None:
        """
        :param widget_item:
        :returns: Nothing
        """
        widget_item.set_margin_start(5)
        widget_item.set_margin_end(5)
        self.list_row.append(widget_item)
        self.lenght = self.lenght + 1

        return


class MainWindow(Gtk.ApplicationWindow):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # Test Code
        box: Gtk.Box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        self.set_child(box)

        title: Gtk.Label = Gtk.Label(label="Execute in the following order:")
        title.set_halign(align=Gtk.Align.START)
        title.set_valign(align=Gtk.Align.START)
        title.set_margin_end(margin=20)
        box.append(title)

        self.list_view: ListRow = ListRow()
        box.append(self.list_view)

        # add List Widgets to test the list action:
        self.list_view.append(Gtk.Label(label="ALPHA"))  # 0
        self.list_view.append(Gtk.Label(label="BETA"))  # 1
        self.list_view.append(Gtk.Label(label="GAMMA"))  # 2
        self.list_view.append(Gtk.Label(label="RED"))  # 3
        self.list_view.append(Gtk.Label(label="GREEN"))  # 4
        return


class MyApp(Gtk.Application):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.win = None
        self.connect('activate', self.on_activate)

    def on_activate(self, app):
        self.win = MainWindow(application=app)
        self.win.present()


if __name__ == "__main__":
    app = MyApp(application_id="com.example.GtkApplication")
    app.run(sys.argv)

Hi Diego,
Thanks for the feedback It did help and I am pleased you found the typing informative.

I noticed you had commented out the callbacks and disabled the button sensitivity control. I assumed you considered I could reinstate as needed. I did because with no list item selected pressing a move button results in a trace-back.

My rational for the model (original post) was a vein attempt to separate concerns and it would make it relatively simple to return the model to the caller (a controller). Which brings me to two other requirements for the widget.

  • Providing an event to notify the caller of changes (done and working),
  • Changing and removing focus between ListRow instances (not a complete blank but stuck).

The last issue requires removal of the focus from the first ListRow instance when an item is selected on another (second) instance. i.e. loss of focus on the first instance ends up calling unselect_all() on the first ListRow instance. Now, I know I need to add an EventControllerFocus, see below, and I have tried that but no events are being emitted and I cannot figure out why - what am I doing wrong?

self.lost_focus_ctrl: Gtk.EventControllerFocus = Gtk.EventControllerFocus()
        # self.lost_focus_ctrl.connect('notify', self._lost_focus_cb)
        # self.lost_focus_ctrl.connect('enter', self._lost_focus_cb)
        self.lost_focus_ctrl.connect('leave', self._lost_focus_cb)
        # self.add_controller(lost_focus_ctrl)

I have tried adding the controller to the ListRow class, to the FlowBox and even to every widget added to ListRow. I know I’m missing something but I cannot figurer what.

Pointers and suggestions welcome.
Regards
Greg

Regarding the model, FlowBox is compatible with GTk’s GListModels and, if the idea is to use a model, it is recommended that you use them through FlowBox’s bind_model() function.

Regarding the button sensitivity control, I just disabled it to facilitate analysis. I ended up posting the code a little in a hurry and forgot to re-add some control in case the list was empty.

I don’t understand what you mean by “Providing an event to notify the caller of changes”, but regarding the second point, I believe the best solution is to add a grab_focus() to the function you use to change the button sensitivity (self.selected_cb). Adding the following line to this function should do the trick:

self.list_row.grab_focus()

This will mean that, whenever you select an element, that FlowBox steals the focus for itself.