2025, Nov 25 21:02

Как в Tkinter не блокировать mainloop и корректно обрабатывать выбор файла

Разбираем типичную ошибку в Tkinter: попытку ждать ввода и блокировать mainloop. Переходим на событийную модель, корректно выбираем файл и создаем Player.

Попытки построить логику GUI, которая «ждет» решения пользователя и только потом продолжает выполнение, — частая ловушка в Tkinter. Хочется «поставить приложение на паузу», пока не сделан выбор, но такой подход идет вразрез с циклом событий и приводит к той самой гонке: код, зависящий от значения, запускается раньше, чем пользователь его задаст. Хорошая новость — решение простое, если действовать в русле событийной модели Tkinter.

Проблема

Представьте простой клиент, где пользователь выбирает каталог игры. Если внутри найдется ровно один файл игрока utilX.dat, код извлекает X как player_id и продолжает работу. Если файлов несколько, появляется небольшое окно-выборщик, предлагающее указать нужный. Проблема в том, что объект Player создается сразу после выбора каталога, даже когда пользователь еще не выбрал файл, и потому player_id по-прежнему равен None.

import tkinter as tk
from tkinter import Menu, Label, Button, Listbox, messagebox
from tkinter import filedialog, ttk
from pathlib import Path
import sys
from player import Player
from config import Config
class ClientApp:
    def __init__(self, window):
        self.window = window
        self.window.title("My Game Client")
        self.base_dir = None
        self.candidate_files = None
        self._picked_idx = ''
        self.chosen_file = None
        self.app_conf = None
        self.profile = None
        self.user_id = None
        self.build_menu()
        print('chosen_file', self.chosen_file)  # только для иллюстрации
    def build_menu(self):
        self.menubar = Menu(self.window)
        self.window.config(menu=self.menubar)
        self.game_menu = Menu(self.menubar, tearoff=0)
        self.menubar.add_cascade(label="Game", menu=self.game_menu)
        self.game_menu.add_command(label="Select game directory", command=self.pick_game_dir)
        self.game_menu.add_separator()
        self.game_menu.add_command(label="Save game")
        self.game_menu.add_command(label="Inventory viewer", command=self.show_inventory)
        self.game_menu.add_command(label="Exit", command=self.window.quit)
    def pick_game_dir(self):
        while True:
            self.base_dir = filedialog.askdirectory()
            if not self.base_dir:
                if messagebox.askyesno(
                    icon='warning',
                    title="Game directory selection",
                    message="You haven't chosen a game directory. Do you want to quit?"):
                    sys.exit(0)
            else:
                p = Path(self.base_dir)
                self.candidate_files = list(p.glob('util[0-9].dat'))
                if not self.candidate_files:
                    messagebox.showerror(
                        title='Directory error',
                        message='Player files not found in directory')
                else:
                    if len(self.candidate_files) > 1:
                        names = [f.name for f in self.candidate_files]
                        self.show_file_picker(names)
                    else:
                        self.chosen_file = self.candidate_files[0]
                        self.user_id = self.chosen_file.stem[-1]
                        print('self.user_id', self.user_id)
                    break
        # Проблема: это выполняется даже если пользователь ещё не выбрал файл
        self.game_menu.entryconfigure('Select game directory', state=tk.DISABLED)
        self.app_conf = Config(self.base_dir)
        self.profile = Player(self.base_dir, self.user_id)  # здесь user_id может быть None
    def on_confirm_pick(self):
        self._picked_idx = self._listbox.curselection()
        self.chosen_file = self.candidate_files[self._picked_idx[0]]
        self.user_id = self.chosen_file.stem[-1]
        print('self.user_id in on_confirm_pick', self.user_id)
        self.chooser_panel.destroy()
    def show_file_picker(self, file_names):
        self.chooser_panel = ttk.Frame(self.window, padding=(2, 2, 2, 2), name='chooser_panel')
        self.chooser_panel.pack()
        lbl = ttk.Label(self.chooser_panel, text="Several player files were found. Please choose one.", width=60)
        lbl.pack()
        var = tk.StringVar(value=file_names)
        self._listbox = Listbox(self.chooser_panel, listvariable=var, height=6, width=20)
        self._listbox.selection_set(0)
        self._listbox.pack()
        btn = ttk.Button(self.chooser_panel, text='Select', command=self.on_confirm_pick)
        btn.pack()
    def show_inventory(self):
        if self.chosen_file:
            self.profile = Player(self.base_dir)
        else:
            messagebox.showerror(
                title='Player file error',
                message='No player file selected. Please select a game directory first.')
if __name__ == "__main__":
    root = tk.Tk()
    app = ClientApp(root)
    root.mainloop()

Что на самом деле происходит

Код после цикла while предполагает, что корректный player_id уже существует. Но при сценарии с несколькими файлами окно с Listbox создается, и управление сразу возвращается в главный цикл. Пользователь еще ничего не нажал, поэтому user_id все еще None. Создание Player в этот момент падает, потому что предварительное условие не выполнено.

«Никогда не блокируйте главный цикл». Не пытайтесь стопорить Tkinter дополнительными вызовами mainloop, quit() или модальными трюками, чтобы симулировать последовательный консольный ввод-вывод. Пусть ход программы определяют события, а следующий шаг запускается из обработчиков.

