Files
Zynq_Flasher/src/gui/widgets.py
T
Jeremy Shen e3e3763dc4 🐛 fix: sub-step readability, erase_all workaround, error state
Fix 1 — SubStepFrame readability:
- FONT_BODY (12pt) instead of FONT_SMALL dots/labels
- Number-circle colored dots (CIRCLE_PENDING/RUNNING/COMPLETE/ERROR)
- Accent-colored labels matching StepHeader (ACCENT_*)
- Percentage label next to running phase name

Fix 2 — erase_all full-chip erase:
- Creates temp zero-filled file of flash capacity (from flash_model)
- Uses -erase_only with dummy file instead of failing -erase_all flag
- Supports s25fl256s1 (32MB) which lacks density info for -erase_all
- _flash_capacity() maps model name→bytes

Fix 3 — Error state:
- set_phase('error') shows ✗ red on all sub-steps
- Only shows 'done' (green ●) on actual success

Fix 4 — Style consistency:
- Sub-step colors match StepHeader: CIRCLE_*/ACCENT_*/SUCCESS/DANGER
2026-06-10 13:51:41 +08:00

788 lines
26 KiB
Python

"""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,
CIRCLE_PENDING,
CIRCLE_RUNNING,
CIRCLE_COMPLETE,
CIRCLE_ERROR,
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,
CIRCLE_PENDING,
CIRCLE_RUNNING,
CIRCLE_COMPLETE,
CIRCLE_ERROR,
CIRCLE_DISABLED,
DESC_TEXT_COLOR,
DESC_TEXT_COLOR_LIGHT,
DISABLED_TITLE_COLOR,
DISABLED_DESC_COLOR,
)
def _font(size: int, weight: str = "") -> tuple[str, int, str]:
"""Build a cross-platform font tuple.
Args:
size: Font size in points.
weight: Font weight ('bold', 'italic', or '').
Returns:
Font tuple compatible with CustomTkinter / tkinter.
"""
if weight:
return ("Roboto", size, weight)
return ("Roboto", size)
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.
Grid layout (no overlaps):
col0 col1 col2 (expand) col3 col4
┌────────┬──────┬───────────────────────┬──────┬──────┐
│ accent │ ① │ Title │ ▶Run │ ✓ │ row 0
│ bar │ │ Description │ │ │ row 1
└────────┴──────┴───────────────────────┴──────┴──────┘
"""
self.grid_rowconfigure((0, 1), weight=0)
self.grid_columnconfigure(0, weight=0, minsize=4) # accent bar
self.grid_columnconfigure(1, weight=0) # number circle
self.grid_columnconfigure(2, weight=1) # content (expands)
self.grid_columnconfigure(3, weight=0) # action button
self.grid_columnconfigure(4, weight=0) # status icon
# ── Accent bar (spans both rows) ──
self._accent_bar = ctk.CTkFrame(
self, width=4, height=44, corner_radius=2,
fg_color=ACCENT_PENDING,
)
self._accent_bar.grid(row=0, column=0, rowspan=2, sticky="ns")
# ── Number circle (spans both rows, vertically centered) ──
circle_size = 28
self._num_label = ctk.CTkLabel(
self, text=str(self._step_number),
font=_font(13, "bold"),
width=circle_size, height=circle_size,
corner_radius=circle_size // 2,
fg_color=CIRCLE_PENDING,
text_color="white",
)
self._num_label.grid(row=0, column=1, rowspan=2, padx=(8, 8), pady=4)
# ── Title (top row of content) ──
self._title_label = ctk.CTkLabel(
self, text=self._title,
font=_font(13, "bold"),
anchor="w",
text_color=DARK_FG if _is_dark() else LIGHT_FG,
)
self._title_label.grid(row=0, column=2, sticky="sw", padx=(0, 6), pady=(5, 0))
# ── Description (bottom row of content) ──
self._desc_label = ctk.CTkLabel(
self, text=self._description,
font=FONT_SMALL,
anchor="w",
text_color=DESC_TEXT_COLOR if _is_dark() else DESC_TEXT_COLOR_LIGHT,
)
self._desc_label.grid(row=1, column=2, sticky="nw", padx=(0, 6), pady=(0, 5))
# ── Action button (spans both rows) ──
self._action_btn = ctk.CTkButton(
self, text="▶",
font=_font(12),
width=56, height=28,
corner_radius=6,
fg_color=STEP_RUN_BG,
hover_color=STEP_RUN_BG_HOVER,
text_color="white",
command=self._on_run,
)
self._action_btn.grid(row=0, column=3, rowspan=2, padx=(4, 4), pady=4)
# ── Status icon (spans both rows, rightmost) ──
self._status_label = ctk.CTkLabel(
self, text="",
font=_font(14),
anchor="center",
width=20,
)
self._status_label.grid(row=0, column=4, rowspan=2, padx=(0, 4), pady=4)
# ── Hover effect ──
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 _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 color ──
circle_colors = {
"pending": CIRCLE_PENDING,
"running": CIRCLE_RUNNING,
"complete": CIRCLE_COMPLETE,
"error": CIRCLE_ERROR,
"disabled": CIRCLE_DISABLED,
}
self._num_label.configure(fg_color=circle_colors.get(status, CIRCLE_PENDING))
# ── Status icon (rightmost column) ──
icons = {"running": "◌", "complete": "✓", "error": "✗"}
icon_colors = {
"running": RUNNING_FADE_LIGHT,
"complete": ACCENT_COMPLETE,
"error": ACCENT_ERROR,
}
self._status_label.configure(
text=icons.get(status, ""),
text_color=icon_colors.get(status, "#666666"),
)
# ── Action button (single slot, text changes per state) ──
if status == "running":
self._action_btn.configure(state="disabled", text="…")
self._start_pulse()
elif status == "disabled":
self._action_btn.configure(state="disabled", text="▶")
self._stop_pulse()
self.configure(fg_color=STEP_CARD_BG)
self._num_label.configure(fg_color=CIRCLE_DISABLED)
self._title_label.configure(text_color=DISABLED_TITLE_COLOR)
self._desc_label.configure(text_color=DISABLED_DESC_COLOR)
elif status == "complete":
self._action_btn.configure(
state="normal" if self._can_run else "disabled",
text="↻",
fg_color=STEP_RUNNER_BG,
hover_color=STEP_RUNNER_BG_HOVER,
)
self._stop_pulse()
elif status == "error":
self._action_btn.configure(
state="normal" if self._can_run else "disabled",
text="↻",
fg_color=STEP_RUNNER_BG,
hover_color=STEP_RUNNER_BG_HOVER,
)
self._stop_pulse()
else: # pending
self._action_btn.configure(
state="normal" if self._can_run else "disabled",
text="▶",
fg_color=STEP_RUN_BG,
hover_color=STEP_RUN_BG_HOVER,
)
self._stop_pulse()
# ── Description text ──
desc_map = {
"pending": self._description or "Not started",
"running": "Running…",
"complete": "Done",
"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._action_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")
# ════════════════════════════════════════════════════════════════════
# SubStepFrame — collapsible sub-step progress display
# ════════════════════════════════════════════════════════════════════
class SubStepFrame(ctk.CTkFrame):
"""Collapsible frame showing flash operation sub-steps with status.
Three sub-steps: Erase → Program → Verify.
Status dots match main Step style (CIRCLE_* / ACCENT_* colors).
Collapsed by default, expands on workflow start.
"""
def __init__(self, parent: ctk.CTkFrame) -> None:
"""Initialize the sub-step frame."""
super().__init__(parent, fg_color="transparent")
self.grid_columnconfigure(0, weight=1)
# Collapse toggle — row 0
self._collapsed = True
toggle_frame = ctk.CTkFrame(self, fg_color="transparent")
toggle_frame.grid(row=0, column=0, sticky="ew", padx=(PADDING, PADDING), pady=(2, 0))
toggle_frame.grid_columnconfigure(1, weight=1)
self._toggle_btn = ctk.CTkButton(
toggle_frame,
text="▶",
width=20,
height=20,
font=(FONT_SMALL[0], 9),
command=self._toggle,
)
self._toggle_btn.grid(row=0, column=0, padx=(0, 4))
self._header_label = ctk.CTkLabel(
toggle_frame,
text="Flash: Erase → Program → Verify",
font=FONT_BODY,
anchor="w",
)
self._header_label.grid(row=0, column=1, sticky="w")
# Sub-items (hidden initially)
self._sub_items: list[dict] = []
sub_names = ["Erase ", "Program", "Verify "]
for i, name in enumerate(sub_names):
item = self._build_sub_row(i + 1, name)
item["dot"].grid_remove()
item["label"].grid_remove()
item["pct"].grid_remove()
self._sub_items.append(item)
def _build_sub_row(self, row: int, name: str) -> dict:
"""Build one sub-item row with dot, label, and percentage label."""
dot = ctk.CTkLabel(
self, text="○",
width=28,
font=(FONT_BODY[0], FONT_BODY[1]),
text_color=CIRCLE_PENDING,
anchor="center",
)
dot.grid(row=row, column=0, padx=(PADDING + 20, 4), pady=(3, 3), sticky="w")
sub_frame = ctk.CTkFrame(self, fg_color="transparent")
sub_frame.grid(row=row, column=0, sticky="ew", padx=(PADDING + 52, PADDING), pady=(3, 3))
sub_frame.grid_columnconfigure(0, weight=1)
label = ctk.CTkLabel(
sub_frame, text=name,
font=FONT_BODY,
text_color=ACCENT_PENDING,
anchor="w",
)
label.grid(row=0, column=0, sticky="w")
pct = ctk.CTkLabel(
sub_frame, text="",
font=FONT_BODY,
text_color=ACCENT_PENDING,
anchor="e",
)
pct.grid(row=0, column=1, sticky="e", padx=(8, 0))
return {"dot": dot, "label": label, "pct": pct, "name": name}
def _toggle(self) -> None:
"""Toggle collapse/expand."""
self._collapsed = not self._collapsed
self._toggle_btn.configure(text="▶" if self._collapsed else "▼")
for item in self._sub_items:
if self._collapsed:
item["dot"].grid_remove()
item["label"].master.grid_remove()
else:
item["dot"].grid()
item["label"].master.grid()
def set_expanded(self, expanded: bool) -> None:
"""Set expanded state."""
if expanded != self._collapsed:
self._toggle()
def set_phase(self, phase: str, pct: int = -1) -> None:
"""Update sub-step status.
Args:
phase: 'erase', 'program', 'verify', 'done', 'error'.
pct: Progress 0-100, -1 means running (no specific %).
"""
phase_map = {"erase": 0, "program": 1, "verify": 2}
if phase == "done":
for item in self._sub_items:
item["dot"].configure(text="●", text_color=CIRCLE_COMPLETE)
item["label"].configure(text_color=SUCCESS_COLOR)
item["pct"].configure(text="OK", text_color=SUCCESS_COLOR)
return
if phase == "error":
for item in self._sub_items:
item["dot"].configure(text="✗", text_color=CIRCLE_ERROR)
item["label"].configure(text_color=DANGER_COLOR)
item["pct"].configure(text="", text_color=DANGER_COLOR)
return
idx = phase_map.get(phase, -1)
if idx < 0:
return
# Running with progress
pct_text = f"{pct}%" if pct >= 0 else ""
running_text = f" {pct_text}" if pct_text else ""
for i, item in enumerate(self._sub_items):
if i < idx:
item["dot"].configure(text="●", text_color=CIRCLE_COMPLETE)
item["label"].configure(text_color=SUCCESS_COLOR)
item["pct"].configure(text="OK", text_color=SUCCESS_COLOR)
elif i == idx:
item["dot"].configure(text="◉", text_color=CIRCLE_RUNNING)
item["label"].configure(text_color=ACCENT_RUNNING)
item["pct"].configure(text=pct_text, text_color=ACCENT_RUNNING)
else:
item["dot"].configure(text="○", text_color=CIRCLE_PENDING)
item["label"].configure(text_color=ACCENT_PENDING)
item["pct"].configure(text="", text_color=ACCENT_PENDING)
def reset(self) -> None:
"""Reset all sub-steps to pending state."""
for item in self._sub_items:
item["dot"].configure(text="○", text_color=CIRCLE_PENDING)
item["label"].configure(text_color=ACCENT_PENDING)
item["pct"].configure(text="", text_color=ACCENT_PENDING)
if not self._collapsed:
self._toggle()
self._toggle()