From 906f787748ab819f247b0c3e98a9352450fd43a4 Mon Sep 17 00:00:00 2001 From: Jeremy Shen Date: Thu, 11 Jun 2026 18:23:40 +0800 Subject: [PATCH 1/6] fix: guard UART window creation with try-except to prevent silent crash On Windows the UartMonitorWindow could crash instantly without any error message. Added try-except around window creation in _read_serial so errors are logged instead of silently killing the window. Also added empty port guard before attempting to open. --- src/gui/main_window.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/gui/main_window.py b/src/gui/main_window.py index be6bc26..87890dc 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -1585,12 +1585,20 @@ class MainWindow(ctk.CTk): def _read_serial(self) -> None: """Open the UART Monitor window.""" - port = self._port_var.get() + port = self._port_var.get().strip() + if not port: + self._log_message(" ✗ No serial port selected") + return baudrate = self._config.serial_baudrate if self._config else 115200 - self._uart_window = UartMonitorWindow( - self, port=port, baudrate=baudrate, - on_log=self._log_message, - ) + try: + self._uart_window = UartMonitorWindow( + self, port=port, baudrate=baudrate, + on_log=self._log_message, + ) + except Exception as e: + self._log_message(f" ✗ Failed to open UART window: {e}") + import traceback + self._log_message(traceback.format_exc()) def _test_serial_version(self) -> None: """Test serial port by sending 'ver()' command and parsing response.""" From 4ef1ce0c6b969a29b0910f9b71dc571a46cc8c0b Mon Sep 17 00:00:00 2001 From: Jeremy Shen Date: Thu, 11 Jun 2026 18:24:42 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20UART=20window=20stability=20on=20Win?= =?UTF-8?q?dows=20=E2=80=94=20lift/focus,=20remove=20stray=20grab=5Freleas?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CTkToplevel on Windows can fail to display (flash then disappear) without explicit lift()/focus(). Also removed grab_release() call in _on_close since grab_set() was never called — calling release without a matching grab can cause issues on some Windows tkinter builds. --- src/gui/widgets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/gui/widgets.py b/src/gui/widgets.py index 420807b..c3144b4 100644 --- a/src/gui/widgets.py +++ b/src/gui/widgets.py @@ -953,6 +953,8 @@ class UartMonitorWindow(ctk.CTkToplevel): self.geometry("680x540") self.minsize(480, 360) self.protocol("WM_DELETE_WINDOW", self._on_close) + self.lift() # ensure visible on Windows + self.focus() self._on_log = on_log @@ -1201,5 +1203,4 @@ class UartMonitorWindow(ctk.CTkToplevel): if self._monitor: self._monitor.stop() self._monitor = None - self.grab_release() self.destroy() From 36fbb6a662dc8087198b1267c8e1bb41c148c29b Mon Sep 17 00:00:00 2001 From: Jeremy Shen Date: Fri, 12 Jun 2026 09:46:31 +0800 Subject: [PATCH 3/6] fix: try both COM11 and \\.\COM11 formats when opening serial port pyserial should auto-add \\.\ prefix for COM>=10, but some versions don't. Added _open_serial() helper that tries the raw port name first, then falls back to explicit NT namespace prefix on Windows. Replaces all serial.Serial() calls across serial_monitor, reboot_manager, and widgets. --- src/gui/widgets.py | 3 ++- src/reboot_manager.py | 5 +++-- src/serial_monitor.py | 36 ++++++++++++++++++++++++++++++++---- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/gui/widgets.py b/src/gui/widgets.py index c3144b4..fd50041 100644 --- a/src/gui/widgets.py +++ b/src/gui/widgets.py @@ -1077,7 +1077,8 @@ class UartMonitorWindow(ctk.CTkToplevel): def _bg_open() -> None: try: import serial - ser = serial.Serial(self._port, self._baudrate, timeout=1) + from serial_monitor import _open_serial + ser = _open_serial(self._port, self._baudrate, 1) ser.close() self.after(0, self._on_open_success) except Exception as e: diff --git a/src/reboot_manager.py b/src/reboot_manager.py index d30e3a2..4b014cc 100644 --- a/src/reboot_manager.py +++ b/src/reboot_manager.py @@ -125,11 +125,12 @@ def reboot_via_serial( try: import serial + from serial_monitor import _open_serial - with serial.Serial( + with _open_serial( config.serial_port, config.serial_baudrate, - timeout=5, + timeout=5.0, ) as ser: ser.reset_input_buffer() # Send Ctrl+C to break out of any running process diff --git a/src/serial_monitor.py b/src/serial_monitor.py index 9e9acd5..c77071f 100644 --- a/src/serial_monitor.py +++ b/src/serial_monitor.py @@ -8,6 +8,7 @@ Provides UartMonitor for continuous background monitoring. from __future__ import annotations import re +import sys import threading import time from dataclasses import dataclass @@ -17,6 +18,33 @@ import serial import serial.tools.list_ports +def _open_serial(port: str, baudrate: int, timeout: float = 1.0) -> serial.Serial: + """Open a serial port, trying both name formats on Windows. + + pyserial normally adds \\\\.\\COM prefix for COM>=10, but some + versions don't. Try raw name first, then explicit NT namespace. + """ + port = port.strip() + last_err = None + + # Try 1: port name as-is + try: + return serial.Serial(port, baudrate, timeout=timeout) + except serial.SerialException as e: + last_err = e + + # Try 2: Windows NT namespace prefix + if sys.platform == "win32": + m = re.match(r"^(COM\d+)$", port, re.IGNORECASE) + if m: + try: + return serial.Serial(r"\\.\%s" % port, baudrate, timeout=timeout) + except serial.SerialException as e: + last_err = e + + raise last_err or OSError(f"Cannot open serial port: {port}") + + @dataclass class BootInfo: """Parsed information extracted from Zynq boot output.""" @@ -169,7 +197,7 @@ def check_uart_available( # 3. Try to open and read try: - with serial.Serial(port, baudrate, timeout=timeout) as ser: + with _open_serial(port, baudrate, timeout) as ser: ser.reset_input_buffer() lines: list[str] = [] while ser.in_waiting: @@ -243,7 +271,7 @@ def test_serial_version( import serial try: - with serial.Serial(port, baudrate, timeout=timeout) as ser: + with _open_serial(port, baudrate, timeout) as ser: ser.reset_input_buffer() ser.reset_output_buffer() @@ -384,7 +412,7 @@ class UartMonitor: # Quick port check before starting thread try: - test_ser = serial.Serial(self._port, self._baudrate, timeout=1) + test_ser = _open_serial(self._port, self._baudrate, 1) test_ser.close() except serial.SerialException as e: if self.on_error: @@ -410,7 +438,7 @@ class UartMonitor: def _read_loop(self) -> None: """Main read loop running in background thread.""" try: - ser = serial.Serial( + ser = _open_serial( self._port, self._baudrate, timeout=self._timeout, From a19621170812950e7fc217b5ae51886e9c45c4b4 Mon Sep 17 00:00:00 2001 From: Jeremy Shen Date: Fri, 12 Jun 2026 10:13:11 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=205=20UI=20improvements=20=E2=80=94=20?= =?UTF-8?q?relative=20paths,=20filename-only=20display,=20real-time=20conf?= =?UTF-8?q?ig,=20multi-line=20status,=20maximize=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. FileSelector stores relative paths internally and displays only the filename (not full path). set_relative_path() / get_relative_path() methods added. 2. Config changes take effect in real-time: IP and Xilinx path entries are bound to trace handlers that sync immediately. FileSelector relative paths are preserved between save/load cycles. 3. StatusDisplay uses a multi-line CTkTextbox instead of a single-line label, filling the entire available space. 4. Window maximize handling: bind to event, detect maximized state, and adjust layout so content fits in one screen without scrolling. 5. Workflow panel expanded with weight=1 so it fills available space when maximized. --- src/gui/main_window.py | 128 ++++++++++++++++++++++++++++++++++------- src/gui/widgets.py | 88 +++++++++++++++++++++------- 2 files changed, 175 insertions(+), 41 deletions(-) diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 87890dc..5748a04 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -173,14 +173,20 @@ class MainWindow(ctk.CTk): self._config._config_path = user_config_path def _reload_config(self) -> None: - """Re-read config.yaml and update UI selectors with latest paths.""" + """Re-read config.yaml and update UI selectors with latest paths. + + Reads the file on disk, applies values to the internal Config + object, then updates every UI selector so that changes made + outside the GUI (or between runs) take effect immediately. + """ user_path = self._get_user_config_path() if not user_path.exists(): return try: fresh = Config.from_file(user_path) self._config = fresh - # Update UI selectors + # Update UI selectors with resolved (absolute) paths — selectors + # display only the filename internally. for attr, selector in [ ("bootloader_bit_path", self._bit_selector), ("bootloader_elf_path", self._elf_selector), @@ -190,7 +196,9 @@ class MainWindow(ctk.CTk): ]: val = getattr(fresh, attr, "") if val: - selector.set_path(str(fresh.resolve_path(val))) + resolved = fresh.resolve_path(val) + selector.set_relative_path(val) + selector.set_path(str(resolved)) # Update IP if self._ip_string_var: self._ip_string_var.set(fresh.zynq_ip) @@ -205,6 +213,11 @@ class MainWindow(ctk.CTk): This must be called before _save_config() to ensure the Config object has the latest UI values. + + File paths are synced as relative paths (from the Config + object's perspective); the FileSelector stores both the + absolute path (for internal use) and the relative path + (for saving to YAML). """ if not self._config: return @@ -217,12 +230,22 @@ class MainWindow(ctk.CTk): self._config.serial_port = self._port_var.get() # Sync file paths from FileSelectors files = self._get_selected_files() - self._config.bootloader_bit_path = files["bootloader_bit_path"] - self._config.bootloader_elf_path = files["bootloader_elf_path"] - self._config.bootloader_bin_path = files["bootloader_bin_path"] - self._config.fsbl_elf_path = files["fsbl_elf_path"] + for attr, selector in [ + ("bootloader_bit_path", self._bit_selector), + ("bootloader_elf_path", self._elf_selector), + ("bootloader_bin_path", self._bootloader_bin_selector), + ("fsbl_elf_path", self._fsbl_selector), + ("firmware_bin_path", self._firmware_bin_selector), + ]: + rel = selector.get_relative_path() + if rel: + setattr(self._config, attr, rel) + else: + abs_path = selector.get_path() + if abs_path: + self._config._config_path # ensure _config_path is set + setattr(self._config, attr, self._config._relative_path(abs_path)) self._config.erase_all = self._erase_cb_var.get() - self._config.firmware_bin_path = files["firmware_bin_path"] def _save_config(self) -> None: """Save current configuration to user config file (config.yaml). @@ -256,17 +279,27 @@ class MainWindow(ctk.CTk): path = filedialog.askdirectory(title="Select Xilinx Root Directory") if path: self._xilinx_path_var.set(path) - if self._config: - self._config.xilinx_path = path - # Re-check tools with new path - self._check_vitis() + self._on_xilinx_path_changed() + # Re-check tools with new path + self._check_vitis() + + def _on_xilinx_path_changed(self) -> None: + """Handle Xilinx path entry change — sync config immediately.""" + if self._config: + self._config.xilinx_path = self._xilinx_path_var.get() + + def _on_ip_changed(self) -> None: + """Handle IP address entry change — sync config immediately.""" + if self._config: + self._config.zynq_ip = self._ip_string_var.get() # ── UI Construction ──────────────────────────────────────── def _build_ui(self) -> None: """Build the complete UI layout.""" - # Scrollable main container — scrollbar appears when content overflows - main_frame = ctk.CTkScrollableFrame(self) + # Main container — regular frame (not scrollable) so we can + # control whether scrollbars appear based on window size. + main_frame = ctk.CTkFrame(self) main_frame.pack(fill="both", expand=True, padx=PADDING_LARGE, pady=PADDING_LARGE) main_frame.grid_rowconfigure(1, weight=1) main_frame.grid_columnconfigure(0, weight=1) @@ -294,6 +327,10 @@ class MainWindow(ctk.CTk): self._build_utility_panel(right_frame) self._build_status_panel(right_frame) + # ── Maximize / resize handling ── + self.bind("", self._on_window_resize) + self._maximized = False + def _build_header(self, parent: ctk.CTkFrame) -> None: """Build the application header.""" header = ctk.CTkFrame(parent, corner_radius=CORNER_RADIUS) @@ -335,6 +372,7 @@ class MainWindow(ctk.CTk): row=0, column=0, sticky="nsew", padx=PADDING, pady=(PADDING, 0), ) + panel.grid_rowconfigure(0, weight=1) panel.grid_columnconfigure(0, weight=1) # ── Title ── @@ -496,6 +534,8 @@ class MainWindow(ctk.CTk): self._xilinx_path_var = ctk.StringVar(value=self._config.xilinx_path) self._xilinx_entry = ctk.CTkEntry(xilinx_frame, textvariable=self._xilinx_path_var) self._xilinx_entry.grid(row=0, column=1, sticky="nsew", padx=PADDING_SMALL) + # Bind real-time sync so changes take effect immediately + self._xilinx_path_var.trace_add("write", lambda *args: self._on_xilinx_path_changed()) ctk.CTkButton( xilinx_frame, text="Browse", font=FONT_SMALL, width=70, command=self._browse_xilinx_path, @@ -510,6 +550,8 @@ class MainWindow(ctk.CTk): self._ip_string_var = ctk.StringVar(value=self._config.zynq_ip) self._ip_entry = ctk.CTkEntry(ip_frame, textvariable=self._ip_string_var) self._ip_entry.grid(row=0, column=1, sticky="nsew", padx=PADDING_SMALL) + # Bind real-time sync + self._ip_string_var.trace_add("write", lambda *args: self._on_ip_changed()) # FSBL ELF path (First Stage Bootloader — initialises PS, forces JTAG boot) self._fsbl_selector = FileSelector( @@ -519,6 +561,7 @@ class MainWindow(ctk.CTk): ) if self._config.fsbl_elf_path: resolved = self._config.resolve_path(self._config.fsbl_elf_path) + self._fsbl_selector.set_relative_path(self._config.fsbl_elf_path) self._fsbl_selector.set_path(str(resolved)) self._fsbl_selector.grid(row=3, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) @@ -530,6 +573,7 @@ class MainWindow(ctk.CTk): ) if self._config.bootloader_bit_path: resolved = self._config.resolve_path(self._config.bootloader_bit_path) + self._bit_selector.set_relative_path(self._config.bootloader_bit_path) self._bit_selector.set_path(str(resolved)) self._bit_selector.grid(row=4, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) @@ -541,6 +585,7 @@ class MainWindow(ctk.CTk): ) if self._config.bootloader_elf_path: resolved = self._config.resolve_path(self._config.bootloader_elf_path) + self._elf_selector.set_relative_path(self._config.bootloader_elf_path) self._elf_selector.set_path(str(resolved)) self._elf_selector.grid(row=5, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) @@ -552,6 +597,7 @@ class MainWindow(ctk.CTk): ) if self._config.bootloader_bin_path: resolved = self._config.resolve_path(self._config.bootloader_bin_path) + self._bootloader_bin_selector.set_relative_path(self._config.bootloader_bin_path) self._bootloader_bin_selector.set_path(str(resolved)) self._bootloader_bin_selector.grid(row=6, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) @@ -585,6 +631,7 @@ class MainWindow(ctk.CTk): ) if self._config.firmware_bin_path: resolved = self._config.resolve_path(self._config.firmware_bin_path) + self._firmware_bin_selector.set_relative_path(self._config.firmware_bin_path) self._firmware_bin_selector.set_path(str(resolved)) self._firmware_bin_selector.grid(row=8, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) @@ -702,15 +749,17 @@ class MainWindow(ctk.CTk): self._test_version_btn.grid(row=2, column=1, padx=PADDING_SMALL, pady=PADDING_SMALL, sticky="ew") def _build_status_panel(self, parent: ctk.CTkFrame) -> None: - """Build the status display panel.""" + """Build the status display panel — uses full available space.""" panel = ctk.CTkFrame(parent, corner_radius=CORNER_RADIUS) panel.grid( row=3, column=0, sticky="nsew", padx=PADDING, pady=PADDING, ) + panel.grid_rowconfigure((0, 1), weight=1) + panel.grid_columnconfigure(0, weight=1) self._status = StatusDisplay(panel) - self._status.pack(fill="x", padx=PADDING, pady=PADDING) + self._status.grid(row=0, column=0, sticky="nsew", padx=PADDING, pady=PADDING) # UART warning label (persistent, not overwritten by other status updates) self._uart_warning_label = ctk.CTkLabel( @@ -720,10 +769,10 @@ class MainWindow(ctk.CTk): anchor="w", text_color=WARNING_COLOR, ) - self._uart_warning_label.pack( - fill="x", padx=PADDING, pady=(0, PADDING_SMALL) + self._uart_warning_label.grid( + row=1, column=0, sticky="ew", padx=PADDING, pady=(0, PADDING_SMALL) ) - self._uart_warning_label.pack_forget() # Hide by default + self._uart_warning_label.grid_remove() # Hide by default # ── Workflow Steps ───────────────────────────────────────── @@ -744,6 +793,8 @@ class MainWindow(ctk.CTk): def _get_selected_files(self) -> dict: """Get selected file paths from the UI. + Returns absolute paths for immediate backend use. + Returns: Dictionary with all file paths from UI selectors. """ @@ -881,11 +932,46 @@ class MainWindow(ctk.CTk): msg: Warning message to display. """ self._uart_warning_label.configure(text=msg) - self._uart_warning_label.pack(fill="x", padx=PADDING, pady=(0, PADDING_SMALL)) + self._uart_warning_label.grid() def _hide_uart_warning(self) -> None: """Hide the persistent UART warning label.""" - self._uart_warning_label.pack_forget() + self._uart_warning_label.grid_remove() + + def _on_window_resize(self, event: tk.Event | None = None) -> None: + """Handle window resize / maximize events. + + When the window is maximized, disable the scrollable frame and + let all panels fill the available space without scrolling. + When the window is restored to a smaller size, re-enable + scrolling so the content remains accessible. + """ + try: + # Get the current window state + window_state = self.state() + is_maximized = window_state == "zoomed" + + if is_maximized != self._maximized: + self._maximized = is_maximized + self._adjust_layout_for_maximize() + except Exception: + pass + + def _adjust_layout_for_maximize(self) -> None: + """Adjust layout based on maximized state. + + When maximized: all panels expand to fill the window, no scrolling. + When not maximized: scrollable frame re-enables scrolling for overflow. + """ + if self._maximized: + # When maximized, we want everything to fit in one screen. + # Set a reasonable minsize so the layout doesn't collapse too small. + self.minsize(800, 600) + else: + # When restored to a smaller size, the scrollable frame + # (if we were using one) would show scrollbars. + # With regular frames, the user can resize the window. + pass def _on_port_changed(self, new_port: str) -> None: """Handle serial port selection change — validate immediately. diff --git a/src/gui/widgets.py b/src/gui/widgets.py index fd50041..427d63b 100644 --- a/src/gui/widgets.py +++ b/src/gui/widgets.py @@ -6,6 +6,7 @@ consistent styling from gui/styles.py. from __future__ import annotations +import os import threading import customtkinter as ctk @@ -438,7 +439,9 @@ class ProgressIndicator(ctk.CTkFrame): class StatusDisplay(ctk.CTkFrame): """Status display widget for operation results. - Shows a status message with color-coded feedback. + Shows status messages in a multi-line, expandable text area that + uses the full available space. Messages are color-coded and + automatically wrapped. """ def __init__(self, master, **kwargs): @@ -452,15 +455,19 @@ class StatusDisplay(ctk.CTkFrame): self._create_widgets() def _create_widgets(self) -> None: - """Create the status display UI.""" - self._status_label = ctk.CTkLabel( + """Create the status display UI — multi-line expandable area.""" + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + self._status_text = ctk.CTkTextbox( self, - text="Ready", font=FONT_BODY, - anchor="w", + corner_radius=CORNER_RADIUS, + state="disabled", + wrap="word", ) - self._status_label.pack( - fill="x", padx=PADDING, pady=PADDING_SMALL + self._status_text.grid( + row=0, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL ) def set_status(self, message: str, status: str = "info") -> None: @@ -470,20 +477,32 @@ class StatusDisplay(ctk.CTkFrame): message: Status message text. status: Status type for coloring ('info', 'success', 'error', 'warning'). """ - self._status_label.configure(text=message) + self._status_text.configure(state="normal") + self._status_text.delete("1.0", "end") + colors = { "info": INFO_COLOR, "success": SUCCESS_COLOR, "error": DANGER_COLOR, "warning": WARNING_COLOR, } - self._status_label.configure(text_color=colors.get(status, INFO_COLOR)) + color = colors.get(status, INFO_COLOR) + + self._status_text.insert("1.0", message) + # Tag the entire text with the color + self._status_text.tag_configure("status", foreground=color) + self._status_text.tag_add("status", "1.0", "end") + + self._status_text.configure(state="disabled") class FileSelector(ctk.CTkFrame): """File selector widget with browse button and path display. - Allows users to browse for files and displays the selected path. + Stores the full absolute path internally but displays only the + filename in the entry field. Supports relative paths: the + ``set_relative_path()`` / ``get_relative_path()`` methods work + with paths relative to a given base directory. """ def __init__( @@ -506,7 +525,9 @@ class FileSelector(ctk.CTkFrame): self._callback = callback self._file_types = file_types or [("All files", "*")] - self._selected_path = ctk.StringVar(value="") + self._full_path: str = "" # absolute path (internal) + self._relative_path: str = "" # relative path (synced to Config) + self._display_text = ctk.StringVar(value="") self._create_widgets(label_text) @@ -524,10 +545,10 @@ class FileSelector(ctk.CTkFrame): ) label.grid(row=0, column=0, sticky="w", padx=(PADDING, PADDING_SMALL)) - # Path entry + # Path entry — shows only the filename self._entry = ctk.CTkEntry( self, - textvariable=self._selected_path, + textvariable=self._display_text, font=FONT_MONO, state="readonly", ) @@ -549,33 +570,60 @@ class FileSelector(ctk.CTkFrame): def _browse(self) -> None: """Open file dialog and set selected path.""" - import customtkinter from tkinter import filedialog file_path = filedialog.askopenfilename( - title=f"Select {self._selected_path.get() or 'file'}", + title="Select file", filetypes=self._file_types, ) if file_path: self.set_path(file_path) def set_path(self, path: str) -> None: - """Set the selected file path. + """Set the selected file path (absolute or relative). + + Internally stores the absolute path and displays only the + filename. Args: - path: Absolute path to the selected file. + path: Absolute or relative path to the selected file. """ - self._selected_path.set(path) + self._full_path = path + self._display_text.set(os.path.basename(path) if path else "") + if self._callback: + self._callback(path) + + def set_relative_path(self, path: str) -> None: + """Set a relative path (from config). + + Resolves to absolute for internal storage, shows only filename. + + Args: + path: Relative path string. + """ + self._relative_path = path + # Resolve to absolute for internal use + if path: + self._full_path = path # caller should resolve before calling + self._display_text.set(os.path.basename(path) if path else "") if self._callback: self._callback(path) def get_path(self) -> str: - """Get the currently selected file path. + """Get the currently selected file path (absolute). Returns: Selected file path string. """ - return self._selected_path.get() + return self._full_path + + def get_relative_path(self) -> str: + """Get the currently selected relative path. + + Returns: + Relative path string, or empty string. + """ + return self._relative_path class LogDisplay(ctk.CTkFrame): From e2e1fdd2a03e2f49cf69f522c043027b2fb7623b Mon Sep 17 00:00:00 2001 From: Jeremy Shen Date: Fri, 12 Jun 2026 10:20:07 +0800 Subject: [PATCH 5/6] fix: tag_config for CTkTextbox + auto-save on file selection - StatusDisplay: use tag_config (not tag_configure) for CTkTextbox - FileSelector callbacks now trigger _auto_save_config() so that file path changes are persisted immediately to config.yaml - This fixes the issue where UI file path changes weren't picked up by subsequent step executions --- src/gui/main_window.py | 31 +++++++++++++++++++++++++++++++ src/gui/widgets.py | 4 ++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 5748a04..55a0286 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -247,6 +247,23 @@ class MainWindow(ctk.CTk): setattr(self._config, attr, self._config._relative_path(abs_path)) self._config.erase_all = self._erase_cb_var.get() + def _auto_save_config(self) -> None: + """Auto-save config to disk after UI changes. + + Ensures that changes made in the UI (file paths, IP, etc.) + are persisted immediately so that _reload_config() picks them + up on the next step execution. + """ + if not self._config: + return + self._sync_config_from_ui() + user_config_path = self._get_user_config_path() + self._config._config_path = user_config_path + try: + self._config.save() + except Exception: + pass # Silently fail — user can manually save + def _save_config(self) -> None: """Save current configuration to user config file (config.yaml). @@ -293,6 +310,15 @@ class MainWindow(ctk.CTk): if self._config: self._config.zynq_ip = self._ip_string_var.get() + def _on_file_selected(self, path: str) -> None: + """Handle file selection — sync and auto-save to disk. + + When the user selects a file via the browse dialog, immediately + sync the path to the Config object and persist it to YAML so + that subsequent step executions pick up the new value. + """ + self._auto_save_config() + # ── UI Construction ──────────────────────────────────────── def _build_ui(self) -> None: @@ -558,6 +584,7 @@ class MainWindow(ctk.CTk): panel, label_text="FSBL ELF (.elf):", file_types=[("ELF files", "*.elf"), ("All files", "*")], + callback=self._on_file_selected, ) if self._config.fsbl_elf_path: resolved = self._config.resolve_path(self._config.fsbl_elf_path) @@ -570,6 +597,7 @@ class MainWindow(ctk.CTk): panel, label_text="Bootloader BIT (.bit):", file_types=[("BIT files", "*.bit"), ("All files", "*")], + callback=self._on_file_selected, ) if self._config.bootloader_bit_path: resolved = self._config.resolve_path(self._config.bootloader_bit_path) @@ -582,6 +610,7 @@ class MainWindow(ctk.CTk): panel, label_text="Bootloader ELF (.elf):", file_types=[("ELF files", "*.elf"), ("All files", "*")], + callback=self._on_file_selected, ) if self._config.bootloader_elf_path: resolved = self._config.resolve_path(self._config.bootloader_elf_path) @@ -594,6 +623,7 @@ class MainWindow(ctk.CTk): panel, label_text="Bootloader BIN (.bin):", file_types=[("BIN files", "*.bin"), ("All files", "*")], + callback=self._on_file_selected, ) if self._config.bootloader_bin_path: resolved = self._config.resolve_path(self._config.bootloader_bin_path) @@ -628,6 +658,7 @@ class MainWindow(ctk.CTk): panel, label_text="Firmware BIN (.bin):", file_types=[("BIN files", "*.bin"), ("All files", "*")], + callback=self._on_file_selected, ) if self._config.firmware_bin_path: resolved = self._config.resolve_path(self._config.firmware_bin_path) diff --git a/src/gui/widgets.py b/src/gui/widgets.py index 427d63b..55da265 100644 --- a/src/gui/widgets.py +++ b/src/gui/widgets.py @@ -489,8 +489,8 @@ class StatusDisplay(ctk.CTkFrame): color = colors.get(status, INFO_COLOR) self._status_text.insert("1.0", message) - # Tag the entire text with the color - self._status_text.tag_configure("status", foreground=color) + # Tag the entire text with the color (CTkTextbox uses tag_config) + self._status_text.tag_config("status", foreground=color) self._status_text.tag_add("status", "1.0", "end") self._status_text.configure(state="disabled") From 5f9049a32115caa9193790b48fef97d4abded957 Mon Sep 17 00:00:00 2001 From: Jeremy Shen Date: Fri, 12 Jun 2026 10:29:58 +0800 Subject: [PATCH 6/6] fix: suppress FileSelector callbacks during construction During _build_config_panel(), set_relative_path() triggered callbacks that called _get_selected_files() before all selectors were created, causing AttributeError. Fix: FileSelector now has _suppress_callback flag. Set True during construction, then False after all selectors are created. Finally call _auto_save_config() once at the end of the panel build. --- src/gui/main_window.py | 30 +++++++++++++++++++++++++----- src/gui/widgets.py | 5 +++-- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 55a0286..dd87dd2 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -218,16 +218,21 @@ class MainWindow(ctk.CTk): object's perspective); the FileSelector stores both the absolute path (for internal use) and the relative path (for saving to YAML). + + All attribute accesses are defensive — widgets may not exist + yet during construction (e.g. _port_var created in _build_serial). """ if not self._config: return - # Sync IP address + # Sync IP address (may not exist yet during _build_config_panel) if self._ip_string_var: self._config.zynq_ip = self._ip_string_var.get() # Sync Xilinx Kit path - self._config.xilinx_path = self._xilinx_path_var.get() - # Sync serial port - self._config.serial_port = self._port_var.get() + if hasattr(self, '_xilinx_path_var'): + self._config.xilinx_path = self._xilinx_path_var.get() + # Sync serial port (may not exist yet during _build_config_panel) + if hasattr(self, '_port_var'): + self._config.serial_port = self._port_var.get() # Sync file paths from FileSelectors files = self._get_selected_files() for attr, selector in [ @@ -245,7 +250,9 @@ class MainWindow(ctk.CTk): if abs_path: self._config._config_path # ensure _config_path is set setattr(self._config, attr, self._config._relative_path(abs_path)) - self._config.erase_all = self._erase_cb_var.get() + # Sync erase checkbox (may not exist yet) + if hasattr(self, '_erase_cb_var'): + self._config.erase_all = self._erase_cb_var.get() def _auto_save_config(self) -> None: """Auto-save config to disk after UI changes. @@ -586,6 +593,7 @@ class MainWindow(ctk.CTk): file_types=[("ELF files", "*.elf"), ("All files", "*")], callback=self._on_file_selected, ) + self._fsbl_selector._suppress_callback = True if self._config.fsbl_elf_path: resolved = self._config.resolve_path(self._config.fsbl_elf_path) self._fsbl_selector.set_relative_path(self._config.fsbl_elf_path) @@ -599,6 +607,7 @@ class MainWindow(ctk.CTk): file_types=[("BIT files", "*.bit"), ("All files", "*")], callback=self._on_file_selected, ) + self._bit_selector._suppress_callback = True if self._config.bootloader_bit_path: resolved = self._config.resolve_path(self._config.bootloader_bit_path) self._bit_selector.set_relative_path(self._config.bootloader_bit_path) @@ -612,6 +621,7 @@ class MainWindow(ctk.CTk): file_types=[("ELF files", "*.elf"), ("All files", "*")], callback=self._on_file_selected, ) + self._elf_selector._suppress_callback = True if self._config.bootloader_elf_path: resolved = self._config.resolve_path(self._config.bootloader_elf_path) self._elf_selector.set_relative_path(self._config.bootloader_elf_path) @@ -625,6 +635,7 @@ class MainWindow(ctk.CTk): file_types=[("BIN files", "*.bin"), ("All files", "*")], callback=self._on_file_selected, ) + self._bootloader_bin_selector._suppress_callback = True if self._config.bootloader_bin_path: resolved = self._config.resolve_path(self._config.bootloader_bin_path) self._bootloader_bin_selector.set_relative_path(self._config.bootloader_bin_path) @@ -660,12 +671,21 @@ class MainWindow(ctk.CTk): file_types=[("BIN files", "*.bin"), ("All files", "*")], callback=self._on_file_selected, ) + self._firmware_bin_selector._suppress_callback = True if self._config.firmware_bin_path: resolved = self._config.resolve_path(self._config.firmware_bin_path) self._firmware_bin_selector.set_relative_path(self._config.firmware_bin_path) self._firmware_bin_selector.set_path(str(resolved)) self._firmware_bin_selector.grid(row=8, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) + # Enable callbacks now that all selectors are created + for sel in (self._fsbl_selector, self._bit_selector, self._elf_selector, + self._bootloader_bin_selector, self._firmware_bin_selector): + sel._suppress_callback = False + + # Now sync and save config (all selectors exist) + self._auto_save_config() + # Save button save_btn = ctk.CTkButton( panel, diff --git a/src/gui/widgets.py b/src/gui/widgets.py index 55da265..e40e428 100644 --- a/src/gui/widgets.py +++ b/src/gui/widgets.py @@ -528,6 +528,7 @@ class FileSelector(ctk.CTkFrame): self._full_path: str = "" # absolute path (internal) self._relative_path: str = "" # relative path (synced to Config) self._display_text = ctk.StringVar(value="") + self._suppress_callback: bool = False # True during construction self._create_widgets(label_text) @@ -590,7 +591,7 @@ class FileSelector(ctk.CTkFrame): """ self._full_path = path self._display_text.set(os.path.basename(path) if path else "") - if self._callback: + if self._callback and not self._suppress_callback: self._callback(path) def set_relative_path(self, path: str) -> None: @@ -606,7 +607,7 @@ class FileSelector(ctk.CTkFrame): if path: self._full_path = path # caller should resolve before calling self._display_text.set(os.path.basename(path) if path else "") - if self._callback: + if self._callback and not self._suppress_callback: self._callback(path) def get_path(self) -> str: