diff --git a/PyReconstruct/modules/datatypes/default_settings.py b/PyReconstruct/modules/datatypes/default_settings.py index 3fee2ee7..c34f7486 100644 --- a/PyReconstruct/modules/datatypes/default_settings.py +++ b/PyReconstruct/modules/datatypes/default_settings.py @@ -88,6 +88,7 @@ def get_username() -> str: "homeview_act": "Home", "selectall_act": "Ctrl+A", "deselect_act": "Ctrl+D", + "invertselection_act": "Ctrl+Shift+I", "edittrace_act": "Ctrl+E", "mergetraces_act": "Ctrl+M", "mergeobjects_act": "Ctrl+Shift+M", diff --git a/PyReconstruct/modules/datatypes/section.py b/PyReconstruct/modules/datatypes/section.py index 9f9ee740..56b34127 100644 --- a/PyReconstruct/modules/datatypes/section.py +++ b/PyReconstruct/modules/datatypes/section.py @@ -667,7 +667,37 @@ def selectAllTraces(self): self.deselectAllTraces() for trace in self.tracesAsList(): self.addSelectedTrace(trace) - + + def invertTraceSelection(self, include_hidden=False): + """Invert the trace selection: deselect every selected trace and + select every unselected trace. + + Only traces visible in the field can become selected: hidden and + group-hidden traces are skipped unless include_hidden is True (the + show-all-traces mode). Locked objects are never selected + (addSelectedTrace refuses them). Selected ztrace points and flags are + left untouched. + + (Only meant for GUI use.) + + Params: + include_hidden (bool): True if hidden traces may be selected + """ + selected = set(self.selected_traces) + group_hidden = set(self.traces_group_hide) + + to_select = [] + for trace in self.tracesAsList(): + if trace in selected: + continue + if not include_hidden and (trace.hidden or trace in group_hidden): + continue + to_select.append(trace) + + self.selected_traces : list[Trace] = [] + for trace in to_select: + self.addSelectedTrace(trace) + def hideTraces(self, traces : list = None, hide=True, log_event=True): """Hide traces. diff --git a/PyReconstruct/modules/gui/dialog/shortcuts.py b/PyReconstruct/modules/gui/dialog/shortcuts.py index 8d735f17..72f6365d 100644 --- a/PyReconstruct/modules/gui/dialog/shortcuts.py +++ b/PyReconstruct/modules/gui/dialog/shortcuts.py @@ -160,6 +160,7 @@ def getStaticShortcuts(w : QWidget) -> list[QKeySequence]: "Field Interactions", ("selectall_act", "Select all traces on section"), ("deselect_act", "Deselect all traces on section"), + ("invertselection_act", "Invert selected traces on section"), ("edittrace_act", "Edit attributes of selected trace(s)"), ("mergetraces_act", "Merge selected traces"), ("mergeobjects_act", "Merge attributes of selected traces"), diff --git a/PyReconstruct/modules/gui/main/context_menu_list.py b/PyReconstruct/modules/gui/main/context_menu_list.py index 8fac77c9..054fc2ff 100644 --- a/PyReconstruct/modules/gui/main/context_menu_list.py +++ b/PyReconstruct/modules/gui/main/context_menu_list.py @@ -48,6 +48,7 @@ def get_field_menu_list(self): None, ("selectall_act", "Select all traces", self.series, self.field.selectAllTraces), ("deselect_act", "Deselect traces", self.series, self.field.deselectAllTraces), + ("invertselection_act", "Invert selection", self.series, self.field.invertTraceSelection), None, ("delete_act", "Delete", "Del", self.backspace), ] @@ -94,6 +95,8 @@ def get_context_menu_list_obj(self): None, ("hideobj_act", "Hide", "", self.hideObj), ("unhideobj_act", "Unhide", "", lambda : self.hideObj(False)), + ("hideunselectedobj_act", "Hide unselected objects", "", self.hideUnselectedObjects), + ("showallobj_act", "Show all objects", "", self.unhideAllObjects), None, ("removealltags_act", "Remove all tags", "", self.removeAllTags), None, diff --git a/PyReconstruct/modules/gui/main/field_widget_2_trace.py b/PyReconstruct/modules/gui/main/field_widget_2_trace.py index e2edf754..3eff1218 100644 --- a/PyReconstruct/modules/gui/main/field_widget_2_trace.py +++ b/PyReconstruct/modules/gui/main/field_widget_2_trace.py @@ -417,6 +417,14 @@ def selectAllTraces(self): self.section.selectAllTraces() self.generateView(generate_image=False) + def invertTraceSelection(self): + """Invert which traces are selected on the section.""" + # disable if trace layer is hidden + if self.hide_trace_layer: + return + self.section.invertTraceSelection(include_hidden=self.show_all_traces) + self.generateView(generate_image=False) + ############################################################################ ## Interactions only accessible through the field ########################## ############################################################################ diff --git a/PyReconstruct/modules/gui/main/field_widget_3_object.py b/PyReconstruct/modules/gui/main/field_widget_3_object.py index f82721e2..8b672c97 100644 --- a/PyReconstruct/modules/gui/main/field_widget_3_object.py +++ b/PyReconstruct/modules/gui/main/field_widget_3_object.py @@ -364,6 +364,51 @@ def hideObj(self, obj_names : list, hide=True): return True + # update_objects=False so the decorator does NOT run its locked-check on the + # selection (the SELECTED objects are the ones being kept, not modified) and + # does not refresh the wrong rows -- both are handled against the complement + # below. + @object_function(update_objects=False, reload_field=True) + def hideUnselectedObjects(self, obj_names : list): + """Isolate the selected object(s): hide every OTHER object throughout the + whole series, so the isolation persists as sections change. + + Objects in the complement are hidden regardless of their locked state -- + locking guards edits and quantification, not visibility. An empty + selection is a no-op (the decorator returns before we get here), so this + can never blank the series. + + Params: + obj_names (list): the objects to keep visible (object-list + selection, or the objects owning the field's selected traces) + Returns: + (bool): True if any object was hidden + """ + keep = set(obj_names) + others = [name for name in self.series.data["objects"] if name not in keep] + if not others: # everything is already selected -> nothing to hide + return False + + self.series.hideObjects(others, True, self.series_states) + self.table_manager.updateObjects(others) + + return True + + def unhideAllObjects(self): + """Show all objects: unhide every object throughout the whole series. + + The clear restore for "Hide unselected objects"; undoable series-wide, + like the object hide itself. + """ + all_names = list(self.series.data["objects"].keys()) + if not all_names: + return + self.mainwindow.saveAllData() + self.series.hideObjects(all_names, False, self.series_states) + self.table_manager.updateObjects(all_names) + self.mainwindow.seriesModified(True) + self.reload() + @object_function(update_objects=False, reload_field=False) def addTo3D(self, obj_names : list): """Generate a 3D view of an object""" diff --git a/PyReconstruct/modules/gui/table/object.py b/PyReconstruct/modules/gui/table/object.py index 3eee2460..63325c75 100644 --- a/PyReconstruct/modules/gui/table/object.py +++ b/PyReconstruct/modules/gui/table/object.py @@ -12,7 +12,7 @@ QPalette, QColor ) -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QItemSelection, QItemSelectionModel from .data_table import DataTable from .history import HistoryTableWidget @@ -36,6 +36,23 @@ TextWidget, ) + +def invert_object_rows(row_names : list, selected : set): + """Return the row indices to select when inverting an object selection. + + Every row that is not currently selected becomes selected. Object rows are + freely selectable in the list (no lock restriction), so nothing is excluded + here -- this matches the object list's existing selection behavior. + + Params: + row_names (list): object name at each row, in row order + selected (set): names of the currently selected objects + Returns: + (list): the row indices that should end up selected + """ + return [r for r, name in enumerate(row_names) if name not in selected] + + class ObjectTableWidget(DataTable): def __init__(self, series : Series, mainwindow : QWidget, manager, hidden=False): @@ -130,6 +147,17 @@ def getCall(col_name, opt_name): ("export_act", "Export...", "", self.export), ] }, + { + "attr_name": "selectionmenu", + "text": "Selection", + "opts": + [ + ("invertobjselection_act", "Invert selection", "", self.invertSelection), + None, + ("hideunselectedobj_act1", "Hide unselected objects", "", self.mainwindow.field.hideUnselectedObjects), + ("showallobj_act1", "Show all objects", "", self.mainwindow.field.unhideAllObjects), + ] + }, { "attr_name": "filtermenu", "text": "Filter", @@ -194,8 +222,13 @@ def getCall(col_name, opt_name): # fill in the menu bar object populateMenuBar(self, self.menubar, menubar_list) - # create the right-click menu - context_menu_list = self.mainwindow.field.getObjMenu() + # create the right-click menu -- prepend the object-list-only + # "Invert selection" (a table selection op, so it lives here rather + # than in the shared field object menu) + context_menu_list = [ + ("invertobjselection_act1", "Invert selection", "", self.invertSelection), + None, + ] + self.mainwindow.field.getObjMenu() self.context_menu = QMenu(self) populateMenu(self, self.context_menu, context_menu_list) @@ -575,6 +608,32 @@ def getSelected(self, single=False): else: return obj_names + def invertSelection(self): + """Invert which objects are selected in the list. + + Every object shown in the list that is not currently selected becomes + selected, and vice versa. Operates on the rows currently displayed, so + with no active filter this inverts against every object in the series. + """ + row_count = self.table.rowCount() + if not row_count: + return + + row_names = [self.table.item(r, 0).text() for r in range(row_count)] + selected = set(self.getSelected()) + to_select = invert_object_rows(row_names, selected) + + model = self.table.model() + last_col = self.table.columnCount() - 1 + new_selection = QItemSelection() + for r in to_select: + new_selection.select(model.index(r, 0), model.index(r, last_col)) + + self.table.selectionModel().select( + new_selection, + QItemSelectionModel.ClearAndSelect + ) + def itemChanged(self, item : QTableWidgetItem): """User checked a checkbox.""" # check for curation