From e046d967cbd486cf49a9dd4d02ca62822f447164 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Sun, 28 Jun 2026 15:13:36 -0500 Subject: [PATCH 1/9] feat(ui): register global lists-panel QSettings defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add lists_panel_collapsed (False) and open_tables (["object"]) to the global default_settings dict so they resolve via Series.qsettings_defaults (QSettings("KHLab","PyReconstruct")), not the per-series options dict — the series load-prune (series.py:440-444) therefore never touches them. Backs UI v1 Slice 3 (lists-left tabbed/collapsible panel). Tests (offscreen): defaults registered and global-not-per-series/internal, getOption returns defaults when unset, setOption/getOption round-trip, and cross-instance resolution via global QSettings. Adds tests/conftest.py session-autouse fixture isolating HOME/XDG_CONFIG_HOME/APPDATA + QSettings paths to a temp dir so the suite never touches real preferences. --- .../modules/datatypes/default_settings.py | 4 + tests/conftest.py | 45 +++++++++++ tests/test_lists_panel.py | 76 +++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_lists_panel.py diff --git a/PyReconstruct/modules/datatypes/default_settings.py b/PyReconstruct/modules/datatypes/default_settings.py index dfa1a7c0..56939b7c 100644 --- a/PyReconstruct/modules/datatypes/default_settings.py +++ b/PyReconstruct/modules/datatypes/default_settings.py @@ -157,6 +157,10 @@ def get_username() -> str: "scale_bar_width": 25, # displayed as this percentage of the screen (min should be 20) "show_scale_bar_text": True, "show_scale_bar_ticks": True, + + # lists panel (UI v1 Slice 3) — global, not per-series + "lists_panel_collapsed": False, # left list-dock group collapsed? + "open_tables": ["object"], # which list docks auto-open (Object promoted/default-on) } default_series_settings = { diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..8e07c7d4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +"""Shared test fixtures. + +The autouse ``_isolate_qsettings`` fixture redirects every per-user config location +(env + Qt's QSettings paths) to a session temp dir BEFORE any QSettings or QApplication +is constructed, so a test run can never read or clobber the user's real preferences. +It asserts the isolation actually took effect and aborts the run otherwise — important +for unattended runs that exercise setOption/getOption round-trips. +""" + +import os + +import pytest + +# Project-wide QSettings identity (mirrors PyReconstruct.modules.gui.utils.theme). +QSETTINGS_ORG = "KHLab" +QSETTINGS_APP = "PyReconstruct" + + +@pytest.fixture(scope="session", autouse=True) +def _isolate_qsettings(tmp_path_factory): + cfg = tmp_path_factory.mktemp("qsettings_home") + + # Belt: redirect the env vars Qt / the OS use to locate per-user config + data. + for var in ("HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "APPDATA", "LOCALAPPDATA"): + os.environ[var] = str(cfg) + + # Suspenders: pin QSettings to the temp dir explicitly (works even if env is ignored). + try: + from PySide6.QtCore import QSettings + except Exception: + # No Qt available — env redirect still stands; pure-logic tests run fine. + yield cfg + return + + QSettings.setDefaultFormat(QSettings.IniFormat) + for scope in (QSettings.UserScope, QSettings.SystemScope): + QSettings.setPath(QSettings.IniFormat, scope, str(cfg)) + + resolved = QSettings(QSETTINGS_ORG, QSETTINGS_APP).fileName() + assert str(cfg) in resolved, ( + f"QSettings is NOT isolated to the temp dir (resolved to {resolved!r}); " + "aborting so the run cannot touch real user preferences." + ) + + yield cfg diff --git a/tests/test_lists_panel.py b/tests/test_lists_panel.py new file mode 100644 index 00000000..1ed203c9 --- /dev/null +++ b/tests/test_lists_panel.py @@ -0,0 +1,76 @@ +"""UI v1 Slice 3 — lists-left tabbed/collapsible panel (headless backbone). + +These tests cover BEHAVIOR only (offscreen); the look is signed off interactively. +QSettings is isolated to a temp dir by the session-autouse fixture in conftest.py, so +the getOption/setOption round-trips never touch real user preferences. +""" + +import pytest + +pytest.importorskip("PySide6") + +from PySide6.QtCore import QSettings # noqa: E402 + +from PyReconstruct.modules.datatypes.series import Series # noqa: E402 + + +def _global_series(): + """A minimal Series instance for exercising GLOBAL (computer-wide) options. + + The global getOption/setOption path only reads ``self.options`` and the class-level + ``qsettings_defaults``; it never needs the heavy jser construction, so __new__ + an + empty options dict is sufficient and isolated. + """ + s = Series.__new__(Series) + s.options = {} + return s + + +def _clear(*keys): + settings = QSettings("KHLab", "PyReconstruct") + for k in keys: + settings.remove(k) + settings.sync() + + +# --- B1: register the two global QSettings defaults ------------------------------- + +def test_b1_keys_registered_global_with_defaults(): + assert Series.qsettings_defaults.get("lists_panel_collapsed") is False + assert Series.qsettings_defaults.get("open_tables") == ["object"] + + +def test_b1_keys_are_global_not_per_series_or_internal(): + # not per-series + assert "lists_panel_collapsed" not in Series.qsettings_series_defaults + assert "open_tables" not in Series.qsettings_series_defaults + # not an internal per-series option (so the series.py:440-444 prune never touches them) + empty_opts = Series.getEmptyDict()["options"] + assert "lists_panel_collapsed" not in empty_opts + assert "open_tables" not in empty_opts + + +def test_b1_getOption_returns_defaults_when_unset(): + _clear("lists_panel_collapsed", "open_tables") + s = _global_series() + assert s.getOption("lists_panel_collapsed") is False + assert s.getOption("open_tables") == ["object"] + + +def test_b1_setOption_getOption_roundtrip(): + s = _global_series() + s.setOption("lists_panel_collapsed", True) + assert s.getOption("lists_panel_collapsed") is True + s.setOption("open_tables", ["object", "trace"]) + assert s.getOption("open_tables") == ["object", "trace"] + + +def test_b1_resolves_via_global_qsettings_not_internal_options(): + # The keys must route through the global QSettings("KHLab","PyReconstruct"), + # not self.options. A fresh instance with empty options still resolves them. + s = _global_series() + assert "open_tables" not in s.options + s.setOption("open_tables", ["object", "ztrace"]) + # value lives in global QSettings, readable by an independent instance + other = _global_series() + assert other.getOption("open_tables") == ["object", "ztrace"] From 66ad0c53382fcfca170fe98cba107c950271541d Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Sun, 28 Jun 2026 15:20:26 -0500 Subject: [PATCH 2/9] fix(table): tab list docks into one left group; fix closeAll skip-bug Tab the on-demand list docks (object/trace/section/ztrace/flag) into a single left dock group: enable dock nesting in TableManager and tabify each new dock onto the first already-open list dock. Backs UI v1 Slice 3 (lists-left panel). Also fix a pre-existing mutate-while-iterate bug in closeAll: each close() fires closeEvent, which removes the table from the same self.tables[name] list being iterated, so a plain loop skipped about half the tables. Iterate a copy. Tests (offscreen): closeAll closes every table for a multi-table list; newTable places each dock in the left area and groups them via tabifiedDockWidgets; a single dock does not crash; dock nesting is enabled on init. Adds a shared qapp fixture to conftest. --- .../modules/backend/table/manager.py | 18 +++- tests/conftest.py | 10 +++ tests/test_lists_panel.py | 84 +++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/PyReconstruct/modules/backend/table/manager.py b/PyReconstruct/modules/backend/table/manager.py index 0e4d1e00..4c947ef1 100644 --- a/PyReconstruct/modules/backend/table/manager.py +++ b/PyReconstruct/modules/backend/table/manager.py @@ -37,6 +37,9 @@ def __init__(self, series : Series, section : Section, series_states, mainwindow self.section = section self.mainwindow = mainwindow self.series_states = series_states + + # allow the list docks to nest/tab into a single group (UI v1 Slice 3) + self.mainwindow.setDockNestingEnabled(True) def newTable(self, table_type : str, section=None): """Create a new object list widget.""" @@ -55,9 +58,20 @@ def newTable(self, table_type : str, section=None): ) new_table = table_type_classes[table_type](*args) + + # find an already-open list dock to tab the new one onto, so all list + # types share a single left dock group (UI v1 Slice 3) + anchor = None + for tt in self.tables: + if self.tables[tt]: + anchor = self.tables[tt][0] + break + self.tables[table_type].append(new_table) self.mainwindow.addDockWidget(Qt.LeftDockWidgetArea, new_table) + if anchor is not None: + self.mainwindow.tabifyDockWidget(anchor, new_table) def updateObjects(self, obj_names : list = None, clear_tracking=True): """Update the object info for the OBJECT AND TRACE LISTS ONLY. @@ -185,6 +199,8 @@ def refresh(self): def closeAll(self): """Close all tables.""" + # iterate a copy: each close() fires closeEvent, which removes the table + # from self.tables[name] mid-iteration (data_table.py) and would skip tables for n, l in self.tables.items(): - for t in l: + for t in l.copy(): t.close() diff --git a/tests/conftest.py b/tests/conftest.py index 8e07c7d4..09139a33 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,3 +43,13 @@ def _isolate_qsettings(tmp_path_factory): ) yield cfg + + +@pytest.fixture(scope="session") +def qapp(): + """A single QApplication for widget tests (offscreen). Reuses the singleton so it + coexists with any module-local qapp fixtures.""" + pytest.importorskip("PySide6") + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) diff --git a/tests/test_lists_panel.py b/tests/test_lists_panel.py index 1ed203c9..2368394b 100644 --- a/tests/test_lists_panel.py +++ b/tests/test_lists_panel.py @@ -74,3 +74,87 @@ def test_b1_resolves_via_global_qsettings_not_internal_options(): # value lives in global QSettings, readable by an independent instance other = _global_series() assert other.getOption("open_tables") == ["object", "ztrace"] + + +# --- B2: tabify the list docks into one left group + fix closeAll skip-bug -------- + +import PyReconstruct.modules.backend.table.manager as mgrmod # noqa: E402 + + +class _StubDock: + """Stand-in for a DataTable: a no-widget object that mirrors the one behavior + closeAll depends on — closeEvent removing itself from manager.tables[name].""" + def __init__(self, manager, name): + self.manager = manager + self.name = name + self.closed = False + + def close(self): + self.closed = True + self.manager.tables[self.name].remove(self) # mirrors data_table.py:351 + + +def test_b2_closeall_closes_every_table(): + # Reproduces the mutate-while-iterate skip-bug: closing a table removes it from + # the same list closeAll iterates, so a plain `for t in l` skips half. + mgr = mgrmod.TableManager.__new__(mgrmod.TableManager) + mgr.tables = {tt: [] for tt in mgrmod.table_type_classes} + tables = [_StubDock(mgr, "object") for _ in range(4)] + mgr.tables["object"].extend(tables) + + mgr.closeAll() + + assert mgr.tables["object"] == [], "closeAll left tables open (skip-bug)" + assert all(t.closed for t in tables), "not every table received close()" + + +def _qmainwindow(): + from PySide6.QtWidgets import QMainWindow + return QMainWindow() + + +def _stub_table_classes(): + from PySide6.QtWidgets import QDockWidget, QWidget + + class StubTableDock(QDockWidget): + def __init__(self, *args): + super().__init__() + self.setWidget(QWidget()) + + return {k: StubTableDock for k in mgrmod.table_type_classes} + + +def test_b2_init_enables_dock_nesting(qapp): + win = _qmainwindow() + mgrmod.TableManager(None, None, None, win) + assert win.isDockNestingEnabled() is True + + +def test_b2_newtable_tabifies_into_one_left_group(qapp, monkeypatch): + from PySide6.QtCore import Qt + + monkeypatch.setattr(mgrmod, "table_type_classes", _stub_table_classes()) + win = _qmainwindow() + mgr = mgrmod.TableManager(None, None, None, win) + + for tt in ("object", "trace", "ztrace"): + mgr.newTable(tt) + + obj_dock = mgr.tables["object"][0] + for tt in ("object", "trace", "ztrace"): + d = mgr.tables[tt][0] + assert win.dockWidgetArea(d) == Qt.LeftDockWidgetArea + + tabbed = set(win.tabifiedDockWidgets(obj_dock)) + assert mgr.tables["trace"][0] in tabbed + assert mgr.tables["ztrace"][0] in tabbed + + +def test_b2_newtable_single_dock_no_crash(qapp, monkeypatch): + from PySide6.QtCore import Qt + + monkeypatch.setattr(mgrmod, "table_type_classes", _stub_table_classes()) + win = _qmainwindow() + mgr = mgrmod.TableManager(None, None, None, win) + mgr.newTable("object") # no tabify partner — must not raise + assert win.dockWidgetArea(mgr.tables["object"][0]) == Qt.LeftDockWidgetArea From f0be1faec080bfcccc51f7d65784735ad94696eb Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Sun, 28 Jun 2026 15:25:38 -0500 Subject: [PATCH 3/9] test(table): prove the list tab-group survives closing the anchor dock A B2-council lens raised that anchoring newTable to the first open dock could fragment the group if the object (anchor) dock closes. It cannot: once docks are tabified they share one group, so any remaining dock is an equivalent anchor. Add a regression test that closes the anchor and confirms a later dock still joins the single group. --- tests/test_lists_panel.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_lists_panel.py b/tests/test_lists_panel.py index 2368394b..911b4ddf 100644 --- a/tests/test_lists_panel.py +++ b/tests/test_lists_panel.py @@ -158,3 +158,23 @@ def test_b2_newtable_single_dock_no_crash(qapp, monkeypatch): mgr = mgrmod.TableManager(None, None, None, win) mgr.newTable("object") # no tabify partner — must not raise assert win.dockWidgetArea(mgr.tables["object"][0]) == Qt.LeftDockWidgetArea + + +def test_b2_tab_group_survives_anchor_close(qapp, monkeypatch): + # Closing the anchor (object) dock must not fragment the group: a later dock + # still joins the single existing tab group via whatever dock remains. + monkeypatch.setattr(mgrmod, "table_type_classes", _stub_table_classes()) + win = _qmainwindow() + mgr = mgrmod.TableManager(None, None, None, win) + for tt in ("object", "trace", "ztrace"): + mgr.newTable(tt) + + obj = mgr.tables["object"][0] # the anchor + mgr.tables["object"].remove(obj) + obj.close(); obj.setParent(None) + + mgr.newTable("flag") # anchors onto the remaining group + + group = set(win.tabifiedDockWidgets(mgr.tables["trace"][0])) + assert mgr.tables["ztrace"][0] in group + assert mgr.tables["flag"][0] in group # one group, not fragmented From 8a05c0f73ed8fc392a5f8e54b6b023eeef13f1ab Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Sun, 28 Jun 2026 15:28:39 -0500 Subject: [PATCH 4/9] feat(ui): auto-open promoted lists and persist open_tables On series open, restore the saved list docks (default Object) via a new FieldWidgetBase._restoreOpenTables, skipping the welcome series. Keep the global open_tables option in sync: TableManager.newTable adds a type, and DataTable.closeEvent -> TableManager.onTableClosed drops it when the last dock of that type closes. closeAll suppresses the sync so a series switch keeps the user's saved preference. Each update writes back a new list (the qsettings defaults are a shallow copy, so the returned list is never mutated in place). Tests (offscreen): sync add/idempotent, remove-when-last-closes, keep-if-another-open, suppress during closeAll, no corruption of the global default, restore opens saved tables, welcome series opens nothing. --- .../modules/backend/table/manager.py | 41 +++++++- .../modules/gui/main/field_widget_1_base.py | 14 ++- PyReconstruct/modules/gui/table/data_table.py | 1 + tests/test_lists_panel.py | 99 +++++++++++++++++++ 4 files changed, 150 insertions(+), 5 deletions(-) diff --git a/PyReconstruct/modules/backend/table/manager.py b/PyReconstruct/modules/backend/table/manager.py index 4c947ef1..e3903962 100644 --- a/PyReconstruct/modules/backend/table/manager.py +++ b/PyReconstruct/modules/backend/table/manager.py @@ -40,6 +40,9 @@ def __init__(self, series : Series, section : Section, series_states, mainwindow # allow the list docks to nest/tab into a single group (UI v1 Slice 3) self.mainwindow.setDockNestingEnabled(True) + + # guards open_tables persistence during programmatic teardown (closeAll) + self._suppress_open_tables_sync = False def newTable(self, table_type : str, section=None): """Create a new object list widget.""" @@ -72,6 +75,30 @@ def newTable(self, table_type : str, section=None): self.mainwindow.addDockWidget(Qt.LeftDockWidgetArea, new_table) if anchor is not None: self.mainwindow.tabifyDockWidget(anchor, new_table) + + self._syncOpenTables(table_type, True) + + def _syncOpenTables(self, table_type : str, is_open : bool): + """Add/remove a list type from the global open_tables option so the panel + restores the same lists next open. Writes back a NEW list (the qsettings + defaults are a shallow copy, so never mutate the returned list in place).""" + if self._suppress_open_tables_sync or self.series is None: + return + current = list(self.series.getOption("open_tables")) + if is_open and table_type not in current: + current.append(table_type) + elif not is_open and table_type in current: + current.remove(table_type) + else: + return + self.series.setOption("open_tables", current) + + def onTableClosed(self, table_type : str): + """Called from DataTable.closeEvent after a dock removes itself; drop the + type from open_tables only when no dock of that type remains.""" + if self.tables.get(table_type): + return + self._syncOpenTables(table_type, False) def updateObjects(self, obj_names : list = None, clear_tracking=True): """Update the object info for the OBJECT AND TRACE LISTS ONLY. @@ -200,7 +227,13 @@ def refresh(self): def closeAll(self): """Close all tables.""" # iterate a copy: each close() fires closeEvent, which removes the table - # from self.tables[name] mid-iteration (data_table.py) and would skip tables - for n, l in self.tables.items(): - for t in l.copy(): - t.close() + # from self.tables[name] mid-iteration (data_table.py) and would skip tables. + # suppress open_tables sync: this is programmatic teardown (e.g. series + # switch), not the user closing lists, so the saved preference must persist. + self._suppress_open_tables_sync = True + try: + for n, l in self.tables.items(): + for t in l.copy(): + t.close() + finally: + self._suppress_open_tables_sync = False diff --git a/PyReconstruct/modules/gui/main/field_widget_1_base.py b/PyReconstruct/modules/gui/main/field_widget_1_base.py index 542cccf2..44abe4d5 100644 --- a/PyReconstruct/modules/gui/main/field_widget_1_base.py +++ b/PyReconstruct/modules/gui/main/field_widget_1_base.py @@ -156,6 +156,9 @@ def createField(self, series : Series): self.mainwindow, ) + ## Auto-open the promoted lists (UI v1 Slice 3) + self._restoreOpenTables() + ## Reset cursor self.mouse_mode = 0 self.setCursor(QCursor(Qt.ArrowCursor)) @@ -471,11 +474,20 @@ def reloadImage(self) -> None: def openList(self, list_type : str): """Open a list. - + Params: list_type (str): object, trace, section, ztrace, or flag """ self.table_manager.newTable(list_type, self.section) + + def _restoreOpenTables(self): + """Auto-open the saved list docks on series open (UI v1 Slice 3). The + welcome series opens nothing. Iterates a snapshot since openList -> newTable + updates the open_tables option as each dock opens.""" + if self.series.isWelcomeSeries(): + return + for list_type in list(self.series.getOption("open_tables")): + self.openList(list_type) def updateData(self, clear_tracking=True) -> None: """Update the series data object and the tables. diff --git a/PyReconstruct/modules/gui/table/data_table.py b/PyReconstruct/modules/gui/table/data_table.py index 6bb741a0..0a608707 100644 --- a/PyReconstruct/modules/gui/table/data_table.py +++ b/PyReconstruct/modules/gui/table/data_table.py @@ -349,5 +349,6 @@ def copy(self): def closeEvent(self, event): """Remove self from manager table list.""" self.manager.tables[self.name].remove(self) + self.manager.onTableClosed(self.name) super().closeEvent(event) diff --git a/tests/test_lists_panel.py b/tests/test_lists_panel.py index 911b4ddf..ff6b6322 100644 --- a/tests/test_lists_panel.py +++ b/tests/test_lists_panel.py @@ -178,3 +178,102 @@ def test_b2_tab_group_survives_anchor_close(qapp, monkeypatch): group = set(win.tabifiedDockWidgets(mgr.tables["trace"][0])) assert mgr.tables["ztrace"][0] in group assert mgr.tables["flag"][0] in group # one group, not fragmented + + +# --- B3: auto-open promoted lists + keep open_tables synced ----------------------- + +class _FakeSeries: + def __init__(self, open_tables=("object",), welcome=False): + self._opts = {"open_tables": list(open_tables)} + self._welcome = welcome + + def getOption(self, k): + return self._opts[k] + + def setOption(self, k, v): + self._opts[k] = v + + def isWelcomeSeries(self): + return self._welcome + + +def _bare_manager(series): + mgr = mgrmod.TableManager.__new__(mgrmod.TableManager) + mgr.series = series + mgr.tables = {tt: [] for tt in mgrmod.table_type_classes} + mgr._suppress_open_tables_sync = False + return mgr + + +def test_b3_sync_adds_type_to_open_tables(): + s = _FakeSeries(["object"]) + mgr = _bare_manager(s) + mgr._syncOpenTables("trace", True) + assert s.getOption("open_tables") == ["object", "trace"] + mgr._syncOpenTables("trace", True) # idempotent + assert s.getOption("open_tables") == ["object", "trace"] + + +def test_b3_onclosed_removes_type_when_last_closes(): + s = _FakeSeries(["object", "trace"]) + mgr = _bare_manager(s) + mgr.tables["trace"] = [] # last trace dock just removed itself + mgr.onTableClosed("trace") + assert s.getOption("open_tables") == ["object"] + + +def test_b3_onclosed_keeps_type_if_another_still_open(): + s = _FakeSeries(["object", "trace"]) + mgr = _bare_manager(s) + mgr.tables["trace"] = ["still-open"] # another trace dock remains + mgr.onTableClosed("trace") + assert s.getOption("open_tables") == ["object", "trace"] + + +def test_b3_closeall_suppresses_sync(): + s = _FakeSeries(["object", "trace"]) + mgr = _bare_manager(s) + mgr._suppress_open_tables_sync = True + mgr.tables["trace"] = [] + mgr.onTableClosed("trace") + assert s.getOption("open_tables") == ["object", "trace"] # preserved across teardown + + +def test_b3_sync_does_not_corrupt_global_default(): + from PyReconstruct.modules.datatypes.default_settings import default_settings + + _clear("open_tables") + s = Series.__new__(Series) + s.options = {} + mgr = _bare_manager(s) + snapshot = list(default_settings["open_tables"]) + mgr._syncOpenTables("trace", True) # would corrupt the default if it mutated the alias + assert default_settings["open_tables"] == snapshot + assert s.getOption("open_tables") == ["object", "trace"] + + +def _bare_field(series): + import PyReconstruct.modules.gui.main.field_widget_1_base as fwmod + fw = fwmod.FieldWidgetBase.__new__(fwmod.FieldWidgetBase) + fw.series = series + fw.section = object() + opened = [] + + class FakeMgr: + def newTable(self, lt, section=None): + opened.append(lt) + + fw.table_manager = FakeMgr() + return fw, opened + + +def test_b3_restore_opens_saved_tables(): + fw, opened = _bare_field(_FakeSeries(["object", "trace"])) + fw._restoreOpenTables() + assert opened == ["object", "trace"] + + +def test_b3_restore_skips_welcome_series(): + fw, opened = _bare_field(_FakeSeries(["object"], welcome=True)) + fw._restoreOpenTables() + assert opened == [] From c43c1a52f4b5b8982ccea362f6b1d2372192a90a Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Sun, 28 Jun 2026 15:32:48 -0500 Subject: [PATCH 5/9] fix(ui): skip unknown types when restoring open list docks A B3-council lens noted that a stale or corrupted open_tables entry (e.g. a list type removed in a future version) would raise KeyError in newTable and crash field loading. Skip unknown types in _restoreOpenTables instead. Test (offscreen): restore with an invalid type opens only the valid lists. --- PyReconstruct/modules/gui/main/field_widget_1_base.py | 7 +++++-- tests/test_lists_panel.py | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/PyReconstruct/modules/gui/main/field_widget_1_base.py b/PyReconstruct/modules/gui/main/field_widget_1_base.py index 44abe4d5..cfed8f04 100644 --- a/PyReconstruct/modules/gui/main/field_widget_1_base.py +++ b/PyReconstruct/modules/gui/main/field_widget_1_base.py @@ -483,11 +483,14 @@ def openList(self, list_type : str): def _restoreOpenTables(self): """Auto-open the saved list docks on series open (UI v1 Slice 3). The welcome series opens nothing. Iterates a snapshot since openList -> newTable - updates the open_tables option as each dock opens.""" + updates the open_tables option as each dock opens. Unknown types (corrupted + QSettings, or a type removed in a future version) are skipped, not fatal.""" if self.series.isWelcomeSeries(): return + from PyReconstruct.modules.backend.table.manager import table_type_classes for list_type in list(self.series.getOption("open_tables")): - self.openList(list_type) + if list_type in table_type_classes: + self.openList(list_type) def updateData(self, clear_tracking=True) -> None: """Update the series data object and the tables. diff --git a/tests/test_lists_panel.py b/tests/test_lists_panel.py index ff6b6322..d87d6de7 100644 --- a/tests/test_lists_panel.py +++ b/tests/test_lists_panel.py @@ -277,3 +277,11 @@ def test_b3_restore_skips_welcome_series(): fw, opened = _bare_field(_FakeSeries(["object"], welcome=True)) fw._restoreOpenTables() assert opened == [] + + +def test_b3_restore_skips_invalid_types(): + # a stale/corrupted open_tables entry (e.g. a type removed in a future version) + # must not crash field loading — skip it. + fw, opened = _bare_field(_FakeSeries(["object", "bogus", "trace"])) + fw._restoreOpenTables() + assert opened == ["object", "trace"] From a7fd440d477101c57fbe828620f6ea277b3ab1f5 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Sun, 28 Jun 2026 15:35:09 -0500 Subject: [PATCH 6/9] feat(ui): add a View-menu toggle to collapse the lists panel Add a "Collapse lists panel" checkbox action (no accelerator, so no shortcut collision) to the View menu, wired to MainWindow.toggleListsPanel. TableManager.setListsPanelCollapsed hides or shows every list dock and persists lists_panel_collapsed; collapse HIDES (not closes) the docks, so open_tables is untouched. Tests (offscreen): listsPanelCollapsed reads the option; setListsPanelCollapsed hides/shows all docks and persists; collapse leaves open_tables intact; MainWindow.toggleListsPanel flips both the state and the menu checkbox. --- .../modules/backend/table/manager.py | 12 +++ PyReconstruct/modules/gui/main/main_window.py | 9 ++- PyReconstruct/modules/gui/main/menubar.py | 1 + tests/test_lists_panel.py | 80 ++++++++++++++++++- 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/PyReconstruct/modules/backend/table/manager.py b/PyReconstruct/modules/backend/table/manager.py index e3903962..24a9d7aa 100644 --- a/PyReconstruct/modules/backend/table/manager.py +++ b/PyReconstruct/modules/backend/table/manager.py @@ -99,6 +99,18 @@ def onTableClosed(self, table_type : str): if self.tables.get(table_type): return self._syncOpenTables(table_type, False) + + def listsPanelCollapsed(self) -> bool: + """Whether the left lists panel is collapsed (global preference).""" + return self.series.getOption("lists_panel_collapsed") + + def setListsPanelCollapsed(self, collapsed : bool): + """Hide/show every list dock and persist the choice. Collapse HIDES the + docks (it does not close them), so open_tables is untouched.""" + for tt in self.tables: + for t in self.tables[tt]: + t.setHidden(collapsed) + self.series.setOption("lists_panel_collapsed", collapsed) def updateObjects(self, obj_names : list = None, clear_tracking=True): """Update the object info for the OBJECT AND TRACE LISTS ONLY. diff --git a/PyReconstruct/modules/gui/main/main_window.py b/PyReconstruct/modules/gui/main/main_window.py index 06ef2aa4..36b3dfaf 100644 --- a/PyReconstruct/modules/gui/main/main_window.py +++ b/PyReconstruct/modules/gui/main/main_window.py @@ -1621,7 +1621,14 @@ def toggleZtraces(self): self.field.deselectAllTraces() self.series.setOption("show_ztraces", not self.series.getOption("show_ztraces")) self.field.generateView(generate_image=False) - + + def toggleListsPanel(self): + """Collapse/expand the left lists dock panel (UI v1 Slice 3).""" + manager = self.field.table_manager + collapsed = not manager.listsPanelCollapsed() + manager.setListsPanelCollapsed(collapsed) + self.togglelistspanel_act.setChecked(collapsed) + def setToObject(self, obj_name : str, section_num : int): """Focus the field on an object from a specified section. diff --git a/PyReconstruct/modules/gui/main/menubar.py b/PyReconstruct/modules/gui/main/menubar.py index 7aaf5dab..868bd07f 100644 --- a/PyReconstruct/modules/gui/main/menubar.py +++ b/PyReconstruct/modules/gui/main/menubar.py @@ -348,6 +348,7 @@ def return_view_menu(self): ("findview_act", "Set zoom when finding contours...", "", self.setFindZoom), None, ("toggleztraces_act", "Toggle show Z-traces", "", self.toggleZtraces), + ("togglelistspanel_act", "Collapse lists panel", "checkbox", self.toggleListsPanel), None, { "attr_name": "palettemenu", diff --git a/tests/test_lists_panel.py b/tests/test_lists_panel.py index d87d6de7..44efdf16 100644 --- a/tests/test_lists_panel.py +++ b/tests/test_lists_panel.py @@ -183,8 +183,8 @@ def test_b2_tab_group_survives_anchor_close(qapp, monkeypatch): # --- B3: auto-open promoted lists + keep open_tables synced ----------------------- class _FakeSeries: - def __init__(self, open_tables=("object",), welcome=False): - self._opts = {"open_tables": list(open_tables)} + def __init__(self, open_tables=("object",), welcome=False, collapsed=False): + self._opts = {"open_tables": list(open_tables), "lists_panel_collapsed": collapsed} self._welcome = welcome def getOption(self, k): @@ -285,3 +285,79 @@ def test_b3_restore_skips_invalid_types(): fw, opened = _bare_field(_FakeSeries(["object", "bogus", "trace"])) fw._restoreOpenTables() assert opened == ["object", "trace"] + + +# --- B4: collapse/expand toggle for the left lists panel -------------------------- + +def _win_with_docks(qty_by_type): + from PySide6.QtWidgets import QMainWindow, QDockWidget + from PySide6.QtCore import Qt + win = QMainWindow() + tables = {tt: [] for tt in mgrmod.table_type_classes} + for tt, n in qty_by_type.items(): + for _ in range(n): + d = QDockWidget(win) + win.addDockWidget(Qt.LeftDockWidgetArea, d) + tables[tt].append(d) + return win, tables + + +def test_b4_listsPanelCollapsed_reads_option(): + mgr = _bare_manager(_FakeSeries(collapsed=True)) + assert mgr.listsPanelCollapsed() is True + mgr.series.setOption("lists_panel_collapsed", False) + assert mgr.listsPanelCollapsed() is False + + +def test_b4_set_collapsed_hides_and_shows_all_left_docks(qapp): + win, tables = _win_with_docks({"object": 1, "trace": 1}) + mgr = _bare_manager(_FakeSeries(["object", "trace"])) + mgr.tables = tables + + mgr.setListsPanelCollapsed(True) + assert all(d.isHidden() for ds in tables.values() for d in ds) + assert mgr.series.getOption("lists_panel_collapsed") is True + + mgr.setListsPanelCollapsed(False) + assert not any(d.isHidden() for ds in tables.values() for d in ds) + assert mgr.series.getOption("lists_panel_collapsed") is False + + +def test_b4_collapse_does_not_close_docks_or_change_open_tables(qapp): + # collapse hides (not closes) so open_tables is untouched + win, tables = _win_with_docks({"object": 1, "trace": 1}) + s = _FakeSeries(["object", "trace"]) + mgr = _bare_manager(s) + mgr.tables = tables + mgr.setListsPanelCollapsed(True) + assert s.getOption("open_tables") == ["object", "trace"] + assert tables["object"] and tables["trace"] # still tracked/open + + +def test_b4_mainwindow_toggle_flips_state_and_checkbox(qapp): + from PyReconstruct.modules.gui.main.main_window import MainWindow + from PySide6.QtGui import QAction + + win, tables = _win_with_docks({"object": 1}) + s = _FakeSeries(["object"], collapsed=False) + mgr = _bare_manager(s) + mgr.tables = tables + + mw = MainWindow.__new__(MainWindow) + + class _Field: + pass + + mw.field = _Field() + mw.field.table_manager = mgr + mw.togglelistspanel_act = QAction() + mw.togglelistspanel_act.setCheckable(True) + + mw.toggleListsPanel() + assert tables["object"][0].isHidden() is True + assert s.getOption("lists_panel_collapsed") is True + assert mw.togglelistspanel_act.isChecked() is True + + mw.toggleListsPanel() + assert tables["object"][0].isHidden() is False + assert mw.togglelistspanel_act.isChecked() is False From 531f325a124ac1531354575d80d5b34b832ddf83 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Sun, 28 Jun 2026 15:40:12 -0500 Subject: [PATCH 7/9] feat(ui): restore lists-panel collapsed state on open and sync the menu Apply the saved lists_panel_collapsed state to the docks and sync the View-menu checkbox after the menu is rebuilt on series open (MainWindow._applyListsPanelState, called in openSeries after createMenuBar, once docks are restored), and re-sync the checkbox in checkActions alongside the other toggle actions. The state lives in global QSettings, so it survives the in-app restart (which recreates the window and re-enters openSeries). Resolves the B4-council findings (restore-on-open and checkbox sync were B5's remit). Tests (offscreen): apply-collapsed hides docks and checks the box; apply-expanded shows docks and unchecks; collapsed state persists across a simulated restart. --- PyReconstruct/modules/gui/main/main_window.py | 13 +++++ tests/test_lists_panel.py | 56 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/PyReconstruct/modules/gui/main/main_window.py b/PyReconstruct/modules/gui/main/main_window.py index 36b3dfaf..91eb2dca 100644 --- a/PyReconstruct/modules/gui/main/main_window.py +++ b/PyReconstruct/modules/gui/main/main_window.py @@ -256,6 +256,9 @@ def checkActions(self, context_menu=False, clicked_trace=None, clicked_label=Non self.togglebc_act.setChecked(not self.mouse_palette.bc_hidden) self.togglesb_act.setChecked(not self.mouse_palette.sb_hidden) + ## Check for lists panel collapse (UI v1 Slice 3) + self.togglelistspanel_act.setChecked(self.field.table_manager.listsPanelCollapsed()) + ## Group visibility for group, viz in self.series.groups_visibility.items(): try: @@ -702,6 +705,7 @@ def openSeries(self, series_obj=None, jser_fp=None, query_prev=True): # create the menus self.createMenuBar() + self._applyListsPanelState() self.createContextMenus() if not self.actions_initialized: self.createShortcuts() @@ -1629,6 +1633,15 @@ def toggleListsPanel(self): manager.setListsPanelCollapsed(collapsed) self.togglelistspanel_act.setChecked(collapsed) + def _applyListsPanelState(self): + """Apply the saved lists-panel collapsed state to the docks and sync the + menu checkbox. Called after the menu is (re)built on series open / restart, + once the docks have been restored (UI v1 Slice 3).""" + manager = self.field.table_manager + collapsed = manager.listsPanelCollapsed() + manager.setListsPanelCollapsed(collapsed) + self.togglelistspanel_act.setChecked(collapsed) + def setToObject(self, obj_name : str, section_num : int): """Focus the field on an object from a specified section. diff --git a/tests/test_lists_panel.py b/tests/test_lists_panel.py index 44efdf16..09b9648c 100644 --- a/tests/test_lists_panel.py +++ b/tests/test_lists_panel.py @@ -361,3 +361,59 @@ class _Field: mw.toggleListsPanel() assert tables["object"][0].isHidden() is False assert mw.togglelistspanel_act.isChecked() is False + + +# --- B5: restore collapsed state on open + sync the checkbox; survive restart ----- + +def _mainwindow_with(mgr): + from PyReconstruct.modules.gui.main.main_window import MainWindow + from PySide6.QtGui import QAction + + mw = MainWindow.__new__(MainWindow) + + class _Field: + pass + + mw.field = _Field() + mw.field.table_manager = mgr + mw.togglelistspanel_act = QAction() + mw.togglelistspanel_act.setCheckable(True) + return mw + + +def test_b5_apply_state_collapsed_hides_docks_and_checks_box(qapp): + win, tables = _win_with_docks({"object": 1, "trace": 1}) + mgr = _bare_manager(_FakeSeries(["object", "trace"], collapsed=True)) + mgr.tables = tables + mw = _mainwindow_with(mgr) + + mw._applyListsPanelState() + + assert all(d.isHidden() for ds in tables.values() for d in ds) + assert mw.togglelistspanel_act.isChecked() is True + + +def test_b5_apply_state_expanded_shows_docks_and_unchecks_box(qapp): + win, tables = _win_with_docks({"object": 1}) + mgr = _bare_manager(_FakeSeries(["object"], collapsed=False)) + mgr.tables = tables + mw = _mainwindow_with(mgr) + + mw._applyListsPanelState() + + assert not tables["object"][0].isHidden() + assert mw.togglelistspanel_act.isChecked() is False + + +def test_b5_collapsed_state_survives_restart(qapp): + # The run.py restart recreates MainWindow and re-enters openSeries; the global + # QSettings value must persist. Simulate by reading it from a fresh series. + _clear("lists_panel_collapsed") + s1 = Series.__new__(Series) + s1.options = {} + mgr1 = _bare_manager(s1) + mgr1.setListsPanelCollapsed(True) # user collapses; writes global QSettings + + s2 = Series.__new__(Series) # "restarted" process reads the same global setting + s2.options = {} + assert s2.getOption("lists_panel_collapsed") is True From ff0adbc96d62e112585fde6bd0db01e236fbd878 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Sun, 28 Jun 2026 15:41:51 -0500 Subject: [PATCH 8/9] test(ui): end-to-end lists-panel lifecycle across a simulated restart Compose the slice: each "boot" is a fresh series + manager + field + window reading the same global QSettings (mirroring run.py recreating MainWindow). Proves open_tables and lists_panel_collapsed both persist and are re-applied on restart, and the View-menu checkbox stays in sync. --- tests/test_lists_panel.py | 54 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/test_lists_panel.py b/tests/test_lists_panel.py index 09b9648c..3a8f25cf 100644 --- a/tests/test_lists_panel.py +++ b/tests/test_lists_panel.py @@ -417,3 +417,57 @@ def test_b5_collapsed_state_survives_restart(qapp): s2 = Series.__new__(Series) # "restarted" process reads the same global setting s2.options = {} assert s2.getOption("lists_panel_collapsed") is True + + +# --- Whole-slice: open -> collapse -> restart, end to end ------------------------- + +def test_whole_slice_open_collapse_restart_cycle(qapp, monkeypatch): + """Composed lifecycle across a simulated run.py restart: each 'boot' is a fresh + series + manager + field + window reading the SAME global QSettings. Proves + open_tables AND lists_panel_collapsed both persist and are re-applied, and the + menu checkbox stays in sync. (Reachability/persistence end-to-end.)""" + from types import SimpleNamespace + from PySide6.QtWidgets import QMainWindow + from PySide6.QtGui import QAction + import PyReconstruct.modules.gui.main.field_widget_1_base as fwmod + from PyReconstruct.modules.gui.main.main_window import MainWindow + + monkeypatch.setattr(mgrmod, "table_type_classes", _stub_table_classes()) + _clear("open_tables", "lists_panel_collapsed") + + def boot(): + series = Series.__new__(Series) + series.options = {} + series.filepath = "/nonexistent/not-a-welcome.ser" # isWelcomeSeries -> False + win = QMainWindow() + mgr = mgrmod.TableManager(series, None, None, win) + + field = fwmod.FieldWidgetBase.__new__(fwmod.FieldWidgetBase) + field.series = series + field.section = None + field.table_manager = mgr + field._restoreOpenTables() # createField step + + mw = MainWindow.__new__(MainWindow) + mw.field = SimpleNamespace(table_manager=mgr) + mw.togglelistspanel_act = QAction() + mw.togglelistspanel_act.setCheckable(True) + mw._applyListsPanelState() # openSeries-after-createMenuBar step + return series, mgr, mw + + # Boot 1: defaults -> Object auto-opens, panel expanded + _, mgr1, mw1 = boot() + assert mgr1.tables["object"], "Object list did not auto-open" + assert mgr1.tables["object"][0].isHidden() is False + assert mw1.togglelistspanel_act.isChecked() is False + + # User collapses the panel + mw1.toggleListsPanel() + assert mgr1.tables["object"][0].isHidden() is True + + # Boot 2 = restart: same global QSettings + series2, mgr2, mw2 = boot() + assert series2.getOption("open_tables") == ["object"] # open_tables persisted + assert mgr2.tables["object"], "Object list not restored after restart" + assert mgr2.tables["object"][0].isHidden() is True # collapse persisted + applied + assert mw2.togglelistspanel_act.isChecked() is True # checkbox synced From 95f670135aa7740f420d8f18ea58538a27850249 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Sun, 28 Jun 2026 15:47:58 -0500 Subject: [PATCH 9/9] test(ui): strengthen lifecycle assertions per final council Assert the View-menu checkbox updates in real time on toggle (Boot 1), and tighten the post-restart restoration check to assert exactly one Object dock. --- tests/test_lists_panel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_lists_panel.py b/tests/test_lists_panel.py index 3a8f25cf..47decdca 100644 --- a/tests/test_lists_panel.py +++ b/tests/test_lists_panel.py @@ -464,10 +464,11 @@ def boot(): # User collapses the panel mw1.toggleListsPanel() assert mgr1.tables["object"][0].isHidden() is True + assert mw1.togglelistspanel_act.isChecked() is True # checkbox updates in real time # Boot 2 = restart: same global QSettings series2, mgr2, mw2 = boot() assert series2.getOption("open_tables") == ["object"] # open_tables persisted - assert mgr2.tables["object"], "Object list not restored after restart" + assert len(mgr2.tables["object"]) == 1, "Object list not restored after restart" assert mgr2.tables["object"][0].isHidden() is True # collapse persisted + applied assert mw2.togglelistspanel_act.isChecked() is True # checkbox synced