Double-click event fires after every successive click in python tkinter

Viewed 1930

I'm writing an Explorer-like application using the tkinter listbox. On a double click, I want to enter the selected folder, so I clear the list and input the folders content.

When I click directly after I double-clicked, it is still considered as a new double-click. Thus, no single-click is performed, meaning no list box entry is selected and a change occurs before I actually double-clicked again.

Is there a way to "reset" the double-click so my program will consider the next click a single-click no matter what I did before?

I tried to use the click event coordinate to get the "double-clicked" entry, but this will fire at the third instead of the fourth click, which is not desired behaviour. I also tried to bind a triple-click to block off a second double click, but then the program won't respond if I click more than 3 times and only react again after a delay.

import tkinter as tk
import random


def fill_box(event):
    """Clear and refresh listbox"""
    try:
        listbox.get(listbox.curselection()[0])
        selection = True
    except IndexError:
        selection = False
    print("Event:", event, "Selection:", selection)
    listbox.delete(0, tk.END)
    for _ in range(10):
        listbox.insert(tk.END, random.randint(0, 1000))


root = tk.Tk()
listbox = tk.Listbox(root)
for _ in range(10):
    listbox.insert(tk.END, random.randint(0, 1000))
listbox.bind("<Double-Button-1>", fill_box)
# listbox.bind("<Triple-Button-1>", lambda x: 1)  # Triple click
listbox.pack()
root.mainloop()

My Expectation is that after I double-clicked an entry, I can immediately interact with the GUI again without having to wait for a double-click cooldown to pass. Also, I do not want to double-click new entries with (relative to the current view) single click.

4 Answers

I would handle it with creating a new class to remember curselection and intercepting rapid clicks:

import tkinter as tk
import random


class MyListbox(tk.Listbox):
    def __init__(self, parent):
        super().__init__(parent)
        self.clicked = None


def fill_box(event):
    """Clear and refresh listbox"""
    try:
        listbox.get(listbox.curselection()[0])
        selection = True
    except IndexError:
        selection = False
        activate()                               # intercept rapid click
        return
    print("Event:", event, "Selection:", selection)
    listbox.clicked = listbox.curselection()[0]  # remember the curselection
    listbox.delete(0, tk.END)
    for _ in range(10):
        listbox.insert(tk.END, random.randint(0, 1000))

def activate():
    listbox.selection_set(listbox.clicked)


root = tk.Tk()

listbox = MyListbox(root)
for _ in range(10):
    listbox.insert(tk.END, random.randint(0, 1000))
listbox.bind("<Double-Button-1>", fill_box)
listbox.pack()

root.mainloop()

As mentioned @Reblochon Masque, activate could be a method of the class. In this case function name should be changed, because listbox has own activate method:

class MyListbox(tk.Listbox):
    def __init__(self, parent):
        super().__init__(parent)
        self.clicked = None

    def activate_clicked(self):
        self.selection_set(listbox.clicked)

And can be called listbox.activate_clicked() istead of activate().

The following takes inspiration from @VladimirShkaberda's answer.

FastClickListbox is a subclass of tk.Listbox that abstracts the logic of handling fast successive double clicks, without lag. This allows the user to focus on the desired actions triggered by a double click, and not worry about the implementation details.

import tkinter as tk
import random


class FastClickListbox(tk.Listbox):
    """a listbox that allows for rapid fire double clicks
    by keeping track of the index last selected, and substituting
    it when the next click happens before the new list is populated

    remembers curselection and intercepts rapid successive double clicks
    """

    def _activate(self, ndx):
        if ndx >= 0:
            self.ACTIVE = ndx
            self.activate(ndx)
            return True
        else:
            self.selection_set(self.ACTIVE)
            self.activate(self.ACTIVE)
            return False

    def _curselection(self):
        ndxs = self.curselection()
        return ndxs if len(ndxs) > 0 else (-1,)

    def is_ready(self):
        """returns True if ready, False otherwise
        """
        return self._activate(listbox._curselection()[0])


# vastly simplified logic on the user side
def clear_and_refresh(dummy_event):
    if listbox.is_ready():
        listbox.delete(0, tk.END)
        for _ in range(random.randint(1, 11)):
            listbox.insert(tk.END, random.randint(0, 1000))


root = tk.Tk()
listbox = FastClickListbox(root)

for _ in range(random.randint(1, 11)):
    listbox.insert(tk.END, random.randint(0, 1000))

listbox.bind("<Double-Button-1>", clear_and_refresh)
listbox.pack()

root.mainloop()

The best way to fix this would be to have a global variable storing the click state. Place this at the start:

dclick = False
def sclick(e):
    global dclick
    dclick = True #Set double click flag to True. If the delay passes, the flag will be reset on the next click

Then, replace the fill_box function with:

    """Clear and refresh listbox"""
    global dclick
    if not dclick: #if clicked before the delay passed
        sclick(event) #treat like a single click
        return #Do nothing else
    else: #if this is an actual double click
        dclick = False #the next double is a single
    try:
        listbox.get(listbox.curselection()[0])
        selection = True
    except IndexError:
        selection = False
    print("Event:", event, "Selection:", selection)
    listbox.delete(0, tk.END)
    for _ in range(10):
        listbox.insert(tk.END, random.randint(0, 1000))

Then, bind the sclick function to a single click. This works because: * If the user double-clicks, the dclick is set to True by the single-click, meaning the second click counts as a double. * If the user then single-clicks, the dclick flag was set back to False, meaning it is treated as a single. * If the user waits out the delay, the first click resets the flag, as it counts as a single.

It's untested but I hope it helps.

While trying to implement the solutions from @Vadim Shkaberda and @Reblochon Masque, I ran into a problem, these solutions worked flawlessly for the example, but apparently I made it too minimal to catch all, because the solutions introduced some new edge cases in my project, e.g. when I refresh the List programmatically.

Though I could apply the idea of suppressing the false double-click (or better: have it invoke the single-click functionality) by making the following edits to the function bound to the double-click:

def double_clicked(event):
    """Check if double-click was genuine, if not, perform single-click function."""
    try:
        current = self.current_val()
    except KeyError:  # False-positive Double-click
        # Simulate single click funktion by marking the currently hovered item
        index = self.listbox.index("@{},{}".format(event.x, event.y))
        self.listbox.select_set(index)
        return
    # If this is reached, a genuine Double-click happened
    functionality()

listbox.bind("<Double-Button-1>", double_clicked)

This works because here, a false double-click can be detected by checking if something is selected, if not, no single-click has happened before.

Related