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:
2026-05-22 10:00:23 +02:00
parent 7804893054
commit 5e5dc2d54a
4 changed files with 94 additions and 24 deletions
+7
View File
@@ -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 (`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
release tag (so the auto-updater, D18, can compare versions). 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 ## [0.23.0] - 2026-05-22
### Added ### Added
- **Crash-logger trigger modes (M9 / D6)** via `systemd --user`, no root: **manual**, - **Crash-logger trigger modes (M9 / D6)** via `systemd --user`, no root: **manual**,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "rigdoctor" name = "rigdoctor"
version = "0.23.0" version = "0.24.0"
description = "Modular hardware monitoring & crash diagnostics for Linux gamers." description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.23.0" __version__ = "0.24.0"
+85 -22
View File
@@ -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. 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 a sudo password) and the guest (renders the streamed PTY, sends keystrokes). Renders pyte's
now; cursor addressing / layout (vim, top) work via pyte. 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 from __future__ import annotations
import html as _html
import pyte import pyte
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFontDatabase, QFontMetrics, QTextCursor from PySide6.QtGui import QFontDatabase, QFontMetrics
from PySide6.QtWidgets import QPlainTextEdit 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 keys = Signal(bytes) # user keystrokes -> bytes for the PTY
resized = Signal(int, int) # rows, cols resized = Signal(int, int) # rows, cols
def __init__(self, rows: int = 24, cols: int = 80): def __init__(self, rows: int = 24, cols: int = 80):
super().__init__() super().__init__()
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) self.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
self.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) self.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
self.setUndoRedoEnabled(False) 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._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) self._stream = pyte.ByteStream(self._screen)
def grid(self) -> tuple[int, int]: def grid(self) -> tuple[int, int]:
@@ -38,24 +74,51 @@ class TerminalView(QPlainTextEdit):
self._screen.reset() self._screen.reset()
self._render() self._render()
def _row_text(self, row) -> str: # --- rendering ---------------------------------------------------------------------
return "".join(row[x].data for x in range(self._cols)).rstrip() 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(" ", "&nbsp;")
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: def _render(self) -> None:
bar = self.verticalScrollBar() bar = self.verticalScrollBar()
at_bottom = bar.value() >= bar.maximum() - 2 at_bottom = bar.value() >= bar.maximum() - 2
prev = bar.value() 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))) history = list(self._screen.history.top)[-_HISTORY_RENDER:]
if at_bottom: # follow output; place caret at the real (row, col) lines = [self._row_html(r, None) for r in history]
cursor = self.textCursor() cur_y = self._screen.cursor.y
cursor.movePosition(QTextCursor.MoveOperation.Start) for y in range(self._rows):
cursor.movePosition(QTextCursor.MoveOperation.Down, QTextCursor.MoveMode.MoveAnchor, len(history) + self._screen.cursor.y) cursor_x = self._screen.cursor.x if y == cur_y else None
cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, self._screen.cursor.x) lines.append(self._row_html(self._screen.buffer[y], cursor_x))
self.setTextCursor(cursor) self.setHtml('<div style="white-space:pre;line-height:100%;">' + "<br>".join(lines) + "</div>")
self.ensureCursorVisible()
else: # user scrolled up to read — keep their place bar.setValue(bar.maximum() if at_bottom else prev)
bar.setValue(prev)
def resizeEvent(self, event): # noqa: N802 (Qt override) def resizeEvent(self, event): # noqa: N802 (Qt override)
super().resizeEvent(event) super().resizeEvent(event)