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