2025, Oct 01 07:00

Tkinter Listbox Autocomplete: Arrow Keys Jump After Mouse Click? Here's the Simple Fix

Why Tkinter Listbox autocomplete jumps after mouse click, and how to fix it. Bind ButtonRelease-1 instead of <<ListboxSelect>> to keep navigation predictable.

Building an autocomplete popup with Tkinter’s Listbox is straightforward until mouse and keyboard interactions intersect. A common pitfall appears after selecting an item with the mouse: pressing the arrow keys immediately navigates to an unexpected item, typically looking like it jumps to the second row. Below is a minimal, working setup that reproduces the issue and then a precise fix.

Reproducing the behavior

The example wires a Listbox to an Entry via a StringVar. Arrow keys move the highlight and Enter confirms the choice. Mouse selection also works — but after clicking, using the arrows again makes the Listbox change selection in a surprising way.

import tkinter as tk

people_source = ["Daniel", "Ronel", "Dana", "Elani", "Isabel", "Hercules", "Karien", "Amor", "Piet", "Koos", "Jan", "Johan", "Denise", "Jean", "Petri"]
global lb_nav_index


def refresh_lb_candidates(*args):
    global lb_nav_index

    lb_nav_index = -1
    lb_popup.delete(0, tk.END)

    term = query_var.get()

    if term == "":
        lb_popup.place_forget()
        return
    else:
        for name in people_source:
            if term.lower() in name.lower():
                lb_popup.insert(tk.END, name)
        show_lb_below_entry()

    print(f"|refresh_lb_candidates|New dataset applied. Index = {lb_nav_index}")


def show_lb_below_entry():
    x = input_entry.winfo_rootx() - app.winfo_rootx()
    y = input_entry.winfo_rooty() - app.winfo_rooty() + input_entry.winfo_height()
    lb_popup.place(x=x, y=y)
    input_entry.focus_set()


def on_lb_click_pick(event=None):
    idx = lb_popup.curselection()[0]
    val = lb_popup.get(idx)
    print("|on_lb_click_pick|Mouse-picked")
    render_choice_to_label(val)


def render_choice_to_label(text):
    global lb_nav_index

    chosen_label.config(text=text)
    lb_popup.place_forget()
    input_entry.focus_set()
    input_entry.delete(0, tk.END)
    lb_nav_index = -1
    print(f"|render_choice_to_label|Rendered: {text}")


def sync_selection_after_arrows():
    global lb_nav_index

    lb_popup.selection_clear(0, tk.END)
    lb_popup.selection_set(lb_nav_index)
    lb_popup.activate(lb_nav_index)
    print(f"|sync_selection_after_arrows|Now at = {lb_nav_index}")


def on_input_key_release(event):
    global lb_nav_index

    if lb_popup.size() > 0:
        if event.keysym == "Down":
            print(f"|on_input_key_release|Down pressed. Before = {lb_nav_index}")
            lb_nav_index += 1
            if lb_nav_index >= lb_popup.size():
                lb_nav_index = 0
            sync_selection_after_arrows()

        elif event.keysym == "Up":
            print("|on_input_key_release|Up pressed.")
            lb_nav_index -= 1
            if lb_nav_index < 0:
                lb_nav_index = lb_popup.size() - 1
            sync_selection_after_arrows()

        elif event.keysym == "Return":
            print("|on_input_key_release|Return pressed.")
            if 0 <= lb_nav_index < lb_popup.size():
                render_choice_to_label(lb_popup.get(lb_nav_index))

        elif event.keysym == "Escape":
            print("|on_input_key_release|Escape pressed.")
            lb_popup.delete(0, tk.END)
            lb_popup.place_forget()
            input_entry.focus_set()

        else:
            print(f"|on_input_key_release|{event.keysym} pressed.")
            lb_nav_index = -1
    else:
        print(f"|on_input_key_release|{event.keysym} pressed.")
        lb_nav_index = -1

    print(f"|on_input_key_release|query_var=\"{query_var.get()}\" Focus={app.focus_get()} Index={lb_nav_index} curselection={lb_popup.curselection()}")
    print()


app = tk.Tk()
app.title("Listbox appear after typing")
app.geometry("400x200")

query_var = tk.StringVar()
query_var.trace("w", refresh_lb_candidates)

input_entry = tk.Entry(app, textvariable=query_var, width=60)
input_entry.pack(pady=5)
input_entry.focus_set()
input_entry.bind("<KeyRelease>", on_input_key_release)

lb_popup = tk.Listbox(app)
# Problematic binding
lb_popup.bind("<<ListboxSelect>>", on_lb_click_pick)

lb_nav_index = -1

chosen_label = tk.Label(app, text="No option is selected.")
chosen_label.pack(pady=5)

app.mainloop()

What actually goes wrong

In a Tkinter Listbox there is an active item and there is a selection. The active item is the one navigated with the keyboard using Up and Down, and it’s visually highlighted. The selection is what the user explicitly picks, for example with a mouse click. The event <<ListboxSelect>> fires whenever the selection changes. A mouse click sets a selection, which triggers the handler. The subtlety is that if a selection already exists, subsequent arrow key presses adjust the selection rather than only moving the active item. That means your selection-change handler runs during keyboard navigation and commits an item unexpectedly, which looks like a jump straight to a different row.

