feat: initial Zynq XC7Z100 Flasher GUI project
Add cross-platform GUI application for programming Zynq devices with step-by-step workflow: - CustomTkinter GUI with dark/light theme support - 4-step workflow: Check Env → Flash → Bootloader → Main Program - YAML configuration with relative path resolution - Serial monitoring and boot log parsing - TFTP upload with CRC32 verification - SSH/serial reboot management - Vivado/Impact tool auto-detection Project structure: - app.py: Entry point - src/: Core modules (config, flash, bitstream, tftp, serial, gui) - config/: Default configuration template - .flasher_env/: Python virtual environment
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Zynq XC7Z100 Flasher GUI — GUI components."""
|
||||
@@ -0,0 +1,882 @@
|
||||
"""Main GUI window for Zynq XC7Z100 Flasher.
|
||||
|
||||
Integrates all backend modules into a modern, step-by-step workflow GUI
|
||||
built with CustomTkinter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from config_manager import Config
|
||||
from vitis_checker import check_vitis, get_tool_status_dict
|
||||
from flash_programmer import full_flash_program, FlashResult
|
||||
from bitstream_programmer import full_bitstream_program, BitstreamResult
|
||||
from ip_verifier import ping_ip
|
||||
from tftp_manager import tftp_upload_verify, TftpResult
|
||||
from reboot_manager import reboot_zynq, RebootResult
|
||||
from boot_verifier import verify_boot, BootVerificationResult
|
||||
from serial_monitor import detect_serial_ports, parse_boot_output
|
||||
from gui.styles import (
|
||||
WINDOW_WIDTH,
|
||||
WINDOW_HEIGHT,
|
||||
WINDOW_TITLE,
|
||||
FONT_TITLE,
|
||||
FONT_HEADING,
|
||||
FONT_BODY,
|
||||
FONT_SMALL,
|
||||
FONT_MONO,
|
||||
PADDING,
|
||||
PADDING_LARGE,
|
||||
PADDING_SMALL,
|
||||
CORNER_RADIUS,
|
||||
PRIMARY_COLOR,
|
||||
SUCCESS_COLOR,
|
||||
WARNING_COLOR,
|
||||
DANGER_COLOR,
|
||||
INFO_COLOR,
|
||||
CONNECTOR_COLOR,
|
||||
get_theme,
|
||||
)
|
||||
from gui.widgets import (
|
||||
StepHeader,
|
||||
ProgressIndicator,
|
||||
StatusDisplay,
|
||||
FileSelector,
|
||||
LogDisplay,
|
||||
)
|
||||
|
||||
|
||||
class MainWindow(ctk.CTk):
|
||||
"""Main application window for Zynq Flasher GUI."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the main window."""
|
||||
super().__init__()
|
||||
|
||||
self.title(WINDOW_TITLE)
|
||||
self.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}")
|
||||
self.minsize(800, 600)
|
||||
|
||||
ctk.set_appearance_mode("dark")
|
||||
ctk.set_default_color_theme("blue")
|
||||
|
||||
self._config: Config | None = None
|
||||
self._config_path = self._find_config()
|
||||
self._steps: list[StepHeader] = []
|
||||
self._step_statuses: dict[int, str] = {}
|
||||
self._is_running = False
|
||||
self._worker_thread: threading.Thread | None = None
|
||||
|
||||
self._load_config()
|
||||
self._build_ui()
|
||||
self._check_vitis()
|
||||
self._refresh_ports_initial()
|
||||
self._update_step_dependencies()
|
||||
|
||||
# Handle window close
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
# ── Config ─────────────────────────────────────────────────
|
||||
|
||||
def _find_config(self) -> Path | None:
|
||||
"""Find the configuration file.
|
||||
|
||||
Checks common locations:
|
||||
1. config/default_config.yaml (relative to app)
|
||||
2. config/zynq_flasher.yaml
|
||||
3. User-specified path via env var
|
||||
|
||||
Returns:
|
||||
Path to config file, or None if not found.
|
||||
"""
|
||||
candidates = [
|
||||
Path(__file__).parent.parent / "config" / "default_config.yaml",
|
||||
]
|
||||
|
||||
env_path = os.environ.get("ZYNQ_CONFIG")
|
||||
if env_path:
|
||||
candidates.insert(0, Path(env_path))
|
||||
|
||||
for path in candidates:
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def _load_config(self) -> None:
|
||||
"""Load configuration from file or create default."""
|
||||
if self._config_path:
|
||||
try:
|
||||
self._config = Config.from_file(self._config_path)
|
||||
except Exception:
|
||||
self._config = Config.from_default()
|
||||
else:
|
||||
self._config = Config.from_default()
|
||||
|
||||
def _save_config(self) -> None:
|
||||
"""Save current configuration to file."""
|
||||
if self._config and self._config.config_path:
|
||||
self._config.save()
|
||||
|
||||
# ── UI Construction ────────────────────────────────────────
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
"""Build the complete UI layout."""
|
||||
# Main container with padding
|
||||
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)
|
||||
|
||||
# ── Header ──
|
||||
self._build_header(main_frame)
|
||||
|
||||
# ── Left Panel: Workflow Steps ──
|
||||
left_frame = ctk.CTkFrame(main_frame)
|
||||
left_frame.grid(row=0, column=0, rowspan=2, sticky="nsew", padx=(0, PADDING))
|
||||
left_frame.grid_rowconfigure(3, weight=1)
|
||||
left_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self._build_workflow_panel(left_frame)
|
||||
self._build_log_panel(left_frame)
|
||||
|
||||
# ── Right Panel: Configuration ──
|
||||
right_frame = ctk.CTkFrame(main_frame)
|
||||
right_frame.grid(row=0, column=1, sticky="nsew", padx=(PADDING, 0))
|
||||
right_frame.grid_rowconfigure(4, weight=1)
|
||||
|
||||
self._build_config_panel(right_frame)
|
||||
self._build_serial_panel(right_frame)
|
||||
self._build_status_panel(right_frame)
|
||||
|
||||
def _build_header(self, parent: ctk.CTkFrame) -> None:
|
||||
"""Build the application header."""
|
||||
header = ctk.CTkFrame(parent, corner_radius=CORNER_RADIUS)
|
||||
header.grid(row=0, column=0, columnspan=2, sticky="nsew", pady=(0, PADDING))
|
||||
header.grid_columnconfigure(1, weight=1)
|
||||
|
||||
title = ctk.CTkLabel(
|
||||
header,
|
||||
text="Zynq XC7Z100 Flasher",
|
||||
font=FONT_TITLE,
|
||||
)
|
||||
title.grid(row=0, column=0, padx=PADDING, pady=PADDING)
|
||||
|
||||
self._vitis_status = ctk.CTkLabel(
|
||||
header,
|
||||
text="Vitis: Checking...",
|
||||
font=FONT_BODY,
|
||||
)
|
||||
self._vitis_status.grid(row=0, column=1, sticky="e", padx=PADDING)
|
||||
|
||||
self._ip_status = ctk.CTkLabel(
|
||||
header,
|
||||
text=f"IP: {self._config.zynq_ip}",
|
||||
font=FONT_BODY,
|
||||
)
|
||||
self._ip_status.grid(row=0, column=2, sticky="e", padx=PADDING)
|
||||
|
||||
def _build_workflow_panel(self, parent: ctk.CTkFrame) -> None:
|
||||
"""Build the step-by-step workflow panel."""
|
||||
panel = ctk.CTkFrame(parent, corner_radius=CORNER_RADIUS)
|
||||
panel.grid(
|
||||
row=0, column=0, sticky="nsew",
|
||||
padx=PADDING, pady=PADDING,
|
||||
)
|
||||
panel.grid_rowconfigure(9, weight=1)
|
||||
|
||||
title = ctk.CTkLabel(
|
||||
panel,
|
||||
text="Workflow",
|
||||
font=FONT_HEADING,
|
||||
)
|
||||
title.grid(row=0, column=0, padx=PADDING, pady=PADDING)
|
||||
|
||||
# Step definitions with descriptions
|
||||
step_defs = [
|
||||
{"num": 1, "title": "Check Environment", "desc": "Verify tools & connectivity"},
|
||||
{"num": 2, "title": "Program & Verify Flash", "desc": "Wipe, program, and verify flash"},
|
||||
{"num": 3, "title": "Load Bootloader", "desc": "Program BIT + run ELF"},
|
||||
{"num": 4, "title": "Load Main Program", "desc": "TFTP upload, reboot, verify boot"},
|
||||
]
|
||||
|
||||
# Create step headers with callbacks
|
||||
self._steps = []
|
||||
self._step_statuses = {}
|
||||
for i, defn in enumerate(step_defs):
|
||||
step = StepHeader(
|
||||
panel,
|
||||
step_number=defn["num"],
|
||||
title=defn["title"],
|
||||
description=defn["desc"],
|
||||
callback=lambda idx=i: self._execute_step(idx),
|
||||
can_run=(i == 0), # First step always runnable
|
||||
)
|
||||
self._steps.append(step)
|
||||
self._step_statuses[i] = "pending"
|
||||
|
||||
# Add connector line between steps
|
||||
if i > 0:
|
||||
connector = ctk.CTkFrame(
|
||||
panel,
|
||||
width=2,
|
||||
height=8,
|
||||
fg_color=CONNECTOR_COLOR,
|
||||
corner_radius=1,
|
||||
)
|
||||
connector.grid(
|
||||
row=i * 2, column=0,
|
||||
sticky="n",
|
||||
padx=(PADDING_SMALL + 24 + PADDING_SMALL, PADDING_SMALL),
|
||||
)
|
||||
|
||||
# Position step
|
||||
step.grid(
|
||||
row=i * 2 + 1, column=0,
|
||||
sticky="nsew",
|
||||
padx=PADDING_SMALL,
|
||||
pady=PADDING_SMALL,
|
||||
)
|
||||
|
||||
# Progress indicator
|
||||
self._progress = ProgressIndicator(panel)
|
||||
self._progress.grid(
|
||||
row=8, column=0,
|
||||
sticky="nsew",
|
||||
padx=PADDING,
|
||||
pady=PADDING,
|
||||
)
|
||||
|
||||
# Run button
|
||||
self._run_btn = ctk.CTkButton(
|
||||
panel,
|
||||
text="▶ Run All Steps",
|
||||
font=FONT_HEADING,
|
||||
command=self._run_all_steps,
|
||||
height=40,
|
||||
corner_radius=CORNER_RADIUS,
|
||||
)
|
||||
self._run_btn.grid(
|
||||
row=9, column=0,
|
||||
sticky="nsew",
|
||||
padx=PADDING,
|
||||
pady=PADDING,
|
||||
)
|
||||
|
||||
def _build_log_panel(self, parent: ctk.CTkFrame) -> None:
|
||||
"""Build the log display panel."""
|
||||
panel = ctk.CTkFrame(parent, corner_radius=CORNER_RADIUS)
|
||||
panel.grid(
|
||||
row=1, column=0, sticky="nsew",
|
||||
padx=PADDING, pady=PADDING,
|
||||
)
|
||||
panel.grid_rowconfigure(1, weight=1)
|
||||
panel.grid_columnconfigure(0, weight=1)
|
||||
|
||||
title = ctk.CTkLabel(
|
||||
panel,
|
||||
text="Log",
|
||||
font=FONT_HEADING,
|
||||
)
|
||||
title.grid(row=0, column=0, padx=PADDING, pady=PADDING)
|
||||
|
||||
self._log_display = LogDisplay(panel, height=250)
|
||||
self._log_display.grid(row=1, column=0, sticky="nsew", padx=PADDING, pady=PADDING)
|
||||
|
||||
# Clear button
|
||||
clear_btn = ctk.CTkButton(
|
||||
panel,
|
||||
text="Clear Log",
|
||||
font=FONT_SMALL,
|
||||
command=self._log_display.clear,
|
||||
width=100,
|
||||
)
|
||||
clear_btn.grid(row=0, column=1, padx=PADDING, pady=PADDING)
|
||||
|
||||
def _build_config_panel(self, parent: ctk.CTkFrame) -> None:
|
||||
"""Build the configuration panel."""
|
||||
panel = ctk.CTkFrame(parent, corner_radius=CORNER_RADIUS)
|
||||
panel.grid(
|
||||
row=0, column=0, sticky="nsew",
|
||||
padx=PADDING, pady=PADDING,
|
||||
)
|
||||
panel.grid_rowconfigure(5, weight=1)
|
||||
|
||||
title = ctk.CTkLabel(
|
||||
panel,
|
||||
text="Configuration",
|
||||
font=FONT_HEADING,
|
||||
)
|
||||
title.grid(row=0, column=0, padx=PADDING, pady=PADDING)
|
||||
|
||||
# IP address
|
||||
ip_frame = ctk.CTkFrame(panel)
|
||||
ip_frame.grid(row=1, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
|
||||
ip_frame.grid_columnconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkLabel(ip_frame, text="Zynq IP:", font=FONT_BODY).grid(row=0, column=0, sticky="w")
|
||||
self._ip_entry = ctk.CTkEntry(ip_frame, textvariable=ctk.StringVar(value=self._config.zynq_ip))
|
||||
self._ip_entry.grid(row=0, column=1, sticky="nsew", padx=PADDING_SMALL)
|
||||
|
||||
# BIN path
|
||||
self._bin_selector = FileSelector(
|
||||
panel,
|
||||
label_text="BIN File:",
|
||||
file_types=[("BIN files", "*.bin"), ("All files", "*")],
|
||||
)
|
||||
if self._config.bin_path:
|
||||
resolved = self._config.resolve_path(self._config.bin_path)
|
||||
self._bin_selector.set_path(str(resolved))
|
||||
self._bin_selector.grid(row=2, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
|
||||
|
||||
# ELF path
|
||||
self._elf_selector = FileSelector(
|
||||
panel,
|
||||
label_text="ELF File:",
|
||||
file_types=[("ELF files", "*.elf"), ("All files", "*")],
|
||||
)
|
||||
if self._config.elf_path:
|
||||
resolved = self._config.resolve_path(self._config.elf_path)
|
||||
self._elf_selector.set_path(str(resolved))
|
||||
self._elf_selector.grid(row=3, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
|
||||
|
||||
# BIT path
|
||||
self._bit_selector = FileSelector(
|
||||
panel,
|
||||
label_text="BIT File:",
|
||||
file_types=[("BIT files", "*.bit"), ("All files", "*")],
|
||||
)
|
||||
if self._config.bit_path:
|
||||
resolved = self._config.resolve_path(self._config.bit_path)
|
||||
self._bit_selector.set_path(str(resolved))
|
||||
self._bit_selector.grid(row=4, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
|
||||
|
||||
# Save button
|
||||
save_btn = ctk.CTkButton(
|
||||
panel,
|
||||
text="Save Config",
|
||||
font=FONT_SMALL,
|
||||
command=self._save_config,
|
||||
)
|
||||
save_btn.grid(row=6, column=0, padx=PADDING, pady=PADDING)
|
||||
|
||||
def _build_serial_panel(self, parent: ctk.CTkFrame) -> None:
|
||||
"""Build the serial monitor panel."""
|
||||
panel = ctk.CTkFrame(parent, corner_radius=CORNER_RADIUS)
|
||||
panel.grid(
|
||||
row=1, column=0, sticky="nsew",
|
||||
padx=PADDING, pady=PADDING,
|
||||
)
|
||||
panel.grid_rowconfigure(3, weight=1)
|
||||
panel.grid_columnconfigure(0, weight=1)
|
||||
|
||||
title = ctk.CTkLabel(
|
||||
panel,
|
||||
text="Serial Monitor",
|
||||
font=FONT_HEADING,
|
||||
)
|
||||
title.grid(row=0, column=0, padx=PADDING, pady=PADDING)
|
||||
|
||||
# Port selector
|
||||
port_frame = ctk.CTkFrame(panel)
|
||||
port_frame.grid(row=1, column=0, sticky="nsew", padx=PADDING, pady=PADDING_SMALL)
|
||||
port_frame.grid_columnconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkLabel(port_frame, text="Port:", font=FONT_BODY).grid(row=0, column=0, sticky="w")
|
||||
self._port_var = ctk.StringVar(value=self._config.serial_port)
|
||||
self._port_menu = ctk.CTkComboBox(
|
||||
port_frame,
|
||||
values=[""] + [p.device for p in detect_serial_ports()],
|
||||
variable=self._port_var,
|
||||
)
|
||||
self._port_menu.grid(row=0, column=1, sticky="nsew", padx=PADDING_SMALL)
|
||||
|
||||
# Refresh button
|
||||
refresh_btn = ctk.CTkButton(
|
||||
port_frame,
|
||||
text="Refresh",
|
||||
font=FONT_SMALL,
|
||||
command=self._refresh_ports,
|
||||
width=80,
|
||||
)
|
||||
refresh_btn.grid(row=0, column=2, padx=PADDING_SMALL)
|
||||
|
||||
# Read button
|
||||
read_btn = ctk.CTkButton(
|
||||
panel,
|
||||
text="Read Serial",
|
||||
font=FONT_BODY,
|
||||
command=self._read_serial,
|
||||
)
|
||||
read_btn.grid(row=2, column=0, padx=PADDING, pady=PADDING_SMALL)
|
||||
|
||||
def _build_status_panel(self, parent: ctk.CTkFrame) -> None:
|
||||
"""Build the status display panel."""
|
||||
panel = ctk.CTkFrame(parent, corner_radius=CORNER_RADIUS)
|
||||
panel.grid(
|
||||
row=2, column=0, sticky="nsew",
|
||||
padx=PADDING, pady=PADDING,
|
||||
)
|
||||
|
||||
self._status = StatusDisplay(panel)
|
||||
self._status.pack(fill="x", padx=PADDING, pady=PADDING)
|
||||
|
||||
# ── Workflow Steps ─────────────────────────────────────────
|
||||
|
||||
def _log_message(self, message: str) -> None:
|
||||
"""Append a message to the log display (thread-safe)."""
|
||||
self.after(0, lambda: self._log_display.append(message))
|
||||
|
||||
def _set_step_status(self, index: int, status: str) -> None:
|
||||
"""Set the status of a workflow step.
|
||||
|
||||
Args:
|
||||
index: Step index (0-based).
|
||||
status: Status string ('pending', 'running', 'complete', 'error').
|
||||
"""
|
||||
if 0 <= index < len(self._steps):
|
||||
self._steps[index].set_status(status)
|
||||
|
||||
def _get_selected_files(self) -> dict:
|
||||
"""Get selected file paths from the UI.
|
||||
|
||||
Returns:
|
||||
Dictionary with bin_path, elf_path, bit_path.
|
||||
"""
|
||||
return {
|
||||
"bin_path": self._bin_selector.get_path(),
|
||||
"elf_path": self._elf_selector.get_path(),
|
||||
"bit_path": self._bit_selector.get_path(),
|
||||
}
|
||||
|
||||
# ── Step Implementations ───────────────────────────────────
|
||||
|
||||
def _run_step_1_check_environment(self) -> bool:
|
||||
"""Step 1: Check environment (Vitis/Vivado tools + Zynq IP)."""
|
||||
self._log_message("Step 1: Checking environment...")
|
||||
|
||||
if not self._config:
|
||||
self._log_message(" No config loaded")
|
||||
return False
|
||||
|
||||
# Check Vitis/Vivado tools
|
||||
self._log_message(" Checking Vitis/Vivado tools...")
|
||||
result = check_vitis(self._config)
|
||||
status_dict = get_tool_status_dict(result)
|
||||
|
||||
for tool_name, tool_info in status_dict["tools"].items():
|
||||
if tool_info["found"]:
|
||||
self._log_message(f" ✓ {tool_name}: {tool_info['path']}")
|
||||
else:
|
||||
self._log_message(f" ✗ {tool_name}: {tool_info['error']}")
|
||||
|
||||
if result.is_ready:
|
||||
self._vitis_status.configure(text="Vitis: Ready", text_color=SUCCESS_COLOR)
|
||||
self._log_message(" Vitis/Vivado tools available")
|
||||
else:
|
||||
self._vitis_status.configure(text="Vitis: Partial", text_color=WARNING_COLOR)
|
||||
self._log_message(f" Issues: {'; '.join(result.errors)}")
|
||||
|
||||
# Check Zynq IP connectivity
|
||||
ip = self._config.zynq_ip
|
||||
self._log_message(f" Checking Zynq IP {ip}...")
|
||||
if ping_ip(ip, self._config.ping_count, self._config.ping_timeout):
|
||||
self._log_message(f" ✓ {ip} is reachable")
|
||||
self._ip_status.configure(text=f"IP: {ip} ✓", text_color=SUCCESS_COLOR)
|
||||
else:
|
||||
self._log_message(f" ✗ {ip} is not reachable")
|
||||
self._ip_status.configure(text=f"IP: {ip} ✗", text_color=DANGER_COLOR)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _run_step_2_program_flash(self) -> bool:
|
||||
"""Step 2: Program and verify Flash."""
|
||||
if not self._config:
|
||||
return False
|
||||
|
||||
files = self._get_selected_files()
|
||||
if not files["bin_path"]:
|
||||
self._log_message(" No BIN file selected")
|
||||
return False
|
||||
|
||||
self._log_message(f"Step 2: Programming Flash with {files['bin_path']}...")
|
||||
|
||||
try:
|
||||
bin_path = Path(files["bin_path"])
|
||||
results = full_flash_program(self._config, bin_path, self._flash_callback)
|
||||
|
||||
for result in results:
|
||||
status = "✓" if result.success else "✗"
|
||||
self._log_message(f" {status} {result.step}: {result.message}")
|
||||
|
||||
return all(r.success for r in results)
|
||||
except Exception as e:
|
||||
self._log_message(f" ✗ Flash error: {e}")
|
||||
return False
|
||||
|
||||
def _flash_callback(self, status: str, message: str) -> None:
|
||||
"""Callback for flash programming progress."""
|
||||
self._log_message(f" [{status}] {message}")
|
||||
|
||||
def _run_step_3_load_bootloader(self) -> bool:
|
||||
"""Step 3: Load bootloader (BIT + ELF) and verify."""
|
||||
if not self._config:
|
||||
return False
|
||||
|
||||
files = self._get_selected_files()
|
||||
if not files["bit_path"]:
|
||||
self._log_message(" No BIT file selected")
|
||||
return False
|
||||
|
||||
self._log_message(f"Step 3: Loading bootloader (BIT + ELF)...")
|
||||
self._log_message(f" BIT: {files['bit_path']}")
|
||||
if files["elf_path"]:
|
||||
self._log_message(f" ELF: {files['elf_path']}")
|
||||
|
||||
try:
|
||||
bit_path = Path(files["bit_path"])
|
||||
elf_path = Path(files["elf_path"]) if files["elf_path"] else bit_path.parent / "temp.elf"
|
||||
|
||||
results = full_bitstream_program(
|
||||
self._config, bit_path, elf_path,
|
||||
self._bitstream_callback,
|
||||
)
|
||||
|
||||
for result in results:
|
||||
status = "✓" if result.success else "✗"
|
||||
self._log_message(f" {status} {result.step}: {result.message}")
|
||||
|
||||
return all(r.success for r in results)
|
||||
except Exception as e:
|
||||
self._log_message(f" ✗ Bootloader error: {e}")
|
||||
return False
|
||||
|
||||
def _bitstream_callback(self, status: str, message: str) -> None:
|
||||
"""Callback for bitstream programming progress."""
|
||||
self._log_message(f" [{status}] {message}")
|
||||
|
||||
def _run_step_4_load_main_program(self) -> bool:
|
||||
"""Step 4: Load main program (TFTP upload + reboot + boot verify)."""
|
||||
if not self._config:
|
||||
return False
|
||||
|
||||
files = self._get_selected_files()
|
||||
if not files["bin_path"]:
|
||||
self._log_message(" No BIN file selected")
|
||||
return False
|
||||
|
||||
all_results: list[bool] = []
|
||||
|
||||
# 4a: TFTP upload
|
||||
self._log_message("Step 4a: TFTP upload & verify...")
|
||||
try:
|
||||
bin_path = Path(files["bin_path"])
|
||||
tftp_result = tftp_upload_verify(
|
||||
self._config, bin_path,
|
||||
callback=self._tftp_callback,
|
||||
)
|
||||
status = "✓" if tftp_result.success else "✗"
|
||||
self._log_message(f" {status} {tftp_result.message}")
|
||||
all_results.append(tftp_result.success)
|
||||
except Exception as e:
|
||||
self._log_message(f" ✗ TFTP error: {e}")
|
||||
all_results.append(False)
|
||||
|
||||
if not all_results[-1]:
|
||||
self._log_message(" Skipping reboot & boot verify (TFTP failed)")
|
||||
return False
|
||||
|
||||
# 4b: Reboot
|
||||
self._log_message("Step 4b: Rebooting Zynq...")
|
||||
try:
|
||||
reboot_result = reboot_zynq(
|
||||
self._config,
|
||||
method="auto",
|
||||
callback=self._reboot_callback,
|
||||
)
|
||||
status = "✓" if reboot_result.success else "✗"
|
||||
self._log_message(f" {status} {reboot_result.message}")
|
||||
all_results.append(reboot_result.success)
|
||||
except Exception as e:
|
||||
self._log_message(f" ✗ Reboot error: {e}")
|
||||
all_results.append(False)
|
||||
|
||||
if not all_results[-1]:
|
||||
self._log_message(" Skipping boot verify (reboot failed)")
|
||||
return False
|
||||
|
||||
# 4c: Verify boot
|
||||
self._log_message("Step 4c: Verifying boot...")
|
||||
try:
|
||||
boot_result = verify_boot(
|
||||
self._config,
|
||||
callback=self._boot_callback,
|
||||
)
|
||||
status = "✓" if boot_result.success else "✗"
|
||||
self._log_message(f" {status} {boot_result.message}")
|
||||
|
||||
if boot_result.ip_reachable:
|
||||
self._log_message(f" IP {self._config.zynq_ip} is reachable")
|
||||
if boot_result.serial_found:
|
||||
self._log_message(f" Boot info: {boot_result.boot_info}")
|
||||
|
||||
all_results.append(boot_result.success)
|
||||
except Exception as e:
|
||||
self._log_message(f" ✗ Boot verify error: {e}")
|
||||
all_results.append(False)
|
||||
|
||||
return all(all_results)
|
||||
|
||||
def _tftp_callback(self, status: str, message: str) -> None:
|
||||
"""Callback for TFTP progress."""
|
||||
self._log_message(f" [{status}] {message}")
|
||||
|
||||
def _reboot_callback(self, status: str, message: str) -> None:
|
||||
"""Callback for reboot progress."""
|
||||
self._log_message(f" [{status}] {message}")
|
||||
|
||||
def _boot_callback(self, status: str, message: str) -> None:
|
||||
"""Callback for boot verification progress."""
|
||||
self._log_message(f" [{status}] {message}")
|
||||
|
||||
# ── Run All Steps ──────────────────────────────────────────
|
||||
|
||||
def _update_step_dependencies(self) -> None:
|
||||
"""Update step run-ability based on dependency status.
|
||||
|
||||
Step 0 (env) is always runnable.
|
||||
Step N is runnable only if all steps before it have completed.
|
||||
"""
|
||||
for i, step in enumerate(self._steps):
|
||||
if i == 0:
|
||||
step.set_can_run(True)
|
||||
if self._step_statuses.get(i) == "pending":
|
||||
step.set_status("pending")
|
||||
else:
|
||||
prev_completed = all(
|
||||
self._step_statuses.get(j) == "complete"
|
||||
for j in range(i)
|
||||
)
|
||||
step.set_can_run(prev_completed)
|
||||
status = self._step_statuses.get(i, "pending")
|
||||
if not prev_completed and status == "pending":
|
||||
step.set_status("disabled")
|
||||
|
||||
def _execute_step(self, index: int) -> None:
|
||||
"""Execute a single step independently.
|
||||
|
||||
Args:
|
||||
index: Step index (0-based).
|
||||
"""
|
||||
if self._is_running:
|
||||
return
|
||||
|
||||
if index >= len(self._steps):
|
||||
return
|
||||
|
||||
self._is_running = True
|
||||
self._run_btn.configure(state="disabled", text="Running...")
|
||||
self._progress.reset()
|
||||
|
||||
# Reset status for this step and all after it
|
||||
for i in range(index, len(self._steps)):
|
||||
self._step_statuses[i] = "pending"
|
||||
self._steps[i].set_status("pending")
|
||||
|
||||
self._progress.set_value(0.0)
|
||||
|
||||
# Start background worker thread
|
||||
self._worker_thread = threading.Thread(
|
||||
target=self._run_steps_worker,
|
||||
daemon=True,
|
||||
args=(index,),
|
||||
)
|
||||
self._worker_thread.start()
|
||||
|
||||
def _set_step_status(self, index: int, status: str) -> None:
|
||||
"""Update the status of a step in the UI.
|
||||
|
||||
Args:
|
||||
index: Step index (0-based).
|
||||
status: New status string.
|
||||
"""
|
||||
self._step_statuses[index] = status
|
||||
self._steps[index].set_status(status)
|
||||
|
||||
def _run_all_steps(self) -> None:
|
||||
"""Execute all workflow steps sequentially in a background thread."""
|
||||
if self._is_running:
|
||||
return
|
||||
|
||||
self._is_running = True
|
||||
self._run_btn.configure(state="disabled", text="Running...")
|
||||
self._progress.reset()
|
||||
|
||||
# Reset all step statuses
|
||||
for i in range(len(self._steps)):
|
||||
self._step_statuses[i] = "pending"
|
||||
self._steps[i].set_status("pending")
|
||||
|
||||
# Start background worker thread
|
||||
self._worker_thread = threading.Thread(
|
||||
target=self._run_steps_worker,
|
||||
daemon=True,
|
||||
args=(0,), # Start from step 0
|
||||
)
|
||||
self._worker_thread.start()
|
||||
|
||||
def _run_steps_worker(self, start_index: int = 0) -> None:
|
||||
"""Worker method that runs workflow steps in the background.
|
||||
|
||||
Args:
|
||||
start_index: Index of the first step to run (0-based).
|
||||
"""
|
||||
step_funcs = [
|
||||
self._run_step_1_check_environment,
|
||||
self._run_step_2_program_flash,
|
||||
self._run_step_3_load_bootloader,
|
||||
self._run_step_4_load_main_program,
|
||||
]
|
||||
|
||||
total = len(step_funcs) - start_index
|
||||
results: list[bool] = []
|
||||
|
||||
for i in range(start_index, len(step_funcs)):
|
||||
step_idx = i - start_index
|
||||
self._set_step_status(i, "running")
|
||||
self._progress.set_value(step_idx / total)
|
||||
|
||||
try:
|
||||
success = step_funcs[i]()
|
||||
results.append(success)
|
||||
self._set_step_status(i, "complete" if success else "error")
|
||||
except Exception as e:
|
||||
results.append(False)
|
||||
self._set_step_status(i, "error")
|
||||
self._log_message(f" Exception in step {i+1}: {e}")
|
||||
|
||||
self._progress.set_value((step_idx + 1) / total)
|
||||
|
||||
# Stop on first failure when running all steps
|
||||
if start_index == 0 and not success:
|
||||
break
|
||||
|
||||
self._progress.set_value(1.0)
|
||||
self._is_running = False
|
||||
self._run_btn.configure(state="normal", text="▶ Run All Steps")
|
||||
|
||||
# Update dependency statuses
|
||||
self._update_step_dependencies()
|
||||
|
||||
# Set final status message
|
||||
if results:
|
||||
all_ok = all(results)
|
||||
any_fail = any(not r for r in results)
|
||||
if all_ok:
|
||||
self._status.set_status("Workflow complete — all steps passed", "success")
|
||||
elif any_fail:
|
||||
failed_count = results.count(False)
|
||||
self._status.set_status(
|
||||
f"Workflow complete — {failed_count}/{len(results)} step(s) failed",
|
||||
"warning",
|
||||
)
|
||||
else:
|
||||
self._status.set_status("Workflow complete", "info")
|
||||
else:
|
||||
self._status.set_status("Workflow complete", "info")
|
||||
|
||||
# Save config after running
|
||||
self._save_config()
|
||||
|
||||
# ── Serial ─────────────────────────────────────────────────
|
||||
|
||||
def _refresh_ports(self) -> None:
|
||||
"""Refresh the serial port list."""
|
||||
ports = detect_serial_ports()
|
||||
self._port_menu.configure(values=[""] + [p.device for p in ports])
|
||||
self._log_message(f"Found {len(ports)} serial port(s)")
|
||||
|
||||
def _refresh_ports_initial(self) -> None:
|
||||
"""Refresh the serial port list on startup (silent, no log)."""
|
||||
ports = detect_serial_ports()
|
||||
self._port_menu.configure(values=[""] + [p.device for p in ports])
|
||||
# Auto-select if only one port found and config has one
|
||||
if ports and self._config and self._config.serial_port:
|
||||
devices = [p.device for p in ports]
|
||||
if self._config.serial_port in devices:
|
||||
self._port_var.set(self._config.serial_port)
|
||||
|
||||
def _on_close(self) -> None:
|
||||
"""Handle window close event: save config and cleanup."""
|
||||
if self._config:
|
||||
try:
|
||||
self._config.save()
|
||||
except Exception:
|
||||
pass
|
||||
self.destroy()
|
||||
|
||||
def _read_serial(self) -> None:
|
||||
"""Read and display serial output."""
|
||||
port = self._port_var.get()
|
||||
if not port:
|
||||
self._log_message("No serial port selected")
|
||||
return
|
||||
|
||||
self._log_message(f"Reading from {port}...")
|
||||
try:
|
||||
import serial
|
||||
|
||||
with serial.Serial(port, self._config.serial_baudrate if self._config else 115200, timeout=5) as ser:
|
||||
ser.reset_input_buffer()
|
||||
lines = []
|
||||
while ser.in_waiting:
|
||||
line = ser.readline().decode("utf-8", errors="replace").strip()
|
||||
if line:
|
||||
lines.append(line)
|
||||
|
||||
if lines:
|
||||
info = parse_boot_output(lines)
|
||||
self._log_message(f" Parsed: IP={info.ip_address}, Ver={info.version}")
|
||||
for line in lines[-10:]:
|
||||
self._log_message(f" > {line}")
|
||||
else:
|
||||
self._log_message(" No data received")
|
||||
except Exception as e:
|
||||
self._log_message(f" Error: {e}")
|
||||
|
||||
# ── Vitis Check ────────────────────────────────────────────
|
||||
|
||||
def _check_vitis(self) -> None:
|
||||
"""Check Vitis/Vivado on startup."""
|
||||
if not self._config:
|
||||
return
|
||||
|
||||
result = check_vitis(self._config)
|
||||
status_dict = get_tool_status_dict(result)
|
||||
|
||||
if result.is_ready:
|
||||
self._vitis_status.configure(text="Vitis: Ready", text_color=SUCCESS_COLOR)
|
||||
self._log_message("Vitis/Vivado tools available")
|
||||
else:
|
||||
self._vitis_status.configure(text="Vitis: Partial", text_color=WARNING_COLOR)
|
||||
self._log_message(f"Vitis issues: {'; '.join(result.errors)}")
|
||||
|
||||
|
||||
# ── Entry Point ────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
"""Launch the Zynq Flasher GUI application."""
|
||||
app = MainWindow()
|
||||
app.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Theme and styling constants for the CustomTkinter GUI.
|
||||
|
||||
Provides consistent cross-platform styling with dark mode detection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import darkdetect
|
||||
|
||||
|
||||
# ── Color Theme ──────────────────────────────────────────────
|
||||
|
||||
PRIMARY_COLOR: str = "#0066CC"
|
||||
SECONDARY_COLOR: str = "#00A86B"
|
||||
DANGER_COLOR: str = "#CC3333"
|
||||
WARNING_COLOR: str = "#E6A817"
|
||||
SUCCESS_COLOR: str = "#00A86B"
|
||||
INFO_COLOR: str = "#0066CC"
|
||||
|
||||
DARK_BG: str = "#2B2B2B"
|
||||
DARK_FG: str = "#E0E0E0"
|
||||
DARK_CARD: str = "#3A3A3A"
|
||||
DARK_BORDER: str = "#555555"
|
||||
|
||||
LIGHT_BG: str = "#F5F5F5"
|
||||
LIGHT_FG: str = "#333333"
|
||||
LIGHT_CARD: str = "#FFFFFF"
|
||||
LIGHT_BORDER: str = "#DDDDDD"
|
||||
|
||||
|
||||
def get_theme() -> dict:
|
||||
"""Get the current theme configuration.
|
||||
|
||||
Auto-detects dark/light mode based on the system setting.
|
||||
|
||||
Returns:
|
||||
Dictionary with theme color values.
|
||||
"""
|
||||
is_dark = darkdetect.isDark()
|
||||
|
||||
return {
|
||||
"bg": DARK_BG if is_dark else LIGHT_BG,
|
||||
"fg": DARK_FG if is_dark else LIGHT_FG,
|
||||
"card": DARK_CARD if is_dark else LIGHT_CARD,
|
||||
"border": DARK_BORDER if is_dark else LIGHT_BORDER,
|
||||
"primary": PRIMARY_COLOR,
|
||||
"secondary": SECONDARY_COLOR,
|
||||
"danger": DANGER_COLOR,
|
||||
"warning": WARNING_COLOR,
|
||||
"success": SUCCESS_COLOR,
|
||||
"info": INFO_COLOR,
|
||||
"is_dark": is_dark,
|
||||
}
|
||||
|
||||
|
||||
# ── Typography ───────────────────────────────────────────────
|
||||
|
||||
FONT_TITLE: tuple[str, int, str] = ("Segoe UI", 18, "bold")
|
||||
FONT_HEADING: tuple[str, int, str] = ("Segoe UI", 14, "bold")
|
||||
FONT_BODY: tuple[str, int, str] = ("Segoe UI", 11)
|
||||
FONT_SMALL: tuple[str, int, str] = ("Segoe UI", 9)
|
||||
FONT_MONO: tuple[str, int, str] = ("Consolas", 10)
|
||||
|
||||
|
||||
# ── Spacing ──────────────────────────────────────────────────
|
||||
|
||||
PADDING: int = 12
|
||||
PADDING_LARGE: int = 20
|
||||
PADDING_SMALL: int = 6
|
||||
CORNER_RADIUS: int = 8
|
||||
BUTTON_HEIGHT: int = 32
|
||||
|
||||
|
||||
# ── Layout ───────────────────────────────────────────────────
|
||||
|
||||
WINDOW_WIDTH: int = 900
|
||||
WINDOW_HEIGHT: int = 700
|
||||
WINDOW_TITLE: str = "Zynq XC7Z100 Flasher"
|
||||
|
||||
STEP_HEADER_HEIGHT: int = 36
|
||||
LOG_HEIGHT: int = 200
|
||||
|
||||
# ── Step UI Enhancements ─────────────────────────────────────
|
||||
|
||||
# Accent bar colors for step states
|
||||
ACCENT_PENDING: str = "#666666"
|
||||
ACCENT_RUNNING: str = PRIMARY_COLOR
|
||||
ACCENT_COMPLETE: str = SUCCESS_COLOR
|
||||
ACCENT_ERROR: str = DANGER_COLOR
|
||||
ACCENT_DISABLED: str = "#444444"
|
||||
|
||||
# Connector line color
|
||||
CONNECTOR_COLOR: str = "#555555"
|
||||
CONNECTOR_COLOR_LIGHT: str = "#CCCCCC"
|
||||
|
||||
# Button hover states
|
||||
STEP_RUN_BG: str = "#0055AA"
|
||||
STEP_RUN_BG_HOVER: str = "#0077DD"
|
||||
STEP_RUNNER_BG: str = "#555555"
|
||||
STEP_RUNNER_BG_HOVER: str = "#777777"
|
||||
|
||||
# Animation
|
||||
PULSE_DURATION: int = 500 # ms between pulse flashes
|
||||
RUNNING_FADE_LIGHT: str = "#0066CC"
|
||||
RUNNING_FADE_DARK: str = "#003366"
|
||||
|
||||
# Step card styling
|
||||
STEP_CARD_BG: str = "#333333"
|
||||
STEP_CARD_BG_HOVER: str = "#3D3D3D"
|
||||
STEP_CARD_BG_LIGHT: str = "#FFFFFF"
|
||||
STEP_CARD_BG_HOVER_LIGHT: str = "#F0F0F0"
|
||||
STEP_DISABLED_ALPHA: float = 0.5
|
||||
|
||||
# Icon sizes
|
||||
STEP_ICON_SIZE: int = 28
|
||||
STEP_BUTTON_SIZE: int = 28
|
||||
@@ -0,0 +1,656 @@
|
||||
"""Custom widget components for the Zynq Flasher GUI.
|
||||
|
||||
Provides reusable UI components built on CustomTkinter with
|
||||
consistent styling from gui/styles.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import customtkinter as ctk
|
||||
from typing import Callable
|
||||
|
||||
from gui.styles import (
|
||||
FONT_BODY,
|
||||
FONT_MONO,
|
||||
FONT_SMALL,
|
||||
PADDING,
|
||||
PADDING_LARGE,
|
||||
PADDING_SMALL,
|
||||
CORNER_RADIUS,
|
||||
PRIMARY_COLOR,
|
||||
SUCCESS_COLOR,
|
||||
DANGER_COLOR,
|
||||
WARNING_COLOR,
|
||||
INFO_COLOR,
|
||||
ACCENT_PENDING,
|
||||
ACCENT_RUNNING,
|
||||
ACCENT_COMPLETE,
|
||||
ACCENT_ERROR,
|
||||
ACCENT_DISABLED,
|
||||
CONNECTOR_COLOR,
|
||||
DARK_FG,
|
||||
LIGHT_FG,
|
||||
STEP_RUN_BG,
|
||||
STEP_RUN_BG_HOVER,
|
||||
STEP_RUNNER_BG,
|
||||
STEP_RUNNER_BG_HOVER,
|
||||
RUNNING_FADE_LIGHT,
|
||||
RUNNING_FADE_DARK,
|
||||
PULSE_DURATION,
|
||||
STEP_ICON_SIZE,
|
||||
STEP_BUTTON_SIZE,
|
||||
STEP_CARD_BG,
|
||||
STEP_CARD_BG_HOVER,
|
||||
STEP_DISABLED_ALPHA,
|
||||
)
|
||||
|
||||
|
||||
def _is_dark() -> bool:
|
||||
"""Check if dark mode is active.
|
||||
|
||||
Returns:
|
||||
True if dark mode is detected.
|
||||
"""
|
||||
try:
|
||||
import darkdetect
|
||||
return darkdetect.isDark()
|
||||
except ImportError:
|
||||
return True
|
||||
|
||||
|
||||
class StepHeader(ctk.CTkFrame):
|
||||
"""Enhanced step header widget with run/re-run buttons and visual feedback.
|
||||
|
||||
Displays a numbered step with title, status, accent bar, connector line,
|
||||
and per-step action buttons (run/re-run).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
master,
|
||||
step_number: int,
|
||||
title: str,
|
||||
status: str = "pending",
|
||||
description: str = "",
|
||||
callback: Callable[[], None] | None = None,
|
||||
can_run: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the step header.
|
||||
|
||||
Args:
|
||||
master: Parent widget.
|
||||
step_number: Step number (1-based).
|
||||
title: Step title text.
|
||||
status: Status indicator ('pending', 'running', 'complete', 'error', 'disabled').
|
||||
description: Secondary description text shown below the title.
|
||||
callback: Called when the run/re-run button is clicked.
|
||||
can_run: Whether this step can be executed independently.
|
||||
"""
|
||||
super().__init__(
|
||||
master,
|
||||
corner_radius=CORNER_RADIUS,
|
||||
fg_color=STEP_CARD_BG,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._step_number = step_number
|
||||
self._title = title
|
||||
self._status = status
|
||||
self._description = description
|
||||
self._callback = callback
|
||||
self._can_run = can_run
|
||||
self._pulse_var = False
|
||||
self._pulse_job: str | None = None
|
||||
|
||||
self._create_widgets()
|
||||
self._set_status(status)
|
||||
|
||||
def _create_widgets(self) -> None:
|
||||
"""Create the step header UI components."""
|
||||
self.grid_rowconfigure(0, weight=1)
|
||||
self.grid_columnconfigure((0, 1, 2, 3), weight=0, minsize=PADDING_SMALL)
|
||||
self.grid_columnconfigure(1, weight=1)
|
||||
|
||||
# Accent bar (left edge)
|
||||
self._accent_bar = ctk.CTkFrame(
|
||||
self,
|
||||
width=4,
|
||||
height=40,
|
||||
corner_radius=2,
|
||||
fg_color=ACCENT_PENDING,
|
||||
)
|
||||
self._accent_bar.grid(
|
||||
row=0, column=0,
|
||||
sticky="n",
|
||||
pady=PADDING,
|
||||
)
|
||||
|
||||
# Step number circle
|
||||
self._num_label = ctk.CTkLabel(
|
||||
self,
|
||||
text=str(self._step_number),
|
||||
font=("Segoe UI", 13, "bold"),
|
||||
width=STEP_ICON_SIZE,
|
||||
height=STEP_ICON_SIZE,
|
||||
corner_radius=STEP_ICON_SIZE // 2,
|
||||
fg_color="transparent",
|
||||
text_color=DARK_FG if _is_dark() else LIGHT_FG,
|
||||
)
|
||||
self._num_label.grid(
|
||||
row=0, column=1,
|
||||
sticky="n",
|
||||
pady=PADDING,
|
||||
)
|
||||
|
||||
# Content area (title + description)
|
||||
content_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
content_frame.grid(
|
||||
row=0, column=2,
|
||||
sticky="nsew",
|
||||
pady=PADDING,
|
||||
padx=PADDING_SMALL,
|
||||
)
|
||||
content_frame.grid_rowconfigure(0, weight=1)
|
||||
content_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self._title_label = ctk.CTkLabel(
|
||||
content_frame,
|
||||
text=self._title,
|
||||
font=("Segoe UI", 12, "bold"),
|
||||
anchor="w",
|
||||
text_color=DARK_FG if _is_dark() else LIGHT_FG,
|
||||
)
|
||||
self._title_label.grid(
|
||||
row=0, column=0,
|
||||
sticky="nw",
|
||||
pady=(0, 2),
|
||||
)
|
||||
|
||||
self._desc_label = ctk.CTkLabel(
|
||||
content_frame,
|
||||
text=self._description,
|
||||
font=FONT_SMALL,
|
||||
anchor="w",
|
||||
text_color="#999999",
|
||||
wraplength=400,
|
||||
)
|
||||
self._desc_label.grid(
|
||||
row=0, column=0,
|
||||
sticky="nw",
|
||||
pady=(0, 0),
|
||||
)
|
||||
|
||||
# Action buttons area
|
||||
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
btn_frame.grid(
|
||||
row=0, column=3,
|
||||
sticky="n",
|
||||
pady=PADDING,
|
||||
)
|
||||
btn_frame.grid_rowconfigure(0, weight=1)
|
||||
btn_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Run button (▶)
|
||||
self._run_btn = ctk.CTkButton(
|
||||
btn_frame,
|
||||
text="▶",
|
||||
font=("Segoe UI", 12, "bold"),
|
||||
width=STEP_BUTTON_SIZE,
|
||||
height=STEP_BUTTON_SIZE,
|
||||
corner_radius=6,
|
||||
fg_color=STEP_RUN_BG,
|
||||
hover_color=STEP_RUN_BG_HOVER,
|
||||
text_color="white",
|
||||
command=self._on_run,
|
||||
state="disabled",
|
||||
)
|
||||
self._run_btn.grid(row=0, column=0, padx=(0, PADDING_SMALL))
|
||||
|
||||
# Re-run button (↻) — hidden by default
|
||||
self._rerun_btn = ctk.CTkButton(
|
||||
btn_frame,
|
||||
text="↻",
|
||||
font=("Segoe UI", 12, "bold"),
|
||||
width=STEP_BUTTON_SIZE,
|
||||
height=STEP_BUTTON_SIZE,
|
||||
corner_radius=6,
|
||||
fg_color=STEP_RUNNER_BG,
|
||||
hover_color=STEP_RUNNER_BG_HOVER,
|
||||
text_color="white",
|
||||
command=self._on_rerun,
|
||||
state="hidden",
|
||||
)
|
||||
self._rerun_btn.grid(row=0, column=0, padx=(0, PADDING_SMALL))
|
||||
|
||||
# Status icon
|
||||
self._status_label = ctk.CTkLabel(
|
||||
btn_frame,
|
||||
text="",
|
||||
font=("Segoe UI", 14),
|
||||
anchor="e",
|
||||
)
|
||||
self._status_label.grid(row=0, column=0)
|
||||
|
||||
# Hover effect bindings
|
||||
self.bind("<Enter>", self._on_hover_enter)
|
||||
self.bind("<Leave>", self._on_hover_leave)
|
||||
|
||||
def _on_hover_enter(self, event) -> None:
|
||||
"""Handle mouse enter for hover effect."""
|
||||
if self._status not in ("running", "disabled"):
|
||||
self.configure(fg_color=STEP_CARD_BG_HOVER)
|
||||
|
||||
def _on_hover_leave(self, event) -> None:
|
||||
"""Handle mouse leave for hover effect."""
|
||||
if self._status not in ("running", "disabled"):
|
||||
self.configure(fg_color=STEP_CARD_BG)
|
||||
|
||||
def _on_run(self) -> None:
|
||||
"""Handle run button click."""
|
||||
if self._callback:
|
||||
self._callback()
|
||||
|
||||
def _on_rerun(self) -> None:
|
||||
"""Handle re-run button click."""
|
||||
if self._callback:
|
||||
self._callback()
|
||||
|
||||
def _set_status(self, status: str) -> None:
|
||||
"""Set the step status with appropriate colors and icons.
|
||||
|
||||
Args:
|
||||
status: One of 'pending', 'running', 'complete', 'error', 'disabled'.
|
||||
"""
|
||||
self._status = status
|
||||
|
||||
# Accent bar color
|
||||
accent_colors = {
|
||||
"pending": ACCENT_PENDING,
|
||||
"running": RUNNING_FADE_LIGHT,
|
||||
"complete": ACCENT_COMPLETE,
|
||||
"error": ACCENT_ERROR,
|
||||
"disabled": ACCENT_DISABLED,
|
||||
}
|
||||
self._accent_bar.configure(fg_color=accent_colors.get(status, ACCENT_PENDING))
|
||||
|
||||
# Number circle
|
||||
circle_colors = {
|
||||
"pending": "#666666",
|
||||
"running": RUNNING_FADE_LIGHT,
|
||||
"complete": ACCENT_COMPLETE,
|
||||
"error": ACCENT_ERROR,
|
||||
"disabled": ACCENT_DISABLED,
|
||||
}
|
||||
circle_color = circle_colors.get(status, "#666666")
|
||||
self._num_label.configure(fg_color=circle_color)
|
||||
|
||||
# Status icon
|
||||
icons = {
|
||||
"pending": "",
|
||||
"running": "◌",
|
||||
"complete": "✓",
|
||||
"error": "✗",
|
||||
"disabled": "",
|
||||
}
|
||||
icon = icons.get(status, "")
|
||||
self._status_label.configure(text=icon)
|
||||
icon_colors = {
|
||||
"pending": "#666666",
|
||||
"running": RUNNING_FADE_LIGHT,
|
||||
"complete": ACCENT_COMPLETE,
|
||||
"error": ACCENT_ERROR,
|
||||
"disabled": ACCENT_DISABLED,
|
||||
}
|
||||
self._status_label.configure(text_color=icon_colors.get(status, "#666666"))
|
||||
|
||||
# Buttons visibility
|
||||
if status == "running":
|
||||
self._run_btn.configure(state="hidden")
|
||||
self._rerun_btn.configure(state="hidden")
|
||||
self._start_pulse()
|
||||
elif status == "disabled":
|
||||
self._run_btn.configure(state="hidden")
|
||||
self._rerun_btn.configure(state="hidden")
|
||||
self._stop_pulse()
|
||||
self.configure(fg_color="#2A2A2A")
|
||||
self._num_label.configure(fg_color=ACCENT_DISABLED)
|
||||
self._title_label.configure(text_color="#777777")
|
||||
self._desc_label.configure(text_color="#666666")
|
||||
elif status == "complete":
|
||||
self._run_btn.configure(state="hidden")
|
||||
self._rerun_btn.configure(state="normal")
|
||||
self._stop_pulse()
|
||||
elif status == "error":
|
||||
self._run_btn.configure(state="hidden")
|
||||
self._rerun_btn.configure(state="normal")
|
||||
self._stop_pulse()
|
||||
else: # pending
|
||||
if self._can_run:
|
||||
self._run_btn.configure(state="normal")
|
||||
else:
|
||||
self._run_btn.configure(state="disabled")
|
||||
self._rerun_btn.configure(state="hidden")
|
||||
self._stop_pulse()
|
||||
|
||||
# Update description
|
||||
desc_map = {
|
||||
"pending": self._description or "Not started",
|
||||
"running": "Running...",
|
||||
"complete": "Completed successfully",
|
||||
"error": "Failed — click ↻ to retry",
|
||||
"disabled": "Waiting for dependencies",
|
||||
}
|
||||
self._desc_label.configure(text=desc_map.get(status, self._description))
|
||||
|
||||
def set_status(self, status: str) -> None:
|
||||
"""Public method to update step status.
|
||||
|
||||
Args:
|
||||
status: New status string.
|
||||
"""
|
||||
self.after(0, lambda: self._set_status(status))
|
||||
|
||||
def set_description(self, description: str) -> None:
|
||||
"""Update the step description text.
|
||||
|
||||
Args:
|
||||
description: New description text.
|
||||
"""
|
||||
self._description = description
|
||||
if self._status == "pending":
|
||||
self._desc_label.configure(text=description or "Not started")
|
||||
|
||||
def set_callback(self, callback: Callable[[], None] | None) -> None:
|
||||
"""Update the callback for run/re-run buttons.
|
||||
|
||||
Args:
|
||||
callback: New callback function.
|
||||
"""
|
||||
self._callback = callback
|
||||
|
||||
def set_can_run(self, can_run: bool) -> None:
|
||||
"""Update whether this step can be run independently.
|
||||
|
||||
Args:
|
||||
can_run: Whether the step is runnable.
|
||||
"""
|
||||
self._can_run = can_run
|
||||
if self._status == "pending":
|
||||
self._run_btn.configure(state="normal" if can_run else "disabled")
|
||||
|
||||
def _start_pulse(self) -> None:
|
||||
"""Start the pulsing animation for running state."""
|
||||
self._stop_pulse()
|
||||
self._pulse_var = not self._pulse_var
|
||||
color = RUNNING_FADE_LIGHT if self._pulse_var else RUNNING_FADE_DARK
|
||||
self._accent_bar.configure(fg_color=color)
|
||||
self._num_label.configure(fg_color=color)
|
||||
self._pulse_job = self.after(PULSE_DURATION, self._start_pulse)
|
||||
|
||||
def _stop_pulse(self) -> None:
|
||||
"""Stop the pulsing animation."""
|
||||
if self._pulse_job:
|
||||
self.after_cancel(self._pulse_job)
|
||||
self._pulse_job = None
|
||||
|
||||
|
||||
class ProgressIndicator(ctk.CTkFrame):
|
||||
"""Progress indicator widget for workflow steps.
|
||||
|
||||
Shows a progress bar and percentage for long-running operations.
|
||||
"""
|
||||
|
||||
def __init__(self, master, **kwargs):
|
||||
"""Initialize the progress indicator.
|
||||
|
||||
Args:
|
||||
master: Parent widget.
|
||||
"""
|
||||
super().__init__(master, **kwargs)
|
||||
|
||||
self._progress_var = ctk.DoubleVar(value=0)
|
||||
self._create_widgets()
|
||||
|
||||
def _create_widgets(self) -> None:
|
||||
"""Create the progress indicator UI."""
|
||||
self.grid_rowconfigure(0, weight=1)
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self._progress_bar = ctk.CTkProgressBar(
|
||||
self,
|
||||
variable=self._progress_var,
|
||||
corner_radius=CORNER_RADIUS,
|
||||
height=8,
|
||||
)
|
||||
self._progress_bar.grid(
|
||||
row=0, column=0, sticky="nsew", padx=PADDING
|
||||
)
|
||||
|
||||
self._percent_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="0%",
|
||||
font=FONT_SMALL,
|
||||
)
|
||||
self._percent_label.grid(
|
||||
row=0, column=0, sticky="ne", padx=PADDING, pady=PADDING_SMALL
|
||||
)
|
||||
|
||||
def set_value(self, value: float) -> None:
|
||||
"""Set the progress bar value.
|
||||
|
||||
Args:
|
||||
value: Progress value between 0.0 and 1.0.
|
||||
"""
|
||||
value = max(0.0, min(1.0, value))
|
||||
self._progress_var.set(value)
|
||||
self._percent_label.configure(text=f"{int(value * 100)}%")
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the progress indicator to 0%."""
|
||||
self.set_value(0.0)
|
||||
|
||||
|
||||
class StatusDisplay(ctk.CTkFrame):
|
||||
"""Status display widget for operation results.
|
||||
|
||||
Shows a status message with color-coded feedback.
|
||||
"""
|
||||
|
||||
def __init__(self, master, **kwargs):
|
||||
"""Initialize the status display.
|
||||
|
||||
Args:
|
||||
master: Parent widget.
|
||||
"""
|
||||
super().__init__(master, **kwargs)
|
||||
|
||||
self._create_widgets()
|
||||
|
||||
def _create_widgets(self) -> None:
|
||||
"""Create the status display UI."""
|
||||
self._status_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="Ready",
|
||||
font=FONT_BODY,
|
||||
anchor="w",
|
||||
)
|
||||
self._status_label.pack(
|
||||
fill="x", padx=PADDING, pady=PADDING_SMALL
|
||||
)
|
||||
|
||||
def set_status(self, message: str, status: str = "info") -> None:
|
||||
"""Update the status message and color.
|
||||
|
||||
Args:
|
||||
message: Status message text.
|
||||
status: Status type for coloring ('info', 'success', 'error', 'warning').
|
||||
"""
|
||||
self._status_label.configure(text=message)
|
||||
colors = {
|
||||
"info": INFO_COLOR,
|
||||
"success": SUCCESS_COLOR,
|
||||
"error": DANGER_COLOR,
|
||||
"warning": WARNING_COLOR,
|
||||
}
|
||||
self._status_label.configure(text_color=colors.get(status, INFO_COLOR))
|
||||
|
||||
|
||||
class FileSelector(ctk.CTkFrame):
|
||||
"""File selector widget with browse button and path display.
|
||||
|
||||
Allows users to browse for files and displays the selected path.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
master,
|
||||
label_text: str,
|
||||
file_types: list[tuple[str, str]] | None = None,
|
||||
callback: Callable[[str], None] | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the file selector.
|
||||
|
||||
Args:
|
||||
master: Parent widget.
|
||||
label_text: Label text for the selector.
|
||||
file_types: File dialog filter types (e.g., [("BIN files", "*.bin")]).
|
||||
callback: Optional callback when file is selected.
|
||||
"""
|
||||
super().__init__(master, **kwargs)
|
||||
|
||||
self._callback = callback
|
||||
self._file_types = file_types or [("All files", "*")]
|
||||
self._selected_path = ctk.StringVar(value="")
|
||||
|
||||
self._create_widgets(label_text)
|
||||
|
||||
def _create_widgets(self, label_text: str) -> None:
|
||||
"""Create the file selector UI."""
|
||||
self.grid_rowconfigure(0, weight=1)
|
||||
self.grid_columnconfigure(1, weight=1)
|
||||
|
||||
# Label
|
||||
label = ctk.CTkLabel(
|
||||
self,
|
||||
text=label_text,
|
||||
font=FONT_SMALL,
|
||||
anchor="w",
|
||||
)
|
||||
label.grid(row=0, column=0, sticky="w", padx=(PADDING, PADDING_SMALL))
|
||||
|
||||
# Path entry
|
||||
self._entry = ctk.CTkEntry(
|
||||
self,
|
||||
textvariable=self._selected_path,
|
||||
font=FONT_MONO,
|
||||
state="readonly",
|
||||
)
|
||||
self._entry.grid(
|
||||
row=0, column=1, sticky="nsew", padx=PADDING_SMALL
|
||||
)
|
||||
|
||||
# Browse button
|
||||
browse_btn = ctk.CTkButton(
|
||||
self,
|
||||
text="Browse",
|
||||
font=FONT_SMALL,
|
||||
command=self._browse,
|
||||
width=80,
|
||||
)
|
||||
browse_btn.grid(
|
||||
row=0, column=2, padx=(PADDING_SMALL, PADDING)
|
||||
)
|
||||
|
||||
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'}",
|
||||
filetypes=self._file_types,
|
||||
)
|
||||
if file_path:
|
||||
self.set_path(file_path)
|
||||
|
||||
def set_path(self, path: str) -> None:
|
||||
"""Set the selected file path.
|
||||
|
||||
Args:
|
||||
path: Absolute path to the selected file.
|
||||
"""
|
||||
self._selected_path.set(path)
|
||||
if self._callback:
|
||||
self._callback(path)
|
||||
|
||||
def get_path(self) -> str:
|
||||
"""Get the currently selected file path.
|
||||
|
||||
Returns:
|
||||
Selected file path string.
|
||||
"""
|
||||
return self._selected_path.get()
|
||||
|
||||
|
||||
class LogDisplay(ctk.CTkFrame):
|
||||
"""Log display widget for operation output.
|
||||
|
||||
Shows a scrollable text area with monospace font for log output.
|
||||
"""
|
||||
|
||||
def __init__(self, master, height: int = 200, **kwargs):
|
||||
"""Initialize the log display.
|
||||
|
||||
Args:
|
||||
master: Parent widget.
|
||||
height: Height of the log display in pixels.
|
||||
"""
|
||||
super().__init__(master, **kwargs)
|
||||
|
||||
self._create_widgets(height)
|
||||
|
||||
def _create_widgets(self, height: int) -> None:
|
||||
"""Create the log display UI."""
|
||||
self.grid_rowconfigure(0, weight=1)
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self._text_widget = ctk.CTkTextbox(
|
||||
self,
|
||||
height=height,
|
||||
font=FONT_MONO,
|
||||
corner_radius=CORNER_RADIUS,
|
||||
state="disabled",
|
||||
)
|
||||
self._text_widget.grid(
|
||||
row=0, column=0, sticky="nsew", padx=PADDING, pady=PADDING
|
||||
)
|
||||
|
||||
def append(self, message: str) -> None:
|
||||
"""Append a message to the log display.
|
||||
|
||||
Args:
|
||||
message: Text to append.
|
||||
"""
|
||||
self._text_widget.configure(state="normal")
|
||||
self._text_widget.insert("end", message + "\n")
|
||||
self._text_widget.see("end")
|
||||
self._text_widget.configure(state="disabled")
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the log display."""
|
||||
self._text_widget.configure(state="normal")
|
||||
self._text_widget.delete("1.0", "end")
|
||||
self._text_widget.configure(state="disabled")
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
"""Replace all text in the log display.
|
||||
|
||||
Args:
|
||||
text: New text content.
|
||||
"""
|
||||
self._text_widget.configure(state="normal")
|
||||
self._text_widget.delete("1.0", "end")
|
||||
self._text_widget.insert("1.0", text)
|
||||
self._text_widget.configure(state="disabled")
|
||||
Reference in New Issue
Block a user