Drag and Scroll

Hello all
I’ve created a script, which shows a list of buttons inside a ScrolledWindow. It allows to move a button to a different position within this list. A button can moved if the left mouse button is hold:


import wx


class MyFrame(wx.Frame):

    def __init__(self, parent, title):
        super(MyFrame, self).__init__(parent, title=title, size=(300, 400))
        
        self.dragged_button = None
        self.scroll_pos_y = 0
        self.label_list = ["RED (1)", 
                           "BLUE (2)", 
                           "GREEN (3)", 
                           "WHITE (4)", 
                           "BLACK (5)", 
                           "YELLOW (6)", 
                           "VIOLETT (7)", 
                           "GREY (8)",
                           "LEMMON (9)",
                           "ORANGE (10",
                           "LIGHT BLUE (11)",
                           "DARK BLUE (12)",
                           "DARK GREY (13)",
                           "GOLD (14)",
                           "SILVER (15)",
                           "VIOLETT (16)", 
                           "GREY (17)",
                           "LEMMON (18)",
                           "ORANGE (19",
                           "LIGHT BLUE (20)",
                           "DARK BLUE (21)",
                           "DARK GREY (22)",
                           "GOLD (23)",
                           "SILVER (24)",
                           ]
        self.button_list = []
        
        self.scroll_window = wx.ScrolledWindow(self)
        self.scroll_window.SetScrollRate(0, 10)
        
        self.sw_sizer = wx.BoxSizer(wx.VERTICAL)
        self.scroll_window.SetSizerAndFit(self.sw_sizer)
        
        for idx, lbl in enumerate(self.label_list):
            self.add_button(lbl)
            self.sw_sizer.Add(self.button_list[idx])


    def add_button(self, lbl):
        button = wx.Button(self.scroll_window, label=lbl)
        self.button_list.append(button)
        button.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown)
        button.Bind(wx.EVT_MOTION,    self.OnMotion)
        button.Bind(wx.EVT_LEFT_UP,   self.OnLeftUp)



    def OnLeftDown(self, event):
        touched = event.GetEventObject()
        self.last_button = self.button_list[-1]
        self.sw_sizer.Hide(self.last_button)
        self.last_button.SetLabel(touched.GetLabel())
        self.last_button.Show()
        ix = 0
        for button in self.button_list:
            if button == touched:
                ix += 1
            if ix == len(self.label_list):
                break
            button.SetLabel(self.label_list[ix])
            ix += 1
        self.OnMotion(event)


    def OnMotion(self, event):
        if event.LeftIsDown():
            self.last_button.SetPosition(wx.GetMousePosition() - (30, 80))
            self.move_to = ((self.scroll_window.CalcUnscrolledPosition(wx.GetMousePosition())[1] - 60)  // 
                self.button_list[0].GetSize()[1])
 
            limit = (self.scroll_window.GetScrollLines(wx.VSCROLL) - 
                self.scroll_window.GetScrollPixelsPerUnit()[1])
            step = 0.5

            # moving the list with buttons if dragged button reaches top / bottom area of window 
            if wx.GetMousePosition()[1] > self.scroll_window.GetSize()[1] + 40:
                self.scroll_pos_y = self.scroll_pos_y + step if self.scroll_pos_y < limit else limit
                self.scroll_window.Scroll(-1, int(self.scroll_pos_y))

            if wx.GetMousePosition()[1] < 90:
                self.scroll_pos_y = self.scroll_pos_y - step if self.scroll_pos_y > 0 else 0
                self.scroll_window.Scroll(-1, int(self.scroll_pos_y))


    def OnLeftUp(self, event):
        self.label_list.remove(self.last_button.GetLabel())
        self.label_list.insert(self.move_to, self.last_button.GetLabel())
        for ix, button in enumerate(self.button_list):
            button.SetLabel(self.label_list[ix])
        self.sw_sizer.Layout()
        self.sw_sizer.FitInside(self.scroll_window)
        self.scroll_pos_y = self.scroll_window.GetViewStart()[1]
        

app = wx.App()
frame = MyFrame(None, "Draggable Buttons")
frame.Show()
app.MainLoop()

