"""Render plain-text run summaries as foldable sections.
The verdict opens by default and later sections begin collapsed. Sections are
parsed from underline-style headings in the text because the input may be a
saved summary from another spaCR version or an unstructured statsmodels
summary, not a live ``RunSummary`` object. Text without recognized headings is
shown unchanged.
The source summary remains ordinary plain text suitable for terminals and run
artifacts; this widget adds navigation without becoming a second serializer.
"""
import re
import html
import logging
from typing import List, Tuple
from PySide6.QtCore import Qt
from PySide6.QtGui import (QColor, QFontDatabase, QSyntaxHighlighter,
QTextCharFormat)
from PySide6.QtWidgets import (QHBoxLayout, QLabel, QPlainTextEdit,
QPushButton, QScrollArea, QVBoxLayout,
QWidget)
from .collapsible_section import CollapsibleSection
LOG = logging.getLogger(__name__)
#: The section that answers the question, and so the one left open.
#: `format_run_summary` writes it first and quotes every line of it from the
#: sections below, so a reader who never unfolds anything still has the answer.
ANSWER_HEADING = "THE ANSWER"
#: The document title, which is not a section and gets no fold of its own.
DOCUMENT_HEADING = "spaCR RUN SUMMARY"
[docs]
def split_sections(text: str) -> Tuple[str, List[Tuple[str, str]]]:
"""Split ``text`` into the preamble and its ``(heading, body)`` sections.
:param text: the whole summary text; a heading is a line underlined by
``-`` or ``=`` of the same length. None counts as empty.
:returns: everything before the first section, then the sections. A text
with no headings comes back as ``(text, [])`` -- which is the signal
to show it unchanged.
"""
lines = str(text or "").splitlines()
marks: List[int] = []
for i in range(len(lines) - 1):
title = lines[i].strip()
rule = lines[i + 1].strip()
if not title or not rule:
continue
if len(rule) != len(title):
continue
if set(rule) not in ({"-"}, {"="}):
continue
if title == DOCUMENT_HEADING:
continue
marks.append(i)
if not marks:
return str(text or ""), []
preamble = "\n".join(lines[:marks[0]]).strip("\n")
sections: List[Tuple[str, str]] = []
for index, start in enumerate(marks):
end = marks[index + 1] if index + 1 < len(marks) else len(lines)
heading = lines[start].strip()
body = "\n".join(lines[start + 2:end]).strip("\n")
sections.append((heading, body))
return preamble, sections
[docs]
def split_rows(body: str) -> list:
"""A section body as ``[(label, value)]``, or ``[]`` if it is not rows.
``format_run_summary`` uses a fixed-width label column for terminal and
file output. This parser recovers those rows so the Qt view can reflow the
value column to the available width. The label width is inferred from the
first labelled row for compatibility with summaries from other versions.
:param body: one section's body text, as written by ``format_run_summary``;
None counts as empty.
"""
lines = [line for line in str(body or "").splitlines() if line.strip()]
if not lines:
return []
lead = None
for line in lines:
if not line.startswith(" "):
continue
stripped = line[2:]
gap = len(stripped) - len(stripped.lstrip())
match = _ROW.match(line)
if match:
lead = len(match.group(1))
break
if lead is None:
return []
rows: list = []
for line in lines:
if len(line) > lead and line[:lead].strip():
rows.append([line[:lead].strip(), line[lead:].strip()])
elif rows and len(line) > lead:
rows[-1][1] = (rows[-1][1] + " " + line[lead:].strip()).strip()
elif line.strip():
rows.append(["", line.strip()])
return [(label, value) for label, value in rows]
#: A summary row: two spaces, a label, then at least two more spaces.
_ROW = re.compile(r"^( \S[^\s]*(?:[ \t]\S+)*? +)\S")
class _RejectionHighlighter(QSyntaxHighlighter):
"""Highlight rejected assumptions and blocking recommendations.
Cautions and small diagnostic values retain the normal text colour. The
error colour comes from the active theme so rejection markers remain
readable in both light and dark themes.
"""
#: What marks a line as a broken assumption. `REJECTED at` is written by
#: `regression_summary._verdict`; the `!` prefix is what
#: `run_recommendations` puts on a blocking recommendation.
MARKERS = ("REJECTED at", " ! ")
def __init__(self, document, colour: str):
"""Colour the lines that mark a broken assumption.
:param document: the ``QTextDocument`` to highlight. ALSO THE QOBJECT
PARENT, which is what keeps the highlighter alive for exactly as
long as the document it formats.
:param colour: the foreground for a marked line, as anything
``QColor`` accepts. Resolved once here, so a theme change needs
a new highlighter.
"""
super().__init__(document)
self._format = QTextCharFormat()
self._format.setForeground(QColor(colour))
def highlightBlock(self, text: str) -> None: # noqa: N802 - Qt naming
"""Colour a whole line that carries a rejection marker.
THE WHOLE LINE, not the matched word: "REJECTED at 0.05" is the
verdict on the sentence it sits in, and colouring three words inside a
grey line reads as emphasis rather than as a state.
:param text: the line.
"""
line = str(text)
if any(marker in line for marker in self.MARKERS):
self.setFormat(0, len(line), self._format)
[docs]
class FoldingSummaryView(QScrollArea):
"""A drop-in for the Summary tab's ``QPlainTextEdit``.
Keeps ``setPlainText`` and ``toPlainText`` so the panel that fills it does
not have to know which it got, and so the text a test reads is the text
the file holds.
:param parent: parent widget.
"""
def __init__(self, parent=None):
"""Build the scrolling summary view with its copy and save actions.
The action row is held as one widget rather than as a bare layout, and
that is load-bearing: the rebuild empties the body by taking items out
and deleting them, and it only finds widgets. A bare layout was taken
out and dropped while the buttons inside it stayed children of the body
with nothing laying them out -- still visible, still clickable, stuck at
their last geometry and painted under everything added afterwards, which
is the giant save button reachable only where no text covered it.
:param parent: parent widget, or ``None``.
"""
super().__init__(parent)
self.setWidgetResizable(True)
self._body = QWidget(self)
self._layout = QVBoxLayout(self._body)
self._layout.setContentsMargins(6, 6, 6, 6)
self._layout.setSpacing(4)
self.setWidget(self._body)
self._actions = QWidget(self._body)
actions = QHBoxLayout(self._actions)
actions.setContentsMargins(0, 0, 0, 0)
self.copy_button = QPushButton("\u29c9 Copy")
self.copy_button.setToolTip(
"Copy the whole summary to the clipboard, exactly as it is "
"written to the run folder.")
self.copy_button.setFlat(True)
self.copy_button.clicked.connect(self.copy_to_clipboard)
actions.addWidget(self.copy_button)
self.save_button = QPushButton("Save\u2026")
self.save_button.setToolTip(
"Write the summary to a text file. It is the run's own summary, "
"so this is a copy rather than a new rendering.")
self.save_button.setFlat(True)
self.save_button.clicked.connect(self.save_to_file)
actions.addWidget(self.save_button)
actions.addStretch(1)
self._layout.addWidget(self._actions)
self._text = ""
self._sections: List[CollapsibleSection] = []
self._mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
[docs]
def setPlainText(self, text: str) -> None: # noqa: N802 - Qt naming
"""Replace the text and rebuild the folded sections from it.
:param text: the summary text.
"""
self._text = str(text or "")
self._rebuild()
[docs]
def toPlainText(self) -> str: # noqa: N802 - Qt naming
"""The text as set, whatever is folded away right now.
THE SOURCE, NOT THE VIEW. Folding is a display state, so a caller
copying this out gets the whole summary rather than only the parts
that happen to be open.
:returns: the summary text.
"""
return self._text
[docs]
def isReadOnly(self) -> bool: # noqa: N802 - Qt naming
"""Always. A summary is a run artefact, not a document to edit."""
return True
[docs]
def setReadOnly(self, read_only: bool) -> None: # noqa: N802 - Qt naming
"""Accepted and ignored -- see :meth:`isReadOnly`.
Kept so this stays a drop-in for the QPlainTextEdit it replaced: a
caller that sets what is already true should not have to know which
widget it got.
:param read_only: ignored; the view is always read-only.
"""
[docs]
def font(self):
"""The fixed-width font the bodies are laid out in.
The summary is aligned with spaces, so the font is part of whether it
is readable at all -- and a test asserts the widget was GIVEN a fixed
font rather than trusting a style hint.
"""
return self._mono
[docs]
def copy_to_clipboard(self) -> bool:
"""The whole summary, as the run wrote it. Returns whether it went."""
from PySide6.QtWidgets import QApplication
text = self.toPlainText()
if not text.strip():
return False
clipboard = QApplication.clipboard()
if clipboard is None:
return False
clipboard.setText(text)
return True
[docs]
def save_to_file(self, path: str = "") -> str:
"""Write the summary out. Returns the path written, or ``""``.
A COPY, NOT A RE-RENDER. The run wrote this text when it was fitted;
rendering it again here would differ in the statsmodels `Time:`
header alone and invite the reader to wonder which is authoritative.
"""
from PySide6.QtWidgets import QFileDialog
text = self.toPlainText()
if not text.strip():
return ""
chosen = str(path or "")
if not chosen:
chosen, _filter = QFileDialog.getSaveFileName(
self, "Save the summary", "model_summary.txt",
"Text (*.txt)")
if not chosen:
return ""
try:
with open(chosen, "w", encoding="utf-8") as handle:
handle.write(text)
except OSError:
return ""
return chosen
[docs]
def section_titles(self) -> tuple:
"""Every section's title, in document order.
:returns: the titles.
"""
return tuple(s.title() for s in self._sections)
[docs]
def is_section_expanded(self, title: str) -> bool:
"""Whether one section is currently open.
:param title: the section's title.
:returns: True when expanded.
"""
for section in self._sections:
if section.title() == str(title):
return section.is_expanded()
return False
[docs]
def set_section_expanded(self, title: str, expanded: bool) -> None:
"""Open or close one section.
:param title: the section's title.
:param expanded: True to open it.
"""
for section in self._sections:
if section.title() == str(title):
section.set_expanded(bool(expanded))
def _clear(self) -> None:
"""Empty the body, KEEPING the action row to put back.
It is taken out with everything else -- so a rebuild controls where
it sits -- but never deleted, because Copy and Save belong to the
panel rather than to whichever summary is currently in it.
"""
self._sections = []
keep = getattr(self, "_actions", None)
while self._layout.count():
item = self._layout.takeAt(0)
widget = item.widget()
if widget is None or widget is keep:
continue
widget.setParent(None)
widget.deleteLater()
if keep is not None:
self._layout.addWidget(keep)
def _reading_surface(self) -> str:
"""Return an ``rgba(...)`` surface that keeps summary text legible.
The active ``surface_alt`` colour and the pane opacity separate text
from the animated background without making the panel fully opaque --
the colour and the alpha are asked for under the same role, so the
alpha is the one solved for legibility over that colour. Fall back to
transparency when the theme cannot be resolved.
"""
try:
from ..preferences import get_pane_opacity, resolve_effective_theme
from ..theme import palette_for, panel_alpha
theme = resolve_effective_theme()
colour = str(palette_for(theme).get("surface_alt", "#161719"))
alpha = panel_alpha(theme, "surface_alt", get_pane_opacity())
r, g, b = (int(colour[i:i + 2], 16) for i in (1, 3, 5))
return f"rgba({r}, {g}, {b}, {max(0.0, min(1.0, float(alpha))):.3f})"
except Exception: # noqa: BLE001
LOG.debug("could not resolve the summary reading surface",
exc_info=True)
return "transparent"
def _table(self, rows: list) -> QWidget:
"""Build an aligned two-column view for one summary section.
Labels are bold and values wrap to the available width. A text browser
is used so the value column can reflow without a horizontal scroll
bar.
"""
from PySide6.QtWidgets import QTextBrowser
view = QTextBrowser(self._body)
view.setOpenExternalLinks(True)
view.setFrameShape(QTextBrowser.NoFrame)
view.viewport().setAutoFillBackground(False)
view.setStyleSheet(
f"QTextBrowser {{ background: {self._reading_surface()};"
f" border-radius: 6px; }}")
try:
from ..theme import active_palette
alarm = active_palette()["error"]
except Exception: # noqa: BLE001
alarm = ""
def _tint(label: str, value: str) -> str:
"""One cell, coloured only when the row is an alarm."""
if not alarm:
return ""
line = f"{label} {value}"
if any(marker in line
for marker in _RejectionHighlighter.MARKERS):
return f"color:{alarm};"
return ""
cells = []
for label, value in rows:
tint = _tint(label, value)
if label:
cells.append(
f"<tr><td style='padding:1px 14px 1px 0;"
f"white-space:nowrap;vertical-align:top;{tint}'><b>"
f"{html.escape(label)}</b></td>"
f"<td style='padding:1px 0;{tint}'>"
f"{html.escape(value)}</td></tr>")
else:
cells.append(
f"<tr><td colspan='2' style='padding:4px 0;{tint}'>"
f"{html.escape(value)}</td></tr>")
view.setHtml("<table style='border-collapse:collapse'>"
+ "".join(cells) + "</table>")
view.document().setDocumentMargin(2)
rows_shown = max(2, len(rows))
view.setMinimumHeight(min(460, 20 * rows_shown + 12))
return view
def _block(self, text: str) -> QPlainTextEdit:
"""One body, in the fixed-width font the file was laid out for.
The summary is aligned with spaces -- a label column and a wrapped
explanation -- so a proportional font would ruin every row of it.
"""
view = QPlainTextEdit(self._body)
view.setReadOnly(True)
view.setLineWrapMode(QPlainTextEdit.NoWrap)
view.setFont(self._mono)
view.viewport().setAutoFillBackground(False)
view.setStyleSheet(
f"QPlainTextEdit {{ background: {self._reading_surface()};"
f" border-radius: 6px; }}")
try:
from ..theme import active_palette
view._spacr_highlighter = _RejectionHighlighter(
view.document(), active_palette()["error"])
except Exception: # noqa: BLE001
pass
view.setPlainText(text)
rows = max(3, text.count("\n") + 2)
view.setMinimumHeight(min(420, 18 * rows))
return view
def _rebuild(self) -> None:
"""Rebuild the summary as folded sections, the verdict open.
Text with no spaCR headings is shown whole: chopping up a statsmodels
summary by a guess would be worse than leaving it. A section whose body
is rows becomes a table; anything else stays a block, because the
statsmodels summary is column-aligned ASCII and re-laying it out would
destroy the alignment it carries itself.
"""
self._clear()
preamble, sections = split_sections(self._text)
if not sections:
self._layout.addWidget(self._block(self._text), 1)
return
if preamble.strip():
label = QLabel(preamble, self._body)
label.setWordWrap(True)
label.setTextInteractionFlags(Qt.TextSelectableByMouse)
self._layout.addWidget(label)
for heading, body in sections:
rows = split_rows(body)
expanded = heading.upper() == ANSWER_HEADING
content = self._table(rows) if rows else self._block(body)
section = CollapsibleSection(heading, content,
expanded=expanded, parent=self._body)
self._layout.addWidget(section)
self._sections.append(section)
self._layout.addStretch(1)