Processing multi-select in wx.ListCtrl

I’m using a one-column (report view) list control to present a list of items. I want the user to be able to select multiple items. I’ve noticed that using CTRL-Click or SHIFT-Click triggers one wx.EVT_LIST_ITEM_SELECTED event for each item selected. This is not a problem for single item selection, however, on multi-select, for example, selecting items 3-9, it means that the event is triggered seven times resulting in the processing of items

3
3,4
3,4,5
3,4,5,6
3,4,5,6,7
3,4,5,6,7,8
3,4,5,6,7,8,9

For obvious reasons this is not desirable. Because there is no way to determine when the user is doing a multiple selection I don’t see how this can be easily managed. This must be a frequent concern so I imagine there is a solution. Rather than re-inventing the wheel perhaps someone can suggest an approach. Here is some code to demonstrate the problem.

import wx

class MyApp(wx.App):

    def OnInit(self):
        frame = Frame()
        return True

class Frame(wx.Frame):

    def __init__(self,parent=None,id=-1):

        wx.Frame.__init__(self, None, -1)

        self.panel = Panel(self)
        self.SetSize(200, 400)      
        self.Show()
        

class Panel(wx.Panel):

    def __init__(self, parent):

        wx.Panel.__init__(self, parent, -1)
        
        self.list = wx.ListCtrl(self, wx.ID_ANY, style=wx.LC_NO_HEADER | wx.LC_REPORT)
        self.list.Bind(wx.EVT_LIST_ITEM_SELECTED, self.OnClick)
        self.list.AppendColumn('',width=200)

        names = 'one two three four five six seven eight nine ten eleven twelve'.split()

        for indx,name in enumerate(names):
            self.list.InsertItem(indx,name)

        sizer = wx.BoxSizer(wx.VERTICAL)
        sizer.Add(self.list, 1, wx.ALIGN_LEFT | wx.EXPAND, 0)
        self.SetSizer(sizer)          
        
    def OnClick(self, evt):

        lst = evt.GetEventObject()

        print('\nCount=',lst.GetSelectedItemCount())
        indx = lst.GetFirstSelected()

        while indx != -1:
            name = lst.GetItemText(indx)
            print(f'{indx=} {name=}')
            indx = lst.GetNextSelected(indx)            
        
if __name__ == '__main__':
    app = wx.App(False)
    frame = Frame()
    app.MainLoop()

try (and watch self.sidx)
multiselect - Copy.py (1.4 KB)

That exhibits exactly the same behaviour as my original code except with another variable. I’d be happy if the event was triggered only on each click (like if I click the first item, then shift-click the last). The problem is that it is getting triggered once for every item in the selected range.

Well, the difference is that the list object has a state (like most objects, in OO even stateless is a state) and after the user has selected the lines another event must be triggered what to do with those lines (change the binder to EVT_LIST_ITEM_FOCUSED and there is less looping)
But I suppose you are looking for a behaviour like the file explorer of Windows has: selecting by hovering the cursor over the line; I have only found that for a window but not for an item of the listctrl (there is still the check box, so the two step behaviour is well entrenched)
multiselect - Copy.py (1.4 KB)

You could start a short single-shot timer (like a quarter second or something) when the EVT_LIST_ITEM_SELECTED event happens. If it happens again before the timer expires then reset the timer. Then in the timer handler loop through the selected items to get your list of indexes and do whatever you need with them.

So basically your processing of the selected items will be delayed a little bit from when the last item was selected, but short enough that the user probably won’t notice. And you’ll only need to process the selected group when the multi-selection is done.

Knock on wood! :joy:

a somewhat more standard solution: select & context

multiselect_1.py (1.8 KB)

You’re not the first one to come across this problem. See https://trac.wxwidgets.org/ticket/4541.

I ended up with a timer-based workaround exactly as Robin describes it.

Looks like the timer solution is the closest to what I want. Thanks. Conceptually I still think it is wrong to fire the event once for each selection on a shift-click but perhaps there are reasons why this is not the approach that was taken.

