Skip to content

visualdynamics.gui.report_editor

report_editor

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

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

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: