Skip to content

Commit 381df15

Browse files
feat(gui): invert trace selection from field or edit menu
1 parent 4bc3cc7 commit 381df15

9 files changed

Lines changed: 159 additions & 6 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ dist/
66
*.egg-info/
77
**/__pycache__/
88
*.pyc
9+
.claude
10+
CLAUDE.md
911
run_julian.sh
1012
convert_to_zarr.sh
1113
fix_field_tags.sh

PyReconstruct/modules/datatypes/default_settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ def get_username() -> str:
8888
"homeview_act": "Home",
8989
"selectall_act": "Ctrl+A",
9090
"deselect_act": "Ctrl+D",
91+
"invertselection_act": "Ctrl+Shift+I",
9192
"edittrace_act": "Ctrl+E",
9293
"mergetraces_act": "Ctrl+M",
9394
"mergeobjects_act": "Ctrl+Shift+M",

PyReconstruct/modules/datatypes/section.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,37 @@ def selectAllTraces(self):
667667
self.deselectAllTraces()
668668
for trace in self.tracesAsList():
669669
self.addSelectedTrace(trace)
670-
670+
671+
def invertTraceSelection(self, include_hidden=False):
672+
"""Invert the trace selection: deselect every selected trace and
673+
select every unselected trace.
674+
675+
Only traces visible in the field can become selected: hidden and
676+
group-hidden traces are skipped unless include_hidden is True (the
677+
show-all-traces mode). Locked objects are never selected
678+
(addSelectedTrace refuses them). Selected ztrace points and flags are
679+
left untouched.
680+
681+
(Only meant for GUI use.)
682+
683+
Params:
684+
include_hidden (bool): True if hidden traces may be selected
685+
"""
686+
selected = set(self.selected_traces)
687+
group_hidden = set(self.traces_group_hide)
688+
689+
to_select = []
690+
for trace in self.tracesAsList():
691+
if trace in selected:
692+
continue
693+
if not include_hidden and (trace.hidden or trace in group_hidden):
694+
continue
695+
to_select.append(trace)
696+
697+
self.selected_traces : list[Trace] = []
698+
for trace in to_select:
699+
self.addSelectedTrace(trace)
700+
671701
def hideTraces(self, traces : list = None, hide=True, log_event=True):
672702
"""Hide traces.
673703

PyReconstruct/modules/gui/dialog/shortcuts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ def getStaticShortcuts(w : QWidget) -> list[QKeySequence]:
160160
"Field Interactions",
161161
("selectall_act", "Select all traces on section"),
162162
("deselect_act", "Deselect all traces on section"),
163+
("invertselection_act", "Invert selected traces on section"),
163164
("edittrace_act", "Edit attributes of selected trace(s)"),
164165
("mergetraces_act", "Merge selected traces"),
165166
("mergeobjects_act", "Merge attributes of selected traces"),

PyReconstruct/modules/gui/main/context_menu_list.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ def get_field_menu_list(self):
4646
self.paste_act,
4747
self.pasteattributes_act,
4848
None,
49-
("selectall_act", "Select all traces", self.series, self.field.selectAllTraces),
50-
("deselect_act", "Deselect traces", self.series, self.field.deselectAllTraces),
49+
self.selectall_act,
50+
self.deselect_act,
51+
self.invertselection_act,
5152
None,
5253
("delete_act", "Delete", "Del", self.backspace),
5354
]
@@ -94,6 +95,8 @@ def get_context_menu_list_obj(self):
9495
None,
9596
("hideobj_act", "Hide", "", self.hideObj),
9697
("unhideobj_act", "Unhide", "", lambda : self.hideObj(False)),
98+
("hideunselectedobj_act", "Hide unselected objects", "", self.hideUnselectedObjects),
99+
("showallobj_act", "Show all objects", "", self.unhideAllObjects),
97100
None,
98101
("removealltags_act", "Remove all tags", "", self.removeAllTags),
99102
None,

PyReconstruct/modules/gui/main/field_widget_2_trace.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,14 @@ def selectAllTraces(self):
417417
self.section.selectAllTraces()
418418
self.generateView(generate_image=False)
419419

420+
def invertTraceSelection(self):
421+
"""Invert which traces are selected on the section."""
422+
# disable if trace layer is hidden
423+
if self.hide_trace_layer:
424+
return
425+
self.section.invertTraceSelection(include_hidden=self.show_all_traces)
426+
self.generateView(generate_image=False)
427+
420428
############################################################################
421429
## Interactions only accessible through the field ##########################
422430
############################################################################

PyReconstruct/modules/gui/main/field_widget_3_object.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,51 @@ def hideObj(self, obj_names : list, hide=True):
364364

365365
return True
366366

367+
# update_objects=False so the decorator does NOT run its locked-check on the
368+
# selection (the SELECTED objects are the ones being kept, not modified) and
369+
# does not refresh the wrong rows -- both are handled against the complement
370+
# below.
371+
@object_function(update_objects=False, reload_field=True)
372+
def hideUnselectedObjects(self, obj_names : list):
373+
"""Isolate the selected object(s): hide every OTHER object throughout the
374+
whole series, so the isolation persists as sections change.
375+
376+
Objects in the complement are hidden regardless of their locked state --
377+
locking guards edits and quantification, not visibility. An empty
378+
selection is a no-op (the decorator returns before we get here), so this
379+
can never blank the series.
380+
381+
Params:
382+
obj_names (list): the objects to keep visible (object-list
383+
selection, or the objects owning the field's selected traces)
384+
Returns:
385+
(bool): True if any object was hidden
386+
"""
387+
keep = set(obj_names)
388+
others = [name for name in self.series.data["objects"] if name not in keep]
389+
if not others: # everything is already selected -> nothing to hide
390+
return False
391+
392+
self.series.hideObjects(others, True, self.series_states)
393+
self.table_manager.updateObjects(others)
394+
395+
return True
396+
397+
def unhideAllObjects(self):
398+
"""Show all objects: unhide every object throughout the whole series.
399+
400+
The clear restore for "Hide unselected objects"; undoable series-wide,
401+
like the object hide itself.
402+
"""
403+
all_names = list(self.series.data["objects"].keys())
404+
if not all_names:
405+
return
406+
self.mainwindow.saveAllData()
407+
self.series.hideObjects(all_names, False, self.series_states)
408+
self.table_manager.updateObjects(all_names)
409+
self.mainwindow.seriesModified(True)
410+
self.reload()
411+
367412
@object_function(update_objects=False, reload_field=False)
368413
def addTo3D(self, obj_names : list):
369414
"""Generate a 3D view of an object"""

PyReconstruct/modules/gui/main/menubar.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ def return_edit_menu(self):
8585
("undo_act", "Undo", self.series, self.undo),
8686
("redo_act", "Redo", self.series, lambda : self.undo(True)),
8787
None,
88+
("selectall_act", "Select all traces", self.series, self.field.selectAllTraces),
89+
("deselect_act", "Deselect traces", self.series, self.field.deselectAllTraces),
90+
("invertselection_act", "Invert selection", self.series, self.field.invertTraceSelection),
91+
None,
8892
("cut_act", "Cut", self.series, self.field.cut),
8993
("copy_act", "Copy", self.series, self.copy),
9094
("paste_act", "Paste", self.series, self.field.paste),

PyReconstruct/modules/gui/table/object.py

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
QPalette,
1313
QColor
1414
)
15-
from PySide6.QtCore import Qt
15+
from PySide6.QtCore import Qt, QItemSelection, QItemSelectionModel
1616

1717
from .data_table import DataTable
1818
from .history import HistoryTableWidget
@@ -36,6 +36,23 @@
3636
TextWidget,
3737
)
3838

39+
40+
def invert_object_rows(row_names : list, selected : set):
41+
"""Return the row indices to select when inverting an object selection.
42+
43+
Every row that is not currently selected becomes selected. Object rows are
44+
freely selectable in the list (no lock restriction), so nothing is excluded
45+
here -- this matches the object list's existing selection behavior.
46+
47+
Params:
48+
row_names (list): object name at each row, in row order
49+
selected (set): names of the currently selected objects
50+
Returns:
51+
(list): the row indices that should end up selected
52+
"""
53+
return [r for r, name in enumerate(row_names) if name not in selected]
54+
55+
3956
class ObjectTableWidget(DataTable):
4057

4158
def __init__(self, series : Series, mainwindow : QWidget, manager, hidden=False):
@@ -130,6 +147,17 @@ def getCall(col_name, opt_name):
130147
("export_act", "Export...", "", self.export),
131148
]
132149
},
150+
{
151+
"attr_name": "selectionmenu",
152+
"text": "Selection",
153+
"opts":
154+
[
155+
("invertobjselection_act", "Invert selection", "", self.invertSelection),
156+
None,
157+
("hideunselectedobj_act1", "Hide unselected objects", "", self.mainwindow.field.hideUnselectedObjects),
158+
("showallobj_act1", "Show all objects", "", self.mainwindow.field.unhideAllObjects),
159+
]
160+
},
133161
{
134162
"attr_name": "filtermenu",
135163
"text": "Filter",
@@ -194,8 +222,13 @@ def getCall(col_name, opt_name):
194222
# fill in the menu bar object
195223
populateMenuBar(self, self.menubar, menubar_list)
196224

197-
# create the right-click menu
198-
context_menu_list = self.mainwindow.field.getObjMenu()
225+
# create the right-click menu -- prepend the object-list-only
226+
# "Invert selection" (a table selection op, so it lives here rather
227+
# than in the shared field object menu)
228+
context_menu_list = [
229+
("invertobjselection_act1", "Invert selection", "", self.invertSelection),
230+
None,
231+
] + self.mainwindow.field.getObjMenu()
199232
self.context_menu = QMenu(self)
200233
populateMenu(self, self.context_menu, context_menu_list)
201234

@@ -575,6 +608,32 @@ def getSelected(self, single=False):
575608
else:
576609
return obj_names
577610

611+
def invertSelection(self):
612+
"""Invert which objects are selected in the list.
613+
614+
Every object shown in the list that is not currently selected becomes
615+
selected, and vice versa. Operates on the rows currently displayed, so
616+
with no active filter this inverts against every object in the series.
617+
"""
618+
row_count = self.table.rowCount()
619+
if not row_count:
620+
return
621+
622+
row_names = [self.table.item(r, 0).text() for r in range(row_count)]
623+
selected = set(self.getSelected())
624+
to_select = invert_object_rows(row_names, selected)
625+
626+
model = self.table.model()
627+
last_col = self.table.columnCount() - 1
628+
new_selection = QItemSelection()
629+
for r in to_select:
630+
new_selection.select(model.index(r, 0), model.index(r, last_col))
631+
632+
self.table.selectionModel().select(
633+
new_selection,
634+
QItemSelectionModel.ClearAndSelect
635+
)
636+
578637
def itemChanged(self, item : QTableWidgetItem):
579638
"""User checked a checkbox."""
580639
# check for curation

0 commit comments

Comments
 (0)