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