UartMonitorWindow now accepts on_log callback. When boot info (IP, version) is parsed from serial output, it: - Appears in the UART window's own log (prefix [UART]) - Forwards to main window's log panel via on_log callback - Unified [UART] prefix across both windows (was [BOOT] in UART window, [UART] Boot detected: in main log)
1047 lines
36 KiB
Python
1047 lines
36 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 threading
|
|
|
|
import customtkinter as ctk
|
|
from typing import Callable
|
|
|
|
from serial_monitor import UartMonitor
|
|
|
|
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,
|
|
DARK_CARD,
|
|
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()
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════
|
|
# UartMonitorWindow — standalone UART monitoring window
|
|
# ════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class UartMonitorWindow(ctk.CTkToplevel):
|
|
"""Standalone UART monitor window with real-time serial output.
|
|
|
|
Opens a Toplevel window that connects to the selected serial port,
|
|
displays live output in a scrollable log, and provides Start/Stop/Clear
|
|
controls. Reuses UartMonitor from serial_monitor.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
master: ctk.CTk | ctk.CTkToplevel,
|
|
port: str,
|
|
baudrate: int = 115200,
|
|
on_log: Callable[[str], None] | None = None,
|
|
) -> None:
|
|
"""Initialize the UART monitor window.
|
|
|
|
Args:
|
|
master: Parent window (MainWindow).
|
|
port: Serial port device path (e.g., '/dev/ttyUSB0').
|
|
baudrate: Baud rate for serial communication.
|
|
on_log: Optional callback to forward parsed events to main log.
|
|
"""
|
|
super().__init__(master)
|
|
self.title("UART Monitor")
|
|
self.geometry("680x540")
|
|
self.minsize(480, 360)
|
|
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
|
|
|
self._on_log = on_log
|
|
|
|
self._port = port
|
|
self._baudrate = baudrate
|
|
self._monitor: UartMonitor | None = None
|
|
self._line_count = 0
|
|
self._is_opening = False
|
|
|
|
self._build_title_bar()
|
|
self._build_controls()
|
|
self._build_log_area()
|
|
self._build_status_bar()
|
|
|
|
# Try to open serial port after window is drawn
|
|
self.after(100, self._try_open)
|
|
|
|
# ── Title Bar ──────────────────────────────────────────────
|
|
|
|
def _build_title_bar(self) -> None:
|
|
bar = ctk.CTkFrame(self, fg_color=DARK_CARD)
|
|
bar.pack(fill="x", padx=PADDING, pady=(PADDING, PADDING_SMALL))
|
|
|
|
self._title_label = ctk.CTkLabel(
|
|
bar, text="UART Monitor", font=_font(14, "bold"),
|
|
)
|
|
self._title_label.pack(side="left", padx=(PADDING_SMALL, 0), pady=PADDING_SMALL)
|
|
|
|
self._status_indicator = ctk.CTkLabel(
|
|
bar, text="●", font=_font(12),
|
|
text_color=ACCENT_PENDING,
|
|
)
|
|
self._status_indicator.pack(side="right", padx=(PADDING_SMALL, PADDING_SMALL))
|
|
|
|
self._status_text = ctk.CTkLabel(
|
|
bar, text="Connecting...", font=FONT_SMALL,
|
|
text_color=DESC_TEXT_COLOR,
|
|
)
|
|
self._status_text.pack(side="right")
|
|
|
|
# ── Controls ───────────────────────────────────────────────
|
|
|
|
def _build_controls(self) -> None:
|
|
frame = ctk.CTkFrame(self, fg_color="transparent")
|
|
frame.pack(fill="x", padx=PADDING, pady=PADDING_SMALL)
|
|
|
|
self._port_info = ctk.CTkLabel(
|
|
frame, text=f"Port: {self._port} @ {self._baudrate} baud",
|
|
font=FONT_SMALL,
|
|
)
|
|
self._port_info.pack(side="left")
|
|
|
|
btn_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
|
btn_frame.pack(side="right")
|
|
|
|
self._start_btn = ctk.CTkButton(
|
|
btn_frame, text="Start", font=FONT_SMALL, width=64,
|
|
fg_color=PRIMARY_COLOR, hover_color=STEP_RUN_BG_HOVER,
|
|
command=self._on_start,
|
|
)
|
|
self._start_btn.pack(side="left", padx=(0, PADDING_SMALL))
|
|
|
|
self._stop_btn = ctk.CTkButton(
|
|
btn_frame, text="Stop", font=FONT_SMALL, width=64,
|
|
fg_color=DANGER_COLOR, hover_color="#B92B2F",
|
|
command=self._on_stop, state="disabled",
|
|
)
|
|
self._stop_btn.pack(side="left", padx=(0, PADDING_SMALL))
|
|
|
|
self._clear_btn = ctk.CTkButton(
|
|
btn_frame, text="Clear", font=FONT_SMALL, width=64,
|
|
fg_color=STEP_RUNNER_BG, hover_color=STEP_RUNNER_BG_HOVER,
|
|
command=self._on_clear, state="disabled",
|
|
)
|
|
self._clear_btn.pack(side="left")
|
|
|
|
# ── Log Area ───────────────────────────────────────────────
|
|
|
|
def _build_log_area(self) -> None:
|
|
self._log_frame = ctk.CTkFrame(self)
|
|
self._log_frame.pack(fill="both", expand=True, padx=PADDING, pady=PADDING_SMALL)
|
|
|
|
self._log_text = ctk.CTkTextbox(
|
|
self._log_frame,
|
|
font=FONT_MONO,
|
|
corner_radius=CORNER_RADIUS,
|
|
state="disabled",
|
|
)
|
|
self._log_text.pack(fill="both", expand=True, padx=PADDING, pady=PADDING)
|
|
|
|
# ── Status Bar ─────────────────────────────────────────────
|
|
|
|
def _build_status_bar(self) -> None:
|
|
bar = ctk.CTkFrame(self, fg_color=DARK_CARD)
|
|
bar.pack(fill="x", padx=PADDING, pady=(0, PADDING))
|
|
|
|
self._line_label = ctk.CTkLabel(
|
|
bar, text="Lines: 0", font=FONT_SMALL,
|
|
text_color=DESC_TEXT_COLOR,
|
|
)
|
|
self._line_label.pack(side="left", padx=PADDING_SMALL)
|
|
|
|
self._error_label = ctk.CTkLabel(
|
|
bar, text="", font=FONT_SMALL,
|
|
text_color=DANGER_COLOR,
|
|
)
|
|
self._error_label.pack(side="right", padx=PADDING_SMALL)
|
|
|
|
# ── Serial Port Open / Close ───────────────────────────────
|
|
|
|
def _try_open(self) -> None:
|
|
"""Attempt to open the serial port on init."""
|
|
if not self._port:
|
|
self._show_unavailable("未选择串口")
|
|
return
|
|
|
|
self._is_opening = True
|
|
self._update_status(ACCENT_PENDING, "Connecting...")
|
|
|
|
def _bg_open() -> None:
|
|
try:
|
|
import serial
|
|
ser = serial.Serial(self._port, self._baudrate, timeout=1)
|
|
ser.close()
|
|
self.after(0, self._on_open_success)
|
|
except Exception as e:
|
|
self.after(0, lambda: self._show_unavailable(str(e)))
|
|
|
|
threading.Thread(target=_bg_open, daemon=True).start()
|
|
|
|
def _on_open_success(self) -> None:
|
|
"""Serial port opened — auto-start monitoring."""
|
|
self._is_opening = False
|
|
self._start_btn.configure(state="normal")
|
|
self._log(f"[{self._port}] Port ready, auto-starting monitor...\n")
|
|
self._on_start()
|
|
|
|
def _show_unavailable(self, reason: str) -> None:
|
|
"""Show UART unavailable state."""
|
|
self._is_opening = False
|
|
self._status_indicator.configure(text_color=DANGER_COLOR)
|
|
self._status_text.configure(text="UART 不可用")
|
|
self._port_info.configure(text=f"Port: {self._port} (Unavailable)")
|
|
self._start_btn.configure(state="disabled")
|
|
self._log(f"[UART] 不可用: {reason}\n")
|
|
|
|
# ── Start / Stop / Clear ───────────────────────────────────
|
|
|
|
def _on_start(self) -> None:
|
|
if self._monitor and self._monitor.is_running():
|
|
return
|
|
|
|
self._start_btn.configure(state="disabled")
|
|
self._stop_btn.configure(state="normal")
|
|
self._clear_btn.configure(state="normal")
|
|
self._update_status(ACCENT_RUNNING, "Monitoring...")
|
|
self._log(f"[*] Starting monitor on {self._port} @ {self._baudrate} baud\n")
|
|
|
|
self._monitor = UartMonitor(self._port, self._baudrate)
|
|
self._monitor.on_line = self._on_line
|
|
self._monitor.on_boot_info = self._on_boot_info
|
|
self._monitor.on_error = self._on_error
|
|
|
|
if not self._monitor.start():
|
|
self._log("[!] Failed to start monitor\n")
|
|
self._update_status(ACCENT_ERROR, "Start failed")
|
|
self._start_btn.configure(state="normal")
|
|
self._stop_btn.configure(state="disabled")
|
|
self._clear_btn.configure(state="disabled")
|
|
|
|
def _on_stop(self) -> None:
|
|
if self._monitor:
|
|
self._monitor.stop()
|
|
self._monitor = None
|
|
self._start_btn.configure(state="normal")
|
|
self._stop_btn.configure(state="disabled")
|
|
self._clear_btn.configure(state="disabled")
|
|
self._update_status(ACCENT_PENDING, "Stopped")
|
|
self._log("[*] Monitor stopped\n")
|
|
|
|
def _on_clear(self) -> None:
|
|
self._log_text.configure(state="normal")
|
|
self._log_text.delete("1.0", "end")
|
|
self._log_text.configure(state="disabled")
|
|
self._line_count = 0
|
|
self._line_label.configure(text="Lines: 0")
|
|
|
|
# ── Callbacks (called from UartMonitor background thread) ──
|
|
|
|
def _on_line(self, line: str) -> None:
|
|
self.after(0, lambda: self._log_line(line))
|
|
|
|
def _on_boot_info(self, info) -> None:
|
|
parts = []
|
|
if info.ip_address:
|
|
parts.append(f"IP={info.ip_address}")
|
|
if info.version:
|
|
parts.append(f"Ver={info.version}")
|
|
if parts:
|
|
msg = f"[UART] {' '.join(parts)}"
|
|
self.after(0, lambda: self._log(f"{msg}\n"))
|
|
# Forward to main window log
|
|
if self._on_log:
|
|
self.after(0, lambda: self._on_log(msg))
|
|
|
|
def _on_error(self, error: str) -> None:
|
|
self.after(0, lambda: self._log_error(error))
|
|
|
|
# ── UI Update Helpers ──────────────────────────────────────
|
|
|
|
def _log(self, text: str) -> None:
|
|
"""Append text to the log (thread-safe via after)."""
|
|
self._log_text.configure(state="normal")
|
|
self._log_text.insert("end", text)
|
|
self._log_text.see("end")
|
|
self._log_text.configure(state="disabled")
|
|
|
|
def _log_line(self, line: str) -> None:
|
|
self._line_count += 1
|
|
self._log(f"{line}\n")
|
|
self._line_label.configure(text=f"Lines: {self._line_count}")
|
|
|
|
def _log_error(self, error: str) -> None:
|
|
self._error_label.configure(text=error)
|
|
|
|
def _update_status(self, color: str, text: str) -> None:
|
|
self._status_indicator.configure(text_color=color)
|
|
self._status_text.configure(text=text)
|
|
|
|
# ── Window Close ───────────────────────────────────────────
|
|
|
|
@property
|
|
def is_monitoring(self) -> bool:
|
|
"""True if the UART monitor thread is currently running."""
|
|
return self._monitor is not None and self._monitor.is_running()
|
|
|
|
def _on_close(self) -> None:
|
|
"""Cleanup UartMonitor and destroy window."""
|
|
if self._monitor:
|
|
self._monitor.stop()
|
|
self._monitor = None
|
|
self.grab_release()
|
|
self.destroy()
|