Skip to content

visualdynamics.gui.report_editor

report_editor

The report editor: the exported page as the preview, with the actions on a bar above it.

The page shown is the same HTML the export writes, so it is exactly what the reader will get, with the figures drawn by the same JavaScript the reader's browser will run. It does two things the exported page does not: it outlines the selected block, and it reports which block was clicked. Everything else is in Qt. Above the page is a bar of actions (Insert, Reference, Move Up, Move Down, Delete, and Export as HTML or as a report template), and beside it is a settings pane for the selection: a figure block's sources and caption, a text block's Markdown in a Qt editor, or the report's title, marking and Report Features panel when nothing is selected (report_features_panel). Each action is one operation on the Report model, which the window records in the journal, followed by a new render of the page. The exported file carries none of the editor's controls.

Classes:

Name Description
ReportEditor

The report editor: the web view, the bar and the settings pane.

Classes

ReportEditor

ReportEditor(parent: QWidget | None = None)

Bases: QWidget

The report editor: the web view, the bar and the settings pane.

It applies each operation to the report and renders the page again. edited is emitted after each change to the report, and export_requested and template_requested when Export → HTML or Export → Report template is chosen.

Methods:

Name Description
insert

Insert a block of kind, one of the Insert menu's kinds, after

insert_reference

Insert {{figure:caption}} or {{table:caption}} at the text

flush_text

Apply the text editor's Markdown to the selected block now. The

show_report

Open report in the editor, with nothing selected.

rebuild

Render the page again from the report and load it, keeping 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.object_groups: 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 page, made here and owned here rather than by the view:
    #: a view deletes a page it made itself in the same call that
    #: detaches it (`QWebEngineViewPrivate::bindPageAndView`), so
    #: its live web contents were always torn down with the widget
    #: they drew into still standing — the hang `stand_down` exists
    #: for. A page the view does not own is only detached by
    #: `setPage(None)`, and its teardown can be sequenced.
    self.page: QWebEnginePage = QWebEnginePage(self)
    self.view.setPage(self.page)
    #: 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.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
    # a plot block of a measured spectrum: whether its figure draws
    # the record's caption block, and that caption read out
    self.record_caption_check: QCheckBox | None = None
    self.record_caption_label: QLabel | None = None
    self.title_edit: QLineEdit | None = None
    self.marking_edit: QLineEdit | None = None
    self.color_box: QComboBox | None = None
    #: the Report Features panel, while the report's own settings
    #: are in the pane
    self.features_panel: ReportFeaturesPanel | None = None
    #: how a change of report features is made: the window sets the
    #: project's verb, so the change is journaled as a script would
    #: make it; on its own the editor calls the registry directly.
    #: Called as `features_changer(report, enable=, disable=,
    #: preset=)` and returns a `FeatureSelection`.
    self.features_changer: Callable[..., Any] | None = None
    #: how the readings of the report's check blocks are made before
    #: the page is drawn (report feature `check_blocks`): the window
    #: sets a call that makes them on its worker, so a slow reading
    #: never holds the GUI thread. Called as `checks_preparer(report)`;
    #: False means the worker is busy with another task and the page
    #: waits for the next rebuild. On its own the editor reads them as it renders.
    self.checks_preparer: Callable[[Report], bool] | None = None
    #: the Identity and Issue fields, by field key, and the line
    #: that says what was refused, while the report's own settings
    #: are in the pane and the feature is on
    self.identity_edits: dict[str, Any] = {}
    self.identity_notes: QLabel | None = None
    self.amend_button: QPushButton | None = None
    self.identity_box: QWidget | None = None
    #: how identification is entered and an amendment made: the
    #: window sets the project's verbs, so each is journaled as a
    #: script would make it; on its own the editor calls
    #: `report.identity` directly. Called as `identity_changer(report,
    #: **fields)` and `amender(report, changes, revision=, number=)`.
    self.identity_changer: Callable[..., Any] | None = None
    self.amender: Callable[..., Any] | None = None
    #: a skeleton section's None button and its Interpretation, by
    #: field, while such a block is in the pane
    self.none_button: QPushButton | None = None
    self.by_edit: QLineEdit | None = None
    #: the test dates on a channel table's pane, the Setup and
    #: Method pane's Insert Calibration Status button, and the line
    #: under them that says what was refused, while calibration in
    #: date is on (`report.calibration`)
    self.date_edit: QLineEdit | None = None
    self.end_edit: QLineEdit | None = None
    self.calibration_button: QPushButton | None = None
    self.calibration_note: QLabel | None = None
    #: how the test dates are set: the window sets the project's
    #: verb, so the change is journaled; on its own the editor
    #: calls `report.calibration.set_dates`. Called as
    #: `calibration_dater(report, start, end)`.
    self.calibration_dater: Callable[..., Any] | None = None
    #: the level run, while a responses-against-prediction block is
    #: in the pane
    self.level_edit: QLineEdit | None = None
    #: the runs panels (report feature `run_sections`), while shown,
    #: and what the last of their changes had to say
    self.runs_panel: Any = None
    self.chronology_panel: Any = None
    self._runs_message: str = ''
    #: the window's worker for a verb that takes a while, called as
    #: `run_long(status, verb, *args)`; None runs it here
    self.run_long: Callable[..., Any] | None = None
    #: the findings register under section 9, while that section is
    #: in the pane and the report has the register on; the finding
    #: it shows, and the words of the last change it refused
    self.findings_pane: Any = None
    self.finding_selected: str | None = None
    self._finding_note: str = ''
    #: how a fact draft is read off a measurement: the window sets
    #: the project's verb on its worker (the reading takes a few
    #: tenths of a second), so it is journaled and the window stays
    #: alive; on its own the editor reads it here. Called as
    #: `fact_drafter(report, number, measured, specification,
    #: limits=)`.
    self.fact_drafter: Callable[..., Any] | 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, one of the Insert menu's kinds, 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`, one of the Insert menu's kinds, 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