if you don’t mix shift/ctrl then kindly have a look at

multiselect_su.py (1.6 KB)

That’s an interesting approach. I think I’ll play around with it a bit. Thanks.

and if you want to start synchronizing this selecting/processing a bit then just throw in an EventBlocker

multiselect_su_b.py (2.0 KB)

and if the blocking takes too long, just thread it: a fast non-workaround…

multiselect_su_b_t.py (2.5 KB)

I think the last approach makes something that should be simple way too complex to be used although as an exercise it is interesting. A little explanation of how the code works would be useful. I’ve used threading but not blocking, and have no idea what’s going on.

Well, you didn’t say much about what you actually are going to achieve except that it be ‘rapid’ and since technology changes and software must adapt there are bound to be some bumps along the way (at screen selection there is sensitive, hyper sensitive and touch-tap-swipe etc)
here is a somewhat more robust version

multiselect_su_b_t.py (2.6 KB)

Staying on the ‘rapid’ path I don’t think there is any complexity so far, unless we are talking about the selection of classes you seem to like (which is, honestly, not quite as I would have done it):
fast selection means accurate GUI input and the processing has to be done in the background (maybe some plausies in-between)
so you may imagine real programming starts at the background (error handling, bookings etc) and threading was just meant for demo purpose)

(programming the error makes more fun than the boring rest)

What I was trying to achieve was in effect “one click generates one event.” With the scenario:

  • user clicks on item 3
  • user shift-clicks on item 7

it is reasonable to expect that step 1 will result in the selection and processing of item 3. After all there is no way to anticipate an upcoming extended selection. It is also reasonable to expect that step 2 will result in the selection and processing of items 3-7. Instead, step 2 results in the selection and processing (each by a separate event) of item 3, then items 3-4, then 3-5, then 3-6, then 3-7. I think Alan Cooper (The Essentials of User Interface Design) would agree that this is counter-intuitive and therefore wrong.

Yes, this could be alleviated by a two step process

  • select all desired items
  • right-click context menu to execute desired processing on selected items

but this then requires two steps to process a single selection.

Perhaps an example would help. Imagine you have a large number of photo images. Several thousand, in fact. The images are named as “base name ####.jpg”. You want to show only the images selected from a list of base names. You may want to show more than one set at a time. Clicking on one base name shows that set and hides all others. CTRL-clicking a base name skips the “hide previous selection” step. I have tried checking for CTRL and/or SHIFT down in the event handler but this typically causes the system to think the key is permanently in the pressed state, even outside the application. If it were not for this sticky behaviour it would be trivial to get the items processed the way that I want.

Well, I don’t know Alan Cooper (although I’m somewhat in front of him, age wise) and wx is doing exactly what you assume he fancies. But selecting is one thing and processing another (maybe the Chinese will change that: camera, brainprint etc)
try your procedure with this (too many printouts gives too many headaches)

multiselect_su_b_t_ac.py (2.6 KB)

The last code is doing what you described but I doubt it’s what you expected. So let’s throw in the breaks from the dungeons of the ivory tower in order to unify the racer with the purist (no AI so far)

multiselect_su_b_t_ac.py (3.2 KB)
and the DIY version
multiselect_su_b_t_ac_diy.py (2.9 KB)

if you test it carefully (and I hope no bugs pending) then you will see that there is only a difference on the single select: if the racer taps quickly CTRL he’ll live up to his name
(by the way, subclassing without customizing doesn’t make all that much sense unless the machine is too fast) :wink:

I think this is the simplest solution. It does not require timers, threads, or a context menu. I maintain the state variable self.extended which tracks whether an extended election is in progress (shift or control pressed). If True then an OnClick merely updates self.selected to include and new selections. If we transit from extended to non-extended then the OnClick event is called to process the selected items. I may have to play with it a bit to allow removal (ctrl-click) or previously selected items but this is 99%

import wx

class MyApp(wx.App):

    def OnInit(self):
        frame = Frame()
        return True


