/
DimerTrue
/
ecg_gui
Обзор
Документация
Войти
/
DimerTrue
/
ecg_gui
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
ui/start_window.py
1 161 строка
44 KB
dimer
fix window position
05 авг 2026, 12:39
05 авг 2026, 12:39
10da83c
Код
Авторство
О чём код?
# start_window.py from core.app_logger import AppLogger logger = AppLogger.get_logger(__name__) import threading import queue import tkinter as tk from tkinter import ttk, messagebox from typing import List, Any, Dict from config.theme import Theme from config import ui_config from config.write_data_config import VALID_BAUDRATES from ui.tooltips import ToolTip from ui.device_window import DeviceWindow from models.common import MessageType from modbus_device.device import utils from modbus_device.device.manager import DeviceConfig from core.connection_manager import ConnectionManager from core.app_settings import AppSettings from core.app_controller import AppController class StartWindow: """ Стартовое окно для настройки параметров подключения и запуска системы. """ def __init__(self, root: tk.Tk, stop_event: threading.Event, app: AppController): self.root = root self.stop_event = stop_event self.app = app self.conn_manager = ConnectionManager(status_callback=self.update_status_bar) self.device_windows: List[DeviceWindow] = [] self.read_data_queues: List[queue.Queue] = [] self.write_data_queues: List[queue.Queue] = [] self.message_queues: List[queue.Queue] = [] self.devices: List[DeviceConfig] = [] self.device_count = 1 self.com_port = None self.ser = None self.found_ports = [] self.baudrate = None self.max_devices = None self.use_simulator = None self.theme = None self.device_count_var = tk.IntVar(value=1) self.baudrate_var = tk.StringVar(value=str(self.baudrate)) # Хранилище ссылок на виджеты для динамического обновления self._widgets: Dict[str, Any] = {} # Переменная для обновления остановки обновления стартового окна после подключения self.device_connected = False self.root.protocol("WM_DELETE_WINDOW", self.exit) self._load_settings() self._setup_ui() self.update_display() # ------------------------------------------------ # Инициализация UI # ------------------------------------------------ def _setup_ui(self): colors = Theme.get_colors() self.root.title("Настройка подключения") self.root.configure(bg=colors["bg"]) self.root.resizable(False, False) width = 350 height = 280 screen_width = self.root.winfo_screenwidth() screen_height = self.root.winfo_screenheight() x = (screen_width // 2) - (width // 2) y = (screen_height // 2) - (height // 2) self.root.geometry(f"{width}x{height}+{x}+{y}") icon = tk.PhotoImage(file=ui_config.ICON_PATH) self.root.iconphoto(True, icon) main_frame = tk.Frame(self.root, bg=colors["bg"]) main_frame.pack(fill=tk.X, side=tk.TOP, padx=20, pady=(10, 0)) self._build_menu() self._build_main_layout(main_frame) self._build_status_bar(self.root) self._configure_styles() self.set_theme(Theme.current) def _build_main_layout(self, parent): """Создание основной структуры layout""" colors = Theme.get_colors() columns_container = tk.Frame(parent, bg=colors["bg"]) columns_container.pack(fill=tk.BOTH, side=tk.TOP) columns_container.columnconfigure(0, weight=2) columns_container.columnconfigure(1, weight=1) columns_container.rowconfigure(0, weight=1) left_panel = tk.Frame(columns_container, bg=colors["bg"]) left_panel.grid(row=0, column=0, sticky="nsew", padx=(3, 4)) right_panel = tk.Frame(columns_container, bg=colors["bg"]) right_panel.grid(row=0, column=1, sticky="nsew", padx=(4, 3)) self._build_left_panel(left_panel) self._build_right_panel(right_panel) def _build_left_panel(self, parent): """Построение левой панели""" colors = Theme.get_colors() # Настраиваем 2 колонки parent.grid_columnconfigure(0, weight=1) parent.grid_columnconfigure(1, weight=0) parent.grid_rowconfigure(0, weight=0) # label parent.grid_rowconfigure(1, weight=1) # listbox + scrollbar parent.grid_rowconfigure(2, weight=0) # button com_label = tk.Label( parent, text="Выбор COM-порта", bg=colors["bg"], fg=colors["title_fg"], font=(ui_config.MAIN_FONT, ui_config.MAIN_TEXT_SIZE, "bold"), ) com_label.grid(row=0, column=0, columnspan=2, sticky="w", padx=3, pady=(3, 3)) listbox_com = tk.Listbox( parent, selectmode=tk.SINGLE, bg=colors["listbox_bg"], fg=colors["listbox_fg"], selectbackground=colors["listbox_bg_active"], selectforeground=colors["listbox_fg_active"], borderwidth=1, highlightthickness=0, font=(ui_config.MAIN_FONT, ui_config.MAIN_TEXT_SIZE), ) scrollbar = ttk.Scrollbar( parent, orient=tk.VERTICAL, command=listbox_com.yview, ) self._widgets["com_scrollbar"] = scrollbar listbox_com.config(yscrollcommand=scrollbar.set) # Listbox занимает колонку 0, scrollbar - колонку 1 listbox_com.grid(row=1, column=0, sticky="nsew", padx=(3, 0), pady=(3, 3)) scrollbar.grid(row=1, column=1, sticky="ns", padx=(0, 3), pady=(3, 3)) self._find_com_port() for item in self.found_ports: listbox_com.insert(tk.END, item) listbox_com.bind("<<ListboxSelect>>", self._on_select_com) ToolTip( widget=listbox_com, text=f"Выберите COM-порт", delay=ui_config.TOOLTIP_DELAY, follow_mouse=True, ) self._widgets["listbox_com"] = listbox_com btn_update_com = tk.Button( parent, text="Обновить", command=lambda: self.on_button_update_ports(), bg=colors["button_bg"], fg=colors["button_fg"], activebackground=colors["button_active_bg"], activeforeground=colors["button_fg"], font=(ui_config.MAIN_FONT, ui_config.MAIN_TEXT_SIZE, "bold"), wraplength=120, justify=tk.CENTER, cursor="hand2", relief=tk.FLAT, height=1, pady=1, ) btn_update_com.grid( row=2, column=0, columnspan=2, sticky="ew", padx=3, pady=(3, 3) ) self._widgets["btn_update_com"] = btn_update_com ToolTip( widget=btn_update_com, text=f"Обновить список COM-портов", delay=ui_config.TOOLTIP_DELAY, follow_mouse=True, ) def _build_right_panel(self, parent): """Построение правой панели""" colors = Theme.get_colors() # # Настройка сетки правой панели # parent.grid_columnconfigure(0, weight=1) # parent.grid_rowconfigure(0, weight=0) # label скорость # parent.grid_rowconfigure(1, weight=0) # combobox скорость # parent.grid_rowconfigure(2, weight=0) # label количество # parent.grid_rowconfigure(3, weight=0) # entry количество # parent.grid_rowconfigure(4, weight=1) # spacer (растягивается) # parent.grid_rowconfigure(5, weight=0) # btn_connect # parent.grid_rowconfigure(6, weight=0) # btn_scan_connect # Настройка сетки правой панели parent.grid_columnconfigure(0, weight=1) parent.grid_rowconfigure(0, weight=0) # label скорость parent.grid_rowconfigure(1, weight=0) # combobox скорость parent.grid_rowconfigure(2, weight=1) # spacer (растягивается) parent.grid_rowconfigure(3, weight=0) # label количество parent.grid_rowconfigure(4, weight=0) # entry количество parent.grid_rowconfigure(5, weight=1) # spacer (растягивается) parent.grid_rowconfigure(6, weight=0) # btn_connect parent.grid_rowconfigure(7, weight=1) # spacer (растягивается) parent.grid_rowconfigure(8, weight=0) # btn_scan_connect # Виджет скорости baudrate_label = tk.Label( parent, text="Скорость:", bg=colors["bg"], fg=colors["title_fg"], font=(ui_config.MAIN_FONT, ui_config.MAIN_TEXT_SIZE, "bold"), ) baudrate_label.grid(row=0, column=0, sticky="w", padx=3, pady=(3, 3)) self._widgets["baudrate_label"] = baudrate_label # Виджет скорости self._build_baudrate_combobox(parent) # Виджет количества устройств device_count_label = tk.Label( parent, text="Количество устройств:", bg=colors["bg"], fg=colors["title_fg"], font=(ui_config.MAIN_FONT, ui_config.MAIN_TEXT_SIZE, "bold"), ) device_count_label.grid(row=3, column=0, sticky="w", padx=3, pady=(3, 3)) self._widgets["device_count_label"] = device_count_label # Виджет количества устройств device_count_spinbox = tk.Spinbox( parent, from_=1, to=self.max_devices, textvariable=self.device_count_var, width=5, bg=colors["spinbox_bg"], fg=colors["spinbox_fg"], disabledbackground=colors["spinbox_bg"], disabledforeground=colors["spinbox_fg"], font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), command=self._update_device_count, ) device_count_spinbox.grid(row=4, column=0, sticky="ew", padx=3, pady=(0, 3)) device_count_spinbox.bind("<KeyRelease>", self._on_spinbox_key_release) device_count_spinbox.bind("<FocusOut>", self._on_spinbox_focus_out) ToolTip( widget=device_count_spinbox, text=f"Введите число от 1 до {self.max_devices}", delay=ui_config.TOOLTIP_DELAY, follow_mouse=True, ) self._widgets["device_count_spinbox"] = device_count_spinbox # Кнопка подключения вручную btn_connect = tk.Button( parent, text="Подключиться\nвручную", command=self.on_button_connect, bg=colors["button_bg"], fg=colors["button_fg"], activebackground=colors.get("button_active_bg", colors["button_bg"]), activeforeground=colors["button_fg"], font=(ui_config.MAIN_FONT, ui_config.MAIN_TEXT_SIZE, "bold"), wraplength=120, justify=tk.CENTER, cursor="hand2", relief=tk.FLAT, height=2, pady=1, ) btn_connect.grid(row=6, column=0, sticky="ew", padx=3, pady=(3, 3)) ToolTip( widget=btn_connect, text="Подключение к указанному COM-порту на заданной скорости", delay=ui_config.TOOLTIP_DELAY, follow_mouse=True, ) self._widgets["btn_connect"] = btn_connect # Кнопка автоматического подключения btn_scan_connect = tk.Button( parent, text="Автоматическое\nподключение", command=self.on_button_scan_connect, bg=colors["button_bg"], fg=colors["button_fg"], activebackground=colors["button_active_bg"], activeforeground=colors["button_fg"], font=(ui_config.MAIN_FONT, ui_config.MAIN_TEXT_SIZE, "bold"), wraplength=120, justify=tk.CENTER, cursor="hand2", relief=tk.FLAT, height=2, pady=1, ) btn_scan_connect.grid(row=8, column=0, sticky="ew", padx=3, pady=(0, 3)) ToolTip( widget=btn_scan_connect, text="Широковещательный поиск 1 генератора на линии", delay=ui_config.TOOLTIP_DELAY, follow_mouse=True, ) self._widgets["btn_scan_connect"] = btn_scan_connect def _build_baudrate_combobox(self, parent): """ Создает или пересоздает Combobox скорости Костыльный метод для изменения цвета виджета ttk """ # Удаляем старый виджет и его Tooltip, если они есть if "baudrate_entry" in self._widgets: old_widget = self._widgets["baudrate_entry"] # Удаляем связанный Tooltip if hasattr(self, "_tooltips") and "baudrate_entry" in self._tooltips: old_tooltip = self._tooltips["baudrate_entry"] # Принудительно скрываем окно, если оно открыто old_tooltip.hide_tip() # Удаляем объект old_tooltip.unbind_events() del self._tooltips["baudrate_entry"] old_widget.grid_forget() old_widget.destroy() # Создаем новый виджет baudrate_entry = ttk.Combobox( parent, values=list(VALID_BAUDRATES.keys()), state="readonly", font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ) # Восстанавливаем значение baudrate_entry.set(self.baudrate) # Размещаем в сетке baudrate_entry.grid(row=1, column=0, sticky="ew", padx=3, pady=(0, 3)) # Восстанавливаем привязки событий baudrate_entry.bind("<<ComboboxSelected>>", self._on_select_baudrate) # Обновляем ссылку в словаре виджетов self._widgets["baudrate_entry"] = baudrate_entry # Создаем и сохраняем новый Tooltip new_tooltip = ToolTip( widget=baudrate_entry, text=f"Выберите скорость подключения", delay=ui_config.TOOLTIP_DELAY, follow_mouse=True, ) # Сохраняем ссылку на tooltip if not hasattr(self, "_tooltips"): self._tooltips = {} self._tooltips["baudrate_entry"] = new_tooltip return baudrate_entry def _build_status_bar(self, parent): """Создание статус бара""" colors = Theme.get_colors() # Создаем контейнер для статус-бара, прижимаем к низу родительского окна status_container = tk.Frame(parent, bg=colors["bg"]) status_container.pack(fill=tk.X, side=tk.BOTTOM) # Верхняя рамка (разделитель) self.status_bar_border = tk.Frame( status_container, bg=colors["bar_border"], height=1, ) self.status_bar_border.pack(fill=tk.X) # Лейбл bar_label = tk.Label( status_container, text=f"Порт: --- | скорость: --- | id: ---", anchor="w", relief="flat", borderwidth=0, highlightthickness=0, bg=colors["bar_bg"], fg=colors["bar_fg"], font=(ui_config.MAIN_FONT, ui_config.MAIN_TEXT_SIZE), padx=5, pady=0, ) bar_label.pack(fill=tk.X) self._widgets["bar_label"] = bar_label def _build_menu(self): """Создание главного меню""" main_menu = tk.Menu(self.root) self.root.config(menu=main_menu) option_menu = tk.Menu( main_menu, tearoff=0, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE) ) themes_menu = tk.Menu( option_menu, tearoff=0, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ) themes_menu.add_command( label="Светлая", command=lambda: self.set_theme("light") ) themes_menu.add_command( label="Темная", command=lambda: self.set_theme("dark"), font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ) option_menu.add_cascade( label="Тема", menu=themes_menu, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ) option_menu.add_separator() option_menu.add_command( label="Выход", command=self.exit, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ) help_menu = tk.Menu( main_menu, tearoff=0, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE) ) help_menu.add_command(label="О программе", command=self.show_about_dialog) # help_menu = tk.Menu(main_menu, tearoff=0) # help_menu.add_command( # label="О программе", # command=lambda: messagebox.showinfo( # "О программе", # f"{ui_config.TOPAZ_VERSION}\n\n" # f"{ui_config.SOFTWARE_DESC}\n\n" # f"{ui_config.COPYRIGHT_TEXT}", # ), # font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), # ) main_menu.add_cascade( label="Опции", menu=option_menu, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ) main_menu.add_cascade( label="Справка", menu=help_menu, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), ) def show_about_dialog(self): """Кастомное окно 'О программе' для корректного отображения на Linux""" about_win = tk.Toplevel(self.root) about_win.withdraw() about_win.title("О программе") about_win.resizable(False, False) frame = tk.Frame(about_win, padx=10, pady=10) frame.pack(fill="both", expand=True) title_label = tk.Label( frame, text=ui_config.TOPAZ_VERSION, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE, "bold"), ) title_label.pack(anchor="w", pady=(0, 10)) desc_label = tk.Label( frame, text=ui_config.SOFTWARE_DESC, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), justify="left", wraplength=350, ) desc_label.pack(anchor="w", pady=(0, 10)) bottom_frame = tk.Frame(frame) bottom_frame.pack(fill="x", side="bottom", pady=(10, 0)) copy_label = tk.Label( bottom_frame, text=ui_config.COPYRIGHT_TEXT, font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE - 2), foreground="gray", ) copy_label.pack(side="left", anchor="sw") btn_close = ttk.Button( bottom_frame, text="OK", command=about_win.destroy, width=10, ) btn_close.pack(side="right", anchor="se") about_win.update_idletasks() win_w = about_win.winfo_reqwidth() win_h = about_win.winfo_reqheight() parent_x = self.root.winfo_x() parent_y = self.root.winfo_y() parent_w = self.root.winfo_width() parent_h = self.root.winfo_height() x = parent_x + (parent_w - win_w) // 2 y = parent_y + (parent_h - win_h) // 2 about_win.geometry(f"{win_w}x{win_h}+{x}+{y}") about_win.deiconify() about_win.transient(self.root) about_win.grab_set() about_win.focus_set() about_win.protocol("WM_DELETE_WINDOW", about_win.destroy) # ------------------------------------------------ # Вспомогательные методы # ------------------------------------------------ def _find_com_port(self): self.found_ports = utils.find_com_ports() def _update_listbox_com(self): """Обновление списка COM-портов с сохранением выбора и фокуса""" self._find_com_port() listbox = self._widgets["listbox_com"] # Сохраняем текущий выбранный порт current_selection = listbox.curselection() selected_port_name = None if current_selection: selected_port_name = listbox.get(current_selection[0]) if not selected_port_name and self.com_port: selected_port_name = self.com_port listbox.delete(0, tk.END) for item in self.found_ports: listbox.insert(tk.END, item) # Логика выделения и фокуса new_index_to_select = None if selected_port_name and selected_port_name in self.found_ports: new_index_to_select = self.found_ports.index(selected_port_name) elif self.found_ports: new_index_to_select = 0 if new_index_to_select is not None: listbox.selection_clear(0, tk.END) listbox.selection_set(new_index_to_select) # Устанавливаем активный элемент listbox.activate(new_index_to_select) listbox.see(new_index_to_select) # Обновляем переменную self._on_select_com(None) else: self.com_port = None # ------------------------------------------------ # Обработчики пользовательских событий # ------------------------------------------------ def _on_select_com(self, event): """Получаем выбранный COM-порт""" # Получаем индекс выбранного элемента widget = self._widgets["listbox_com"] selected_index = widget.curselection() if selected_index: self.com_port = widget.get(selected_index) def _on_select_baudrate(self, event): """ Сохранение скорости """ widget = self._widgets["baudrate_entry"] selection = widget.get() self.baudrate = int(selection) def _on_spinbox_key_release(self, event) -> None: """Валидация при ручном вводе текста""" widget = self._widgets["device_count_spinbox"] current_value = widget.get() # Убираем буквы if not current_value.isdigit() and current_value != "": cleaned = "".join(filter(str.isdigit, current_value)) widget.delete(0, tk.END) widget.insert(0, cleaned) widget.icursor(tk.END) messagebox.showwarning("Ошибка ввода", "Разрешены только цифры!") return # Проверяем цифры if current_value.isdigit(): value = int(current_value) if value == 0: widget.delete(0, tk.END) widget.insert(0, "1") self.device_count = 1 self.device_count_var.set("1") messagebox.showwarning( "Ошибка ввода", f"Значение должно быть в диапазоне от {1} до {self.max_devices}!", ) elif value > self.max_devices: self.device_count = self.max_devices messagebox.showwarning( "Ошибка ввода", f"Значение должно быть в диапазоне от {1} до {self.max_devices}!", ) else: self.device_count = value def _on_spinbox_focus_out(self, event=None) -> None: """Проверка и коррекция при потере фокуса""" widget = self._widgets["device_count_spinbox"] current_value = widget.get().strip() # Определяем корректное значение if current_value.isdigit(): val = int(current_value) corrected_val = max(1, min(val, self.max_devices)) else: corrected_val = 1 # Обновляем виджет и внутреннее состояние widget.delete(0, tk.END) widget.insert(0, str(corrected_val)) self.device_count = corrected_val def _update_device_count(self) -> None: """ Обработчик клика по стрелкам Spinbox. """ self.device_count = int(self.device_count_var.get()) # ------------------------------------------------ # Обработчики кнопок # ------------------------------------------------ def on_button_update_ports(self): self._update_listbox_com() if self.found_ports is None or len(self.found_ports) == 0: logger.warning(f"COM-порты не обнаружены") message = f"COM-порты не обнаружены" messagebox.showerror("Ошибка", message) def on_button_connect(self): """Обработчик кнопки ручного подключения""" if not self.device_count or self.device_count > self.max_devices: logger.warning(f"Некорректное количество устройств") return # Блокируем интерфейс на время подключения, чтобы пользователь не тыкал кнопки self.set_interface_state(False) # Запускаем подключение в отдельном потоке thread = threading.Thread( target=self._worker_connect, args=( self.com_port, self.baudrate, self.device_count, self.use_simulator, "default", ), daemon=True, ) thread.start() def on_button_scan_connect(self): """Обработчик кнопки""" if not self.device_count or self.device_count > self.max_devices: logger.warning( f"Попытка автоскана с некорректным количеством устройств: {self.device_count}" ) return # Блокируем интерфейс на время подключения, чтобы пользователь не тыкал кнопки self.set_interface_state(False) # TODO: При необходимости поиска N-устройств убираем меняем device_count self.device_count = 1 # Запускаем подключение в отдельном потоке thread = threading.Thread( target=self._worker_connect, args=( self.com_port, self.baudrate, self.device_count, self.use_simulator, "scan", ), daemon=True, ) thread.start() def _worker_connect( self, com_port, baudrate, device_count, use_simulator, type_connection ): """Рабочий поток для подключения, не блокирующий UI""" logger.info( f"Ручное подключение: порт={self.com_port}, скорость={self.baudrate}, устройств={self.device_count}" ) if type_connection == "scan": success, message, msg_type = self.conn_manager.try_auto_connect( found_ports=self.found_ports, device_count=self.device_count, use_simulator=self.use_simulator, ) elif type_connection == "default": success, message, msg_type = self.conn_manager.try_manual_connect( com_port=com_port, baudrate=baudrate, device_count=device_count, use_simulator=use_simulator, ) else: return # Передаем результат обратно в главный поток result = { "success": success, "message": message, "msg_type": msg_type, "devices": self.conn_manager.devices, "ser": self.conn_manager.ser, "com_port": self.conn_manager.com_port, } self.root.after(0, self._handle_connect_result, result) def _handle_connect_result(self, result): """Обработка результата подключения в главном потоке""" # Разблокируем интерфейс self.set_interface_state(True) success = result["success"] message = result["message"] msg_type = result["msg_type"] if not success: self.update_status_bar("---", "---", "---") if msg_type == MessageType.ERROR: logger.error(f"Ошибка ручного подключения: {message}") messagebox.showerror("Ошибка", message) elif msg_type == MessageType.INFO: logger.error(f"Ошибка ручного подключения: {message}") messagebox.showinfo("Информация", message) return if msg_type == MessageType.INFO: messagebox.showinfo("Информация", message) # Синхронизируем данные self.devices = result["devices"] self.ser = result["ser"] self.com_port = result["com_port"] self.device_count = len(self.devices) # Запускаем систему опроса self._start_system() # ------------------------------------------------ # Загрузка системы # ------------------------------------------------ def _start_system(self): """Запуск системы через AppController""" logger.info( f"Запуск системы для {len(self.devices)} устройств на порту {self.com_port}" ) state = self.app.start_system( ser=self.ser, devices=self.devices, ) screen_width = self.root.winfo_screenwidth() screen_height = self.root.winfo_screenheight() device_window_w = ui_config.DEVICE_WINDOW_WIDHT device_window_h = ui_config.DEVICE_WINDOW_HEIGHT # Параметры каскада CASCADE_OFFSET_X = 50 CASCADE_OFFSET_Y = 50 BASE_X = (screen_width // 2) - (device_window_w // 2) BASE_Y = (screen_height // 2) - (device_window_h // 2) for i, device in enumerate(state.devices): top = tk.Toplevel(self.root) top.protocol("WM_DELETE_WINDOW", self.exit) # Рассчитываем позицию с учетом каскада x = BASE_X + (i * CASCADE_OFFSET_X) y = BASE_Y + (i * CASCADE_OFFSET_Y) # Не даем окну уйти за пределы экрана if x + 600 > screen_width: # 600 - примерная ширина окна устройства x = screen_width - 650 if y + 550 > screen_height: y = screen_height - 600 top.geometry(f"+{x}+{y}") win = DeviceWindow( root=top, parent_app=self, read_data_queue=state.read_queues[i], write_data_queue=state.write_queues[i], message_queue=state.message_queues[i], stop_event=self.stop_event, device_id=device.id, com_port=state.com_port, ) self.device_windows.append(win) self.device_connected = True logger.info("Интерфейс мониторинга успешно открыт") self.root.withdraw() # ------------------------------------------------ # Функции работы с настройками приложения # ------------------------------------------------ def _load_settings(self): settings = AppSettings.load() logger.info( f"Загружены настройки: тема={settings['theme']}, симулятор={settings['use_simulator']}" ) Theme.current = settings["theme"] self.baudrate = settings["baudrate"] self.max_devices = settings["max_devices"] self.use_simulator = True if settings["use_simulator"] == "True" else False def _save_settings(self): new_settings = { "theme": Theme.current, "baudrate": self.baudrate, "max_devices": self.max_devices, "use_simulator": str(self.use_simulator), } AppSettings.save(new_settings) # ------------------------------------------------ # Управление темой # ------------------------------------------------ def set_interface_state(self, enabled: bool): """Включает или выключает все интерактивные элементы управления""" state_val = tk.NORMAL if enabled else tk.DISABLED for key, widget in self._widgets.items(): try: # Проверяем тип виджета, так как state настраивается по-разному if isinstance(widget, ttk.Combobox): widget.configure(state="readonly" if enabled else "disabled") elif isinstance(widget, (tk.Button, tk.Entry, tk.Spinbox)): widget.configure(state=state_val) except Exception as e: logger.exception(f"Ошибка переключения {type(widget).__name__}:") for device_windows in self.device_windows: device_windows.set_interface_state(enabled) def _configure_styles(self): """Настройка стилей ttk""" style = ttk.Style() style.theme_use("clam") colors = Theme.get_colors() # Настраиваем цвета элементов scrollbar style.configure( "Vertical.TScrollbar", background=colors["scrollbar_bg"], troughcolor=colors["scrollbar_tc"], bordercolor=colors["scrollbar_border"], lightcolor=colors["scrollbar_bg"], darkcolor=colors["scrollbar_bg"], arrowcolor=colors["scrollbar_tc"], gripcount=0, ) # Маппинг состояний (наведение мыши, нажатие) style.map( "Vertical.TScrollbar", background=[ ("active", colors["scrollbar_tc"]), # Цвет при наведении ("pressed", colors["scrollbar_tc"]), ], troughcolor=[ ("disabled", colors["scrollbar_tc"]), ], ) # Глобальный стиль для всех TCombobox style.configure( "TCombobox", font=(ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE), background=colors["combobox_bg"], foreground=colors["combobox_fg"], fieldbackground=colors["combobox_bg"], arrowcolor=colors["fg"], selectbackground=colors["combobox_bg"], selectforeground=colors["combobox_fg"], bordercolor=colors["border"], lightcolor=colors["combobox_bg"], darkcolor=colors["combobox_bg"], ) style.map( "TCombobox", background=[ ("active", colors["combobox_bg"]), ("readonly", colors["combobox_bg"]), ], foreground=[ ("active", colors["combobox_fg"]), ("readonly", colors["combobox_fg"]), ], fieldbackground=[ ("readonly", colors["combobox_bg"]), ("focus", colors["combobox_bg"]), ], arrowcolor=[ ("readonly", colors["fg"]), ("active", colors["fg"]), ("disabled", colors["border"]), ], ) self.root.option_add( "*TCombobox*Listbox.font", (ui_config.MAIN_FONT, ui_config.WIDGET_TEXT_SIZE) ) self.root.option_add("*TCombobox*Listbox.background", colors["combobox_bg"]) self.root.option_add("*TCombobox*Listbox.foreground", colors["combobox_fg"]) self.root.option_add( "*TCombobox*Listbox.selectBackground", colors["combobox_active_bg"] ) self.root.option_add( "*TCombobox*Listbox.selectForeground", colors["combobox_active_fg"] ) # self.root.tk.call("tk_setPalette", colors["combobox_bg"]) def set_theme(self, theme_name: str): """Смена темы оформления""" Theme.current = theme_name # Обновляем стили ttk self._configure_styles() # Обновляем только основное окно self._apply_theme_to_main_window(Theme.get_colors()) if "baudrate_entry" in self._widgets: parent_widget = self._widgets["baudrate_entry"].master self._build_baudrate_combobox(parent_widget) # Обновляем дочерние окна, если есть if len(self.device_windows) > 0: for device_window in self.device_windows: if device_window and device_window.root.winfo_exists(): device_window.set_theme(theme_name) self._save_settings() def _apply_theme_to_main_window(self, colors): """Применение темы только к основному окну и его прямым виджетам""" # Обновляем корневое окно self.root.configure(bg=colors["bg"]) # Обновляем все виджеты, которые являются прямыми потомками root # и которые мы создали в основном окне for widget in self.root.winfo_children(): self._apply_theme_to_widget(widget, colors) def _apply_theme_to_widget(self, widget, colors): """Применение темы к конкретному виджету и его детям""" # Пропускаем ttk виджеты (они обновляются через стили) if isinstance(widget, ttk.Combobox): # Обновляем только дочерние элементы (если есть) for child in widget.winfo_children(): self._apply_theme_to_widget(child, colors) return try: # Обновляем фон самого окна (Tk или Toplevel) if isinstance(widget, (tk.Tk, tk.Toplevel)): widget.configure(bg=colors["bg"]) if widget == self.status_bar_border: widget.configure(bg=colors["bar_border"]) # Обновляем фреймы и рамки elif isinstance(widget, (tk.Frame, tk.LabelFrame)): widget.configure(bg=colors["bg"]) if isinstance(widget, tk.LabelFrame): widget.configure(fg=colors["title_fg"]) # Обновляем метки elif isinstance(widget, tk.Label): if widget == self._widgets["bar_label"]: widget.configure(bg=colors["bar_bg"], fg=colors["bar_fg"]) else: widget.configure(bg=colors["bg"], fg=colors["title_fg"]) # Обновляем списки elif isinstance(widget, tk.Listbox): widget.configure( bg=colors["listbox_bg"], fg=colors["listbox_fg"], selectbackground=colors["listbox_bg_active"], selectforeground=colors["listbox_fg_active"], ) # Обновляем кнопки elif isinstance(widget, tk.Button): widget.configure( bg=colors["button_bg"], fg=colors["button_fg"], activebackground=colors["button_active_bg"], activeforeground=colors["button_fg"], ) # Обновляем поля ввода elif isinstance(widget, tk.Entry): widget.configure( bg=colors["entry_bg"], fg=colors["entry_fg"], disabledbackground=colors["entry_bg"], disabledforeground=colors["entry_fg"], insertbackground=colors["fg"], ) # Обновляем Spinbox elif isinstance(widget, tk.Spinbox): widget.configure( bg=colors["spinbox_bg"], fg=colors["spinbox_fg"], buttonbackground=colors["spinbox_bg"], activebackground=colors["spinbox_bg"], disabledbackground=colors["spinbox_bg"], disabledforeground=colors["spinbox_fg"], readonlybackground=colors["spinbox_bg"], ) except Exception as e: logger.exception( f"Ошибка применения темы к виджету {type(widget).__name__}" ) # Рекурсивный вызов для всех дочерних элементов for child in widget.winfo_children(): self._apply_theme_to_widget(child, colors) # ------------------------------------------------ # Обновление системы # ------------------------------------------------ def update_status_bar(self, port, baudrate, id): """Обновление статус-бара из любого потока""" text = f"Порт: {port} | скорость: {baudrate} | id: {id}" self.root.after(10, lambda: self._update_status_bar_ui(text)) def _update_status_bar_ui(self, text: str): """Обновление статус-бара в UI потоке""" widget = self._widgets["bar_label"] widget.config(text=text) def update_display(self): """Метод обновления окна""" # Обновляем только, если устройство не подключено if self.device_connected: return self._update_listbox_com() if not self.stop_event.is_set(): self.root.after(1000, self.update_display) def set_connection_command(self, command: bool) -> None: """Метод управляющий отображением статуса подключения у дочерних окон (включено или нет)""" for device_windows in self.device_windows: device_windows.is_connected = command def set_connect_status(self, connect_status: str): """Метод управляющий отображением статуса подключения у дочерних окон (описание)""" for device_windows in self.device_windows: device_windows.connect_status = connect_status def exit(self): """Единая функция подтверждения выхода для всего приложения""" logger.info("Пользователь инициировал выход из приложения") if messagebox.askyesno("Выход", "Вы уверены, что хотите закрыть программу?"): self.app.shutdown() self.stop_event.set() self.root.after(100, self.root.destroy) # self.app.shutdown() # self.stop_event.set() # self.root.after(100, self.root.destroy)