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):