Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions PyReconstruct/modules/backend/table/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ 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)

# 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."""
Expand All @@ -55,9 +61,56 @@ 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)

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 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.
Expand Down Expand Up @@ -185,6 +238,14 @@ def refresh(self):

def closeAll(self):
"""Close all tables."""
for n, l in self.tables.items():
for t in l:
t.close()
# iterate a copy: each close() fires closeEvent, which removes the table
# 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
4 changes: 4 additions & 0 deletions PyReconstruct/modules/datatypes/default_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,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 = {
Expand Down
17 changes: 16 additions & 1 deletion PyReconstruct/modules/gui/main/field_widget_1_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,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))
Expand Down Expand Up @@ -475,11 +478,23 @@ 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. 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")):
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.
Expand Down
22 changes: 21 additions & 1 deletion PyReconstruct/modules/gui/main/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,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:
Expand Down Expand Up @@ -762,6 +765,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()
Expand Down Expand Up @@ -1703,7 +1707,23 @@ 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 _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.

Expand Down
1 change: 1 addition & 0 deletions PyReconstruct/modules/gui/main/menubar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions PyReconstruct/modules/gui/table/data_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,5 +347,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)

55 changes: 55 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""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


@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([])
Loading