The fix

Bind the handler to the left mouse button release instead. The <ButtonRelease-1> event fires only when the left mouse button is released, and it does not get triggered when arrow keys move through the Listbox. Mouse picks still work, while arrow navigation stops invoking the pick handler.

import tkinter as tk

people_source = ["Daniel", "Ronel", "Dana", "Elani", "Isabel", "Hercules", "Karien", "Amor", "Piet", "Koos", "Jan", "Johan", "Denise", "Jean", "Petri"]
global lb_nav_index


def refresh_lb_candidates(*args):
    global lb_nav_index

    lb_nav_index = -1
    lb_popup.delete(0, tk.END)

    term = query_var.get()

    if term == "":
        lb_popup.place_forget()
        return
    else:
        for name in people_source:
            if term.lower() in name.lower():
                lb_popup.insert(tk.END, name)
        show_lb_below_entry()

    print(f"|refresh_lb_candidates|New dataset applied. Index = {lb_nav_index}")


def show_lb_below_entry():
    x = input_entry.winfo_rootx() - app.winfo_rootx()
    y = input_entry.winfo_rooty() - app.winfo_rooty() + input_entry.winfo_height()
    lb_popup.place(x=x, y=y)
    input_entry.focus_set()


def on_lb_click_pick(event=None):
    idx = lb_popup.curselection()[0]
    val = lb_popup.get(idx)
    print("|on_lb_click_pick|Mouse-picked")
    render_choice_to_label(val)


def render_choice_to_label(text):
    global lb_nav_index

    chosen_label.config(text=text)
    lb_popup.place_forget()
    input_entry.focus_set()
    input_entry.delete(0, tk.END)
    lb_nav_index = -1
    print(f"|render_choice_to_label|Rendered: {text}")


def sync_selection_after_arrows():
    global lb_nav_index

    lb_popup.selection_clear(0, tk.END)
    lb_popup.selection_set(lb_nav_index)
    lb_popup.activate(lb_nav_index)
    print(f"|sync_selection_after_arrows|Now at = {lb_nav_index}")


def on_input_key_release(event):
    global lb_nav_index

    if lb_popup.size() > 0:
        if event.keysym == "Down":
            print(f"|on_input_key_release|Down pressed. Before = {lb_nav_index}")
            lb_nav_index += 1
            if lb_nav_index >= lb_popup.size():
                lb_nav_index = 0
            sync_selection_after_arrows()

        elif event.keysym == "Up":
            print("|on_input_key_release|Up pressed.")
            lb_nav_index -= 1
            if lb_nav_index < 0:
                lb_nav_index = lb_popup.size() - 1
            sync_selection_after_arrows()

        elif event.keysym == "Return":
            print("|on_input_key_release|Return pressed.")
            if 0 <= lb_nav_index < lb_popup.size():
                render_choice_to_label(lb_popup.get(lb_nav_index))

        elif event.keysym == "Escape":
            print("|on_input_key_release|Escape pressed.")
            lb_popup.delete(0, tk.END)
            lb_popup.place_forget()
            input_entry.focus_set()

        else:
            print(f"|on_input_key_release|{event.keysym} pressed.")
            lb_nav_index = -1
    else:
        print(f"|on_input_key_release|{event.keysym} pressed.")
        lb_nav_index = -1

    print(f"|on_input_key_release|query_var=\"{query_var.get()}\" Focus={app.focus_get()} Index={lb_nav_index} curselection={lb_popup.curselection()}")
    print()


app = tk.Tk()
app.title("Listbox appear after typing")
app.geometry("400x200")

query_var = tk.StringVar()
query_var.trace("w", refresh_lb_candidates)

input_entry = tk.Entry(app, textvariable=query_var, width=60)
input_entry.pack(pady=5)
input_entry.focus_set()
input_entry.bind("<KeyRelease>", on_input_key_release)

lb_popup = tk.Listbox(app)
# Fixed binding
lb_popup.bind("<ButtonRelease-1>", on_lb_click_pick)

lb_nav_index = -1

chosen_label = tk.Label(app, text="No option is selected.")
chosen_label.pack(pady=5)

app.mainloop()

Why this matters

Autocomplete UIs depend on predictable input handling. When selection-change events double as keyboard navigation triggers, control flow gets tangled and users see unintended confirmations or jumps. Separating the intent of mouse actions from arrow navigation keeps behavior consistent and avoids accidental autocompletes.

Takeaways

When you need mouse-driven selection in a Tkinter Listbox without interfering with keyboard traversal, prefer binding to <ButtonRelease-1> instead of <<ListboxSelect>>. Keep navigation state isolated, reset indices when the query changes, and show or hide the popup based on the input value to maintain a clean, predictable experience.

The article is based on a question from StackOverflow by Daniel Louw and an answer by Aadvik.