2025, Dec 08 18:03

Фокус в Tkinter: почему каретка Text скрывается после выбора в Listbox и как это исправить

Почему в Tkinter после выбора в Listbox каретка в Text не появляется: тайминг VirtualEvent, управление фокусом и решения через after, FocusIn и ButtonRelease.

Фокус в Tkinter и тайминг VirtualEvent: почему курсор в Text скрывается после выбора в Listbox

Когда в Tkinter выбираешь элемент Listbox, хочется сразу перенести каретку ввода в виджет Text и продолжить работу там. Но есть нюанс: каретка часто не появляется до тех пор, пока вы не нажмёте Tab. С Button такого не происходит, что делает поведение ещё загадочнее. Давайте воспроизведём ситуацию и разберём практические решения.

Как воспроизвести проблему

Фрагмент ниже привязывается к выбору в Listbox и пытается передать фокус виджету Text, поставить каретку и пролистать к ней. Каретка сразу не будет видна.

import tkinter as tk
jump_points = ['2.5', '5.10', '8.15']
def handle_pick(evt):
    idx = lbox.curselection()[0]
    where = jump_points[idx]
    editor.focus_set()
    editor.mark_set('insert', where)
    editor.see(where)
    editor.event_generate('<FocusIn>')
app = tk.Tk()
lbox = tk.Listbox(app)
lbox.pack(side=tk.LEFT, padx=10, pady=10)
for p in jump_points:
    lbox.insert('end', f'Go to {p} in the text')
lbox.bind('<<ListboxSelect>>', handle_pick)
editor = tk.Text(app, height=15, width=50)
editor.pack(side=tk.RIGHT, padx=10, pady=10)
text_blob = ''
for n in range(9):
    text_blob += f"{n+1}:234567890123456789\n"
editor.insert('1.0', text_blob)
app.mainloop()

Что происходит

Используемое событие, <<ListboxSelect>>, — это VirtualEvent. Оно может вести себя иначе, чем прямые биндинги вроде <ButtonRelease> или параметр command=. Практический эффект — во времени: ваш колбэк может выполниться до того, как Tkinter окончательно зафиксирует фокус на Listbox. Даже если внутри обработчика вы направляете фокус в Text, затем Tkinter может вернуть его обратно в Listbox — поэтому каретка становится видимой только после нажатия Tab.

Есть и другая тонкость. Нажатие Shift со стрелками может заново запустить логику выбора, но в этот момент curselection() может быть пустым, что приведёт к IndexError при обращении к [0]. Добавьте проверку на пустой выбор — и проблема исчезнет.

Рабочие решения

Надёжный подход — отложить смену фокуса до момента, когда Tkinter завершит собственные операции с фокусом. Планирование focus_set() через after() аккуратно решает задачу.

import tkinter as tk
jump_points = ['2.5', '5.10', '8.15']
def handle_pick(evt):
    sel = lbox.curselection()
    if not sel:
        return
    mark = jump_points[sel[0]]
    editor.mark_set('insert', mark)
    editor.see(mark)
    root.after(100, focus_editor)
def focus_editor():
    editor.focus_set()
root = tk.Tk()
lbox = tk.Listbox(root)
lbox.pack(side='left', padx=10, pady=10)
for point in jump_points:
    lbox.insert('end', f'Go to {point} in the text')
lbox.bind('<<ListboxSelect>>', handle_pick)
editor = tk.Text(root, height=15, width=50)
editor.pack(side='right', padx=10, pady=10)
content = ''
for i in range(1, 10):
    content += f"{i}:234567890123456789\n"
editor.insert('1.0', content)
root.mainloop()

Альтернатива — слушать <FocusIn> у Listbox и сразу перенаправлять фокус в Text. Это вписывается в порядок действий фреймворка: обработчик срабатывает после того, как Listbox формально получил фокус.

import tkinter as tk
jump_points = ['2.5', '5.10', '8.15']
def move_caret(evt):
    sel = lbox.curselection()
    if not sel:
        return
    loc = jump_points[sel[0]]
    editor.mark_set('insert', loc)
    editor.see(loc)
def redirect_focus(evt):
    editor.focus_set()
root = tk.Tk()
lbox = tk.Listbox(root)
lbox.pack(side='left', padx=10, pady=10)
for pt in jump_points:
    lbox.insert('end', f'Go to {pt} in the text')
lbox.bind('<<ListboxSelect>>', move_caret)
lbox.bind('<FocusIn>', redirect_focus)
editor = tk.Text(root, height=15, width=50)
editor.pack(side='right', padx=10, pady=10)
blob = ''
for n in range(1, 10):
    blob += f"{n}:234567890123456789\n"
editor.insert('1.0', blob)
root.mainloop()

Если хотите вовсе обойтись без VirtualEvent, привяжитесь к событию отпускания кнопки мыши. Замена <<ListboxSelect>> на <ButtonRelease> или <ButtonRelease-1> — простой способ гарантировать, что к моменту запуска вашего обработчика состояние Listbox уже стабилизировалось.

import tkinter as tk
jump_points = ['2.5', '5.10', '8.15']
def pick_on_release(evt):
    sel = lbox.curselection()
    if not sel:
        return
    pos = jump_points[sel[0]]
    editor.focus_set()
    editor.mark_set('insert', pos)
    editor.see(pos)
root = tk.Tk()
lbox = tk.Listbox(root)
lbox.pack(side='left', padx=10, pady=10)
for p in jump_points:
    lbox.insert('end', f'Go to {p} in the text')
lbox.bind('<ButtonRelease-1>', pick_on_release)
editor = tk.Text(root, height=15, width=50)
editor.pack(side='right', padx=10, pady=10)
text_data = ''
for i in range(1, 10):
    text_data += f"{i}:234567890123456789\n"
editor.insert('1.0', text_data)
root.mainloop()

Почему это важно

Управление фокусом — один из столпов отзывчивости GUI. Когда порядок событий ведёт себя неинтуитивно, это сразу ощущается: вы кликаете, готовы печатать, а каретки нет. Понимание того, как тайминг VirtualEvent взаимодействует с фокусом виджетов, помогает избежать мерцаний, «призрачных» переключений фокуса и странного поведения клавиатуры. Это также подчёркивает более общий принцип в Tkinter: правильный выбор события нередко определяет, будет ли интерфейс плавным или столкнётся с неожиданными изменениями состояния.

Итоги

Если каретка в Text не появляется после выбора в Listbox, причина может быть во времени срабатывания <<ListboxSelect>> относительно внутренней логики фокуса. Отложить смену фокуса через after(), перенаправить фокус на <FocusIn> или перейти на <ButtonRelease> — надёжные способы добиться ожидаемого поведения. Добавьте небольшую проверку вокруг curselection(), чтобы избежать ошибок, когда выбор временно пуст. С этими корректировками каретка появится там, где вы ждёте, и в тот момент, когда это нужно.