"""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("", self._on_hover_enter) self.bind("", 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()