feat(share): render colors in the shared terminal — 0.24.0
The terminal view rendered monochrome (QPlainTextEdit.setPlainText), dropping pyte's per-cell attributes. Rewritten as a QTextEdit that renders fg/bg/bold/ reverse per cell (block cursor = inverted cell), preserving scrollback. The session already runs the host's $SHELL + config with TERM=xterm-256color, so fish/ls/git/prompts now look the same as locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,13 @@ All notable changes to RigDoctor are recorded here. Format follows
|
||||
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
|
||||
release tag (so the auto-updater, D18, can compare versions).
|
||||
|
||||
## [0.24.0] - 2026-05-22
|
||||
### Added
|
||||
- **Shared terminal is now in color.** The terminal view renders pyte's per-cell foreground/
|
||||
background, bold, and reverse, so the host's real shell keeps its theming — fish, `ls`,
|
||||
`git`, prompts, etc. look the same as locally (the session already runs the host's `$SHELL`
|
||||
with its config and `TERM=xterm-256color`; only the rendering was monochrome).
|
||||
|
||||
## [0.23.0] - 2026-05-22
|
||||
### Added
|
||||
- **Crash-logger trigger modes (M9 / D6)** via `systemd --user`, no root: **manual**,
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rigdoctor"
|
||||
version = "0.23.0"
|
||||
version = "0.24.0"
|
||||
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
||||
|
||||
__version__ = "0.23.0"
|
||||
__version__ = "0.24.0"
|
||||
|
||||
@@ -1,30 +1,66 @@
|
||||
"""A minimal terminal view: renders PTY output via pyte and emits keystrokes (M12, Tier 3).
|
||||
"""A terminal view: renders PTY output via pyte (with colors) and emits keystrokes (M12).
|
||||
|
||||
Used by both sides of a shared session — the host (mirrors its local PTY, can also type, e.g.
|
||||
a sudo password) and the guest (renders the streamed PTY, sends keystrokes). Monochrome for
|
||||
now; cursor addressing / layout (vim, top) work via pyte.
|
||||
a sudo password) and the guest (renders the streamed PTY, sends keystrokes). Renders pyte's
|
||||
per-cell foreground/background/bold/reverse so the host's real shell (e.g. fish) keeps its
|
||||
colors and theming; cursor addressing (vim, top) works via pyte. Scrollback is preserved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _html
|
||||
|
||||
import pyte
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QFontDatabase, QFontMetrics, QTextCursor
|
||||
from PySide6.QtWidgets import QPlainTextEdit
|
||||
from PySide6.QtGui import QFontDatabase, QFontMetrics
|
||||
from PySide6.QtWidgets import QTextEdit
|
||||
|
||||
# ANSI named colors → RGB (a dark, modern palette). pyte also yields 6-hex strings for
|
||||
# 256-color / truecolor, which we pass through, and "default" which maps to the theme.
|
||||
_FG_DEFAULT = "#d6dae0"
|
||||
_BG_DEFAULT = "#0d0f13"
|
||||
_NAMED = {
|
||||
"black": "#2a2f39", "red": "#f87171", "green": "#4ade80", "brown": "#e5c07b",
|
||||
"yellow": "#e5c07b", "blue": "#60a5fa", "magenta": "#c084fc", "cyan": "#38bdf8",
|
||||
"white": "#d6dae0",
|
||||
}
|
||||
_BRIGHT = { # bold brightens the standard 8
|
||||
"black": "#5b626c", "red": "#fca5a5", "green": "#86efac", "brown": "#fde68a",
|
||||
"yellow": "#fde68a", "blue": "#93c5fd", "magenta": "#d8b4fe", "cyan": "#7dd3fc",
|
||||
"white": "#ffffff",
|
||||
}
|
||||
_HISTORY_RENDER = 400 # cap scrollback rows rendered per frame (perf)
|
||||
|
||||
|
||||
class TerminalView(QPlainTextEdit):
|
||||
def _color(name: str, default: str, bright: bool) -> str:
|
||||
if name == "default":
|
||||
return default
|
||||
table = _BRIGHT if bright else _NAMED
|
||||
if name in table:
|
||||
return table[name]
|
||||
if len(name) == 6: # pyte 256/truecolor as a hex string
|
||||
try:
|
||||
int(name, 16)
|
||||
return "#" + name
|
||||
except ValueError:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
class TerminalView(QTextEdit):
|
||||
keys = Signal(bytes) # user keystrokes -> bytes for the PTY
|
||||
resized = Signal(int, int) # rows, cols
|
||||
|
||||
def __init__(self, rows: int = 24, cols: int = 80):
|
||||
super().__init__()
|
||||
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
||||
self.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
|
||||
self.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
|
||||
self.setUndoRedoEnabled(False)
|
||||
self.setMinimumHeight(260)
|
||||
self.setReadOnly(False) # we capture keys ourselves; no local editing
|
||||
self.setStyleSheet(f"QTextEdit {{ background: {_BG_DEFAULT}; border: none; }}")
|
||||
self.setMinimumHeight(320)
|
||||
self._rows, self._cols = rows, cols
|
||||
self._screen = pyte.HistoryScreen(cols, rows, history=1000, ratio=0.5)
|
||||
self._screen = pyte.HistoryScreen(cols, rows, history=2000, ratio=0.5)
|
||||
self._stream = pyte.ByteStream(self._screen)
|
||||
|
||||
def grid(self) -> tuple[int, int]:
|
||||
@@ -38,24 +74,51 @@ class TerminalView(QPlainTextEdit):
|
||||
self._screen.reset()
|
||||
self._render()
|
||||
|
||||
def _row_text(self, row) -> str:
|
||||
return "".join(row[x].data for x in range(self._cols)).rstrip()
|
||||
# --- rendering ---------------------------------------------------------------------
|
||||
def _span(self, style, text: str) -> str:
|
||||
fg_name, bg_name, bold, reverse = style
|
||||
fg = _color(fg_name, _FG_DEFAULT, bold)
|
||||
bg = _color(bg_name, _BG_DEFAULT, False)
|
||||
if reverse:
|
||||
fg, bg = bg, fg
|
||||
esc = _html.escape(text, quote=False).replace(" ", " ")
|
||||
weight = "font-weight:bold;" if bold else ""
|
||||
return f'<span style="color:{fg};background:{bg};{weight}">{esc}</span>'
|
||||
|
||||
def _row_html(self, row, cursor_x) -> str:
|
||||
out: list[str] = []
|
||||
buf: list[str] = []
|
||||
cur_style = None
|
||||
for x in range(self._cols):
|
||||
ch = row[x]
|
||||
reverse = ch.reverse
|
||||
if cursor_x is not None and x == cursor_x and self.hasFocus():
|
||||
reverse = not reverse # block cursor = inverted cell
|
||||
style = (ch.fg, ch.bg, ch.bold, reverse)
|
||||
if style != cur_style:
|
||||
if buf:
|
||||
out.append(self._span(cur_style, "".join(buf)))
|
||||
buf = []
|
||||
cur_style = style
|
||||
buf.append(ch.data or " ")
|
||||
if buf:
|
||||
out.append(self._span(cur_style, "".join(buf)))
|
||||
return "".join(out)
|
||||
|
||||
def _render(self) -> None:
|
||||
bar = self.verticalScrollBar()
|
||||
at_bottom = bar.value() >= bar.maximum() - 2
|
||||
prev = bar.value()
|
||||
history = [self._row_text(r) for r in self._screen.history.top] # scrollback
|
||||
self.setPlainText("\n".join(history + list(self._screen.display)))
|
||||
if at_bottom: # follow output; place caret at the real (row, col)
|
||||
cursor = self.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.Start)
|
||||
cursor.movePosition(QTextCursor.MoveOperation.Down, QTextCursor.MoveMode.MoveAnchor, len(history) + self._screen.cursor.y)
|
||||
cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, self._screen.cursor.x)
|
||||
self.setTextCursor(cursor)
|
||||
self.ensureCursorVisible()
|
||||
else: # user scrolled up to read — keep their place
|
||||
bar.setValue(prev)
|
||||
|
||||
history = list(self._screen.history.top)[-_HISTORY_RENDER:]
|
||||
lines = [self._row_html(r, None) for r in history]
|
||||
cur_y = self._screen.cursor.y
|
||||
for y in range(self._rows):
|
||||
cursor_x = self._screen.cursor.x if y == cur_y else None
|
||||
lines.append(self._row_html(self._screen.buffer[y], cursor_x))
|
||||
self.setHtml('<div style="white-space:pre;line-height:100%;">' + "<br>".join(lines) + "</div>")
|
||||
|
||||
bar.setValue(bar.maximum() if at_bottom else prev)
|
||||
|
||||
def resizeEvent(self, event): # noqa: N802 (Qt override)
|
||||
super().resizeEvent(event)
|
||||
|
||||
Reference in New Issue
Block a user