Uh oh!
There was an error while loading. Please reload this page.
Main widgets vs. sidebar widgets, automatic tab pages for multi-widget modules (#150) - #157
Conversation
Replaces the interface -> widget dict (first-match-wins, silently dropping every other interface a module implements) with an ordered MAIN_WIDGETS registry plus collect_main_widgets(): a module matching several main widgets now gets one nav entry whose page is a tab widget, one tab per widget, with a shared sidebar (ModulePage) that's the universal page host regardless of match count. Sidebar-preferred interfaces (filters, focuser, temperatures, cooling) demote into the sidebar when another main widget already matched, and promote back to their own page/tabs when nothing else did. Partial widget-open failures drop just that tab instead of tearing down the whole page. Custom widgets:/sidebar: config gained real interface-targeted replace and overwrite semantics. The VideoWidget split (D6's first paired_sidebar_widget consumer) is tracked separately.
Fake interface/widget registry so the promotion rule, universal ModulePage, custom widgets:/sidebar: config, disconnect teardown, FITS-header aggregation and D5 partial-open-failure handling are all exercised without needing real comm plumbing. Updates the two existing DEFAULT_WIDGETS/DEFAULT_ICONS assertions for the new registry shape.
…ract Also records that the DEFAULT_CONFIG deletion (D1) has no maintainer objection -- deleting outright, no fallback constant.
ALWAYS_SIDEBAR_WIDGETS unconditionally added FITS-header controls to every module's sidebar (roof, weather, standalone filter wheel, ...), but that panel only makes sense for modules that actually write FITS files. Move it to a per-entry sidebar=((None, FitsHeadersWidget), ...) declaration on the ICamera and IVideo MAIN_WIDGETS rows instead (and CameraWidget.sidebar_fills, restoring its pre-registry behavior), and leave ALWAYS_SIDEBAR_WIDGETS empty -- kept as a mechanism for genuinely interface-agnostic sidebar content, per D2, just unused for now.
thusser
commented
Sep 1, 2026
Reviewed the full diff at head ( 1. HIGH — duplicate sidebar widgets when a |
1. HIGH, confirmed: Camera's/Telescope's declared sidebar tuples fully overlapped their own sidebar_preferred registry entries (IFilters, ICooling, ITemperatures, IFocuser), so every one of those widgets was added to the sidebar twice. Drop the redundant declarations (registry and sidebar_fills class attributes) and add an interface-keyed dedup in open_module_page() as a second line of defense, so a future entry can't silently reintroduce the same duplication. WidgetChoice now carries the MAIN_WIDGETS interface it originated from (or the slot it's replacing, for a custom widgets: entry) so the dedup still holds when a demoted slot has been custom-replaced. 2. MEDIUM, spec deviation: implement D5's promised sidebar-widget failure isolation in BaseWidget.add_to_sidebar() -- a failing sidebar widget is now logged and dropped instead of propagating and tearing down the whole page. Also drops the redundant explicit .open() call on demoted sidebar_preferred widgets (add_to_sidebar already opens them via _open_child), which is what makes their failures go through the same isolation as every other sidebar fill. 3. LOW: interface: custom config can now target an interface that's currently demoted into the sidebar (sidebar_preferred), not just a plain main-widget slot -- collect_main_widgets() falls back to looking it up there before logging "module doesn't implement it". 4. LOW: interface: combined with overwrite: true now logs a warning that interface-replace wins and overwrite is ignored, instead of silently doing so. 5. LOW: ModuleWindow.open() now raises when open_module_page() reports every main widget failed, matching how the old single-widget code surfaced a total failure, instead of leaving a silent empty page. Nit: base.py's register_event() docstring still referenced the deleted DEFAULT_WIDGETS. Adds 5 regression tests (79 total, up from 74).
thusser
commented
Sep 1, 2026
Pushed fixes for all 5 findings plus the nits, in 1. HIGH, confirmed — dropped the redundant 2. MEDIUM — implemented the promised D5 catch-and-drop in 3. LOW — 4. LOW — 5. LOW — Nit — fixed the stale 79 tests passing (74 + 5 new), |
horizontalLayout had no explicit margins, so its QGroupBox border sat inset by Qt's default layout margin instead of flush with the sidebar column like the other two sidebar-preferred widgets.
The shared sidebar (D2) aggregates fills across every tab, so it can grow taller than any single old widget's hand-picked sidebar did. Wrap widgetSidebar in a QScrollArea: vertical-as-needed, horizontal always off, frameless to keep the same visual look when it fits.
Uh oh!
There was an error while loading. Please reload this page.
Summary
Implements #150 per
specs/2026-08-28-gui-main-vs-sidebar-widgets.md(D1-D6).Replaces the
DEFAULT_WIDGETS/DEFAULT_ICONS/DEFAULT_CONFIGfirst-match-wins dict (a moduleimplementing several interfaces only ever showed one widget, silently dropping the rest) with:
MAIN_WIDGETSregistry (MainWidgetEntry): interface, widget class, tab label/icon,declared sidebar fills,
sidebar_preferredflag, and apaired_sidebar_widgetslot (D6, noconsumer yet -- see below).
collect_main_widgets(): matches a module's interfaces against the registry, applies thesidebar_preferredpromotion rule (a cross-interface interface like filters/focuser/temperatures/cooling demotes into the sidebar when a "real" main widget also matched, and
promotes back to its own page/tabs when nothing else did -- fixes a double-display bug found
while re-deriving the design), and layers the
widgets:custom-config merge/overwrite/extra-tabsemantics on top (D3).
ModulePage: the universal page host, one match or many. Renders bare (no tab chrome) forone match, a
QTabWidgetfor several; the sidebar column is a page-level property sharedacross every tab (previously per-widget-class, and only actually visible on the three widgets
whose
.uihappened to declare awidgetSidebar-- now every module gets one, so a customsidebar:entry is finally visible regardless of module type).ALWAYS_SIDEBAR_WIDGETS:FitsHeadersWidgetlands in every module's sidebarunconditionally, including a module whose only matches were promoted into tabs.
logged, discarded, and its tab dropped -- the rest of the page stays up. All failing tears the
client down exactly like today's single-widget path.
ModuleWindow(standalone mode) now shares the samecollect_main_widgets/ModulePage/open_module_pageassembly asMainWindow, instead of its own copy of the first-match loop.CameraWidget.open()/TelescopeWidget.open()intosidebar_fillsclass attributes, consulted by the assembler via
getattr(widget_class, "sidebar_fills", ...)so wiring either class in via custom
widgets:config keeps its built-in sidebar.Out of scope (tracked separately,
specs/2026-09-01-gui-video-widget-split.md): actuallysplitting
VideoWidgetinto a main widget +paired_sidebar_widgetpair. The mechanism itself isimplemented and wired in
open_module_page(), just unused -- noMAIN_WIDGETSentry setspaired_sidebar_widgetyet.One deviation from the plan's literal sketch:
_add_client's signature is unchanged (stilltakes a single
widget: BaseWidget) rather than aList[WidgetChoice]._open_clientinsteaddispatches on
isinstance(widget, ModulePage)-- aModulePagegets the new gather-open +sidebar-fill treatment, anything else (Shell/Events/Status, and the existing
test_mainwindow_startup.pyfakes) takes the untouched original single-widget path. This keptevery existing test passing unmodified and minimized blast radius.
Test plan
ruff check .cleanpyrefly checkclean (0 errors)black --checkclean on all touched filespytest -q: 74 passed (59 pre-existing + 15 new intests/test_multiwidget_pages.py,covering the promotion rule both directions, universal
ModulePagefor a single match,ALWAYS_SIDEBAR_WIDGETS, declared-sidebar-fill gating viahas_proxy, all threewidgets:config modes (extra tab / interface-replace / overwrite), customsidebar:visibility on a bare module, disconnect teardown of every tab + sidebar widget,
get_fits_headersaggregation, D5 partial- and all-failure handling, andModuleWindowparity)
page with focuser demoted to sidebar; a standalone filter-wheel-only fixture still getting
its own page) -- not done in this environment (headless, no live fleet); the fake-registry
test suite exercises the equivalent code paths with assertions instead.
🤖 Generated with Claude Code