feat: make module widgets appear and respond immediately at startup - #141
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_pageearly-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 heavyopen()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_clientregisters the widget, a clickable "Loading…" placeholder page, and a backgroundopen()task the moment a module connects;_change_page/shortcuts always find the client, so the click is never dropped. Whenopen()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 failingopen()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_clientsusesasyncio.gather; the per-connect_update_client_list/_check_warningsglobal 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._showEventruns_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 ownCommandModelon 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 fivesubscribe_statecalls concurrently.camerawidget.py—_init()runs each interface's caps → state → subscribe chain concurrently (per-interface ordering preserved) andwait_for_stateuses 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_widgetsdrain, parallel_init_clients, telescope parallel subscriptions,_showEventsingle-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, movingCommandModel.init's introspection off the UI thread,StatusWidget's own per-module RPC chains, and the pre-existingShellWidgetevent-handler registration leak.