Попытки вроде Toplevel с grab_set() или жонглирование mainloop()/quit() не решают проблему порядка выполнения. Нормальный GUI не «ждет ввода, чтобы продолжить»; он реагирует на ввод и продолжает из соответствующего обработчика.

Решение

Переместите зависимую логику в функцию, которую вы вызываете только после того, как станет известен user_id. На практике вынесите часть, которая отключает пункт меню, загружает Config и создает Player, в отдельный метод. Вызывайте его сразу в случае с единственным файлом и из обработчика кнопки Select — при множественном варианте. Уберите безусловные вызовы, которые срабатывают слишком рано.

import tkinter as tk
from tkinter import Menu, Label, Button, Listbox, messagebox
from tkinter import filedialog, ttk
from pathlib import Path
import sys
from player import Player
from config import Config
class ClientApp:
    def __init__(self, window):
        self.window = window
        self.window.title("My Game Client")
        self.base_dir = None
        self.candidate_files = None
        self._picked_idx = ''
        self.chosen_file = None
        self.app_conf = None
        self.profile = None
        self.user_id = None
        self.build_menu()
    def build_menu(self):
        self.menubar = Menu(self.window)
        self.window.config(menu=self.menubar)
        self.game_menu = Menu(self.menubar, tearoff=0)
        self.menubar.add_cascade(label="Game", menu=self.game_menu)
        self.game_menu.add_command(label="Select game directory", command=self.pick_game_dir)
        self.game_menu.add_separator()
        self.game_menu.add_command(label="Save game")
        self.game_menu.add_command(label="Inventory viewer", command=self.show_inventory)
        self.game_menu.add_command(label="Exit", command=self.window.quit)
    def activate_player(self):
        self.game_menu.entryconfigure('Select game directory', state=tk.DISABLED)
        self.app_conf = Config(self.base_dir)
        self.profile = Player(self.base_dir, self.user_id)
    def pick_game_dir(self):
        while True:
            self.base_dir = filedialog.askdirectory()
            if not self.base_dir:
                if messagebox.askyesno(
                    icon='warning',
                    title="Game directory selection",
                    message="You haven't chosen a game directory. Do you want to quit?"):
                    sys.exit(0)
            else:
                p = Path(self.base_dir)
                self.candidate_files = list(p.glob('util[0-9].dat'))
                if not self.candidate_files:
                    messagebox.showerror(
                        title='Directory error',
                        message='Player files not found in directory')
                else:
                    if len(self.candidate_files) > 1:
                        names = [f.name for f in self.candidate_files]
                        self.show_file_picker(names)
                    else:
                        self.chosen_file = self.candidate_files[0]
                        self.user_id = self.chosen_file.stem[-1]
                        print('self.user_id', self.user_id)
                        self.activate_player()  # запускать только когда user_id уже известен
                    break
    def on_confirm_pick(self):
        self._picked_idx = self._listbox.curselection()
        self.chosen_file = self.candidate_files[self._picked_idx[0]]
        self.user_id = self.chosen_file.stem[-1]
        print('self.user_id in on_confirm_pick', self.user_id)
        self.activate_player()  # запускать только когда user_id уже известен
        self.chooser_panel.destroy()
    def show_file_picker(self, file_names):
        self.chooser_panel = ttk.Frame(self.window, padding=(2, 2, 2, 2), name='chooser_panel')
        self.chooser_panel.pack()
        lbl = ttk.Label(self.chooser_panel, text="Several player files were found. Please choose one.", width=60)
        lbl.pack()
        var = tk.StringVar(value=file_names)
        self._listbox = Listbox(self.chooser_panel, listvariable=var, height=6, width=20)
        self._listbox.selection_set(0)
        self._listbox.pack()
        btn = ttk.Button(self.chooser_panel, text='Select', command=self.on_confirm_pick)
        btn.pack()
    def show_inventory(self):
        if self.chosen_file:
            self.profile = Player(self.base_dir)
        else:
            messagebox.showerror(
                title='Player file error',
                message='No player file selected. Please select a game directory first.')
if __name__ == "__main__":
    root = tk.Tk()
    app = ClientApp(root)
    root.mainloop()

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

В GUI-приложениях пользователь может вызвать действие в любой момент. Код, который рассчитывает на конкретный порядок во времени, рано или поздно сломается. Перенося зависимую работу в обработчики событий, где выставляется нужное состояние, вы гарантируете запуск только при выполненных предпосылках. Интерфейс при этом остается отзывчивым, потому что главный цикл ничто не блокирует, и не нужны хрупкие «костыли» вроде вложенных mainloop или модальных захватов для управления потоком.

Вывод

Думайте категориями событий, а не пауз. Когда действие пользователя устанавливает значение, например player_id, сразу вызывайте следующий шаг из соответствующего обработчика. Если логика расходится на ветви, переключайтесь прямо там — как в вызове activate_player сразу после того, как выяснилось, что файл один, или после подтверждения выбора пользователем. Такой подход сохраняет корректность логики, плавность UI и делает поток выполнения легко объяснимым.