Compare commits

...
7 Commits
4 changed files with 159 additions and 211 deletions
+96 -88
View File
@@ -218,20 +218,15 @@ 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 (may not exist yet during _build_config_panel)
# Sync IP address
if self._ip_string_var:
self._config.zynq_ip = self._ip_string_var.get()
# Sync Xilinx Kit path
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'):
# Sync serial port
self._config.serial_port = self._port_var.get()
# Sync file paths from FileSelectors
files = self._get_selected_files()
@@ -250,27 +245,8 @@ 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))
# 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).
@@ -317,31 +293,21 @@ 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:
"""Build the complete UI layout."""
# 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)
# Scrollable main container — scrollbar appears when content overflows
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)
@@ -350,7 +316,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)
@@ -519,6 +485,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)
@@ -591,9 +558,7 @@ class MainWindow(ctk.CTk):
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)
@@ -605,9 +570,7 @@ 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)
@@ -619,9 +582,7 @@ 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)
@@ -633,9 +594,7 @@ 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)
@@ -669,23 +628,13 @@ 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,
@@ -989,16 +938,14 @@ class MainWindow(ctk.CTk):
"""Hide the persistent UART warning label."""
self._uart_warning_label.grid_remove()
def _on_window_resize(self, event: tk.Event | None = None) -> None:
def _on_window_resize(self, event) -> 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.
When the window is maximized, scale content to fit one screen
and hide scrollbars. When restored, use normal sizing with
scrollbars on overflow.
"""
try:
# Get the current window state
window_state = self.state()
is_maximized = window_state == "zoomed"
@@ -1009,20 +956,89 @@ class MainWindow(ctk.CTk):
pass
def _adjust_layout_for_maximize(self) -> None:
"""Adjust layout based on maximized state.
"""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')
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)
# 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:
# 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
# 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.
@@ -1722,20 +1738,12 @@ class MainWindow(ctk.CTk):
def _read_serial(self) -> None:
"""Open the UART Monitor window."""
port = self._port_var.get().strip()
if not port:
self._log_message(" ✗ No serial port selected")
return
port = self._port_var.get()
baudrate = self._config.serial_baudrate if self._config else 115200
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."""
+49 -80
View File
@@ -6,7 +6,6 @@ consistent styling from gui/styles.py.
from __future__ import annotations
import os
import threading
import customtkinter as ctk
@@ -439,9 +438,7 @@ class ProgressIndicator(ctk.CTkFrame):
class StatusDisplay(ctk.CTkFrame):
"""Status display widget for operation results.
Shows status messages in a multi-line, expandable text area that
uses the full available space. Messages are color-coded and
automatically wrapped.
Shows a status message with color-coded feedback.
"""
def __init__(self, master, **kwargs):
@@ -455,19 +452,15 @@ class StatusDisplay(ctk.CTkFrame):
self._create_widgets()
def _create_widgets(self) -> None:
"""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(
"""Create the status display UI."""
self._status_label = ctk.CTkLabel(
self,
text="Ready",
font=FONT_BODY,
corner_radius=CORNER_RADIUS,
state="disabled",
wrap="word",
anchor="w",
)
self._status_text.grid(
row=0, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL
self._status_label.pack(
fill="x", padx=PADDING, pady=PADDING_SMALL
)
def set_status(self, message: str, status: str = "info") -> None:
@@ -477,32 +470,20 @@ class StatusDisplay(ctk.CTkFrame):
message: Status message text.
status: Status type for coloring ('info', 'success', 'error', 'warning').
"""
self._status_text.configure(state="normal")
self._status_text.delete("1.0", "end")
self._status_label.configure(text=message)
colors = {
"info": INFO_COLOR,
"success": SUCCESS_COLOR,
"error": DANGER_COLOR,
"warning": WARNING_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")
self._status_label.configure(text_color=colors.get(status, INFO_COLOR))
class FileSelector(ctk.CTkFrame):
"""File selector widget with browse button and path display.
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.
Allows users to browse for files and displays the selected path.
"""
def __init__(
@@ -525,10 +506,7 @@ class FileSelector(ctk.CTkFrame):
self._callback = callback
self._file_types = file_types or [("All files", "*")]
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._selected_path = ctk.StringVar(value="")
self._create_widgets(label_text)
@@ -546,10 +524,10 @@ class FileSelector(ctk.CTkFrame):
)
label.grid(row=0, column=0, sticky="w", padx=(PADDING, PADDING_SMALL))
# Path entry — shows only the filename
# Path entry
self._entry = ctk.CTkEntry(
self,
textvariable=self._display_text,
textvariable=self._selected_path,
font=FONT_MONO,
state="readonly",
)
@@ -571,60 +549,33 @@ 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="Select file",
title=f"Select {self._selected_path.get() or '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 (absolute or relative).
Internally stores the absolute path and displays only the
filename.
"""Set the selected file path.
Args:
path: Absolute or relative path to the selected file.
path: Absolute path to the selected file.
"""
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._selected_path.set(path)
if self._callback:
self._callback(path)
def get_path(self) -> str:
"""Get the currently selected file path (absolute).
"""Get the currently selected file path.
Returns:
Selected file path string.
"""
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
return self._selected_path.get()
class LogDisplay(ctk.CTkFrame):
@@ -699,15 +650,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")
@@ -732,6 +683,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 "")
@@ -833,19 +794,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")
@@ -868,6 +829,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 "")
@@ -1002,8 +973,6 @@ 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
@@ -1126,8 +1095,7 @@ class UartMonitorWindow(ctk.CTkToplevel):
def _bg_open() -> None:
try:
import serial
from serial_monitor import _open_serial
ser = _open_serial(self._port, self._baudrate, 1)
ser = serial.Serial(self._port, self._baudrate, timeout=1)
ser.close()
self.after(0, self._on_open_success)
except Exception as e:
@@ -1253,4 +1221,5 @@ class UartMonitorWindow(ctk.CTkToplevel):
if self._monitor:
self._monitor.stop()
self._monitor = None
self.grab_release()
self.destroy()
+2 -3
View File
@@ -125,12 +125,11 @@ def reboot_via_serial(
try:
import serial
from serial_monitor import _open_serial
with _open_serial(
with serial.Serial(
config.serial_port,
config.serial_baudrate,
timeout=5.0,
timeout=5,
) as ser:
ser.reset_input_buffer()
# Send Ctrl+C to break out of any running process
+4 -32
View File
@@ -8,7 +8,6 @@ Provides UartMonitor for continuous background monitoring.
from __future__ import annotations
import re
import sys
import threading
import time
from dataclasses import dataclass
@@ -18,33 +17,6 @@ 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."""
@@ -197,7 +169,7 @@ def check_uart_available(
# 3. Try to open and read
try:
with _open_serial(port, baudrate, timeout) as ser:
with serial.Serial(port, baudrate, timeout=timeout) as ser:
ser.reset_input_buffer()
lines: list[str] = []
while ser.in_waiting:
@@ -271,7 +243,7 @@ def test_serial_version(
import serial
try:
with _open_serial(port, baudrate, timeout) as ser:
with serial.Serial(port, baudrate, timeout=timeout) as ser:
ser.reset_input_buffer()
ser.reset_output_buffer()
@@ -412,7 +384,7 @@ class UartMonitor:
# Quick port check before starting thread
try:
test_ser = _open_serial(self._port, self._baudrate, 1)
test_ser = serial.Serial(self._port, self._baudrate, timeout=1)
test_ser.close()
except serial.SerialException as e:
if self.on_error:
@@ -438,7 +410,7 @@ class UartMonitor:
def _read_loop(self) -> None:
"""Main read loop running in background thread."""
try:
ser = _open_serial(
ser = serial.Serial(
self._port,
self._baudrate,
timeout=self._timeout,