Insert {{figure:caption}} or {{table:caption}} at the text editor's cursor. This is the token the report stores, numbered by the same code that numbers the page. Nothing happens unless a text block's editor is open.

Source code in src/visualdynamics/gui/report_editor.py
def insert_reference(self, kind: str, caption: str) -> None:
    """Insert `{{figure:caption}}` or `{{table:caption}}` at the text
    editor's cursor. This is the token the report stores, numbered by
    the same code that numbers the page. Nothing happens unless a
    text block's editor is open."""
    if self.text_editor is None:
        return
    self.text_editor.textCursor().insertText(f'{{{{{kind}:{caption}}}}}')
    self.text_editor.setFocus()
flush_text
flush_text() -> None

Apply the text editor's Markdown to the selected block now. The editor does this itself once typing has paused for TEXT_DEBOUNCE_MS, and tests call it directly.

Source code in src/visualdynamics/gui/report_editor.py
def flush_text(self) -> None:
    """Apply the text editor's Markdown to the selected block now. The
    editor does this itself once typing has paused for
    `TEXT_DEBOUNCE_MS`, and tests call it directly."""
    self._text_timer.stop()
    if self.findings_pane is not None:
        # the register's parts typed and not yet entered
        self.findings_pane.flush()
    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})
show_report
show_report(report: Report, objects: Callable[[], dict[str, Any]], unit_system: Any, object_groups: Callable[[], list[Any]] | None = None) -> None

Open report in the editor, with nothing selected.

objects returns the project's objects by name and object_groups, when given, the project's object groups. Both are called on every render, so the editor reads the project as it stands. unit_system is the display units.

Source code in src/visualdynamics/gui/report_editor.py
def show_report(self, report: Report,
                objects: Callable[[], dict[str, Any]],
                unit_system: Any,
                object_groups: Callable[[], list[Any]] | None = None) -> None:
    """Open `report` in the editor, with nothing selected.

    `objects` returns the project's objects by name and
    `object_groups`, when given, the project's object groups. Both
    are called on every render, so the editor reads the project as it
    stands. `unit_system` is the display units.
    """
    self.report = report
    self.objects = objects
    self.object_groups = object_groups
    self.unit_system = unit_system
    self._scroll = 0
    self.selected = None
    self.rebuild()
    self._show_selection()
rebuild
rebuild() -> None

Render the page again from the report and load it, keeping the scroll position. When the render raises, the page shows the traceback and failure holds a one-line summary of it.

