Widgets¶
A widget is a plain function. It takes ctx (and whatever else it needs) and calls Dear ImGui draw commands. No classes, no inheritance, no Widget base, no registration with the app.
def emg_viewer(ctx: Context, stream: str = "emg") -> None:
env_min, env_max = ctx.streams[stream].get_display(n_pixels=800)
if implot.begin_plot("EMG", imgui.ImVec2(-1, 300)):
for ch in range(env_min.shape[1]):
implot.plot_line(f"##min{ch}", env_min[:, ch])
implot.plot_line(f"##max{ch}", env_max[:, ch])
implot.end_plot()
That's literally it. Drop it inside @app.ui and it draws every frame.
The contract¶
- One file per widget. ~100–200 LOC max. If a widget grows past 200 lines, split its private state into a
_<widget>_state.pymodule - never split the public function across files. - Stateless function. The widget computes its UI from
ctx(and arguments) every frame. There's no instance, noself. - State, when needed, is keyed by widget identity. A
signal_viewer(ctx, "emg")andsignal_viewer(ctx, "imu")need separate scroll positions and channel toggles. The widget keeps adict[key, _State]indexed by something likestream_name. Two calls with the same key share state; two calls with different keys are independent. - No work in the render path. Widgets read precomputed values (
get_display,pipeline.predictions,ctx.session). Heavy computation runs on acquisition or predict threads.
ImGui immediate mode¶
Dear ImGui is immediate mode: the UI is described by code that runs every frame. There is no retained DOM. A button appears because you called imgui.button(...) this frame; it disappears next frame if you don't.
This is why widgets can be functions: there's no widget tree to maintain, no callbacks to register. The "register" step is just calling the function inside @app.ui.
@app.ui
def ui(ctx):
signal_viewer(ctx, "emg") # draws this frame
if imgui.button("Click me"):
print("clicked") # only true on the frame the click landed
The _<widget>_state.py pattern¶
When a widget needs persistent per-instance state - selected channel set, scroll offsets, popup open/closed flags - it lives in a private module:
myogestic/widgets/
├── signal.py # public: def signal_viewer(ctx, stream, ...)
├── _signal_viewer_state.py # private: state dict keyed by stream
├── _signal_viewer_controls.py # private: control panel rendering
└── _signal_viewer_plot.py # private: plot rendering
The private modules are explicitly underscore-prefixed and not exported from myogestic.widgets. User code never imports them. The split is purely organisational - keep the public entry under ~200 lines and any single helper under ~350, with each helper focused on one concern (state, controls, plot).
Inside _signal_viewer_state.py:
@dataclass
class _ViewerState:
visible_channels: set[int] = field(default_factory=set)
gain: float = 1.0
scale_mode: str = "auto"
# ...
_states: dict[str, _ViewerState] = {}
def get_state(key: str) -> _ViewerState:
return _states.setdefault(key, _ViewerState())
The widget calls get_state(stream_name) to look up its state. Two signal_viewer(ctx, "emg") calls share the dict entry; signal_viewer(ctx, "imu") gets a separate one.
Layout: Grid¶
Grid(rows, cols) is a matplotlib-style helper for the common "panels in a grid" layout. It uses ImGui's BeginChild under the hood to allocate fixed-size cells.
grid = Grid(8, 3)
@app.ui
def ui(ctx):
with grid[0:8, 1:3]: # right two columns
signal_viewer(ctx, "emg")
with grid[0, 0]:
process_launcher(processes)
with grid[1, 0]:
recording_controls(ctx, classes, ...)
with grid[2:6, 0]: # rows 2–5, column 0
session_manager("sessions")
with grid[6, 0]:
pipeline_panel(pipeline)
with grid[7, 0]:
save_model_button(pipeline, "model.pkl")
Slices accept Python conventions: 0:8 is rows 0–7 inclusive, 1:3 is cols 1–2 inclusive. There's no flex layout - sizes are even fractions of the window. If you want non-uniform sizing, drop down to imgui.set_next_window_size directly.
Pop-out windows¶
Inside App(docking=True), any panel can be torn off into its own native window:
app = App("Demo", docking=True)
app.popout("Signal viewer", lambda: signal_viewer(app.ctx, "emg"))
app.popout("Recording", lambda: recording_controls(app.ctx, classes, ...))
app.run()
Drag the tab outside the main OS window and it floats. Layout state persists in .imgui_state/<App>.ini so the next launch restores your arrangement.
popout_panel(title, gui_fn) is the inline fallback - it renders gui_fn directly inside @app.ui if docking is off, or creates a docked window if docking is on. Useful for big secondary panels (a training-log dashboard, a per-class trial preview) that you may want to tear off on multi-monitor setups.
Pop-outs are experimental on macOS
Retina viewport sizing of detached windows can be wrong on initial draw. Native dialogs (pfd.open_file) plus detached viewports may stack badly. Treat it as experimental until verified for your specific use case.
Common mistakes¶
See also: full Troubleshooting index, organised by symptom across every subsystem.
- Calling
signal_viewer(ctx)outside@app.ui. It'll throw because no ImGui context is bound. Widgets only work inside the render frame. - Putting computation in the widget. If you find yourself doing
np.fft.rfft(stream.get_window()[0])inside a widget, move that to a thread (acquisition or predict) and stash the result onctxor your own object. - Sharing state across widget instances accidentally. If you need per-key state, use the keyed dict pattern. If two widgets can be on screen at once with different keys, make sure their state dict doesn't collide.
- Reading
pipeline.predictionsmid-write.predictionsis a dict; widgets get a reference. Read scalar fields and you're fine. If you need a coherent snapshot, copy-on-read.