Major refactor of Ameba Control Panel v3.1.0: - Three-column layout: icon sidebar, config+history, log view - Dracula PRO theme with light/dark toggle - DTR/RTS GPIO control (replaces ASCII commands) - Multi-CDC firmware support for AmebaSmart control device - Dynamic DUT tabs with +/- management - NN Model flash image support - Settings dialog (Font, Serial, Flash, Command tabs) - Background port scanning, debounced session store - Adaptive log flush rate, format cache optimization - Smooth sidebar animation, deferred startup - pytest test framework with session/log/settings tests - Thread safety fixes: _alive guards, parented timers, safe baud parsing - Find highlight: needle-only highlighting with focused match color - Partial line buffering for table output - PyInstaller packaging with version stamp and module exclusions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
152 lines
5.5 KiB
Python
152 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import deque
|
|
from typing import Iterable, List
|
|
|
|
from PySide6.QtGui import QColor, QFont, QTextCharFormat, QTextCursor, QTextOption
|
|
from PySide6.QtWidgets import QTextEdit
|
|
|
|
from ameba_control_panel.config import Direction
|
|
from ameba_control_panel.services.log_buffer import LogLine
|
|
|
|
_HIGHLIGHT_BG = QColor("#fff59d") # Yellow — all matches
|
|
_FOCUS_BG = QColor("#ff9800") # Orange — current focused match
|
|
|
|
|
|
class LogView(QTextEdit):
|
|
"""Fast append-only log with selectable text and match highlighting."""
|
|
|
|
def __init__(self, max_items: int, parent=None) -> None:
|
|
super().__init__(parent)
|
|
self.setReadOnly(True)
|
|
self.setWordWrapMode(QTextOption.NoWrap)
|
|
self.setLineWrapMode(QTextEdit.NoWrap)
|
|
self.setHorizontalScrollBarPolicy(self.horizontalScrollBarPolicy())
|
|
font = QFont("JetBrains Mono")
|
|
font.setStyleHint(QFont.Monospace)
|
|
font.setPointSize(10)
|
|
self.setFont(font)
|
|
self._max_items = max_items
|
|
self._lines: deque = deque()
|
|
self._colors = {
|
|
Direction.RX: QColor("#1a8a3d"),
|
|
Direction.TX: QColor("#2944a8"),
|
|
Direction.INFO: QColor("#7970a9"),
|
|
}
|
|
self._fmt_cache = self._build_fmt_cache()
|
|
self._match_rows: List[int] = []
|
|
self._focus_idx: int = -1
|
|
self._needle: str = ""
|
|
self._case_sensitive: bool = False
|
|
|
|
def _build_fmt_cache(self) -> dict:
|
|
cache = {}
|
|
for direction, color in self._colors.items():
|
|
fmt = QTextCharFormat()
|
|
fmt.setForeground(color)
|
|
cache[direction] = fmt
|
|
return cache
|
|
|
|
def set_colors(self, rx: str, tx: str, info: str) -> None:
|
|
self._colors = {
|
|
Direction.RX: QColor(rx),
|
|
Direction.TX: QColor(tx),
|
|
Direction.INFO: QColor(info),
|
|
}
|
|
self._fmt_cache = self._build_fmt_cache()
|
|
|
|
def append_lines(self, lines: Iterable[LogLine]) -> None:
|
|
if not lines:
|
|
return
|
|
cursor = self.textCursor()
|
|
cursor.movePosition(QTextCursor.End)
|
|
cursor.beginEditBlock()
|
|
doc = self.document()
|
|
# Reuse format objects — avoids allocation per line
|
|
for line in lines:
|
|
self._lines.append(line)
|
|
cursor.insertText(line.as_display(), self._fmt_cache[line.direction])
|
|
cursor.insertBlock()
|
|
# Batch trim — remove excess in one block
|
|
overflow = len(self._lines) - self._max_items
|
|
if overflow > 0:
|
|
for _ in range(overflow):
|
|
self._lines.popleft()
|
|
block = doc.firstBlock()
|
|
cur = QTextCursor(block)
|
|
cur.select(QTextCursor.BlockUnderCursor)
|
|
cur.removeSelectedText()
|
|
cur.deleteChar()
|
|
if self._match_rows:
|
|
self._match_rows = []
|
|
self._focus_idx = -1
|
|
self.setExtraSelections([])
|
|
cursor.endEditBlock()
|
|
self.verticalScrollBar().setValue(self.verticalScrollBar().maximum())
|
|
|
|
def clear_log(self) -> None:
|
|
self._lines.clear()
|
|
self.clear()
|
|
self._match_rows = []
|
|
self._focus_idx = -1
|
|
self._needle = ""
|
|
|
|
def set_needle(self, needle: str, case_sensitive: bool = False) -> None:
|
|
self._needle = needle
|
|
self._case_sensitive = case_sensitive
|
|
|
|
def set_matches(self, rows: List[int], focus_idx: int = -1) -> None:
|
|
self._match_rows = rows
|
|
self._focus_idx = focus_idx
|
|
self._apply_matches()
|
|
|
|
def _apply_matches(self) -> None:
|
|
extra: list = []
|
|
doc = self.document()
|
|
needle = self._needle
|
|
case_sensitive = self._case_sensitive
|
|
|
|
for i, row in enumerate(self._match_rows):
|
|
block = doc.findBlockByNumber(row)
|
|
if not block.isValid():
|
|
continue
|
|
is_focus = (i == self._focus_idx)
|
|
bg = _FOCUS_BG if is_focus else _HIGHLIGHT_BG
|
|
|
|
if needle:
|
|
text = block.text()
|
|
search_text = text if case_sensitive else text.lower()
|
|
search_needle = needle if case_sensitive else needle.lower()
|
|
start = 0
|
|
while True:
|
|
pos = search_text.find(search_needle, start)
|
|
if pos < 0:
|
|
break
|
|
cursor = QTextCursor(block)
|
|
cursor.movePosition(QTextCursor.StartOfBlock)
|
|
cursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, pos)
|
|
cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, len(needle))
|
|
sel = QTextEdit.ExtraSelection()
|
|
sel.cursor = cursor
|
|
sel.format.setBackground(bg)
|
|
extra.append(sel)
|
|
start = pos + len(needle)
|
|
else:
|
|
# Fallback: highlight whole line
|
|
cursor = QTextCursor(block)
|
|
cursor.movePosition(QTextCursor.StartOfBlock)
|
|
cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor)
|
|
sel = QTextEdit.ExtraSelection()
|
|
sel.cursor = cursor
|
|
sel.format.setBackground(bg)
|
|
sel.format.setProperty(QTextCharFormat.FullWidthSelection, True)
|
|
extra.append(sel)
|
|
|
|
self.setExtraSelections(extra)
|
|
|
|
def displayed_lines(self) -> list:
|
|
return list(self._lines)
|
|
|
|
def copy_selected(self) -> None:
|
|
self.copy()
|