QtWidgets → QML Migration Notes
This is a scoping document for a long-term, incremental migration of BioGUI's UI layer from QtWidgets to QML. It is not a schedule or a commitment — it's a map of what has to change, in what rough order, and why, so the migration can proceed gradually without losing track of the overall shape.
Can data_sources/ be kept as-is?
Not the files as currently laid out, but the I/O logic itself needs no rewrite.
Every file under biogui/data_sources/ (e.g. serial.py) currently defines two classes in one
file: a pure-QtCore *DataSourceWorker (QObject/QThread/Signal only) and a QtWidgets
*ConfigWidget (the form shown in "Add data source"). Because they share a file, and because
base.py/__init__.py import QWidget unconditionally (just for type hints and the abstract
DataSourceConfigWidget), a worker class can't currently be imported without dragging in
QtWidgets.
The fix is a mechanical split — worker → its own QtCore-only file, config widget → its own
QtWidgets file — not a behavioral rewrite. The two wulpus/wulpus_pro platform families go
further and embed a QDialog-popping config callback directly in/next to the interface file;
those need the same treatment, since it's the same worker/config-UI entanglement at the plugin
level.
What's already portable unchanged
biogui/controllers/streaming_controller.py,biogui/controllers/signal_filters.py— pure QtCore/numpy/scipy, zero QtWidgets.- Most
biogui/platforms/*/interface_*.pydecode modules — pure numpy, no Qt at all (exceptions: the wulpus family, see above). - The
*DataSourceWorkerclasses themselves (post-split). main.py'sSocketListener(remote start/stop over TCP).- No
QSettingsusage anywhere (plain JSON/dicts) — nothing to migrate there.
The acquisition/streaming core was already built in a QtCore-clean style, mostly as a side effect of using QObject/QThread for worker plumbing. The rewrite is concentrated in the view and controller layers.
What has to change, in broad terms
-
App bootstrap (
biogui/biogui.py) —BioGUI(QApplication)directly builds and shows aQMainWindow. A QML app needsQGuiApplication+QQmlApplicationEngineloading a root.qmlfile instead. Treat this as the last step, not the first. -
MainController(biogui/controllers/main_controller.py, 971 lines — the single biggest item) — currently reaches directly intoMainWindow's named child widgets everywhere: sets models ondataSourceTree, toggles buttonsetEnabled, constructs and.exec()s dialogs inline, callsmainWin.addPlotWidget/removePlotWidget, popsQMessageBoxdirectly, and defines aQStyledItemDelegatewith manualQPainterrow-drawing plus a widgeteventFilter. QML needs this turned into a real view-model: properties/signals/models only, no widget-name reach-through, no inline dialog popping. -
ModuleControllerand the three pluggable modules (modules/trigger.py,forwarding.py,teleprompter.py) — each currently doesmainWin.moduleContainer.layout().addWidget(...)to inject its sidebar config UI, andtrigger.pyadditionally hand-paints stimulus frames with rawQPainter/QPixmap. The "grab a layout and addWidget" plugin contract has no QML equivalent — needs to become "each module exposes a QMLComponent, host usesLoader/Repeater." -
Platform config plugin point (
PlatformConfig.configureInterfaceModule, used bywulpus/wulpus_pro) — today it's literally "a callback that pops aQDialog." Same redesign as (3): swap for a QML component/path contract. -
The whole
biogui/views/tree — forms (SignalConfigWidget/Dialog), aQWizard-based flow (SignalConfigWizard— QWizard has no QML analog, becomes aStackView), a tree-in-combobox picker (DataSourceConfigDialog, 614 lines), one genuinely hand-painted widget (SidebarSplitter's collapse arrow), and thepost_run_plotters/dashboards (dense, pyqtgraph+pandas driven). -
pyqtgraph — the hardest problem, deliberately last. pyqtgraph has no QML binding, and it's the dominant graphics dependency: live line plots, a real-time scrolling image/waterfall for ultrasound M-mode (
mmode_plot_mode.py, updates apg.ImageItemevery frame), and every post-run dashboard. A prior attempt to back pyqtgraph withQOpenGLWidgetwas abandoned for performance reasons — a signal that current plotting is tuned for CPU rendering, not GPU. Realistic options, roughly in the order to try them: - Keep pyqtgraph, embed it via
QQuickWidgetinside otherwise-QML panels — legitimate as a permanent end state, not just a stopgap, especially for M-mode. - Prototype Qt Graphs (QML module) for the simple line-plot screens only, with a throughput spike first — real-time append performance at streaming rates is unproven, don't assume it.
- Fall back to
QQuickPaintedItem(closest match to pyqtgraph's current CPU-painted approach) if Qt Graphs disappoints. QQuickFramebufferObjectonly if that also disappoints — it reintroduces the same GPU uncertainty the oldQOpenGLWidgetattempt hit.
It's fine — arguably correct — for M-mode and the dashboards to simply stay
pyqtgraph-in-QQuickWidget indefinitely.
Recommended approach: incremental, hybrid, bottom-up
Not a big-bang rewrite. Use QQuickWidget to embed QML panels inside the existing
QMainWindow, one screen at a time, so the app stays runnable at every step. Full replacement
of the root window with QQmlApplicationEngine is optional and should come last, if at all —
staying permanently hybrid is a legitimate architecture with no deadline attached.
Suggested phase order (each phase should ship before starting the next):
- Phase 0 (do first, regardless of QML timing): prerequisite refactors — split
utils.py(pulldetectTheme()/QWidget-parameterized type alias out, leave it pure dataclasses), split everydata_sources/*.pyinto worker + config-widget files, same split for the wulpus/wulpus_pro config mechanism, and introduce a narrow interface betweenMainControllerandMainWindow(setters/properties instead of reaching into named children). These are pure refactors with no visible behavior change — verifiable against the current running app, and valuable even if QML stalls. - Phase 1 — learn QML on low-stakes, low-coupling screens:
HelpDialog(self-contained),SidebarSplitter's collapse arrow (small, and genuinely easier in QML — a good early win). - Phase 2 — simple forms with real controller coupling:
SignalConfigWidget/Dialog, the data-source config widgets (now split out per Phase 0), the tree-in-combobox picker, thenSignalConfigWizard(as aStackView) — this is where the "QML view + Python view-model via properties/QAbstractItemModel" pattern gets established for reuse everywhere after. - Phase 3 — plugin architecture: redefine the module contract (
Loader-based instead oflayout().addWidget) once, then portteleprompter.py→forwarding.py→trigger.pythrough it; redefine and port the platform config-dialog contract the same way. - Phase 4 —
MainController/MainWindow: only once Phases 1–3 have working QML replacements for everything it currently instantiates. Highest line count, most consequential, most worth doing last. - Phase 5 — pyqtgraph/real-time plotting: highest risk, deliberately last, so QML fluency is already built and effort goes entirely into the graphics problem. See options above.
- Phase 6 (optional, "graduation"): swap
BioGUI(QApplication)/MainWindowforQGuiApplication+QQmlApplicationEngine. Not required — the hybrid state from Phase 0 onward is a fine permanent architecture.
Critical files (representative, not exhaustive)
biogui/biogui.py— app bootstrap, changes last (Phase 6)biogui/utils.py— split pure dataclasses from QtWidgets helpers (Phase 0)biogui/data_sources/base.py,data_sources/__init__.py,data_sources/serial.py(representative of all transport files) — worker/config-widget split (Phase 0)biogui/controllers/main_controller.py— the big view-model rewrite (Phase 4)biogui/controllers/module_controller.py— smaller version of the same problembiogui/modules/trigger.py,forwarding.py,teleprompter.py— plugin contract redesign (Phase 3)biogui/platforms/wulpus/runtime.py,wulpus_pro/runtime.py— platform config contract redesign (Phase 3)biogui/views/(whole directory) — form/dialog/wizard ports (Phase 2),sidebar_splitter.py(Phase 1),help_dialog.py(Phase 1)biogui/views/plot_modes/mmode_plot_mode.py,time_series_plot_mode.py,amode_plot_mode.py,views/post_run_plotters/— pyqtgraph problem (Phase 5)