Can a GridBagSizer row be highlighted and have an event attached?

I’m trying to build a tool that permits users to choose a row and then change its position up or down in a GridBagSizer. I’ve already written the code that swaps two rows, which works fine.

What I’d like to do is select a row with the mouse which sets the row ID in a SpinCtrl, Then the SpinCtrl would be used to move the row up or down in the GBS.

This is the code that I’m using to swap rows.

    def gbs_swap_rows(self, gbs, row0, row1, flag=0, border=0):
        """
        if rows = 2 and cols = 3 the GridBagSizer positions should be
        [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)]
        """
        rows = gbs.GetRows()
        cols = gbs.GetCols()
        assert row0 != row1, (f"row0 ({row0}) and row1 ({row1}) cannot "
                              "be the same.")
        assert -1 < row0 < rows, ("The row0 value is invalid can only be "
                                  f"between 0 and {rows-1}, found {row0}")
        assert -1 < row1 < rows, ("The row1 value is invalid can only be "
                                  f"between 0 and {rows-1}, found {row1}")
        assert -1 < cols, f"The number of columns must be >= 0, found {cols}."
        positions = []
        old_sizer_items = []
        windows = []
        #print(f"rows: {rows}, cols: {cols}, row0: {row0}, row1: {row1}")

        # Find and store the GBSizerItem objects.
        for idx, r in enumerate((row0, row1)):
            old_sizer_items.append([])
            positions.append([])

            for c in range(cols):
                positions[idx].append((r, c))
                old_sizer_items[idx].append(gbs.FindItemAtPosition((r, c)))

        # Remove GBSizerItem in both rows.
        for idx, row in enumerate(old_sizer_items):
            windows.append([])

            for item in row:
                if item:
                    windows[idx].append((item.GetWindow(), item.GetSpan()))
                    gbs.Remove(self.get_sizer_item_index(gbs, item))

        # Add the new GBSizerItem objects into the GridBagSizer.
        for row, rpos in enumerate(reversed(positions)):
            for idx, item in enumerate(windows[row]):
                gbs.Add(item[0], rpos[idx], item[1], flag=flag, border=border)

GridBagSizers are not there for displaying, editing and re-organizing data.
That’s rather a job for a Grid.
See e.g. the GridCustTable and GridDragable demos.

Although I’ve never used a Grid I do know about them. The problem is I have widgets that span more than one column. And I don’t want the screen to look like a spreadsheet. Maybe I just don’t know enough about how Grids work. The examples that I’ve seen in the demo package are not what I’m looking for. Maybe a good example could convince me to rewrite my code to use a Grid.

not GBS but up-to-date dragging :rofl:

import wx

class Gui(wx.Frame):

    def __init__(self, parent):
        super().__init__(parent, title='DnD for newbies')

        self.sizer = wx.WrapSizer()
        for n in range(20):
            self.sizer_add(self.get_btn(self, ''.join(('btn', str(n)))))
        self.SetSizer(self.sizer)
        self.Centre()
        self.Show()

    def evt_motion(self, evt):
        if evt.Dragging() and evt.LeftIsDown():
            obj = evt.GetEventObject()
            if 'src' in vars(self):
                self.cur.Move(
                    (obj.ClientToScreen(evt.GetPosition()) - self.delta))
            else:
                obj.SetBackgroundColour(wx.SystemSettings.GetColour(
                                                    wx.SYS_COLOUR_HIGHLIGHT))
                self.src = obj
                dlg = wx.Dialog(self,
                    pos=self.ClientToScreen(obj.GetPosition()),
                    size=obj.GetSize(),
                    style=wx.DEFAULT_FRAME_STYLE & ~\
                                    (wx.RESIZE_BORDER | wx.CAPTION))
                dlg.SetCursor(wx.Cursor(wx.CURSOR_HAND))
                btn = self.get_btn(dlg, obj.GetLabel())
                btn.Bind(wx.EVT_LEFT_UP, self.evt_left_up)
                dlg.SetTransparent(128)
                dlg.Show()
                self.delta = dlg.ClientToScreen(evt.GetPosition())\
                                                            - dlg.GetPosition()
                self.cur = dlg

    def evt_left_up(self, evt):
        if 'src' in vars(self):
            wx.SetCursor(wx.Cursor())
            x = wx.GetMouseState().GetX()
            y = wx.GetMouseState().GetY()
            objs = []
            for entry in self.sizer.GetChildren():
                obj = entry.GetWindow()
                objs.append(obj)
                if obj != self.cur:
                    rect = obj.GetScreenRect()
                    l = rect[0]
                    r = l + rect[2]
                    t = rect[1]
                    b = t + rect[3]
                    if l < x and x < r and t < y and y < b:
                        t_obj = obj
            if 't_obj' in vars():
                self.sizer.Clear()
                for entry in objs:
                    if entry == self.src:
                        self.sizer_add(t_obj)
                    elif entry == t_obj:
                        self.sizer_add(self.src)
                    else:
                        self.sizer_add(entry)
                self.Layout()
            else:
                wx.Bell()
            self.src.SetBackgroundColour(None)
            del self.src
            self.cur.HideWithEffect(wx.SHOW_EFFECT_EXPAND)
            self.cur.Destroy()
        evt.Skip()

    def get_btn(self, parent, lb):
        btn = wx.Button(parent, label=lb)
        btn.Bind(wx.EVT_MOTION, self.evt_motion)
        return btn

    def sizer_add(self, obj):
        self.sizer.Add(obj, 0, wx.LEFT|wx.TOP|wx.RIGHT, 5)