Source code in src/visualdynamics/gui/report_editor.py
def rebuild(self) -> None:
    """Render the page again from the report and load it, keeping the
    scroll position. When the render raises, the page shows the
    traceback and `failure` holds a one-line summary of it."""
    from ..report import render_html

    if self.report is None:
        return
    if (self.checks_preparer is not None
            and not self.checks_preparer(self.report)):
        # the check readings wait on the worker; the page is drawn
        # by the rebuild that follows them
        self.stale = True
        return
    self.stale = False
    if self._channel_js is None:
        # Qt's own qwebchannel.js, inlined into the page: the page
        # loads from a file (setHtml silently blanks past
        # Chromium's 2 MB limit — a real coherence map is bigger)
        # and file: pages cannot reach qrc:
        resource = QFile(':/qtwebchannel/qwebchannel.js')
        resource.open(QFile.OpenModeFlag.ReadOnly)
        self._channel_js = bytes(resource.readAll()).decode('utf-8')
        resource.close()
    labels: list[tuple[str, str]] = []
    try:
        html = render_html(self.report, self.objects(), self.unit_system,
                           edit=True, channel_js=self._channel_js,
                           object_groups=self.object_groups() if self.object_groups else None,
                           selected=self.selected, labels=labels)
    except Exception as failure:  # noqa: BLE001 — the page reports it
        # A build that raised used to leave the view white and the
        # pane reading "No report", the exception gone to a console
        # nobody was looking at (Brandon, 2026-09-18: "just getting a
        # white screen and no report"). The page carries the
        # traceback now, the pane and the status line the one line
        # that names it, and the report object is still there to
        # be looked at.
        self.failure = f'{type(failure).__name__}: {failure}'
        html = self._failure_page(traceback.format_exc())
        labels = []
    else:
        self.failure = None
    self._labels = labels
    self._fill_insert_menu()
    self._fill_reference_menu()
    if self._page_path is None:
        handle, self._page_path = tempfile.mkstemp(
            prefix='visualdynamics_report_', suffix='.html')
        os.close(handle)
    with open(self._page_path, 'w', encoding='utf-8') as out:
        out.write(html)
    self.view.load(QUrl.fromLocalFile(self._page_path))
    scroll = self._scroll

    def restore(_ok: bool) -> None:
        self._restore = None
        self.view.loadFinished.disconnect(restore)
        # the view owns the timer: a window closed inside those
        # 50 ms takes the view with it, and a bare singleShot then
        # fired into a deleted QWebEngineView (2026-09-13, once
        # windows really died at teardown); and `stand_down` stops
        # it, so no script fires into a page being discarded
        self._scroll_to = int(scroll)
        self._scroll_timer.start()

    # one slot at a time: a rebuild before the last page finished
    # would otherwise leave the earlier slot connected, to fire on
    # this load and disconnect itself twice
    if self._restore is not None:
        with contextlib.suppress(RuntimeError, TypeError):
            self.view.loadFinished.disconnect(self._restore)
    self._restore = restore
    self.view.loadFinished.connect(restore)
    self._offer()
stand_down
stand_down() -> None

Leave the page with nothing for the view's destructor to wait on.

Call this before the editor is destroyed. A QWebEngineView destroyed while its page is live can block the whole process, because Chromium's teardown waits on the render process and does not return. A hidden page can be discarded instead (its render process shut down and the page unloaded), and a discarded page is destroyed in about a millisecond.

So the navigation in flight and its scroll-restoring slot are stopped, the view is hidden, the page is detached from the view, the events that remove the widget are processed, and only then is the page discarded, with nothing left to wait on but the render process it is shutting down. Each step is written to the debug log.

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.

    Call this before the editor is destroyed. A `QWebEngineView`
    destroyed while its page is live can block the whole process,
    because Chromium's teardown waits on the render process and does
    not return. A hidden page can be discarded instead (its render
    process shut down and the page unloaded), and a discarded page is
    destroyed in about a millisecond.

    So the navigation in flight and its scroll-restoring slot are
    stopped, the view is hidden, the page is detached from the view,
    the events that remove the widget are processed, and only then is
    the page discarded, with nothing left to wait on but the render
    process it is shutting down. Each step is written to the debug
    log.
    """
    # History. A destroyed view blocked in a synchronous `mach_msg`
    # call that never returned: the gate's "stall at 98 %", sampled on
    # 2026-09-18 with a worker's main thread parked inside
    # QtWebEngineCore, then named 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, was not enough: a loaded, rendering page hangs the
    # destructor the same way. Discarding took the destructor from a
    # hang to 0.001 s (measured).
    # Then the discard itself hung (2026-09-28, a stack this time:
    # `setLifecycleState(Discarded)` never returning from
    # `WebContentsAdapter::discard()`). Qt's source says why: discard
    # asks the render process to shut down and then destroys the live
    # web contents synchronously, the same teardown the destructor did,
    # while the page is still bound to the widget it drew into, with
    # that widget's compositor and surfaces alive to be waited on. The
    # view's destructor deletes an owned page in the same call that
    # detaches it. Hence detach first, pump, then discard. That is a
    # mechanism, not a witnessed cure (the hang needs a starved machine
    # and never reproduced alone), so the log still records each step.
    from PySide6.QtWidgets import QApplication

    page = self.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()
    # detached before discarded: the view forgets a page it does
    # not own without deleting it, and the widget the page drew
    # into goes away on the events pumped here
    self.view.setPage(None)
    for _ in range(20):
        QApplication.processEvents()
    _log.debug('stand_down: detached')
    with contextlib.suppress(RuntimeError):
        page.setLifecycleState(QWebEnginePage.LifecycleState.Discarded)
    _log.debug('stand_down: discarded, state=%s', page.lifecycleState())
    for _ in range(20):
        QApplication.processEvents()

Functions: