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>>. Изолируйте состояние навигации, сбрасывайте индексы при изменении запроса и показывайте или скрывайте попап в зависимости от введённого значения — так взаимодействие останется чистым и предсказуемым.