app = wx.App()
Gui(None)
app.MainLoop()

I tried your code and it didn’t exactly work correctly I think. When I click on one item, another very small window detached from the main window is created. Nothing on the screen actually moves anywhere. I’m using Kubuntu for my development, if you’re using Windows or a Mac that may be why it’s not working for me.

oh, I forgot the usage: grabbing one btn & dropping it on another one will change their position accordingly (I hope) :ghost:

That works but only for the first item I move after that it stops working. It also dumps a bunch of GTK warnings, Negative content height -6 (allocation 8, extents 7x7) while allocating gadget (node button, owner GtkButton
GTK is the back end that wxWidgets uses on Linux.

I guess

self.cur.HideWithEffect(wx.SHOW_EFFECT_EXPAND)

must be taken out :thinking:

DeepObjectList in wxdo does something like that, treating entire rows of a GridBagSizer as an entity that can be moved up and down as a whole.

Highlighting a line is done in the background repaint - binding wx.EVT_ERASE_BACKGROUND, and using RefreshRect when the highlighted line moves. It’s tricky stuff, and I’m not sure I’ve got it entirely right, there’s seems to be some repaint artifacts sometimes. But it works well enough.

The wxdo package seems to be very interesting, but I don’t see how it would help me to swap rows in a GBS. It looks like it would be mostly used to do long-running calls that don’t block the GUI.

I’m working on a solution that I will post if I can get it to work.

The DeepObjectList() in “wxdo” has support for reorganizing items in a GBS. It’s not just a tool for long running tasks. That said, I only read all the code, I didn’t try making an example app with it. Not sure how well it works for this task.

The code in wxdo/deep_object_list.py literally swaps rows in a GBS, when you move an item up or down. I thought you might be able to find inspiration. Or you might be able to use it as is, depending on what your requirements are.

Swaps are achieved by detaching all the controls from the GridBagSizer and then re-adding them in the right order. This is simpler and faster than it sounds. The hard part is getting the RefreshRect’s right, so that you redraw only those things that have actually moved and avoid flicker.

The original code I posted above does that. I forgot to add one small method to it, but it works perfectly. My issue was how to highlight a row in a GBS and then have that fire off an event that I can use to do the swap.

Maybe I’ll add the forgotten method to my original post. Duh, it looks like the system won’t let me update my own code.

This is the missing code:

    def get_sizer_item_index(self, sizer, item):
        """
        Determines the index of an item in a sizer.

        :params sizer: The sizer to search.
        :type sizer: wx.Sizer
        :param item: The item to find.
        :type item: wx.SizerItem
        :returns: The index of the item in the sizer, or -1 if the item
                  is not in the sizer.
        :rtype: int
        """
        index = -1

        for idx, child in enumerate(sizer.GetChildren()):
            if child == item:
                index = idx
                break

        return index

can be shortened (you remember: speed) :wink:

        for idx, child in enumerate(sizer.GetChildren()):
            if child == item:
                return idx
        else:
            return -1

It’s so true, da-dada. I come from C programming in the HPC space, and Python is shockingly slow. It’s a wonderful language, and I love it, but fast it is not. Very important to look for every little bit you can save on.

OK, so it looks like I have it all working.
The code below is 239 lines long a bit much, but it has everything in it. My actual code will be split up between different files.

from itertools import chain
import wx


class GBSRowSwapping:

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def gbs_swap_rows(self, gbs, row0, row1, flag=0, border=0):
        """
        Swap any two rows in a GridBagSizer keeping most parameters.
        """
        rows = gbs.GetRows()
        cols = gbs.GetCols()
        assert row0 != row1, (f"row0 ({row0}) and row1 ({row1}) cannot "
                              "be the same.")
        assert -1 < row0 < rows, ("The row0 value is invalid can only be "
                                  f"between 0 and {rows-1}, found {row0}")
        assert -1 < row1 < rows, ("The row1 value is invalid can only be "
                                  f"between 0 and {rows-1}, found {row1}")
        assert -1 < cols, f"The number of columns must be >= 0, found {cols}."
        # Get GBS positions.
        positions = [[(r, c) for c in range(cols)] for r in (row0, row1)]
        # Remove the GBSizerItem in both rows.
        sizer_items = [[gbs.FindItemAtPosition(rc) for rc in row]
                       for row in positions]
        # Get list of windows (widgets).
        windows = [[(item.GetWindow(), item.GetSpan()) for item in row]
                   for row in sizer_items]
        # Remove GBSizerItem in both rows.
        [gbs.Remove(self.get_sizer_item_index(gbs, item))
         for item in list(chain(*sizer_items)) if item]
        # Add the widgets objects to the GridBagSizer with swapped positions.
        [[gbs.Add(item[0], rpos[idx], item[1], flag=flag, border=border)
          for idx, item in enumerate(windows[row])]
         for row, rpos in enumerate(reversed(positions))]

    def get_sizer_item_index(self, sizer, item):
        """
        Determines the index of an item in a sizer.

        :params sizer: The sizer to search.
        :type sizer: wx.Sizer
        :param item: The item to find.
        :type item: wx.SizerItem
        :returns: The index of the item in the sizer, or -1 if the item
                  is not in the sizer.
        :rtype: int
        """
        index = -1

        for idx, child in enumerate(sizer.GetChildren()):
            if child == item:
                index = idx
                break

        return index

    def highlight_row(self, gbs, row, color=None):
        if row is not None and color:
            cols = gbs.GetCols()
            positions = [(row, c) for c in range(cols)]
            widgets = [gbs.FindItemAtPosition(pos).GetWindow()
                       for pos in positions]
            [w.SetBackgroundColour(color) for w in widgets]


class _ClickPosition:
    """
    A borg pattern to hold new widget type ID.
    """
    _shared_state = {}
    _new_types = {}

    def __init__(self):
        self.__dict__ = self._shared_state

    def get_new_event_type(self, w_name):
        return self._new_types.setdefault(w_name, wx.NewEventType())

    def get_click_position(self, w_name):
        assert w_name in self._new_types, ("The 'get_new_event_type' must "
                                           "be called first.")
        return wx.PyEventBinder(self._new_types[w_name], 1)


class WidgetEvent(wx.PyCommandEvent):
    """
    For some reason wx.PyCommandEvent screws up the use of properties,
    bummer.
    """

    def __init__(self, evt_type, id):
        super().__init__(evt_type, id)
        self.__value = None

    def get_value(self):
        return self.__value

    def set_value(self, value):
        self.__value = value


class EventStaticText(wx.StaticText):
    __type_name = 'event_static_text'
    _cp = _ClickPosition()
    _type_id = _cp.get_new_event_type(__type_name)

    def __init__(self, parent=None, id=wx.ID_ANY, label="",
                 pos=wx.DefaultPosition, size=wx.DefaultSize,
                 style=0, name=wx.StaticTextNameStr):
        super().__init__(parent=parent, id=id, label=label, pos=pos,
                         size=size, style=style, name=name)
        self.Bind(wx.EVT_LEFT_DOWN, self.on_left_down)

    @property
    def new_event_type(self):
        return self._cp.get_new_event_type(self.__type_name)

    @property
    def event_click_position(self):
        return self._cp.get_click_position(self.__type_name)

    def on_left_down(self, event):
        obj = event.GetEventObject()
        sizer = obj.GetContainingSizer()
        pos = None

        if isinstance(sizer, wx.GridBagSizer):
            item = sizer.FindItem(obj)
            pos = item.GetPos()
        elif isinstance(sizer, wx.BoxSizer):
            item = sizer.GetItem(obj)
            pos = item.GetPosition()

        evt = WidgetEvent(self._type_id, self.GetId())
        evt.set_value(pos)
        self.GetEventHandler().ProcessEvent(evt)
        event.Skip()


class MyFrame(GBSRowSwapping, wx.Frame):
    __previous_row = None
    __cl = None

    def __init__(self, parent, *args, **kwargs):
        super().__init__(parent, *args, **kwargs)
        # Create the sizer objects.
        sizer = wx.BoxSizer(wx.VERTICAL)
        self.SetSizer(sizer)

        bg_color = wx.Colour(*(128, 128, 128))
        self.SetBackgroundColour(bg_color)

        button = wx.SpinCtrl(self, wx.ID_ANY, "")
        sizer.Add(button, 0, wx.CENTER | wx.ALL, 0)

        grid_bag_sizer = wx.GridBagSizer()
        sizer.Add(grid_bag_sizer, 0, wx.CENTER | wx.ALL, 6)
        self.Bind(wx.EVT_SPINCTRL,
                  self.swap_rows_closure(grid_bag_sizer, bg_color))

        # Add the widgets to the GridBagSizer.
        self.create_widgets(grid_bag_sizer, button, bg_color)
        button.SetRange(0, grid_bag_sizer.GetRows()-1)

        self.Show()

    def create_widgets(self, gbs, sb, bg_color):
        num = -1

        for idx in range(15):
            dec = idx % 3
            if not dec: num += 1
            label = f"Widget {num}.{dec}"
            widget = EventStaticText(self, -1, label, style=0)
            widget.SetBackgroundColour(bg_color)
            pos, span = (num, dec), (1, 1)
            gbs.Add(widget, pos, span, wx.ALIGN_CENTER | wx.ALL, 6)
            self.Bind(widget.event_click_position,
                      self.test_event_closure(gbs, sb, bg_color),
                      id=widget.GetId())

    def swap_rows_closure(self, gbs, orig_color):
        """
        Event to swap the two rows.
        """
        def swap_rows(event):
            if self.__previous_row is not None:
                row0 = self.__previous_row
                obj = event.GetEventObject()
                row1 = obj.GetValue()
                self.__previous_row = row1

                if row0 != row1:
                    self.stop_call_later()
                    self.gbs_swap_rows(gbs, row0, row1,
                                       wx.ALIGN_CENTER | wx.ALL, 6)
                    self.Layout()
                    self.__cl = wx.CallLater(4000, self.turn_off_highlight,
                                             gbs, orig_color)

        return swap_rows

    def test_event_closure(self, gbs, sb, orig_color=None, color='blue'):
        """
        Event to highlight the GBS row when a widget is clicked.
        """
        def test_event(event):
            self.stop_call_later()
            pos = event.get_value()
            row, col = pos
            self.highlight_row(gbs, self.__previous_row, color=orig_color)
            self.highlight_row(gbs, row, color=color)
            self.__previous_row = row
            sb.SetValue(row)
            self.__cl = wx.CallLater(4000, self.turn_off_highlight,
                                     gbs, orig_color)

        return test_event

    def turn_off_highlight(self, gbs, orig_color):
        for row in range(gbs.GetRows()):
            self.__previous_row = None
            self.highlight_row(gbs, row, color=orig_color)
            self.Layout()

    def stop_call_later(self):
        if self.__cl and self.__cl.IsRunning():
            self.__cl.Stop()
            self.__cl = None


if __name__ == "__main__":
    app = wx.App()
    frame = MyFrame(None, title="Swap Widgets in a GridBagSizer")
    app.MainLoop()

no Borg in my Org :rofl:

  • click row zero

  • spin down → nothing happens

  • spin up / down → spinner goes up / down, but no rows moving

I’m running on Linux and with Python 3.11.5 which BTW is 33% faster than Python 3.10.
The only thing I wish I could do is to attach an event on an actual GBS row instead of overriding the StaticText. It works but not as cleanly as an event on the GBS row would.

@da-dada are you getting any errors?
Are you clicking on any of the widgets or in between widgets–that won’t work–bummer?

BTW, I’ve been using the Borg pattern for years, way back with Python 2.2 I think.

Seems to work here.
Python 3.10.12
wxPython 4.2.1

I’ve found a few bugs related to irregular row lengths. This wasn’t tested in my script above, but my actual code has this, where bugs raise their ugly heads. I’m still working on it.

Is there a way to post a file instead of copy and paste to this blog?