wongyiekheng c92fbe7548 Second Commit
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>
2026-03-29 13:01:12 +08:00

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