class Frame(wx.Frame):

    def __init__(self,parent=None,id=-1):

        wx.Frame.__init__(self, None, -1)

        self.panel = Panel(self)
        self.SetSize(200, 400)      
        self.Show()
        self.event = None
        
       
class Panel(wx.Panel):

    def __init__(self, parent):

        wx.Panel.__init__(self, parent, -1)

        self.extended = False
        self.selected = set()
        
        self.list = wx.ListCtrl(self, wx.ID_ANY, style=wx.LC_NO_HEADER | wx.LC_REPORT)
        self.list.Bind(wx.EVT_LIST_ITEM_SELECTED, self.OnClick)
        self.list.AppendColumn('',width=200)

        self.list.Bind(wx.EVT_KEY_DOWN, self.KeyDown)
        self.list.Bind(wx.EVT_KEY_UP,   self.KeyUp)

        names = 'one two three four five six seven eight nine ten eleven twelve'.split()

        for indx,name in enumerate(names):
            self.list.InsertItem(indx,name)

        sizer = wx.BoxSizer(wx.VERTICAL)
        sizer.Add(self.list, 1, wx.ALIGN_LEFT | wx.EXPAND, 0)
        self.SetSizer(sizer)          

    def KeyDown(self, event):
        self.extended = event.ControlDown() or event.ShiftDown()
        event.Skip()

    def KeyUp(self, event):
        self.extended = event.ControlDown() or event.ShiftDown()

        if self.selected and not self.extended:
            self.OnClick(None)
        event.Skip()
        
    def OnClick(self, event):

        if event is None:
            print("process",self.selected)
            self.selected.clear()
            return
        
        if self.extended:   
            indx = -1
            while (indx := self.list.GetNextSelected(indx)) != -1:
                self.selected.add(indx)
        else:
            self.selected.clear()
            self.selected.add(self.list.GetFirstSelected())
            self.OnClick(None)        

       
if __name__ == '__main__':
    app = wx.App(False)
    frame = Frame()
    app.MainLoop()

OK. I think this is 100%

import wx

class MyApp(wx.App):

    def OnInit(self):
        frame = Frame()
        return True


class Frame(wx.Frame):

    def __init__(self,parent=None,id=-1):

        wx.Frame.__init__(self, None, -1)

        self.panel = Panel(self)
        self.SetSize(200, 400)      
        self.Show()
        self.event = None
        
       
class Panel(wx.Panel):

    def __init__(self, parent):

        wx.Panel.__init__(self, parent, -1)

        self.extended = False
        
        self.list = wx.ListCtrl(self, wx.ID_ANY, style=wx.LC_NO_HEADER | wx.LC_REPORT)
        self.list.Bind(wx.EVT_LIST_ITEM_SELECTED, self.OnClick)
        self.list.AppendColumn('',width=200)

        self.list.Bind(wx.EVT_KEY_DOWN, self.KeyDown)
        self.list.Bind(wx.EVT_KEY_UP,   self.KeyUp)

        names = 'zero one two three four five six seven eight nine ten eleven twelve'.split()

        for indx,name in enumerate(names):
            self.list.InsertItem(indx,name)

        sizer = wx.BoxSizer(wx.VERTICAL)
        sizer.Add(self.list, 1, wx.ALIGN_LEFT | wx.EXPAND, 0)
        self.SetSizer(sizer)          

    def KeyDown(self, event):
        self.extended = event.ControlDown() or event.ShiftDown()
        event.Skip()

    def KeyUp(self, event):
        self.extended = event.ControlDown() or event.ShiftDown()

        if self.list.GetSelectedItemCount() and not self.extended:
            self.OnClick(None)
        event.Skip()
        
    def OnClick(self, event):

        if event is None:
            indx = -1
            while (indx := self.list.GetNextSelected(indx)) != -1:
                print(f'process {indx=}')
            return

        if not self.extended:       
            self.OnClick(None)        

       
if __name__ == '__main__':
    app = wx.App(False)
    frame = Frame()
    app.MainLoop()