Now moving and repositioning a button works fine.
If a button is moved to the top / end of the window, the content scrolls as well, but - and that’s my problem - only, if the mouse is moved. That’s normal, since the scrolling routine is part of the wx.EVT_MOTION handlder (method “OnMotion”).
Is it possible to allow a continuous scrolling (once the mouse is inside the top / botton area) without the need to move the mouse in this area?

Many thanks for your ideas!

One possibility would be to use a timer to scroll the window when the left button is down, but the mouse is not moving.

I have modified your code to demonstrate the idea by starting the timer at the end of OnLeftDown() and stopping it at the start of OnLeftUp(). I also stop and restart it in OnMotion().

For this simple demonstration I have simply refactored your code from OnMotion() into a new method dragButton() and I call this method from both OnMotion() and OnTimer()

It sort of works, but the button flickers badly when moved by OnTimer() so needs some refining.

import wx

TIMER_INTERVAL = 50

class MyFrame(wx.Frame):

    def __init__(self, parent, title):
        super(MyFrame, self).__init__(parent, title=title, size=(300, 400))

        self.dragged_button = None
        self.scroll_pos_y = 0
        self.label_list = ["RED (1)",
                           "BLUE (2)",
                           "GREEN (3)",
                           "WHITE (4)",
                           "BLACK (5)",
                           "YELLOW (6)",
                           "VIOLETT (7)",
                           "GREY (8)",
                           "LEMMON (9)",
                           "ORANGE (10",
                           "LIGHT BLUE (11)",
                           "DARK BLUE (12)",
                           "DARK GREY (13)",
                           "GOLD (14)",
                           "SILVER (15)",
                           "VIOLETT (16)",
                           "GREY (17)",
                           "LEMMON (18)",
                           "ORANGE (19",
                           "LIGHT BLUE (20)",
                           "DARK BLUE (21)",
                           "DARK GREY (22)",
                           "GOLD (23)",
                           "SILVER (24)",
                           ]
        self.button_list = []

        self.scroll_window = wx.ScrolledWindow(self)
        self.scroll_window.SetScrollRate(0, 10)

        self.sw_sizer = wx.BoxSizer(wx.VERTICAL)
        self.scroll_window.SetSizerAndFit(self.sw_sizer)

        for idx, lbl in enumerate(self.label_list):
            self.add_button(lbl)
            self.sw_sizer.Add(self.button_list[idx])

        self.timer = wx.Timer(self)
        self.Bind(wx.EVT_TIMER, self.OnTimer)


    def add_button(self, lbl):
        button = wx.Button(self.scroll_window, label=lbl)
        self.button_list.append(button)
        button.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown)
        button.Bind(wx.EVT_MOTION, self.OnMotion)
        button.Bind(wx.EVT_LEFT_UP, self.OnLeftUp)


    def dragButton(self):
        self.last_button.SetPosition(wx.GetMousePosition() - (30, 80))
        self.move_to = ((self.scroll_window.CalcUnscrolledPosition(wx.GetMousePosition())[1] - 60) //
                        self.button_list[0].GetSize()[1])
        limit = (self.scroll_window.GetScrollLines(wx.VSCROLL) -
                 self.scroll_window.GetScrollPixelsPerUnit()[1])
        step = 0.5
        # moving the list with buttons if dragged button reaches top / bottom area of window
        if wx.GetMousePosition()[1] > self.scroll_window.GetSize()[1] + 40:
            self.scroll_pos_y = self.scroll_pos_y + step if self.scroll_pos_y < limit else limit
            self.scroll_window.Scroll(-1, int(self.scroll_pos_y))
        if wx.GetMousePosition()[1] < 90:
            self.scroll_pos_y = self.scroll_pos_y - step if self.scroll_pos_y > 0 else 0
            self.scroll_window.Scroll(-1, int(self.scroll_pos_y))


    def OnLeftDown(self, event):
        touched = event.GetEventObject()
        self.last_button = self.button_list[-1]
        self.sw_sizer.Hide(self.last_button)
        self.last_button.SetLabel(touched.GetLabel())
        self.last_button.Show()
        ix = 0
        for button in self.button_list:
            if button == touched:
                ix += 1
            if ix == len(self.label_list):
                break
            button.SetLabel(self.label_list[ix])
            ix += 1
        self.OnMotion(event)

        self.timer.Start(TIMER_INTERVAL)


    def OnMotion(self, event):
        if event.LeftIsDown():
            self.timer.Stop()
            self.dragButton()
            self.timer.Start(TIMER_INTERVAL)


    def OnLeftUp(self, event):
        self.timer.Stop()
        self.label_list.remove(self.last_button.GetLabel())
        self.label_list.insert(self.move_to, self.last_button.GetLabel())
        for ix, button in enumerate(self.button_list):
            button.SetLabel(self.label_list[ix])
        self.sw_sizer.Layout()
        self.sw_sizer.FitInside(self.scroll_window)
        self.scroll_pos_y = self.scroll_window.GetViewStart()[1]


    def OnTimer(self, event):
        self.dragButton()


