The report editor: the exported page as the preview, the acts on the bar.
The page shown is the very HTML the export writes — a browser view of
exactly what the reader will get, figures drawn by the same JavaScript
their browser will run — and it does two things the export's page does
not: it frames the selected block, and it says which block was clicked.
Everything else lives in Qt (Brandon, 2026-09-08: "the toolbar should be
the same as the task bar at the top of the report screen how we have for
every other GUI object"): a bar of acts above the page — Insert,
Reference, Move Up, Move Down, Delete, Export — and a settings pane
beside it carrying what the selected block has to say: a figure block's
sources and caption, a text block's Markdown in a Qt editor, the
report's title and marking when nothing is selected. Every act is one
operation on the Report model, journaled by the window, followed by a
re-render of the page; the exported file never carries any chrome.
Classes:
| Name |
Description |
ReportEditor |
Owns the web view, the bar and the pane; mutates the report the
|
Classes
ReportEditor
ReportEditor(parent: QWidget | None = None)
Bases: QWidget
Owns the web view, the bar and the pane; mutates the report the
operations describe.
Methods:
| Name |
Description |
insert |
Insert a block of kind after the selected block, or at the
|
insert_reference |
{{figure:caption}} or {{table:caption}} at the text
|
flush_text |
Land the text editor's Markdown on the block now — what the
|
stand_down |
Leave the page with nothing for the view's destructor to wait on.
|
Source code in src/visualdynamics/gui/report_editor.py
| def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.report: Any = None
#: callables the window sets, so the editor reads the project
#: as it stands rather than a copy taken when it opened
self.objects: Callable[[], dict[str, Any]] | None = None
self.links: Callable[[], list[Any]] | None = None
self.unit_system: Any = None
#: the block the bar and the pane act on, or None for the report
self.selected: int | None = None
#: (label, caption) for every numbered figure and table, from
#: the last render — the Reference menu's offer
self._labels: list[tuple[str, str]] = []
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.toolbar: QToolBar = self._build_toolbar()
layout.addWidget(self.toolbar)
self.split: QSplitter = QSplitter(Qt.Orientation.Horizontal)
self.view: QWebEngineView = QWebEngineView()
#: the loadFinished slot of the navigation in flight, if any
self._restore = None
#: the one timer that scrolls a freshly loaded page back to
#: where it was — held, so standing down can stop it: a bare
#: single-shot could still fire its script into a page in the
#: middle of being discarded (the gate's stall, 2026-09-19)
self._scroll_timer: QTimer = QTimer(self.view)
self._scroll_timer.setSingleShot(True)
self._scroll_timer.setInterval(50)
self._scroll_timer.timeout.connect(self._scroll_back)
self._scroll_to: int = 0
self.split.addWidget(self.view)
self.pane: QScrollArea = QScrollArea()
self.pane.setWidgetResizable(True)
self.pane.setMinimumWidth(240)
self.split.addWidget(self.pane)
self.split.setStretchFactor(0, 3)
self.split.setStretchFactor(1, 1)
self.split.setSizes([900, 300])
layout.addWidget(self.split, 1)
self.bridge: _Bridge = _Bridge(self)
self.bridge.operated.connect(self._operate)
self.channel: QWebChannel = QWebChannel(self)
self.channel.registerObject('bridge', self.bridge)
self.view.page().setWebChannel(self.channel)
self._scroll = 0
self._page_path = None
self._channel_js = None
#: the project changed while this page was not on screen; the
#: window rebuilds before showing it again rather than paying
#: 254 ms per change for a document nobody is looking at
self.stale: bool = False
#: what stopped the last build, or None: a page that could not
#: be built says so instead of staying white
self.failure: str | None = None
# the pane's widgets, rebuilt when the selection changes; None
# while the selection has no such field
self.text_editor: QPlainTextEdit | None = None
self.caption_edit: QLineEdit | None = None
self.title_edit: QLineEdit | None = None
self.marking_edit: QLineEdit | None = None
self.color_box: QComboBox | None = None
self.field_boxes: dict[str, QComboBox] = {}
self._loading_pane = False
self._text_timer: QTimer = QTimer(self)
self._text_timer.setSingleShot(True)
self._text_timer.setInterval(TEXT_DEBOUNCE_MS)
self._text_timer.timeout.connect(self.flush_text)
self._show_selection()
|
Methods:
insert
insert(kind: str) -> None
Insert a block of kind after the selected block, or at the
end, and select it.
Source code in src/visualdynamics/gui/report_editor.py
| def insert(self, kind: str) -> None:
"""Insert a block of `kind` after the selected block, or at the
end, and select it."""
if self.report is None:
return
at = (self.selected + 1 if self.selected is not None
else self.report.num_blocks)
self._operate({'op': 'insert', 'at': at, 'kind': kind})
|
insert_reference
insert_reference(kind: str, caption: str) -> None
{{figure:caption}} or {{table:caption}} at the text
editor's cursor — the token the model stores, renumbered by
the same code that numbers the page.
Source code in src/visualdynamics/gui/report_editor.py
| def insert_reference(self, kind: str, caption: str) -> None:
"""`{{figure:caption}}` or `{{table:caption}}` at the text
editor's cursor — the token the model stores, renumbered by
the same code that numbers the page."""
if self.text_editor is None:
return
self.text_editor.textCursor().insertText(f'{{{{{kind}:{caption}}}}}')
self.text_editor.setFocus()
|
flush_text
Land the text editor's Markdown on the block now — what the
debounce does after typing rests, and what a test calls.
Source code in src/visualdynamics/gui/report_editor.py
| def flush_text(self) -> None:
"""Land the text editor's Markdown on the block now — what the
debounce does after typing rests, and what a test calls."""
self._text_timer.stop()
if (self.text_editor is None or self.report is None
or self.selected is None
or not 0 <= self.selected < self.report.num_blocks):
return
text = self.text_editor.toPlainText()
if text == self.report.blocks[self.selected].get('text', ''):
return
self._operate({'op': 'field', 'at': self.selected, 'field': 'text',
'value': text})
|
stand_down
Leave the page with nothing for the view's destructor to wait on.
A QWebEngineView destroyed while its page is live blocked the
whole process: Chromium's teardown waits on the render process
in a synchronous mach_msg call that never returned. That was
the gate's "stall at 98 %" — sampled on 2026-09-18 with a
worker's main thread parked inside QtWebEngineCore, and then
named exactly by a faulthandler dump: the window fixture
delivering the deferred delete to a window whose report page
had just finished loading. Stopping a navigation in flight
(the first cut of this) was not enough; a loaded, rendering
page hangs the destructor just the same.
The cure is Qt's own: a hidden page can be discarded — its
render process shut down gracefully, the page unloaded — and
a discarded page is destroyed in a millisecond (measured: the
destructor went from a hang to 0.001 s). So the view is
hidden, the page discarded, and the events that carry the
change are pumped before the caller goes on to destroy
anything. The scroll-restoring slot of a navigation in flight
is dropped too, since it would fire into the discarded page.
Source code in src/visualdynamics/gui/report_editor.py
| def stand_down(self) -> None:
"""Leave the page with nothing for the view's destructor to wait on.
A `QWebEngineView` destroyed while its page is live blocked the
whole process: Chromium's teardown waits on the render process
in a synchronous `mach_msg` call that never returned. That was
the gate's "stall at 98 %" — sampled on 2026-09-18 with a
worker's main thread parked inside QtWebEngineCore, and then
named exactly by a faulthandler dump: the window fixture
delivering the deferred delete to a window whose report page
had *just finished* loading. Stopping a navigation in flight
(the first cut of this) was not enough; a loaded, rendering
page hangs the destructor just the same.
The cure is Qt's own: a hidden page can be *discarded* — its
render process shut down gracefully, the page unloaded — and
a discarded page is destroyed in a millisecond (measured: the
destructor went from a hang to 0.001 s). So the view is
hidden, the page discarded, and the events that carry the
change are pumped before the caller goes on to destroy
anything. The scroll-restoring slot of a navigation in flight
is dropped too, since it would fire into the discarded page.
"""
from PySide6.QtWidgets import QApplication
page = self.view.page()
# the witness the stall's record asks for (PLAN.md "The 98 %
# stall, named"): the page's state at the moment of the
# discard, on the debug log — the faulthandler dump names the
# line and not the page
_log.debug('stand_down: loading=%s state=%s restore=%s scroll_timer=%s',
page.isLoading(), page.lifecycleState(),
self._restore is not None, self._scroll_timer.isActive())
if self._restore is not None:
with contextlib.suppress(RuntimeError, TypeError):
self.view.loadFinished.disconnect(self._restore)
self._restore = None
self._scroll_timer.stop()
self.view.stop()
self.view.hide()
# the enum through the page rather than a QtWebEngineCore
# import: the rulebook's sanctioned Qt modules are the ones
# already imported, and the page carries its own states
with contextlib.suppress(RuntimeError):
page.setLifecycleState(type(page).LifecycleState.Discarded)
_log.debug('stand_down: discarded')
for _ in range(20):
QApplication.processEvents()
|
Functions: