Skip to content

feat: make module widgets appear and respond immediately at startup - #141

Merged
thusser merged 2 commits into
developfrom
feat/widget-startup-responsiveness
Aug 23, 2026
Merged

feat: make module widgets appear and respond immediately at startup#141
thusser merged 2 commits into
developfrom
feat/widget-startup-responsiveness

Conversation

@thusser

@thusser thusser commented Aug 22, 2026

Copy link
Copy Markdown
Member

Implements specs/2026-08-21-gui-widget-startup-responsiveness.md.

Problem

When pyobs-gui starts (or a module connects), the nav item appears before the widget behind it is ready: clicks during open() are silently dropped (_change_page early-returns for unregistered clients), the page shows a blank/disabled widget until state arrives, and startup itself is serialized per module — with the telescope's heavy open() chain blocking everything else and the per-connect Shell command-model rebuild being O(N²) with synchronous interface introspection on the UI thread.

Changes

  • mainwindow.py — placeholder + background open. _add_client registers the widget, a clickable "Loading…" placeholder page, and a background open() task the moment a module connects; _change_page/shortcuts always find the client, so the click is never dropped. When open() finishes, the real widget replaces the placeholder at the same stack index (and becomes current + _init() fires if the user was sitting on that page). A failing open() tears the client down (_fail_open) instead of leaving a permanent dead page; a mid-open disconnect cancels and awaits the open before discarding, and removes the placeholder from the stack (no ghost page); discard_all_widgets() drains pending opens before teardown.
  • mainwindow.py — parallel startup. _init_clients uses asyncio.gather; the per-connect _update_client_list/_check_warnings global work is dropped (menu rebuild kept as cheap sync _update_clients_menu, warnings covered by the periodic task + one post-gather pass).
  • base.py — memoized _init. _showEvent runs _init() through a memoized task so two rapid show/hide/show cycles can't double-subscribe; a failing _init() leaves the widget un-initialized so the next show retries.
  • shellwidget.py — debounced, visibility-gated command model. The Shell rebuilds its own CommandModel on module events, debounced (0.5 s) and only while its page is visible; a first-show hook rebuilds lazily so a never-opened page isn't left with an empty completer. (Required for the O(N²) startup-cost fix, since the mainwindow no longer triggers the rebuild per connect.)
  • telescopewidget.py_init() fires the five subscribe_state calls concurrently.
  • camerawidget.py_init() runs each interface's caps → state → subscribe chain concurrently (per-interface ordering preserved) and wait_for_state uses a 2 s timeout instead of the 10 s default, so a slow-publishing camera can't hold the page blank for ~70 s.

Tests

New tests/test_mainwindow_startup.py (9 tests, offscreen Qt): placeholder-then-swap, open-failure teardown, mid-open disconnect without ghost page, discard_all_widgets drain, parallel _init_clients, telescope parallel subscriptions, _showEvent single-init + retry-on-failure, and the shell visibility gate / lazy rebuild. Full suite: 41 passed.

Black, ruff, and pyrefly are clean.

Out of scope (see spec)

Pre-warming _init() at open time, moving CommandModel.init's introspection off the UI thread, StatusWidget's own per-module RPC chains, and the pre-existing ShellWidget event-handler registration leak.

Widgets now register a clickable 'Loading…' placeholder page the moment a
module connects, and their heavy open() chain runs in the background;
the real widget is swapped in when it finishes (and if the page was
current, shown immediately). Clicks and shortcuts are never dropped
during open(), a failed open() tears the client down instead of leaving
a permanent dead page, and mid-open disconnects cancel-and-await the
open before discarding.

Startup is parallelized (asyncio.gather in _init_clients) and the
per-connect O(N^2) Shell command-model rebuild / all-clients warning
scans are dropped; the Shell rebuilds its own model debounced and only
while its page is visible. BaseWidget._showEvent memoizes _init() so
rapid show/hide cycles can't double-subscribe, retrying after failure.
Telescope/camera _init() fire their subscriptions/fetches concurrently,
and camera wait_for_state uses a 2 s timeout instead of the 10 s
default.
_init() sub-steps that call comm.subscribe_state() could run twice on
retry after a partial gather() failure, since subscribe_state() is not
idempotent. Add BaseWidget._init_once() to memoize each sub-step across
retries, and use it in CameraWidget/TelescopeWidget.

Also cancel a still-in-flight _init_task in discard(), so a widget torn
down mid-init can't leak a subscription registered after comm's own
disconnect cleanup already ran. Factor the cancel+drain idiom used here
and in mainwindow.py into a shared cancel_and_drain() helper.
@thusser
thusser merged commit 123161b into develop Aug 23, 2026
2 checks passed
@thusser
thusser deleted the feat/widget-startup-responsiveness branch August 23, 2026 11:26
thusser added a commit that referenced this pull request Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant