diff --git a/src/gui/main_window.py b/src/gui/main_window.py index be6bc26..5575b3b 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,24 +213,63 @@ 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). + + 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() - 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"] - self._config.erase_all = self._erase_cb_var.get() - self._config.firmware_bin_path = files["firmware_bin_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)) + # 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. + + 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). @@ -256,26 +303,44 @@ 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() + + 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: """Build the complete UI layout.""" # Scrollable main container — scrollbar appears when content overflows - main_frame = ctk.CTkScrollableFrame(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) + self.main_frame = ctk.CTkScrollableFrame(self) + self.main_frame.pack(fill="both", expand=True, padx=PADDING_LARGE, pady=PADDING_LARGE) + self.main_frame.grid_rowconfigure(1, weight=1) + self.main_frame.grid_columnconfigure(0, weight=1) # ── Header ── - self._build_header(main_frame) + self._build_header(self.main_frame) # ── Left Panel: Workflow Steps ── - left_frame = ctk.CTkFrame(main_frame) + left_frame = ctk.CTkFrame(self.main_frame) left_frame.grid(row=1, column=0, sticky="nsew", padx=(0, PADDING)) left_frame.grid_rowconfigure(1, weight=1) left_frame.grid_columnconfigure(0, weight=1) @@ -284,7 +349,7 @@ class MainWindow(ctk.CTk): self._build_log_panel(left_frame) # ── Right Panel: Configuration ── - right_frame = ctk.CTkFrame(main_frame) + right_frame = ctk.CTkFrame(self.main_frame) right_frame.grid(row=1, column=1, sticky="nsew", padx=(PADDING, 0)) right_frame.grid_rowconfigure(3, weight=1) right_frame.grid_columnconfigure(0, weight=1) @@ -294,6 +359,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 +404,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 ── @@ -448,6 +518,7 @@ class MainWindow(ctk.CTk): row=1, column=0, sticky="nsew", padx=PADDING, pady=PADDING, ) + self._log_panel = panel panel.grid_rowconfigure(1, weight=1) panel.grid_columnconfigure(0, weight=1) @@ -496,6 +567,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,15 +583,20 @@ 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( panel, label_text="FSBL ELF (.elf):", 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) self._fsbl_selector.set_path(str(resolved)) self._fsbl_selector.grid(row=3, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) @@ -527,9 +605,12 @@ class MainWindow(ctk.CTk): panel, label_text="Bootloader BIT (.bit):", 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) self._bit_selector.set_path(str(resolved)) self._bit_selector.grid(row=4, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) @@ -538,9 +619,12 @@ class MainWindow(ctk.CTk): panel, label_text="Bootloader ELF (.elf):", 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) self._elf_selector.set_path(str(resolved)) self._elf_selector.grid(row=5, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) @@ -549,9 +633,12 @@ class MainWindow(ctk.CTk): panel, label_text="Bootloader BIN (.bin):", 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) self._bootloader_bin_selector.set_path(str(resolved)) self._bootloader_bin_selector.grid(row=6, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL) @@ -582,12 +669,23 @@ class MainWindow(ctk.CTk): panel, label_text="Firmware BIN (.bin):", 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, @@ -702,15 +800,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 +820,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 +844,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 +983,113 @@ 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) -> None: + """Handle window resize / maximize events. + + When the window is maximized, scale content to fit one screen + and hide scrollbars. When restored, use normal sizing with + scrollbars on overflow. + """ + try: + 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: + """Scale content and toggle scrollbars based on maximized state.""" + if not hasattr(self, 'main_frame'): + return + sf = self.main_frame + has_scrollbars = hasattr(sf, '_scrollbar_y') + + if self._maximized: + # Hide scrollbars when maximized + if has_scrollbars: + sf._scrollbar_y.grid_remove() + sf._scrollbar_x.grid_remove() + # Scale down to fit one screen + self._scale_ui(0.6) + # Hide the log panel when maximized to save space + if hasattr(self, '_log_panel'): + self._log_panel.grid_remove() + else: + # Show scrollbars when not maximized + if has_scrollbars: + sf._scrollbar_y.grid() + sf._scrollbar_x.grid() + # Restore normal scale + self._scale_ui(1.0) + # Show the log panel when not maximized + if hasattr(self, '_log_panel'): + self._log_panel.grid() + + def _scale_ui(self, factor: float) -> None: + """Scale UI elements by a factor to fit screen. + + Args: + factor: Scaling factor (1.0 = normal, <1.0 = smaller). + """ + # Scale font sizes in key labels + scale_font = lambda orig: (orig[0], max(8, int(orig[1] * factor)), orig[2] if len(orig) > 2 else "") + + # Header fonts + if hasattr(self, '_vitis_status'): + self._vitis_status.configure(font=scale_font(FONT_BODY)) + if hasattr(self, '_ip_status'): + self._ip_status.configure(font=scale_font(FONT_BODY)) + if hasattr(self, '_jtag_status'): + self._jtag_status.configure(font=scale_font(FONT_BODY)) + + # Workflow panel fonts + if hasattr(self, '_steps'): + for step in self._steps: + if hasattr(step, '_title_label'): + step._title_label.configure(font=scale_font((FONT_BODY[0], 13, "bold"))) + if hasattr(step, '_desc_label'): + step._desc_label.configure(font=scale_font(FONT_SMALL)) + if hasattr(step, '_action_btn'): + step._action_btn.configure(font=scale_font((FONT_BODY[0], 12))) + + # Config panel fonts + if hasattr(self, '_xilinx_entry'): + self._xilinx_entry.configure(font=scale_font(FONT_BODY)) + if hasattr(self, '_ip_entry'): + self._ip_entry.configure(font=scale_font(FONT_BODY)) + + # Log display font + if hasattr(self, '_log_display') and hasattr(self._log_display, '_text_widget'): + self._log_display._text_widget.configure(font=scale_font(FONT_MONO)) + + # Status display font + if hasattr(self, '_status') and hasattr(self._status, '_status_text'): + self._status._status_text.configure(font=scale_font(FONT_BODY)) + + # Scale padding/spacing on all frames + if hasattr(self, 'config_frame'): + self.config_frame.configure(padx=int(5 * factor), pady=int(5 * factor)) + if hasattr(self, 'workflow_frame'): + self.workflow_frame.configure(padx=int(5 * factor), pady=int(5 * factor)) + if hasattr(self, 'log_frame'): + self.log_frame.configure(padx=int(5 * factor), pady=int(5 * factor)) + if hasattr(self, 'status_frame'): + self.status_frame.configure(padx=int(5 * factor), pady=int(5 * factor)) + + # Scale step frame spacing + if hasattr(self, '_steps'): + for step in self._steps: + if hasattr(step, '_inner_frame'): + step._inner_frame.configure(padx=int(5 * factor), pady=int(5 * factor)) 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 420807b..5521b5c 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 (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") 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,10 @@ 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._suppress_callback: bool = False # True during construction self._create_widgets(label_text) @@ -524,10 +546,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 +571,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) - if self._callback: + self._full_path = path + self._display_text.set(os.path.basename(path) if path else "") + if self._callback and not self._suppress_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 and not self._suppress_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): @@ -650,15 +699,15 @@ class SubStepFrame(ctk.CTkFrame): """Collapsible frame showing flash operation sub-steps with status. Colors match StepHeader: gray pending, blue running (+pulse), - green complete, red error. Default expanded. + green complete, red error. Default collapsed. """ def __init__(self, parent: ctk.CTkFrame) -> None: super().__init__(parent, fg_color="transparent") - self._collapsed = False + self._collapsed = True self._toggle_btn = ctk.CTkButton( - self, text="▼", width=22, height=22, + self, text="▶", width=22, height=22, font=(FONT_SMALL[0], 9), command=self._toggle, ) self._toggle_btn.grid(row=0, column=0, padx=(4, 4), pady=(2, 2), sticky="w") @@ -683,6 +732,16 @@ class SubStepFrame(ctk.CTkFrame): self.grid_columnconfigure(1, weight=1) self._pulse_job: str | None = None + # Apply initial collapsed state (hide sub-items) + self._hide() + + def _hide(self) -> None: + """Hide sub-items without toggling state.""" + for item in self._sub_items: + item["dot"].grid_remove() + item["label"].grid_remove() + item["pct"].grid_remove() + def _toggle(self) -> None: self._collapsed = not self._collapsed self._toggle_btn.configure(text="▶" if self._collapsed else "▼") @@ -784,19 +843,19 @@ class TftpSubStepFrame(ctk.CTkFrame): Sub-steps: Upload, Download Verify, CRC Check, Reboot, Boot Verify. Colors match StepHeader: gray pending, blue running (+pulse), - green complete, red error. Default expanded. + green complete, red error. Default collapsed. """ def __init__(self, parent: ctk.CTkFrame, substep_names: list[str] | None = None) -> None: super().__init__(parent, fg_color="transparent") - self._collapsed = False + self._collapsed = True self._pulse_job: str | None = None self._substep_names = substep_names or ["Upload", "Download Verify", "CRC Check", "Reboot", "Boot Verify"] self._num_steps = len(self._substep_names) self._toggle_btn = ctk.CTkButton( - self, text="▼", width=22, height=22, + self, text="▶", width=22, height=22, font=(FONT_SMALL[0], 9), command=self._toggle, ) self._toggle_btn.grid(row=0, column=0, padx=(4, 4), pady=(2, 2), sticky="w") @@ -819,6 +878,16 @@ class TftpSubStepFrame(ctk.CTkFrame): self.grid_columnconfigure(1, weight=1) + # Apply initial collapsed state (hide sub-items) + self._hide() + + def _hide(self) -> None: + """Hide sub-items without toggling state.""" + for item in self._sub_items: + item["dot"].grid_remove() + item["label"].grid_remove() + item["pct"].grid_remove() + def _toggle(self) -> None: self._collapsed = not self._collapsed self._toggle_btn.configure(text="▶" if self._collapsed else "▼") @@ -967,7 +1036,7 @@ class UartMonitorWindow(ctk.CTkToplevel): self._build_log_area() self._build_status_bar() - # Try to open serial port after window is drawn + # Try to open serial port after window is drawn self.after(100, self._try_open) # ── Title Bar ──────────────────────────────────────────────