fix: 5 UI improvements — relative paths, filename-only display, real-time config, multi-line status, maximize layout

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 <Configure> 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.
This commit is contained in:
2026-06-12 10:13:11 +08:00
parent 36fbb6a662
commit a196211708
2 changed files with 175 additions and 41 deletions
+107 -21
View File
@@ -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("<Configure>", 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.
+68 -20
View File
@@ -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):