app = wx.App()
frame = MyFrame(None, "Draggable Buttons")
frame.Show()
app.MainLoop()

I am using wxPython 4.2.1 gtk3 (phoenix) wxWidgets 3.2.4 + Python 3.12.3 + Linux Mint 22

The previous version doesn’t convert mouse positions from screen to client coords, so if the frame is not at the top-left corner of the screen, the button being dragged will be outside the scrolled window and thus invisible.

Also, the technique of relabelling the buttons means that sometimes a long label will get cropped when placed on a shorter button.

The following version does convert to client coords. It also detaches the button being dragged from the sizer and then inserts it in the new position in the sizer.

I haven’t fixed the flickering of the dragged button yet.

I have also noticed that if the scrollbar has been moved down and you then move the mouse pointer out of the frame (without pressing a mouse button), when you move the pointer back into the frame the scrollbar will suddenly jump to a new position (mainly the top, but sometimes about halfway). I don’t know why it’s doing that.

import wx

TIMER_INTERVAL = 25

class MyFrame(wx.Frame):

    def __init__(self, parent, title):
        super(MyFrame, self).__init__(parent, title=title, size=(300, 400))

        self.dragged_button = None
        self.scroll_pos_y = 0

        self.label_list = ["RED (1)",
                           "BLUE (2)",
                           "GREEN (3)",
                           "WHITE (4)",
                           "BLACK (5)",
                           "YELLOW (6)",
                           "VIOLETT (7)",
                           "GREY (8)",
                           "LEMMON (9)",
                           "ORANGE (10",
                           "LIGHT BLUE (11)",
                           "DARK BLUE (12)",
                           "DARK GREY (13)",
                           "GOLD (14)",
                           "SILVER (15)",
                           "VIOLETT (16)",
                           "GREY (17)",
                           "LEMMON (18)",
                           "ORANGE (19",
                           "LIGHT BLUE (20)",
                           "DARK BLUE (21)",
                           "DARK GREY (22)",
                           "GOLD (23)",
                           "SILVER (24)",
                           ]
        self.num_buttons = len(self.label_list)
        self.button_list = []

        self.scroll_window = wx.ScrolledWindow(self)
        self.scroll_window.SetScrollRate(0, 10)

        self.sw_sizer = wx.BoxSizer(wx.VERTICAL)
        self.scroll_window.SetSizerAndFit(self.sw_sizer)

        for idx, lbl in enumerate(self.label_list):
            self.add_button(lbl)
            self.sw_sizer.Add(self.button_list[idx])

        self.button_height = self.button_list[0].GetSize()[1]
        self.button_offset = self.button_height // 2

        # print(self.sw_sizer.GetChildren()[0].GetWindow().GetLabel())

        self.timer = wx.Timer(self)
        self.Bind(wx.EVT_TIMER, self.OnTimer)


    def add_button(self, lbl):
        button = wx.Button(self.scroll_window, label=lbl)
        self.button_list.append(button)
        button.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown)
        button.Bind(wx.EVT_MOTION, self.OnMotion)
        button.Bind(wx.EVT_LEFT_UP, self.OnLeftUp)


    def dragButton(self, step):
        rel_mouse_pos = self.scroll_window.ScreenToClient(wx.GetMousePosition())
        self.dragged_button.SetPosition(rel_mouse_pos + (60, -self.button_offset))
        self.move_to = self.scroll_window.CalcUnscrolledPosition(rel_mouse_pos)[1] // self.button_height
        self.move_to = min(max(self.move_to, 0), self.num_buttons)

        limit = (self.scroll_window.GetScrollLines(wx.VSCROLL) -
                 self.scroll_window.GetScrollPixelsPerUnit()[1])
        if rel_mouse_pos[1] > self.scroll_window.GetSize()[1] - 90:
            self.scroll_pos_y = self.scroll_pos_y + step if self.scroll_pos_y < limit else limit
            self.scroll_window.Scroll(-1, int(self.scroll_pos_y))
        if rel_mouse_pos[1] < 90:
            self.scroll_pos_y = self.scroll_pos_y - step if self.scroll_pos_y > 0 else 0
            self.scroll_window.Scroll(-1, int(self.scroll_pos_y))


    def OnLeftDown(self, event):
        self.dragged_button = event.GetEventObject()
        self.dragged_button.SetFocus()
        self.sw_sizer.Detach(self.dragged_button)
        self.button_list.remove(self.dragged_button)
        self.scroll_window.Layout()
        self.OnMotion(event)
        self.timer.Start(TIMER_INTERVAL)


    def OnMotion(self, event):
        if event.LeftIsDown():
            self.timer.Stop()
            self.dragButton(0.5)
            self.timer.Start(TIMER_INTERVAL)


    def OnLeftUp(self, event):
        self.timer.Stop()
        self.button_list.insert(self.move_to, self.dragged_button)
        self.sw_sizer.Insert(self.move_to, self.dragged_button)
        self.scroll_window.Layout()
        self.scroll_pos_y = self.scroll_window.GetViewStart()[1]


    def OnTimer(self, event):
        self.dragButton(0.1)


