Files
Zynq_Flasher/src/gui/widgets.py
T
Jeremy Shen 09be20791f feat: forward UART parsed boot info to main log window
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)
2026-06-10 15:40:09 +08:00

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()