2025, Oct 01 07:18
Автодополнение на Tkinter Listbox: решение конфликта мыши и стрелок
Как починить автодополнение на Tkinter Listbox: после клика мышью навигация стрелками скачет. Причина и точное решение — привязка к событию ButtonRelease-1.
Собрать всплывающий список автодополнения на базе Listbox из Tkinter несложно — пока не пересекаются действия мыши и клавиатуры. Типичная ловушка проявляется после выбора элемента мышью: при нажатии стрелок выделение сразу уходит на неожиданный пункт, обычно выглядит как прыжок на вторую строку. Ниже — минимальный рабочий пример, который воспроизводит проблему, и затем точное решение.
Воспроизводим поведение
В примере Listbox связан с Entry через StringVar. Стрелки перемещают подсветку, а Enter подтверждает выбор. Клик мышью тоже работает — но после него повторное использование стрелок приводит к неожиданной смене выделения в Listbox.
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)
# Проблемная привязка
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()Что на самом деле происходит
В Tkinter Listbox есть активный элемент и есть выделение. Активный элемент — тот, по которому перемещаются клавишами Up и Down; он визуально подсвечен. Выделение — это то, что пользователь явно выбирает, например кликом мыши. Событие <<ListboxSelect>> срабатывает при каждом изменении выделения. Клик мышью устанавливает выделение и вызывает обработчик. Тонкость в том, что если выделение уже существует, последующие нажатия стрелок меняют именно выделение, а не только активный элемент. В итоге ваш обработчик изменения выделения запускается во время навигации с клавиатуры и неожиданно подтверждает элемент, что выглядит как скачок на другую строку.
Решение
Привяжите обработчик к отпусканию левой кнопки мыши. Событие <ButtonRelease-1> срабатывает только при отпускании ЛКМ и не возникает при перемещении по Listbox стрелками. Выбор мышью по‑прежнему работает, а навигация клавишами перестаёт вызывать обработчик выбора.
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)
# Исправленная привязка
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()Почему это важно
Интерфейсы автодополнения зависят от предсказуемой обработки ввода. Когда события изменения выделения одновременно выступают триггером навигации с клавиатуры, логика переплетается, и пользователи видят непреднамеренные подтверждения или скачки. Разведение намерений действий мышью и перемещения стрелками сохраняет последовательность поведения и предотвращает случайные автодополнения.
Выводы
Если вам нужна выборка мышью в Tkinter Listbox без вмешательства в перемещение по стрелкам, предпочтите привязку к <ButtonRelease-1> вместо <<ListboxSelect>>. Изолируйте состояние навигации, сбрасывайте индексы при изменении запроса и показывайте или скрывайте попап в зависимости от введённого значения — так взаимодействие останется чистым и предсказуемым.
Статья основана на вопросе на StackOverflow от Daniel Louw и ответе от Aadvik.