app = wx.App()
frame = MyFrame(None, "Draggable Buttons")
frame.Show()
app.MainLoop()

Hi RichardT

Many thanks for sharing your ideas on this topic.

Let me first share my environemt:
wx.Python: ‘4.2.1 gtk3 (phoenix) wxWidgets 3.2.4’
Linux: Linux manjaro-mh 5.15.164-1-MANJARO #1 SMP PREEMPT Sat Jul 27 13:08:43 UTC 2024 x86_64 GNU/Linux

I actually did not see in my environment your first point - using your first script I’ve not “lost” the dragged button at any position of the frame on the screen.

Your second point (incorrect button size if label text changes) I could address with:

button.SetSize(-1, -1, -1, -1, wx.SIZE_AUTO)

This autosizes the button, once a new label is set.

I also see the flickering of the dragged button. This seems to occur only, if the mouse is moved during scrolling. If you start scrolling and don’t move the mouse - there is no flickering.

I’ve also observed your last point (jumping of the scrollbar to the top / to another position), if the mouse leaves and enters the frame. This happens also, if you resize the frame.

Last point:
I used re-labelling in my script, since using the clicked button directly for dragging will have the effect, that the dragged button will get hidden by the buttons under him. The dragged button will stay visible for all buttons above. The reason for that is, that only the last added element will stay visible, moving it over all other elements.
Therefore I detect the selected button and re-label the last button with it’s label. Then I use the last button for moving - to ensure, that this button stays visible. Here my version using your suggestings while still doing re-labeling:
drag_labelchange.py (4.4 KB)

Some more observations on “jumping scrollbar”:

  • wx.Scrolled has a method “StopAutoScrolling)”
    Adding

self.scroll_window.StopAutoScrolling()
will not move the scrollbar position if the mouse leaves and enters the frame - but only, if no other application gets focussed. As soon as another application gets focussed, the scrollbar will move again to the top position.

Using the wx.EVT_LEAVE_WINDOW and wx.EVT_ENTER_WINDOW and corresponding handlers:

        ...
        self.Bind(wx.EVT_LEAVE_WINDOW, self.OnLeave)
        self.Bind(wx.EVT_ENTER_WINDOW, self.OnEnter)

    def OnLeave(self, event):
        self.scroll_pos_y = self.scroll_window.GetViewStart()[1]


    def OnEnter(self, event):
        self.scroll_window.Scroll(-1, int(self.scroll_pos_y))

has the same effect as using:

self.scroll_window.StopAutoScrolling()

Both codes would at least stop “scrollbar jumping” within the application itsefldrag_labelchange_no_jump.py (4.5 KB)