1. SubStepFrame defaults to expanded (▼), not collapsed 2. erase_all checkbox has command=_on_erase_toggle → immediate config update 3. _deferred_init runs _check_vitis/_refresh_ports in background thread Fixes UI freeze during startup (subprocess.run for tool versions) 4. Sub-step colors now match StepHeader: - pending: gray ACCENT_PENDING (#888888) - running: blue CIRCLE_RUNNING (#0078D4) with pulse animation - complete: green ACCENT_COMPLETE (#107C10) - error: red ACCENT_ERROR (#D13438) - Running sub-step pulses between light/dark blue like StepHeader
764 lines
25 KiB
Python
764 lines
25 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.
|
|
|
|
Colors match StepHeader: gray pending, blue running (+pulse),
|
|
green complete, red error. Default expanded.
|
|
"""
|
|
|
|
def __init__(self, parent: ctk.CTkFrame) -> None:
|
|
super().__init__(parent, fg_color="transparent")
|
|
|
|
self._collapsed = False
|
|
self._toggle_btn = ctk.CTkButton(
|
|
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")
|
|
|
|
self._header_label = ctk.CTkLabel(
|
|
self, text="Erase → Program → Verify",
|
|
font=FONT_SMALL,
|
|
)
|
|
self._header_label.grid(row=0, column=1, padx=(0, 4), pady=(2, 2), sticky="w")
|
|
|
|
self._sub_items: list[dict] = []
|
|
names = ["Erase ", "Program", "Verify "]
|
|
for i, name in enumerate(names):
|
|
dot = ctk.CTkLabel(self, text="○", font=FONT_BODY, text_color=ACCENT_PENDING, anchor="w")
|
|
label = ctk.CTkLabel(self, text=name, font=FONT_BODY, text_color=ACCENT_PENDING, anchor="w")
|
|
pct = ctk.CTkLabel(self, text="", font=FONT_BODY, text_color=ACCENT_PENDING, anchor="e")
|
|
dot.grid(row=i + 1, column=0, padx=(16, 2), pady=(2, 2), sticky="w")
|
|
label.grid(row=i + 1, column=1, padx=(2, 4), pady=(2, 2), sticky="w")
|
|
pct.grid(row=i + 1, column=2, padx=(4, 8), pady=(2, 2), sticky="e")
|
|
self._sub_items.append({"dot": dot, "label": label, "pct": pct})
|
|
|
|
self.grid_columnconfigure(1, weight=1)
|
|
self._pulse_job: str | None = None
|
|
|
|
def _toggle(self) -> None:
|
|
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"].grid_remove()
|
|
item["pct"].grid_remove()
|
|
else:
|
|
item["dot"].grid()
|
|
item["label"].grid()
|
|
item["pct"].grid()
|
|
|
|
def set_expanded(self, expanded: bool) -> None:
|
|
if expanded != self._collapsed:
|
|
self._toggle()
|
|
|
|
def set_phase(self, phase: str, pct: int = -1) -> None:
|
|
_C_PENDING = ACCENT_PENDING
|
|
_C_RUNNING = CIRCLE_RUNNING
|
|
_C_DONE = ACCENT_COMPLETE
|
|
|
|
idx = {"erase": 0, "program": 1, "verify": 2}.get(phase, -1)
|
|
if idx < 0:
|
|
if phase == "done":
|
|
self._stop_pulse()
|
|
for i in self._sub_items:
|
|
i["dot"].configure(text="●", text_color=_C_DONE)
|
|
i["label"].configure(text_color=_C_DONE)
|
|
i["pct"].configure(text="OK", text_color=_C_DONE)
|
|
elif phase == "error":
|
|
self._stop_pulse()
|
|
for i in self._sub_items:
|
|
i["dot"].configure(text="✗", text_color=ACCENT_ERROR)
|
|
i["label"].configure(text_color=ACCENT_ERROR)
|
|
i["pct"].configure(text="", text_color=ACCENT_ERROR)
|
|
return
|
|
|
|
pct_text = f"{pct}%" if pct >= 0 else ""
|
|
for i, item in enumerate(self._sub_items):
|
|
if i < idx:
|
|
item["dot"].configure(text="●", text_color=_C_DONE)
|
|
item["label"].configure(text_color=_C_DONE)
|
|
item["pct"].configure(text="OK", text_color=_C_DONE)
|
|
elif i == idx:
|
|
item["dot"].configure(text="◉", text_color=_C_RUNNING)
|
|
item["label"].configure(text_color=_C_RUNNING)
|
|
item["pct"].configure(text=pct_text, text_color=_C_RUNNING)
|
|
else:
|
|
item["dot"].configure(text="○", text_color=_C_PENDING)
|
|
item["label"].configure(text_color=_C_PENDING)
|
|
item["pct"].configure(text="", text_color=_C_PENDING)
|
|
|
|
if phase in ("erase", "program", "verify") and not self._pulse_job:
|
|
self._start_pulse(idx)
|
|
|
|
def _start_pulse(self, idx: int) -> None:
|
|
"""Start pulsing the running sub-step to match StepHeader animation."""
|
|
_LIGHT = RUNNING_FADE_LIGHT
|
|
_DARK = RUNNING_FADE_DARK
|
|
|
|
def _pulse(state=[True]):
|
|
if idx >= len(self._sub_items):
|
|
return
|
|
item = self._sub_items[idx]
|
|
c = _LIGHT if state[0] else _DARK
|
|
item["dot"].configure(text_color=c)
|
|
item["label"].configure(text_color=c)
|
|
item["pct"].configure(text_color=c)
|
|
state[0] = not state[0]
|
|
self._pulse_job = self.after(PULSE_DURATION, lambda: _pulse(state))
|
|
|
|
_pulse()
|
|
|
|
def _stop_pulse(self) -> None:
|
|
if self._pulse_job:
|
|
self.after_cancel(self._pulse_job)
|
|
self._pulse_job = None
|
|
|
|
def reset(self) -> None:
|
|
self._stop_pulse()
|
|
_C = ACCENT_PENDING
|
|
for i in self._sub_items:
|
|
i["dot"].configure(text="○", text_color=_C)
|
|
i["label"].configure(text_color=_C)
|
|
i["pct"].configure(text="", text_color=_C)
|
|
if not self._collapsed:
|
|
self._toggle()
|
|
self._toggle() # collapse then expand to reset
|
|
self._toggle()
|